diff --git a/package.json b/package.json index 643a08324..b3eea1105 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,9 @@ "@maphubs/tokml": "^0.6.1", "@popperjs/core": "^2.11.6", "@terraformer/wkt": "^2.2.0", + "@trussworks/react-uswds": "^7.0.0", "@turf/turf": "^6.5.0", + "@uswds/uswds": "^3.13.0", "bcryptjs": "^2.4.3", "busboy": "1.6", "camelcase": "^5.3.1", @@ -152,6 +154,7 @@ "sequelize": "^6.33.0", "sharp": "^0.31.2", "showdown": "^2.1.0", + "shpjs": "^6.2.0", "snap-bbox": "^0.2.0", "sortablejs": "^1.15.0", "swagger-ui-express": "^4.1.4", @@ -192,6 +195,7 @@ "@babel/plugin-transform-private-property-in-object": "^7.22.11", "@playwright/test": "^1.57.0", "@svgr/webpack": "^8.1.0", + "@types/jest": "^30.0.0", "@typescript-eslint/eslint-plugin": "^2.10.0", "@typescript-eslint/parser": "^2.10.0", "babel-eslint": "^10.1.0", @@ -219,6 +223,7 @@ "postcss-normalize": "8.0.1", "postcss-preset-env": "6.7.0", "react-dev-utils": "^12.0.1", + "sass": "^1.99.0", "sass-loader": "8.0.2", "string-replace-loader": "^3.1.0", "style-loader": "0.23.1", diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index 840953595..117e393a0 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -290,6 +290,10 @@ let Map_ = { reEmit('drawcancel', 'map:drawcancel') reEmit('move', 'map:move') reEmit('moveend', 'map:moveend') + + engine.onFeatureClick((info) => + window.mmgisAPI.emit('map:featureClick', info) + ) } //Make our layers diff --git a/src/essence/Tools/AOI/AOIComponent/AOIComponent.scss b/src/essence/Tools/AOI/AOIComponent/AOIComponent.scss new file mode 100644 index 000000000..747e950a9 --- /dev/null +++ b/src/essence/Tools/AOI/AOIComponent/AOIComponent.scss @@ -0,0 +1,468 @@ +// AOI plugin — scoped under .aoi-tool-host / .aoi-tool / .aoi-tooltip roots only. +// All literal values live in _aoi-tokens.scss; everywhere else uses var(--aoi-*). +// A host theme overrides --mmgis-* on :root and propagates through the var() fallbacks. + +@use 'aoi-tokens' as tokens; + +.aoi-tool-host { + position: fixed; + top: 70px; + right: 16px; + width: 372px; + max-height: calc(100vh - 90px); + overflow: hidden; + z-index: 1003; + border-radius: var(--mmgis-radius-md, 6px); + box-shadow: var(--mmgis-shadow-pop, 0 2px 6px rgba(0, 0, 0, 0.18)); + pointer-events: auto; +} + +.aoi-tool, +.aoi-tooltip { + @include tokens.aoi-tokens; + + box-sizing: border-box; + color: var(--aoi-fg); + font-family: var(--aoi-font-body); + font-size: var(--aoi-font-size-md); + background: var(--aoi-bg); +} + +.aoi-tool *, +.aoi-tooltip * { + box-sizing: border-box; +} + +.aoi-tool { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + padding: var(--aoi-space-4); + gap: var(--aoi-space-3); +} + +.aoi-tool__header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.aoi-tool__title { + display: inline-flex; + align-items: center; + gap: var(--aoi-space-2); + font-size: var(--aoi-font-size-lg); + font-weight: 600; +} + +.aoi-tool__title-icon { + font-size: 16px; + line-height: 1; + color: var(--aoi-accent); +} + +.aoi-tool__close { + background: none; + border: 0; + color: var(--aoi-fg-muted); + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: var(--aoi-space-1); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.aoi-tool__close:hover { + color: var(--aoi-fg); +} + +.aoi-tool__tabs { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--aoi-space-1); + border: 1px solid var(--aoi-border); + border-radius: var(--aoi-radius-sm); + padding: var(--aoi-space-1); +} + +.aoi-tool__tab { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--aoi-space-1); + padding: var(--aoi-space-2) var(--aoi-space-1); + border: 0; + background: transparent; + color: var(--aoi-fg-muted); + border-radius: var(--aoi-radius-sm); + cursor: pointer; + font-family: inherit; + font-size: var(--aoi-font-size-sm); +} + +.aoi-tool__tab:hover { + color: var(--aoi-fg); +} + +.aoi-tool__tab--active { + background: var(--aoi-bg-muted); + color: var(--aoi-fg); +} + +.aoi-tool__tab-icon { + font-size: 18px; + line-height: 1; +} + +.aoi-tool__tab-label { + font-size: var(--aoi-font-size-sm); +} + +.aoi-tool__body { + flex: 1 1 auto; + min-height: 0; +} + +.aoi-panel { + display: flex; + flex-direction: column; + gap: var(--aoi-space-3); +} + +.aoi-panel__hint { + margin: 0; + color: var(--aoi-fg); + font-size: var(--aoi-font-size-sm); +} + +.aoi-panel__hint--secondary { + color: var(--aoi-fg-muted); +} + +.aoi-panel__empty { + margin: 0; + color: var(--aoi-fg-muted); + font-size: var(--aoi-font-size-sm); +} + +.aoi-panel__error { + margin: 0; + color: var(--aoi-danger); + font-size: var(--aoi-font-size-sm); +} + +.aoi-panel--analyzing { + flex: 1 1 auto; + align-items: center; + justify-content: center; + padding: var(--aoi-space-5) var(--aoi-space-4); + text-align: center; + gap: var(--aoi-space-2); +} + +.aoi-analyzing__spinner { + width: 40px; + height: 40px; + border: 3px solid var(--aoi-bg-muted); + border-top-color: var(--aoi-accent); + border-radius: 50%; + margin-bottom: var(--aoi-space-3); + animation: aoi-spin 800ms linear infinite; +} + +@keyframes aoi-spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .aoi-analyzing__spinner { animation-duration: 2400ms; } +} + +.aoi-analyzing__caption { + margin: 0; + color: var(--aoi-fg-muted); + font-size: var(--aoi-font-size-md); +} + +.aoi-analyzing__label { + margin: 0; + color: var(--aoi-fg); + font-size: var(--aoi-font-size-lg); + font-weight: 700; + line-height: 1.2; +} + +.aoi-analyzing__percent { + margin: var(--aoi-space-3) 0 0; + color: var(--aoi-accent); + font-size: 28px; + font-weight: 700; + line-height: 1; +} + +.aoi-analyzing__bar { + width: 80%; + height: 6px; + background: var(--aoi-bg-muted); + border-radius: 999px; + overflow: hidden; + margin-top: var(--aoi-space-2); +} + +.aoi-analyzing__bar-fill { + height: 100%; + background: var(--aoi-accent); + border-radius: 999px; + transition: width 200ms ease-out; +} + +.aoi-analyzing__status { + margin: var(--aoi-space-3) 0 0; + color: var(--aoi-fg-muted); + font-size: var(--aoi-font-size-sm); +} + +.aoi-search { + position: relative; + display: block; +} + +.aoi-search__input { + width: 100%; + height: 36px; + padding: 0 var(--aoi-space-5) 0 var(--aoi-space-3); + border: 1px solid var(--aoi-border); + border-radius: var(--aoi-radius-sm); + background: var(--aoi-bg); + color: var(--aoi-fg); + font: inherit; +} + +.aoi-search__input:focus { + outline: 2px solid var(--aoi-accent); + outline-offset: -1px; +} + +.aoi-search__input:disabled { + background: var(--aoi-bg-muted); + color: var(--aoi-fg-muted); + cursor: not-allowed; +} + +.aoi-search__icon { + position: absolute; + right: var(--aoi-space-3); + top: 50%; + transform: translateY(-50%); + font-size: 16px; + line-height: 1; + color: var(--aoi-fg-muted); + pointer-events: none; +} + +.aoi-search__results { + list-style: none; + margin: 0; + padding: 0; + border: 1px solid var(--aoi-border); + border-radius: var(--aoi-radius-sm); + max-height: 220px; + overflow-y: auto; +} + +.aoi-search__result { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + padding: var(--aoi-space-2) var(--aoi-space-3); + background: transparent; + border: 0; + border-bottom: 1px solid var(--aoi-border); + color: var(--aoi-fg); + cursor: pointer; + text-align: left; + font: inherit; +} + +.aoi-search__result:last-child { + border-bottom: 0; +} + +.aoi-search__result:hover { + background: var(--aoi-bg-muted); +} + +.aoi-search__result-kind { + color: var(--aoi-fg-muted); + font-size: var(--aoi-font-size-sm); + text-transform: capitalize; +} + +.aoi-draw__shapes { + display: flex; + align-items: center; + gap: var(--aoi-space-2); + flex-wrap: wrap; +} + +.aoi-draw__shape, +.aoi-draw__history { + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--aoi-border); + border-radius: var(--aoi-radius-sm); + background: var(--aoi-bg); + color: var(--aoi-fg); + cursor: pointer; +} + +.aoi-draw__shape:hover:not(:disabled), +.aoi-draw__history:hover:not(:disabled) { + border-color: var(--aoi-border-hover); +} + +.aoi-draw__shape--active { + background: var(--aoi-bg-muted); + border-color: var(--aoi-fg); +} + +.aoi-draw__shape:disabled, +.aoi-draw__history:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.aoi-draw__shape-icon, +.aoi-draw__history-icon { + font-size: 16px; + line-height: 1; +} + +.aoi-draw__divider { + width: 1px; + height: 24px; + background: var(--aoi-border); + margin: 0 var(--aoi-space-1); +} + +.aoi-draw__actions { + display: flex; + gap: var(--aoi-space-2); +} + +.aoi-draw__confirm, +.aoi-draw__cancel { + flex: 1 1 auto; + height: 36px; + border-radius: var(--aoi-radius-sm); + cursor: pointer; + font: inherit; + font-weight: 600; +} + +.aoi-draw__confirm { + border: 1px solid var(--aoi-accent); + background: var(--aoi-accent); + color: var(--aoi-accent-fg); +} + +.aoi-draw__confirm:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.aoi-draw__cancel { + border: 1px solid var(--aoi-border); + background: var(--aoi-bg); + color: var(--aoi-fg); +} + +.aoi-upload__button { + width: 100%; + height: 40px; + border: 1px dashed var(--aoi-accent); + border-radius: var(--aoi-radius-sm); + background: transparent; + color: var(--aoi-accent); + cursor: pointer; + font: inherit; + font-weight: 600; + text-decoration: underline; +} + +.aoi-upload__button:hover:not(:disabled) { + background: var(--aoi-bg-muted); +} + +.aoi-upload__button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.aoi-upload__input { + display: none; +} + +.aoi-upload__formats { + margin: 0; + padding-left: var(--aoi-space-4); + color: var(--aoi-fg); + font-size: var(--aoi-font-size-sm); +} + +.aoi-tooltip { + position: absolute; + transform: translate(-50%, calc(-100% - var(--aoi-space-3))); + min-width: 220px; + padding: var(--aoi-space-3); + border: 1px solid var(--aoi-border); + border-radius: var(--aoi-radius-md); + box-shadow: var(--aoi-shadow-pop); + pointer-events: auto; + z-index: 1000; +} + +.aoi-tooltip__label { + margin: 0 0 var(--aoi-space-2); + font-size: var(--aoi-font-size-md); + font-weight: 600; +} + +.aoi-tooltip__actions { + display: flex; + gap: var(--aoi-space-2); +} + +.aoi-tooltip__primary, +.aoi-tooltip__secondary { + flex: 1 1 auto; + height: 32px; + border-radius: var(--aoi-radius-sm); + cursor: pointer; + font: inherit; + font-weight: 600; +} + +.aoi-tooltip__primary { + border: 1px solid var(--aoi-accent); + background: var(--aoi-accent); + color: var(--aoi-accent-fg); +} + +.aoi-tooltip__primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.aoi-tooltip__secondary { + border: 1px solid var(--aoi-border); + background: var(--aoi-bg); + color: var(--aoi-fg); +} diff --git a/src/essence/Tools/AOI/AOIComponent/AOIComponent.test.tsx b/src/essence/Tools/AOI/AOIComponent/AOIComponent.test.tsx new file mode 100644 index 000000000..761d95de5 --- /dev/null +++ b/src/essence/Tools/AOI/AOIComponent/AOIComponent.test.tsx @@ -0,0 +1,44 @@ +// Stub test file for AOIComponent. +// +// Mirrors the tinacms-portal-monorepo per-component test convention. +// Real assertions can be added once MMGIS's Jest config picks up TSX files +// under Tools/. For now this file documents the public surface contract. + +import React from 'react' +import AOIComponent, { + AOIComponentProps, + AOIMode, + AOIShape, +} from './AOIComponent' + +describe('AOIComponent', () => { + it('exports its public types', () => { + const _mode: AOIMode = 'search' + const _shape: AOIShape = 'polygon' + // Component is exported as default + expect(AOIComponent).toBeDefined() + expect(_mode).toBe('search') + expect(_shape).toBe('polygon') + }) + + it('accepts the full AOIComponentProps shape', () => { + const props: AOIComponentProps = { + mode: 'search', + onModeChange: () => undefined, + searchQuery: '', + searchResults: [], + onSearchQueryChange: () => undefined, + onSearchSelect: () => undefined, + drawShape: null, + isDrawing: false, + drawVerticesCount: 0, + onDrawShapeChange: () => undefined, + onDrawConfirm: () => undefined, + onDrawCancel: () => undefined, + uploadStatus: 'idle', + onUploadFile: () => undefined, + onClose: () => undefined, + } + expect(props.mode).toBe('search') + }) +}) diff --git a/src/essence/Tools/AOI/AOIComponent/AOIComponent.tsx b/src/essence/Tools/AOI/AOIComponent/AOIComponent.tsx new file mode 100644 index 000000000..557bccc74 --- /dev/null +++ b/src/essence/Tools/AOI/AOIComponent/AOIComponent.tsx @@ -0,0 +1,343 @@ +import React, { useEffect, useRef } from 'react' +import { Alert, Button, TextInput } from '@trussworks/react-uswds' +import './AOIComponent.scss' + +export type AOIMode = 'search' | 'inspect' | 'draw' | 'upload' +export type AOIShape = 'polygon' | 'rectangle' | 'circle' +export type UploadStatus = 'idle' | 'parsing' | 'error' +export type AnalysisStatus = 'idle' | 'running' + +export interface AOISearchResult { + id: string + label: string + kind: 'city' | 'county' | 'state' +} + +export interface AOIComponentProps { + mode: AOIMode + onModeChange: (mode: AOIMode) => void + + searchQuery: string + searchResults: AOISearchResult[] + searchDisabled?: boolean + searchLoading?: boolean + onSearchQueryChange: (q: string) => void + onSearchSelect: (id: string) => void + + drawShape: AOIShape | null + drawDisabled?: boolean + isDrawing: boolean + drawVerticesCount: number + onDrawShapeChange: (shape: AOIShape) => void + onDrawConfirm: () => void + onDrawCancel: () => void + + uploadStatus: UploadStatus + uploadError?: string + onUploadFile: (file: File) => void + + analysisStatus?: AnalysisStatus + analysisLabel?: string + analysisDone?: number + analysisTotal?: number + + onClose: () => void +} + +const MODES: Array<{ id: AOIMode; label: string; icon: string }> = [ + { id: 'search', label: 'Search', icon: 'magnify' }, + { id: 'inspect', label: 'Inspect', icon: 'hand-pointing-up' }, + { id: 'draw', label: 'Draw', icon: 'vector-polyline' }, + { id: 'upload', label: 'Upload', icon: 'tray-arrow-up' }, +] + +export function AOIComponent(props: AOIComponentProps) { + const isAnalyzing = props.analysisStatus === 'running' + + return ( +
+
+
+