From 9ca74b29e2cef19e2e921ea0c05d26a2b6dfe7f0 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Mon, 4 May 2026 14:58:52 -0500 Subject: [PATCH 01/19] aoi plugin:wip --- PLUGIN-DEVELOPMENT-GUIDE.md | 524 ++++++++++++++++++ src/essence/Tools/AOI/AOIComponent.css | 394 +++++++++++++ src/essence/Tools/AOI/AOIComponent.tsx | 271 +++++++++ src/essence/Tools/AOI/AOITool.js | 438 +++++++++++++++ src/essence/Tools/AOI/AOITooltip.tsx | 42 ++ src/essence/Tools/AOI/BLOCKERS.md | 41 ++ src/essence/Tools/AOI/aoiBoundaryLoader.ts | 161 ++++++ src/essence/Tools/AOI/aoiHelpers.ts | 235 ++++++++ .../Tools/AOI/assets/geo-data/sample.geojson | 38 ++ .../assets/geo-data/states/Alabama.geojson | 1 + .../AOI/assets/geo-data/states/Alaska.geojson | 1 + .../assets/geo-data/states/Arizona.geojson | 1 + .../assets/geo-data/states/Arkansas.geojson | 1 + .../assets/geo-data/states/California.geojson | 1 + .../assets/geo-data/states/Colorado.geojson | 1 + .../geo-data/states/Connecticut.geojson | 1 + .../assets/geo-data/states/Delaware.geojson | 1 + .../states/District of Columbia.geojson | 1 + .../assets/geo-data/states/Florida.geojson | 1 + .../assets/geo-data/states/Georgia.geojson | 1 + .../AOI/assets/geo-data/states/Hawaii.geojson | 1 + .../AOI/assets/geo-data/states/Idaho.geojson | 1 + .../assets/geo-data/states/Illinois.geojson | 1 + .../assets/geo-data/states/Indiana.geojson | 1 + .../AOI/assets/geo-data/states/Iowa.geojson | 1 + .../AOI/assets/geo-data/states/Kansas.geojson | 1 + .../assets/geo-data/states/Kentucky.geojson | 1 + .../assets/geo-data/states/Louisiana.geojson | 1 + .../AOI/assets/geo-data/states/Maine.geojson | 1 + .../assets/geo-data/states/Maryland.geojson | 1 + .../geo-data/states/Massachusetts.geojson | 1 + .../assets/geo-data/states/Michigan.geojson | 1 + .../assets/geo-data/states/Minnesota.geojson | 1 + .../geo-data/states/Mississippi.geojson | 1 + .../assets/geo-data/states/Missouri.geojson | 1 + .../assets/geo-data/states/Montana.geojson | 1 + .../assets/geo-data/states/Nebraska.geojson | 1 + .../AOI/assets/geo-data/states/Nevada.geojson | 1 + .../geo-data/states/New Hampshire.geojson | 1 + .../assets/geo-data/states/New Jersey.geojson | 1 + .../assets/geo-data/states/New Mexico.geojson | 1 + .../assets/geo-data/states/New York.geojson | 1 + .../geo-data/states/North Carolina.geojson | 1 + .../geo-data/states/North Dakota.geojson | 1 + .../AOI/assets/geo-data/states/Ohio.geojson | 1 + .../assets/geo-data/states/Oklahoma.geojson | 1 + .../AOI/assets/geo-data/states/Oregon.geojson | 1 + .../geo-data/states/Pennsylvania.geojson | 1 + .../geo-data/states/Puerto Rico.geojson | 1 + .../geo-data/states/Rhode Island.geojson | 1 + .../geo-data/states/South Carolina.geojson | 1 + .../geo-data/states/South Dakota.geojson | 1 + .../assets/geo-data/states/Tennessee.geojson | 1 + .../AOI/assets/geo-data/states/Texas.geojson | 1 + .../states/United States (Contiguous).geojson | 1 + .../geo-data/states/United States.geojson | 1 + .../AOI/assets/geo-data/states/Utah.geojson | 1 + .../assets/geo-data/states/Vermont.geojson | 1 + .../assets/geo-data/states/Virginia.geojson | 1 + .../assets/geo-data/states/Washington.geojson | 1 + .../geo-data/states/West Virginia.geojson | 1 + .../assets/geo-data/states/Wisconsin.geojson | 1 + .../assets/geo-data/states/Wyoming.geojson | 1 + src/essence/Tools/AOI/config.json | 70 +++ 64 files changed, 2268 insertions(+) create mode 100644 PLUGIN-DEVELOPMENT-GUIDE.md create mode 100644 src/essence/Tools/AOI/AOIComponent.css create mode 100644 src/essence/Tools/AOI/AOIComponent.tsx create mode 100644 src/essence/Tools/AOI/AOITool.js create mode 100644 src/essence/Tools/AOI/AOITooltip.tsx create mode 100644 src/essence/Tools/AOI/BLOCKERS.md create mode 100644 src/essence/Tools/AOI/aoiBoundaryLoader.ts create mode 100644 src/essence/Tools/AOI/aoiHelpers.ts create mode 100644 src/essence/Tools/AOI/assets/geo-data/sample.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Alabama.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Alaska.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Arizona.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Arkansas.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/California.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Colorado.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Connecticut.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Delaware.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/District of Columbia.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Florida.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Georgia.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Hawaii.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Idaho.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Illinois.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Indiana.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Iowa.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Kansas.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Kentucky.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Louisiana.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Maine.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Maryland.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Massachusetts.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Michigan.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Minnesota.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Mississippi.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Missouri.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Montana.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Nebraska.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Nevada.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/New Hampshire.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/New Jersey.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/New Mexico.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/New York.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/North Carolina.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/North Dakota.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Ohio.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Oklahoma.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Oregon.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Pennsylvania.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Puerto Rico.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Rhode Island.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/South Carolina.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/South Dakota.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Tennessee.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Texas.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/United States (Contiguous).geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/United States.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Utah.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Vermont.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Virginia.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Washington.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/West Virginia.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Wisconsin.geojson create mode 100644 src/essence/Tools/AOI/assets/geo-data/states/Wyoming.geojson create mode 100644 src/essence/Tools/AOI/config.json diff --git a/PLUGIN-DEVELOPMENT-GUIDE.md b/PLUGIN-DEVELOPMENT-GUIDE.md new file mode 100644 index 000000000..910d1d899 --- /dev/null +++ b/PLUGIN-DEVELOPMENT-GUIDE.md @@ -0,0 +1,524 @@ +# MMGIS Plugin Development Guideline (for Coding AIs) + +**Audience**: Coding agents (Claude Code, Cursor, Copilot, etc.) building or modifying an MMGIS Tool plugin under `src/essence/Tools//`. + +**Purpose**: Keep plugins decoupled from MMGIS core, visually consistent, and safe to merge. If you cannot satisfy a rule below, **stop and flag it** — do not work around it. + +**Reference plugin**: [src/essence/Tools/Chart/](src/essence/Tools/Chart/) — the canonical layout this guide describes. + +--- + +## 0. TL;DR — the hard rules + +1. **Build a standalone React component first.** "Standalone" means *the component*, not an app — a single React component (plus children) that any other React app could `import` and render with plain props. The MMGIS wrapper is a separate file that mounts this component via ReactDOM and adapts it to the MMGIS plugin lifecycle. +2. **Never modify core.** Anything outside `src/essence/Tools//` is off-limits. If a needed API or event isn't exposed, write a blocker entry. Missing core events are the *only* legitimate reason to request a core change — and even then, you propose, a human reviewer decides. +3. **Use only the approved plugin APIs.** + - **Communication** — `window.mmgisAPI` event bus (`on / off / emit / provide / request`, scoped via `mmgisAPI.forPlugin(id)`). No `document.dispatchEvent`, no `L_.subscribe*` in new code. + - **Map interaction** — the engine-agnostic factory: `mapEngineRegistry.getActiveEngine()` returning `IMapEngine`. Must work for both legacy Leaflet missions and new deck.gl missions. No direct `Map_.map.*`, `L.map`, or `Deck` calls. +4. **Strict styling segregation, theme-ready.** Match Figma exactly, use USWDS tokens/components where applicable, scope every selector under your tool's root class, and route every color/spacing/radius through a CSS custom property so a future theme file can flip them centrally. No hard-coded hex values. + +The rest of this document expands these rules and gives the do/don't details. + +--- + +## 1. Standalone React component + MMGIS wrapper + +### What this means + +Every plugin has **exactly two layers**, in two files: + +| Layer | File | Responsibility | +|---|---|---| +| **Standalone React component** | `Component.tsx` | A plain React component that any React app could import and render with props. Owns the feature's UI and local UI state. **No knowledge of MMGIS.** | +| **MMGIS wrapper** | `.js` (the `Tool_` plugin) | Implements the MMGIS plugin lifecycle (`make` / `destroy` / `getUrlString`). Reads from `L_` / `Map_` / mission config, fetches data, and renders the React component into the MMGIS-provided DOM node via ReactDOM. | + +"Standalone" here means *the component* is standalone, **not** that it ships as a separate app or package. It just must not depend on MMGIS, so it can be lifted into Storybook, the Configure UI, or any unrelated React app unchanged. + +### Why + +- The component is testable in isolation with React Testing Library — no MMGIS boot, no jQuery, no `L_`. +- It is reusable: another plugin, the Configure admin UI, or a future framework migration can render it directly. +- It forces a clean prop contract — the wrapper translates MMGIS state into plain props, which surfaces hidden coupling early. +- The wrapper stays small enough to read in one screen. + +### Component contract + +The component is the unit of work. Design its props the way a third-party React user would expect: + +```tsx +// Component.tsx +import * as React from 'react' +import './Component.css' + +export type Status = 'idle' | 'loading' | 'empty' | 'error' | 'ready' + +export interface ComponentProps { + status: Status + data?: /* plain data shape, documented in this file */ + config?: /* plain config shape, documented in this file */ + errorMessage?: string + emptyMessage?: string + onSomeUserAction?: (payload: /*…*/) => void +} + +export function Component(props: ComponentProps) { + // …local UI state via useState/useReducer, render JSX +} + +export default Component +``` + +### Do + +- Make it a function component with hooks. Default-export it AND named-export the props type. +- Accept all inputs as plain serializable props. Document each prop's shape inline. +- Own only React state (`useState` / `useReducer`) and refs to elements you render. Use `useEffect` cleanups for any subscriptions/instances (Chart.js, ResizeObserver, …). +- Render USWDS / Figma-aligned markup; let the parent control overall sizing via CSS. +- Communicate user intent up via `on*` callback props — never reach out to MMGIS yourself. + +### Don't + +- ❌ Import `L_`, `Map_`, `F_`, `mmgisAPI`, or anything from `src/essence/Basics/` / `src/essence/Ancillary/` inside the component file. +- ❌ Touch `window`, `document.querySelector`, or any DOM outside the JSX subtree React renders for you. +- ❌ Make network calls from the component. Fetching is the wrapper's job; pass data in as props. +- ❌ Mutate props you were handed. +- ❌ Use class components (no reason to in new code on React 16.13+). +- ❌ Pull in a state library (Redux, Zustand, etc.) just for this component — local hooks are enough. + +### Wrapper contract + +The wrapper is the *only* file that knows about MMGIS. It: + +1. Implements the `Tool_` shape MMGIS already expects (`make`, `destroy`, `getUrlString`, …). +2. Resolves config and data from MMGIS (`L_.layers.data[name].variables.`, click events, fetches). +3. Renders the React component into the `#tools` container via `ReactDOM.render`. +4. Re-renders by calling `ReactDOM.render` again with fresh props when MMGIS state changes. +5. On `destroy`, calls `ReactDOM.unmountComponentAtNode` on the container, then detaches MMGIS-side listeners. + +```js +// .js (MMGIS wrapper — vanilla, no JSX) +import $ from 'jquery' +import React from 'react' +import ReactDOM from 'react-dom' +import L_ from '../../Basics/Layers_/Layers_' +import Component from './Component' + +const = { + height: 200, + width: 'full', + MMGISInterface: null, + _root: null, // the DOM node we mount React into + _state: { // wrapper-side state, fed to the component as props + status: 'idle', + data: null, + config: null, + errorMessage: '', + }, + + make() { + const tools = $('#tools') + tools.css('background', 'var(--color-k)') + tools.empty() + const root = document.createElement('div') + root.style.height = '100%' + tools.append(root) + this._root = root + + this._renderReact() + // …subscribe to MMGIS events, fetch initial data, then call _setState/_renderReact + }, + + destroy() { + if (this._root) { + ReactDOM.unmountComponentAtNode(this._root) + this._root.remove() + this._root = null + } + // …unsubscribe MMGIS-side listeners + }, + + getUrlString() { return '' }, + + _setState(patch) { + this._state = { ...this._state, ...patch } + this._renderReact() + }, + + _renderReact() { + if (!this._root) return + ReactDOM.render( + React.createElement(Component, { + status: this._state.status, + data: this._state.data, + config: this._state.config, + errorMessage: this._state.errorMessage, + onSomeUserAction: (payload) => { /* talk back to MMGIS */ }, + }), + this._root + ) + }, +} + +export default +``` + +> **React 16.13 note** — this codebase is on React 16, so use `ReactDOM.render` / `ReactDOM.unmountComponentAtNode`. Do **not** introduce `createRoot` (React 18 API) — it isn't available and pulling in a newer React is a core change, which is forbidden by §2. + +> **Existing Chart tool** — at the time of writing, [src/essence/Tools/Chart/ChartComponent.js](src/essence/Tools/Chart/ChartComponent.js) is still a vanilla class for historical reasons. New tools must follow the React pattern above; the Chart component should be migrated when convenient. + +--- + +## 2. Hands off the core + +### Definition of "core" + +Anything outside `src/essence/Tools//`. In particular: + +- `src/essence/Basics/**` (Map_, Layers_, MapEngines, TimeControl_, …) +- `src/essence/Ancillary/**` +- `src/essence/Tools//**` +- `API/**` (backend) +- `configure/**` +- Build config (`configuration/webpack.config.js`, `tsconfig.json`, `package.json` scripts) +- Any shared CSS variables or global stylesheets + +### The rule + +You may **read** core APIs that are already public (e.g. `L_.layers.data[name]`, `Map_.map`, the layer click event). You may **not** edit core to expose new ones, fix a bug you noticed, or "improve" something nearby. + +If your plugin needs a hook that doesn't exist: + +1. **Stop.** Do not add the hook yourself. +2. **Document the gap** in the plugin's `BLOCKERS.md` (create if missing) using the template below. +3. **Pivot** to a part of the feature that the existing APIs already support, or report back that you are blocked. + +### Blocker entry template + +Create `src/essence/Tools//BLOCKERS.md`: + +```markdown +## [BLOCKER] + +- **Date**: YYYY-MM-DD +- **What I needed**: e.g. "An event fired when the active layer's time window changes." +- **Where I looked**: files/symbols inspected (`Map_.js`, `TimeControl_.js`, …) +- **Why existing APIs don't suffice**: short reason. +- **Proposed core change** (for a human reviewer to decide on): + describe the minimal new API surface — name, signature, where it would live. +- **Workaround in this plugin**: what the plugin does instead (polling, no-op, degraded UX, …) +``` + +### Do + +- Treat `L_`, `Map_`, layer config, and the existing event bus as **read-only contracts**. +- If you need configuration, put it on the layer's `variables.` block in mission JSON — that path is already plugin-extensible (see how [ChartTool.js](src/essence/Tools/Chart/ChartTool.js) reads `layerData.variables.chart`). +- If a core API misbehaves, file a blocker, don't patch it. + +### Don't + +- ❌ Edit any file outside your tool directory, **even for typos or lint fixes**. +- ❌ Add a new export to `Map_.js` "just for the plugin." +- ❌ Reach into another tool's internals. +- ❌ Add new top-level dependencies in `package.json` without flagging the cost — every dep ships to all users. Prefer a peer dep already in the tree (Chart.js, D3, ECharts, Turf, Proj4 are already there). + +--- + +## 3. Strict styling segregation (and theme-ready) + +### The rule + +1. **Match Figma exactly.** Spacing, color, type scale, radii, iconography. If Figma and USWDS conflict, default to Figma for plugin-internal UI; default to USWDS for any control that appears in shared chrome. +2. **Use USWDS tokens and components** (`uswds` is available) for buttons, inputs, alerts, and color where Figma doesn't specify — do not hand-roll a button. +3. **CSS lives only with the component.** One file: `Component.css`. Imported only by the component file. Never imported from the wrapper, never added to a global stylesheet, never edited from outside the tool directory. +4. **Scope every rule.** Every selector starts with a root class unique to your tool, e.g. `.your-tool …`. No bare element selectors, no global `*`, no `#tools` / `#toolPanel` rules. Removing the root element from the DOM must remove 100% of the plugin's visual footprint. +5. **No core CSS edits.** Do not touch `src/essence/**/*.css` outside your tool. Do not add or redefine CSS variables on `:root`. + +### Theme-ready: every value goes through a custom property + +A theme file is coming in a later PI. Plugins written today must be written so that switching the theme file flips every relevant value with **zero edits to plugin CSS**. + +Inside the component CSS, declare a block of locally-scoped CSS custom properties at the root of your tool, with current Figma values as defaults, and reference those properties everywhere else: + +```css +.your-tool { + /* Local tokens — defaults today, overridable by a future theme file. */ + --your-tool-bg: var(--mmgis-surface, #1f2024); + --your-tool-fg: var(--mmgis-text, #e6e6e6); + --your-tool-accent: var(--mmgis-accent, #4fc3f7); + --your-tool-radius-sm: var(--mmgis-radius-sm, 4px); + --your-tool-space-2: var(--mmgis-space-2, 8px); + --your-tool-font-body: var(--mmgis-font-body, 'Source Sans Pro', sans-serif); +} + +.your-tool__header { + background: var(--your-tool-bg); + color: var(--your-tool-fg); + padding: var(--your-tool-space-2); + border-radius: var(--your-tool-radius-sm); +} +``` + +The two-layer lookup (`var(--mmgis-*, fallback)`) means: +- **Today**, when no theme file is loaded, the fallback (your Figma value) wins. +- **Later**, the theme file defines `--mmgis-surface` etc. on `:root`, and every plugin picks it up automatically. + +### Do + +- Define a single root class on the outermost element (e.g. `.chart-tool`) and BEM-nest everything under it (`.chart-tool__header`, `.chart-tool__row`). +- Route every color, spacing, radius, font, shadow, and z-index through a `--your-tool-*` custom property declared on the root. +- Reference existing core CSS variables (e.g. `var(--color-k)`) where they already exist, behind the same `var(--mmgis-*, fallback)` pattern so the future theme can override. +- Prefix internal class names with the tool name (`.chart-tool__header`) so they cannot collide. + +### Don't + +- ❌ Inline a hard-coded hex, rem, or px value anywhere outside the tokens block. Every literal lives in exactly one place: the root tokens block. +- ❌ Set styles on `body`, `html`, `#tools`, `#toolPanel`, or any element you did not create. +- ❌ Use `!important` to win specificity battles with core — that means your scope is wrong, fix the scope. +- ❌ Define CSS custom properties on `:root` from a plugin. Define them on your `.your-tool` root only. +- ❌ Ship a second copy of an existing dependency for styling (no second icon font, no second reset). +- ❌ Use inline `style={{…}}` for anything theme-relevant (color, spacing, font). Inline styles bypass the theme system. + +--- + +## 4. Approved plugin APIs (the only ones) + +A plugin talks to MMGIS through exactly two surfaces. Anything else (touching `L_` directly, importing internals from `Map_`, dispatching DOM events, etc.) is forbidden in new code. + +### 4.1 Communication: the `mmgisAPI` event bus + +Reference: [docs/adr/20260209-plugin-communication-model.md](docs/adr/20260209-plugin-communication-model.md). Implementation: [src/essence/mmgisAPI/mmgisAPI.js](src/essence/mmgisAPI/mmgisAPI.js). + +The bus exposes: + +| Method | Purpose | +|---|---| +| `mmgisAPI.on(event, cb)` → returns `unsubscribe()` | Subscribe to events emitted anywhere (core or other plugins). | +| `mmgisAPI.off(event, cb)` | Unsubscribe (prefer the returned `unsubscribe()`). | +| `mmgisAPI.emit(event, data)` | Notify everyone an event happened. | +| `mmgisAPI.provide(name, handler)` → returns `cleanup()` | Register a request handler that returns data. | +| `mmgisAPI.request(name, data)` → `Promise` | Ask another module for data (async). | +| `mmgisAPI.hasHandler(name)` → `boolean` | Capability check. | +| `mmgisAPI.forPlugin(pluginId)` | Returns `{ emit, provide }` that auto-prefix names with `plugin::`. | + +#### Required usage pattern + +```js +// In your wrapper (.js): +const api = window.mmgisAPI.forPlugin('your-tool') // pluginId is your tool's slug +const cleanups = [] + +// Listen to core events (use full paths, NOT through forPlugin): +cleanups.push(window.mmgisAPI.on('feature:active', (data) => { + this._setState({ activeFeature: data.activeFeature }) +})) + +// Provide data other plugins / core may ask for (auto-prefixed): +cleanups.push(api.provide('getCurrentSelection', () => this._state.data)) +// ^ registers as 'plugin:your-tool:getCurrentSelection' + +// Notify others when something happens (auto-prefixed): +api.emit('selectionChanged', { id }) +// ^ emits 'plugin:your-tool:selectionChanged' + +// In destroy(): run every cleanup. +cleanups.forEach(fn => fn()) +``` + +#### Do + +- **Pick a stable `pluginId`** matching your tool directory (kebab-case). Document it at the top of the wrapper. +- **Subscribe in `make()`, unsubscribe in `destroy()`** — store every returned `unsubscribe` / `cleanup` and call them all on teardown. +- **Use namespaced event names**: `domain:verb` for core (`layer:toggle`, `feature:active`, `time:change`), and let `forPlugin()` add the `plugin::` prefix for your own. +- **Document every event your plugin emits and consumes** at the top of the wrapper. This is the plugin's public API. +- **Pass plain serializable data** through the bus. No DOM nodes, no class instances, no Leaflet/deck.gl handles. + +#### Don't + +- ❌ Use `document.dispatchEvent(new CustomEvent(...))` or attach listeners to `document` for cross-module communication. +- ❌ Use `L_.subscribeTimeChange` / `L_.subscribeOnLayerToggle` / any other `L_.subscribe*` in new code — these are legacy, route through `mmgisAPI.on('time:change', …)` etc. +- ❌ Reach into another plugin's internals — call `mmgisAPI.request('plugin:other-tool:something')` instead. +- ❌ Bypass `forPlugin()` and emit unprefixed plugin events — collisions with core or other plugins are silent. + +#### Missing-event blocker + +If the event you need does not exist on the bus today, **do not add it to core yourself**. File a [blocker entry](#blocker-entry-template) with: + +- The event name you propose (`domain:verb`). +- Where in core it should be emitted (file + symbol). +- The payload shape. +- What your plugin does in the meantime (degraded UX, polling, no-op). + +A human reviewer decides whether core gains the event. New core events are the **only** core change a plugin task may propose. + +### 4.2 Map interaction: the engine factory + +Reference: [docs/adr/02102026-maps-engine-architecture.md](docs/adr/02102026-maps-engine-architecture.md). Implementation: [src/essence/Basics/MapEngines/](src/essence/Basics/MapEngines/). + +Old missions use Leaflet. New missions use deck.gl. Your plugin must work on both, unmodified. The way to achieve this is to talk to the map only through `IMapEngine`, never to a specific engine's API. + +#### Required usage pattern + +```js +import { mapEngineRegistry } from '../../Basics/MapEngines' + +// Inside the wrapper: +const engine = mapEngineRegistry.getActiveEngine() +if (!engine) { + // No map yet — wait for an init event on the bus, or no-op. + return +} + +// Engine-agnostic calls: +engine.fitBounds(bounds, { padding: [20, 20] }) +engine.on('moveend', this._onMoveEnd) +engine.onFeatureClick(({ feature, layer }) => { /* ... */ }) + +// In destroy(): +engine.off('moveend', this._onMoveEnd) +``` + +The full contract is defined in [src/essence/Basics/MapEngines/IMapEngine.ts](src/essence/Basics/MapEngines/IMapEngine.ts). Adapters: [LeafletAdapter.ts](src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts), [DeckGLAdapter.ts](src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts). + +#### Do + +- **Always go through `mapEngineRegistry.getActiveEngine()`**. Treat the returned object as `IMapEngine` only; ignore which adapter it actually is. +- **Pass `LatLngLike` objects** (`{ lat, lng }`) — the adapter handles axis-order conversion for deck.gl internally. +- **Use `engine.getNativeMap()` only as an explicit, justified escape hatch** — and write a comment explaining why `IMapEngine` is insufficient. This counts as a [blocker](#blocker-entry-template) candidate: the missing capability should be added to `IMapEngine`, not to your plugin. +- **Check `ENGINE_LAYER_SUPPORT` / `engineSupportsLayer(...)`** if your plugin creates a layer type (some types only render on one engine). If your tool can't run on the active engine, render an `empty`/`error` state and emit a `tool:incompatible` notice — don't crash. + +#### Don't + +- ❌ Import `leaflet`, `L`, `deck.gl`, or `Cesium` from a plugin. +- ❌ Read `Map_.map.*` or `window.map.*` directly. That is the legacy path and skips the adapter. +- ❌ Branch on engine type (`if (engine.engineType === 'leaflet') …`). If you find yourself wanting to, the `IMapEngine` contract is missing something — file a blocker. +- ❌ Hold onto the engine instance across an engine swap. Re-fetch via `mapEngineRegistry.getActiveEngine()` on each operation, or subscribe to the engine-change event on the bus and refresh. + +#### Missing-capability blocker + +If `IMapEngine` doesn't expose what you need (e.g. a new layer type, a query, a coordinate transform), file a blocker proposing the addition to the **interface** — every adapter must implement it. Do not add it to a single adapter "for now." + +--- + +## 5. Configuration + +Per-mission configuration lives on `L_.layers.data[layerName].variables.` in mission JSON. Read it from the wrapper, validate it, pass plain values as props to the component. Don't invent a new config file or location — if `variables.` can't express what you need, file a blocker. + +That's the whole rule. No more. + +--- + +## 6. File structure (canonical) + +``` +src/essence/Tools// +├── .js # MMGIS wrapper (the "Tool_" plugin) — vanilla JS, mounts React +├── Component.tsx # standalone React component, MMGIS-free +├── Component.css # scoped styles, imported only by the component +├── config.json # tool registration / defaults (if applicable) +├── BLOCKERS.md # only if you hit a core gap +└── __tests__/ + └── Component.test.tsx +``` + +Wrapper lifecycle contract (matches existing tools): + +```js +const YourTool = { + height: 200, // px or 'full' + width: 'full', + MMGISInterface: null, + _root: null, // host DOM node for ReactDOM.render + + make() { /* create host node, ReactDOM.render the component, subscribe MMGIS events */ }, + destroy() { /* ReactDOM.unmountComponentAtNode, remove host, detach listeners */ }, + getUrlString(){ /* serialize tool state for shareable URL, or '' */ }, +} +``` + +Refer to [src/essence/Tools/New Tool Template.js](src/essence/Tools/New%20Tool%20Template.js) for the unmodified base shape — but note new tools should mount a React component inside `make()` per §1, rather than building DOM imperatively. + +--- + +## 7. Lifecycle and cleanup + +A plugin is mounted/unmounted many times in one session. Leaks compound. Cleanup is split between the React component (its own subscriptions) and the wrapper (everything React doesn't know about). + +### Component side (React) + +- Use `useEffect` for any subscription, observer, library instance (Chart.js, ResizeObserver, …). +- Always return the cleanup function from `useEffect`. Tear down the instance there. +- Use refs (`useRef`) for mutable handles you need across renders. +- Don't attach listeners to `document` / `window` from inside the component if a prop callback would do. + +### Wrapper side (MMGIS) + +- In `destroy()`, **first** call `ReactDOM.unmountComponentAtNode(this._root)` so the component's `useEffect` cleanups run, **then** remove the host node and detach MMGIS-side listeners. +- Hold every MMGIS-side listener (layer click handler, time control sub, websocket sub) on the wrapper object. +- Make `destroy()` idempotent and safe to call after a failed `make()` (guard with `if (this._root)`). + +### Don't + +- ❌ Re-create the host node on every state change — call `ReactDOM.render` again on the same node; React will reconcile. +- ❌ Rely on garbage collection to clean up Chart.js / Cesium / Leaflet handles — call their `.destroy()` in the `useEffect` cleanup. +- ❌ Leave timers running after `destroy()`. +- ❌ Forget to `unmountComponentAtNode` — without it, the component's `useEffect` cleanups never fire and you leak everything they own. + +--- + +## 8. Testing + +- **Component**: render with React Testing Library in jsdom, drive every status (`idle` / `loading` / `empty` / `error` / `ready`) via props, and assert on output. Verify one user interaction fires its `on*` callback with the expected payload. Verify unmounting tears down library instances (e.g. spy on `Chart.destroy`). +- **Wrapper**: integration-test only the data-translation seam — the function that turns layer config + fetch response into component props. Mock `fetch`. Don't mock `L_`/`Map_`; pass plain fixtures. +- Aim for the project's 80% coverage minimum (see `AGENTS.md` § Constitution IV). + +--- + +## 9. Definition of Done — self-checklist + +Before reporting a plugin task complete, verify each item: + +- [ ] Component file is a single React function component with named-exported props type and default export. +- [ ] Component file has **zero** imports from `src/essence/Basics/`, `Ancillary/`, `mmgisAPI`, or other tools — verify with `grep -E "from '\\.\\./\\.\\./" Component.tsx` returning nothing. +- [ ] Component would render in an unrelated React app given only its prop types — confirm by reading the import list. +- [ ] Wrapper calls `ReactDOM.unmountComponentAtNode` in `destroy()` before removing the host node. +- [ ] **Communication**: every cross-module event goes through `window.mmgisAPI`. No `document.dispatchEvent`, no `L_.subscribe*` in new code. Plugin owns events are emitted via `mmgisAPI.forPlugin('')`. +- [ ] Every `mmgisAPI.on` / `provide` returns a function stored in a cleanup list, and `destroy()` calls every cleanup. +- [ ] **Map**: every map call goes through `mapEngineRegistry.getActiveEngine()` / `IMapEngine`. No imports of `leaflet`, `L`, `deck.gl`, or `Cesium`. No reads of `Map_.map` or `window.map`. +- [ ] Plugin verified mentally (or in browser) on **both** a Leaflet-engine and a deck.gl-engine mission, OR explicitly `engineSupportsLayer`-gated with a clear empty/error state. +- [ ] `git diff --stat` shows changes **only** under `src/essence/Tools//` (and possibly a tool-registration entry — flag that one explicitly in your report). +- [ ] No `package.json` changes, or new deps are explicitly justified in the PR description. No React-version bump. +- [ ] Every CSS selector starts with the tool's root class. `grep -E "^[^.{]" Component.css` returns nothing meaningful. +- [ ] **Theme-ready**: every literal value (color, spacing, radius, font, shadow) lives only in the root tokens block of the CSS, referenced everywhere else via `var(--your-tool-*)`. No inline style props for theme-relevant values. +- [ ] Figma spec referenced in the PR; visible diffs against Figma noted. +- [ ] USWDS components used for any standard control (button, input, alert). +- [ ] All `useEffect` hooks return cleanup functions where they own external instances. +- [ ] Tool mounts → unmounts → remounts cleanly with no console warnings (especially no "Can't perform a React state update on an unmounted component"). +- [ ] All blockers (if any) recorded in `BLOCKERS.md` with the template fields filled. Missing-event proposals scoped to `mmgisAPI` core; missing-map-capability proposals scoped to `IMapEngine`. +- [ ] `npm test -- src/essence/Tools/` passes. +- [ ] `npm run build` (only if you actually changed build inputs) succeeds. + +--- + +## 10. Things that look reasonable but are not + +- "This selector is awkward; let me add a helper to `Map_.js`." → **No.** File a blocker — and target `IMapEngine`, not `Map_.js`. +- "I'll just call `engine.getNativeMap().something()` because the interface is missing it." → That's a blocker, not a workaround. Flag the missing `IMapEngine` capability and degrade until it lands. +- "I'll branch on `engine.engineType === 'leaflet'`." → **No.** Adapter-specific code in plugins defeats the entire factory. File a blocker against `IMapEngine`. +- "I'll add a quick `document.addEventListener('myEvent', …)` between my plugin and another." → **No.** Use `mmgisAPI.on` / `emit`. If the event you need doesn't exist on the bus, file a blocker. +- "`L_.subscribeOnLayerToggle` already works, I'll just use that." → **No.** Legacy. New code uses `mmgisAPI.on('layer:toggle', …)`. If that event isn't being emitted yet, blocker. +- "I'll bump Chart.js / React / any dep to a newer minor while I'm here." → **No.** Out of scope. +- "Core's CSS variable name is wrong, I'll rename it." → **No.** Not your tree. +- "I'll add a `console.log` for debugging." → Remove before completion. +- "I'll reuse another tool's CSS class because it looks the same." → **No.** Copy the rule into your scope or extract to USWDS-aligned tokens via a blocker request. +- "I'll inline this color since it's only used once." → **No.** Add it as a `--your-tool-*` token. Future theming has zero tolerance for inline literals. +- "The Figma is missing this state, I'll improvise." → Flag the gap, render a neutral fallback (empty/error props), do not invent visual design. + +--- + +## 11. When in doubt + +Stop and ask. A good blocker entry beats a bad core patch. The cost of pausing is one message; the cost of a quietly invasive plugin is weeks of untangling. + +--- + +_Sections to extend later (placeholders): accessibility requirements, i18n, performance budgets, telemetry/logging conventions._ diff --git a/src/essence/Tools/AOI/AOIComponent.css b/src/essence/Tools/AOI/AOIComponent.css new file mode 100644 index 000000000..8af187f74 --- /dev/null +++ b/src/essence/Tools/AOI/AOIComponent.css @@ -0,0 +1,394 @@ +/* AOI plugin — scoped under .aoi-tool-host / .aoi-tool / .aoi-tooltip roots only. + * All literal values live in the tokens block; everywhere else uses var(--aoi-*). + * Future theme files override --mmgis-* on :root and propagate through the + * var() fallbacks. + */ + +.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 { + --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)); + + box-sizing: border-box; + color: var(--aoi-fg); + font-family: var(--aoi-font-body); + font-size: var(--aoi-font-size-md); + background: var(--aoi-bg); +} + +.aoi-tool *, +.aoi-tooltip * { + box-sizing: border-box; +} + +.aoi-tool { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + padding: var(--aoi-space-4); + gap: var(--aoi-space-3); +} + +.aoi-tool__header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.aoi-tool__title { + display: inline-flex; + align-items: center; + gap: var(--aoi-space-2); + font-size: var(--aoi-font-size-lg); + font-weight: 600; +} + +.aoi-tool__title-icon { + font-size: 16px; + line-height: 1; + color: var(--aoi-accent); +} + +.aoi-tool__close { + background: none; + border: 0; + color: var(--aoi-fg-muted); + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: var(--aoi-space-1); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.aoi-tool__close:hover { + color: var(--aoi-fg); +} + +.aoi-tool__tabs { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--aoi-space-1); + border: 1px solid var(--aoi-border); + border-radius: var(--aoi-radius-sm); + padding: var(--aoi-space-1); +} + +.aoi-tool__tab { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--aoi-space-1); + padding: var(--aoi-space-2) var(--aoi-space-1); + border: 0; + background: transparent; + color: var(--aoi-fg-muted); + border-radius: var(--aoi-radius-sm); + cursor: pointer; + font-family: inherit; + font-size: var(--aoi-font-size-sm); +} + +.aoi-tool__tab:hover { + color: var(--aoi-fg); +} + +.aoi-tool__tab--active { + background: var(--aoi-bg-muted); + color: var(--aoi-fg); +} + +.aoi-tool__tab-icon { + font-size: 18px; + line-height: 1; +} + +.aoi-tool__tab-label { + font-size: var(--aoi-font-size-sm); +} + +.aoi-tool__body { + flex: 1 1 auto; + min-height: 0; +} + +.aoi-panel { + display: flex; + flex-direction: column; + gap: var(--aoi-space-3); +} + +.aoi-panel__hint { + margin: 0; + color: var(--aoi-fg); + font-size: var(--aoi-font-size-sm); +} + +.aoi-panel__hint--secondary { + color: var(--aoi-fg-muted); +} + +.aoi-panel__empty { + margin: 0; + color: var(--aoi-fg-muted); + font-size: var(--aoi-font-size-sm); +} + +.aoi-panel__error { + margin: 0; + color: var(--aoi-danger); + font-size: var(--aoi-font-size-sm); +} + +.aoi-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-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-upload__formats-disabled { + color: var(--aoi-fg-muted); +} + +.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.tsx b/src/essence/Tools/AOI/AOIComponent.tsx new file mode 100644 index 000000000..87f021a66 --- /dev/null +++ b/src/essence/Tools/AOI/AOIComponent.tsx @@ -0,0 +1,271 @@ +import React, { useEffect, useRef } from 'react' +import './AOIComponent.css' + +export type AOIMode = 'search' | 'inspect' | 'draw' | 'upload' +export type AOIShape = 'polygon' | 'rectangle' | 'circle' +export type UploadStatus = 'idle' | 'parsing' | 'error' + +export interface AOISearchResult { + id: string + label: string + kind: 'city' | 'county' | 'state' +} + +export interface AOIComponentProps { + mode: AOIMode + onModeChange: (mode: AOIMode) => void + + searchQuery: string + searchResults: AOISearchResult[] + searchDisabled?: boolean + searchLoading?: boolean + onSearchQueryChange: (q: string) => void + onSearchSelect: (id: string) => void + + drawShape: AOIShape | null + drawDisabled?: boolean + canUndo: boolean + canRedo: boolean + onDrawShapeChange: (shape: AOIShape) => void + onDrawUndo: () => void + onDrawRedo: () => void + + uploadStatus: UploadStatus + uploadError?: string + onUploadFile: (file: File) => 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) { + return ( +
+
+
+