// 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 (
{/* Grid */} {ticks.map((t, i) => ( ))} {/* run_date divider — badge auto-flips to whichever side has room. The right axis ("INVENTORY" label + tick values) starts at w-padR+8, so a centered badge at the rightmost x position would overlap it. */} {run_date_idx >= 0 && (() => { const tx = xFor(run_date_idx); const flipLeft = tx > w - padR - 22; // not enough room on the right const flipRight = tx < padL + 22; // not enough room on the left // Default: centered. Otherwise anchor entirely on the side that fits. let rectX, textX, textAnchor; if (flipLeft) { rectX = tx - 38; textX = tx - 4; textAnchor = 'end'; } else if (flipRight) { rectX = tx + 2; textX = tx + 4; textAnchor = 'start'; } else { rectX = tx - 18; textX = tx; textAnchor = 'middle'; } return ( TODAY ); })()} {/* Festival vertical markers */} {festivals.map((f, i) => { const idx = data.findIndex(d => d.date === f.date); if (idx < 0) return null; return ( ); })} {/* Confidence shades — one per render group, colored from palette */} {bandPaths.map((p, i) => ( ))} {/* Series */} {series.forecast && ( !d.isPast) ? "0" : "0"} /> )} {series.actual && ( )} {series.requisitions && ( )} {series.inventory && ( )} {/* Hover crosshair */} {hoverIdx != null && ( )} {/* Hover dots */} {hoverIdx != null && ( {series.forecast && data[hoverIdx].forecast != null && ( )} {series.actual && data[hoverIdx].actual != null && ( )} {series.requisitions && data[hoverIdx].requisitions != null && ( )} {series.inventory && data[hoverIdx].inventory != null && ( )} )} {/* Y axis primary (left) */} {ticks.map((t, i) => ( {Math.round(t)} ))} {/* Y axis secondary (right, inventory) */} {ticksSec.map((t, i) => ( {Math.round(t)} ))} UNITS INVENTORY {/* X axis — month-day on every shown tick; year on the first tick and whenever the year changes vs. the previous shown tick. Keeps labels uncluttered while still visually separating year boundaries. */} {(() => { let lastShownYear = null; return data.map((d, i) => { if (i % xTickEvery !== 0 && i !== data.length - 1) return null; const yr = new Date(d.date).getFullYear(); const showYear = yr !== lastShownYear; lastShownYear = yr; return ( {fmtShort(d.date)} {showYear && ( {yr} )} ); }); })()} {/* Tooltip */} {hoverIdx != null && ( )} {/* Legend */}
{series.forecast && } {series.actual && } {series.requisitions && } {series.inventory && } {bandPaths.map((p, i) => ( ))} {festivals.length > 0 && ( Festival )}
); }; const LegendDot = ({ color, label, faded }) => ( {label} ); const ChartTooltip = ({ d, series, selection, festName, xPct }) => { const date = new Date(d.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }); const left = xPct > 70 ? 'auto' : `${xPct}%`; const right = xPct > 70 ? `${100 - xPct}%` : 'auto'; // Build a band sub-label that lists each currently-selected bound that // actually has a value at this date. Order matches selection order. const bandParts = (selection || []) .map(k => { const v = d.bands && d.bands[k]; if (v == null) return null; const m = /^(upper|lower)_(\d+)$/.exec(k); const arrow = m && m[1] === 'upper' ? '↑' : '↓'; const lvl = m ? `${m[2]}%` : k; return `${arrow}${lvl} ${v}`; }) .filter(Boolean); const bandSub = bandParts.length ? bandParts.join(' · ') : null; return (
70 ? 'none' : 'translateX(-50%)', minWidth: 180, zIndex: 5 }} >
{date}
{festName && (
{festName}
)}
{series.forecast && d.forecast != null && ( )} {series.actual && d.actual != null && ( )} {series.requisitions && d.requisitions != null && ( )} {series.inventory && d.inventory != null && ( )}
); }; const TooltipRow = ({ color, label, value, sub }) => (
{label} {value.toLocaleString()} {sub && ({sub})}
); window.Chart = Chart;