feat: better heatmap styling
This commit is contained in:
@@ -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<string>();
|
||||
@@ -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 (
|
||||
<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 }) => {
|
||||
const chartType = String(component.properties.chartType ?? 'bar');
|
||||
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 isLine = chartType === 'line';
|
||||
const isArea = chartType === 'area';
|
||||
const isPie = chartType === 'pie';
|
||||
const isDonut = chartType === 'donut';
|
||||
const isHeatmap = chartType === 'heatmap';
|
||||
|
||||
return (
|
||||
<div className="assistant-panel-chart">
|
||||
{title && <p className="assistant-panel-chart-title">{title}</p>}
|
||||
<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) => {
|
||||
const totalValue = isStacked && entry.segments
|
||||
? entry.segments.reduce((sum, s) => sum + s.value, 0)
|
||||
@@ -109,6 +483,7 @@ export const A2UIChart: React.FC<A2UIComponentProps> = ({ component }) => {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isStacked && segmentLabels.length > 0 && (
|
||||
<div className="assistant-panel-chart-legend">
|
||||
{segmentLabels.map((label, i) => (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user