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__/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/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 && ( +
+
+
+ )} + { 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/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/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/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++) {