diff --git a/src/essence/Tools/Chart/ChartComponent.css b/src/essence/Tools/Chart/ChartComponent.css
new file mode 100644
index 000000000..3f3be1957
--- /dev/null
+++ b/src/essence/Tools/Chart/ChartComponent.css
@@ -0,0 +1,193 @@
+/* Chart plugin — scoped under .chart-tool-host / .chart-tool.
+ * Every literal lives in theme.css; this file references var(--chart-*) only.
+ */
+
+.chart-tool-host {
+ position: fixed;
+ top: 70px;
+ right: 16px;
+ width: 372px;
+ max-height: calc(100vh - 90px);
+ overflow: hidden;
+ z-index: 1003;
+ border-radius: var(--chart-radius-md);
+ box-shadow: var(--chart-shadow-pop);
+ pointer-events: auto;
+}
+
+.chart-tool {
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 100%;
+ background: var(--chart-bg);
+ color: var(--chart-fg);
+ font-family: var(--chart-font-body);
+ font-size: var(--chart-font-size-md);
+}
+
+.chart-tool * {
+ box-sizing: border-box;
+}
+
+.chart-tool__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--chart-space-3) var(--chart-space-4);
+ border-bottom: 1px solid var(--chart-border);
+}
+
+.chart-tool__title {
+ margin: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: var(--chart-space-2);
+ font-size: var(--chart-font-size-lg);
+ font-weight: 600;
+}
+
+.chart-tool__title-icon {
+ color: var(--chart-accent);
+ font-size: 16px;
+ line-height: 1;
+}
+
+.chart-tool__close {
+ background: none;
+ border: 0;
+ color: var(--chart-fg-muted);
+ font-size: 18px;
+ line-height: 1;
+ cursor: pointer;
+ padding: var(--chart-space-1);
+}
+
+.chart-tool__close:hover {
+ color: var(--chart-fg);
+}
+
+.chart-tool__body {
+ flex: 1 1 auto;
+ overflow-y: auto;
+ padding: var(--chart-space-4);
+ display: flex;
+ flex-direction: column;
+ gap: var(--chart-space-3);
+}
+
+.chart-tool__placeholder {
+ margin: 0;
+ color: var(--chart-fg-muted);
+ font-size: var(--chart-font-size-sm);
+ text-align: center;
+ padding: var(--chart-space-5) var(--chart-space-3);
+}
+
+.chart-tool__exit {
+ width: 100%;
+ padding: var(--chart-space-2) var(--chart-space-3);
+ border: 1px solid var(--chart-border);
+ border-radius: var(--chart-radius-sm);
+ background: var(--chart-bg);
+ color: var(--chart-fg);
+ cursor: pointer;
+ font: inherit;
+ font-weight: 600;
+}
+
+.chart-tool__exit:hover {
+ background: var(--chart-bg-muted);
+}
+
+.chart-tool__card {
+ border: 1px solid var(--chart-border);
+ border-radius: var(--chart-radius-md);
+ padding: var(--chart-space-3);
+ display: flex;
+ flex-direction: column;
+ gap: var(--chart-space-3);
+}
+
+.chart-tool__card--empty {
+ background: var(--chart-bg-muted);
+}
+
+.chart-tool__card-header {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.chart-tool__card-title {
+ margin: 0;
+ font-size: var(--chart-font-size-md);
+ font-weight: 600;
+}
+
+.chart-tool__card-subtitle {
+ color: var(--chart-fg-muted);
+ font-family: var(--chart-font-mono);
+ font-size: var(--chart-font-size-sm);
+}
+
+.chart-tool__card-empty {
+ margin: 0;
+ color: var(--chart-fg-muted);
+ font-size: var(--chart-font-size-sm);
+ font-style: italic;
+}
+
+.chart-tool__headline {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.chart-tool__headline-label {
+ color: var(--chart-fg-muted);
+ font-size: var(--chart-font-size-sm);
+}
+
+.chart-tool__headline-value {
+ font-size: var(--chart-font-size-xl);
+ font-weight: 600;
+ line-height: 1.1;
+}
+
+.chart-tool__headline-meta {
+ color: var(--chart-fg-muted);
+ font-size: var(--chart-font-size-sm);
+}
+
+.chart-tool__histogram {
+ height: 120px;
+ width: 100%;
+}
+
+.chart-tool__stats {
+ margin: 0;
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: var(--chart-space-2);
+}
+
+.chart-tool__stat {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.chart-tool__stat-label {
+ margin: 0;
+ color: var(--chart-fg-muted);
+ font-size: var(--chart-font-size-sm);
+}
+
+.chart-tool__stat-value {
+ margin: 0;
+ font-family: var(--chart-font-mono);
+ font-size: var(--chart-font-size-sm);
+ font-weight: 600;
+}
diff --git a/src/essence/Tools/Chart/ChartComponent.tsx b/src/essence/Tools/Chart/ChartComponent.tsx
new file mode 100644
index 000000000..a5df776d5
--- /dev/null
+++ b/src/essence/Tools/Chart/ChartComponent.tsx
@@ -0,0 +1,220 @@
+import React, { useEffect, useRef } from 'react'
+import ChartJS from 'chart.js/auto'
+import './theme.css'
+import './ChartComponent.css'
+import {
+ AnalysisData,
+ AssetStats,
+ ResultCard,
+ cardsFromAnalysisData,
+ emptyLayers,
+ buildHistogramBins,
+ formatStat,
+ formatCount,
+ formatPercent,
+} from './chartHelpers'
+
+export interface ChartComponentProps {
+ analysisData?: AnalysisData | null
+ onClose?: () => void
+ onExit?: () => void
+}
+
+export function ChartComponent({ analysisData, onClose, onExit }: ChartComponentProps) {
+ const cards = cardsFromAnalysisData(analysisData)
+ const empties = emptyLayers(analysisData)
+ const isIdle = !analysisData
+
+ return (
+
+
+
+
+ Analyze areas
+
+ {onClose && (
+
+ )}
+
+
+
+ {isIdle && (
+
+ Draw an area on the map and click Analyze area to see statistics here.
+
+ )}
+
+ {!isIdle && (
+ <>
+ {onExit && (
+
+ )}
+
+ {cards.length === 0 && empties.length === 0 && (
+
+ No analysis-supported layers are visible.
+
+ )}
+
+ {cards.map((card) => (
+
+ ))}
+
+ {empties.map((layerName) => (
+
+ ))}
+ >
+ )}
+
+
+ )
+}
+
+function ResultCardView({ card }: { card: ResultCard }) {
+ const { layerName, assetName, stats } = card
+ const noPixels = stats.valid_pixels === 0
+
+ return (
+
+
+ {layerName}
+ {assetName}
+
+
+ {noPixels ? (
+ No usable pixels in this area.
+ ) : (
+ <>
+
+
Mean
+
{formatStat(stats.mean)}
+
+ {formatPercent(stats.valid_percent)} valid · {formatCount(stats.valid_pixels)} pixels
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ )
+}
+
+function Stat({ label, value }: { label: string; value: string }) {
+ return (
+
+
{label}
+ {value}
+
+ )
+}
+
+function EmptyCard({ layerName }: { layerName: string }) {
+ return (
+
+
+ Could not fetch statistics for this.
+
+ )
+}
+
+function Histogram({ stats }: { stats: AssetStats }) {
+ const canvasRef = useRef(null)
+ const chartRef = useRef(null)
+
+ useEffect(() => {
+ const canvas = canvasRef.current
+ if (!canvas) return
+
+ const styles = getComputedStyle(canvas)
+ const themeVar = (name: string, fallback: string) =>
+ styles.getPropertyValue(name).trim() || fallback
+ const accent = themeVar('--chart-accent', '#137480')
+ const grid = themeVar('--chart-border', '#dfe1e2')
+ const muted = themeVar('--chart-fg-muted', '#565c65')
+
+ const bins = buildHistogramBins(stats.histogram)
+
+ chartRef.current = new ChartJS(canvas, {
+ type: 'bar',
+ data: {
+ labels: bins.map((b) => b.label),
+ datasets: [
+ {
+ label: 'count',
+ data: bins.map((b) => b.count),
+ backgroundColor: accent,
+ borderRadius: 2,
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ callbacks: {
+ title: (ctx) => {
+ const bin = bins[ctx[0].dataIndex]
+ return `${formatStat(bin.lo)} – ${formatStat(bin.hi)}`
+ },
+ label: (ctx) => `${formatCount(ctx.parsed.y)} pixels`,
+ },
+ },
+ },
+ scales: {
+ x: {
+ ticks: { color: muted, maxTicksLimit: 6, autoSkip: true },
+ grid: { display: false },
+ },
+ y: {
+ beginAtZero: true,
+ ticks: { color: muted, callback: (v) => formatCount(Number(v)) },
+ grid: { color: grid },
+ },
+ },
+ },
+ })
+
+ return () => {
+ chartRef.current?.destroy()
+ chartRef.current = null
+ }
+ }, [stats])
+
+ return (
+
+
+
+ )
+}
+
+export default ChartComponent
diff --git a/src/essence/Tools/Chart/ChartTool.js b/src/essence/Tools/Chart/ChartTool.js
new file mode 100644
index 000000000..0b5173fac
--- /dev/null
+++ b/src/essence/Tools/Chart/ChartTool.js
@@ -0,0 +1,127 @@
+/**
+ * Chart plugin — MMGIS wrapper.
+ *
+ * Receiver-only. Subscribes (at module scope) to `plugin:aoi:analysisReady`
+ * and renders the per-layer stats payload via ChartComponent.
+ *
+ * pluginId: 'chart'
+ *
+ * Listens to:
+ * - plugin:aoi:analysisReady { analysisData: { [layerName]: } }
+ *
+ * This file is the only one in the plugin that touches mmgisAPI.
+ * ChartComponent.tsx and chartHelpers.ts stay MMGIS-agnostic.
+ */
+
+import React from 'react'
+import { render, unmountComponentAtNode } from 'react-dom'
+
+import ChartComponent from './ChartComponent'
+
+const PLUGIN_ID = 'chart'
+
+// ── Module-level state ────────────────────────────────────────────────────────
+// MMGIS tools are mutually exclusive, so when AOI emits `analysisReady`,
+// Chart's `make()` hasn't run yet. The bus listener has to live at module
+// scope to catch the emit; we stash the latest payload here and replay it
+// to the instance once it mounts.
+let _instance = null
+let _latestAnalysisData = null
+
+function _onAnalysisReady(payload) {
+ _latestAnalysisData = payload?.analysisData ?? null
+ if (_instance && _instance._root) {
+ _instance._render()
+ return
+ }
+ // Auto-open Chart panel by clicking its toolbar button.
+ const btn =
+ typeof document !== 'undefined' &&
+ document.getElementById('toolButtonChart')
+ if (btn) btn.click()
+}
+
+function _subscribeBus() {
+ const api = typeof window !== 'undefined' ? window.mmgisAPI : null
+ if (!api?.on) return false
+ api.on('plugin:aoi:analysisReady', _onAnalysisReady)
+ return true
+}
+
+if (typeof window !== 'undefined') {
+ if (!_subscribeBus()) {
+ const id = setInterval(() => {
+ if (_subscribeBus()) clearInterval(id)
+ }, 50)
+ setTimeout(() => clearInterval(id), 5000)
+ }
+}
+
+// ── Tool ──────────────────────────────────────────────────────────────────────
+const ChartTool = {
+ // Collapse the docked side rail; the panel is `separatedTool: true`.
+ height: 0,
+ width: 0,
+ MMGISInterface: null,
+ _root: null,
+ _api: null,
+
+ make(targetId) {
+ this.MMGISInterface = new interfaceWithMMGIS(this, targetId)
+
+ this._api =
+ (typeof window !== 'undefined' && window.mmgisAPI?.forPlugin?.(PLUGIN_ID)) ||
+ { emit: () => {}, provide: () => () => {} }
+
+ _instance = this
+ this._render()
+ },
+
+ destroy() {
+ if (this._root) {
+ unmountComponentAtNode(this._root)
+ this._root = null
+ }
+ if (this.MMGISInterface) {
+ this.MMGISInterface.separateFromMMGIS()
+ this.MMGISInterface = null
+ }
+ if (_instance === this) _instance = null
+ this._api = null
+ },
+
+ getUrlString() {
+ return ''
+ },
+
+ _render() {
+ if (!this._root) return
+ render(
+ React.createElement(ChartComponent, {
+ analysisData: _latestAnalysisData,
+ onClose: () => this._onClose(),
+ }),
+ this._root
+ )
+ },
+
+ _onClose() {
+ const btn = document.getElementById('toolButtonChart')
+ if (btn) btn.click()
+ },
+}
+
+function interfaceWithMMGIS(tool) {
+ const root = document.createElement('div')
+ root.className = 'chart-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 ChartTool
diff --git a/src/essence/Tools/Chart/chartHelpers.ts b/src/essence/Tools/Chart/chartHelpers.ts
new file mode 100644
index 000000000..9a4404737
--- /dev/null
+++ b/src/essence/Tools/Chart/chartHelpers.ts
@@ -0,0 +1,114 @@
+/**
+ * Chart plugin — pure helpers.
+ *
+ * No React, no DOM, no MMGIS. Turns the AOI plugin's `analysisReady`
+ * payload into render-ready cards and formats stat values.
+ */
+
+export interface AssetStats {
+ min: number | null
+ max: number | null
+ mean: number | null
+ count: number
+ sum: number
+ std: number | null
+ median: number | null
+ majority: number | null
+ minority: number | null
+ unique: number
+ histogram: [number[], number[]]
+ valid_percent: number
+ masked_pixels: number
+ valid_pixels: number
+ percentile_2: number | null
+ percentile_98: number | null
+}
+
+export interface StatsResponse {
+ type: 'Feature'
+ geometry: unknown
+ properties: { statistics: Record }
+}
+
+export type AnalysisData = Record
+
+export interface ResultCard {
+ layerName: string
+ assetName: string
+ stats: AssetStats
+}
+
+export interface HistBin {
+ label: string
+ count: number
+ lo: number
+ hi: number
+}
+
+/** Flatten the keyed-by-layer payload into one card per (layer, asset). */
+export function cardsFromAnalysisData(
+ data: AnalysisData | null | undefined
+): ResultCard[] {
+ if (!data) return []
+ const out: ResultCard[] = []
+ for (const [layerName, resp] of Object.entries(data)) {
+ if (!resp) continue
+ const stats = resp.properties?.statistics ?? {}
+ for (const [assetName, assetStats] of Object.entries(stats)) {
+ out.push({ layerName, assetName, stats: assetStats })
+ }
+ }
+ return out
+}
+
+/** Layer names that came back as null (errored or non-2xx). */
+export function emptyLayers(
+ data: AnalysisData | null | undefined
+): string[] {
+ if (!data) return []
+ return Object.keys(data).filter((k) => data[k] === null)
+}
+
+/**
+ * histogram is [counts, edges] with `edges.length === counts.length + 1`.
+ * Returns one bin per count, labeled at the bin midpoint.
+ */
+export function buildHistogramBins(
+ histogram: [number[], number[]]
+): HistBin[] {
+ const [counts, edges] = histogram
+ return counts.map((count, i) => {
+ const lo = edges[i]
+ const hi = edges[i + 1]
+ return { count, lo, hi, label: formatShort((lo + hi) / 2) }
+ })
+}
+
+/** Format a stat value. Returns '—' for null/undefined/NaN. */
+export function formatStat(
+ value: number | null | undefined,
+ opts?: { unit?: string; digits?: number }
+): string {
+ if (value == null || !Number.isFinite(value)) return '—'
+ const digits =
+ opts?.digits ??
+ (Math.abs(value) >= 100 ? 0 : Math.abs(value) >= 1 ? 2 : 4)
+ const s = value.toFixed(digits)
+ return opts?.unit ? `${s} ${opts.unit}` : s
+}
+
+export function formatCount(n: number): string {
+ return Number.isFinite(n) ? n.toLocaleString() : '—'
+}
+
+export function formatPercent(p: number): string {
+ if (!Number.isFinite(p)) return '—'
+ return p < 1 ? `${p.toFixed(2)}%` : `${p.toFixed(0)}%`
+}
+
+function formatShort(n: number): string {
+ if (!Number.isFinite(n)) return '—'
+ if (Math.abs(n) >= 1000) return n.toFixed(0)
+ if (Math.abs(n) >= 1) return n.toFixed(1)
+ return n.toFixed(2)
+}
diff --git a/src/essence/Tools/Chart/config.json b/src/essence/Tools/Chart/config.json
new file mode 100644
index 000000000..31ebd3f8b
--- /dev/null
+++ b/src/essence/Tools/Chart/config.json
@@ -0,0 +1,15 @@
+{
+ "defaultIcon": "chart-bar",
+ "description": "Render per-layer analysis results published by the AOI plugin.",
+ "descriptionFull": {
+ "title": "Subscribes to plugin:aoi:analysisReady on the mmgisAPI Event Bus and renders one card per (layer, asset) with a headline mean, histogram, and stats grid. No per-layer configuration required — analysis-supported layers are configured on the AOI plugin side.",
+ "example": {}
+ },
+ "hasVars": false,
+ "name": "Chart",
+ "toolbarPriority": 6,
+ "separatedTool": true,
+ "paths": {
+ "ChartTool": "essence/Tools/Chart/ChartTool"
+ }
+}
diff --git a/src/essence/Tools/Chart/theme.css b/src/essence/Tools/Chart/theme.css
new file mode 100644
index 000000000..6ac238325
--- /dev/null
+++ b/src/essence/Tools/Chart/theme.css
@@ -0,0 +1,37 @@
+/* Chart plugin theme tokens.
+ *
+ * Every literal lives here. ChartComponent.css references only var(--chart-*).
+ * A host theme can override these on :root or `.chart-tool-host` and every
+ * fallback below picks the override up automatically.
+ */
+
+.chart-tool-host,
+.chart-tool {
+ --chart-bg: #ffffff;
+ --chart-bg-muted: #f6f7f8;
+ --chart-fg: #1b1b1b;
+ --chart-fg-muted: #565c65;
+ --chart-accent: #137480;
+ --chart-accent-fg: #ffffff;
+ --chart-border: #dfe1e2;
+ --chart-border-hover: #a9aeb1;
+ --chart-danger: #b50909;
+
+ --chart-radius-sm: 4px;
+ --chart-radius-md: 6px;
+
+ --chart-space-1: 4px;
+ --chart-space-2: 8px;
+ --chart-space-3: 12px;
+ --chart-space-4: 16px;
+ --chart-space-5: 24px;
+
+ --chart-font-body: 'Source Sans Pro', system-ui, sans-serif;
+ --chart-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
+ --chart-font-size-sm: 13px;
+ --chart-font-size-md: 14px;
+ --chart-font-size-lg: 16px;
+ --chart-font-size-xl: 24px;
+
+ --chart-shadow-pop: 0 2px 6px rgba(0, 0, 0, 0.18);
+}