From 2891ec07ac75399f95f69370a1b55f3693290ba4 Mon Sep 17 00:00:00 2001 From: bgoniasa Date: Thu, 23 Jul 2026 04:29:46 +0000 Subject: [PATCH 1/4] feat(dashboard): add indicator widget type (issue #1381) Add a KPI indicator tile to the dashboard panel alongside the existing chart widgets (histogram, scatter, bar, line, box, pie). Changes: - Add 'indicator' to DashboardWidgetType in types.ts - Add IndicatorAggregation type: count, sum, mean, min, max, median - Add indicator fields to DashboardWidget: indicatorAggregation, prefix, suffix (field is reused from existing schema) - Implement computeIndicator() and formatIndicatorValue() in DashboardPanel with a centered big-number tile render - Add indicator option to WidgetEditorDialog with aggregation picker, field selector (hidden for count), and prefix/suffix inputs - Register 'indicator' in DASHBOARD_WIDGET_TYPES in normalizeWidgets - Add INDICATOR_AGGREGATIONS validation array in project.ts - Add i18n keys for indicator chart type and aggregations (en, es) - Add tests: indicator round-trip, count without field, invalid aggregation rejection The indicator widget is the first step toward richer dashboard elements. Cross-filtering and selector/list widgets will follow in subsequent PRs. --- .../src/components/panels/DashboardPanel.tsx | 113 +++++++++++++++--- .../components/panels/WidgetEditorDialog.tsx | 110 ++++++++++++++++- .../geolibre-desktop/src/i18n/locales/en.json | 13 +- .../geolibre-desktop/src/i18n/locales/es.json | 13 +- packages/core/src/project.ts | 21 ++++ packages/core/src/types.ts | 29 ++++- tests/dashboard-widgets.test.ts | 63 ++++++++++ 7 files changed, 338 insertions(+), 24 deletions(-) diff --git a/apps/geolibre-desktop/src/components/panels/DashboardPanel.tsx b/apps/geolibre-desktop/src/components/panels/DashboardPanel.tsx index f73359fde..c515983fc 100644 --- a/apps/geolibre-desktop/src/components/panels/DashboardPanel.tsx +++ b/apps/geolibre-desktop/src/components/panels/DashboardPanel.tsx @@ -1,4 +1,4 @@ -import type { DashboardWidget } from "@geolibre/core"; +import type { DashboardWidget, IndicatorAggregation } from "@geolibre/core"; import { MAX_DASHBOARD_COLUMNS, MIN_DASHBOARD_COLUMNS, useAppStore } from "@geolibre/core"; import { Button, Select } from "@geolibre/ui"; import { @@ -27,6 +27,51 @@ const DEFAULT_DASHBOARD_HEIGHT = 360; // fills and resizes with the panel height (issue #728). const MIN_DASHBOARD_ROW_HEIGHT = 200; +/** Compute the indicator value from layer data and an aggregation. Returns + * null when there is no numeric data to aggregate. Count works on any layer. */ +function computeIndicator( + rows: Record[], + field: string | undefined, + aggregation: IndicatorAggregation, +): number | null { + if (aggregation === "count") { + return rows.length; + } + if (!field) return null; + const values = rows + .map((r) => r[field]) + .filter((v): v is number => typeof v === "number" && !Number.isNaN(v)); + if (values.length === 0) return null; + switch (aggregation) { + case "sum": + return values.reduce((a, b) => a + b, 0); + case "mean": + return values.reduce((a, b) => a + b, 0) / values.length; + case "min": + return Math.min(...values); + case "max": + return Math.max(...values); + case "median": { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 !== 0 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2; + } + default: + return null; + } +} + +/** Format a number for display in the indicator tile. Large numbers get + * locale-aware grouping; small numbers keep up to 2 decimal places. */ +function formatIndicatorValue(value: number): string { + if (Number.isInteger(value)) { + return value.toLocaleString(); + } + return value.toLocaleString(undefined, { maximumFractionDigits: 2 }); +} + /** Turn a stored widget into the render-side {@link ChartSpec}. */ function widgetToSpec(widget: DashboardWidget): ChartSpec { return { @@ -351,6 +396,13 @@ function WidgetCard({ return `${t("dashboard.chartType.box")} · ${widget.field ?? ""}`; case "pie": return `${t("dashboard.chartType.pie")} · ${widget.category ?? ""}`; + case "indicator": { + const agg = widget.indicatorAggregation ?? "count"; + const aggLabel = t(`dashboard.indicatorAggregation.${agg}`); + return widget.field + ? `${aggLabel} · ${widget.field}` + : aggLabel; + } } }; const title = widget.title?.trim() || defaultWidgetTitle(); @@ -412,19 +464,52 @@ function WidgetCard({ - {/* [&>svg] targets ChartView's chart SVG, which is a direct DOM child - (React Fragments emit no nodes): it flexes to fill, min-h-0 lets it - shrink past its intrinsic aspect-ratio height, and preserveAspectRatio - letterboxes it. Update this if a chart ever wraps its SVG (issue #728). */} -
- {data.hasData ? ( - - ) : ( -

- {t("dashboard.noData")} -

- )} -
+ {/* Indicator widgets render a KPI tile instead of a chart. */} + {widget.type === "indicator" ? ( +
+ {(() => { + const agg = widget.indicatorAggregation ?? "count"; + const value = computeIndicator(data.rows, widget.field, agg); + if (value === null) { + return ( +

+ {t("dashboard.noData")} +

+ ); + } + const formatted = formatIndicatorValue(value); + const colorStyle = widget.color + ? { color: widget.color } + : undefined; + return ( + <> + + {widget.prefix ?? ""} + {formatted} + {widget.suffix ?? ""} + + + {t(`dashboard.indicatorAggregation.${agg}`)} + {widget.field ? ` · ${widget.field}` : ""} + + + ); + })()} +
+ ) : ( +
+ {data.hasData ? ( + + ) : ( +

+ {t("dashboard.noData")} +

+ )} +
+ )} ); } diff --git a/apps/geolibre-desktop/src/components/panels/WidgetEditorDialog.tsx b/apps/geolibre-desktop/src/components/panels/WidgetEditorDialog.tsx index 80e5b6caf..18ebcb8e2 100644 --- a/apps/geolibre-desktop/src/components/panels/WidgetEditorDialog.tsx +++ b/apps/geolibre-desktop/src/components/panels/WidgetEditorDialog.tsx @@ -1,4 +1,4 @@ -import type { DashboardWidget } from "@geolibre/core"; +import type { DashboardWidget, IndicatorAggregation } from "@geolibre/core"; import { Button, ColorField, @@ -65,6 +65,11 @@ export function WidgetEditorDialog({ const [aggregation, setAggregation] = useState("count"); const [valueField, setValueField] = useState(""); const [title, setTitle] = useState(""); + // "indicator" widget fields (issue #1381). + const [indicatorAggregation, setIndicatorAggregation] = + useState("count"); + const [prefix, setPrefix] = useState(""); + const [suffix, setSuffix] = useState(""); // "" means no custom color: fall back to the theme primary / palette. const [color, setColor] = useState(""); @@ -82,6 +87,9 @@ export function WidgetEditorDialog({ setValueField(widget?.valueField ?? ""); setTitle(widget?.title ?? ""); setColor(widget?.color ?? ""); + setIndicatorAggregation(widget?.indicatorAggregation ?? "count"); + setPrefix(widget?.prefix ?? ""); + setSuffix(widget?.suffix ?? ""); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, widget]); @@ -97,13 +105,16 @@ export function WidgetEditorDialog({ // sum/average rather than count); scatter needs two numeric fields so x and y // aren't forced to the same column; the rest need one numeric field. const isCategorical = type === "bar" || type === "pie"; + const isIndicator = type === "indicator"; const canSave = layerId !== "" && - (isCategorical - ? hasCategory && (aggregation === "count" || hasNumeric) - : type === "scatter" - ? numericCols.length >= 2 - : hasNumeric); + (isIndicator + ? indicatorAggregation === "count" || hasNumeric + : isCategorical + ? hasCategory && (aggregation === "count" || hasNumeric) + : type === "scatter" + ? numericCols.length >= 2 + : hasNumeric); const save = () => { if (!canSave) return; @@ -134,6 +145,16 @@ export function WidgetEditorDialog({ next.aggregation = agg; if (agg !== "count") next.valueField = pick(valueField, numericCols); } + if (type === "indicator") { + next.indicatorAggregation = indicatorAggregation; + if (indicatorAggregation !== "count") { + next.field = pick(field, numericCols); + } + const trimmedPrefix = prefix.trim(); + if (trimmedPrefix) next.prefix = trimmedPrefix; + const trimmedSuffix = suffix.trim(); + if (trimmedSuffix) next.suffix = trimmedSuffix; + } onSave(next); onOpenChange(false); }; @@ -207,6 +228,9 @@ export function WidgetEditorDialog({ + @@ -312,6 +336,80 @@ export function WidgetEditorDialog({ )} )} + + {type === "indicator" && ( + <> +
+ + +
+ {indicatorAggregation !== "count" && ( + + )} +
+
+ + setPrefix(event.target.value)} + /> +
+
+ + setSuffix(event.target.value)} + /> +
+
+ + )} )} diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index 4dbb512b7..213e75ab6 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -2675,13 +2675,22 @@ "bar": "Bar", "line": "Line", "box": "Box plot", - "pie": "Pie" + "pie": "Pie", + "indicator": "Indicator" }, "aggregate": { "count": "Count", "sum": "Sum", "mean": "Average" }, + "indicatorAggregation": { + "count": "Count", + "sum": "Sum", + "mean": "Average", + "min": "Minimum", + "max": "Maximum", + "median": "Median" + }, "chart": { "emptyNumeric": "No numeric values to plot.", "emptyScatter": "No rows have both fields set to a number.", @@ -2707,6 +2716,8 @@ "category": "Category", "aggregate": "Aggregate", "value": "Value", + "prefix": "Prefix", + "suffix": "Suffix", "titleLabel": "Title", "titlePlaceholder": "Optional title", "color": "Color", diff --git a/apps/geolibre-desktop/src/i18n/locales/es.json b/apps/geolibre-desktop/src/i18n/locales/es.json index 915a3bbec..1ca69e6e9 100644 --- a/apps/geolibre-desktop/src/i18n/locales/es.json +++ b/apps/geolibre-desktop/src/i18n/locales/es.json @@ -2669,13 +2669,22 @@ "bar": "Barras", "line": "Líneas", "box": "Diagrama de caja", - "pie": "Circular" + "pie": "Circular", + "indicator": "Indicador" }, "aggregate": { "count": "Recuento", "sum": "Suma", "mean": "Promedio" }, + "indicatorAggregation": { + "count": "Recuento", + "sum": "Suma", + "mean": "Promedio", + "min": "Mínimo", + "max": "Máximo", + "median": "Mediana" + }, "chart": { "emptyNumeric": "No hay valores numéricos que graficar.", "emptyScatter": "Ninguna fila tiene ambos campos establecidos como número.", @@ -2701,6 +2710,8 @@ "category": "Categoría", "aggregate": "Agregado", "value": "Valor", + "prefix": "Prefijo", + "suffix": "Sufijo", "titleLabel": "Título", "titlePlaceholder": "Título opcional", "color": "Color", diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 7e9f35f63..1068e92c0 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -15,6 +15,7 @@ import { type DashboardWidgetType, type GeoLibreLayer, type GeoLibreProject, + type IndicatorAggregation, type LayerGroup, type LayerStyle, type LegendConfig, @@ -663,12 +664,21 @@ const DASHBOARD_WIDGET_TYPES: readonly DashboardWidgetType[] = [ "line", "box", "pie", + "indicator", ]; const DASHBOARD_WIDGET_AGGREGATIONS: readonly DashboardWidgetAggregation[] = [ "count", "sum", "mean", ]; +const INDICATOR_AGGREGATIONS: readonly IndicatorAggregation[] = [ + "count", + "sum", + "mean", + "min", + "max", + "median", +]; /** * Coerce an untrusted (possibly hand-edited) `widgets` array into valid @@ -724,6 +734,17 @@ export function normalizeWidgets(value: unknown): DashboardWidget[] | null { } const valueField = normalizeString(candidate.valueField).trim(); if (valueField) widget.valueField = valueField; + // Indicator widget fields (issue #1381). + if ( + candidate.indicatorAggregation && + INDICATOR_AGGREGATIONS.includes(candidate.indicatorAggregation) + ) { + widget.indicatorAggregation = candidate.indicatorAggregation; + } + const prefix = normalizeString(candidate.prefix).trim(); + if (prefix) widget.prefix = prefix; + const suffix = normalizeString(candidate.suffix).trim(); + if (suffix) widget.suffix = suffix; widgets.push(widget); } return widgets.length > 0 ? widgets : null; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e6dd5b132..194c1622e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1456,8 +1456,16 @@ export const MAX_DASHBOARD_COLUMNS = 6; export const DEFAULT_DASHBOARD_COLUMNS = 2; /** The chart a {@link DashboardWidget} draws. Mirrors the attribute Charts - * panel's types so a widget reuses the same rendering. */ -export type DashboardWidgetType = "histogram" | "scatter" | "bar" | "line" | "box" | "pie"; + * panel's types so a widget reuses the same rendering. The `"indicator"` type + * is a non-chart KPI tile (issue #1381). */ +export type DashboardWidgetType = + | "histogram" + | "scatter" + | "bar" + | "line" + | "box" + | "pie" + | "indicator"; /** How a bar widget reduces its category groups. */ export type DashboardWidgetAggregation = "count" | "sum" | "mean"; @@ -1497,8 +1505,25 @@ export interface DashboardWidget { aggregation?: DashboardWidgetAggregation; /** Value field a bar chart's sum/mean reduces (ignored for `count`). */ valueField?: string; + /** Indicator widget: aggregation function for the KPI value (issue #1381). + * Extends bar aggregation with min, max, and median. */ + indicatorAggregation?: IndicatorAggregation; + /** Indicator widget: optional prefix (e.g. "€", "$"). */ + prefix?: string; + /** Indicator widget: optional suffix (e.g. " kg", " ha"). */ + suffix?: string; } +/** Aggregation functions for indicator widgets (issue #1381). Extends the bar + * widget aggregation with min, max, and median. */ +export type IndicatorAggregation = + | "count" + | "sum" + | "mean" + | "min" + | "max" + | "median"; + /** * What slice of a layer's styling a Style Manager entry captures (issue #1294). * diff --git a/tests/dashboard-widgets.test.ts b/tests/dashboard-widgets.test.ts index 25eb92454..1b5653892 100644 --- a/tests/dashboard-widgets.test.ts +++ b/tests/dashboard-widgets.test.ts @@ -117,6 +117,39 @@ describe("normalizeWidgets", () => { assert.equal(result?.find((w) => w.id === "b")?.aggregation, "mean"); }); + it("keeps an indicator widget with aggregation, prefix, and suffix", () => { + const widgets: DashboardWidget[] = [ + { + id: "ind-1", + layerId: "layer-a", + type: "indicator", + indicatorAggregation: "sum", + field: "area_ha", + prefix: "€", + suffix: " ha", + }, + ]; + assert.deepEqual(normalizeWidgets(widgets), widgets); + }); + + it("keeps a count indicator that needs no field", () => { + const result = normalizeWidgets([ + { id: "ind-c", layerId: "l", type: "indicator", indicatorAggregation: "count" }, + ] as never); + assert.equal(result?.[0].type, "indicator"); + assert.equal(result?.[0].indicatorAggregation, "count"); + }); + + it("drops an invalid indicator aggregation", () => { + const result = normalizeWidgets([ + { id: "ind-x", layerId: "l", type: "indicator", indicatorAggregation: "mode" }, + ] as never); + assert.equal( + "indicatorAggregation" in (result?.find((w) => w.id === "ind-x") ?? {}), + false, + ); + }); + it("returns null for a non-array or an all-invalid list", () => { assert.equal(normalizeWidgets(undefined), null); assert.equal(normalizeWidgets("nope"), null); @@ -146,6 +179,36 @@ describe("widgets in the project file", () => { assert.deepEqual(reparsed.widgets, widgets); }); + it("round-trips an indicator widget through serialize/parse", () => { + const widgets: DashboardWidget[] = [ + { + id: "ind-1", + layerId: "layer-a", + type: "indicator", + indicatorAggregation: "median", + field: "area_ha", + prefix: "€", + suffix: " ha", + title: "Mediana", + color: "#004D43", + }, + ]; + const project = projectFromStore({ + projectName: "Indicator", + mapView: { center: [0, 0], zoom: 2, bearing: 0, pitch: 0 }, + basemapStyleUrl: DEFAULT_BASEMAP, + basemapVisible: true, + basemapOpacity: 1, + layers: [], + preferences: createEmptyProject().preferences, + widgets, + metadata: {}, + }); + assert.deepEqual(project.widgets, widgets); + const reparsed = parseProject(serializeProject(project)); + assert.deepEqual(reparsed.widgets, widgets); + }); + it("persists a non-default column count and omits the default", () => { const base = { projectName: "Widgets", From 52932a07a9355b065d1067067ba77ab3f33c9c25 Mon Sep 17 00:00:00 2001 From: bgoniasa Date: Thu, 23 Jul 2026 04:33:38 +0000 Subject: [PATCH 2/4] fix: preserve whitespace in indicator prefix/suffix normalizeString does not trim, so removing the explicit .trim() call on prefix/suffix preserves intentional leading/trailing spaces like ' ha' or '$ '. --- package-lock.json | 40 ++---------------------------------- packages/core/src/project.ts | 6 ++++-- 2 files changed, 6 insertions(+), 40 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3f7140c80..73722f086 100644 --- a/package-lock.json +++ b/package-lock.json @@ -613,7 +613,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -2749,7 +2748,6 @@ "resolved": "https://registry.npmjs.org/@deck.gl/aggregation-layers/-/aggregation-layers-9.3.7.tgz", "integrity": "sha512-eRzddMlHuBBFeSfhBig5V/32psav5JLvRYjuMqHgcwWXKfanAcxsKBfDSA4tzwWpnDU4id9sfdGW1RDzbVgY5Q==", "license": "MIT", - "peer": true, "dependencies": { "@luma.gl/shadertools": "^9.3.3", "@math.gl/core": "^4.1.0", @@ -2989,7 +2987,6 @@ "resolved": "https://registry.npmjs.org/@developmentseed/morecantile/-/morecantile-0.7.0.tgz", "integrity": "sha512-m9tWDase9COlP/EZ2KK/ydraRU/5yfwvmF/y/2g/iFMFW1vYHQTW/8JYjmo84SiJXUifDDY2xoA3ErYlcWJctg==", "license": "MIT", - "peer": true, "dependencies": { "@developmentseed/affine": "^0.7.0" } @@ -3118,8 +3115,7 @@ "version": "0.5.4", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.4.tgz", "integrity": "sha512-yYZUyyXrHU7tPlCjwZQJ6hIG9DscdCCn7Uk0mYKwC1FeHX286AbcmFveMiRBEak8e9iPupjsoVImN3yJZVed2g==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-postgis": { "version": "0.2.4", @@ -3735,7 +3731,6 @@ "resolved": "https://registry.npmjs.org/@esri/arcgis-rest-portal/-/arcgis-rest-portal-4.10.3.tgz", "integrity": "sha512-o5iwxSDS2M8lwLkNtaJTKqyN3NHv2Ne5bL+7tfdcmmHukcGMuuSroRG/+IT3CXOS7gk9sYVdR0cMEPHs1lTWNQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -3751,7 +3746,6 @@ "resolved": "https://registry.npmjs.org/@esri/arcgis-rest-request/-/arcgis-rest-request-4.10.3.tgz", "integrity": "sha512-gBKSRC7L3cD1KX4fIleRiEKtzlvKjuDW5cYpDcDyQCn/Bw7bJzlGrlaMw87FQ+ReTZp9vlTYFXyrGMuUtR0dKw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@esri/arcgis-rest-fetch": "^4.10.3", "@esri/arcgis-rest-form-data": "^4.10.3", @@ -3880,7 +3874,6 @@ "resolved": "https://registry.npmjs.org/@geoman-io/maplibre-geoman-free/-/maplibre-geoman-free-0.8.4.tgz", "integrity": "sha512-nOLEpMtORSR0XceXfOHc0RGqrkCQo3719o1d1Bp64xzijxn37KvkxB7S3dwOFV+i3Z3MleaSGAcIjvszil2D3A==", "license": "MIT", - "peer": true, "dependencies": { "@turf/area": "^7.3.5", "@turf/bbox": "^7.3.5", @@ -5456,7 +5449,6 @@ "resolved": "https://registry.npmjs.org/@math.gl/polygon/-/polygon-4.1.0.tgz", "integrity": "sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==", "license": "MIT", - "peer": true, "dependencies": { "@math.gl/core": "4.1.0" } @@ -5487,7 +5479,6 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", - "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", @@ -5616,7 +5607,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -10665,7 +10655,6 @@ "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", @@ -11347,7 +11336,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -11480,7 +11468,6 @@ "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.2.0.tgz", "integrity": "sha512-Hxe6Agq26gQOM954qpzYSllJBPJl+e16U5CkfuMUhLrNba+5nKkttIVlflaovN6oaTratqMGAO8H5u/aNhmHWQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@types/node": "^25.2.0", "flatbuffers": "^25.1.24", @@ -11895,7 +11882,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", @@ -12173,7 +12159,6 @@ "resolved": "https://registry.npmjs.org/cog-tiler-wasm/-/cog-tiler-wasm-0.3.1.tgz", "integrity": "sha512-5mH36bBRrArqeCeCW3Gl2IhlWcU5g64eQRIfIsyo+XQwaBYfmjzuHJ36nbS7fCLUNk9khNUdgVWTU9C1zS33iw==", "license": "MIT", - "peer": true, "peerDependencies": { "geotiff": "^2.1.0 || ^3.0.0", "geotiff-geokeys-to-proj4": "^2024.4.13", @@ -13030,7 +13015,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -13101,7 +13085,6 @@ "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -13372,7 +13355,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -14124,7 +14106,6 @@ "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz", "integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==", "license": "MIT", - "peer": true, "dependencies": { "@petamoriken/float16": "^3.9.3", "lerc": "^3.0.0", @@ -14143,8 +14124,7 @@ "version": "2024.4.13", "resolved": "https://registry.npmjs.org/geotiff-geokeys-to-proj4/-/geotiff-geokeys-to-proj4-2024.4.13.tgz", "integrity": "sha512-Jgtm/lcPkgB44wCqQHaVQx5/fyhmiVDRUKQcI/vMolsED8/GRWBnn5qkQo/CgutQg9xkGzig21DY9Px9mFRdvg==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/geotiff/node_modules/lerc": { "version": "3.0.0", @@ -14927,7 +14907,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz", "integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -15036,7 +15015,6 @@ } ], "license": "MIT", - "peer": true, "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, @@ -18139,7 +18117,6 @@ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -18318,7 +18295,6 @@ "resolved": "https://registry.npmjs.org/proj4/-/proj4-2.20.9.tgz", "integrity": "sha512-GLBGqXaTcdWnppre3o1sMmy4DcMGSGq/ng+9k2MTNddarRK6SveINqlqYzi3xEXuy06ljY1TTrC6H9C4f360IQ==", "license": "MIT", - "peer": true, "dependencies": { "mgrs": "1.0.0", "wkt-parser": "^1.5.5" @@ -18530,7 +18506,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -18540,7 +18515,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -18941,7 +18915,6 @@ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -19936,7 +19909,6 @@ "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -20126,7 +20098,6 @@ "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -20390,7 +20361,6 @@ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -20663,7 +20633,6 @@ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", @@ -21067,7 +21036,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -21282,7 +21250,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -21455,7 +21422,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -21533,7 +21499,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -21596,7 +21561,6 @@ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", "license": "MIT", - "peer": true, "engines": { "node": ">=12.20.0" }, diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 1068e92c0..2f2809f55 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -741,9 +741,11 @@ export function normalizeWidgets(value: unknown): DashboardWidget[] | null { ) { widget.indicatorAggregation = candidate.indicatorAggregation; } - const prefix = normalizeString(candidate.prefix).trim(); + // Prefix/suffix are not trimmed: a leading/trailing space is intentional + // (e.g. " ha" or "$ "). + const prefix = normalizeString(candidate.prefix); if (prefix) widget.prefix = prefix; - const suffix = normalizeString(candidate.suffix).trim(); + const suffix = normalizeString(candidate.suffix); if (suffix) widget.suffix = suffix; widgets.push(widget); } From 461233c0cbfa6086736680c7c707d64edab3739b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:37:40 +0000 Subject: [PATCH 3/4] style: auto-format (ruff + oxfmt) [pre-commit.ci] --- .../src/components/panels/DashboardPanel.tsx | 21 ++++------------- .../components/panels/WidgetEditorDialog.tsx | 23 +++++-------------- packages/core/src/types.ts | 8 +------ tests/dashboard-widgets.test.ts | 5 +--- 4 files changed, 13 insertions(+), 44 deletions(-) diff --git a/apps/geolibre-desktop/src/components/panels/DashboardPanel.tsx b/apps/geolibre-desktop/src/components/panels/DashboardPanel.tsx index c515983fc..b9d1e4982 100644 --- a/apps/geolibre-desktop/src/components/panels/DashboardPanel.tsx +++ b/apps/geolibre-desktop/src/components/panels/DashboardPanel.tsx @@ -54,9 +54,7 @@ function computeIndicator( case "median": { const sorted = [...values].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 !== 0 - ? sorted[mid] - : (sorted[mid - 1] + sorted[mid]) / 2; + return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; } default: return null; @@ -399,9 +397,7 @@ function WidgetCard({ case "indicator": { const agg = widget.indicatorAggregation ?? "count"; const aggLabel = t(`dashboard.indicatorAggregation.${agg}`); - return widget.field - ? `${aggLabel} · ${widget.field}` - : aggLabel; + return widget.field ? `${aggLabel} · ${widget.field}` : aggLabel; } } }; @@ -472,21 +468,14 @@ function WidgetCard({ const value = computeIndicator(data.rows, widget.field, agg); if (value === null) { return ( -

- {t("dashboard.noData")} -

+

{t("dashboard.noData")}

); } const formatted = formatIndicatorValue(value); - const colorStyle = widget.color - ? { color: widget.color } - : undefined; + const colorStyle = widget.color ? { color: widget.color } : undefined; return ( <> - + {widget.prefix ?? ""} {formatted} {widget.suffix ?? ""} diff --git a/apps/geolibre-desktop/src/components/panels/WidgetEditorDialog.tsx b/apps/geolibre-desktop/src/components/panels/WidgetEditorDialog.tsx index 18ebcb8e2..d83a79984 100644 --- a/apps/geolibre-desktop/src/components/panels/WidgetEditorDialog.tsx +++ b/apps/geolibre-desktop/src/components/panels/WidgetEditorDialog.tsx @@ -66,8 +66,7 @@ export function WidgetEditorDialog({ const [valueField, setValueField] = useState(""); const [title, setTitle] = useState(""); // "indicator" widget fields (issue #1381). - const [indicatorAggregation, setIndicatorAggregation] = - useState("count"); + const [indicatorAggregation, setIndicatorAggregation] = useState("count"); const [prefix, setPrefix] = useState(""); const [suffix, setSuffix] = useState(""); // "" means no custom color: fall back to the theme primary / palette. @@ -228,9 +227,7 @@ export function WidgetEditorDialog({ - + @@ -348,14 +345,10 @@ export function WidgetEditorDialog({ className="w-32" value={indicatorAggregation} onChange={(event) => - setIndicatorAggregation( - event.target.value as IndicatorAggregation, - ) + setIndicatorAggregation(event.target.value as IndicatorAggregation) } > - + @@ -384,9 +377,7 @@ export function WidgetEditorDialog({ )}
- +
- + { const result = normalizeWidgets([ { id: "ind-x", layerId: "l", type: "indicator", indicatorAggregation: "mode" }, ] as never); - assert.equal( - "indicatorAggregation" in (result?.find((w) => w.id === "ind-x") ?? {}), - false, - ); + assert.equal("indicatorAggregation" in (result?.find((w) => w.id === "ind-x") ?? {}), false); }); it("returns null for a non-array or an all-invalid list", () => { From b793e45879820a1b39f7ee55080730f3707c8464 Mon Sep 17 00:00:00 2001 From: bgoniasa Date: Thu, 23 Jul 2026 04:50:37 +0000 Subject: [PATCH 4/4] fix: address CodeRabbit review feedback - Preserve prefix/suffix whitespace in editor save (was trimming) - Allow count indicator on layers without chartable columns - Use DashboardWidgetType instead of ChartType in editor state (ChartType does not include 'indicator') - Remove unused ChartType import Tests: 27/27 pass. --- .../components/panels/WidgetEditorDialog.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/geolibre-desktop/src/components/panels/WidgetEditorDialog.tsx b/apps/geolibre-desktop/src/components/panels/WidgetEditorDialog.tsx index d83a79984..a43d243f9 100644 --- a/apps/geolibre-desktop/src/components/panels/WidgetEditorDialog.tsx +++ b/apps/geolibre-desktop/src/components/panels/WidgetEditorDialog.tsx @@ -1,4 +1,4 @@ -import type { DashboardWidget, IndicatorAggregation } from "@geolibre/core"; +import type { DashboardWidget, DashboardWidgetType, IndicatorAggregation } from "@geolibre/core"; import { Button, ColorField, @@ -20,7 +20,6 @@ import { MIN_HISTOGRAM_BINS, numericColumns, type BarAggregation, - type ChartType, } from "../../lib/attribute-charts"; import { useLayerChartData } from "../../hooks/useLayerChartData"; @@ -56,7 +55,7 @@ export function WidgetEditorDialog({ }: WidgetEditorDialogProps) { const { t } = useTranslation(); const [layerId, setLayerId] = useState(""); - const [type, setType] = useState("histogram"); + const [type, setType] = useState("histogram"); const [field, setField] = useState(""); const [xField, setXField] = useState(""); const [yField, setYField] = useState(""); @@ -114,7 +113,6 @@ export function WidgetEditorDialog({ : type === "scatter" ? numericCols.length >= 2 : hasNumeric); - const save = () => { if (!canSave) return; const next: DashboardWidget = { @@ -149,10 +147,9 @@ export function WidgetEditorDialog({ if (indicatorAggregation !== "count") { next.field = pick(field, numericCols); } - const trimmedPrefix = prefix.trim(); - if (trimmedPrefix) next.prefix = trimmedPrefix; - const trimmedSuffix = suffix.trim(); - if (trimmedSuffix) next.suffix = trimmedSuffix; + // Preserve whitespace: prefix/suffix may have intentional spaces (" ha"). + if (prefix) next.prefix = prefix; + if (suffix) next.suffix = suffix; } onSave(next); onOpenChange(false); @@ -189,7 +186,7 @@ export function WidgetEditorDialog({
- {!hasChartable ? ( + {!hasChartable && type !== "indicator" ? (

{t("dashboard.editor.noFields")}

) : (
@@ -200,7 +197,7 @@ export function WidgetEditorDialog({ className="w-36" value={type} onChange={(event) => { - const nextType = event.target.value as ChartType; + const nextType = event.target.value as DashboardWidgetType; setType(nextType); // Pie has no "average"; drop a carried-over mean so the // select doesn't show a stale value with no matching option.