feat: migrate Treemap and PunchcardHeatmap to Recharts
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 31s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped

Replace custom SVG chart implementations with Recharts components:
- Treemap: uses Recharts Treemap with custom content renderer for
  SLA-colored cells, labels, and click navigation
- PunchcardHeatmap: uses Recharts ScatterChart with custom Rectangle
  shape for weekday x hour heatmap grid cells

Both use ResponsiveContainer (no more explicit width/height props) and
rechartsTheme from the design system for consistent tooltip styling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-30 15:20:29 +02:00
parent dd91a4989b
commit 41397ae067
6 changed files with 587 additions and 204 deletions

View File

@@ -454,17 +454,15 @@ export default function DashboardL1() {
<Card title="Application Volume vs SLA Compliance">
<Treemap
items={treemapItems}
width={600}
height={300}
onItemClick={(id) => navigate(`/dashboard/${id}`)}
/>
</Card>
<div className={styles.punchcardStack}>
<Card title="Transactions (7-day pattern)">
<PunchcardHeatmap cells={punchcardData ?? []} mode="transactions" width={400} height={140} />
<PunchcardHeatmap cells={punchcardData ?? []} mode="transactions" />
</Card>
<Card title="Errors (7-day pattern)">
<PunchcardHeatmap cells={punchcardData ?? []} mode="errors" width={400} height={140} />
<PunchcardHeatmap cells={punchcardData ?? []} mode="errors" />
</Card>
</div>
</div>

View File

@@ -433,17 +433,15 @@ export default function DashboardL2() {
<Card title="Route Volume vs SLA Compliance">
<Treemap
items={treemapItems}
width={600}
height={300}
onItemClick={(id) => navigate(`/dashboard/${appId}/${id}`)}
/>
</Card>
<div className={styles.punchcardStack}>
<Card title="Transactions (7-day pattern)">
<PunchcardHeatmap cells={punchcardData ?? []} mode="transactions" width={400} height={140} />
<PunchcardHeatmap cells={punchcardData ?? []} mode="transactions" />
</Card>
<Card title="Errors (7-day pattern)">
<PunchcardHeatmap cells={punchcardData ?? []} mode="errors" width={400} height={140} />
<PunchcardHeatmap cells={punchcardData ?? []} mode="errors" />
</Card>
</div>
</div>

View File

@@ -1,4 +1,9 @@
import { useMemo } from 'react';
import {
ScatterChart, Scatter, XAxis, YAxis, Tooltip,
ResponsiveContainer, Rectangle,
} from 'recharts';
import { rechartsTheme } from '@cameleer/design-system';
export interface PunchcardCell {
weekday: number;
@@ -10,116 +15,121 @@ export interface PunchcardCell {
interface PunchcardHeatmapProps {
cells: PunchcardCell[];
mode: 'transactions' | 'errors';
width: number;
height: number;
}
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const LEFT_MARGIN = 28;
const TOP_MARGIN = 18;
const BOTTOM_MARGIN = 4;
const RIGHT_MARGIN = 4;
function transactionColor(ratio: number): string {
if (ratio === 0) return 'hsl(220, 15%, 95%)';
if (ratio === 0) return 'var(--bg-inset)';
const lightness = 90 - ratio * 55;
return `hsl(220, 60%, ${Math.round(lightness)}%)`;
}
function errorColor(ratio: number): string {
if (ratio === 0) return 'hsl(0, 10%, 95%)';
if (ratio === 0) return 'var(--bg-inset)';
const lightness = 90 - ratio * 55;
return `hsl(0, 65%, ${Math.round(lightness)}%)`;
}
export function PunchcardHeatmap({ cells, mode, width, height }: PunchcardHeatmapProps) {
const grid = useMemo(() => {
const map = new Map<string, PunchcardCell>();
for (const c of cells) {
map.set(`${c.weekday}-${c.hour}`, c);
}
interface HeatmapPoint {
weekday: number;
hour: number;
value: number;
fill: string;
}
const values: number[] = [];
for (const c of cells) {
values.push(mode === 'errors' ? c.failedCount : c.totalCount);
}
function HeatmapCell(props: Record<string, unknown>) {
const { cx, cy, payload } = props as {
cx: number; cy: number; payload: HeatmapPoint;
};
if (!payload) return null;
// Cell size: chart area / grid divisions. Approximate from scatter positioning.
const cellW = 32;
const cellH = 10;
return (
<Rectangle
x={cx - cellW / 2}
y={cy - cellH / 2}
width={cellW}
height={cellH}
fill={payload.fill}
radius={2}
/>
);
}
function formatDay(value: number): string {
return DAYS[value] ?? '';
}
function formatHour(value: number): string {
return String(value).padStart(2, '0');
}
export function PunchcardHeatmap({ cells, mode }: PunchcardHeatmapProps) {
const data: HeatmapPoint[] = useMemo(() => {
const values = cells.map(c => mode === 'errors' ? c.failedCount : c.totalCount);
const maxVal = Math.max(...values, 1);
const gridWidth = width - LEFT_MARGIN - RIGHT_MARGIN;
const gridHeight = height - TOP_MARGIN - BOTTOM_MARGIN;
const cellW = gridWidth / 7;
const cellH = gridHeight / 24;
const rects: { x: number; y: number; w: number; h: number; fill: string; value: number; day: string; hour: number }[] = [];
// Build full 7x24 grid
const points: HeatmapPoint[] = [];
const cellMap = new Map<string, PunchcardCell>();
for (const c of cells) cellMap.set(`${c.weekday}-${c.hour}`, c);
for (let d = 0; d < 7; d++) {
for (let h = 0; h < 24; h++) {
const cell = map.get(`${d}-${h}`);
const cell = cellMap.get(`${d}-${h}`);
const val = cell ? (mode === 'errors' ? cell.failedCount : cell.totalCount) : 0;
const ratio = maxVal > 0 ? val / maxVal : 0;
const fill = mode === 'errors' ? errorColor(ratio) : transactionColor(ratio);
rects.push({
x: LEFT_MARGIN + d * cellW,
y: TOP_MARGIN + h * cellH,
w: cellW,
h: cellH,
fill,
value: val,
day: DAYS[d],
points.push({
weekday: d,
hour: h,
value: val,
fill: mode === 'errors' ? errorColor(ratio) : transactionColor(ratio),
});
}
}
return { rects, cellW, cellH };
}, [cells, mode, width, height]);
return points;
}, [cells, mode]);
return (
<svg viewBox={`0 0 ${width} ${height}`} style={{ width: '100%', height: 'auto', display: 'block' }}>
{/* Day labels (top) */}
{DAYS.map((day, i) => (
<text
key={day}
x={LEFT_MARGIN + i * grid.cellW + grid.cellW / 2}
y={12}
textAnchor="middle"
fill="var(--text-muted)"
fontSize={9}
fontFamily="var(--font-mono)"
>
{day}
</text>
))}
{/* Hour labels (left, every 4 hours) */}
{[0, 4, 8, 12, 16, 20].map((h) => (
<text
key={h}
x={LEFT_MARGIN - 4}
y={TOP_MARGIN + h * grid.cellH + grid.cellH / 2 + 3}
textAnchor="end"
fill="var(--text-muted)"
fontSize={8}
fontFamily="var(--font-mono)"
>
{String(h).padStart(2, '0')}
</text>
))}
{/* Cells */}
{grid.rects.map((r) => (
<rect
key={`${r.day}-${r.hour}`}
x={r.x + 0.5}
y={r.y + 0.5}
width={Math.max(r.w - 1, 0)}
height={Math.max(r.h - 1, 0)}
rx={1.5}
fill={r.fill}
>
<title>{`${r.day} ${String(r.hour).padStart(2, '0')}:00 — ${r.value.toLocaleString()} ${mode}`}</title>
</rect>
))}
</svg>
<ResponsiveContainer width="100%" height={300}>
<ScatterChart margin={{ top: 10, right: 10, bottom: 10, left: 10 }}>
<XAxis
type="number"
dataKey="weekday"
domain={[0, 6]}
ticks={[0, 1, 2, 3, 4, 5, 6]}
tickFormatter={formatDay}
{...rechartsTheme.xAxis}
/>
<YAxis
type="number"
dataKey="hour"
domain={[0, 23]}
ticks={[0, 4, 8, 12, 16, 20]}
tickFormatter={formatHour}
reversed
{...rechartsTheme.yAxis}
/>
<Tooltip
contentStyle={rechartsTheme.tooltip.contentStyle}
labelStyle={rechartsTheme.tooltip.labelStyle}
itemStyle={rechartsTheme.tooltip.itemStyle}
cursor={false}
formatter={(_val: unknown, _name: string, entry: { payload?: HeatmapPoint }) => {
const p = entry.payload;
if (!p) return '';
return [`${p.value.toLocaleString()} ${mode}`, `${DAYS[p.weekday]} ${formatHour(p.hour)}:00`];
}}
labelFormatter={() => ''}
/>
<Scatter
data={data}
shape={HeatmapCell as unknown as React.FC}
isAnimationActive={false}
/>
</ScatterChart>
</ResponsiveContainer>
);
}

View File

@@ -1,4 +1,6 @@
import { useMemo } from 'react';
import { useCallback } from 'react';
import { Treemap as RechartsTreemap, ResponsiveContainer, Tooltip } from 'recharts';
import { rechartsTheme } from '@cameleer/design-system';
export interface TreemapItem {
id: string;
@@ -10,19 +12,9 @@ export interface TreemapItem {
interface TreemapProps {
items: TreemapItem[];
width: number;
height: number;
onItemClick?: (id: string) => void;
}
interface LayoutRect {
item: TreemapItem;
x: number;
y: number;
w: number;
h: number;
}
function slaColor(pct: number): string {
if (pct >= 99) return 'hsl(120, 45%, 85%)';
if (pct >= 97) return 'hsl(90, 45%, 85%)';
@@ -44,116 +36,93 @@ function slaTextColor(pct: number): string {
return 'hsl(0, 40%, 30%)';
}
/** Squarified treemap layout */
function layoutTreemap(items: TreemapItem[], x: number, y: number, w: number, h: number): LayoutRect[] {
if (items.length === 0) return [];
const total = items.reduce((s, i) => s + i.value, 0);
if (total === 0) return items.map((item, i) => ({ item, x: x + i, y, w: 1, h: 1 }));
/** Custom cell renderer for the Recharts Treemap */
function CustomCell(props: Record<string, unknown>) {
const { x, y, width, height, name, slaCompliance, onItemClick } = props as {
x: number; y: number; width: number; height: number;
name: string; slaCompliance: number; onItemClick?: (id: string) => void;
};
const sorted = [...items].sort((a, b) => b.value - a.value);
const rects: LayoutRect[] = [];
layoutSlice(sorted, total, x, y, w, h, rects);
return rects;
const w = width ?? 0;
const h = height ?? 0;
if (w < 2 || h < 2) return null;
const showLabel = w > 40 && h > 20;
const showSla = w > 60 && h > 34;
const sla = slaCompliance ?? 100;
return (
<g
onClick={() => onItemClick?.(name)}
style={{ cursor: onItemClick ? 'pointer' : 'default' }}
>
<rect
x={x + 1} y={y + 1} width={w - 2} height={h - 2}
rx={3}
fill={slaColor(sla)}
stroke={slaBorderColor(sla)}
strokeWidth={1}
/>
{showLabel && (
<text
x={x + 5} y={y + 15}
fill={slaTextColor(sla)}
fontSize={11} fontWeight={600}
style={{ pointerEvents: 'none' }}
>
{name.length > w / 6.5 ? name.slice(0, Math.floor(w / 6.5)) + '\u2026' : name}
</text>
)}
{showSla && (
<text
x={x + 5} y={y + 28}
fill={slaTextColor(sla)}
fontSize={10} fontWeight={400}
style={{ pointerEvents: 'none' }}
>
{sla.toFixed(1)}% SLA
</text>
)}
</g>
);
}
function layoutSlice(
items: TreemapItem[], total: number,
x: number, y: number, w: number, h: number,
out: LayoutRect[],
) {
if (items.length === 0) return;
if (items.length === 1) {
out.push({ item: items[0], x, y, w, h });
return;
}
export function Treemap({ items, onItemClick }: TreemapProps) {
// Recharts Treemap expects { name, size, ...extra }
const data = items.map(i => ({
name: i.label,
size: i.value,
slaCompliance: i.slaCompliance,
}));
const isWide = w >= h;
let partialSum = 0;
let splitIndex = 0;
// Find split point closest to half the total area
const halfTotal = total / 2;
for (let i = 0; i < items.length - 1; i++) {
partialSum += items[i].value;
if (partialSum >= halfTotal) {
splitIndex = i + 1;
break;
}
splitIndex = i + 1;
}
const leftTotal = items.slice(0, splitIndex).reduce((s, i) => s + i.value, 0);
const ratio = total > 0 ? leftTotal / total : 0.5;
if (isWide) {
const splitX = x + w * ratio;
layoutSlice(items.slice(0, splitIndex), leftTotal, x, y, w * ratio, h, out);
layoutSlice(items.slice(splitIndex), total - leftTotal, splitX, y, w * (1 - ratio), h, out);
} else {
const splitY = y + h * ratio;
layoutSlice(items.slice(0, splitIndex), leftTotal, x, y, w, h * ratio, out);
layoutSlice(items.slice(splitIndex), total - leftTotal, x, splitY, w, h * (1 - ratio), out);
}
}
export function Treemap({ items, width, height, onItemClick }: TreemapProps) {
const rects = useMemo(() => layoutTreemap(items, 1, 1, width - 2, height - 2), [items, width, height]);
const renderContent = useCallback(
(props: Record<string, unknown>) => <CustomCell {...props} onItemClick={onItemClick} />,
[onItemClick],
);
if (items.length === 0) {
return (
<svg viewBox={`0 0 ${width} ${height}`} style={{ width: '100%', height: 'auto', display: 'block' }}>
<text x={width / 2} y={height / 2} textAnchor="middle" fill="#9CA3AF" fontSize={12}>No data</text>
</svg>
);
return <div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '2rem' }}>No data</div>;
}
return (
<svg viewBox={`0 0 ${width} ${height}`} style={{ width: '100%', height: 'auto', display: 'block' }}>
{rects.map(({ item, x, y, w, h }) => {
const pad = 1;
const rx = x + pad;
const ry = y + pad;
const rw = Math.max(w - pad * 2, 0);
const rh = Math.max(h - pad * 2, 0);
const showLabel = rw > 40 && rh > 20;
const showSla = rw > 60 && rh > 34;
return (
<g
key={item.id}
onClick={() => onItemClick?.(item.id)}
style={{ cursor: onItemClick ? 'pointer' : 'default' }}
>
<rect
x={rx} y={ry} width={rw} height={rh}
rx={3}
fill={slaColor(item.slaCompliance)}
stroke={slaBorderColor(item.slaCompliance)}
strokeWidth={1}
/>
{showLabel && (
<text
x={rx + 4} y={ry + 13}
fill={slaTextColor(item.slaCompliance)}
fontSize={11} fontWeight={600}
style={{ pointerEvents: 'none' }}
>
{item.label.length > rw / 6.5 ? item.label.slice(0, Math.floor(rw / 6.5)) + '\u2026' : item.label}
</text>
)}
{showSla && (
<text
x={rx + 4} y={ry + 26}
fill={slaTextColor(item.slaCompliance)}
fontSize={10} fontWeight={400}
style={{ pointerEvents: 'none' }}
>
{item.slaCompliance.toFixed(1)}% SLA
</text>
)}
</g>
);
})}
</svg>
<ResponsiveContainer width="100%" height={300}>
<RechartsTreemap
data={data}
dataKey="size"
nameKey="name"
stroke="none"
content={renderContent}
>
<Tooltip
contentStyle={rechartsTheme.tooltip.contentStyle}
labelStyle={rechartsTheme.tooltip.labelStyle}
itemStyle={rechartsTheme.tooltip.itemStyle}
formatter={(value: number, _name: string, entry: { payload?: { slaCompliance?: number } }) => {
const sla = entry.payload?.slaCompliance ?? 0;
return [`${value.toLocaleString()} exchanges · ${sla.toFixed(1)}% SLA`];
}}
/>
</RechartsTreemap>
</ResponsiveContainer>
);
}