feat: better heatmap styling
This commit is contained in:
@@ -264,7 +264,7 @@ The assistant works entirely with your local blog content. It does not have acce
|
|||||||
|
|
||||||
### Charts
|
### 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"
|
**Try asking:** "Show me a chart of posts published per month this year"
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const CATALOG_ENTRIES: A2UICatalogEntry[] = [
|
|||||||
{ type: 'text', description: 'Text block with Markdown support' },
|
{ type: 'text', description: 'Text block with Markdown support' },
|
||||||
{ type: 'button', description: 'Clickable button that dispatches an action' },
|
{ type: 'button', description: 'Clickable button that dispatches an action' },
|
||||||
{ type: 'card', description: 'Card with title, subtitle, body, and action buttons' },
|
{ 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: 'table', description: 'Data table with columns and rows', custom: true },
|
||||||
{ type: 'textField', description: 'Text input field with data binding' },
|
{ type: 'textField', description: 'Text input field with data binding' },
|
||||||
{ type: 'checkBox', description: 'Checkbox input with data binding' },
|
{ type: 'checkBox', description: 'Checkbox input with data binding' },
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function createSurfaceMessages(
|
|||||||
// ---- Tool argument interfaces ----
|
// ---- Tool argument interfaces ----
|
||||||
|
|
||||||
export interface RenderChartArgs {
|
export interface RenderChartArgs {
|
||||||
chartType: 'bar' | 'stacked-bar' | 'line' | 'pie';
|
chartType: 'bar' | 'stacked-bar' | 'line' | 'area' | 'pie' | 'donut' | 'heatmap';
|
||||||
title?: string;
|
title?: string;
|
||||||
series: Array<{ label: string; value: number; segments?: Array<{ label: string; value: number }> }>;
|
series: Array<{ label: string; value: number; segments?: Array<{ label: string; value: number }> }>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ Available Data Tools:
|
|||||||
- get_media_posts: Get posts that use a specific media file.
|
- get_media_posts: Get posts that use a specific media file.
|
||||||
|
|
||||||
Available UI Render Tools (use these to show rich interactive elements):
|
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_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_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.
|
- render_card: Show an information card with title, body, and action buttons.
|
||||||
|
|||||||
@@ -924,31 +924,31 @@ export class OpenCodeManager {
|
|||||||
input_schema: {
|
input_schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
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' },
|
title: { type: 'string', description: 'Optional chart title' },
|
||||||
series: {
|
series: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
label: { type: 'string', description: 'Data point label' },
|
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)' },
|
value: { type: 'number', description: 'Data point value (total for stacked bars, ignored for heatmaps)' },
|
||||||
segments: {
|
segments: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
label: { type: 'string', description: 'Segment category label' },
|
label: { type: 'string', description: 'Segment/column label (e.g., month name for heatmaps)' },
|
||||||
value: { type: 'number', description: 'Segment value' },
|
value: { type: 'number', description: 'Segment value (color intensity for heatmaps)' },
|
||||||
},
|
},
|
||||||
required: ['label', 'value'],
|
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'],
|
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'],
|
required: ['chartType', 'series'],
|
||||||
@@ -1070,12 +1070,24 @@ export class OpenCodeManager {
|
|||||||
value: { type: 'string', description: 'Display value (for type metric)' },
|
value: { type: 'string', description: 'Display value (for type metric)' },
|
||||||
title: { type: 'string', description: 'Title (for type list, chart, or table)' },
|
title: { type: 'string', description: 'Title (for type list, chart, or table)' },
|
||||||
items: { type: 'array', items: { type: 'string' }, description: 'Items (for type list)' },
|
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: {
|
series: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
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'],
|
required: ['label', 'value'],
|
||||||
},
|
},
|
||||||
description: 'Data series (for type chart)',
|
description: 'Data series (for type chart)',
|
||||||
|
|||||||
@@ -33,6 +33,76 @@ function getSegmentColor(index: number): string {
|
|||||||
return SEGMENT_COLORS[index % SEGMENT_COLORS.length];
|
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. */
|
/** Collect unique segment labels across all series entries, preserving order. */
|
||||||
function collectSegmentLabels(series: SeriesEntry[]): string[] {
|
function collectSegmentLabels(series: SeriesEntry[]): string[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -50,6 +120,297 @@ function collectSegmentLabels(series: SeriesEntry[]): string[] {
|
|||||||
return labels;
|
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 (
|
||||||
|
<svg
|
||||||
|
className="assistant-panel-chart-line-svg"
|
||||||
|
viewBox={`0 0 300 ${LINE_CHART_HEIGHT}`}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
>
|
||||||
|
{/* Horizontal grid lines + Y-axis labels */}
|
||||||
|
{yTicks.map((tick) => {
|
||||||
|
const y = pad.top + (yMax > 0 ? (1 - tick / yMax) * plotHeight : plotHeight);
|
||||||
|
return (
|
||||||
|
<g key={`grid-${tick}`}>
|
||||||
|
<line
|
||||||
|
className="assistant-panel-chart-line-grid"
|
||||||
|
x1={pad.left}
|
||||||
|
y1={y}
|
||||||
|
x2={pad.left + plotWidth}
|
||||||
|
y2={y}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
className="assistant-panel-chart-line-y-label"
|
||||||
|
x={pad.left - 4}
|
||||||
|
y={y}
|
||||||
|
textAnchor="end"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
>
|
||||||
|
{tick}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Area fill (when showArea is true) */}
|
||||||
|
{showArea && (
|
||||||
|
<polygon
|
||||||
|
className="assistant-panel-chart-area-fill"
|
||||||
|
points={areaPoints}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Line */}
|
||||||
|
<polyline
|
||||||
|
className="assistant-panel-chart-line-path"
|
||||||
|
points={polylinePoints}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dots */}
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<circle
|
||||||
|
key={`${component.id}-dot-${i}`}
|
||||||
|
className="assistant-panel-chart-line-dot"
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r={3}
|
||||||
|
>
|
||||||
|
<title>{`${p.entry.label}: ${p.entry.value}`}</title>
|
||||||
|
</circle>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* X-axis labels */}
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<text
|
||||||
|
key={`${component.id}-xlabel-${i}`}
|
||||||
|
className="assistant-panel-chart-line-x-label"
|
||||||
|
x={p.x}
|
||||||
|
y={LINE_CHART_HEIGHT - 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
>
|
||||||
|
{p.entry.label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<circle
|
||||||
|
key={`${component.id}-slice-${i}`}
|
||||||
|
className="assistant-panel-chart-pie-slice"
|
||||||
|
cx={PIE_CHART_CENTER}
|
||||||
|
cy={PIE_CHART_CENTER}
|
||||||
|
r={PIE_CHART_RADIUS}
|
||||||
|
fill={getSegmentColor(i)}
|
||||||
|
>
|
||||||
|
<title>{`${entry.label}: ${entry.value}`}</title>
|
||||||
|
</circle>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={`${component.id}-slice-${i}`}
|
||||||
|
className="assistant-panel-chart-pie-slice"
|
||||||
|
d={describePieSlice(PIE_CHART_CENTER, PIE_CHART_CENTER, PIE_CHART_RADIUS, startAngle, endAngle)}
|
||||||
|
fill={getSegmentColor(i)}
|
||||||
|
>
|
||||||
|
<title>{`${entry.label}: ${entry.value}`}</title>
|
||||||
|
</path>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const donutInnerRadius = PIE_CHART_RADIUS * 0.58;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="assistant-panel-chart-pie-svg"
|
||||||
|
viewBox={`0 0 ${PIE_CHART_SIZE} ${PIE_CHART_SIZE}`}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
>
|
||||||
|
{slices}
|
||||||
|
{isDonut && (
|
||||||
|
<>
|
||||||
|
<circle
|
||||||
|
className="assistant-panel-chart-donut-hole"
|
||||||
|
cx={PIE_CHART_CENTER}
|
||||||
|
cy={PIE_CHART_CENTER}
|
||||||
|
r={donutInnerRadius}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
className="assistant-panel-chart-donut-total"
|
||||||
|
x={PIE_CHART_CENTER}
|
||||||
|
y={PIE_CHART_CENTER}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
>
|
||||||
|
{total}
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
<div className="assistant-panel-chart-legend">
|
||||||
|
{series.map((entry, i) => (
|
||||||
|
<span key={entry.label} className="assistant-panel-chart-legend-item">
|
||||||
|
<span
|
||||||
|
className="assistant-panel-chart-legend-swatch"
|
||||||
|
style={{ backgroundColor: getSegmentColor(i) }}
|
||||||
|
/>
|
||||||
|
{entry.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="assistant-panel-chart-heatmap"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `auto repeat(${columnCount}, 1fr)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header row: empty corner + column labels */}
|
||||||
|
<span className="assistant-panel-chart-heatmap-corner" />
|
||||||
|
{columnLabels.map((col) => (
|
||||||
|
<span key={`col-${col}`} className="assistant-panel-chart-heatmap-col-label">
|
||||||
|
{col}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Data rows */}
|
||||||
|
{rows.map((row) => {
|
||||||
|
const segMap = new Map<string, number>();
|
||||||
|
for (const seg of row.segments!) {
|
||||||
|
segMap.set(seg.label, seg.value);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<React.Fragment key={`${component.id}-row-${row.label}`}>
|
||||||
|
<span className="assistant-panel-chart-heatmap-row-label">{row.label}</span>
|
||||||
|
{columnLabels.map((col) => {
|
||||||
|
const val = segMap.get(col) ?? 0;
|
||||||
|
const alpha = globalMax > 0 ? val / globalMax : 0;
|
||||||
|
const { bg, fg } = heatmapCellColors(alpha, ins, del);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`${component.id}-cell-${row.label}-${col}`}
|
||||||
|
className="assistant-panel-chart-heatmap-cell"
|
||||||
|
style={{ background: bg, color: fg }}
|
||||||
|
title={String(val)}
|
||||||
|
>
|
||||||
|
{val > 0 ? val : ''}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const A2UIChart: React.FC<A2UIComponentProps> = ({ component }) => {
|
export const A2UIChart: React.FC<A2UIComponentProps> = ({ component }) => {
|
||||||
const chartType = String(component.properties.chartType ?? 'bar');
|
const chartType = String(component.properties.chartType ?? 'bar');
|
||||||
const title = component.properties.title as string | undefined;
|
const title = component.properties.title as string | undefined;
|
||||||
@@ -68,11 +429,24 @@ export const A2UIChart: React.FC<A2UIComponentProps> = ({ component }) => {
|
|||||||
|
|
||||||
const segmentLabels = isStacked ? collectSegmentLabels(series) : [];
|
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 (
|
return (
|
||||||
<div className="assistant-panel-chart">
|
<div className="assistant-panel-chart">
|
||||||
{title && <p className="assistant-panel-chart-title">{title}</p>}
|
{title && <p className="assistant-panel-chart-title">{title}</p>}
|
||||||
<div className="assistant-panel-chart-type">{chartType}</div>
|
<div className="assistant-panel-chart-type">{chartType}</div>
|
||||||
<div className="assistant-panel-chart-body">
|
{isPie || isDonut ? (
|
||||||
|
renderPieChart(component, series, isDonut)
|
||||||
|
) : isHeatmap ? (
|
||||||
|
renderHeatmap(component, series)
|
||||||
|
) : isLine || isArea ? (
|
||||||
|
renderLineChart(component, series, isArea)
|
||||||
|
) : (
|
||||||
|
<div className="assistant-panel-chart-body">
|
||||||
{series.map((entry, index) => {
|
{series.map((entry, index) => {
|
||||||
const totalValue = isStacked && entry.segments
|
const totalValue = isStacked && entry.segments
|
||||||
? entry.segments.reduce((sum, s) => sum + s.value, 0)
|
? entry.segments.reduce((sum, s) => sum + s.value, 0)
|
||||||
@@ -109,6 +483,7 @@ export const A2UIChart: React.FC<A2UIComponentProps> = ({ component }) => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{isStacked && segmentLabels.length > 0 && (
|
{isStacked && segmentLabels.length > 0 && (
|
||||||
<div className="assistant-panel-chart-legend">
|
<div className="assistant-panel-chart-legend">
|
||||||
{segmentLabels.map((label, i) => (
|
{segmentLabels.map((label, i) => (
|
||||||
|
|||||||
@@ -240,6 +240,115 @@
|
|||||||
flex-shrink: 0;
|
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 {
|
.assistant-panel-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -30,14 +30,20 @@ const actionElementSchema = z.object({
|
|||||||
payload: z.record(z.string(), z.unknown()).optional(),
|
payload: z.record(z.string(), z.unknown()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const segmentSchema = z.object({
|
||||||
|
label: z.string().min(1),
|
||||||
|
value: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
const chartElementSchema = z.object({
|
const chartElementSchema = z.object({
|
||||||
type: z.literal('chart'),
|
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(),
|
title: z.string().min(1).optional(),
|
||||||
series: z.array(
|
series: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
label: z.string().min(1),
|
label: z.string().min(1),
|
||||||
value: z.number(),
|
value: z.number(),
|
||||||
|
segments: z.array(segmentSchema).optional(),
|
||||||
}),
|
}),
|
||||||
).min(1),
|
).min(1),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ describe('A2UI catalog', () => {
|
|||||||
const entry = getCatalogEntry('chart');
|
const entry = getCatalogEntry('chart');
|
||||||
expect(entry).toEqual({
|
expect(entry).toEqual({
|
||||||
type: 'chart',
|
type: 'chart',
|
||||||
description: 'Bar, line, or pie chart visualization',
|
description: 'Bar, stacked-bar, line, area, pie, donut, or heatmap chart visualization',
|
||||||
custom: true,
|
custom: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -61,7 +61,7 @@ describe('A2UI catalog', () => {
|
|||||||
const description = buildCatalogDescription();
|
const description = buildCatalogDescription();
|
||||||
expect(description).toContain('Supported UI component types:');
|
expect(description).toContain('Supported UI component types:');
|
||||||
expect(description).toContain('text: Text block with Markdown support');
|
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)');
|
expect(description).toContain('table: Data table with columns and rows (custom)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -212,4 +212,627 @@ describe('A2UIChart', () => {
|
|||||||
expect(container.querySelector('.assistant-panel-chart-legend')).toBeNull();
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('Trend')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders chart type label', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'line' } },
|
||||||
|
lineSeries,
|
||||||
|
);
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('line')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles single data point', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'line' } },
|
||||||
|
[{ label: 'Only', value: 42 }],
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-line-svg')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders horizontal grid lines', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'line' } },
|
||||||
|
lineSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('Distribution')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders chart type label', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'pie' } },
|
||||||
|
pieSeries,
|
||||||
|
);
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('pie')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles single slice', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'pie' } },
|
||||||
|
[{ label: 'All', value: 100 }],
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const polyline = container.querySelector('polyline');
|
||||||
|
expect(polyline).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders x-axis labels', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'area' } },
|
||||||
|
areaSeries,
|
||||||
|
);
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
// 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(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('Mon')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tue')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders column header labels', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'heatmap' } },
|
||||||
|
heatmapSeries,
|
||||||
|
);
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('Posting Activity')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty series for heatmap', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'heatmap' } },
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
// No segments → no cells, no grid rendered
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-heatmap')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user