diff --git a/app/e2e/fixtures.ts b/app/e2e/fixtures.ts index 68f330f5..1c43c746 100644 --- a/app/e2e/fixtures.ts +++ b/app/e2e/fixtures.ts @@ -148,8 +148,9 @@ export async function typeInEditor( return; } - // Strategy 2: Keyboard fallback (for environments where cmView is not accessible) - if (dispatched === "no-view") { + // Strategy 2: Keyboard fallback (for environments where cmView is not accessible + // or when the view is temporarily readonly during initialization) + if (dispatched === "no-view" || dispatched === "readonly") { await expect(cm).toHaveAttribute("contenteditable", "true", { timeout: 2_000 }); await cm.click(); await page.keyboard.press("ControlOrMeta+a"); @@ -162,7 +163,7 @@ export async function typeInEditor( return; } - // Retry-worthy states: no-editor, readonly, dispatch-failed + // Retry-worthy states: no-editor, dispatch-failed throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); }).toPass({ timeout: 20_000 }); } diff --git a/app/src/lib/__tests__/chart-registry.test.ts b/app/src/lib/__tests__/chart-registry.test.ts index 84da75d1..2e2a5c1b 100644 --- a/app/src/lib/__tests__/chart-registry.test.ts +++ b/app/src/lib/__tests__/chart-registry.test.ts @@ -1491,21 +1491,137 @@ describe("radar transform", () => { expect(result.indicators[0].name).toBe("X"); }); - it("auto-scales max from data when max column is missing", () => { + 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[] }; // 80 * 1.1 = 88, ceil → 88 expect(result.indicators[0].max).toBe(88); }); - it("handles flat tabular data without indicator column (uses column names as indicators)", () => { + it("uses global max across all indicators for relative comparison", () => { + const data = [ + { indicator: "ACTED_IN", value: 172 }, + { indicator: "PRODUCED", value: 15 }, + { indicator: "DIRECTED", value: 44 }, + { indicator: "WROTE", value: 10 }, + { indicator: "REVIEWED", value: 9 }, + ]; + 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); + // All indicators should share the same max + for (const ind of result.indicators) { + expect(ind.max).toBe(globalMax); + } + // 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 + }); + + it("preserves explicit max column values when provided", () => { + const data = [ + { indicator: "Speed", value: 80, max: 200 }, + { indicator: "Strength", value: 40, max: 150 }, + ]; + 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 }>; 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) { + expect(ind.max).toBe(globalMax); + } expect(result.indicators.map((i) => i.name)).toContain("Speed"); expect(result.indicators.map((i) => i.name)).toContain("Strength"); expect(result.series[0].values).toHaveLength(3); }); + it("falls back to globalMax when max column contains null/undefined/NaN", () => { + // When the max column exists but values are invalid (null/NaN/0), + // indicators should use globalMax instead of treating 0 or NaN as explicit. + const data = [ + { indicator: "Speed", value: 80, max: null }, + { indicator: "Strength", value: 60, max: undefined }, + { indicator: "Agility", value: 90, max: NaN }, + ]; + 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) { + expect(ind.max).toBe(globalMax); + } + }); + + it("falls back to globalMax when max column value is 0", () => { + const data = [ + { indicator: "Speed", value: 50, max: 0 }, + { indicator: "Strength", value: 30, max: 0 }, + ]; + 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) { + expect(ind.max).toBe(globalMax); + } + }); + + it("falls back to globalMax when max column value is negative", () => { + const data = [ + { indicator: "Speed", value: 50, max: -100 }, + { indicator: "Strength", value: 30, max: -50 }, + ]; + 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); + } + }); + + it("mixes explicit and fallback max when some indicators have valid max", () => { + const data = [ + { indicator: "Speed", value: 80, max: 200 }, + { indicator: "Strength", value: 60, max: null }, + { indicator: "Agility", value: 90, max: 150 }, + ]; + 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[1].max).toBe(globalMax); // Strength — fallback + expect(result.indicators[2].max).toBe(150); // Agility — explicit + }); + + it("falls back to globalMax when max column contains non-numeric strings", () => { + const data = [ + { 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 globalMax = Math.ceil(80 * 1.1); + for (const ind of result.indicators) { + expect(ind.max).toBe(globalMax); + } + }); + + it("falls back to globalMax when max column contains Infinity", () => { + const data = [ + { indicator: "Speed", value: 80, max: Infinity }, + { indicator: "Strength", value: 60, max: -Infinity }, + ]; + 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); + } + }); + it("transformWithMapping returns same result as transform", () => { const data = [{ indicator: "Speed", value: 80, max: 100 }]; const result = chartRegistry.radar.transformWithMapping(data, {}); diff --git a/app/src/lib/chart-registry.ts b/app/src/lib/chart-registry.ts index 56507c78..753c8c32 100644 --- a/app/src/lib/chart-registry.ts +++ b/app/src/lib/chart-registry.ts @@ -494,21 +494,23 @@ function transformToRadarData(data: unknown): unknown { const serName = seriesKey ? String(normalizeValue(r[seriesKey]) ?? "Default") : "Default"; if (maxKey) { - const explicitMax = Number(r[maxKey]) || 100; - if (!indicatorExplicitMax.has(indName)) indicatorExplicitMax.set(indName, explicitMax); + const explicitMax = Number(r[maxKey]); + if (Number.isFinite(explicitMax) && explicitMax > 0 && !indicatorExplicitMax.has(indName)) { + indicatorExplicitMax.set(indName, explicitMax); + } } indicatorMaxFromData.set(indName, Math.max(indicatorMaxFromData.get(indName) ?? 0, val)); if (!seriesMap.has(serName)) seriesMap.set(serName, new Map()); seriesMap.get(serName)!.set(indName, val); } - // Use explicit max if provided, otherwise auto-scale from observed values (+10% headroom) + // Use explicit max if provided, otherwise use a single global max across all + // indicators so relative magnitudes are visible (e.g. 172 vs 9). const indicatorEntries = Array.from(indicatorMaxFromData.keys()); + const globalMax = Math.ceil(Math.max(...indicatorMaxFromData.values()) * 1.1) || 100; const indicators = indicatorEntries.map((name) => ({ name, - max: maxKey && indicatorExplicitMax.has(name) - ? indicatorExplicitMax.get(name)! - : Math.ceil((indicatorMaxFromData.get(name) ?? 100) * 1.1) || 100, + max: indicatorExplicitMax.get(name) ?? globalMax, })); const series = Array.from(seriesMap.entries()).map(([name, valMap]) => ({ name, @@ -519,17 +521,18 @@ function transformToRadarData(data: unknown): unknown { } // Wide-format: each column is an indicator, each row is a series - // Auto-scale max from observed values per column (+10% headroom) - const maxPerCol = new Map(); + // Use a single global max so all axes share the same scale + let wideGlobalMax = 0; for (const r of records) { for (const k of keys) { const v = Number(r[k]) || 0; - maxPerCol.set(k, Math.max(maxPerCol.get(k) ?? 0, v)); + if (v > wideGlobalMax) wideGlobalMax = v; } } + const wideMax = Math.ceil(wideGlobalMax * 1.1) || 100; const indicators = keys.map((k) => ({ name: k, - max: Math.ceil((maxPerCol.get(k) ?? 100) * 1.1) || 100, + max: wideMax, })); const series = records.map((r, i) => ({ name: String(i + 1), diff --git a/component/src/charts/__tests__/base-chart.test.tsx b/component/src/charts/__tests__/base-chart.test.tsx index b2b37bbe..8514118a 100644 --- a/component/src/charts/__tests__/base-chart.test.tsx +++ b/component/src/charts/__tests__/base-chart.test.tsx @@ -35,6 +35,8 @@ vi.mock("echarts/components", () => ({ DataZoomComponent: vi.fn(), AriaComponent: vi.fn(), RadarComponent: vi.fn(), + MarkLineComponent: vi.fn(), + GraphicComponent: vi.fn(), })); describe("BaseChart", () => { diff --git a/component/src/charts/__tests__/format-number.test.ts b/component/src/charts/__tests__/format-number.test.ts new file mode 100644 index 00000000..48e735da --- /dev/null +++ b/component/src/charts/__tests__/format-number.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { formatNumber, buildTooltipFormatter } from "../chart-utils"; + +describe("formatNumber", () => { + it("returns plain number by default", () => { + expect(formatNumber(1234)).toBe("1234"); + }); + + it("respects decimalPlaces", () => { + expect(formatNumber(3.14159, { decimalPlaces: 2 })).toBe("3.14"); + }); + + it("pads with zeros when decimalPlaces exceeds precision", () => { + expect(formatNumber(5, { decimalPlaces: 2 })).toBe("5.00"); + }); + + it("applies comma formatting", () => { + expect(formatNumber(1234567, { numberFormat: "comma" })).toBe("1,234,567"); + }); + + it("applies comma formatting with decimalPlaces", () => { + expect(formatNumber(1234567.891, { numberFormat: "comma", decimalPlaces: 2 })).toBe("1,234,567.89"); + }); + + it("applies compact notation", () => { + const result = formatNumber(1500000, { numberFormat: "compact" }); + expect(result).toMatch(/1\.5M/i); + }); + + it("applies compact notation with decimalPlaces", () => { + const result = formatNumber(1234, { numberFormat: "compact", decimalPlaces: 1 }); + expect(result).toMatch(/1\.2K/i); + }); + + it("applies percent format", () => { + expect(formatNumber(75, { numberFormat: "percent" })).toBe("75%"); + }); + + it("applies percent format with decimalPlaces", () => { + expect(formatNumber(75.678, { numberFormat: "percent", decimalPlaces: 1 })).toBe("75.7%"); + }); + + it("adds prefix", () => { + expect(formatNumber(100, { prefix: "$" })).toBe("$100"); + }); + + it("adds suffix", () => { + expect(formatNumber(100, { suffix: " items" })).toBe("100 items"); + }); + + it("combines prefix, suffix, decimalPlaces, and comma", () => { + expect(formatNumber(9876.5, { prefix: "$", suffix: "M", numberFormat: "comma", decimalPlaces: 1 })).toBe("$9,876.5M"); + }); + + it("handles zero", () => { + expect(formatNumber(0, { decimalPlaces: 2 })).toBe("0.00"); + }); + + it("handles negative numbers", () => { + expect(formatNumber(-42.567, { decimalPlaces: 1 })).toBe("-42.6"); + }); + + it("returns string values unchanged", () => { + expect(formatNumber("N/A" as unknown as number)).toBe("N/A"); + }); +}); + +describe("buildTooltipFormatter", () => { + it("returns a function", () => { + const formatter = buildTooltipFormatter({}); + expect(typeof formatter).toBe("function"); + }); + + it("formats a single value with config", () => { + const formatter = buildTooltipFormatter({ decimalPlaces: 1, prefix: "$" }); + // ECharts tooltip params shape for axis trigger + const result = formatter({ + seriesName: "Revenue", + value: 1234.56, + name: "Jan", + marker: '', + }); + expect(result).toContain("$1,234.6"); + expect(result).toContain("Revenue"); + }); + + it("handles array params (axis trigger with multiple series)", () => { + const formatter = buildTooltipFormatter({ decimalPlaces: 0 }); + const result = formatter([ + { seriesName: "A", value: 100.7, name: "Jan", marker: "●" }, + { seriesName: "B", value: 200.3, name: "Jan", marker: "●" }, + ]); + expect(result).toContain("101"); + expect(result).toContain("200"); + }); +}); diff --git a/component/src/charts/__tests__/graph-chart.test.tsx b/component/src/charts/__tests__/graph-chart.test.tsx index 059d4f06..435e97bb 100644 --- a/component/src/charts/__tests__/graph-chart.test.tsx +++ b/component/src/charts/__tests__/graph-chart.test.tsx @@ -8,7 +8,7 @@ * - Click callback wiring * - Layout mapping */ -import { render, screen, cleanup, fireEvent, waitFor } 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"; @@ -530,29 +530,75 @@ describe("GraphChart", () => { expect(nvlNodes[0].caption).not.toBe("[object Object]"); }); - // --- autoFit --- + // --- Loading overlay / layoutReady --- - describe("autoFit", () => { - afterEach(() => { - vi.restoreAllMocks(); + describe("loading overlay", () => { + it("shows loading overlay on initial render when nodes are present", () => { + render(); + expect(screen.getByTestId("graph-loading-overlay")).toBeInTheDocument(); }); - it("schedules a delayed fit via requestAnimationFrame when autoFit is true", () => { - const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 0); - render(); - expect(rafSpy).toHaveBeenCalledTimes(1); + it("does not show loading overlay when there are no nodes", () => { + render(); + expect(screen.queryByTestId("graph-loading-overlay")).not.toBeInTheDocument(); + }); + + it("removes loading overlay after onLayoutDone fires", () => { + render(); + 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(); }); - it("does not call requestAnimationFrame for autoFit when prop is false", () => { - const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 0); - render(); - expect(rafSpy).not.toHaveBeenCalled(); + it("resets loading overlay when nodes change", () => { + 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(); + + // Change nodes — overlay should reappear + const newNodes = [ + { id: "4", label: "Diana", value: 10 }, + { id: "5", label: "Eve", value: 15 }, + ]; + rerender(); + expect(screen.getByTestId("graph-loading-overlay")).toBeInTheDocument(); }); + }); - it("does not call requestAnimationFrame for autoFit when prop is absent", () => { - const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 0); - render(); - expect(rafSpy).not.toHaveBeenCalled(); + // --- nvlOptions --- + + it("disables web workers in nvlOptions (Next.js bundler compatibility)", () => { + render(); + const opts = capturedProps.nvlOptions as Record; + expect(opts.disableWebWorkers).toBe(true); + }); + + // --- autoFit --- + + describe("autoFit", () => { + it("does not call fitGraph before onLayoutDone fires", () => { + // We can't directly spy on fitGraph, but we can verify through the nvlRef. + // The NVL wrapper is mocked, so we check that autoFit alone doesn't + // cause immediate side effects — the overlay should still be visible. + render(); + // Overlay is still present — layout hasn't completed + expect(screen.getByTestId("graph-loading-overlay")).toBeInTheDocument(); + }); + + it("calls fitGraph (via onLayoutDone) when autoFit and layout completes", () => { + render(); + // Fire 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(); }); }); }); diff --git a/component/src/charts/__tests__/single-value-chart.test.tsx b/component/src/charts/__tests__/single-value-chart.test.tsx index fe89c9bc..5f11bbf9 100644 --- a/component/src/charts/__tests__/single-value-chart.test.tsx +++ b/component/src/charts/__tests__/single-value-chart.test.tsx @@ -123,6 +123,28 @@ describe("SingleValueChart", () => { expect(container.querySelector("[style]")).not.toBeInTheDocument(); }); + // --- decimalPlaces --- + + it("formats value with decimalPlaces", () => { + render(); + expect(screen.getByText("3.14")).toBeInTheDocument(); + }); + + it("pads with zeros when decimalPlaces exceeds precision", () => { + render(); + expect(screen.getByText("5.00")).toBeInTheDocument(); + }); + + it("combines decimalPlaces with numberFormat comma", () => { + render(); + expect(screen.getByText("1,234,567.9")).toBeInTheDocument(); + }); + + it("ignores decimalPlaces of -1 (automatic)", () => { + render(); + expect(screen.getByText("3.14159")).toBeInTheDocument(); + }); + it("handles invalid JSON in colorThresholds gracefully", () => { expect(() => render(), diff --git a/component/src/charts/base-chart.tsx b/component/src/charts/base-chart.tsx index 17589360..26519355 100644 --- a/component/src/charts/base-chart.tsx +++ b/component/src/charts/base-chart.tsx @@ -9,6 +9,8 @@ import { DataZoomComponent, AriaComponent, RadarComponent, + MarkLineComponent, + GraphicComponent, } from "echarts/components"; import { CanvasRenderer } from "echarts/renderers"; import type { EChartsOption } from "echarts"; @@ -35,6 +37,8 @@ echarts.use([ DataZoomComponent, AriaComponent, RadarComponent, + MarkLineComponent, + GraphicComponent, CanvasRenderer, ]); @@ -99,6 +103,7 @@ function BaseChart({ onChartReady, onClick, onDataZoom, + ariaDescription, colorblindMode = false, colorPalette, }: BaseChartProps) { @@ -153,11 +158,12 @@ function BaseChart({ aria: { enabled: true, ...userAria, + ...(ariaDescription ? { label: { description: ariaDescription } } : {}), decal: { show: colorblindMode, ...userDecal }, }, }; instance.setOption(merged, { notMerge: true }); - }, [options, colorblindMode, colorPalette, dark]); + }, [options, colorblindMode, colorPalette, dark, ariaDescription]); // Loading state useEffect(() => { @@ -213,7 +219,9 @@ function BaseChart({ ref={containerRef} className={cn("h-full w-full", className)} data-testid="base-chart" - aria-label="Chart visualization" + role="img" + aria-label={ariaDescription ?? "Chart visualization"} + tabIndex={0} /> ); } diff --git a/component/src/charts/chart-utils.ts b/component/src/charts/chart-utils.ts index 7fc9ba74..37ada2c3 100644 --- a/component/src/charts/chart-utils.ts +++ b/component/src/charts/chart-utils.ts @@ -4,6 +4,87 @@ import { resolveThresholdColor } from "./color-threshold"; import type { StylingRule } from "./styling-rule"; import { resolveStylingRuleColor } from "./styling-rule"; +// --------------------------------------------------------------------------- +// Number formatting +// --------------------------------------------------------------------------- + +export type NumberFormat = "plain" | "comma" | "compact" | "percent"; + +export interface NumberFormatConfig { + numberFormat?: NumberFormat; + decimalPlaces?: number; + prefix?: string; + suffix?: string; +} + +/** + * Format a numeric value with optional decimal places, locale formatting, + * compact notation, prefix, and suffix. Non-numeric values pass through as-is. + */ +export function formatNumber(value: number | string, config: NumberFormatConfig = {}): string { + if (typeof value !== "number" || !Number.isFinite(value)) return String(value); + + const { numberFormat = "plain", decimalPlaces, prefix = "", suffix = "" } = config; + + let formatted: string; + + switch (numberFormat) { + case "comma": + formatted = decimalPlaces !== undefined + ? value.toLocaleString("en-US", { minimumFractionDigits: decimalPlaces, maximumFractionDigits: decimalPlaces }) + : value.toLocaleString("en-US"); + break; + case "compact": + formatted = Intl.NumberFormat("en", { + notation: "compact", + ...(decimalPlaces !== undefined ? { minimumFractionDigits: decimalPlaces, maximumFractionDigits: decimalPlaces } : {}), + }).format(value); + break; + case "percent": + formatted = decimalPlaces !== undefined + ? `${value.toFixed(decimalPlaces)}%` + : `${value}%`; + break; + default: // "plain" + formatted = decimalPlaces !== undefined ? value.toFixed(decimalPlaces) : String(value); + break; + } + + return `${prefix}${formatted}${suffix}`; +} + +// --------------------------------------------------------------------------- +// ECharts tooltip formatter +// --------------------------------------------------------------------------- + +interface TooltipParam { + seriesName?: string; + name?: string; + value?: number | string | (number | string)[]; + marker?: string; +} + +/** + * Build an ECharts tooltip formatter function that applies consistent number + * formatting across all chart types. Works with both single and array params + * (item trigger vs axis trigger). + */ +export function buildTooltipFormatter(config: NumberFormatConfig): (params: TooltipParam | TooltipParam[]) => string { + // Tooltip always uses comma format for readability unless explicitly set + const tooltipConfig: NumberFormatConfig = { numberFormat: "comma", ...config }; + + return (params: TooltipParam | TooltipParam[]) => { + const items = Array.isArray(params) ? params : [params]; + const header = items[0]?.name ?? ""; + const lines = items.map((p) => { + const raw = Array.isArray(p.value) ? p.value[1] : p.value; + const val = typeof raw === "number" ? formatNumber(raw, tooltipConfig) : String(raw ?? ""); + return `${p.marker ?? ""} ${p.seriesName ?? ""}: ${val}`; + }); + return header ? `${header}
${lines.join("
")}` : lines.join("
"); + }; +} + /** Detect whether the document is currently in dark mode. */ export function isDark(): boolean { if (typeof document === "undefined") return false; diff --git a/component/src/charts/graph-chart.tsx b/component/src/charts/graph-chart.tsx index 0658e8eb..7bcec670 100644 --- a/component/src/charts/graph-chart.tsx +++ b/component/src/charts/graph-chart.tsx @@ -296,12 +296,21 @@ export function GraphChart({ className, }: GraphChartProps) { const nvlRef = useRef(null); - const cleanupRef = useRef<(() => void) | null>(null); + const [layoutReady, setLayoutReady] = useState(false); const [layout, setLayout] = useState( initialLayout ?? layoutProp, ); const dark = useDarkMode(); + // Reset layoutReady synchronously during render when nodes change. + // Using useEffect would race with onLayoutDone (which fires before + // effects run when the simulation completes on the main thread). + const prevNodesRef = useRef(nodes); + if (prevNodesRef.current !== nodes) { + prevNodesRef.current = nodes; + if (layoutReady) setLayoutReady(false); + } + // Build the label → property keys map from current nodes const labelPropertyMap = useMemo(() => buildLabelPropertyMap(nodes), [nodes]); @@ -375,25 +384,13 @@ export function GraphChart({ } }, []); - // When autoFit is true, schedule a delayed fit after mount so that containers - // which animate to their final size (e.g. fullscreen dialogs) have settled. - // The fullscreen dialog defers mounting until the 200ms animation completes, - // but a small extra delay ensures the canvas is fully initialized. + // When autoFit is true, fit the graph after layout has settled. + // layoutReady flips to true when onLayoutDone fires — deterministic, + // not based on an arbitrary timer. useEffect(() => { - if (!autoFit) return; - const raf = requestAnimationFrame(() => { - const timer = setTimeout(() => { - fitGraph(); - }, 100); - cleanupRef.current = () => clearTimeout(timer); - }); - return () => { - cancelAnimationFrame(raf); - cleanupRef.current?.(); - }; - // fitGraph is stable (useCallback with no deps), so this is safe - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoFit]); + if (!autoFit || !layoutReady) return; + fitGraph(); + }, [autoFit, layoutReady, fitGraph]); const mouseEventCallbacks = useMemo( (): InteractiveNvlWrapperProps["mouseEventCallbacks"] => ({ @@ -437,7 +434,10 @@ export function GraphChart({ const nvlCallbacks = useMemo( () => ({ - onLayoutDone: fitGraph, + onLayoutDone: () => { + fitGraph(); + setLayoutReady(true); + }, }), [fitGraph], ); @@ -446,6 +446,7 @@ export function GraphChart({ () => ({ allowDynamicMinZoom: true, initialZoom: 0.7, + // Web workers require bundler-specific config in Next.js; keep on main thread. disableWebWorkers: true, // When physics is disabled, use a static layout (no force simulation) useStaticLayout: !physics, @@ -574,6 +575,15 @@ export function GraphChart({ )} + {!layoutReady && nodes.length > 0 && ( +
+
+
+ )} + = { sm: "text-xl", @@ -46,6 +34,8 @@ export interface SingleValueChartProps { fontSize?: SingleValueFontSize; /** Built-in number formatting applied when value is numeric and format is not provided */ numberFormat?: SingleValueNumberFormat; + /** Fixed decimal places (0-6). Set to -1 or omit for automatic. */ + decimalPlaces?: number; /** @deprecated Use stylingRules instead. JSON string of thresholds */ colorThresholds?: string; /** Rule-based styling rules */ @@ -73,6 +63,7 @@ function SingleValueChart({ format, fontSize = "lg", numberFormat = "plain", + decimalPlaces, colorThresholds, stylingRules, paramValues, @@ -96,10 +87,9 @@ function SingleValueChart({ if (typeof value === "number") { if (format) { displayValue = format(value); - } else if (numberFormat !== "plain") { - displayValue = applyNumberFormat(value, numberFormat); } else { - displayValue = value; + const dp = decimalPlaces !== undefined && decimalPlaces >= 0 ? decimalPlaces : undefined; + displayValue = formatNumber(value, { numberFormat, decimalPlaces: dp }); } } else { displayValue = value; diff --git a/component/src/charts/types.ts b/component/src/charts/types.ts index 9fff6050..81ad05aa 100644 --- a/component/src/charts/types.ts +++ b/component/src/charts/types.ts @@ -16,6 +16,8 @@ export interface BaseChartProps { onClick?: (params: EChartsClickEvent) => void; /** Called when data zoom changes */ onDataZoom?: (params: unknown) => void; + /** Custom ARIA description for screen readers (e.g. "Bar chart showing revenue by month") */ + ariaDescription?: string; /** Enable decal overlay patterns for colorblind accessibility */ colorblindMode?: boolean; /** diff --git a/component/src/components/composed/__tests__/markdown-widget.test.tsx b/component/src/components/composed/__tests__/markdown-widget.test.tsx index 375e3284..cbe49c54 100644 --- a/component/src/components/composed/__tests__/markdown-widget.test.tsx +++ b/component/src/components/composed/__tests__/markdown-widget.test.tsx @@ -392,4 +392,67 @@ describe("MarkdownWidget", () => { const container = screen.getByTestId("markdown-widget"); expect(container.innerHTML).toContain(""); }); + + // ── GFM Tables ──────────────────────────────────────────────────────────── + + it("renders a basic GFM table with headers and body rows", () => { + const md = "| Name | Age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 25 |"; + render(); + const container = screen.getByTestId("markdown-widget"); + const table = container.querySelector("table"); + expect(table).not.toBeNull(); + const headers = table!.querySelectorAll("th"); + expect(headers).toHaveLength(2); + expect(headers[0].textContent).toBe("Name"); + expect(headers[1].textContent).toBe("Age"); + const rows = table!.querySelectorAll("tbody tr"); + expect(rows).toHaveLength(2); + const cells = rows[0].querySelectorAll("td"); + expect(cells[0].textContent).toBe("Alice"); + expect(cells[1].textContent).toBe("30"); + }); + + it("renders a GFM table with alignment markers (colons)", () => { + const md = "| Left | Center | Right |\n| :--- | :---: | ---: |\n| a | b | c |"; + render(); + const container = screen.getByTestId("markdown-widget"); + expect(container.querySelector("table")).not.toBeNull(); + const headers = container.querySelectorAll("th"); + expect(headers).toHaveLength(3); + }); + + it("renders table with empty cells when row has fewer columns than header", () => { + const md = "| A | B | C |\n| --- | --- | --- |\n| x |"; + render(); + const container = screen.getByTestId("markdown-widget"); + const cells = container.querySelectorAll("tbody td"); + expect(cells).toHaveLength(3); + // Last two cells should be empty + expect(cells[1].textContent).toBe(""); + expect(cells[2].textContent).toBe(""); + }); + + it("closes an open list before rendering a table", () => { + const md = "- item\n| A | B |\n| --- | --- |\n| 1 | 2 |"; + render(); + const container = screen.getByTestId("markdown-widget"); + const ulCloseIndex = container.innerHTML.indexOf(""); + const tableIndex = container.innerHTML.indexOf(" { + const md = "| Header |\n| --- |\n| |"; + render(); + const container = screen.getByTestId("markdown-widget"); + expect(container.innerHTML).toContain("<script>"); + expect(container.querySelector("script")).toBeNull(); + }); + + it("does not treat lines as table when alignment row is missing", () => { + const md = "| not | a | table |\n| these are just pipes |"; + render(); + const container = screen.getByTestId("markdown-widget"); + expect(container.querySelector("table")).toBeNull(); + }); }); diff --git a/component/src/components/composed/__tests__/query-editor.test.tsx b/component/src/components/composed/__tests__/query-editor.test.tsx index 5ba6f7a3..0d185b1e 100644 --- a/component/src/components/composed/__tests__/query-editor.test.tsx +++ b/component/src/components/composed/__tests__/query-editor.test.tsx @@ -89,6 +89,8 @@ vi.mock("@codemirror/commands", () => ({ vi.mock("@codemirror/autocomplete", () => ({ autocompletion: () => ({ type: "autocompletion" }), completionKeymap: [], + closeBrackets: () => ({ type: "closeBrackets" }), + closeBracketsKeymap: [], })); vi.mock("@codemirror/theme-one-dark", () => ({ diff --git a/component/src/components/composed/chart-options-schema.ts b/component/src/components/composed/chart-options-schema.ts index 384d21eb..f12343b0 100644 --- a/component/src/components/composed/chart-options-schema.ts +++ b/component/src/components/composed/chart-options-schema.ts @@ -12,6 +12,11 @@ export interface ChartOptionDef { description?: string; } +/** 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", @@ -73,6 +78,7 @@ 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", @@ -364,9 +370,9 @@ const treemapOptions: ChartOptionDef[] = [ ]; const chartOptionsRegistry: Record = { - bar: [...barOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions], - line: [...lineOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions], - pie: [...pieOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions], + bar: [...barOptions, ...tooltipFormatOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions], + line: [...lineOptions, ...tooltipFormatOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions], + pie: [...pieOptions, ...tooltipFormatOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions], "single-value": [...singleValueOptions, ...behaviorOptions], graph: [...graphOptions, ...behaviorOptions], map: [...mapOptions, ...behaviorOptions], diff --git a/component/src/components/composed/code-preview.tsx b/component/src/components/composed/code-preview.tsx index 30fbbe6f..2535e6b9 100644 --- a/component/src/components/composed/code-preview.tsx +++ b/component/src/components/composed/code-preview.tsx @@ -34,7 +34,7 @@ function CodePreview({ value, language, maxLines = 3, className }: Readonly {language && ( - + {language} )} diff --git a/component/src/components/composed/cross-filter-tag.tsx b/component/src/components/composed/cross-filter-tag.tsx index 6af42562..3e66a108 100644 --- a/component/src/components/composed/cross-filter-tag.tsx +++ b/component/src/components/composed/cross-filter-tag.tsx @@ -28,25 +28,40 @@ function CrossFilterTag({ className, ); + // When onClick is set the outer element is a + ) + ); + const content = ( <> {field} = {value} - {onRemove && ( - - )} + {removeControl} ); diff --git a/component/src/components/composed/json-viewer.tsx b/component/src/components/composed/json-viewer.tsx index 29859442..8967df29 100644 --- a/component/src/components/composed/json-viewer.tsx +++ b/component/src/components/composed/json-viewer.tsx @@ -75,10 +75,13 @@ function JsonNode({ keyName, value, depth, initialExpanded, isLast }: JsonNodePr return (
-
setExpanded(!expanded)} + aria-expanded={expanded} + aria-label={`${expanded ? "Collapse" : "Expand"} ${keyName ?? (type === "array" ? "array" : "object")}`} > ,} )} -
+ {expanded && !isEmpty && ( <> {entries.map(([key, val], index) => ( diff --git a/component/src/components/composed/markdown-widget.tsx b/component/src/components/composed/markdown-widget.tsx index 1c76b487..0157534f 100644 --- a/component/src/components/composed/markdown-widget.tsx +++ b/component/src/components/composed/markdown-widget.tsx @@ -28,6 +28,26 @@ function isSafeUrl(url: string): boolean { return true; } +/** + * Checks whether a line is a GFM table alignment row (e.g. `| --- | :---: |`). + * Uses a linear split-and-check approach instead of a single regex to avoid + * ReDoS (catastrophic backtracking) on adversarial input. + */ +function isTableAlignmentRow(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed) return false; + // Split by pipe, trim each cell, filter out empty leading/trailing cells + const cells = trimmed.split("|").map((c) => c.trim()); + // Remove empty strings caused by leading/trailing pipes + const filtered = cells.filter((c, i) => + c.length > 0 || (i > 0 && i < cells.length - 1), + ); + if (filtered.length === 0) return false; + // Each non-empty cell must match :?-{3,}:? + const cellPattern = /^:?-{3,}:?$/; + return filtered.every((c) => c.length === 0 || cellPattern.test(c)); +} + /** * Simple markdown parser that converts a subset of markdown to HTML. * Handles: headings, bold, italic, code, links, lists, blockquotes, paragraphs. @@ -112,6 +132,39 @@ function parseMarkdown(md: string): string { inBlockquote = false; } + // GFM tables: pipe-delimited rows where the next line is the alignment row + if ( + line.includes("|") && + i + 1 < lines.length && + isTableAlignmentRow(lines[i + 1]) + ) { + closeList(); + const parseCells = (row: string) => + row.split("|").map((c: string) => c.trim()).filter((c: string) => c.length > 0); + const headers = parseCells(line); + i++; // skip alignment row + const bodyRows = []; + while (i + 1 < lines.length && lines[i + 1].includes("|")) { + i++; + bodyRows.push(parseCells(lines[i])); + } + result.push(''); + result.push(""); + for (const h of headers) { + result.push(``); + } + result.push(""); + for (const row of bodyRows) { + result.push(""); + for (let c = 0; c < headers.length; c++) { + result.push(``); + } + result.push(""); + } + result.push("
${escapeHtml(h)}
${escapeHtml(row[c] ?? "")}
"); + continue; + } + // Unordered lists if (line.match(/^[-*+]\s+/)) { if (listType !== "ul") { diff --git a/component/src/components/composed/query-editor.tsx b/component/src/components/composed/query-editor.tsx index bd79de56..6dd3f427 100644 --- a/component/src/components/composed/query-editor.tsx +++ b/component/src/components/composed/query-editor.tsx @@ -59,7 +59,7 @@ async function buildExtensions( const [ { EditorView, keymap, placeholder: cmPlaceholder }, { defaultKeymap, historyKeymap, history: historyExt }, - { autocompletion, completionKeymap }, + { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap }, { oneDark }, ] = await Promise.all([ import("@codemirror/view"), @@ -102,7 +102,8 @@ async function buildExtensions( return [ historyExt(), - keymap.of([...defaultKeymap, ...historyKeymap, ...completionKeymap]), + closeBrackets(), + keymap.of([...defaultKeymap, ...historyKeymap, ...completionKeymap, ...closeBracketsKeymap]), runKeymap, langCompartmentExt, autocompletion(), diff --git a/component/vitest.setup.ts b/component/vitest.setup.ts index f8443727..48e07097 100644 --- a/component/vitest.setup.ts +++ b/component/vitest.setup.ts @@ -41,6 +41,8 @@ vi.mock("echarts/components", () => ({ DataZoomComponent: vi.fn(), AriaComponent: vi.fn(), RadarComponent: vi.fn(), + MarkLineComponent: vi.fn(), + GraphicComponent: vi.fn(), })); vi.mock("echarts/renderers", () => ({ diff --git a/docker/neo4j/init.cypher b/docker/neo4j/init.cypher index fc4c20ba..864e5588 100755 --- a/docker/neo4j/init.cypher +++ b/docker/neo4j/init.cypher @@ -519,3 +519,31 @@ CREATE (:City {name: "Seattle", latitude: 47.6062, longitude: -122.3321, populat CREATE (:City {name: "Denver", latitude: 39.7392, longitude: -104.9903, population: 715522}); CREATE (:City {name: "Boston", latitude: 42.3601, longitude: -71.0589, population: 692600}); CREATE (:City {name: "Atlanta", latitude: 33.7490, longitude: -84.3880, population: 498715}); + +// ── Filming locations — connect movies to cities ────────────────────────── +MATCH (m:Movie {title: 'The Matrix'}), (c:City {name: 'San Francisco'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: 'The Matrix'}), (c:City {name: 'Los Angeles'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: 'Top Gun'}), (c:City {name: 'San Francisco'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: 'Top Gun'}), (c:City {name: 'Miami'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: 'A Few Good Men'}), (c:City {name: 'Boston'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: 'A Few Good Men'}), (c:City {name: 'Miami'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: "Jerry Maguire"}), (c:City {name: 'Los Angeles'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: "Jerry Maguire"}), (c:City {name: 'Houston'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: "Sleepless in Seattle"}), (c:City {name: 'Seattle'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: "Sleepless in Seattle"}), (c:City {name: 'New York'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: "When Harry Met Sally"}), (c:City {name: 'New York'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: "When Harry Met Sally"}), (c:City {name: 'Chicago'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: "Apollo 13"}), (c:City {name: 'Houston'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: "Apollo 13"}), (c:City {name: 'Los Angeles'}) CREATE (m)-[:FILMED_IN]->(c); + +// ── Birthplaces — connect people to cities ──────────────────────────────── +MATCH (p:Person {name: 'Keanu Reeves'}), (c:City {name: 'Los Angeles'}) CREATE (p)-[:BORN_IN]->(c); +MATCH (p:Person {name: 'Tom Hanks'}), (c:City {name: 'San Francisco'}) CREATE (p)-[:BORN_IN]->(c); +MATCH (p:Person {name: 'Tom Cruise'}), (c:City {name: 'New York'}) CREATE (p)-[:BORN_IN]->(c); +MATCH (p:Person {name: 'Jack Nicholson'}), (c:City {name: 'New York'}) CREATE (p)-[:BORN_IN]->(c); +MATCH (p:Person {name: 'Meg Ryan'}), (c:City {name: 'Los Angeles'}) CREATE (p)-[:BORN_IN]->(c); +MATCH (p:Person {name: 'Kevin Bacon'}), (c:City {name: 'Boston'}) CREATE (p)-[:BORN_IN]->(c); +MATCH (p:Person {name: 'Demi Moore'}), (c:City {name: 'Atlanta'}) CREATE (p)-[:BORN_IN]->(c); +MATCH (p:Person {name: 'Cuba Gooding Jr.'}), (c:City {name: 'New York'}) CREATE (p)-[:BORN_IN]->(c); +MATCH (p:Person {name: 'Renee Zellweger'}), (c:City {name: 'Houston'}) CREATE (p)-[:BORN_IN]->(c); +MATCH (p:Person {name: 'Bonnie Hunt'}), (c:City {name: 'Chicago'}) CREATE (p)-[:BORN_IN]->(c); diff --git a/scripts/seed-demo.mjs b/scripts/seed-demo.mjs index f9148a33..77338a34 100644 --- a/scripts/seed-demo.mjs +++ b/scripts/seed-demo.mjs @@ -1740,6 +1740,17 @@ async function main() { true ); + const catalogLayout = buildChartCatalog(neo4jConnId); + patchGridIds(catalogLayout); + await upsertDashboard( + sql, + adminId, + "Chart Catalog", + "One page per chart type. Each page shows every palette, feature variant, rule-based styling, click actions, and accessibility modes.", + catalogLayout, + true + ); + console.log(" Demo dashboards seeded."); } finally { await sql.end(); @@ -1957,6 +1968,634 @@ function buildStylingRulesDemo(neo4jConnId, pgConnId) { } /** Set gridLayout[n].i = widgets[n].id for each page. */ +// ─── Chart Catalog — comprehensive per-chart-type showcase ────────── +function buildChartCatalog(neo4jId) { + const P = ["deep-ocean", "warm-sunset", "cool-breeze", "earth-tones", "neon", "monochrome"]; + const detailPageId = uuid(); + const behaviorPageId = uuid(); + + // Reusable queries (Neo4j movie dataset) + const Q = { + barData: "MATCH (m:Movie) RETURN (m.released / 10) * 10 AS label, count(*) AS count ORDER BY label", + barMulti: "MATCH (p:Person)-[r]->(m:Movie) WITH (m.released / 10) * 10 AS decade, type(r) AS rel, count(*) AS cnt RETURN decade AS label, rel, cnt ORDER BY decade", + lineData: "MATCH (m:Movie) RETURN m.released AS x, count(*) AS count ORDER BY x", + pieData: "MATCH ()-[r]->() RETURN type(r) AS name, count(*) AS value", + singleVal: "MATCH (m:Movie) RETURN count(m) AS value", + singleTrend: "MATCH (m:Movie) RETURN count(m) AS value, count(m) - 5 AS previous", + tableData: "MATCH (p:Person)-[r:ACTED_IN]->(m:Movie) RETURN p.name AS name, m.title AS movie, m.released AS year ORDER BY year DESC LIMIT 30", + gaugeData: "MATCH (m:Movie) RETURN count(m) AS value, 'Movies' AS name", + radarData: "MATCH (p:Person)-[r]->(m:Movie) WITH type(r) AS indicator, count(*) AS value RETURN indicator, value", + sankeyData: "MATCH (p:Person)-[r]->(m:Movie) WHERE type(r) IN ['ACTED_IN','DIRECTED'] WITH p.name AS source, m.title AS target, 1 AS value RETURN source, target, value LIMIT 20", + sunburstData: "MATCH ()-[r]->() WITH type(r) AS relType, count(*) AS cnt RETURN '' AS parent, relType AS name, cnt AS value UNION ALL MATCH (p:Person)-[r]->(m:Movie) WITH type(r) AS relType, m.title AS movie, count(p) AS cnt RETURN relType AS parent, movie AS name, cnt AS value LIMIT 30", + treemapData: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH m, count(p) AS cast RETURN m.title AS name, cast AS value ORDER BY cast DESC LIMIT 15", + graphData: "MATCH (p:Person)-[r]->(m:Movie) RETURN p, r, m LIMIT 15", + graphSmall: "MATCH (p:Person)-[r:DIRECTED]->(m:Movie) RETURN p, r, m LIMIT 10", + selectSeed: "MATCH (p:Person) RETURN DISTINCT p.name AS value, p.name AS label ORDER BY p.name LIMIT 20", + mapCities: "MATCH (c:City) RETURN c.name AS name, c.latitude AS lat, c.longitude AS lng, c.population AS value", + mapFilming: "MATCH (m:Movie)-[:FILMED_IN]->(c:City) RETURN c.name AS name, c.latitude AS lat, c.longitude AS lng, count(m) AS value", + mapBirthplaces: "MATCH (p:Person)-[:BORN_IN]->(c:City) RETURN c.name AS name, c.latitude AS lat, c.longitude AS lng, count(p) AS value, collect(p.name)[0..3] AS people", + }; + + // Styling rules reusable across pages + const barStyling = { + enabled: true, + rules: [ + { id: uuid(), operator: "<=", value: 5, color: "#ef4444", target: "color" }, + { id: uuid(), operator: ">=", value: 15, color: "#22c55e", target: "color" }, + ], + }; + const singleValueStyling = { + enabled: true, + rules: [ + { id: uuid(), operator: "<", value: 20, color: "#ef4444", target: "color" }, + { id: uuid(), operator: ">=", value: 20, color: "#22c55e", target: "color" }, + { id: uuid(), operator: ">=", value: 20, color: "#dcfce7", target: "backgroundColor" }, + ], + }; + + // Click action: set parameter on click + const clickSetParam = (triggerCol, paramName) => ({ + type: "set-parameter", + rules: [{ + id: uuid(), type: "set-parameter", + triggerColumn: triggerCol, + parameterMapping: { parameterName: paramName, sourceField: triggerCol }, + }], + }); + + // Click action: navigate to page + const clickNavPage = (triggerCol, pageId) => ({ + type: "navigate-to-page", + rules: [{ + id: uuid(), type: "navigate-to-page", + triggerColumn: triggerCol, + targetPageId: pageId, + }], + }); + + // Helper to make a palette row of widgets for a given chart type + function paletteRow(chartType, query, baseSettings = {}) { + return P.map((p) => ({ + id: uuid(), + chartType, + connectionId: neo4jId, + query, + settings: { ...baseSettings, title: p, colorPalette: p, chartOptions: { ...baseSettings.chartOptions } }, + })); + } + + function paletteGrid(yStart = 0) { + // 3×2 grid for 6 palettes, each 4×4 + return P.map((_, i) => ({ + i: null, + x: (i % 3) * 4, + y: yStart + Math.floor(i / 3) * 4, + w: 4, + h: 4, + })); + } + + return { + version: 2, + pages: [ + // ── Page 1: Bar Chart ────────────────────────────────────────── + { + id: uuid(), + title: "Bar Chart", + widgets: [ + // Vertical bar (default) + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Vertical (default)" } }, + // Horizontal bar + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Horizontal", chartOptions: { orientation: "horizontal" } } }, + // Stacked bar + { id: uuid(), chartType: "bar", connectionId: neo4jId, + query: "MATCH (p:Person)-[r]->(m:Movie) WITH (m.released / 10) * 10 AS decade, type(r) AS rel, count(*) AS cnt RETURN decade AS label, rel, cnt ORDER BY decade", + settings: { title: "Stacked", chartOptions: { stacked: true } } }, + // Bar with values shown + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Show Values", chartOptions: { showValues: true } } }, + // Bar with styling rules + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Rule-Based Styling", stylingConfig: barStyling } }, + // Bar with click action + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Click → Set Parameter", clickAction: clickSetParam("label", "bar_decade") } }, + // Bar with colorblind mode + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Colorblind Mode", chartOptions: { colorblindMode: true } } }, + // 6 palette variants + ...paletteRow("bar", Q.barData), + ], + gridLayout: [ + // Row 1: feature variants (4 widgets, 3×4 each) + { i: null, x: 0, y: 0, w: 3, h: 4 }, + { i: null, x: 3, y: 0, w: 3, h: 4 }, + { i: null, x: 6, y: 0, w: 3, h: 4 }, + { i: null, x: 9, y: 0, w: 3, h: 4 }, + // Row 2: styling, click, accessibility + { i: null, x: 0, y: 4, w: 4, h: 4 }, + { i: null, x: 4, y: 4, w: 4, h: 4 }, + { i: null, x: 8, y: 4, w: 4, h: 4 }, + // Rows 3-4: palette grid + ...paletteGrid(8), + ], + }, + + // ── Page 2: Line Chart ───────────────────────────────────────── + { + id: uuid(), + title: "Line Chart", + widgets: [ + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Default" } }, + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Smooth + Area", chartOptions: { smooth: true, area: true } } }, + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Stepped", chartOptions: { stepped: true } } }, + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Show Points", chartOptions: { showPoints: true, lineWidth: 3 } } }, + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Colorblind Mode", chartOptions: { colorblindMode: true, area: true } } }, + ...paletteRow("line", Q.lineData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 3, h: 4 }, + { i: null, x: 3, y: 0, w: 3, h: 4 }, + { i: null, x: 6, y: 0, w: 3, h: 4 }, + { i: null, x: 9, y: 0, w: 3, h: 4 }, + { i: null, x: 0, y: 4, w: 4, h: 4 }, + ...paletteGrid(8), + ], + }, + + // ── Page 3: Pie Chart ────────────────────────────────────────── + { + id: uuid(), + title: "Pie Chart", + widgets: [ + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Default Pie" } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Donut", chartOptions: { donut: true } } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Rose / Nightingale", chartOptions: { roseMode: true } } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Labels Inside", chartOptions: { labelPosition: "inside" } } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Click → Set Param", clickAction: clickSetParam("name", "pie_type") } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Colorblind Mode", chartOptions: { colorblindMode: true } } }, + ...paletteRow("pie", Q.pieData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 4, h: 4 }, + { i: null, x: 4, y: 0, w: 4, h: 4 }, + { i: null, x: 8, y: 0, w: 4, h: 4 }, + { i: null, x: 0, y: 4, w: 4, h: 4 }, + { i: null, x: 4, y: 4, w: 4, h: 4 }, + { i: null, x: 8, y: 4, w: 4, h: 4 }, + ...paletteGrid(8), + ], + }, + + // ── Page 4: Single Value ─────────────────────────────────────── + { + id: uuid(), + title: "Single Value", + widgets: [ + { id: uuid(), chartType: "single-value", connectionId: neo4jId, query: Q.singleVal, + settings: { title: "Default", chartOptions: { fontSize: "lg" } } }, + { id: uuid(), chartType: "single-value", connectionId: neo4jId, query: Q.singleVal, + settings: { title: "With Prefix/Suffix", chartOptions: { prefix: "$", suffix: "M", fontSize: "xl" } } }, + { id: uuid(), chartType: "single-value", connectionId: neo4jId, query: Q.singleVal, + settings: { title: "Comma Format", chartOptions: { numberFormat: "comma", fontSize: "lg" } } }, + { id: uuid(), chartType: "single-value", connectionId: neo4jId, query: Q.singleVal, + settings: { title: "Compact Format", chartOptions: { numberFormat: "compact", fontSize: "lg" } } }, + { id: uuid(), chartType: "single-value", connectionId: neo4jId, query: Q.singleVal, + settings: { title: "Rule-Based Styling", stylingConfig: singleValueStyling, chartOptions: { fontSize: "xl" } } }, + { id: uuid(), chartType: "single-value", connectionId: neo4jId, + query: "MATCH (m:Movie) RETURN count(m) AS value, count(m) - 5 AS previous", + settings: { title: "With Trend", chartOptions: { fontSize: "lg", trendEnabled: true } } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 4, h: 3 }, + { i: null, x: 4, y: 0, w: 4, h: 3 }, + { i: null, x: 8, y: 0, w: 4, h: 3 }, + { i: null, x: 0, y: 3, w: 4, h: 3 }, + { i: null, x: 4, y: 3, w: 4, h: 3 }, + { i: null, x: 8, y: 3, w: 4, h: 3 }, + ], + }, + + // ── Page 5: Table ────────────────────────────────────────────── + { + id: uuid(), + title: "Table", + widgets: [ + { id: uuid(), chartType: "table", connectionId: neo4jId, query: Q.tableData, + settings: { title: "Default Table" } }, + { id: uuid(), chartType: "table", connectionId: neo4jId, query: Q.tableData, + settings: { title: "With Sorting + Filters", chartOptions: { enableSorting: true, enableColumnFilters: true, enableGlobalFilter: true } } }, + { id: uuid(), chartType: "table", connectionId: neo4jId, query: Q.tableData, + settings: { title: "Row Selection", chartOptions: { enableSelection: true } } }, + { id: uuid(), chartType: "table", connectionId: neo4jId, query: Q.tableData, + settings: { title: "Click → Set Parameter", clickAction: clickSetParam("name", "table_actor") } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + { i: null, x: 0, y: 5, w: 6, h: 5 }, + { i: null, x: 6, y: 5, w: 6, h: 5 }, + ], + }, + + // ── Page 6: Gauge Chart ──────────────────────────────────────── + { + id: uuid(), + title: "Gauge Chart", + widgets: [ + { id: uuid(), chartType: "gauge", connectionId: neo4jId, query: Q.gaugeData, + settings: { title: "Default Gauge" } }, + { id: uuid(), chartType: "gauge", connectionId: neo4jId, query: Q.gaugeData, + settings: { title: "No Pointer", chartOptions: { showPointer: false } } }, + { id: uuid(), chartType: "gauge", connectionId: neo4jId, query: Q.gaugeData, + settings: { title: "Half Gauge", chartOptions: { startAngle: 180, endAngle: 0 } } }, + { id: uuid(), chartType: "gauge", connectionId: neo4jId, query: Q.gaugeData, + settings: { title: "Rule-Based Styling", stylingConfig: singleValueStyling } }, + ...paletteRow("gauge", Q.gaugeData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 3, h: 4 }, + { i: null, x: 3, y: 0, w: 3, h: 4 }, + { i: null, x: 6, y: 0, w: 3, h: 4 }, + { i: null, x: 9, y: 0, w: 3, h: 4 }, + ...paletteGrid(4), + ], + }, + + // ── Page 7: Radar Chart ──────────────────────────────────────── + { + id: uuid(), + title: "Radar Chart", + widgets: [ + { id: uuid(), chartType: "radar", connectionId: neo4jId, query: Q.radarData, + settings: { title: "Default Radar" } }, + { id: uuid(), chartType: "radar", connectionId: neo4jId, query: Q.radarData, + settings: { title: "Circle Shape", chartOptions: { shape: "circle" } } }, + { id: uuid(), chartType: "radar", connectionId: neo4jId, query: Q.radarData, + settings: { title: "Filled + Values", chartOptions: { filled: true, showValues: true } } }, + { id: uuid(), chartType: "radar", connectionId: neo4jId, query: Q.radarData, + settings: { title: "Colorblind Mode", chartOptions: { colorblindMode: true } } }, + ...paletteRow("radar", Q.radarData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 3, h: 4 }, + { i: null, x: 3, y: 0, w: 3, h: 4 }, + { i: null, x: 6, y: 0, w: 3, h: 4 }, + { i: null, x: 9, y: 0, w: 3, h: 4 }, + ...paletteGrid(4), + ], + }, + + // ── Page 8: Sankey Chart ─────────────────────────────────────── + { + id: uuid(), + title: "Sankey Chart", + widgets: [ + { id: uuid(), chartType: "sankey", connectionId: neo4jId, query: Q.sankeyData, + settings: { title: "Horizontal (default)" } }, + { id: uuid(), chartType: "sankey", connectionId: neo4jId, query: Q.sankeyData, + settings: { title: "Vertical", chartOptions: { orient: "vertical" } } }, + ...paletteRow("sankey", Q.sankeyData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + ...paletteGrid(5), + ], + }, + + // ── Page 9: Treemap Chart ────────────────────────────────────── + { + id: uuid(), + title: "Treemap Chart", + widgets: [ + { id: uuid(), chartType: "treemap", connectionId: neo4jId, query: Q.treemapData, + settings: { title: "Default Treemap" } }, + { id: uuid(), chartType: "treemap", connectionId: neo4jId, query: Q.treemapData, + settings: { title: "With Values", chartOptions: { showValues: true } } }, + ...paletteRow("treemap", Q.treemapData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + ...paletteGrid(5), + ], + }, + + // ── Page 10: Sunburst Chart ──────────────────────────────────── + { + id: uuid(), + title: "Sunburst Chart", + widgets: [ + { id: uuid(), chartType: "sunburst", connectionId: neo4jId, query: Q.sunburstData, + settings: { title: "Default Sunburst" } }, + { id: uuid(), chartType: "sunburst", connectionId: neo4jId, query: Q.sunburstData, + settings: { title: "No Labels", chartOptions: { showLabels: false } } }, + ...paletteRow("sunburst", Q.sunburstData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + ...paletteGrid(5), + ], + }, + + // ── Page 11: Content Widgets ─────────────────────────────────── + { + id: uuid(), + title: "Content Widgets", + widgets: [ + { id: uuid(), chartType: "markdown", connectionId: "", query: "", + settings: { + title: "Markdown Widget", + chartOptions: { + content: "# NeoBoard Chart Catalog\n\nThis dashboard showcases **every chart type** with all feature variants.\n\n## Features\n- Rule-based styling\n- Click actions\n- Color palettes\n- Accessibility modes\n\n| Chart | Variants |\n| --- | --- |\n| Bar | Vertical, Horizontal, Stacked |\n| Line | Smooth, Area, Stepped |\n| Pie | Donut, Rose, Labels Inside |", + }, + }, + }, + { id: uuid(), chartType: "json", connectionId: neo4jId, + query: "MATCH (m:Movie) RETURN m ORDER BY m.released DESC LIMIT 3", + settings: { title: "JSON Viewer", chartOptions: { initialExpanded: 2 } } }, + { id: uuid(), chartType: "iframe", connectionId: "", query: "", + settings: { + title: "Embedded Content", + chartOptions: { url: "https://en.wikipedia.org/wiki/Data_visualization", iframeTitle: "Data Visualization — Wikipedia" }, + }, + }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 6 }, + { i: null, x: 6, y: 0, w: 6, h: 6 }, + { i: null, x: 0, y: 6, w: 12, h: 5 }, + ], + }, + + // ── Page 12: Map Chart ────────────────────────────────────── + { + id: uuid(), + title: "Map Chart", + widgets: [ + // OSM — cities by population + { id: uuid(), chartType: "map", connectionId: neo4jId, query: Q.mapCities, + settings: { title: "Cities (OSM)", chartOptions: { tileLayer: "osm", autoFitBounds: true, markerSize: 8, showPopup: true } } }, + // Carto Light — filming locations + { id: uuid(), chartType: "map", connectionId: neo4jId, query: Q.mapFilming, + settings: { title: "Filming Locations (Carto Light)", chartOptions: { tileLayer: "carto-light", autoFitBounds: true, markerSize: 10 } } }, + // Carto Dark — birthplaces + { id: uuid(), chartType: "map", connectionId: neo4jId, query: Q.mapBirthplaces, + settings: { title: "Birthplaces (Carto Dark)", chartOptions: { tileLayer: "carto-dark", autoFitBounds: true } } }, + // Cluster markers + { id: uuid(), chartType: "map", connectionId: neo4jId, query: Q.mapCities, + settings: { title: "Clustered Markers", chartOptions: { clusterMarkers: true, autoFitBounds: true } } }, + // Custom zoom + no popup + { id: uuid(), chartType: "map", connectionId: neo4jId, query: Q.mapCities, + settings: { title: "Zoom 4 / No Popup", chartOptions: { zoom: 4, minZoom: 2, maxZoom: 10, showPopup: false, autoFitBounds: false } } }, + // Large markers + click action + { id: uuid(), chartType: "map", connectionId: neo4jId, query: Q.mapCities, + settings: { title: "Large Markers + Click", chartOptions: { markerSize: 14, autoFitBounds: true }, clickAction: clickSetParam("name", "map_city") } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 4, h: 5 }, + { i: null, x: 4, y: 0, w: 4, h: 5 }, + { i: null, x: 8, y: 0, w: 4, h: 5 }, + { i: null, x: 0, y: 5, w: 4, h: 5 }, + { i: null, x: 4, y: 5, w: 4, h: 5 }, + { i: null, x: 8, y: 5, w: 4, h: 5 }, + ], + }, + + // ── Page 13: Graph Chart ───────────────────────────────────── + { + id: uuid(), + title: "Graph Chart", + widgets: [ + { id: uuid(), chartType: "graph", connectionId: neo4jId, query: Q.graphData, + settings: { title: "Force Layout (default)", chartOptions: { layout: "force", showLabels: true, showRelationshipLabels: true, physics: true } } }, + { id: uuid(), chartType: "graph", connectionId: neo4jId, query: Q.graphSmall, + settings: { title: "Circular Layout", chartOptions: { layout: "circular", showLabels: true } } }, + { id: uuid(), chartType: "graph", connectionId: neo4jId, query: Q.graphSmall, + settings: { title: "Hierarchical", chartOptions: { layout: "hierarchical", showLabels: true } } }, + { id: uuid(), chartType: "graph", connectionId: neo4jId, query: Q.graphSmall, + settings: { title: "No Labels / No Physics", chartOptions: { showLabels: false, showRelationshipLabels: false, physics: false, nodeSize: "large" } } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 6 }, + { i: null, x: 6, y: 0, w: 6, h: 6 }, + { i: null, x: 0, y: 6, w: 6, h: 6 }, + { i: null, x: 6, y: 6, w: 6, h: 6 }, + ], + }, + + // ── Page 13: Parameter Widgets ───────────────────────────────── + { + id: uuid(), + title: "Parameter Widgets", + widgets: [ + { id: uuid(), chartType: "parameter-select", connectionId: neo4jId, query: "", + settings: { title: "Select (Searchable)", chartOptions: { parameterType: "select", parameterName: "cat_person", seedQuery: Q.selectSeed, searchable: true, placeholder: "Choose a person\u2026" } } }, + { id: uuid(), chartType: "parameter-select", connectionId: neo4jId, query: "", + settings: { title: "Select (Not Searchable)", chartOptions: { parameterType: "select", parameterName: "cat_person2", seedQuery: Q.selectSeed, searchable: false } } }, + { id: uuid(), chartType: "parameter-select", connectionId: "", query: "", + settings: { title: "Free Text", chartOptions: { parameterType: "text", parameterName: "cat_text", placeholder: "Type anything\u2026" } } }, + { id: uuid(), chartType: "parameter-select", connectionId: "", query: "", + settings: { title: "Date Picker", chartOptions: { parameterType: "date", parameterName: "cat_date" } } }, + { id: uuid(), chartType: "parameter-select", connectionId: "", query: "", + settings: { title: "Date Range", chartOptions: { parameterType: "date-range", parameterName: "cat_daterange" } } }, + { id: uuid(), chartType: "parameter-select", connectionId: "", query: "", + settings: { title: "Relative Date", chartOptions: { parameterType: "date-relative", parameterName: "cat_reldate" } } }, + // Bound widget showing parameter in use + { id: uuid(), chartType: "table", connectionId: neo4jId, + query: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WHERE p.name = $param_cat_person RETURN m.title AS movie, m.released AS year ORDER BY year", + settings: { title: "Movies for $param_cat_person" } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 4, h: 2 }, + { i: null, x: 4, y: 0, w: 4, h: 2 }, + { i: null, x: 8, y: 0, w: 4, h: 2 }, + { i: null, x: 0, y: 2, w: 4, h: 2 }, + { i: null, x: 4, y: 2, w: 4, h: 2 }, + { i: null, x: 8, y: 2, w: 4, h: 2 }, + { i: null, x: 0, y: 4, w: 12, h: 4 }, + ], + }, + + // ── Page 14: Form Widget ─────────────────────────────────────── + { + id: uuid(), + title: "Form Widget", + widgets: [ + { id: uuid(), chartType: "form", connectionId: neo4jId, + query: "CREATE (n:Feedback {author: $param_cat_author, message: $param_cat_msg}) RETURN n.author AS author", + settings: { + title: "Default Form", + formFields: [ + { id: uuid(), label: "Author", parameterName: "cat_author", parameterType: "text", placeholder: "Your name" }, + { id: uuid(), label: "Message", parameterName: "cat_msg", parameterType: "text", placeholder: "Your message" }, + ], + chartOptions: { submitButtonText: "Submit", successMessage: "Feedback submitted!", resetOnSuccess: true }, + }, + }, + { id: uuid(), chartType: "form", connectionId: neo4jId, + query: "CREATE (p:Person {name: $param_cat_name, born: toInteger($param_cat_born_min)}) RETURN p.name AS name", + settings: { + title: "Custom Button + No Reset", + formFields: [ + { id: uuid(), label: "Name", parameterName: "cat_name", parameterType: "text", placeholder: "Full name" }, + { id: uuid(), label: "Born", parameterName: "cat_born", parameterType: "number-range", rangeMin: 1900, rangeMax: 2010, rangeStep: 1 }, + ], + chartOptions: { submitButtonText: "Create Person", successMessage: "Person created!", resetOnSuccess: false }, + }, + }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + ], + }, + + // ── Page 15: Behavior Options ────────────────────────────────── + { + id: behaviorPageId, + title: "Behavior Options", + widgets: [ + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Refresh Button", chartOptions: { showRefreshButton: true } } }, + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Manual Run", chartOptions: { manualRun: true } } }, + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Cache Forever", chartOptions: { cacheMode: "forever", showRefreshButton: true } } }, + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Line + Refresh", chartOptions: { showRefreshButton: true } } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Pie + Manual Run", chartOptions: { manualRun: true } } }, + { id: uuid(), chartType: "table", connectionId: neo4jId, query: Q.tableData, + settings: { title: "Table + Cache Forever", chartOptions: { cacheMode: "forever", showRefreshButton: true } } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 4, h: 4 }, + { i: null, x: 4, y: 0, w: 4, h: 4 }, + { i: null, x: 8, y: 0, w: 4, h: 4 }, + { i: null, x: 0, y: 4, w: 4, h: 4 }, + { i: null, x: 4, y: 4, w: 4, h: 4 }, + { i: null, x: 8, y: 4, w: 4, h: 4 }, + ], + }, + + // ── Page 16: Missing Options — Axis, Grid, Legend ────────────── + { + id: uuid(), + title: "Axis & Grid Options", + widgets: [ + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "With Axis Labels", chartOptions: { xAxisLabel: "Decade", yAxisLabel: "Count" } } }, + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "No Grid Lines", chartOptions: { showGridLines: false } } }, + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Custom Bar Width/Gap", chartOptions: { barWidth: 20, barGap: "50%" } } }, + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barMulti, + settings: { title: "No Legend", chartOptions: { showLegend: false, stacked: true } } }, + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Line + Axis Labels", chartOptions: { xAxisLabel: "Year", yAxisLabel: "Movies", showGridLines: false } } }, + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Line No Legend", chartOptions: { showLegend: false } } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "No Labels", chartOptions: { showLabel: false } } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "No % + Sorted", chartOptions: { showPercentage: false, sortSlices: true } } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "No Legend", chartOptions: { showLegend: false } } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 4, h: 4 }, + { i: null, x: 4, y: 0, w: 4, h: 4 }, + { i: null, x: 8, y: 0, w: 4, h: 4 }, + { i: null, x: 0, y: 4, w: 4, h: 4 }, + { i: null, x: 4, y: 4, w: 4, h: 4 }, + { i: null, x: 8, y: 4, w: 4, h: 4 }, + { i: null, x: 0, y: 8, w: 4, h: 4 }, + { i: null, x: 4, y: 8, w: 4, h: 4 }, + { i: null, x: 8, y: 8, w: 4, h: 4 }, + ], + }, + + // ── Page 17: Missing Options — Table, Gauge, Others ──────────── + { + id: uuid(), + title: "Advanced Options", + widgets: [ + { id: uuid(), chartType: "table", connectionId: neo4jId, query: Q.tableData, + settings: { title: "No Pagination (pageSize=100)", chartOptions: { enablePagination: false, pageSize: 100 } } }, + { id: uuid(), chartType: "table", connectionId: neo4jId, query: Q.tableData, + settings: { title: "Page Size 5", chartOptions: { pageSize: 5 } } }, + { id: uuid(), chartType: "gauge", connectionId: neo4jId, query: Q.gaugeData, + settings: { title: "Min=0 Max=200", chartOptions: { min: 0, max: 200 } } }, + { id: uuid(), chartType: "gauge", connectionId: neo4jId, query: Q.gaugeData, + settings: { title: "No Progress Arc", chartOptions: { showProgress: false } } }, + { id: uuid(), chartType: "gauge", connectionId: neo4jId, query: Q.gaugeData, + settings: { title: "No Detail", chartOptions: { showDetail: false } } }, + { id: uuid(), chartType: "radar", connectionId: neo4jId, query: Q.radarData, + settings: { title: "Radar No Legend", chartOptions: { showLegend: false } } }, + { id: uuid(), chartType: "sankey", connectionId: neo4jId, query: Q.sankeyData, + settings: { title: "No Labels + Wide Nodes", chartOptions: { showLabels: false, nodeWidth: 30, nodeGap: 12 } } }, + { id: uuid(), chartType: "sunburst", connectionId: neo4jId, query: Q.sunburstData, + settings: { title: "Sort Asc + No Highlight", chartOptions: { sort: "asc", highlightOnHover: false } } }, + { id: uuid(), chartType: "treemap", connectionId: neo4jId, query: Q.treemapData, + settings: { title: "No Labels + Low Saturation", chartOptions: { showLabels: false, colorSaturation: "low" } } }, + { id: uuid(), chartType: "treemap", connectionId: neo4jId, query: Q.treemapData, + settings: { title: "No Breadcrumb + High Saturation", chartOptions: { showBreadcrumb: false, colorSaturation: "high" } } }, + { id: uuid(), chartType: "json", connectionId: neo4jId, + query: "MATCH (m:Movie) RETURN m ORDER BY m.released DESC LIMIT 3", + settings: { title: "JSON Large + Light Theme", chartOptions: { initialExpanded: 3, fontSize: "lg", theme: "light", showCopyButton: false } } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + { i: null, x: 0, y: 5, w: 3, h: 4 }, + { i: null, x: 3, y: 5, w: 3, h: 4 }, + { i: null, x: 6, y: 5, w: 3, h: 4 }, + { i: null, x: 9, y: 5, w: 3, h: 4 }, + { i: null, x: 0, y: 9, w: 4, h: 4 }, + { i: null, x: 4, y: 9, w: 4, h: 4 }, + { i: null, x: 8, y: 9, w: 4, h: 4 }, + { i: null, x: 0, y: 13, w: 6, h: 4 }, + { i: null, x: 6, y: 13, w: 6, h: 4 }, + ], + }, + + // ── Page 18: Detail (click target) ───────────────────────────── + { + id: detailPageId, + title: "Detail View", + widgets: [ + { id: uuid(), chartType: "single-value", connectionId: neo4jId, + query: "RETURN $param_bar_decade AS value", + settings: { title: "Selected Decade", chartOptions: { fontSize: "xl", prefix: "Decade: " } } }, + { id: uuid(), chartType: "table", connectionId: neo4jId, + query: "MATCH (m:Movie) WHERE (m.released / 10) * 10 = toInteger($param_bar_decade) RETURN m.title AS title, m.released AS year ORDER BY year", + settings: { title: "Movies in Decade" } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 4, h: 3 }, + { i: null, x: 4, y: 0, w: 8, h: 6 }, + ], + }, + ], + }; +} + function patchGridIds(layout) { for (const page of layout.pages) { for (let idx = 0; idx < page.gridLayout.length; idx++) {