From 0ebc08e3381032ebf4a33c18c77fc1541006440e Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Tue, 12 May 2026 13:48:25 -0500 Subject: [PATCH 1/5] chartplugin:wip --- src/essence/Tools/Chart/ChartComponent.css | 166 +++++++++ src/essence/Tools/Chart/ChartComponent.tsx | 223 ++++++++++++ src/essence/Tools/Chart/ChartTool.js | 387 +++++++++++++++++++++ src/essence/Tools/Chart/chartHelpers.ts | 238 +++++++++++++ src/essence/Tools/Chart/config.json | 26 ++ 5 files changed, 1040 insertions(+) create mode 100644 src/essence/Tools/Chart/ChartComponent.css create mode 100644 src/essence/Tools/Chart/ChartComponent.tsx create mode 100644 src/essence/Tools/Chart/ChartTool.js create mode 100644 src/essence/Tools/Chart/chartHelpers.ts create mode 100644 src/essence/Tools/Chart/config.json diff --git a/src/essence/Tools/Chart/ChartComponent.css b/src/essence/Tools/Chart/ChartComponent.css new file mode 100644 index 000000000..ac529dcdc --- /dev/null +++ b/src/essence/Tools/Chart/ChartComponent.css @@ -0,0 +1,166 @@ +/* Chart plugin — scoped under .chart-tool. Theme tokens come from + * `--theme-*` custom properties; fallback values match the Disasters + * Portal Figma (light theme). When a future theme file defines + * `--theme-*` on :root, every value below picks it up automatically. */ + +.chart-tool { + display: flex; + flex-direction: column; + height: 100%; + background: var(--theme-bg, #ffffff); + color: var(--theme-text, #1b1b1b); + font-family: var(--theme-font-body, 'Source Sans Pro', sans-serif); + border: 1px solid var(--theme-border, #dfe1e2); + border-radius: var(--theme-radius-sm, 4px); +} + +.chart-tool__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--theme-space-3, 12px) var(--theme-space-4, 16px); + border-bottom: 1px solid var(--theme-border, #dfe1e2); +} + +.chart-tool__title { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--theme-text, #1b1b1b); +} + +.chart-tool__icon-btn { + background: transparent; + border: none; + color: var(--theme-muted, #565c65); + font-size: 1.25rem; + line-height: 1; + cursor: pointer; + padding: var(--theme-space-1, 4px); +} + +.chart-tool__icon-btn:hover { + color: var(--theme-text, #1b1b1b); +} + +.chart-tool__body { + flex: 1; + overflow-y: auto; + padding: var(--theme-space-4, 16px); + display: flex; + flex-direction: column; + gap: var(--theme-space-4, 16px); +} + +.chart-tool__exit { + width: 100%; + padding: var(--theme-space-2, 8px) var(--theme-space-3, 12px); + background: transparent; + color: var(--theme-accent, #0e7482); + border: 1px solid var(--theme-accent, #0e7482); + border-radius: var(--theme-radius-sm, 4px); + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; +} + +.chart-tool__exit:hover { + background: var(--theme-accent-soft, rgba(14, 116, 130, 0.08)); +} + +.chart-tool__select { + width: 100%; + padding: var(--theme-space-2, 8px) var(--theme-space-3, 12px); + background: var(--theme-bg, #ffffff); + color: var(--theme-text, #1b1b1b); + border: 1px solid var(--theme-border, #dfe1e2); + border-radius: var(--theme-radius-sm, 4px); + font-family: inherit; + font-size: 0.875rem; +} + +.chart-tool__summary { + display: flex; + flex-direction: column; + gap: var(--theme-space-2, 8px); +} + +.chart-tool__summary-label { + margin: 0; + font-size: 0.75rem; + font-weight: 400; + color: var(--theme-muted, #565c65); +} + +.chart-tool__summary-text { + margin: 0; + font-size: 0.875rem; + line-height: 1.45; + color: var(--theme-text, #1b1b1b); +} + +.chart-tool__card { + border: 1px solid var(--theme-border, #dfe1e2); + border-radius: var(--theme-radius-sm, 4px); + padding: var(--theme-space-3, 12px); + background: var(--theme-bg, #ffffff); +} + +.chart-tool__card-title { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--theme-text, #1b1b1b); +} + +.chart-tool__card-meta { + margin: var(--theme-space-1, 4px) 0 var(--theme-space-3, 12px); + font-size: 0.8125rem; + color: var(--theme-muted, #565c65); +} + +.chart-tool__card-canvas { + position: relative; + height: 160px; +} + +.chart-tool__placeholder { + margin: 0; + padding: var(--theme-space-6, 32px) var(--theme-space-4, 16px); + text-align: center; + color: var(--theme-muted, #565c65); + font-size: 0.875rem; +} + +.chart-tool__placeholder--error { + color: var(--theme-error, #b50909); +} + +/* Loading state */ +.chart-tool__loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--theme-space-3, 12px); + padding: var(--theme-space-6, 32px) var(--theme-space-4, 16px); +} + +.chart-tool__spinner { + width: 32px; + height: 32px; + border: 3px solid var(--theme-border, #dfe1e2); + border-top-color: var(--theme-accent, #0e7482); + border-radius: 50%; + animation: chart-tool-spin 800ms linear infinite; +} + +.chart-tool__loading-text { + margin: 0; + color: var(--theme-muted, #565c65); + font-size: 0.875rem; +} + +@keyframes chart-tool-spin { + to { transform: rotate(360deg); } +} diff --git a/src/essence/Tools/Chart/ChartComponent.tsx b/src/essence/Tools/Chart/ChartComponent.tsx new file mode 100644 index 000000000..6fa78c9ec --- /dev/null +++ b/src/essence/Tools/Chart/ChartComponent.tsx @@ -0,0 +1,223 @@ +import React, { useEffect, useRef } from 'react' +import ChartJS from 'chart.js/auto' +import './ChartComponent.css' + +export type ChartType = 'bar' | 'line' +export type ChartStatus = 'idle' | 'loading' | 'empty' | 'error' | 'ready' + +export interface ChartSpec { + title: string + unit?: string + type?: ChartType + data: { time: string | number; value: number }[] +} + +export interface LayerOption { + name: string + title?: string +} + +export interface ChartComponentProps { + status: ChartStatus + summary?: string + charts?: ChartSpec[] + layers?: LayerOption[] + selectedLayer?: string | null + errorMessage?: string + onSelectLayer?: (name: string) => void + onExit?: () => void + onClose?: () => void +} + +export function ChartComponent(props: ChartComponentProps) { + const charts = props.charts ?? [] + const layers = props.layers ?? [] + const showDropdown = layers.length > 1 + + return ( +
+
+

Analyze areas

+ {props.onClose && ( + + )} +
+ +
+ {props.status === 'ready' && ( + <> + + + {showDropdown && ( + + )} + + {props.summary && ( +
+

Analysis summary

+

{props.summary}

+
+ )} + + {charts.map((c, i) => ( + + ))} + + )} + + {props.status === 'loading' && ( +
+ + )} + + {props.status === 'idle' && ( +
+ Draw an Area of Interest to start analysis. +
+ )} + + {props.status === 'empty' && ( +
+ No active layer with timeseries analysis support. +
+ )} + + {props.status === 'error' && ( +
+ {props.errorMessage || 'Failed to load timeseries data.'} +
+ )} +
+
+ ) +} + +function Chart({ title, unit, type = 'bar', data }: ChartSpec) { + 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('--theme-accent', '#0e7482') + const grid = themeVar('--theme-border', '#dfe1e2') + const muted = themeVar('--theme-muted', '#565c65') + + chartRef.current = new ChartJS(canvas, { + type, + data: { + labels: data.map((p) => formatShortDate(p.time)), + datasets: [ + { + label: title, + data: data.map((p) => p.value), + backgroundColor: accent, + borderColor: accent, + borderWidth: type === 'line' ? 2 : 0, + borderRadius: type === 'bar' ? 2 : 0, + pointRadius: 0, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false }, tooltip: { intersect: false } }, + scales: { + x: { + ticks: { color: muted, maxTicksLimit: 6, autoSkip: true }, + grid: { display: false }, + }, + y: { + beginAtZero: true, + ticks: { color: muted }, + grid: { color: grid }, + }, + }, + }, + }) + + return () => { + chartRef.current?.destroy() + chartRef.current = null + } + }, [title, type, unit, data]) + + const meta = describeData(data, unit) + + return ( +
+

{title}

+ {meta &&

{meta}

} +
+ +
+
+ ) +} + +function describeData( + data: ChartSpec['data'], + unit?: string +): string { + if (!data.length) return '' + const first = formatTime(data[0].time) + const last = formatTime(data[data.length - 1].time) + const sum = data.reduce((s, p) => s + p.value, 0) + const mean = sum / data.length + const rounded = Math.abs(mean) >= 10 ? Math.round(mean) : Math.round(mean * 100) / 100 + const meanStr = unit ? `${rounded} ${unit}` : String(rounded) + return `${first} – ${last} Mean: ${meanStr}` +} + +function formatTime(t: string | number): string { + const d = new Date(t) + if (Number.isNaN(d.getTime())) return String(t) + return d.toLocaleString(undefined, { + month: 'short', + year: 'numeric', + timeZone: 'UTC', + }) +} + +function formatShortDate(t: string | number): string { + const d = new Date(t) + if (Number.isNaN(d.getTime())) return String(t) + return d.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }) +} + +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..c51ae34be --- /dev/null +++ b/src/essence/Tools/Chart/ChartTool.js @@ -0,0 +1,387 @@ +/** + * Chart plugin — MMGIS wrapper. + * + * Pluggable contract (see PLUGIN-DEVELOPMENT-GUIDE.md): + * + * pluginId: 'chart' + * + * Listens to: + * - plugin:aoi:analysisRequested { feature, layerName? } + * - plugin:aoi:analysisCancelled + * + * Requests (existing core handlers): + * - layers:getAll -> string[] (all layer UUIDs) + * - layers:getVisible -> { uuid: boolean } (visibility map) + * - layers:getConfig -> full layer config (incl. variables.analysis) + * - time:getStart -> ISO string + * - time:getEnd -> ISO string + * + * 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' +import { + bboxOf, + buildChartSpecs, + buildItemsUrl, + buildStatsUrl, + proxyifyForDev, + readAnalysisConfig, +} from './chartHelpers' + +const PLUGIN_ID = 'chart' + +/** + * Module-level bridge. + * + * `pre/tools.js` imports this file eagerly at app startup, so the + * subscription below runs before the user clicks any toolbar button. + * That lets AOI's emit reach Chart even when Chart's panel isn't open + * yet — the bridge auto-opens Chart by clicking its toolbar button and + * stashes the payload until `make()` runs and picks it up. + */ +let _chartToolInstance = null +let _pendingPayload = null + +function _bridgeAnalysisRequested(payload) { + if (_chartToolInstance && _chartToolInstance._root) { + _chartToolInstance._handleAnalysisRequest(payload) + return + } + _pendingPayload = payload + const btn = + typeof document !== 'undefined' && + document.getElementById('toolButtonChart') + if (btn) btn.click() +} + +function _bridgeAnalysisCancelled() { + _pendingPayload = null + if (_chartToolInstance && _chartToolInstance._root) { + _chartToolInstance._reset() + } +} + +function _subscribeBus() { + const api = typeof window !== 'undefined' ? window.mmgisAPI : null + if (!api?.on) return false + api.on('plugin:aoi:analysisRequested', _bridgeAnalysisRequested) + api.on('plugin:aoi:analysisCancelled', _bridgeAnalysisCancelled) + return true +} + +if (typeof window !== 'undefined') { + if (!_subscribeBus()) { + // mmgisAPI not yet bound — retry briefly, then stop. + const id = setInterval(() => { + if (_subscribeBus()) clearInterval(id) + }, 50) + setTimeout(() => clearInterval(id), 5000) + } +} + +const initialState = () => ({ + status: 'idle', + summary: '', + charts: [], + layers: [], + selectedLayer: null, + errorMessage: '', +}) + +const ChartTool = { + height: 400, + width: 360, + MMGISInterface: null, + _root: null, + _state: initialState(), + _cleanups: [], + _api: null, + _lastFeature: null, + + make(targetId) { + // ToolController_ passes 'toolContentSeparated_Chart' for separated tools. + // We mount our root into that container if it exists; otherwise fall back + // to body (used as a development safety net). + this.MMGISInterface = new interfaceWithMMGIS(this, targetId) + + this._api = + (typeof window !== 'undefined' && window.mmgisAPI?.forPlugin?.(PLUGIN_ID)) || + { emit: () => {}, provide: () => () => {} } + + // Bus subscriptions live at module level (see top of file) so AOI's + // emit can auto-open this panel even when it isn't currently mounted. + // Register this instance with the bridge. + _chartToolInstance = this + + this._render() + + // If a payload arrived before we mounted, process it now. + if (_pendingPayload) { + const payload = _pendingPayload + _pendingPayload = null + this._handleAnalysisRequest(payload) + } + }, + + destroy() { + this._cleanups.forEach((fn) => { + try { + fn() + } catch { + // intentionally swallow — destroy must remain idempotent + } + }) + this._cleanups = [] + + if (this._root) { + unmountComponentAtNode(this._root) + } + + if (this.MMGISInterface) { + this.MMGISInterface.separateFromMMGIS() + this.MMGISInterface = null + } + + this._root = null + + this._state = initialState() + this._lastFeature = null + this._api = null + }, + + getUrlString() { + return '' + }, + + _setState(patch) { + this._state = { ...this._state, ...patch } + this._render() + }, + + _render() { + if (!this._root) return + render( + React.createElement(ChartComponent, { + status: this._state.status, + summary: this._state.summary, + charts: this._state.charts, + layers: this._state.layers, + selectedLayer: this._state.selectedLayer, + errorMessage: this._state.errorMessage, + onSelectLayer: (name) => this._onSelectLayer(name), + onExit: () => this._onExit(), + onClose: () => this._onClose(), + }), + this._root + ) + }, + + async _handleAnalysisRequest(payload) { + const feature = payload?.feature + if (!feature) return + this._lastFeature = feature + + const layers = await this._getAnalyzableLayers() + if (!layers.length) { + this._setState({ + status: 'empty', + charts: [], + layers: [], + selectedLayer: null, + }) + return + } + + // Preserve selection if still valid; otherwise default to first. + let selectedLayer = this._state.selectedLayer + if (!selectedLayer || !layers.find((l) => l.name === selectedLayer)) { + selectedLayer = layers[0].name + } + + this._setState({ + status: 'loading', + layers, + selectedLayer, + charts: [], + errorMessage: '', + }) + + await this._fetchSelectedLayer() + }, + + async _onSelectLayer(name) { + if (name === this._state.selectedLayer) return + this._setState({ selectedLayer: name, status: 'loading' }) + if (this._lastFeature) await this._fetchSelectedLayer() + }, + + async _fetchSelectedLayer() { + const layer = this._state.layers.find( + (l) => l.name === this._state.selectedLayer + ) + if (!layer || !this._lastFeature) return + + const bbox = bboxOf(this._lastFeature.geometry) + if (!bbox) { + this._setState({ + status: 'error', + errorMessage: 'AOI geometry has no usable bounding box.', + }) + return + } + + try { + // Step 1 — list STAC items in the collection that intersect the bbox. + const itemsUrl = proxyifyForDev( + buildItemsUrl({ + baseUrl: layer.baseUrl, + collection: layer.collection, + bbox, + }) + ) + const itemsResp = await fetch(itemsUrl, { credentials: 'include' }) + if (!itemsResp.ok) { + throw new Error(`STAC items request failed (${itemsResp.status})`) + } + const itemsJson = await itemsResp.json() + const items = Array.isArray(itemsJson?.features) + ? itemsJson.features + : [] + + if (!items.length) { + this._setState({ + status: 'empty', + charts: [], + summary: layer.summary || '', + errorMessage: '', + }) + return + } + + // Step 2 — POST per-item statistics with the AOI geometry. + const body = JSON.stringify({ + type: 'Feature', + geometry: this._lastFeature.geometry, + properties: {}, + }) + const headers = { 'Content-Type': 'application/json' } + const pairs = await Promise.all( + items.map(async (item) => { + try { + const statsUrl = proxyifyForDev( + buildStatsUrl({ + baseUrl: layer.baseUrl, + collection: layer.collection, + itemId: item.id, + }) + ) + const resp = await fetch(statsUrl, { + method: 'POST', + headers, + body, + credentials: 'include', + }) + if (!resp.ok) return { item, stats: null } + return { item, stats: await resp.json() } + } catch { + return { item, stats: null } + } + }) + ) + + const charts = buildChartSpecs(pairs, layer.assets, layer.statistic) + this._setState({ + status: charts.length ? 'ready' : 'empty', + charts, + summary: layer.summary || '', + errorMessage: '', + }) + } catch (err) { + this._setState({ + status: 'error', + errorMessage: err?.message || 'Failed to fetch timeseries data.', + }) + } + }, + + async _getAnalyzableLayers() { + const api = window.mmgisAPI + if (!api?.request) return [] + try { + const [allNames, visible] = await Promise.all([ + api.request('layers:getAll'), + api.request('layers:getVisible'), + ]) + const names = Array.isArray(allNames) ? allNames : [] + const out = [] + for (const name of names) { + if (!visible?.[name]) continue + const cfg = await api.request('layers:getConfig', name) + const analysis = readAnalysisConfig(cfg, name) + if (analysis) out.push(analysis) + } + return out + } catch (err) { + console.warn('[Chart] layer enumeration failed', err) + return [] + } + }, + + _onExit() { + this._reset() + this._api?.emit('analysisExited', {}) + }, + + _reset() { + this._lastFeature = null + this._setState({ + status: 'idle', + summary: '', + charts: [], + layers: [], + selectedLayer: null, + errorMessage: '', + }) + }, + + _onClose() { + const btn = document.getElementById('toolButtonChart') + if (btn) btn.click() + }, +} + +function interfaceWithMMGIS(tool, targetId) { + // Prefer the container ToolController_ created for this separated tool + // (toolContentSeparated_Chart); fall back to body if missing. + const container = + (typeof targetId === 'string' && document.getElementById(targetId)) || + document.body + + const root = document.createElement('div') + root.className = 'chart-tool-host' + root.style.position = 'fixed' + root.style.top = '60px' + root.style.right = '16px' + root.style.width = '360px' + root.style.height = '600px' + root.style.maxHeight = 'calc(100vh - 80px)' + root.style.zIndex = '1005' + root.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.12)' + root.style.borderRadius = '4px' + root.style.overflow = 'hidden' + container.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..be96c68bc --- /dev/null +++ b/src/essence/Tools/Chart/chartHelpers.ts @@ -0,0 +1,238 @@ +/** + * Chart plugin — pure helpers. + * No MMGIS imports, no DOM mutation, no network. Inputs in, values out. + */ + +import type { ChartSpec } from './ChartComponent' + +export const DEFAULT_BASE_URL = 'https://dev.openveda.cloud/api' +export const DEFAULT_STATISTIC = 'mean' + +/** + * Mirror of Layers_.js:377-386's dev-only corsproxy wrap. In production + * this is a no-op; in dev, an absolute cross-origin URL is rewritten to + * `/corsproxy/` — the same path MMGIS already proxies + * tile requests through. + */ +export function proxyifyForDev(url: string): string { + if (typeof window === 'undefined') return url + if (process.env.NODE_ENV !== 'development') return url + try { + const parsed = new URL(url) + if (parsed.origin === window.location.origin) return url + } catch { + return url + } + const rootPath = + ((window as unknown as { mmgisglobal?: { ROOT_PATH?: string } }) + .mmgisglobal?.ROOT_PATH) || '' + return `${rootPath}/corsproxy/${url}` +} + +export interface AnalysisLayerConfig { + name: string + title: string + summary: string + collection: string + baseUrl: string + assets: string[] | null // null = auto-discover from first stats response + statistic: string +} + +/** + * Pull `variables.analysis.collection`-driven config from a layer config blob + * returned by `layers:getConfig`. Returns null if the layer has no analysis + * block (the plugin filters it out). + */ +export function readAnalysisConfig( + cfg: unknown, + layerName: string +): AnalysisLayerConfig | null { + if (!cfg || typeof cfg !== 'object') return null + const variables = (cfg as { variables?: unknown }).variables + if (!variables || typeof variables !== 'object') return null + const analysis = (variables as { analysis?: unknown }).analysis + if (!analysis || typeof analysis !== 'object') return null + const a = analysis as Record + const collection = typeof a.collection === 'string' ? a.collection : '' + if (!collection) return null + return { + name: layerName, + title: typeof a.title === 'string' ? a.title : layerName, + summary: typeof a.summary === 'string' ? a.summary : '', + collection, + baseUrl: typeof a.baseUrl === 'string' ? a.baseUrl : DEFAULT_BASE_URL, + assets: Array.isArray(a.assets) ? (a.assets as string[]) : null, + statistic: + typeof a.statistic === 'string' ? a.statistic : DEFAULT_STATISTIC, + } +} + +/** + * Compute axis-aligned bounding box of a GeoJSON geometry as + * `[minx, miny, maxx, maxy]` (lng/lat). Returns null for empty / unsupported. + */ +export function bboxOf(geometry: unknown): [number, number, number, number] | null { + if (!geometry || typeof geometry !== 'object') return null + const g = geometry as { type?: string; coordinates?: unknown } + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + const visit = (point: number[]) => { + const [x, y] = point + if (Number.isFinite(x) && Number.isFinite(y)) { + if (x < minX) minX = x + if (y < minY) minY = y + if (x > maxX) maxX = x + if (y > maxY) maxY = y + } + } + const walk = (coords: unknown, depth: number): void => { + if (!Array.isArray(coords)) return + if (depth === 0) { + visit(coords as number[]) + return + } + for (const c of coords) walk(c, depth - 1) + } + switch (g.type) { + case 'Point': + walk(g.coordinates, 0) + break + case 'MultiPoint': + case 'LineString': + walk(g.coordinates, 1) + break + case 'MultiLineString': + case 'Polygon': + walk(g.coordinates, 2) + break + case 'MultiPolygon': + walk(g.coordinates, 3) + break + default: + return null + } + if (!Number.isFinite(minX)) return null + return [minX, minY, maxX, maxY] +} + +/** + * Build the items-list URL for a STAC collection. + * Adds `bbox` and (optionally) a `datetime` range. + */ +export function buildItemsUrl(input: { + baseUrl: string + collection: string + bbox: [number, number, number, number] + start?: string + end?: string + limit?: number +}): string { + const params = new URLSearchParams() + params.set('bbox', input.bbox.join(',')) + if (input.start && input.end) { + params.set('datetime', `${input.start}/${input.end}`) + } + params.set('limit', String(input.limit ?? 200)) + return `${input.baseUrl}/stac/collections/${encodeURIComponent( + input.collection + )}/items?${params.toString()}` +} + +/** + * Build the per-item statistics URL for a STAC item in a collection. + */ +export function buildStatsUrl(input: { + baseUrl: string + collection: string + itemId: string +}): string { + return `${input.baseUrl}/raster/collections/${encodeURIComponent( + input.collection + )}/items/${encodeURIComponent(input.itemId)}/statistics` +} + +/** + * From an array of (item, stats-response) pairs, build one ChartSpec per + * asset. If `assetNames` is null, auto-discover assets from the keys of the + * first stats response's `properties.statistics`. + */ +export function buildChartSpecs( + pairs: Array<{ item: StacItem; stats: StatsResponse | null }>, + assetNames: string[] | null, + statistic: string +): ChartSpec[] { + if (!pairs.length) return [] + + const firstStats = pairs.find((p) => !!p.stats)?.stats ?? null + const discovered = firstStats + ? Object.keys(firstStats.properties?.statistics ?? {}) + : [] + const assets = + assetNames && assetNames.length ? assetNames : discovered + + const out: ChartSpec[] = [] + for (const asset of assets) { + const data: ChartSpec['data'] = [] + for (const { item, stats } of pairs) { + const props = item.properties ?? {} + const time = props.datetime ?? props.start_datetime ?? props.end_datetime + if (!time) continue + const value = readStat(stats, asset, statistic) + if (value == null || !Number.isFinite(value)) continue + data.push({ time, value }) + } + if (!data.length) continue + // STAC items aren't always ordered — sort by time ascending. + data.sort((a, b) => + String(a.time) < String(b.time) ? -1 : String(a.time) > String(b.time) ? 1 : 0 + ) + // Strip titiler's `_b` band-index suffix and uppercase for display. + const title = asset.replace(/_b\d+$/i, '').toUpperCase() + out.push({ title, data, type: 'bar' }) + } + return out +} + +interface StacItem { + id: string + properties?: { + datetime?: string + start_datetime?: string + end_datetime?: string + [key: string]: unknown + } +} + +interface StatsResponse { + properties?: { + statistics?: Record> + [key: string]: unknown + } +} + +/** + * Read `properties.statistics[asset][statistic]` from a stats response. + * VEDA sometimes suffixes asset names with `_b1` (band index) or `_1` — + * if the exact match is missing, try the first key that starts with `asset`. + */ +function readStat( + stats: StatsResponse | null, + asset: string, + statistic: string +): number | null { + const all = stats?.properties?.statistics + if (!all || typeof all !== 'object') return null + let entry = all[asset] + if (!entry) { + const match = Object.keys(all).find( + (k) => k === asset || k.startsWith(`${asset}_`) + ) + if (match) entry = all[match] + } + if (!entry) return null + const v = entry[statistic] + return typeof v === 'number' && Number.isFinite(v) ? v : null +} diff --git a/src/essence/Tools/Chart/config.json b/src/essence/Tools/Chart/config.json new file mode 100644 index 000000000..b6b361f95 --- /dev/null +++ b/src/essence/Tools/Chart/config.json @@ -0,0 +1,26 @@ +{ + "defaultIcon": "chart-bar", + "description": "View timeseries analysis for an Area of Interest. Listens for plugin:aoi:analysisRequested and renders one chart per asset of each analyzable raster layer.", + "descriptionFull": { + "title": "Renders a timeseries chart panel for an Area of Interest. Subscribes to plugin:aoi:analysisRequested on the mmgisAPI Event Bus, queries currently-visible layers via layers:getAll / layers:getVisible / layers:getConfig, and for each layer that declares variables.analysis.collection runs a two-step VEDA fetch: list STAC items intersecting the AOI, then POST per-item statistics with the AOI geometry. One chart card is rendered per asset.", + "example": { + "layerVariables": { + "analysis": { + "collection": "(str) STAC collection id, e.g. 'landsat-ndvi-daily'", + "baseUrl": "(str, optional) defaults to https://dev.openveda.cloud/api", + "title": "(str, optional) display name in the layer dropdown", + "summary": "(str, optional) summary paragraph shown above the charts", + "assets": "(str[], optional) which asset names to chart; if omitted, all assets in the first stats response are charted", + "statistic": "(str, optional) one of mean | median | min | max | std (default 'mean')" + } + } + } + }, + "hasVars": false, + "name": "Chart", + "toolbarPriority": 6, + "separatedTool": true, + "paths": { + "ChartTool": "essence/Tools/Chart/ChartTool" + } +} From a0902872433b1729cdcc4c0331d805e6f4f3fdd4 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Mon, 18 May 2026 12:54:09 -0500 Subject: [PATCH 2/5] chart plugin :wip --- src/essence/Tools/Chart/ChartComponent.tsx | 4 ++- src/essence/Tools/Chart/ChartTool.js | 29 ++++++++++++++++++++++ src/essence/Tools/Chart/chartHelpers.ts | 10 +++++++- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/essence/Tools/Chart/ChartComponent.tsx b/src/essence/Tools/Chart/ChartComponent.tsx index 6fa78c9ec..043612fd6 100644 --- a/src/essence/Tools/Chart/ChartComponent.tsx +++ b/src/essence/Tools/Chart/ChartComponent.tsx @@ -24,6 +24,7 @@ export interface ChartComponentProps { layers?: LayerOption[] selectedLayer?: string | null errorMessage?: string + emptyMessage?: string onSelectLayer?: (name: string) => void onExit?: () => void onClose?: () => void @@ -104,7 +105,8 @@ export function ChartComponent(props: ChartComponentProps) { {props.status === 'empty' && (
- No active layer with timeseries analysis support. + {props.emptyMessage || + 'No active layer with timeseries analysis support.'}
)} diff --git a/src/essence/Tools/Chart/ChartTool.js b/src/essence/Tools/Chart/ChartTool.js index c51ae34be..333491962 100644 --- a/src/essence/Tools/Chart/ChartTool.js +++ b/src/essence/Tools/Chart/ChartTool.js @@ -91,6 +91,7 @@ const initialState = () => ({ layers: [], selectedLayer: null, errorMessage: '', + emptyMessage: '', }) const ChartTool = { @@ -173,6 +174,7 @@ const ChartTool = { layers: this._state.layers, selectedLayer: this._state.selectedLayer, errorMessage: this._state.errorMessage, + emptyMessage: this._state.emptyMessage, onSelectLayer: (name) => this._onSelectLayer(name), onExit: () => this._onExit(), onClose: () => this._onClose(), @@ -190,6 +192,8 @@ const ChartTool = { if (!layers.length) { this._setState({ status: 'empty', + emptyMessage: + 'No active layer with timeseries analysis support.', charts: [], layers: [], selectedLayer: null, @@ -256,6 +260,7 @@ const ChartTool = { if (!items.length) { this._setState({ status: 'empty', + emptyMessage: 'No data found for this area.', charts: [], summary: layer.summary || '', errorMessage: '', @@ -272,12 +277,21 @@ const ChartTool = { const headers = { 'Content-Type': 'application/json' } const pairs = await Promise.all( items.map(async (item) => { + // titiler-pgstac requires `assets=` on POST /statistics. + // Prefer the layer's config; fall back to the asset keys + // declared on the STAC item itself. + const assetsForRequest = + layer.assets && layer.assets.length + ? layer.assets + : Object.keys(item.assets || {}) + if (!assetsForRequest.length) return { item, stats: null } try { const statsUrl = proxyifyForDev( buildStatsUrl({ baseUrl: layer.baseUrl, collection: layer.collection, itemId: item.id, + assets: assetsForRequest, }) ) const resp = await fetch(statsUrl, { @@ -294,9 +308,23 @@ const ChartTool = { }) ) + // If every POST failed, surface as an error — don't collapse to + // the same "empty" copy the config-missing case uses. + const failedCount = pairs.filter((p) => p.stats === null).length + if (failedCount === pairs.length) { + this._setState({ + status: 'error', + errorMessage: `Failed to fetch statistics for this area (${failedCount} of ${pairs.length} requests failed).`, + }) + return + } + const charts = buildChartSpecs(pairs, layer.assets, layer.statistic) this._setState({ status: charts.length ? 'ready' : 'empty', + emptyMessage: charts.length + ? '' + : 'No valid statistics found for this area.', charts, summary: layer.summary || '', errorMessage: '', @@ -346,6 +374,7 @@ const ChartTool = { layers: [], selectedLayer: null, errorMessage: '', + emptyMessage: '', }) }, diff --git a/src/essence/Tools/Chart/chartHelpers.ts b/src/essence/Tools/Chart/chartHelpers.ts index be96c68bc..998289311 100644 --- a/src/essence/Tools/Chart/chartHelpers.ts +++ b/src/essence/Tools/Chart/chartHelpers.ts @@ -143,15 +143,23 @@ export function buildItemsUrl(input: { /** * Build the per-item statistics URL for a STAC item in a collection. + * `assets` is appended as repeated `assets=` query params — titiler-pgstac + * needs to know which asset(s) to compute pixel statistics for. */ export function buildStatsUrl(input: { baseUrl: string collection: string itemId: string + assets: string[] }): string { + const params = new URLSearchParams() + for (const asset of input.assets) { + if (asset) params.append('assets', asset) + } + const qs = params.toString() return `${input.baseUrl}/raster/collections/${encodeURIComponent( input.collection - )}/items/${encodeURIComponent(input.itemId)}/statistics` + )}/items/${encodeURIComponent(input.itemId)}/statistics${qs ? `?${qs}` : ''}` } /** From 2abac5770e1035b3f24bfee6e4423a1d82e5712f Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Wed, 20 May 2026 18:33:36 -0500 Subject: [PATCH 3/5] chart plugin added histogram and stats --- src/essence/Tools/Chart/ChartComponent.css | 219 +++++++------ src/essence/Tools/Chart/ChartComponent.tsx | 281 ++++++++--------- src/essence/Tools/Chart/ChartTool.js | 347 ++------------------- src/essence/Tools/Chart/chartHelpers.ts | 304 +++++------------- 4 files changed, 376 insertions(+), 775 deletions(-) diff --git a/src/essence/Tools/Chart/ChartComponent.css b/src/essence/Tools/Chart/ChartComponent.css index ac529dcdc..3f3be1957 100644 --- a/src/essence/Tools/Chart/ChartComponent.css +++ b/src/essence/Tools/Chart/ChartComponent.css @@ -1,166 +1,193 @@ -/* Chart plugin — scoped under .chart-tool. Theme tokens come from - * `--theme-*` custom properties; fallback values match the Disasters - * Portal Figma (light theme). When a future theme file defines - * `--theme-*` on :root, every value below picks it up automatically. */ +/* 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%; - background: var(--theme-bg, #ffffff); - color: var(--theme-text, #1b1b1b); - font-family: var(--theme-font-body, 'Source Sans Pro', sans-serif); - border: 1px solid var(--theme-border, #dfe1e2); - border-radius: var(--theme-radius-sm, 4px); + 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(--theme-space-3, 12px) var(--theme-space-4, 16px); - border-bottom: 1px solid var(--theme-border, #dfe1e2); + padding: var(--chart-space-3) var(--chart-space-4); + border-bottom: 1px solid var(--chart-border); } .chart-tool__title { margin: 0; - font-size: 1rem; + display: inline-flex; + align-items: center; + gap: var(--chart-space-2); + font-size: var(--chart-font-size-lg); font-weight: 600; - color: var(--theme-text, #1b1b1b); } -.chart-tool__icon-btn { - background: transparent; - border: none; - color: var(--theme-muted, #565c65); - font-size: 1.25rem; +.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(--theme-space-1, 4px); + padding: var(--chart-space-1); } -.chart-tool__icon-btn:hover { - color: var(--theme-text, #1b1b1b); +.chart-tool__close:hover { + color: var(--chart-fg); } .chart-tool__body { - flex: 1; + flex: 1 1 auto; overflow-y: auto; - padding: var(--theme-space-4, 16px); + padding: var(--chart-space-4); display: flex; flex-direction: column; - gap: var(--theme-space-4, 16px); + 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(--theme-space-2, 8px) var(--theme-space-3, 12px); - background: transparent; - color: var(--theme-accent, #0e7482); - border: 1px solid var(--theme-accent, #0e7482); - border-radius: var(--theme-radius-sm, 4px); - font-size: 0.875rem; - font-weight: 600; + 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(--theme-accent-soft, rgba(14, 116, 130, 0.08)); + background: var(--chart-bg-muted); } -.chart-tool__select { - width: 100%; - padding: var(--theme-space-2, 8px) var(--theme-space-3, 12px); - background: var(--theme-bg, #ffffff); - color: var(--theme-text, #1b1b1b); - border: 1px solid var(--theme-border, #dfe1e2); - border-radius: var(--theme-radius-sm, 4px); - font-family: inherit; - font-size: 0.875rem; +.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__summary { +.chart-tool__card-header { display: flex; flex-direction: column; - gap: var(--theme-space-2, 8px); + gap: 2px; } -.chart-tool__summary-label { +.chart-tool__card-title { margin: 0; - font-size: 0.75rem; - font-weight: 400; - color: var(--theme-muted, #565c65); + 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__summary-text { +.chart-tool__card-empty { margin: 0; - font-size: 0.875rem; - line-height: 1.45; - color: var(--theme-text, #1b1b1b); + color: var(--chart-fg-muted); + font-size: var(--chart-font-size-sm); + font-style: italic; } -.chart-tool__card { - border: 1px solid var(--theme-border, #dfe1e2); - border-radius: var(--theme-radius-sm, 4px); - padding: var(--theme-space-3, 12px); - background: var(--theme-bg, #ffffff); +.chart-tool__headline { + display: flex; + flex-direction: column; + gap: 2px; } -.chart-tool__card-title { - margin: 0; - font-size: 0.9375rem; - font-weight: 600; - color: var(--theme-text, #1b1b1b); +.chart-tool__headline-label { + color: var(--chart-fg-muted); + font-size: var(--chart-font-size-sm); } -.chart-tool__card-meta { - margin: var(--theme-space-1, 4px) 0 var(--theme-space-3, 12px); - font-size: 0.8125rem; - color: var(--theme-muted, #565c65); +.chart-tool__headline-value { + font-size: var(--chart-font-size-xl); + font-weight: 600; + line-height: 1.1; } -.chart-tool__card-canvas { - position: relative; - height: 160px; +.chart-tool__headline-meta { + color: var(--chart-fg-muted); + font-size: var(--chart-font-size-sm); } -.chart-tool__placeholder { - margin: 0; - padding: var(--theme-space-6, 32px) var(--theme-space-4, 16px); - text-align: center; - color: var(--theme-muted, #565c65); - font-size: 0.875rem; +.chart-tool__histogram { + height: 120px; + width: 100%; } -.chart-tool__placeholder--error { - color: var(--theme-error, #b50909); +.chart-tool__stats { + margin: 0; + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: var(--chart-space-2); } -/* Loading state */ -.chart-tool__loading { +.chart-tool__stat { display: flex; flex-direction: column; - align-items: center; - justify-content: center; - gap: var(--theme-space-3, 12px); - padding: var(--theme-space-6, 32px) var(--theme-space-4, 16px); -} - -.chart-tool__spinner { - width: 32px; - height: 32px; - border: 3px solid var(--theme-border, #dfe1e2); - border-top-color: var(--theme-accent, #0e7482); - border-radius: 50%; - animation: chart-tool-spin 800ms linear infinite; + gap: 2px; } -.chart-tool__loading-text { +.chart-tool__stat-label { margin: 0; - color: var(--theme-muted, #565c65); - font-size: 0.875rem; + color: var(--chart-fg-muted); + font-size: var(--chart-font-size-sm); } -@keyframes chart-tool-spin { - to { transform: rotate(360deg); } +.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 index 043612fd6..a5df776d5 100644 --- a/src/essence/Tools/Chart/ChartComponent.tsx +++ b/src/essence/Tools/Chart/ChartComponent.tsx @@ -1,126 +1,151 @@ import React, { useEffect, useRef } from 'react' import ChartJS from 'chart.js/auto' +import './theme.css' import './ChartComponent.css' - -export type ChartType = 'bar' | 'line' -export type ChartStatus = 'idle' | 'loading' | 'empty' | 'error' | 'ready' - -export interface ChartSpec { - title: string - unit?: string - type?: ChartType - data: { time: string | number; value: number }[] -} - -export interface LayerOption { - name: string - title?: string -} +import { + AnalysisData, + AssetStats, + ResultCard, + cardsFromAnalysisData, + emptyLayers, + buildHistogramBins, + formatStat, + formatCount, + formatPercent, +} from './chartHelpers' export interface ChartComponentProps { - status: ChartStatus - summary?: string - charts?: ChartSpec[] - layers?: LayerOption[] - selectedLayer?: string | null - errorMessage?: string - emptyMessage?: string - onSelectLayer?: (name: string) => void - onExit?: () => void + analysisData?: AnalysisData | null onClose?: () => void + onExit?: () => void } -export function ChartComponent(props: ChartComponentProps) { - const charts = props.charts ?? [] - const layers = props.layers ?? [] - const showDropdown = layers.length > 1 +export function ChartComponent({ analysisData, onClose, onExit }: ChartComponentProps) { + const cards = cardsFromAnalysisData(analysisData) + const empties = emptyLayers(analysisData) + const isIdle = !analysisData return ( -
+
-

Analyze areas

- {props.onClose && ( +

+