import { ChartFrame, SvgA11y } from "./frame.tsx"; import { chartId, finiteDomain, formatValue, truncateLabel } from "./format.ts"; import type { MatrixCell, RetentionMatrixProps } from "./types.ts"; export function RetentionMatrix({ meta, rows, cols, cells, domain, format = "percent", digits = 1, width = 720, height = 360, }: RetentionMatrixProps) { const uniqueRows = [...new Set(rows.filter(Boolean))]; const uniqueCols = [...new Set(cols.filter(Boolean))]; if (uniqueRows.length === 0 || uniqueCols.length === 0) { return ; } const validKeys = new Set(uniqueRows.flatMap((row) => uniqueCols.map((col) => `${row}\u0000${col}`))); const cellMap = new Map(); let invalid = 0; let outside = 0; for (const cell of cells) { const key = `${cell.row}\u0000${cell.col}`; if (!validKeys.has(key)) { outside += 1; continue; } if (cell.value !== null && !Number.isFinite(cell.value)) { invalid += 1; continue; } cellMap.set(key, cell); } const numeric = [...cellMap.values()].flatMap((cell) => cell.value === null ? [] : [cell.value]); const fallback: [number, number] = numeric.length > 0 ? [Math.min(...numeric), Math.max(...numeric)] : [0, 1]; const [min, max] = finiteDomain(domain, fallback); const normalize = (value: number) => Math.max(0, Math.min(1, (value - min) / (max - min))); const left = 126; const top = 70; const cellWidth = 82; const cellHeight = 48; const viewWidth = Math.max(width, left + uniqueCols.length * cellWidth + 18); const viewHeight = Math.max(height, top + uniqueRows.length * cellHeight + 46); const titleId = chartId(meta, "title"); const descriptionId = chartId(meta, "description"); const warnings = [ invalid > 0 ? `${invalid} non-finite cell${invalid === 1 ? " was" : "s were"} treated as missing.` : "", outside > 0 ? `${outside} cell${outside === 1 ? " was" : "s were"} omitted because its row or column was undeclared.` : "", ].filter(Boolean).join(" ") || undefined; const graphic = ( {uniqueCols.map((col, index) => ( {truncateLabel(col, 12)} ))} {uniqueRows.map((row, rowIndex) => ( {truncateLabel(row, 18)} {uniqueCols.map((col, colIndex) => { const value = cellMap.get(`${row}\u0000${col}`)?.value ?? null; const opacity = value === null ? 0 : 0.18 + normalize(value) * 0.82; return ( {`${row}, ${col}: ${value === null ? "No data" : formatValue(value, format, digits)}`} 0.58 ? " site-chart-matrix-value-strong" : ""}`} x={left + colIndex * cellWidth + cellWidth / 2} y={top + rowIndex * cellHeight + cellHeight / 2 + 4} text-anchor="middle" > {value === null ? "—" : formatValue(value, format, digits)} ); })} ))} {meta.xLabel && {meta.xLabel}} {meta.yLabel && {meta.yLabel}} ); const table = ( {uniqueCols.map((col) => )} {uniqueRows.map((row) => ( {uniqueCols.map((col) => { const value = cellMap.get(`${row}\u0000${col}`)?.value ?? null; return ; })} ))}
{meta.title}
Task{col}
{row}{value === null ? "No data" : formatValue(value, format, digits)}
); return ; }