feat: migrate Treemap and PunchcardHeatmap to Recharts
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user