@keyfunc/keybricks-ds 2.4.10 → 2.4.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +0,0 @@
1
- window.__KB_SRC__=Object.assign(window.__KB_SRC__||{},{"charts/chart-geometry.js":"// Framework-neutral chart math shared by the React and Vue chart components.\n// Everything here is pure: values in, geometry out. No DOM, no tokens.\n\nexport const SERIES = [\"var(--series-1)\",\"var(--series-2)\",\"var(--series-3)\",\"var(--series-4)\",\"var(--series-5)\",\"var(--series-6)\",\"var(--series-7)\",\"var(--series-8)\",\"var(--series-9)\",\"var(--series-10)\"];\nexport const seriesColor = (i) => SERIES[i % SERIES.length];\n\n/** Compact number label: 1.24M · 843K · 12,400 → 12.4K · 0.82 */\nexport function fmtNum(v, opts = {}) {\n if (v == null || isNaN(v)) return \"–\";\n const abs = Math.abs(v);\n const sign = v < 0 ? \"-\" : \"\";\n const f = (n, d) => n.toFixed(d).replace(/\\.0+$|(\\.\\d*[1-9])0+$/, \"$1\");\n if (opts.pct) return f(v, abs < 10 ? 1 : 0) + \"%\";\n if (abs >= 1e9) return sign + f(abs / 1e9, 2) + \"B\";\n if (abs >= 1e6) return sign + f(abs / 1e6, 2) + \"M\";\n if (abs >= 1e4) return sign + f(abs / 1e3, 1) + \"K\";\n if (abs >= 1000) return sign + abs.toLocaleString(\"en-US\");\n return sign + f(abs, abs < 1 && abs > 0 ? 2 : abs < 100 && abs % 1 ? 1 : 0);\n}\n\n/** Nice axis ticks covering [min,max]. Returns {ticks, lo, hi}. */\nexport function niceTicks(min, max, count = 4) {\n if (min === max) { max = min + (min === 0 ? 1 : Math.abs(min) * 0.1); }\n const span = max - min;\n const step0 = span / Math.max(1, count);\n const mag = Math.pow(10, Math.floor(Math.log10(step0)));\n const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => span / s <= count + 0.5) || 10 * mag;\n const lo = Math.floor(min / step) * step;\n const hi = Math.ceil(max / step) * step;\n const ticks = [];\n for (let t = lo; t <= hi + step / 1e6; t += step) ticks.push(Math.abs(t) < step / 1e6 ? 0 : +t.toPrecision(12));\n return { ticks, lo, hi };\n}\n\nexport const scaleLin = (lo, hi, r0, r1) => (v) => r0 + ((v - lo) / (hi - lo || 1)) * (r1 - r0);\n\n/** Extent over arrays of numbers (ignores null). */\nexport function extent(arrs, zero = true) {\n let min = Infinity, max = -Infinity;\n arrs.forEach((a) => a.forEach((v) => { if (v == null) return; if (v < min) min = v; if (v > max) max = v; }));\n if (min === Infinity) { min = 0; max = 1; }\n if (zero) { min = Math.min(0, min); max = Math.max(0, max); }\n return [min, max];\n}\n\nexport const linePath = (pts) => pts.length ? \"M\" + pts.map((p) => p[0].toFixed(1) + \" \" + p[1].toFixed(1)).join(\" L\") : \"\";\nexport const areaPath = (pts, y0) => pts.length ? linePath(pts) + \" L\" + pts[pts.length - 1][0].toFixed(1) + \" \" + y0.toFixed(1) + \" L\" + pts[0][0].toFixed(1) + \" \" + y0.toFixed(1) + \" Z\" : \"\";\n\n/** Stack rows of series values → per-series [y0,y1] bands (positive stacking). */\nexport function stack(seriesValues) {\n const n = seriesValues[0] ? seriesValues[0].length : 0;\n const acc = new Array(n).fill(0);\n return seriesValues.map((vals) => vals.map((v, i) => { const y0 = acc[i]; acc[i] += v || 0; return [y0, acc[i]]; }));\n}\n\n/** Polar arc path (annular sector) for donut/gauge. Angles in degrees, 0 = 12 o'clock, clockwise. */\nexport function arcPath(cx, cy, rOuter, rInner, a0, a1) {\n const rad = (a) => ((a - 90) * Math.PI) / 180;\n const p = (r, a) => [cx + r * Math.cos(rad(a)), cy + r * Math.sin(rad(a))];\n const large = a1 - a0 > 180 ? 1 : 0;\n const [x0, y0] = p(rOuter, a0), [x1, y1] = p(rOuter, a1), [x2, y2] = p(rInner, a1), [x3, y3] = p(rInner, a0);\n return `M${x0.toFixed(2)} ${y0.toFixed(2)} A${rOuter} ${rOuter} 0 ${large} 1 ${x1.toFixed(2)} ${y1.toFixed(2)} L${x2.toFixed(2)} ${y2.toFixed(2)} A${rInner} ${rInner} 0 ${large} 0 ${x3.toFixed(2)} ${y3.toFixed(2)} Z`;\n}\n\n/** Waterfall: steps [{label,value,total}] → bars [{label,y0,y1,kind}] with running sum. kind: pos|neg|total */\nexport function waterfall(steps) {\n let run = 0;\n return steps.map((s) => {\n if (s.total) { const b = { label: s.label, y0: 0, y1: run, kind: \"total\", value: run }; return b; }\n const y0 = run; run += s.value;\n return { label: s.label, y0: Math.min(y0, run), y1: Math.max(y0, run), kind: s.value >= 0 ? \"pos\" : \"neg\", value: s.value, from: y0, to: run };\n });\n}\n\n/** Squarified treemap. items [{value,...}] → [{x,y,w,h,item}] in a w×h box. */\nexport function treemap(items, w, h) {\n const total = items.reduce((s, it) => s + it.value, 0) || 1;\n const scaled = items.map((it) => ({ it, a: (it.value / total) * w * h })).sort((a, b) => b.a - a.a);\n const out = [];\n let x = 0, y = 0, rw = w, rh = h, row = [];\n const worst = (row, side) => {\n const s = row.reduce((t, r) => t + r.a, 0);\n let mx = 0;\n row.forEach((r) => { const ratio = Math.max((side * side * r.a) / (s * s), (s * s) / (side * side * r.a)); if (ratio > mx) mx = ratio; });\n return mx;\n };\n const layoutRow = (row) => {\n const s = row.reduce((t, r) => t + r.a, 0);\n const horiz = rw >= rh;\n const side = horiz ? rh : rw;\n const thick = s / side;\n let off = 0;\n row.forEach((r) => {\n const len = r.a / thick;\n out.push(horiz ? { x, y: y + off, w: thick, h: len, item: r.it } : { x: x + off, y, w: len, h: thick, item: r.it });\n off += len;\n });\n if (horiz) { x += thick; rw -= thick; } else { y += thick; rh -= thick; }\n };\n scaled.forEach((r) => {\n const side = rw >= rh ? rh : rw;\n if (row.length && worst([...row, r], side) > worst(row, side)) { layoutRow(row); row = [r]; } else row.push(r);\n });\n if (row.length) layoutRow(row);\n return out;\n}\n\n/** 5-step sequential heat color index for v within [lo,hi]. */\nexport function heatStep(v, lo, hi) {\n const t = (v - lo) / (hi - lo || 1);\n return Math.max(0, Math.min(4, Math.floor(t * 5 - 1e-9)));\n}\n\n/** Monotone cubic path through points — smooth without overshooting real values. */\nexport function curvePath(pts) {\n const n = pts.length;\n if (n < 3) return linePath(pts);\n const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]);\n const dx = [], dy = [], m = [];\n for (let i = 0; i < n - 1; i++) { dx.push(xs[i + 1] - xs[i]); dy.push(ys[i + 1] - ys[i]); m.push(dy[i] / (dx[i] || 1)); }\n const t = [m[0]];\n for (let i = 1; i < n - 1; i++) t.push(m[i - 1] * m[i] <= 0 ? 0 : (m[i - 1] + m[i]) / 2);\n t.push(m[n - 2]);\n let d = \"M\" + xs[0].toFixed(1) + \" \" + ys[0].toFixed(1);\n for (let i = 0; i < n - 1; i++) {\n const c = dx[i] / 3;\n d += \" C\" + (xs[i] + c).toFixed(1) + \" \" + (ys[i] + t[i] * c).toFixed(1) + \",\" + (xs[i + 1] - c).toFixed(1) + \" \" + (ys[i + 1] - t[i + 1] * c).toFixed(1) + \",\" + xs[i + 1].toFixed(1) + \" \" + ys[i + 1].toFixed(1);\n }\n return d;\n}\n/** Step path (stairs) — for values that hold until the next reading. */\nexport function stepPath(pts) {\n if (!pts.length) return \"\";\n let d = \"M\" + pts[0][0].toFixed(1) + \" \" + pts[0][1].toFixed(1);\n for (let i = 1; i < pts.length; i++) { const mx = (pts[i - 1][0] + pts[i][0]) / 2; d += \" L\" + mx.toFixed(1) + \" \" + pts[i - 1][1].toFixed(1) + \" L\" + mx.toFixed(1) + \" \" + pts[i][1].toFixed(1) + \" L\" + pts[i][0].toFixed(1) + \" \" + pts[i][1].toFixed(1); }\n return d;\n}\n/** linear | smooth | step in one call. */\nexport const shapePath = (pts, curve) => (curve === \"smooth\" ? curvePath(pts) : curve === \"step\" ? stepPath(pts) : linePath(pts));\nexport const closeToBaseline = (d, pts, y0) => (d ? d + \" L\" + pts[pts.length - 1][0].toFixed(1) + \" \" + y0.toFixed(1) + \" L\" + pts[0][0].toFixed(1) + \" \" + y0.toFixed(1) + \" Z\" : \"\");\n\n/** Sankey layout. nodes:[{id,label}], links:[{source,target,value}] — depth by longest path. */\nexport function sankey(nodes, links, w, h, nodeW, pad) {\n nodeW = nodeW || 14; pad = pad == null ? 14 : pad;\n const byId = {};\n nodes.forEach((n) => { byId[n.id] = Object.assign({}, n, { in: [], out: [], depth: 0, value: 0 }); });\n links.forEach((l) => { byId[l.source].out.push(l); byId[l.target].in.push(l); });\n let changed = true, guard = 0;\n while (changed && guard++ < 60) {\n changed = false;\n links.forEach((l) => { const d = byId[l.source].depth + 1; if (d > byId[l.target].depth) { byId[l.target].depth = d; changed = true; } });\n }\n const list = Object.keys(byId).map((k) => byId[k]);\n list.forEach((n) => { n.value = Math.max(n.in.reduce((s, l) => s + l.value, 0), n.out.reduce((s, l) => s + l.value, 0)); });\n const maxDepth = Math.max.apply(null, list.map((n) => n.depth));\n /* Sinks hug the right edge so the diagram reads left-to-right even with uneven path lengths. */\n list.forEach((n) => { if (!n.out.length) n.depth = maxDepth; });\n const cols = [];\n list.forEach((n) => { (cols[n.depth] = cols[n.depth] || []).push(n); });\n const colX = maxDepth ? (w - nodeW) / maxDepth : 0;\n const scale = Math.min.apply(null, cols.map((c) => (h - pad * (c.length - 1)) / (c.reduce((s, n) => s + n.value, 0) || 1)));\n cols.forEach((col, ci) => {\n let y = 0;\n col.forEach((n) => { n.x = ci * colX; n.w = nodeW; n.y = y; n.h = Math.max(2, n.value * scale); y += n.h + pad; });\n const extra = (h - (y - pad)) / 2;\n col.forEach((n) => { n.y += extra; });\n });\n const outAt = {}, inAt = {};\n const ribbons = links.map((l) => {\n const s = byId[l.source], t = byId[l.target];\n const so = (outAt[l.source] = outAt[l.source] || 0), ti = (inAt[l.target] = inAt[l.target] || 0);\n const th = Math.max(1, l.value * scale);\n outAt[l.source] += th; inAt[l.target] += th;\n const x0 = s.x + s.w, x1 = t.x, cx = (x1 - x0) / 2;\n const y0 = s.y + so, y1 = t.y + ti;\n const d = \"M\" + x0 + \" \" + y0 + \" C\" + (x0 + cx) + \" \" + y0 + \", \" + (x1 - cx) + \" \" + y1 + \", \" + x1 + \" \" + y1\n + \" L\" + x1 + \" \" + (y1 + th) + \" C\" + (x1 - cx) + \" \" + (y1 + th) + \", \" + (x0 + cx) + \" \" + (y0 + th) + \", \" + x0 + \" \" + (y0 + th) + \" Z\";\n return { link: l, thickness: th, d: d, source: s, target: t };\n });\n return { nodes: list, ribbons: ribbons, maxDepth: maxDepth };\n}\n\n/** Chord (circular sankey) layout from a square matrix of flows. */\nexport function chord(matrix, gapDeg) {\n gapDeg = gapDeg == null ? 2 : gapDeg;\n const n = matrix.length;\n const totals = matrix.map((row, i) => row.reduce((s, v) => s + v, 0) + matrix.reduce((s, r2, j) => s + (j === i ? 0 : r2[i]), 0));\n const grand = totals.reduce((s, v) => s + v, 0) || 1;\n const span = 360 - gapDeg * n;\n const arcs = []; let a = 0;\n totals.forEach((v, i) => { const sweep = (v / grand) * span; arcs.push({ i: i, a0: a, a1: a + sweep, value: v }); a += sweep + gapDeg; });\n const cursor = arcs.map((x) => x.a0);\n const ribbons = [];\n for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) {\n const v = matrix[i][j];\n if (!v || i === j) continue;\n const sweep = (v / grand) * span;\n const a0 = cursor[i], a1 = a0 + sweep; cursor[i] = a1;\n const b0 = cursor[j], b1 = b0 + sweep; cursor[j] = b1;\n ribbons.push({ i: i, j: j, value: v, a0: a0, a1: a1, b0: b0, b1: b1 });\n }\n return { arcs: arcs, ribbons: ribbons, totals: totals, grand: grand };\n}\nexport const polar = (cx, cy, r, deg) => [cx + r * Math.cos(((deg - 90) * Math.PI) / 180), cy + r * Math.sin(((deg - 90) * Math.PI) / 180)];\n/** Ribbon between two arc spans, bowed through the circle centre. */\nexport function chordRibbon(cx, cy, r, a0, a1, b0, b1) {\n const p = (deg) => polar(cx, cy, r, deg);\n const s0 = p(a0), s1 = p(a1), t0 = p(b0), t1 = p(b1);\n const big = (x, y) => (Math.abs(y - x) > 180 ? 1 : 0);\n return \"M\" + s0[0].toFixed(1) + \" \" + s0[1].toFixed(1)\n + \" A\" + r + \" \" + r + \" 0 \" + big(a0, a1) + \" 1 \" + s1[0].toFixed(1) + \" \" + s1[1].toFixed(1)\n + \" Q\" + cx + \" \" + cy + \" \" + t0[0].toFixed(1) + \" \" + t0[1].toFixed(1)\n + \" A\" + r + \" \" + r + \" 0 \" + big(b0, b1) + \" 1 \" + t1[0].toFixed(1) + \" \" + t1[1].toFixed(1)\n + \" Q\" + cx + \" \" + cy + \" \" + s0[0].toFixed(1) + \" \" + s0[1].toFixed(1) + \" Z\";\n}\n/** Quantile buckets for choropleth shading — returns a 0..steps-1 index fn. */\nexport function quantize(values, steps) {\n const sorted = values.filter((v) => v != null).slice().sort((a, b) => a - b);\n if (!sorted.length) return () => 0;\n const cuts = [];\n for (let i = 1; i < steps; i++) cuts.push(sorted[Math.floor((i / steps) * sorted.length)]);\n return (v) => { if (v == null) return -1; let k = 0; while (k < cuts.length && v >= cuts[k]) k++; return k; };\n}\n","charts/ChartKit.jsx":"import React from \"react\";\nimport { fmtNum, seriesColor } from \"./chart-geometry.js\";\n\n/* Chart numbers use the UI font with tabular figures; the monospace font is reserved for code. */\nconst sans = \"var(--font-sans)\", num = { fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\" };\n\n/** Legend row. items: [{label,color}] — with onToggle, clicking mutes a series. */\nexport function ChartLegend({ items = [], hidden = [], onToggle, align = \"left\", style }) {\n return (\n <div style={{ display: \"flex\", flexWrap: \"wrap\", gap: \"4px 14px\", justifyContent: align === \"right\" ? \"flex-end\" : \"flex-start\", lineHeight: \"normal\", ...style }}>\n {items.map((it, i) => {\n const off = hidden.includes(it.label);\n return (\n <span key={it.label} role={onToggle ? \"button\" : undefined} tabIndex={onToggle ? 0 : undefined}\n onClick={onToggle ? () => onToggle(it.label) : undefined} data-ink={onToggle ? \"\" : undefined}\n style={{ display: \"inline-flex\", alignItems: \"center\", gap: 6, cursor: onToggle ? \"pointer\" : \"default\", opacity: off ? 0.4 : 1, transition: \"var(--t-hover)\" }}>\n <span style={{ width: 8, height: 8, borderRadius: 2, background: it.color || seriesColor(i), flexShrink: 0 }} />\n <span style={{ fontFamily: sans, fontSize: \"var(--text-xs)\", color: onToggle ? \"var(--ink, var(--text-secondary))\" : \"var(--text-secondary)\", textDecoration: off ? \"line-through\" : \"none\" }}>{it.label}</span>\n </span>\n );\n })}\n </div>\n );\n}\n\n/** Floating tooltip. rows: [{label,value,color}]; pins near (x,y) inside the chart wrapper. */\nexport function ChartTooltip({ x, y, title, rows = [], flip }) {\n return (\n <div style={{ position: \"absolute\", left: x, top: y, transform: `translate(${flip ? \"calc(-100% - 10px)\" : \"10px\"}, -50%)`, pointerEvents: \"none\", zIndex: 5, background: \"var(--bg-surface)\", border: \"1px solid var(--border-card)\", borderRadius: \"var(--radius-md)\", boxShadow: \"var(--shadow-menu)\", padding: \"7px 10px\", minWidth: 90, lineHeight: \"normal\", whiteSpace: \"nowrap\" }}>\n {title != null ? <div style={{ fontFamily: sans, fontSize: \"var(--text-2xs)\", color: \"var(--text-muted)\", paddingBottom: rows.length ? 4 : 0, whiteSpace: \"nowrap\" }}>{title}</div> : null}\n {rows.map((r, i) => (\n <div key={i} style={{ display: \"flex\", alignItems: \"center\", gap: 6, padding: \"1px 0\" }}>\n {r.color ? <span style={{ width: 7, height: 7, borderRadius: 2, background: r.color, flexShrink: 0 }} /> : null}\n <span style={{ fontFamily: sans, fontSize: \"var(--text-xs)\", color: \"var(--text-secondary)\", whiteSpace: \"nowrap\" }}>{r.label}</span>\n <span style={{ ...num, fontSize: \"var(--text-xs)\", color: \"var(--text-heading)\", marginLeft: \"auto\", paddingLeft: 10 }}>{r.value}</span>\n </div>\n ))}\n </div>\n );\n}\n\n/** y-axis tick labels + horizontal gridlines, x category labels. Shared plot furniture. */\nexport function PlotGrid({ w, h, pad, ticks, sy, xLabels = [], sxLabel, showAxisLine = false, yFormat = fmtNum }) {\n return (\n <g>\n {ticks.map((t) => (\n <g key={t}>\n <line x1={pad.l} x2={w - pad.r} y1={sy(t)} y2={sy(t)} stroke={t === 0 ? \"var(--chart-axis)\" : \"var(--chart-grid)\"} strokeWidth=\"1\" />\n <text x={pad.l - 8} y={sy(t) + 3} textAnchor=\"end\" style={{ ...num, fontSize: \"var(--text-2xs-mono)\", fill: \"var(--chart-label)\" }}>{yFormat(t)}</text>\n </g>\n ))}\n {showAxisLine ? <line x1={pad.l} x2={pad.l} y1={pad.t} y2={h - pad.b} stroke=\"var(--chart-axis)\" strokeWidth=\"1\" /> : null}\n {xLabels.map((lb, i) => (lb == null ? null : (\n <text key={i} x={sxLabel(i)} y={h - pad.b + 16} textAnchor=\"middle\" style={{ fontFamily: sans, fontSize: \"var(--text-2xs)\", fill: \"var(--chart-label)\" }}>{lb}</text>\n )))}\n </g>\n );\n}\n\n/** Thin x labels only (sm tier / no y axis). */\nexport function XLabels({ h, pad, xLabels = [], sxLabel }) {\n return <g>{xLabels.map((lb, i) => (lb == null ? null : <text key={i} x={sxLabel(i)} y={h - pad.b + 16} textAnchor=\"middle\" style={{ fontFamily: sans, fontSize: \"var(--text-2xs)\", fill: \"var(--chart-label)\" }}>{lb}</text>))}</g>;\n}\n\n/** Skip x labels so they never collide: keeps ~every nth. */\nexport function thinLabels(labels, maxShown = 8) {\n const n = labels.length;\n if (n <= maxShown) return labels;\n const step = Math.ceil(n / maxShown);\n return labels.map((l, i) => (i % step === 0 || i === n - 1 ? l : null));\n}\n\n/** Wrapper div a chart mounts into: relative for tooltips, block-level svg. */\nexport const wrapStyle = { position: \"relative\", display: \"inline-block\", verticalAlign: \"top\", lineHeight: 0 };\n\n/** Namespace handle so the kit can be imported as one symbol. */\nexport const ChartKit = { ChartLegend, ChartTooltip, PlotGrid, XLabels, thinLabels, wrapStyle };\n","charts/Sparkline.jsx":"import React from \"react\";\nimport { extent, scaleLin, shapePath, closeToBaseline, fmtNum } from \"./chart-geometry.js\";\n\n/**\n * Micro line/area/bar chart for stat tiles, table cells and list rows. No axes, no labels.\n * mode: \"line\" | \"area\" | \"bars\". curve: \"linear\" | \"smooth\" | \"step\".\n * tone: \"default\" (series-1) | \"auto\" (green up / red down) | \"pos\" | \"neg\" | \"muted\".\n * Interactive by default: hovering snaps to the nearest point and shows a compact value chip.\n */\nexport function Sparkline({ values = [], mode = \"line\", curve = \"linear\", tone = \"default\", width = 96, height = 28, strokeWidth = 1.5, dot = true, baseline, interactive = true, labels, valueFormat = fmtNum, style }) {\n const [hover, setHover] = React.useState(null);\n const uid = React.useId ? React.useId().replace(/:/g, \"\") : \"kbspark\";\n const [lo, hi] = extent([values], mode === \"bars\");\n const pad = strokeWidth + 1.5;\n const sx = scaleLin(0, values.length - 1 || 1, pad, width - pad);\n const sy = scaleLin(lo, hi, height - pad, pad);\n const up = values.length > 1 && values[values.length - 1] >= values[0];\n const color = tone === \"auto\" ? (up ? \"var(--chart-pos)\" : \"var(--chart-neg)\")\n : tone === \"pos\" ? \"var(--chart-pos)\" : tone === \"neg\" ? \"var(--chart-neg)\"\n : tone === \"muted\" ? \"var(--chart-guide)\" : \"var(--series-1)\";\n const pts = values.map((v, i) => [sx(i), sy(v)]);\n const d = shapePath(pts, curve);\n const bw = Math.max(2, (width - pad * 2) / (values.length || 1) - 2);\n const move = (e) => {\n if (!interactive) return;\n const r = e.currentTarget.getBoundingClientRect();\n const i = Math.round(((e.clientX - r.left - pad) / (width - pad * 2)) * (values.length - 1 || 1));\n setHover(Math.max(0, Math.min(values.length - 1, i)));\n };\n const chip = hover != null && interactive ? (\n <span style={{ position: \"absolute\", left: Math.min(Math.max(sx(hover), 18), width - 18), top: -4, transform: \"translate(-50%,-100%)\", pointerEvents: \"none\", whiteSpace: \"nowrap\", background: \"var(--bg-surface)\", border: \"1px solid var(--border-card)\", borderRadius: \"var(--radius-sm)\", boxShadow: \"var(--shadow-menu)\", padding: \"2px 6px\", fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", lineHeight: \"14px\", color: \"var(--text-heading)\", zIndex: 5 }}>\n {labels && labels[hover] ? <span style={{ color: \"var(--text-muted)\", paddingRight: 5 }}>{labels[hover]}</span> : null}\n {valueFormat(values[hover])}\n </span>\n ) : null;\n return (\n <span style={{ position: \"relative\", display: \"inline-block\", verticalAlign: \"middle\", lineHeight: 0, ...style }}>\n <svg width={width} height={height} onMouseMove={move} onMouseLeave={() => setHover(null)} role=\"img\" style={{ display: \"block\", overflow: \"visible\" }}>\n {mode === \"area\" ? (\n <defs>\n <linearGradient id={uid} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0%\" stopColor={color} stopOpacity=\"0.38\" />\n <stop offset=\"100%\" stopColor={color} stopOpacity=\"0.02\" />\n </linearGradient>\n </defs>\n ) : null}\n {mode === \"bars\" ? values.map((v, i) => {\n const y = sy(Math.max(0, v)), y0 = sy(Math.min(0, v));\n return <rect key={i} x={sx(i) - bw / 2} y={y} width={bw} height={Math.max(1.5, y0 - y)} rx=\"1\" fill={v < 0 ? \"var(--chart-neg)\" : color} opacity={hover === i ? 1 : hover != null ? 0.4 : i === values.length - 1 ? 1 : 0.55} />;\n }) : (\n <>\n {baseline != null ? <line x1={pad} x2={width - pad} y1={sy(baseline)} y2={sy(baseline)} stroke=\"var(--chart-guide)\" strokeWidth=\"1\" strokeDasharray=\"2 3\" /> : null}\n {mode === \"area\" ? <path d={closeToBaseline(d, pts, height - pad)} fill={\"url(#\" + uid + \")\"} /> : null}\n <path d={d} fill=\"none\" stroke={color} strokeWidth={strokeWidth} strokeLinejoin=\"round\" strokeLinecap=\"round\" />\n {dot && pts.length ? <circle cx={pts[pts.length - 1][0]} cy={pts[pts.length - 1][1]} r={strokeWidth + 0.8} fill={color} /> : null}\n {hover != null && interactive ? <circle cx={sx(hover)} cy={sy(values[hover])} r={strokeWidth + 1.4} fill={color} stroke=\"var(--bg-surface)\" strokeWidth=\"1.5\" /> : null}\n </>\n )}\n </svg>\n {chip}\n </span>\n );\n}\n","charts/Sparkline.vue":"<script setup>\nimport { computed, ref } from 'vue'\nimport { extent, scaleLin, shapePath, closeToBaseline, fmtNum } from './chart-geometry.js'\n// Micro line/area/bar trend — no axes, no labels. tone \"auto\" = green up / red down.\nconst props = defineProps({ values:{type:Array,default:()=>[]}, mode:{type:String,default:'line'}, curve:{type:String,default:'linear'}, tone:{type:String,default:'default'}, width:{type:Number,default:96}, height:{type:Number,default:28}, strokeWidth:{type:Number,default:1.5}, dot:{type:Boolean,default:true}, baseline:Number, interactive:{type:Boolean,default:true}, labels:Array, valueFormat:{type:Function,default:fmtNum}, style:Object })\nconst hover = ref(null)\nconst uid = 'kbspark-' + Math.random().toString(36).slice(2, 8)\nfunction move(e){\n if (!props.interactive) return\n const r = e.currentTarget.getBoundingClientRect()\n const pad = props.strokeWidth + 1.5\n const i = Math.round(((e.clientX-r.left-pad)/(props.width-pad*2))*(props.values.length-1||1))\n hover.value = Math.max(0, Math.min(props.values.length-1, i))\n}\nconst g = computed(() => {\n const [lo,hi] = extent([props.values], props.mode==='bars')\n const pad = props.strokeWidth + 1.5\n const sx = scaleLin(0, props.values.length-1||1, pad, props.width-pad)\n const sy = scaleLin(lo, hi, props.height-pad, pad)\n const up = props.values.length>1 && props.values[props.values.length-1] >= props.values[0]\n const color = props.tone==='auto' ? (up?'var(--chart-pos)':'var(--chart-neg)') : props.tone==='pos'?'var(--chart-pos)' : props.tone==='neg'?'var(--chart-neg)' : props.tone==='muted'?'var(--chart-guide)':'var(--series-1)'\n const pts = props.values.map((v,i)=>[sx(i),sy(v)])\n const bw = Math.max(2,(props.width-pad*2)/props.values.length-2)\n return { pad, sx, sy, color, pts, bw,\n line: shapePath(pts, props.curve), area: closeToBaseline(shapePath(pts, props.curve), pts, props.height-pad),\n baseY: props.baseline!=null ? sy(props.baseline) : null,\n bars: props.values.map((v,i)=>({x:sx(i)-bw/2,y:sy(Math.max(0,v)),h:Math.max(1.5,sy(Math.min(0,v))-sy(Math.max(0,v))),neg:v<0,last:i===props.values.length-1})) }\n})\n</script>\n<template>\n <span :style=\"{position:'relative',display:'inline-block',verticalAlign:'middle',lineHeight:0,...style}\">\n <svg :width=\"width\" :height=\"height\" @mousemove=\"move\" @mouseleave=\"hover=null\" role=\"img\" style=\"display:block;overflow:visible\">\n <defs v-if=\"mode==='area'\">\n <linearGradient :id=\"uid\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0%\" :stop-color=\"g.color\" stop-opacity=\"0.38\"/>\n <stop offset=\"100%\" :stop-color=\"g.color\" stop-opacity=\"0.02\"/>\n </linearGradient>\n </defs>\n <template v-if=\"mode==='bars'\">\n <rect v-for=\"(b,i) in g.bars\" :key=\"i\" :x=\"b.x\" :y=\"b.y\" :width=\"g.bw\" :height=\"b.h\" rx=\"1\" :fill=\"b.neg?'var(--chart-neg)':g.color\" :opacity=\"hover===i?1:(hover!=null?.4:(b.last?1:.55))\"/>\n </template>\n <template v-else>\n <line v-if=\"g.baseY!=null\" :x1=\"g.pad\" :x2=\"width-g.pad\" :y1=\"g.baseY\" :y2=\"g.baseY\" stroke=\"var(--chart-guide)\" stroke-width=\"1\" stroke-dasharray=\"2 3\"/>\n <path v-if=\"mode==='area'\" :d=\"g.area\" :fill=\"'url(#'+uid+')'\"/>\n <path :d=\"g.line\" fill=\"none\" :stroke=\"g.color\" :stroke-width=\"strokeWidth\" stroke-linejoin=\"round\" stroke-linecap=\"round\"/>\n <circle v-if=\"dot && g.pts.length\" :cx=\"g.pts[g.pts.length-1][0]\" :cy=\"g.pts[g.pts.length-1][1]\" :r=\"strokeWidth+0.8\" :fill=\"g.color\"/>\n <circle v-if=\"hover!=null && interactive\" :cx=\"g.sx(hover)\" :cy=\"g.sy(values[hover])\" :r=\"strokeWidth+1.4\" :fill=\"g.color\" stroke=\"var(--bg-surface)\" stroke-width=\"1.5\"/>\n </template>\n </svg>\n <span v-if=\"hover!=null && interactive\" :style=\"{position:'absolute',left:Math.min(Math.max(g.sx(hover),18),width-18)+'px',top:'-4px',transform:'translate(-50%,-100%)',pointerEvents:'none',whiteSpace:'nowrap',background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-sm)',boxShadow:'var(--shadow-menu)',padding:'2px 6px',fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums',fontSize:'var(--text-2xs-mono)',lineHeight:'14px',color:'var(--text-heading)',zIndex:5}\">\n <span v-if=\"labels && labels[hover]\" style=\"color:var(--text-muted);padding-right:5px\">{{ labels[hover] }}</span>{{ valueFormat(values[hover]) }}\n </span>\n </span>\n</template>\n","charts/LineChart.jsx":"import React from \"react\";\nimport { fmtNum, extent, scaleLin, shapePath, closeToBaseline, stack, niceTicks, seriesColor } from \"./chart-geometry.js\";\nimport { ChartLegend, ChartTooltip, PlotGrid, thinLabels, wrapStyle } from \"./ChartKit.jsx\";\n\n/**\n * Line / area / stacked-area chart. series: [{name, values, color?}] over shared xLabels.\n * curve: \"linear\" (default) | \"smooth\" (monotone cubic, never overshoots a real value) | \"step\".\n * Interactive by default at every size: hover guideline + tooltip, click legend to mute a series.\n * sm tier: axes={false} legend={false} for a tile-sized trend — the tooltip stays.\n */\nexport function LineChart({ series = [], xLabels = [], width = 560, height = 240, area = false, stacked = false, curve = \"linear\", axes = true, legend = true, interactive = true, yFormat = fmtNum, target, targetLabel, style }) {\n const [hidden, setHidden] = React.useState([]);\n const [hover, setHover] = React.useState(null);\n const shown = series.filter((s) => !hidden.includes(s.name));\n const pad = { l: axes ? 44 : 4, r: 8, t: 10, b: axes ? 24 : 4 };\n const vals = shown.map((s) => s.values);\n const bands = stacked && shown.length ? stack(vals) : null;\n const [lo0, hi0] = stacked ? [0, Math.max(1, ...(bands[bands.length - 1] || []).map((b) => b[1]))] : extent(vals.concat(target != null ? [[target]] : []));\n const { ticks, lo, hi } = niceTicks(lo0, hi0, 4);\n const n = Math.max(...series.map((s) => s.values.length), 2);\n const sx = scaleLin(0, n - 1, pad.l + 4, width - pad.r - 4);\n const sy = scaleLin(lo, hi, height - pad.b, pad.t);\n const colorOf = (s) => s.color || seriesColor(series.indexOf(s));\n const base = sy(Math.max(lo, 0));\n const move = (e) => {\n if (!interactive) return;\n const r = e.currentTarget.getBoundingClientRect();\n const x = e.clientX - r.left;\n setHover(Math.max(0, Math.min(n - 1, Math.round(((x - pad.l - 4) / (width - pad.l - pad.r - 8)) * (n - 1)))));\n };\n const gid = React.useId ? React.useId().replace(/:/g, \"\") : \"kbline\";\n /* Stacked bands are closed against the band below, not the baseline: overlapping\n translucent fills stack up into mud. Each band gets its own gradient, scaled to that\n band's own box (objectBoundingBox), so the fade reads the same at any thickness. */\n const layers = shown.map((s, si) => {\n const pts = s.values.map((v, i) => [sx(i), sy(stacked ? bands[si][i][1] : v)]);\n const d = shapePath(pts, curve);\n const under = stacked\n ? [...s.values.keys()].reverse().map((i) => sx(i).toFixed(1) + \" \" + sy(bands[si][i][0]).toFixed(1)).join(\" L\")\n : null;\n return { s, si, pts, d, color: colorOf(s), fill: stacked ? d + \" L\" + under + \" Z\" : closeToBaseline(d, pts, base) };\n });\n const painted = layers;\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <svg width={width} height={height} onMouseMove={move} onMouseLeave={() => setHover(null)} role=\"img\">\n <defs>\n {series.map((s, i) => {\n const c = s.color || seriesColor(i);\n return (\n <linearGradient key={i} id={gid + \"-\" + i} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0%\" stopColor={c} stopOpacity={stacked ? \"0.30\" : \"0.34\"} />\n <stop offset=\"100%\" stopColor={c} stopOpacity={stacked ? \"0.10\" : \"0.02\"} />\n </linearGradient>\n );\n })}\n </defs>\n {axes ? <PlotGrid w={width} h={height} pad={pad} ticks={ticks} sy={sy} xLabels={thinLabels(xLabels)} sxLabel={sx} yFormat={yFormat} /> : null}\n {target != null ? <g>\n <line x1={pad.l} x2={width - pad.r} y1={sy(target)} y2={sy(target)} stroke=\"var(--chart-guide)\" strokeWidth=\"1\" strokeDasharray=\"3 3\" />\n <text x={width - pad.r} y={sy(target) - 4} textAnchor=\"end\" style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", fill: \"var(--chart-label)\" }}>{targetLabel || \"Target \" + yFormat(target)}</text>\n </g> : null}\n {painted.map((L) => (\n <g key={L.s.name}>\n {(area || stacked) ? <path d={L.fill} fill={\"url(#\" + gid + \"-\" + series.indexOf(L.s) + \")\"} /> : null}\n <path d={L.d} fill=\"none\" stroke={L.color} strokeWidth=\"1.5\" strokeLinejoin=\"round\" strokeLinecap=\"round\" />\n </g>\n ))}\n {hover != null && interactive ? <line x1={sx(hover)} x2={sx(hover)} y1={pad.t} y2={height - pad.b} stroke=\"var(--chart-guide)\" strokeWidth=\"1\" strokeDasharray=\"2 3\" /> : null}\n {hover != null ? layers.map((L) => (L.s.values[hover] == null ? null : (\n <circle key={L.s.name} cx={sx(hover)} cy={sy(stacked ? bands[L.si][hover][1] : L.s.values[hover])} r=\"3\" fill={L.color} stroke=\"var(--bg-surface)\" strokeWidth=\"1.5\" />\n ))) : null}\n </svg>\n {hover != null && interactive ? (\n <ChartTooltip x={sx(hover)} y={height / 2 - 10} flip={sx(hover) > width * 0.62} title={xLabels[hover]}\n rows={shown.map((s) => ({ label: s.name, value: yFormat(s.values[hover]), color: colorOf(s) }))} />\n ) : null}\n {legend && series.length > 1 ? (\n <ChartLegend style={{ paddingTop: 8 }} items={series.map((s) => ({ label: s.name, color: colorOf(s) }))} hidden={hidden}\n onToggle={(l) => setHidden(hidden.includes(l) ? hidden.filter((x) => x !== l) : hidden.length + 1 < series.length ? [...hidden, l] : hidden)} />\n ) : null}\n </div>\n );\n}\n","charts/LineChart.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { fmtNum, extent, scaleLin, shapePath, closeToBaseline, stack, niceTicks, SERIES } from './chart-geometry.js'\n// Line / area / stacked-area over shared x labels. Hover guideline + tooltip; legend click mutes.\nconst props = defineProps({ series:{type:Array,default:()=>[]}, xLabels:{type:Array,default:()=>[]}, width:{type:Number,default:560}, height:{type:Number,default:240}, area:Boolean, stacked:Boolean, axes:{type:Boolean,default:true}, legend:{type:Boolean,default:true}, interactive:{type:Boolean,default:true}, yFormat:{type:Function,default:fmtNum}, target:Number, targetLabel:String, style:Object })\nconst hidden = ref([]); const hover = ref(null)\nconst uid = 'kbline-' + Math.random().toString(36).slice(2, 8)\nconst g = computed(() => {\n const shown = props.series.filter(s=>!hidden.value.includes(s.name))\n const pad = { l: props.axes?44:4, r:8, t:10, b: props.axes?24:4 }\n const vals = shown.map(s=>s.values)\n const bands = props.stacked && shown.length ? stack(vals) : null\n const [lo0,hi0] = props.stacked ? [0, Math.max(1,...(bands[bands.length-1]||[]).map(b=>b[1]))] : extent(vals.concat(props.target!=null?[[props.target]]:[]))\n const { ticks, lo, hi } = niceTicks(lo0, hi0, 4)\n const n = Math.max(...props.series.map(s=>s.values.length), 2)\n const sx = scaleLin(0, n-1, pad.l+4, props.width-pad.r-4)\n const sy = scaleLin(lo, hi, props.height-pad.b, pad.t)\n const colorOf = s => s.color || SERIES[props.series.indexOf(s)%SERIES.length]\n /* Stacked bands close against the band below, not the baseline — overlapping\n translucent fills stack up into mud. */\n const base = sy(Math.max(lo, 0))\n const lines = shown.map((s,si)=>{\n const pts = s.values.map((v,i)=>[sx(i), sy(props.stacked?bands[si][i][1]:v)])\n const d = shapePath(pts, props.curve)\n const fill = props.stacked\n ? d + ' L' + [...s.values.keys()].reverse().map(i=>sx(i).toFixed(1)+' '+sy(bands[si][i][0]).toFixed(1)).join(' L') + ' Z'\n : closeToBaseline(d, pts, base)\n return { s, color: colorOf(s), path: d, fill, pts, at:(i)=>[sx(i), sy(props.stacked?bands[si][i][1]:s.values[i])] }\n })\n return { pad, ticks, sx, sy, n, lines, shown, colorOf }\n})\nfunction move(e){\n if (!props.interactive) return\n const r = e.currentTarget.getBoundingClientRect()\n const gg = g.value\n hover.value = Math.max(0, Math.min(gg.n-1, Math.round(((e.clientX-r.left-gg.pad.l-4)/(props.width-gg.pad.l-gg.pad.r-8))*(gg.n-1))))\n}\nfunction toggle(name){\n if (hidden.value.includes(name)) hidden.value = hidden.value.filter(x=>x!==name)\n else if (hidden.value.length+1 < props.series.length) hidden.value = [...hidden.value, name]\n}\n</script>\n<template>\n <div :style=\"{position:'relative',display:'inline-block',lineHeight:0,...style}\">\n <svg :width=\"width\" :height=\"height\" @mousemove=\"move\" @mouseleave=\"hover=null\" role=\"img\">\n <defs>\n <linearGradient v-for=\"(s,i) in series\" :key=\"'grad'+i\" :id=\"uid+'-'+i\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0%\" :stop-color=\"s.color||SERIES[i%SERIES.length]\" :stop-opacity=\"stacked ? 0.30 : 0.34\"/>\n <stop offset=\"100%\" :stop-color=\"s.color||SERIES[i%SERIES.length]\" :stop-opacity=\"stacked ? 0.10 : 0.02\"/>\n </linearGradient>\n </defs>\n <template v-if=\"axes\">\n <g v-for=\"t in g.ticks\" :key=\"t\">\n <line :x1=\"g.pad.l\" :x2=\"width-g.pad.r\" :y1=\"g.sy(t)\" :y2=\"g.sy(t)\" :stroke=\"t===0?'var(--chart-axis)':'var(--chart-grid)'\" stroke-width=\"1\"/>\n <text :x=\"g.pad.l-8\" :y=\"g.sy(t)+3\" text-anchor=\"end\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ yFormat(t) }}</text>\n </g>\n <text v-for=\"(lb,i) in xLabels\" :key=\"'x'+i\" :x=\"g.sx(i)\" :y=\"height-g.pad.b+16\" text-anchor=\"middle\" style=\"font-family:var(--font-sans);font-size:var(--text-2xs);fill:var(--chart-label)\">{{ lb }}</text>\n </template>\n <template v-if=\"target!=null\">\n <line :x1=\"g.pad.l\" :x2=\"width-g.pad.r\" :y1=\"g.sy(target)\" :y2=\"g.sy(target)\" stroke=\"var(--chart-guide)\" stroke-width=\"1\" stroke-dasharray=\"3 3\"/>\n <text :x=\"width-g.pad.r\" :y=\"g.sy(target)-4\" text-anchor=\"end\" style=\"font-family:var(--font-sans);font-size:var(--text-2xs);fill:var(--chart-label)\">{{ targetLabel || 'Target '+yFormat(target) }}</text>\n </template>\n <g v-for=\"ln in g.lines\" :key=\"ln.s.name\">\n <path v-if=\"area||stacked\" :d=\"ln.fill\" :fill=\"'url(#'+uid+'-'+series.indexOf(ln.s)+')'\"/>\n <path :d=\"ln.path\" fill=\"none\" :stroke=\"ln.color\" stroke-width=\"1.5\" stroke-linejoin=\"round\"/>\n <circle v-if=\"hover!=null && ln.s.values[hover]!=null\" :cx=\"ln.at(hover)[0]\" :cy=\"ln.at(hover)[1]\" r=\"3\" :fill=\"ln.color\" stroke=\"var(--bg-surface)\" stroke-width=\"1.5\"/>\n </g>\n <line v-if=\"hover!=null && interactive\" :x1=\"g.sx(hover)\" :x2=\"g.sx(hover)\" :y1=\"g.pad.t\" :y2=\"height-g.pad.b\" stroke=\"var(--chart-guide)\" stroke-width=\"1\" stroke-dasharray=\"2 3\"/>\n </svg>\n <div v-if=\"hover!=null && interactive\" :style=\"{position:'absolute',left:g.sx(hover)+'px',top:(height/2-10)+'px',transform:g.sx(hover)>width*0.62?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',minWidth:'90px',lineHeight:'normal'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:4px;white-space:nowrap\">{{ xLabels[hover] }}</div>\n <div v-for=\"ln in g.lines\" :key=\"ln.s.name\" style=\"display:flex;align-items:center;gap:6px;padding:1px 0\">\n <span :style=\"{width:'7px',height:'7px',borderRadius:'2px',background:ln.color,flexShrink:0}\"/>\n <span style=\"font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-secondary);white-space:nowrap\">{{ ln.s.name }}</span>\n <span style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading);margin-left:auto;padding-left:10px\">{{ yFormat(ln.s.values[hover]) }}</span>\n </div>\n </div>\n <div v-if=\"legend && series.length>1\" style=\"display:flex;flex-wrap:wrap;gap:4px 14px;padding-top:8px;line-height:normal\">\n <span v-for=\"(s,i) in series\" :key=\"s.name\" role=\"button\" tabindex=\"0\" @click=\"toggle(s.name)\"\n :style=\"{display:'inline-flex',alignItems:'center',gap:'6px',cursor:'pointer',opacity:hidden.includes(s.name)?0.4:1}\">\n <span :style=\"{width:'8px',height:'8px',borderRadius:'2px',background:s.color||SERIES[i%SERIES.length],flexShrink:0}\"/>\n <span :style=\"{fontFamily:'var(--font-sans)',fontSize:'var(--text-xs)',color:'var(--text-secondary)',textDecoration:hidden.includes(s.name)?'line-through':'none'}\">{{ s.name }}</span>\n </span>\n </div>\n </div>\n</template>\n","charts/BarChart.jsx":"import React from \"react\";\nimport { fmtNum, extent, scaleLin, stack, niceTicks, seriesColor, linePath } from \"./chart-geometry.js\";\nimport { ChartLegend, ChartTooltip, PlotGrid, thinLabels, wrapStyle } from \"./ChartKit.jsx\";\n\n/** Bar with only its outer end rounded — a stack must read as one bar, not stacked pills. */\nfunction barPath(x, y, w, h, r, side) {\n const rr = Math.max(0, Math.min(r, w / 2, h / 2));\n if (!rr) return `M${x} ${y} h${w} v${h} h${-w} Z`;\n if (side === \"top\") return `M${x} ${y + h} V${y + rr} a${rr} ${rr} 0 0 1 ${rr} ${-rr} h${w - rr * 2} a${rr} ${rr} 0 0 1 ${rr} ${rr} V${y + h} Z`;\n if (side === \"bottom\") return `M${x} ${y} V${y + h - rr} a${rr} ${rr} 0 0 0 ${rr} ${rr} h${w - rr * 2} a${rr} ${rr} 0 0 0 ${rr} ${-rr} V${y} Z`;\n if (side === \"right\") return `M${x} ${y} h${w - rr} a${rr} ${rr} 0 0 1 ${rr} ${rr} v${h - rr * 2} a${rr} ${rr} 0 0 1 ${-rr} ${rr} h${-(w - rr)} Z`;\n if (side === \"left\") return `M${x + w} ${y} h${-(w - rr)} a${rr} ${rr} 0 0 0 ${-rr} ${rr} v${h - rr * 2} a${rr} ${rr} 0 0 0 ${rr} ${rr} h${w - rr} Z`;\n return `M${x} ${y} h${w} v${h} h${-w} Z`;\n}\n\n/**\n * Bar chart. series over shared categories; mode \"grouped\" | \"stacked\" | \"stacked100\"\n * (share of each category) | \"diverging\" (single series, positive --chart-pos / negative\n * --chart-neg — variance vs. plan). orientation=\"horizontal\" swaps the axes for long\n * category names and ranked comparisons. overlay draws a line on its own secondary axis\n * (vertical only). Inside a stack only the outermost segment is rounded, so joins are flush.\n */\nexport function BarChart({ series = [], categories = [], mode = \"grouped\", orientation = \"vertical\", overlay, width = 560, height = 240, axes = true, legend = true, interactive = true, showValues = false, radius = 2, yFormat = fmtNum, overlayFormat = (v) => fmtNum(v, { pct: true }), style }) {\n const [hidden, setHidden] = React.useState([]);\n const [hover, setHover] = React.useState(null);\n const horiz = orientation === \"horizontal\";\n const shown = series.filter((s) => !hidden.includes(s.name));\n const n = categories.length || Math.max(...series.map((s) => s.values.length));\n const pct = mode === \"stacked100\";\n const stacked = mode === \"stacked\" || pct;\n\n const norm = pct\n ? shown.map((s) => s.values.map((v, i) => {\n const tot = shown.reduce((t, o) => t + (o.values[i] || 0), 0) || 1;\n return ((v || 0) / tot) * 100;\n }))\n : shown.map((s) => s.values);\n const bands = stacked && shown.length ? stack(norm) : null;\n const [lo0, hi0] = pct ? [0, 100] : stacked ? extent([(bands[bands.length - 1] || []).map((b) => b[1])]) : extent(norm);\n const { ticks, lo, hi } = niceTicks(lo0, hi0, 4);\n const labelPad = horiz ? Math.min(140, Math.max(...categories.map((c) => String(c).length)) * 6.4 + 12) : 44;\n const pad = horiz\n ? { l: axes ? labelPad : 4, r: 12, t: 6, b: axes ? 22 : 4 }\n : { l: axes ? 44 : 4, r: overlay ? 40 : 8, t: 10, b: axes ? 24 : 4 };\n\n const sv = horiz ? scaleLin(lo, hi, pad.l, width - pad.r) : scaleLin(lo, hi, height - pad.b, pad.t);\n const band = ((horiz ? height - pad.t - pad.b : width - pad.l - pad.r) / n);\n const mid = (i) => (horiz ? pad.t : pad.l) + band * i + band / 2;\n const groupW = Math.min(band * 0.66, mode === \"grouped\" ? 20 * Math.max(1, shown.length) : 30);\n const bw = mode === \"grouped\" ? groupW / Math.max(1, shown.length) : groupW;\n const colorOf = (s) => s.color || seriesColor(series.indexOf(s));\n const zero = sv(Math.max(lo, 0));\n\n const oScale = overlay && !horiz ? (() => { const [ol, oh] = extent([overlay.values]); const t = niceTicks(ol, oh, 4); return { sy: scaleLin(t.lo, t.hi, height - pad.b, pad.t), ticks: t.ticks }; })() : null;\n const move = (e) => {\n if (!interactive) return;\n const r = e.currentTarget.getBoundingClientRect();\n const off = horiz ? e.clientY - r.top - pad.t : e.clientX - r.left - pad.l;\n const i = Math.floor(off / band);\n setHover(i >= 0 && i < n ? i : null);\n };\n const fmtVal = (s, i) => (pct ? norm[shown.indexOf(s)][i].toFixed(1) + \"%\" : yFormat(s.values[i]));\n\n const rects = [];\n shown.forEach((s, si) => (pct ? norm[si] : s.values).forEach((v, i) => {\n if (v == null) return;\n let fill = mode === \"diverging\" ? (v >= 0 ? \"var(--chart-pos)\" : \"var(--chart-neg)\") : colorOf(s);\n const outer = !shown.slice(si + 1).some((o) => ((pct ? norm[shown.indexOf(o)][i] : o.values[i]) || 0) > 0);\n let x, y, w, h, side;\n if (stacked) {\n const a = sv(bands[si][i][0]), b = sv(bands[si][i][1]);\n if (horiz) { x = Math.min(a, b); w = Math.max(1, Math.abs(b - a)); y = mid(i) - bw / 2; h = bw; side = outer ? \"right\" : null; }\n else { y = Math.min(a, b); h = Math.max(1, Math.abs(a - b)); x = mid(i) - bw / 2; w = bw; side = outer ? \"top\" : null; }\n } else if (horiz) {\n const e = sv(v);\n x = Math.min(zero, e); w = Math.max(1, Math.abs(e - zero)); y = mid(i) - groupW / 2 + si * bw + 0.5; h = Math.max(1, bw - 1);\n side = v >= 0 ? \"right\" : \"left\";\n } else {\n const e = sv(v);\n y = Math.min(zero, e); h = Math.max(1, Math.abs(e - zero)); x = mid(i) - groupW / 2 + si * bw + 0.5; w = Math.max(1, bw - 1);\n side = v >= 0 ? \"top\" : \"bottom\";\n }\n rects.push({ key: s.name + i, d: barPath(x, y, w, h, radius, side), fill, x, y, w, h, v, s, i });\n }));\n\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <svg width={width} height={height} onMouseMove={move} onMouseLeave={() => setHover(null)} role=\"img\">\n {axes && !horiz ? <PlotGrid w={width} h={height} pad={pad} ticks={ticks} sy={sv} xLabels={thinLabels(categories)} sxLabel={mid} yFormat={pct ? (v) => v + \"%\" : yFormat} /> : null}\n {axes && horiz ? (\n <g>\n {ticks.map((t) => (\n <g key={t}>\n <line x1={sv(t)} x2={sv(t)} y1={pad.t} y2={height - pad.b} stroke={t === 0 ? \"var(--chart-axis)\" : \"var(--chart-grid)\"} strokeWidth=\"1\" />\n <text x={sv(t)} y={height - pad.b + 15} textAnchor=\"middle\" style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", fill: \"var(--chart-label)\" }}>{pct ? t + \"%\" : yFormat(t)}</text>\n </g>\n ))}\n {categories.map((c, i) => (\n <text key={i} x={pad.l - 8} y={mid(i) + 3.5} textAnchor=\"end\" style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", fill: hover === i ? \"var(--text-heading)\" : \"var(--chart-label)\" }}>{c}</text>\n ))}\n </g>\n ) : null}\n {hover != null ? (horiz\n ? <rect x={pad.l} y={pad.t + band * hover} width={width - pad.l - pad.r} height={band} fill=\"var(--bg-hover)\" />\n : <rect x={pad.l + band * hover} y={pad.t} width={band} height={height - pad.t - pad.b} fill=\"var(--bg-hover)\" />) : null}\n {rects.map((r) => <path key={r.key} d={r.d} fill={r.fill} opacity={hover == null || hover === r.i ? 1 : 0.55} style={{ transition: \"opacity var(--dur-fast) var(--ease-out)\" }} />)}\n {showValues ? rects.map((r) => (\n (horiz ? r.w : r.h) < 22 ? null : (\n <text key={\"v\" + r.key} x={horiz ? r.x + r.w - 5 : r.x + r.w / 2} y={horiz ? r.y + r.h / 2 + 3.5 : r.y + 12}\n textAnchor={horiz ? \"end\" : \"middle\"} style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", fill: \"var(--on-accent)\" }}>{fmtVal(r.s, r.i)}</text>\n )\n )) : null}\n {oScale ? <g>\n <path d={linePath(overlay.values.map((v, i) => [mid(i), oScale.sy(v)]))} fill=\"none\" stroke={overlay.color || \"var(--chart-total)\"} strokeWidth=\"1.5\" />\n {overlay.values.map((v, i) => <circle key={i} cx={mid(i)} cy={oScale.sy(v)} r=\"2.5\" fill={overlay.color || \"var(--chart-total)\"} />)}\n {axes ? oScale.ticks.map((t) => <text key={t} x={width - pad.r + 6} y={oScale.sy(t) + 3} style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", fill: \"var(--chart-label)\" }}>{overlayFormat(t)}</text>) : null}\n </g> : null}\n </svg>\n {hover != null && interactive ? (\n <ChartTooltip\n x={horiz ? Math.min(width - 20, pad.l + (width - pad.l - pad.r) * 0.5) : mid(hover)}\n y={horiz ? pad.t + band * hover + band / 2 : height / 2 - 10}\n flip={horiz ? true : mid(hover) > width * 0.62} title={categories[hover]}\n rows={shown.map((s) => ({ label: s.name, value: fmtVal(s, hover), color: mode === \"diverging\" ? (s.values[hover] >= 0 ? \"var(--chart-pos)\" : \"var(--chart-neg)\") : colorOf(s) }))\n .concat(overlay ? [{ label: overlay.name, value: overlayFormat(overlay.values[hover]), color: overlay.color || \"var(--chart-total)\" }] : [])} />\n ) : null}\n {legend && (series.length > 1 || overlay) ? (\n <ChartLegend style={{ paddingTop: 8 }}\n items={series.map((s) => ({ label: s.name, color: colorOf(s) })).concat(overlay ? [{ label: overlay.name, color: overlay.color || \"var(--chart-total)\" }] : [])}\n hidden={hidden}\n onToggle={(l) => series.some((s) => s.name === l) && setHidden(hidden.includes(l) ? hidden.filter((x) => x !== l) : hidden.length + 1 < series.length ? [...hidden, l] : hidden)} />\n ) : null}\n </div>\n );\n}\n","charts/BarChart.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { fmtNum, extent, scaleLin, stack, niceTicks, SERIES, linePath } from './chart-geometry.js'\n/**\n * Bar chart. mode \"grouped\" | \"stacked\" | \"stacked100\" | \"diverging\";\n * orientation=\"horizontal\" swaps the axes. Only the outermost stack segment is rounded,\n * so colour joins inside a stack stay flush. overlay = line on a secondary axis (vertical).\n */\nconst props = defineProps({ series:{type:Array,default:()=>[]}, categories:{type:Array,default:()=>[]}, mode:{type:String,default:'grouped'}, orientation:{type:String,default:'vertical'}, overlay:Object, width:{type:Number,default:560}, height:{type:Number,default:240}, axes:{type:Boolean,default:true}, legend:{type:Boolean,default:true}, interactive:{type:Boolean,default:true}, showValues:Boolean, radius:{type:Number,default:2}, yFormat:{type:Function,default:fmtNum}, overlayFormat:{type:Function,default:(v)=>fmtNum(v,{pct:true})}, style:Object })\nconst hidden = ref([]); const hover = ref(null)\nfunction barPath(x,y,w,h,r,side){\n const rr = Math.max(0, Math.min(r, w/2, h/2))\n if (!rr) return `M${x} ${y} h${w} v${h} h${-w} Z`\n if (side==='top') return `M${x} ${y+h} V${y+rr} a${rr} ${rr} 0 0 1 ${rr} ${-rr} h${w-rr*2} a${rr} ${rr} 0 0 1 ${rr} ${rr} V${y+h} Z`\n if (side==='bottom') return `M${x} ${y} V${y+h-rr} a${rr} ${rr} 0 0 0 ${rr} ${rr} h${w-rr*2} a${rr} ${rr} 0 0 0 ${rr} ${-rr} V${y} Z`\n if (side==='right') return `M${x} ${y} h${w-rr} a${rr} ${rr} 0 0 1 ${rr} ${rr} v${h-rr*2} a${rr} ${rr} 0 0 1 ${-rr} ${rr} h${-(w-rr)} Z`\n if (side==='left') return `M${x+w} ${y} h${-(w-rr)} a${rr} ${rr} 0 0 0 ${-rr} ${rr} v${h-rr*2} a${rr} ${rr} 0 0 0 ${rr} ${rr} h${w-rr} Z`\n return `M${x} ${y} h${w} v${h} h${-w} Z`\n}\nconst g = computed(() => {\n const horiz = props.orientation === 'horizontal'\n const shown = props.series.filter(s=>!hidden.value.includes(s.name))\n const n = props.categories.length || Math.max(...props.series.map(s=>s.values.length))\n const pct = props.mode === 'stacked100'\n const stacked = props.mode === 'stacked' || pct\n const norm = pct\n ? shown.map(s=>s.values.map((v,i)=>{ const tot = shown.reduce((t,o)=>t+(o.values[i]||0),0)||1; return ((v||0)/tot)*100 }))\n : shown.map(s=>s.values)\n const bands = stacked && shown.length ? stack(norm) : null\n const ext = pct ? [0,100] : (stacked ? extent([(bands[bands.length-1]||[]).map(b=>b[1])]) : extent(norm))\n const { ticks, lo, hi } = niceTicks(ext[0], ext[1], 4)\n const labelPad = horiz ? Math.min(140, Math.max(...props.categories.map(c=>String(c).length))*6.4+12) : 44\n const pad = horiz ? { l: props.axes?labelPad:4, r:12, t:6, b: props.axes?22:4 }\n : { l: props.axes?44:4, r: props.overlay?40:8, t:10, b: props.axes?24:4 }\n const sv = horiz ? scaleLin(lo,hi,pad.l,props.width-pad.r) : scaleLin(lo,hi,props.height-pad.b,pad.t)\n const band = (horiz ? props.height-pad.t-pad.b : props.width-pad.l-pad.r)/n\n const mid = i => (horiz?pad.t:pad.l) + band*i + band/2\n const groupW = Math.min(band*0.66, props.mode==='grouped' ? 20*Math.max(1,shown.length) : 30)\n const bw = props.mode==='grouped' ? groupW/Math.max(1,shown.length) : groupW\n const colorOf = s => s.color || SERIES[props.series.indexOf(s)%SERIES.length]\n const zero = sv(Math.max(lo,0))\n const rects = []\n shown.forEach((s,si)=>(pct?norm[si]:s.values).forEach((v,i)=>{\n if (v==null) return\n const fill = props.mode==='diverging' ? (v>=0?'var(--chart-pos)':'var(--chart-neg)') : colorOf(s)\n const outer = !shown.slice(si+1).some(o=>((pct?norm[shown.indexOf(o)][i]:o.values[i])||0)>0)\n let x,y,w,h,side\n if (stacked) {\n const a = sv(bands[si][i][0]), b = sv(bands[si][i][1])\n if (horiz) { x=Math.min(a,b); w=Math.max(1,Math.abs(b-a)); y=mid(i)-bw/2; h=bw; side=outer?'right':null }\n else { y=Math.min(a,b); h=Math.max(1,Math.abs(a-b)); x=mid(i)-bw/2; w=bw; side=outer?'top':null }\n } else if (horiz) {\n const e = sv(v); x=Math.min(zero,e); w=Math.max(1,Math.abs(e-zero)); y=mid(i)-groupW/2+si*bw+0.5; h=Math.max(1,bw-1); side=v>=0?'right':'left'\n } else {\n const e = sv(v); y=Math.min(zero,e); h=Math.max(1,Math.abs(e-zero)); x=mid(i)-groupW/2+si*bw+0.5; w=Math.max(1,bw-1); side=v>=0?'top':'bottom'\n }\n rects.push({ key:s.name+i, d:barPath(x,y,w,h,props.radius,side), fill, x, y, w, h, s, i,\n text: pct ? norm[si][i].toFixed(1)+'%' : props.yFormat(s.values[i]) })\n }))\n let ov = null\n if (props.overlay && !horiz) {\n const e2 = extent([props.overlay.values]); const t = niceTicks(e2[0],e2[1],4)\n const osy = scaleLin(t.lo,t.hi,props.height-pad.b,pad.t)\n ov = { path: linePath(props.overlay.values.map((v,i)=>[mid(i),osy(v)])), dots: props.overlay.values.map((v,i)=>[mid(i),osy(v)]), ticks:t.ticks, osy, color: props.overlay.color||'var(--chart-total)' }\n }\n return { horiz, shown, pct, stacked, ticks, sv, band, mid, pad, rects, ov, n, colorOf,\n fmtAxis: pct ? (v=>v+'%') : props.yFormat }\n})\nfunction move(e){\n if(!props.interactive) return\n const r = e.currentTarget.getBoundingClientRect()\n const off = g.value.horiz ? e.clientY-r.top-g.value.pad.t : e.clientX-r.left-g.value.pad.l\n const i = Math.floor(off/g.value.band)\n hover.value = i>=0 && i<g.value.n ? i : null\n}\nfunction toggle(name){\n if (hidden.value.includes(name)) hidden.value = hidden.value.filter(x=>x!==name)\n else if (hidden.value.length+1 < props.series.length) hidden.value = [...hidden.value, name]\n}\nfunction rowVal(s){\n const i = hover.value\n return g.value.pct ? (g.value.rects.find(r=>r.s===s && r.i===i)||{}).text : props.yFormat(s.values[i])\n}\n</script>\n<template>\n <div :style=\"{position:'relative',display:'inline-block',lineHeight:0,...style}\">\n <svg :width=\"width\" :height=\"height\" @mousemove=\"move\" @mouseleave=\"hover=null\" role=\"img\">\n <template v-if=\"axes\">\n <g v-for=\"t in g.ticks\" :key=\"t\">\n <line v-if=\"g.horiz\" :x1=\"g.sv(t)\" :x2=\"g.sv(t)\" :y1=\"g.pad.t\" :y2=\"height-g.pad.b\" :stroke=\"t===0?'var(--chart-axis)':'var(--chart-grid)'\" stroke-width=\"1\"/>\n <line v-else :x1=\"g.pad.l\" :x2=\"width-g.pad.r\" :y1=\"g.sv(t)\" :y2=\"g.sv(t)\" :stroke=\"t===0?'var(--chart-axis)':'var(--chart-grid)'\" stroke-width=\"1\"/>\n <text v-if=\"g.horiz\" :x=\"g.sv(t)\" :y=\"height-g.pad.b+15\" text-anchor=\"middle\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ g.fmtAxis(t) }}</text>\n <text v-else :x=\"g.pad.l-8\" :y=\"g.sv(t)+3\" text-anchor=\"end\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ g.fmtAxis(t) }}</text>\n </g>\n <text v-for=\"(c,i) in categories\" :key=\"'c'+i\"\n :x=\"g.horiz ? g.pad.l-8 : g.mid(i)\" :y=\"g.horiz ? g.mid(i)+3.5 : height-g.pad.b+16\"\n :text-anchor=\"g.horiz ? 'end' : 'middle'\"\n :style=\"{fontFamily:'var(--font-sans)',fontSize:g.horiz?'10.5px':'10px',fill:hover===i?'var(--text-heading)':'var(--chart-label)'}\">{{ c }}</text>\n </template>\n <rect v-if=\"hover!=null && g.horiz\" :x=\"g.pad.l\" :y=\"g.pad.t+g.band*hover\" :width=\"width-g.pad.l-g.pad.r\" :height=\"g.band\" fill=\"var(--bg-hover)\"/>\n <rect v-else-if=\"hover!=null\" :x=\"g.pad.l+g.band*hover\" :y=\"g.pad.t\" :width=\"g.band\" :height=\"height-g.pad.t-g.pad.b\" fill=\"var(--bg-hover)\"/>\n <path v-for=\"r in g.rects\" :key=\"r.key\" :d=\"r.d\" :fill=\"r.fill\" :opacity=\"hover==null||hover===r.i?1:0.55\" style=\"transition:opacity var(--dur-fast) var(--ease-out)\"/>\n <template v-if=\"showValues\">\n <text v-for=\"r in g.rects.filter(r=>(g.horiz?r.w:r.h)>=22)\" :key=\"'v'+r.key\"\n :x=\"g.horiz ? r.x+r.w-5 : r.x+r.w/2\" :y=\"g.horiz ? r.y+r.h/2+3.5 : r.y+12\"\n :text-anchor=\"g.horiz ? 'end' : 'middle'\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--on-accent)\">{{ r.text }}</text>\n </template>\n <template v-if=\"g.ov\">\n <path :d=\"g.ov.path\" fill=\"none\" :stroke=\"g.ov.color\" stroke-width=\"1.5\"/>\n <circle v-for=\"(d,i) in g.ov.dots\" :key=\"'o'+i\" :cx=\"d[0]\" :cy=\"d[1]\" r=\"2.5\" :fill=\"g.ov.color\"/>\n <text v-if=\"axes\" v-for=\"t in g.ov.ticks\" :key=\"'ot'+t\" :x=\"width-g.pad.r+6\" :y=\"g.ov.osy(t)+3\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ overlayFormat(t) }}</text>\n </template>\n </svg>\n <div v-if=\"hover!=null && interactive\" :style=\"{position:'absolute',left:(g.horiz ? Math.min(width-20, g.pad.l+(width-g.pad.l-g.pad.r)*0.5) : g.mid(hover))+'px',top:(g.horiz ? g.pad.t+g.band*hover+g.band/2 : height/2-10)+'px',transform:(g.horiz || g.mid(hover)>width*0.62)?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal',whiteSpace:'nowrap'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:4px\">{{ categories[hover] }}</div>\n <div v-for=\"s in g.shown\" :key=\"s.name\" style=\"display:flex;align-items:center;gap:6px;padding:1px 0\">\n <span :style=\"{width:'7px',height:'7px',borderRadius:'2px',background:mode==='diverging'?(s.values[hover]>=0?'var(--chart-pos)':'var(--chart-neg)'):g.colorOf(s),flexShrink:0}\"/>\n <span style=\"font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-secondary)\">{{ s.name }}</span>\n <span style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading);margin-left:auto;padding-left:10px\">{{ rowVal(s) }}</span>\n </div>\n <div v-if=\"overlay\" style=\"display:flex;align-items:center;gap:6px;padding:1px 0\">\n <span :style=\"{width:'7px',height:'7px',borderRadius:'2px',background:overlay.color||'var(--chart-total)'}\"/>\n <span style=\"font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-secondary)\">{{ overlay.name }}</span>\n <span style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading);margin-left:auto;padding-left:10px\">{{ overlayFormat(overlay.values[hover]) }}</span>\n </div>\n </div>\n <div v-if=\"legend && (series.length>1 || overlay)\" style=\"display:flex;flex-wrap:wrap;gap:4px 14px;padding-top:8px;line-height:normal\">\n <span v-for=\"(s,i) in series\" :key=\"s.name\" role=\"button\" tabindex=\"0\" @click=\"toggle(s.name)\"\n :style=\"{display:'inline-flex',alignItems:'center',gap:'6px',cursor:'pointer',opacity:hidden.includes(s.name)?0.4:1}\">\n <span :style=\"{width:'8px',height:'8px',borderRadius:'2px',background:s.color||SERIES[i%SERIES.length]}\"/>\n <span :style=\"{fontFamily:'var(--font-sans)',fontSize:'var(--text-xs)',color:'var(--text-secondary)',textDecoration:hidden.includes(s.name)?'line-through':'none'}\">{{ s.name }}</span>\n </span>\n <span v-if=\"overlay\" style=\"display:inline-flex;align-items:center;gap:6px\">\n <span :style=\"{width:'8px',height:'8px',borderRadius:'2px',background:overlay.color||'var(--chart-total)'}\"/>\n <span style=\"font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-secondary)\">{{ overlay.name }}</span>\n </span>\n </div>\n </div>\n</template>\n","charts/BarList.jsx":"import React from \"react\";\nimport { fmtNum } from \"./chart-geometry.js\";\n\n/**\n * Horizontal labeled bars — top-N rankings, budget consumption, category comparison.\n * rows: [{label, value, color?, delta?}] — delta renders a signed % at the right.\n */\nexport function BarList({ rows = [], max, width = 360, valueFormat = fmtNum, showTrack = true, barHeight = 8, interactive = true, onSelect, style }) {\n const [hover, setHover] = React.useState(null);\n const top = max != null ? max : Math.max(1, ...rows.map((r) => r.value));\n const total = rows.reduce((s, r) => s + r.value, 0) || 1;\n return (\n <div style={{ display: \"flex\", flexDirection: \"column\", gap: 10, width, ...style }}>\n {rows.map((r) => (\n <div key={r.label} onMouseEnter={interactive ? () => setHover(r.label) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}\n onClick={onSelect ? () => onSelect(r) : undefined}\n style={{ opacity: hover == null || hover === r.label ? 1 : 0.5, cursor: onSelect ? \"pointer\" : \"default\", transition: \"var(--t-hover)\" }}>\n <div style={{ display: \"flex\", alignItems: \"baseline\", gap: 8, paddingBottom: 4 }}>\n <span style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-xs)\", color: \"var(--text-body)\", whiteSpace: \"nowrap\", overflow: \"hidden\", textOverflow: \"ellipsis\" }}>{r.label}</span>\n <span style={{ marginLeft: \"auto\", fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", color: \"var(--text-heading)\" }}>{valueFormat(r.value)}</span>\n {hover === r.label && r.delta == null ? <span style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", color: \"var(--text-muted)\", paddingLeft: 6 }}>{((r.value / total) * 100).toFixed(1)}%</span> : null}\n {r.delta != null ? <span style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", color: r.delta >= 0 ? \"var(--chart-pos)\" : \"var(--chart-neg)\", minWidth: 38, textAlign: \"right\" }}>{(r.delta >= 0 ? \"+\" : \"\") + r.delta.toFixed(1)}%</span> : null}\n </div>\n <div style={{ height: barHeight, borderRadius: \"var(--radius-full)\", background: showTrack ? \"var(--chart-track)\" : \"transparent\", overflow: \"hidden\" }}>\n <div style={{ width: Math.max(1, (r.value / top) * 100) + \"%\", height: \"100%\", borderRadius: \"var(--radius-full)\", background: r.color || \"var(--series-1)\", filter: hover === r.label ? \"brightness(1.08)\" : \"none\", transition: \"width var(--dur) var(--ease-out), filter var(--dur-fast) var(--ease-out)\" }} />\n </div>\n </div>\n ))}\n </div>\n );\n}\n","charts/BarList.vue":"<script setup>\nimport { computed, ref } from 'vue'\nimport { fmtNum } from './chart-geometry.js'\n// Horizontal labeled bars — top-N rankings, budget consumption.\nconst props = defineProps({ rows:{type:Array,default:()=>[]}, max:Number, width:{type:Number,default:360}, barHeight:{type:Number,default:8}, showTrack:{type:Boolean,default:true}, valueFormat:{type:Function,default:fmtNum}, interactive:{type:Boolean,default:true}, style:Object })\nconst top = computed(() => props.max!=null ? props.max : Math.max(1,...props.rows.map(r=>r.value)))\nconst total = computed(() => props.rows.reduce((s,r)=>s+r.value,0)||1)\nconst hover = ref(null)\n</script>\n<template>\n <div :style=\"{display:'flex',flexDirection:'column',gap:'10px',width:width+'px',...style}\">\n <div v-for=\"r in rows\" :key=\"r.label\" @mouseenter=\"interactive && (hover=r.label)\" @mouseleave=\"interactive && (hover=null)\"\n :style=\"{opacity:hover==null||hover===r.label?1:0.5,transition:'var(--t-hover)'}\">\n <div style=\"display:flex;align-items:baseline;gap:8px;padding-bottom:4px\">\n <span style=\"font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-body);white-space:nowrap;overflow:hidden;text-overflow:ellipsis\">{{ r.label }}</span>\n <span style=\"margin-left:auto;font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)\">{{ valueFormat(r.value) }}</span>\n <span v-if=\"hover===r.label && r.delta==null\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);color:var(--text-muted);padding-left:6px\">{{ ((r.value/total)*100).toFixed(1) }}%</span>\n <span v-if=\"r.delta!=null\" :style=\"{fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums',fontSize:'var(--text-2xs-mono)',color:r.delta>=0?'var(--chart-pos)':'var(--chart-neg)',minWidth:'38px',textAlign:'right'}\">{{ (r.delta>=0?'+':'')+r.delta.toFixed(1) }}%</span>\n </div>\n <div :style=\"{height:barHeight+'px',borderRadius:'var(--radius-full)',background:showTrack?'var(--chart-track)':'transparent',overflow:'hidden'}\">\n <div :style=\"{width:Math.max(1,(r.value/top)*100)+'%',height:'100%',borderRadius:'var(--radius-full)',background:r.color||'var(--series-1)',filter:hover===r.label?'brightness(1.08)':'none',transition:'width var(--dur) var(--ease-out), filter var(--dur-fast) var(--ease-out)'}\"/>\n </div>\n </div>\n </div>\n</template>\n","charts/DonutChart.jsx":"import React from \"react\";\nimport { fmtNum, arcPath, polar, seriesColor } from \"./chart-geometry.js\";\nimport { ChartTooltip, wrapStyle } from \"./ChartKit.jsx\";\n\n/**\n * Donut / pie. segments: [{label, value, color?}]. The donut keeps a KPI center\n * (total by default, or a fixed centerValue); pie={true} fills to the middle and\n * relies on the tooltip. Hovering lifts a segment and pins a tooltip at its arc.\n */\nexport function DonutChart({ segments = [], size = 180, thickness = 22, pie = false, centerLabel, centerValue, legend = true, interactive = true, valueFormat = fmtNum, style }) {\n const [hover, setHover] = React.useState(null);\n const total = segments.reduce((s, x) => s + x.value, 0) || 1;\n const cx = size / 2, cy = size / 2;\n const rO = size / 2 - 4, rI = pie ? 0 : rO - thickness;\n let a = 0;\n const arcs = segments.map((s, i) => {\n const a0 = a, a1 = a + (s.value / total) * 360;\n a = a1;\n return { s, i, a0, a1, mid: (a0 + a1) / 2 };\n });\n const colorOf = (s, i) => s.color || seriesColor(i);\n const h = hover != null ? arcs[hover] : null;\n /* A pinned centerValue is a stat, not a readout: swapping it on hover made the tile\n look broken, so the tooltip carries per-segment detail and the center stays put. */\n const swapCenter = centerValue == null;\n const tipAt = h ? polar(cx, cy, pie ? rO * 0.62 : (rO + rI) / 2, h.mid) : null;\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 18 }}>\n <div style={{ position: \"relative\", width: size, height: size, flexShrink: 0 }}>\n <svg width={size} height={size} role=\"img\">\n {arcs.map(({ s, i, a0, a1, mid }) => {\n const lift = interactive && hover === i ? 3 : 0;\n const [lx, ly] = polar(0, 0, lift, mid);\n return (\n <path key={i} d={arcPath(cx + lx, cy + ly, rO, rI, a0 + 0.6, Math.max(a0 + 0.8, a1 - 0.6))}\n fill={colorOf(s, i)} opacity={hover == null || hover === i ? 1 : 0.35}\n onMouseEnter={interactive ? () => setHover(i) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}\n style={{ transition: \"opacity var(--dur-fast) var(--ease-out)\", cursor: interactive ? \"pointer\" : \"default\" }} />\n );\n })}\n </svg>\n {!pie ? (\n <div style={{ position: \"absolute\", inset: 0, display: \"flex\", flexDirection: \"column\", alignItems: \"center\", justifyContent: \"center\", pointerEvents: \"none\", lineHeight: \"normal\", gap: 3 }}>\n <span style={{ fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-light)\", fontSize: Math.round(size / 6.4), lineHeight: 1, color: \"var(--text-heading)\" }}>\n {h && swapCenter ? valueFormat(h.s.value) : centerValue != null ? centerValue : valueFormat(total)}\n </span>\n <span style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", lineHeight: 1.2, color: \"var(--text-muted)\", maxWidth: rI * 1.8, textAlign: \"center\", whiteSpace: \"nowrap\", overflow: \"hidden\", textOverflow: \"ellipsis\" }}>\n {h && swapCenter ? h.s.label : centerLabel || \"Total\"}\n </span>\n </div>\n ) : null}\n {h && interactive ? (\n <ChartTooltip x={tipAt[0]} y={tipAt[1]} flip={tipAt[0] > size * 0.55} title={h.s.label}\n rows={[{ label: \"value\", value: valueFormat(h.s.value), color: colorOf(h.s, h.i) }, { label: \"share\", value: ((h.s.value / total) * 100).toFixed(1) + \"%\" }]} />\n ) : null}\n </div>\n {legend ? (\n <div style={{ display: \"flex\", flexDirection: \"column\", gap: 6, lineHeight: \"normal\" }}>\n {segments.map((s, i) => (\n <span key={s.label} onMouseEnter={interactive ? () => setHover(i) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}\n style={{ display: \"inline-flex\", alignItems: \"center\", gap: 7, cursor: interactive ? \"default\" : undefined, opacity: hover == null || hover === i ? 1 : 0.45, transition: \"var(--t-hover)\" }}>\n <span style={{ width: 8, height: 8, borderRadius: 2, background: colorOf(s, i), flexShrink: 0 }} />\n <span style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-xs)\", color: \"var(--text-secondary)\" }}>{s.label}</span>\n <span style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", color: \"var(--text-heading)\", marginLeft: 6 }}>{((s.value / total) * 100).toFixed(0)}%</span>\n </span>\n ))}\n </div>\n ) : null}\n </div>\n </div>\n );\n}\n","charts/DonutChart.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { fmtNum, arcPath, polar, SERIES } from './chart-geometry.js'\n// Donut with a KPI center (or pie). Hovering a segment or its legend row lifts it.\nconst props = defineProps({ segments:{type:Array,default:()=>[]}, size:{type:Number,default:180}, thickness:{type:Number,default:22}, pie:Boolean, centerLabel:String, centerValue:[String,Number], legend:{type:Boolean,default:true}, interactive:{type:Boolean,default:true}, valueFormat:{type:Function,default:fmtNum}, style:Object })\nconst hover = ref(null)\nconst total = computed(() => props.segments.reduce((s,x)=>s+x.value,0) || 1)\nconst arcs = computed(() => {\n const cx=props.size/2, cy=props.size/2, rO=props.size/2-4, rI=props.pie?0:rO-props.thickness\n let a=0\n return props.segments.map((s,i)=>{\n const a0=a, a1=a+(s.value/total.value)*360; a=a1\n const mid=(a0+a1)/2, rad=((mid-90)*Math.PI)/180\n const lift = props.interactive && hover.value===i ? 3 : 0\n return { s, i, mid, color: s.color||SERIES[i%SERIES.length],\n d: arcPath(cx+lift*Math.cos(rad), cy+lift*Math.sin(rad), rO, rI, a0+0.6, Math.max(a0+0.8,a1-0.6)) }\n })\n})\nconst rI = computed(() => props.size/2-4-props.thickness)\nconst tipStyle = computed(() => {\n const a = arcs.value[hover.value]\n const r = props.pie ? (props.size/2-4)*0.62 : (props.size/2-4+rI.value)/2\n const p = polar(props.size/2, props.size/2, r, a.mid)\n return { position:'absolute', left:p[0]+'px', top:p[1]+'px',\n transform: p[0] > props.size*0.55 ? 'translate(calc(-100% - 10px),-50%)' : 'translate(10px,-50%)',\n pointerEvents:'none', zIndex:5, background:'var(--bg-surface)', border:'1px solid var(--border-card)',\n borderRadius:'var(--radius-md)', boxShadow:'var(--shadow-menu)', padding:'7px 10px', lineHeight:'normal' }\n})\n</script>\n<template>\n <div :style=\"{display:'inline-block',...style}\">\n <div style=\"display:flex;align-items:center;gap:18px\">\n <div :style=\"{position:'relative',width:size+'px',height:size+'px',flexShrink:0}\">\n <svg :width=\"size\" :height=\"size\" role=\"img\">\n <path v-for=\"a in arcs\" :key=\"a.i\" :d=\"a.d\" :fill=\"a.color\" :opacity=\"hover==null||hover===a.i?1:.35\"\n @mouseenter=\"interactive && (hover=a.i)\" @mouseleave=\"interactive && (hover=null)\"\n :style=\"{transition:'opacity var(--dur-fast) var(--ease-out)',cursor:interactive?'pointer':'default'}\"/>\n </svg>\n <!-- A pinned centerValue is a stat, not a readout: the tooltip carries per-segment detail. -->\n <div v-if=\"!pie\" style=\"position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;line-height:normal;gap:3px\">\n <span :style=\"{fontFamily:'var(--font-sans)',fontWeight:'var(--weight-light)',fontSize:Math.round(size/6.4)+'px',lineHeight:1,color:'var(--text-heading)'}\">\n {{ hover!=null && centerValue==null ? valueFormat(segments[hover].value) : (centerValue!=null ? centerValue : valueFormat(total)) }}</span>\n <span :style=\"{fontFamily:'var(--font-sans)',fontSize:'var(--text-2xs)',lineHeight:1.2,color:'var(--text-muted)',maxWidth:rI*1.8+'px',textAlign:'center',whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}\">\n {{ hover!=null && centerValue==null ? segments[hover].label : (centerLabel||'Total') }}</span>\n </div>\n <div v-if=\"hover!=null && interactive\" :style=\"tipStyle\" >\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:3px;white-space:nowrap\">{{ segments[hover].label }}</div>\n <div style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading);white-space:nowrap\">{{ valueFormat(segments[hover].value) }} · {{ ((segments[hover].value/total)*100).toFixed(1) }}%</div>\n </div>\n </div>\n <div v-if=\"legend\" style=\"display:flex;flex-direction:column;gap:6px\">\n <span v-for=\"a in arcs\" :key=\"a.s.label\" @mouseenter=\"interactive && (hover=a.i)\" @mouseleave=\"interactive && (hover=null)\"\n :style=\"{display:'inline-flex',alignItems:'center',gap:'7px',opacity:hover==null||hover===a.i?1:.45,transition:'var(--t-hover)'}\">\n <span :style=\"{width:'8px',height:'8px',borderRadius:'2px',background:a.color,flexShrink:0}\"/>\n <span style=\"font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-secondary)\">{{ a.s.label }}</span>\n <span style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading);margin-left:6px\">{{ ((a.s.value/total)*100).toFixed(0) }}%</span>\n </span>\n </div>\n </div>\n </div>\n</template>\n","charts/ScatterChart.jsx":"import React from \"react\";\nimport { fmtNum, extent, scaleLin, niceTicks, seriesColor } from \"./chart-geometry.js\";\nimport { ChartLegend, ChartTooltip, PlotGrid, wrapStyle } from \"./ChartKit.jsx\";\n\n/**\n * Scatter / bubble. points: [{x, y, r?, label?, series?}] — r in data units scaled to\n * 3–14px bubbles. Hover pins a tooltip to the nearest point.\n */\nexport function ScatterChart({ points = [], width = 560, height = 260, xFormat = fmtNum, yFormat = fmtNum, xLabel, yLabel, axes = true, legend = true, interactive = true, style }) {\n const [hover, setHover] = React.useState(null);\n const names = [...new Set(points.map((p) => p.series).filter(Boolean))];\n const pad = { l: axes ? 44 : 4, r: 10, t: 10, b: axes ? 26 : 4 };\n const [xl, xh] = extent([points.map((p) => p.x)], false);\n const [yl, yh] = extent([points.map((p) => p.y)], false);\n const tx = niceTicks(xl, xh, 5), ty = niceTicks(yl, yh, 4);\n const sx = scaleLin(tx.lo, tx.hi, pad.l + 6, width - pad.r - 6);\n const sy = scaleLin(ty.lo, ty.hi, height - pad.b - 6, pad.t + 6);\n const rs = points.map((p) => p.r != null ? p.r : 1);\n const [rl, rh] = extent([rs], false);\n const sr = (r) => 3 + ((r - rl) / (rh - rl || 1)) * 11;\n const colorOf = (p) => p.color || seriesColor(p.series ? names.indexOf(p.series) : 0);\n const move = (e) => {\n if (!interactive) return;\n const rc = e.currentTarget.getBoundingClientRect();\n const mx = e.clientX - rc.left, my = e.clientY - rc.top;\n let best = null, bd = 24 * 24;\n points.forEach((p, i) => { const d = (sx(p.x) - mx) ** 2 + (sy(p.y) - my) ** 2; if (d < bd) { bd = d; best = i; } });\n setHover(best);\n };\n const h = hover != null ? points[hover] : null;\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <svg width={width} height={height} onMouseMove={move} onMouseLeave={() => setHover(null)} role=\"img\">\n {axes ? <g>\n <PlotGrid w={width} h={height} pad={pad} ticks={ty.ticks} sy={sy} xLabels={[]} sxLabel={() => 0} yFormat={yFormat} />\n {tx.ticks.map((t) => <text key={t} x={sx(t)} y={height - pad.b + 16} textAnchor=\"middle\" style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", fill: \"var(--chart-label)\" }}>{xFormat(t)}</text>)}\n {xLabel ? <text x={width - pad.r} y={height - 2} textAnchor=\"end\" style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", fill: \"var(--chart-label)\" }}>{xLabel}</text> : null}\n {yLabel ? <text x={pad.l} y={pad.t - 1} style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", fill: \"var(--chart-label)\" }}>{yLabel}</text> : null}\n </g> : null}\n {points.map((p, i) => (\n <circle key={i} cx={sx(p.x)} cy={sy(p.y)} r={sr(p.r != null ? p.r : 1)} fill={colorOf(p)}\n opacity={hover == null ? 0.55 : hover === i ? 0.9 : 0.2} stroke={hover === i ? \"var(--bg-surface)\" : \"none\"} strokeWidth=\"1.5\"\n style={{ transition: \"opacity var(--dur-fast) var(--ease-out)\" }} />\n ))}\n </svg>\n {h && interactive ? (\n <ChartTooltip x={sx(h.x)} y={sy(h.y)} flip={sx(h.x) > width * 0.62} title={h.label || h.series}\n rows={[{ label: xLabel || \"x\", value: xFormat(h.x) }, { label: yLabel || \"y\", value: yFormat(h.y) }].concat(h.r != null ? [{ label: \"size\", value: fmtNum(h.r) }] : [])} />\n ) : null}\n {legend && names.length > 1 ? <ChartLegend style={{ paddingTop: 8 }} items={names.map((nm, i) => ({ label: nm, color: seriesColor(i) }))} /> : null}\n </div>\n );\n}\n","charts/ScatterChart.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { fmtNum, extent, scaleLin, niceTicks, SERIES } from './chart-geometry.js'\n// Scatter / bubble with nearest-point tooltip.\nconst props = defineProps({ points:{type:Array,default:()=>[]}, width:{type:Number,default:560}, height:{type:Number,default:260}, xFormat:{type:Function,default:fmtNum}, yFormat:{type:Function,default:fmtNum}, xLabel:String, yLabel:String, axes:{type:Boolean,default:true}, legend:{type:Boolean,default:true}, interactive:{type:Boolean,default:true}, style:Object })\nconst hover = ref(null)\nconst g = computed(() => {\n const names = [...new Set(props.points.map(p=>p.series).filter(Boolean))]\n const pad = { l: props.axes?44:4, r:10, t:10, b: props.axes?26:4 }\n const [xl,xh] = extent([props.points.map(p=>p.x)], false)\n const [yl,yh] = extent([props.points.map(p=>p.y)], false)\n const tx = niceTicks(xl,xh,5), ty = niceTicks(yl,yh,4)\n const sx = scaleLin(tx.lo,tx.hi,pad.l+6,props.width-pad.r-6)\n const sy = scaleLin(ty.lo,ty.hi,props.height-pad.b-6,pad.t+6)\n const rs = props.points.map(p=>p.r!=null?p.r:1)\n const [rl,rh] = extent([rs], false)\n return { names, pad, tx, ty, sx, sy,\n dots: props.points.map((p,i)=>({ p, i, cx:sx(p.x), cy:sy(p.y), r: 3+(((p.r!=null?p.r:1)-rl)/(rh-rl||1))*11,\n color: p.color || SERIES[(p.series?names.indexOf(p.series):0)%SERIES.length] })) }\n})\nfunction move(e){\n if (!props.interactive) return\n const rc = e.currentTarget.getBoundingClientRect()\n const mx = e.clientX-rc.left, my = e.clientY-rc.top\n let best=null, bd=576\n g.value.dots.forEach(d=>{ const dd=(d.cx-mx)**2+(d.cy-my)**2; if(dd<bd){bd=dd;best=d.i} })\n hover.value = best\n}\n</script>\n<template>\n <div :style=\"{position:'relative',display:'inline-block',lineHeight:0,...style}\">\n <svg :width=\"width\" :height=\"height\" @mousemove=\"move\" @mouseleave=\"hover=null\" role=\"img\">\n <template v-if=\"axes\">\n <g v-for=\"t in g.ty.ticks\" :key=\"t\">\n <line :x1=\"g.pad.l\" :x2=\"width-g.pad.r\" :y1=\"g.sy(t)\" :y2=\"g.sy(t)\" :stroke=\"t===0?'var(--chart-axis)':'var(--chart-grid)'\" stroke-width=\"1\"/>\n <text :x=\"g.pad.l-8\" :y=\"g.sy(t)+3\" text-anchor=\"end\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ yFormat(t) }}</text>\n </g>\n <text v-for=\"t in g.tx.ticks\" :key=\"'x'+t\" :x=\"g.sx(t)\" :y=\"height-g.pad.b+16\" text-anchor=\"middle\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ xFormat(t) }}</text>\n <text v-if=\"xLabel\" :x=\"width-g.pad.r\" :y=\"height-2\" text-anchor=\"end\" style=\"font-family:var(--font-sans);font-size:var(--text-2xs);fill:var(--chart-label)\">{{ xLabel }}</text>\n <text v-if=\"yLabel\" :x=\"g.pad.l\" :y=\"g.pad.t-1\" style=\"font-family:var(--font-sans);font-size:var(--text-2xs);fill:var(--chart-label)\">{{ yLabel }}</text>\n </template>\n <circle v-for=\"d in g.dots\" :key=\"d.i\" :cx=\"d.cx\" :cy=\"d.cy\" :r=\"d.r\" :fill=\"d.color\"\n :opacity=\"hover==null?.55:hover===d.i?.9:.2\" :stroke=\"hover===d.i?'var(--bg-surface)':'none'\" stroke-width=\"1.5\"\n style=\"transition:opacity var(--dur-fast) var(--ease-out)\"/>\n </svg>\n <div v-if=\"hover!=null && interactive\" :style=\"{position:'absolute',left:g.dots[hover].cx+'px',top:g.dots[hover].cy+'px',transform:g.dots[hover].cx>width*0.62?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:4px;white-space:nowrap\">{{ points[hover].label || points[hover].series }}</div>\n <div style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading);white-space:nowrap\">{{ (xLabel||'x')+' '+xFormat(points[hover].x) }} · {{ (yLabel||'y')+' '+yFormat(points[hover].y) }}</div>\n </div>\n <div v-if=\"legend && g.names.length>1\" style=\"display:flex;gap:14px;padding-top:8px;line-height:normal\">\n <span v-for=\"(nm,i) in g.names\" :key=\"nm\" style=\"display:inline-flex;align-items:center;gap:6px\">\n <span :style=\"{width:'8px',height:'8px',borderRadius:'2px',background:SERIES[i%SERIES.length]}\"/>\n <span style=\"font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-secondary)\">{{ nm }}</span>\n </span>\n </div>\n </div>\n</template>\n","charts/HeatmapChart.jsx":"import React from \"react\";\nimport { fmtNum, extent, heatStep } from \"./chart-geometry.js\";\nimport { ChartTooltip, wrapStyle } from \"./ChartKit.jsx\";\n\nconst HEAT = [\"var(--chart-heat-1)\",\"var(--chart-heat-2)\",\"var(--chart-heat-3)\",\"var(--chart-heat-4)\",\"var(--chart-heat-5)\"];\n/**\n * Matrix heatmap — entity × month, hour × weekday. values[row][col].\n * scale \"blue\" (sequential) or \"diverging\" (negative red / positive green, for variance).\n * showValues prints the number in each cell (md+ sizes).\n */\nexport function HeatmapChart({ xLabels = [], yLabels = [], values = [], width = 560, cellHeight = 26, gap = 2, scale = \"blue\", showValues = false, valueFormat = fmtNum, interactive = true, style }) {\n const [hover, setHover] = React.useState(null);\n const labelW = 86;\n const cols = xLabels.length, rows = yLabels.length;\n const cw = (width - labelW) / cols - gap;\n const [lo, hi] = extent(values, false);\n const amax = Math.max(Math.abs(lo), Math.abs(hi)) || 1;\n const cellFill = (v) => {\n if (v == null) return \"var(--chart-track)\";\n if (scale === \"diverging\") {\n const t = Math.min(1, Math.abs(v) / amax);\n const a = (0.15 + t * 0.85).toFixed(2);\n return v >= 0 ? `color-mix(in srgb, var(--chart-pos) ${Math.round(a * 100)}%, var(--bg-surface))` : `color-mix(in srgb, var(--chart-neg) ${Math.round(a * 100)}%, var(--bg-surface))`;\n }\n return HEAT[heatStep(v, lo, hi)];\n };\n const textOn = (v) => {\n if (v == null) return \"var(--text-muted)\";\n if (scale === \"diverging\") return Math.abs(v) / amax > 0.55 ? \"var(--on-accent)\" : \"var(--text-heading)\";\n return heatStep(v, lo, hi) >= 3 ? \"var(--chart-heat-ink)\" : \"var(--text-heading)\";\n };\n const h = hover ? { v: values[hover.r] && values[hover.r][hover.c] } : null;\n return (\n <div style={{ ...wrapStyle, width, ...style }}>\n <div style={{ display: \"grid\", gridTemplateColumns: `${labelW}px repeat(${cols}, 1fr)`, gap: gap + \"px\", lineHeight: \"normal\" }}>\n <span />\n {xLabels.map((x) => <span key={x} style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", color: \"var(--chart-label)\", textAlign: \"center\", paddingBottom: 2 }}>{x}</span>)}\n {yLabels.map((y, r) => (\n <React.Fragment key={y}>\n <span style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-xs)\", color: \"var(--text-secondary)\", display: \"flex\", alignItems: \"center\", whiteSpace: \"nowrap\", overflow: \"hidden\", textOverflow: \"ellipsis\", paddingRight: 6 }}>{y}</span>\n {xLabels.map((x, c) => {\n const v = values[r] ? values[r][c] : null;\n const on = hover && hover.r === r && hover.c === c;\n return (\n <span key={c}\n onMouseEnter={interactive ? () => setHover({ r, c }) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}\n style={{ height: cellHeight, borderRadius: 4, background: cellFill(v), display: \"flex\", alignItems: \"center\", justifyContent: \"center\",\n outline: on ? \"2px solid var(--focus-border)\" : \"none\", outlineOffset: -1, transition: \"var(--t-hover)\", cursor: interactive ? \"default\" : undefined }}>\n {showValues && v != null ? <span style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", color: textOn(v) }}>{valueFormat(v)}</span> : null}\n </span>\n );\n })}\n </React.Fragment>\n ))}\n </div>\n {hover && interactive ? (\n <ChartTooltip x={labelW + (hover.c + 0.5) * (cw + gap)} y={20 + (hover.r + 0.5) * (cellHeight + gap)} flip={hover.c > cols * 0.62}\n title={yLabels[hover.r] + \" · \" + xLabels[hover.c]} rows={[{ label: \"value\", value: valueFormat(h.v) }]} />\n ) : null}\n </div>\n );\n}\n","charts/HeatmapChart.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { fmtNum, extent, heatStep } from './chart-geometry.js'\n// Matrix heatmap. scale \"blue\" (sequential) or \"diverging\" (red/green variance).\nconst HEAT = ['var(--chart-heat-1)','var(--chart-heat-2)','var(--chart-heat-3)','var(--chart-heat-4)','var(--chart-heat-5)']\nconst props = defineProps({ xLabels:{type:Array,default:()=>[]}, yLabels:{type:Array,default:()=>[]}, values:{type:Array,default:()=>[]}, width:{type:Number,default:560}, cellHeight:{type:Number,default:26}, gap:{type:Number,default:2}, scale:{type:String,default:'blue'}, showValues:Boolean, valueFormat:{type:Function,default:fmtNum}, interactive:{type:Boolean,default:true}, style:Object })\nconst hover = ref(null)\nconst dom = computed(() => { const [lo,hi] = extent(props.values,false); return { lo, hi, amax: Math.max(Math.abs(lo),Math.abs(hi))||1 } })\nfunction fill(v){\n if (v==null) return 'var(--chart-track)'\n if (props.scale==='diverging'){\n const a = Math.round((0.15+Math.min(1,Math.abs(v)/dom.value.amax)*0.85)*100)\n return (v>=0?`color-mix(in srgb, var(--chart-pos) ${a}%, var(--bg-surface))`:`color-mix(in srgb, var(--chart-neg) ${a}%, var(--bg-surface))`)\n }\n return HEAT[heatStep(v, dom.value.lo, dom.value.hi)]\n}\nfunction ink(v){\n if (v==null) return 'var(--text-muted)'\n if (props.scale==='diverging') return Math.abs(v)/dom.value.amax>0.55 ? 'var(--on-accent)' : 'var(--text-heading)'\n return heatStep(v, dom.value.lo, dom.value.hi)>=3 ? 'var(--chart-heat-ink)' : 'var(--text-heading)'\n}\n</script>\n<template>\n <div :style=\"{width:width+'px',...style}\">\n <div :style=\"{display:'grid',gridTemplateColumns:'86px repeat('+xLabels.length+', 1fr)',gap:gap+'px'}\">\n <span/>\n <span v-for=\"x in xLabels\" :key=\"x\" style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--chart-label);text-align:center;padding-bottom:2px\">{{ x }}</span>\n <template v-for=\"(y,r) in yLabels\" :key=\"y\">\n <span style=\"font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-secondary);display:flex;align-items:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:6px\">{{ y }}</span>\n <span v-for=\"(x,c) in xLabels\" :key=\"c\" :title=\"y+' · '+x+(values[r]&&values[r][c]!=null?' = '+valueFormat(values[r][c]):'')\"\n @mouseenter=\"interactive && (hover={r,c})\" @mouseleave=\"interactive && (hover=null)\"\n :style=\"{height:cellHeight+'px',borderRadius:'4px',background:fill(values[r]?values[r][c]:null),display:'flex',alignItems:'center',justifyContent:'center',outline:hover&&hover.r===r&&hover.c===c?'2px solid var(--focus-border)':'none',outlineOffset:'-1px',transition:'var(--t-hover)'}\">\n <span v-if=\"showValues && values[r] && values[r][c]!=null\" :style=\"{fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums',fontSize:'var(--text-2xs-mono)',color:ink(values[r][c])}\">{{ valueFormat(values[r][c]) }}</span>\n </span>\n </template>\n </div>\n </div>\n</template>\n","charts/WaterfallChart.jsx":"import React from \"react\";\nimport { fmtNum, waterfall, niceTicks, scaleLin } from \"./chart-geometry.js\";\nimport { ChartTooltip, PlotGrid, wrapStyle } from \"./ChartKit.jsx\";\n\n/**\n * Waterfall bridge — plan → actual, revenue → net income. steps: [{label, value}] plus\n * {label, total:true} rows that re-baseline (subtotals/result). Positive --chart-pos,\n * negative --chart-neg, totals --chart-total; dashed connectors carry the running sum.\n * Hovering a bar dims the rest and reports the delta and the running total.\n */\nexport function WaterfallChart({ steps = [], width = 560, height = 260, axes = true, valueFormat = fmtNum, showValues = true, interactive = true, style }) {\n const [hover, setHover] = React.useState(null);\n const bars = waterfall(steps);\n const pad = { l: axes ? 44 : 4, r: 8, t: showValues ? 22 : 10, b: 40 };\n const [lo0, hi0] = [Math.min(0, ...bars.map((b) => b.y0)), Math.max(0, ...bars.map((b) => b.y1))];\n const { ticks, lo, hi } = niceTicks(lo0, hi0, 4);\n const sy = scaleLin(lo, hi, height - pad.b, pad.t);\n const slot = (width - pad.l - pad.r) / bars.length;\n const bw = Math.min(slot * 0.58, 40);\n const fill = { pos: \"var(--chart-pos)\", neg: \"var(--chart-neg)\", total: \"var(--chart-total)\" };\n const move = (e) => {\n if (!interactive) return;\n const r = e.currentTarget.getBoundingClientRect();\n const i = Math.floor((e.clientX - r.left - pad.l) / slot);\n setHover(i >= 0 && i < bars.length ? i : null);\n };\n const h = hover != null ? bars[hover] : null;\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <svg width={width} height={height} onMouseMove={move} onMouseLeave={() => setHover(null)} role=\"img\">\n {axes ? <PlotGrid w={width} h={height} pad={pad} ticks={ticks} sy={sy} xLabels={[]} sxLabel={() => 0} yFormat={valueFormat} /> : null}\n {hover != null ? <rect x={pad.l + slot * hover} y={pad.t} width={slot} height={height - pad.t - pad.b} fill=\"var(--bg-hover)\" /> : null}\n {bars.map((b, i) => {\n const x = pad.l + slot * i + slot / 2;\n const y = sy(b.y1), hgt = Math.max(1.5, sy(b.y0) - y);\n const runEnd = b.kind === \"total\" ? b.y1 : b.to;\n return (\n <g key={i} opacity={hover == null || hover === i ? 1 : 0.45} style={{ transition: \"opacity var(--dur-fast) var(--ease-out)\" }}>\n <rect x={x - bw / 2} y={y} width={bw} height={hgt} rx=\"2\" fill={fill[b.kind]} />\n {i < bars.length - 1 ? <line x1={x + bw / 2} x2={pad.l + slot * (i + 1) + slot / 2 - bw / 2} y1={sy(runEnd)} y2={sy(runEnd)} stroke=\"var(--chart-guide)\" strokeWidth=\"1\" strokeDasharray=\"2 3\" /> : null}\n {showValues ? <text x={x} y={y - 5} textAnchor=\"middle\" style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", fill: b.kind === \"neg\" ? \"var(--chart-neg)\" : \"var(--text-heading)\" }}>{(b.kind === \"pos\" ? \"+\" : \"\") + valueFormat(b.value)}</text> : null}\n <text x={x} y={height - pad.b + 15} textAnchor=\"middle\" style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", fill: \"var(--chart-label)\" }}>\n {String(b.label).split(\"\\n\").map((ln, li) => <tspan key={li} x={x} dy={li ? 11 : 0}>{ln}</tspan>)}\n </text>\n </g>\n );\n })}\n </svg>\n {h && interactive ? (\n <ChartTooltip x={pad.l + slot * hover + slot / 2} y={height / 2 - 10} flip={pad.l + slot * hover > width * 0.62}\n title={String(h.label).replace(\"\\n\", \" \")}\n rows={h.kind === \"total\"\n ? [{ label: \"running total\", value: valueFormat(h.value), color: fill.total }]\n : [{ label: h.kind === \"pos\" ? \"gain\" : \"loss\", value: (h.kind === \"pos\" ? \"+\" : \"\") + valueFormat(h.value), color: fill[h.kind] },\n { label: \"running total\", value: valueFormat(h.to) }]} />\n ) : null}\n </div>\n );\n}\n","charts/WaterfallChart.vue":"<script setup>\nimport { computed, ref } from 'vue'\nimport { fmtNum, waterfall, niceTicks, scaleLin } from './chart-geometry.js'\n// Finance bridge: green gains, red losses, neutral totals, dashed running-sum connectors.\nconst props = defineProps({ steps:{type:Array,default:()=>[]}, width:{type:Number,default:560}, height:{type:Number,default:260}, axes:{type:Boolean,default:true}, showValues:{type:Boolean,default:true}, valueFormat:{type:Function,default:fmtNum}, interactive:{type:Boolean,default:true}, style:Object })\nconst hover = ref(null)\nfunction move(e){ if(!props.interactive) return; const r=e.currentTarget.getBoundingClientRect(); const slot=(props.width-g.value.pad.l-g.value.pad.r)/g.value.out.length; const i=Math.floor((e.clientX-r.left-g.value.pad.l)/slot); hover.value = i>=0 && i<g.value.out.length ? i : null }\nconst FILL = { pos:'var(--chart-pos)', neg:'var(--chart-neg)', total:'var(--chart-total)' }\nconst g = computed(() => {\n const bars = waterfall(props.steps)\n const pad = { l: props.axes?44:4, r:8, t: props.showValues?22:10, b:40 }\n const { ticks, lo, hi } = niceTicks(Math.min(0,...bars.map(b=>b.y0)), Math.max(0,...bars.map(b=>b.y1)), 4)\n const sy = scaleLin(lo, hi, props.height-pad.b, pad.t)\n const slot = (props.width-pad.l-pad.r)/bars.length\n const bw = Math.min(slot*0.58, 40)\n return { pad, ticks, sy, slot, out: bars.map((b,i)=>{\n const x = pad.l + slot*i + slot/2\n return { ...b, x, bw, y: sy(b.y1), h: Math.max(1.5, sy(b.y0)-sy(b.y1)), fill: FILL[b.kind],\n run: sy(b.kind==='total'?b.y1:b.to), nextX: pad.l + slot*(i+1) + slot/2 - bw/2, last: i===bars.length-1,\n lines: String(b.label).split('\\\\n') } }) }\n})\n</script>\n<template>\n <div :style=\"{position:'relative',display:'inline-block',lineHeight:0,...style}\">\n <svg :width=\"width\" :height=\"height\" @mousemove=\"move\" @mouseleave=\"hover=null\" role=\"img\">\n <template v-if=\"axes\">\n <g v-for=\"t in g.ticks\" :key=\"t\">\n <line :x1=\"g.pad.l\" :x2=\"width-g.pad.r\" :y1=\"g.sy(t)\" :y2=\"g.sy(t)\" :stroke=\"t===0?'var(--chart-axis)':'var(--chart-grid)'\" stroke-width=\"1\"/>\n <text :x=\"g.pad.l-8\" :y=\"g.sy(t)+3\" text-anchor=\"end\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ valueFormat(t) }}</text>\n </g>\n </template>\n <rect v-if=\"hover!=null\" :x=\"g.pad.l+g.slot*hover\" :y=\"g.pad.t\" :width=\"g.slot\" :height=\"height-g.pad.t-g.pad.b\" fill=\"var(--bg-hover)\"/>\n <g v-for=\"(b,i) in g.out\" :key=\"i\" :opacity=\"hover==null||hover===i?1:0.45\" style=\"transition:opacity var(--dur-fast) var(--ease-out)\">\n <rect :x=\"b.x-b.bw/2\" :y=\"b.y\" :width=\"b.bw\" :height=\"b.h\" rx=\"2\" :fill=\"b.fill\"/>\n <line v-if=\"!b.last\" :x1=\"b.x+b.bw/2\" :x2=\"b.nextX\" :y1=\"b.run\" :y2=\"b.run\" stroke=\"var(--chart-guide)\" stroke-width=\"1\" stroke-dasharray=\"2 3\"/>\n <text v-if=\"showValues\" :x=\"b.x\" :y=\"b.y-5\" text-anchor=\"middle\" :style=\"{fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums',fontSize:'var(--text-2xs-mono)',fill:b.kind==='neg'?'var(--chart-neg)':'var(--text-heading)'}\">{{ (b.kind==='pos'?'+':'')+valueFormat(b.value) }}</text>\n <text :x=\"b.x\" :y=\"height-g.pad.b+15\" text-anchor=\"middle\" style=\"font-family:var(--font-sans);font-size:var(--text-2xs);fill:var(--chart-label)\">\n <tspan v-for=\"(ln,li) in b.lines\" :key=\"li\" :x=\"b.x\" :dy=\"li?11:0\">{{ ln }}</tspan>\n </text>\n </g>\n </svg>\n <div v-if=\"hover!=null && interactive\" :style=\"{position:'absolute',left:g.out[hover].x+'px',top:(height/2-10)+'px',transform:g.out[hover].x>width*0.62?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal',whiteSpace:'nowrap'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:3px\">{{ String(g.out[hover].label).replace('\\n',' ') }}</div>\n <div style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)\">{{ g.out[hover].kind==='total' ? valueFormat(g.out[hover].value) : ((g.out[hover].kind==='pos'?'+':'')+valueFormat(g.out[hover].value)+' → '+valueFormat(g.out[hover].to)) }}</div>\n </div>\n </div>\n</template>\n","charts/BulletChart.jsx":"import React from \"react\";\nimport { fmtNum } from \"./chart-geometry.js\";\n\n/**\n * Bullet rows — actual vs. target on a qualitative track. rows:\n * [{label, value, target, max?, ranges?: [poor, ok] }] — ranges tint the track;\n * the bar is --series-1 (or --chart-neg when value misses target with tone=\"auto\").\n */\nexport function BulletChart({ rows = [], width = 360, valueFormat = fmtNum, tone = \"default\", interactive = true, style }) {\n const [hover, setHover] = React.useState(null);\n return (\n <div style={{ display: \"flex\", flexDirection: \"column\", gap: 14, width, ...style }}>\n {rows.map((r) => {\n const max = r.max != null ? r.max : Math.max(r.value, r.target) * 1.15;\n const pc = (v) => Math.min(100, (v / max) * 100) + \"%\";\n const missed = r.value < r.target;\n const bar = tone === \"auto\" ? (missed ? \"var(--chart-neg)\" : \"var(--chart-pos)\") : \"var(--series-1)\";\n const on = hover === r.label;\n return (\n <div key={r.label} onMouseEnter={interactive ? () => setHover(r.label) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}\n style={{ opacity: hover == null || on ? 1 : 0.55, transition: \"var(--t-hover)\" }}>\n <div style={{ display: \"flex\", alignItems: \"baseline\", paddingBottom: 4 }}>\n <span style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-xs)\", color: \"var(--text-body)\" }}>{r.label}</span>\n <span style={{ marginLeft: \"auto\", fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", color: \"var(--text-heading)\" }}>{valueFormat(r.value)}</span>\n <span style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", color: \"var(--text-muted)\", paddingLeft: 6 }}>/ {valueFormat(r.target)}</span>\n {on ? <span style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", color: missed ? \"var(--chart-neg)\" : \"var(--chart-pos)\", paddingLeft: 8 }}>{(missed ? \"\" : \"+\") + (((r.value - r.target) / (r.target || 1)) * 100).toFixed(1)}%</span> : null}\n </div>\n <div style={{ position: \"relative\", height: 12, borderRadius: 3, background: \"var(--chart-track)\", overflow: \"hidden\" }}>\n {r.ranges ? <>\n <div style={{ position: \"absolute\", left: 0, top: 0, bottom: 0, width: pc(r.ranges[0]), background: \"var(--neutral-alpha-12)\", opacity: 0.5 }} />\n <div style={{ position: \"absolute\", left: 0, top: 0, bottom: 0, width: pc(r.ranges[1]), background: \"var(--neutral-alpha-12)\", opacity: 0.25 }} />\n </> : null}\n <div style={{ position: \"absolute\", left: 0, top: 3, bottom: 3, width: pc(r.value), borderRadius: 2, background: bar, transition: \"width var(--dur) var(--ease-out)\" }} />\n <div style={{ position: \"absolute\", left: pc(r.target), top: 0, bottom: 0, width: 2, background: \"var(--chart-total)\" }} />\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n","charts/BulletChart.vue":"<script setup>\nimport { ref } from 'vue'\nimport { fmtNum } from './chart-geometry.js'\n// Actual vs. target rows with a target tick. tone \"auto\" colors misses red.\nconst props = defineProps({ rows:{type:Array,default:()=>[]}, width:{type:Number,default:360}, tone:{type:String,default:'default'}, valueFormat:{type:Function,default:fmtNum}, interactive:{type:Boolean,default:true}, style:Object })\nconst hover = ref(null)\nfunction geom(r){\n const max = r.max!=null ? r.max : Math.max(r.value,r.target)*1.15\n const pc = v => Math.min(100,(v/max)*100)+'%'\n return { pc, bar: props.tone==='auto' ? (r.value<r.target?'var(--chart-neg)':'var(--chart-pos)') : 'var(--series-1)' }\n}\n</script>\n<template>\n <div :style=\"{display:'flex',flexDirection:'column',gap:'14px',width:width+'px',...style}\">\n <div v-for=\"r in rows\" :key=\"r.label\" @mouseenter=\"interactive && (hover=r.label)\" @mouseleave=\"interactive && (hover=null)\"\n :style=\"{opacity:hover==null||hover===r.label?1:0.55,transition:'var(--t-hover)'}\">\n <div style=\"display:flex;align-items:baseline;padding-bottom:4px\">\n <span style=\"font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-body)\">{{ r.label }}</span>\n <span style=\"margin-left:auto;font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)\">{{ valueFormat(r.value) }}</span>\n <span style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);color:var(--text-muted);padding-left:6px\">/ {{ valueFormat(r.target) }}</span>\n <span v-if=\"hover===r.label\" :style=\"{fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums',fontSize:'var(--text-2xs-mono)',paddingLeft:'8px',color:r.value<r.target?'var(--chart-neg)':'var(--chart-pos)'}\">{{ (r.value<r.target?'':'+')+(((r.value-r.target)/(r.target||1))*100).toFixed(1) }}%</span>\n </div>\n <div style=\"position:relative;height:12px;border-radius:3px;background:var(--chart-track);overflow:hidden\">\n <template v-if=\"r.ranges\">\n <div :style=\"{position:'absolute',left:0,top:0,bottom:0,width:geom(r).pc(r.ranges[0]),background:'var(--neutral-alpha-12)',opacity:.5}\"/>\n <div :style=\"{position:'absolute',left:0,top:0,bottom:0,width:geom(r).pc(r.ranges[1]),background:'var(--neutral-alpha-12)',opacity:.25}\"/>\n </template>\n <div :style=\"{position:'absolute',left:0,top:'3px',bottom:'3px',width:geom(r).pc(r.value),borderRadius:'2px',background:geom(r).bar,transition:'width var(--dur) var(--ease-out)'}\"/>\n <div :style=\"{position:'absolute',left:geom(r).pc(r.target),top:0,bottom:0,width:'2px',background:'var(--chart-total)'}\"/>\n </div>\n </div>\n </div>\n</template>\n","charts/GaugeChart.jsx":"import React from \"react\";\nimport { fmtNum, arcPath } from \"./chart-geometry.js\";\n\n/**\n * Semi-circular gauge KPI — utilization, data quality, SLA. Flat arc fill over a track\n * (no needle). bands: [{upTo, color}] recolor the fill by threshold; big light numeral.\n */\nexport function GaugeChart({ value = 0, max = 100, size = 180, thickness = 14, label, valueFormat = (v) => fmtNum(v, { pct: true }), bands, interactive = true, style }) {\n const [hover, setHover] = React.useState(false);\n const rO = size / 2 - 4, rI = rO - thickness;\n const cx = size / 2, cy = rO + 4;\n const H = cy + 18;\n const t = Math.max(0, Math.min(1, value / max));\n const band = bands && bands.find((b) => value <= b.upTo);\n const color = (band && band.color) || \"var(--series-1)\";\n const A0 = -90;\n return (\n <div style={{ position: \"relative\", width: size, height: H, display: \"inline-block\", ...style }}>\n <svg width={size} height={H} role=\"img\">\n <path d={arcPath(cx, cy, rO, rI, A0, 90)} fill=\"var(--chart-track)\" />\n {t > 0 ? <path d={arcPath(cx, cy, rO, rI, A0, A0 + t * 180)} fill={color}\n onMouseEnter={interactive ? () => setHover(true) : undefined} onMouseLeave={interactive ? () => setHover(false) : undefined}\n style={{ filter: hover ? \"brightness(1.1)\" : \"none\", transition: \"filter var(--dur-fast) var(--ease-out)\" }} /> : null}\n <text x={cx - rO + thickness / 2} y={cy + 13} textAnchor=\"middle\" style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", fill: \"var(--chart-label)\" }}>0</text>\n <text x={cx + rO - thickness / 2} y={cy + 13} textAnchor=\"middle\" style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", fill: \"var(--chart-label)\" }}>{fmtNum(max)}</text>\n </svg>\n <div style={{ position: \"absolute\", left: 0, right: 0, top: cy - size / 7, textAlign: \"center\", pointerEvents: \"none\" }}>\n <div style={{ fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-light)\", fontSize: size / 7, lineHeight: 1, color: \"var(--text-heading)\" }}>{valueFormat(value)}</div>\n {label ? <div style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", color: \"var(--text-muted)\", paddingTop: 3 }}>{hover ? Math.round(t * 100) + \"% of \" + fmtNum(max) : label}</div> : null}\n </div>\n </div>\n );\n}\n","charts/GaugeChart.vue":"<script setup>\nimport { computed, ref } from 'vue'\nimport { fmtNum, arcPath } from './chart-geometry.js'\n// Semi-circular KPI gauge — flat arc fill, no needle. bands recolor by threshold.\nconst props = defineProps({ value:{type:Number,default:0}, max:{type:Number,default:100}, size:{type:Number,default:180}, thickness:{type:Number,default:14}, label:String, valueFormat:{type:Function,default:(v)=>fmtNum(v,{pct:true})}, bands:Array, interactive:{type:Boolean,default:true}, style:Object })\nconst hover = ref(false)\nconst g = computed(() => {\n const rO = props.size/2-4, rI = rO-props.thickness, cx = props.size/2, cy = rO+4, H = cy+18\n const t = Math.max(0, Math.min(1, props.value/props.max))\n const band = props.bands && props.bands.find(b=>props.value<=b.upTo)\n return { rO, rI, cx, cy, H, t, color:(band&&band.color)||'var(--series-1)',\n track: arcPath(cx,cy,rO,rI,-90,90), fill: t>0 ? arcPath(cx,cy,rO,rI,-90,-90+t*180) : null }\n})\n</script>\n<template>\n <div :style=\"{position:'relative',width:size+'px',height:g.H+'px',display:'inline-block',...style}\">\n <svg :width=\"size\" :height=\"g.H\" role=\"img\">\n <path :d=\"g.track\" fill=\"var(--chart-track)\"/>\n <path v-if=\"g.fill\" :d=\"g.fill\" :fill=\"g.color\" @mouseenter=\"interactive && (hover=true)\" @mouseleave=\"interactive && (hover=false)\"\n :style=\"{filter:hover?'brightness(1.1)':'none',transition:'filter var(--dur-fast) var(--ease-out)'}\"/>\n <text :x=\"g.cx-g.rO+thickness/2\" :y=\"g.cy+13\" text-anchor=\"middle\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">0</text>\n <text :x=\"g.cx+g.rO-thickness/2\" :y=\"g.cy+13\" text-anchor=\"middle\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ fmtNum(max) }}</text>\n </svg>\n <div :style=\"{position:'absolute',left:0,right:0,top:(g.cy-size/7)+'px',textAlign:'center',pointerEvents:'none'}\">\n <div :style=\"{fontFamily:'var(--font-sans)',fontWeight:'var(--weight-light)',fontSize:size/7+'px',lineHeight:1,color:'var(--text-heading)'}\">{{ valueFormat(value) }}</div>\n <div v-if=\"label\" style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-top:3px\">{{ hover ? Math.round(g.t*100)+'% of '+max : label }}</div>\n </div>\n </div>\n</template>\n","charts/FunnelChart.jsx":"import React from \"react\";\nimport { fmtNum } from \"./chart-geometry.js\";\n\n/**\n * Conversion funnel — onboarding, approval chains, migration stages.\n * stages: [{label, value}]; each band shows its share of stage 1 and the\n * step-to-step conversion underneath.\n */\nexport function FunnelChart({ stages = [], width = 420, bandHeight = 34, gap = 14, valueFormat = fmtNum, interactive = true, style }) {\n const [hover, setHover] = React.useState(null);\n const top = stages[0] ? stages[0].value : 1;\n return (\n <div style={{ width, ...style }}>\n {stages.map((s, i) => {\n const share = s.value / (top || 1);\n const prev = i > 0 ? s.value / (stages[i - 1].value || 1) : null;\n return (\n <div key={s.label} onMouseEnter={interactive ? () => setHover(i) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}\n style={{ paddingBottom: i < stages.length - 1 ? gap : 0, opacity: hover == null || hover === i ? 1 : 0.55, transition: \"var(--t-hover)\" }}>\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <div style={{ width: Math.max(12, share * 100) + \"%\", height: bandHeight, borderRadius: \"var(--radius-sm)\", background: \"var(--series-1)\", opacity: 1 - i * (0.55 / Math.max(1, stages.length - 1)), display: \"flex\", alignItems: \"center\", justifyContent: \"center\", gap: 8, minWidth: 150, transition: \"width var(--dur) var(--ease-out)\" }}>\n <span style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-xs)\", color: \"var(--on-accent)\", whiteSpace: \"nowrap\" }}>{s.label}</span>\n <span style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", color: \"var(--on-accent)\" }}>{valueFormat(s.value)}</span>\n {hover === i ? <span style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", color: \"var(--on-accent)\", opacity: 0.8 }}>{((share) * 100).toFixed(0)}% of top</span> : null}\n </div>\n </div>\n {prev != null ? (\n <div style={{ textAlign: \"center\", paddingTop: 3, fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", color: prev < 0.5 ? \"var(--chart-neg)\" : \"var(--text-muted)\" }}>\n ↓ {(prev * 100).toFixed(0)}%\n </div>\n ) : null}\n </div>\n );\n })}\n </div>\n );\n}\n","charts/FunnelChart.vue":"<script setup>\nimport { computed, ref } from 'vue'\nimport { fmtNum } from './chart-geometry.js'\n// Centered conversion funnel with step-to-step % under each band.\nconst props = defineProps({ stages:{type:Array,default:()=>[]}, width:{type:Number,default:420}, bandHeight:{type:Number,default:34}, gap:{type:Number,default:14}, valueFormat:{type:Function,default:fmtNum}, interactive:{type:Boolean,default:true}, style:Object })\nconst hover = ref(null)\nconst rows = computed(() => {\n const top = props.stages[0] ? props.stages[0].value : 1\n return props.stages.map((s,i)=>({ ...s,\n share: Math.max(12, (s.value/(top||1))*100),\n conv: i>0 ? s.value/(props.stages[i-1].value||1) : null,\n opacity: 1 - i*(0.55/Math.max(1,props.stages.length-1)) }))\n})\n</script>\n<template>\n <div :style=\"{width:width+'px',...style}\">\n <div v-for=\"(s,i) in rows\" :key=\"s.label\" @mouseenter=\"interactive && (hover=i)\" @mouseleave=\"interactive && (hover=null)\"\n :style=\"{paddingBottom:i<rows.length-1?gap+'px':0,opacity:hover==null||hover===i?1:0.55,transition:'var(--t-hover)'}\">\n <div style=\"display:flex;justify-content:center\">\n <div :style=\"{width:s.share+'%',height:bandHeight+'px',borderRadius:'var(--radius-sm)',background:'var(--series-1)',opacity:s.opacity,display:'flex',alignItems:'center',justifyContent:'center',gap:'8px',minWidth:'150px',transition:'width var(--dur) var(--ease-out)'}\">\n <span style=\"font-family:var(--font-sans);font-size:var(--text-xs);color:var(--on-accent);white-space:nowrap\">{{ s.label }}</span>\n <span style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--on-accent)\">{{ valueFormat(s.value) }}</span>\n <span v-if=\"hover===i\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);color:var(--on-accent);opacity:.8\">{{ s.share.toFixed(0) }}% of top</span>\n </div>\n </div>\n <div v-if=\"s.conv!=null\" :style=\"{textAlign:'center',paddingTop:'3px',fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums',fontSize:'var(--text-2xs-mono)',color:s.conv<0.5?'var(--chart-neg)':'var(--text-muted)'}\">↓ {{ (s.conv*100).toFixed(0) }}%</div>\n </div>\n </div>\n</template>\n","charts/TreemapChart.jsx":"import React from \"react\";\nimport { fmtNum, treemap, seriesColor } from \"./chart-geometry.js\";\nimport { ChartTooltip, wrapStyle } from \"./ChartKit.jsx\";\n\n/**\n * Squarified treemap — composition at a glance (cost base, storage by dataset).\n * items: [{label, value, color?}]. Labels render only where they fit.\n */\nexport function TreemapChart({ items = [], width = 560, height = 260, valueFormat = fmtNum, interactive = true, style }) {\n const [hover, setHover] = React.useState(null);\n const cells = treemap(items, width, height);\n const total = items.reduce((s, i) => s + i.value, 0) || 1;\n const colorOf = (it) => it.color || seriesColor(items.indexOf(it));\n const h = hover != null ? cells[hover] : null;\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <svg width={width} height={height} role=\"img\">\n {cells.map((c, i) => (\n <g key={i} onMouseEnter={interactive ? () => setHover(i) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined} style={{ cursor: interactive ? \"default\" : undefined }}>\n <rect x={c.x + 1} y={c.y + 1} width={Math.max(1, c.w - 2)} height={Math.max(1, c.h - 2)} rx=\"3\" fill={colorOf(c.item)}\n opacity={hover == null ? 0.85 : hover === i ? 1 : 0.35} style={{ transition: \"opacity var(--dur-fast) var(--ease-out)\" }} />\n {c.w > 74 && c.h > 34 ? <>\n <text x={c.x + 9} y={c.y + 18} style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-xs)\", fill: \"var(--on-accent)\" }}>{c.item.label}</text>\n <text x={c.x + 9} y={c.y + 32} style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", fill: \"var(--on-accent)\", opacity: 0.85 }}>{valueFormat(c.item.value)}</text>\n </> : null}\n </g>\n ))}\n </svg>\n {h && interactive ? (\n <ChartTooltip x={h.x + h.w / 2} y={h.y + h.h / 2} flip={h.x + h.w / 2 > width * 0.62} title={h.item.label}\n rows={[{ label: \"value\", value: valueFormat(h.item.value) }, { label: \"share\", value: ((h.item.value / total) * 100).toFixed(1) + \"%\" }]} />\n ) : null}\n </div>\n );\n}\n","charts/TreemapChart.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { fmtNum, treemap, SERIES } from './chart-geometry.js'\n// Squarified treemap; labels render only where they fit, hover dims the rest.\nconst props = defineProps({ items:{type:Array,default:()=>[]}, width:{type:Number,default:560}, height:{type:Number,default:260}, valueFormat:{type:Function,default:fmtNum}, interactive:{type:Boolean,default:true}, style:Object })\nconst hover = ref(null)\nconst cells = computed(() => treemap(props.items, props.width, props.height).map(c => ({ ...c, color: c.item.color || SERIES[props.items.indexOf(c.item)%SERIES.length] })))\nconst total = computed(() => props.items.reduce((s,i)=>s+i.value,0)||1)\n</script>\n<template>\n <div :style=\"{position:'relative',display:'inline-block',lineHeight:0,...style}\">\n <svg :width=\"width\" :height=\"height\" role=\"img\">\n <g v-for=\"(c,i) in cells\" :key=\"i\" @mouseenter=\"interactive && (hover=i)\" @mouseleave=\"interactive && (hover=null)\">\n <rect :x=\"c.x+1\" :y=\"c.y+1\" :width=\"Math.max(1,c.w-2)\" :height=\"Math.max(1,c.h-2)\" rx=\"3\" :fill=\"c.color\"\n :opacity=\"hover==null?.85:hover===i?1:.35\" style=\"transition:opacity var(--dur-fast) var(--ease-out)\"/>\n <template v-if=\"c.w>74 && c.h>34\">\n <text :x=\"c.x+9\" :y=\"c.y+18\" style=\"font-family:var(--font-sans);font-size:var(--text-xs);fill:var(--on-accent)\">{{ c.item.label }}</text>\n <text :x=\"c.x+9\" :y=\"c.y+32\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--on-accent);opacity:.85\">{{ valueFormat(c.item.value) }}</text>\n </template>\n </g>\n </svg>\n <div v-if=\"hover!=null && interactive\" :style=\"{position:'absolute',left:(cells[hover].x+cells[hover].w/2)+'px',top:(cells[hover].y+cells[hover].h/2)+'px',transform:(cells[hover].x+cells[hover].w/2)>width*0.62?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);white-space:nowrap;padding-bottom:2px\">{{ cells[hover].item.label }}</div>\n <div style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading);white-space:nowrap\">{{ valueFormat(cells[hover].item.value) }} · {{ ((cells[hover].item.value/total)*100).toFixed(1) }}%</div>\n </div>\n </div>\n</template>\n","charts/RadarChart.jsx":"import React from \"react\";\nimport { fmtNum, niceTicks, polar, seriesColor } from \"./chart-geometry.js\";\nimport { ChartLegend, ChartTooltip, wrapStyle } from \"./ChartKit.jsx\";\n\n/**\n * Radar (spider) chart — compare a few entities across the same 3–10 measures.\n * axes: [{label, max?}]; series: [{name, values, color?}] aligned to axes.\n * Hovering an axis spoke highlights it and reports every series at that measure.\n */\nexport function RadarChart({ axes = [], series = [], size = 260, levels = 4, legend = true, interactive = true, valueFormat = fmtNum, style }) {\n const [hover, setHover] = React.useState(null);\n const [hidden, setHidden] = React.useState([]);\n const shown = series.filter((s) => !hidden.includes(s.name));\n /* Axis labels sit outside the outer ring, so the plot radius has to leave room for the\n longest one — otherwise \"Accuracy\" gets clipped at the SVG edge. */\n const cx = size / 2, cy = size / 2, r = size / 2 - 42;\n const n = axes.length || 1;\n const step = 360 / n;\n const max = axes.map((a, i) => a.max != null ? a.max : niceTicks(0, Math.max(...series.map((s) => s.values[i] || 0)), 3).hi);\n const at = (si, i) => {\n const v = (shown[si].values[i] || 0) / (max[i] || 1);\n return polar(cx, cy, Math.max(0, Math.min(1, v)) * r, i * step);\n };\n const colorOf = (s) => s.color || seriesColor(series.indexOf(s));\n const move = (e) => {\n if (!interactive) return;\n const rc = e.currentTarget.getBoundingClientRect();\n const dx = e.clientX - rc.left - cx, dy = e.clientY - rc.top - cy;\n if (Math.hypot(dx, dy) > r + 22) return setHover(null);\n let deg = (Math.atan2(dy, dx) * 180) / Math.PI + 90;\n if (deg < 0) deg += 360;\n setHover(Math.round(deg / step) % n);\n };\n const tip = hover != null ? polar(cx, cy, r * 0.72, hover * step) : null;\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <svg width={size} height={size} onMouseMove={move} onMouseLeave={() => setHover(null)} role=\"img\" style={{ overflow: \"visible\" }}>\n {[...Array(levels)].map((_, li) => {\n const rr = (r * (li + 1)) / levels;\n const pts = [...Array(n)].map((__, i) => polar(cx, cy, rr, i * step).map((v) => v.toFixed(1)).join(\" \")).join(\" L\");\n return <path key={li} d={\"M\" + pts + \" Z\"} fill=\"none\" stroke=\"var(--chart-grid)\" strokeWidth=\"1\" />;\n })}\n {axes.map((a, i) => {\n const [x, y] = polar(cx, cy, r, i * step);\n const [lx, ly] = polar(cx, cy, r + 14, i * step);\n /* An anchor=\"start\" label grows rightward, so near-vertical spokes must center\n and the right column must not sit so close to the edge that it clips. */\n const anchor = Math.abs(lx - cx) < 12 ? \"middle\" : lx > cx ? \"start\" : \"end\";\n const on = hover === i;\n return (\n <g key={a.label}>\n <line x1={cx} y1={cy} x2={x} y2={y} stroke={on ? \"var(--chart-guide)\" : \"var(--chart-axis)\"} strokeWidth=\"1\" />\n <text x={lx} y={ly + 3} textAnchor={anchor}\n style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", fill: on ? \"var(--text-heading)\" : \"var(--chart-label)\" }}>{a.label}</text>\n </g>\n );\n })}\n {shown.map((s, si) => {\n const pts = axes.map((_, i) => at(si, i));\n const d = \"M\" + pts.map((p) => p[0].toFixed(1) + \" \" + p[1].toFixed(1)).join(\" L\") + \" Z\";\n return (\n <g key={s.name}>\n <path d={d} fill={colorOf(s)} fillOpacity=\"0.14\" stroke={colorOf(s)} strokeWidth=\"1.5\" strokeLinejoin=\"round\" />\n {pts.map((p, i) => <circle key={i} cx={p[0]} cy={p[1]} r={hover === i ? 3.4 : 2.2} fill={colorOf(s)} stroke=\"var(--bg-surface)\" strokeWidth={hover === i ? 1.5 : 0} />)}\n </g>\n );\n })}\n </svg>\n {hover != null && interactive ? (\n <ChartTooltip x={tip[0]} y={tip[1]} flip={tip[0] > size * 0.58} title={axes[hover].label}\n rows={shown.map((s) => ({ label: s.name, value: valueFormat(s.values[hover]), color: colorOf(s) }))} />\n ) : null}\n {legend && series.length > 1 ? (\n <ChartLegend style={{ paddingTop: 6 }} items={series.map((s) => ({ label: s.name, color: colorOf(s) }))} hidden={hidden}\n onToggle={(l) => setHidden(hidden.includes(l) ? hidden.filter((x) => x !== l) : hidden.length + 1 < series.length ? [...hidden, l] : hidden)} />\n ) : null}\n </div>\n );\n}\n","charts/RadarChart.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { fmtNum, niceTicks, polar, SERIES } from './chart-geometry.js'\n// Spider chart — a few entities across the same 3–10 measures. Hovering a spoke reports all series.\nconst props = defineProps({ axes:{type:Array,default:()=>[]}, series:{type:Array,default:()=>[]}, size:{type:Number,default:260}, levels:{type:Number,default:4}, legend:{type:Boolean,default:true}, interactive:{type:Boolean,default:true}, valueFormat:{type:Function,default:fmtNum}, style:Object })\nconst hover = ref(null); const hidden = ref([])\nconst g = computed(() => {\n const cx = props.size/2, cy = props.size/2, r = props.size/2-42\n const n = props.axes.length||1, step = 360/n\n const shown = props.series.filter(s=>!hidden.value.includes(s.name))\n const max = props.axes.map((a,i)=> a.max!=null ? a.max : niceTicks(0, Math.max(...props.series.map(s=>s.values[i]||0)), 3).hi)\n const rings = [...Array(props.levels)].map((_,li)=>{\n const rr = (r*(li+1))/props.levels\n return 'M' + [...Array(n)].map((__,i)=>polar(cx,cy,rr,i*step).map(v=>v.toFixed(1)).join(' ')).join(' L') + ' Z'\n })\n const spokes = props.axes.map((a,i)=>{\n const p = polar(cx,cy,r,i*step), l = polar(cx,cy,r+14,i*step)\n // An anchor=\"start\" label grows rightward, so near-vertical spokes must center.\n return { label:a.label, x:p[0], y:p[1], lx:l[0], ly:l[1],\n anchor: Math.abs(l[0]-cx)<12 ? 'middle' : (l[0]>cx ? 'start' : 'end') }\n })\n const shapes = shown.map(s=>{\n const pts = props.axes.map((_,i)=>polar(cx,cy,Math.max(0,Math.min(1,(s.values[i]||0)/(max[i]||1)))*r, i*step))\n return { s, color: s.color||SERIES[props.series.indexOf(s)%SERIES.length], pts,\n d:'M'+pts.map(p=>p[0].toFixed(1)+' '+p[1].toFixed(1)).join(' L')+' Z' }\n })\n return { cx, cy, r, n, step, rings, spokes, shapes, shown }\n})\nfunction move(e){\n if(!props.interactive) return\n const rc = e.currentTarget.getBoundingClientRect()\n const dx = e.clientX-rc.left-g.value.cx, dy = e.clientY-rc.top-g.value.cy\n if (Math.hypot(dx,dy) > g.value.r+22) { hover.value=null; return }\n let deg = (Math.atan2(dy,dx)*180)/Math.PI + 90\n if (deg<0) deg += 360\n hover.value = Math.round(deg/g.value.step) % g.value.n\n}\nconst tip = computed(()=> hover.value==null ? null : polar(g.value.cx, g.value.cy, g.value.r*0.72, hover.value*g.value.step))\nfunction toggle(name){\n if (hidden.value.includes(name)) hidden.value = hidden.value.filter(x=>x!==name)\n else if (hidden.value.length+1 < props.series.length) hidden.value = [...hidden.value, name]\n}\n</script>\n<template>\n <div :style=\"{position:'relative',display:'inline-block',lineHeight:0,...style}\">\n <svg :width=\"size\" :height=\"size\" @mousemove=\"move\" @mouseleave=\"hover=null\" role=\"img\" style=\"overflow:visible\">\n <path v-for=\"(d,i) in g.rings\" :key=\"'r'+i\" :d=\"d\" fill=\"none\" stroke=\"var(--chart-grid)\" stroke-width=\"1\"/>\n <g v-for=\"(sp,i) in g.spokes\" :key=\"sp.label\">\n <line :x1=\"g.cx\" :y1=\"g.cy\" :x2=\"sp.x\" :y2=\"sp.y\" :stroke=\"hover===i?'var(--chart-guide)':'var(--chart-axis)'\" stroke-width=\"1\"/>\n <text :x=\"sp.lx\" :y=\"sp.ly+3\" :text-anchor=\"sp.anchor\" :style=\"{fontFamily:'var(--font-sans)',fontSize:'var(--text-2xs)',fill:hover===i?'var(--text-heading)':'var(--chart-label)'}\">{{ sp.label }}</text>\n </g>\n <g v-for=\"sh in g.shapes\" :key=\"sh.s.name\">\n <path :d=\"sh.d\" :fill=\"sh.color\" fill-opacity=\"0.14\" :stroke=\"sh.color\" stroke-width=\"1.5\" stroke-linejoin=\"round\"/>\n <circle v-for=\"(p,i) in sh.pts\" :key=\"i\" :cx=\"p[0]\" :cy=\"p[1]\" :r=\"hover===i?3.4:2.2\" :fill=\"sh.color\" stroke=\"var(--bg-surface)\" :stroke-width=\"hover===i?1.5:0\"/>\n </g>\n </svg>\n <div v-if=\"hover!=null && interactive\" :style=\"{position:'absolute',left:tip[0]+'px',top:tip[1]+'px',transform:tip[0]>size*0.58?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal',whiteSpace:'nowrap'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:3px\">{{ axes[hover].label }}</div>\n <div v-for=\"sh in g.shapes\" :key=\"sh.s.name\" style=\"display:flex;align-items:center;gap:6px\">\n <span :style=\"{width:'7px',height:'7px',borderRadius:'2px',background:sh.color}\"/>\n <span style=\"font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-secondary)\">{{ sh.s.name }}</span>\n <span style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading);margin-left:auto;padding-left:10px\">{{ valueFormat(sh.s.values[hover]) }}</span>\n </div>\n </div>\n <div v-if=\"legend && series.length>1\" style=\"display:flex;flex-wrap:wrap;gap:4px 14px;padding-top:6px;line-height:normal\">\n <span v-for=\"(s,i) in series\" :key=\"s.name\" role=\"button\" tabindex=\"0\" @click=\"toggle(s.name)\"\n :style=\"{display:'inline-flex',alignItems:'center',gap:'6px',cursor:'pointer',opacity:hidden.includes(s.name)?0.4:1}\">\n <span :style=\"{width:'8px',height:'8px',borderRadius:'2px',background:s.color||SERIES[i%SERIES.length]}\"/>\n <span style=\"font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-secondary)\">{{ s.name }}</span>\n </span>\n </div>\n </div>\n</template>\n","charts/Histogram.jsx":"import React from \"react\";\nimport { fmtNum, niceTicks, scaleLin } from \"./chart-geometry.js\";\nimport { ChartTooltip, PlotGrid, wrapStyle } from \"./ChartKit.jsx\";\n\nconst bucketize = (values, bins) => {\n const lo = Math.min(...values), hi = Math.max(...values);\n const w = (hi - lo) / bins || 1;\n const out = [...Array(bins)].map((_, i) => ({ from: lo + i * w, to: lo + (i + 1) * w, n: 0 }));\n values.forEach((v) => { out[Math.max(0, Math.min(bins - 1, Math.floor((v - lo) / w)))].n++; });\n return out;\n};\n\n/**\n * Histogram — the distribution of one measure. Pass raw `values` (auto-bucketed into\n * `bins`) or pre-computed `buckets: [{from, to, n}]`. mean/median guides are optional.\n */\nexport function Histogram({ values = [], buckets, bins = 12, width = 560, height = 240, axes = true, showMean = false, valueFormat = fmtNum, countFormat = fmtNum, interactive = true, color = \"var(--series-1)\", style }) {\n const [hover, setHover] = React.useState(null);\n const bkt = buckets || bucketize(values, bins);\n const pad = { l: axes ? 40 : 4, r: 8, t: showMean ? 22 : 10, b: axes ? 26 : 4 };\n const { ticks, lo, hi } = niceTicks(0, Math.max(1, ...bkt.map((b) => b.n)), 4);\n const sy = scaleLin(lo, hi, height - pad.b, pad.t);\n const slot = (width - pad.l - pad.r) / bkt.length;\n const mean = showMean && values.length ? values.reduce((s, v) => s + v, 0) / values.length : null;\n const sxVal = scaleLin(bkt[0].from, bkt[bkt.length - 1].to, pad.l, width - pad.r);\n const move = (e) => {\n if (!interactive) return;\n const r = e.currentTarget.getBoundingClientRect();\n const i = Math.floor((e.clientX - r.left - pad.l) / slot);\n setHover(i >= 0 && i < bkt.length ? i : null);\n };\n const total = bkt.reduce((s, b) => s + b.n, 0) || 1;\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <svg width={width} height={height} onMouseMove={move} onMouseLeave={() => setHover(null)} role=\"img\">\n {axes ? <PlotGrid w={width} h={height} pad={pad} ticks={ticks} sy={sy} xLabels={[]} sxLabel={() => 0} yFormat={countFormat} /> : null}\n {bkt.map((b, i) => (\n <rect key={i} x={pad.l + slot * i + 0.5} y={sy(b.n)} width={Math.max(1, slot - 1.5)} height={Math.max(1, sy(0) - sy(b.n))} rx=\"2\"\n fill={color} opacity={hover == null ? 0.9 : hover === i ? 1 : 0.4} style={{ transition: \"opacity var(--dur-fast) var(--ease-out)\" }} />\n ))}\n {mean != null ? <g>\n <line x1={sxVal(mean)} x2={sxVal(mean)} y1={pad.t} y2={height - pad.b} stroke=\"var(--chart-guide)\" strokeWidth=\"1\" strokeDasharray=\"3 3\" />\n <text x={Math.min(sxVal(mean) + 4, width - 60)} y={pad.t - 8} style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", fill: \"var(--chart-label)\" }}>mean {valueFormat(mean)}</text>\n </g> : null}\n {axes ? [0, Math.floor(bkt.length / 2), bkt.length - 1].map((i) => (\n <text key={i} x={pad.l + slot * i + slot / 2} y={height - pad.b + 16} textAnchor=\"middle\" style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", fill: \"var(--chart-label)\" }}>{valueFormat(bkt[i].from)}</text>\n )) : null}\n </svg>\n {hover != null && interactive ? (\n <ChartTooltip x={pad.l + slot * hover + slot / 2} y={height / 2 - 10} flip={pad.l + slot * hover > width * 0.62}\n title={valueFormat(bkt[hover].from) + \" – \" + valueFormat(bkt[hover].to)}\n rows={[{ label: \"count\", value: countFormat(bkt[hover].n), color }, { label: \"share\", value: ((bkt[hover].n / total) * 100).toFixed(1) + \"%\" }]} />\n ) : null}\n </div>\n );\n}\n","charts/Histogram.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { fmtNum, niceTicks, scaleLin } from './chart-geometry.js'\n// Distribution of one measure — raw values are bucketed for you.\nconst props = defineProps({ values:{type:Array,default:()=>[]}, buckets:Array, bins:{type:Number,default:12}, width:{type:Number,default:560}, height:{type:Number,default:240}, axes:{type:Boolean,default:true}, showMean:Boolean, valueFormat:{type:Function,default:fmtNum}, countFormat:{type:Function,default:fmtNum}, interactive:{type:Boolean,default:true}, color:{type:String,default:'var(--series-1)'}, style:Object })\nconst hover = ref(null)\nconst g = computed(() => {\n let bkt = props.buckets\n if (!bkt) {\n const lo = Math.min(...props.values), hi = Math.max(...props.values)\n const w = (hi-lo)/props.bins || 1\n bkt = [...Array(props.bins)].map((_,i)=>({ from: lo+i*w, to: lo+(i+1)*w, n: 0 }))\n props.values.forEach(v=>{ bkt[Math.max(0,Math.min(props.bins-1,Math.floor((v-lo)/w)))].n++ })\n }\n const pad = { l: props.axes?40:4, r:8, t: props.showMean?22:10, b: props.axes?26:4 }\n const { ticks, lo, hi } = niceTicks(0, Math.max(1,...bkt.map(b=>b.n)), 4)\n const sy = scaleLin(lo, hi, props.height-pad.b, pad.t)\n const slot = (props.width-pad.l-pad.r)/bkt.length\n const mean = props.showMean && props.values.length ? props.values.reduce((s,v)=>s+v,0)/props.values.length : null\n const sxVal = scaleLin(bkt[0].from, bkt[bkt.length-1].to, pad.l, props.width-pad.r)\n return { bkt, pad, ticks, sy, slot, mean, meanX: mean!=null ? Math.min(sxVal(mean)+4, props.width-60) : 0, meanLine: mean!=null ? sxVal(mean) : 0,\n total: bkt.reduce((s,b)=>s+b.n,0)||1, labels:[0, Math.floor(bkt.length/2), bkt.length-1] }\n})\nfunction move(e){\n if(!props.interactive) return\n const r = e.currentTarget.getBoundingClientRect()\n const i = Math.floor((e.clientX-r.left-g.value.pad.l)/g.value.slot)\n hover.value = i>=0 && i<g.value.bkt.length ? i : null\n}\n</script>\n<template>\n <div :style=\"{position:'relative',display:'inline-block',lineHeight:0,...style}\">\n <svg :width=\"width\" :height=\"height\" @mousemove=\"move\" @mouseleave=\"hover=null\" role=\"img\">\n <template v-if=\"axes\">\n <g v-for=\"t in g.ticks\" :key=\"t\">\n <line :x1=\"g.pad.l\" :x2=\"width-g.pad.r\" :y1=\"g.sy(t)\" :y2=\"g.sy(t)\" :stroke=\"t===0?'var(--chart-axis)':'var(--chart-grid)'\" stroke-width=\"1\"/>\n <text :x=\"g.pad.l-8\" :y=\"g.sy(t)+3\" text-anchor=\"end\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ countFormat(t) }}</text>\n </g>\n </template>\n <rect v-for=\"(b,i) in g.bkt\" :key=\"i\" :x=\"g.pad.l+g.slot*i+0.5\" :y=\"g.sy(b.n)\" :width=\"Math.max(1,g.slot-1.5)\" :height=\"Math.max(1,g.sy(0)-g.sy(b.n))\" rx=\"2\"\n :fill=\"color\" :opacity=\"hover==null?0.9:(hover===i?1:0.4)\" style=\"transition:opacity var(--dur-fast) var(--ease-out)\"/>\n <template v-if=\"g.mean!=null\">\n <line :x1=\"g.meanLine\" :x2=\"g.meanLine\" :y1=\"g.pad.t\" :y2=\"height-g.pad.b\" stroke=\"var(--chart-guide)\" stroke-width=\"1\" stroke-dasharray=\"3 3\"/>\n <text :x=\"g.meanX\" :y=\"g.pad.t-8\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">mean {{ valueFormat(g.mean) }}</text>\n </template>\n <text v-if=\"axes\" v-for=\"i in g.labels\" :key=\"'l'+i\" :x=\"g.pad.l+g.slot*i+g.slot/2\" :y=\"height-g.pad.b+16\" text-anchor=\"middle\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ valueFormat(g.bkt[i].from) }}</text>\n </svg>\n <div v-if=\"hover!=null && interactive\" :style=\"{position:'absolute',left:(g.pad.l+g.slot*hover+g.slot/2)+'px',top:(height/2-10)+'px',transform:(g.pad.l+g.slot*hover)>width*0.62?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal',whiteSpace:'nowrap'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:3px\">{{ valueFormat(g.bkt[hover].from) }} – {{ valueFormat(g.bkt[hover].to) }}</div>\n <div style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)\">{{ countFormat(g.bkt[hover].n) }} · {{ ((g.bkt[hover].n/g.total)*100).toFixed(1) }}%</div>\n </div>\n </div>\n</template>\n","charts/BoxPlot.jsx":"import React from \"react\";\nimport { fmtNum, niceTicks, scaleLin, seriesColor } from \"./chart-geometry.js\";\nimport { ChartTooltip, PlotGrid, wrapStyle } from \"./ChartKit.jsx\";\n\n/** Five-number summary from raw values (Tukey fences for outliers). */\nexport function summarize(values) {\n const v = values.slice().sort((a, b) => a - b);\n const q = (p) => { const i = (v.length - 1) * p, lo = Math.floor(i), hi = Math.ceil(i); return v[lo] + (v[hi] - v[lo]) * (i - lo); };\n const q1 = q(0.25), med = q(0.5), q3 = q(0.75), iqr = q3 - q1;\n const lo = Math.max(v[0], q1 - 1.5 * iqr), hi = Math.min(v[v.length - 1], q3 + 1.5 * iqr);\n return { q1, med, q3, lo, hi, outliers: v.filter((x) => x < lo || x > hi) };\n}\n\n/**\n * Box & whisker — spread and outliers per group. groups: [{label, values}] (summarized\n * for you) or [{label, q1, med, q3, lo, hi, outliers}] when you have the stats already.\n */\nexport function BoxPlot({ groups = [], width = 560, height = 260, axes = true, valueFormat = fmtNum, interactive = true, style }) {\n const [hover, setHover] = React.useState(null);\n const boxes = groups.map((g) => (g.values ? { label: g.label, color: g.color, ...summarize(g.values) } : g));\n const pad = { l: axes ? 44 : 4, r: 8, t: 12, b: axes ? 26 : 4 };\n const all = boxes.flatMap((b) => [b.lo, b.hi, ...(b.outliers || [])]);\n const { ticks, lo, hi } = niceTicks(Math.min(...all), Math.max(...all), 4);\n const sy = scaleLin(lo, hi, height - pad.b, pad.t);\n const slot = (width - pad.l - pad.r) / boxes.length;\n const bw = Math.min(slot * 0.5, 38);\n const move = (e) => {\n if (!interactive) return;\n const r = e.currentTarget.getBoundingClientRect();\n const i = Math.floor((e.clientX - r.left - pad.l) / slot);\n setHover(i >= 0 && i < boxes.length ? i : null);\n };\n const h = hover != null ? boxes[hover] : null;\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <svg width={width} height={height} onMouseMove={move} onMouseLeave={() => setHover(null)} role=\"img\">\n {axes ? <PlotGrid w={width} h={height} pad={pad} ticks={ticks} sy={sy} xLabels={[]} sxLabel={() => 0} yFormat={valueFormat} /> : null}\n {boxes.map((b, i) => {\n const x = pad.l + slot * i + slot / 2;\n const c = b.color || seriesColor(i);\n return (\n <g key={b.label} opacity={hover == null || hover === i ? 1 : 0.45} style={{ transition: \"opacity var(--dur-fast) var(--ease-out)\" }}>\n <line x1={x} x2={x} y1={sy(b.hi)} y2={sy(b.q3)} stroke=\"var(--chart-axis)\" strokeWidth=\"1\" />\n <line x1={x} x2={x} y1={sy(b.q1)} y2={sy(b.lo)} stroke=\"var(--chart-axis)\" strokeWidth=\"1\" />\n <line x1={x - bw / 3} x2={x + bw / 3} y1={sy(b.hi)} y2={sy(b.hi)} stroke=\"var(--chart-axis)\" strokeWidth=\"1\" />\n <line x1={x - bw / 3} x2={x + bw / 3} y1={sy(b.lo)} y2={sy(b.lo)} stroke=\"var(--chart-axis)\" strokeWidth=\"1\" />\n <rect x={x - bw / 2} y={sy(b.q3)} width={bw} height={Math.max(1, sy(b.q1) - sy(b.q3))} rx=\"2\" fill={c} fillOpacity=\"0.28\" stroke={c} strokeWidth=\"1.2\" />\n <line x1={x - bw / 2} x2={x + bw / 2} y1={sy(b.med)} y2={sy(b.med)} stroke={c} strokeWidth=\"2\" />\n {(b.outliers || []).map((o, oi) => <circle key={oi} cx={x} cy={sy(o)} r=\"2.2\" fill=\"none\" stroke={c} strokeWidth=\"1\" />)}\n <text x={x} y={height - pad.b + 16} textAnchor=\"middle\" style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", fill: \"var(--chart-label)\" }}>{b.label}</text>\n </g>\n );\n })}\n </svg>\n {h && interactive ? (\n <ChartTooltip x={pad.l + slot * hover + slot / 2} y={height / 2 - 10} flip={pad.l + slot * hover > width * 0.62} title={h.label}\n rows={[{ label: \"max\", value: valueFormat(h.hi) }, { label: \"Q3\", value: valueFormat(h.q3) }, { label: \"median\", value: valueFormat(h.med), color: h.color || seriesColor(hover) }, { label: \"Q1\", value: valueFormat(h.q1) }, { label: \"min\", value: valueFormat(h.lo) }]\n .concat((h.outliers || []).length ? [{ label: \"outliers\", value: String(h.outliers.length) }] : [])} />\n ) : null}\n </div>\n );\n}\n","charts/BoxPlot.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { fmtNum, niceTicks, scaleLin, SERIES } from './chart-geometry.js'\n// Box & whisker with Tukey-fence outliers. Pass raw values or the five-number stats.\nconst props = defineProps({ groups:{type:Array,default:()=>[]}, width:{type:Number,default:560}, height:{type:Number,default:260}, axes:{type:Boolean,default:true}, valueFormat:{type:Function,default:fmtNum}, interactive:{type:Boolean,default:true}, style:Object })\nconst hover = ref(null)\nfunction summarize(values){\n const v = values.slice().sort((a,b)=>a-b)\n const q = p => { const i=(v.length-1)*p, lo=Math.floor(i), hi=Math.ceil(i); return v[lo]+(v[hi]-v[lo])*(i-lo) }\n const q1=q(0.25), med=q(0.5), q3=q(0.75), iqr=q3-q1\n const lo=Math.max(v[0], q1-1.5*iqr), hi=Math.min(v[v.length-1], q3+1.5*iqr)\n return { q1, med, q3, lo, hi, outliers: v.filter(x=>x<lo||x>hi) }\n}\nconst g = computed(() => {\n const boxes = props.groups.map((x,i)=> Object.assign({ label:x.label, color:x.color||SERIES[i%SERIES.length] }, x.values ? summarize(x.values) : x))\n const pad = { l: props.axes?44:4, r:8, t:12, b: props.axes?26:4 }\n const all = boxes.flatMap(b=>[b.lo, b.hi, ...(b.outliers||[])])\n const { ticks, lo, hi } = niceTicks(Math.min(...all), Math.max(...all), 4)\n const sy = scaleLin(lo, hi, props.height-pad.b, pad.t)\n const slot = (props.width-pad.l-pad.r)/boxes.length\n const bw = Math.min(slot*0.5, 38)\n return { boxes: boxes.map((b,i)=>Object.assign({}, b, { x: pad.l+slot*i+slot/2, bw })), pad, ticks, sy, slot }\n})\nfunction move(e){\n if(!props.interactive) return\n const r = e.currentTarget.getBoundingClientRect()\n const i = Math.floor((e.clientX-r.left-g.value.pad.l)/g.value.slot)\n hover.value = i>=0 && i<g.value.boxes.length ? i : null\n}\n</script>\n<template>\n <div :style=\"{position:'relative',display:'inline-block',lineHeight:0,...style}\">\n <svg :width=\"width\" :height=\"height\" @mousemove=\"move\" @mouseleave=\"hover=null\" role=\"img\">\n <template v-if=\"axes\">\n <g v-for=\"t in g.ticks\" :key=\"t\">\n <line :x1=\"g.pad.l\" :x2=\"width-g.pad.r\" :y1=\"g.sy(t)\" :y2=\"g.sy(t)\" :stroke=\"t===0?'var(--chart-axis)':'var(--chart-grid)'\" stroke-width=\"1\"/>\n <text :x=\"g.pad.l-8\" :y=\"g.sy(t)+3\" text-anchor=\"end\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ valueFormat(t) }}</text>\n </g>\n </template>\n <g v-for=\"(b,i) in g.boxes\" :key=\"b.label\" :opacity=\"hover==null||hover===i?1:0.45\" style=\"transition:opacity var(--dur-fast) var(--ease-out)\">\n <line :x1=\"b.x\" :x2=\"b.x\" :y1=\"g.sy(b.hi)\" :y2=\"g.sy(b.q3)\" stroke=\"var(--chart-axis)\" stroke-width=\"1\"/>\n <line :x1=\"b.x\" :x2=\"b.x\" :y1=\"g.sy(b.q1)\" :y2=\"g.sy(b.lo)\" stroke=\"var(--chart-axis)\" stroke-width=\"1\"/>\n <line :x1=\"b.x-b.bw/3\" :x2=\"b.x+b.bw/3\" :y1=\"g.sy(b.hi)\" :y2=\"g.sy(b.hi)\" stroke=\"var(--chart-axis)\" stroke-width=\"1\"/>\n <line :x1=\"b.x-b.bw/3\" :x2=\"b.x+b.bw/3\" :y1=\"g.sy(b.lo)\" :y2=\"g.sy(b.lo)\" stroke=\"var(--chart-axis)\" stroke-width=\"1\"/>\n <rect :x=\"b.x-b.bw/2\" :y=\"g.sy(b.q3)\" :width=\"b.bw\" :height=\"Math.max(1,g.sy(b.q1)-g.sy(b.q3))\" rx=\"2\" :fill=\"b.color\" fill-opacity=\"0.28\" :stroke=\"b.color\" stroke-width=\"1.2\"/>\n <line :x1=\"b.x-b.bw/2\" :x2=\"b.x+b.bw/2\" :y1=\"g.sy(b.med)\" :y2=\"g.sy(b.med)\" :stroke=\"b.color\" stroke-width=\"2\"/>\n <circle v-for=\"(o,oi) in (b.outliers||[])\" :key=\"oi\" :cx=\"b.x\" :cy=\"g.sy(o)\" r=\"2.2\" fill=\"none\" :stroke=\"b.color\" stroke-width=\"1\"/>\n <text :x=\"b.x\" :y=\"height-g.pad.b+16\" text-anchor=\"middle\" style=\"font-family:var(--font-sans);font-size:var(--text-2xs);fill:var(--chart-label)\">{{ b.label }}</text>\n </g>\n </svg>\n <div v-if=\"hover!=null && interactive\" :style=\"{position:'absolute',left:g.boxes[hover].x+'px',top:(height/2-10)+'px',transform:g.boxes[hover].x>width*0.62?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal',whiteSpace:'nowrap'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:3px\">{{ g.boxes[hover].label }}</div>\n <div style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)\">\n {{ valueFormat(g.boxes[hover].lo) }} · Q1 {{ valueFormat(g.boxes[hover].q1) }} · med {{ valueFormat(g.boxes[hover].med) }} · Q3 {{ valueFormat(g.boxes[hover].q3) }} · {{ valueFormat(g.boxes[hover].hi) }}\n </div>\n </div>\n </div>\n</template>\n","charts/ParetoChart.jsx":"import React from \"react\";\nimport { fmtNum, niceTicks, scaleLin, linePath } from \"./chart-geometry.js\";\nimport { ChartTooltip, PlotGrid, wrapStyle } from \"./ChartKit.jsx\";\n\n/**\n * Pareto — bars sorted descending with a cumulative % line on the right axis, plus the\n * 80% guide. items: [{label, value}]; the component sorts and accumulates for you.\n */\nexport function ParetoChart({ items = [], width = 560, height = 260, axes = true, threshold = 80, valueFormat = fmtNum, interactive = true, style }) {\n const [hover, setHover] = React.useState(null);\n const sorted = items.slice().sort((a, b) => b.value - a.value);\n const total = sorted.reduce((s, i) => s + i.value, 0) || 1;\n let run = 0;\n const rows = sorted.map((it) => { run += it.value; return { ...it, cum: (run / total) * 100 }; });\n const pad = { l: axes ? 44 : 4, r: axes ? 38 : 4, t: 12, b: 30 };\n const { ticks, lo, hi } = niceTicks(0, Math.max(...rows.map((r) => r.value)), 4);\n const sy = scaleLin(lo, hi, height - pad.b, pad.t);\n const sc = scaleLin(0, 100, height - pad.b, pad.t);\n const slot = (width - pad.l - pad.r) / rows.length;\n const bw = Math.min(slot * 0.62, 40);\n const cx = (i) => pad.l + slot * i + slot / 2;\n const move = (e) => {\n if (!interactive) return;\n const r = e.currentTarget.getBoundingClientRect();\n const i = Math.floor((e.clientX - r.left - pad.l) / slot);\n setHover(i >= 0 && i < rows.length ? i : null);\n };\n const vital = rows.findIndex((r) => r.cum >= threshold);\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <svg width={width} height={height} onMouseMove={move} onMouseLeave={() => setHover(null)} role=\"img\">\n {axes ? <PlotGrid w={width} h={height} pad={pad} ticks={ticks} sy={sy} xLabels={[]} sxLabel={() => 0} yFormat={valueFormat} /> : null}\n {hover != null ? <rect x={pad.l + slot * hover} y={pad.t} width={slot} height={height - pad.t - pad.b} fill=\"var(--bg-hover)\" /> : null}\n <line x1={pad.l} x2={width - pad.r} y1={sc(threshold)} y2={sc(threshold)} stroke=\"var(--chart-guide)\" strokeWidth=\"1\" strokeDasharray=\"3 3\" />\n <text x={width - pad.r - 2} y={sc(threshold) - 4} textAnchor=\"end\" style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", fill: \"var(--chart-label)\" }}>{threshold}%</text>\n {rows.map((r, i) => (\n <rect key={r.label} x={cx(i) - bw / 2} y={sy(r.value)} width={bw} height={Math.max(1, sy(0) - sy(r.value))} rx=\"2\"\n fill={i <= vital ? \"var(--series-1)\" : \"var(--chart-track)\"} stroke={i > vital ? \"var(--border-control)\" : \"none\"}\n opacity={hover == null || hover === i ? 1 : 0.5} style={{ transition: \"opacity var(--dur-fast) var(--ease-out)\" }} />\n ))}\n <path d={linePath(rows.map((r, i) => [cx(i), sc(r.cum)]))} fill=\"none\" stroke=\"var(--chart-total)\" strokeWidth=\"1.5\" />\n {rows.map((r, i) => <circle key={i} cx={cx(i)} cy={sc(r.cum)} r={hover === i ? 3.4 : 2.4} fill=\"var(--chart-total)\" />)}\n {axes ? [0, 25, 50, 75, 100].map((t) => <text key={t} x={width - pad.r + 6} y={sc(t) + 3} style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", fill: \"var(--chart-label)\" }}>{t}%</text>) : null}\n {rows.map((r, i) => <text key={\"l\" + i} x={cx(i)} y={height - pad.b + 15} textAnchor=\"middle\" style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", fill: \"var(--chart-label)\" }}>{r.label}</text>)}\n </svg>\n {hover != null && interactive ? (\n <ChartTooltip x={cx(hover)} y={height / 2 - 10} flip={cx(hover) > width * 0.62} title={rows[hover].label}\n rows={[{ label: \"value\", value: valueFormat(rows[hover].value), color: \"var(--series-1)\" },\n { label: \"share\", value: ((rows[hover].value / total) * 100).toFixed(1) + \"%\" },\n { label: \"cumulative\", value: rows[hover].cum.toFixed(1) + \"%\", color: \"var(--chart-total)\" }]} />\n ) : null}\n </div>\n );\n}\n","charts/ParetoChart.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { fmtNum, niceTicks, scaleLin, linePath } from './chart-geometry.js'\n// Sorted bars + cumulative % line and the 80% guide — the \"vital few\" chart.\nconst props = defineProps({ items:{type:Array,default:()=>[]}, width:{type:Number,default:560}, height:{type:Number,default:260}, axes:{type:Boolean,default:true}, threshold:{type:Number,default:80}, valueFormat:{type:Function,default:fmtNum}, interactive:{type:Boolean,default:true}, style:Object })\nconst hover = ref(null)\nconst g = computed(() => {\n const sorted = props.items.slice().sort((a,b)=>b.value-a.value)\n const total = sorted.reduce((s,i)=>s+i.value,0)||1\n let run = 0\n const rows = sorted.map(it=>{ run+=it.value; return Object.assign({}, it, { cum:(run/total)*100 }) })\n const pad = { l: props.axes?44:4, r: props.axes?38:4, t:12, b:30 }\n const { ticks, lo, hi } = niceTicks(0, Math.max(...rows.map(r=>r.value)), 4)\n const sy = scaleLin(lo, hi, props.height-pad.b, pad.t)\n const sc = scaleLin(0, 100, props.height-pad.b, pad.t)\n const slot = (props.width-pad.l-pad.r)/rows.length\n const bw = Math.min(slot*0.62, 40)\n const cx = i => pad.l+slot*i+slot/2\n const vital = rows.findIndex(r=>r.cum>=props.threshold)\n return { rows, total, pad, ticks, sy, sc, slot, bw, cx, vital,\n line: linePath(rows.map((r,i)=>[cx(i), sc(r.cum)])), pct:[0,25,50,75,100] }\n})\nfunction move(e){\n if(!props.interactive) return\n const r = e.currentTarget.getBoundingClientRect()\n const i = Math.floor((e.clientX-r.left-g.value.pad.l)/g.value.slot)\n hover.value = i>=0 && i<g.value.rows.length ? i : null\n}\n</script>\n<template>\n <div :style=\"{position:'relative',display:'inline-block',lineHeight:0,...style}\">\n <svg :width=\"width\" :height=\"height\" @mousemove=\"move\" @mouseleave=\"hover=null\" role=\"img\">\n <template v-if=\"axes\">\n <g v-for=\"t in g.ticks\" :key=\"t\">\n <line :x1=\"g.pad.l\" :x2=\"width-g.pad.r\" :y1=\"g.sy(t)\" :y2=\"g.sy(t)\" :stroke=\"t===0?'var(--chart-axis)':'var(--chart-grid)'\" stroke-width=\"1\"/>\n <text :x=\"g.pad.l-8\" :y=\"g.sy(t)+3\" text-anchor=\"end\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ valueFormat(t) }}</text>\n </g>\n </template>\n <rect v-if=\"hover!=null\" :x=\"g.pad.l+g.slot*hover\" :y=\"g.pad.t\" :width=\"g.slot\" :height=\"height-g.pad.t-g.pad.b\" fill=\"var(--bg-hover)\"/>\n <line :x1=\"g.pad.l\" :x2=\"width-g.pad.r\" :y1=\"g.sc(threshold)\" :y2=\"g.sc(threshold)\" stroke=\"var(--chart-guide)\" stroke-width=\"1\" stroke-dasharray=\"3 3\"/>\n <text :x=\"width-g.pad.r-2\" :y=\"g.sc(threshold)-4\" text-anchor=\"end\" style=\"font-family:var(--font-sans);font-size:var(--text-2xs);fill:var(--chart-label)\">{{ threshold }}%</text>\n <rect v-for=\"(r,i) in g.rows\" :key=\"r.label\" :x=\"g.cx(i)-g.bw/2\" :y=\"g.sy(r.value)\" :width=\"g.bw\" :height=\"Math.max(1,g.sy(0)-g.sy(r.value))\" rx=\"2\"\n :fill=\"i<=g.vital?'var(--series-1)':'var(--chart-track)'\" :stroke=\"i>g.vital?'var(--border-control)':'none'\"\n :opacity=\"hover==null||hover===i?1:0.5\" style=\"transition:opacity var(--dur-fast) var(--ease-out)\"/>\n <path :d=\"g.line\" fill=\"none\" stroke=\"var(--chart-total)\" stroke-width=\"1.5\"/>\n <circle v-for=\"(r,i) in g.rows\" :key=\"'c'+i\" :cx=\"g.cx(i)\" :cy=\"g.sc(r.cum)\" :r=\"hover===i?3.4:2.4\" fill=\"var(--chart-total)\"/>\n <text v-if=\"axes\" v-for=\"t in g.pct\" :key=\"'p'+t\" :x=\"width-g.pad.r+6\" :y=\"g.sc(t)+3\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ t }}%</text>\n <text v-for=\"(r,i) in g.rows\" :key=\"'l'+i\" :x=\"g.cx(i)\" :y=\"height-g.pad.b+15\" text-anchor=\"middle\" style=\"font-family:var(--font-sans);font-size:var(--text-2xs);fill:var(--chart-label)\">{{ r.label }}</text>\n </svg>\n <div v-if=\"hover!=null && interactive\" :style=\"{position:'absolute',left:g.cx(hover)+'px',top:(height/2-10)+'px',transform:g.cx(hover)>width*0.62?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal',whiteSpace:'nowrap'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:3px\">{{ g.rows[hover].label }}</div>\n <div style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)\">{{ valueFormat(g.rows[hover].value) }} · {{ ((g.rows[hover].value/g.total)*100).toFixed(1) }}% · cum {{ g.rows[hover].cum.toFixed(1) }}%</div>\n </div>\n </div>\n</template>\n","charts/CandlestickChart.jsx":"import React from \"react\";\nimport { fmtNum, niceTicks, scaleLin } from \"./chart-geometry.js\";\nimport { ChartTooltip, PlotGrid, wrapStyle } from \"./ChartKit.jsx\";\n\n/**\n * Candlestick (K-line) — OHLC price action with an optional volume band and moving average.\n * candles: [{t, o, h, l, c, v?}]. Up candles are --chart-pos, down --chart-neg; a hovered\n * candle pins the full OHLC readout. Set hollowUp for the East-Asian hollow-body convention.\n */\nexport function CandlestickChart({ candles = [], width = 620, height = 300, axes = true, volume = true, ma = 0, hollowUp = false, valueFormat = fmtNum, interactive = true, style }) {\n const [hover, setHover] = React.useState(null);\n const volH = volume ? Math.round(height * 0.22) : 0;\n const pad = { l: axes ? 46 : 4, r: 8, t: 10, b: (axes ? 22 : 4) + volH };\n const { ticks, lo, hi } = niceTicks(Math.min(...candles.map((c) => c.l)), Math.max(...candles.map((c) => c.h)), 4);\n const sy = scaleLin(lo, hi, height - pad.b, pad.t);\n const slot = (width - pad.l - pad.r) / (candles.length || 1);\n const bw = Math.max(2, Math.min(slot * 0.62, 14));\n const cx = (i) => pad.l + slot * i + slot / 2;\n const maxV = Math.max(1, ...candles.map((c) => c.v || 0));\n const vy = (v) => height - (axes ? 22 : 4) - (v / maxV) * (volH - 6);\n const avg = ma > 1 ? candles.map((_, i) => (i < ma - 1 ? null : candles.slice(i - ma + 1, i + 1).reduce((s, c) => s + c.c, 0) / ma)) : null;\n const move = (e) => {\n if (!interactive) return;\n const r = e.currentTarget.getBoundingClientRect();\n const i = Math.floor((e.clientX - r.left - pad.l) / slot);\n setHover(i >= 0 && i < candles.length ? i : null);\n };\n const h = hover != null ? candles[hover] : null;\n const up = (c) => c.c >= c.o;\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <svg width={width} height={height} onMouseMove={move} onMouseLeave={() => setHover(null)} role=\"img\">\n {axes ? <PlotGrid w={width} h={height} pad={pad} ticks={ticks} sy={sy} xLabels={[]} sxLabel={() => 0} yFormat={valueFormat} /> : null}\n {hover != null ? <rect x={pad.l + slot * hover} y={pad.t} width={slot} height={height - pad.t - (axes ? 22 : 4)} fill=\"var(--bg-hover)\" /> : null}\n {avg ? <path d={\"M\" + avg.map((v, i) => (v == null ? null : cx(i).toFixed(1) + \" \" + sy(v).toFixed(1))).filter(Boolean).join(\" L\")} fill=\"none\" stroke=\"var(--chart-total)\" strokeWidth=\"1.3\" /> : null}\n {candles.map((c, i) => {\n const col = up(c) ? \"var(--chart-pos)\" : \"var(--chart-neg)\";\n const yO = sy(c.o), yC = sy(c.c);\n const top = Math.min(yO, yC), body = Math.max(1.5, Math.abs(yC - yO));\n const solid = !hollowUp || !up(c);\n return (\n <g key={i} opacity={hover == null || hover === i ? 1 : 0.5} style={{ transition: \"opacity var(--dur-fast) var(--ease-out)\" }}>\n <line x1={cx(i)} x2={cx(i)} y1={sy(c.h)} y2={sy(c.l)} stroke={col} strokeWidth=\"1\" />\n <rect x={cx(i) - bw / 2} y={top} width={bw} height={body} fill={solid ? col : \"var(--bg-surface)\"} stroke={col} strokeWidth=\"1\" />\n {volume && c.v != null ? <rect x={cx(i) - bw / 2} y={vy(c.v)} width={bw} height={Math.max(1, height - (axes ? 22 : 4) - vy(c.v))} fill={col} opacity=\"0.35\" /> : null}\n </g>\n );\n })}\n {volume ? <line x1={pad.l} x2={width - pad.r} y1={height - (axes ? 22 : 4)} y2={height - (axes ? 22 : 4)} stroke=\"var(--chart-axis)\" strokeWidth=\"1\" /> : null}\n {axes ? candles.map((c, i) => (i % Math.ceil(candles.length / 6) ? null : (\n <text key={\"t\" + i} x={cx(i)} y={height - 6} textAnchor=\"middle\" style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", fill: \"var(--chart-label)\" }}>{c.t}</text>\n ))) : null}\n </svg>\n {h && interactive ? (\n <ChartTooltip x={cx(hover)} y={height / 2 - 20} flip={cx(hover) > width * 0.62} title={h.t}\n rows={[{ label: \"open\", value: valueFormat(h.o) }, { label: \"high\", value: valueFormat(h.h) }, { label: \"low\", value: valueFormat(h.l) },\n { label: \"close\", value: valueFormat(h.c), color: up(h) ? \"var(--chart-pos)\" : \"var(--chart-neg)\" },\n { label: \"change\", value: ((h.c - h.o) >= 0 ? \"+\" : \"\") + (((h.c - h.o) / (h.o || 1)) * 100).toFixed(2) + \"%\" }]\n .concat(h.v != null ? [{ label: \"volume\", value: fmtNum(h.v) }] : [])} />\n ) : null}\n </div>\n );\n}\n","charts/CandlestickChart.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { fmtNum, niceTicks, scaleLin } from './chart-geometry.js'\n// OHLC K-line with optional volume band and moving average.\nconst props = defineProps({ candles:{type:Array,default:()=>[]}, width:{type:Number,default:620}, height:{type:Number,default:300}, axes:{type:Boolean,default:true}, volume:{type:Boolean,default:true}, ma:{type:Number,default:0}, hollowUp:Boolean, valueFormat:{type:Function,default:fmtNum}, interactive:{type:Boolean,default:true}, style:Object })\nconst hover = ref(null)\nconst g = computed(() => {\n const volH = props.volume ? Math.round(props.height*0.22) : 0\n const axisB = props.axes ? 22 : 4\n const pad = { l: props.axes?46:4, r:8, t:10, b: axisB+volH }\n const { ticks, lo, hi } = niceTicks(Math.min(...props.candles.map(c=>c.l)), Math.max(...props.candles.map(c=>c.h)), 4)\n const sy = scaleLin(lo, hi, props.height-pad.b, pad.t)\n const slot = (props.width-pad.l-pad.r)/(props.candles.length||1)\n const bw = Math.max(2, Math.min(slot*0.62, 14))\n const cx = i => pad.l+slot*i+slot/2\n const maxV = Math.max(1, ...props.candles.map(c=>c.v||0))\n const vy = v => props.height-axisB-(v/maxV)*(volH-6)\n const avg = props.ma>1 ? props.candles.map((_,i)=> i<props.ma-1 ? null : props.candles.slice(i-props.ma+1,i+1).reduce((s,c)=>s+c.c,0)/props.ma) : null\n const bars = props.candles.map((c,i)=>{\n const up = c.c>=c.o, col = up?'var(--chart-pos)':'var(--chart-neg)'\n const yO=sy(c.o), yC=sy(c.c)\n return { c, i, up, col, x:cx(i), bw, top:Math.min(yO,yC), body:Math.max(1.5,Math.abs(yC-yO)), yH:sy(c.h), yL:sy(c.l),\n solid: !props.hollowUp || !up, vY: c.v!=null?vy(c.v):null, vH: c.v!=null?Math.max(1, props.height-axisB-vy(c.v)):0 }\n })\n const maPath = avg ? 'M'+avg.map((v,i)=> v==null?null:cx(i).toFixed(1)+' '+sy(v).toFixed(1)).filter(Boolean).join(' L') : null\n return { pad, ticks, sy, slot, cx, bars, maPath, axisB, every: Math.ceil(props.candles.length/6) }\n})\nfunction move(e){\n if(!props.interactive) return\n const r = e.currentTarget.getBoundingClientRect()\n const i = Math.floor((e.clientX-r.left-g.value.pad.l)/g.value.slot)\n hover.value = i>=0 && i<props.candles.length ? i : null\n}\n</script>\n<template>\n <div :style=\"{position:'relative',display:'inline-block',lineHeight:0,...style}\">\n <svg :width=\"width\" :height=\"height\" @mousemove=\"move\" @mouseleave=\"hover=null\" role=\"img\">\n <template v-if=\"axes\">\n <g v-for=\"t in g.ticks\" :key=\"t\">\n <line :x1=\"g.pad.l\" :x2=\"width-g.pad.r\" :y1=\"g.sy(t)\" :y2=\"g.sy(t)\" :stroke=\"t===0?'var(--chart-axis)':'var(--chart-grid)'\" stroke-width=\"1\"/>\n <text :x=\"g.pad.l-8\" :y=\"g.sy(t)+3\" text-anchor=\"end\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ valueFormat(t) }}</text>\n </g>\n </template>\n <rect v-if=\"hover!=null\" :x=\"g.pad.l+g.slot*hover\" :y=\"g.pad.t\" :width=\"g.slot\" :height=\"height-g.pad.t-g.axisB\" fill=\"var(--bg-hover)\"/>\n <path v-if=\"g.maPath\" :d=\"g.maPath\" fill=\"none\" stroke=\"var(--chart-total)\" stroke-width=\"1.3\"/>\n <g v-for=\"b in g.bars\" :key=\"b.i\" :opacity=\"hover==null||hover===b.i?1:0.5\" style=\"transition:opacity var(--dur-fast) var(--ease-out)\">\n <line :x1=\"b.x\" :x2=\"b.x\" :y1=\"b.yH\" :y2=\"b.yL\" :stroke=\"b.col\" stroke-width=\"1\"/>\n <rect :x=\"b.x-b.bw/2\" :y=\"b.top\" :width=\"b.bw\" :height=\"b.body\" :fill=\"b.solid?b.col:'var(--bg-surface)'\" :stroke=\"b.col\" stroke-width=\"1\"/>\n <rect v-if=\"volume && b.vY!=null\" :x=\"b.x-b.bw/2\" :y=\"b.vY\" :width=\"b.bw\" :height=\"b.vH\" :fill=\"b.col\" opacity=\"0.35\"/>\n </g>\n <line v-if=\"volume\" :x1=\"g.pad.l\" :x2=\"width-g.pad.r\" :y1=\"height-g.axisB\" :y2=\"height-g.axisB\" stroke=\"var(--chart-axis)\" stroke-width=\"1\"/>\n <text v-if=\"axes\" v-for=\"b in g.bars.filter(b=>b.i%g.every===0)\" :key=\"'t'+b.i\" :x=\"b.x\" :y=\"height-6\" text-anchor=\"middle\" style=\"font-family:var(--font-sans);font-size:var(--text-2xs);fill:var(--chart-label)\">{{ b.c.t }}</text>\n </svg>\n <div v-if=\"hover!=null && interactive\" :style=\"{position:'absolute',left:g.cx(hover)+'px',top:(height/2-20)+'px',transform:g.cx(hover)>width*0.62?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal',whiteSpace:'nowrap'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:3px\">{{ candles[hover].t }}</div>\n <div style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)\">O {{ valueFormat(candles[hover].o) }} · H {{ valueFormat(candles[hover].h) }} · L {{ valueFormat(candles[hover].l) }} · C {{ valueFormat(candles[hover].c) }}</div>\n <div :style=\"{fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums',fontSize:'var(--text-2xs-mono)',color:candles[hover].c>=candles[hover].o?'var(--chart-pos)':'var(--chart-neg)'}\">\n {{ (candles[hover].c-candles[hover].o>=0?'+':'')+(((candles[hover].c-candles[hover].o)/(candles[hover].o||1))*100).toFixed(2) }}%<span v-if=\"candles[hover].v!=null\" style=\"color:var(--text-muted)\"> · vol {{ fmtNum(candles[hover].v) }}</span>\n </div>\n </div>\n </div>\n</template>\n","charts/SankeyChart.jsx":"import React from \"react\";\nimport { fmtNum, sankey, seriesColor } from \"./chart-geometry.js\";\nimport { ChartTooltip, wrapStyle } from \"./ChartKit.jsx\";\n\n/**\n * Sankey flow diagram — budget allocation, headcount movement, data lineage.\n * nodes: [{id, label, color?}]; links: [{source, target, value}]. Depth (column) is\n * derived from the longest path, so you never position nodes by hand. Hovering a\n * ribbon or a node isolates that flow.\n */\nexport function SankeyChart({ nodes = [], links = [], width = 620, height = 320, nodeWidth = 13, nodePadding = 16, valueFormat = fmtNum, interactive = true, labelWidth = 96, style }) {\n const [hover, setHover] = React.useState(null);\n const pad = { l: 6, r: labelWidth, t: 8, b: 8 };\n const inner = { w: width - pad.l - pad.r, h: height - pad.t - pad.b };\n const { nodes: laid, ribbons } = React.useMemo(\n () => sankey(nodes, links, inner.w, inner.h, nodeWidth, nodePadding),\n [nodes, links, inner.w, inner.h, nodeWidth, nodePadding]\n );\n const byId = Object.fromEntries(laid.map((n) => [n.id, n]));\n const colorOf = (n) => n.color || seriesColor(laid.indexOf(n));\n const litRibbon = (r) => hover == null ? 0.42\n : hover.kind === \"ribbon\" ? (hover.i === ribbons.indexOf(r) ? 0.78 : 0.12)\n : (r.link.source === hover.id || r.link.target === hover.id ? 0.7 : 0.1);\n const h = hover;\n const tip = h ? (h.kind === \"ribbon\"\n ? { x: (ribbons[h.i].source.x + ribbons[h.i].target.x) / 2 + pad.l, y: 0, title: byId[ribbons[h.i].link.source].label + \" → \" + byId[ribbons[h.i].link.target].label,\n rows: [{ label: \"flow\", value: valueFormat(ribbons[h.i].link.value), color: colorOf(byId[ribbons[h.i].link.source]) }] }\n : { x: byId[h.id].x + pad.l + nodeWidth, y: byId[h.id].y + pad.t + byId[h.id].h / 2, title: byId[h.id].label,\n rows: [{ label: \"total\", value: valueFormat(byId[h.id].value), color: colorOf(byId[h.id]) },\n { label: \"links\", value: String(byId[h.id].in.length + byId[h.id].out.length) }] }) : null;\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <svg width={width} height={height} role=\"img\">\n <g transform={\"translate(\" + pad.l + \",\" + pad.t + \")\"}>\n {ribbons.map((r, i) => (\n <path key={i} d={r.d} fill={colorOf(byId[r.link.source])} opacity={litRibbon(r)}\n onMouseEnter={interactive ? () => setHover({ kind: \"ribbon\", i }) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}\n style={{ transition: \"opacity var(--dur-fast) var(--ease-out)\", cursor: interactive ? \"default\" : undefined }} />\n ))}\n {laid.map((n) => {\n const c = colorOf(n);\n const rightSide = n.x > inner.w * 0.55;\n return (\n <g key={n.id} onMouseEnter={interactive ? () => setHover({ kind: \"node\", id: n.id }) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}\n style={{ cursor: interactive ? \"default\" : undefined }}>\n <rect x={n.x} y={n.y} width={n.w} height={n.h} rx=\"2\" fill={c} opacity={hover == null || (hover.kind === \"node\" && hover.id === n.id) ? 1 : 0.55} />\n <text x={rightSide ? n.x + n.w + 6 : n.x + n.w + 6} y={n.y + n.h / 2 + 3.5}\n style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-xs)\", fill: \"var(--text-heading)\" }}>{n.label}</text>\n <text x={n.x + n.w + 6} y={n.y + n.h / 2 + 15}\n style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", fill: \"var(--chart-label)\" }}>{valueFormat(n.value)}</text>\n </g>\n );\n })}\n </g>\n </svg>\n {tip && interactive ? <ChartTooltip x={tip.x} y={tip.y || height / 2} flip={tip.x > width * 0.6} title={tip.title} rows={tip.rows} /> : null}\n </div>\n );\n}\n","charts/SankeyChart.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { fmtNum, sankey, SERIES } from './chart-geometry.js'\n// Flow diagram; node columns are derived from the longest path, never positioned by hand.\nconst props = defineProps({ nodes:{type:Array,default:()=>[]}, links:{type:Array,default:()=>[]}, width:{type:Number,default:620}, height:{type:Number,default:320}, nodeWidth:{type:Number,default:13}, nodePadding:{type:Number,default:16}, labelWidth:{type:Number,default:96}, valueFormat:{type:Function,default:fmtNum}, interactive:{type:Boolean,default:true}, style:Object })\nconst hover = ref(null)\nconst g = computed(() => {\n const pad = { l:6, r:props.labelWidth, t:8, b:8 }\n const inner = { w: props.width-pad.l-pad.r, h: props.height-pad.t-pad.b }\n const laid = sankey(props.nodes, props.links, inner.w, inner.h, props.nodeWidth, props.nodePadding)\n const byId = {}; laid.nodes.forEach(n=>{ byId[n.id]=n })\n const color = n => n.color || SERIES[laid.nodes.indexOf(n)%SERIES.length]\n return { pad, inner, laid, byId, color,\n ribbons: laid.ribbons.map((r,i)=>Object.assign({}, r, { i, fill: color(byId[r.link.source]) })) }\n})\nfunction ribbonOpacity(r){\n const h = hover.value\n if (!h) return 0.42\n if (h.kind==='ribbon') return h.i===r.i ? 0.78 : 0.12\n return (r.link.source===h.id || r.link.target===h.id) ? 0.7 : 0.1\n}\nconst tip = computed(()=>{\n const h = hover.value\n if (!h) return null\n if (h.kind==='ribbon') {\n const r = g.value.ribbons[h.i]\n return { x:(r.source.x+r.target.x)/2+g.value.pad.l, y:props.height/2,\n title: g.value.byId[r.link.source].label+' → '+g.value.byId[r.link.target].label, value: props.valueFormat(r.link.value) }\n }\n const n = g.value.byId[h.id]\n return { x:n.x+g.value.pad.l+props.nodeWidth, y:n.y+g.value.pad.t+n.h/2, title:n.label, value: props.valueFormat(n.value) }\n})\n</script>\n<template>\n <div :style=\"{position:'relative',display:'inline-block',lineHeight:0,...style}\">\n <svg :width=\"width\" :height=\"height\" role=\"img\">\n <g :transform=\"'translate('+g.pad.l+','+g.pad.t+')'\">\n <path v-for=\"r in g.ribbons\" :key=\"r.i\" :d=\"r.d\" :fill=\"r.fill\" :opacity=\"ribbonOpacity(r)\"\n @mouseenter=\"interactive && (hover={kind:'ribbon',i:r.i})\" @mouseleave=\"interactive && (hover=null)\"\n style=\"transition:opacity var(--dur-fast) var(--ease-out)\"/>\n <g v-for=\"n in g.laid.nodes\" :key=\"n.id\" @mouseenter=\"interactive && (hover={kind:'node',id:n.id})\" @mouseleave=\"interactive && (hover=null)\">\n <rect :x=\"n.x\" :y=\"n.y\" :width=\"n.w\" :height=\"n.h\" rx=\"2\" :fill=\"g.color(n)\" :opacity=\"hover==null||(hover.kind==='node'&&hover.id===n.id)?1:0.55\"/>\n <text :x=\"n.x+n.w+6\" :y=\"n.y+n.h/2+3.5\" style=\"font-family:var(--font-sans);font-size:var(--text-xs);fill:var(--text-heading)\">{{ n.label }}</text>\n <text :x=\"n.x+n.w+6\" :y=\"n.y+n.h/2+15\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ valueFormat(n.value) }}</text>\n </g>\n </g>\n </svg>\n <div v-if=\"tip && interactive\" :style=\"{position:'absolute',left:tip.x+'px',top:tip.y+'px',transform:tip.x>width*0.6?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal',whiteSpace:'nowrap'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:3px\">{{ tip.title }}</div>\n <div style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)\">{{ tip.value }}</div>\n </div>\n </div>\n</template>\n","charts/ChordChart.jsx":"import React from \"react\";\nimport { fmtNum, chord, chordRibbon, arcPath, polar, seriesColor } from \"./chart-geometry.js\";\nimport { ChartTooltip, wrapStyle } from \"./ChartKit.jsx\";\n\n/**\n * Chord diagram — the circular Sankey. A square `matrix` of flows between `labels`\n * (matrix[i][j] = i → j). Use it when flows are many-to-many and cyclical, where a\n * left-to-right Sankey would cross itself. Hovering an arc isolates that node's flows.\n */\nexport function ChordChart({ labels = [], matrix = [], size = 380, thickness = 14, gap = 2, valueFormat = fmtNum, interactive = true, style }) {\n const [hover, setHover] = React.useState(null);\n const cx = size / 2, cy = size / 2;\n const rO = size / 2 - 58, rI = rO - thickness;\n const { arcs, ribbons, totals, grand } = React.useMemo(() => chord(matrix, gap), [matrix, gap]);\n const colorOf = (i) => seriesColor(i);\n const lit = (r) => hover == null ? 0.42 : hover.kind === \"ribbon\" ? (hover.k === r.k ? 0.8 : 0.08) : (r.i === hover.i || r.j === hover.i ? 0.72 : 0.07);\n const keyed = ribbons.map((r, k) => ({ ...r, k }));\n const h = hover;\n const tipRows = () => {\n if (h.kind === \"ribbon\") {\n const r = keyed[h.k];\n return { title: labels[r.i] + \" → \" + labels[r.j], rows: [{ label: \"flow\", value: valueFormat(r.value), color: colorOf(r.i) }, { label: \"share\", value: ((r.value / grand) * 100).toFixed(1) + \"%\" }] };\n }\n const out = matrix[h.i].reduce((s, v, j) => s + (j === h.i ? 0 : v), 0);\n const inc = matrix.reduce((s, row, j) => s + (j === h.i ? 0 : row[h.i]), 0);\n return { title: labels[h.i], rows: [{ label: \"outgoing\", value: valueFormat(out), color: colorOf(h.i) }, { label: \"incoming\", value: valueFormat(inc) }, { label: \"total\", value: valueFormat(totals[h.i]) }] };\n };\n const tipAt = h ? polar(cx, cy, rI * 0.55, h.kind === \"ribbon\" ? (keyed[h.k].a0 + keyed[h.k].b1) / 2 : (arcs[h.i].a0 + arcs[h.i].a1) / 2) : null;\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <svg width={size} height={size} role=\"img\">\n {keyed.map((r) => (\n <path key={r.k} d={chordRibbon(cx, cy, rI - 1, r.a0, r.a1, r.b0, r.b1)} fill={colorOf(r.i)} opacity={lit(r)}\n onMouseEnter={interactive ? () => setHover({ kind: \"ribbon\", k: r.k }) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}\n style={{ transition: \"opacity var(--dur-fast) var(--ease-out)\" }} />\n ))}\n {arcs.map((a) => {\n const mid = (a.a0 + a.a1) / 2;\n const [lx, ly] = polar(cx, cy, rO + 12, mid);\n const on = hover && hover.kind === \"node\" && hover.i === a.i;\n const anchor = Math.abs(lx - cx) < 10 ? \"middle\" : lx > cx ? \"start\" : \"end\";\n return (\n <g key={a.i} onMouseEnter={interactive ? () => setHover({ kind: \"node\", i: a.i }) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}\n style={{ cursor: interactive ? \"default\" : undefined }}>\n <path d={arcPath(cx, cy, rO, rI, a.a0, a.a1)} fill={colorOf(a.i)} opacity={hover == null || on ? 1 : 0.4} style={{ transition: \"opacity var(--dur-fast) var(--ease-out)\" }} />\n <text x={lx} y={ly + 3} textAnchor={anchor} style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", fill: on ? \"var(--text-heading)\" : \"var(--text-secondary)\" }}>{labels[a.i]}</text>\n <text x={lx} y={ly + 14} textAnchor={anchor} style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", fill: \"var(--chart-label)\" }}>{valueFormat(a.value)}</text>\n </g>\n );\n })}\n </svg>\n {h && interactive ? <ChartTooltip x={tipAt[0]} y={tipAt[1]} flip={tipAt[0] > size * 0.55} {...tipRows()} /> : null}\n </div>\n );\n}\n","charts/ChordChart.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { fmtNum, chord, chordRibbon, arcPath, polar, SERIES } from './chart-geometry.js'\n// Circular Sankey — many-to-many, cyclical flows. matrix[i][j] = flow from i to j.\nconst props = defineProps({ labels:{type:Array,default:()=>[]}, matrix:{type:Array,default:()=>[]}, size:{type:Number,default:380}, thickness:{type:Number,default:14}, gap:{type:Number,default:2}, valueFormat:{type:Function,default:fmtNum}, interactive:{type:Boolean,default:true}, style:Object })\nconst hover = ref(null)\nconst g = computed(() => {\n const cx = props.size/2, cy = props.size/2\n const rO = props.size/2-58, rI = rO-props.thickness\n const { arcs, ribbons, totals, grand } = chord(props.matrix, props.gap)\n return { cx, cy, rO, rI, grand, totals,\n arcs: arcs.map(a=>{\n const mid=(a.a0+a.a1)/2, l=polar(cx,cy,rO+12,mid)\n return Object.assign({}, a, { mid, d: arcPath(cx,cy,rO,rI,a.a0,a.a1), color: SERIES[a.i%SERIES.length],\n lx:l[0], ly:l[1], anchor: Math.abs(l[0]-cx)<10 ? 'middle' : (l[0]>cx?'start':'end') })\n }),\n ribbons: ribbons.map((r,k)=>Object.assign({}, r, { k, d: chordRibbon(cx,cy,rI-1,r.a0,r.a1,r.b0,r.b1), color: SERIES[r.i%SERIES.length] })) }\n})\nfunction op(r){\n const h = hover.value\n if (!h) return 0.42\n return h.kind==='ribbon' ? (h.k===r.k?0.8:0.08) : ((r.i===h.i||r.j===h.i)?0.72:0.07)\n}\nconst tip = computed(()=>{\n const h = hover.value\n if (!h) return null\n if (h.kind==='ribbon') {\n const r = g.value.ribbons[h.k], p = polar(g.value.cx,g.value.cy,g.value.rI*0.55,(r.a0+r.b1)/2)\n return { x:p[0], y:p[1], title: props.labels[r.i]+' → '+props.labels[r.j], value: props.valueFormat(r.value)+' · '+((r.value/g.value.grand)*100).toFixed(1)+'%' }\n }\n const a = g.value.arcs[h.i], p = polar(g.value.cx,g.value.cy,g.value.rI*0.55,a.mid)\n const out = props.matrix[h.i].reduce((s,v,j)=>s+(j===h.i?0:v),0)\n const inc = props.matrix.reduce((s,row,j)=>s+(j===h.i?0:row[h.i]),0)\n return { x:p[0], y:p[1], title: props.labels[h.i], value: 'out '+props.valueFormat(out)+' · in '+props.valueFormat(inc) }\n})\n</script>\n<template>\n <div :style=\"{position:'relative',display:'inline-block',lineHeight:0,...style}\">\n <svg :width=\"size\" :height=\"size\" role=\"img\">\n <path v-for=\"r in g.ribbons\" :key=\"r.k\" :d=\"r.d\" :fill=\"r.color\" :opacity=\"op(r)\"\n @mouseenter=\"interactive && (hover={kind:'ribbon',k:r.k})\" @mouseleave=\"interactive && (hover=null)\"\n style=\"transition:opacity var(--dur-fast) var(--ease-out)\"/>\n <g v-for=\"a in g.arcs\" :key=\"a.i\" @mouseenter=\"interactive && (hover={kind:'node',i:a.i})\" @mouseleave=\"interactive && (hover=null)\">\n <path :d=\"a.d\" :fill=\"a.color\" :opacity=\"hover==null||(hover.kind==='node'&&hover.i===a.i)?1:0.4\" style=\"transition:opacity var(--dur-fast) var(--ease-out)\"/>\n <text :x=\"a.lx\" :y=\"a.ly+3\" :text-anchor=\"a.anchor\" style=\"font-family:var(--font-sans);font-size:var(--text-2xs);fill:var(--text-secondary)\">{{ labels[a.i] }}</text>\n <text :x=\"a.lx\" :y=\"a.ly+14\" :text-anchor=\"a.anchor\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ valueFormat(a.value) }}</text>\n </g>\n </svg>\n <div v-if=\"tip && interactive\" :style=\"{position:'absolute',left:tip.x+'px',top:tip.y+'px',transform:tip.x>size*0.55?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal',whiteSpace:'nowrap'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:3px\">{{ tip.title }}</div>\n <div style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)\">{{ tip.value }}</div>\n </div>\n </div>\n</template>\n","charts/SunburstChart.jsx":"import React from \"react\";\nimport { fmtNum, arcPath, polar, seriesColor } from \"./chart-geometry.js\";\nimport { ChartTooltip, wrapStyle } from \"./ChartKit.jsx\";\n\n/**\n * Sunburst — a hierarchy as concentric rings (the radial treemap). data: a root\n * {label, children:[{label, value, children?}]} tree; leaf values roll up. Clicking a\n * ring zooms into that branch, and the center returns to the parent.\n */\nexport function SunburstChart({ data, size = 320, ringWidth, valueFormat = fmtNum, interactive = true, style }) {\n const [zoom, setZoom] = React.useState([]);\n const [hover, setHover] = React.useState(null);\n const roll = (n) => (n.children && n.children.length ? { ...n, value: n.children.reduce((s, c) => s + roll(c).value, 0), children: n.children.map(roll) } : { ...n, value: n.value || 0 });\n const rooted = data ? roll(data) : { label: \"\", value: 0, children: [] };\n const focus = zoom.reduce((n, i) => (n.children ? n.children[i] : n), rooted);\n const depthOf = (n) => (n.children && n.children.length ? 1 + Math.max(...n.children.map(depthOf)) : 0);\n const rings = Math.max(1, depthOf(focus));\n const rInner = size * 0.17;\n const rw = ringWidth || (size / 2 - 6 - rInner) / rings;\n const segs = [];\n const walk = (node, depth, a0, a1, path, colorIdx) => {\n (node.children || []).forEach((c, ci) => {\n const span = (c.value / (node.value || 1)) * (a1 - a0);\n const idx = depth === 0 ? ci : colorIdx;\n segs.push({ node: c, depth, a0, a1: a0 + span, path: [...path, ci], colorIdx: idx });\n walk(c, depth + 1, a0, a0 + span, [...path, ci], idx);\n a0 += span;\n });\n };\n walk(focus, 0, 0, 360, [], 0);\n const h = hover != null ? segs[hover] : null;\n const tipAt = h ? polar(size / 2, size / 2, rInner + rw * (h.depth + 0.5), (h.a0 + h.a1) / 2) : null;\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <div style={{ position: \"relative\", width: size, height: size }}>\n <svg width={size} height={size} role=\"img\">\n {segs.map((s, i) => (\n <path key={i} d={arcPath(size / 2, size / 2, rInner + rw * (s.depth + 1) - 1.5, rInner + rw * s.depth, s.a0 + 0.35, Math.max(s.a0 + 0.5, s.a1 - 0.35))}\n fill={seriesColor(s.colorIdx)} opacity={hover == null ? 0.92 - s.depth * 0.16 : hover === i ? 1 : 0.28}\n onMouseEnter={interactive ? () => setHover(i) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}\n onClick={interactive && s.node.children ? () => { setZoom([...zoom, ...s.path]); setHover(null); } : undefined}\n style={{ transition: \"opacity var(--dur-fast) var(--ease-out)\", cursor: interactive && s.node.children ? \"pointer\" : \"default\" }} />\n ))}\n <circle cx={size / 2} cy={size / 2} r={rInner - 2} fill=\"var(--bg-surface)\" stroke=\"var(--border-subtle)\" strokeWidth=\"1\"\n onClick={interactive && zoom.length ? () => setZoom(zoom.slice(0, -1)) : undefined} style={{ cursor: interactive && zoom.length ? \"pointer\" : \"default\" }} />\n </svg>\n <div style={{ position: \"absolute\", inset: 0, display: \"flex\", flexDirection: \"column\", alignItems: \"center\", justifyContent: \"center\", pointerEvents: \"none\", lineHeight: \"normal\", gap: 2 }}>\n <span style={{ fontFamily: \"var(--font-sans)\", fontWeight: \"var(--weight-light)\", fontSize: Math.round(size / 13), color: \"var(--text-heading)\" }}>{valueFormat(focus.value)}</span>\n <span style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", color: \"var(--text-muted)\", maxWidth: rInner * 1.7, textAlign: \"center\", whiteSpace: \"nowrap\", overflow: \"hidden\", textOverflow: \"ellipsis\" }}>\n {zoom.length ? focus.label : focus.label || \"Total\"}\n </span>\n {zoom.length ? <span style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", color: \"var(--action-primary)\" }}>↑ back</span> : null}\n </div>\n {h && interactive ? (\n <ChartTooltip x={tipAt[0]} y={tipAt[1]} flip={tipAt[0] > size * 0.55} title={h.node.label}\n rows={[{ label: \"value\", value: valueFormat(h.node.value), color: seriesColor(h.colorIdx) }, { label: \"of parent\", value: ((h.node.value / (focus.value || 1)) * 100).toFixed(1) + \"%\" }]} />\n ) : null}\n </div>\n </div>\n );\n}\n","charts/SunburstChart.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { fmtNum, arcPath, polar, SERIES } from './chart-geometry.js'\n// Hierarchy as concentric rings; click to zoom a branch, click the center to go back.\nconst props = defineProps({ data:Object, size:{type:Number,default:320}, ringWidth:Number, valueFormat:{type:Function,default:fmtNum}, interactive:{type:Boolean,default:true}, style:Object })\nconst zoom = ref([]); const hover = ref(null)\nfunction roll(n){ return n.children && n.children.length ? Object.assign({}, n, { value: n.children.reduce((s,c)=>s+roll(c).value,0), children: n.children.map(roll) }) : Object.assign({}, n, { value: n.value||0 }) }\nconst g = computed(() => {\n const rooted = props.data ? roll(props.data) : { label:'', value:0, children:[] }\n const focus = zoom.value.reduce((n,i)=> n.children ? n.children[i] : n, rooted)\n const depthOf = n => n.children && n.children.length ? 1+Math.max(...n.children.map(depthOf)) : 0\n const rings = Math.max(1, depthOf(focus))\n const rInner = props.size*0.17\n const rw = props.ringWidth || (props.size/2-6-rInner)/rings\n const segs = []\n const walk = (node, depth, a0, a1, path, colorIdx) => {\n (node.children||[]).forEach((c,ci)=>{\n const span = (c.value/(node.value||1))*(a1-a0)\n const idx = depth===0 ? ci : colorIdx\n segs.push({ node:c, depth, a0, a1:a0+span, path:[...path,ci], colorIdx:idx,\n d: arcPath(props.size/2, props.size/2, rInner+rw*(depth+1)-1.5, rInner+rw*depth, a0+0.35, Math.max(a0+0.5, a0+span-0.35)),\n color: SERIES[idx%SERIES.length] })\n walk(c, depth+1, a0, a0+span, [...path,ci], idx)\n a0 += span\n })\n }\n walk(focus, 0, 0, 360, [], 0)\n return { focus, rInner, rw, segs }\n})\nconst tip = computed(()=>{\n if (hover.value==null) return null\n const s = g.value.segs[hover.value]\n const p = polar(props.size/2, props.size/2, g.value.rInner+g.value.rw*(s.depth+0.5), (s.a0+s.a1)/2)\n return { x:p[0], y:p[1], title:s.node.label, value: props.valueFormat(s.node.value)+' · '+((s.node.value/(g.value.focus.value||1))*100).toFixed(1)+'%' }\n})\n</script>\n<template>\n <div :style=\"{position:'relative',display:'inline-block',lineHeight:0,...style}\">\n <div :style=\"{position:'relative',width:size+'px',height:size+'px'}\">\n <svg :width=\"size\" :height=\"size\" role=\"img\">\n <path v-for=\"(s,i) in g.segs\" :key=\"i\" :d=\"s.d\" :fill=\"s.color\"\n :opacity=\"hover==null ? 0.92-s.depth*0.16 : (hover===i?1:0.28)\"\n @mouseenter=\"interactive && (hover=i)\" @mouseleave=\"interactive && (hover=null)\"\n @click=\"interactive && s.node.children && (zoom=[...zoom,...s.path], hover=null)\"\n :style=\"{transition:'opacity var(--dur-fast) var(--ease-out)',cursor:interactive&&s.node.children?'pointer':'default'}\"/>\n <circle :cx=\"size/2\" :cy=\"size/2\" :r=\"g.rInner-2\" fill=\"var(--bg-surface)\" stroke=\"var(--border-subtle)\" stroke-width=\"1\"\n @click=\"interactive && zoom.length && (zoom=zoom.slice(0,-1))\" :style=\"{cursor:interactive&&zoom.length?'pointer':'default'}\"/>\n </svg>\n <div style=\"position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;line-height:normal;gap:2px\">\n <span :style=\"{fontFamily:'var(--font-sans)',fontWeight:'var(--weight-light)',fontSize:Math.round(size/13)+'px',color:'var(--text-heading)'}\">{{ valueFormat(g.focus.value) }}</span>\n <span :style=\"{fontFamily:'var(--font-sans)',fontSize:'var(--text-2xs)',color:'var(--text-muted)',maxWidth:g.rInner*1.7+'px',textAlign:'center',whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}\">{{ g.focus.label || 'Total' }}</span>\n <span v-if=\"zoom.length\" style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--action-primary)\">↑ back</span>\n </div>\n <div v-if=\"tip && interactive\" :style=\"{position:'absolute',left:tip.x+'px',top:tip.y+'px',transform:tip.x>size*0.55?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal',whiteSpace:'nowrap'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:3px\">{{ tip.title }}</div>\n <div style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)\">{{ tip.value }}</div>\n </div>\n </div>\n </div>\n</template>\n","charts/WorldMap.jsx":"import React from \"react\";\nimport { fmtNum, quantize } from \"./chart-geometry.js\";\nimport { ChartTooltip, wrapStyle } from \"./ChartKit.jsx\";\n\nconst HEAT = [\"var(--chart-heat-1)\", \"var(--chart-heat-2)\", \"var(--chart-heat-3)\", \"var(--chart-heat-4)\", \"var(--chart-heat-5)\"];\nconst TOPO = \"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json\";\n/* Natural Earth spells several countries formally; accept the everyday name too so\n callers can write { \"United States\": 1 } instead of \"United States of America\". */\nconst ALIAS = {\n \"united states\": \"United States of America\", usa: \"United States of America\", us: \"United States of America\",\n uk: \"United Kingdom\", \"great britain\": \"United Kingdom\", britain: \"United Kingdom\",\n \"south korea\": \"South Korea\", korea: \"South Korea\", \"czech republic\": \"Czechia\",\n uae: \"United Arab Emirates\", drc: \"Dem. Rep. Congo\", \"democratic republic of the congo\": \"Dem. Rep. Congo\",\n \"bosnia and herzegovina\": \"Bosnia and Herz.\", \"dominican republic\": \"Dominican Rep.\",\n \"central african republic\": \"Central African Rep.\", \"south sudan\": \"S. Sudan\", \"ivory coast\": \"Côte d'Ivoire\",\n};\n\n/**\n * World choropleth. Real Natural Earth geometry (world-atlas 110m) projected with d3-geo —\n * never hand-drawn outlines. `values` maps country name → number (\"United States\",\n * \"Germany\"…); unmatched countries render as empty land. Hover highlights a country and\n * pins its value; click reports it.\n *\n * The host page must load the pinned d3 + topojson-client tags (see the card source);\n * the component reads them off `window` and fetches the pinned topology itself.\n */\nexport function WorldMap({ values = {}, width = 660, height = 340, projection = \"naturalEarth\", scale = \"blue\", valueFormat = fmtNum, legend = true, unit, interactive = true, onSelect, style }) {\n const [geo, setGeo] = React.useState(null);\n const [err, setErr] = React.useState(null);\n const [hover, setHover] = React.useState(null);\n\n React.useEffect(() => {\n let dead = false;\n const d3 = window.d3, topojson = window.topojson;\n if (!d3 || !topojson) { setErr(\"d3 + topojson-client are required on the page\"); return; }\n fetch(TOPO).then((r) => r.json()).then((topo) => {\n if (dead) return;\n const fc = topojson.feature(topo, topo.objects.countries);\n setGeo(fc);\n }).catch(() => !dead && setErr(\"could not load country geometry\"));\n return () => { dead = true; };\n }, []);\n\n const paths = React.useMemo(() => {\n if (!geo || !window.d3) return null;\n const d3 = window.d3;\n const proj = (projection === \"mercator\" ? d3.geoMercator() : projection === \"equalEarth\" && d3.geoEqualEarth ? d3.geoEqualEarth() : d3.geoNaturalEarth1())\n .fitSize([width, height - (legend ? 22 : 0)], geo);\n const gp = d3.geoPath(proj);\n return geo.features.map((f) => ({ name: f.properties.name, d: gp(f), c: gp.centroid(f) }));\n }, [geo, width, height, projection, legend]);\n\n /* Resolve caller keys to Natural Earth feature names once, so both the scale and every\n lookup agree on the same key set. */\n const resolved = React.useMemo(() => {\n const out = {};\n Object.keys(values).forEach((k) => {\n const canon = ALIAS[k.toLowerCase()] || k;\n out[canon] = values[k];\n });\n return out;\n }, [values]);\n const nums = Object.values(resolved).filter((v) => typeof v === \"number\");\n const bucket = quantize(nums, 5);\n const lo = Math.min(...nums), hi = Math.max(...nums);\n const fillOf = (name) => {\n const v = resolved[name];\n if (v == null) return \"var(--chart-track)\";\n if (scale === \"diverging\") {\n const amax = Math.max(Math.abs(lo), Math.abs(hi)) || 1;\n const a = Math.round((0.18 + Math.min(1, Math.abs(v) / amax) * 0.82) * 100);\n return \"color-mix(in srgb, var(--chart-\" + (v >= 0 ? \"pos\" : \"neg\") + \") \" + a + \"%, var(--bg-surface))\";\n }\n return HEAT[Math.max(0, bucket(v))];\n };\n\n if (err) return <div style={{ width, height, display: \"flex\", alignItems: \"center\", justifyContent: \"center\", fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", color: \"var(--text-muted)\", background: \"var(--bg-inset)\", borderRadius: \"var(--radius-lg)\", ...style }}>{err}</div>;\n if (!paths) return <div style={{ width, height, display: \"flex\", alignItems: \"center\", justifyContent: \"center\", fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", color: \"var(--text-muted)\", ...style }}>loading map…</div>;\n\n const h = hover != null ? paths[hover] : null;\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <svg width={width} height={height - (legend ? 22 : 0)} role=\"img\">\n {paths.map((p, i) => (\n <path key={p.name + i} d={p.d} fill={fillOf(p.name)} stroke=\"var(--bg-surface)\" strokeWidth=\"0.5\"\n onMouseEnter={interactive ? () => setHover(i) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}\n onClick={onSelect ? () => onSelect(p.name, resolved[p.name]) : undefined}\n style={{ cursor: onSelect ? \"pointer\" : \"default\", outline: \"none\", opacity: hover == null || hover === i ? 1 : 0.75, transition: \"opacity var(--dur-fast) var(--ease-out)\" }} />\n ))}\n {h ? <path d={h.d} fill=\"none\" stroke=\"var(--action-primary)\" strokeWidth=\"1.4\" pointerEvents=\"none\" /> : null}\n </svg>\n {legend ? (\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 6, paddingTop: 6, lineHeight: \"normal\" }}>\n <span style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", color: \"var(--chart-label)\" }}>{valueFormat(lo)}</span>\n {(scale === \"diverging\" ? [\"var(--chart-neg)\", \"color-mix(in srgb, var(--chart-neg) 40%, var(--bg-surface))\", \"var(--chart-track)\", \"color-mix(in srgb, var(--chart-pos) 40%, var(--bg-surface))\", \"var(--chart-pos)\"] : HEAT).map((c, i) => (\n <span key={i} style={{ width: 22, height: 8, borderRadius: 2, background: c }} />\n ))}\n <span style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", color: \"var(--chart-label)\" }}>{valueFormat(hi)}{unit ? \" \" + unit : \"\"}</span>\n </div>\n ) : null}\n {h && interactive ? (\n <ChartTooltip x={h.c[0]} y={h.c[1]} flip={h.c[0] > width * 0.62} title={h.name}\n rows={[{ label: unit || \"value\", value: resolved[h.name] == null ? \"no data\" : valueFormat(resolved[h.name]), color: resolved[h.name] == null ? undefined : fillOf(h.name) }]} />\n ) : null}\n </div>\n );\n}\n","charts/WorldMap.vue":"<script setup>\nimport { ref, computed, onMounted } from 'vue'\nimport { fmtNum, quantize } from './chart-geometry.js'\n/**\n * World choropleth on real Natural Earth geometry (world-atlas 110m) projected with d3-geo.\n * The host page MUST load the pinned d3 + topojson-client tags; this reads them off window.\n */\nconst HEAT = ['var(--chart-heat-1)','var(--chart-heat-2)','var(--chart-heat-3)','var(--chart-heat-4)','var(--chart-heat-5)']\nconst TOPO = 'https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json'\nconst ALIAS = { 'united states':'United States of America', usa:'United States of America', us:'United States of America',\n uk:'United Kingdom', britain:'United Kingdom', 'great britain':'United Kingdom', 'czech republic':'Czechia',\n uae:'United Arab Emirates', 'south sudan':'S. Sudan', 'dominican republic':'Dominican Rep.',\n 'bosnia and herzegovina':'Bosnia and Herz.', 'central african republic':'Central African Rep.', 'ivory coast':\"Côte d'Ivoire\" }\nconst props = defineProps({ values:{type:Object,default:()=>({})}, width:{type:Number,default:660}, height:{type:Number,default:340}, projection:{type:String,default:'naturalEarth'}, scale:{type:String,default:'blue'}, valueFormat:{type:Function,default:fmtNum}, legend:{type:Boolean,default:true}, unit:String, interactive:{type:Boolean,default:true}, style:Object })\nconst emit = defineEmits(['select'])\nconst geo = ref(null); const err = ref(null); const hover = ref(null)\nonMounted(() => {\n const d3 = window.d3, topojson = window.topojson\n if (!d3 || !topojson) { err.value = 'd3 + topojson-client are required on the page'; return }\n fetch(TOPO).then(r=>r.json()).then(topo=>{ geo.value = topojson.feature(topo, topo.objects.countries) })\n .catch(()=>{ err.value = 'could not load country geometry' })\n})\nconst resolved = computed(() => {\n const out = {}\n Object.keys(props.values).forEach(k=>{ out[ALIAS[k.toLowerCase()]||k] = props.values[k] })\n return out\n})\nconst plotH = computed(() => props.height - (props.legend ? 22 : 0))\nconst paths = computed(() => {\n if (!geo.value || !window.d3) return null\n const d3 = window.d3\n const proj = (props.projection==='mercator' ? d3.geoMercator() : (props.projection==='equalEarth' && d3.geoEqualEarth ? d3.geoEqualEarth() : d3.geoNaturalEarth1())).fitSize([props.width, plotH.value], geo.value)\n const gp = d3.geoPath(proj)\n return geo.value.features.map(f=>({ name:f.properties.name, d:gp(f), c:gp.centroid(f) }))\n})\nconst nums = computed(() => Object.values(resolved.value).filter(v=>typeof v==='number'))\nconst lo = computed(() => Math.min(...nums.value)); const hi = computed(() => Math.max(...nums.value))\nconst bucket = computed(() => quantize(nums.value, 5))\nfunction fill(name){\n const v = resolved.value[name]\n if (v==null) return 'var(--chart-track)'\n if (props.scale==='diverging') {\n const amax = Math.max(Math.abs(lo.value), Math.abs(hi.value))||1\n const a = Math.round((0.18+Math.min(1,Math.abs(v)/amax)*0.82)*100)\n return 'color-mix(in srgb, var(--chart-'+(v>=0?'pos':'neg')+') '+a+'%, var(--bg-surface))'\n }\n return HEAT[Math.max(0, bucket.value(v))]\n}\nconst swatches = computed(() => props.scale==='diverging'\n ? ['var(--chart-neg)','color-mix(in srgb, var(--chart-neg) 40%, var(--bg-surface))','var(--chart-track)','color-mix(in srgb, var(--chart-pos) 40%, var(--bg-surface))','var(--chart-pos)']\n : HEAT)\n</script>\n<template>\n <div v-if=\"err\" :style=\"{width:width+'px',height:height+'px',display:'flex',alignItems:'center',justifyContent:'center',fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums',fontSize:'var(--text-xs)',color:'var(--text-muted)',background:'var(--bg-inset)',borderRadius:'var(--radius-lg)',...style}\">{{ err }}</div>\n <div v-else-if=\"!paths\" :style=\"{width:width+'px',height:height+'px',display:'flex',alignItems:'center',justifyContent:'center',fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums',fontSize:'var(--text-xs)',color:'var(--text-muted)',...style}\">loading map…</div>\n <div v-else :style=\"{position:'relative',display:'inline-block',lineHeight:0,...style}\">\n <svg :width=\"width\" :height=\"plotH\" role=\"img\">\n <path v-for=\"(p,i) in paths\" :key=\"p.name+i\" :d=\"p.d\" :fill=\"fill(p.name)\" stroke=\"var(--bg-surface)\" stroke-width=\"0.5\"\n @mouseenter=\"interactive && (hover=i)\" @mouseleave=\"interactive && (hover=null)\" @click=\"emit('select', p.name, resolved[p.name])\"\n :style=\"{opacity:hover==null||hover===i?1:0.75,transition:'opacity var(--dur-fast) var(--ease-out)'}\"/>\n <path v-if=\"hover!=null\" :d=\"paths[hover].d\" fill=\"none\" stroke=\"var(--action-primary)\" stroke-width=\"1.4\" pointer-events=\"none\"/>\n </svg>\n <div v-if=\"legend\" style=\"display:flex;align-items:center;gap:6px;padding-top:6px;line-height:normal\">\n <span style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);color:var(--chart-label)\">{{ valueFormat(lo) }}</span>\n <span v-for=\"(c,i) in swatches\" :key=\"i\" :style=\"{width:'22px',height:'8px',borderRadius:'2px',background:c}\"/>\n <span style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);color:var(--chart-label)\">{{ valueFormat(hi) }}{{ unit ? ' '+unit : '' }}</span>\n </div>\n <div v-if=\"hover!=null && interactive\" :style=\"{position:'absolute',left:paths[hover].c[0]+'px',top:paths[hover].c[1]+'px',transform:paths[hover].c[0]>width*0.62?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal',whiteSpace:'nowrap'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:3px\">{{ paths[hover].name }}</div>\n <div style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)\">{{ resolved[paths[hover].name]==null ? 'no data' : valueFormat(resolved[paths[hover].name]) }}</div>\n </div>\n </div>\n</template>\n","charts/GanttChart.jsx":"import React from \"react\";\nimport { seriesColor } from \"./chart-geometry.js\";\nimport { ChartTooltip, wrapStyle } from \"./ChartKit.jsx\";\n\nconst DAY = 86400000;\nconst parse = (d) => (d instanceof Date ? d : new Date(d));\n\n/**\n * Gantt timeline — close calendars, migration waves, release plans.\n * tasks: [{label, start, end, progress?, lane?, color?, milestone?}] with ISO dates.\n * Weekend columns are tinted, today draws a live marker, and hovering a bar reports\n * its window, duration and progress.\n */\nexport function GanttChart({ tasks = [], width = 660, rowHeight = 26, today, unit = \"day\", interactive = true, style }) {\n const [hover, setHover] = React.useState(null);\n const labelW = 150;\n const t0 = new Date(Math.min(...tasks.map((t) => parse(t.start))));\n const t1 = new Date(Math.max(...tasks.map((t) => parse(t.end || t.start))));\n const days = Math.max(1, Math.round((t1 - t0) / DAY) + 1);\n const plotW = width - labelW - 8;\n const x = (d) => labelW + ((parse(d) - t0) / (days * DAY)) * plotW;\n const height = tasks.length * rowHeight + 26;\n /* One tick per ~52px of plot: enough room for \"Mar 12\" without labels touching. */\n const ticks = [];\n const perLabel = 52;\n const stepDays = Math.max(unit === \"week\" ? 7 : 1, Math.ceil(days / Math.max(2, Math.floor(plotW / perLabel))));\n for (let i = 0; i <= days; i += stepDays) ticks.push(new Date(+t0 + i * DAY));\n const fmt = (d) => d.toLocaleDateString(\"en-US\", { month: \"short\", day: \"numeric\" });\n const h = hover != null ? tasks[hover] : null;\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <svg width={width} height={height} role=\"img\">\n {ticks.map((d, i) => (\n <g key={i}>\n <line x1={x(d)} x2={x(d)} y1={20} y2={height - 4} stroke=\"var(--chart-grid)\" strokeWidth=\"1\" />\n <text x={x(d)} y={12} textAnchor=\"middle\" style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", fill: \"var(--chart-label)\" }}>{fmt(d)}</text>\n </g>\n ))}\n {[...Array(days)].map((_, i) => {\n const d = new Date(+t0 + i * DAY);\n const wk = d.getDay() === 0 || d.getDay() === 6;\n return wk ? <rect key={i} x={x(d)} y={20} width={plotW / days} height={height - 24} fill=\"var(--bg-inset)\" opacity=\"0.6\" /> : null;\n })}\n {tasks.map((t, i) => {\n const y = 24 + i * rowHeight;\n const c = t.color || seriesColor(t.lane != null ? t.lane : i);\n const bx = x(t.start), bw = Math.max(4, x(t.end || t.start) - bx + plotW / days);\n const on = hover === i;\n return (\n <g key={t.label + i} onMouseEnter={interactive ? () => setHover(i) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}\n opacity={hover == null || on ? 1 : 0.55} style={{ transition: \"opacity var(--dur-fast) var(--ease-out)\", cursor: interactive ? \"default\" : undefined }}>\n <text x={0} y={y + rowHeight / 2 + 3} style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-xs)\", fill: on ? \"var(--text-heading)\" : \"var(--text-secondary)\" }}>\n {t.label.length > 22 ? t.label.slice(0, 21) + \"…\" : t.label}\n </text>\n {t.milestone ? (\n <path d={\"M\" + bx + \" \" + (y + rowHeight / 2 - 6) + \" L\" + (bx + 6) + \" \" + (y + rowHeight / 2) + \" L\" + bx + \" \" + (y + rowHeight / 2 + 6) + \" L\" + (bx - 6) + \" \" + (y + rowHeight / 2) + \" Z\"} fill={c} />\n ) : (\n <>\n <rect x={bx} y={y + 4} width={bw} height={rowHeight - 12} rx=\"3\" fill={c} opacity=\"0.32\" />\n {t.progress != null ? <rect x={bx} y={y + 4} width={Math.max(2, bw * Math.min(1, t.progress))} height={rowHeight - 12} rx=\"3\" fill={c} /> : null}\n </>\n )}\n </g>\n );\n })}\n {today ? <g>\n <line x1={x(today)} x2={x(today)} y1={18} y2={height - 4} stroke=\"var(--action-primary)\" strokeWidth=\"1.2\" />\n <circle cx={x(today)} cy={18} r=\"2.6\" fill=\"var(--action-primary)\" />\n </g> : null}\n </svg>\n {h && interactive ? (\n <ChartTooltip x={x(h.start)} y={24 + hover * rowHeight + rowHeight / 2} flip={x(h.start) > width * 0.6} title={h.label}\n rows={[{ label: \"start\", value: fmt(parse(h.start)), color: h.color || seriesColor(h.lane != null ? h.lane : hover) },\n { label: \"end\", value: fmt(parse(h.end || h.start)) },\n { label: \"duration\", value: Math.max(1, Math.round((parse(h.end || h.start) - parse(h.start)) / DAY) + 1) + \"d\" }]\n .concat(h.progress != null ? [{ label: \"progress\", value: Math.round(h.progress * 100) + \"%\" }] : [])} />\n ) : null}\n </div>\n );\n}\n","charts/GanttChart.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { SERIES } from './chart-geometry.js'\n// Timeline of tasks with weekend tinting, progress fill, today marker and milestones.\nconst DAY = 86400000\nconst props = defineProps({ tasks:{type:Array,default:()=>[]}, width:{type:Number,default:660}, rowHeight:{type:Number,default:26}, today:[String,Object], unit:{type:String,default:'day'}, interactive:{type:Boolean,default:true}, style:Object })\nconst hover = ref(null)\nconst parse = d => d instanceof Date ? d : new Date(d)\nconst fmt = d => parse(d).toLocaleDateString('en-US',{month:'short',day:'numeric'})\nconst g = computed(() => {\n const labelW = 150\n const t0 = new Date(Math.min(...props.tasks.map(t=>parse(t.start))))\n const t1 = new Date(Math.max(...props.tasks.map(t=>parse(t.end||t.start))))\n const days = Math.max(1, Math.round((t1-t0)/DAY)+1)\n const plotW = props.width-labelW-8\n const x = d => labelW + ((parse(d)-t0)/(days*DAY))*plotW\n const height = props.tasks.length*props.rowHeight+26\n // One tick per ~52px of plot: enough room for \"Mar 12\" without labels touching.\n const stepDays = Math.max(props.unit==='week'?7:1, Math.ceil(days/Math.max(2, Math.floor(plotW/52))))\n const ticks = []\n for (let i=0;i<=days;i+=stepDays) ticks.push(new Date(+t0+i*DAY))\n const weekends = [...Array(days)].map((_,i)=>new Date(+t0+i*DAY)).filter(d=>d.getDay()===0||d.getDay()===6)\n return { labelW, t0, days, plotW, x, height, ticks, weekends, colW: plotW/days,\n rows: props.tasks.map((t,i)=>{\n const bx = x(t.start)\n return { t, i, y: 24+i*props.rowHeight, color: t.color||SERIES[(t.lane!=null?t.lane:i)%SERIES.length],\n bx, bw: Math.max(4, x(t.end||t.start)-bx+plotW/days),\n label: t.label.length>22 ? t.label.slice(0,21)+'…' : t.label,\n dur: Math.max(1, Math.round((parse(t.end||t.start)-parse(t.start))/DAY)+1) }\n }) }\n})\n</script>\n<template>\n <div :style=\"{position:'relative',display:'inline-block',lineHeight:0,...style}\">\n <svg :width=\"width\" :height=\"g.height\" role=\"img\">\n <g v-for=\"(d,i) in g.ticks\" :key=\"'t'+i\">\n <line :x1=\"g.x(d)\" :x2=\"g.x(d)\" :y1=\"20\" :y2=\"g.height-4\" stroke=\"var(--chart-grid)\" stroke-width=\"1\"/>\n <text :x=\"g.x(d)\" :y=\"12\" text-anchor=\"middle\" style=\"font-family:var(--font-sans);font-size:var(--text-2xs);fill:var(--chart-label)\">{{ fmt(d) }}</text>\n </g>\n <rect v-for=\"(d,i) in g.weekends\" :key=\"'w'+i\" :x=\"g.x(d)\" :y=\"20\" :width=\"g.colW\" :height=\"g.height-24\" fill=\"var(--bg-inset)\" opacity=\"0.6\"/>\n <g v-for=\"r in g.rows\" :key=\"r.t.label+r.i\" @mouseenter=\"interactive && (hover=r.i)\" @mouseleave=\"interactive && (hover=null)\"\n :opacity=\"hover==null||hover===r.i?1:0.55\" style=\"transition:opacity var(--dur-fast) var(--ease-out)\">\n <text :x=\"0\" :y=\"r.y+rowHeight/2+3\" :style=\"{fontFamily:'var(--font-sans)',fontSize:'var(--text-xs)',fill:hover===r.i?'var(--text-heading)':'var(--text-secondary)'}\">{{ r.label }}</text>\n <path v-if=\"r.t.milestone\" :d=\"'M'+r.bx+' '+(r.y+rowHeight/2-6)+' L'+(r.bx+6)+' '+(r.y+rowHeight/2)+' L'+r.bx+' '+(r.y+rowHeight/2+6)+' L'+(r.bx-6)+' '+(r.y+rowHeight/2)+' Z'\" :fill=\"r.color\"/>\n <template v-else>\n <rect :x=\"r.bx\" :y=\"r.y+4\" :width=\"r.bw\" :height=\"rowHeight-12\" rx=\"3\" :fill=\"r.color\" opacity=\"0.32\"/>\n <rect v-if=\"r.t.progress!=null\" :x=\"r.bx\" :y=\"r.y+4\" :width=\"Math.max(2,r.bw*Math.min(1,r.t.progress))\" :height=\"rowHeight-12\" rx=\"3\" :fill=\"r.color\"/>\n </template>\n </g>\n <g v-if=\"today\">\n <line :x1=\"g.x(today)\" :x2=\"g.x(today)\" :y1=\"18\" :y2=\"g.height-4\" stroke=\"var(--action-primary)\" stroke-width=\"1.2\"/>\n <circle :cx=\"g.x(today)\" :cy=\"18\" r=\"2.6\" fill=\"var(--action-primary)\"/>\n </g>\n </svg>\n <div v-if=\"hover!=null && interactive\" :style=\"{position:'absolute',left:g.rows[hover].bx+'px',top:(g.rows[hover].y+rowHeight/2)+'px',transform:g.rows[hover].bx>width*0.6?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal',whiteSpace:'nowrap'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:3px\">{{ tasks[hover].label }}</div>\n <div style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)\">{{ fmt(tasks[hover].start) }} → {{ fmt(tasks[hover].end||tasks[hover].start) }} · {{ g.rows[hover].dur }}d<span v-if=\"tasks[hover].progress!=null\"> · {{ Math.round(tasks[hover].progress*100) }}%</span></div>\n </div>\n </div>\n</template>\n","charts/chart-3d.js":"// Shared 3D chart math: yaw/pitch rotation, orthographic projection, depth sorting,\n// face shading. Pure functions — no DOM, no tokens.\n\n/** Rotate (x,y,z) by yaw (around Y) then pitch (around X); degrees. */\nexport function rotate3(x, y, z, yaw, pitch) {\n const a = (yaw * Math.PI) / 180, b = (pitch * Math.PI) / 180;\n const x1 = x * Math.cos(a) - z * Math.sin(a);\n const z1 = x * Math.sin(a) + z * Math.cos(a);\n const y1 = y * Math.cos(b) - z1 * Math.sin(b);\n const z2 = y * Math.sin(b) + z1 * Math.cos(b);\n return [x1, y1, z2];\n}\n\n/** Projector for a unit-ish box: world coords in, screen [x,y] + depth out. */\nexport function makeProj(cx, cy, scale, yaw, pitch) {\n return (x, y, z) => {\n const [rx, ry, rz] = rotate3(x, -y, z, yaw, pitch);\n return { x: cx + rx * scale, y: cy + ry * scale, depth: rz };\n };\n}\n\nexport const facePath = (pts) => \"M\" + pts.map((p) => p.x.toFixed(1) + \" \" + p.y.toFixed(1)).join(\" L\") + \" Z\";\nexport const faceDepth = (pts) => pts.reduce((s, p) => s + p.depth, 0) / pts.length;\n\n/** Lambert-ish shade for a quad: mixes the base color toward white/black by orientation. */\nexport function shade(color, amount) {\n const pct = Math.round(Math.abs(amount) * 100);\n return amount >= 0\n ? `color-mix(in srgb, ${color} ${100 - pct}%, #fff)`\n : `color-mix(in srgb, ${color} ${100 - pct}%, #000)`;\n}\n\n/** Normal-based light factor for a quad given 3 projected-space corners (screen z). */\nexport function lightOf(p0, p1, p2) {\n const ux = p1.x - p0.x, uy = p1.y - p0.y, uz = p1.depth - p0.depth;\n const vx = p2.x - p0.x, vy = p2.y - p0.y, vz = p2.depth - p0.depth;\n const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;\n const len = Math.hypot(nx, ny, nz) || 1;\n // Light from upper-left-front\n return (nx * -0.35 + ny * -0.6 + nz * -0.72) / len;\n}\n\n/** The 6 faces of a cuboid spanning [x0,x1]×[y0,y1]×[z0,z1], projected + depth-sorted. */\nexport function cuboidFaces(P, x0, x1, y0, y1, z0, z1) {\n const c = [\n P(x0, y0, z0), P(x1, y0, z0), P(x1, y1, z0), P(x0, y1, z0),\n P(x0, y0, z1), P(x1, y0, z1), P(x1, y1, z1), P(x0, y1, z1),\n ];\n const F = [\n { pts: [c[0], c[1], c[2], c[3]], kind: \"front\" },\n { pts: [c[4], c[5], c[6], c[7]], kind: \"back\" },\n { pts: [c[3], c[2], c[6], c[7]], kind: \"top\" },\n { pts: [c[0], c[1], c[5], c[4]], kind: \"bottom\" },\n { pts: [c[0], c[3], c[7], c[4]], kind: \"left\" },\n { pts: [c[1], c[2], c[6], c[5]], kind: \"right\" },\n ];\n F.forEach((f) => { f.depth = faceDepth(f.pts); f.d = facePath(f.pts); });\n return F.sort((a, b) => b.depth - a.depth);\n}\n\n/** Drag-to-rotate pointer handlers: returns props to spread on the svg.\n Drag state lives on the DOM node — a closure variable would be reset by every\n React re-render mid-drag, which silently kills the rotation after one frame. */\nexport function dragRotate(get, set) {\n return {\n onPointerDown: (e) => { e.currentTarget.__kbDrag = { x: e.clientX, y: e.clientY, ...get() }; e.currentTarget.setPointerCapture && e.currentTarget.setPointerCapture(e.pointerId); },\n onPointerMove: (e) => {\n const s = e.currentTarget.__kbDrag;\n if (!s) return;\n set({\n yaw: s.yaw + (e.clientX - s.x) * 0.45,\n pitch: Math.max(8, Math.min(80, s.pitch + (e.clientY - s.y) * 0.35)),\n });\n },\n onPointerUp: (e) => { e.currentTarget.__kbDrag = null; },\n onPointerLeave: (e) => { e.currentTarget.__kbDrag = null; },\n };\n}\n\n/** Sequential heat color for t∈[0,1] on the chart heat ramp (surface shading). */\nexport function heatColor(t) {\n const stops = [\"var(--chart-heat-1)\", \"var(--chart-heat-2)\", \"var(--chart-heat-3)\", \"var(--chart-heat-4)\", \"var(--chart-heat-5)\"];\n return stops[Math.max(0, Math.min(4, Math.floor(t * 5 - 1e-9)))];\n}\n","charts/Surface3D.jsx":"import React from \"react\";\nimport { fmtNum, niceTicks } from \"./chart-geometry.js\";\nimport { makeProj, facePath, faceDepth, shade, lightOf, dragRotate, heatColor } from \"./chart-3d.js\";\nimport { ChartTooltip, wrapStyle } from \"./ChartKit.jsx\";\n\n/**\n * 3D surface — z = f(x, y) over a grid. values[row][col]; drag to orbit. Cells shade on\n * the sequential heat ramp by height with light from the upper left, so the shape reads\n * in both themes without a legend.\n */\nexport function Surface3D({ values = [], width = 420, height = 300, yaw: yaw0 = 38, pitch: pitch0 = 32, zScale = 0.55, wire = true, xLabel = \"x\", yLabel = \"y\", interactive = true, valueFormat = fmtNum, style }) {\n const [rot, setRot] = React.useState({ yaw: yaw0, pitch: pitch0 });\n const [hover, setHover] = React.useState(null);\n const rows = values.length, cols = values[0] ? values[0].length : 0;\n let lo = Infinity, hi = -Infinity;\n values.forEach((r) => r.forEach((v) => { if (v < lo) lo = v; if (v > hi) hi = v; }));\n const span = hi - lo || 1;\n const scale = Math.min(width, height) * 0.42;\n const P = makeProj(width / 2, height / 2 + height * 0.05, scale, rot.yaw, rot.pitch);\n const pt = (r, c) => P((c / (cols - 1)) - 0.5, (((values[r][c] - lo) / span) - 0.5) * zScale * 2, (r / (rows - 1)) - 0.5);\n const quads = [];\n for (let r = 0; r < rows - 1; r++) for (let c = 0; c < cols - 1; c++) {\n const pts = [pt(r, c), pt(r, c + 1), pt(r + 1, c + 1), pt(r + 1, c)];\n const avg = (values[r][c] + values[r][c + 1] + values[r + 1][c] + values[r + 1][c + 1]) / 4;\n const t = (avg - lo) / span;\n quads.push({ pts, t, avg, r, c, depth: faceDepth(pts), light: lightOf(pts[0], pts[1], pts[3]) });\n }\n quads.sort((a, b) => b.depth - a.depth);\n /* Base frame + three axes meeting at the front-left corner: X along the columns,\n Y along the rows (depth), Z vertical with real ticks. */\n const yB = -zScale - 0.06;\n const base = [P(-0.5, yB, -0.5), P(0.5, yB, -0.5), P(0.5, yB, 0.5), P(-0.5, yB, 0.5)];\n const basePath = \"M\" + base.map((p) => p.x.toFixed(1) + \" \" + p.y.toFixed(1)).join(\" L\") + \" Z\";\n const corner = P(-0.5, yB, 0.5);\n const axes3 = [\n { a: corner, b: P(0.5, yB, 0.5), label: xLabel, lp: P(0, yB - 0.16, 0.5), anchor: \"middle\" },\n { a: corner, b: P(-0.5, yB, -0.5), label: yLabel, lp: P(-0.62, yB - 0.12, 0), anchor: \"end\" },\n { a: corner, b: P(-0.5, zScale, 0.5) },\n ];\n const zt = niceTicks(lo, hi, 3);\n const zTicks = zt.ticks.filter((t) => t >= lo && t <= hi).map((t) => ({\n p: P(-0.5, ((t - lo) / span - 0.5) * zScale * 2, 0.5), v: valueFormat(t),\n }));\n const handlers = interactive ? dragRotate(() => rot, setRot) : {};\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <svg width={width} height={height} {...handlers} role=\"img\" style={{ cursor: interactive ? \"grab\" : \"default\", touchAction: \"none\" }}>\n <path d={basePath} fill=\"var(--bg-inset)\" stroke=\"var(--chart-axis)\" strokeWidth=\"1\" />\n {axes3.map((ax, i) => <line key={\"ae\" + i} x1={ax.a.x} y1={ax.a.y} x2={ax.b.x} y2={ax.b.y} stroke=\"var(--chart-guide)\" strokeWidth=\"1\" />)}\n {axes3.map((ax, i) => ax.label ? <text key={\"al\" + i} x={ax.lp.x} y={ax.lp.y + 3} textAnchor={ax.anchor} style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", fill: \"var(--text-secondary)\" }}>{ax.label}</text> : null)}\n {zTicks.map((t, i) => (\n <g key={\"zt\" + i}>\n <line x1={t.p.x - 4} y1={t.p.y} x2={t.p.x} y2={t.p.y} stroke=\"var(--chart-guide)\" strokeWidth=\"1\" />\n <text x={t.p.x - 7} y={t.p.y + 3} textAnchor=\"end\" style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", fill: \"var(--chart-label)\" }}>{t.v}</text>\n </g>\n ))}\n {quads.map((q, i) => (\n <path key={i} d={facePath(q.pts)} fill={shade(heatColor(q.t), Math.max(-0.35, Math.min(0.3, q.light * 0.4)) + (hover && hover.i === i ? 0.18 : 0))}\n stroke={wire ? \"var(--bg-surface)\" : \"none\"} strokeWidth=\"0.5\"\n onMouseEnter={interactive ? () => setHover({ i, q, x: q.pts.reduce((s, p) => s + p.x, 0) / 4, y: q.pts.reduce((s, p) => s + p.y, 0) / 4 }) : undefined}\n onMouseLeave={interactive ? () => setHover(null) : undefined} />\n ))}\n </svg>\n {hover && interactive ? (\n <ChartTooltip x={hover.x} y={hover.y} flip={hover.x > width * 0.6} title={\"row \" + (hover.q.r + 1) + \" · col \" + (hover.q.c + 1)}\n rows={[{ label: \"value\", value: valueFormat(hover.q.avg), color: heatColor(hover.q.t) }]} />\n ) : null}\n </div>\n );\n}\n","charts/Scatter3D.jsx":"import React from \"react\";\nimport { fmtNum } from \"./chart-geometry.js\";\nimport { makeProj, dragRotate } from \"./chart-3d.js\";\nimport { ChartTooltip, wrapStyle } from \"./ChartKit.jsx\";\n\n/**\n * 3D scatter — points in x/y/z with group coloring; drag to orbit. Depth-sorted circles\n * with radius attenuated by distance so nearer points read closer.\n */\nexport function Scatter3D({ points = [], width = 420, height = 300, yaw: yaw0 = 35, pitch: pitch0 = 28, r = 4, xLabel = \"x\", yLabel = \"y\", zLabel = \"z\", legend = true, interactive = true, valueFormat = fmtNum, style }) {\n const [rot, setRot] = React.useState({ yaw: yaw0, pitch: pitch0 });\n const [hover, setHover] = React.useState(null);\n const names = [...new Set(points.map((p) => p.series).filter(Boolean))];\n const norm = (arr) => { const l = Math.min(...arr), h = Math.max(...arr), s = h - l || 1; return (v) => (v - l) / s - 0.5; };\n const nx = norm(points.map((p) => p.x)), ny = norm(points.map((p) => p.y)), nz = norm(points.map((p) => p.z));\n const scale = Math.min(width, height) * 0.44;\n const P = makeProj(width / 2, height / 2, scale, rot.yaw, rot.pitch);\n const dots = points.map((p, i) => ({ p, i, ...P(nx(p.x), ny(p.y), nz(p.z)) })).sort((a, b) => b.depth - a.depth);\n // Cube edges for orientation\n const C = [-0.5, 0.5];\n const edges = [];\n C.forEach((a) => C.forEach((b) => { edges.push([P(a, b, -0.5), P(a, b, 0.5)], [P(a, -0.5, b), P(a, 0.5, b)], [P(-0.5, a, b), P(0.5, a, b)]); }));\n const colorOf = (p) => p.color || `var(--series-${(names.indexOf(p.series) % 10) + 1})`;\n /* Three axis edges meet at visible cube corners; names sit outside the cube along the\n screen-space outward direction, so they never land inside the plot at any rotation. */\n const axes3 = [\n { a: P(-0.5, -0.5, 0.5), b: P(0.5, -0.5, 0.5), name: xLabel },\n { a: P(-0.5, -0.5, 0.5), b: P(-0.5, 0.5, 0.5), name: yLabel },\n { a: P(0.5, -0.5, 0.5), b: P(0.5, -0.5, -0.5), name: zLabel },\n ].map((ax) => {\n const mx = (ax.a.x + ax.b.x) / 2, my = (ax.a.y + ax.b.y) / 2;\n const dx = mx - width / 2, dy = my - height / 2;\n const len = Math.hypot(dx, dy) || 1;\n return { ...ax, nx: mx + (dx / len) * 18, ny: my + (dy / len) * 18 };\n });\n const handlers = interactive ? dragRotate(() => rot, setRot) : {};\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <svg width={width} height={height} {...handlers} role=\"img\" style={{ cursor: interactive ? \"grab\" : \"default\", touchAction: \"none\" }}>\n {edges.map(([a, b], i) => <line key={i} x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke=\"var(--chart-axis)\" strokeWidth=\"1\" />)}\n {axes3.map((ax, i) => <line key={\"ae\" + i} x1={ax.a.x} y1={ax.a.y} x2={ax.b.x} y2={ax.b.y} stroke=\"var(--chart-guide)\" strokeWidth=\"1\" />)}\n {axes3.map((ax, i) => (\n <text key={\"at\" + i} x={ax.nx} y={ax.ny + 3} textAnchor=\"middle\" style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", fill: \"var(--text-secondary)\" }}>{ax.name}</text>\n ))}\n {dots.map((d) => (\n <circle key={d.i} cx={d.x} cy={d.y} r={r * (1.18 - (d.depth + 0.75) * 0.35) * (hover === d.i ? 1.5 : 1)}\n fill={colorOf(d.p)} opacity={hover === d.i ? 1 : (0.85 - (d.depth + 0.75) * 0.28) * (hover == null ? 1 : 0.45)}\n stroke={hover === d.i ? \"var(--bg-surface)\" : \"none\"} strokeWidth=\"1.5\"\n onMouseEnter={interactive ? () => setHover(d.i) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined} />\n ))}\n </svg>\n {hover != null && interactive ? (() => {\n const d = dots.find((x) => x.i === hover), p = d.p;\n return <ChartTooltip x={d.x} y={d.y} flip={d.x > width * 0.6} title={p.label || p.series}\n rows={[{ label: \"x\", value: valueFormat(p.x) }, { label: \"y\", value: valueFormat(p.y) }, { label: \"z\", value: valueFormat(p.z) }]} />;\n })() : null}\n {legend && names.length > 1 ? (\n <div style={{ display: \"flex\", gap: 14, paddingTop: 6, lineHeight: \"normal\" }}>\n {names.map((nm, i) => (\n <span key={nm} style={{ display: \"inline-flex\", alignItems: \"center\", gap: 6 }}>\n <span style={{ width: 8, height: 8, borderRadius: 4, background: `var(--series-${(i % 10) + 1})` }} />\n <span style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-xs)\", color: \"var(--text-secondary)\" }}>{nm}</span>\n </span>\n ))}\n </div>\n ) : null}\n </div>\n );\n}\n","charts/Bar3D.jsx":"import React from \"react\";\nimport { fmtNum, seriesColor, stack, niceTicks } from \"./chart-geometry.js\";\nimport { makeProj, cuboidFaces, shade, dragRotate, facePath } from \"./chart-3d.js\";\nimport { ChartTooltip, wrapStyle } from \"./ChartKit.jsx\";\n\nconst FACE_SHADE = { top: 0.22, front: 0, right: -0.22, left: -0.3, back: -0.1, bottom: -0.4 };\n\n/**\n * 3D bars — categories along x, series as depth rows (or stacked in place). Drag to orbit.\n * mode \"grouped\" (one depth row per series) | \"stacked\" (segments pile up in one row).\n */\nexport function Bar3D({ series = [], categories = [], width = 460, height = 300, yaw: yaw0 = 30, pitch: pitch0 = 26, mode = \"grouped\", gap = 0.35, interactive = true, valueFormat = fmtNum, style }) {\n const [rot, setRot] = React.useState({ yaw: yaw0, pitch: pitch0 });\n const [hover, setHover] = React.useState(null);\n const n = categories.length || Math.max(...series.map((s) => s.values.length));\n const m = mode === \"grouped\" ? series.length : 1;\n const hi = mode === \"stacked\"\n ? Math.max(...[...Array(n)].map((_, i) => series.reduce((t, s) => t + (s.values[i] || 0), 0)))\n : Math.max(...series.flatMap((s) => s.values));\n const bands = mode === \"stacked\" ? stack(series.map((s) => s.values)) : null;\n const scale = Math.min(width, height) * 0.42;\n const P = makeProj(width / 2, height / 2 + height * 0.06, scale, rot.yaw, rot.pitch);\n const bw = (1 - gap) / n, bd = (1 - gap) / Math.max(m, 2);\n const bars = [];\n series.forEach((s, si) => s.values.forEach((v, i) => {\n if (v == null) return;\n const x0 = i / n - 0.5 + (gap / n) / 2, x1 = x0 + bw / (n / (n)) * (1 / 1) * (bw > 0 ? 1 : 1) * 1;\n const cx0 = i / n - 0.5 + (1 / n) * (gap / 2), cx1 = cx0 + (1 / n) * (1 - gap);\n const zi = mode === \"grouped\" ? si : 0;\n const cz0 = zi / Math.max(m, 1) - 0.5 + (1 / Math.max(m, 2)) * (gap / 2), cz1 = cz0 + (1 / Math.max(m, 2)) * (1 - gap);\n const y0 = mode === \"stacked\" ? (bands[si][i][0] / hi) : 0;\n const y1 = mode === \"stacked\" ? (bands[si][i][1] / hi) : v / hi;\n bars.push({ s, si, i, v, x0: cx0, x1: cx1, z0: cz0, z1: cz1, y0: (y0 - 0.5) * 0.9, y1: (y1 - 0.5) * 0.9 });\n }));\n // draw far-to-near: sort by projected center depth\n const drawn = bars.map((b, key) => {\n const faces = cuboidFaces(P, b.x0, b.x1, b.y0, b.y1, b.z0, b.z1);\n const depth = faces.reduce((s2, f) => s2 + f.depth, 0) / faces.length;\n const top = P((b.x0 + b.x1) / 2, b.y1, (b.z0 + b.z1) / 2);\n return { ...b, key, faces, depth, top, color: b.s.color || seriesColor(b.si) };\n }).sort((a, b2) => b2.depth - a.depth);\n const base = [P(-0.5, -0.46, -0.5), P(0.5, -0.46, -0.5), P(0.5, -0.46, 0.5), P(-0.5, -0.46, 0.5)];\n /* Vertical value axis with real ticks at the front-left corner. */\n const vt = niceTicks(0, hi, 3);\n const vTicks = vt.ticks.filter((t) => t <= hi * 1.001).map((t) => ({ p: P(-0.5, (t / hi - 0.5) * 0.9, 0.5), v: valueFormat(t) }));\n const vAxis = [P(-0.5, -0.45, 0.5), P(-0.5, 0.45, 0.5)];\n const handlers = interactive ? dragRotate(() => rot, setRot) : {};\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <svg width={width} height={height} {...handlers} role=\"img\" style={{ cursor: interactive ? \"grab\" : \"default\", touchAction: \"none\" }}>\n <path d={facePath(base)} fill=\"var(--bg-inset)\" stroke=\"var(--chart-axis)\" strokeWidth=\"1\" />\n <line x1={vAxis[0].x} y1={vAxis[0].y} x2={vAxis[1].x} y2={vAxis[1].y} stroke=\"var(--chart-guide)\" strokeWidth=\"1\" />\n {vTicks.map((t, i) => (\n <g key={\"vt\" + i}>\n <line x1={t.p.x - 4} y1={t.p.y} x2={t.p.x} y2={t.p.y} stroke=\"var(--chart-guide)\" strokeWidth=\"1\" />\n <text x={t.p.x - 7} y={t.p.y + 3} textAnchor=\"end\" style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", fill: \"var(--chart-label)\" }}>{t.v}</text>\n </g>\n ))}\n {categories.map((c, i) => {\n /* Labels follow whichever base edge currently faces the viewer (larger screen y),\n sit outside it, and paint BEFORE the bars, so they never cover a column. */\n const zE = P(0, -0.46, 0.5).y >= P(0, -0.46, -0.5).y ? 0.5 : -0.5;\n const p0 = P(i / n - 0.5 + 0.5 / n, -0.46, zE);\n const ctr = P(0, -0.46, 0);\n const dx = p0.x - ctr.x, dy = p0.y - ctr.y, len = Math.hypot(dx, dy) || 1;\n return <text key={\"c\" + i} x={p0.x + (dx / len) * 18} y={p0.y + (dy / len) * 18 + 3} textAnchor=\"middle\" style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", fill: \"var(--chart-label)\" }}>{c}</text>;\n })}\n {drawn.map((b) => (\n <g key={b.key} opacity={hover == null || hover === b.key ? 1 : 0.45}\n onMouseEnter={interactive ? () => setHover(b.key) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}\n style={{ transition: \"opacity var(--dur-fast) var(--ease-out)\" }}>\n {b.faces.map((f, fi) => (\n <path key={fi} d={f.d} fill={shade(b.color, FACE_SHADE[f.kind])} stroke=\"var(--bg-surface)\" strokeWidth=\"0.4\" />\n ))}\n </g>\n ))}\n </svg>\n {hover != null && interactive ? (() => {\n const b = drawn.find((x) => x.key === hover);\n return <ChartTooltip x={b.top.x} y={b.top.y} flip={b.top.x > width * 0.6} title={categories[b.i]}\n rows={[{ label: b.s.name, value: valueFormat(b.v), color: b.color }]} />;\n })() : null}\n <div style={{ display: \"flex\", gap: 14, paddingTop: 6, lineHeight: \"normal\" }}>\n {series.map((s, i) => (\n <span key={s.name} style={{ display: \"inline-flex\", alignItems: \"center\", gap: 6 }}>\n <span style={{ width: 8, height: 8, borderRadius: 2, background: s.color || seriesColor(i) }} />\n <span style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-xs)\", color: \"var(--text-secondary)\" }}>{s.name}</span>\n </span>\n ))}\n </div>\n </div>\n );\n}\n","charts/Pie3D.jsx":"import React from \"react\";\nimport { fmtNum, seriesColor } from \"./chart-geometry.js\";\nimport { shade } from \"./chart-3d.js\";\nimport { ChartTooltip, wrapStyle } from \"./ChartKit.jsx\";\n\n/* Intersect the arc [a0,a1] (degrees, a1 > a0) with the window [w0,w1]; returns visible\n sub-arcs. Handles arcs that spin past 360 by testing the window at ±360. */\nfunction clipArc(a0, a1, w0, w1) {\n const A0 = ((a0 % 360) + 360) % 360, len = a1 - a0, A1 = A0 + len;\n const out = [];\n for (const off of [-360, 0, 360]) {\n const s = Math.max(A0, w0 + off), e = Math.min(A1, w1 + off);\n if (e - s > 0.4) out.push([s, e]);\n }\n return out;\n}\n\n/**\n * 3D pie / donut — extruded slices. Drag horizontally to spin in place (tilt stays fixed:\n * free orbit only distorts an extruded pie). stairs={true} extrudes each slice to its own\n * height by value — the stepped presentation pie. Hover lifts a slice and pins a tooltip.\n */\nexport function Pie3D({ segments = [], size = 300, thickness = 26, innerRatio = 0, tilt = 0.45, explode = 6, stairs = false, spin: spin0 = 0, valueFormat = fmtNum, legend = true, interactive = true, style }) {\n const [hover, setHover] = React.useState(null);\n const [spin, setSpin] = React.useState(spin0);\n const drag = React.useRef(null);\n const total = segments.reduce((s, x) => s + x.value, 0) || 1;\n const vmax = Math.max(...segments.map((s) => s.value), 1);\n const maxTh = stairs ? Math.round(thickness * 2.1) : thickness;\n const cx = size / 2, cy = size / 2 - maxTh / 2 + 6;\n const rO = size / 2 - 10, rI = rO * innerRatio;\n const pt = (r, deg, dz = 0) => [cx + r * Math.cos(((deg - 90) * Math.PI) / 180), cy + r * Math.sin(((deg - 90) * Math.PI) / 180) * tilt + dz];\n let a = 0;\n const slices = segments.map((s, i) => {\n const a0 = a + spin, a1 = a + (s.value / total) * 360 + spin; a += (s.value / total) * 360;\n const mid = ((((a0 + a1) / 2) % 360) + 360) % 360;\n /* Stairs: bottoms share one plane, each top rises by value — the stepped pie. */\n const h = stairs ? maxTh * (0.35 + 0.65 * (s.value / vmax)) : thickness;\n return { s, i, a0, a1, mid, h, color: s.color || seriesColor(i) };\n });\n const arcPts = (r, a0, a1, dz) => {\n const steps = Math.max(2, Math.ceil(Math.abs(a1 - a0) / 6));\n return [...Array(steps + 1)].map((_, k) => pt(r, a0 + ((a1 - a0) * k) / steps, dz));\n };\n const path = (pts) => \"M\" + pts.map((p) => p[0].toFixed(1) + \" \" + p[1].toFixed(1)).join(\" L\") + \" Z\";\n const off = (sl) => {\n if (hover !== sl.i || !interactive) return [0, 0];\n return [Math.cos(((sl.mid - 90) * Math.PI) / 180) * explode, Math.sin(((sl.mid - 90) * Math.PI) / 180) * explode * tilt];\n };\n const hoverProps = (sl) => interactive ? {\n onMouseEnter: () => !drag.current && setHover(sl.i),\n onMouseLeave: () => setHover(null),\n } : {};\n const dim = (sl) => ({ opacity: hover == null || hover === sl.i ? 1 : 0.55, style: { transition: \"opacity var(--dur-fast) var(--ease-out)\" } });\n /* deg 180 is the ellipse edge nearest the viewer. An outer wall is visible only where\n its arc crosses the near window (90–270); an inner wall only where it crosses the far\n window, seen through the hole. Clipping each wall to its visible window means the\n visible pieces never overlap in angle — paint order between them stops mattering,\n and no slice can ever show a gap between its top and its wall. */\n const nearness = (sl) => -Math.cos((sl.mid * Math.PI) / 180);\n const sorted = [...slices].sort((p, q) => nearness(p) - nearness(q));\n /* Bottoms first: any sliver the walls miss reads as dark interior, never as a hole in\n the render. */\n const bottoms = slices.map((sl) => {\n const [dx, dy] = off(sl);\n const d = rI > 0\n ? path([...arcPts(rO, sl.a0, sl.a1, maxTh), ...arcPts(rI, sl.a1, sl.a0, maxTh)].map(([x, y]) => [x + dx, y + dy]))\n : path([[cx + dx, cy + dy + maxTh], ...arcPts(rO, sl.a0, sl.a1, maxTh).map(([x, y]) => [x + dx, y + dy])]);\n return <path key={\"b\" + sl.i} d={d} fill={shade(sl.color, -0.45)} {...hoverProps(sl)} {...dim(sl)} />;\n });\n /* Each slice is a closed solid: two radial faces, then its full outer wall. Solids paint\n far-to-near, so a taller near slice covers the interior faces behind it and the side\n of the pie is never open at any spin angle. */\n const solids = sorted.map((sl) => {\n const [dx, dy] = off(sl);\n const zTop = maxTh - sl.h;\n const radial = (deg, k) => {\n const pts = [pt(rI || 0.01, deg, zTop), pt(rO, deg, zTop), pt(rO, deg, maxTh), pt(rI || 0.01, deg, maxTh)].map(([x, y]) => [x + dx, y + dy]);\n return <path key={\"r\" + k + sl.i} d={path(pts)} fill={shade(sl.color, -0.18)} />;\n };\n const top = arcPts(rO, sl.a0, sl.a1, zTop).map(([x, y]) => [x + dx, y + dy]);\n const bot = arcPts(rO, sl.a0, sl.a1, maxTh).map(([x, y]) => [x + dx, y + dy]);\n return (\n <g key={\"solid\" + sl.i} {...hoverProps(sl)} {...dim(sl)}>\n {radial(sl.a0, \"s\")}\n {radial(sl.a1, \"e\")}\n <path d={path([...top, ...bot.reverse()])} fill={shade(sl.color, -0.28)} />\n </g>\n );\n });\n const innerWalls = rI ? slices.flatMap((sl) => clipArc(sl.a0, sl.a1, -90, 90).map(([s, e], k) => {\n const [dx, dy] = off(sl);\n const top = arcPts(rI, s, e, maxTh - sl.h).map(([x, y]) => [x + dx, y + dy]);\n const bot = arcPts(rI, e, s, maxTh).map(([x, y]) => [x + dx, y + dy]);\n return <path key={\"iw\" + sl.i + \"-\" + k} d={path([...top, ...bot])} fill={shade(sl.color, -0.38)} {...hoverProps(sl)} {...dim(sl)} />;\n })) : [];\n const tops = sorted.map((sl) => {\n const [dx, dy] = off(sl);\n const zTop = maxTh - sl.h;\n const d = rI > 0\n ? path([...arcPts(rO, sl.a0, sl.a1, zTop), ...arcPts(rI, sl.a1, sl.a0, zTop)].map(([x, y]) => [x + dx, y + dy]))\n : path([[cx + dx, cy + dy + zTop], ...arcPts(rO, sl.a0, sl.a1, zTop).map(([x, y]) => [x + dx, y + dy])]);\n return <path key={\"t\" + sl.i} d={d} fill={sl.color} stroke=\"var(--bg-surface)\" strokeWidth=\"1\" {...hoverProps(sl)} {...dim(sl)} />;\n });\n const spinHandlers = interactive ? {\n onPointerDown: (e) => { drag.current = { x: e.clientX, s: spin }; e.currentTarget.setPointerCapture && e.currentTarget.setPointerCapture(e.pointerId); },\n onPointerMove: (e) => { if (drag.current) setSpin(drag.current.s + (e.clientX - drag.current.x) * 0.5); },\n onPointerUp: () => { drag.current = null; },\n onPointerLeave: () => { drag.current = null; },\n } : {};\n const h = hover != null ? slices.find((sl) => sl.i === hover) : null;\n const tipAt = h ? pt(rO * 0.8, (h.a0 + h.a1) / 2, (maxTh - h.h) / 2) : null;\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 16 }}>\n <div style={{ position: \"relative\" }}>\n <svg width={size} height={size * 0.72 + maxTh} {...spinHandlers} role=\"img\" style={{ cursor: interactive ? \"grab\" : \"default\", touchAction: \"none\" }}>\n {bottoms}\n {innerWalls}\n {solids}\n {tops}\n </svg>\n {h && interactive ? (\n <ChartTooltip x={tipAt[0]} y={tipAt[1]} flip={tipAt[0] > size * 0.55} title={h.s.label}\n rows={[{ label: \"value\", value: valueFormat(h.s.value), color: h.color }, { label: \"share\", value: ((h.s.value / total) * 100).toFixed(1) + \"%\" }]} />\n ) : null}\n </div>\n {legend ? (\n <div style={{ display: \"flex\", flexDirection: \"column\", gap: 6, lineHeight: \"normal\" }}>\n {slices.map(({ s, i, color }) => (\n <span key={s.label} onMouseEnter={interactive ? () => setHover(i) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}\n style={{ display: \"inline-flex\", alignItems: \"center\", gap: 7, opacity: hover == null || hover === i ? 1 : 0.45 }}>\n <span style={{ width: 8, height: 8, borderRadius: 2, background: color, flexShrink: 0 }} />\n <span style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-xs)\", color: \"var(--text-secondary)\" }}>{s.label}</span>\n <span style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-xs)\", color: \"var(--text-heading)\", marginLeft: 6 }}>\n {hover === i ? valueFormat(s.value) : ((s.value / total) * 100).toFixed(0) + \"%\"}\n </span>\n </span>\n ))}\n </div>\n ) : null}\n </div>\n </div>\n );\n}\n","charts/Ribbon3D.jsx":"import React from \"react\";\nimport { fmtNum, seriesColor, niceTicks } from \"./chart-geometry.js\";\nimport { makeProj, facePath, faceDepth, shade, dragRotate } from \"./chart-3d.js\";\nimport { ChartTooltip, wrapStyle } from \"./ChartKit.jsx\";\n\n/**\n * 3D ribbon series — each series is an extruded band at its own depth row (the classic\n * \"3D area\" presentation). Drag to orbit.\n */\nexport function Ribbon3D({ series = [], xLabels = [], width = 460, height = 300, yaw: yaw0 = 34, pitch: pitch0 = 30, ribbon = 0.55, interactive = true, valueFormat = fmtNum, style }) {\n const [rot, setRot] = React.useState({ yaw: yaw0, pitch: pitch0 });\n const [hover, setHover] = React.useState(null);\n const n = Math.max(...series.map((s) => s.values.length), 2);\n const hi = Math.max(...series.flatMap((s) => s.values)) || 1;\n const m = series.length;\n const scale = Math.min(width, height) * 0.42;\n const P = makeProj(width / 2, height / 2 + height * 0.05, scale, rot.yaw, rot.pitch);\n const base = [P(-0.5, -0.45, -0.5), P(0.5, -0.45, -0.5), P(0.5, -0.45, 0.5), P(-0.5, -0.45, 0.5)];\n /* Vertical value axis with real ticks at the front-left corner. */\n const vt = niceTicks(0, hi, 3);\n const vTicks = vt.ticks.filter((t) => t <= hi * 1.001).map((t) => ({ p: P(-0.5, (t / hi - 0.5) * 0.9, 0.5), v: fmtNum(t) }));\n const vAxis = [P(-0.5, -0.45, 0.5), P(-0.5, 0.45, 0.5)];\n const rows = series.map((s, si) => {\n const z0 = si / m - 0.5 + (1 / m) * (0.5 - ribbon / 2), z1 = z0 + (ribbon / m);\n const y = (i) => ((s.values[i] || 0) / hi - 0.5) * 0.9;\n const quads = [];\n for (let i = 0; i < n - 1; i++) {\n const x0 = i / (n - 1) - 0.5, x1 = (i + 1) / (n - 1) - 0.5;\n const top = [P(x0, y(i), z0), P(x1, y(i + 1), z0), P(x1, y(i + 1), z1), P(x0, y(i), z1)];\n const front = [P(x0, -0.45, z1), P(x1, -0.45, z1), P(x1, y(i + 1), z1), P(x0, y(i), z1)];\n const back = [P(x0, -0.45, z0), P(x1, -0.45, z0), P(x1, y(i + 1), z0), P(x0, y(i), z0)];\n quads.push({ pts: back, kind: \"back\" }, { pts: top, kind: \"top\" }, { pts: front, kind: \"front\" });\n }\n const end = [P(0.5, -0.45, z0), P(0.5, -0.45, z1), P(0.5, y(n - 1), z1), P(0.5, y(n - 1), z0)];\n const start = [P(-0.5, -0.45, z0), P(-0.5, -0.45, z1), P(-0.5, y(0), z1), P(-0.5, y(0), z0)];\n quads.push({ pts: end, kind: \"end\" }, { pts: start, kind: \"end\" });\n quads.forEach((q) => { q.depth = faceDepth(q.pts); });\n const rowDepth = quads.reduce((t, q) => t + q.depth, 0) / quads.length;\n return { s, si, quads, rowDepth, color: s.color || seriesColor(si) };\n }).sort((a, b) => b.rowDepth - a.rowDepth);\n const handlers = interactive ? dragRotate(() => rot, setRot) : {};\n return (\n <div style={{ ...wrapStyle, ...style }}>\n <svg width={width} height={height} {...handlers} role=\"img\" style={{ cursor: interactive ? \"grab\" : \"default\", touchAction: \"none\" }}>\n <path d={facePath(base)} fill=\"var(--bg-inset)\" stroke=\"var(--chart-axis)\" strokeWidth=\"1\" />\n <line x1={vAxis[0].x} y1={vAxis[0].y} x2={vAxis[1].x} y2={vAxis[1].y} stroke=\"var(--chart-guide)\" strokeWidth=\"1\" />\n {vTicks.map((t, i) => (\n <g key={\"vt\" + i}>\n <line x1={t.p.x - 4} y1={t.p.y} x2={t.p.x} y2={t.p.y} stroke=\"var(--chart-guide)\" strokeWidth=\"1\" />\n <text x={t.p.x - 7} y={t.p.y + 3} textAnchor=\"end\" style={{ fontFamily: \"var(--font-sans)\", fontVariantNumeric: \"tabular-nums\", fontSize: \"var(--text-2xs-mono)\", fill: \"var(--chart-label)\" }}>{t.v}</text>\n </g>\n ))}\n {xLabels.map((lb, i) => {\n if (lb == null || (xLabels.length > 8 && i % Math.ceil(xLabels.length / 8) && i !== xLabels.length - 1)) return null;\n /* Follow whichever base edge faces the viewer; outside it, painted before the bands. */\n const zE = P(0, -0.45, 0.5).y >= P(0, -0.45, -0.5).y ? 0.62 : -0.62;\n const p0 = P(i / (n - 1) - 0.5, -0.45, zE);\n const ctr = P(0, -0.45, 0);\n const dx = p0.x - ctr.x, dy = p0.y - ctr.y, len = Math.hypot(dx, dy) || 1;\n return <text key={\"x\" + i} x={p0.x + (dx / len) * 14} y={p0.y + (dy / len) * 14 + 3} textAnchor=\"middle\" style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-2xs)\", fill: \"var(--chart-label)\" }}>{lb}</text>;\n })}\n {rows.map((r) => (\n <g key={r.s.name} opacity={hover == null || hover === r.si ? 1 : 0.4}\n onMouseEnter={interactive ? () => setHover(r.si) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}\n style={{ transition: \"opacity var(--dur-fast) var(--ease-out)\" }}>\n {r.quads.sort((a, b) => b.depth - a.depth).map((q, qi) => (\n <path key={qi} d={facePath(q.pts)} fill={shade(r.color, q.kind === \"top\" ? 0.14 : q.kind === \"front\" ? -0.12 : q.kind === \"back\" ? -0.2 : -0.28)} stroke=\"var(--bg-surface)\" strokeWidth=\"0.4\" />\n ))}\n </g>\n ))}\n </svg>\n {hover != null && interactive ? (() => {\n const r = rows.find((x) => x.si === hover);\n const peak = Math.max(...r.s.values);\n return <ChartTooltip x={width / 2} y={height * 0.28} flip={false} title={r.s.name}\n rows={[{ label: \"peak\", value: valueFormat(peak), color: r.color }, { label: \"latest\", value: valueFormat(r.s.values[r.s.values.length - 1]) }]} />;\n })() : null}\n <div style={{ display: \"flex\", gap: 14, paddingTop: 6, lineHeight: \"normal\" }}>\n {series.map((s, i) => (\n <span key={s.name} onMouseEnter={interactive ? () => setHover(i) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}\n style={{ display: \"inline-flex\", alignItems: \"center\", gap: 6, opacity: hover == null || hover === i ? 1 : 0.45, transition: \"var(--t-hover)\" }}>\n <span style={{ width: 8, height: 8, borderRadius: 2, background: s.color || seriesColor(i) }} />\n <span style={{ fontFamily: \"var(--font-sans)\", fontSize: \"var(--text-xs)\", color: \"var(--text-secondary)\" }}>{s.name}</span>\n </span>\n ))}\n </div>\n </div>\n );\n}\n","charts/Pie3D.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { fmtNum, SERIES } from './chart-geometry.js'\nimport { shade } from './chart-3d.js'\n/**\n * 3D pie / donut — extruded slices. Drag horizontally to spin in place (tilt stays fixed:\n * free orbit only distorts an extruded pie). stairs extrudes each slice to its own height\n * by value — the stepped presentation pie. Hover lifts a slice and pins a tooltip.\n */\nconst props = defineProps({ segments:{type:Array,default:()=>[]}, size:{type:Number,default:300}, thickness:{type:Number,default:26}, innerRatio:{type:Number,default:0}, tilt:{type:Number,default:0.45}, explode:{type:Number,default:6}, stairs:Boolean, spin:{type:Number,default:0}, valueFormat:{type:Function,default:fmtNum}, legend:{type:Boolean,default:true}, interactive:{type:Boolean,default:true}, style:Object })\nconst hover = ref(null)\nconst spinNow = ref(props.spin)\nconst drag = ref(null)\nfunction down(e){ drag.value = { x:e.clientX, s:spinNow.value }; e.currentTarget.setPointerCapture && e.currentTarget.setPointerCapture(e.pointerId) }\nfunction move(e){ if (drag.value) spinNow.value = drag.value.s + (e.clientX-drag.value.x)*0.5 }\nfunction up(){ drag.value = null }\nconst g = computed(() => {\n const total = props.segments.reduce((s2,x)=>s2+x.value,0) || 1\n const vmax = Math.max(...props.segments.map(s2=>s2.value), 1)\n const maxTh = props.stairs ? Math.round(props.thickness*2.1) : props.thickness\n const cx = props.size/2, cy = props.size/2 - maxTh/2 + 6\n const rO = props.size/2-10, rI = rO*props.innerRatio\n const pt = (r,deg,dz=0) => [cx + r*Math.cos(((deg-90)*Math.PI)/180), cy + r*Math.sin(((deg-90)*Math.PI)/180)*props.tilt + dz]\n const arcPts = (r,a0,a1,dz) => { const steps = Math.max(2, Math.ceil(Math.abs(a1-a0)/6)); return [...Array(steps+1)].map((_,k)=>pt(r, a0+((a1-a0)*k)/steps, dz)) }\n const path = pts => 'M' + pts.map(p=>p[0].toFixed(1)+' '+p[1].toFixed(1)).join(' L') + ' Z'\n let a = 0\n const slices = props.segments.map((s2,i)=>{\n const a0 = a + spinNow.value, a1 = a + (s2.value/total)*360 + spinNow.value; a += (s2.value/total)*360\n const mid = ((((a0+a1)/2)%360)+360)%360\n // Stairs: bottoms share one plane, each top rises by value.\n const h = props.stairs ? maxTh*(0.35+0.65*(s2.value/vmax)) : props.thickness\n return { s:s2, i, a0, a1, mid, h, color: s2.color || SERIES[i%SERIES.length] }\n })\n const off = sl => (hover.value===sl.i && props.interactive)\n ? [Math.cos(((sl.mid-90)*Math.PI)/180)*props.explode, Math.sin(((sl.mid-90)*Math.PI)/180)*props.explode*props.tilt]\n : [0,0]\n const nearness = sl => -Math.cos((sl.mid*Math.PI)/180)\n const sorted = [...slices].sort((p,q)=>nearness(p)-nearness(q))\n // Bottoms first: any sliver the walls miss reads as dark interior, never a hole.\n const bottoms = slices.map(sl=>{\n const [dx,dy] = off(sl)\n const d = rI>0\n ? path([...arcPts(rO,sl.a0,sl.a1,maxTh), ...arcPts(rI,sl.a1,sl.a0,maxTh)].map(([x,y])=>[x+dx,y+dy]))\n : path([[cx+dx, cy+dy+maxTh], ...arcPts(rO,sl.a0,sl.a1,maxTh).map(([x,y])=>[x+dx,y+dy])])\n return { key:'b'+sl.i, d, fill: shade(sl.color,-0.45), i:sl.i }\n })\n const innerWalls = rI ? sorted.map(sl=>{\n const [dx,dy] = off(sl)\n const top = arcPts(rI,sl.a0,sl.a1,maxTh-sl.h).map(([x,y])=>[x+dx,y+dy])\n const bot = arcPts(rI,sl.a1,sl.a0,maxTh).map(([x,y])=>[x+dx,y+dy])\n return { key:'iw'+sl.i, d: path([...top,...bot]), fill: shade(sl.color,-0.38), i:sl.i }\n }) : []\n /* Each slice is a closed solid: two radial faces plus its full outer wall, painted\n far-to-near, so the side of the pie is never open at any spin angle. */\n const solids = sorted.map(sl=>{\n const [dx,dy] = off(sl)\n const zTop = maxTh - sl.h\n const radial = deg => path([pt(rI||0.01,deg,zTop), pt(rO,deg,zTop), pt(rO,deg,maxTh), pt(rI||0.01,deg,maxTh)].map(([x,y])=>[x+dx,y+dy]))\n const top = arcPts(rO,sl.a0,sl.a1,zTop).map(([x,y])=>[x+dx,y+dy])\n const bot = arcPts(rO,sl.a0,sl.a1,maxTh).map(([x,y])=>[x+dx,y+dy])\n return { key:'sd'+sl.i, i:sl.i, r0: radial(sl.a0), r1: radial(sl.a1),\n wall: path([...top,...bot.reverse()]), side: shade(sl.color,-0.18), wallFill: shade(sl.color,-0.28) }\n })\n const tops = sorted.map(sl=>{\n const [dx,dy] = off(sl)\n const zTop = maxTh - sl.h\n const d = rI>0\n ? path([...arcPts(rO,sl.a0,sl.a1,zTop), ...arcPts(rI,sl.a1,sl.a0,zTop)].map(([x,y])=>[x+dx,y+dy]))\n : path([[cx+dx, cy+dy+zTop], ...arcPts(rO,sl.a0,sl.a1,zTop).map(([x,y])=>[x+dx,y+dy])])\n return { key:'t'+sl.i, d, fill: sl.color, i:sl.i }\n })\n const h = hover.value!=null ? slices.find(sl=>sl.i===hover.value) : null\n return { total, maxTh, slices, bottoms, innerWalls, solids, tops,\n tip: h ? { p: pt(rO*0.8, (h.a0+h.a1)/2, (maxTh-h.h)/2), s:h.s, color:h.color } : null }\n})\nfunction dim(i){ return hover.value==null || hover.value===i ? 1 : 0.55 }\nfunction enter(i){ if (props.interactive && !drag.value) hover.value = i }\n</script>\n<template>\n <div :style=\"{display:'inline-block',lineHeight:0,...style}\">\n <div style=\"display:flex;align-items:center;gap:16px\">\n <div style=\"position:relative\">\n <svg :width=\"size\" :height=\"size*0.72+g.maxTh\" @pointerdown=\"down\" @pointermove=\"move\" @pointerup=\"up\" @pointerleave=\"up\"\n role=\"img\" :style=\"{cursor:interactive?'grab':'default',touchAction:'none'}\">\n <path v-for=\"b in g.bottoms\" :key=\"b.key\" :d=\"b.d\" :fill=\"b.fill\" :opacity=\"dim(b.i)\"\n @mouseenter=\"enter(b.i)\" @mouseleave=\"interactive && (hover=null)\"/>\n <path v-for=\"w in g.innerWalls\" :key=\"w.key\" :d=\"w.d\" :fill=\"w.fill\" :opacity=\"dim(w.i)\"\n @mouseenter=\"enter(w.i)\" @mouseleave=\"interactive && (hover=null)\"/>\n <g v-for=\"sd in g.solids\" :key=\"sd.key\" :opacity=\"dim(sd.i)\"\n @mouseenter=\"enter(sd.i)\" @mouseleave=\"interactive && (hover=null)\"\n style=\"transition:opacity var(--dur-fast) var(--ease-out)\">\n <path :d=\"sd.r0\" :fill=\"sd.side\"/>\n <path :d=\"sd.r1\" :fill=\"sd.side\"/>\n <path :d=\"sd.wall\" :fill=\"sd.wallFill\"/>\n </g>\n <path v-for=\"t in g.tops\" :key=\"t.key\" :d=\"t.d\" :fill=\"t.fill\" stroke=\"var(--bg-surface)\" stroke-width=\"1\" :opacity=\"dim(t.i)\"\n @mouseenter=\"enter(t.i)\" @mouseleave=\"interactive && (hover=null)\"/>\n </svg>\n <div v-if=\"g.tip && interactive\" :style=\"{position:'absolute',left:g.tip.p[0]+'px',top:g.tip.p[1]+'px',transform:g.tip.p[0]>size*0.55?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal',whiteSpace:'nowrap'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:3px\">{{ g.tip.s.label }}</div>\n <div style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)\">{{ valueFormat(g.tip.s.value) }} · {{ ((g.tip.s.value/g.total)*100).toFixed(1) }}%</div>\n </div>\n </div>\n <div v-if=\"legend\" style=\"display:flex;flex-direction:column;gap:6px;line-height:normal\">\n <span v-for=\"sl in g.slices\" :key=\"sl.s.label\" @mouseenter=\"interactive && (hover=sl.i)\" @mouseleave=\"interactive && (hover=null)\"\n :style=\"{display:'inline-flex',alignItems:'center',gap:'7px',opacity:dim(sl.i)}\">\n <span :style=\"{width:'8px',height:'8px',borderRadius:'2px',background:sl.color,flexShrink:0}\"/>\n <span style=\"font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-secondary)\">{{ sl.s.label }}</span>\n <span style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading);margin-left:6px\">\n {{ hover===sl.i ? valueFormat(sl.s.value) : ((sl.s.value/g.total)*100).toFixed(0)+'%' }}</span>\n </span>\n </div>\n </div>\n </div>\n</template>\n","charts/Surface3D.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { fmtNum, niceTicks } from './chart-geometry.js'\nimport { makeProj, facePath, faceDepth, shade, lightOf, heatColor } from './chart-3d.js'\n// 3D surface z = f(x,y); drag to orbit. Cells shade on the heat ramp by height.\nconst props = defineProps({ values:{type:Array,default:()=>[]}, width:{type:Number,default:420}, height:{type:Number,default:300}, yaw:{type:Number,default:38}, pitch:{type:Number,default:32}, zScale:{type:Number,default:0.55}, wire:{type:Boolean,default:true}, xLabel:{type:String,default:'x'}, yLabel:{type:String,default:'y'}, interactive:{type:Boolean,default:true}, valueFormat:{type:Function,default:fmtNum}, style:Object })\nconst rot = ref({ yaw: props.yaw, pitch: props.pitch })\nconst hover = ref(null)\n// Drag state on the DOM node: a closure var is reset by re-render mid-drag.\nfunction down(e){ e.currentTarget.__kbDrag = { x:e.clientX, y:e.clientY, ...rot.value }; e.currentTarget.setPointerCapture && e.currentTarget.setPointerCapture(e.pointerId) }\nfunction move(e){ const s = e.currentTarget.__kbDrag; if(!s) return; rot.value = { yaw: s.yaw+(e.clientX-s.x)*0.45, pitch: Math.max(8, Math.min(80, s.pitch+(e.clientY-s.y)*0.35)) } }\nfunction up(e){ e.currentTarget.__kbDrag = null }\nconst g = computed(() => {\n const rows = props.values.length, cols = props.values[0] ? props.values[0].length : 0\n let lo = Infinity, hi = -Infinity\n props.values.forEach(r=>r.forEach(v=>{ if(v<lo) lo=v; if(v>hi) hi=v }))\n const span = hi-lo || 1\n const scale = Math.min(props.width, props.height)*0.42\n const P = makeProj(props.width/2, props.height/2 + props.height*0.05, scale, rot.value.yaw, rot.value.pitch)\n const pt = (r,c) => P((c/(cols-1))-0.5, (((props.values[r][c]-lo)/span)-0.5)*props.zScale*2, (r/(rows-1))-0.5)\n const quads = []\n for (let r=0;r<rows-1;r++) for (let c=0;c<cols-1;c++) {\n const pts = [pt(r,c), pt(r,c+1), pt(r+1,c+1), pt(r+1,c)]\n const avg = (props.values[r][c]+props.values[r][c+1]+props.values[r+1][c]+props.values[r+1][c+1])/4\n quads.push({ d: facePath(pts), t:(avg-lo)/span, avg, r, c, depth: faceDepth(pts), light: lightOf(pts[0],pts[1],pts[3]),\n cx: pts.reduce((s,p)=>s+p.x,0)/4, cy: pts.reduce((s,p)=>s+p.y,0)/4 })\n }\n quads.sort((a,b)=>b.depth-a.depth)\n const yB = -props.zScale-0.06\n const base = [P(-0.5,yB,-0.5), P(0.5,yB,-0.5), P(0.5,yB,0.5), P(-0.5,yB,0.5)]\n const corner = P(-0.5,yB,0.5)\n const axes3 = [\n { a:corner, b:P(0.5,yB,0.5), label:props.xLabel, lp:P(0,yB-0.16,0.5), anchor:'middle' },\n { a:corner, b:P(-0.5,yB,-0.5), label:props.yLabel, lp:P(-0.62,yB-0.12,0), anchor:'end' },\n { a:corner, b:P(-0.5,props.zScale,0.5) },\n ]\n const zt = niceTicks(lo,hi,3)\n const zTicks = zt.ticks.filter(t=>t>=lo&&t<=hi).map(t=>({ p:P(-0.5, ((t-lo)/span-0.5)*props.zScale*2, 0.5), v:props.valueFormat(t) }))\n return { quads, basePath: facePath(base), axes3, zTicks }\n})\nfunction fill(q){ return shade(heatColor(q.t), Math.max(-0.35, Math.min(0.3, q.light*0.4)) + (hover.value===q ? 0.18 : 0)) }\n</script>\n<template>\n <div :style=\"{position:'relative',display:'inline-block',lineHeight:0,...style}\">\n <svg :width=\"width\" :height=\"height\" @pointerdown=\"down\" @pointermove=\"move\" @pointerup=\"up\" @pointerleave=\"up\"\n role=\"img\" :style=\"{cursor:interactive?'grab':'default',touchAction:'none'}\">\n <path :d=\"g.basePath\" fill=\"var(--bg-inset)\" stroke=\"var(--chart-axis)\" stroke-width=\"1\"/>\n <line v-for=\"(ax,i) in g.axes3\" :key=\"'ae'+i\" :x1=\"ax.a.x\" :y1=\"ax.a.y\" :x2=\"ax.b.x\" :y2=\"ax.b.y\" stroke=\"var(--chart-guide)\" stroke-width=\"1\"/>\n <text v-for=\"(ax,i) in g.axes3.filter(a=>a.label)\" :key=\"'al'+i\" :x=\"ax.lp.x\" :y=\"ax.lp.y+3\" :text-anchor=\"ax.anchor\"\n style=\"font-family:var(--font-sans);font-size:var(--text-2xs);fill:var(--text-secondary)\">{{ ax.label }}</text>\n <g v-for=\"(t,i) in g.zTicks\" :key=\"'zt'+i\">\n <line :x1=\"t.p.x-4\" :y1=\"t.p.y\" :x2=\"t.p.x\" :y2=\"t.p.y\" stroke=\"var(--chart-guide)\" stroke-width=\"1\"/>\n <text :x=\"t.p.x-7\" :y=\"t.p.y+3\" text-anchor=\"end\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ t.v }}</text>\n </g>\n <path v-for=\"(q,i) in g.quads\" :key=\"i\" :d=\"q.d\" :fill=\"fill(q)\" :stroke=\"wire?'var(--bg-surface)':'none'\" stroke-width=\"0.5\"\n @mouseenter=\"interactive && (hover=q)\" @mouseleave=\"interactive && (hover=null)\"/>\n </svg>\n <div v-if=\"hover && interactive\" :style=\"{position:'absolute',left:hover.cx+'px',top:hover.cy+'px',transform:hover.cx>width*0.6?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal',whiteSpace:'nowrap'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:3px\">row {{ hover.r+1 }} · col {{ hover.c+1 }}</div>\n <div style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)\">{{ valueFormat(hover.avg) }}</div>\n </div>\n </div>\n</template>\n","charts/Scatter3D.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { fmtNum, SERIES } from './chart-geometry.js'\nimport { makeProj } from './chart-3d.js'\n// 3D scatter in an orientation cube; drag to orbit. Depth attenuates radius and opacity.\nconst props = defineProps({ points:{type:Array,default:()=>[]}, width:{type:Number,default:420}, height:{type:Number,default:300}, yaw:{type:Number,default:35}, pitch:{type:Number,default:28}, r:{type:Number,default:4}, xLabel:{type:String,default:'x'}, yLabel:{type:String,default:'y'}, zLabel:{type:String,default:'z'}, legend:{type:Boolean,default:true}, interactive:{type:Boolean,default:true}, valueFormat:{type:Function,default:fmtNum}, style:Object })\nconst rot = ref({ yaw: props.yaw, pitch: props.pitch })\nconst hover = ref(null)\nfunction down(e){ e.currentTarget.__kbDrag = { x:e.clientX, y:e.clientY, ...rot.value }; e.currentTarget.setPointerCapture && e.currentTarget.setPointerCapture(e.pointerId) }\nfunction move(e){ const s = e.currentTarget.__kbDrag; if(!s) return; rot.value = { yaw: s.yaw+(e.clientX-s.x)*0.45, pitch: Math.max(8, Math.min(80, s.pitch+(e.clientY-s.y)*0.35)) } }\nfunction up(e){ e.currentTarget.__kbDrag = null }\nconst g = computed(() => {\n const names = [...new Set(props.points.map(p=>p.series).filter(Boolean))]\n const norm = arr => { const l=Math.min(...arr), h=Math.max(...arr), s=h-l||1; return v=>(v-l)/s-0.5 }\n const nx = norm(props.points.map(p=>p.x)), ny = norm(props.points.map(p=>p.y)), nz = norm(props.points.map(p=>p.z))\n const scale = Math.min(props.width, props.height)*0.44\n const P = makeProj(props.width/2, props.height/2, scale, rot.value.yaw, rot.value.pitch)\n const dots = props.points.map((p,i)=>({ p, i, ...P(nx(p.x), ny(p.y), nz(p.z)),\n color: p.color || SERIES[(p.series?names.indexOf(p.series):0)%SERIES.length] })).sort((a,b)=>b.depth-a.depth)\n const C = [-0.5,0.5]; const edges = []\n C.forEach(a=>C.forEach(b=>{ edges.push([P(a,b,-0.5),P(a,b,0.5)],[P(a,-0.5,b),P(a,0.5,b)],[P(-0.5,a,b),P(0.5,a,b)]) }))\n // Axis names sit outside the cube along the screen-space outward direction.\n const axes3 = [\n { a:P(-0.5,-0.5,0.5), b:P(0.5,-0.5,0.5), name:props.xLabel },\n { a:P(-0.5,-0.5,0.5), b:P(-0.5,0.5,0.5), name:props.yLabel },\n { a:P(0.5,-0.5,0.5), b:P(0.5,-0.5,-0.5), name:props.zLabel },\n ].map(ax=>{\n const mx=(ax.a.x+ax.b.x)/2, my=(ax.a.y+ax.b.y)/2\n const dx=mx-props.width/2, dy=my-props.height/2, len=Math.hypot(dx,dy)||1\n return { ...ax, nx: mx+(dx/len)*18, ny: my+(dy/len)*18 }\n })\n return { names, dots, edges, axes3 }\n})\n</script>\n<template>\n <div :style=\"{position:'relative',display:'inline-block',lineHeight:0,...style}\">\n <svg :width=\"width\" :height=\"height\" @pointerdown=\"down\" @pointermove=\"move\" @pointerup=\"up\" @pointerleave=\"up\"\n role=\"img\" :style=\"{cursor:interactive?'grab':'default',touchAction:'none'}\">\n <line v-for=\"(e,i) in g.edges\" :key=\"'e'+i\" :x1=\"e[0].x\" :y1=\"e[0].y\" :x2=\"e[1].x\" :y2=\"e[1].y\" stroke=\"var(--chart-axis)\" stroke-width=\"1\"/>\n <line v-for=\"(ax,i) in g.axes3\" :key=\"'ae'+i\" :x1=\"ax.a.x\" :y1=\"ax.a.y\" :x2=\"ax.b.x\" :y2=\"ax.b.y\" stroke=\"var(--chart-guide)\" stroke-width=\"1\"/>\n <text v-for=\"(ax,i) in g.axes3\" :key=\"'at'+i\" :x=\"ax.nx\" :y=\"ax.ny+3\" text-anchor=\"middle\" style=\"font-family:var(--font-sans);font-size:var(--text-2xs);fill:var(--text-secondary)\">{{ ax.name }}</text>\n <circle v-for=\"d in g.dots\" :key=\"d.i\" :cx=\"d.x\" :cy=\"d.y\"\n :r=\"r*(1.18-(d.depth+0.75)*0.35)*(hover===d.i?1.5:1)\" :fill=\"d.color\"\n :opacity=\"hover===d.i ? 1 : (0.85-(d.depth+0.75)*0.28)*(hover==null?1:0.45)\"\n :stroke=\"hover===d.i?'var(--bg-surface)':'none'\" stroke-width=\"1.5\"\n @mouseenter=\"interactive && (hover=d.i)\" @mouseleave=\"interactive && (hover=null)\"/>\n </svg>\n <div v-if=\"hover!=null && interactive\" :style=\"{position:'absolute',left:g.dots.find(d=>d.i===hover).x+'px',top:g.dots.find(d=>d.i===hover).y+'px',transform:g.dots.find(d=>d.i===hover).x>width*0.6?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal',whiteSpace:'nowrap'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:3px\">{{ points[hover].label || points[hover].series }}</div>\n <div style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)\">{{ xLabel }} {{ valueFormat(points[hover].x) }} · {{ yLabel }} {{ valueFormat(points[hover].y) }} · {{ zLabel }} {{ valueFormat(points[hover].z) }}</div>\n </div>\n <div v-if=\"legend && g.names.length>1\" style=\"display:flex;gap:14px;padding-top:6px;line-height:normal\">\n <span v-for=\"(nm,i) in g.names\" :key=\"nm\" style=\"display:inline-flex;align-items:center;gap:6px\">\n <span :style=\"{width:'8px',height:'8px',borderRadius:'4px',background:SERIES[i%SERIES.length]}\"/>\n <span style=\"font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-secondary)\">{{ nm }}</span>\n </span>\n </div>\n </div>\n</template>\n","charts/Bar3D.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { fmtNum, SERIES, stack, niceTicks } from './chart-geometry.js'\nimport { makeProj, cuboidFaces, shade, facePath } from './chart-3d.js'\n// 3D cuboid bars — categories on x, series as depth rows or stacked; drag to orbit.\nconst FACE_SHADE = { top:0.22, front:0, right:-0.22, left:-0.3, back:-0.1, bottom:-0.4 }\nconst props = defineProps({ series:{type:Array,default:()=>[]}, categories:{type:Array,default:()=>[]}, mode:{type:String,default:'grouped'}, width:{type:Number,default:460}, height:{type:Number,default:300}, yaw:{type:Number,default:30}, pitch:{type:Number,default:26}, gap:{type:Number,default:0.35}, interactive:{type:Boolean,default:true}, valueFormat:{type:Function,default:fmtNum}, style:Object })\nconst rot = ref({ yaw: props.yaw, pitch: props.pitch })\nconst hover = ref(null)\nfunction down(e){ e.currentTarget.__kbDrag = { x:e.clientX, y:e.clientY, ...rot.value }; e.currentTarget.setPointerCapture && e.currentTarget.setPointerCapture(e.pointerId) }\nfunction move(e){ const s = e.currentTarget.__kbDrag; if(!s) return; rot.value = { yaw: s.yaw+(e.clientX-s.x)*0.45, pitch: Math.max(8, Math.min(80, s.pitch+(e.clientY-s.y)*0.35)) } }\nfunction up(e){ e.currentTarget.__kbDrag = null }\nconst g = computed(() => {\n const n = props.categories.length || Math.max(...props.series.map(s=>s.values.length))\n const m = props.mode==='grouped' ? props.series.length : 1\n const hi = props.mode==='stacked'\n ? Math.max(...[...Array(n)].map((_,i)=>props.series.reduce((t,s)=>t+(s.values[i]||0),0)))\n : Math.max(...props.series.flatMap(s=>s.values))\n const bands = props.mode==='stacked' ? stack(props.series.map(s=>s.values)) : null\n const scale = Math.min(props.width, props.height)*0.42\n const P = makeProj(props.width/2, props.height/2 + props.height*0.06, scale, rot.value.yaw, rot.value.pitch)\n const bars = []\n props.series.forEach((s,si)=>s.values.forEach((v,i)=>{\n if (v==null) return\n const cx0 = i/n-0.5+(1/n)*(props.gap/2), cx1 = cx0+(1/n)*(1-props.gap)\n const zi = props.mode==='grouped' ? si : 0\n const cz0 = zi/Math.max(m,1)-0.5+(1/Math.max(m,2))*(props.gap/2), cz1 = cz0+(1/Math.max(m,2))*(1-props.gap)\n const y0 = props.mode==='stacked' ? (bands[si][i][0]/hi) : 0\n const y1 = props.mode==='stacked' ? (bands[si][i][1]/hi) : v/hi\n bars.push({ s, si, i, v, x0:cx0, x1:cx1, z0:cz0, z1:cz1, y0:(y0-0.5)*0.9, y1:(y1-0.5)*0.9 })\n }))\n const drawn = bars.map((b,key)=>{\n const faces = cuboidFaces(P, b.x0, b.x1, b.y0, b.y1, b.z0, b.z1)\n return { ...b, key, faces, depth: faces.reduce((s2,f)=>s2+f.depth,0)/faces.length,\n top: P((b.x0+b.x1)/2, b.y1, (b.z0+b.z1)/2), color: b.s.color || SERIES[b.si%SERIES.length] }\n }).sort((a,b2)=>b2.depth-a.depth)\n const base = [P(-0.5,-0.46,-0.5), P(0.5,-0.46,-0.5), P(0.5,-0.46,0.5), P(-0.5,-0.46,0.5)]\n const vt = niceTicks(0, hi, 3)\n const vTicks = vt.ticks.filter(t=>t<=hi*1.001).map(t=>({ p:P(-0.5,(t/hi-0.5)*0.9,0.5), v:props.valueFormat(t) }))\n // Labels follow whichever base edge faces the viewer, outside it.\n const zE = P(0,-0.46,0.5).y >= P(0,-0.46,-0.5).y ? 0.5 : -0.5\n const ctr = P(0,-0.46,0)\n const labels = props.categories.map((c,i)=>{\n const p0 = P(i/n-0.5+0.5/n, -0.46, zE)\n const dx = p0.x-ctr.x, dy = p0.y-ctr.y, len = Math.hypot(dx,dy)||1\n return { c, x: p0.x+(dx/len)*18, y: p0.y+(dy/len)*18+3 }\n })\n return { drawn, basePath: facePath(base), vTicks, vAxis:[P(-0.5,-0.45,0.5), P(-0.5,0.45,0.5)], labels, FACE_SHADE }\n})\n</script>\n<template>\n <div :style=\"{position:'relative',display:'inline-block',lineHeight:0,...style}\">\n <svg :width=\"width\" :height=\"height\" @pointerdown=\"down\" @pointermove=\"move\" @pointerup=\"up\" @pointerleave=\"up\"\n role=\"img\" :style=\"{cursor:interactive?'grab':'default',touchAction:'none'}\">\n <path :d=\"g.basePath\" fill=\"var(--bg-inset)\" stroke=\"var(--chart-axis)\" stroke-width=\"1\"/>\n <line :x1=\"g.vAxis[0].x\" :y1=\"g.vAxis[0].y\" :x2=\"g.vAxis[1].x\" :y2=\"g.vAxis[1].y\" stroke=\"var(--chart-guide)\" stroke-width=\"1\"/>\n <g v-for=\"(t,i) in g.vTicks\" :key=\"'vt'+i\">\n <line :x1=\"t.p.x-4\" :y1=\"t.p.y\" :x2=\"t.p.x\" :y2=\"t.p.y\" stroke=\"var(--chart-guide)\" stroke-width=\"1\"/>\n <text :x=\"t.p.x-7\" :y=\"t.p.y+3\" text-anchor=\"end\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ t.v }}</text>\n </g>\n <text v-for=\"(l,i) in g.labels\" :key=\"'c'+i\" :x=\"l.x\" :y=\"l.y\" text-anchor=\"middle\" style=\"font-family:var(--font-sans);font-size:var(--text-2xs);fill:var(--chart-label)\">{{ l.c }}</text>\n <g v-for=\"b in g.drawn\" :key=\"b.key\" :opacity=\"hover==null||hover===b.key?1:0.45\"\n @mouseenter=\"interactive && (hover=b.key)\" @mouseleave=\"interactive && (hover=null)\"\n style=\"transition:opacity var(--dur-fast) var(--ease-out)\">\n <path v-for=\"(f,fi) in b.faces\" :key=\"fi\" :d=\"f.d\" :fill=\"shade(b.color, g.FACE_SHADE[f.kind])\" stroke=\"var(--bg-surface)\" stroke-width=\"0.4\"/>\n </g>\n </svg>\n <div v-if=\"hover!=null && interactive\" :style=\"{position:'absolute',left:g.drawn.find(b=>b.key===hover).top.x+'px',top:g.drawn.find(b=>b.key===hover).top.y+'px',transform:g.drawn.find(b=>b.key===hover).top.x>width*0.6?'translate(calc(-100% - 10px),-50%)':'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal',whiteSpace:'nowrap'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:3px\">{{ categories[g.drawn.find(b=>b.key===hover).i] }}</div>\n <div style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)\">{{ g.drawn.find(b=>b.key===hover).s.name }} · {{ valueFormat(g.drawn.find(b=>b.key===hover).v) }}</div>\n </div>\n <div style=\"display:flex;gap:14px;padding-top:6px;line-height:normal\">\n <span v-for=\"(s,i) in series\" :key=\"s.name\" style=\"display:inline-flex;align-items:center;gap:6px\">\n <span :style=\"{width:'8px',height:'8px',borderRadius:'2px',background:s.color||SERIES[i%SERIES.length]}\"/>\n <span style=\"font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-secondary)\">{{ s.name }}</span>\n </span>\n </div>\n </div>\n</template>\n","charts/Ribbon3D.vue":"<script setup>\nimport { ref, computed } from 'vue'\nimport { fmtNum, SERIES, niceTicks } from './chart-geometry.js'\nimport { makeProj, facePath, faceDepth, shade } from './chart-3d.js'\n// Each series as an extruded band at its own depth row (the classic 3D area); drag to orbit.\nconst props = defineProps({ series:{type:Array,default:()=>[]}, xLabels:{type:Array,default:()=>[]}, width:{type:Number,default:460}, height:{type:Number,default:300}, yaw:{type:Number,default:34}, pitch:{type:Number,default:30}, ribbon:{type:Number,default:0.55}, interactive:{type:Boolean,default:true}, valueFormat:{type:Function,default:fmtNum}, style:Object })\nconst rot = ref({ yaw: props.yaw, pitch: props.pitch })\nconst hover = ref(null)\nfunction down(e){ e.currentTarget.__kbDrag = { x:e.clientX, y:e.clientY, ...rot.value }; e.currentTarget.setPointerCapture && e.currentTarget.setPointerCapture(e.pointerId) }\nfunction move(e){ const s = e.currentTarget.__kbDrag; if(!s) return; rot.value = { yaw: s.yaw+(e.clientX-s.x)*0.45, pitch: Math.max(8, Math.min(80, s.pitch+(e.clientY-s.y)*0.35)) } }\nfunction up(e){ e.currentTarget.__kbDrag = null }\nconst SHADE = { top:0.14, front:-0.12, back:-0.2, end:-0.28 }\nconst g = computed(() => {\n const n = Math.max(...props.series.map(s=>s.values.length), 2)\n const hi = Math.max(...props.series.flatMap(s=>s.values)) || 1\n const m = props.series.length\n const scale = Math.min(props.width, props.height)*0.42\n const P = makeProj(props.width/2, props.height/2 + props.height*0.05, scale, rot.value.yaw, rot.value.pitch)\n const base = [P(-0.5,-0.45,-0.5), P(0.5,-0.45,-0.5), P(0.5,-0.45,0.5), P(-0.5,-0.45,0.5)]\n const rows = props.series.map((s,si)=>{\n const z0 = si/m-0.5+(1/m)*(0.5-props.ribbon/2), z1 = z0+(props.ribbon/m)\n const y = i => ((s.values[i]||0)/hi-0.5)*0.9\n const quads = []\n for (let i=0;i<n-1;i++) {\n const x0 = i/(n-1)-0.5, x1 = (i+1)/(n-1)-0.5\n quads.push({ pts:[P(x0,-0.45,z0),P(x1,-0.45,z0),P(x1,y(i+1),z0),P(x0,y(i),z0)], kind:'back' })\n quads.push({ pts:[P(x0,y(i),z0),P(x1,y(i+1),z0),P(x1,y(i+1),z1),P(x0,y(i),z1)], kind:'top' })\n quads.push({ pts:[P(x0,-0.45,z1),P(x1,-0.45,z1),P(x1,y(i+1),z1),P(x0,y(i),z1)], kind:'front' })\n }\n quads.push({ pts:[P(0.5,-0.45,z0),P(0.5,-0.45,z1),P(0.5,y(n-1),z1),P(0.5,y(n-1),z0)], kind:'end' })\n quads.push({ pts:[P(-0.5,-0.45,z0),P(-0.5,-0.45,z1),P(-0.5,y(0),z1),P(-0.5,y(0),z0)], kind:'end' })\n quads.forEach(q=>{ q.depth = faceDepth(q.pts); q.d = facePath(q.pts) })\n quads.sort((a,b)=>b.depth-a.depth)\n return { s, si, quads, rowDepth: quads.reduce((t,q)=>t+q.depth,0)/quads.length, color: s.color || SERIES[si%SERIES.length] }\n }).sort((a,b)=>b.rowDepth-a.rowDepth)\n const vt = niceTicks(0, hi, 3)\n const vTicks = vt.ticks.filter(t=>t<=hi*1.001).map(t=>({ p:P(-0.5,(t/hi-0.5)*0.9,0.5), v:fmtNum(t) }))\n const zE = P(0,-0.45,0.5).y >= P(0,-0.45,-0.5).y ? 0.62 : -0.62\n const ctr = P(0,-0.45,0)\n const every = props.xLabels.length > 8 ? Math.ceil(props.xLabels.length/8) : 1\n const labels = props.xLabels.map((lb,i)=>{\n if (lb==null || (every>1 && i%every && i!==props.xLabels.length-1)) return null\n const p0 = P(i/(n-1)-0.5, -0.45, zE)\n const dx = p0.x-ctr.x, dy = p0.y-ctr.y, len = Math.hypot(dx,dy)||1\n return { lb, x: p0.x+(dx/len)*14, y: p0.y+(dy/len)*14+3 }\n }).filter(Boolean)\n return { rows, basePath: facePath(base), vTicks, vAxis:[P(-0.5,-0.45,0.5), P(-0.5,0.45,0.5)], labels, SHADE }\n})\n</script>\n<template>\n <div :style=\"{position:'relative',display:'inline-block',lineHeight:0,...style}\">\n <svg :width=\"width\" :height=\"height\" @pointerdown=\"down\" @pointermove=\"move\" @pointerup=\"up\" @pointerleave=\"up\"\n role=\"img\" :style=\"{cursor:interactive?'grab':'default',touchAction:'none'}\">\n <path :d=\"g.basePath\" fill=\"var(--bg-inset)\" stroke=\"var(--chart-axis)\" stroke-width=\"1\"/>\n <line :x1=\"g.vAxis[0].x\" :y1=\"g.vAxis[0].y\" :x2=\"g.vAxis[1].x\" :y2=\"g.vAxis[1].y\" stroke=\"var(--chart-guide)\" stroke-width=\"1\"/>\n <g v-for=\"(t,i) in g.vTicks\" :key=\"'vt'+i\">\n <line :x1=\"t.p.x-4\" :y1=\"t.p.y\" :x2=\"t.p.x\" :y2=\"t.p.y\" stroke=\"var(--chart-guide)\" stroke-width=\"1\"/>\n <text :x=\"t.p.x-7\" :y=\"t.p.y+3\" text-anchor=\"end\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ t.v }}</text>\n </g>\n <text v-for=\"(l,i) in g.labels\" :key=\"'x'+i\" :x=\"l.x\" :y=\"l.y\" text-anchor=\"middle\" style=\"font-family:var(--font-sans);font-size:var(--text-2xs);fill:var(--chart-label)\">{{ l.lb }}</text>\n <g v-for=\"r in g.rows\" :key=\"r.s.name\" :opacity=\"hover==null||hover===r.si?1:0.4\"\n @mouseenter=\"interactive && (hover=r.si)\" @mouseleave=\"interactive && (hover=null)\"\n style=\"transition:opacity var(--dur-fast) var(--ease-out)\">\n <path v-for=\"(q,qi) in r.quads\" :key=\"qi\" :d=\"q.d\" :fill=\"shade(r.color, g.SHADE[q.kind])\" stroke=\"var(--bg-surface)\" stroke-width=\"0.4\"/>\n </g>\n </svg>\n <div v-if=\"hover!=null && interactive\" :style=\"{position:'absolute',left:(width/2)+'px',top:(height*0.28)+'px',transform:'translate(10px,-50%)',pointerEvents:'none',zIndex:5,background:'var(--bg-surface)',border:'1px solid var(--border-card)',borderRadius:'var(--radius-md)',boxShadow:'var(--shadow-menu)',padding:'7px 10px',lineHeight:'normal',whiteSpace:'nowrap'}\">\n <div style=\"font-family:var(--font-sans);font-size:var(--text-2xs);color:var(--text-muted);padding-bottom:3px\">{{ series[hover].name }}</div>\n <div style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)\">peak {{ valueFormat(Math.max(...series[hover].values)) }} · latest {{ valueFormat(series[hover].values[series[hover].values.length-1]) }}</div>\n </div>\n <div style=\"display:flex;gap:14px;padding-top:6px;line-height:normal\">\n <span v-for=\"(s,i) in series\" :key=\"s.name\" @mouseenter=\"interactive && (hover=i)\" @mouseleave=\"interactive && (hover=null)\"\n :style=\"{display:'inline-flex',alignItems:'center',gap:'6px',opacity:hover==null||hover===i?1:0.45,transition:'var(--t-hover)'}\">\n <span :style=\"{width:'8px',height:'8px',borderRadius:'2px',background:s.color||SERIES[i%SERIES.length]}\"/>\n <span style=\"font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-secondary)\">{{ s.name }}</span>\n </span>\n </div>\n </div>\n</template>\n","charts/ChartLegend.vue":"<script setup>\nimport { seriesColor } from './chart-geometry.js'\n// Legend row. items: [{ label, color? }]. Pass toggleable + listen to @toggle(label) to mute a series; hidden lists muted labels.\ndefineProps({ items: { type: Array, default: () => [] }, hidden: { type: Array, default: () => [] }, toggleable: Boolean, align: { type: String, default: 'left' }, style: Object })\nconst emit = defineEmits(['toggle'])\n</script>\n<template>\n <div :style=\"{ display: 'flex', flexWrap: 'wrap', gap: '4px 14px', justifyContent: align === 'right' ? 'flex-end' : 'flex-start', lineHeight: 'normal', ...style }\">\n <span v-for=\"(it, i) in items\" :key=\"it.label\" :role=\"toggleable ? 'button' : undefined\" :tabindex=\"toggleable ? 0 : undefined\"\n @click=\"toggleable && emit('toggle', it.label)\" :data-ink=\"toggleable ? '' : undefined\" @keydown.enter=\"toggleable && emit('toggle', it.label)\"\n :style=\"{ display: 'inline-flex', alignItems: 'center', gap: '6px', cursor: toggleable ? 'pointer' : 'default', opacity: hidden.includes(it.label) ? 0.4 : 1, transition: 'var(--t-hover)' }\">\n <span :style=\"{ width: '8px', height: '8px', borderRadius: '2px', background: it.color || seriesColor(i), flexShrink: 0 }\"></span>\n <span :style=\"{ fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)', color: toggleable ? 'var(--ink, var(--text-secondary))' : 'var(--text-secondary)', textDecoration: hidden.includes(it.label) ? 'line-through' : 'none' }\">{{ it.label }}</span>\n </span>\n </div>\n</template>\n","charts/ChartTooltip.vue":"<script setup>\nimport { px } from '../px.js'\n// Floating chart tooltip. rows: [{ label, value, color? }]; pins near (x, y) inside a position:relative chart wrapper.\n// flip puts it to the left of the point (use near the right edge).\ndefineProps({ x: [Number, String], y: [Number, String], title: [String, Number], rows: { type: Array, default: () => [] }, flip: Boolean })\n</script>\n<template>\n <div :style=\"{ position: 'absolute', left: px(x), top: px(y), transform: `translate(${flip ? 'calc(-100% - 10px)' : '10px'}, -50%)`, pointerEvents: 'none', zIndex: 5, background: 'var(--bg-surface)', border: '1px solid var(--border-card)', borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-menu)', padding: '7px 10px', minWidth: '90px', lineHeight: 'normal', whiteSpace: 'nowrap' }\">\n <div v-if=\"title != null\" :style=\"{ fontFamily: 'var(--font-sans)', fontSize: 'var(--text-2xs)', color: 'var(--text-muted)', paddingBottom: rows.length ? '4px' : 0, whiteSpace: 'nowrap' }\">{{ title }}</div>\n <div v-for=\"(r, i) in rows\" :key=\"i\" style=\"display:flex;align-items:center;gap:6px;padding:1px 0\">\n <span v-if=\"r.color\" :style=\"{ width: '7px', height: '7px', borderRadius: '2px', background: r.color, flexShrink: 0 }\"></span>\n <span style=\"font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-secondary);white-space:nowrap\">{{ r.label }}</span>\n <span style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading);margin-left:auto;padding-left:10px\">{{ r.value }}</span>\n </div>\n </div>\n</template>\n","charts/PlotGrid.vue":"<script setup>\nimport { fmtNum } from './chart-geometry.js'\n// SVG plot furniture: y tick labels + horizontal gridlines (zero line darker), x category labels.\n// Render inside an <svg>. sy / sxLabel are the chart's scale functions.\ndefineProps({ w: Number, h: Number, pad: Object, ticks: { type: Array, default: () => [] }, sy: Function, xLabels: { type: Array, default: () => [] }, sxLabel: Function, showAxisLine: Boolean, yFormat: { type: Function, default: fmtNum } })\n</script>\n<template>\n <g>\n <g v-for=\"t in ticks\" :key=\"t\">\n <line :x1=\"pad.l\" :x2=\"w - pad.r\" :y1=\"sy(t)\" :y2=\"sy(t)\" :stroke=\"t === 0 ? 'var(--chart-axis)' : 'var(--chart-grid)'\" stroke-width=\"1\" />\n <text :x=\"pad.l - 8\" :y=\"sy(t) + 3\" text-anchor=\"end\" style=\"font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-2xs-mono);fill:var(--chart-label)\">{{ yFormat(t) }}</text>\n </g>\n <line v-if=\"showAxisLine\" :x1=\"pad.l\" :x2=\"pad.l\" :y1=\"pad.t\" :y2=\"h - pad.b\" stroke=\"var(--chart-axis)\" stroke-width=\"1\" />\n <template v-for=\"(lb, i) in xLabels\" :key=\"'x' + i\">\n <text v-if=\"lb != null\" :x=\"sxLabel(i)\" :y=\"h - pad.b + 16\" text-anchor=\"middle\" style=\"font-family:var(--font-sans);font-size:var(--text-2xs);fill:var(--chart-label)\">{{ lb }}</text>\n </template>\n </g>\n</template>\n","charts/XLabels.vue":"<script setup>\n// Thin x-axis category labels only (small charts with no y axis). Render inside an <svg>.\ndefineProps({ h: Number, pad: Object, xLabels: { type: Array, default: () => [] }, sxLabel: Function })\n</script>\n<template>\n <g>\n <template v-for=\"(lb, i) in xLabels\" :key=\"i\">\n <text v-if=\"lb != null\" :x=\"sxLabel(i)\" :y=\"h - pad.b + 16\" text-anchor=\"middle\" style=\"font-family:var(--font-sans);font-size:var(--text-2xs);fill:var(--chart-label)\">{{ lb }}</text>\n </template>\n </g>\n</template>\n"});