From 8a50e50f5487f76bc6de0e116aa53abc7dbdd145 Mon Sep 17 00:00:00 2001 From: hugo Date: Thu, 26 Feb 2026 13:18:24 +0100 Subject: [PATCH] feat: better heatmap styling --- DOCUMENTATION.md | 2 +- src/main/a2ui/catalog.ts | 2 +- src/main/a2ui/generator.ts | 2 +- src/main/engine/ChatEngine.ts | 2 +- src/main/engine/OpenCodeManager.ts | 30 +- src/renderer/a2ui/components/A2UIChart.tsx | 377 ++++++++++- .../AssistantSidebar/AssistantSidebar.css | 109 +++ src/renderer/navigation/assistantPanelSpec.ts | 8 +- tests/engine/a2ui/catalog.test.ts | 4 +- tests/renderer/a2ui/A2UIChart.test.tsx | 623 ++++++++++++++++++ 10 files changed, 1142 insertions(+), 17 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 4d3bce9..728e942 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -264,7 +264,7 @@ The assistant works entirely with your local blog content. It does not have acce ### Charts -The assistant can display bar, line, and pie charts to help you spot patterns and trends in your blog data. Charts include a title, labeled data points, and a visual representation that makes it easy to compare values at a glance. +The assistant can display bar, stacked-bar, line, area, pie, donut, and heatmap charts to help you spot patterns and trends in your blog data. Charts include a title, labeled data points, and a visual representation that makes it easy to compare values at a glance. Use stacked-bar charts when each bar has multiple segments, area charts for cumulative trends, donut charts for proportional breakdowns with a total in the center, and heatmap charts for matrix data where color intensity encodes value. **Try asking:** "Show me a chart of posts published per month this year" diff --git a/src/main/a2ui/catalog.ts b/src/main/a2ui/catalog.ts index 6a59c40..96b2977 100644 --- a/src/main/a2ui/catalog.ts +++ b/src/main/a2ui/catalog.ts @@ -15,7 +15,7 @@ const CATALOG_ENTRIES: A2UICatalogEntry[] = [ { type: 'text', description: 'Text block with Markdown support' }, { type: 'button', description: 'Clickable button that dispatches an action' }, { type: 'card', description: 'Card with title, subtitle, body, and action buttons' }, - { type: 'chart', description: 'Bar, line, or pie chart visualization', custom: true }, + { type: 'chart', description: 'Bar, stacked-bar, line, area, pie, donut, or heatmap chart visualization', custom: true }, { type: 'table', description: 'Data table with columns and rows', custom: true }, { type: 'textField', description: 'Text input field with data binding' }, { type: 'checkBox', description: 'Checkbox input with data binding' }, diff --git a/src/main/a2ui/generator.ts b/src/main/a2ui/generator.ts index 898b2d9..a3256ae 100644 --- a/src/main/a2ui/generator.ts +++ b/src/main/a2ui/generator.ts @@ -56,7 +56,7 @@ function createSurfaceMessages( // ---- Tool argument interfaces ---- export interface RenderChartArgs { - chartType: 'bar' | 'stacked-bar' | 'line' | 'pie'; + chartType: 'bar' | 'stacked-bar' | 'line' | 'area' | 'pie' | 'donut' | 'heatmap'; title?: string; series: Array<{ label: string; value: number; segments?: Array<{ label: string; value: number }> }>; } diff --git a/src/main/engine/ChatEngine.ts b/src/main/engine/ChatEngine.ts index 4cee617..758a244 100644 --- a/src/main/engine/ChatEngine.ts +++ b/src/main/engine/ChatEngine.ts @@ -323,7 +323,7 @@ Available Data Tools: - get_media_posts: Get posts that use a specific media file. Available UI Render Tools (use these to show rich interactive elements): -- render_chart: Show data as a bar, stacked-bar, line, or pie chart. Use when presenting statistics or comparisons. Use stacked-bar when each bar has multiple segments (e.g., published vs draft posts per year). +- render_chart: Show data as a bar, stacked-bar, line, area, pie, donut, or heatmap chart. Use when presenting statistics or comparisons. Use stacked-bar when each bar has multiple segments (e.g., published vs draft posts per year). Use area for cumulative or trend data where the filled region emphasizes volume. Use donut for proportional breakdowns with a total displayed in the center. Use heatmap for grid/matrix visualizations where color intensity shows magnitude — e.g., posts per month across years (each series entry is a row like a year, each segment is a column like a month), or a calendar view where rows are weekdays and columns are week numbers. ALWAYS prefer heatmap over a table with emojis or color indicators when showing intensity grids or calendar-style activity views. - render_table: Show data in a structured table. Use for tabular comparisons and listings. - render_form: Show an interactive form to collect user input (e.g., metadata edits, settings). - render_card: Show an information card with title, body, and action buttons. diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index c25fcd0..7a54da0 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -924,31 +924,31 @@ export class OpenCodeManager { input_schema: { type: 'object', properties: { - chartType: { type: 'string', enum: ['bar', 'stacked-bar', 'line', 'pie'], description: 'The type of chart to render. Use stacked-bar when each bar has multiple segments (categories).' }, + chartType: { type: 'string', enum: ['bar', 'stacked-bar', 'line', 'area', 'pie', 'donut', 'heatmap'], description: 'The type of chart to render. Use stacked-bar when each bar has multiple segments (categories). Use area for trend/cumulative data. Use donut for proportional data with a total in the center. Use heatmap for grid/matrix visualizations where color intensity shows magnitude (e.g., posts per month across years). Prefer heatmap over tables with emojis for intensity data.' }, title: { type: 'string', description: 'Optional chart title' }, series: { type: 'array', items: { type: 'object', properties: { - label: { type: 'string', description: 'Data point label' }, - value: { type: 'number', description: 'Data point value (total for stacked bars)' }, + label: { type: 'string', description: 'Data point label (row label for heatmaps, e.g., year)' }, + value: { type: 'number', description: 'Data point value (total for stacked bars, ignored for heatmaps)' }, segments: { type: 'array', items: { type: 'object', properties: { - label: { type: 'string', description: 'Segment category label' }, - value: { type: 'number', description: 'Segment value' }, + label: { type: 'string', description: 'Segment/column label (e.g., month name for heatmaps)' }, + value: { type: 'number', description: 'Segment value (color intensity for heatmaps)' }, }, required: ['label', 'value'], }, - description: 'Segments for stacked-bar charts. Each segment is a category within the bar.', + description: 'Segments within this data point. Required for stacked-bar and heatmap charts. For heatmaps, each segment becomes a cell in that row.', }, }, required: ['label', 'value'], }, - description: 'Array of data points with label and value. For stacked-bar charts, include segments.', + description: 'Array of data points. For stacked-bar and heatmap charts, include segments. For heatmaps, each entry is a row and segments are columns.', }, }, required: ['chartType', 'series'], @@ -1070,12 +1070,24 @@ export class OpenCodeManager { value: { type: 'string', description: 'Display value (for type metric)' }, title: { type: 'string', description: 'Title (for type list, chart, or table)' }, items: { type: 'array', items: { type: 'string' }, description: 'Items (for type list)' }, - chartType: { type: 'string', enum: ['bar', 'line', 'pie'], description: 'Chart type (for type chart)' }, + chartType: { type: 'string', enum: ['bar', 'stacked-bar', 'line', 'area', 'pie', 'donut', 'heatmap'], description: 'Chart type (for type chart). Use heatmap for intensity grids.' }, series: { type: 'array', items: { type: 'object', - properties: { label: { type: 'string' }, value: { type: 'number' } }, + properties: { + label: { type: 'string' }, + value: { type: 'number' }, + segments: { + type: 'array', + items: { + type: 'object', + properties: { label: { type: 'string' }, value: { type: 'number' } }, + required: ['label', 'value'], + }, + description: 'Segments for stacked-bar and heatmap charts', + }, + }, required: ['label', 'value'], }, description: 'Data series (for type chart)', diff --git a/src/renderer/a2ui/components/A2UIChart.tsx b/src/renderer/a2ui/components/A2UIChart.tsx index faf87bc..3e35a07 100644 --- a/src/renderer/a2ui/components/A2UIChart.tsx +++ b/src/renderer/a2ui/components/A2UIChart.tsx @@ -33,6 +33,76 @@ function getSegmentColor(index: number): string { return SEGMENT_COLORS[index % SEGMENT_COLORS.length]; } +/* ── Heatmap colour helpers ── */ + +type RGB = [number, number, number]; + +const FALLBACK_INS: RGB = [53, 117, 56]; +const FALLBACK_DEL: RGB = [183, 72, 72]; + +/** Parse a CSS color value (hex or rgb/rgba) into an [r,g,b] triple. */ +function parseCssColor(raw: string): RGB | null { + const v = raw.trim(); + const hexMatch = v.match(/^#([0-9a-f]{6})$/i); + if (hexMatch) { + const hex = hexMatch[1]; + return [ + Number.parseInt(hex.slice(0, 2), 16), + Number.parseInt(hex.slice(2, 4), 16), + Number.parseInt(hex.slice(4, 6), 16), + ]; + } + const rgbMatch = v.match(/^rgba?\(([^)]+)\)$/i); + if (rgbMatch) { + const channels = rgbMatch[1].split(',').map((c) => Math.round(Number.parseFloat(c.trim()))).slice(0, 3); + if (channels.length === 3 && channels.every((c) => Number.isFinite(c))) { + return channels.map((c) => Math.max(0, Math.min(255, c))) as RGB; + } + } + return null; +} + +function lerpRGB(a: RGB, b: RGB, t: number): RGB { + return [ + Math.round(a[0] + (b[0] - a[0]) * t), + Math.round(a[1] + (b[1] - a[1]) * t), + Math.round(a[2] + (b[2] - a[2]) * t), + ]; +} + +/** Relative luminance (0-255 scale) for contrast decision. */ +function luminance(c: RGB): number { + return 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]; +} + +function readPicoInsDelColors(): { ins: RGB; del: RGB } { + try { + const style = window.getComputedStyle(document.documentElement); + const ins = parseCssColor(style.getPropertyValue('--pico-ins-color')) ?? FALLBACK_INS; + const del = parseCssColor(style.getPropertyValue('--pico-del-color')) ?? FALLBACK_DEL; + return { ins, del }; + } catch { + return { ins: FALLBACK_INS, del: FALLBACK_DEL }; + } +} + +/** Compute heatmap cell background and contrasting text color. */ +function heatmapCellColors(alpha: number, ins: RGB, del: RGB): { bg: string; fg: string } { + if (alpha <= 0) return { bg: 'transparent', fg: 'inherit' }; + const rgb = lerpRGB(ins, del, alpha); + // Scale opacity so even low values are visible (0.25 → 1.0) + const opacity = 0.25 + alpha * 0.75; + const bg = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${opacity.toFixed(2)})`; + // Blend the effective RGB with the assumed dark background (~30) for contrast calc + const effective: RGB = [ + Math.round(rgb[0] * opacity + 30 * (1 - opacity)), + Math.round(rgb[1] * opacity + 30 * (1 - opacity)), + Math.round(rgb[2] * opacity + 30 * (1 - opacity)), + ]; + const fg = luminance(effective) > 140 ? '#000' : '#fff'; + return { bg, fg }; +} + /** Collect unique segment labels across all series entries, preserving order. */ function collectSegmentLabels(series: SeriesEntry[]): string[] { const seen = new Set(); @@ -50,6 +120,297 @@ function collectSegmentLabels(series: SeriesEntry[]): string[] { return labels; } +/** Generate nice round tick values for the Y-axis. */ +function computeYTicks(maxValue: number, tickCount: number = 4): number[] { + if (maxValue <= 0) return [0]; + const rawStep = maxValue / (tickCount - 1); + const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep))); + const normalised = rawStep / magnitude; + let niceStep: number; + if (normalised <= 1) niceStep = magnitude; + else if (normalised <= 2) niceStep = 2 * magnitude; + else if (normalised <= 5) niceStep = 5 * magnitude; + else niceStep = 10 * magnitude; + + const ticks: number[] = []; + for (let v = 0; v <= maxValue + niceStep * 0.01; v += niceStep) { + ticks.push(Math.round(v * 1e6) / 1e6); + } + if (ticks.length < 2) ticks.push(niceStep); + return ticks; +} + +const LINE_CHART_PADDING = { top: 8, right: 12, bottom: 24, left: 40 }; +const LINE_CHART_HEIGHT = 140; + +function renderLineChart(component: A2UIResolvedComponent, series: SeriesEntry[], showArea: boolean = false): React.ReactNode { + if (series.length === 0) return null; + + const maxValue = Math.max(...series.map((e) => e.value), 0); + const yTicks = computeYTicks(maxValue); + const yMax = yTicks[yTicks.length - 1]; + + const pad = LINE_CHART_PADDING; + const plotWidth = 300 - pad.left - pad.right; + const plotHeight = LINE_CHART_HEIGHT - pad.top - pad.bottom; + + const xStep = series.length > 1 ? plotWidth / (series.length - 1) : 0; + + const points = series.map((entry, i) => { + const x = pad.left + (series.length > 1 ? i * xStep : plotWidth / 2); + const y = pad.top + (yMax > 0 ? (1 - entry.value / yMax) * plotHeight : plotHeight); + return { x, y, entry }; + }); + + const polylinePoints = points.map((p) => `${p.x},${p.y}`).join(' '); + const baselineY = pad.top + plotHeight; + const areaPoints = showArea + ? `${points[0].x},${baselineY} ${polylinePoints} ${points[points.length - 1].x},${baselineY}` + : ''; + + return ( + + {/* Horizontal grid lines + Y-axis labels */} + {yTicks.map((tick) => { + const y = pad.top + (yMax > 0 ? (1 - tick / yMax) * plotHeight : plotHeight); + return ( + + + + {tick} + + + ); + })} + + {/* Area fill (when showArea is true) */} + {showArea && ( + + )} + + {/* Line */} + + + {/* Dots */} + {points.map((p, i) => ( + + {`${p.entry.label}: ${p.entry.value}`} + + ))} + + {/* X-axis labels */} + {points.map((p, i) => ( + + {p.entry.label} + + ))} + + ); +} + +const PIE_CHART_SIZE = 140; +const PIE_CHART_RADIUS = 56; +const PIE_CHART_CENTER = PIE_CHART_SIZE / 2; + +function describePieSlice(cx: number, cy: number, r: number, startAngle: number, endAngle: number): string { + const startRad = (startAngle - 90) * (Math.PI / 180); + const endRad = (endAngle - 90) * (Math.PI / 180); + const x1 = cx + r * Math.cos(startRad); + const y1 = cy + r * Math.sin(startRad); + const x2 = cx + r * Math.cos(endRad); + const y2 = cy + r * Math.sin(endRad); + const largeArc = endAngle - startAngle > 180 ? 1 : 0; + return `M${cx},${cy} L${x1},${y1} A${r},${r} 0 ${largeArc} 1 ${x2},${y2} Z`; +} + +function renderPieChart(component: A2UIResolvedComponent, series: SeriesEntry[], isDonut: boolean = false): React.ReactNode { + if (series.length === 0) return null; + + const total = series.reduce((sum, e) => sum + e.value, 0); + if (total <= 0) return null; + + let currentAngle = 0; + const slices = series.map((entry, i) => { + const sliceAngle = (entry.value / total) * 360; + const startAngle = currentAngle; + const endAngle = currentAngle + sliceAngle; + currentAngle = endAngle; + + // For a full circle (single slice), use a circle element instead + if (sliceAngle >= 359.99) { + return ( + + {`${entry.label}: ${entry.value}`} + + ); + } + + return ( + + {`${entry.label}: ${entry.value}`} + + ); + }); + + const donutInnerRadius = PIE_CHART_RADIUS * 0.58; + + return ( + <> + + {slices} + {isDonut && ( + <> + + + {total} + + + )} + +
+ {series.map((entry, i) => ( + + + {entry.label} + + ))} +
+ + ); +} + +function renderHeatmap(component: A2UIResolvedComponent, series: SeriesEntry[]): React.ReactNode { + // Heatmap requires segments — each entry is a row, each segment is a column + const rows = series.filter((e) => e.segments && e.segments.length > 0); + if (rows.length === 0) return null; + + const columnLabels = collectSegmentLabels(rows); + if (columnLabels.length === 0) return null; + + // Find global max for normalisation + let globalMax = 0; + for (const row of rows) { + for (const seg of row.segments!) { + if (seg.value > globalMax) globalMax = seg.value; + } + } + + const { ins, del } = readPicoInsDelColors(); + + // Build a lookup for each row's segment values by column label + const columnCount = columnLabels.length; + + return ( +
+ {/* Header row: empty corner + column labels */} + + {columnLabels.map((col) => ( + + {col} + + ))} + + {/* Data rows */} + {rows.map((row) => { + const segMap = new Map(); + for (const seg of row.segments!) { + segMap.set(seg.label, seg.value); + } + return ( + + {row.label} + {columnLabels.map((col) => { + const val = segMap.get(col) ?? 0; + const alpha = globalMax > 0 ? val / globalMax : 0; + const { bg, fg } = heatmapCellColors(alpha, ins, del); + return ( + + {val > 0 ? val : ''} + + ); + })} + + ); + })} +
+ ); +} + export const A2UIChart: React.FC = ({ component }) => { const chartType = String(component.properties.chartType ?? 'bar'); const title = component.properties.title as string | undefined; @@ -68,11 +429,24 @@ export const A2UIChart: React.FC = ({ component }) => { const segmentLabels = isStacked ? collectSegmentLabels(series) : []; + const isLine = chartType === 'line'; + const isArea = chartType === 'area'; + const isPie = chartType === 'pie'; + const isDonut = chartType === 'donut'; + const isHeatmap = chartType === 'heatmap'; + return (
{title &&

{title}

}
{chartType}
-
+ {isPie || isDonut ? ( + renderPieChart(component, series, isDonut) + ) : isHeatmap ? ( + renderHeatmap(component, series) + ) : isLine || isArea ? ( + renderLineChart(component, series, isArea) + ) : ( +
{series.map((entry, index) => { const totalValue = isStacked && entry.segments ? entry.segments.reduce((sum, s) => sum + s.value, 0) @@ -109,6 +483,7 @@ export const A2UIChart: React.FC = ({ component }) => { ); })}
+ )} {isStacked && segmentLabels.length > 0 && (
{segmentLabels.map((label, i) => ( diff --git a/src/renderer/components/AssistantSidebar/AssistantSidebar.css b/src/renderer/components/AssistantSidebar/AssistantSidebar.css index 2aed072..5092327 100644 --- a/src/renderer/components/AssistantSidebar/AssistantSidebar.css +++ b/src/renderer/components/AssistantSidebar/AssistantSidebar.css @@ -240,6 +240,115 @@ flex-shrink: 0; } +/* Line chart */ +.assistant-panel-chart-line-svg { + width: 100%; + height: auto; + max-height: 180px; +} + +.assistant-panel-chart-line-grid { + stroke: var(--vscode-panel-border, #444); + stroke-width: 0.5; + stroke-dasharray: 3 3; +} + +.assistant-panel-chart-line-y-label { + font-size: 9px; + fill: var(--vscode-foreground, #ccc); + opacity: 0.7; +} + +.assistant-panel-chart-line-path { + stroke: var(--vscode-charts-blue, #75beff); + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; +} + +.assistant-panel-chart-line-dot { + fill: var(--vscode-charts-blue, #75beff); + stroke: var(--vscode-editor-background, #1e1e1e); + stroke-width: 1.5; +} + +.assistant-panel-chart-line-x-label { + font-size: 9px; + fill: var(--vscode-foreground, #ccc); + opacity: 0.7; +} + +.assistant-panel-chart-area-fill { + fill: var(--vscode-charts-blue, #75beff); + opacity: 0.15; +} + +/* Pie chart */ +.assistant-panel-chart-pie-svg { + width: 100%; + max-width: 160px; + height: auto; + align-self: center; +} + +.assistant-panel-chart-pie-slice { + stroke: var(--vscode-editor-background, #1e1e1e); + stroke-width: 1.5; +} + +/* Donut chart */ +.assistant-panel-chart-donut-hole { + fill: var(--vscode-editor-background, #1e1e1e); +} + +.assistant-panel-chart-donut-total { + font-size: 16px; + font-weight: 600; + fill: var(--vscode-foreground, #ccc); + font-variant-numeric: tabular-nums; +} + +/* Heatmap chart */ +.assistant-panel-chart-heatmap { + display: grid; + gap: 2px; + font-size: 11px; +} + +.assistant-panel-chart-heatmap-corner { + /* empty top-left cell */ +} + +.assistant-panel-chart-heatmap-col-label { + text-align: center; + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.assistant-panel-chart-heatmap-row-label { + text-align: right; + padding-right: 4px; + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 80px; +} + +.assistant-panel-chart-heatmap-cell { + aspect-ratio: 1; + min-width: 14px; + min-height: 14px; + border-radius: 2px; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 500; +} + .assistant-panel-form { display: flex; flex-direction: column; diff --git a/src/renderer/navigation/assistantPanelSpec.ts b/src/renderer/navigation/assistantPanelSpec.ts index 36501af..85beb10 100644 --- a/src/renderer/navigation/assistantPanelSpec.ts +++ b/src/renderer/navigation/assistantPanelSpec.ts @@ -30,14 +30,20 @@ const actionElementSchema = z.object({ payload: z.record(z.string(), z.unknown()).optional(), }); +const segmentSchema = z.object({ + label: z.string().min(1), + value: z.number(), +}); + const chartElementSchema = z.object({ type: z.literal('chart'), - chartType: z.enum(['bar', 'line', 'pie']), + chartType: z.enum(['bar', 'stacked-bar', 'line', 'area', 'pie', 'donut', 'heatmap']), title: z.string().min(1).optional(), series: z.array( z.object({ label: z.string().min(1), value: z.number(), + segments: z.array(segmentSchema).optional(), }), ).min(1), }); diff --git a/tests/engine/a2ui/catalog.test.ts b/tests/engine/a2ui/catalog.test.ts index d686152..3922779 100644 --- a/tests/engine/a2ui/catalog.test.ts +++ b/tests/engine/a2ui/catalog.test.ts @@ -43,7 +43,7 @@ describe('A2UI catalog', () => { const entry = getCatalogEntry('chart'); expect(entry).toEqual({ type: 'chart', - description: 'Bar, line, or pie chart visualization', + description: 'Bar, stacked-bar, line, area, pie, donut, or heatmap chart visualization', custom: true, }); }); @@ -61,7 +61,7 @@ describe('A2UI catalog', () => { const description = buildCatalogDescription(); expect(description).toContain('Supported UI component types:'); expect(description).toContain('text: Text block with Markdown support'); - expect(description).toContain('chart: Bar, line, or pie chart visualization (custom)'); + expect(description).toContain('chart: Bar, stacked-bar, line, area, pie, donut, or heatmap chart visualization (custom)'); expect(description).toContain('table: Data table with columns and rows (custom)'); }); diff --git a/tests/renderer/a2ui/A2UIChart.test.tsx b/tests/renderer/a2ui/A2UIChart.test.tsx index 5e4b30b..d2e1bcb 100644 --- a/tests/renderer/a2ui/A2UIChart.test.tsx +++ b/tests/renderer/a2ui/A2UIChart.test.tsx @@ -212,4 +212,627 @@ describe('A2UIChart', () => { expect(container.querySelector('.assistant-panel-chart-legend')).toBeNull(); }); }); + + describe('line chart', () => { + const lineSeries = [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 25 }, + { label: 'Mar', value: 15 }, + { label: 'Apr', value: 30 }, + ]; + + it('renders an SVG element for line charts', () => { + const comp = makeChartComponent( + { properties: { chartType: 'line', title: 'Monthly Posts' } }, + lineSeries, + ); + const { container } = render( + , + ); + const svg = container.querySelector('.assistant-panel-chart-line-svg'); + expect(svg).not.toBeNull(); + expect(svg!.tagName.toLowerCase()).toBe('svg'); + }); + + it('does not render bar chart body for line charts', () => { + const comp = makeChartComponent( + { properties: { chartType: 'line' } }, + lineSeries, + ); + const { container } = render( + , + ); + expect(container.querySelector('.assistant-panel-chart-body')).toBeNull(); + }); + + it('renders a polyline connecting data points', () => { + const comp = makeChartComponent( + { properties: { chartType: 'line' } }, + lineSeries, + ); + const { container } = render( + , + ); + const polyline = container.querySelector('polyline'); + expect(polyline).not.toBeNull(); + const points = polyline!.getAttribute('points')!; + // Should have 4 coordinate pairs + const pairs = points.trim().split(/\s+/); + expect(pairs).toHaveLength(4); + }); + + it('renders circle dots at each data point', () => { + const comp = makeChartComponent( + { properties: { chartType: 'line' } }, + lineSeries, + ); + const { container } = render( + , + ); + const dots = container.querySelectorAll('.assistant-panel-chart-line-dot'); + expect(dots).toHaveLength(4); + }); + + it('renders x-axis labels for each data point', () => { + const comp = makeChartComponent( + { properties: { chartType: 'line' } }, + lineSeries, + ); + render(); + expect(screen.getByText('Jan')).toBeInTheDocument(); + expect(screen.getByText('Feb')).toBeInTheDocument(); + expect(screen.getByText('Mar')).toBeInTheDocument(); + expect(screen.getByText('Apr')).toBeInTheDocument(); + }); + + it('renders y-axis value labels', () => { + const comp = makeChartComponent( + { properties: { chartType: 'line' } }, + lineSeries, + ); + const { container } = render( + , + ); + const yLabels = container.querySelectorAll('.assistant-panel-chart-line-y-label'); + expect(yLabels.length).toBeGreaterThanOrEqual(2); + }); + + it('renders chart title for line charts', () => { + const comp = makeChartComponent( + { properties: { chartType: 'line', title: 'Trend' } }, + lineSeries, + ); + render(); + expect(screen.getByText('Trend')).toBeInTheDocument(); + }); + + it('renders chart type label', () => { + const comp = makeChartComponent( + { properties: { chartType: 'line' } }, + lineSeries, + ); + render(); + expect(screen.getByText('line')).toBeInTheDocument(); + }); + + it('handles single data point', () => { + const comp = makeChartComponent( + { properties: { chartType: 'line' } }, + [{ label: 'Only', value: 42 }], + ); + const { container } = render( + , + ); + const dots = container.querySelectorAll('.assistant-panel-chart-line-dot'); + expect(dots).toHaveLength(1); + }); + + it('handles empty series for line chart', () => { + const comp = makeChartComponent( + { properties: { chartType: 'line' } }, + [], + ); + const { container } = render( + , + ); + expect(container.querySelector('.assistant-panel-chart-line-svg')).toBeNull(); + }); + + it('renders horizontal grid lines', () => { + const comp = makeChartComponent( + { properties: { chartType: 'line' } }, + lineSeries, + ); + const { container } = render( + , + ); + const gridLines = container.querySelectorAll('.assistant-panel-chart-line-grid'); + expect(gridLines.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('pie chart', () => { + const pieSeries = [ + { label: 'Published', value: 60 }, + { label: 'Draft', value: 25 }, + { label: 'Archived', value: 15 }, + ]; + + it('renders an SVG element for pie charts', () => { + const comp = makeChartComponent( + { properties: { chartType: 'pie', title: 'Post Status' } }, + pieSeries, + ); + const { container } = render( + , + ); + const svg = container.querySelector('.assistant-panel-chart-pie-svg'); + expect(svg).not.toBeNull(); + expect(svg!.tagName.toLowerCase()).toBe('svg'); + }); + + it('does not render bar chart body for pie charts', () => { + const comp = makeChartComponent( + { properties: { chartType: 'pie' } }, + pieSeries, + ); + const { container } = render( + , + ); + expect(container.querySelector('.assistant-panel-chart-body')).toBeNull(); + }); + + it('renders a path slice for each data point', () => { + const comp = makeChartComponent( + { properties: { chartType: 'pie' } }, + pieSeries, + ); + const { container } = render( + , + ); + const slices = container.querySelectorAll('.assistant-panel-chart-pie-slice'); + expect(slices).toHaveLength(3); + }); + + it('renders a legend with all labels and values', () => { + const comp = makeChartComponent( + { properties: { chartType: 'pie' } }, + pieSeries, + ); + const { container } = render( + , + ); + const legend = container.querySelector('.assistant-panel-chart-legend'); + expect(legend).not.toBeNull(); + expect(screen.getByText('Published')).toBeInTheDocument(); + expect(screen.getByText('Draft')).toBeInTheDocument(); + expect(screen.getByText('Archived')).toBeInTheDocument(); + }); + + it('renders chart title for pie charts', () => { + const comp = makeChartComponent( + { properties: { chartType: 'pie', title: 'Distribution' } }, + pieSeries, + ); + render(); + expect(screen.getByText('Distribution')).toBeInTheDocument(); + }); + + it('renders chart type label', () => { + const comp = makeChartComponent( + { properties: { chartType: 'pie' } }, + pieSeries, + ); + render(); + expect(screen.getByText('pie')).toBeInTheDocument(); + }); + + it('handles single slice', () => { + const comp = makeChartComponent( + { properties: { chartType: 'pie' } }, + [{ label: 'All', value: 100 }], + ); + const { container } = render( + , + ); + const slices = container.querySelectorAll('.assistant-panel-chart-pie-slice'); + expect(slices).toHaveLength(1); + }); + + it('handles empty series for pie chart', () => { + const comp = makeChartComponent( + { properties: { chartType: 'pie' } }, + [], + ); + const { container } = render( + , + ); + expect(container.querySelector('.assistant-panel-chart-pie-svg')).toBeNull(); + }); + + it('assigns different colors to each slice', () => { + const comp = makeChartComponent( + { properties: { chartType: 'pie' } }, + pieSeries, + ); + const { container } = render( + , + ); + const slices = container.querySelectorAll('.assistant-panel-chart-pie-slice'); + const fills = Array.from(slices).map((s) => (s as SVGElement).getAttribute('fill')); + // All fills should be different + const unique = new Set(fills); + expect(unique.size).toBe(3); + }); + }); + + describe('area chart', () => { + const areaSeries = [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 25 }, + { label: 'Mar', value: 15 }, + { label: 'Apr', value: 30 }, + ]; + + it('renders an SVG element for area charts', () => { + const comp = makeChartComponent( + { properties: { chartType: 'area', title: 'Cumulative Posts' } }, + areaSeries, + ); + const { container } = render( + , + ); + const svg = container.querySelector('.assistant-panel-chart-line-svg'); + expect(svg).not.toBeNull(); + }); + + it('does not render bar chart body for area charts', () => { + const comp = makeChartComponent( + { properties: { chartType: 'area' } }, + areaSeries, + ); + const { container } = render( + , + ); + expect(container.querySelector('.assistant-panel-chart-body')).toBeNull(); + }); + + it('renders a filled polygon area beneath the line', () => { + const comp = makeChartComponent( + { properties: { chartType: 'area' } }, + areaSeries, + ); + const { container } = render( + , + ); + const area = container.querySelector('.assistant-panel-chart-area-fill'); + expect(area).not.toBeNull(); + expect(area!.tagName.toLowerCase()).toBe('polygon'); + }); + + it('renders dots at each data point', () => { + const comp = makeChartComponent( + { properties: { chartType: 'area' } }, + areaSeries, + ); + const { container } = render( + , + ); + const dots = container.querySelectorAll('.assistant-panel-chart-line-dot'); + expect(dots).toHaveLength(4); + }); + + it('renders a polyline on top of the area', () => { + const comp = makeChartComponent( + { properties: { chartType: 'area' } }, + areaSeries, + ); + const { container } = render( + , + ); + const polyline = container.querySelector('polyline'); + expect(polyline).not.toBeNull(); + }); + + it('renders x-axis labels', () => { + const comp = makeChartComponent( + { properties: { chartType: 'area' } }, + areaSeries, + ); + render(); + expect(screen.getByText('Jan')).toBeInTheDocument(); + expect(screen.getByText('Apr')).toBeInTheDocument(); + }); + + it('handles empty series for area chart', () => { + const comp = makeChartComponent( + { properties: { chartType: 'area' } }, + [], + ); + const { container } = render( + , + ); + expect(container.querySelector('.assistant-panel-chart-line-svg')).toBeNull(); + }); + }); + + describe('donut chart', () => { + const donutSeries = [ + { label: 'Published', value: 60 }, + { label: 'Draft', value: 25 }, + { label: 'Archived', value: 15 }, + ]; + + it('renders an SVG element for donut charts', () => { + const comp = makeChartComponent( + { properties: { chartType: 'donut', title: 'Post Status' } }, + donutSeries, + ); + const { container } = render( + , + ); + const svg = container.querySelector('.assistant-panel-chart-pie-svg'); + expect(svg).not.toBeNull(); + }); + + it('does not render bar chart body for donut charts', () => { + const comp = makeChartComponent( + { properties: { chartType: 'donut' } }, + donutSeries, + ); + const { container } = render( + , + ); + expect(container.querySelector('.assistant-panel-chart-body')).toBeNull(); + }); + + it('renders arc path slices (not filled wedges)', () => { + const comp = makeChartComponent( + { properties: { chartType: 'donut' } }, + donutSeries, + ); + const { container } = render( + , + ); + const slices = container.querySelectorAll('.assistant-panel-chart-pie-slice'); + expect(slices).toHaveLength(3); + }); + + it('renders a center hole circle', () => { + const comp = makeChartComponent( + { properties: { chartType: 'donut' } }, + donutSeries, + ); + const { container } = render( + , + ); + const hole = container.querySelector('.assistant-panel-chart-donut-hole'); + expect(hole).not.toBeNull(); + }); + + it('renders center total text', () => { + const comp = makeChartComponent( + { properties: { chartType: 'donut' } }, + donutSeries, + ); + const { container } = render( + , + ); + const centerText = container.querySelector('.assistant-panel-chart-donut-total'); + expect(centerText).not.toBeNull(); + expect(centerText!.textContent).toBe('100'); + }); + + it('renders a legend with all labels', () => { + const comp = makeChartComponent( + { properties: { chartType: 'donut' } }, + donutSeries, + ); + const { container } = render( + , + ); + const legend = container.querySelector('.assistant-panel-chart-legend'); + expect(legend).not.toBeNull(); + expect(screen.getByText('Published')).toBeInTheDocument(); + expect(screen.getByText('Draft')).toBeInTheDocument(); + }); + + it('handles single slice donut', () => { + const comp = makeChartComponent( + { properties: { chartType: 'donut' } }, + [{ label: 'All', value: 100 }], + ); + const { container } = render( + , + ); + const slices = container.querySelectorAll('.assistant-panel-chart-pie-slice'); + expect(slices).toHaveLength(1); + expect(container.querySelector('.assistant-panel-chart-donut-hole')).not.toBeNull(); + }); + + it('handles empty series for donut chart', () => { + const comp = makeChartComponent( + { properties: { chartType: 'donut' } }, + [], + ); + const { container } = render( + , + ); + expect(container.querySelector('.assistant-panel-chart-pie-svg')).toBeNull(); + }); + }); + + describe('heatmap chart', () => { + const heatmapSeries = [ + { + label: 'Mon', + value: 0, + segments: [ + { label: 'W1', value: 3 }, + { label: 'W2', value: 0 }, + { label: 'W3', value: 5 }, + ], + }, + { + label: 'Tue', + value: 0, + segments: [ + { label: 'W1', value: 1 }, + { label: 'W2', value: 4 }, + { label: 'W3', value: 2 }, + ], + }, + ]; + + it('renders a grid container for heatmap charts', () => { + const comp = makeChartComponent( + { properties: { chartType: 'heatmap', title: 'Activity' } }, + heatmapSeries, + ); + const { container } = render( + , + ); + const grid = container.querySelector('.assistant-panel-chart-heatmap'); + expect(grid).not.toBeNull(); + }); + + it('does not render bar chart body for heatmap', () => { + const comp = makeChartComponent( + { properties: { chartType: 'heatmap' } }, + heatmapSeries, + ); + const { container } = render( + , + ); + expect(container.querySelector('.assistant-panel-chart-body')).toBeNull(); + }); + + it('renders cells for each data point', () => { + const comp = makeChartComponent( + { properties: { chartType: 'heatmap' } }, + heatmapSeries, + ); + const { container } = render( + , + ); + // 2 rows x 3 columns = 6 cells + const cells = container.querySelectorAll('.assistant-panel-chart-heatmap-cell'); + expect(cells).toHaveLength(6); + }); + + it('renders row labels', () => { + const comp = makeChartComponent( + { properties: { chartType: 'heatmap' } }, + heatmapSeries, + ); + render(); + expect(screen.getByText('Mon')).toBeInTheDocument(); + expect(screen.getByText('Tue')).toBeInTheDocument(); + }); + + it('renders column header labels', () => { + const comp = makeChartComponent( + { properties: { chartType: 'heatmap' } }, + heatmapSeries, + ); + render(); + expect(screen.getByText('W1')).toBeInTheDocument(); + expect(screen.getByText('W2')).toBeInTheDocument(); + expect(screen.getByText('W3')).toBeInTheDocument(); + }); + + it('applies ins-to-del gradient background based on value', () => { + const comp = makeChartComponent( + { properties: { chartType: 'heatmap' } }, + heatmapSeries, + ); + const { container } = render( + , + ); + const cells = container.querySelectorAll('.assistant-panel-chart-heatmap-cell'); + // Cell with value 0 should have transparent background + const zeroCell = Array.from(cells).find((c) => c.getAttribute('title') === '0'); + expect(zeroCell).toBeDefined(); + expect((zeroCell as HTMLElement).style.background).toBe('transparent'); + // Cell with max value (5) should have an rgba/rgb background (del color at full opacity) + const maxCell = Array.from(cells).find((c) => c.getAttribute('title') === '5'); + expect(maxCell).toBeDefined(); + expect((maxCell as HTMLElement).style.background).toMatch(/^rgba?\(/); + // Mid-value cell should also have an rgba/rgb background + const midCell = Array.from(cells).find((c) => c.getAttribute('title') === '3'); + expect(midCell).toBeDefined(); + expect((midCell as HTMLElement).style.background).toMatch(/^rgba?\(/); + }); + + it('sets contrasting text color on cells', () => { + const comp = makeChartComponent( + { properties: { chartType: 'heatmap' } }, + heatmapSeries, + ); + const { container } = render( + , + ); + const cells = container.querySelectorAll('.assistant-panel-chart-heatmap-cell'); + // Non-zero cells should have explicit black or white text color + const maxCell = Array.from(cells).find((c) => c.getAttribute('title') === '5'); + expect(maxCell).toBeDefined(); + const maxColor = (maxCell as HTMLElement).style.color; + expect(maxColor).toMatch(/#(000|fff)|rgb\((0, 0, 0|255, 255, 255)\)/); + // Zero cell should have inherit + const zeroCell = Array.from(cells).find((c) => c.getAttribute('title') === '0'); + expect(zeroCell).toBeDefined(); + expect((zeroCell as HTMLElement).style.color).toBe('inherit'); + }); + + it('displays value text inside non-zero cells', () => { + const comp = makeChartComponent( + { properties: { chartType: 'heatmap' } }, + heatmapSeries, + ); + const { container } = render( + , + ); + const cells = container.querySelectorAll('.assistant-panel-chart-heatmap-cell'); + // Non-zero cell should display value as text + const cell3 = Array.from(cells).find((c) => c.getAttribute('title') === '3'); + expect(cell3).toBeDefined(); + expect(cell3!.textContent).toBe('3'); + // Zero cell should be empty + const cell0 = Array.from(cells).find((c) => c.getAttribute('title') === '0'); + expect(cell0).toBeDefined(); + expect(cell0!.textContent).toBe(''); + }); + + it('renders chart title for heatmap', () => { + const comp = makeChartComponent( + { properties: { chartType: 'heatmap', title: 'Posting Activity' } }, + heatmapSeries, + ); + render(); + expect(screen.getByText('Posting Activity')).toBeInTheDocument(); + }); + + it('handles empty series for heatmap', () => { + const comp = makeChartComponent( + { properties: { chartType: 'heatmap' } }, + [], + ); + const { container } = render( + , + ); + expect(container.querySelector('.assistant-panel-chart-heatmap')).toBeNull(); + }); + + it('handles series without segments gracefully', () => { + const comp = makeChartComponent( + { properties: { chartType: 'heatmap' } }, + [{ label: 'Mon', value: 5 }], + ); + const { container } = render( + , + ); + // No segments → no cells, no grid rendered + expect(container.querySelector('.assistant-panel-chart-heatmap')).toBeNull(); + }); + }); });