Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
319 changes: 307 additions & 12 deletions apps/geolibre-desktop/src/components/panels/DashboardPanel.tsx

Large diffs are not rendered by default.

135 changes: 130 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,13 @@ 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);
// "list" widget fields (issue #1381).
const [listFields, setListFields] = useState<string[]>([]);
const [sortBy, setSortBy] = useState("");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const [limit, setLimit] = useState(20);
// "" means no custom color: fall back to the theme primary / palette.
const [color, setColor] = useState("");

Expand All @@ -88,6 +95,11 @@ export function WidgetEditorDialog({
setIndicatorAggregation(widget?.indicatorAggregation ?? "count");
setPrefix(widget?.prefix ?? "");
setSuffix(widget?.suffix ?? "");
setMultiple(widget?.multiple ?? false);
setListFields(widget?.listFields ?? []);
setSortBy(widget?.sortBy ?? "");
setSortDir(widget?.sortDir ?? "desc");
setLimit(widget?.limit ?? 20);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, widget]);

Expand All @@ -104,15 +116,21 @@ 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 isList = type === "list";
const canSave =
layerId !== "" &&
(isIndicator
? indicatorAggregation === "count" || hasNumeric
: isCategorical
? hasCategory && (aggregation === "count" || hasNumeric)
: type === "scatter"
? numericCols.length >= 2
: hasNumeric);
: isSelector
? hasCategory
: isList
? hasChartable
: 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 +169,19 @@ 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;
}
if (type === "list") {
// Ensure at least one column is selected; default to all available.
const allCols = [...numericCols, ...categoryCols];
const cols = listFields.filter((f) => allCols.includes(f));
next.listFields = cols.length > 0 ? cols : allCols.slice(0, 3);
if (sortBy) next.sortBy = sortBy;
if (sortDir !== "desc") next.sortDir = sortDir;
if (limit !== 20) next.limit = limit;
}
onSave(next);
onOpenChange(false);
};
Expand Down Expand Up @@ -229,6 +260,12 @@ 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>
<option value="list" disabled={!hasChartable}>
{t("dashboard.chartType.list")}
</option>
</Select>
</div>

Expand Down Expand Up @@ -335,6 +372,94 @@ 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 === "list" && (
<>
<div className="grid gap-1.5">
<Label>{t("dashboard.editor.listColumns")}</Label>
<div className="flex flex-wrap gap-1.5">
{[...numericCols, ...categoryCols].map((col) => {
const checked = listFields.includes(col);
return (
<label
key={col}
className="flex items-center gap-1 rounded border border-border px-2 py-0.5 text-xs"
>
<input
type="checkbox"
checked={checked}
onChange={(event) => {
if (event.target.checked) {
setListFields((prev) => [...prev, col]);
} else {
setListFields((prev) => prev.filter((f) => f !== col));
}
}}
/>
{col}
</label>
);
})}
</div>
</div>
<div className="flex flex-wrap items-end gap-3">
<FieldSelect
id="widget-list-sort"
label={t("dashboard.editor.sortBy")}
value={sortBy}
options={["", ...numericCols, ...categoryCols]}
onChange={setSortBy}
/>
<div className="grid gap-1.5">
<Label htmlFor="widget-list-sortdir">{t("dashboard.editor.sortDir")}</Label>
<Select
id="widget-list-sortdir"
className="w-28"
value={sortDir}
onChange={(event) => setSortDir(event.target.value as "asc" | "desc")}
>
<option value="asc">{t("dashboard.editor.ascending")}</option>
<option value="desc">{t("dashboard.editor.descending")}</option>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="widget-list-limit">{t("dashboard.editor.limit")}</Label>
<Input
id="widget-list-limit"
type="number"
className="w-20"
min={1}
max={500}
value={limit}
onChange={(event) => {
const v = Number(event.target.value);
if (Number.isFinite(v)) setLimit(Math.max(1, Math.trunc(v)));
}}
/>
</div>
</div>
</>
)}

