From 585a694334c4db504c72c0ddae13a146710b5f18 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Sun, 5 Apr 2026 13:29:19 +0200 Subject: [PATCH 01/36] feat: clicking user icon in sidebar opens profile settings (#351) Convert the user footer section from a static div to a clickable button that navigates to /settings/profile. Adds cursor-pointer, hover styling, and aria-label for keyboard accessibility. Closes #351 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/app/(dashboard)/layout.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/app/(dashboard)/layout.tsx b/app/src/app/(dashboard)/layout.tsx index 063bb2ed..a314db4a 100644 --- a/app/src/app/(dashboard)/layout.tsx +++ b/app/src/app/(dashboard)/layout.tsx @@ -86,8 +86,11 @@ export default function DashboardLayout({ footer={ <> {userName && ( -
router.push("/settings/profile")} + aria-label="Open profile settings" + className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground transition-colors ${collapsed ? "justify-center" : ""}`} > {!collapsed && ( @@ -103,7 +106,7 @@ export default function DashboardLayout({ )} )} -
+ )} From f1fcf071b9a03498f9fa58a46460ec1234ca57bd Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Sun, 5 Apr 2026 13:32:03 +0200 Subject: [PATCH 02/36] fix: bar chart blank render on initial widget placement (#332) When a widget is first placed on a dashboard grid, the container often starts with 0 dimensions during ECharts init. The first setOption() draws to a 0x0 canvas, leaving the chart blank until the user resizes or reloads. Force instance.resize() after setOption so the chart picks up the real container size on first render. Closes #332 Co-Authored-By: Claude Opus 4.6 (1M context) --- component/src/charts/base-chart.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/component/src/charts/base-chart.tsx b/component/src/charts/base-chart.tsx index caf64ed9..316b72fd 100644 --- a/component/src/charts/base-chart.tsx +++ b/component/src/charts/base-chart.tsx @@ -205,6 +205,10 @@ function BaseChart({ }, }; instance.setOption(merged, { notMerge: true }); + // Fix blank render on initial widget placement: if the container was + // 0x0 when ECharts initialized, the first setOption draws to a 0x0 + // canvas. Force a resize after setOption so it picks up the real size. + instance.resize(); }, [ options, enableDataZoom, From 2e47ff7134866e1356ccccda2822062f8f90ef9d Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Sun, 5 Apr 2026 13:34:17 +0200 Subject: [PATCH 03/36] feat: responsive bar chart label rotation based on widget width (#337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildCategoryAxisLabel now considers container width when computing rotation — narrower containers rotate more aggressively so labels don't overlap. Thresholds (pixels per label): - < 40px: 60° - < 70px: 45° - < 100px: 30° - >= 100px: 0° Also tightens label truncation to 10 chars when container is < 400px. Closes #337 Co-Authored-By: Claude Opus 4.6 (1M context) --- component/src/charts/bar-chart.tsx | 56 ++++++++++++++++++++++++----- component/src/charts/chart-utils.ts | 30 +++++++++++++--- 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/component/src/charts/bar-chart.tsx b/component/src/charts/bar-chart.tsx index 0e741dca..f92e3d97 100644 --- a/component/src/charts/bar-chart.tsx +++ b/component/src/charts/bar-chart.tsx @@ -84,11 +84,17 @@ function BarChart({ if (!data.length) return buildEmptyDataOption(); const seriesKeys = Object.keys(data[0]).filter((k) => k !== "label"); - const effectiveShowLegend = resolveShowLegend(showLegend, seriesKeys.length, hideLegend); + const effectiveShowLegend = resolveShowLegend( + showLegend, + seriesKeys.length, + hideLegend, + ); const isHorizontal = orientation === "horizontal"; const effectiveShowValues = compact ? false : showValues; const effectiveBarWidth = barWidth > 0 ? barWidth : undefined; - const thresholds = stylingRules ? [] : parseColorThresholds(colorThresholds ?? ""); + const thresholds = stylingRules + ? [] + : parseColorThresholds(colorThresholds ?? ""); const refLines = parseReferenceLines(referenceLinesJson); const markLine = buildMarkLineFromRefs(refLines); @@ -96,6 +102,7 @@ function BarChart({ const axisLabelConfig = buildCategoryAxisLabel(categoryLabels.length, { compact, rotateOverride: axisLabelRotation, + containerWidth: width, }); const categoryAxis = { @@ -103,7 +110,7 @@ function BarChart({ data: categoryLabels, axisLabel: axisLabelConfig, axisPointer: { type: "shadow" as const }, - name: compact ? undefined : (isHorizontal ? yAxisLabel : xAxisLabel), + name: compact ? undefined : isHorizontal ? yAxisLabel : xAxisLabel, nameLocation: "middle" as const, nameGap: axisLabelConfig.rotate > 0 ? 50 : 30, }; @@ -111,13 +118,17 @@ function BarChart({ type: "value" as const, axisLabel: { show: !compact }, splitLine: { show: showGridLines }, - name: compact ? undefined : (isHorizontal ? xAxisLabel : yAxisLabel), + name: compact ? undefined : isHorizontal ? xAxisLabel : yAxisLabel, nameLocation: "middle" as const, nameGap: 50, }; return { - tooltip: { trigger: "axis" as const, axisPointer: { type: "shadow" as const }, formatter: buildTooltipFormatter() }, + 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, @@ -127,9 +138,15 @@ function BarChart({ type: "bar" as const, data: data.map((d) => { const rawValue = d[key]; - const numericValue = typeof rawValue === "number" ? rawValue : Number(rawValue); + const numericValue = + typeof rawValue === "number" ? rawValue : Number(rawValue); const color = Number.isFinite(numericValue) - ? resolveItemColor(numericValue, stylingRules, paramValues, thresholds) + ? resolveItemColor( + numericValue, + stylingRules, + paramValues, + thresholds, + ) : undefined; return color ? { value: rawValue, itemStyle: { color } } : rawValue; }), @@ -137,14 +154,35 @@ function BarChart({ barWidth: effectiveBarWidth, barGap, label: effectiveShowValues - ? { show: true, position: isHorizontal ? ("right" as const) : ("top" as const) } + ? { + 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, axisLabelRotation, referenceLinesJson, 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/chart-utils.ts b/component/src/charts/chart-utils.ts index dcabd632..11405588 100644 --- a/component/src/charts/chart-utils.ts +++ b/component/src/charts/chart-utils.ts @@ -133,6 +133,8 @@ export interface CategoryAxisLabelOptions { maxLabelLength?: number; /** Whether the chart is in compact mode (hides labels). */ compact?: boolean; + /** Container width in pixels — used for width-based auto-rotation. */ + containerWidth?: number; } export interface CategoryAxisLabelConfig { @@ -156,7 +158,7 @@ export function buildCategoryAxisLabel( categoryCount: number, options: CategoryAxisLabelOptions = {}, ): CategoryAxisLabelConfig { - const { maxLabelLength = 15, compact = false } = options; + const { maxLabelLength = 15, compact = false, containerWidth } = options; // Normalize -1 sentinel (automatic mode) to undefined so ECharts uses its // default auto-rotation instead of receiving an invalid rotate: -1. const rotateOverride = @@ -165,6 +167,19 @@ export function buildCategoryAxisLabel( let rotate: number; if (rotateOverride !== undefined) { rotate = rotateOverride; + } else if (containerWidth && categoryCount > 0) { + // Width-aware rotation: compute available space per label. + // Rough budget: label width ≈ maxLabelLength * 7px at 12px font. + const pixelsPerLabel = containerWidth / categoryCount; + if (pixelsPerLabel < 40) { + rotate = 60; + } else if (pixelsPerLabel < 70) { + rotate = 45; + } else if (pixelsPerLabel < 100) { + rotate = 30; + } else { + rotate = 0; + } } else if (categoryCount >= 15) { rotate = 45; } else if (categoryCount >= 8) { @@ -173,11 +188,18 @@ export function buildCategoryAxisLabel( rotate = 0; } - const needsTruncation = categoryCount >= 8; + // Width-aware truncation: tighter limit in narrow containers + const effectiveMaxLength = + containerWidth && containerWidth < 400 + ? Math.min(maxLabelLength, 10) + : maxLabelLength; + const needsTruncation = + categoryCount >= 8 || + (containerWidth !== undefined && containerWidth < 400); const formatter = needsTruncation ? (value: string) => - value.length > maxLabelLength - ? value.slice(0, maxLabelLength - 1) + "\u2026" + value.length > effectiveMaxLength + ? value.slice(0, effectiveMaxLength - 1) + "\u2026" : value : undefined; From 558f1dc7f6ae2918e0bce91856ddbc6f55fc3d9b Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Sun, 5 Apr 2026 13:40:49 +0200 Subject: [PATCH 04/36] feat: scrollable pie chart legend with better controls (#338) --- .../src/charts/__tests__/pie-chart.test.tsx | 25 +++++- component/src/charts/pie-chart.tsx | 85 ++++++++++++++----- 2 files changed, 88 insertions(+), 22 deletions(-) diff --git a/component/src/charts/__tests__/pie-chart.test.tsx b/component/src/charts/__tests__/pie-chart.test.tsx index 3e4e37e7..326eb64a 100644 --- a/component/src/charts/__tests__/pie-chart.test.tsx +++ b/component/src/charts/__tests__/pie-chart.test.tsx @@ -68,6 +68,18 @@ describe("PieChart", () => { expect(optionsCall.legend).toBeUndefined(); }); + it("configures legend with scrollable type and enlarged pagination controls", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.legend).toMatchObject({ + type: "scroll", + bottom: 0, + orient: "horizontal", + pageIconSize: 12, + }); + expect(optionsCall.legend.pageTextStyle.fontSize).toBe(11); + }); + it("handles empty data", () => { render(); const optionsCall = mockSetOption.mock.calls[0][0]; @@ -121,14 +133,18 @@ describe("PieChart", () => { it("sorts slices by value descending when sortSlices is true", () => { render(); const optionsCall = mockSetOption.mock.calls[0][0]; - const values = (optionsCall.series[0].data as Array<{ value: number }>).map((d) => d.value); + const values = (optionsCall.series[0].data as Array<{ value: number }>).map( + (d) => d.value, + ); expect(values).toEqual([60, 30, 10]); }); it("preserves original order when sortSlices is false (default)", () => { render(); const optionsCall = mockSetOption.mock.calls[0][0]; - const names = (optionsCall.series[0].data as Array<{ name: string }>).map((d) => d.name); + const names = (optionsCall.series[0].data as Array<{ name: string }>).map( + (d) => d.name, + ); expect(names).toEqual(["Desktop", "Mobile", "Tablet"]); }); @@ -160,7 +176,10 @@ describe("PieChart", () => { it("groups slices beyond topN into Other", () => { render(); const optionsCall = mockSetOption.mock.calls[0][0]; - const seriesData = optionsCall.series[0].data as Array<{ name: string; value: number }>; + const seriesData = optionsCall.series[0].data as Array<{ + name: string; + value: number; + }>; expect(seriesData).toHaveLength(3); // 2 top + "Other" expect(seriesData[2].name).toBe("Other"); expect(seriesData[2].value).toBe(10); diff --git a/component/src/charts/pie-chart.tsx b/component/src/charts/pie-chart.tsx index ff6549f6..fd1326df 100644 --- a/component/src/charts/pie-chart.tsx +++ b/component/src/charts/pie-chart.tsx @@ -3,7 +3,13 @@ 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, groupTopN } from "./chart-utils"; +import { + buildEmptyDataOption, + getCompactState, + isDark, + resolveItemColor, + groupTopN, +} from "./chart-utils"; import { parseColorThresholds } from "./color-threshold"; import type { StylingRule } from "./styling-rule"; @@ -77,9 +83,16 @@ function PieChart({ : data; const sortedData = groupTopN(sorted, topN); - const thresholds = stylingRules ? [] : parseColorThresholds(colorThresholds ?? ""); + const thresholds = stylingRules + ? [] + : parseColorThresholds(colorThresholds ?? ""); const coloredData = sortedData.map((d) => { - const color = resolveItemColor(d.value, stylingRules, paramValues, thresholds); + const color = resolveItemColor( + d.value, + stylingRules, + paramValues, + thresholds, + ); return color ? { ...d, itemStyle: { color } } : d; }); @@ -91,7 +104,19 @@ function PieChart({ trigger: "item", formatter: "{b}: {c} ({d}%)", }, - legend: effectiveShowLegend ? { bottom: 0, type: "scroll" } : undefined, + legend: effectiveShowLegend + ? { + bottom: 0, + type: "scroll", + orient: "horizontal", + width: "90%", + pageIconSize: 12, + pageTextStyle: { fontSize: 11 }, + pageButtonItemGap: 6, + itemGap: 12, + textStyle: { fontSize: 12 }, + } + : undefined, series: [ { type: "pie", @@ -121,22 +146,44 @@ 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", - }, - }], - } : {}), + ...(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, topN, donutCenterText, colorThresholds, stylingRules, paramValues, compact, hideLegend]); + }, [ + data, + donut, + showLabel, + showLegend, + roseMode, + labelPosition, + showPercentage, + sortSlices, + topN, + donutCenterText, + colorThresholds, + stylingRules, + paramValues, + compact, + hideLegend, + ]); return (
From 4704a426ee7562940e698abbfa44e60b12505ef8 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Sun, 5 Apr 2026 13:42:52 +0200 Subject: [PATCH 05/36] feat: connect nulls and end labels for line chart (#146) Add two new boolean options to LineChart: - connectNulls: draw lines through missing (null) data points - endLabel: show series name label at the end of each line Exposed via the chart options schema so users can toggle in the UI. Closes #146 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/charts/__tests__/line-chart.test.tsx | 51 +++++++++++++++++++ component/src/charts/line-chart.tsx | 10 ++++ .../components/composed/chart-options/line.ts | 17 +++++++ 3 files changed, 78 insertions(+) diff --git a/component/src/charts/__tests__/line-chart.test.tsx b/component/src/charts/__tests__/line-chart.test.tsx index c33c2962..fc8676d9 100644 --- a/component/src/charts/__tests__/line-chart.test.tsx +++ b/component/src/charts/__tests__/line-chart.test.tsx @@ -160,6 +160,57 @@ describe("LineChart", () => { expect(optionsCall.series[0].step).toBeUndefined(); }); + // --- Connect nulls --- + + it("defaults connectNulls to false", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].connectNulls).toBe(false); + }); + + it("enables connectNulls when true", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].connectNulls).toBe(true); + }); + + it("applies connectNulls to every series in multi-series", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].connectNulls).toBe(true); + expect(optionsCall.series[1].connectNulls).toBe(true); + }); + + // --- End label --- + + it("does not set endLabel by default", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].endLabel).toBeUndefined(); + }); + + it("enables endLabel when true", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].endLabel).toEqual({ + show: true, + formatter: "{a}", + }); + }); + + it("applies endLabel to every series in multi-series", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].endLabel).toEqual({ + show: true, + formatter: "{a}", + }); + expect(optionsCall.series[1].endLabel).toEqual({ + show: true, + formatter: "{a}", + }); + }); + // --- Reference lines --- it("attaches markLine to the first series when referenceLines is provided", () => { diff --git a/component/src/charts/line-chart.tsx b/component/src/charts/line-chart.tsx index c2b5136e..6469e53a 100644 --- a/component/src/charts/line-chart.tsx +++ b/component/src/charts/line-chart.tsx @@ -38,6 +38,10 @@ export interface LineChartProps extends Omit { showGridLines?: boolean; /** Use stepped line style */ stepped?: boolean; + /** Draw lines through missing (null) data points */ + connectNulls?: boolean; + /** Show series name label at the end of each line */ + endLabel?: boolean; /** JSON string of reference lines: [{ value, label?, color? }] */ referenceLines?: string; /** @deprecated Use stylingRules instead. JSON string of thresholds */ @@ -68,6 +72,8 @@ function LineChart({ lineWidth = 2, showGridLines = true, stepped = false, + connectNulls = false, + endLabel = false, referenceLines: referenceLinesJson, colorThresholds, stylingRules, @@ -140,6 +146,8 @@ function LineChart({ : data.map((d) => d[key] as number), smooth, step: stepped ? ("start" as const) : undefined, + connectNulls, + endLabel: endLabel ? { show: true, formatter: "{a}" } : undefined, lineStyle: { width: lineWidth, color: seriesColor }, itemStyle: seriesColor ? { color: seriesColor } : undefined, showSymbol: showPoints, @@ -165,6 +173,8 @@ function LineChart({ lineWidth, showGridLines, stepped, + connectNulls, + endLabel, referenceLinesJson, colorThresholds, stylingRules, diff --git a/component/src/components/composed/chart-options/line.ts b/component/src/components/composed/chart-options/line.ts index b4046183..a2a8c8ae 100644 --- a/component/src/components/composed/chart-options/line.ts +++ b/component/src/components/composed/chart-options/line.ts @@ -51,6 +51,23 @@ export const lineOptions: ChartOptionDef[] = [ category: "Style", description: "Draw a dot at each data point along the line.", }, + { + key: "connectNulls", + label: "Connect Nulls", + type: "boolean", + default: false, + category: "Style", + description: + "Draw a continuous line through missing (null) data points instead of breaking the line.", + }, + { + key: "endLabel", + label: "Show End Labels", + type: "boolean", + default: false, + category: "Style", + description: "Show the series name as a label at the end of each line.", + }, SHARED_SHOW_GRID_LINES, SHARED_X_AXIS_LABEL, SHARED_Y_AXIS_LABEL, From 8ee84044f740a9ccff096b11b354fe0af3bd4925 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Sun, 5 Apr 2026 13:43:59 +0200 Subject: [PATCH 06/36] fix: show ID and entity type in graph inspection panel (#352) - Add edge ID to metadata items in relationship inspection panel - Add Badge indicator showing 'Node' or 'Relationship' in panel header - Thread optional id through GraphEdge type and transform pipeline so the inspection panel can surface the internal element id for edges Closes #352 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/graph-exploration-wrapper.tsx | 21 ++++++++++++------- app/src/lib/chart-registry.ts | 1 + component/src/charts/types.ts | 1 + 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/src/components/graph-exploration-wrapper.tsx b/app/src/components/graph-exploration-wrapper.tsx index 91c5cde3..efb2d352 100644 --- a/app/src/components/graph-exploration-wrapper.tsx +++ b/app/src/components/graph-exploration-wrapper.tsx @@ -6,6 +6,7 @@ import { GraphChart, useGraphExploration, PropertyPanel, + Badge, } from "@neoboard/components"; import type { GraphNode, @@ -119,6 +120,7 @@ function edgeToSections(edge: GraphEdge): PropertySection[] { }; }); const metaItems = [ + ...(edge.id ? [{ key: "id", value: edge.id }] : []), { key: "type", value: edge.label ?? "UNKNOWN" }, { key: "source", value: edge.source }, { key: "target", value: edge.target }, @@ -332,13 +334,18 @@ export function GraphExplorationWrapper({ {inspectedElement && (
-

- {inspectedElement.type === "node" - ? (inspectedElement.node.label ?? - inspectedElement.node.id ?? - "Node") - : (inspectedElement.edge.label ?? "Relationship")} -

+
+ + {inspectedElement.type === "node" ? "Node" : "Relationship"} + +

+ {inspectedElement.type === "node" + ? (inspectedElement.node.label ?? + inspectedElement.node.id ?? + "Node") + : (inspectedElement.edge.label ?? "Relationship")} +

+
+ {/* Static options (for select only) */} + {field.parameterType === "select" && ( + onUpdate(field.id, { staticOptions: v })} + placeholder="e.g. low,medium,high" + /> + )} + {/* Seed query (for select/multi-select/cascading-select) */} {needsSeedQuery(field.parameterType) && (
- +