diff --git a/app/e2e/code-completion.spec.ts b/app/e2e/code-completion.spec.ts index 9ea1d647..1cf0fec5 100644 --- a/app/e2e/code-completion.spec.ts +++ b/app/e2e/code-completion.spec.ts @@ -99,21 +99,38 @@ async function triggerCypherAutocomplete( } /** - * Click inside the CM editor content area and type text via keyboard events. - * After typing, re-clicks the editor to ensure focus is fully settled — the - * neo4j-cypher editor's completion keymap requires a settled focus state that - * keyboard.type() alone doesn't guarantee. + * Insert exact text into the CM editor via CM6 dispatch (bypasses closeBrackets + * auto-insertion that corrupts partial Cypher like "MATCH (n:" → "MATCH (n:)"). + * After dispatch, clicks the editor to ensure focus for keyboard shortcuts. */ async function typeInCmEditor( dialog: Locator, page: Page, text: string, ): Promise { - const cm = dialog.locator("[data-testid='codemirror-container'] .cm-content"); - await cm.click(); - await page.keyboard.type(text, { delay: 30 }); + const cmContainer = dialog.locator("[data-testid='codemirror-container']"); + const cm = cmContainer.locator(".cm-content"); + + // Use CM6 dispatch to set exact text without closeBrackets interference + await cmContainer.evaluate((el: HTMLElement, t: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function findView(node: Element | null): any { + if (!node) return null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tile = (node as any).cmTile; + return tile?.root?.view ?? tile?.view ?? null; + } + const view = findView(el.querySelector(".cm-content")) ?? findView(el.querySelector(".cm-editor")); + if (!view) throw new Error("CM6 view not found for typeInCmEditor"); + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: t }, + selection: { anchor: t.length }, + }); + }, text); + // Re-focus: the Cypher editor's CM6 completion keymap needs a settled focus - // state after programmatic typing. Without this, Ctrl+Space may not trigger. + // state after programmatic dispatch. Without this, Ctrl+Space may not trigger. + // eslint-disable-next-line playwright/no-wait-for-timeout await page.waitForTimeout(100); await cm.click(); } diff --git a/app/e2e/fixtures.ts b/app/e2e/fixtures.ts index 68f330f5..6bbbc2b1 100644 --- a/app/e2e/fixtures.ts +++ b/app/e2e/fixtures.ts @@ -106,16 +106,19 @@ export async function typeInEditor( } // Strategy 1: Use CM6's internal dispatch API (most reliable). - // In CM6 v6.x, each DOM node managed by the editor has a `cmTile` - // property (Tile instance). The `.cm-content` element's cmTile is a - // DocTile whose `.root.view` yields the EditorView. This mirrors the - // logic of the static `EditorView.findFromDOM()` method. + // CM6 decorates managed DOM nodes with a `cmTile` property (Tile instance). + // We mirror EditorView.findFromDOM(): try .cm-content first, then .cm-editor. const dispatched = await cmContainer.evaluate((el: HTMLElement, text: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function findView(node: Element | null): any { + if (!node) return null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tile = (node as any).cmTile; + return tile?.root?.view ?? tile?.view ?? null; + } const cmContent = el.querySelector(".cm-content"); if (!cmContent) return "no-editor"; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const tile = (cmContent as any).cmTile; - const view = tile?.root?.view ?? tile?.view; + const view = findView(cmContent) ?? findView(el.querySelector(".cm-editor")); if (!view) return "no-view"; if (view.state.readOnly) return "readonly"; @@ -134,11 +137,14 @@ export async function typeInEditor( // eslint-disable-next-line playwright/no-wait-for-timeout await page.waitForTimeout(300); const stillPresent = await cmContainer.evaluate((el: HTMLElement, text: string) => { - const c = el.querySelector(".cm-content"); - if (!c) return false; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const tile = (c as any).cmTile; - const view = tile?.root?.view ?? tile?.view; + function findView(node: Element | null): any { + if (!node) return null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tile = (node as any).cmTile; + return tile?.root?.view ?? tile?.view ?? null; + } + const view = findView(el.querySelector(".cm-content")) ?? findView(el.querySelector(".cm-editor")); if (!view) return false; return view.state.doc.toString().includes(text.substring(0, 20)); }, query); @@ -148,8 +154,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 +169,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/package-lock.json b/app/package-lock.json index bd252a27..6edcab01 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -58,12 +58,12 @@ "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/lang-sql": "^6.10.0", + "@codemirror/language": "^6.12.2", "@codemirror/state": "^6.5.4", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.39.15", "@hookform/resolvers": "^5.2.2", - "@neo4j-cypher/codemirror": "^1.0.3", - "@neo4j-cypher/editor-support": "^1.0.2", + "@neo4j-cypher/language-support": "^2.0.0-next.30", "@neo4j-nvl/react": "^1.1.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", diff --git a/app/src/components/chart-renderer.tsx b/app/src/components/chart-renderer.tsx index 02c287f9..76f1c38d 100644 --- a/app/src/components/chart-renderer.tsx +++ b/app/src/components/chart-renderer.tsx @@ -123,9 +123,20 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab const handleEChartsClick = useMemo(() => { if (!onChartClick) return undefined; - return (e: EChartsClickEvent) => - onChartClick({ name: e.name, value: e.value, seriesName: e.seriesName, dataIndex: e.dataIndex }); - }, [onChartClick]); + return (e: EChartsClickEvent) => { + // Enrich the click point with the original data row so that + // column-name source fields (e.g. "revenue") resolve correctly + // in click action rules — not just ECharts built-in fields. + const row = Array.isArray(data) ? (data[e.dataIndex] as Record | undefined) : undefined; + onChartClick({ + ...(row ?? {}), + name: e.name, + value: e.value, + seriesName: e.seriesName, + dataIndex: e.dataIndex, + }); + }; + }, [onChartClick, data]); switch (type) { case "bar": @@ -141,10 +152,13 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab xAxisLabel={settings.xAxisLabel as string | undefined} yAxisLabel={settings.yAxisLabel as string | undefined} showGridLines={settings.showGridLines as boolean | undefined} + axisLabelRotation={settings.axisLabelRotation as number | undefined} + referenceLines={settings.referenceLines as string | undefined} colorThresholds={colorThresholds} stylingRules={stylingRules} paramValues={paramValues} onClick={handleEChartsClick} + enableDataZoom={settings.enableDataZoom as boolean | undefined} colorPalette={settings.colorPalette as string | undefined} colorblindMode={settings.colorblindMode as boolean | undefined} /> @@ -163,10 +177,12 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab stepped={settings.stepped as boolean | undefined} showPoints={settings.showPoints as boolean | undefined} showGridLines={settings.showGridLines as boolean | undefined} + referenceLines={settings.referenceLines as string | undefined} colorThresholds={colorThresholds} stylingRules={stylingRules} paramValues={paramValues} onClick={handleEChartsClick} + enableDataZoom={settings.enableDataZoom as boolean | undefined} colorPalette={settings.colorPalette as string | undefined} colorblindMode={settings.colorblindMode as boolean | undefined} /> @@ -183,6 +199,8 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab labelPosition={settings.labelPosition as "outside" | "inside" | "center" | undefined} showPercentage={settings.showPercentage as boolean | undefined} sortSlices={settings.sortSlices as boolean | undefined} + topN={settings.topN as number | undefined} + donutCenterText={settings.donutCenterText as string | undefined} colorThresholds={colorThresholds} stylingRules={stylingRules} paramValues={paramValues} @@ -203,6 +221,7 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab suffix={settings.suffix as string | undefined} fontSize={settings.fontSize as "sm" | "md" | "lg" | "xl" | undefined} numberFormat={settings.numberFormat as "plain" | "comma" | "compact" | "percent" | undefined} + decimalPlaces={settings.decimalPlaces as number | undefined} colorThresholds={colorThresholds} stylingRules={stylingRules} paramValues={paramValues} diff --git a/app/src/lib/__tests__/chart-registry.test.ts b/app/src/lib/__tests__/chart-registry.test.ts index 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__/axis-label-utils.test.ts b/component/src/charts/__tests__/axis-label-utils.test.ts new file mode 100644 index 00000000..8058a0a5 --- /dev/null +++ b/component/src/charts/__tests__/axis-label-utils.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { buildCategoryAxisLabel } from "../chart-utils"; + +describe("buildCategoryAxisLabel", () => { + it("returns default config for small category count", () => { + const result = buildCategoryAxisLabel(5); + expect(result.rotate).toBe(0); + expect(result.formatter).toBeUndefined(); + }); + + it("rotates labels at 30° when categories >= 8", () => { + const result = buildCategoryAxisLabel(8); + expect(result.rotate).toBe(30); + }); + + it("rotates labels at 45° when categories >= 15", () => { + const result = buildCategoryAxisLabel(15); + expect(result.rotate).toBe(45); + }); + + it("truncates labels longer than 15 chars with ellipsis", () => { + const result = buildCategoryAxisLabel(10); + expect(result.formatter).toBeDefined(); + const fmt = result.formatter as (value: string) => string; + expect(fmt("Short")).toBe("Short"); + expect(fmt("This is a very long label text")).toBe("This is a very\u2026"); + }); + + it("respects custom maxLength", () => { + const result = buildCategoryAxisLabel(10, { maxLabelLength: 8 }); + const fmt = result.formatter as (value: string) => string; + expect(fmt("12345678")).toBe("12345678"); + expect(fmt("123456789")).toBe("1234567\u2026"); + }); + + it("respects rotation override", () => { + const result = buildCategoryAxisLabel(100, { rotateOverride: 60 }); + expect(result.rotate).toBe(60); + }); + + it("returns rotate 0 with override of 0", () => { + const result = buildCategoryAxisLabel(20, { rotateOverride: 0 }); + expect(result.rotate).toBe(0); + }); + + it("always includes tooltip config for full text", () => { + const result = buildCategoryAxisLabel(10); + expect(result.tooltip).toEqual({ show: true }); + }); + + it("returns show: false when compact is true", () => { + const result = buildCategoryAxisLabel(10, { compact: true }); + expect(result.show).toBe(false); + }); + + it("normalizes -1 sentinel to automatic rotation", () => { + // -1 is the "automatic" sentinel from the UI; it should fall through + // to the category-count heuristic, not produce rotate: -1 + const few = buildCategoryAxisLabel(5, { rotateOverride: -1 }); + expect(few.rotate).toBe(0); + + const medium = buildCategoryAxisLabel(10, { rotateOverride: -1 }); + expect(medium.rotate).toBe(30); + + const many = buildCategoryAxisLabel(20, { rotateOverride: -1 }); + expect(many.rotate).toBe(45); + }); +}); diff --git a/component/src/charts/__tests__/base-chart.test.tsx b/component/src/charts/__tests__/base-chart.test.tsx index b2b37bbe..44a40b39 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", () => { @@ -207,4 +209,30 @@ describe("BaseChart", () => { { notMerge: true }, ); }); + + // --- DataZoom --- + + it("does not include dataZoom by default", () => { + render(); + const call = mockSetOption.mock.calls[0][0]; + expect(call.dataZoom).toBeUndefined(); + }); + + it("injects dataZoom config when enableDataZoom is true", () => { + render(); + const call = mockSetOption.mock.calls[0][0]; + expect(call.dataZoom).toBeDefined(); + expect(Array.isArray(call.dataZoom)).toBe(true); + expect(call.dataZoom).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "inside" }), + ]), + ); + }); + + it("does not inject dataZoom when enableDataZoom is false", () => { + render(); + const call = mockSetOption.mock.calls[0][0]; + expect(call.dataZoom).toBeUndefined(); + }); }); 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..6c911156 --- /dev/null +++ b/component/src/charts/__tests__/format-number.test.ts @@ -0,0 +1,109 @@ +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"); + }); + + it("omits seriesName label when seriesName is undefined", () => { + const formatter = buildTooltipFormatter({}); + const result = formatter({ value: 42, name: "Jan" }); + expect(result).not.toContain("undefined"); + expect(result).toContain(""); + }); + + it("omits seriesName label when seriesName is empty string", () => { + const formatter = buildTooltipFormatter({}); + const result = formatter({ seriesName: "", value: 42, name: "Jan" }); + expect(result).not.toContain(": "); + }); +}); 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__/line-chart.test.tsx b/component/src/charts/__tests__/line-chart.test.tsx index dc3839c1..f6960d63 100644 --- a/component/src/charts/__tests__/line-chart.test.tsx +++ b/component/src/charts/__tests__/line-chart.test.tsx @@ -159,4 +159,31 @@ describe("LineChart", () => { const optionsCall = mockSetOption.mock.calls[0][0]; expect(optionsCall.series[0].step).toBeUndefined(); }); + + // --- Reference lines --- + + it("attaches markLine to the first series when referenceLines is provided", () => { + const refs = JSON.stringify([{ value: 50, label: "Target", color: "#ff0000" }]); + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].markLine).toBeDefined(); + expect(optionsCall.series[0].markLine.data).toHaveLength(1); + expect(optionsCall.series[0].markLine.data[0].yAxis).toBe(50); + expect(optionsCall.series[0].markLine.data[0].label.formatter).toBe("Target"); + expect(optionsCall.series[0].markLine.data[0].lineStyle.color).toBe("#ff0000"); + }); + + it("does not attach markLine when referenceLines is not provided", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].markLine).toBeUndefined(); + }); + + it("only attaches markLine to the first series in multi-series", () => { + const refs = JSON.stringify([{ value: 100 }]); + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].markLine).toBeDefined(); + expect(optionsCall.series[1].markLine).toBeUndefined(); + }); }); diff --git a/component/src/charts/__tests__/pie-utils.test.ts b/component/src/charts/__tests__/pie-utils.test.ts new file mode 100644 index 00000000..8377827d --- /dev/null +++ b/component/src/charts/__tests__/pie-utils.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { groupTopN } from "../chart-utils"; +import type { PieChartDataPoint } from "../types"; + +describe("groupTopN", () => { + const data: PieChartDataPoint[] = [ + { name: "A", value: 100 }, + { name: "B", value: 80 }, + { name: "C", value: 60 }, + { name: "D", value: 40 }, + { name: "E", value: 20 }, + ]; + + it("returns all data when topN is 0 (disabled)", () => { + expect(groupTopN(data, 0)).toEqual(data); + }); + + it("returns all data when topN >= data length", () => { + expect(groupTopN(data, 5)).toEqual(data); + expect(groupTopN(data, 10)).toEqual(data); + }); + + it("groups remaining items into Other when topN < data length", () => { + const result = groupTopN(data, 3); + expect(result).toHaveLength(4); + expect(result[0].name).toBe("A"); + expect(result[1].name).toBe("B"); + expect(result[2].name).toBe("C"); + expect(result[3].name).toBe("Other"); + expect(result[3].value).toBe(60); // 40 + 20 + }); + + it("handles topN of 1", () => { + const result = groupTopN(data, 1); + expect(result).toHaveLength(2); + expect(result[0].name).toBe("A"); + expect(result[1].name).toBe("Other"); + expect(result[1].value).toBe(200); // 80+60+40+20 + }); + + it("returns empty array for empty input", () => { + expect(groupTopN([], 5)).toEqual([]); + }); +}); diff --git a/component/src/charts/__tests__/reference-line.test.ts b/component/src/charts/__tests__/reference-line.test.ts new file mode 100644 index 00000000..996800e2 --- /dev/null +++ b/component/src/charts/__tests__/reference-line.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { parseReferenceLines, buildMarkLineFromRefs } from "../chart-utils"; +import type { ReferenceLine } from "../chart-utils"; + +describe("parseReferenceLines", () => { + it("returns empty array for undefined input", () => { + expect(parseReferenceLines(undefined)).toEqual([]); + }); + + it("returns empty array for empty string", () => { + expect(parseReferenceLines("")).toEqual([]); + }); + + it("parses a single horizontal reference line", () => { + const input = JSON.stringify([{ value: 50, label: "Target", color: "#ff0000" }]); + const result = parseReferenceLines(input); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ value: 50, label: "Target", color: "#ff0000" }); + }); + + it("parses multiple reference lines", () => { + const input = JSON.stringify([ + { value: 25, label: "Low" }, + { value: 75, label: "High", color: "#00ff00" }, + ]); + const result = parseReferenceLines(input); + expect(result).toHaveLength(2); + }); + + it("returns empty array for invalid JSON", () => { + expect(parseReferenceLines("not-json")).toEqual([]); + }); + + it("filters out entries without a value", () => { + const input = JSON.stringify([{ label: "No value" }, { value: 50, label: "OK" }]); + const result = parseReferenceLines(input); + expect(result).toHaveLength(1); + expect(result[0].value).toBe(50); + }); +}); + +describe("buildMarkLineFromRefs", () => { + it("returns undefined for empty array", () => { + expect(buildMarkLineFromRefs([])).toBeUndefined(); + }); + + it("builds markLine for a single reference line with defaults", () => { + const result = buildMarkLineFromRefs([{ value: 50 }]); + expect(result).toBeDefined(); + expect(result!.silent).toBe(true); + expect(result!.symbol).toBe("none"); + expect(result!.data).toHaveLength(1); + + const entry = result!.data[0]; + expect(entry.yAxis).toBe(50); + expect(entry.label.formatter).toBe("50"); + expect(entry.lineStyle.color).toBe("#888"); + expect(entry.lineStyle.type).toBe("dashed"); + }); + + it("uses the label text when provided", () => { + const result = buildMarkLineFromRefs([{ value: 75, label: "Target" }]); + expect(result!.data[0].label.formatter).toBe("Target"); + }); + + it("uses custom color when provided", () => { + const result = buildMarkLineFromRefs([{ value: 25, color: "#ff0000" }]); + expect(result!.data[0].lineStyle.color).toBe("#ff0000"); + }); + + it("falls back to default color #888 when color is omitted", () => { + const result = buildMarkLineFromRefs([{ value: 10 }]); + expect(result!.data[0].lineStyle.color).toBe("#888"); + }); + + it("builds markLine for multiple reference lines", () => { + const lines: ReferenceLine[] = [ + { value: 20, label: "Low", color: "#00ff00" }, + { value: 80, label: "High", color: "#ff0000" }, + ]; + const result = buildMarkLineFromRefs(lines); + expect(result!.data).toHaveLength(2); + expect(result!.data[0].yAxis).toBe(20); + expect(result!.data[0].label.formatter).toBe("Low"); + expect(result!.data[0].lineStyle.color).toBe("#00ff00"); + expect(result!.data[1].yAxis).toBe(80); + expect(result!.data[1].label.formatter).toBe("High"); + expect(result!.data[1].lineStyle.color).toBe("#ff0000"); + }); + + it("sets label position to insideEndTop", () => { + const result = buildMarkLineFromRefs([{ value: 42 }]); + expect(result!.data[0].label.position).toBe("insideEndTop"); + }); +}); + +describe("ReferenceLine type", () => { + it("accepts minimal reference line", () => { + const line: ReferenceLine = { value: 100 }; + expect(line.value).toBe(100); + }); +}); 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/__tests__/tooltip-formatter.test.ts b/component/src/charts/__tests__/tooltip-formatter.test.ts new file mode 100644 index 00000000..07fd6173 --- /dev/null +++ b/component/src/charts/__tests__/tooltip-formatter.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { buildTooltipFormatter } from "../chart-utils"; +import type { TooltipParam } from "../chart-utils"; + +describe("buildTooltipFormatter", () => { + it("returns a function", () => { + const formatter = buildTooltipFormatter(); + expect(typeof formatter).toBe("function"); + }); + + it("includes seriesName when provided", () => { + const formatter = buildTooltipFormatter(); + const param: TooltipParam = { + seriesName: "Revenue", + value: 1234, + name: "Jan", + marker: '', + }; + const result = formatter(param); + expect(result).toContain("Revenue: "); + expect(result).toContain("Jan"); + expect(result).toContain("1,234"); + }); + + it("omits seriesName label when seriesName is undefined", () => { + const formatter = buildTooltipFormatter(); + const param: TooltipParam = { + value: 42, + name: "Category A", + }; + const result = formatter(param); + expect(result).not.toContain("undefined"); + expect(result).toContain("42"); + expect(result).toContain("Category A"); + }); + + it("omits seriesName label when seriesName is empty string", () => { + const formatter = buildTooltipFormatter(); + const param: TooltipParam = { + seriesName: "", + value: 100, + name: "X", + }; + const result = formatter(param); + expect(result).not.toContain(": "); + }); + + it("handles array params (axis trigger with multiple series)", () => { + const formatter = buildTooltipFormatter(); + const params: TooltipParam[] = [ + { seriesName: "A", value: 100, name: "Jan", marker: "●" }, + { seriesName: "B", value: 200, name: "Jan", marker: "●" }, + ]; + const result = formatter(params); + expect(result).toContain("A: "); + expect(result).toContain("B: "); + expect(result).toContain("100"); + expect(result).toContain("200"); + expect(result).toContain("Jan"); + }); + + it("handles array value (e.g. scatter/candlestick)", () => { + const formatter = buildTooltipFormatter(); + const param: TooltipParam = { + seriesName: "Data", + value: ["x", 999], + name: "Point", + }; + const result = formatter(param); + expect(result).toContain("999"); + }); + + it("handles missing value gracefully", () => { + const formatter = buildTooltipFormatter(); + const param: TooltipParam = { + seriesName: "Series", + name: "Label", + }; + const result = formatter(param); + expect(result).toContain("Series: "); + expect(result).toContain(""); + }); + + it("handles non-string marker gracefully", () => { + const formatter = buildTooltipFormatter(); + const param: TooltipParam = { + seriesName: "Test", + value: 50, + name: "X", + marker: { type: "rich" } as unknown, + }; + const result = formatter(param); + expect(result).toContain("Test: "); + expect(result).toContain("50"); + expect(result).not.toContain("[object"); + }); +}); diff --git a/component/src/charts/bar-chart.tsx b/component/src/charts/bar-chart.tsx index 91f8e88b..0e741dca 100644 --- a/component/src/charts/bar-chart.tsx +++ b/component/src/charts/bar-chart.tsx @@ -9,6 +9,10 @@ import { resolveShowLegend, buildCompactGrid, resolveItemColor, + buildTooltipFormatter, + buildCategoryAxisLabel, + parseReferenceLines, + buildMarkLineFromRefs, } from "./chart-utils"; import { parseColorThresholds } from "./color-threshold"; import type { StylingRule } from "./styling-rule"; @@ -34,6 +38,10 @@ export interface BarChartProps extends Omit { xAxisLabel?: string; /** Y-axis name label */ yAxisLabel?: string; + /** Override axis label rotation angle (0-90). Omit for automatic. */ + axisLabelRotation?: number; + /** JSON string of reference lines: [{ value, label?, color? }] */ + referenceLines?: string; /** @deprecated Use stylingRules instead. JSON string of thresholds for per-bar coloring */ colorThresholds?: string; /** Rule-based styling rules */ @@ -62,6 +70,8 @@ function BarChart({ showGridLines = true, xAxisLabel, yAxisLabel, + axisLabelRotation, + referenceLines: referenceLinesJson, colorThresholds, stylingRules, paramValues, @@ -79,14 +89,23 @@ function BarChart({ const effectiveShowValues = compact ? false : showValues; const effectiveBarWidth = barWidth > 0 ? barWidth : undefined; const thresholds = stylingRules ? [] : parseColorThresholds(colorThresholds ?? ""); + const refLines = parseReferenceLines(referenceLinesJson); + const markLine = buildMarkLineFromRefs(refLines); + + const categoryLabels = data.map((d) => d.label); + const axisLabelConfig = buildCategoryAxisLabel(categoryLabels.length, { + compact, + rotateOverride: axisLabelRotation, + }); const categoryAxis = { type: "category" as const, - data: data.map((d) => d.label), - axisLabel: { show: !compact }, + data: categoryLabels, + axisLabel: axisLabelConfig, + axisPointer: { type: "shadow" as const }, name: compact ? undefined : (isHorizontal ? yAxisLabel : xAxisLabel), nameLocation: "middle" as const, - nameGap: 30, + nameGap: axisLabelConfig.rotate > 0 ? 50 : 30, }; const valueAxis = { type: "value" as const, @@ -98,12 +117,12 @@ function BarChart({ }; return { - tooltip: { trigger: "axis" as const, axisPointer: { type: "shadow" as const } }, + tooltip: { trigger: "axis" as const, axisPointer: { type: "shadow" as const }, formatter: buildTooltipFormatter() }, legend: effectiveShowLegend ? { bottom: 0 } : undefined, grid: buildCompactGrid(compact, effectiveShowLegend), xAxis: isHorizontal ? valueAxis : categoryAxis, yAxis: isHorizontal ? categoryAxis : valueAxis, - series: seriesKeys.map((key) => ({ + series: seriesKeys.map((key, idx) => ({ name: key, type: "bar" as const, data: data.map((d) => { @@ -121,9 +140,11 @@ function BarChart({ ? { show: true, position: isHorizontal ? ("right" as const) : ("top" as const) } : undefined, emphasis: seriesKeys.length > 1 ? { focus: "series" as const } : {}, + // Attach reference lines to the first series only + ...(idx === 0 && markLine ? { markLine } : {}), })), }; - }, [data, orientation, stacked, showValues, showLegend, barWidth, barGap, showGridLines, xAxisLabel, yAxisLabel, colorThresholds, stylingRules, paramValues, compact, hideLegend]); + }, [data, orientation, stacked, showValues, showLegend, barWidth, barGap, showGridLines, xAxisLabel, yAxisLabel, axisLabelRotation, referenceLinesJson, colorThresholds, stylingRules, paramValues, compact, hideLegend]); return (
diff --git a/component/src/charts/base-chart.tsx b/component/src/charts/base-chart.tsx index 17589360..b5027498 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,8 @@ function BaseChart({ onChartReady, onClick, onDataZoom, + enableDataZoom = false, + ariaDescription, colorblindMode = false, colorPalette, }: BaseChartProps) { @@ -150,14 +156,23 @@ function BaseChart({ const merged: EChartsOption = { color: resolvedColors, ...options, + ...(enableDataZoom + ? { + dataZoom: [ + { type: "inside", xAxisIndex: 0 }, + { type: "inside", yAxisIndex: 0 }, + ], + } + : {}), aria: { enabled: true, ...userAria, + ...(ariaDescription ? { label: { description: ariaDescription } } : {}), decal: { show: colorblindMode, ...userDecal }, }, }; instance.setOption(merged, { notMerge: true }); - }, [options, colorblindMode, colorPalette, dark]); + }, [options, enableDataZoom, colorblindMode, colorPalette, dark, ariaDescription]); // Loading state useEffect(() => { @@ -213,7 +228,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..b666fec2 100644 --- a/component/src/charts/chart-utils.ts +++ b/component/src/charts/chart-utils.ts @@ -4,6 +4,209 @@ 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: unknown) => string { + // Tooltip always uses comma format for readability unless explicitly set + const tooltipConfig: NumberFormatConfig = { numberFormat: "comma", ...config }; + + return (params: unknown) => { + const items = Array.isArray(params) ? (params as TooltipParam[]) : [params as TooltipParam]; + 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 ?? ""); + const label = p.seriesName ? `${p.seriesName}: ` : ""; + const marker = typeof p.marker === "string" ? p.marker : ""; + return `${marker} ${label}${val}`; + }); + return header ? `${header}
${lines.join("
")}` : lines.join("
"); + }; +} + +// --------------------------------------------------------------------------- +// Axis label auto-rotation and truncation +// --------------------------------------------------------------------------- + +export interface CategoryAxisLabelOptions { + /** Override the automatic rotation angle. -1 means automatic (sentinel). */ + rotateOverride?: number; + /** Maximum label length before truncation (default: 15). */ + maxLabelLength?: number; + /** Whether the chart is in compact mode (hides labels). */ + compact?: boolean; +} + +export interface CategoryAxisLabelConfig { + show: boolean; + rotate: number; + formatter?: (value: string) => string; + tooltip: { show: boolean }; +} + +/** + * Compute axis label rotation and truncation based on category count. + * - 8+ categories: rotate 30° + * - 15+ categories: rotate 45° + * - Labels longer than maxLabelLength are truncated with ellipsis (U+2026) + * - ECharts axisPointer tooltip shows the full text on hover + * + * A `rotateOverride` of -1 is the "automatic" sentinel from the UI and is + * normalized to undefined so the category-count heuristic applies. + */ +export function buildCategoryAxisLabel( + categoryCount: number, + options: CategoryAxisLabelOptions = {}, +): CategoryAxisLabelConfig { + const { maxLabelLength = 15, compact = false } = options; + // Normalize -1 sentinel (automatic mode) to undefined so ECharts uses its + // default auto-rotation instead of receiving an invalid rotate: -1. + const rotateOverride = options.rotateOverride === -1 ? undefined : options.rotateOverride; + + let rotate: number; + if (rotateOverride !== undefined) { + rotate = rotateOverride; + } else if (categoryCount >= 15) { + rotate = 45; + } else if (categoryCount >= 8) { + rotate = 30; + } else { + rotate = 0; + } + + const needsTruncation = categoryCount >= 8; + const formatter = needsTruncation + ? (value: string) => + value.length > maxLabelLength + ? value.slice(0, maxLabelLength - 1) + "\u2026" + : value + : undefined; + + return { + show: !compact, + rotate, + formatter, + tooltip: { show: true }, + }; +} + +// --------------------------------------------------------------------------- +// Reference lines (markLine) +// --------------------------------------------------------------------------- + +export interface ReferenceLine { + value: number; + label?: string; + color?: string; +} + +/** + * Parse a JSON string of reference lines. Returns empty array on + * invalid input or missing values. + */ +export function parseReferenceLines(input: string | undefined): ReferenceLine[] { + if (!input) return []; + try { + const parsed = JSON.parse(input); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (item: unknown): item is ReferenceLine => + typeof item === "object" && + item !== null && + "value" in item && + typeof (item as ReferenceLine).value === "number", + ); + } catch { + return []; + } +} + +/** + * Build ECharts markLine data from reference lines. + */ +export function buildMarkLineFromRefs(lines: ReferenceLine[]) { + if (!lines.length) return undefined; + return { + silent: true, + symbol: "none", + data: lines.map((line) => ({ + yAxis: line.value, + label: { + formatter: line.label ?? String(line.value), + position: "insideEndTop" as const, + }, + lineStyle: { + color: line.color ?? "#888", + type: "dashed" as const, + }, + })), + }; +} + + /** Detect whether the document is currently in dark mode. */ export function isDark(): boolean { if (typeof document === "undefined") return false; @@ -97,3 +300,22 @@ export function resolveItemColor( } return undefined; } + +// --------------------------------------------------------------------------- +// Pie chart Top-N grouping +// --------------------------------------------------------------------------- + +import type { PieChartDataPoint } from "./types"; + +/** + * Group pie chart data by keeping the top N slices and aggregating the rest + * into an "Other" slice. Returns the original data when topN is 0 or >= data length. + * Data must already be sorted descending by value. + */ +export function groupTopN(data: PieChartDataPoint[], topN: number): PieChartDataPoint[] { + if (!data.length || topN <= 0 || topN >= data.length) return data; + const top = data.slice(0, topN); + const rest = data.slice(topN); + const otherValue = rest.reduce((sum, d) => sum + d.value, 0); + return [...top, { name: "Other", value: otherValue }]; +} 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 && ( +
+
+
+ )} + { showGridLines?: boolean; /** Use stepped line style */ stepped?: boolean; + /** JSON string of reference lines: [{ value, label?, color? }] */ + referenceLines?: string; /** @deprecated Use stylingRules instead. JSON string of thresholds */ colorThresholds?: string; /** Rule-based styling rules */ @@ -62,6 +67,7 @@ function LineChart({ lineWidth = 2, showGridLines = true, stepped = false, + referenceLines: referenceLinesJson, colorThresholds, stylingRules, paramValues, @@ -76,9 +82,11 @@ function LineChart({ const seriesKeys = Object.keys(data[0]).filter((k) => k !== "x"); const effectiveShowLegend = resolveShowLegend(showLegend, seriesKeys.length, hideLegend); const thresholds = stylingRules ? [] : parseColorThresholds(colorThresholds ?? ""); + const refLines = parseReferenceLines(referenceLinesJson); + const markLine = buildMarkLineFromRefs(refLines); return { - tooltip: { trigger: "axis" }, + tooltip: { trigger: "axis", formatter: buildTooltipFormatter() }, legend: effectiveShowLegend ? { bottom: 0 } : undefined, grid: { ...buildCompactGrid(compact, effectiveShowLegend), @@ -100,7 +108,7 @@ function LineChart({ axisLabel: { show: !compact }, splitLine: { show: showGridLines }, }, - series: seriesKeys.map((key) => { + series: seriesKeys.map((key, idx) => { let lastValue: number | undefined; for (let i = data.length - 1; i >= 0; i -= 1) { const candidate = data[i][key]; @@ -123,10 +131,12 @@ function LineChart({ showSymbol: showPoints, areaStyle: area ? {} : undefined, emphasis: seriesKeys.length > 1 ? { focus: "series" as const } : {}, + // Attach reference lines to the first series only + ...(idx === 0 && markLine ? { markLine } : {}), }; }), }; - }, [data, xAxisLabel, yAxisLabel, smooth, area, showLegend, showPoints, lineWidth, showGridLines, stepped, colorThresholds, stylingRules, paramValues, compact, hideLegend]); + }, [data, xAxisLabel, yAxisLabel, smooth, area, showLegend, showPoints, lineWidth, showGridLines, stepped, referenceLinesJson, colorThresholds, stylingRules, paramValues, compact, hideLegend]); return (
diff --git a/component/src/charts/pie-chart.tsx b/component/src/charts/pie-chart.tsx index 98e5af0d..ff6549f6 100644 --- a/component/src/charts/pie-chart.tsx +++ b/component/src/charts/pie-chart.tsx @@ -3,7 +3,7 @@ import type { EChartsOption } from "echarts"; import { BaseChart } from "./base-chart"; import type { BaseChartProps, PieChartDataPoint } from "./types"; import { useContainerSize } from "@/hooks/useContainerSize"; -import { buildEmptyDataOption, getCompactState, isDark, resolveItemColor } from "./chart-utils"; +import { buildEmptyDataOption, getCompactState, isDark, resolveItemColor, groupTopN } from "./chart-utils"; import { parseColorThresholds } from "./color-threshold"; import type { StylingRule } from "./styling-rule"; @@ -24,6 +24,10 @@ export interface PieChartProps extends Omit { showPercentage?: boolean; /** Sort slices by value descending */ sortSlices?: boolean; + /** Group slices beyond top N into "Other". 0 = show all. */ + topN?: number; + /** Text shown in the center of a donut chart (e.g. total value). Empty = auto-total. */ + donutCenterText?: string; /** @deprecated Use stylingRules instead. JSON string of thresholds */ colorThresholds?: string; /** Rule-based styling rules */ @@ -49,6 +53,8 @@ function PieChart({ labelPosition = "outside", showPercentage = true, sortSlices = false, + topN = 0, + donutCenterText, colorThresholds, stylingRules, paramValues, @@ -58,15 +64,18 @@ function PieChart({ const compact = width > 0 && (width < 300 || height < 200); const { hideLegend } = getCompactState(width, height); - const options = useMemo((): EChartsOption => { + // EChartsOption from modular imports may not include 'graphic' — + // we use GraphicComponent which extends the option type at runtime. + const options = useMemo((): EChartsOption & { graphic?: unknown } => { if (!data.length) return buildEmptyDataOption(); const effectiveShowLabel = compact ? false : showLabel; const effectiveShowLegend = hideLegend ? false : showLegend; - const sortedData = sortSlices + const sorted = sortSlices ? [...data].sort((a, b) => b.value - a.value) : data; + const sortedData = groupTopN(sorted, topN); const thresholds = stylingRules ? [] : parseColorThresholds(colorThresholds ?? ""); const coloredData = sortedData.map((d) => { @@ -111,8 +120,23 @@ function PieChart({ }, }, ], + // Donut center text: show total or custom text in the center hole + ...(donut && !compact ? { + graphic: [{ + type: "text", + left: "center", + top: effectiveShowLegend ? "42%" : "47%", + style: { + text: donutCenterText ?? String(sortedData.reduce((s, d) => s + d.value, 0)), + align: "center", + fontSize: 20, + fontWeight: "bold", + fill: isDark() ? "#e5e5e5" : "#262626", + }, + }], + } : {}), }; - }, [data, donut, showLabel, showLegend, roseMode, labelPosition, showPercentage, sortSlices, colorThresholds, stylingRules, paramValues, compact, hideLegend]); + }, [data, donut, showLabel, showLegend, roseMode, labelPosition, showPercentage, sortSlices, topN, donutCenterText, colorThresholds, stylingRules, paramValues, compact, hideLegend]); return (
diff --git a/component/src/charts/single-value-chart.tsx b/component/src/charts/single-value-chart.tsx index 76e67aa6..fbb747b0 100644 --- a/component/src/charts/single-value-chart.tsx +++ b/component/src/charts/single-value-chart.tsx @@ -3,24 +3,12 @@ import { cn } from "@/lib/utils"; import { parseColorThresholds, resolveThresholdColor } from "./color-threshold"; import type { StylingRule } from "./styling-rule"; import { resolveStylingRuleColor } from "./styling-rule"; +import { formatNumber } from "./chart-utils"; +import type { NumberFormat } from "./chart-utils"; export type { ColorThreshold } from "./color-threshold"; export type SingleValueFontSize = "sm" | "md" | "lg" | "xl"; -export type SingleValueNumberFormat = "plain" | "comma" | "compact" | "percent"; - -/** Format a numeric value according to the chosen format. */ -function applyNumberFormat(numericValue: number, fmt: SingleValueNumberFormat): string { - switch (fmt) { - case "comma": - return numericValue.toLocaleString(); - case "compact": - return Intl.NumberFormat("en", { notation: "compact" }).format(numericValue); - case "percent": - return `${numericValue}%`; - default: - return String(numericValue); - } -} +export type SingleValueNumberFormat = NumberFormat; const FONT_SIZE_CLASS: Record = { 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..7a9abc38 100644 --- a/component/src/charts/types.ts +++ b/component/src/charts/types.ts @@ -16,6 +16,10 @@ export interface BaseChartProps { onClick?: (params: EChartsClickEvent) => void; /** Called when data zoom changes */ onDataZoom?: (params: unknown) => void; + /** Enable scroll-to-zoom on the data axis (DataZoom type: 'inside') */ + enableDataZoom?: boolean; + /** 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..1982b318 100644 --- a/component/src/components/composed/chart-options-schema.ts +++ b/component/src/components/composed/chart-options-schema.ts @@ -12,6 +12,16 @@ export interface ChartOptionDef { description?: string; } +/** DataZoom option for axis-based charts (bar, line). */ +const dataZoomOptions: ChartOptionDef[] = [ + { key: "enableDataZoom", label: "Enable Scroll Zoom", type: "boolean", default: false, category: "Interaction", description: "Allow scroll-to-zoom on the data axis to explore large datasets." }, +]; + +/** Shared number formatting options for tooltip values on axis-based charts. */ +const tooltipFormatOptions: ChartOptionDef[] = [ + { key: "decimalPlaces", label: "Decimal Places", type: "number", default: -1, category: "Labels", description: "Fixed number of decimal places in tooltips (0-6). Set to -1 for automatic." }, +]; + const barOptions: ChartOptionDef[] = [ { key: "orientation", @@ -33,6 +43,8 @@ const barOptions: ChartOptionDef[] = [ { key: "xAxisLabel", label: "X-Axis Label", type: "text", default: "", category: "Labels", description: "Custom label displayed below the horizontal axis." }, { key: "yAxisLabel", label: "Y-Axis Label", type: "text", default: "", category: "Labels", description: "Custom label displayed beside the vertical axis." }, { key: "showGridLines", label: "Show Grid Lines", type: "boolean", default: true, category: "Style", description: "Show faint horizontal reference lines behind the bars." }, + { key: "axisLabelRotation", label: "Axis Label Rotation (°)", type: "number", default: -1, category: "Labels", description: "Override axis label rotation angle (0-90). Set to -1 for automatic (rotates at 8+ categories)." }, + { key: "referenceLines", label: "Reference Lines (JSON)", type: "text", default: "", category: "Annotations", description: 'Horizontal reference lines as JSON: [{"value":50,"label":"Target","color":"#ff0000"}]' }, ]; const lineOptions: ChartOptionDef[] = [ @@ -45,6 +57,7 @@ const lineOptions: ChartOptionDef[] = [ { key: "xAxisLabel", label: "X-Axis Label", type: "text", default: "", category: "Labels", description: "Custom label displayed below the horizontal axis." }, { key: "yAxisLabel", label: "Y-Axis Label", type: "text", default: "", category: "Labels", description: "Custom label displayed beside the vertical axis." }, { key: "showLegend", label: "Show Legend", type: "boolean", default: true, category: "Labels", description: "Show the chart legend identifying each data series." }, + { key: "referenceLines", label: "Reference Lines (JSON)", type: "text", default: "", category: "Annotations", description: 'Horizontal reference lines as JSON: [{"value":50,"label":"Target","color":"#ff0000"}]' }, ]; const pieOptions: ChartOptionDef[] = [ @@ -67,12 +80,15 @@ const pieOptions: ChartOptionDef[] = [ { key: "showPercentage", label: "Show Percentage", type: "boolean", default: true, category: "Labels", description: "Show the percentage value on each slice." }, { key: "showLegend", label: "Show Legend", type: "boolean", default: true, category: "Labels", description: "Show the chart legend identifying each slice." }, { key: "sortSlices", label: "Sort Slices by Value", type: "boolean", default: false, category: "Layout", description: "Sort slices by value (largest first) for a cleaner visual layout." }, + { key: "topN", label: "Top N Slices", type: "number", default: 0, category: "Layout", description: "Show only the top N slices and group the rest into 'Other'. Set to 0 to show all." }, + { key: "donutCenterText", label: "Donut Center Text", type: "text", default: "", category: "Labels", description: "Custom text in the donut center. Leave blank to show the total." }, ]; const singleValueOptions: ChartOptionDef[] = [ { key: "title", label: "Title", type: "text", default: "", category: "Display", description: "Custom heading shown above the value. Leave blank to hide." }, { key: "prefix", label: "Prefix", type: "text", default: "", category: "Display", description: "Text prepended to the value (e.g. '$', '€')." }, { key: "suffix", label: "Suffix", type: "text", default: "", category: "Display", description: "Text appended to the value (e.g. '%', ' items')." }, + { key: "decimalPlaces", label: "Decimal Places", type: "number", default: -1, category: "Display", description: "Fixed number of decimal places (0-6). Set to -1 for automatic." }, { key: "fontSize", label: "Font Size", @@ -364,9 +380,9 @@ const treemapOptions: ChartOptionDef[] = [ ]; const chartOptionsRegistry: Record = { - bar: [...barOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions], - line: [...lineOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions], - pie: [...pieOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions], + bar: [...barOptions, ...dataZoomOptions, ...tooltipFormatOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions], + line: [...lineOptions, ...dataZoomOptions, ...tooltipFormatOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions], + pie: [...pieOptions, ...tooltipFormatOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions], "single-value": [...singleValueOptions, ...behaviorOptions], graph: [...graphOptions, ...behaviorOptions], map: [...mapOptions, ...behaviorOptions], 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..7839fe0b 100644 --- a/scripts/seed-demo.mjs +++ b/scripts/seed-demo.mjs @@ -483,6 +483,7 @@ function buildWidgetShowcase(neo4jConnId, pgConnId) { { i: null, x: 0, y: 0, w: 8, h: 5 }, ], }, + ], }; } @@ -1740,6 +1741,28 @@ 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 + ); + + const improvementsLayout = buildChartImprovements(neo4jConnId); + patchGridIds(improvementsLayout); + await upsertDashboard( + sql, + adminId, + "Chart Improvements", + "Number formatting, DataZoom, reference lines, axis rotation, donut/top-N pie, click enrichment, radar global scale, graph anti-clump, markdown tables.", + improvementsLayout, + true + ); + console.log(" Demo dashboards seeded."); } finally { await sql.end(); @@ -1956,7 +1979,885 @@ function buildStylingRulesDemo(neo4jConnId, pgConnId) { }; } +// ─── Chart Improvements — dedicated dashboard for new features ────── +function buildChartImprovements(neo4jConnId) { + return { + version: 2, + pages: [ + // ── Page 1: Number Formatting ── + { + id: uuid(), + title: "Number Formatting", + widgets: [ + { + id: uuid(), chartType: "single-value", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN count(m) * 12345 AS value", + settings: { title: "Plain (default)", chartOptions: { fontSize: "lg" } }, + }, + { + id: uuid(), chartType: "single-value", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN count(m) * 12345 AS value", + settings: { title: "Comma + prefix/suffix", chartOptions: { numberFormat: "comma", prefix: "$", suffix: " USD", decimalPlaces: 2, fontSize: "lg" } }, + }, + { + id: uuid(), chartType: "single-value", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN count(m) * 12345 AS value", + settings: { title: "Compact notation", chartOptions: { numberFormat: "compact", decimalPlaces: 1, fontSize: "lg" } }, + }, + { + id: uuid(), chartType: "single-value", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN 87.654 AS value", + settings: { title: "Percent format", chartOptions: { numberFormat: "percent", decimalPlaces: 1, fontSize: "lg" } }, + }, + { + id: uuid(), chartType: "bar", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN (m.released / 10) * 10 AS decade, count(*) AS count ORDER BY decade", + settings: { title: "Bar — tooltip decimal places = 2", chartOptions: { decimalPlaces: 2, xAxisLabel: "Decade", yAxisLabel: "Count" } }, + }, + { + id: uuid(), chartType: "line", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN m.released AS year, count(*) AS count ORDER BY year", + settings: { title: "Line — tooltip decimal places = 1", chartOptions: { decimalPlaces: 1, showPoints: true } }, + }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 3, h: 2 }, + { i: null, x: 3, y: 0, w: 3, h: 2 }, + { i: null, x: 6, y: 0, w: 3, h: 2 }, + { i: null, x: 9, y: 0, w: 3, h: 2 }, + { i: null, x: 0, y: 2, w: 6, h: 4 }, + { i: null, x: 6, y: 2, w: 6, h: 4 }, + ], + }, + + // ── Page 2: DataZoom + Reference Lines ── + { + id: uuid(), + title: "DataZoom + Reference Lines", + widgets: [ + { + id: uuid(), chartType: "bar", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH p.name AS name, count(m) AS movies ORDER BY movies DESC RETURN name, movies LIMIT 20", + settings: { title: "Bar — scroll to zoom + 2 reference lines", chartOptions: { + enableDataZoom: true, xAxisLabel: "Actor", yAxisLabel: "Movies", + referenceLines: JSON.stringify([{ value: 3, label: "Average", color: "#f59e0b" }, { value: 5, label: "Prolific", color: "#22c55e" }]), + } }, + }, + { + id: uuid(), chartType: "line", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN m.released AS year, count(*) AS count ORDER BY year", + settings: { title: "Line — scroll to zoom + target line", chartOptions: { + enableDataZoom: true, showPoints: true, xAxisLabel: "Year", yAxisLabel: "Releases", + referenceLines: JSON.stringify([{ value: 5, label: "Target", color: "#ef4444" }]), + } }, + }, + { + id: uuid(), chartType: "bar", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN (m.released / 10) * 10 AS decade, count(*) AS count ORDER BY decade", + settings: { title: "Bar — no DataZoom (control)", chartOptions: { xAxisLabel: "Decade", yAxisLabel: "Count" } }, + }, + { + id: uuid(), chartType: "line", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN m.released AS year, count(*) AS count ORDER BY year", + settings: { title: "Line — reference line only (no zoom)", chartOptions: { + smooth: true, area: true, + referenceLines: JSON.stringify([{ value: 3, label: "Threshold", color: "#8b5cf6" }]), + } }, + }, + ], + 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 3: Axis Labels + Rotation ── + { + id: uuid(), + title: "Axis Labels", + widgets: [ + { + id: uuid(), chartType: "bar", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN m.title AS label, m.released AS value ORDER BY m.released LIMIT 15", + settings: { title: "Auto-rotate (15 items)" }, + }, + { + id: uuid(), chartType: "bar", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH p.name AS name, count(m) AS movies ORDER BY movies DESC RETURN name AS label, movies AS value LIMIT 20", + settings: { title: "Forced 45\u00b0 rotation", chartOptions: { axisLabelRotation: 45, xAxisLabel: "Actor Name", yAxisLabel: "Movie Count" } }, + }, + { + id: uuid(), chartType: "bar", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH p.name AS name, count(m) AS movies ORDER BY movies DESC RETURN name AS label, movies AS value LIMIT 10", + settings: { title: "Forced 90\u00b0 rotation", chartOptions: { axisLabelRotation: 90 } }, + }, + { + id: uuid(), chartType: "bar", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN (m.released / 10) * 10 AS decade, count(*) AS count ORDER BY decade", + settings: { title: "No rotation needed (few items)" }, + }, + ], + 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 4: Pie Donut + Top-N ── + { + id: uuid(), + title: "Pie Donut + Top-N", + widgets: [ + { + id: uuid(), chartType: "pie", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH p.name AS name, count(m) AS value ORDER BY value DESC RETURN name, value LIMIT 15", + settings: { title: "Standard pie (all 15 slices)" }, + }, + { + id: uuid(), chartType: "pie", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH p.name AS name, count(m) AS value ORDER BY value DESC RETURN name, value LIMIT 15", + settings: { title: "Donut mode", chartOptions: { donut: true } }, + }, + { + id: uuid(), chartType: "pie", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH p.name AS name, count(m) AS value ORDER BY value DESC RETURN name, value LIMIT 15", + settings: { title: "Donut + Top 5 + center text", chartOptions: { donut: true, topN: 5, donutCenterText: "Top Actors", showPercentage: true } }, + }, + { + id: uuid(), chartType: "pie", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH p.name AS name, count(m) AS value ORDER BY value DESC RETURN name, value LIMIT 15", + settings: { title: "Top 3 only (rest grouped as Other)", chartOptions: { topN: 3, showPercentage: true, sortSlices: true } }, + }, + ], + 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 5: Click Action Row Enrichment ── + { + id: uuid(), + title: "Click Action Enrichment", + widgets: [ + { + id: uuid(), chartType: "table", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN m.title AS title, m.released AS released, m.tagline AS tagline ORDER BY m.released DESC LIMIT 10", + settings: { + title: "Click a row \u2192 tagline fills below", + clickAction: { + type: "set-parameter", + rules: [{ + id: uuid(), type: "set-parameter", triggerColumn: "title", + parameterMapping: { parameterName: "clicked_tagline", sourceField: "tagline" }, + }], + }, + }, + }, + { + id: uuid(), chartType: "parameter-select", connectionId: "", query: "", + settings: { title: "Clicked Tagline", chartOptions: { parameterType: "text", parameterName: "clicked_tagline" } }, + }, + { + id: uuid(), chartType: "table", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN m.title AS title, m.released AS released, m.tagline AS tagline ORDER BY m.released DESC LIMIT 10", + settings: { title: "Reference table (verify tagline matches)" }, + }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 8, h: 5 }, + { i: null, x: 8, y: 0, w: 4, h: 2 }, + { i: null, x: 0, y: 5, w: 12, h: 4 }, + ], + }, + + // ── Page 6: Radar Global Scale + Graph Anti-Clump + Markdown Table ── + { + id: uuid(), + title: "Radar, Graph, Markdown", + widgets: [ + { + id: uuid(), chartType: "radar", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[r]->(m:Movie) WITH type(r) AS indicator, count(*) AS value RETURN indicator, value", + settings: { title: "Radar \u2014 global scale (magnitudes visible)", chartOptions: { filled: true, shape: "polygon" } }, + }, + { + id: uuid(), chartType: "graph", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[r]->(m:Movie) RETURN p, r, m LIMIT 25", + settings: { title: "Graph \u2014 anti-clumping layout", chartOptions: { showLabels: true } }, + }, + { + id: uuid(), chartType: "markdown", connectionId: "", query: "", + settings: { title: "Markdown table rendering", chartOptions: { + content: [ + "## Chart Improvements Checklist", + "", + "| # | Feature | Chart Type | What to Check |", + "|---|---|---|---|", + "| 1 | Number Format | Single Value | comma, compact, percent, decimal places |", + "| 2 | Tooltip Decimals | Bar, Line | Hover tooltip shows fixed decimals |", + "| 3 | DataZoom | Bar, Line | Scroll-zoom on axes |", + "| 4 | Reference Lines | Bar, Line | Dashed horizontal lines with labels |", + "| 5 | Axis Rotation | Bar | Auto-rotate at 8+ items, manual override |", + "| 6 | Donut Mode | Pie | Hole in center with text |", + "| 7 | Top-N Grouping | Pie | Extra slices grouped as Other |", + "| 8 | Click Enrichment | All | Non-axis columns available in click data |", + "| 9 | Radar Scale | Radar | Single global max, not per-indicator |", + "| 10 | Graph Layout | Graph | Nodes spread out, no clumping |", + "| 11 | Markdown Table | Markdown | This table renders correctly |", + "| 12 | A11y (ARIA) | All ECharts | role=img, aria-label, tabIndex=0 |", + ].join("\n"), + } }, + }, + ], + 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: 12, h: 5 }, + ], + }, + ], + }; +} + /** 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" } } }, + // Grouped (multi-series, side-by-side) + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barMulti, + settings: { title: "Grouped (multi-series)", chartOptions: { showLegend: true } } }, + // Stacked bar + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barMulti, + 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: vertical, horizontal, grouped, stacked (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: show values, styling, click, accessibility (3×4 each) + { i: null, x: 0, y: 4, w: 3, h: 4 }, + { i: null, x: 3, y: 4, w: 3, h: 4 }, + { i: null, x: 6, y: 4, w: 3, h: 4 }, + { i: null, x: 9, y: 4, w: 3, 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++) {