{type === "indicator" && (
<>
<div className="grid gap-1.5">
Expand Down
14 changes: 13 additions & 1 deletion apps/geolibre-desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3247,6 +3247,9 @@
"noLayersHint": "Add a vector or DuckDB query layer first.",
"layerMissing": "Layer unavailable",
"noData": "This layer has no chartable attributes.",
"selectorMatches_one": "{{count}} of {{total}} feature",
"selectorMatches_other": "{{count}} of {{total}} features",
"selectorClear": "Clear",
"moveBack": "Move back",
"moveForward": "Move forward",
"editWidget": "Edit widget",
Expand All @@ -3258,7 +3261,9 @@
"line": "Line",
"box": "Box plot",
"pie": "Pie",
"indicator": "Indicator"
"indicator": "Indicator",
"selector": "Selector",
"list": "List"
},
"aggregate": {
"count": "Count",
Expand Down Expand Up @@ -3300,6 +3305,13 @@
"value": "Value",
"prefix": "Prefix",
"suffix": "Suffix",
"multiSelect": "Allow multiple selection",
"listColumns": "Columns",
"sortBy": "Sort by",
"sortDir": "Direction",
"ascending": "Ascending",
"descending": "Descending",
"limit": "Row limit",
"titleLabel": "Title",
"titlePlaceholder": "Optional title",
"color": "Color",
Expand Down
55 changes: 55 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,61 @@ 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));
}

/** One selector widget's active choice: a category field and the values picked
* from it. An empty `values` list means the selector is not filtering. */
export interface CategorySelection {
field: string;
values: string[];
}

/**
* Narrow rows to those matching every active selection. Values within one
* selection are OR-ed (a multi-select picking Africa and Asia keeps both), and
* separate selections are AND-ed (a continent selector and an income selector
* together keep only rows satisfying both). Selections with no values are
* ignored, so an untouched selector never hides anything.
*
* @param rows The rows to narrow.
* @param selections The active selections to apply.
* @returns The matching rows, or the original array when nothing is selected.
*/
export function filterRowsBySelections(
rows: ChartRow[],
selections: CategorySelection[],
): ChartRow[] {
const active = selections.filter((selection) => selection.values.length > 0);
if (active.length === 0) return rows;
// Compare against the same string form distinctCategoryValues produces, so a
// numeric or boolean category matches the chip the user actually clicked.
const matchers = active.map((selection) => ({
field: selection.field,
values: new Set(selection.values),
}));
return rows.filter((row) =>
matchers.every((matcher) => matcher.values.has(String(row.properties[matcher.field] ?? ""))),
);
}

/**
* 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
56 changes: 47 additions & 9 deletions packages/core/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,15 +753,25 @@ 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",
];
/** Upper bound for a persisted list-widget row limit, mirroring the widget
* editor's row-count input (`max={500}` in `WidgetEditorDialog`). */
const MAX_PERSISTED_LIST_ROWS = 500;

// 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,
list: true,
} satisfies Record<DashboardWidgetType, true>) as readonly DashboardWidgetType[];
const DASHBOARD_WIDGET_AGGREGATIONS: readonly DashboardWidgetAggregation[] = [
"count",
"sum",
Expand Down Expand Up @@ -846,6 +856,34 @@ 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.
// List widget fields (issue #1381). normalizeWidgets also runs on the save
// path (projectFromStore), so dropping these would blank a list widget the
// moment its project is saved — the renderer falls back to "no data"
// without listFields.
if (type === "list") {
if (Array.isArray(candidate.listFields)) {
const listFields = candidate.listFields
.map((entry) => normalizeString(entry).trim())
.filter((entry) => entry !== "");
if (listFields.length > 0) widget.listFields = listFields;
}
const sortBy = normalizeString(candidate.sortBy).trim();
if (sortBy) widget.sortBy = sortBy;
if (candidate.sortDir === "asc" || candidate.sortDir === "desc") {
widget.sortDir = candidate.sortDir;
}
if (typeof candidate.limit === "number" && Number.isFinite(candidate.limit)) {
// Clamp to the editor's range so a hand-edited 0 or 10_000 cannot reach
// the renderer.
const limit = Math.trunc(candidate.limit);
if (limit >= 1) widget.limit = Math.min(MAX_PERSISTED_LIST_ROWS, limit);
}
}
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 @@ -478,8 +478,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 @@ -1284,6 +1290,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
Loading
Loading