// Chart component — hand-built SVG, dual-axis line chart // Renders forecast/actual/inventory/requisitions with confidence band + festival markers // Palette for confidence-band fills. Color slot N is assigned in the order // groups are formed by `groupBandSelection` — paired (same level) groups get // one slot; unpaired uppers/lowers each get their own. const BAND_PALETTE = [ '#3b82f6', // blue '#7c3aed', // violet '#ea580c', // orange '#16a34a', // green '#0891b2', // cyan '#db2777', // pink '#ca8a04', // yellow-amber '#475569', // slate ]; // Convert an array of selection keys into render groups. Same-level upper+lower // pairs collapse into a single group ('pair') drawn as one polygon between // upper and lower bounds. Unpaired keys become their own group ('upper' or // 'lower') drawn as a half-band between the forecast line and the bound. function groupBandSelection(selection) { const byLevel = {}; (selection || []).forEach(s => { const m = /^(upper|lower)_(\d+)$/.exec(s); if (!m) return; const lvl = m[2]; byLevel[lvl] = byLevel[lvl] || {}; byLevel[lvl][m[1]] = true; }); const groups = []; Object.keys(byLevel) .map(n => parseInt(n, 10)) .sort((a, b) => b - a) .forEach(lvl => { const g = byLevel[String(lvl)]; if (g.upper && g.lower) { groups.push({ kind: 'pair', level: lvl, label: `${lvl}% band` }); } else if (g.upper) { groups.push({ kind: 'upper', level: lvl, label: `Upper ${lvl}%` }); } else { groups.push({ kind: 'lower', level: lvl, label: `Lower ${lvl}%` }); } }); return groups; } const Chart = ({ data, // [{date, forecast, actual, inventory, requisitions, bands: {upper_80, lower_80, ...}}] series, // {forecast, actual, inventory, requisitions} bandSelection, // string[] — selected band keys e.g. ["upper_80","lower_60"] festivals, // [{date, name}] within range width = 900, height = 380, onHoverChange, }) => { const [hoverIdx, setHoverIdx] = React.useState(null); const svgRef = React.useRef(null); const padL = 64, padR = 64, padT = 36, padB = 56; const w = width, h = height; const innerW = w - padL - padR; const innerH = h - padT - padB; // Compute scales // Primary axis: forecast/actual/requisitions/selected confidence bounds // Secondary axis: inventory const selection = bandSelection || []; let primaryMax = 0, secondaryMax = 0; data.forEach(d => { if (series.forecast && d.forecast != null) primaryMax = Math.max(primaryMax, d.forecast); if (series.forecast && d.bands) { selection.forEach(k => { const v = d.bands[k]; if (v != null) primaryMax = Math.max(primaryMax, v); }); } if (series.actual && d.actual != null) primaryMax = Math.max(primaryMax, d.actual); if (series.requisitions && d.requisitions != null) primaryMax = Math.max(primaryMax, d.requisitions); if (series.inventory && d.inventory != null) secondaryMax = Math.max(secondaryMax, d.inventory); }); primaryMax = Math.ceil((primaryMax || 100) * 1.15 / 100) * 100; secondaryMax = Math.ceil((secondaryMax || 100) * 1.15 / 100) * 100; const xFor = (i) => padL + (data.length <= 1 ? innerW / 2 : (i / (data.length - 1)) * innerW); const yPrimary = (v) => padT + innerH - (v / primaryMax) * innerH; const ySecondary = (v) => padT + innerH - (v / secondaryMax) * innerH; // Path builder const buildPath = (key, scale) => { const pts = []; data.forEach((d, i) => { if (d[key] != null) pts.push(`${pts.length === 0 ? 'M' : 'L'}${xFor(i).toFixed(1)},${scale(d[key]).toFixed(1)}`); }); return pts.join(' '); }; // Build one filled polygon per render group with an assigned palette color. // - 'pair' → polygon from lower_N up to upper_N (full N% interval). // - 'upper' → half-band from forecast line up to upper_N. // - 'lower' → half-band from forecast line down to lower_N. // Empty paths (no data on any point) are dropped before render. const bandPaths = React.useMemo(() => { if (!series.forecast) return []; const groups = groupBandSelection(selection); return groups.map((g, i) => { const color = BAND_PALETTE[i % BAND_PALETTE.length]; const top = [], bot = []; const upperKey = `upper_${g.level}`; const lowerKey = `lower_${g.level}`; data.forEach((d, idx) => { const f = d.forecast; const u = d.bands && d.bands[upperKey]; const l = d.bands && d.bands[lowerKey]; if (g.kind === 'pair') { if (u != null && l != null) { top.push(`${top.length === 0 ? 'M' : 'L'}${xFor(idx).toFixed(1)},${yPrimary(u).toFixed(1)}`); bot.push(`L${xFor(idx).toFixed(1)},${yPrimary(l).toFixed(1)}`); } } else if (g.kind === 'upper') { if (u != null && f != null) { top.push(`${top.length === 0 ? 'M' : 'L'}${xFor(idx).toFixed(1)},${yPrimary(u).toFixed(1)}`); bot.push(`L${xFor(idx).toFixed(1)},${yPrimary(f).toFixed(1)}`); } } else { if (l != null && f != null) { top.push(`${top.length === 0 ? 'M' : 'L'}${xFor(idx).toFixed(1)},${yPrimary(f).toFixed(1)}`); bot.push(`L${xFor(idx).toFixed(1)},${yPrimary(l).toFixed(1)}`); } } }); if (top.length === 0) return null; return { d: top.join(' ') + ' ' + bot.reverse().join(' ') + ' Z', color, label: g.label, }; }).filter(Boolean); }, [data, selection.join('|'), series.forecast, primaryMax]); // Y ticks const yTicks = 5; const ticks = Array.from({ length: yTicks + 1 }, (_, i) => i * (primaryMax / yTicks)); const ticksSec = Array.from({ length: yTicks + 1 }, (_, i) => i * (secondaryMax / yTicks)); // X ticks — show every Nth date const xTickEvery = Math.max(1, Math.ceil(data.length / 8)); // run_date marker index (the "TODAY" badge on the chart) const run_date_idx = data.findIndex(d => d.is_run_date); // Festival markers const festByDate = React.useMemo(() => { const m = {}; festivals.forEach(f => m[f.date] = f.name); return m; }, [festivals]); const handleMouseMove = (e) => { const rect = svgRef.current.getBoundingClientRect(); const x = ((e.clientX - rect.left) / rect.width) * w; const rel = (x - padL) / innerW; const idx = Math.round(rel * (data.length - 1)); if (idx >= 0 && idx < data.length) { setHoverIdx(idx); onHoverChange && onHoverChange(idx); } }; const handleMouseLeave = () => { setHoverIdx(null); onHoverChange && onHoverChange(null); }; const fmtShort = (dStr) => { const d = new Date(dStr); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }; return (