Initial commit
This commit is contained in:
+269
-29
@@ -9,11 +9,22 @@ import { initPrint } from "./print.js";
|
||||
|
||||
const FIELD_ID_SET = new Set(FIELD_IDS);
|
||||
const STORAGE_KEY = "roastPlannerPlan.v1";
|
||||
const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section
|
||||
|
||||
export const state = { plan: loadFromStorage() ?? blankPlan() };
|
||||
|
||||
const form = document.getElementById("plan-form");
|
||||
|
||||
// The print worksheet (#print-sheet) sits outside #plan-form on purpose — see index.html —
|
||||
// so its radios don't fight the screen form's identically-named radios for exclusivity.
|
||||
// Every sync pass therefore has to reach both containers explicitly.
|
||||
function allNamedInputs() {
|
||||
return document.querySelectorAll(
|
||||
"#plan-form input, #plan-form select, #plan-form textarea, " +
|
||||
"#print-sheet input, #print-sheet select, #print-sheet textarea",
|
||||
);
|
||||
}
|
||||
|
||||
// ---- nested path get/set for names like "temps.charge.tempC" or "blendComponents.0.cultivar"
|
||||
function getPath(obj, path) {
|
||||
return path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj);
|
||||
@@ -43,7 +54,10 @@ function setValueForName(name, value) {
|
||||
}
|
||||
|
||||
// ---- dynamic rows: blend components + actuator schedule
|
||||
function renderBlendRows() {
|
||||
// Each renders into TWO places that share `name` attributes: the interactive screen
|
||||
// component, and the hidden print-only worksheet table (#blend-rows / #actuator-rows),
|
||||
// which is kept in sync by renderFormFromPlan() and only needs to look right on paper.
|
||||
function renderBlendPrintRows() {
|
||||
const tbody = document.getElementById("blend-rows");
|
||||
tbody.innerHTML = "";
|
||||
state.plan.blendComponents.forEach((_, i) => {
|
||||
@@ -59,7 +73,56 @@ function renderBlendRows() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderActuatorRows() {
|
||||
function renderBlendCards() {
|
||||
const wrap = document.getElementById("blend-cards");
|
||||
wrap.innerHTML = "";
|
||||
state.plan.blendComponents.forEach((_, i) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "blend-card";
|
||||
card.innerHTML = `
|
||||
<label class="field"><span class="field-label">Cultivar</span><input class="field-input" name="blendComponents.${i}.cultivar" placeholder="e.g. Caturra"></label>
|
||||
<label class="field"><span class="field-label">Group</span><input class="field-input" name="blendComponents.${i}.group"></label>
|
||||
<label class="field"><span class="field-label">Process</span><input class="field-input" name="blendComponents.${i}.process"></label>
|
||||
<label class="field"><span class="field-label">Share %</span><input class="field-input sm" name="blendComponents.${i}.sharePct"></label>
|
||||
<label class="field"><span class="field-label">FC anchor</span><input class="field-input sm" name="blendComponents.${i}.fcAnchor"></label>
|
||||
<button type="button" class="blend-remove" data-remove-blend="${i}" aria-label="Remove component" ${state.plan.blendComponents.length <= 1 ? "disabled" : ""}>✕</button>
|
||||
`;
|
||||
wrap.appendChild(card);
|
||||
});
|
||||
for (const btn of wrap.querySelectorAll("[data-remove-blend]")) {
|
||||
btn.addEventListener("click", () => {
|
||||
const i = Number(btn.dataset.removeBlend);
|
||||
if (state.plan.blendComponents.length <= 1) return;
|
||||
state.plan.blendComponents.splice(i, 1);
|
||||
renderBlend();
|
||||
renderFormFromPlan();
|
||||
recompute();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateBlendTotal() {
|
||||
const total = state.plan.blendComponents.reduce((sum, c) => sum + (Number.parseFloat(c.sharePct) || 0), 0);
|
||||
const fill = document.getElementById("blend-total-fill");
|
||||
const label = document.getElementById("blend-total-label");
|
||||
if (!fill || !label) return;
|
||||
fill.style.width = `${Math.min(100, total)}%`;
|
||||
fill.classList.toggle("over", total > 100);
|
||||
fill.classList.toggle("under", total > 0 && total < 100);
|
||||
label.textContent = `${Math.round(total * 10) / 10}% of 100%`;
|
||||
}
|
||||
|
||||
function updateBlendVisibility(mode) {
|
||||
document.getElementById("blend-body").classList.toggle("collapsed", mode !== "blend");
|
||||
}
|
||||
|
||||
function renderBlend() {
|
||||
renderBlendPrintRows();
|
||||
renderBlendCards();
|
||||
updateBlendTotal();
|
||||
}
|
||||
|
||||
function renderActuatorPrintRows() {
|
||||
const tbody = document.getElementById("actuator-rows");
|
||||
tbody.innerHTML = "";
|
||||
state.plan.actuators.forEach((_, i) => {
|
||||
@@ -75,9 +138,45 @@ function renderActuatorRows() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderActuatorTimeline() {
|
||||
const wrap = document.getElementById("actuator-timeline");
|
||||
wrap.innerHTML = "";
|
||||
state.plan.actuators.forEach((_, i) => {
|
||||
const step = document.createElement("div");
|
||||
step.className = "actuator-step";
|
||||
step.innerHTML = `
|
||||
<div class="actuator-rail"><div class="actuator-dot"></div><div class="actuator-line"></div></div>
|
||||
<div class="actuator-card">
|
||||
<label class="field"><span class="field-label">Time</span><input class="field-input sm" name="actuators.${i}.time" placeholder="m:ss"></label>
|
||||
<label class="field"><span class="field-label">Heat %</span><input class="field-input sm" name="actuators.${i}.heatPct"></label>
|
||||
<label class="field"><span class="field-label">Fan %</span><input class="field-input sm" name="actuators.${i}.fanPct"></label>
|
||||
<button type="button" class="actuator-remove" data-remove-actuator="${i}" aria-label="Remove step" ${state.plan.actuators.length <= 1 ? "disabled" : ""}>✕</button>
|
||||
<label class="field"><span class="field-label">Expected BT</span><input class="field-input sm" name="actuators.${i}.expectedBt"></label>
|
||||
<label class="field actuator-why"><span class="field-label">Why this change</span><input class="field-input" name="actuators.${i}.why" placeholder="What you're watching for"></label>
|
||||
</div>
|
||||
`;
|
||||
wrap.appendChild(step);
|
||||
});
|
||||
for (const btn of wrap.querySelectorAll("[data-remove-actuator]")) {
|
||||
btn.addEventListener("click", () => {
|
||||
const i = Number(btn.dataset.removeActuator);
|
||||
if (state.plan.actuators.length <= 1) return;
|
||||
state.plan.actuators.splice(i, 1);
|
||||
renderActuators();
|
||||
renderFormFromPlan();
|
||||
recompute();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderActuators() {
|
||||
renderActuatorPrintRows();
|
||||
renderActuatorTimeline();
|
||||
}
|
||||
|
||||
// ---- populate every control in the form from state.plan
|
||||
export function renderFormFromPlan() {
|
||||
for (const el of form.querySelectorAll("input, select, textarea")) {
|
||||
for (const el of allNamedInputs()) {
|
||||
const name = el.name;
|
||||
if (!name) continue;
|
||||
const value = valueForName(name);
|
||||
@@ -89,11 +188,12 @@ export function renderFormFromPlan() {
|
||||
el.value = value ?? "";
|
||||
}
|
||||
}
|
||||
updateBlendVisibility(state.plan.fields["2.1"]);
|
||||
document.getElementById("header-coffee-name").textContent = state.plan.fields["0.1"] || "New plan";
|
||||
}
|
||||
|
||||
function fmtOut(id, text) {
|
||||
const el = document.querySelector(`[data-out="${id}"]`);
|
||||
if (el) el.textContent = text ?? "—";
|
||||
for (const el of document.querySelectorAll(`[data-out="${id}"]`)) el.textContent = text ?? "—";
|
||||
}
|
||||
|
||||
function renderLedger() {
|
||||
@@ -120,10 +220,10 @@ function renderLedger() {
|
||||
fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS));
|
||||
|
||||
for (const [key, check] of Object.entries(ledger.checks)) {
|
||||
const cell = document.querySelector(`[data-pass="${key}"]`);
|
||||
if (!cell) continue;
|
||||
cell.classList.remove("pass", "fail", "unknown");
|
||||
cell.classList.add(check.pass === null ? "unknown" : check.pass ? "pass" : "fail");
|
||||
for (const cell of document.querySelectorAll(`[data-pass="${key}"]`)) {
|
||||
cell.classList.remove("pass", "fail", "unknown");
|
||||
cell.classList.add(check.pass === null ? "unknown" : check.pass ? "pass" : "fail");
|
||||
}
|
||||
}
|
||||
|
||||
// Box 8 / box 11 derived time columns
|
||||
@@ -138,33 +238,67 @@ function renderLedger() {
|
||||
fmtOut("pa-drop", d(ledger.D));
|
||||
|
||||
document.getElementById("back-coffee-name").textContent = state.plan.fields["0.1"] || "";
|
||||
document.getElementById("header-coffee-name").textContent = state.plan.fields["0.1"] || "New plan";
|
||||
|
||||
return ledger;
|
||||
}
|
||||
|
||||
function renderCurve(ledger) {
|
||||
const planPoints = buildPlanCurve(state.plan, ledger);
|
||||
const planGroup = document.getElementById("plan-curve");
|
||||
function domainPct(v) {
|
||||
const [lo, hi] = BAND_DOMAIN;
|
||||
return Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
|
||||
}
|
||||
|
||||
function renderBandRanges() {
|
||||
for (const row of document.querySelectorAll(".band-row")) {
|
||||
const lo = Number(row.dataset.bandLo);
|
||||
const hi = Number(row.dataset.bandHi);
|
||||
const range = row.querySelector(".band-range");
|
||||
if (!range) continue;
|
||||
const loPct = domainPct(lo);
|
||||
range.style.left = `${loPct}%`;
|
||||
range.style.width = `${domainPct(hi) - loPct}%`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBandMarkers() {
|
||||
for (const row of document.querySelectorAll(".band-row")) {
|
||||
const input = row.querySelector('input[name$=".tempC"]');
|
||||
const marker = row.querySelector(".band-marker");
|
||||
if (!input || !marker) continue;
|
||||
const v = Number.parseFloat(input.value);
|
||||
if (!Number.isFinite(v)) {
|
||||
marker.style.display = "none";
|
||||
continue;
|
||||
}
|
||||
const lo = Number(row.dataset.bandLo);
|
||||
const hi = Number(row.dataset.bandHi);
|
||||
marker.style.display = "block";
|
||||
marker.style.left = `${domainPct(v)}%`;
|
||||
marker.classList.toggle("out-of-band", v < lo || v > hi);
|
||||
}
|
||||
}
|
||||
|
||||
function paintCurveInto(planGroup, refGroup, planPoints, ref) {
|
||||
planGroup.innerHTML = "";
|
||||
if (planPoints.length > 0) {
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("d", pointsToPathD(planPoints));
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke", "#1a1512");
|
||||
path.setAttribute("stroke", "currentColor");
|
||||
path.setAttribute("stroke-width", "1.6");
|
||||
path.style.color = "var(--ink, #1a1512)";
|
||||
planGroup.appendChild(path);
|
||||
for (const p of planPoints) {
|
||||
const c = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||
c.setAttribute("cx", tToX(p.timeS).toFixed(1));
|
||||
c.setAttribute("cy", tempToY(p.tempC).toFixed(1));
|
||||
c.setAttribute("r", "3.2");
|
||||
c.setAttribute("fill", "#1a1512");
|
||||
c.setAttribute("fill", "currentColor");
|
||||
c.style.color = "var(--ink, #1a1512)";
|
||||
planGroup.appendChild(c);
|
||||
}
|
||||
}
|
||||
|
||||
const ref = state.plan.reference;
|
||||
const refGroup = document.getElementById("ref-curve");
|
||||
refGroup.innerHTML = "";
|
||||
if (ref?.curve?.length) {
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
@@ -173,10 +307,11 @@ function renderCurve(ledger) {
|
||||
.join(" ");
|
||||
path.setAttribute("d", d);
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke", "#5a5048");
|
||||
path.setAttribute("stroke", "currentColor");
|
||||
path.setAttribute("stroke-width", "1.3");
|
||||
path.setAttribute("stroke-dasharray", "4 3");
|
||||
path.setAttribute("opacity", ".65");
|
||||
path.style.color = "var(--ink-2, #5a5048)";
|
||||
refGroup.appendChild(path);
|
||||
for (const m of ref.milestones ?? []) {
|
||||
if (m.tempC === null) continue;
|
||||
@@ -185,27 +320,64 @@ function renderCurve(ledger) {
|
||||
c.setAttribute("cy", tempToY(m.tempC).toFixed(1));
|
||||
c.setAttribute("r", "2.6");
|
||||
c.setAttribute("fill", "none");
|
||||
c.setAttribute("stroke", "#5a5048");
|
||||
c.setAttribute("stroke", "currentColor");
|
||||
c.setAttribute("stroke-width", "1.2");
|
||||
c.style.color = "var(--ink-2, #5a5048)";
|
||||
refGroup.appendChild(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderCurve(ledger) {
|
||||
const planPoints = buildPlanCurve(state.plan, ledger);
|
||||
const ref = state.plan.reference;
|
||||
|
||||
paintCurveInto(
|
||||
document.getElementById("plan-curve"),
|
||||
document.getElementById("ref-curve"),
|
||||
planPoints,
|
||||
ref,
|
||||
);
|
||||
paintCurveInto(
|
||||
document.getElementById("plan-curve-live"),
|
||||
document.getElementById("ref-curve-live"),
|
||||
planPoints,
|
||||
ref,
|
||||
);
|
||||
}
|
||||
|
||||
export function recompute() {
|
||||
const ledger = renderLedger();
|
||||
renderCurve(ledger);
|
||||
renderBandMarkers();
|
||||
autosave();
|
||||
}
|
||||
|
||||
let autosaveAgeTimer = null;
|
||||
function autosave() {
|
||||
const chip = document.getElementById("autosave-status");
|
||||
const text = chip.querySelector(".autosave-text");
|
||||
chip.classList.remove("saved");
|
||||
chip.classList.add("saving");
|
||||
text.textContent = "Saving…";
|
||||
|
||||
clearTimeout(autosave._t);
|
||||
autosave._t = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.plan));
|
||||
document.getElementById("autosave-status").textContent = `saved ${new Date().toLocaleTimeString()}`;
|
||||
const savedAt = Date.now();
|
||||
chip.classList.remove("saving");
|
||||
chip.classList.add("saved");
|
||||
const tick = () => {
|
||||
const secs = Math.round((Date.now() - savedAt) / 1000);
|
||||
text.textContent = secs < 8 ? "Saved just now" : secs < 60 ? `Saved ${secs}s ago` : `Saved ${Math.round(secs / 60)}m ago`;
|
||||
};
|
||||
tick();
|
||||
clearInterval(autosaveAgeTimer);
|
||||
autosaveAgeTimer = setInterval(tick, 5000);
|
||||
} catch {
|
||||
/* storage unavailable — non-fatal */
|
||||
chip.classList.remove("saving", "saved");
|
||||
text.textContent = "Save unavailable";
|
||||
}
|
||||
}, 400);
|
||||
}
|
||||
@@ -241,17 +413,49 @@ function wireForm() {
|
||||
if (el.type === "radio" && !el.checked) return;
|
||||
setValueForName(el.name, el.value);
|
||||
if (el.name === "1.1") cultivarAutofill(el.value);
|
||||
if (el.name === "2.1") updateBlendVisibility(el.value);
|
||||
if (el.name.startsWith("blendComponents.")) updateBlendTotal();
|
||||
recompute();
|
||||
});
|
||||
}
|
||||
|
||||
function wireToolbar() {
|
||||
function wireDrawers() {
|
||||
const overlay = document.getElementById("drawer-overlay");
|
||||
const prefill = document.getElementById("panel-prefill");
|
||||
const alog = document.getElementById("panel-alog");
|
||||
|
||||
function open(panel) {
|
||||
for (const p of [prefill, alog]) p.classList.add("hidden");
|
||||
panel.classList.remove("hidden");
|
||||
panel.setAttribute("aria-hidden", "false");
|
||||
overlay.classList.remove("hidden");
|
||||
}
|
||||
function closeAll() {
|
||||
for (const p of [prefill, alog]) {
|
||||
p.classList.add("hidden");
|
||||
p.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
overlay.classList.add("hidden");
|
||||
}
|
||||
|
||||
document.getElementById("btn-toggle-prefill").addEventListener("click", () => {
|
||||
document.getElementById("panel-prefill").classList.toggle("hidden");
|
||||
prefill.classList.contains("hidden") ? open(prefill) : closeAll();
|
||||
});
|
||||
document.getElementById("btn-toggle-alog").addEventListener("click", () => {
|
||||
document.getElementById("panel-alog").classList.toggle("hidden");
|
||||
alog.classList.contains("hidden") ? open(alog) : closeAll();
|
||||
});
|
||||
overlay.addEventListener("click", closeAll);
|
||||
for (const btn of document.querySelectorAll("[data-close-drawer]")) {
|
||||
btn.addEventListener("click", closeAll);
|
||||
}
|
||||
}
|
||||
|
||||
function wireToolbar() {
|
||||
document.getElementById("btn-toggle-fids").addEventListener("click", (e) => {
|
||||
const on = document.body.classList.toggle("show-fids");
|
||||
e.currentTarget.setAttribute("aria-pressed", String(on));
|
||||
});
|
||||
|
||||
document.getElementById("btn-save").addEventListener("click", () => {
|
||||
const blob = new Blob([JSON.stringify(state.plan, null, 2)], { type: "application/json" });
|
||||
const a = document.createElement("a");
|
||||
@@ -267,26 +471,62 @@ function wireToolbar() {
|
||||
try {
|
||||
const loaded = JSON.parse(await file.text());
|
||||
state.plan = { ...blankPlan(), ...loaded };
|
||||
renderBlendRows();
|
||||
renderActuatorRows();
|
||||
renderBlend();
|
||||
renderActuators();
|
||||
renderFormFromPlan();
|
||||
recompute();
|
||||
} catch (err) {
|
||||
alert(`Could not load plan: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btn-add-blend").addEventListener("click", () => {
|
||||
state.plan.blendComponents.push({ cultivar: "", group: "", process: "", sharePct: "", fcAnchor: "" });
|
||||
renderBlend();
|
||||
renderFormFromPlan();
|
||||
recompute();
|
||||
});
|
||||
document.getElementById("btn-add-actuator").addEventListener("click", () => {
|
||||
state.plan.actuators.push({ time: "", heatPct: "", fanPct: "", expectedBt: "", why: "" });
|
||||
renderActuators();
|
||||
renderFormFromPlan();
|
||||
recompute();
|
||||
});
|
||||
}
|
||||
|
||||
function wireSectionNav() {
|
||||
const links = [...document.querySelectorAll(".section-nav a")];
|
||||
const sections = links
|
||||
.map((a) => document.querySelector(a.getAttribute("href")))
|
||||
.filter(Boolean);
|
||||
if (sections.length === 0) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
const id = `#${entry.target.id}`;
|
||||
for (const a of links) a.classList.toggle("active", a.getAttribute("href") === id);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "-20% 0px -70% 0px" },
|
||||
);
|
||||
for (const s of sections) observer.observe(s);
|
||||
}
|
||||
|
||||
function init() {
|
||||
renderBlendRows();
|
||||
renderActuatorRows();
|
||||
renderBlend();
|
||||
renderActuators();
|
||||
wireCultivarDatalist();
|
||||
renderBandRanges();
|
||||
renderFormFromPlan();
|
||||
wireForm();
|
||||
wireDrawers();
|
||||
wireToolbar();
|
||||
initPrefillPanel({ state, renderBlendRows, renderActuatorRows, renderFormFromPlan, recompute });
|
||||
wireSectionNav();
|
||||
initPrefillPanel({ state, renderBlendRows: renderBlend, renderActuatorRows: renderActuators, renderFormFromPlan, recompute });
|
||||
initAlogPanel({ state, recompute });
|
||||
initPrint({ form });
|
||||
initPrint({ beforePrint: renderFormFromPlan });
|
||||
recompute();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user