diff --git a/app/src/app/api/widget-templates/__tests__/route.test.ts b/app/src/app/api/widget-templates/__tests__/route.test.ts
index 6b52e998..87774679 100644
--- a/app/src/app/api/widget-templates/__tests__/route.test.ts
+++ b/app/src/app/api/widget-templates/__tests__/route.test.ts
@@ -9,15 +9,14 @@ import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
// Mocks
// ---------------------------------------------------------------------------
-const mockRequireSession =
- vi.fn<
- () => Promise<{
- userId: string;
- role: string;
- canWrite: boolean;
- tenantId: string;
- }>
- >();
+const mockRequireSession = vi.fn<
+ () => Promise<{
+ userId: string;
+ role: string;
+ canWrite: boolean;
+ tenantId: string;
+ }>
+>();
const mockDb = {
select: vi.fn(),
diff --git a/app/src/components/__tests__/chart-error-boundary-unit.test.tsx b/app/src/components/__tests__/chart-error-boundary-unit.test.tsx
new file mode 100644
index 00000000..5fb5bcf5
--- /dev/null
+++ b/app/src/components/__tests__/chart-error-boundary-unit.test.tsx
@@ -0,0 +1,50 @@
+import { describe, it, expect, vi, afterAll } from "vitest";
+import { render, screen } from "@testing-library/react";
+import React from "react";
+import { ChartErrorBoundary } from "../chart-error-boundary";
+
+const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+
+function ThrowingChild(): React.JSX.Element {
+ throw new Error("test explosion");
+}
+
+function GoodChild() {
+ return
OK
;
+}
+
+describe("ChartErrorBoundary", () => {
+ afterAll(() => consoleError.mockRestore());
+
+ it("renders children when no error", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByTestId("good-child")).toBeDefined();
+ });
+
+ it("renders fallback UI when child throws", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText("Chart failed to render")).toBeDefined();
+ expect(screen.getByText("test explosion")).toBeDefined();
+ });
+
+ it("logs error with chart type", () => {
+ render(
+
+
+ ,
+ );
+ expect(consoleError).toHaveBeenCalledWith(
+ expect.stringContaining("[ChartErrorBoundary] sankey crashed:"),
+ expect.any(Error),
+ expect.anything(),
+ );
+ });
+});
diff --git a/app/src/components/__tests__/chart-error-boundary.test.tsx b/app/src/components/__tests__/chart-error-boundary.test.tsx
new file mode 100644
index 00000000..0c97351e
--- /dev/null
+++ b/app/src/components/__tests__/chart-error-boundary.test.tsx
@@ -0,0 +1,113 @@
+import { describe, it, expect, vi, afterAll } from "vitest";
+import { render, screen } from "@testing-library/react";
+import React from "react";
+
+// Mock @neoboard/components to avoid pulling in ECharts
+vi.mock("@neoboard/components", () => ({
+ Skeleton: ({ className }: { className?: string }) => (
+
+ ),
+ EmptyState: ({
+ title,
+ description,
+ }: {
+ title: string;
+ description?: string;
+ }) => (
+
+ {title}
+ {description && {description}}
+
+ ),
+ JsonViewer: () => ,
+ MarkdownWidget: () => ,
+ IframeWidget: () => ,
+}));
+
+// Mock next/dynamic to just render children synchronously
+vi.mock("next/dynamic", () => ({
+ default: () => {
+ return function DynamicStub() {
+ return ;
+ };
+ },
+}));
+
+vi.mock("@/lib/normalize-value", () => ({
+ normalizeValue: (v: unknown) => v,
+}));
+vi.mock("@/components/parameter-widget-renderer", () => ({
+ ParameterWidgetRenderer: () => ,
+}));
+vi.mock("@/components/graph-exploration-wrapper", () => ({
+ GraphExplorationWrapper: () => ,
+}));
+vi.mock("@/components/form-widget-renderer", () => ({
+ FormWidgetRenderer: () => ,
+}));
+
+// Suppress console.error from the error boundary during tests
+const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+
+import { ChartRenderer } from "../chart-renderer";
+
+describe("ChartRenderer error boundary", () => {
+ afterAll(() => {
+ consoleError.mockRestore();
+ });
+
+ it("renders fallback when a chart throws during render", () => {
+ // Force a render error by passing data that will cause JSON.stringify to throw
+ const circular: Record = {};
+ circular.self = circular;
+
+ // The table renderer will try to process this — but we need something
+ // that actually throws. Let's use a getter that throws.
+ const badData = [
+ new Proxy(
+ {},
+ {
+ get() {
+ throw new Error("Boom!");
+ },
+ ownKeys() {
+ throw new Error("Boom!");
+ },
+ },
+ ),
+ ];
+
+ render(
+ [0]["type"]}
+ data={badData}
+ />,
+ );
+
+ expect(screen.getByText("Chart failed to render")).toBeDefined();
+ expect(screen.getByText("Boom!")).toBeDefined();
+ });
+
+ it("renders chart normally when no error occurs", () => {
+ render(
+ [0]["type"]}
+ data={{ hello: "world" }}
+ />,
+ );
+
+ // JSON viewer should render (mocked)
+ expect(screen.getByTestId("json-viewer")).toBeDefined();
+ });
+
+ it("renders unknown chart type as empty state (not error boundary)", () => {
+ render(
+ [0]["type"]}
+ data={null}
+ />,
+ );
+
+ expect(screen.getByText("Unknown chart type")).toBeDefined();
+ });
+});
diff --git a/app/src/components/card-container.tsx b/app/src/components/card-container.tsx
index 1db9784c..55de5770 100644
--- a/app/src/components/card-container.tsx
+++ b/app/src/components/card-container.tsx
@@ -435,6 +435,7 @@ export function CardContainer({
type={chartConfig.type}
data={null}
settings={resolvedContentOptions}
+ meta={{ widgetId: widget.id }}
/>
diff --git a/app/src/components/chart-error-boundary.tsx b/app/src/components/chart-error-boundary.tsx
new file mode 100644
index 00000000..fd9ab924
--- /dev/null
+++ b/app/src/components/chart-error-boundary.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import React from "react";
+import { AlertCircle } from "lucide-react";
+
+interface ChartErrorBoundaryState {
+ error: Error | null;
+}
+
+/**
+ * Catches render errors from any chart component so one broken widget
+ * doesn't crash the entire dashboard.
+ */
+export class ChartErrorBoundary extends React.Component<
+ { chartType: string; children: React.ReactNode },
+ ChartErrorBoundaryState
+> {
+ state: ChartErrorBoundaryState = { error: null };
+
+ static getDerivedStateFromError(error: Error) {
+ return { error };
+ }
+
+ componentDidCatch(error: Error, info: React.ErrorInfo) {
+ console.error(
+ `[ChartErrorBoundary] ${this.props.chartType} crashed:`,
+ error,
+ info.componentStack,
+ );
+ }
+
+ render() {
+ if (this.state.error) {
+ return (
+
+
+
Chart failed to render
+
+ {this.state.error.message}
+
+
+ );
+ }
+ return this.props.children;
+ }
+}
diff --git a/app/src/components/chart-renderer.tsx b/app/src/components/chart-renderer.tsx
index a6b7a893..1514fc57 100644
--- a/app/src/components/chart-renderer.tsx
+++ b/app/src/components/chart-renderer.tsx
@@ -5,6 +5,7 @@ import dynamic from "next/dynamic";
import { AlertCircle } from "lucide-react";
import { normalizeValue } from "@/lib/normalize-value";
import type { ChartType } from "@/lib/chart-registry";
+import { ChartErrorBoundary } from "./chart-error-boundary";
import {
Skeleton,
EmptyState,
@@ -136,9 +137,20 @@ export interface ChartRendererProps {
/**
* Renders the appropriate chart component based on widget type and data.
- * Forwards chart-specific settings as props to the underlying chart component.
+ * Wrapped in an error boundary so one broken widget doesn't crash the dashboard.
*/
-export function ChartRenderer({
+export function ChartRenderer(props: ChartRendererProps) {
+ return (
+
+
+
+ );
+}
+
+function ChartRendererInner({
type,
data,
settings = {},
@@ -319,6 +331,8 @@ export function ChartRenderer({
: undefined
}
autoFit={autoFit}
+ stylingRules={stylingRules}
+ paramValues={paramValues}
/>
);
}
@@ -344,6 +358,8 @@ export function ChartRenderer({
})
: undefined
}
+ stylingRules={stylingRules}
+ paramValues={paramValues}
/>
);
}
diff --git a/app/src/lib/__tests__/chart-registry.test.ts b/app/src/lib/__tests__/chart-registry.test.ts
index 2e2a5c1b..c461c8ed 100644
--- a/app/src/lib/__tests__/chart-registry.test.ts
+++ b/app/src/lib/__tests__/chart-registry.test.ts
@@ -63,7 +63,7 @@ describe("getCompatibleChartTypes", () => {
it("postgresql result excludes only neo4j-only chart types", () => {
const allTypes = Object.keys(chartRegistry) as ChartType[];
const neo4jOnlyCount = allTypes.filter(
- (t) => !chartRegistry[t].compatibleWith?.includes("postgresql")
+ (t) => !chartRegistry[t].compatibleWith?.includes("postgresql"),
).length;
const result = getCompatibleChartTypes("postgresql");
expect(result).toHaveLength(allTypes.length - neo4jOnlyCount);
@@ -107,7 +107,10 @@ describe("chartRegistry compatibleWith field", () => {
it("every registry entry has a compatibleWith field", () => {
for (const [type, cfg] of Object.entries(chartRegistry)) {
- expect(cfg.compatibleWith, `${type} missing compatibleWith`).toBeDefined();
+ expect(
+ cfg.compatibleWith,
+ `${type} missing compatibleWith`,
+ ).toBeDefined();
expect(Array.isArray(cfg.compatibleWith)).toBe(true);
}
});
@@ -129,7 +132,9 @@ describe("chartRegistry compatibleWith field", () => {
it("single-value is compatible with both connector types", () => {
expect(chartRegistry["single-value"].compatibleWith).toContain("neo4j");
- expect(chartRegistry["single-value"].compatibleWith).toContain("postgresql");
+ expect(chartRegistry["single-value"].compatibleWith).toContain(
+ "postgresql",
+ );
});
it("map is compatible with both connector types", () => {
@@ -144,7 +149,9 @@ describe("chartRegistry compatibleWith field", () => {
it("parameter-select is compatible with both connector types", () => {
expect(chartRegistry["parameter-select"].compatibleWith).toContain("neo4j");
- expect(chartRegistry["parameter-select"].compatibleWith).toContain("postgresql");
+ expect(chartRegistry["parameter-select"].compatibleWith).toContain(
+ "postgresql",
+ );
});
});
@@ -455,10 +462,7 @@ describe("map transform", () => {
});
it("filters out records that have no numeric values", () => {
- const data = [
- { name: "text-only" },
- { lat: 10.0, lng: 20.0 },
- ];
+ const data = [{ name: "text-only" }, { lat: 10.0, lng: 20.0 }];
const result = transform(data) as Array>;
// "text-only" row filtered out; lat/lng row kept
expect(result).toHaveLength(1);
@@ -951,7 +955,10 @@ describe("transformToGraphData handles native number properties", () => {
},
},
];
- const result = transform(data) as { nodes: Record[]; edges: Record[] };
+ const result = transform(data) as {
+ nodes: Record[];
+ edges: Record[];
+ };
expect(result.nodes).toHaveLength(1);
const props = result.nodes[0].properties as Record;
expect(props.age).toBe(30);
@@ -974,7 +981,10 @@ describe("transformToGraphData handles native number properties", () => {
},
},
];
- const result = transform(data) as { nodes: Record[]; edges: Record[] };
+ const result = transform(data) as {
+ nodes: Record[];
+ edges: Record[];
+ };
expect(result.edges).toHaveLength(1);
const props = result.edges[0].properties as Record;
expect(props.weight).toBe(5);
@@ -992,7 +1002,11 @@ describe("chartSupportsStyling", () => {
},
);
- it.each(["graph", "map", "json", "parameter-select", "form"] as const)(
+ it.each(["graph", "map"] as const)("returns true for %s", (type) => {
+ expect(chartSupportsStyling(type)).toBe(true);
+ });
+
+ it.each(["json", "parameter-select", "form"] as const)(
"returns false for %s",
(type) => {
expect(chartSupportsStyling(type)).toBe(false);
@@ -1014,29 +1028,49 @@ describe("getStylingTargets", () => {
});
it("returns [color] for line", () => {
- expect(getStylingTargets("line")).toEqual([{ value: "color", label: "Color" }]);
+ expect(getStylingTargets("line")).toEqual([
+ { value: "color", label: "Color" },
+ ]);
});
it("returns [color] for pie", () => {
- expect(getStylingTargets("pie")).toEqual([{ value: "color", label: "Color" }]);
+ expect(getStylingTargets("pie")).toEqual([
+ { value: "color", label: "Color" },
+ ]);
});
it("returns color + backgroundColor for single-value", () => {
const targets = getStylingTargets("single-value");
expect(targets).toHaveLength(2);
expect(targets).toContainEqual({ value: "color", label: "Text Color" });
- expect(targets).toContainEqual({ value: "backgroundColor", label: "Background Color" });
+ expect(targets).toContainEqual({
+ value: "backgroundColor",
+ label: "Background Color",
+ });
});
it("returns backgroundColor + textColor for table", () => {
const targets = getStylingTargets("table");
expect(targets).toHaveLength(2);
- expect(targets).toContainEqual({ value: "backgroundColor", label: "Background Color" });
+ expect(targets).toContainEqual({
+ value: "backgroundColor",
+ label: "Background Color",
+ });
expect(targets).toContainEqual({ value: "textColor", label: "Text Color" });
});
- it("returns empty array for graph", () => {
- expect(getStylingTargets("graph")).toEqual([]);
+ it("returns node color target for graph", () => {
+ expect(getStylingTargets("graph")).toContainEqual({
+ value: "color",
+ label: "Node Color",
+ });
+ });
+
+ it("returns marker color target for map", () => {
+ expect(getStylingTargets("map")).toContainEqual({
+ value: "color",
+ label: "Marker Color",
+ });
});
it("returns empty array for unknown type", () => {
@@ -1076,7 +1110,9 @@ describe("markdown chart type", () => {
});
it("transformWithMapping returns null", () => {
- expect(chartRegistry.markdown.transformWithMapping([{ a: 1 }], {})).toBeNull();
+ expect(
+ chartRegistry.markdown.transformWithMapping([{ a: 1 }], {}),
+ ).toBeNull();
});
it("supportsClickAction is false", () => {
@@ -1118,7 +1154,9 @@ describe("iframe chart type", () => {
});
it("transformWithMapping returns null", () => {
- expect(chartRegistry.iframe.transformWithMapping([{ a: 1 }], {})).toBeNull();
+ expect(
+ chartRegistry.iframe.transformWithMapping([{ a: 1 }], {}),
+ ).toBeNull();
});
it("supportsClickAction is false", () => {
@@ -1163,7 +1201,9 @@ describe("gauge chart type", () => {
});
it("getStylingTargets returns Gauge Color target", () => {
- expect(getStylingTargets("gauge")).toEqual([{ value: "color", label: "Gauge Color" }]);
+ expect(getStylingTargets("gauge")).toEqual([
+ { value: "color", label: "Gauge Color" },
+ ]);
});
it("is included in compatible chart types for both connectors", () => {
@@ -1249,7 +1289,9 @@ describe("sankey chart type", () => {
});
it("getStylingTargets returns Link Color target", () => {
- expect(getStylingTargets("sankey")).toEqual([{ value: "color", label: "Link Color" }]);
+ expect(getStylingTargets("sankey")).toEqual([
+ { value: "color", label: "Link Color" },
+ ]);
});
it("is included in compatible chart types for both connectors", () => {
@@ -1266,7 +1308,10 @@ describe("sankey transform", () => {
{ source: "A", target: "B", value: 10 },
{ source: "B", target: "C", value: 5 },
];
- const result = transform(data) as { nodes: Array<{ name: string }>; links: Array<{ source: string; target: string; value: number }> };
+ const result = transform(data) as {
+ nodes: Array<{ name: string }>;
+ links: Array<{ source: string; target: string; value: number }>;
+ };
expect(result.nodes).toBeDefined();
expect(result.links).toBeDefined();
expect(result.links).toHaveLength(2);
@@ -1280,7 +1325,10 @@ describe("sankey transform", () => {
{ source: "A", target: "B", value: 10 },
{ source: "A", target: "C", value: 5 },
];
- const result = transform(data) as { nodes: Array<{ name: string }>; links: unknown[] };
+ const result = transform(data) as {
+ nodes: Array<{ name: string }>;
+ links: unknown[];
+ };
const nodeNames = result.nodes.map((n) => n.name);
expect(nodeNames.filter((n) => n === "A")).toHaveLength(1);
expect(nodeNames).toContain("B");
@@ -1301,7 +1349,10 @@ describe("sankey transform", () => {
it("handles postgresql { records } wrapper format", () => {
const data = { records: [{ source: "X", target: "Y", value: 3 }] };
- const result = transform(data) as { nodes: unknown[]; links: Array<{ value: number }> };
+ const result = transform(data) as {
+ nodes: unknown[];
+ links: Array<{ value: number }>;
+ };
expect(result.links[0].value).toBe(3);
});
@@ -1342,7 +1393,9 @@ describe("sunburst chart type", () => {
});
it("getStylingTargets returns Segment Color target", () => {
- expect(getStylingTargets("sunburst")).toEqual([{ value: "color", label: "Segment Color" }]);
+ expect(getStylingTargets("sunburst")).toEqual([
+ { value: "color", label: "Segment Color" },
+ ]);
});
it("is included in compatible chart types for both connectors", () => {
@@ -1355,8 +1408,14 @@ describe("sunburst transform", () => {
const { transform } = chartRegistry.sunburst;
it("passes through hierarchical data unchanged", () => {
- const data = [{ name: "Root", value: 100, children: [{ name: "Child", value: 50 }] }];
- const result = transform(data) as Array<{ name: string; value: number; children?: unknown[] }>;
+ const data = [
+ { name: "Root", value: 100, children: [{ name: "Child", value: 50 }] },
+ ];
+ const result = transform(data) as Array<{
+ name: string;
+ value: number;
+ children?: unknown[];
+ }>;
expect(result[0].name).toBe("Root");
expect(result[0].value).toBe(100);
expect(result[0].children).toHaveLength(1);
@@ -1368,7 +1427,10 @@ describe("sunburst transform", () => {
{ name: "A", parent: "root", value: 10 },
{ name: "B", parent: "root", value: 20 },
];
- const result = transform(data) as Array<{ name: string; children?: Array<{ name: string }> }>;
+ const result = transform(data) as Array<{
+ name: string;
+ children?: Array<{ name: string }>;
+ }>;
// Top-level nodes (no parent or parent is "")
expect(result.some((r) => r.name === "root")).toBe(true);
const rootNode = result.find((r) => r.name === "root");
@@ -1432,7 +1494,9 @@ describe("radar chart type", () => {
});
it("getStylingTargets returns Area Color target", () => {
- expect(getStylingTargets("radar")).toEqual([{ value: "color", label: "Area Color" }]);
+ expect(getStylingTargets("radar")).toEqual([
+ { value: "color", label: "Area Color" },
+ ]);
});
it("is included in compatible chart types for both connectors", () => {
@@ -1450,7 +1514,10 @@ describe("radar transform", () => {
{ indicator: "Strength", value: 60, max: 100 },
{ indicator: "Agility", value: 90, max: 100 },
];
- const result = transform(data) as { indicators: Array<{ name: string; max: number }>; series: Array<{ name: string; values: number[] }> };
+ const result = transform(data) as {
+ indicators: Array<{ name: string; max: number }>;
+ series: Array<{ name: string; values: number[] }>;
+ };
expect(result.indicators).toBeDefined();
expect(result.series).toBeDefined();
expect(result.indicators).toHaveLength(3);
@@ -1467,14 +1534,20 @@ describe("radar transform", () => {
{ indicator: "Speed", value: 70, max: 100, series: "Player B" },
{ indicator: "Strength", value: 85, max: 100, series: "Player B" },
];
- const result = transform(data) as { indicators: unknown[]; series: Array<{ name: string; values: number[] }> };
+ const result = transform(data) as {
+ indicators: unknown[];
+ series: Array<{ name: string; values: number[] }>;
+ };
expect(result.series).toHaveLength(2);
expect(result.series.map((s) => s.name)).toContain("Player A");
expect(result.series.map((s) => s.name)).toContain("Player B");
});
it("returns empty { indicators: [], series: [] } for empty input", () => {
- const result = transform([]) as { indicators: unknown[]; series: unknown[] };
+ const result = transform([]) as {
+ indicators: unknown[];
+ series: unknown[];
+ };
expect(result.indicators).toEqual([]);
expect(result.series).toEqual([]);
});
@@ -1487,13 +1560,19 @@ describe("radar transform", () => {
it("handles postgresql { records } wrapper format", () => {
const data = { records: [{ indicator: "X", value: 50, max: 100 }] };
- const result = transform(data) as { indicators: Array<{ name: string }>; series: unknown[] };
+ const result = transform(data) as {
+ indicators: Array<{ name: string }>;
+ series: unknown[];
+ };
expect(result.indicators[0].name).toBe("X");
});
it("auto-scales max from data when max column is missing (single indicator)", () => {
const data = [{ indicator: "Speed", value: 80 }];
- const result = transform(data) as { indicators: Array<{ name: string; max: number }>; series: unknown[] };
+ const result = transform(data) as {
+ indicators: Array<{ name: string; max: number }>;
+ series: unknown[];
+ };
// 80 * 1.1 = 88, ceil → 88
expect(result.indicators[0].max).toBe(88);
});
@@ -1506,7 +1585,10 @@ describe("radar transform", () => {
{ indicator: "WROTE", value: 10 },
{ indicator: "REVIEWED", value: 9 },
];
- const result = transform(data) as { indicators: Array<{ name: string; max: number }>; series: Array<{ values: number[] }> };
+ const result = transform(data) as {
+ indicators: Array<{ name: string; max: number }>;
+ series: Array<{ values: number[] }>;
+ };
// Global max: ceil(172 * 1.1) = 190
const globalMax = Math.ceil(172 * 1.1);
expect(result.indicators).toHaveLength(5);
@@ -1517,7 +1599,7 @@ describe("radar transform", () => {
// The shape should NOT be uniform — values differ significantly
const values = result.series[0].values;
expect(values[0]).toBe(172); // ACTED_IN
- expect(values[4]).toBe(9); // REVIEWED
+ expect(values[4]).toBe(9); // REVIEWED
});
it("preserves explicit max column values when provided", () => {
@@ -1525,14 +1607,19 @@ describe("radar transform", () => {
{ indicator: "Speed", value: 80, max: 200 },
{ indicator: "Strength", value: 40, max: 150 },
];
- const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
+ const result = transform(data) as {
+ indicators: Array<{ name: string; max: number }>;
+ };
expect(result.indicators[0].max).toBe(200);
expect(result.indicators[1].max).toBe(150);
});
it("uses global max for wide-format tabular data", () => {
const data = [{ Speed: 80, Strength: 60, Agility: 90 }];
- const result = transform(data) as { indicators: Array<{ name: string; max: number }>; series: Array<{ values: number[] }> };
+ const result = transform(data) as {
+ indicators: Array<{ name: string; max: number }>;
+ series: Array<{ values: number[] }>;
+ };
// Global max: ceil(90 * 1.1) = 99
const globalMax = Math.ceil(90 * 1.1);
for (const ind of result.indicators) {
@@ -1551,7 +1638,9 @@ describe("radar transform", () => {
{ indicator: "Strength", value: 60, max: undefined },
{ indicator: "Agility", value: 90, max: NaN },
];
- const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
+ const result = transform(data) as {
+ indicators: Array<{ name: string; max: number }>;
+ };
// globalMax: ceil(90 * 1.1) = 99
const globalMax = Math.ceil(90 * 1.1);
for (const ind of result.indicators) {
@@ -1564,7 +1653,9 @@ describe("radar transform", () => {
{ indicator: "Speed", value: 50, max: 0 },
{ indicator: "Strength", value: 30, max: 0 },
];
- const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
+ const result = transform(data) as {
+ indicators: Array<{ name: string; max: number }>;
+ };
// 0 is not a valid explicit max (not > 0), so globalMax is used
const globalMax = Math.ceil(50 * 1.1);
for (const ind of result.indicators) {
@@ -1577,7 +1668,9 @@ describe("radar transform", () => {
{ indicator: "Speed", value: 50, max: -100 },
{ indicator: "Strength", value: 30, max: -50 },
];
- const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
+ const result = transform(data) as {
+ indicators: Array<{ name: string; max: number }>;
+ };
const globalMax = Math.ceil(50 * 1.1);
for (const ind of result.indicators) {
expect(ind.max).toBe(globalMax);
@@ -1590,12 +1683,14 @@ describe("radar transform", () => {
{ indicator: "Strength", value: 60, max: null },
{ indicator: "Agility", value: 90, max: 150 },
];
- const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
+ const result = transform(data) as {
+ indicators: Array<{ name: string; max: number }>;
+ };
const globalMax = Math.ceil(90 * 1.1);
// Speed and Agility have valid explicit max; Strength falls back to globalMax
- expect(result.indicators[0].max).toBe(200); // Speed — explicit
+ expect(result.indicators[0].max).toBe(200); // Speed — explicit
expect(result.indicators[1].max).toBe(globalMax); // Strength — fallback
- expect(result.indicators[2].max).toBe(150); // Agility — explicit
+ expect(result.indicators[2].max).toBe(150); // Agility — explicit
});
it("falls back to globalMax when max column contains non-numeric strings", () => {
@@ -1603,7 +1698,9 @@ describe("radar transform", () => {
{ indicator: "Speed", value: 80, max: "not-a-number" },
{ indicator: "Strength", value: 60, max: "" },
];
- const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
+ const result = transform(data) as {
+ indicators: Array<{ name: string; max: number }>;
+ };
const globalMax = Math.ceil(80 * 1.1);
for (const ind of result.indicators) {
expect(ind.max).toBe(globalMax);
@@ -1615,7 +1712,9 @@ describe("radar transform", () => {
{ indicator: "Speed", value: 80, max: Infinity },
{ indicator: "Strength", value: 60, max: -Infinity },
];
- const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
+ const result = transform(data) as {
+ indicators: Array<{ name: string; max: number }>;
+ };
const globalMax = Math.ceil(80 * 1.1);
for (const ind of result.indicators) {
expect(ind.max).toBe(globalMax);
@@ -1653,7 +1752,9 @@ describe("treemap chart type", () => {
});
it("getStylingTargets returns Block Color target", () => {
- expect(getStylingTargets("treemap")).toEqual([{ value: "color", label: "Block Color" }]);
+ expect(getStylingTargets("treemap")).toEqual([
+ { value: "color", label: "Block Color" },
+ ]);
});
it("is included in compatible chart types for both connectors", () => {
@@ -1682,7 +1783,11 @@ describe("treemap transform", () => {
const data = [
{ name: "Root", value: 100, children: [{ name: "Child", value: 50 }] },
];
- const result = transform(data) as Array<{ name: string; value: number; children?: unknown[] }>;
+ const result = transform(data) as Array<{
+ name: string;
+ value: number;
+ children?: unknown[];
+ }>;
expect(result[0].children).toHaveLength(1);
});
@@ -1691,7 +1796,10 @@ describe("treemap transform", () => {
{ name: "root", parent: "", value: 0 },
{ name: "A", parent: "root", value: 10 },
];
- const result = transform(data) as Array<{ name: string; children?: unknown[] }>;
+ const result = transform(data) as Array<{
+ name: string;
+ children?: unknown[];
+ }>;
const rootNode = result.find((r) => r.name === "root");
expect(rootNode?.children).toBeDefined();
});
@@ -1729,4 +1837,3 @@ describe("treemap transform", () => {
expect(result).toEqual(transform(data));
});
});
-
diff --git a/app/src/lib/__tests__/widget-actions.test.ts b/app/src/lib/__tests__/widget-actions.test.ts
index d2acb7ba..5c2dd4ea 100644
--- a/app/src/lib/__tests__/widget-actions.test.ts
+++ b/app/src/lib/__tests__/widget-actions.test.ts
@@ -184,7 +184,7 @@ describe("buildStylingConfigFromEditor", () => {
expect(
buildStylingConfigFromEditor({
stylingEnabled: true,
- chartType: "graph",
+ chartType: "json",
stylingRules: [sampleRule],
}),
).toBeUndefined();
diff --git a/app/src/lib/chart-registry.ts b/app/src/lib/chart-registry.ts
index 548a82e4..cdaa7b14 100644
--- a/app/src/lib/chart-registry.ts
+++ b/app/src/lib/chart-registry.ts
@@ -741,7 +741,7 @@ export const chartRegistry: Record = {
transformWithMapping: transformToGraphData,
validate: validateGraphData,
compatibleWith: ["neo4j"],
- supportsStyling: false,
+ stylingTargets: [{ value: "color", label: "Node Color" }],
},
map: {
type: "map",
@@ -750,7 +750,7 @@ export const chartRegistry: Record = {
transformWithMapping: transformToMapData,
validate: validateMapData,
compatibleWith: ["neo4j", "postgresql"],
- supportsStyling: false,
+ stylingTargets: [{ value: "color", label: "Marker Color" }],
},
json: {
type: "json",
diff --git a/app/src/stores/__tests__/widget-editor-store.test.ts b/app/src/stores/__tests__/widget-editor-store.test.ts
index 7dd67629..4a6ca8fd 100644
--- a/app/src/stores/__tests__/widget-editor-store.test.ts
+++ b/app/src/stores/__tests__/widget-editor-store.test.ts
@@ -44,7 +44,7 @@ describe("widget-editor-store", () => {
it("disables styling for unsupported types", () => {
getState().setStylingEnabled(true);
- getState().setChartType("graph"); // doesn't support styling
+ getState().setChartType("json"); // doesn't support styling
expect(getState().stylingEnabled).toBe(false);
});
});
@@ -193,7 +193,7 @@ describe("widget-editor-store", () => {
});
it("returns undefined for unsupported chart types", () => {
- getState().setChartType("graph");
+ getState().setChartType("json");
getState().setStylingEnabled(true);
expect(getState().buildStylingConfig()).toBeUndefined();
});
diff --git a/component/src/charts/__tests__/base-chart.test.tsx b/component/src/charts/__tests__/base-chart.test.tsx
index 44a40b39..fe4417b6 100644
--- a/component/src/charts/__tests__/base-chart.test.tsx
+++ b/component/src/charts/__tests__/base-chart.test.tsx
@@ -1,5 +1,5 @@
-import { render, screen } from "@testing-library/react";
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, act } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { BaseChart } from "../base-chart";
// echarts/charts, echarts/components, echarts/renderers are mocked globally
@@ -168,7 +168,12 @@ describe("BaseChart", () => {
});
it("uses default palette colors when colorPalette is 'deep-ocean'", () => {
- render();
+ render(
+ ,
+ );
// deep-ocean triggers the default CSS-var path (same as unset)
expect(mockSetOption).toHaveBeenCalledWith(
expect.objectContaining({
@@ -179,7 +184,12 @@ describe("BaseChart", () => {
});
it("overrides colors with warm-sunset palette when colorPalette is set", () => {
- render();
+ render(
+ ,
+ );
expect(mockSetOption).toHaveBeenCalledWith(
expect.objectContaining({
// warm-sunset first color is tomato red
@@ -224,15 +234,41 @@ describe("BaseChart", () => {
expect(call.dataZoom).toBeDefined();
expect(Array.isArray(call.dataZoom)).toBe(true);
expect(call.dataZoom).toEqual(
- expect.arrayContaining([
- expect.objectContaining({ type: "inside" }),
- ]),
+ expect.arrayContaining([expect.objectContaining({ type: "inside" })]),
);
});
it("does not inject dataZoom when enableDataZoom is false", () => {
- render();
+ render(
+ ,
+ );
const call = mockSetOption.mock.calls[0][0];
expect(call.dataZoom).toBeUndefined();
});
+
+ // --- Dark mode via events ---
+
+ describe("dark mode reactivity", () => {
+ afterEach(() => {
+ document.documentElement.classList.remove("dark");
+ });
+
+ it("re-renders chart when neoboard-theme-change event fires", () => {
+ const onReady = vi.fn();
+ render();
+ const callsBefore = onReady.mock.calls.length;
+
+ // Simulate app theme toggle: add dark class + fire custom event
+ act(() => {
+ document.documentElement.classList.add("dark");
+ globalThis.dispatchEvent(new Event("neoboard-theme-change"));
+ });
+
+ // Chart should reinitialize (new onChartReady call)
+ expect(onReady.mock.calls.length).toBeGreaterThan(callsBefore);
+ });
+ });
});
diff --git a/component/src/charts/__tests__/graph-chart.test.tsx b/component/src/charts/__tests__/graph-chart.test.tsx
index 435e97bb..fa10fabd 100644
--- a/component/src/charts/__tests__/graph-chart.test.tsx
+++ b/component/src/charts/__tests__/graph-chart.test.tsx
@@ -8,10 +8,20 @@
* - Click callback wiring
* - Layout mapping
*/
-import { render, screen, cleanup, fireEvent, waitFor, act } from "@testing-library/react";
+import {
+ render,
+ screen,
+ cleanup,
+ fireEvent,
+ waitFor,
+ act,
+} from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { GraphChart } from "../graph-chart";
-import type { Node as NvlNode, Relationship as NvlRelationship } from "@neo4j-nvl/base";
+import type {
+ Node as NvlNode,
+ Relationship as NvlRelationship,
+} from "@neo4j-nvl/base";
/** Capture the last set of props passed to InteractiveNvlWrapper */
let capturedProps: Record = {};
@@ -102,7 +112,9 @@ describe("GraphChart", () => {
});
it("maps 'force' layout to 'forceDirected'", () => {
- render();
+ render(
+ ,
+ );
expect(capturedProps.layout).toBe("forceDirected");
});
@@ -115,7 +127,12 @@ describe("GraphChart", () => {
it("seeds layout state from initialLayout prop", () => {
render(
- ,
+ ,
);
expect(capturedProps.layout).toBe("circular");
});
@@ -123,9 +140,15 @@ describe("GraphChart", () => {
it("fires onLayoutChange when layout is changed", async () => {
const onLayoutChange = vi.fn();
render(
- ,
+ ,
);
- fireEvent.change(screen.getByLabelText("Graph layout"), { target: { value: "circular" } });
+ fireEvent.change(screen.getByLabelText("Graph layout"), {
+ target: { value: "circular" },
+ });
expect(onLayoutChange).toHaveBeenCalledWith("circular");
});
@@ -195,7 +218,12 @@ describe("GraphChart", () => {
it("explicit node.color takes precedence over label-derived color", () => {
const nodes = [
- { id: "p1", labels: ["Person"], properties: { name: "Alice" }, color: "#custom" },
+ {
+ id: "p1",
+ labels: ["Person"],
+ properties: { name: "Alice" },
+ color: "#custom",
+ },
];
render();
const nvlNodes = capturedProps.nodes as NvlNode[];
@@ -227,7 +255,9 @@ describe("GraphChart", () => {
// --- Pinned nodes ---
it("maps fixed=true to NVL pinned=true", () => {
- const pinnedNodes = [{ id: "1", label: "Fixed", fixed: true, x: 100, y: 200 }];
+ const pinnedNodes = [
+ { id: "1", label: "Fixed", fixed: true, x: 100, y: 200 },
+ ];
render();
const nvlNodes = capturedProps.nodes as NvlNode[];
expect(nvlNodes[0].pinned).toBe(true);
@@ -240,7 +270,11 @@ describe("GraphChart", () => {
it("wires onNodeClick to toggle node selection", () => {
const onNodeSelect = vi.fn();
render(
- ,
+ ,
);
const callbacks = capturedProps.mouseEventCallbacks as {
onNodeClick?: (node: { id: string }) => void;
@@ -279,7 +313,11 @@ describe("GraphChart", () => {
it("applies custom className to wrapper when data is present", () => {
const { container } = render(
- ,
+ ,
);
expect(container.firstChild).toHaveClass("my-graph");
});
@@ -294,13 +332,30 @@ describe("GraphChart", () => {
// --- Label property selector ---
const labeledNodes = [
- { id: "p1", label: "Tom Hanks", labels: ["Person"], properties: { name: "Tom Hanks", born: 1956 } },
- { id: "p2", label: "Keanu Reeves", labels: ["Person"], properties: { name: "Keanu Reeves", born: 1964 } },
- { id: "m1", label: "The Matrix", labels: ["Movie"], properties: { title: "The Matrix", released: 1999, tagline: "Welcome to the Real World" } },
- ];
- const labeledEdges = [
- { source: "p2", target: "m1", label: "ACTED_IN" },
+ {
+ id: "p1",
+ label: "Tom Hanks",
+ labels: ["Person"],
+ properties: { name: "Tom Hanks", born: 1956 },
+ },
+ {
+ id: "p2",
+ label: "Keanu Reeves",
+ labels: ["Person"],
+ properties: { name: "Keanu Reeves", born: 1964 },
+ },
+ {
+ id: "m1",
+ label: "The Matrix",
+ labels: ["Movie"],
+ properties: {
+ title: "The Matrix",
+ released: 1999,
+ tagline: "Welcome to the Real World",
+ },
+ },
];
+ const labeledEdges = [{ source: "p2", target: "m1", label: "ACTED_IN" }];
it("shows label settings button when nodes have labels", () => {
render();
@@ -309,7 +364,9 @@ describe("GraphChart", () => {
it("does not show label settings button when nodes have no labels", () => {
render();
- expect(screen.queryByTestId("label-settings-button")).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("label-settings-button"),
+ ).not.toBeInTheDocument();
});
it("opens label settings panel when button is clicked", async () => {
@@ -333,7 +390,9 @@ describe("GraphChart", () => {
render();
fireEvent.click(screen.getByTestId("label-settings-button"));
await waitFor(() => {
- const personSelect = screen.getByTestId("caption-select-Person") as HTMLSelectElement;
+ const personSelect = screen.getByTestId(
+ "caption-select-Person",
+ ) as HTMLSelectElement;
const options = Array.from(personSelect.options).map((o) => o.value);
expect(options).toContain("name");
expect(options).toContain("born");
@@ -356,7 +415,9 @@ describe("GraphChart", () => {
expect(screen.getByTestId("caption-select-Person")).toBeInTheDocument();
});
// Change Person caption to 'born'
- fireEvent.change(screen.getByTestId("caption-select-Person"), { target: { value: "born" } });
+ fireEvent.change(screen.getByTestId("caption-select-Person"), {
+ target: { value: "born" },
+ });
// Check that NVL nodes now show born year for Person nodes
const nvlNodes = capturedProps.nodes as NvlNode[];
const personNode = nvlNodes.find((n) => n.id === "p1");
@@ -365,13 +426,23 @@ describe("GraphChart", () => {
it("fires onCaptionMapChange when caption property is changed", async () => {
const onCaptionMapChange = vi.fn();
- render();
+ render(
+ ,
+ );
fireEvent.click(screen.getByTestId("label-settings-button"));
await waitFor(() => {
expect(screen.getByTestId("caption-select-Person")).toBeInTheDocument();
});
- fireEvent.change(screen.getByTestId("caption-select-Person"), { target: { value: "born" } });
- expect(onCaptionMapChange).toHaveBeenCalledWith(expect.objectContaining({ Person: "born" }));
+ fireEvent.change(screen.getByTestId("caption-select-Person"), {
+ target: { value: "born" },
+ });
+ expect(onCaptionMapChange).toHaveBeenCalledWith(
+ expect.objectContaining({ Person: "born" }),
+ );
});
it("seeds captionMap from initialCaptionMap prop", () => {
@@ -391,7 +462,11 @@ describe("GraphChart", () => {
it("resolves caption from properties even without label settings interaction", () => {
// Nodes with labels + properties should auto-resolve via default captionMap
const nodesWithProps = [
- { id: "x1", labels: ["City"], properties: { name: "Berlin", population: 3600000 } },
+ {
+ id: "x1",
+ labels: ["City"],
+ properties: { name: "Berlin", population: 3600000 },
+ },
];
render();
const nvlNodes = capturedProps.nodes as NvlNode[];
@@ -433,13 +508,25 @@ describe("GraphChart", () => {
});
it("includes relationship caption when showRelationshipLabels is true (default)", () => {
- render();
+ render(
+ ,
+ );
const nvlRels = capturedProps.rels as NvlRelationship[];
expect(nvlRels[0].caption).toBe("knows");
});
it("omits relationship caption when showRelationshipLabels is false", () => {
- render();
+ render(
+ ,
+ );
const nvlRels = capturedProps.rels as NvlRelationship[];
expect(nvlRels[0].caption).toBeUndefined();
});
@@ -451,7 +538,9 @@ describe("GraphChart", () => {
});
it("passes useStaticLayout true to NVL when physics is disabled", () => {
- render();
+ render(
+ ,
+ );
const opts = capturedProps.nvlOptions as Record;
expect(opts.useStaticLayout).toBe(true);
});
@@ -540,7 +629,9 @@ describe("GraphChart", () => {
it("does not show loading overlay when there are no nodes", () => {
render();
- expect(screen.queryByTestId("graph-loading-overlay")).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("graph-loading-overlay"),
+ ).not.toBeInTheDocument();
});
it("removes loading overlay after onLayoutDone fires", () => {
@@ -548,19 +639,33 @@ describe("GraphChart", () => {
expect(screen.getByTestId("graph-loading-overlay")).toBeInTheDocument();
// Simulate NVL calling onLayoutDone
- const callbacks = capturedProps.nvlCallbacks as { onLayoutDone?: () => void };
- act(() => { callbacks.onLayoutDone?.(); });
-
- expect(screen.queryByTestId("graph-loading-overlay")).not.toBeInTheDocument();
+ const callbacks = capturedProps.nvlCallbacks as {
+ onLayoutDone?: () => void;
+ };
+ act(() => {
+ callbacks.onLayoutDone?.();
+ });
+
+ expect(
+ screen.queryByTestId("graph-loading-overlay"),
+ ).not.toBeInTheDocument();
});
it("resets loading overlay when nodes change", () => {
- const { rerender } = render();
+ const { rerender } = render(
+ ,
+ );
// Fire onLayoutDone to clear overlay
- const callbacks = capturedProps.nvlCallbacks as { onLayoutDone?: () => void };
- act(() => { callbacks.onLayoutDone?.(); });
- expect(screen.queryByTestId("graph-loading-overlay")).not.toBeInTheDocument();
+ const callbacks = capturedProps.nvlCallbacks as {
+ onLayoutDone?: () => void;
+ };
+ act(() => {
+ callbacks.onLayoutDone?.();
+ });
+ expect(
+ screen.queryByTestId("graph-loading-overlay"),
+ ).not.toBeInTheDocument();
// Change nodes — overlay should reappear
const newNodes = [
@@ -595,10 +700,75 @@ describe("GraphChart", () => {
it("calls fitGraph (via onLayoutDone) when autoFit and layout completes", () => {
render();
// Fire onLayoutDone
- const callbacks = capturedProps.nvlCallbacks as { onLayoutDone?: () => void };
- act(() => { callbacks.onLayoutDone?.(); });
+ const callbacks = capturedProps.nvlCallbacks as {
+ onLayoutDone?: () => void;
+ };
+ act(() => {
+ callbacks.onLayoutDone?.();
+ });
// Overlay should be gone — fitGraph was called
- expect(screen.queryByTestId("graph-loading-overlay")).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("graph-loading-overlay"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe("rule-based styling", () => {
+ const styledNodes = [
+ { id: "1", label: "Alice", value: 10, labels: ["Person"] },
+ { id: "2", label: "Bob", value: 50, labels: ["Person"] },
+ { id: "3", label: "Charlie", value: 90, labels: ["Person"] },
+ ];
+
+ it("applies styling rule color to nodes matching the rule", () => {
+ const rules = [
+ { id: "r1", operator: ">=" as const, value: 50, color: "#ff0000" },
+ ];
+ render(
+ ,
+ );
+ const nvlNodes = capturedProps.nodes as Array<{
+ id: string;
+ color?: string;
+ }>;
+ // Node "2" (value=50) and "3" (value=90) match >= 50
+ expect(nvlNodes.find((n) => n.id === "2")?.color).toBe("#ff0000");
+ expect(nvlNodes.find((n) => n.id === "3")?.color).toBe("#ff0000");
+ // Node "1" (value=10) does NOT match — falls back to palette
+ expect(nvlNodes.find((n) => n.id === "1")?.color).not.toBe("#ff0000");
+ });
+
+ it("styling rule takes priority over explicit node.color", () => {
+ const nodesWithColor = [
+ { id: "1", label: "X", value: 100, color: "#00ff00" },
+ ];
+ const rules = [
+ { id: "r1", operator: ">=" as const, value: 50, color: "#ff0000" },
+ ];
+ render(
+ ,
+ );
+ const nvlNodes = capturedProps.nodes as Array<{
+ id: string;
+ color?: string;
+ }>;
+ expect(nvlNodes[0].color).toBe("#ff0000");
+ });
+
+ it("does not apply styling when node has no value", () => {
+ const noValueNodes = [{ id: "1", label: "NoVal", labels: ["Thing"] }];
+ const rules = [
+ { id: "r1", operator: ">=" as const, value: 0, color: "#ff0000" },
+ ];
+ render(
+ ,
+ );
+ const nvlNodes = capturedProps.nodes as Array<{
+ id: string;
+ color?: string;
+ }>;
+ // No value → rule not evaluated → palette color
+ expect(nvlNodes[0].color).not.toBe("#ff0000");
});
});
});
diff --git a/component/src/charts/__tests__/map-chart.test.tsx b/component/src/charts/__tests__/map-chart.test.tsx
index b7b27188..7a274f9a 100644
--- a/component/src/charts/__tests__/map-chart.test.tsx
+++ b/component/src/charts/__tests__/map-chart.test.tsx
@@ -104,7 +104,9 @@ describe("MapChart", () => {
render();
expect(L.tileLayer).toHaveBeenCalledWith(
expect.stringContaining("basemaps.cartocdn.com/light_all"),
- expect.objectContaining({ attribution: expect.stringContaining("OpenStreetMap") }),
+ expect.objectContaining({
+ attribution: expect.stringContaining("OpenStreetMap"),
+ }),
);
});
@@ -142,27 +144,42 @@ describe("MapChart", () => {
[40.7, -74.0],
[51.5, -0.1],
]);
- expect(mockFitBounds).toHaveBeenCalledWith(mockLatLngBounds, { padding: [20, 20] });
+ expect(mockFitBounds).toHaveBeenCalledWith(mockLatLngBounds, {
+ padding: [20, 20],
+ });
});
it("respects custom fitBoundsPadding", () => {
const markers = [{ id: "1", lat: 10, lng: 20 }];
- render();
- expect(mockFitBounds).toHaveBeenCalledWith(mockLatLngBounds, { padding: [50, 50] });
+ render(
+ ,
+ );
+ expect(mockFitBounds).toHaveBeenCalledWith(mockLatLngBounds, {
+ padding: [50, 50],
+ });
});
it("does not call setView when autoFitBounds is true", () => {
- render();
+ render(
+ ,
+ );
// setView is called on mount via L.map config, but the setView useEffect should not fire
expect(mockSetView).not.toHaveBeenCalled();
});
it("binds popup when marker has popup", () => {
const markers = [
- { id: "1", lat: 40.7, lng: -74.0, popup: "New York
Population: 8M" },
+ {
+ id: "1",
+ lat: 40.7,
+ lng: -74.0,
+ popup: "New York
Population: 8M",
+ },
];
render();
- expect(mockBindPopup).toHaveBeenCalledWith("New York
Population: 8M");
+ expect(mockBindPopup).toHaveBeenCalledWith(
+ "New York
Population: 8M",
+ );
});
it("does not bind popup when marker has no popup", () => {
@@ -226,4 +243,48 @@ describe("MapChart", () => {
render();
expect(mockBindPopup).not.toHaveBeenCalled();
});
+
+ describe("rule-based styling", () => {
+ it("applies styling rule color to markers matching the rule", () => {
+ const markers = [
+ { id: "1", lat: 10, lng: 20, value: 80 },
+ { id: "2", lat: 30, lng: 40, value: 20 },
+ ];
+ const rules = [
+ { id: "r1", operator: ">=" as const, value: 50, color: "#ff0000" },
+ ];
+ render();
+
+ const calls = (L.circleMarker as ReturnType).mock.calls;
+ // First marker (value=80) matches >= 50 → red
+ expect(calls[0][1].fillColor).toBe("#ff0000");
+ // Second marker (value=20) does NOT match → default color
+ expect(calls[1][1].fillColor).not.toBe("#ff0000");
+ });
+
+ it("styling rule color takes priority over explicit marker.color", () => {
+ const markers = [
+ { id: "1", lat: 10, lng: 20, value: 100, color: "#00ff00" },
+ ];
+ const rules = [
+ { id: "r1", operator: ">=" as const, value: 50, color: "#ff0000" },
+ ];
+ render();
+
+ const calls = (L.circleMarker as ReturnType).mock.calls;
+ expect(calls[0][1].fillColor).toBe("#ff0000");
+ });
+
+ it("does not apply styling when marker has no value", () => {
+ const markers = [{ id: "1", lat: 10, lng: 20 }];
+ const rules = [
+ { id: "r1", operator: ">=" as const, value: 0, color: "#ff0000" },
+ ];
+ render();
+
+ const calls = (L.circleMarker as ReturnType).mock.calls;
+ // No value → rule not evaluated → default color
+ expect(calls[0][1].fillColor).not.toBe("#ff0000");
+ });
+ });
});
diff --git a/component/src/charts/base-chart.tsx b/component/src/charts/base-chart.tsx
index b5027498..caf64ed9 100644
--- a/component/src/charts/base-chart.tsx
+++ b/component/src/charts/base-chart.tsx
@@ -1,6 +1,12 @@
import { useEffect, useRef, useState } from "react";
import * as echarts from "echarts/core";
-import { BarChart as EBarChart, LineChart as ELineChart, PieChart as EPieChart, GraphChart as EGraphChart, RadarChart as ERadarChart } from "echarts/charts";
+import {
+ BarChart as EBarChart,
+ LineChart as ELineChart,
+ PieChart as EPieChart,
+ GraphChart as EGraphChart,
+ RadarChart as ERadarChart,
+} from "echarts/charts";
import {
TitleComponent,
TooltipComponent,
@@ -66,8 +72,16 @@ function resolveChartColors(): string[] {
}
const CHART_COLOR_VARS = [
- "--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5",
- "--chart-6", "--chart-7", "--chart-8", "--chart-9", "--chart-10",
+ "--chart-1",
+ "--chart-2",
+ "--chart-3",
+ "--chart-4",
+ "--chart-5",
+ "--chart-6",
+ "--chart-7",
+ "--chart-8",
+ "--chart-9",
+ "--chart-10",
];
const CHART_COLORS_FALLBACK = DEEP_OCEAN_LIGHT;
@@ -77,15 +91,34 @@ function isDarkMode(): boolean {
return document.documentElement.classList.contains("dark");
}
-/** Watch the `dark` class on `` and re-render when it toggles. */
+/**
+ * Reactive dark-mode hook that listens to theme changes via:
+ * 1. `neoboard-theme-change` custom event (dispatched by the app's useTheme)
+ * 2. OS `prefers-color-scheme` media query changes
+ * 3. `storage` events (cross-tab theme sync)
+ *
+ * Falls back to reading `` — works regardless of
+ * whether the host app uses NeoBoard's useTheme or any other theme library.
+ */
function useDarkMode(): boolean {
const [dark, setDark] = useState(isDarkMode);
useEffect(() => {
- const el = document.documentElement;
- const observer = new MutationObserver(() => setDark(el.classList.contains("dark")));
- observer.observe(el, { attributes: true, attributeFilter: ["class"] });
- return () => observer.disconnect();
+ const sync = () => setDark(isDarkMode());
+
+ // App-level theme change event (NeoBoard's useTheme dispatches this)
+ globalThis.addEventListener("neoboard-theme-change", sync);
+ // Cross-tab sync (localStorage theme key changed in another tab)
+ globalThis.addEventListener("storage", sync);
+ // OS-level dark mode toggle
+ const mql = globalThis.matchMedia?.("(prefers-color-scheme: dark)");
+ mql?.addEventListener("change", sync);
+
+ return () => {
+ globalThis.removeEventListener("neoboard-theme-change", sync);
+ globalThis.removeEventListener("storage", sync);
+ mql?.removeEventListener("change", sync);
+ };
}, []);
return dark;
@@ -172,7 +205,14 @@ function BaseChart({
},
};
instance.setOption(merged, { notMerge: true });
- }, [options, enableDataZoom, colorblindMode, colorPalette, dark, ariaDescription]);
+ }, [
+ options,
+ enableDataZoom,
+ colorblindMode,
+ colorPalette,
+ dark,
+ ariaDescription,
+ ]);
// Loading state
useEffect(() => {
@@ -181,9 +221,7 @@ function BaseChart({
if (loading) {
instance.showLoading("default", {
text: "",
- maskColor: dark
- ? "rgba(10, 15, 30, 0.6)"
- : "rgba(255, 255, 255, 0.6)",
+ maskColor: dark ? "rgba(10, 15, 30, 0.6)" : "rgba(255, 255, 255, 0.6)",
zlevel: 0,
});
} else {
@@ -197,7 +235,9 @@ function BaseChart({
if (!instance) return;
if (onClick) {
- instance.on("click", (params: unknown) => onClick(params as EChartsClickEvent));
+ instance.on("click", (params: unknown) =>
+ onClick(params as EChartsClickEvent),
+ );
}
if (onDataZoom) {
instance.on("dataZoom", onDataZoom);
@@ -235,4 +275,9 @@ function BaseChart({
);
}
-export { BaseChart, CHART_COLORS_FALLBACK as CHART_COLORS, resolveChartColors, useDarkMode };
+export {
+ BaseChart,
+ CHART_COLORS_FALLBACK as CHART_COLORS,
+ resolveChartColors,
+ useDarkMode,
+};
diff --git a/component/src/charts/graph-chart.tsx b/component/src/charts/graph-chart.tsx
index 41284527..4458abe9 100644
--- a/component/src/charts/graph-chart.tsx
+++ b/component/src/charts/graph-chart.tsx
@@ -12,6 +12,8 @@ import type {
GraphNodeEvent,
GraphEdgeEvent,
} from "./types";
+import type { StylingRule } from "./styling-rule";
+import { resolveStylingRuleColor } from "./styling-rule";
import {
Popover,
PopoverTrigger,
@@ -125,6 +127,10 @@ export interface GraphChartProps {
* Useful when the container size may change after initial render (e.g. fullscreen dialogs).
*/
autoFit?: boolean;
+ /** Rule-based styling rules for node color/size */
+ stylingRules?: StylingRule[];
+ /** Resolved parameter values for styling rule evaluation */
+ paramValues?: Record;
/** Additional CSS classes */
className?: string;
}
@@ -229,6 +235,8 @@ function toNvlNode(
captionMap: Record,
labelColorMap: Map,
nodeSizeScale: number,
+ stylingRules?: StylingRule[],
+ paramValues?: Record,
): NvlNode {
let x = node.x;
let y = node.y;
@@ -238,15 +246,26 @@ function toNvlNode(
x = Math.cos(angle) * radius;
y = Math.sin(angle) * radius;
}
- // Use the last label to determine the color (most specific label wins)
+
+ // Rule-based color: evaluate against node.value (numeric property)
+ const ruleColor =
+ stylingRules?.length && node.value != null
+ ? resolveStylingRuleColor(node.value, stylingRules, paramValues)
+ : undefined;
+
+ // Color priority: styling rule > explicit node.color > label palette
const color =
+ ruleColor ??
node.color ??
(node.labels?.length
? labelColorMap.get(node.labels[node.labels.length - 1])
: undefined);
+
+ // Size: base from node.value, scaled by nodeSizeScale
const baseSize = node.value
? Math.max(20, Math.min(60, node.value))
: undefined;
+
return {
id: node.id,
caption: showLabels ? resolveCaption(node, captionMap) : undefined,
@@ -304,6 +323,8 @@ export function GraphChart({
onLayoutChange,
onCaptionMapChange,
autoFit,
+ stylingRules,
+ paramValues,
className,
}: GraphChartProps) {
const nvlRef = useRef(null);
@@ -383,9 +404,19 @@ export function GraphChart({
captionMap,
labelColorMap,
nodeSizeScale,
+ stylingRules,
+ paramValues,
),
),
- [nodes, showLabels, captionMap, labelColorMap, nodeSizeScale],
+ [
+ nodes,
+ showLabels,
+ captionMap,
+ labelColorMap,
+ nodeSizeScale,
+ stylingRules,
+ paramValues,
+ ],
);
const nvlRels = useMemo(
diff --git a/component/src/charts/map-chart.tsx b/component/src/charts/map-chart.tsx
index 46b2baad..1dadc504 100644
--- a/component/src/charts/map-chart.tsx
+++ b/component/src/charts/map-chart.tsx
@@ -3,6 +3,8 @@ import L from "leaflet";
import "leaflet/dist/leaflet.css";
import { cn } from "@/lib/utils";
import { MAP_MARKER_DEFAULT_COLOR } from "@/lib/design-tokens";
+import type { StylingRule } from "./styling-rule";
+import { resolveStylingRuleColor } from "./styling-rule";
import { useDarkMode } from "./base-chart";
export type TileLayerPreset = "osm" | "carto-light" | "carto-dark";
@@ -38,21 +40,31 @@ export interface MapChartProps {
loading?: boolean;
error?: Error | null;
onMarkerClick?: (marker: MapMarker) => void;
+ /** Rule-based styling rules for marker color/size */
+ stylingRules?: StylingRule[];
+ /** Resolved parameter values for styling rule evaluation */
+ paramValues?: Record;
className?: string;
}
-const TILE_PRESETS: Record = {
+const TILE_PRESETS: Record<
+ TileLayerPreset,
+ { url: string; attribution: string }
+> = {
osm: {
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
- attribution: '© OpenStreetMap',
+ attribution:
+ '© OpenStreetMap',
},
"carto-light": {
url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
- attribution: '© OpenStreetMap © CARTO',
+ attribution:
+ '© OpenStreetMap © CARTO',
},
"carto-dark": {
url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
- attribution: '© OpenStreetMap © CARTO',
+ attribution:
+ '© OpenStreetMap © CARTO',
},
};
@@ -73,7 +85,9 @@ function resolveTileLayer(
}
return {
url: effectivePreset,
- attribution: attribution ?? '© OpenStreetMap',
+ attribution:
+ attribution ??
+ '© OpenStreetMap',
};
}
@@ -111,6 +125,8 @@ function MapChart({
loading = false,
error = null,
onMarkerClick,
+ stylingRules,
+ paramValues,
className,
}: MapChartProps) {
const containerRef = useRef(null);
@@ -134,7 +150,9 @@ function MapChart({
maxZoom,
});
- const tl = L.tileLayer(tile.url, { attribution: tile.attribution }).addTo(map);
+ const tl = L.tileLayer(tile.url, { attribution: tile.attribution }).addTo(
+ map,
+ );
tileLayerRef.current = tl;
markersLayerRef.current = L.layerGroup().addTo(map);
mapRef.current = map;
@@ -161,7 +179,9 @@ function MapChart({
if (tileLayerRef.current) {
map.removeLayer(tileLayerRef.current);
}
- const tl = L.tileLayer(tile.url, { attribution: tile.attribution }).addTo(map);
+ const tl = L.tileLayer(tile.url, { attribution: tile.attribution }).addTo(
+ map,
+ );
tileLayerRef.current = tl;
}, [tile.url, tile.attribution]);
@@ -181,10 +201,17 @@ function MapChart({
layer.clearLayers();
markers.forEach((m) => {
+ // Rule-based color: evaluate against marker.value
+ const ruleColor =
+ stylingRules?.length && m.value != null
+ ? resolveStylingRuleColor(m.value, stylingRules, paramValues)
+ : undefined;
+ const markerColor = ruleColor ?? m.color ?? MAP_MARKER_DEFAULT_COLOR;
+
const circleMarker = L.circleMarker([m.lat, m.lng], {
radius: m.value ? Math.min(Math.max(m.value, 4), 30) : markerSize,
- fillColor: m.color ?? MAP_MARKER_DEFAULT_COLOR,
- color: m.color ?? MAP_MARKER_DEFAULT_COLOR,
+ fillColor: markerColor,
+ color: markerColor,
weight: 1,
opacity: 0.8,
fillOpacity: 0.6,
@@ -212,10 +239,21 @@ function MapChart({
// Auto-fit bounds
if (autoFitBounds && map && markers.length > 0) {
- const bounds = L.latLngBounds(markers.map((m) => [m.lat, m.lng] as [number, number]));
+ const bounds = L.latLngBounds(
+ markers.map((m) => [m.lat, m.lng] as [number, number]),
+ );
map.fitBounds(bounds, { padding: fitBoundsPadding });
}
- }, [markers, onMarkerClick, autoFitBounds, fitBoundsPadding, markerSize, showPopup]);
+ }, [
+ markers,
+ onMarkerClick,
+ autoFitBounds,
+ fitBoundsPadding,
+ markerSize,
+ showPopup,
+ stylingRules,
+ paramValues,
+ ]);
if (error) {
return (
diff --git a/component/src/components/composed/__tests__/chart-options-schema.test.ts b/component/src/components/composed/__tests__/chart-options-schema.test.ts
index 4d9b6852..1010b939 100644
--- a/component/src/components/composed/__tests__/chart-options-schema.test.ts
+++ b/component/src/components/composed/__tests__/chart-options-schema.test.ts
@@ -420,8 +420,12 @@ describe("colorPalette option", () => {
const options = getChartOptions("bar");
const opt = options.find((o) => o.key === "colorPalette");
expect(opt?.options).toBeDefined();
- expect(opt?.options!.map((o) => o.value)).toContain("deep-ocean");
- expect(opt?.options!.map((o) => o.value)).toContain("warm-sunset");
+ expect(opt?.options!.map((o: { value: string }) => o.value)).toContain(
+ "deep-ocean",
+ );
+ expect(opt?.options!.map((o: { value: string }) => o.value)).toContain(
+ "warm-sunset",
+ );
});
it("colorPalette select options all have non-empty label and value", () => {
diff --git a/component/src/components/composed/chart-options-schema.ts b/component/src/components/composed/chart-options-schema.ts
index fbd8c043..02431dde 100644
--- a/component/src/components/composed/chart-options-schema.ts
+++ b/component/src/components/composed/chart-options-schema.ts
@@ -1,1098 +1,5 @@
-import { COLOR_PALETTES } from "@/charts/palettes";
-
-export interface ChartOptionDef {
- key: string;
- label: string;
- type: "boolean" | "select" | "text" | "number" | "column-multi-select";
- default: unknown;
- category: string;
- /** Only for type: "select" */
- options?: { label: string; value: string }[];
- /** Short description shown in a tooltip next to the option label. */
- description?: string;
-}
-
-// ---------------------------------------------------------------------------
-// Shared option constants — reused across multiple chart type definitions
-// to avoid duplication.
-// ---------------------------------------------------------------------------
-
-const SHARED_SHOW_LEGEND: ChartOptionDef = {
- key: "showLegend",
- label: "Show Legend",
- type: "boolean",
- default: true,
- category: "Labels",
- description: "Show the chart legend identifying each data series.",
-};
-
-const SHARED_X_AXIS_LABEL: ChartOptionDef = {
- key: "xAxisLabel",
- label: "X-Axis Label",
- type: "text",
- default: "",
- category: "Labels",
- description: "Custom label displayed below the horizontal axis.",
-};
-
-const SHARED_Y_AXIS_LABEL: ChartOptionDef = {
- key: "yAxisLabel",
- label: "Y-Axis Label",
- type: "text",
- default: "",
- category: "Labels",
- description: "Custom label displayed beside the vertical axis.",
-};
-
-const SHARED_SHOW_GRID_LINES: ChartOptionDef = {
- key: "showGridLines",
- label: "Show Grid Lines",
- type: "boolean",
- default: true,
- category: "Style",
- description: "Show faint horizontal reference lines behind the chart.",
-};
-
-const SHARED_REFERENCE_LINES: ChartOptionDef = {
- key: "referenceLines",
- label: "Reference Lines (JSON)",
- type: "text",
- default: "",
- category: "Annotations",
- description:
- 'Horizontal reference lines as JSON: [{"value":50,"label":"Target","color":"#ff0000"}]',
-};
-
-const SHARED_SHOW_LABELS: ChartOptionDef = {
- key: "showLabels",
- label: "Show Labels",
- type: "boolean",
- default: true,
- category: "Labels",
- description: "Show the name label on each element.",
-};
-
-/** DataZoom option for axis-based charts (bar, line). */
-const dataZoomOptions: ChartOptionDef[] = [
- {
- key: "enableDataZoom",
- label: "Enable Scroll Zoom",
- type: "boolean",
- default: false,
- category: "Interaction",
- description:
- "Allow scroll-to-zoom on the data axis to explore large datasets.",
- },
-];
-
-/** Shared number formatting options for tooltip values on axis-based charts. */
-const tooltipFormatOptions: ChartOptionDef[] = [
- {
- key: "decimalPlaces",
- label: "Decimal Places",
- type: "number",
- default: -1,
- category: "Labels",
- description:
- "Fixed number of decimal places in tooltips (0-6). Set to -1 for automatic.",
- },
-];
-
-const barOptions: ChartOptionDef[] = [
- {
- key: "orientation",
- label: "Orientation",
- type: "select",
- default: "vertical",
- category: "Layout",
- description:
- "Vertical bars grow upward; horizontal bars grow left-to-right.",
- options: [
- { label: "Vertical", value: "vertical" },
- { label: "Horizontal", value: "horizontal" },
- ],
- },
- {
- key: "stacked",
- label: "Stacked",
- type: "boolean",
- default: false,
- category: "Layout",
- description:
- "Stack series on top of each other instead of placing them side by side.",
- },
- {
- key: "barWidth",
- label: "Bar Width (px, 0=auto)",
- type: "number",
- default: 0,
- category: "Layout",
- description:
- "Width of each bar in pixels. Set to 0 to let the chart auto-size.",
- },
- {
- key: "barGap",
- label: "Bar Gap",
- type: "text",
- default: "30%",
- category: "Layout",
- description:
- "Gap between bar groups as a percentage of the bar width (e.g. '30%').",
- },
- {
- key: "showValues",
- label: "Show Values",
- type: "boolean",
- default: false,
- category: "Labels",
- description: "Display the numeric value as a label on each bar.",
- },
- SHARED_SHOW_LEGEND,
- SHARED_X_AXIS_LABEL,
- SHARED_Y_AXIS_LABEL,
- SHARED_SHOW_GRID_LINES,
- {
- key: "axisLabelRotation",
- label: "Axis Label Rotation (°)",
- type: "number",
- default: -1,
- category: "Labels",
- description:
- "Override axis label rotation angle (0-90). Set to -1 for automatic (rotates at 8+ categories).",
- },
- SHARED_REFERENCE_LINES,
-];
-
-const lineOptions: ChartOptionDef[] = [
- {
- key: "smooth",
- label: "Smooth Curve",
- type: "boolean",
- default: false,
- category: "Style",
- description:
- "Render lines as smooth Bézier curves instead of straight segments.",
- },
- {
- key: "area",
- label: "Fill Area",
- type: "boolean",
- default: false,
- category: "Style",
- description:
- "Fill the area beneath the line to emphasise volume over time.",
- },
- {
- key: "lineWidth",
- label: "Line Width (px)",
- type: "number",
- default: 2,
- category: "Style",
- description: "Stroke width of the line in pixels.",
- },
- {
- key: "stepped",
- label: "Stepped Line",
- type: "boolean",
- default: false,
- category: "Style",
- description:
- "Draw the line as a step function — useful for discrete state changes.",
- },
- {
- key: "showPoints",
- label: "Show Data Points",
- type: "boolean",
- default: false,
- category: "Style",
- description: "Draw a dot at each data point along the line.",
- },
- SHARED_SHOW_GRID_LINES,
- SHARED_X_AXIS_LABEL,
- SHARED_Y_AXIS_LABEL,
- SHARED_SHOW_LEGEND,
- SHARED_REFERENCE_LINES,
- {
- key: "samplingThreshold",
- label: "Sampling Threshold",
- type: "number",
- default: 1000,
- category: "Performance",
- description:
- "Enable LTTB downsampling when data points exceed this count. Set to 0 to disable.",
- },
- {
- key: "samplingMethod",
- label: "Sampling Method",
- type: "select",
- default: "lttb",
- options: [
- { label: "LTTB", value: "lttb" },
- { label: "Average", value: "average" },
- { label: "Max", value: "max" },
- { label: "Min", value: "min" },
- ],
- category: "Performance",
- description: "Algorithm for downsampling large datasets.",
- },
-];
-
-const pieOptions: ChartOptionDef[] = [
- {
- key: "donut",
- label: "Donut Style",
- type: "boolean",
- default: false,
- category: "Style",
- description:
- "Cut a circular hole in the centre to render the chart as a donut.",
- },
- {
- key: "roseMode",
- label: "Rose/Nightingale Mode",
- type: "boolean",
- default: false,
- category: "Style",
- description:
- "Vary each slice's radius by its value (Nightingale / rose chart).",
- },
- {
- key: "labelPosition",
- label: "Label Position",
- type: "select",
- default: "outside",
- category: "Labels",
- description: "Where to place the slice labels relative to the chart.",
- options: [
- { label: "Outside", value: "outside" },
- { label: "Inside", value: "inside" },
- { label: "Center", value: "center" },
- ],
- },
- {
- key: "showLabel",
- label: "Show Labels",
- type: "boolean",
- default: true,
- category: "Labels",
- description: "Show the category name on each slice.",
- },
- {
- key: "showPercentage",
- label: "Show Percentage",
- type: "boolean",
- default: true,
- category: "Labels",
- description: "Show the percentage value on each slice.",
- },
- {
- ...SHARED_SHOW_LEGEND,
- description: "Show the chart legend identifying each slice.",
- },
- {
- key: "sortSlices",
- label: "Sort Slices by Value",
- type: "boolean",
- default: false,
- category: "Layout",
- description:
- "Sort slices by value (largest first) for a cleaner visual layout.",
- },
- {
- key: "topN",
- label: "Top N Slices",
- type: "number",
- default: 0,
- category: "Layout",
- description:
- "Show only the top N slices and group the rest into 'Other'. Set to 0 to show all.",
- },
- {
- key: "donutCenterText",
- label: "Donut Center Text",
- type: "text",
- default: "",
- category: "Labels",
- description:
- "Custom text in the donut center. Leave blank to show the total.",
- },
-];
-
-const singleValueOptions: ChartOptionDef[] = [
- {
- key: "title",
- label: "Title",
- type: "text",
- default: "",
- category: "Display",
- description: "Custom heading shown above the value. Leave blank to hide.",
- },
- {
- key: "prefix",
- label: "Prefix",
- type: "text",
- default: "",
- category: "Display",
- description: "Text prepended to the value (e.g. '$', '€').",
- },
- {
- key: "suffix",
- label: "Suffix",
- type: "text",
- default: "",
- category: "Display",
- description: "Text appended to the value (e.g. '%', ' items').",
- },
- {
- key: "decimalPlaces",
- label: "Decimal Places",
- type: "number",
- default: -1,
- category: "Display",
- description:
- "Fixed number of decimal places (0-6). Set to -1 for automatic.",
- },
- {
- key: "fontSize",
- label: "Font Size",
- type: "select",
- default: "lg",
- category: "Display",
- description: "Size of the main displayed value.",
- options: [
- { label: "Small", value: "sm" },
- { label: "Medium", value: "md" },
- { label: "Large", value: "lg" },
- { label: "Extra Large", value: "xl" },
- ],
- },
- {
- key: "numberFormat",
- label: "Number Format",
- type: "select",
- default: "plain",
- category: "Display",
- description:
- "How to format the numeric value — plain, comma-separated, compact (1.2k), or percentage.",
- options: [
- { label: "Plain", value: "plain" },
- { label: "Comma", value: "comma" },
- { label: "Compact", value: "compact" },
- { label: "Percent", value: "percent" },
- ],
- },
- {
- key: "trendEnabled",
- label: "Show Trend Indicator",
- type: "boolean",
- default: false,
- category: "Display",
- description:
- "Show a trend arrow comparing the current value to the previous period (requires 2 rows in the query result).",
- },
-];
-
-const graphOptions: ChartOptionDef[] = [
- {
- key: "layout",
- label: "Layout",
- type: "select",
- default: "force",
- category: "Layout",
- description:
- "Algorithm used to position nodes: force simulation, circular ring, or hierarchical tree.",
- options: [
- { label: "Force", value: "force" },
- { label: "Circular", value: "circular" },
- { label: "Hierarchical", value: "hierarchical" },
- ],
- },
- {
- key: "nodeSize",
- label: "Node Size",
- type: "select",
- default: "medium",
- category: "Layout",
- description: "Visual size of each node circle.",
- options: [
- { label: "Small", value: "small" },
- { label: "Medium", value: "medium" },
- { label: "Large", value: "large" },
- ],
- },
- {
- ...SHARED_SHOW_LABELS,
- description: "Show the node label (first string property) on each node.",
- },
- {
- key: "showRelationshipLabels",
- label: "Show Relationship Labels",
- type: "boolean",
- default: true,
- category: "Labels",
- description: "Show the relationship type name on each edge.",
- },
- {
- key: "physics",
- label: "Enable Physics",
- type: "boolean",
- default: true,
- category: "Style",
- description:
- "Enable physics simulation so nodes repel and edges act as springs.",
- },
-];
-
-const mapOptions: ChartOptionDef[] = [
- {
- key: "tileLayer",
- label: "Tile Layer",
- type: "select",
- default: "osm",
- category: "Map",
- description:
- "Base-map tile provider. OpenStreetMap is open and free; Carto variants are cleaner for data overlays.",
- options: [
- { label: "OpenStreetMap", value: "osm" },
- { label: "Carto Light", value: "carto-light" },
- { label: "Carto Dark", value: "carto-dark" },
- ],
- },
- {
- key: "zoom",
- label: "Default Zoom",
- type: "number",
- default: 3,
- category: "Map",
- description:
- "Initial zoom level when the map first renders (1 = world view, 18 = street level).",
- },
- {
- key: "minZoom",
- label: "Min Zoom",
- type: "number",
- default: 2,
- category: "Map",
- description: "Minimum zoom level the user can zoom out to.",
- },
- {
- key: "maxZoom",
- label: "Max Zoom",
- type: "number",
- default: 18,
- category: "Map",
- description: "Maximum zoom level the user can zoom in to.",
- },
- {
- key: "autoFitBounds",
- label: "Auto-fit Bounds",
- type: "boolean",
- default: true,
- category: "Map",
- description:
- "Automatically pan and zoom to fit all markers on the initial load.",
- },
- {
- key: "markerSize",
- label: "Marker Size (px)",
- type: "number",
- default: 6,
- category: "Markers",
- description: "Radius of each map marker circle in pixels.",
- },
- {
- key: "clusterMarkers",
- label: "Cluster Markers",
- type: "boolean",
- default: false,
- category: "Markers",
- description:
- "Group nearby markers into a single cluster badge at lower zoom levels.",
- },
- {
- key: "showPopup",
- label: "Show Popup on Click",
- type: "boolean",
- default: true,
- category: "Markers",
- description:
- "Show a popup with the row data when the user clicks a marker.",
- },
-];
-
-const tableOptions: ChartOptionDef[] = [
- {
- key: "enableSorting",
- label: "Enable Sorting",
- type: "boolean",
- default: true,
- category: "Features",
- description:
- "Allow clicking column headers to sort rows ascending or descending.",
- },
- {
- key: "enableSelection",
- label: "Row Selection",
- type: "boolean",
- default: false,
- category: "Features",
- description: "Allow selecting individual rows by clicking them.",
- },
- {
- key: "enableGlobalFilter",
- label: "Global Search",
- type: "boolean",
- default: true,
- category: "Features",
- description: "Show a search box that filters all rows across all columns.",
- },
- {
- key: "enableColumnFilters",
- label: "Column Filters",
- type: "boolean",
- default: true,
- category: "Features",
- description: "Show per-column filter inputs below each column header.",
- },
- {
- key: "enableColumnResizing",
- label: "Column Resizing",
- type: "boolean",
- default: false,
- category: "Features",
- description:
- "Allow drag-to-resize column borders. Double-click to auto-fit.",
- },
- {
- key: "enablePagination",
- label: "Enable Pagination",
- type: "boolean",
- default: true,
- category: "Pagination",
- description:
- "Show Previous / Next controls to page through large result sets.",
- },
- {
- key: "pageSize",
- label: "Page Size",
- type: "number",
- default: 10,
- category: "Pagination",
- description: "Number of rows shown per page when pagination is enabled.",
- },
- {
- key: "emptyMessage",
- label: "Empty Message",
- type: "text",
- default: "No results",
- category: "Display",
- description: "Text displayed when the query returns no rows.",
- },
- {
- key: "enableGrouping",
- label: "Enable Row Grouping",
- type: "boolean",
- default: false,
- category: "Grouping",
- description:
- "Allow grouping rows by column values. Columns to group by are set in the groupBy field below.",
- },
- {
- key: "groupBy",
- label: "Group By Columns",
- type: "column-multi-select",
- default: "",
- category: "Grouping",
- description:
- "Select columns to group by. Nested grouping is supported — order determines nesting hierarchy.",
- },
- {
- key: "aggregationFn",
- label: "Aggregation Function",
- type: "select",
- default: "sum",
- category: "Grouping",
- description: "Aggregation function for numeric columns in grouped rows.",
- options: [
- { label: "Sum", value: "sum" },
- { label: "Average", value: "mean" },
- { label: "Median", value: "median" },
- { label: "Count", value: "count" },
- { label: "Min", value: "min" },
- { label: "Max", value: "max" },
- ],
- },
-];
-
-const jsonOptions: ChartOptionDef[] = [
- {
- key: "initialExpanded",
- label: "Initial Expand Depth",
- type: "number",
- default: 2,
- category: "Display",
- description:
- "How many levels deep the JSON tree is expanded when first rendered (0 = collapsed).",
- },
- {
- key: "fontSize",
- label: "Font Size",
- type: "select",
- default: "sm",
- category: "Display",
- description: "Font size used for the JSON syntax highlighting.",
- options: [
- { label: "Small", value: "sm" },
- { label: "Medium", value: "md" },
- { label: "Large", value: "lg" },
- ],
- },
- {
- key: "showCopyButton",
- label: "Show Copy Button",
- type: "boolean",
- default: true,
- category: "Display",
- description:
- "Show a button to copy the full JSON payload to the clipboard.",
- },
- {
- key: "theme",
- label: "Theme",
- type: "select",
- default: "dark",
- category: "Display",
- description: "Colour theme for the JSON syntax highlighting.",
- options: [
- { label: "Dark", value: "dark" },
- { label: "Light", value: "light" },
- ],
- },
-];
-
-const parameterSelectOptions: ChartOptionDef[] = [
- {
- key: "placeholder",
- label: "Placeholder",
- type: "text",
- default: "",
- category: "Parameter",
- description:
- "Hint text shown inside the selector when no value has been chosen.",
- },
- {
- key: "searchable",
- label: "Search-as-you-type",
- type: "boolean",
- default: true,
- category: "Parameter",
- description:
- "Allow the user to type to filter the option list in real time.",
- },
- {
- key: "defaultValue",
- label: "Default Value",
- type: "text",
- default: "",
- category: "Parameter",
- description:
- "Value used on dashboard load when no selection has been made. Leave empty for no default.",
- },
- {
- key: "syncToUrl",
- label: "Sync to URL",
- type: "boolean",
- default: true,
- category: "Parameter",
- description:
- "Include this parameter in the URL query string for deep-linking. Disable for noisy or internal params.",
- },
-];
-
-const formOptions: ChartOptionDef[] = [
- {
- key: "submitButtonText",
- label: "Submit Button Text",
- type: "text",
- default: "Submit",
- category: "Form",
- description: "Label for the form submit button.",
- },
- {
- key: "successMessage",
- label: "Success Message",
- type: "text",
- default: "Form submitted successfully",
- category: "Form",
- description: "Message shown after a successful submission.",
- },
- {
- key: "resetOnSuccess",
- label: "Reset on Success",
- type: "boolean",
- default: true,
- category: "Form",
- description: "Clear all form fields after a successful submission.",
- },
-];
-
-const markdownOptions: ChartOptionDef[] = [
- {
- key: "content",
- label: "Markdown Content",
- type: "text",
- default: "",
- category: "Content",
- description:
- "Markdown text to render. Supports headings, bold, italic, links, lists, code blocks, and blockquotes.",
- },
-];
-
-const iframeOptions: ChartOptionDef[] = [
- {
- key: "url",
- label: "URL",
- type: "text",
- default: "",
- category: "Content",
- description:
- "The URL of the external page to embed. Must be an https:// URL.",
- },
- {
- key: "iframeTitle",
- label: "Title",
- type: "text",
- default: "Embedded content",
- category: "Content",
- description:
- "Accessible title for the embedded content (used by screen readers).",
- },
- {
- key: "sandbox",
- label: "Sandbox Policy",
- type: "text",
- default: "allow-scripts allow-popups",
- category: "Security",
- description:
- "HTML sandbox attributes controlling what the embedded page can do. Restrict for untrusted content.",
- },
-];
-
-/** Accessibility options for ECharts-based chart types. */
-const accessibilityOptions: ChartOptionDef[] = [
- {
- key: "colorblindMode",
- label: "Colorblind Mode",
- type: "boolean",
- default: false,
- category: "Accessibility",
- description:
- "Overlay distinct patterns on chart elements so data series are distinguishable without relying on color alone.",
- },
-];
-
-/** Appearance options (color palette) for ECharts-based chart types. */
-const appearanceOptions: ChartOptionDef[] = [
- {
- key: "colorPalette",
- label: "Color Palette",
- type: "select",
- default: "deep-ocean",
- category: "Appearance",
- description: "Color scheme for chart series and data points.",
- options: Object.entries(COLOR_PALETTES).map(([k, v]) => ({
- value: k,
- label: v.label,
- })),
- },
-];
-
-/** Shared behavior options available to all chart types except parameter-select and form. */
-const behaviorOptions: ChartOptionDef[] = [
- {
- key: "showRefreshButton",
- label: "Show Refresh Button",
- type: "boolean",
- default: false,
- category: "Behavior",
- description:
- "Display a refresh button in the widget header to manually re-fetch the query.",
- },
- {
- key: "manualRun",
- label: "Manual Run",
- type: "boolean",
- default: false,
- category: "Behavior",
- description:
- "Start with the query disabled. A 'Run Query' button must be clicked to execute. On parameter change the widget resets to the overlay.",
- },
- {
- key: "cacheMode",
- label: "Cache Mode",
- type: "select",
- default: "ttl",
- category: "Behavior",
- description:
- "TTL re-fetches data based on the cache timeout. Forever fetches once and caches until manually refreshed.",
- options: [
- { label: "TTL (time-based)", value: "ttl" },
- { label: "Forever (until refresh)", value: "forever" },
- ],
- },
-];
-
-const gaugeOptions: ChartOptionDef[] = [
- {
- key: "min",
- label: "Min Value",
- type: "number",
- default: 0,
- category: "Range",
- description: "Minimum value on the gauge scale.",
- },
- {
- key: "max",
- label: "Max Value",
- type: "number",
- default: 100,
- category: "Range",
- description: "Maximum value on the gauge scale.",
- },
- {
- key: "showProgress",
- label: "Show Progress Arc",
- type: "boolean",
- default: true,
- category: "Style",
- description: "Fill the gauge arc to show progress toward the maximum.",
- },
- {
- key: "showPointer",
- label: "Show Pointer",
- type: "boolean",
- default: true,
- category: "Style",
- description: "Display a needle pointer on the gauge.",
- },
- {
- key: "showDetail",
- label: "Show Value Detail",
- type: "boolean",
- default: true,
- category: "Labels",
- description: "Show the numeric value and name below the gauge.",
- },
- {
- key: "startAngle",
- label: "Start Angle (°)",
- type: "number",
- default: 225,
- category: "Layout",
- description: "Starting angle of the gauge arc in degrees (0 = 3 o'clock).",
- },
- {
- key: "endAngle",
- label: "End Angle (°)",
- type: "number",
- default: -45,
- category: "Layout",
- description: "Ending angle of the gauge arc in degrees.",
- },
- {
- key: "thresholdZones",
- label: "Threshold Zones (JSON)",
- type: "text",
- default: "",
- category: "Style",
- description:
- 'Colored zones on the gauge arc: [{"value":30,"color":"#67e0e3"},{"value":70,"color":"#37a2da"},{"value":100,"color":"#fd666d"}]',
- },
-];
-
-const sankeyOptions: ChartOptionDef[] = [
- {
- key: "orient",
- label: "Orientation",
- type: "select",
- default: "horizontal",
- category: "Layout",
- description:
- "Direction of the flow: left-to-right (horizontal) or top-to-bottom (vertical).",
- options: [
- { label: "Horizontal", value: "horizontal" },
- { label: "Vertical", value: "vertical" },
- ],
- },
- {
- ...SHARED_SHOW_LABELS,
- label: "Show Node Labels",
- description: "Show the node name alongside each block.",
- },
- {
- key: "nodeWidth",
- label: "Node Width (px)",
- type: "number",
- default: 20,
- category: "Layout",
- description: "Width of each node block in pixels.",
- },
- {
- key: "nodeGap",
- label: "Node Gap (px)",
- type: "number",
- default: 8,
- category: "Layout",
- description: "Vertical gap between nodes at the same level in pixels.",
- },
-];
-
-const sunburstOptions: ChartOptionDef[] = [
- { ...SHARED_SHOW_LABELS, description: "Show the name of each segment." },
- {
- key: "sort",
- label: "Sort Segments",
- type: "select",
- default: "desc",
- category: "Layout",
- description: "Order in which segments are arranged around the chart.",
- options: [
- { label: "Largest First", value: "desc" },
- { label: "Smallest First", value: "asc" },
- { label: "Natural (data order)", value: "none" },
- ],
- },
- {
- key: "highlightOnHover",
- label: "Highlight on Hover",
- type: "boolean",
- default: true,
- category: "Style",
- description: "Enlarge and emphasise a segment when hovered.",
- },
-];
-
-const radarOptions: ChartOptionDef[] = [
- {
- key: "shape",
- label: "Shape",
- type: "select",
- default: "polygon",
- category: "Style",
- description: "Outline shape of the radar grid.",
- options: [
- { label: "Polygon", value: "polygon" },
- { label: "Circle", value: "circle" },
- ],
- },
- {
- key: "filled",
- label: "Fill Area",
- type: "boolean",
- default: true,
- category: "Style",
- description: "Fill the area enclosed by the data polygon.",
- },
- {
- ...SHARED_SHOW_LEGEND,
- description: "Show the legend identifying each series.",
- },
- {
- key: "showValues",
- label: "Show Values on Points",
- type: "boolean",
- default: false,
- category: "Labels",
- description: "Display the numeric value at each data point on the radar.",
- },
-];
-
-const treemapOptions: ChartOptionDef[] = [
- { ...SHARED_SHOW_LABELS, description: "Show the name of each rectangle." },
- {
- key: "showBreadcrumb",
- label: "Show Breadcrumb",
- type: "boolean",
- default: true,
- category: "Labels",
- description:
- "Show the navigation breadcrumb when drilling down into nested data.",
- },
- {
- key: "showValues",
- label: "Show Values",
- type: "boolean",
- default: false,
- category: "Labels",
- description: "Display the numeric value inside each rectangle.",
- },
- {
- key: "colorSaturation",
- label: "Color Saturation Range",
- type: "select",
- default: "medium",
- category: "Style",
- description:
- "Controls the saturation gradient used to shade child rectangles within a parent.",
- options: [
- { label: "Low", value: "low" },
- { label: "Medium", value: "medium" },
- { label: "High", value: "high" },
- ],
- },
-];
-
-const chartOptionsRegistry: Record = {
- bar: [
- ...barOptions,
- ...dataZoomOptions,
- ...tooltipFormatOptions,
- ...behaviorOptions,
- ...appearanceOptions,
- ...accessibilityOptions,
- ],
- line: [
- ...lineOptions,
- ...dataZoomOptions,
- ...tooltipFormatOptions,
- ...behaviorOptions,
- ...appearanceOptions,
- ...accessibilityOptions,
- ],
- pie: [
- ...pieOptions,
- ...tooltipFormatOptions,
- ...behaviorOptions,
- ...appearanceOptions,
- ...accessibilityOptions,
- ],
- "single-value": [...singleValueOptions, ...behaviorOptions],
- graph: [...graphOptions, ...behaviorOptions],
- map: [...mapOptions, ...behaviorOptions],
- table: [...tableOptions, ...behaviorOptions],
- json: [...jsonOptions, ...behaviorOptions],
- "parameter-select": parameterSelectOptions,
- form: formOptions,
- markdown: markdownOptions,
- iframe: iframeOptions,
- gauge: [...gaugeOptions, ...behaviorOptions, ...appearanceOptions],
- sankey: [...sankeyOptions, ...behaviorOptions, ...appearanceOptions],
- sunburst: [...sunburstOptions, ...behaviorOptions, ...appearanceOptions],
- radar: [...radarOptions, ...behaviorOptions, ...appearanceOptions],
- treemap: [...treemapOptions, ...behaviorOptions, ...appearanceOptions],
-};
-
-export function getChartOptions(chartType: string): ChartOptionDef[] {
- return chartOptionsRegistry[chartType] ?? [];
-}
-
-export function getDefaultChartSettings(
- chartType: string,
-): Record {
- const options = getChartOptions(chartType);
- const defaults: Record = {};
- for (const opt of options) {
- defaults[opt.key] = opt.default;
- }
- return defaults;
-}
+export {
+ getChartOptions,
+ getDefaultChartSettings,
+ type ChartOptionDef,
+} from "./chart-options/index";
diff --git a/component/src/components/composed/chart-options/bar.ts b/component/src/components/composed/chart-options/bar.ts
new file mode 100644
index 00000000..0c4b4857
--- /dev/null
+++ b/component/src/components/composed/chart-options/bar.ts
@@ -0,0 +1,73 @@
+import {
+ type ChartOptionDef,
+ SHARED_SHOW_LEGEND,
+ SHARED_X_AXIS_LABEL,
+ SHARED_Y_AXIS_LABEL,
+ SHARED_SHOW_GRID_LINES,
+ SHARED_REFERENCE_LINES,
+} from "./shared";
+
+export const barOptions: ChartOptionDef[] = [
+ {
+ key: "orientation",
+ label: "Orientation",
+ type: "select",
+ default: "vertical",
+ category: "Layout",
+ description:
+ "Vertical bars grow upward; horizontal bars grow left-to-right.",
+ options: [
+ { label: "Vertical", value: "vertical" },
+ { label: "Horizontal", value: "horizontal" },
+ ],
+ },
+ {
+ key: "stacked",
+ label: "Stacked",
+ type: "boolean",
+ default: false,
+ category: "Layout",
+ description:
+ "Stack series on top of each other instead of placing them side by side.",
+ },
+ {
+ key: "barWidth",
+ label: "Bar Width (px, 0=auto)",
+ type: "number",
+ default: 0,
+ category: "Layout",
+ description:
+ "Width of each bar in pixels. Set to 0 to let the chart auto-size.",
+ },
+ {
+ key: "barGap",
+ label: "Bar Gap",
+ type: "text",
+ default: "30%",
+ category: "Layout",
+ description:
+ "Gap between bar groups as a percentage of the bar width (e.g. '30%').",
+ },
+ {
+ key: "showValues",
+ label: "Show Values",
+ type: "boolean",
+ default: false,
+ category: "Labels",
+ description: "Display the numeric value as a label on each bar.",
+ },
+ SHARED_SHOW_LEGEND,
+ SHARED_X_AXIS_LABEL,
+ SHARED_Y_AXIS_LABEL,
+ SHARED_SHOW_GRID_LINES,
+ {
+ key: "axisLabelRotation",
+ label: "Axis Label Rotation (°)",
+ type: "number",
+ default: -1,
+ category: "Labels",
+ description:
+ "Override axis label rotation angle (0-90). Set to -1 for automatic (rotates at 8+ categories).",
+ },
+ SHARED_REFERENCE_LINES,
+];
diff --git a/component/src/components/composed/chart-options/form.ts b/component/src/components/composed/chart-options/form.ts
new file mode 100644
index 00000000..d79b3589
--- /dev/null
+++ b/component/src/components/composed/chart-options/form.ts
@@ -0,0 +1,28 @@
+import { type ChartOptionDef } from "./shared";
+
+export const formOptions: ChartOptionDef[] = [
+ {
+ key: "submitButtonText",
+ label: "Submit Button Text",
+ type: "text",
+ default: "Submit",
+ category: "Form",
+ description: "Label for the form submit button.",
+ },
+ {
+ key: "successMessage",
+ label: "Success Message",
+ type: "text",
+ default: "Form submitted successfully",
+ category: "Form",
+ description: "Message shown after a successful submission.",
+ },
+ {
+ key: "resetOnSuccess",
+ label: "Reset on Success",
+ type: "boolean",
+ default: true,
+ category: "Form",
+ description: "Clear all form fields after a successful submission.",
+ },
+];
diff --git a/component/src/components/composed/chart-options/gauge.ts b/component/src/components/composed/chart-options/gauge.ts
new file mode 100644
index 00000000..fd7d3517
--- /dev/null
+++ b/component/src/components/composed/chart-options/gauge.ts
@@ -0,0 +1,69 @@
+import { type ChartOptionDef } from "./shared";
+
+export const gaugeOptions: ChartOptionDef[] = [
+ {
+ key: "min",
+ label: "Min Value",
+ type: "number",
+ default: 0,
+ category: "Range",
+ description: "Minimum value on the gauge scale.",
+ },
+ {
+ key: "max",
+ label: "Max Value",
+ type: "number",
+ default: 100,
+ category: "Range",
+ description: "Maximum value on the gauge scale.",
+ },
+ {
+ key: "showProgress",
+ label: "Show Progress Arc",
+ type: "boolean",
+ default: true,
+ category: "Style",
+ description: "Fill the gauge arc to show progress toward the maximum.",
+ },
+ {
+ key: "showPointer",
+ label: "Show Pointer",
+ type: "boolean",
+ default: true,
+ category: "Style",
+ description: "Display a needle pointer on the gauge.",
+ },
+ {
+ key: "showDetail",
+ label: "Show Value Detail",
+ type: "boolean",
+ default: true,
+ category: "Labels",
+ description: "Show the numeric value and name below the gauge.",
+ },
+ {
+ key: "startAngle",
+ label: "Start Angle (°)",
+ type: "number",
+ default: 225,
+ category: "Layout",
+ description: "Starting angle of the gauge arc in degrees (0 = 3 o'clock).",
+ },
+ {
+ key: "endAngle",
+ label: "End Angle (°)",
+ type: "number",
+ default: -45,
+ category: "Layout",
+ description: "Ending angle of the gauge arc in degrees.",
+ },
+ {
+ key: "thresholdZones",
+ label: "Threshold Zones (JSON)",
+ type: "text",
+ default: "",
+ category: "Style",
+ description:
+ 'Colored zones on the gauge arc: [{"value":30,"color":"#67e0e3"},{"value":70,"color":"#37a2da"},{"value":100,"color":"#fd666d"}]',
+ },
+];
diff --git a/component/src/components/composed/chart-options/graph.ts b/component/src/components/composed/chart-options/graph.ts
new file mode 100644
index 00000000..934d3293
--- /dev/null
+++ b/component/src/components/composed/chart-options/graph.ts
@@ -0,0 +1,52 @@
+import { type ChartOptionDef, SHARED_SHOW_LABELS } from "./shared";
+
+export const graphOptions: ChartOptionDef[] = [
+ {
+ key: "layout",
+ label: "Layout",
+ type: "select",
+ default: "force",
+ category: "Layout",
+ description:
+ "Algorithm used to position nodes: force simulation, circular ring, or hierarchical tree.",
+ options: [
+ { label: "Force", value: "force" },
+ { label: "Circular", value: "circular" },
+ { label: "Hierarchical", value: "hierarchical" },
+ ],
+ },
+ {
+ key: "nodeSize",
+ label: "Node Size",
+ type: "select",
+ default: "medium",
+ category: "Layout",
+ description: "Visual size of each node circle.",
+ options: [
+ { label: "Small", value: "small" },
+ { label: "Medium", value: "medium" },
+ { label: "Large", value: "large" },
+ ],
+ },
+ {
+ ...SHARED_SHOW_LABELS,
+ description: "Show the node label (first string property) on each node.",
+ },
+ {
+ key: "showRelationshipLabels",
+ label: "Show Relationship Labels",
+ type: "boolean",
+ default: true,
+ category: "Labels",
+ description: "Show the relationship type name on each edge.",
+ },
+ {
+ key: "physics",
+ label: "Enable Physics",
+ type: "boolean",
+ default: true,
+ category: "Style",
+ description:
+ "Enable physics simulation so nodes repel and edges act as springs.",
+ },
+];
diff --git a/component/src/components/composed/chart-options/iframe.ts b/component/src/components/composed/chart-options/iframe.ts
new file mode 100644
index 00000000..6d980df6
--- /dev/null
+++ b/component/src/components/composed/chart-options/iframe.ts
@@ -0,0 +1,31 @@
+import { type ChartOptionDef } from "./shared";
+
+export const iframeOptions: ChartOptionDef[] = [
+ {
+ key: "url",
+ label: "URL",
+ type: "text",
+ default: "",
+ category: "Content",
+ description:
+ "The URL of the external page to embed. Must be an https:// URL.",
+ },
+ {
+ key: "iframeTitle",
+ label: "Title",
+ type: "text",
+ default: "Embedded content",
+ category: "Content",
+ description:
+ "Accessible title for the embedded content (used by screen readers).",
+ },
+ {
+ key: "sandbox",
+ label: "Sandbox Policy",
+ type: "text",
+ default: "",
+ category: "Security",
+ description:
+ "HTML sandbox attributes controlling what the embedded page can do. Restrict for untrusted content.",
+ },
+];
diff --git a/component/src/components/composed/chart-options/index.ts b/component/src/components/composed/chart-options/index.ts
new file mode 100644
index 00000000..4ffe39ea
--- /dev/null
+++ b/component/src/components/composed/chart-options/index.ts
@@ -0,0 +1,81 @@
+import type { ChartOptionDef } from "./shared";
+export type { ChartOptionDef } from "./shared";
+import {
+ behaviorOptions,
+ appearanceOptions,
+ accessibilityOptions,
+ dataZoomOptions,
+ tooltipFormatOptions,
+} from "./shared";
+import { barOptions } from "./bar";
+import { lineOptions } from "./line";
+import { pieOptions } from "./pie";
+import { singleValueOptions } from "./single-value";
+import { graphOptions } from "./graph";
+import { mapOptions } from "./map";
+import { tableOptions } from "./table";
+import { jsonOptions } from "./json";
+import { parameterSelectOptions } from "./parameter-select";
+import { formOptions } from "./form";
+import { markdownOptions } from "./markdown";
+import { iframeOptions } from "./iframe";
+import { gaugeOptions } from "./gauge";
+import { sankeyOptions } from "./sankey";
+import { sunburstOptions } from "./sunburst";
+import { radarOptions } from "./radar";
+import { treemapOptions } from "./treemap";
+
+const chartOptionsRegistry: Record = {
+ bar: [
+ ...barOptions,
+ ...dataZoomOptions,
+ ...tooltipFormatOptions,
+ ...behaviorOptions,
+ ...appearanceOptions,
+ ...accessibilityOptions,
+ ],
+ line: [
+ ...lineOptions,
+ ...dataZoomOptions,
+ ...tooltipFormatOptions,
+ ...behaviorOptions,
+ ...appearanceOptions,
+ ...accessibilityOptions,
+ ],
+ pie: [
+ ...pieOptions,
+ ...tooltipFormatOptions,
+ ...behaviorOptions,
+ ...appearanceOptions,
+ ...accessibilityOptions,
+ ],
+ "single-value": [...singleValueOptions, ...behaviorOptions],
+ graph: [...graphOptions, ...behaviorOptions],
+ map: [...mapOptions, ...behaviorOptions],
+ table: [...tableOptions, ...behaviorOptions],
+ json: [...jsonOptions, ...behaviorOptions],
+ "parameter-select": parameterSelectOptions,
+ form: formOptions,
+ markdown: markdownOptions,
+ iframe: iframeOptions,
+ gauge: [...gaugeOptions, ...behaviorOptions, ...appearanceOptions],
+ sankey: [...sankeyOptions, ...behaviorOptions, ...appearanceOptions],
+ sunburst: [...sunburstOptions, ...behaviorOptions, ...appearanceOptions],
+ radar: [...radarOptions, ...behaviorOptions, ...appearanceOptions],
+ treemap: [...treemapOptions, ...behaviorOptions, ...appearanceOptions],
+};
+
+export function getChartOptions(chartType: string): ChartOptionDef[] {
+ return chartOptionsRegistry[chartType] ?? [];
+}
+
+export function getDefaultChartSettings(
+ chartType: string,
+): Record {
+ const options = getChartOptions(chartType);
+ const defaults: Record = {};
+ for (const opt of options) {
+ defaults[opt.key] = opt.default;
+ }
+ return defaults;
+}
diff --git a/component/src/components/composed/chart-options/json.ts b/component/src/components/composed/chart-options/json.ts
new file mode 100644
index 00000000..53c8d9a7
--- /dev/null
+++ b/component/src/components/composed/chart-options/json.ts
@@ -0,0 +1,47 @@
+import { type ChartOptionDef } from "./shared";
+
+export const jsonOptions: ChartOptionDef[] = [
+ {
+ key: "initialExpanded",
+ label: "Initial Expand Depth",
+ type: "number",
+ default: 2,
+ category: "Display",
+ description:
+ "How many levels deep the JSON tree is expanded when first rendered (0 = collapsed).",
+ },
+ {
+ key: "fontSize",
+ label: "Font Size",
+ type: "select",
+ default: "sm",
+ category: "Display",
+ description: "Font size used for the JSON syntax highlighting.",
+ options: [
+ { label: "Small", value: "sm" },
+ { label: "Medium", value: "md" },
+ { label: "Large", value: "lg" },
+ ],
+ },
+ {
+ key: "showCopyButton",
+ label: "Show Copy Button",
+ type: "boolean",
+ default: true,
+ category: "Display",
+ description:
+ "Show a button to copy the full JSON payload to the clipboard.",
+ },
+ {
+ key: "theme",
+ label: "Theme",
+ type: "select",
+ default: "dark",
+ category: "Display",
+ description: "Colour theme for the JSON syntax highlighting.",
+ options: [
+ { label: "Dark", value: "dark" },
+ { label: "Light", value: "light" },
+ ],
+ },
+];
diff --git a/component/src/components/composed/chart-options/line.ts b/component/src/components/composed/chart-options/line.ts
new file mode 100644
index 00000000..b4046183
--- /dev/null
+++ b/component/src/components/composed/chart-options/line.ts
@@ -0,0 +1,82 @@
+import {
+ type ChartOptionDef,
+ SHARED_SHOW_GRID_LINES,
+ SHARED_X_AXIS_LABEL,
+ SHARED_Y_AXIS_LABEL,
+ SHARED_SHOW_LEGEND,
+ SHARED_REFERENCE_LINES,
+} from "./shared";
+
+export const lineOptions: ChartOptionDef[] = [
+ {
+ key: "smooth",
+ label: "Smooth Curve",
+ type: "boolean",
+ default: false,
+ category: "Style",
+ description:
+ "Render lines as smooth Bézier curves instead of straight segments.",
+ },
+ {
+ key: "area",
+ label: "Fill Area",
+ type: "boolean",
+ default: false,
+ category: "Style",
+ description:
+ "Fill the area beneath the line to emphasise volume over time.",
+ },
+ {
+ key: "lineWidth",
+ label: "Line Width (px)",
+ type: "number",
+ default: 2,
+ category: "Style",
+ description: "Stroke width of the line in pixels.",
+ },
+ {
+ key: "stepped",
+ label: "Stepped Line",
+ type: "boolean",
+ default: false,
+ category: "Style",
+ description:
+ "Draw the line as a step function — useful for discrete state changes.",
+ },
+ {
+ key: "showPoints",
+ label: "Show Data Points",
+ type: "boolean",
+ default: false,
+ category: "Style",
+ description: "Draw a dot at each data point along the line.",
+ },
+ SHARED_SHOW_GRID_LINES,
+ SHARED_X_AXIS_LABEL,
+ SHARED_Y_AXIS_LABEL,
+ SHARED_SHOW_LEGEND,
+ SHARED_REFERENCE_LINES,
+ {
+ key: "samplingThreshold",
+ label: "Sampling Threshold",
+ type: "number",
+ default: 1000,
+ category: "Performance",
+ description:
+ "Enable LTTB downsampling when data points exceed this count. Set to 0 to disable.",
+ },
+ {
+ key: "samplingMethod",
+ label: "Sampling Method",
+ type: "select",
+ default: "lttb",
+ options: [
+ { label: "LTTB", value: "lttb" },
+ { label: "Average", value: "average" },
+ { label: "Max", value: "max" },
+ { label: "Min", value: "min" },
+ ],
+ category: "Performance",
+ description: "Algorithm for downsampling large datasets.",
+ },
+];
diff --git a/component/src/components/composed/chart-options/map.ts b/component/src/components/composed/chart-options/map.ts
new file mode 100644
index 00000000..ad148c1a
--- /dev/null
+++ b/component/src/components/composed/chart-options/map.ts
@@ -0,0 +1,78 @@
+import { type ChartOptionDef } from "./shared";
+
+export const mapOptions: ChartOptionDef[] = [
+ {
+ key: "tileLayer",
+ label: "Tile Layer",
+ type: "select",
+ default: "osm",
+ category: "Map",
+ description:
+ "Base-map tile provider. OpenStreetMap is open and free; Carto variants are cleaner for data overlays.",
+ options: [
+ { label: "OpenStreetMap", value: "osm" },
+ { label: "Carto Light", value: "carto-light" },
+ { label: "Carto Dark", value: "carto-dark" },
+ ],
+ },
+ {
+ key: "zoom",
+ label: "Default Zoom",
+ type: "number",
+ default: 3,
+ category: "Map",
+ description:
+ "Initial zoom level when the map first renders (1 = world view, 18 = street level).",
+ },
+ {
+ key: "minZoom",
+ label: "Min Zoom",
+ type: "number",
+ default: 2,
+ category: "Map",
+ description: "Minimum zoom level the user can zoom out to.",
+ },
+ {
+ key: "maxZoom",
+ label: "Max Zoom",
+ type: "number",
+ default: 18,
+ category: "Map",
+ description: "Maximum zoom level the user can zoom in to.",
+ },
+ {
+ key: "autoFitBounds",
+ label: "Auto-fit Bounds",
+ type: "boolean",
+ default: true,
+ category: "Map",
+ description:
+ "Automatically pan and zoom to fit all markers on the initial load.",
+ },
+ {
+ key: "markerSize",
+ label: "Marker Size (px)",
+ type: "number",
+ default: 6,
+ category: "Markers",
+ description: "Radius of each map marker circle in pixels.",
+ },
+ {
+ key: "clusterMarkers",
+ label: "Cluster Markers",
+ type: "boolean",
+ default: false,
+ category: "Markers",
+ description:
+ "Group nearby markers into a single cluster badge at lower zoom levels.",
+ },
+ {
+ key: "showPopup",
+ label: "Show Popup on Click",
+ type: "boolean",
+ default: true,
+ category: "Markers",
+ description:
+ "Show a popup with the row data when the user clicks a marker.",
+ },
+];
diff --git a/component/src/components/composed/chart-options/markdown.ts b/component/src/components/composed/chart-options/markdown.ts
new file mode 100644
index 00000000..f2c9b157
--- /dev/null
+++ b/component/src/components/composed/chart-options/markdown.ts
@@ -0,0 +1,13 @@
+import { type ChartOptionDef } from "./shared";
+
+export const markdownOptions: ChartOptionDef[] = [
+ {
+ key: "content",
+ label: "Markdown Content",
+ type: "text",
+ default: "",
+ category: "Content",
+ description:
+ "Markdown text to render. Supports headings, bold, italic, links, lists, code blocks, and blockquotes.",
+ },
+];
diff --git a/component/src/components/composed/chart-options/parameter-select.ts b/component/src/components/composed/chart-options/parameter-select.ts
new file mode 100644
index 00000000..9329ae48
--- /dev/null
+++ b/component/src/components/composed/chart-options/parameter-select.ts
@@ -0,0 +1,31 @@
+import { type ChartOptionDef } from "./shared";
+
+export const parameterSelectOptions: ChartOptionDef[] = [
+ {
+ key: "placeholder",
+ label: "Placeholder",
+ type: "text",
+ default: "",
+ category: "Parameter",
+ description:
+ "Hint text shown inside the selector when no value has been chosen.",
+ },
+ {
+ key: "searchable",
+ label: "Search-as-you-type",
+ type: "boolean",
+ default: true,
+ category: "Parameter",
+ description:
+ "Allow the user to type to filter the option list in real time.",
+ },
+ {
+ key: "defaultValue",
+ label: "Default Value",
+ type: "text",
+ default: "",
+ category: "Parameter",
+ description:
+ "Value used on dashboard load when no selection has been made. Leave empty for no default.",
+ },
+];
diff --git a/component/src/components/composed/chart-options/pie.ts b/component/src/components/composed/chart-options/pie.ts
new file mode 100644
index 00000000..f65ed5cb
--- /dev/null
+++ b/component/src/components/composed/chart-options/pie.ts
@@ -0,0 +1,82 @@
+import { type ChartOptionDef, SHARED_SHOW_LEGEND } from "./shared";
+
+export const pieOptions: ChartOptionDef[] = [
+ {
+ key: "donut",
+ label: "Donut Style",
+ type: "boolean",
+ default: false,
+ category: "Style",
+ description:
+ "Cut a circular hole in the centre to render the chart as a donut.",
+ },
+ {
+ key: "roseMode",
+ label: "Rose/Nightingale Mode",
+ type: "boolean",
+ default: false,
+ category: "Style",
+ description:
+ "Vary each slice's radius by its value (Nightingale / rose chart).",
+ },
+ {
+ key: "labelPosition",
+ label: "Label Position",
+ type: "select",
+ default: "outside",
+ category: "Labels",
+ description: "Where to place the slice labels relative to the chart.",
+ options: [
+ { label: "Outside", value: "outside" },
+ { label: "Inside", value: "inside" },
+ { label: "Center", value: "center" },
+ ],
+ },
+ {
+ key: "showLabel",
+ label: "Show Labels",
+ type: "boolean",
+ default: true,
+ category: "Labels",
+ description: "Show the category name on each slice.",
+ },
+ {
+ key: "showPercentage",
+ label: "Show Percentage",
+ type: "boolean",
+ default: true,
+ category: "Labels",
+ description: "Show the percentage value on each slice.",
+ },
+ {
+ ...SHARED_SHOW_LEGEND,
+ description: "Show the chart legend identifying each slice.",
+ },
+ {
+ key: "sortSlices",
+ label: "Sort Slices by Value",
+ type: "boolean",
+ default: false,
+ category: "Layout",
+ description:
+ "Sort slices by value (largest first) for a cleaner visual layout.",
+ },
+ {
+ key: "topN",
+ label: "Top N Slices",
+ type: "number",
+ default: 0,
+ category: "Layout",
+ description:
+ "Show only the top N slices and group the rest into 'Other'. Set to 0 to show all.",
+ },
+ {
+ key: "donutCenterText",
+ label: "Donut Center Text",
+ type: "text",
+ default: "",
+ category: "Labels",
+ description:
+ "Custom text in the donut center. Leave blank to show the total.",
+ },
+];
diff --git a/component/src/components/composed/chart-options/radar.ts b/component/src/components/composed/chart-options/radar.ts
new file mode 100644
index 00000000..7f58ea31
--- /dev/null
+++ b/component/src/components/composed/chart-options/radar.ts
@@ -0,0 +1,36 @@
+import { type ChartOptionDef, SHARED_SHOW_LEGEND } from "./shared";
+
+export const radarOptions: ChartOptionDef[] = [
+ {
+ key: "shape",
+ label: "Shape",
+ type: "select",
+ default: "polygon",
+ category: "Style",
+ description: "Outline shape of the radar grid.",
+ options: [
+ { label: "Polygon", value: "polygon" },
+ { label: "Circle", value: "circle" },
+ ],
+ },
+ {
+ key: "filled",
+ label: "Fill Area",
+ type: "boolean",
+ default: true,
+ category: "Style",
+ description: "Fill the area enclosed by the data polygon.",
+ },
+ {
+ ...SHARED_SHOW_LEGEND,
+ description: "Show the legend identifying each series.",
+ },
+ {
+ key: "showValues",
+ label: "Show Values on Points",
+ type: "boolean",
+ default: false,
+ category: "Labels",
+ description: "Display the numeric value at each data point on the radar.",
+ },
+];
diff --git a/component/src/components/composed/chart-options/sankey.ts b/component/src/components/composed/chart-options/sankey.ts
new file mode 100644
index 00000000..ba347b13
--- /dev/null
+++ b/component/src/components/composed/chart-options/sankey.ts
@@ -0,0 +1,38 @@
+import { type ChartOptionDef, SHARED_SHOW_LABELS } from "./shared";
+
+export const sankeyOptions: ChartOptionDef[] = [
+ {
+ key: "orient",
+ label: "Orientation",
+ type: "select",
+ default: "horizontal",
+ category: "Layout",
+ description:
+ "Direction of the flow: left-to-right (horizontal) or top-to-bottom (vertical).",
+ options: [
+ { label: "Horizontal", value: "horizontal" },
+ { label: "Vertical", value: "vertical" },
+ ],
+ },
+ {
+ ...SHARED_SHOW_LABELS,
+ label: "Show Node Labels",
+ description: "Show the node name alongside each block.",
+ },
+ {
+ key: "nodeWidth",
+ label: "Node Width (px)",
+ type: "number",
+ default: 20,
+ category: "Layout",
+ description: "Width of each node block in pixels.",
+ },
+ {
+ key: "nodeGap",
+ label: "Node Gap (px)",
+ type: "number",
+ default: 8,
+ category: "Layout",
+ description: "Vertical gap between nodes at the same level in pixels.",
+ },
+];
diff --git a/component/src/components/composed/chart-options/shared.ts b/component/src/components/composed/chart-options/shared.ts
new file mode 100644
index 00000000..4eb5ca4a
--- /dev/null
+++ b/component/src/components/composed/chart-options/shared.ts
@@ -0,0 +1,163 @@
+import { COLOR_PALETTES } from "@/charts/palettes";
+
+export interface ChartOptionDef {
+ key: string;
+ label: string;
+ type: "boolean" | "select" | "text" | "number" | "column-multi-select";
+ default: unknown;
+ category: string;
+ /** Only for type: "select" */
+ options?: { label: string; value: string }[];
+ /** Short description shown in a tooltip next to the option label. */
+ description?: string;
+}
+
+// ---------------------------------------------------------------------------
+// Shared option constants — reused across multiple chart type definitions
+// to avoid duplication.
+// ---------------------------------------------------------------------------
+
+export const SHARED_SHOW_LEGEND: ChartOptionDef = {
+ key: "showLegend",
+ label: "Show Legend",
+ type: "boolean",
+ default: true,
+ category: "Labels",
+ description: "Show the chart legend identifying each data series.",
+};
+
+export const SHARED_X_AXIS_LABEL: ChartOptionDef = {
+ key: "xAxisLabel",
+ label: "X-Axis Label",
+ type: "text",
+ default: "",
+ category: "Labels",
+ description: "Custom label displayed below the horizontal axis.",
+};
+
+export const SHARED_Y_AXIS_LABEL: ChartOptionDef = {
+ key: "yAxisLabel",
+ label: "Y-Axis Label",
+ type: "text",
+ default: "",
+ category: "Labels",
+ description: "Custom label displayed beside the vertical axis.",
+};
+
+export const SHARED_SHOW_GRID_LINES: ChartOptionDef = {
+ key: "showGridLines",
+ label: "Show Grid Lines",
+ type: "boolean",
+ default: true,
+ category: "Style",
+ description: "Show faint horizontal reference lines behind the chart.",
+};
+
+export const SHARED_REFERENCE_LINES: ChartOptionDef = {
+ key: "referenceLines",
+ label: "Reference Lines (JSON)",
+ type: "text",
+ default: "",
+ category: "Annotations",
+ description:
+ 'Horizontal reference lines as JSON: [{"value":50,"label":"Target","color":"#ff0000"}]',
+};
+
+export const SHARED_SHOW_LABELS: ChartOptionDef = {
+ key: "showLabels",
+ label: "Show Labels",
+ type: "boolean",
+ default: true,
+ category: "Labels",
+ description: "Show the name label on each element.",
+};
+
+/** DataZoom option for axis-based charts (bar, line). */
+export const dataZoomOptions: ChartOptionDef[] = [
+ {
+ key: "enableDataZoom",
+ label: "Enable Scroll Zoom",
+ type: "boolean",
+ default: false,
+ category: "Interaction",
+ description:
+ "Allow scroll-to-zoom on the data axis to explore large datasets.",
+ },
+];
+
+/** Shared number formatting options for tooltip values on axis-based charts. */
+export const tooltipFormatOptions: ChartOptionDef[] = [
+ {
+ key: "decimalPlaces",
+ label: "Decimal Places",
+ type: "number",
+ default: -1,
+ category: "Labels",
+ description:
+ "Fixed number of decimal places in tooltips (0-6). Set to -1 for automatic.",
+ },
+];
+
+/** Shared behavior options available to all chart types except parameter-select and form. */
+export const behaviorOptions: ChartOptionDef[] = [
+ {
+ key: "showRefreshButton",
+ label: "Show Refresh Button",
+ type: "boolean",
+ default: false,
+ category: "Behavior",
+ description:
+ "Display a refresh button in the widget header to manually re-fetch the query.",
+ },
+ {
+ key: "manualRun",
+ label: "Manual Run",
+ type: "boolean",
+ default: false,
+ category: "Behavior",
+ description:
+ "Start with the query disabled. A 'Run Query' button must be clicked to execute. On parameter change the widget resets to the overlay.",
+ },
+ {
+ key: "cacheMode",
+ label: "Cache Mode",
+ type: "select",
+ default: "ttl",
+ category: "Behavior",
+ description:
+ "TTL re-fetches data based on the cache timeout. Forever fetches once and caches until manually refreshed.",
+ options: [
+ { label: "TTL (time-based)", value: "ttl" },
+ { label: "Forever (until refresh)", value: "forever" },
+ ],
+ },
+];
+
+/** Appearance options (color palette) for ECharts-based chart types. */
+export const appearanceOptions: ChartOptionDef[] = [
+ {
+ key: "colorPalette",
+ label: "Color Palette",
+ type: "select",
+ default: "deep-ocean",
+ category: "Appearance",
+ description: "Color scheme for chart series and data points.",
+ options: Object.entries(COLOR_PALETTES).map(([k, v]) => ({
+ value: k,
+ label: v.label,
+ })),
+ },
+];
+
+/** Accessibility options for ECharts-based chart types. */
+export const accessibilityOptions: ChartOptionDef[] = [
+ {
+ key: "colorblindMode",
+ label: "Colorblind Mode",
+ type: "boolean",
+ default: false,
+ category: "Accessibility",
+ description:
+ "Overlay distinct patterns on chart elements so data series are distinguishable without relying on color alone.",
+ },
+];
diff --git a/component/src/components/composed/chart-options/single-value.ts b/component/src/components/composed/chart-options/single-value.ts
new file mode 100644
index 00000000..347c8fe3
--- /dev/null
+++ b/component/src/components/composed/chart-options/single-value.ts
@@ -0,0 +1,75 @@
+import { type ChartOptionDef } from "./shared";
+
+export const singleValueOptions: ChartOptionDef[] = [
+ {
+ key: "title",
+ label: "Title",
+ type: "text",
+ default: "",
+ category: "Display",
+ description: "Custom heading shown above the value. Leave blank to hide.",
+ },
+ {
+ key: "prefix",
+ label: "Prefix",
+ type: "text",
+ default: "",
+ category: "Display",
+ description: "Text prepended to the value (e.g. '$', '€').",
+ },
+ {
+ key: "suffix",
+ label: "Suffix",
+ type: "text",
+ default: "",
+ category: "Display",
+ description: "Text appended to the value (e.g. '%', ' items').",
+ },
+ {
+ key: "decimalPlaces",
+ label: "Decimal Places",
+ type: "number",
+ default: -1,
+ category: "Display",
+ description:
+ "Fixed number of decimal places (0-6). Set to -1 for automatic.",
+ },
+ {
+ key: "fontSize",
+ label: "Font Size",
+ type: "select",
+ default: "lg",
+ category: "Display",
+ description: "Size of the main displayed value.",
+ options: [
+ { label: "Small", value: "sm" },
+ { label: "Medium", value: "md" },
+ { label: "Large", value: "lg" },
+ { label: "Extra Large", value: "xl" },
+ ],
+ },
+ {
+ key: "numberFormat",
+ label: "Number Format",
+ type: "select",
+ default: "plain",
+ category: "Display",
+ description:
+ "How to format the numeric value — plain, comma-separated, compact (1.2k), or percentage.",
+ options: [
+ { label: "Plain", value: "plain" },
+ { label: "Comma", value: "comma" },
+ { label: "Compact", value: "compact" },
+ { label: "Percent", value: "percent" },
+ ],
+ },
+ {
+ key: "trendEnabled",
+ label: "Show Trend Indicator",
+ type: "boolean",
+ default: false,
+ category: "Display",
+ description:
+ "Show a trend arrow comparing the current value to the previous period (requires 2 rows in the query result).",
+ },
+];
diff --git a/component/src/components/composed/chart-options/sunburst.ts b/component/src/components/composed/chart-options/sunburst.ts
new file mode 100644
index 00000000..fbcada7b
--- /dev/null
+++ b/component/src/components/composed/chart-options/sunburst.ts
@@ -0,0 +1,26 @@
+import { type ChartOptionDef, SHARED_SHOW_LABELS } from "./shared";
+
+export const sunburstOptions: ChartOptionDef[] = [
+ { ...SHARED_SHOW_LABELS, description: "Show the name of each segment." },
+ {
+ key: "sort",
+ label: "Sort Segments",
+ type: "select",
+ default: "desc",
+ category: "Layout",
+ description: "Order in which segments are arranged around the chart.",
+ options: [
+ { label: "Largest First", value: "desc" },
+ { label: "Smallest First", value: "asc" },
+ { label: "Natural (data order)", value: "none" },
+ ],
+ },
+ {
+ key: "highlightOnHover",
+ label: "Highlight on Hover",
+ type: "boolean",
+ default: true,
+ category: "Style",
+ description: "Enlarge and emphasise a segment when hovered.",
+ },
+];
diff --git a/component/src/components/composed/chart-options/table.ts b/component/src/components/composed/chart-options/table.ts
new file mode 100644
index 00000000..1cf0d73b
--- /dev/null
+++ b/component/src/components/composed/chart-options/table.ts
@@ -0,0 +1,105 @@
+import { type ChartOptionDef } from "./shared";
+
+export const tableOptions: ChartOptionDef[] = [
+ {
+ key: "enableSorting",
+ label: "Enable Sorting",
+ type: "boolean",
+ default: true,
+ category: "Features",
+ description:
+ "Allow clicking column headers to sort rows ascending or descending.",
+ },
+ {
+ key: "enableSelection",
+ label: "Row Selection",
+ type: "boolean",
+ default: false,
+ category: "Features",
+ description: "Allow selecting individual rows by clicking them.",
+ },
+ {
+ key: "enableGlobalFilter",
+ label: "Global Search",
+ type: "boolean",
+ default: true,
+ category: "Features",
+ description: "Show a search box that filters all rows across all columns.",
+ },
+ {
+ key: "enableColumnFilters",
+ label: "Column Filters",
+ type: "boolean",
+ default: true,
+ category: "Features",
+ description: "Show per-column filter inputs below each column header.",
+ },
+ {
+ key: "enableColumnResizing",
+ label: "Column Resizing",
+ type: "boolean",
+ default: false,
+ category: "Features",
+ description:
+ "Allow drag-to-resize column borders. Double-click to auto-fit.",
+ },
+ {
+ key: "enablePagination",
+ label: "Enable Pagination",
+ type: "boolean",
+ default: true,
+ category: "Pagination",
+ description:
+ "Show Previous / Next controls to page through large result sets.",
+ },
+ {
+ key: "pageSize",
+ label: "Page Size",
+ type: "number",
+ default: 10,
+ category: "Pagination",
+ description: "Number of rows shown per page when pagination is enabled.",
+ },
+ {
+ key: "emptyMessage",
+ label: "Empty Message",
+ type: "text",
+ default: "No results",
+ category: "Display",
+ description: "Text displayed when the query returns no rows.",
+ },
+ {
+ key: "enableGrouping",
+ label: "Enable Row Grouping",
+ type: "boolean",
+ default: false,
+ category: "Grouping",
+ description:
+ "Allow grouping rows by column values. Columns to group by are set in the groupBy field below.",
+ },
+ {
+ key: "groupBy",
+ label: "Group By Columns",
+ type: "column-multi-select",
+ default: [],
+ category: "Grouping",
+ description:
+ "Select columns to group by. Nested grouping is supported — order determines nesting hierarchy.",
+ },
+ {
+ key: "aggregationFn",
+ label: "Aggregation Function",
+ type: "select",
+ default: "sum",
+ category: "Grouping",
+ description: "Aggregation function for numeric columns in grouped rows.",
+ options: [
+ { label: "Sum", value: "sum" },
+ { label: "Average", value: "mean" },
+ { label: "Median", value: "median" },
+ { label: "Count", value: "count" },
+ { label: "Min", value: "min" },
+ { label: "Max", value: "max" },
+ ],
+ },
+];
diff --git a/component/src/components/composed/chart-options/treemap.ts b/component/src/components/composed/chart-options/treemap.ts
new file mode 100644
index 00000000..d39170d9
--- /dev/null
+++ b/component/src/components/composed/chart-options/treemap.ts
@@ -0,0 +1,36 @@
+import { type ChartOptionDef, SHARED_SHOW_LABELS } from "./shared";
+
+export const treemapOptions: ChartOptionDef[] = [
+ { ...SHARED_SHOW_LABELS, description: "Show the name of each rectangle." },
+ {
+ key: "showBreadcrumb",
+ label: "Show Breadcrumb",
+ type: "boolean",
+ default: true,
+ category: "Labels",
+ description:
+ "Show the navigation breadcrumb when drilling down into nested data.",
+ },
+ {
+ key: "showValues",
+ label: "Show Values",
+ type: "boolean",
+ default: false,
+ category: "Labels",
+ description: "Display the numeric value inside each rectangle.",
+ },
+ {
+ key: "colorSaturation",
+ label: "Color Saturation Range",
+ type: "select",
+ default: "medium",
+ category: "Style",
+ description:
+ "Controls the saturation gradient used to shade child rectangles within a parent.",
+ options: [
+ { label: "Low", value: "low" },
+ { label: "Medium", value: "medium" },
+ { label: "High", value: "high" },
+ ],
+ },
+];