From d6305d83979088b5bbee0d94a57c9ba084f5eb24 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Mon, 23 Mar 2026 10:52:25 +0100 Subject: [PATCH 01/10] fix(component,app): bracket wrapping in code editor + radar chart scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 — CodeMirror bracket wrapping: - Add closeBrackets() extension and closeBracketsKeymap to query editor - Selecting text and typing ( now wraps as (text) instead of replacing - Both Cypher and SQL bracket facets are now activated Bug 2 — Radar chart uniform shape: - Use single global max across all indicators instead of per-indicator max - Values like 172 vs 9 now show actual relative differences - Explicit max column values preserved when provided - 4 new/updated tests for global max behavior Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/lib/__tests__/chart-registry.test.ts | 43 +++++++++++++++++-- app/src/lib/chart-registry.ts | 15 ++++--- .../composed/__tests__/query-editor.test.tsx | 2 + .../src/components/composed/query-editor.tsx | 5 ++- 4 files changed, 54 insertions(+), 11 deletions(-) diff --git a/app/src/lib/__tests__/chart-registry.test.ts b/app/src/lib/__tests__/chart-registry.test.ts index 84da75d1..f5a87134 100644 --- a/app/src/lib/__tests__/chart-registry.test.ts +++ b/app/src/lib/__tests__/chart-registry.test.ts @@ -1491,16 +1491,53 @@ 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); diff --git a/app/src/lib/chart-registry.ts b/app/src/lib/chart-registry.ts index 56507c78..793396cf 100644 --- a/app/src/lib/chart-registry.ts +++ b/app/src/lib/chart-registry.ts @@ -502,13 +502,15 @@ function transformToRadarData(data: unknown): unknown { 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, + : 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/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/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(), From 5c166f67e21a35cf1b0d7c0562e4827ca4755de9 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Mon, 23 Mar 2026 11:01:30 +0100 Subject: [PATCH 02/10] chore: add Chart Catalog seed dashboard with full feature showcase New "Chart Catalog" dashboard with 12 pages, one per chart type: - Bar: vertical, horizontal, stacked, show values, styling, click, colorblind + 6 palettes - Line: default, smooth+area, stepped, show points, colorblind + 6 palettes - Pie: default, donut, rose, labels inside, click, colorblind + 6 palettes - Single Value: prefix/suffix, comma, compact, styling, trend - Table: default, sorting+filters, selection, click - Gauge: default, no pointer, half gauge, styling + 6 palettes - Radar: default, circle, filled+values, colorblind + 6 palettes - Sankey: horizontal, vertical + 6 palettes - Treemap: default, with values + 6 palettes - Sunburst: default, no labels + 6 palettes - Content: markdown (with tables), JSON viewer, iframe - Detail: click action target page Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/seed-demo.mjs | 398 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 398 insertions(+) diff --git a/scripts/seed-demo.mjs b/scripts/seed-demo.mjs index f9148a33..a7beab3d 100644 --- a/scripts/seed-demo.mjs +++ b/scripts/seed-demo.mjs @@ -1740,6 +1740,17 @@ async function main() { true ); + const catalogLayout = buildChartCatalog(neo4jConnId); + patchGridIds(catalogLayout); + await upsertDashboard( + sql, + adminId, + "Chart Catalog", + "One page per chart type. Each page shows every palette, feature variant, rule-based styling, click actions, and accessibility modes.", + catalogLayout, + true + ); + console.log(" Demo dashboards seeded."); } finally { await sql.end(); @@ -1957,6 +1968,393 @@ function buildStylingRulesDemo(neo4jConnId, pgConnId) { } /** Set gridLayout[n].i = widgets[n].id for each page. */ +// ─── Chart Catalog — comprehensive per-chart-type showcase ────────── +function buildChartCatalog(neo4jId) { + const palettes = ["deep-ocean", "warm-sunset", "cool-breeze", "earth-tones", "neon", "monochrome"]; + const detailPageId = 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", + 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", + 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", + }; + + // 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, + navigateToPageId: pageId, + }], + }); + + // Helper to make a palette row of widgets for a given chart type + function paletteRow(chartType, query, baseSettings = {}) { + return palettes.map((p) => ({ + id: uuid(), + chartType, + connectionId: neo4jId, + query, + settings: { ...baseSettings, title: p, chartOptions: { ...baseSettings.chartOptions, colorPalette: p } }, + })); + } + + function paletteGrid(yStart = 0) { + // 3×2 grid for 6 palettes, each 4×4 + return palettes.map((_, i) => ({ + i: null, + x: (i % 3) * 4, + y: yStart + Math.floor(i / 3) * 4, + w: 4, + h: 4, + })); + } + + return { + version: 2, + pages: [ + // ── Page 1: Bar Chart ────────────────────────────────────────── + { + id: uuid(), + title: "Bar Chart", + widgets: [ + // Vertical bar (default) + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Vertical (default)" } }, + // Horizontal bar + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Horizontal", chartOptions: { orientation: "horizontal" } } }, + // Stacked bar + { id: uuid(), chartType: "bar", connectionId: neo4jId, + query: "MATCH (p:Person)-[r]->(m:Movie) WITH (m.released / 10) * 10 AS decade, type(r) AS rel, count(*) AS cnt RETURN decade AS label, rel, cnt ORDER BY decade", + settings: { title: "Stacked", chartOptions: { stacked: true } } }, + // Bar with values shown + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Show Values", chartOptions: { showValues: true } } }, + // Bar with styling rules + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Rule-Based Styling", stylingConfig: barStyling } }, + // Bar with click action + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Click → Set Parameter", clickAction: clickSetParam("label", "bar_decade") } }, + // Bar with colorblind mode + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Colorblind Mode", chartOptions: { colorblindMode: true } } }, + // 6 palette variants + ...paletteRow("bar", Q.barData), + ], + gridLayout: [ + // Row 1: feature variants (4 widgets, 3×4 each) + { i: null, x: 0, y: 0, w: 3, h: 4 }, + { i: null, x: 3, y: 0, w: 3, h: 4 }, + { i: null, x: 6, y: 0, w: 3, h: 4 }, + { i: null, x: 9, y: 0, w: 3, h: 4 }, + // Row 2: styling, click, accessibility + { i: null, x: 0, y: 4, w: 4, h: 4 }, + { i: null, x: 4, y: 4, w: 4, h: 4 }, + { i: null, x: 8, y: 4, w: 4, h: 4 }, + // Rows 3-4: palette grid + ...paletteGrid(8), + ], + }, + + // ── Page 2: Line Chart ───────────────────────────────────────── + { + id: uuid(), + title: "Line Chart", + widgets: [ + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Default" } }, + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Smooth + Area", chartOptions: { smooth: true, area: true } } }, + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Stepped", chartOptions: { stepped: true } } }, + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Show Points", chartOptions: { showPoints: true, lineWidth: 3 } } }, + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Colorblind Mode", chartOptions: { colorblindMode: true, area: true } } }, + ...paletteRow("line", Q.lineData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 3, h: 4 }, + { i: null, x: 3, y: 0, w: 3, h: 4 }, + { i: null, x: 6, y: 0, w: 3, h: 4 }, + { i: null, x: 9, y: 0, w: 3, h: 4 }, + { i: null, x: 0, y: 4, w: 4, h: 4 }, + ...paletteGrid(8), + ], + }, + + // ── Page 3: Pie Chart ────────────────────────────────────────── + { + id: uuid(), + title: "Pie Chart", + widgets: [ + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Default Pie" } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Donut", chartOptions: { donut: true } } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Rose / Nightingale", chartOptions: { roseMode: true } } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Labels Inside", chartOptions: { labelPosition: "inside" } } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Click → Set Param", clickAction: clickSetParam("name", "pie_type") } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Colorblind Mode", chartOptions: { colorblindMode: true } } }, + ...paletteRow("pie", Q.pieData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 4, h: 4 }, + { i: null, x: 4, y: 0, w: 4, h: 4 }, + { i: null, x: 8, y: 0, w: 4, h: 4 }, + { i: null, x: 0, y: 4, w: 4, h: 4 }, + { i: null, x: 4, y: 4, w: 4, h: 4 }, + { i: null, x: 8, y: 4, w: 4, h: 4 }, + ...paletteGrid(8), + ], + }, + + // ── Page 4: Single Value ─────────────────────────────────────── + { + id: uuid(), + title: "Single Value", + widgets: [ + { id: uuid(), chartType: "single-value", connectionId: neo4jId, query: Q.singleVal, + settings: { title: "Default", chartOptions: { fontSize: "lg" } } }, + { id: uuid(), chartType: "single-value", connectionId: neo4jId, query: Q.singleVal, + settings: { title: "With Prefix/Suffix", chartOptions: { prefix: "$", suffix: "M", fontSize: "xl" } } }, + { id: uuid(), chartType: "single-value", connectionId: neo4jId, query: Q.singleVal, + settings: { title: "Comma Format", chartOptions: { numberFormat: "comma", fontSize: "lg" } } }, + { id: uuid(), chartType: "single-value", connectionId: neo4jId, query: Q.singleVal, + settings: { title: "Compact Format", chartOptions: { numberFormat: "compact", fontSize: "lg" } } }, + { id: uuid(), chartType: "single-value", connectionId: neo4jId, query: Q.singleVal, + settings: { title: "Rule-Based Styling", stylingConfig: singleValueStyling, chartOptions: { fontSize: "xl" } } }, + { id: uuid(), chartType: "single-value", connectionId: neo4jId, + query: "MATCH (m:Movie) RETURN count(m) AS value, count(m) - 5 AS previous", + settings: { title: "With Trend", chartOptions: { fontSize: "lg", trendEnabled: true } } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 4, h: 3 }, + { i: null, x: 4, y: 0, w: 4, h: 3 }, + { i: null, x: 8, y: 0, w: 4, h: 3 }, + { i: null, x: 0, y: 3, w: 4, h: 3 }, + { i: null, x: 4, y: 3, w: 4, h: 3 }, + { i: null, x: 8, y: 3, w: 4, h: 3 }, + ], + }, + + // ── Page 5: Table ────────────────────────────────────────────── + { + id: uuid(), + title: "Table", + widgets: [ + { id: uuid(), chartType: "table", connectionId: neo4jId, query: Q.tableData, + settings: { title: "Default Table" } }, + { id: uuid(), chartType: "table", connectionId: neo4jId, query: Q.tableData, + settings: { title: "With Sorting + Filters", chartOptions: { enableSorting: true, enableColumnFilters: true, enableGlobalFilter: true } } }, + { id: uuid(), chartType: "table", connectionId: neo4jId, query: Q.tableData, + settings: { title: "Row Selection", chartOptions: { enableSelection: true } } }, + { id: uuid(), chartType: "table", connectionId: neo4jId, query: Q.tableData, + settings: { title: "Click → Set Parameter", clickAction: clickSetParam("name", "table_actor") } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + { i: null, x: 0, y: 5, w: 6, h: 5 }, + { i: null, x: 6, y: 5, w: 6, h: 5 }, + ], + }, + + // ── Page 6: Gauge Chart ──────────────────────────────────────── + { + id: uuid(), + title: "Gauge Chart", + widgets: [ + { id: uuid(), chartType: "gauge", connectionId: neo4jId, query: Q.gaugeData, + settings: { title: "Default Gauge" } }, + { id: uuid(), chartType: "gauge", connectionId: neo4jId, query: Q.gaugeData, + settings: { title: "No Pointer", chartOptions: { showPointer: false } } }, + { id: uuid(), chartType: "gauge", connectionId: neo4jId, query: Q.gaugeData, + settings: { title: "Half Gauge", chartOptions: { startAngle: 180, endAngle: 0 } } }, + { id: uuid(), chartType: "gauge", connectionId: neo4jId, query: Q.gaugeData, + settings: { title: "Rule-Based Styling", stylingConfig: singleValueStyling } }, + ...paletteRow("gauge", Q.gaugeData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 3, h: 4 }, + { i: null, x: 3, y: 0, w: 3, h: 4 }, + { i: null, x: 6, y: 0, w: 3, h: 4 }, + { i: null, x: 9, y: 0, w: 3, h: 4 }, + ...paletteGrid(4), + ], + }, + + // ── Page 7: Radar Chart ──────────────────────────────────────── + { + id: uuid(), + title: "Radar Chart", + widgets: [ + { id: uuid(), chartType: "radar", connectionId: neo4jId, query: Q.radarData, + settings: { title: "Default Radar" } }, + { id: uuid(), chartType: "radar", connectionId: neo4jId, query: Q.radarData, + settings: { title: "Circle Shape", chartOptions: { shape: "circle" } } }, + { id: uuid(), chartType: "radar", connectionId: neo4jId, query: Q.radarData, + settings: { title: "Filled + Values", chartOptions: { filled: true, showValues: true } } }, + { id: uuid(), chartType: "radar", connectionId: neo4jId, query: Q.radarData, + settings: { title: "Colorblind Mode", chartOptions: { colorblindMode: true } } }, + ...paletteRow("radar", Q.radarData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 3, h: 4 }, + { i: null, x: 3, y: 0, w: 3, h: 4 }, + { i: null, x: 6, y: 0, w: 3, h: 4 }, + { i: null, x: 9, y: 0, w: 3, h: 4 }, + ...paletteGrid(4), + ], + }, + + // ── Page 8: Sankey Chart ─────────────────────────────────────── + { + id: uuid(), + title: "Sankey Chart", + widgets: [ + { id: uuid(), chartType: "sankey", connectionId: neo4jId, query: Q.sankeyData, + settings: { title: "Horizontal (default)" } }, + { id: uuid(), chartType: "sankey", connectionId: neo4jId, query: Q.sankeyData, + settings: { title: "Vertical", chartOptions: { orient: "vertical" } } }, + ...paletteRow("sankey", Q.sankeyData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + ...paletteGrid(5), + ], + }, + + // ── Page 9: Treemap Chart ────────────────────────────────────── + { + id: uuid(), + title: "Treemap Chart", + widgets: [ + { id: uuid(), chartType: "treemap", connectionId: neo4jId, query: Q.treemapData, + settings: { title: "Default Treemap" } }, + { id: uuid(), chartType: "treemap", connectionId: neo4jId, query: Q.treemapData, + settings: { title: "With Values", chartOptions: { showValues: true } } }, + ...paletteRow("treemap", Q.treemapData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + ...paletteGrid(5), + ], + }, + + // ── Page 10: Sunburst Chart ──────────────────────────────────── + { + id: uuid(), + title: "Sunburst Chart", + widgets: [ + { id: uuid(), chartType: "sunburst", connectionId: neo4jId, query: Q.sunburstData, + settings: { title: "Default Sunburst" } }, + { id: uuid(), chartType: "sunburst", connectionId: neo4jId, query: Q.sunburstData, + settings: { title: "No Labels", chartOptions: { showLabels: false } } }, + ...paletteRow("sunburst", Q.sunburstData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + ...paletteGrid(5), + ], + }, + + // ── Page 11: Content Widgets ─────────────────────────────────── + { + id: uuid(), + title: "Content Widgets", + widgets: [ + { id: uuid(), chartType: "markdown", connectionId: "", query: "", + settings: { + title: "Markdown Widget", + chartOptions: { + content: "# NeoBoard Chart Catalog\\n\\nThis dashboard showcases **every chart type** with all feature variants.\\n\\n## Features\\n- Rule-based styling\\n- Click actions\\n- Color palettes\\n- Accessibility modes\\n\\n| Chart | Variants |\\n| --- | --- |\\n| Bar | Vertical, Horizontal, Stacked |\\n| Line | Smooth, Area, Stepped |\\n| Pie | Donut, Rose, Labels Inside |", + }, + }, + }, + { id: uuid(), chartType: "json", connectionId: neo4jId, + query: "MATCH (m:Movie) RETURN m ORDER BY m.released DESC LIMIT 3", + settings: { title: "JSON Viewer", chartOptions: { initialExpanded: 2 } } }, + { id: uuid(), chartType: "iframe", connectionId: "", query: "", + settings: { + title: "Embedded Content", + chartOptions: { url: "https://echarts.apache.org/examples/en/index.html", iframeTitle: "ECharts Examples" }, + }, + }, + ], + 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: 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++) { From 6dd50c9d07cd1821324bba7d33a77946ef6851ef Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Mon, 23 Mar 2026 11:59:02 +0100 Subject: [PATCH 03/10] fix(component): resolve nested button hydration error in CrossFilterTag When onClick is set, CrossFilterTag renders as a + ) + ); + const content = ( <> {field} = {value} - {onRemove && ( - - )} + {removeControl} ); From f31270f3856b3ba53d420bdcced6e9fddbed61e3 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Mon, 23 Mar 2026 12:03:58 +0100 Subject: [PATCH 04/10] chore: expand Chart Catalog to cover ALL chart options comprehensively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 6 new pages (18 total) to the Chart Catalog seed dashboard: - Page 12: Graph — force/circular/hierarchical, node sizes, labels, physics - Page 13: Parameter Widgets — select (searchable/not), text, date, date-range, relative-date - Page 14: Form Widget — default form, custom button + no-reset - Page 15: Behavior — showRefreshButton, manualRun, cacheMode (forever/TTL) - Page 16: Axis & Grid — axis labels, grid lines off, bar width/gap, legend off, sorted slices, no percentages - Page 17: Advanced — table pagination/pageSize, gauge min/max/progress/detail, radar legend, sankey nodeWidth/gap, sunburst sort/highlight, treemap breadcrumb/saturation, JSON fontSize/theme/copyButton Every chart option from chart-options-schema.ts now has at least one widget demonstrating it. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/seed-demo.mjs | 218 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 214 insertions(+), 4 deletions(-) diff --git a/scripts/seed-demo.mjs b/scripts/seed-demo.mjs index a7beab3d..bee76b67 100644 --- a/scripts/seed-demo.mjs +++ b/scripts/seed-demo.mjs @@ -1970,21 +1970,27 @@ function buildStylingRulesDemo(neo4jConnId, pgConnId) { /** Set gridLayout[n].i = widgets[n].id for each page. */ // ─── Chart Catalog — comprehensive per-chart-type showcase ────────── function buildChartCatalog(neo4jId) { - const palettes = ["deep-ocean", "warm-sunset", "cool-breeze", "earth-tones", "neon", "monochrome"]; + 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 25", + graphSmall: "MATCH (p:Person)-[: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", }; // Styling rules reusable across pages @@ -2026,7 +2032,7 @@ function buildChartCatalog(neo4jId) { // Helper to make a palette row of widgets for a given chart type function paletteRow(chartType, query, baseSettings = {}) { - return palettes.map((p) => ({ + return P.map((p) => ({ id: uuid(), chartType, connectionId: neo4jId, @@ -2037,7 +2043,7 @@ function buildChartCatalog(neo4jId) { function paletteGrid(yStart = 0) { // 3×2 grid for 6 palettes, each 4×4 - return palettes.map((_, i) => ({ + return P.map((_, i) => ({ i: null, x: (i % 3) * 4, y: yStart + Math.floor(i / 3) * 4, @@ -2334,7 +2340,211 @@ function buildChartCatalog(neo4jId) { ], }, - // ── Page 12: Detail (click target) ───────────────────────────── + // ── Page 12: 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.graphData, + settings: { title: "Circular Layout", chartOptions: { layout: "circular", showLabels: true } } }, + { id: uuid(), chartType: "graph", connectionId: neo4jId, query: Q.graphData, + settings: { title: "Hierarchical", chartOptions: { layout: "hierarchical", showLabels: true } } }, + { id: uuid(), chartType: "graph", connectionId: neo4jId, query: Q.graphData, + settings: { title: "Small Nodes", chartOptions: { nodeSize: "small", showLabels: true } } }, + { id: uuid(), chartType: "graph", connectionId: neo4jId, query: Q.graphData, + settings: { title: "Large Nodes", chartOptions: { nodeSize: "large", showLabels: true } } }, + { id: uuid(), chartType: "graph", connectionId: neo4jId, query: Q.graphData, + settings: { title: "No Labels / No Physics", chartOptions: { showLabels: false, showRelationshipLabels: false, physics: false } } }, + ], + 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: 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", From 943834af773beec84490c68a03e97c2a88e0f5bd Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Mon, 23 Mar 2026 12:11:33 +0100 Subject: [PATCH 05/10] chore: add Map Chart page with geo data (filming locations + birthplaces) Add FILMED_IN and BORN_IN relationships to Neo4j seed connecting Movies and Persons to City nodes (which already had lat/lng coordinates). New Map Chart page in Chart Catalog (6 widgets): - Cities by population (OSM) - Filming locations (Carto Light) - Birthplaces (Carto Dark) - Clustered markers - Custom zoom / no popup - Large markers with click action All map options covered: tileLayer (osm/carto-light/carto-dark), autoFitBounds, markerSize, showPopup, clusterMarkers, zoom, minZoom, maxZoom. Co-Authored-By: Claude Opus 4.6 (1M context) --- docker/neo4j/init.cypher | 28 ++++++++++++++++++++++++++++ scripts/seed-demo.mjs | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) 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 bee76b67..2212f56c 100644 --- a/scripts/seed-demo.mjs +++ b/scripts/seed-demo.mjs @@ -1991,6 +1991,9 @@ function buildChartCatalog(neo4jId) { graphData: "MATCH (p:Person)-[r]->(m:Movie) RETURN p, r, m LIMIT 25", graphSmall: "MATCH (p:Person)-[: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 @@ -2340,7 +2343,41 @@ function buildChartCatalog(neo4jId) { ], }, - // ── Page 12: Graph Chart ───────────────────────────────────── + // ── 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", From b7513f0de5fb7c66f68ca59887eae484fbdcbc5f Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Mon, 23 Mar 2026 12:36:40 +0100 Subject: [PATCH 06/10] fix: markdown newlines and iframe URL in Chart Catalog seed - Markdown: use actual \n newlines instead of escaped \\n literals - iFrame: replace echarts.apache.org (blocks framing via X-Frame-Options) with Wikipedia which allows embedding Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/seed-demo.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/seed-demo.mjs b/scripts/seed-demo.mjs index 2212f56c..4063777b 100644 --- a/scripts/seed-demo.mjs +++ b/scripts/seed-demo.mjs @@ -2322,7 +2322,7 @@ function buildChartCatalog(neo4jId) { 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 |", + 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 |", }, }, }, @@ -2332,7 +2332,7 @@ function buildChartCatalog(neo4jId) { { id: uuid(), chartType: "iframe", connectionId: "", query: "", settings: { title: "Embedded Content", - chartOptions: { url: "https://echarts.apache.org/examples/en/index.html", iframeTitle: "ECharts Examples" }, + chartOptions: { url: "https://en.wikipedia.org/wiki/Data_visualization", iframeTitle: "Data Visualization — Wikipedia" }, }, }, ], From 89dbb1dfaa0dc0da2a2e1ab58e2c48fbb623fb88 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Mon, 23 Mar 2026 12:44:18 +0100 Subject: [PATCH 07/10] fix: markdown table rendering, graph performance, and query typo - Add GFM table parsing to markdown-widget.tsx on this branch (was only on feat/issue-143-markdown-tables branch) - Reduce graph widgets from 6 to 4, use smaller queries (LIMIT 10) to prevent NVL physics engine overload with simultaneous renders - Fix graphSmall query: bind relationship variable properly - Type annotations for parseCells in markdown parser Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/composed/markdown-widget.tsx | 33 +++++++++++++++++++ scripts/seed-demo.mjs | 26 ++++++--------- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/component/src/components/composed/markdown-widget.tsx b/component/src/components/composed/markdown-widget.tsx index 1c76b487..9c7958fa 100644 --- a/component/src/components/composed/markdown-widget.tsx +++ b/component/src/components/composed/markdown-widget.tsx @@ -112,6 +112,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 && + /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/.test(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/scripts/seed-demo.mjs b/scripts/seed-demo.mjs index 4063777b..1d59538a 100644 --- a/scripts/seed-demo.mjs +++ b/scripts/seed-demo.mjs @@ -1988,8 +1988,8 @@ function buildChartCatalog(neo4jId) { 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 25", - graphSmall: "MATCH (p:Person)-[:DIRECTED]->(m:Movie) RETURN p, r, m LIMIT 10", + 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", @@ -2384,24 +2384,18 @@ function buildChartCatalog(neo4jId) { 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.graphData, + { 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.graphData, + { 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.graphData, - settings: { title: "Small Nodes", chartOptions: { nodeSize: "small", showLabels: true } } }, - { id: uuid(), chartType: "graph", connectionId: neo4jId, query: Q.graphData, - settings: { title: "Large Nodes", chartOptions: { nodeSize: "large", showLabels: true } } }, - { id: uuid(), chartType: "graph", connectionId: neo4jId, query: Q.graphData, - settings: { title: "No Labels / No Physics", chartOptions: { showLabels: false, showRelationshipLabels: false, physics: false } } }, + { 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: 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 }, + { 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 }, ], }, From 57b05f07c2a5a9a87b59a0466cc0caead5366c1b Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Mon, 23 Mar 2026 22:07:14 +0100 Subject: [PATCH 08/10] fix(component): prevent graph chart nodes clumping on initial render Gate NVL canvas visibility with a layoutReady state that flips true only when onLayoutDone fires. Reset is synchronous during render (not useEffect) to avoid a race where the effect runs after onLayoutDone on the main thread. Replace the arbitrary 100ms autoFit timer with a deterministic layoutReady guard. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/charts/__tests__/graph-chart.test.tsx | 80 +++++++++++++++---- component/src/charts/graph-chart.tsx | 50 +++++++----- 2 files changed, 93 insertions(+), 37 deletions(-) diff --git a/component/src/charts/__tests__/graph-chart.test.tsx b/component/src/charts/__tests__/graph-chart.test.tsx index 059d4f06..435e97bb 100644 --- a/component/src/charts/__tests__/graph-chart.test.tsx +++ b/component/src/charts/__tests__/graph-chart.test.tsx @@ -8,7 +8,7 @@ * - Click callback wiring * - Layout mapping */ -import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, waitFor, act } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { GraphChart } from "../graph-chart"; import type { Node as NvlNode, Relationship as NvlRelationship } from "@neo4j-nvl/base"; @@ -530,29 +530,75 @@ describe("GraphChart", () => { expect(nvlNodes[0].caption).not.toBe("[object Object]"); }); - // --- autoFit --- + // --- Loading overlay / layoutReady --- - describe("autoFit", () => { - afterEach(() => { - vi.restoreAllMocks(); + describe("loading overlay", () => { + it("shows loading overlay on initial render when nodes are present", () => { + render(); + expect(screen.getByTestId("graph-loading-overlay")).toBeInTheDocument(); }); - it("schedules a delayed fit via requestAnimationFrame when autoFit is true", () => { - const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 0); - render(); - expect(rafSpy).toHaveBeenCalledTimes(1); + it("does not show loading overlay when there are no nodes", () => { + render(); + expect(screen.queryByTestId("graph-loading-overlay")).not.toBeInTheDocument(); + }); + + it("removes loading overlay after onLayoutDone fires", () => { + render(); + expect(screen.getByTestId("graph-loading-overlay")).toBeInTheDocument(); + + // Simulate NVL calling onLayoutDone + const callbacks = capturedProps.nvlCallbacks as { onLayoutDone?: () => void }; + act(() => { callbacks.onLayoutDone?.(); }); + + expect(screen.queryByTestId("graph-loading-overlay")).not.toBeInTheDocument(); }); - it("does not call requestAnimationFrame for autoFit when prop is false", () => { - const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 0); - render(); - expect(rafSpy).not.toHaveBeenCalled(); + it("resets loading overlay when nodes change", () => { + const { rerender } = render(); + + // Fire onLayoutDone to clear overlay + const callbacks = capturedProps.nvlCallbacks as { onLayoutDone?: () => void }; + act(() => { callbacks.onLayoutDone?.(); }); + expect(screen.queryByTestId("graph-loading-overlay")).not.toBeInTheDocument(); + + // Change nodes — overlay should reappear + const newNodes = [ + { id: "4", label: "Diana", value: 10 }, + { id: "5", label: "Eve", value: 15 }, + ]; + rerender(); + expect(screen.getByTestId("graph-loading-overlay")).toBeInTheDocument(); }); + }); - it("does not call requestAnimationFrame for autoFit when prop is absent", () => { - const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 0); - render(); - expect(rafSpy).not.toHaveBeenCalled(); + // --- nvlOptions --- + + it("disables web workers in nvlOptions (Next.js bundler compatibility)", () => { + render(); + const opts = capturedProps.nvlOptions as Record; + expect(opts.disableWebWorkers).toBe(true); + }); + + // --- autoFit --- + + describe("autoFit", () => { + it("does not call fitGraph before onLayoutDone fires", () => { + // We can't directly spy on fitGraph, but we can verify through the nvlRef. + // The NVL wrapper is mocked, so we check that autoFit alone doesn't + // cause immediate side effects — the overlay should still be visible. + render(); + // Overlay is still present — layout hasn't completed + expect(screen.getByTestId("graph-loading-overlay")).toBeInTheDocument(); + }); + + it("calls fitGraph (via onLayoutDone) when autoFit and layout completes", () => { + render(); + // Fire onLayoutDone + const callbacks = capturedProps.nvlCallbacks as { onLayoutDone?: () => void }; + act(() => { callbacks.onLayoutDone?.(); }); + // Overlay should be gone — fitGraph was called + expect(screen.queryByTestId("graph-loading-overlay")).not.toBeInTheDocument(); }); }); }); diff --git a/component/src/charts/graph-chart.tsx b/component/src/charts/graph-chart.tsx index 0658e8eb..7bcec670 100644 --- a/component/src/charts/graph-chart.tsx +++ b/component/src/charts/graph-chart.tsx @@ -296,12 +296,21 @@ export function GraphChart({ className, }: GraphChartProps) { const nvlRef = useRef(null); - const cleanupRef = useRef<(() => void) | null>(null); + const [layoutReady, setLayoutReady] = useState(false); const [layout, setLayout] = useState( initialLayout ?? layoutProp, ); const dark = useDarkMode(); + // Reset layoutReady synchronously during render when nodes change. + // Using useEffect would race with onLayoutDone (which fires before + // effects run when the simulation completes on the main thread). + const prevNodesRef = useRef(nodes); + if (prevNodesRef.current !== nodes) { + prevNodesRef.current = nodes; + if (layoutReady) setLayoutReady(false); + } + // Build the label → property keys map from current nodes const labelPropertyMap = useMemo(() => buildLabelPropertyMap(nodes), [nodes]); @@ -375,25 +384,13 @@ export function GraphChart({ } }, []); - // When autoFit is true, schedule a delayed fit after mount so that containers - // which animate to their final size (e.g. fullscreen dialogs) have settled. - // The fullscreen dialog defers mounting until the 200ms animation completes, - // but a small extra delay ensures the canvas is fully initialized. + // When autoFit is true, fit the graph after layout has settled. + // layoutReady flips to true when onLayoutDone fires — deterministic, + // not based on an arbitrary timer. useEffect(() => { - if (!autoFit) return; - const raf = requestAnimationFrame(() => { - const timer = setTimeout(() => { - fitGraph(); - }, 100); - cleanupRef.current = () => clearTimeout(timer); - }); - return () => { - cancelAnimationFrame(raf); - cleanupRef.current?.(); - }; - // fitGraph is stable (useCallback with no deps), so this is safe - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoFit]); + if (!autoFit || !layoutReady) return; + fitGraph(); + }, [autoFit, layoutReady, fitGraph]); const mouseEventCallbacks = useMemo( (): InteractiveNvlWrapperProps["mouseEventCallbacks"] => ({ @@ -437,7 +434,10 @@ export function GraphChart({ const nvlCallbacks = useMemo( () => ({ - onLayoutDone: fitGraph, + onLayoutDone: () => { + fitGraph(); + setLayoutReady(true); + }, }), [fitGraph], ); @@ -446,6 +446,7 @@ export function GraphChart({ () => ({ allowDynamicMinZoom: true, initialZoom: 0.7, + // Web workers require bundler-specific config in Next.js; keep on main thread. disableWebWorkers: true, // When physics is disabled, use a static layout (no force simulation) useStaticLayout: !physics, @@ -574,6 +575,15 @@ export function GraphChart({ )} + {!layoutReady && nodes.length > 0 && ( +
+
+
+ )} + Date: Tue, 24 Mar 2026 10:53:17 +0100 Subject: [PATCH 09/10] fix: radar max fallback, seed data shape, and markdown ReDoS - Radar chart: only store explicit max when finite and > 0, so invalid/null/NaN values fall back to globalMax instead of being treated as explicit 100. - Seed: move colorPalette to settings.colorPalette (matches chart-renderer.tsx), fix navigateToPageId -> targetPageId. - Markdown: replace ReDoS-vulnerable table alignment regex with linear split-and-check function (isTableAlignmentRow). - Add tests for radar invalid max fallback and GFM table rendering. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/lib/__tests__/chart-registry.test.ts | 79 +++++++++++++++++++ app/src/lib/chart-registry.ts | 10 +-- .../__tests__/markdown-widget.test.tsx | 63 +++++++++++++++ .../components/composed/markdown-widget.tsx | 22 +++++- scripts/seed-demo.mjs | 4 +- 5 files changed, 170 insertions(+), 8 deletions(-) diff --git a/app/src/lib/__tests__/chart-registry.test.ts b/app/src/lib/__tests__/chart-registry.test.ts index f5a87134..2e2a5c1b 100644 --- a/app/src/lib/__tests__/chart-registry.test.ts +++ b/app/src/lib/__tests__/chart-registry.test.ts @@ -1543,6 +1543,85 @@ describe("radar transform", () => { 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 793396cf..753c8c32 100644 --- a/app/src/lib/chart-registry.ts +++ b/app/src/lib/chart-registry.ts @@ -494,8 +494,10 @@ 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()); @@ -508,9 +510,7 @@ function transformToRadarData(data: unknown): unknown { 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)! - : globalMax, + max: indicatorExplicitMax.get(name) ?? globalMax, })); const series = Array.from(seriesMap.entries()).map(([name, valMap]) => ({ name, 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/markdown-widget.tsx b/component/src/components/composed/markdown-widget.tsx index 9c7958fa..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. @@ -116,7 +136,7 @@ function parseMarkdown(md: string): string { if ( line.includes("|") && i + 1 < lines.length && - /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/.test(lines[i + 1]) + isTableAlignmentRow(lines[i + 1]) ) { closeList(); const parseCells = (row: string) => diff --git a/scripts/seed-demo.mjs b/scripts/seed-demo.mjs index 1d59538a..77338a34 100644 --- a/scripts/seed-demo.mjs +++ b/scripts/seed-demo.mjs @@ -2029,7 +2029,7 @@ function buildChartCatalog(neo4jId) { rules: [{ id: uuid(), type: "navigate-to-page", triggerColumn: triggerCol, - navigateToPageId: pageId, + targetPageId: pageId, }], }); @@ -2040,7 +2040,7 @@ function buildChartCatalog(neo4jId) { chartType, connectionId: neo4jId, query, - settings: { ...baseSettings, title: p, chartOptions: { ...baseSettings.chartOptions, colorPalette: p } }, + settings: { ...baseSettings, title: p, colorPalette: p, chartOptions: { ...baseSettings.chartOptions } }, })); } From 1c04d9c7c16327cbfad1b24c511e83c3ded63043 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Tue, 24 Mar 2026 11:23:25 +0100 Subject: [PATCH 10/10] fix(e2e): use keyboard fallback when CM6 editor reports readonly The typeInEditor fixture was retrying until timeout when CM6's internal view.state.readOnly was true. Now falls through to the keyboard fallback strategy instead of throwing. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/e2e/fixtures.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/e2e/fixtures.ts b/app/e2e/fixtures.ts index 68f330f5..1c43c746 100644 --- a/app/e2e/fixtures.ts +++ b/app/e2e/fixtures.ts @@ -148,8 +148,9 @@ export async function typeInEditor( return; } - // Strategy 2: Keyboard fallback (for environments where cmView is not accessible) - if (dispatched === "no-view") { + // Strategy 2: Keyboard fallback (for environments where cmView is not accessible + // or when the view is temporarily readonly during initialization) + if (dispatched === "no-view" || dispatched === "readonly") { await expect(cm).toHaveAttribute("contenteditable", "true", { timeout: 2_000 }); await cm.click(); await page.keyboard.press("ControlOrMeta+a"); @@ -162,7 +163,7 @@ export async function typeInEditor( return; } - // Retry-worthy states: no-editor, readonly, dispatch-failed + // Retry-worthy states: no-editor, dispatch-failed throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); }).toPass({ timeout: 20_000 }); }