// Canonical temperatures remain Celsius internally; reports render Fahrenheit. import { parseDuration } from "./time.js?v=__ASSET_VERSION__"; export const PLOT = { x0: 52, x1: 652, y0: 24, y1: 294, tMaxS: 900 }; export const tToX = (s) => PLOT.x0 + (s / PLOT.tMaxS) * (PLOT.x1 - PLOT.x0); export const tempToY = (c) => PLOT.y1 - (c - 60) * 1.8; export const rorToY = (r) => PLOT.y1 - r * 9; const numeric = (v) => v != null && String(v).trim() !== "" && Number.isFinite(Number(v)) ? Number(v) : null; // No roaster selection, learned median, or override changes these illustrative points. export function buildPlanCurve(plan, ledger) { const t = plan.temps ?? {}; const fc = numeric(t.fc?.tempC) ?? 200; const yellow = numeric(t.yellow?.tempC) ?? 160; const maillardRate = ledger.maillard > 0 ? Math.max(0, (fc - yellow) / ledger.maillard) : 0; // Development's mean rise is lower than Maillard's, even for a short development. const drop = fc + maillardRate * (ledger.C ?? 0) * 0.65; return [ { key: "charge", label: "Charge", timeS: 0, tempC: numeric(t.charge?.tempC) ?? 200, }, { key: "tp", label: "Turning point", timeS: t.tp?.time ? parseDuration(t.tp.time) : 60, tempC: numeric(t.tp?.tempC) ?? 95, }, { key: "yellow", label: "Yellow / dry end", timeS: ledger.yellow, tempC: yellow, }, { key: "fc", label: "First crack", timeS: ledger.A, tempC: fc }, { key: "drop", label: "Drop", timeS: ledger.D, tempC: numeric(t.drop?.tempC) ?? drop, }, ] .filter((p) => Number.isFinite(p.timeS) && p.timeS >= 0) .map((p) => ({ ...p, illustrative: numeric(t[p.key]?.tempC) === null })); } // Shape-preserving cubic Hermite interpolation. At TP the slope is zero; on the // rising side harmonic slopes avoid overshoot and keep phase transitions smooth. export function curveSegments(points) { const p = [...points] .filter((p) => Number.isFinite(p.timeS) && Number.isFinite(p.tempC)) .sort((a, b) => a.timeS - b.timeS) .filter((p, i, a) => !i || p.timeS > a[i - 1].timeS); if (p.length < 2) return { points: p, segments: [] }; const d = p .slice(1) .map((b, i) => (b.tempC - p[i].tempC) / (b.timeS - p[i].timeS)); const m = p.map((_, i) => { if (!i) return d[0]; if (i === p.length - 1) return d.at(-1) * 0.75; if (d[i - 1] * d[i] <= 0) return 0; const h0 = p[i].timeS - p[i - 1].timeS, h1 = p[i + 1].timeS - p[i].timeS; return (3 * (h0 + h1)) / ((2 * h1 + h0) / d[i - 1] + (h1 + 2 * h0) / d[i]); }); return { points: p, segments: p.slice(1).map((b, i) => { const a = p[i], h = (b.timeS - a.timeS) / 3; return { a, b, c1: { timeS: a.timeS + h, tempC: a.tempC + m[i] * h }, c2: { timeS: b.timeS - h, tempC: b.tempC - m[i + 1] * h }, }; }), }; } export function pointsToPathD(points, x = tToX, y = tempToY) { const { points: p, segments } = curveSegments(points); const coord = (p) => `${x(p.timeS).toFixed(2)},${y(p.tempC).toFixed(2)}`; return p.length ? `M${coord(p[0])} ` + segments .map((s) => `C${coord(s.c1)} ${coord(s.c2)} ${coord(s.b)}`) .join(" ") : ""; } export function curveWarnings(points) { const byKey = Object.fromEntries(points.map((p) => [p.key, p])); const p = ["tp", "yellow", "fc", "drop"].map((k) => byKey[k]); if (p.some((v) => !v)) return []; if ( p.some( (v, i) => i && (v.timeS <= p[i - 1].timeS || v.tempC <= p[i - 1].tempC), ) ) return [ "Milestones must progress in time and temperature after turning point. Review your entries.", ]; const rates = p .slice(1) .map((b, i) => (b.tempC - p[i].tempC) / (b.timeS - p[i].timeS)); return rates.some((r, i) => i && r > rates[i - 1]) ? [ "Entered milestones imply an increasing phase-average rate of rise. Review temperatures and timing; smoothing cannot correct inconsistent targets.", ] : []; }