diff --git a/src/essence/Tools/AOI/AOIComponent/AOIComponent.scss b/src/essence/Tools/AOI/AOIComponent/AOIComponent.scss
deleted file mode 100644
index 230173089..000000000
--- a/src/essence/Tools/AOI/AOIComponent/AOIComponent.scss
+++ /dev/null
@@ -1,525 +0,0 @@
-// 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__alert {
- display: flex;
- align-items: flex-start;
- gap: var(--aoi-space-2);
- margin: var(--aoi-space-3) var(--aoi-space-4) 0;
- padding: var(--aoi-space-3);
- border-radius: var(--aoi-radius-sm);
- border-left: 4px solid var(--aoi-danger);
- background: rgba(181, 9, 9, 0.08);
- color: var(--aoi-fg);
- font-size: var(--aoi-font-size-sm);
- line-height: 1.35;
-}
-
-.aoi-tool__alert--error {
- border-left-color: var(--aoi-danger);
- background: rgba(181, 9, 9, 0.08);
-}
-
-.aoi-tool__alert-icon {
- flex: 0 0 auto;
- color: var(--aoi-danger);
- font-size: 18px;
- line-height: 1;
- margin-top: 1px;
-}
-
-.aoi-tool__alert-text {
- flex: 1 1 auto;
- margin: 0;
-}
-
-.aoi-tool__alert-dismiss {
- flex: 0 0 auto;
- width: 24px;
- height: 24px;
- padding: 0;
- border: 0;
- border-radius: var(--aoi-radius-sm);
- background: transparent;
- color: var(--aoi-fg-muted);
- cursor: pointer;
- line-height: 1;
- display: inline-flex;
- align-items: center;
- justify-content: center;
-}
-
-.aoi-tool__alert-dismiss:hover {
- color: var(--aoi-fg);
- background: var(--aoi-bg-muted);
-}
-
-.aoi-tool__alert-dismiss .mdi {
- font-size: 16px;
-}
-
-.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
deleted file mode 100644
index 761d95de5..000000000
--- a/src/essence/Tools/AOI/AOIComponent/AOIComponent.test.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-// 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
deleted file mode 100644
index a2b529a66..000000000
--- a/src/essence/Tools/AOI/AOIComponent/AOIComponent.tsx
+++ /dev/null
@@ -1,389 +0,0 @@
-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 = 'point' | 'linestring' | '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
- drawShapes?: AOIShape[]
- 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
- analysisError?: string | null
- onDismissAnalysisError?: () => void
-
- 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 (
-
-
-
-
- Analyze areas
-
-
-
-
- {props.analysisError ? (
-
-
-
{props.analysisError}
- {props.onDismissAnalysisError && (
-
- )}
-
- ) : null}
-
- {isAnalyzing ? (
-
- ) : (
- <>
-
-
-
- {props.mode === 'search' && }
- {props.mode === 'inspect' && }
- {props.mode === 'draw' && }
- {props.mode === 'upload' && }
-
- >
- )}
-
- )
-}
-
-function AnalyzingPanel(
- { label, done, total }: { label: string; done: number; total: number }
-) {
- const percent = total > 0 ? Math.round((done / total) * 100) : 0
- return (
-
-
-
Analyzing
-
{label}
-
{percent}%
-
-
Looking for insights
-
- )
-}
-
-function SearchPanel(props: AOIComponentProps) {
- const inputRef = useRef(null)
- useEffect(() => {
- inputRef.current?.focus()
- }, [])
-
- const inputDisabled = props.searchDisabled || props.searchLoading
-
- return (
-
-
Search for a country or a region
-
-
- {props.searchLoading && (
-
Loading boundary data…
- )}
-
- {!props.searchLoading && props.searchDisabled && (
-
- No boundary data is available for this mission.
-
- )}
-
- {!inputDisabled && props.searchResults.length > 0 && (
-
- {props.searchResults.map((r) => (
- -
-
-
- ))}
-
- )}
-
- )
-}
-
-function InspectPanel() {
- return (
-
-
- Click on a boundary on the map to select it for analysis.
-
-
- )
-}
-
-function DrawPanel(props: AOIComponentProps) {
- if (props.isDrawing && props.drawShape) {
- return
- }
- return
-}
-
-const ALL_SHAPES: Array<{ id: AOIShape; label: string; icon: string }> = [
- { id: 'point', label: 'Point', icon: 'map-marker-outline' },
- { id: 'linestring', label: 'Line', icon: 'vector-polyline' },
- { id: 'polygon', label: 'Polygon', icon: 'vector-polygon' },
- { id: 'rectangle', label: 'Rectangle', icon: 'square-outline' },
- { id: 'circle', label: 'Circle', icon: 'circle-outline' },
-]
-
-const DEFAULT_ALLOWED_SHAPES: AOIShape[] = ['polygon', 'rectangle', 'circle']
-
-function DrawShapePickerPanel(props: AOIComponentProps) {
- const allow = new Set(props.drawShapes ?? DEFAULT_ALLOWED_SHAPES)
- const shapes = ALL_SHAPES.filter((s) => allow.has(s.id))
- return (
-
-
Choose a shape
-
- {shapes.map((s) => (
-
- ))}
-
-
- Click anywhere on the map to start drawing a shape
-
-
- )
-}
-
-const MIN_VERTICES_BY_SHAPE: Record = {
- point: 1,
- linestring: 2,
- polygon: 3,
- rectangle: 2,
- circle: 2,
-}
-
-const HINT_BY_SHAPE: Record string> = {
- point: () => 'Click on the map to place the point.',
- linestring: (count) =>
- `Click to add vertices. ${count} placed (need 2+). Double-click or Enter to finish.`,
- polygon: (count, min) =>
- `Click on the map to add vertices. ${count} placed (need ${min}+).`,
- rectangle: () => 'Click two corners to define the rectangle.',
- circle: () => 'Click the centre, then click the edge to define the circle.',
-}
-
-function DrawInProgressPanel(props: AOIComponentProps) {
- const shape = props.drawShape!
- const minVertices = MIN_VERTICES_BY_SHAPE[shape]
- const valid = props.drawVerticesCount >= minVertices
- const hint = HINT_BY_SHAPE[shape](props.drawVerticesCount, minVertices)
-
- return (
-
-
{hint}
-
-
-
-
-
- )
-}
-
-function UploadPanel(props: AOIComponentProps) {
- const inputRef = useRef(null)
- return (
-
-
- Upload a GeoJSON, KML, or zipped Shapefile to define your analysis area.
-
-
-
-
{
- const f = e.target.files?.[0]
- if (f) props.onUploadFile(f)
- e.target.value = ''
- }}
- />
-
- {props.uploadStatus === 'error' && (
-
- {props.uploadError || 'Could not parse that file.'}
-
- )}
-
-
- Supported formats:
-
-
- - GeoJSON (.geojson, .json)
- - KML (.kml)
- - Shapefile (.zip — must include .shp, .dbf, and .prj)
-
-
- )
-}
-
-export default AOIComponent
diff --git a/src/essence/Tools/AOI/AOIComponent/_aoi-tokens.scss b/src/essence/Tools/AOI/AOIComponent/_aoi-tokens.scss
deleted file mode 100644
index bd73808ae..000000000
--- a/src/essence/Tools/AOI/AOIComponent/_aoi-tokens.scss
+++ /dev/null
@@ -1,35 +0,0 @@
-// AOI plugin design tokens.
-//
-// Mirrors the tinacms-portal-monorepo theme-tokens.scss pattern: a single
-// partial that any AOI stylesheet @uses to mix into a host selector. The CSS
-// custom properties below carry an `--mmgis-*` fallback so a host theme can
-// override them at `:root` without touching this file.
-
-@mixin aoi-tokens {
- --aoi-bg: var(--mmgis-surface, #ffffff);
- --aoi-bg-muted: var(--mmgis-surface-muted, #f6f7f8);
- --aoi-fg: var(--mmgis-text, #1b1b1b);
- --aoi-fg-muted: var(--mmgis-text-muted, #565c65);
- --aoi-accent: var(--mmgis-accent, #137480);
- --aoi-accent-hover: var(--mmgis-accent-hover, #0e5a64);
- --aoi-accent-fg: var(--mmgis-accent-fg, #ffffff);
- --aoi-border: var(--mmgis-border, #dfe1e2);
- --aoi-border-hover: var(--mmgis-border-hover, #a9aeb1);
- --aoi-danger: var(--mmgis-danger, #b50909);
-
- --aoi-radius-sm: var(--mmgis-radius-sm, 4px);
- --aoi-radius-md: var(--mmgis-radius-md, 6px);
-
- --aoi-space-1: var(--mmgis-space-1, 4px);
- --aoi-space-2: var(--mmgis-space-2, 8px);
- --aoi-space-3: var(--mmgis-space-3, 12px);
- --aoi-space-4: var(--mmgis-space-4, 16px);
- --aoi-space-5: var(--mmgis-space-5, 24px);
-
- --aoi-font-body: var(--mmgis-font-body, 'Source Sans Pro', system-ui, sans-serif);
- --aoi-font-size-sm: var(--mmgis-font-size-sm, 13px);
- --aoi-font-size-md: var(--mmgis-font-size-md, 14px);
- --aoi-font-size-lg: var(--mmgis-font-size-lg, 16px);
-
- --aoi-shadow-pop: var(--mmgis-shadow-pop, 0 2px 6px rgba(0, 0, 0, 0.18));
-}
diff --git a/src/essence/Tools/AOI/AOIComponent/index.ts b/src/essence/Tools/AOI/AOIComponent/index.ts
deleted file mode 100644
index e455cae29..000000000
--- a/src/essence/Tools/AOI/AOIComponent/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { default } from './AOIComponent'
-export * from './AOIComponent'
diff --git a/src/essence/Tools/AOI/AOITool.js b/src/essence/Tools/AOI/AOITool.js
deleted file mode 100644
index 1ba257225..000000000
--- a/src/essence/Tools/AOI/AOITool.js
+++ /dev/null
@@ -1,647 +0,0 @@
-/**
- * AOI plugin — MMGIS wrapper.
- *
- * Pluggable contract (see specs/012-aoi-plugin/plan.md and PLUGIN-DEVELOPMENT-GUIDE.md):
- *
- * pluginId: 'aoi'
- *
- * Emits (auto-prefixed plugin:aoi:):
- * - areaDrawn { feature, source: 'search'|'draw'|'upload'|'inspect' }
- * - analysisAOIReady { feature } — consumed by the FetchStats plugin
- * - drawingCleared {}
- * - drawingCancelled {}
- *
- * Provides (auto-prefixed plugin:aoi:):
- * - getCurrentSelection -> { feature, source } | null
- *
- * Listens to:
- * - tool:change (core)
- * - map:drawstart / drawvertex /
- * drawcomplete / drawcancel (engine bus)
- * - map:featureClick (inspect-mode boundary clicks, filtered by layerId)
- * - plugin:fetch-stats:analysisProgress { done, total }
- * - plugin:fetch-stats:analysisReady { analysisData }
- * - plugin:fetch-stats:analysisSkipped { reason }
- *
- * Requests:
- * - map:createLayer / map:removeLayer
- * - map:fitBounds
- * - map:enableDrawing / map:finishDrawing / map:disableDrawing
- * - map:addOverlay / map:removeOverlay
-
- * AOIComponent.tsx and AOITooltip.tsx must stay MMGIS-agnostic.
- */
-
-import React from 'react'
-import { render, unmountComponentAtNode } from 'react-dom'
-
-import AOIComponent from './AOIComponent'
-import AOITooltip from './AOITooltip'
-import {
- buildSearchIndex,
- searchIndex,
- parseGeoJSONText,
- parseKMLText,
- parseShapefileBuffer,
- featureCentroid,
- featureBounds,
-} from './aoiHelpers'
-import { loadBoundaries } from './aoiBoundaryLoader'
-
-// ── Plugin identity / layer ids ────────────────────────────────────────────────
-const PLUGIN_ID = 'aoi'
-const DEFAULT_DRAW_SHAPES = ['polygon', 'rectangle', 'circle']
-const VALID_DRAW_SHAPES = new Set(['point', 'linestring', 'polygon', 'rectangle', 'circle'])
-const SELECTION_LAYER_ID = 'aoi:selection'
-const INSPECT_BOUNDARIES_LAYER_ID = 'aoi:inspect-boundaries'
-const TOOLTIP_OVERLAY_ID = 'aoi:tooltip'
-
-// ── Styles ─────────────────────────────────────────────────────────────────────
-const SELECTION_STYLE = {
- color: '#005ea2',
- weight: 3,
- fillColor: '#005ea2',
- fillOpacity: 0.25,
-}
-
-const INSPECT_STYLE = {
- color: '#137480',
- weight: 1,
- fillColor: '#137480',
- fillOpacity: 0.06,
-}
-
-const initialState = () => ({
- mode: 'search',
- searchQuery: '',
- searchAllEntries: [],
- searchResults: [],
- searchLoading: false,
- searchDisabled: false,
- drawShape: null,
- drawDisabled: false,
- isDrawing: false,
- drawVerticesCount: 0,
- uploadStatus: 'idle',
- uploadError: '',
- currentAOI: null,
- analysisStatus: 'idle',
- analysisLabel: '',
- analysisDone: 0,
- analysisTotal: 0,
- analysisError: null,
-})
-
-/**
- * Bbox area of a GeoJSON feature, used to order overlapping polygons so
- * smaller ones (states) sit on top of larger ones (country) for picking.
- * Returns 0 when bounds can't be computed.
- */
-function bboxArea(feature) {
- const b = featureBounds(feature)
- if (!b) return 0
- return (b[2] - b[0]) * (b[3] - b[1])
-}
-
-const AOITool = {
- // height:0 + width:0 makes ToolController_ collapse the docked side rail to 0px.
- height: 0,
- width: 0,
- made: false,
- MMGISInterface: null,
- _root: null,
- _state: initialState(),
- _cleanups: [],
- _api: null,
- _analysisErrorTimeout: null,
-
- // ── Lifecycle ──────────────────────────────────────────────────────────────
-
- make(targetId) {
- this.MMGISInterface = new interfaceWithMMGIS(this, targetId)
-
- this._api =
- (typeof window !== 'undefined' && window.mmgisAPI?.forPlugin?.(PLUGIN_ID)) ||
- { emit: () => { }, provide: () => () => { } }
-
- this._cleanups.push(
- this._api.provide('getCurrentSelection', () => this._state.currentAOI)
- )
-
- this._state.searchLoading = true
- loadBoundaries()
- .then((boundaries) => {
- this._setState({
- searchAllEntries: buildSearchIndex(boundaries),
- searchLoading: false,
- })
- if (this._state.mode === 'inspect') {
- this._showInspectBoundaries()
- }
- })
- .catch((err) => {
- console.warn('[AOI] failed to load bundled boundaries', err)
- this._setState({ searchLoading: false, searchDisabled: true })
- })
-
- const api = window.mmgisAPI
- if (api?.on) {
- const subscribe = (event, handler) => {
- const off = api.on(event, handler)
- this._cleanups.push(typeof off === 'function' ? off : () => { })
- }
- subscribe('tool:change', () => this._clearSelection())
- subscribe('map:drawstart', (e) => this._onDrawStart(e))
- subscribe('map:drawvertex', (e) => this._onDrawVertex(e))
- subscribe('map:drawcomplete', (e) => this._onDrawComplete(e))
- subscribe('map:drawcancel', () => this._onDrawCancelEvent())
- subscribe('map:featureClick', (info) => this._onMapFeatureClick(info))
- subscribe('plugin:fetch-stats:analysisProgress', ({ done, total }) => {
- if (done === 0) {
- this._setState({
- analysisStatus: 'running',
- analysisLabel: this._state.currentAOI?.label || 'Area of interest',
- analysisDone: 0,
- analysisTotal: total,
- })
- } else {
- this._setState({ analysisDone: done })
- }
- })
- subscribe('plugin:fetch-stats:analysisReady', () => {
- this._setState({ analysisStatus: 'idle' })
- })
- subscribe('plugin:fetch-stats:analysisSkipped', ({ reason } = {}) => {
- this._showAnalysisError(this._messageForSkipReason(reason))
- })
- }
-
- this._render()
- this.made = true
- },
-
- destroy() {
- this._cleanups.forEach((fn) => {
- try {
- fn()
- } catch {
- // intentionally swallow — destroy must remain idempotent
- }
- })
- this._cleanups = []
-
- if (this._analysisErrorTimeout) {
- clearTimeout(this._analysisErrorTimeout)
- this._analysisErrorTimeout = null
- }
-
- // Fire-and-forget: cancel any active drawing session via the bus.
- window.mmgisAPI?.request?.('map:disableDrawing').catch(() => { })
-
- this._removeSelectionLayer()
- this._hideInspectBoundaries()
- this._hideTooltip()
-
- if (this._root) {
- unmountComponentAtNode(this._root)
- this._root = null
- }
-
- if (this.MMGISInterface) {
- this.MMGISInterface.separateFromMMGIS()
- this.MMGISInterface = null
- }
-
- this._state = initialState()
- this._api = null
- this.made = false
- },
-
- getUrlString() {
- return ''
- },
-
- // ── Rendering ──────────────────────────────────────────────────────────────
-
- _setState(patch) {
- this._state = { ...this._state, ...patch }
- this._render()
- },
-
- /**
- * Resolve the configured Draw-shape allowlist from the mission's AOI
- * tool variables. Accepts either an array (`textarray` config field) or
- * a comma-separated string, trims/normalises entries, and drops any
- * value not in the supported set. Falls back to a sensible default when
- * unset or empty.
- */
- _resolveDrawShapes() {
- const raw = this._api?.getVars?.()?.drawShapes
- const list = Array.isArray(raw)
- ? raw
- : typeof raw === 'string'
- ? raw.split(',')
- : null
- if (!list) return DEFAULT_DRAW_SHAPES
- const cleaned = list
- .map((s) => String(s).trim().toLowerCase())
- .filter((s) => VALID_DRAW_SHAPES.has(s))
- return cleaned.length ? cleaned : DEFAULT_DRAW_SHAPES
- },
-
- /**
- * Surface a transient inline error in the AOI panel. Auto-dismisses after
- * 8s so the user isn't stuck looking at stale feedback; the user can also
- * dismiss it manually via {@link _dismissAnalysisError}.
- */
- _showAnalysisError(message) {
- if (!message) return
- if (this._analysisErrorTimeout) {
- clearTimeout(this._analysisErrorTimeout)
- }
- this._setState({ analysisError: message })
- this._analysisErrorTimeout = setTimeout(() => {
- this._analysisErrorTimeout = null
- this._setState({ analysisError: null })
- }, 8000)
- },
-
- _dismissAnalysisError() {
- if (this._analysisErrorTimeout) {
- clearTimeout(this._analysisErrorTimeout)
- this._analysisErrorTimeout = null
- }
- this._setState({ analysisError: null })
- },
-
- /**
- * Map a `plugin:fetch-stats:analysisSkipped.reason` to a user-facing message.
- * Unknown reasons get a generic fallback.
- */
- _messageForSkipReason(reason) {
- if (reason === 'no-eligible-layers') {
- return 'No analyzable layer is currently visible. Toggle on a layer with analysis support and try again.'
- }
- return 'Analysis could not run.'
- },
-
- _render() {
- if (!this._root) return
- render(
- React.createElement(AOIComponent, {
- mode: this._state.mode,
- onModeChange: (mode) => this._onModeChange(mode),
-
- searchQuery: this._state.searchQuery,
- searchResults: this._state.searchResults,
- searchDisabled: this._state.searchDisabled,
- searchLoading: this._state.searchLoading,
- onSearchQueryChange: (q) => this._onSearchQueryChange(q),
- onSearchSelect: (id) => this._onSearchSelect(id),
-
- drawShape: this._state.drawShape,
- drawShapes: this._resolveDrawShapes(),
- drawDisabled: this._state.drawDisabled,
- isDrawing: this._state.isDrawing,
- drawVerticesCount: this._state.drawVerticesCount,
- onDrawShapeChange: (drawShape) => this._onDrawShapeChange(drawShape),
- onDrawConfirm: () => this._onDrawConfirm(),
- onDrawCancel: () => this._onDrawCancel(),
-
- uploadStatus: this._state.uploadStatus,
- uploadError: this._state.uploadError,
- onUploadFile: (file) => this._onUploadFile(file),
-
- analysisStatus: this._state.analysisStatus,
- analysisLabel: this._state.analysisLabel,
- analysisDone: this._state.analysisDone,
- analysisTotal: this._state.analysisTotal,
- analysisError: this._state.analysisError,
- onDismissAnalysisError: () => this._dismissAnalysisError(),
-
- onClose: () => this._onClose(),
- }),
- this._root
- )
- },
-
- // ── Mode switching ─────────────────────────────────────────────────────────
-
- _onModeChange(nextMode) {
- const prev = this._state.mode
- if (prev === nextMode) return
-
- if (prev === 'inspect') this._hideInspectBoundaries()
- if (nextMode === 'inspect') this._showInspectBoundaries()
-
- if (prev === 'draw') {
- // Cancel any in-flight drawing session when leaving Draw mode.
- // The bus handler is a no-op if no session is active.
- window.mmgisAPI?.request?.('map:disableDrawing').catch(() => { })
- }
-
- this._setState({ mode: nextMode })
- },
-
- _onClose() {
- const btn = document.getElementById('toolButtonAOI')
- if (btn) btn.click()
- },
-
- // ── Search mode ────────────────────────────────────────────────────────────
-
- _onSearchQueryChange(q) {
- this._setState({
- searchQuery: q,
- searchResults: searchIndex(q, this._state.searchAllEntries),
- })
- },
-
- _onSearchSelect(id) {
- const entry = this._state.searchAllEntries.find((e) => e.id === id)
- if (!entry) return
- this._applySelection(entry.feature, 'search', entry.label)
- },
-
- // ── Draw mode ──────────────────────────────────────────────────────────────
-
- _onDrawShapeChange(shape) {
- this._setState({ drawShape: shape, drawVerticesCount: 0 })
- window.mmgisAPI?.request?.('map:enableDrawing', {
- shape,
- options: { style: SELECTION_STYLE },
- }).catch((err) => console.warn('[AOI] enableDrawing failed', err))
- },
-
- _onDrawConfirm() {
- window.mmgisAPI?.request?.('map:finishDrawing')
- .catch((err) => console.warn('[AOI] finishDrawing failed', err))
- },
-
- _onDrawCancel() {
- window.mmgisAPI?.request?.('map:disableDrawing')
- .catch((err) => console.warn('[AOI] disableDrawing failed', err))
- },
-
- _onDrawStart() {
- this._setState({ isDrawing: true, drawVerticesCount: 0 })
- },
-
- _onDrawVertex(e) {
- const count = Array.isArray(e?.vertices) ? e.vertices.length : 0
- this._setState({ drawVerticesCount: count })
- },
-
- _onDrawComplete(e) {
- this._setState({ isDrawing: false, drawShape: null, drawVerticesCount: 0 })
- const feature = e?.feature
- if (!feature) return
- const label = feature.properties?.shape
- ? `Drawn ${feature.properties.shape}`
- : 'Drawn area'
- this._applySelection(feature, 'draw', label)
- },
-
- _onDrawCancelEvent() {
- this._setState({ isDrawing: false, drawShape: null, drawVerticesCount: 0 })
- },
-
- // ── Inspect mode ───────────────────────────────────────────────────────────
-
- _showInspectBoundaries() {
- const entries = this._state.searchAllEntries
- if (!entries.length) return
- const api = window.mmgisAPI
- if (!api?.request) return
-
- // Sort largest-area first so big polygons (e.g. "United States") render
- // first and small ones (states) end up on top. Deck.gl picks the
- // topmost feature, so this ensures a click in Alabama returns Alabama,
- // not the country polygon that contains it.
- const features = entries
- .map((e) => e.feature)
- .slice()
- .sort((a, b) => bboxArea(b) - bboxArea(a))
-
- // Drop any prior layer first; createLayer is not idempotent.
- api.request('map:removeLayer', { id: INSPECT_BOUNDARIES_LAYER_ID })
- .catch(() => { })
- .then(() => api.request('map:createLayer', {
- id: INSPECT_BOUNDARIES_LAYER_ID,
- type: 'vector',
- geojson: {
- type: 'FeatureCollection',
- features,
- },
- style: INSPECT_STYLE,
- interactive: true,
- }))
- .catch((err) => console.warn('[AOI] failed to show inspect boundaries', err))
- },
-
- _hideInspectBoundaries() {
- window.mmgisAPI?.request?.('map:removeLayer', { id: INSPECT_BOUNDARIES_LAYER_ID })
- .catch(() => { })
- },
-
- _onMapFeatureClick(info) {
- if (this._state.mode !== 'inspect') return
- if (info?.layerId !== INSPECT_BOUNDARIES_LAYER_ID) return
- const feature = info?.feature
- if (!feature?.geometry) return
- const gtype = feature.geometry.type
- if (gtype !== 'Polygon' && gtype !== 'MultiPolygon') return
- const props = feature.properties || {}
- const label =
- props.name ||
- props.NAME ||
- props.title ||
- (props._aoiKind ? `Inspected ${props._aoiKind}` : 'Inspected area')
- this._applySelection(feature, 'inspect', label)
- },
-
- // ── Upload mode ────────────────────────────────────────────────────────────
-
- _onUploadFile(file) {
- this._setState({ uploadStatus: 'parsing', uploadError: '' })
- const reader = new FileReader()
- const name = file.name || ''
- const isShapefile = /\.(zip|shp)$/i.test(name)
- const isKml = /\.kml$/i.test(name)
-
- reader.onerror = () => {
- this._setState({ uploadStatus: 'error', uploadError: 'Could not read file.' })
- }
-
- const applyResult = (result) => {
- if (!result.feature) {
- this._setState({
- uploadStatus: 'error',
- uploadError: result.error || 'Could not parse file.',
- })
- return
- }
- const props = result.feature.properties || {}
- const label = props.name || props.NAME || props.title || name
- this._setState({ uploadStatus: 'idle', uploadError: '' })
- this._applySelection(result.feature, 'upload', String(label))
- }
-
- reader.onload = () => {
- if (isShapefile) {
- parseShapefileBuffer(reader.result).then(applyResult).catch((err) => {
- this._setState({
- uploadStatus: 'error',
- uploadError: err?.message || 'Could not parse shapefile.',
- })
- })
- return
- }
- const text = String(reader.result || '')
- applyResult(isKml ? parseKMLText(text) : parseGeoJSONText(text))
- }
-
- if (isShapefile) {
- reader.readAsArrayBuffer(file)
- } else {
- reader.readAsText(file)
- }
- },
-
- // ── Selection lifecycle ────────────────────────────────────────────────────
-
- _applySelection(feature, source, label) {
- this._removeSelectionLayer()
-
- const api = window.mmgisAPI
- api?.request?.('map:createLayer', {
- id: SELECTION_LAYER_ID,
- type: 'vector',
- geojson: { type: 'FeatureCollection', features: [feature] },
- style: SELECTION_STYLE,
- interactive: false,
- }).catch((err) => console.warn('[AOI] failed to add selection layer', err))
-
- this._state.currentAOI = { feature, source, label }
- this._api?.emit('areaDrawn', { feature, source })
-
- const c = featureCentroid(feature)
- const showTooltip = () => {
- if (c) {
- this._showTooltip({
- label,
- latlng: { lat: c[1], lng: c[0] },
- analyzeEnabled: true,
- })
- }
- }
-
- const bbox = featureBounds(feature)
- if (bbox && api?.on && api?.off) {
- // Defer the tooltip until the fitBounds animation settles so it
- // mounts at the final centroid pixel instead of flickering through
- // intermediate positions during the camera move.
- let fallback
- const oneShot = () => {
- api.off('map:moveend', oneShot)
- clearTimeout(fallback)
- showTooltip()
- }
- api.on('map:moveend', oneShot)
- // Belt-and-braces: if no moveend fires (e.g. already at target view),
- // show the tooltip after a short timeout anyway.
- fallback = setTimeout(oneShot, 1500)
-
- api.request('map:fitBounds', {
- bounds: [
- { lat: bbox[1], lng: bbox[0] },
- { lat: bbox[3], lng: bbox[2] },
- ],
- options: { padding: 120, maxZoom: 5 },
- }).catch((err) => {
- console.warn('[AOI] fitBounds failed', err)
- oneShot()
- })
- } else {
- showTooltip()
- }
- },
-
- _clearSelection() {
- if (!this._state.currentAOI) return
- this._removeSelectionLayer()
- this._hideTooltip()
- this._state.currentAOI = null
- this._api?.emit('drawingCleared', {})
- this._render()
- },
-
- _removeSelectionLayer() {
- // removeLayer is idempotent; no need for a hasLayer pre-check.
- window.mmgisAPI?.request?.('map:removeLayer', { id: SELECTION_LAYER_ID })
- .catch(() => { })
- },
-
- // ── Tooltip overlay ────────────────────────────────────────────────────────
-
- /**
- * Show the analyze/cancel tooltip anchored to a feature centroid.
- * Core's `map:addOverlay` owns the DOM and repositions on view change.
- */
- _showTooltip({ label, latlng, analyzeEnabled }) {
- const api = window.mmgisAPI
- if (!api?.request) return
- api.request('map:addOverlay', {
- id: TOOLTIP_OVERLAY_ID,
- latlng,
- mount: (node) => {
- render(
- React.createElement(AOITooltip, {
- label,
- position: { x: 0, y: 0 },
- analyzeEnabled,
- onAnalyze: () => this._onAnalyze(),
- onCancel: () => this._onCancel(),
- }),
- node
- )
- return () => unmountComponentAtNode(node)
- },
- }).catch((err) => console.warn('[AOI] addOverlay failed', err))
- },
-
- _hideTooltip() {
- window.mmgisAPI?.request?.('map:removeOverlay', { id: TOOLTIP_OVERLAY_ID })
- .catch(() => { })
- },
-
- // ── Analysis hand-off ──────────────────────────────────────────────────────
-
- _onAnalyze() {
- const aoi = this._state.currentAOI
- if (!aoi) return
- this._api?.emit('analysisAOIReady', { feature: aoi.feature })
- this._hideTooltip()
- },
-
- _onCancel() {
- this._api?.emit('drawingCancelled', {})
- this._clearSelection()
- },
-
-}
-
-function interfaceWithMMGIS(tool) {
- const root = document.createElement('div')
- root.className = 'aoi-tool-host'
- document.body.appendChild(root)
- tool._root = root
-
- this.separateFromMMGIS = function () {
- if (tool._root && tool._root.parentNode) {
- tool._root.parentNode.removeChild(tool._root)
- }
- }
-}
-
-export default AOITool
diff --git a/src/essence/Tools/AOI/AOITool.tsx b/src/essence/Tools/AOI/AOITool.tsx
new file mode 100644
index 000000000..f73483929
--- /dev/null
+++ b/src/essence/Tools/AOI/AOITool.tsx
@@ -0,0 +1,45 @@
+/**
+ * AOITool — MMGIS tool wrapper for the AOI plugin.
+ *
+ * AOI is a separated (floating) tool: it self-mounts into an `.aoi-tool-host`
+ * div on document.body rather than a docked panel target. All state + bus
+ * wiring lives in MMGISAOIAdapter; the lib/ tree is portable.
+ */
+import React from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { MMGISAOIAdapter } from './MMGISAOIAdapter'
+
+let _root: Root | null = null
+let _host: HTMLDivElement | null = null
+
+const AOITool = {
+ height: 0,
+ width: 0,
+ made: false,
+
+ make() {
+ if (_root) return
+ _host = document.createElement('div')
+ _host.className = 'aoi-tool-host'
+ document.body.appendChild(_host)
+ _root = createRoot(_host)
+ _root.render()
+ this.made = true
+ },
+
+ destroy() {
+ if (_root) {
+ _root.unmount()
+ _root = null
+ }
+ if (_host && _host.parentNode) _host.parentNode.removeChild(_host)
+ _host = null
+ this.made = false
+ },
+
+ getUrlString() {
+ return ''
+ },
+}
+
+export default AOITool
diff --git a/src/essence/Tools/AOI/MMGISAOIAdapter.tsx b/src/essence/Tools/AOI/MMGISAOIAdapter.tsx
new file mode 100644
index 000000000..5bbb3bc9a
--- /dev/null
+++ b/src/essence/Tools/AOI/MMGISAOIAdapter.tsx
@@ -0,0 +1,428 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import type { Feature } from 'geojson'
+import { AOIPanel, AOITooltip } from './lib'
+import type {
+ AOIMode,
+ AOIShape,
+ AOISearchResult,
+ UploadStatus,
+ AnalysisStatus,
+} from './lib'
+import { useMMGISEvent } from './adapters/useMMGISEvent'
+import { useMMGISToolVars } from './adapters/useMMGISToolVars'
+import { mmgisOn } from '../_shared/adapters/mmgisAPI'
+import {
+ buildSearchIndex,
+ searchIndex,
+ type AOISearchEntry,
+} from './adapters/search'
+import { parseGeoJSONText, parseKMLText, parseShapefileBuffer } from './adapters/parse'
+import { featureCentroid, featureBounds } from './adapters/geometry'
+import { loadBoundaries } from './aoiBoundaryLoader'
+import {
+ INSPECT_BOUNDARIES_LAYER_ID,
+ OPEN_PANEL_CHANNEL,
+ emitAOI,
+ provideAOI,
+ enableDrawing,
+ finishDrawing,
+ disableDrawing,
+ drawSelection,
+ removeSelection,
+ showInspectBoundaries,
+ hideInspectBoundaries,
+ fitToBounds,
+ addTooltipOverlay,
+ removeTooltipOverlay,
+} from './adapters/aoiBus'
+
+const VALID_DRAW_SHAPES = new Set([
+ 'point',
+ 'linestring',
+ 'polygon',
+ 'rectangle',
+ 'circle',
+])
+const DEFAULT_DRAW_SHAPES: AOIShape[] = ['polygon', 'rectangle', 'circle']
+
+type ToolVars = { drawShapes?: unknown }
+
+function resolveDrawShapes(raw: unknown): AOIShape[] {
+ const list = Array.isArray(raw)
+ ? raw
+ : typeof raw === 'string'
+ ? raw.split(',')
+ : null
+ if (!list) return DEFAULT_DRAW_SHAPES
+ const cleaned = list
+ .map((s) => String(s).trim().toLowerCase())
+ .filter((s): s is AOIShape => VALID_DRAW_SHAPES.has(s as AOIShape))
+ return cleaned.length ? cleaned : DEFAULT_DRAW_SHAPES
+}
+
+type CurrentAOI = { feature: Feature; source: string; label: string }
+
+export function MMGISAOIAdapter() {
+ // The selection panel stays hidden until the Title plugin's "Analyze area"
+ // button fires OPEN_PANEL_CHANNEL.
+ const [panelVisible, setPanelVisible] = useState(false)
+ const [mode, setMode] = useState('search')
+ const [searchQuery, setSearchQuery] = useState('')
+ const [searchResults, setSearchResults] = useState([])
+ const [searchLoading, setSearchLoading] = useState(true)
+ const [searchDisabled, setSearchDisabled] = useState(false)
+ const [drawShape, setDrawShape] = useState(null)
+ const [isDrawing, setIsDrawing] = useState(false)
+ const [drawVerticesCount, setDrawVerticesCount] = useState(0)
+ const [uploadStatus, setUploadStatus] = useState('idle')
+ const [uploadError, setUploadError] = useState('')
+ const [analysisStatus, setAnalysisStatus] = useState('idle')
+ const [analysisLabel, setAnalysisLabel] = useState('')
+ const [analysisDone, setAnalysisDone] = useState(0)
+ const [analysisTotal, setAnalysisTotal] = useState(0)
+ const [analysisError, setAnalysisError] = useState(null)
+
+ const vars = useMMGISToolVars('aoi')
+ const drawShapes = resolveDrawShapes(vars.drawShapes)
+
+ // Refs that event handlers read without re-subscribing.
+ const entriesRef = useRef([])
+ const currentAOIRef = useRef(null)
+ const modeRef = useRef(mode)
+ const tooltipRootRef = useRef(null)
+ const errorTimeoutRef = useRef | null>(null)
+ useEffect(() => {
+ modeRef.current = mode
+ }, [mode])
+
+ // ── getCurrentSelection provider ─────────────────────────────────────────
+ useEffect(() => {
+ const off = provideAOI('getCurrentSelection', () => currentAOIRef.current)
+ return off
+ }, [])
+
+ // ── Load bundled boundaries once ──────────────────────────────────────────
+ useEffect(() => {
+ let cancelled = false
+ loadBoundaries()
+ .then((boundaries) => {
+ if (cancelled) return
+ entriesRef.current = buildSearchIndex(boundaries)
+ setSearchLoading(false)
+ if (modeRef.current === 'inspect') {
+ showInspectBoundaries(entriesRef.current.map((e) => e.feature))
+ }
+ })
+ .catch((err) => {
+ if (cancelled) return
+ console.warn('[AOI] failed to load bundled boundaries', err)
+ setSearchLoading(false)
+ setSearchDisabled(true)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ // ── Tooltip ───────────────────────────────────────────────────────────────
+ const hideTooltip = useCallback(() => {
+ removeTooltipOverlay()
+ if (tooltipRootRef.current) {
+ tooltipRootRef.current.unmount()
+ tooltipRootRef.current = null
+ }
+ }, [])
+
+ const clearSelection = useCallback(() => {
+ if (!currentAOIRef.current) return
+ removeSelection()
+ hideTooltip()
+ currentAOIRef.current = null
+ emitAOI('drawingCleared', {})
+ }, [hideTooltip])
+
+ const showTooltip = useCallback(
+ (label: string, latlng: { lat: number; lng: number }) => {
+ addTooltipOverlay(latlng, (node) => {
+ const root = createRoot(node)
+ tooltipRootRef.current = root
+ root.render(
+ {
+ const aoi = currentAOIRef.current
+ if (aoi) emitAOI('analysisAOIReady', { feature: aoi.feature })
+ hideTooltip()
+ }}
+ onCancel={() => {
+ emitAOI('drawingCancelled', {})
+ clearSelection()
+ }}
+ />,
+ )
+ return () => {
+ root.unmount()
+ if (tooltipRootRef.current === root) tooltipRootRef.current = null
+ }
+ })
+ },
+ [hideTooltip, clearSelection],
+ )
+
+ // ── Selection lifecycle ────────────────────────────────────────────────────
+ const applySelection = useCallback(
+ (feature: Feature, source: string, label: string) => {
+ removeSelection()
+ drawSelection(feature)
+ currentAOIRef.current = { feature, source, label }
+ emitAOI('areaDrawn', { feature, source })
+
+ const c = featureCentroid(feature)
+ const doShow = () => {
+ if (c) showTooltip(label, { lat: c[1], lng: c[0] })
+ }
+
+ const bbox = featureBounds(feature)
+ if (bbox) {
+ // Defer the tooltip until the fitBounds animation settles.
+ let fallback: ReturnType
+ const oneShot = () => {
+ off()
+ clearTimeout(fallback)
+ doShow()
+ }
+ const off = mmgisOn('map:moveend', oneShot)
+ fallback = setTimeout(oneShot, 1500)
+ fitToBounds(bbox).catch(() => oneShot())
+ } else {
+ doShow()
+ }
+ },
+ [showTooltip],
+ )
+
+ // ── Mode handlers ──────────────────────────────────────────────────────────
+ const onModeChange = useCallback((next: AOIMode) => {
+ setMode((prev) => {
+ if (prev === next) return prev
+ if (prev === 'inspect') hideInspectBoundaries()
+ if (next === 'inspect') showInspectBoundaries(entriesRef.current.map((e) => e.feature))
+ if (prev === 'draw') disableDrawing()
+ return next
+ })
+ }, [])
+
+ // Closing only hides the panel — the adapter stays mounted so it keeps
+ // listening for the next OPEN_PANEL_CHANNEL from the Title plugin.
+ const onClose = useCallback(() => {
+ setPanelVisible(false)
+ }, [])
+
+ // ── Search ──────────────────────────────────────────────────────────────────
+ const onSearchQueryChange = useCallback((q: string) => {
+ setSearchQuery(q)
+ setSearchResults(
+ searchIndex(q, entriesRef.current).map((e) => ({
+ id: e.id,
+ label: e.label,
+ kind: e.kind,
+ })),
+ )
+ }, [])
+
+ const onSearchSelect = useCallback(
+ (id: string) => {
+ const entry = entriesRef.current.find((e) => e.id === id)
+ if (entry) applySelection(entry.feature, 'search', entry.label)
+ },
+ [applySelection],
+ )
+
+ // ── Draw ──────────────────────────────────────────────────────────────────
+ const onDrawShapeChange = useCallback((shape: AOIShape) => {
+ setDrawShape(shape)
+ setDrawVerticesCount(0)
+ enableDrawing(shape)
+ }, [])
+ const onDrawConfirm = useCallback(() => finishDrawing(), [])
+ const onDrawCancel = useCallback(() => disableDrawing(), [])
+
+ // ── Upload ──────────────────────────────────────────────────────────────────
+ const onUploadFile = useCallback(
+ (file: File) => {
+ setUploadStatus('parsing')
+ setUploadError('')
+ const reader = new FileReader()
+ const name = file.name || ''
+ const isShapefile = /\.(zip|shp)$/i.test(name)
+ const isKml = /\.kml$/i.test(name)
+
+ const applyResult = (result: { feature: Feature | null; error?: string }) => {
+ if (!result.feature) {
+ setUploadStatus('error')
+ setUploadError(result.error || 'Could not parse file.')
+ return
+ }
+ const props = (result.feature.properties || {}) as Record
+ const label =
+ (props.name as string) ||
+ (props.NAME as string) ||
+ (props.title as string) ||
+ name
+ setUploadStatus('idle')
+ setUploadError('')
+ applySelection(result.feature, 'upload', String(label))
+ }
+
+ reader.onerror = () => {
+ setUploadStatus('error')
+ setUploadError('Could not read file.')
+ }
+ reader.onload = () => {
+ if (isShapefile) {
+ parseShapefileBuffer(reader.result as ArrayBuffer)
+ .then(applyResult)
+ .catch((err) => {
+ setUploadStatus('error')
+ setUploadError(err?.message || 'Could not parse shapefile.')
+ })
+ return
+ }
+ const text = String(reader.result || '')
+ applyResult(isKml ? parseKMLText(text) : parseGeoJSONText(text))
+ }
+
+ if (isShapefile) reader.readAsArrayBuffer(file)
+ else reader.readAsText(file)
+ },
+ [applySelection],
+ )
+
+ // ── Analysis error toast ───────────────────────────────────────────────────
+ const showAnalysisError = useCallback((message: string) => {
+ if (!message) return
+ if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current)
+ setAnalysisError(message)
+ errorTimeoutRef.current = setTimeout(() => {
+ errorTimeoutRef.current = null
+ setAnalysisError(null)
+ }, 8000)
+ }, [])
+ const dismissAnalysisError = useCallback(() => {
+ if (errorTimeoutRef.current) {
+ clearTimeout(errorTimeoutRef.current)
+ errorTimeoutRef.current = null
+ }
+ setAnalysisError(null)
+ }, [])
+
+ // ── Bus subscriptions ────────────────────────────────────────────────────
+ useMMGISEvent(OPEN_PANEL_CHANNEL, () => setPanelVisible(true))
+ useMMGISEvent('tool:change', clearSelection)
+ useMMGISEvent('map:drawstart', () => {
+ setIsDrawing(true)
+ setDrawVerticesCount(0)
+ })
+ useMMGISEvent('map:drawvertex', (e) => {
+ const v = (e as { vertices?: unknown[] })?.vertices
+ setDrawVerticesCount(Array.isArray(v) ? v.length : 0)
+ })
+ useMMGISEvent('map:drawcomplete', (e) => {
+ setIsDrawing(false)
+ setDrawShape(null)
+ setDrawVerticesCount(0)
+ const feature = (e as { feature?: Feature })?.feature
+ if (!feature) return
+ const shapeName = (feature.properties as Record | null)?.shape
+ const label = shapeName ? `Drawn ${shapeName}` : 'Drawn area'
+ applySelection(feature, 'draw', label)
+ })
+ useMMGISEvent('map:drawcancel', () => {
+ setIsDrawing(false)
+ setDrawShape(null)
+ setDrawVerticesCount(0)
+ })
+ useMMGISEvent('map:featureClick', (info) => {
+ if (modeRef.current !== 'inspect') return
+ const i = info as { layerId?: string; feature?: Feature }
+ if (i?.layerId !== INSPECT_BOUNDARIES_LAYER_ID) return
+ const feature = i?.feature
+ const gtype = feature?.geometry?.type
+ if (gtype !== 'Polygon' && gtype !== 'MultiPolygon') return
+ const props = (feature!.properties || {}) as Record
+ const label =
+ (props.name as string) ||
+ (props.NAME as string) ||
+ (props.title as string) ||
+ (props._aoiKind ? `Inspected ${props._aoiKind}` : 'Inspected area')
+ applySelection(feature as Feature, 'inspect', String(label))
+ })
+ useMMGISEvent('plugin:fetch-stats:analysisProgress', (p) => {
+ const { done, total } = (p as { done: number; total: number }) || {}
+ if (done === 0) {
+ setAnalysisStatus('running')
+ setAnalysisLabel(currentAOIRef.current?.label || 'Area of interest')
+ setAnalysisDone(0)
+ setAnalysisTotal(total)
+ } else {
+ setAnalysisDone(done)
+ }
+ })
+ useMMGISEvent('plugin:fetch-stats:analysisReady', () => setAnalysisStatus('idle'))
+ useMMGISEvent('plugin:fetch-stats:analysisSkipped', (p) => {
+ const reason = (p as { reason?: string } | undefined)?.reason
+ showAnalysisError(
+ reason === 'no-eligible-layers'
+ ? 'No analyzable layer is currently visible. Toggle on a layer with analysis support and try again.'
+ : 'Analysis could not run.',
+ )
+ })
+
+ // Cleanup transient artifacts on unmount.
+ useEffect(() => {
+ return () => {
+ if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current)
+ disableDrawing()
+ removeSelection()
+ hideInspectBoundaries()
+ hideTooltip()
+ }
+ }, [hideTooltip])
+
+ // Hidden until the Title plugin requests it. Hooks above still run, so the
+ // adapter keeps listening on the bus while the panel is not rendered.
+ if (!panelVisible) return null
+
+ return (
+
+ )
+}
diff --git a/src/essence/Tools/AOI/adapters/aoiBus.ts b/src/essence/Tools/AOI/adapters/aoiBus.ts
new file mode 100644
index 000000000..e3f0f7e09
--- /dev/null
+++ b/src/essence/Tools/AOI/adapters/aoiBus.ts
@@ -0,0 +1,97 @@
+// AOI bus operations — every map:* / plugin:aoi:* call the tool makes.
+// MMGIS-coupled; the lib/ components never import this.
+import type { Feature } from 'geojson'
+import { mmgisEmit, mmgisProvide, mmgisRequest } from '../../_shared/adapters/mmgisAPI'
+
+export const PLUGIN_ID = 'aoi'
+export const SELECTION_LAYER_ID = 'aoi:selection'
+export const INSPECT_BOUNDARIES_LAYER_ID = 'aoi:inspect-boundaries'
+export const TOOLTIP_OVERLAY_ID = 'aoi:tooltip'
+
+// External trigger: the Title plugin owns the "Analyze area" button and emits
+// this when it is clicked. AOI reveals its selection panel (search / inspect /
+// draw / upload) in response — the panel stays hidden until then. Matches the
+// `plugin::` convention.
+export const OPEN_PANEL_CHANNEL = 'plugin:title:analyzeAreaBtnClicked'
+
+export const SELECTION_STYLE = {
+ color: '#005ea2',
+ weight: 3,
+ fillColor: '#005ea2',
+ fillOpacity: 0.25,
+}
+export const INSPECT_STYLE = {
+ color: '#137480',
+ weight: 1,
+ fillColor: '#137480',
+ fillOpacity: 0.06,
+}
+
+// Plugin-scoped emit/provide — manual `plugin:aoi:` prefix (shared mmgisAPI has
+// no forPlugin helper; this matches the legacy forPlugin auto-prefix behavior).
+export const emitAOI = (event: string, payload?: unknown) =>
+ mmgisEmit(`plugin:${PLUGIN_ID}:${event}`, payload)
+export const provideAOI = (name: string, handler: (...args: unknown[]) => unknown) =>
+ mmgisProvide(`plugin:${PLUGIN_ID}:${name}`, handler)
+
+const warn = (label: string) => (err: unknown) => console.warn(`[AOI] ${label} failed`, err)
+
+// ── Drawing ─────────────────────────────────────────────────────────────────
+export const enableDrawing = (shape: string) =>
+ mmgisRequest('map:enableDrawing', { shape, options: { style: SELECTION_STYLE } }).catch(
+ warn('enableDrawing'),
+ )
+export const finishDrawing = () =>
+ mmgisRequest('map:finishDrawing').catch(warn('finishDrawing'))
+export const disableDrawing = () =>
+ mmgisRequest('map:disableDrawing').catch(warn('disableDrawing'))
+
+// ── Selection layer ───────────────────────────────────────────────────────
+export const drawSelection = (feature: Feature) =>
+ mmgisRequest('map:createLayer', {
+ id: SELECTION_LAYER_ID,
+ type: 'vector',
+ geojson: { type: 'FeatureCollection', features: [feature] },
+ style: SELECTION_STYLE,
+ interactive: false,
+ }).catch(warn('add selection layer'))
+export const removeSelection = () =>
+ mmgisRequest('map:removeLayer', { id: SELECTION_LAYER_ID }).catch(() => {})
+
+// ── Inspect boundaries layer ──────────────────────────────────────────────
+export const showInspectBoundaries = (features: Feature[]) =>
+ mmgisRequest('map:removeLayer', { id: INSPECT_BOUNDARIES_LAYER_ID })
+ .catch(() => {})
+ .then(() =>
+ mmgisRequest('map:createLayer', {
+ id: INSPECT_BOUNDARIES_LAYER_ID,
+ type: 'vector',
+ geojson: { type: 'FeatureCollection', features },
+ style: INSPECT_STYLE,
+ interactive: true,
+ }),
+ )
+ .catch(warn('show inspect boundaries'))
+export const hideInspectBoundaries = () =>
+ mmgisRequest('map:removeLayer', { id: INSPECT_BOUNDARIES_LAYER_ID }).catch(() => {})
+
+// ── Camera ────────────────────────────────────────────────────────────────
+export const fitToBounds = (bbox: [number, number, number, number]) =>
+ mmgisRequest('map:fitBounds', {
+ bounds: [
+ { lat: bbox[1], lng: bbox[0] },
+ { lat: bbox[3], lng: bbox[2] },
+ ],
+ options: { padding: 120, maxZoom: 5 },
+ })
+
+// ── Tooltip overlay ───────────────────────────────────────────────────────
+export const addTooltipOverlay = (
+ latlng: { lat: number; lng: number },
+ mount: (node: HTMLElement) => (() => void) | void,
+) =>
+ mmgisRequest('map:addOverlay', { id: TOOLTIP_OVERLAY_ID, latlng, mount }).catch(
+ warn('addOverlay'),
+ )
+export const removeTooltipOverlay = () =>
+ mmgisRequest('map:removeOverlay', { id: TOOLTIP_OVERLAY_ID }).catch(() => {})
diff --git a/src/essence/Tools/AOI/adapters/geometry.ts b/src/essence/Tools/AOI/adapters/geometry.ts
new file mode 100644
index 000000000..90b618abb
--- /dev/null
+++ b/src/essence/Tools/AOI/adapters/geometry.ts
@@ -0,0 +1,59 @@
+import type { Feature } from 'geojson'
+
+/** Average-vertex centroid for Point/Polygon/MultiPolygon, else null. */
+export function featureCentroid(f: Feature): [number, number] | null {
+ const g = f.geometry
+ if (!g) return null
+ if (g.type === 'Point') {
+ const c = g.coordinates as [number, number]
+ return [c[0], c[1]]
+ }
+ if (g.type === 'Polygon' || g.type === 'MultiPolygon') {
+ const rings: number[][][] =
+ g.type === 'Polygon'
+ ? (g.coordinates as number[][][])
+ : ((g.coordinates as number[][][][]).flat() as number[][][])
+ let sx = 0
+ let sy = 0
+ let n = 0
+ for (const ring of rings) {
+ const last = ring.length - 1
+ const stopAt =
+ ring.length > 1 &&
+ ring[0][0] === ring[last][0] &&
+ ring[0][1] === ring[last][1]
+ ? last
+ : ring.length
+ for (let i = 0; i < stopAt; i++) {
+ sx += ring[i][0]
+ sy += ring[i][1]
+ n++
+ }
+ }
+ return n > 0 ? [sx / n, sy / n] : null
+ }
+ return null
+}
+
+/** [west, south, east, north] bbox of a Polygon/MultiPolygon, else null. */
+export function featureBounds(f: Feature): [number, number, number, number] | null {
+ const g = f.geometry
+ if (!g || (g.type !== 'Polygon' && g.type !== 'MultiPolygon')) return null
+ const rings: number[][][] =
+ g.type === 'Polygon'
+ ? (g.coordinates as number[][][])
+ : ((g.coordinates as number[][][][]).flat() as number[][][])
+ let w = Infinity
+ let s = Infinity
+ let e = -Infinity
+ let n = -Infinity
+ for (const ring of rings) {
+ for (const [x, y] of ring) {
+ if (x < w) w = x
+ if (y < s) s = y
+ if (x > e) e = x
+ if (y > n) n = y
+ }
+ }
+ return Number.isFinite(w) ? [w, s, e, n] : null
+}
diff --git a/src/essence/Tools/AOI/aoiHelpers.ts b/src/essence/Tools/AOI/adapters/parse.ts
similarity index 58%
rename from src/essence/Tools/AOI/aoiHelpers.ts
rename to src/essence/Tools/AOI/adapters/parse.ts
index 1a82a873f..d77f489cd 100644
--- a/src/essence/Tools/AOI/aoiHelpers.ts
+++ b/src/essence/Tools/AOI/adapters/parse.ts
@@ -1,64 +1,5 @@
-/**
- * AOI plugin — pure helpers.
- *
- * No MMGIS imports, no DOM mutation, no network. Inputs in, values out.
- * The MMGIS wrapper (AOITool.js) calls these; the React component never does.
- */
-
import type { Feature, FeatureCollection, Geometry } from 'geojson'
import shp from 'shpjs'
-
-export type BoundaryKind = 'state' | 'county' | 'city'
-
-export interface AOISearchEntry {
- id: string
- label: string
- kind: BoundaryKind
- feature: Feature
-}
-
-export function buildSearchIndex(input: {
- states?: FeatureCollection | null
- counties?: FeatureCollection | null
- cities?: FeatureCollection | null
-}): AOISearchEntry[] {
- const out: AOISearchEntry[] = []
- const push = (kind: BoundaryKind, fc?: FeatureCollection | null) => {
- if (!fc || !Array.isArray(fc.features)) return
- fc.features.forEach((f, i) => {
- const props = (f.properties ?? {}) as Record
- const label =
- (props.name as string) ||
- (props.NAME as string) ||
- (props.title as string) ||
- `Unnamed ${kind} ${i + 1}`
- const id = (props.id as string) || `${kind}-${i}-${slug(label)}`
- out.push({ id, label, kind, feature: f })
- })
- }
- push('state', input.states)
- push('county', input.counties)
- push('city', input.cities)
- return out
-}
-
-export function searchIndex(
- query: string,
- entries: AOISearchEntry[],
- limit = 8
-): AOISearchEntry[] {
- const q = query.trim().toLowerCase()
- if (!q) return []
- const out: AOISearchEntry[] = []
- for (const e of entries) {
- if (e.label.toLowerCase().includes(q)) {
- out.push(e)
- if (out.length >= limit) break
- }
- }
- return out
-}
-
export interface ParseResult {
feature: Feature | null
error?: string
@@ -95,9 +36,7 @@ export function parseGeoJSONText(text: string): ParseResult {
export async function parseShapefileBuffer(buffer: ArrayBuffer): Promise {
try {
- const result = (await shp(buffer)) as
- | FeatureCollection
- | FeatureCollection[]
+ const result = (await shp(buffer)) as FeatureCollection | FeatureCollection[]
const fcs = Array.isArray(result) ? result : [result]
for (const fc of fcs) {
if (!fc || !Array.isArray(fc.features)) continue
@@ -197,62 +136,6 @@ function textOf(el: Element, tag: string): string | null {
return node?.textContent?.trim() || null
}
-export function featureCentroid(f: Feature): [number, number] | null {
- const g = f.geometry
- if (!g) return null
- if (g.type === 'Point') {
- const c = g.coordinates as [number, number]
- return [c[0], c[1]]
- }
- if (g.type === 'Polygon' || g.type === 'MultiPolygon') {
- const rings: number[][][] =
- g.type === 'Polygon'
- ? (g.coordinates as number[][][])
- : ((g.coordinates as number[][][][]).flat() as number[][][])
- let sx = 0
- let sy = 0
- let n = 0
- for (const ring of rings) {
- const last = ring.length - 1
- const stopAt =
- ring.length > 1 &&
- ring[0][0] === ring[last][0] &&
- ring[0][1] === ring[last][1]
- ? last
- : ring.length
- for (let i = 0; i < stopAt; i++) {
- sx += ring[i][0]
- sy += ring[i][1]
- n++
- }
- }
- return n > 0 ? [sx / n, sy / n] : null
- }
- return null
-}
-
-export function featureBounds(f: Feature): [number, number, number, number] | null {
- const g = f.geometry
- if (!g || (g.type !== 'Polygon' && g.type !== 'MultiPolygon')) return null
- const rings: number[][][] =
- g.type === 'Polygon'
- ? (g.coordinates as number[][][])
- : ((g.coordinates as number[][][][]).flat() as number[][][])
- let w = Infinity
- let s = Infinity
- let e = -Infinity
- let n = -Infinity
- for (const ring of rings) {
- for (const [x, y] of ring) {
- if (x < w) w = x
- if (y < s) s = y
- if (x > e) e = x
- if (y > n) n = y
- }
- }
- return Number.isFinite(w) ? [w, s, e, n] : null
-}
-
function isPolygonal(f: Feature | undefined | null): f is Feature {
return (
!!f &&
@@ -261,7 +144,3 @@ function isPolygonal(f: Feature | undefined | null): f is Feature {
(f.geometry.type === 'Polygon' || f.geometry.type === 'MultiPolygon')
)
}
-
-export function slug(s: string): string {
- return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
-}
diff --git a/src/essence/Tools/AOI/adapters/search.ts b/src/essence/Tools/AOI/adapters/search.ts
new file mode 100644
index 000000000..7517d80bc
--- /dev/null
+++ b/src/essence/Tools/AOI/adapters/search.ts
@@ -0,0 +1,56 @@
+import type { Feature, FeatureCollection } from 'geojson'
+import { slug } from './slug'
+
+export type BoundaryKind = 'state' | 'county' | 'city'
+
+/** A boundary entry in the search index — carries the source feature. */
+export interface AOISearchEntry {
+ id: string
+ label: string
+ kind: BoundaryKind
+ feature: Feature
+}
+
+/** Flatten state/county/city FeatureCollections into a searchable entry list. */
+export function buildSearchIndex(input: {
+ states?: FeatureCollection | null
+ counties?: FeatureCollection | null
+ cities?: FeatureCollection | null
+}): AOISearchEntry[] {
+ const out: AOISearchEntry[] = []
+ const push = (kind: BoundaryKind, fc?: FeatureCollection | null) => {
+ if (!fc || !Array.isArray(fc.features)) return
+ fc.features.forEach((f, i) => {
+ const props = (f.properties ?? {}) as Record
+ const label =
+ (props.name as string) ||
+ (props.NAME as string) ||
+ (props.title as string) ||
+ `Unnamed ${kind} ${i + 1}`
+ const id = (props.id as string) || `${kind}-${i}-${slug(label)}`
+ out.push({ id, label, kind, feature: f })
+ })
+ }
+ push('state', input.states)
+ push('county', input.counties)
+ push('city', input.cities)
+ return out
+}
+
+/** Case-insensitive substring match over entry labels, capped at `limit`. */
+export function searchIndex(
+ query: string,
+ entries: AOISearchEntry[],
+ limit = 8,
+): AOISearchEntry[] {
+ const q = query.trim().toLowerCase()
+ if (!q) return []
+ const out: AOISearchEntry[] = []
+ for (const e of entries) {
+ if (e.label.toLowerCase().includes(q)) {
+ out.push(e)
+ if (out.length >= limit) break
+ }
+ }
+ return out
+}
diff --git a/src/essence/Tools/AOI/adapters/slug.ts b/src/essence/Tools/AOI/adapters/slug.ts
new file mode 100644
index 000000000..d68045c65
--- /dev/null
+++ b/src/essence/Tools/AOI/adapters/slug.ts
@@ -0,0 +1,6 @@
+export function slug(s: string): string {
+ return s
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '')
+}
diff --git a/src/essence/Tools/AOI/adapters/useMMGISEvent.ts b/src/essence/Tools/AOI/adapters/useMMGISEvent.ts
new file mode 100644
index 000000000..d1e95f032
--- /dev/null
+++ b/src/essence/Tools/AOI/adapters/useMMGISEvent.ts
@@ -0,0 +1,12 @@
+import { useEffect } from 'react'
+import { mmgisOn } from '../../_shared/adapters/mmgisAPI'
+
+export const useMMGISEvent = (
+ eventName: string,
+ handler: (payload?: unknown) => void,
+): void => {
+ useEffect(() => {
+ const cleanup = mmgisOn(eventName, handler)
+ return cleanup
+ }, [eventName, handler])
+}
diff --git a/src/essence/Tools/AOI/adapters/useMMGISToolVars.ts b/src/essence/Tools/AOI/adapters/useMMGISToolVars.ts
new file mode 100644
index 000000000..9b6d8fdaf
--- /dev/null
+++ b/src/essence/Tools/AOI/adapters/useMMGISToolVars.ts
@@ -0,0 +1,24 @@
+import { useEffect, useState } from 'react'
+import { mmgisRequest } from '../../_shared/adapters/mmgisAPI'
+
+export const useMMGISToolVars = <
+ T extends Record = Record,
+>(
+ toolName: string,
+): T => {
+ const [vars, setVars] = useState({} as T)
+ useEffect(() => {
+ let cancelled = false
+ mmgisRequest('tool:getVars', toolName)
+ .then((result) => {
+ if (!cancelled && result) setVars(result)
+ })
+ .catch((err) => {
+ if (!cancelled) console.warn(`[useMMGISToolVars] '${toolName}' vars unavailable:`, err)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [toolName])
+ return vars
+}
diff --git a/src/essence/Tools/AOI/aoiBoundaryLoader.ts b/src/essence/Tools/AOI/aoiBoundaryLoader.ts
index cb19b37df..0ab425652 100644
--- a/src/essence/Tools/AOI/aoiBoundaryLoader.ts
+++ b/src/essence/Tools/AOI/aoiBoundaryLoader.ts
@@ -9,7 +9,7 @@
*/
import type { Feature, FeatureCollection } from 'geojson'
-import { slug } from './aoiHelpers'
+import { slug } from './adapters/slug'
declare const require: {
context: (
diff --git a/src/essence/Tools/AOI/config.json b/src/essence/Tools/AOI/config.json
index 1d47dabfc..9d66648e4 100644
--- a/src/essence/Tools/AOI/config.json
+++ b/src/essence/Tools/AOI/config.json
@@ -18,6 +18,10 @@
"paths": {
"AOITool": "essence/Tools/AOI/AOITool"
},
+ "metadata": {
+ "icon": "selection-drag",
+ "modernLayoutSupport": true
+ },
"config": {
"rows": [
{
diff --git a/src/essence/Tools/AOI/lib/geo/AOIPanel/AOIPanel.tsx b/src/essence/Tools/AOI/lib/geo/AOIPanel/AOIPanel.tsx
new file mode 100644
index 000000000..e58402f4a
--- /dev/null
+++ b/src/essence/Tools/AOI/lib/geo/AOIPanel/AOIPanel.tsx
@@ -0,0 +1,158 @@
+import React from 'react'
+import { Button } from '@trussworks/react-uswds'
+import type {
+ AOIMode,
+ AOIShape,
+ AOISearchResult,
+ UploadStatus,
+ AnalysisStatus,
+} from '../../types'
+import { SearchPanel } from '../SearchPanel/SearchPanel'
+import { InspectPanel } from '../InspectPanel/InspectPanel'
+import { DrawPanel } from '../DrawPanel/DrawPanel'
+import { UploadPanel } from '../UploadPanel/UploadPanel'
+import { AnalyzingPanel } from '../AnalyzingPanel/AnalyzingPanel'
+
+export interface AOIPanelProps {
+ 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
+ drawShapes?: AOIShape[]
+ 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
+ analysisError?: string | null
+ onDismissAnalysisError?: () => void
+
+ 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 AOIPanel(props: AOIPanelProps) {
+ const isAnalyzing = props.analysisStatus === 'running'
+
+ return (
+
+
+
+
+ Analyze areas
+
+
+
+
+ {props.analysisError ? (
+
+
+
{props.analysisError}
+ {props.onDismissAnalysisError && (
+
+ )}
+
+ ) : null}
+
+ {isAnalyzing ? (
+
+ ) : (
+ <>
+
+
+
+ {props.mode === 'search' && (
+
+ )}
+ {props.mode === 'inspect' && }
+ {props.mode === 'draw' && (
+
+ )}
+ {props.mode === 'upload' && (
+
+ )}
+
+ >
+ )}
+
+ )
+}
diff --git a/src/essence/Tools/AOI/AOITooltip.tsx b/src/essence/Tools/AOI/lib/geo/AOITooltip/AOITooltip.tsx
similarity index 72%
rename from src/essence/Tools/AOI/AOITooltip.tsx
rename to src/essence/Tools/AOI/lib/geo/AOITooltip/AOITooltip.tsx
index 5c2fa8150..f3a8bef70 100644
--- a/src/essence/Tools/AOI/AOITooltip.tsx
+++ b/src/essence/Tools/AOI/lib/geo/AOITooltip/AOITooltip.tsx
@@ -1,5 +1,4 @@
import React from 'react'
-import './AOIComponent/AOIComponent.scss'
export interface AOITooltipProps {
label: string
@@ -12,16 +11,16 @@ export interface AOITooltipProps {
export function AOITooltip(props: AOITooltipProps) {
return (
-
{props.label}
-
+
{props.label}
+
)
}
-
-export default AOITooltip
diff --git a/src/essence/Tools/AOI/lib/geo/AnalyzingPanel/AnalyzingPanel.tsx b/src/essence/Tools/AOI/lib/geo/AnalyzingPanel/AnalyzingPanel.tsx
new file mode 100644
index 000000000..c3bba883a
--- /dev/null
+++ b/src/essence/Tools/AOI/lib/geo/AnalyzingPanel/AnalyzingPanel.tsx
@@ -0,0 +1,29 @@
+import React from 'react'
+
+export type AnalyzingPanelProps = {
+ label: string
+ done: number
+ total: number
+}
+
+export function AnalyzingPanel({ label, done, total }: AnalyzingPanelProps) {
+ const percent = total > 0 ? Math.round((done / total) * 100) : 0
+ return (
+
+
+
Analyzing
+
{label}
+
{percent}%
+
+
Looking for insights
+
+ )
+}
diff --git a/src/essence/Tools/AOI/lib/geo/DrawPanel/DrawPanel.tsx b/src/essence/Tools/AOI/lib/geo/DrawPanel/DrawPanel.tsx
new file mode 100644
index 000000000..a938c5358
--- /dev/null
+++ b/src/essence/Tools/AOI/lib/geo/DrawPanel/DrawPanel.tsx
@@ -0,0 +1,110 @@
+import React from 'react'
+import { Button } from '@trussworks/react-uswds'
+import type { AOIShape } from '../../types'
+
+export type DrawPanelProps = {
+ shape: AOIShape | null
+ shapes?: AOIShape[]
+ disabled?: boolean
+ isDrawing: boolean
+ verticesCount: number
+ onShapeChange: (shape: AOIShape) => void
+ onConfirm: () => void
+ onCancel: () => void
+}
+
+const ALL_SHAPES: Array<{ id: AOIShape; label: string; icon: string }> = [
+ { id: 'point', label: 'Point', icon: 'map-marker-outline' },
+ { id: 'linestring', label: 'Line', icon: 'vector-polyline' },
+ { id: 'polygon', label: 'Polygon', icon: 'vector-polygon' },
+ { id: 'rectangle', label: 'Rectangle', icon: 'square-outline' },
+ { id: 'circle', label: 'Circle', icon: 'circle-outline' },
+]
+
+const DEFAULT_ALLOWED_SHAPES: AOIShape[] = ['polygon', 'rectangle', 'circle']
+
+const MIN_VERTICES_BY_SHAPE: Record
= {
+ point: 1,
+ linestring: 2,
+ polygon: 3,
+ rectangle: 2,
+ circle: 2,
+}
+
+const HINT_BY_SHAPE: Record string> = {
+ point: () => 'Click on the map to place the point.',
+ linestring: (count) =>
+ `Click to add vertices. ${count} placed (need 2+). Double-click or Enter to finish.`,
+ polygon: (count, min) =>
+ `Click on the map to add vertices. ${count} placed (need ${min}+).`,
+ rectangle: () => 'Click two corners to define the rectangle.',
+ circle: () => 'Click the centre, then click the edge to define the circle.',
+}
+
+export function DrawPanel(props: DrawPanelProps) {
+ if (props.isDrawing && props.shape) {
+ return
+ }
+ return
+}
+
+function ShapePicker({ shape, shapes, disabled, onShapeChange }: DrawPanelProps) {
+ const allow = new Set(shapes ?? DEFAULT_ALLOWED_SHAPES)
+ const list = ALL_SHAPES.filter((s) => allow.has(s.id))
+ return (
+
+
Choose a shape
+
+ {list.map((s) => (
+
+ ))}
+
+
+ Click anywhere on the map to start drawing a shape
+
+
+ )
+}
+
+function DrawInProgress(props: DrawPanelProps & { shape: AOIShape }) {
+ const minVertices = MIN_VERTICES_BY_SHAPE[props.shape]
+ const valid = props.verticesCount >= minVertices
+ const hint = HINT_BY_SHAPE[props.shape](props.verticesCount, minVertices)
+
+ return (
+
+
{hint}
+
+
+
+
+
+ )
+}
diff --git a/src/essence/Tools/AOI/lib/geo/InspectPanel/InspectPanel.tsx b/src/essence/Tools/AOI/lib/geo/InspectPanel/InspectPanel.tsx
new file mode 100644
index 000000000..aff80cf87
--- /dev/null
+++ b/src/essence/Tools/AOI/lib/geo/InspectPanel/InspectPanel.tsx
@@ -0,0 +1,11 @@
+import React from 'react'
+
+export function InspectPanel() {
+ return (
+
+
+ Click on a boundary on the map to select it for analysis.
+
+
+ )
+}
diff --git a/src/essence/Tools/AOI/lib/geo/SearchPanel/SearchPanel.tsx b/src/essence/Tools/AOI/lib/geo/SearchPanel/SearchPanel.tsx
new file mode 100644
index 000000000..273b1af24
--- /dev/null
+++ b/src/essence/Tools/AOI/lib/geo/SearchPanel/SearchPanel.tsx
@@ -0,0 +1,73 @@
+import React, { useEffect, useRef } from 'react'
+import { TextInput } from '@trussworks/react-uswds'
+import type { AOISearchResult } from '../../types'
+
+export type SearchPanelProps = {
+ query: string
+ results: AOISearchResult[]
+ disabled?: boolean
+ loading?: boolean
+ onQueryChange: (q: string) => void
+ onSelect: (id: string) => void
+}
+
+export function SearchPanel({
+ query,
+ results,
+ disabled,
+ loading,
+ onQueryChange,
+ onSelect,
+}: SearchPanelProps) {
+ const inputRef = useRef(null)
+ useEffect(() => {
+ inputRef.current?.focus()
+ }, [])
+
+ const inputDisabled = disabled || loading
+
+ return (
+
+
Search for a country or a region
+
+
+ {loading &&
Loading boundary data…
}
+
+ {!loading && disabled && (
+
+ No boundary data is available for this mission.
+
+ )}
+
+ {!inputDisabled && results.length > 0 && (
+
+ {results.map((r) => (
+ -
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/essence/Tools/AOI/lib/geo/UploadPanel/UploadPanel.tsx b/src/essence/Tools/AOI/lib/geo/UploadPanel/UploadPanel.tsx
new file mode 100644
index 000000000..b0239b7e6
--- /dev/null
+++ b/src/essence/Tools/AOI/lib/geo/UploadPanel/UploadPanel.tsx
@@ -0,0 +1,56 @@
+import React, { useRef } from 'react'
+import { Alert, Button } from '@trussworks/react-uswds'
+import type { UploadStatus } from '../../types'
+
+export type UploadPanelProps = {
+ status: UploadStatus
+ error?: string
+ onUploadFile: (file: File) => void
+}
+
+export function UploadPanel({ status, error, onUploadFile }: UploadPanelProps) {
+ const inputRef = useRef(null)
+ return (
+
+
+ Upload a GeoJSON, KML, or zipped Shapefile to define your analysis area.
+
+
+
+
{
+ const f = e.target.files?.[0]
+ if (f) onUploadFile(f)
+ e.target.value = ''
+ }}
+ />
+
+ {status === 'error' && (
+
+ {error || 'Could not parse that file.'}
+
+ )}
+
+
+ Supported formats:
+
+
+ - GeoJSON (.geojson, .json)
+ - KML (.kml)
+ - Shapefile (.zip — must include .shp, .dbf, and .prj)
+
+
+ )
+}
diff --git a/src/essence/Tools/AOI/lib/index.ts b/src/essence/Tools/AOI/lib/index.ts
new file mode 100644
index 000000000..ccbce6928
--- /dev/null
+++ b/src/essence/Tools/AOI/lib/index.ts
@@ -0,0 +1,20 @@
+// Components
+export { AOIPanel, type AOIPanelProps } from './geo/AOIPanel/AOIPanel'
+export { SearchPanel, type SearchPanelProps } from './geo/SearchPanel/SearchPanel'
+export { InspectPanel } from './geo/InspectPanel/InspectPanel'
+export { DrawPanel, type DrawPanelProps } from './geo/DrawPanel/DrawPanel'
+export { UploadPanel, type UploadPanelProps } from './geo/UploadPanel/UploadPanel'
+export { AnalyzingPanel, type AnalyzingPanelProps } from './geo/AnalyzingPanel/AnalyzingPanel'
+export { AOITooltip, type AOITooltipProps } from './geo/AOITooltip/AOITooltip'
+
+// Shared domain types (component-facing only)
+export type {
+ AOIMode,
+ AOIShape,
+ UploadStatus,
+ AnalysisStatus,
+ AOISearchResult,
+} from './types'
+
+// Side-effect import of compiled styles
+import './styles/index.scss'
diff --git a/src/essence/Tools/AOI/lib/styles/components-geo/aoi-analyzing.scss b/src/essence/Tools/AOI/lib/styles/components-geo/aoi-analyzing.scss
new file mode 100644
index 000000000..e38f27373
--- /dev/null
+++ b/src/essence/Tools/AOI/lib/styles/components-geo/aoi-analyzing.scss
@@ -0,0 +1,67 @@
+.blocks-aoi-analyzing {
+ &__spinner {
+ width: 40px;
+ height: 40px;
+ margin-bottom: var(--theme-spacing-105, 12px);
+ border: 3px solid var(--theme-color-base-lightest, #f6f7f8);
+ border-top-color: var(--theme-color-primary, #137480);
+ border-radius: 50%;
+ animation: blocks-aoi-spin 800ms linear infinite;
+ }
+
+ &__caption {
+ margin: 0;
+ color: var(--theme-color-base-dark, #565c65);
+ font-size: var(--theme-font-size-2xs, 14px);
+ }
+
+ &__label {
+ margin: 0;
+ color: var(--theme-color-ink, #1b1b1b);
+ font-size: var(--theme-font-size-sm, 16px);
+ font-weight: 700;
+ line-height: 1.2;
+ }
+
+ &__percent {
+ margin: var(--theme-spacing-105, 12px) 0 0;
+ color: var(--theme-color-primary, #137480);
+ font-size: 28px;
+ font-weight: 700;
+ line-height: 1;
+ }
+
+ &__bar {
+ width: 80%;
+ height: 6px;
+ margin-top: var(--theme-spacing-1, 8px);
+ background: var(--theme-color-base-lightest, #f6f7f8);
+ border-radius: 999px;
+ overflow: hidden;
+ }
+
+ &__bar-fill {
+ height: 100%;
+ background: var(--theme-color-primary, #137480);
+ border-radius: 999px;
+ transition: width 200ms ease-out;
+ }
+
+ &__status {
+ margin: var(--theme-spacing-105, 12px) 0 0;
+ color: var(--theme-color-base-dark, #565c65);
+ font-size: var(--theme-font-size-2xs, 13px);
+ }
+}
+
+@keyframes blocks-aoi-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .blocks-aoi-analyzing__spinner {
+ animation-duration: 2400ms;
+ }
+}
diff --git a/src/essence/Tools/AOI/lib/styles/components-geo/aoi-draw.scss b/src/essence/Tools/AOI/lib/styles/components-geo/aoi-draw.scss
new file mode 100644
index 000000000..d5d22a889
--- /dev/null
+++ b/src/essence/Tools/AOI/lib/styles/components-geo/aoi-draw.scss
@@ -0,0 +1,72 @@
+.blocks-aoi-draw {
+ &__shapes {
+ display: flex;
+ align-items: center;
+ gap: var(--theme-spacing-1, 8px);
+ flex-wrap: wrap;
+ }
+
+ &__shape {
+ width: 36px;
+ height: 36px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--theme-color-base-lighter, #dfe1e2);
+ border-radius: var(--theme-radius-md, 4px);
+ background: var(--theme-color-white, #ffffff);
+ color: var(--theme-color-ink, #1b1b1b);
+ cursor: pointer;
+
+ &:hover:not(:disabled) {
+ border-color: var(--theme-color-base-light, #a9aeb1);
+ }
+
+ &--active {
+ background: var(--theme-color-base-lightest, #f6f7f8);
+ border-color: var(--theme-color-ink, #1b1b1b);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+
+ &__shape-icon {
+ font-size: 16px;
+ line-height: 1;
+ }
+
+ &__actions {
+ display: flex;
+ gap: var(--theme-spacing-1, 8px);
+ }
+
+ &__confirm,
+ &__cancel {
+ flex: 1 1 auto;
+ height: 36px;
+ border-radius: var(--theme-radius-md, 4px);
+ cursor: pointer;
+ font: inherit;
+ font-weight: 600;
+ }
+
+ &__confirm {
+ border: 1px solid var(--theme-color-primary, #137480);
+ background: var(--theme-color-primary, #137480);
+ color: var(--theme-color-white, #ffffff);
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+
+ &__cancel {
+ border: 1px solid var(--theme-color-base-lighter, #dfe1e2);
+ background: var(--theme-color-white, #ffffff);
+ color: var(--theme-color-ink, #1b1b1b);
+ }
+}
diff --git a/src/essence/Tools/AOI/lib/styles/components-geo/aoi-panel.scss b/src/essence/Tools/AOI/lib/styles/components-geo/aoi-panel.scss
new file mode 100644
index 000000000..f7fbe4034
--- /dev/null
+++ b/src/essence/Tools/AOI/lib/styles/components-geo/aoi-panel.scss
@@ -0,0 +1,38 @@
+// Shared panel base used by every mode panel (search/inspect/draw/upload/analyzing).
+
+.blocks-aoi-panel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--theme-spacing-105, 12px);
+
+ &__hint {
+ margin: 0;
+ color: var(--theme-color-ink, #1b1b1b);
+ font-size: var(--theme-font-size-2xs, 13px);
+
+ &--secondary {
+ color: var(--theme-color-base-dark, #565c65);
+ }
+ }
+
+ &__empty {
+ margin: 0;
+ color: var(--theme-color-base-dark, #565c65);
+ font-size: var(--theme-font-size-2xs, 13px);
+ }
+
+ &__error {
+ margin: 0;
+ color: var(--theme-color-secondary, #b50909);
+ font-size: var(--theme-font-size-2xs, 13px);
+ }
+
+ &--analyzing {
+ flex: 1 1 auto;
+ align-items: center;
+ justify-content: center;
+ padding: var(--theme-spacing-3, 24px) var(--theme-spacing-2, 16px);
+ text-align: center;
+ gap: var(--theme-spacing-1, 8px);
+ }
+}
diff --git a/src/essence/Tools/AOI/lib/styles/components-geo/aoi-search.scss b/src/essence/Tools/AOI/lib/styles/components-geo/aoi-search.scss
new file mode 100644
index 000000000..2470a9417
--- /dev/null
+++ b/src/essence/Tools/AOI/lib/styles/components-geo/aoi-search.scss
@@ -0,0 +1,76 @@
+.blocks-aoi-search {
+ position: relative;
+ display: block;
+
+ &__input {
+ width: 100%;
+ height: 36px;
+ padding: 0 var(--theme-spacing-3, 24px) 0 var(--theme-spacing-105, 12px);
+ border: 1px solid var(--theme-color-base-lighter, #dfe1e2);
+ border-radius: var(--theme-radius-md, 4px);
+ background: var(--theme-color-white, #ffffff);
+ color: var(--theme-color-ink, #1b1b1b);
+ font: inherit;
+
+ &:focus {
+ outline: 2px solid var(--theme-color-primary, #137480);
+ outline-offset: -1px;
+ }
+
+ &:disabled {
+ background: var(--theme-color-base-lightest, #f6f7f8);
+ color: var(--theme-color-base-dark, #565c65);
+ cursor: not-allowed;
+ }
+ }
+
+ &__icon {
+ position: absolute;
+ right: var(--theme-spacing-105, 12px);
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 16px;
+ line-height: 1;
+ color: var(--theme-color-base-dark, #565c65);
+ pointer-events: none;
+ }
+
+ &__results {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ max-height: 220px;
+ overflow-y: auto;
+ border: 1px solid var(--theme-color-base-lighter, #dfe1e2);
+ border-radius: var(--theme-radius-md, 4px);
+ }
+
+ &__result {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--theme-spacing-1, 8px) var(--theme-spacing-105, 12px);
+ background: transparent;
+ border: 0;
+ border-bottom: 1px solid var(--theme-color-base-lighter, #dfe1e2);
+ color: var(--theme-color-ink, #1b1b1b);
+ cursor: pointer;
+ text-align: left;
+ font: inherit;
+
+ &:last-child {
+ border-bottom: 0;
+ }
+
+ &:hover {
+ background: var(--theme-color-base-lightest, #f6f7f8);
+ }
+ }
+
+ &__result-kind {
+ color: var(--theme-color-base-dark, #565c65);
+ font-size: var(--theme-font-size-2xs, 13px);
+ text-transform: capitalize;
+ }
+}
diff --git a/src/essence/Tools/AOI/lib/styles/components-geo/aoi-tool.scss b/src/essence/Tools/AOI/lib/styles/components-geo/aoi-tool.scss
new file mode 100644
index 000000000..cfdba9669
--- /dev/null
+++ b/src/essence/Tools/AOI/lib/styles/components-geo/aoi-tool.scss
@@ -0,0 +1,173 @@
+// Host container the tool mounts into (document.body), + the AOIPanel shell.
+
+.aoi-tool-host {
+ position: fixed;
+ top: 70px;
+ right: 16px;
+ width: 372px;
+ max-height: calc(100vh - 90px);
+ overflow: hidden;
+ z-index: 1003;
+ border-radius: var(--theme-radius-lg, 6px);
+ box-shadow: 0 2px 6px var(--theme-color-shadow, rgba(0, 0, 0, 0.18));
+ pointer-events: auto;
+}
+
+.blocks-aoi-tool {
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 100%;
+ padding: var(--theme-spacing-2, 16px);
+ gap: var(--theme-spacing-105, 12px);
+ background: var(--theme-color-white, #ffffff);
+ color: var(--theme-color-ink, #1b1b1b);
+ font-family: var(--theme-font-ui);
+ font-size: var(--theme-font-size-2xs, 14px);
+
+ * {
+ box-sizing: border-box;
+ }
+
+ &__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ &__title {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--theme-spacing-1, 8px);
+ font-size: var(--theme-font-size-sm, 16px);
+ font-weight: 600;
+ }
+
+ &__title-icon {
+ font-size: 16px;
+ line-height: 1;
+ color: var(--theme-color-primary, #137480);
+ }
+
+ &__close {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--theme-spacing-05, 4px);
+ background: none;
+ border: 0;
+ color: var(--theme-color-base-dark, #565c65);
+ font-size: 18px;
+ line-height: 1;
+ cursor: pointer;
+
+ &:hover {
+ color: var(--theme-color-ink, #1b1b1b);
+ }
+ }
+
+ &__alert {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--theme-spacing-1, 8px);
+ margin: var(--theme-spacing-105, 12px) var(--theme-spacing-2, 16px) 0;
+ padding: var(--theme-spacing-105, 12px);
+ border-radius: var(--theme-radius-md, 4px);
+ border-left: 4px solid var(--theme-color-secondary, #b50909);
+ background: rgba(181, 9, 9, 0.08);
+ color: var(--theme-color-ink, #1b1b1b);
+ font-size: var(--theme-font-size-2xs, 13px);
+ line-height: 1.35;
+
+ &--error {
+ border-left-color: var(--theme-color-secondary, #b50909);
+ background: rgba(181, 9, 9, 0.08);
+ }
+ }
+
+ &__alert-icon {
+ flex: 0 0 auto;
+ margin-top: 1px;
+ color: var(--theme-color-secondary, #b50909);
+ font-size: 18px;
+ line-height: 1;
+ }
+
+ &__alert-text {
+ flex: 1 1 auto;
+ margin: 0;
+ }
+
+ &__alert-dismiss {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ border: 0;
+ border-radius: var(--theme-radius-md, 4px);
+ background: transparent;
+ color: var(--theme-color-base-dark, #565c65);
+ cursor: pointer;
+ line-height: 1;
+
+ &:hover {
+ color: var(--theme-color-ink, #1b1b1b);
+ background: var(--theme-color-base-lightest, #f6f7f8);
+ }
+
+ .mdi {
+ font-size: 16px;
+ }
+ }
+
+ &__tabs {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: var(--theme-spacing-05, 4px);
+ border: 1px solid var(--theme-color-base-lighter, #dfe1e2);
+ border-radius: var(--theme-radius-md, 4px);
+ padding: var(--theme-spacing-05, 4px);
+ }
+
+ &__tab {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--theme-spacing-05, 4px);
+ padding: var(--theme-spacing-1, 8px) var(--theme-spacing-05, 4px);
+ border: 0;
+ background: transparent;
+ color: var(--theme-color-base-dark, #565c65);
+ border-radius: var(--theme-radius-md, 4px);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: var(--theme-font-size-2xs, 13px);
+
+ &:hover {
+ color: var(--theme-color-ink, #1b1b1b);
+ }
+
+ &--active {
+ background: var(--theme-color-base-lightest, #f6f7f8);
+ color: var(--theme-color-ink, #1b1b1b);
+ }
+ }
+
+ &__tab-icon {
+ font-size: 18px;
+ line-height: 1;
+ }
+
+ &__tab-label {
+ font-size: var(--theme-font-size-2xs, 13px);
+ }
+
+ &__body {
+ flex: 1 1 auto;
+ min-height: 0;
+ }
+}
diff --git a/src/essence/Tools/AOI/lib/styles/components-geo/aoi-tooltip.scss b/src/essence/Tools/AOI/lib/styles/components-geo/aoi-tooltip.scss
new file mode 100644
index 000000000..dd29df79f
--- /dev/null
+++ b/src/essence/Tools/AOI/lib/styles/components-geo/aoi-tooltip.scss
@@ -0,0 +1,56 @@
+.blocks-aoi-tooltip {
+ position: absolute;
+ transform: translate(-50%, calc(-100% - var(--theme-spacing-105, 12px)));
+ min-width: 220px;
+ padding: var(--theme-spacing-105, 12px);
+ border: 1px solid var(--theme-color-base-lighter, #dfe1e2);
+ border-radius: var(--theme-radius-lg, 6px);
+ background: var(--theme-color-white, #ffffff);
+ color: var(--theme-color-ink, #1b1b1b);
+ box-shadow: 0 2px 6px var(--theme-color-shadow, rgba(0, 0, 0, 0.18));
+ font-family: var(--theme-font-ui);
+ pointer-events: auto;
+ z-index: 1000;
+
+ * {
+ box-sizing: border-box;
+ }
+
+ &__label {
+ margin: 0 0 var(--theme-spacing-1, 8px);
+ font-size: var(--theme-font-size-2xs, 14px);
+ font-weight: 600;
+ }
+
+ &__actions {
+ display: flex;
+ gap: var(--theme-spacing-1, 8px);
+ }
+
+ &__primary,
+ &__secondary {
+ flex: 1 1 auto;
+ height: 32px;
+ border-radius: var(--theme-radius-md, 4px);
+ cursor: pointer;
+ font: inherit;
+ font-weight: 600;
+ }
+
+ &__primary {
+ border: 1px solid var(--theme-color-primary, #137480);
+ background: var(--theme-color-primary, #137480);
+ color: var(--theme-color-white, #ffffff);
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+
+ &__secondary {
+ border: 1px solid var(--theme-color-base-lighter, #dfe1e2);
+ background: var(--theme-color-white, #ffffff);
+ color: var(--theme-color-ink, #1b1b1b);
+ }
+}
diff --git a/src/essence/Tools/AOI/lib/styles/components-geo/aoi-upload.scss b/src/essence/Tools/AOI/lib/styles/components-geo/aoi-upload.scss
new file mode 100644
index 000000000..f6b4b5c30
--- /dev/null
+++ b/src/essence/Tools/AOI/lib/styles/components-geo/aoi-upload.scss
@@ -0,0 +1,34 @@
+.blocks-aoi-upload {
+ &__button {
+ width: 100%;
+ height: 40px;
+ border: 1px dashed var(--theme-color-primary, #137480);
+ border-radius: var(--theme-radius-md, 4px);
+ background: transparent;
+ color: var(--theme-color-primary, #137480);
+ cursor: pointer;
+ font: inherit;
+ font-weight: 600;
+ text-decoration: underline;
+
+ &:hover:not(:disabled) {
+ background: var(--theme-color-base-lightest, #f6f7f8);
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+ }
+
+ &__input {
+ display: none;
+ }
+
+ &__formats {
+ margin: 0;
+ padding-left: var(--theme-spacing-2, 16px);
+ color: var(--theme-color-ink, #1b1b1b);
+ font-size: var(--theme-font-size-2xs, 13px);
+ }
+}
diff --git a/src/essence/Tools/AOI/lib/styles/components-geo/index.scss b/src/essence/Tools/AOI/lib/styles/components-geo/index.scss
new file mode 100644
index 000000000..e00c3dce0
--- /dev/null
+++ b/src/essence/Tools/AOI/lib/styles/components-geo/index.scss
@@ -0,0 +1,8 @@
+// Aggregator — one @use per component partial in this directory.
+@use 'aoi-tool';
+@use 'aoi-panel';
+@use 'aoi-analyzing';
+@use 'aoi-search';
+@use 'aoi-draw';
+@use 'aoi-upload';
+@use 'aoi-tooltip';
diff --git a/src/essence/Tools/AOI/lib/styles/index.scss b/src/essence/Tools/AOI/lib/styles/index.scss
new file mode 100644
index 000000000..998b166ff
--- /dev/null
+++ b/src/essence/Tools/AOI/lib/styles/index.scss
@@ -0,0 +1,4 @@
+// Theming (USWDS framework + theme tokens + :root --theme-* custom properties)
+// is provided by MMGIS's per-theme bundles at dist/.css, loaded at
+// runtime. Component partials reference --theme-* directly (Path A).
+@forward 'components-geo';
diff --git a/src/essence/Tools/AOI/lib/styles/scss-imports.d.ts b/src/essence/Tools/AOI/lib/styles/scss-imports.d.ts
new file mode 100644
index 000000000..99508d563
--- /dev/null
+++ b/src/essence/Tools/AOI/lib/styles/scss-imports.d.ts
@@ -0,0 +1 @@
+declare module '*.scss'
diff --git a/src/essence/Tools/AOI/lib/types.ts b/src/essence/Tools/AOI/lib/types.ts
new file mode 100644
index 000000000..c00d386e8
--- /dev/null
+++ b/src/essence/Tools/AOI/lib/types.ts
@@ -0,0 +1,15 @@
+// Shared domain types for the AOI library. Framework-agnostic — no MMGIS.
+// Component-facing types only; adapter-side types (search entries, parse
+// results) live next to their helpers in adapters/.
+
+export type AOIMode = 'search' | 'inspect' | 'draw' | 'upload'
+export type AOIShape = 'point' | 'linestring' | 'polygon' | 'rectangle' | 'circle'
+export type UploadStatus = 'idle' | 'parsing' | 'error'
+export type AnalysisStatus = 'idle' | 'running'
+
+/** A search-result row shown in the UI (no source geometry). */
+export interface AOISearchResult {
+ id: string
+ label: string
+ kind: 'city' | 'county' | 'state'
+}