// Plant Analytics — bespoke widgets (hardened copy of the kit's Widgets.jsx). // Differences vs the kit: every `Math.max(...)` / total denominator is guarded // against 0 / empty (real Firebase data is often all-zeros early on, which would // otherwise produce NaN widths/colors). (function () { const NS = window.HyperChartsDesignSystem_e51a95; const { TrendChip, Badge } = NS; const I = window.HyperIcons; function smooth(points, t = 0.5) { if (points.length < 2) return ''; const d = [`M ${points[0][0]} ${points[0][1]}`]; for (let i = 0; i < points.length - 1; i++) { const p0 = points[i - 1] || points[i], p1 = points[i], p2 = points[i + 1], p3 = points[i + 2] || p2; const c1x = p1[0] + ((p2[0] - p0[0]) / 6) * t * 2, c1y = p1[1] + ((p2[1] - p0[1]) / 6) * t * 2; const c2x = p2[0] - ((p3[0] - p1[0]) / 6) * t * 2, c2y = p2[1] - ((p3[1] - p1[1]) / 6) * t * 2; d.push(`C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2[0]} ${p2[1]}`); } return d.join(' '); } function ChartCard({ title, eyebrow, action, children, span, pad = 22, style }) { return (
{(title || action) && (
{eyebrow &&
{eyebrow}
} {title &&
{title}
}
{action}
)} {children}
); } function StatTile({ icon, label, value, delta, sub, accent = 'var(--app-accent)', span }) { return (
{label} {icon && {icon}}
{value} {delta != null && }
{sub && {sub}}
); } function Funnel({ steps, height = 296, stepW = 64, accent = '#8338EC', mode = 'percent' }) { const max = (steps[0] && steps[0].value) || 1; const W = stepW * steps.length; const pillH = 40, funTop = 62; const funH = height - funTop - 6; const cy = funTop + funH / 2; const maxH = funH - 8; const pillW = Math.min(stepW - 8, 62); const xOf = (i) => i * stepW + stepW / 2; const topPts = steps.map((s, i) => [xOf(i), cy - (s.value / max) * maxH / 2]); const botPts = steps.map((s, i) => [xOf(i), cy + (s.value / max) * maxH / 2]); topPts.unshift([0, topPts[0][1]]); botPts.unshift([0, botPts[0][1]]); topPts.push([W, topPts[topPts.length - 1][1]]); botPts.push([W, botPts[botPts.length - 1][1]]); const topD = smooth(topPts); const botRev = [...botPts].reverse(); const botD = smooth(botRev); const area = `${topD} L ${botRev[0][0]} ${botRev[0][1]} ${botD.replace(/^M[^C]*/, '')} Z`; const uid = 'fn' + Math.random().toString(36).slice(2, 7); const haloScale = 1.32; const haloTop = topPts.map(([x, y]) => [x, cy - (cy - y) * haloScale]); const haloBotRev = botRev.map(([x, y]) => [x, cy + (y - cy) * haloScale]); const haloTopD = smooth(haloTop); const haloBotD = smooth(haloBotRev); const haloArea = `${haloTopD} L ${haloBotRev[0][0]} ${haloBotRev[0][1]} ${haloBotD.replace(/^M[^C]*/, '')} Z`; return (
{steps.map((s, i) => { const x = xOf(i); const ty = cy - (s.value / max) * maxH / 2; const by = cy + (s.value / max) * maxH / 2; const reached = (s.value / max) * 100; const drop = i === 0 ? 0 : ((steps[i - 1].value - s.value) / (steps[i - 1].value || 1)) * 100; // Only show the pill where the count actually changes from the // previous step (and on the very first step) — skip the long runs // of identical values so the funnel doesn't repeat the same pill. const showPill = i === 0 || s.value !== steps[i - 1].value; return ( {showPill ? ( {mode === 'number' ? compactNum(s.value) : reached.toFixed(0) + '%'} 0 ? 'var(--negative)' : 'var(--app-accent)' }}>{drop > 0 ? '▼' + drop.toFixed(1) + '%' : 'start'} ) : null} ); })}
{steps.map((s, i) => { const lost = i === 0 ? 0 : steps[i - 1].value - s.value; return (
{String(i + 1).padStart(2, '0')}
{s.name}
0 ? 'var(--negative)' : 'var(--text-muted)' }}>{lost > 0 ? '-' + lost.toLocaleString() : '—'}
{lost > 0 ? 'users left' : 'entry'}
); })}
); } function MiniFunnel({ stages }) { const max = (stages[0] && stages[0].value) || 1; return (
{stages.map((s, i) => { const pct = (s.value / max) * 100; const conv = i === 0 ? 100 : (s.value / (stages[i - 1].value || 1)) * 100; return (
{s.name} {s.value.toLocaleString()}{conv.toFixed(1)}%
); })}
); } function HeatGrid({ data, color = '#2ACF56', rows = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] }) { const hours = Array.from({ length: 12 }, (_, i) => i * 2); const max = Math.max(...data.flat(), 1); return (
{hours.map((h) =>
{String(h).padStart(2, '0')}
)} {rows.map((r, ri) => (
{r}
{hours.map((h, ci) => { const v = (data[ri] && data[ri][ci]) || 0; const a = 0.08 + (v / max) * 0.92; return
; })} ))}
); } function CohortGrid({ cohorts, weeks = 8 }) { return (
Cohort
{Array.from({ length: weeks }).map((_, w) =>
W{w}
)} {cohorts.map((c, ri) => (
{c.label} {c.size.toLocaleString()}
{c.values.map((v, ci) => { const a = v == null ? 0 : 0.12 + (v / 100) * 0.88; return
{v != null && 0.55 ? '#fff' : 'var(--text-secondary)' }}>{v}%}
; })}
))}
); } function ComparisonCard({ title, periodA, periodB, rangeA, rangeB, metrics, span = 6 }) { const Sel = ({ label, range }) => (
{label}
); return (
VS
{periodA}
{periodB}
{metrics.map((m, i) => { const delta = m.a ? ((m.b - m.a) / m.a) * 100 : 0; return (
{m.label}
{m.fmt ? m.fmt(m.a) : m.a.toLocaleString()}
{m.fmt ? m.fmt(m.b) : m.b.toLocaleString()}
); })}
); } function DropList({ items, accent = '#FF5C7A' }) { const max = Math.max(...items.map((i) => i.value), 1); const total = items.reduce((s, i) => s + i.value, 0) || 1; const uid = 'dl' + Math.random().toString(36).slice(2, 7); return (
{items.map((it, i) => { const pct = (it.value / total) * 100; return (
{i + 1}
{it.label}
{it.value.toLocaleString()} {pct.toFixed(0)}%
); })}
); } function DistBars({ items, color = 'var(--app-accent)', unit = '' }) { const max = Math.max(...items.map((i) => i.value), 1); return (
{items.map((it, i) => (
{it.label}
{it.value.toLocaleString()}{unit}
))}
); } function DonutStat({ data, centerValue, centerLabel, size = 188 }) { const { PieChart } = NS; const palette = ['var(--series-violet)', 'var(--series-orange)', 'var(--series-green)', 'var(--series-pink)', 'var(--series-blue)', 'var(--series-amber)']; const glow = (data[0] && data[0].color) || 'var(--series-violet)'; const thickness = 15; return (
{data.map((dd, i) => { const c = dd.color || palette[i % 6]; return (
{dd.label} {dd.pct}%
); })}
); } function WorldMap({ values, top }) { const ref = React.useRef(null); const mapRef = React.useRef(null); React.useEffect(() => { if (!window.jsVectorMap || !ref.current) return; ref.current.innerHTML = ''; try { mapRef.current = new window.jsVectorMap({ selector: ref.current, map: 'world', // Zoom (buttons + scroll wheel) + drag-to-pan when zoomed in. zoomButtons: true, zoomOnScroll: true, zoomMax: 12, zoomMin: 1, zoomAnimate: true, backgroundColor: 'transparent', regionStyle: { initial: { fill: '#EDEDF3', stroke: '#ffffff', 'stroke-width': 0.4 }, hover: { fill: '#8676FF' } }, }); } catch (e) { return; } const vals = Object.values(values); const maxV = vals.length ? Math.max(...vals) : 1; const c0 = [237, 233, 251], c1 = [131, 56, 236]; const lerp = (a, b, t) => Math.round(a + (b - a) * t); const paint = () => { Object.entries(values).forEach(([code, v]) => { const t = Math.pow(v / (maxV || 1), 0.7); const col = `rgb(${lerp(c0[0], c1[0], t)},${lerp(c0[1], c1[1], t)},${lerp(c0[2], c1[2], t)})`; const p = ref.current && ref.current.querySelector(`path[data-code="${code}"]`); if (p) { p.setAttribute('fill', col); p.style.fill = col; } }); }; paint(); setTimeout(paint, 80); }, [JSON.stringify(values)]); return (
Top 5 countries
{top.map((c, i) => (
{c.flag} {c.name} {c.pct}%
))}
); } // ── Nice axis ticks + compact number formatting ── function niceTicks(min, max, count) { if (max <= min) max = min + 1; // avoid a degenerate [v, v] axis let range = max - min; if (range <= 0) range = Math.abs(max) || 1; const rawStep = range / Math.max(count, 1); const mag = Math.pow(10, Math.floor(Math.log10(rawStep))); const norm = rawStep / mag; const step = (norm >= 5 ? 5 : norm >= 2 ? 2 : 1) * mag; const niceMin = Math.floor(min / step) * step; const niceMax = Math.ceil(max / step) * step; const ticks = []; for (let v = niceMin; v <= niceMax + step * 0.5; v += step) ticks.push(Math.round(v * 1e6) / 1e6); return ticks.length >= 2 ? ticks : [min, max]; } function compactNum(n) { const a = Math.abs(n); if (a >= 1e9) return (n / 1e9).toFixed(a >= 1e10 ? 0 : 1) + 'B'; if (a >= 1e6) return (n / 1e6).toFixed(a >= 1e7 ? 0 : 1) + 'M'; if (a >= 1e3) return (n / 1e3).toFixed(a >= 1e4 ? 0 : 1) + 'K'; return String(Math.round(n * 100) / 100); } // ── Interactive line chart: x/y axes, gridlines, hover crosshair + tooltip ── function LineChart({ series, labels, height = 210, area = true, valueFmt }) { const wrapRef = React.useRef(null); const [w, setW] = React.useState(760); const [hi, setHi] = React.useState(null); React.useEffect(() => { if (!wrapRef.current || typeof ResizeObserver === 'undefined') return; const ro = new ResizeObserver((es) => { const cw = es[0] && es[0].contentRect.width; if (cw) setW(Math.max(220, Math.round(cw))); }); ro.observe(wrapRef.current); return () => ro.disconnect(); }, []); const data0 = (series && series[0] && series[0].data) || []; const n = (labels && labels.length) || data0.length; const W = w, H = height; const padL = 48, padR = 16, padT = 14, padB = 30; const plotW = W - padL - padR, plotH = H - padT - padB; const fmtAxis = valueFmt || compactNum; if (!n) return
No data
; const allVals = (series || []).flatMap((s) => (s.data || []).filter((v) => typeof v === 'number')); const rawMax = allVals.length ? Math.max(...allVals) : 1; const rawMin = Math.min(0, allVals.length ? Math.min(...allVals) : 0); const ticks = niceTicks(rawMin, rawMax, 4); const yMin = ticks[0], yMax = ticks[ticks.length - 1]; const span = (yMax - yMin) || 1; const X = (i) => padL + (n <= 1 ? plotW / 2 : (i / (n - 1)) * plotW); const Y = (v) => padT + plotH - ((v - yMin) / span) * plotH; const baseY = Y(Math.max(yMin, 0)); const labStep = Math.max(1, Math.ceil(n / 7)); const showDots = n <= 31; const uid = 'lc' + Math.random().toString(36).slice(2, 7); const onMove = (e) => { const r = wrapRef.current && wrapRef.current.getBoundingClientRect(); if (!r || !r.width) return; const fx = e.clientX - r.left; let best = 0, bd = Infinity; for (let i = 0; i < n; i++) { const dd = Math.abs(X(i) - fx); if (dd < bd) { bd = dd; best = i; } } setHi(best); }; return (
setHi(null)}> {(series || []).map((s, si) => ( ))} {ticks.map((t, i) => ( {fmtAxis(t)} ))} {(labels || []).map((lb, i) => ((i % labStep === 0 || i === n - 1) ? ( {lb} ) : null))} {(series || []).map((s, si) => { const pts = (s.data || []).map((v, i) => [X(i), Y(typeof v === 'number' ? v : 0)]); const col = s.color || 'var(--app-accent)'; const linePath = pts.length >= 2 ? smooth(pts) : (pts.length ? `M ${pts[0][0]} ${pts[0][1]}` : ''); const areaPath = area && pts.length >= 2 ? `${linePath} L ${pts[pts.length - 1][0]} ${baseY} L ${pts[0][0]} ${baseY} Z` : ''; return ( {area && areaPath && } {linePath && } {showDots && pts.map((p, i) => )} ); })} {hi != null && ( {(series || []).map((s, si) => { const v = s.data && s.data[hi]; if (typeof v !== 'number') return null; return ; })} )} {hi != null && (
W * 0.62 ? 'calc(-100% - 10px)' : '10px'})`, background: 'var(--surface-card)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-card)', padding: '8px 11px', minWidth: 96, }}>
{labels ? labels[hi] : '#' + (hi + 1)}
{(series || []).map((s, si) => (
{s.name && {s.name}} {typeof (s.data && s.data[hi]) === 'number' ? Math.round(s.data[hi]).toLocaleString() : '—'}
))}
)}
); } window.PA = { ChartCard, StatTile, Funnel, MiniFunnel, HeatGrid, CohortGrid, ComparisonCard, DistBars, DropList, DonutStat, WorldMap, LineChart, smooth }; })();