feat: add treemap and punchcard heatmap to dashboard L1/L2 (#94)

Treemap: rectangle area = transaction volume, color = SLA compliance
(green→red). Shows apps at L1, routes at L2. Click navigates deeper.

Punchcard heatmap: 7-day rolling weekday x 24-hour grid showing
transaction volume and error patterns. Two side-by-side views
(transactions + errors) reveal temporal clustering.

Backend: new GET /search/stats/punchcard endpoint aggregating
stats_1m_all/app by DOW x hour over rolling 7 days.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-30 10:26:26 +02:00
parent b5c19b6774
commit 9d2d87e7e1
9 changed files with 433 additions and 17 deletions

View File

@@ -0,0 +1,159 @@
import { useMemo } from 'react';
export interface TreemapItem {
id: string;
label: string;
value: number;
/** 0-100, drives green→yellow→red color */
slaCompliance: number;
}
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%)';
if (pct >= 95) return 'hsl(60, 50%, 85%)';
if (pct >= 90) return 'hsl(30, 55%, 85%)';
return 'hsl(0, 55%, 85%)';
}
function slaBorderColor(pct: number): string {
if (pct >= 99) return 'hsl(120, 40%, 45%)';
if (pct >= 97) return 'hsl(90, 40%, 50%)';
if (pct >= 95) return 'hsl(60, 45%, 45%)';
if (pct >= 90) return 'hsl(30, 50%, 45%)';
return 'hsl(0, 50%, 45%)';
}
function slaTextColor(pct: number): string {
if (pct >= 95) return 'hsl(120, 20%, 25%)';
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 }));
const sorted = [...items].sort((a, b) => b.value - a.value);
const rects: LayoutRect[] = [];
layoutSlice(sorted, total, x, y, w, h, rects);
return rects;
}
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;
}
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]);
if (items.length === 0) {
return (
<svg width={width} height={height}>
<text x={width / 2} y={height / 2} textAnchor="middle" fill="#9CA3AF" fontSize={12}>No data</text>
</svg>
);
}
return (
<svg width={width} height={height} style={{ 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>
);
}