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" }, + ], + }, +];