Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 89 additions & 5 deletions apps/geolibre-desktop/src/components/panels/DashboardPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import {
} from "lucide-react";
import { useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { numericValues, type ChartRow, type ChartType } from "../../lib/attribute-charts";
import {
distinctCategoryValues,
numericValues,
type ChartRow,
type ChartType,
} from "../../lib/attribute-charts";
import { isChartableLayer, useLayerChartData } from "../../hooks/useLayerChartData";
import { ChartView, computeChart, type ChartSpec } from "./charts/chart-view";
import { WidgetEditorDialog } from "./WidgetEditorDialog";
Expand Down Expand Up @@ -101,7 +106,7 @@ export function DashboardPanel() {
const setDashboardOpen = useAppStore((s) => s.setDashboardOpen);
const setDashboardColumns = useAppStore((s) => s.setDashboardColumns);
const addWidget = useAppStore((s) => s.addWidget);
const updateWidget = useAppStore((s) => s.updateWidget);
const replaceWidget = useAppStore((s) => s.replaceWidget);
const removeWidget = useAppStore((s) => s.removeWidget);
const moveWidget = useAppStore((s) => s.moveWidget);

Expand Down Expand Up @@ -194,8 +199,11 @@ export function DashboardPanel() {
};
const handleSave = (widget: DashboardWidget) => {
if (widgets.some((w) => w.id === widget.id)) {
const { id: _id, ...patch } = widget;
updateWidget(widget.id, patch);
// The editor hands back a complete record, so replace rather than merge:
// it omits the optional fields that were left empty, and merging would
// keep the previous title/color/prefix/suffix instead of clearing them.
const { id: _id, ...next } = widget;
replaceWidget(widget.id, next);
} else {
addWidget(widget);
}
Expand Down Expand Up @@ -375,7 +383,7 @@ function WidgetCard({
const data = useLayerChartData(widget.layerId);
const result = useMemo(
() =>
widget.type === "indicator"
widget.type === "indicator" || widget.type === "selector"
? null
: computeChart(data.rows, widgetToSpec(widget, widget.type)),
[data.rows, widget],
Expand Down Expand Up @@ -407,6 +415,8 @@ function WidgetCard({
const aggLabel = t(`dashboard.indicatorAggregation.${agg}`);
return widget.field ? `${aggLabel} · ${widget.field}` : aggLabel;
}
case "selector":
return `${t("dashboard.chartType.selector")} · ${widget.category ?? ""}`;
}
};
const title = widget.title?.trim() || defaultWidgetTitle();
Expand Down Expand Up @@ -496,6 +506,36 @@ function WidgetCard({
);
})()}
</div>
) : widget.type === "selector" ? (
<div className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-auto">
{(() => {
if (!widget.category || !data.hasData) {
return (
<p className="text-center text-xs text-muted-foreground">{t("dashboard.noData")}</p>
);
}
// Extract distinct values from the category field, sorted.
const cat = widget.category!;
const values = distinctCategoryValues(data.rows, cat);

if (values.length === 0) {
return (
<p className="text-center text-xs text-muted-foreground">{t("dashboard.noData")}</p>
);
}

// Keyed on the selector config so switching layer, field, or
// single/multi mode remounts with an empty selection instead of
// carrying over values that no longer apply.
return (
<SelectorValues
key={`${widget.layerId}:${cat}:${widget.multiple ?? false}`}
values={values}
multiple={widget.multiple ?? false}
/>
);
})()}
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col [&>svg]:min-h-0 [&>svg]:flex-1">
{data.hasData && result ? (
Expand All @@ -510,3 +550,47 @@ function WidgetCard({
</div>
);
}

/** Renders the selector widget body: a scrollable list of clickable value
* chips. In single mode clicking a value toggles it as the only selected value.
* In multi mode each chip toggles independently. Cross-filtering is not yet
* wired; this prepares the UI and selection state for it. */
function SelectorValues({ values, multiple }: { values: string[]; multiple: boolean }) {
const [selected, setSelected] = useState<Set<string>>(new Set());

const toggle = (value: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(value)) {
next.delete(value);
} else {
if (!multiple) next.clear();
next.add(value);
}
return next;
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
};

return (
<div className="flex flex-wrap gap-1.5">
{values.map((value) => {
const isSelected = selected.has(value);
return (
<button
key={value}
type="button"
aria-pressed={isSelected}
onClick={() => toggle(value)}
className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${
isSelected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-muted-foreground hover:border-primary/50"
}`}
>
{value}
</button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
})}
</div>
);
}
43 changes: 38 additions & 5 deletions apps/geolibre-desktop/src/components/panels/WidgetEditorDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export function WidgetEditorDialog({
const [indicatorAggregation, setIndicatorAggregation] = useState<IndicatorAggregation>("count");
const [prefix, setPrefix] = useState("");
const [suffix, setSuffix] = useState("");
// "selector" widget fields (issue #1381).
const [multiple, setMultiple] = useState(false);
// "" means no custom color: fall back to the theme primary / palette.
const [color, setColor] = useState("");

Expand All @@ -88,6 +90,7 @@ export function WidgetEditorDialog({
setIndicatorAggregation(widget?.indicatorAggregation ?? "count");
setPrefix(widget?.prefix ?? "");
setSuffix(widget?.suffix ?? "");
setMultiple(widget?.multiple ?? false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, widget]);

Expand All @@ -104,15 +107,18 @@ export function WidgetEditorDialog({
// aren't forced to the same column; the rest need one numeric field.
const isCategorical = type === "bar" || type === "pie";
const isIndicator = type === "indicator";
const isSelector = type === "selector";
const canSave =
layerId !== "" &&
(isIndicator
? indicatorAggregation === "count" || hasNumeric
: isCategorical
? hasCategory && (aggregation === "count" || hasNumeric)
: type === "scatter"
? numericCols.length >= 2
: hasNumeric);
: isSelector
? hasCategory
: isCategorical
? hasCategory && (aggregation === "count" || hasNumeric)
: type === "scatter"
? numericCols.length >= 2
: hasNumeric);
const save = () => {
if (!canSave) return;
const next: DashboardWidget = {
Expand Down Expand Up @@ -151,6 +157,10 @@ export function WidgetEditorDialog({
if (prefix) next.prefix = prefix;
if (suffix) next.suffix = suffix;
}
if (type === "selector") {
next.category = pick(category, categoryCols);
if (multiple) next.multiple = true;
}
onSave(next);
onOpenChange(false);
};
Expand Down Expand Up @@ -229,6 +239,9 @@ export function WidgetEditorDialog({
{t("dashboard.chartType.pie")}
</option>
<option value="indicator">{t("dashboard.chartType.indicator")}</option>
<option value="selector" disabled={!hasCategory}>
{t("dashboard.chartType.selector")}
</option>
</Select>
</div>

Expand Down Expand Up @@ -335,6 +348,26 @@ export function WidgetEditorDialog({
</>
)}

{type === "selector" && (
<>
<FieldSelect
id="widget-selector-category"
label={t("dashboard.editor.category")}
value={pick(category, categoryCols)}
options={categoryCols}
onChange={setCategory}
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={multiple}
onChange={(event) => setMultiple(event.target.checked)}
/>
{t("dashboard.editor.multiSelect")}
</label>
</>
)}

{type === "indicator" && (
<>
<div className="grid gap-1.5">
Expand Down
4 changes: 3 additions & 1 deletion apps/geolibre-desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3040,7 +3040,8 @@
"line": "Line",
"box": "Box plot",
"pie": "Pie",
"indicator": "Indicator"
"indicator": "Indicator",
"selector": "Selector"
},
"aggregate": {
"count": "Count",
Expand Down Expand Up @@ -3082,6 +3083,7 @@
"value": "Value",
"prefix": "Prefix",
"suffix": "Suffix",
"multiSelect": "Allow multiple selection",
"titleLabel": "Title",
"titlePlaceholder": "Optional title",
"color": "Color",
Expand Down
20 changes: 20 additions & 0 deletions apps/geolibre-desktop/src/lib/attribute-charts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,26 @@ export function toFiniteNumber(value: unknown): number | null {
return null;
}

/**
* The distinct non-empty values of a category field, sorted for display. Used
* by the selector widget to build its list of value chips.
*
* @param rows The rows to read, each carrying its own property bag.
* @param key The category field to collect values from.
* @returns The sorted distinct values, with blank, whitespace-only, and nullish
* entries dropped.
*/
export function distinctCategoryValues(rows: ChartRow[], key: string): string[] {
const values = new Set<string>();
for (const row of rows) {
// Keep the original spelling as the chip label, but treat an all-whitespace
// value as blank so it cannot render as an empty, unlabelled chip.
const value = String(row.properties[key] ?? "");
if (value.trim() !== "") values.add(value);
}
return Array.from(values).sort((a, b) => a.localeCompare(b));
}

/**
* Columns suitable for charting: a key counts as numeric when it has at least
* two finite-number values and those make up at least half of its non-null
Expand Down
28 changes: 19 additions & 9 deletions packages/core/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,15 +735,20 @@ const HEX_COLOR = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
* renderer's clamp (`MAX_HISTOGRAM_BINS` in the desktop app's chart helpers). */
const MAX_PERSISTED_BINS = 50;

const DASHBOARD_WIDGET_TYPES: readonly DashboardWidgetType[] = [
"histogram",
"scatter",
"bar",
"line",
"box",
"pie",
"indicator",
];
// Spelled as a Record so adding a member to DashboardWidgetType fails to
// compile until it is listed here. A plain array accepted a short list
// silently, and a type missing from it makes normalizeWidgets drop every widget
// of that type — which is how selector widgets vanished on save and reload.
const DASHBOARD_WIDGET_TYPES = Object.keys({
histogram: true,
scatter: true,
bar: true,
line: true,
box: true,
pie: true,
indicator: true,
selector: true,
} satisfies Record<DashboardWidgetType, true>) as readonly DashboardWidgetType[];
const DASHBOARD_WIDGET_AGGREGATIONS: readonly DashboardWidgetAggregation[] = [
"count",
"sum",
Expand Down Expand Up @@ -828,6 +833,11 @@ export function normalizeWidgets(value: unknown): DashboardWidget[] | null {
const suffix = normalizeString(candidate.suffix);
if (suffix) widget.suffix = suffix;
}
// Selector widget fields (issue #1381). Only a selector reads the flag, and
// false is the default, so persist it only when it is on.
if (type === "selector" && candidate.multiple === true) {
widget.multiple = true;
}
Comment thread
giswqs marked this conversation as resolved.
widgets.push(widget);
}
return widgets.length > 0 ? widgets : null;
Expand Down
16 changes: 15 additions & 1 deletion packages/core/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,8 +439,14 @@ export interface AppState {

/** Append a new dashboard widget. */
addWidget: (widget: DashboardWidget) => void;
/** Patch an existing dashboard widget by id (no-op if absent). */
/** Patch an existing dashboard widget by id (no-op if absent). Merges, so an
* omitted key keeps its current value; use replaceWidget to clear one. */
updateWidget: (id: string, patch: Partial<Omit<DashboardWidget, "id">>) => void;
/** Swap an existing dashboard widget for a complete new record, keeping its
* id and position (no-op if absent). Unlike updateWidget this does not merge,
* so fields the caller omits are cleared — what the widget editor needs to
* persist an emptied title, color, prefix, or suffix. */
replaceWidget: (id: string, widget: Omit<DashboardWidget, "id">) => void;
/** Remove a dashboard widget by id. */
removeWidget: (id: string) => void;
/** Move a widget to a new index, clamped into range, preserving the rest. */
Expand Down Expand Up @@ -1187,6 +1193,14 @@ export const useAppStore = create<AppState>()(
isDirty: true,
};
}),
replaceWidget: (id, widget) =>
set((s) => {
if (!s.widgets.some((w) => w.id === id)) return s;
return {
widgets: s.widgets.map((w) => (w.id === id ? { ...widget, id } : w)),
isDirty: true,
};
}),
removeWidget: (id) =>
set((s) => ({
widgets: s.widgets.filter((w) => w.id !== id),
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1520,7 +1520,8 @@ export type DashboardWidgetType =
| "line"
| "box"
| "pie"
| "indicator";
| "indicator"
| "selector";

/** How a bar widget reduces its category groups. */
export type DashboardWidgetAggregation = "count" | "sum" | "mean";
Expand Down Expand Up @@ -1567,6 +1568,8 @@ export interface DashboardWidget {
prefix?: string;
/** Indicator widget: optional suffix (e.g. " kg", " ha"). */
suffix?: string;
/** Selector widget: whether multiple values can be picked (default false). */
multiple?: boolean;
}

/** Aggregation functions for indicator widgets (issue #1381). Extends the bar
Expand Down
Loading
Loading