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 ( No data ); } return ( {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 ( onItemClick?.(item.id)} style={{ cursor: onItemClick ? 'pointer' : 'default' }} > {showLabel && ( {item.label.length > rw / 6.5 ? item.label.slice(0, Math.floor(rw / 6.5)) + '\u2026' : item.label} )} {showSla && ( {item.slaCompliance.toFixed(1)}% SLA )} ); })} ); }