// Componentes de visualização: Cartograma do Brasil, AreaChart, Sparkline, Funnel etc. const { useState, useEffect, useRef, useMemo } = React; // ── Cartograma de estados ───────────────────────────────────── function BrazilCartogram({ data, metric, selected, onSelect }) { const max = Math.max(0, ...data.map((d) => d[metric] || 0)); const hasData = max > 0; const fmtVal = (s) => { if (metric === "ruptureRate") return Math.round(s[metric] * 100) + "%"; if (metric === "patients") { const v = s.patients; return v >= 1000 ? (v / 1000).toFixed(v >= 10000 ? 0 : 1).replace(".", ",") + "k" : v; } const v = s.dispensed; if (v >= 1_000_000) return (v / 1_000_000).toFixed(1).replace(".", ",") + "M"; return (v / 1000).toFixed(0) + "k"; }; const stepBgs = [0.0, 0.25, 0.5, 0.75, 1.0].map((intensity) => { return `oklch(${94 - intensity * 38}% ${0.018 + intensity * 0.07} var(--accent-h, 165))`; }); const cells = []; for (let r = 0; r < 10; r++) { for (let c = 0; c < 11; c++) { const s = data.find((d) => d.col === c && d.row === r); if (!s) { cells.push(
); continue; } const v = s[metric] || 0; const intensity = hasData ? Math.pow(v / max, 0.55) : 0; const bg = hasData ? `oklch(${94 - intensity * 38}% ${0.018 + intensity * 0.075} 165)` : "oklch(91% 0.005 240)"; const fg = intensity > 0.45 ? "oklch(99% 0 0)" : "oklch(28% 0.02 240)"; const isWarn = s.ruptureRate > 0.18; const showVal = metric !== "ruptureRate" ? intensity > 0.18 : true; cells.push( ); } } const metricLabel = metric === "dispensed" ? "Cápsulas dispensadas" : metric === "patients" ? "Pacientes ativos" : "Taxa de ruptura"; return (
{cells}
{metricLabel}
menos {stepBgs.map((bg, i) => )} mais
ruptura ≥ 18%
); } const tipEl = (() => { if (typeof document === "undefined") return null; const el = document.createElement("div"); el.className = "tooltip"; document.body.appendChild(el); return el; })(); function showTip(e, s, metric) { if (!tipEl) return; const num = (n) => n.toLocaleString("pt-BR"); tipEl.innerHTML = `
${s.uf} · ${s.name}
Pacientes ativos${num(s.patients)}
Cápsulas / mês${num(s.dispensed)}
Caixas / mês${num(s.caixas)}
Ruptura${(s.ruptureRate * 100).toFixed(0)}% (${s.ruptureEstabs}/${s.estabs})
`; tipEl.classList.add("is-on"); moveTip(e); } function moveTip(e) { if (!tipEl) return; tipEl.style.left = e.clientX + "px"; tipEl.style.top = e.clientY + "px"; } function hideTip() { if (tipEl) tipEl.classList.remove("is-on"); } // ── Sparkline ───────────────────────────────────────────────── function Sparkline({ data, width = 110, height = 36, stroke = "var(--accent)", fill = true, dot = true }) { if (!data || data.length === 0) return null; const max = Math.max(...data); const min = Math.min(...data); const range = max - min || 1; const step = width / (data.length - 1); const pts = data.map((v, i) => [i * step, height - (v - min) / range * (height - 4) - 2]); const d = pts.map((p, i) => i === 0 ? `M${p[0]},${p[1]}` : `L${p[0]},${p[1]}`).join(" "); const dFill = d + ` L${width},${height} L0,${height} Z`; return ( {fill && } {dot && } ); } // ── Dual-axis chart: dispensação × ruptura ──────────────────── function DispensacaoRupturaChart({ months, height = 240 }) { const ref = useRef(null); const [w, setW] = useState(800); const [hover, setHover] = useState(null); useEffect(() => { const obs = new ResizeObserver(([e]) => setW(e.contentRect.width)); if (ref.current) obs.observe(ref.current); return () => obs.disconnect(); }, []); const startIdx = months.findIndex((m) => m.caixas >= 200); const data = startIdx >= 0 ? months.slice(Math.max(0, startIdx - 1)) : months; const pad = { l: 48, r: 56, t: 18, b: 30 }; const innerW = Math.max(100, w - pad.l - pad.r); const innerH = height - pad.t - pad.b; const maxC = Math.max(...data.map((m) => m.caixas)) * 1.08; const maxR = 0.35; const xStep = data.length > 1 ? innerW / (data.length - 1) : innerW; const yC = (v) => pad.t + innerH - v / maxC * innerH; const yR = (v) => pad.t + innerH - v / maxR * innerH; const ptsC = data.map((m, i) => [pad.l + i * xStep, yC(m.caixas)]); const ptsR = data.map((m, i) => [pad.l + i * xStep, yR(m.ruptura)]); const dC = ptsC.map((p, i) => i === 0 ? `M${p[0]},${p[1]}` : `L${p[0]},${p[1]}`).join(" "); const dCArea = dC + ` L${ptsC[ptsC.length - 1][0]},${pad.t + innerH} L${ptsC[0][0]},${pad.t + innerH} Z`; const dR = ptsR.map((p, i) => i === 0 ? `M${p[0]},${p[1]}` : `L${p[0]},${p[1]}`).join(" "); const yTicks = 4; const yArr = Array.from({ length: yTicks + 1 }, (_, i) => maxC / yTicks * i); const yRArr = Array.from({ length: yTicks + 1 }, (_, i) => maxR / yTicks * i); return (
setHover(null)} onMouseMove={(e) => { const rect = e.currentTarget.getBoundingClientRect(); const x = e.clientX - rect.left - pad.l; const i = Math.round(x / xStep); if (i >= 0 && i < data.length) setHover(i); }}> {yArr.map((t, i) => {t >= 1000 ? (t / 1000).toFixed(t >= 10000 ? 0 : 1).replace(".", ",") + "k" : Math.round(t)} )} {yRArr.map((t, i) => {Math.round(t * 100)}% )} {ptsC.map(([px, py], i) => )} {ptsR.map(([px, py], i) => )} {data.map((m, i) => i % 2 === 0 || i === data.length - 1 ? {fmtMonth(m.c, true)} : null )} {hover !== null && } caixas ruptura {hover !== null &&
{fmtMonth(data[hover].c)}
Caixas {data[hover].caixas.toLocaleString("pt-BR")}
Ruptura {(data[hover].ruptura * 100).toFixed(1).replace(".", ",")}% · {data[hover].estabsRuptura} estab.
}
); } function PatientsChart({ months, height = 280 }) { const ref = useRef(null); const [w, setW] = useState(800); useEffect(() => { const obs = new ResizeObserver(([e]) => setW(e.contentRect.width)); if (ref.current) obs.observe(ref.current); return () => obs.disconnect(); }, []); const pad = { l: 44, r: 18, t: 14, b: 28 }; const innerW = Math.max(100, w - pad.l - pad.r); const innerH = height - pad.t - pad.b; const max = Math.max(...months.map((m) => m.ativos)) * 1.08; const min = 0; const xStep = innerW / (months.length - 1); const yScale = (v) => pad.t + innerH - (v - min) / (max - min) * innerH; const points = months.map((m, i) => [pad.l + i * xStep, yScale(m.ativos)]); const dLine = points.map((p, i) => i === 0 ? `M${p[0]},${p[1]}` : `L${p[0]},${p[1]}`).join(" "); const dArea = dLine + ` L${pad.l + (months.length - 1) * xStep},${pad.t + innerH} L${pad.l},${pad.t + innerH} Z`; const ticks = 4; const yTicks = Array.from({ length: ticks + 1 }, (_, i) => max / ticks * i); const ann = months.findIndex((m) => m.c === "202507"); const [hover, setHover] = useState(null); return (
setHover(null)} onMouseMove={(e) => { const rect = e.currentTarget.getBoundingClientRect(); const x = e.clientX - rect.left - pad.l; const i = Math.round(x / xStep); if (i >= 0 && i < months.length) setHover(i); }}> {yTicks.map((t, i) => {t >= 1000 ? (t / 1000).toFixed(t >= 10000 ? 0 : 1) + "k" : Math.round(t)} )} {ann >= 0 && jul/25 — entrada SUS } {points.map(([px, py], i) => )} {months.map((m, i) => i % 3 === 0 || i === months.length - 1 ? {fmtMonth(m.c, true)} : null )} {hover !== null && } {hover !== null &&
{fmtMonth(months[hover].c)}
Ativos{months[hover].ativos.toLocaleString("pt-BR")}
Novos+{months[hover].novos.toLocaleString("pt-BR")}
Desistência−{months[hover].dropout}
}
); } function fmtMonth(c, short = false) { const y = c.slice(0, 4), m = parseInt(c.slice(4), 10); const names = ["jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"]; return short ? `${names[m - 1]}/${y.slice(2)}` : `${names[m - 1]} / ${y}`; } function Funnel({ rows }) { const max = Math.max(...rows.map((r) => r.value)); return (
{rows.map((r, i) =>
{r.label} {r.value.toLocaleString("pt-BR")}{r.suffix || ""}
)}
); } function Cohort({ data }) { return (
{data.map((c, i) =>
{Math.round(c.pct)}
{c.m}
)}
); } function ReasonsList({ rows }) { return (
{rows.map((r, i) =>
{r.label}
{Math.round(r.value * 100)}%
)}
); } function IndicatorBar({ indicator }) { const v = Math.max(0, Math.min(2, indicator)); const pct = v / 2 * 100; const mid = 50; let tone = "ok"; if (indicator < 0.6) tone = "danger"; else if (indicator < 0.85) tone = "warn"; return (
{indicator === 0 ? "0,00× ruptura" : `${indicator.toFixed(2).replace(".", ",")}× média`}
); } Object.assign(window, { BrazilCartogram, Sparkline, PatientsChart, DispensacaoRupturaChart, Funnel, Cohort, ReasonsList, IndicatorBar, fmtMonth });