conState = ({i: 0})
conTick = { while (true) { yield conState.i; if (!playCon || mode !== "▶ Replay held-out days") return; conState.i = (conState.i + 1) % held_days.length; await Promises.delay(2200); } }
featuresFor = (day, h) => {
const w = day.dow >= 5 ? 1 : 0, tz = (day.temp - 20) / 10, hot = Math.max(tz, 0), t = (day.t0 + (h - 7)) / (365.25 * 16);
const f = {};
for (let hh = 7; hh < 23; hh++) for (const ww of [0, 1]) f[`hw_${hh}_${ww}`] = (h === hh && w === ww) ? 1 : 0;
for (let k = 1; k <= 2; k++) { f[`yr_s${k}`] = Math.sin(2 * Math.PI * k * day.doy / 365.25); f[`yr_c${k}`] = Math.cos(2 * Math.PI * k * day.doy / 365.25); }
Object.assign(f, {dow_fri: +(day.dow === 4), dow_sat: +(day.dow === 5), dow_sun: +(day.dow === 6), trend: t, temp: tz, temp2: tz * tz,
rain: day.rain, rain_eve: day.rain * (h >= 16), promo: day.promo, promo_we: day.promo * w, promo_eve: day.promo * (h >= 17),
heat_aft: hot * (h >= 13 && h <= 16), heat_eve: hot * (h >= 19), event_pm: day.event * (h >= 16 && h <= 19), event_late: day.event * (h >= 20),
payday: day.payday, holiday: day.holiday, prehol_pm: day.pre_holiday * (h >= 14), comp_wd: day.competitor * (day.dow < 5)});
return model_json.features.map(n => f[n]);
}
forecastDay = day => {
const b = model_json.parameters.beta, [p1, p16] = model_json.parameters.phi, s2 = model_json.parameters.sigma ** 2;
let eta = day.e_prev[15], v = 0;
return d3.range(7, 23).map((h, j) => {
const x = featuresFor(day, h), mu = d3.sum(x, (xi, i) => xi * b[i]);
eta = p1 * eta + p16 * day.e_prev[j]; v = p1 * p1 * v + s2; const s = Math.sqrt(v), m = mu + eta;
return {hour: h, mean: Math.expm1(m + v / 2), p10: Math.expm1(m - 1.2816 * s), p80: Math.expm1(m + 0.8416 * s), p90: Math.expm1(m + 1.2816 * s),
actual: day.actual ? day.actual[j] : null};
});
}
conDay = {
if (mode === "▶ Replay held-out days") return held_days[conTick];
const ref = held_days[0];
return {date: "What-if scenario", dow: wi.dow, doy: ref.doy, t0: ref.t0, temp: wi.temp, rain: +wi.rain, promo: +wi.promo, event: +wi.event,
payday: +wi.payday, holiday: +wi.holiday, pre_holiday: +wi.pre, competitor: 1, e_prev: Array(16).fill(0), actual: null};
}
{
const fc = forecastDay(conDay), hasA = conDay.actual !== null;
const err = hasA ? fc.map(d => d.mean - d.actual) : [];
const mae = hasA ? d3.mean(err, Math.abs) : null, bias = hasA ? d3.mean(err) : null;
const cov = hasA ? d3.mean(fc, d => +(d.actual >= d.p10 && d.actual <= d.p90)) : null;
const plan = fc.map(d => ({hour: d.hour, plan: needC(d.p80, 4, 3), need: hasA ? needC(d.actual, 4, 3) : null}));
const under = hasA ? d3.sum(plan, d => +(d.plan < d.need)) : null;
const chip = (k, v, on) => htl.html`<span style="display:inline-flex;gap:6px;align-items:center;padding:4px 10px;border-radius:999px;font-size:.82rem;font-weight:600;border:1px solid rgba(127,127,127,.3);background:${on ? "rgba(99,102,241,.14)" : "transparent"};opacity:${on ? 1 : .55};transition:all .4s">${k} ${v}</span>`;
const dname = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"][conDay.dow];
return htl.html`<div style="border:1px solid rgba(127,127,127,.25);border-radius:16px;padding:14px 16px">
<div style="display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:8px">
<b style="font-size:1.05rem">📆 ${conDay.date}</b>
<span style="font-size:.8rem;opacity:.7">model ${model_json.model} v${model_json.version} · checksum ${model_json.checksum.slice(0, 10)}…</span>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin:10px 0">
${chip("📅", dname, true)}${chip("🌡️", conDay.temp + "°C", true)}${chip("🌧️", conDay.rain ? "rain" : "dry", conDay.rain)}${chip("🏷️", "promo", conDay.promo)}
${chip("🏟️", "event", conDay.event)}${chip("💵", "payday", conDay.payday)}${chip("🎄", "holiday", conDay.holiday)}${chip("🛍️", "pre-holiday", conDay.pre_holiday)}
</div>
${Plot.plot({...msStyle, height: 280, width: 760, marginLeft: 50,
x: {label: "Hour →", domain: [7, 22], tickFormat: d => d + ":00"}, y: {label: "↑ Customers per hour", domain: [0, 160], grid: true},
marks: [Plot.areaY(fc, {x: "hour", y1: "p10", y2: "p90", fill: ms.indigo, fillOpacity: 0.16, curve: "monotone-x", title: d => `${d.hour}:00\n80% interval: ${d.p10.toFixed(1)}–${d.p90.toFixed(1)}`}),
Plot.lineY(fc, {x: "hour", y: "mean", stroke: ms.indigo, strokeWidth: 2.8, curve: "monotone-x", title: d => `${d.hour}:00\nMean forecast: ${d.mean.toFixed(1)}`}),
Plot.lineY(fc, {x: "hour", y: "p80", stroke: ms.violet, strokeDasharray: "5,4", curve: "monotone-x", title: d => `${d.hour}:00\nP80 staffing forecast: ${d.p80.toFixed(1)}`}),
hasA ? Plot.dot(fc, {x: "hour", y: "actual", r: 4.5, fill: d => (d.actual >= d.p10 && d.actual <= d.p90) ? ms.teal : ms.rose, stroke: "white", title: d => `${d.hour}:00\nActual: ${d.actual}\nForecast: ${d.mean.toFixed(1)}\nError: ${(d.mean-d.actual).toFixed(1)}`}) : null,
hasA ? Plot.ruleX(fc, {x: "hour", y1: "mean", y2: "actual", stroke: ms.rose, strokeOpacity: 0.5}) : null]})}
<div style="font-size:.8rem;opacity:.75">Band: 80% interval (P10–P90) · solid: mean · dashed: P80 used for staffing ${hasA ? "· dots: actual (teal inside band, rose outside)" : ""}</div>
${Plot.plot({...msStyle, height: 170, width: 760, marginLeft: 50,
x: {label: null, domain: d3.range(7, 23), tickFormat: d => d + ":00"}, y: {label: "↑ Cashiers", grid: true},
marks: [Plot.barY(plan, {x: "hour", y: "plan", fill: ms.teal, fillOpacity: 0.8, rx: 3, inset: 3, title: d => `${d.hour}:00\nScheduled cashiers: ${d.plan}${d.need !== null ? `\nActual need: ${d.need}` : ""}`}),
hasA ? Plot.tickY(plan, {x: "hour", y: "need", stroke: ms.rose, strokeWidth: 3}) : null]})}
<div style="font-size:.8rem;opacity:.75">Bars: cashiers scheduled from the P80 forecast ${hasA ? "· red ticks: cashiers the actual traffic needed" : ""}</div>
${hasA ? htl.html`<div class="ms-readout"><span>MAE</span><b>${mae.toFixed(1)}</b><span>Bias</span><b>${bias > 0 ? "+" : ""}${bias.toFixed(1)}</b><span>In 80% band</span><b>${Math.round(cov * 100)}%</b><span>Understaffed hours</span><b>${under} / 16</b></div>`
: htl.html`<div class="ms-readout"><span>Customers expected</span><b>${Math.round(d3.sum(fc, d => d.mean))}</b><span>Peak hour</span><b>${fc.reduce((a, b) => b.mean > a.mean ? b : a).hour}:00</b><span>Cashier-hours</span><b>${d3.sum(plan, d => d.plan)}</b></div>`}
</div>`;
}