Test and deploy / test-and-deploy (push) Successful in 1m8s
The planner's Artisan .alog drawer now shows what's attached with a "Remove reference curve" control (re-rendered per drawer open), so adding a reference curve is no longer a one-way door; /api/alog shares the 8 MB body cap so real-sized logs parse instead of failing with "bad_request". Deep-evaluation fixes: editing a brew of an archived bean no longer silently detaches the bean; the roasts pending-review poll no longer wipes in-progress after-roast edits; roasters gain an Edit (rename/model) action; the gear page refuses to autosave over a failed load; duplicating a plan carries its custom name; cupping sessions can attach a plan after creation (ownership-checked PUT + selector); admin user deletion also refreshes plans/audit; cupping cup-count subtitle stays live; roasts error-row colspan corrected. Regression tests cover the new cupping PUT and the /api/alog body cap. Academy scenes drop the flat paper-cutout look: shared defs provide radial-gradient shading on every bean/half-bean/particle, flame gradients with radiant halos, soft ground shadows, and a warm-lit stage background; fill-shift animations now ride a partial-opacity tint overlay so shading survives the color change. Co-Authored-By: Claude Fable 5 <[email protected]>
1303 lines
62 KiB
JavaScript
1303 lines
62 KiB
JavaScript
import { BREW_SILHOUETTES, findBrewMethod } from "/shared/brew-data.js?v=__ASSET_VERSION__";
|
||
|
||
const NS = "http://www.w3.org/2000/svg";
|
||
|
||
// ── Layout discipline ──────────────────────────────────────────────────────
|
||
// Canvas is 560×300. Everything must live inside the safe area x∈[30,530] y∈[24,266];
|
||
// the single caption line sits on the BASELINE at y=286 and stays ≤ 78 characters.
|
||
// Every animation must depict the mechanism the narration is describing — no decoration.
|
||
|
||
const C = {
|
||
green: "#7a8c5e",
|
||
greenDark: "#5d6b45",
|
||
yellow: "#d9ba5a",
|
||
tan: "#b98d54",
|
||
brown: "#8a5a30",
|
||
dark: "#54371f",
|
||
ember: "#a8481a",
|
||
emberSoft: "#fbefe6",
|
||
ink: "#1b1614",
|
||
ink2: "#5b524b",
|
||
line: "#e4dcd2",
|
||
water: "#5f8cb0",
|
||
paper: "#fbf8f4",
|
||
pass: "#2f6f4e",
|
||
fail: "#b03a22",
|
||
};
|
||
|
||
function el(tag, attrs = {}, ...children) {
|
||
const node = document.createElementNS(NS, tag);
|
||
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
|
||
node.append(...children);
|
||
return node;
|
||
}
|
||
|
||
// ── Shading helpers ────────────────────────────────────────────────────────
|
||
// Flat fills read as paper cutouts; every solid body (beans, flames, liquids)
|
||
// instead gets a radial gradient built from its base color — warm-white toward
|
||
// the light, warm-black away from it — so the shapes model light like objects.
|
||
const hexToRgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
|
||
const mixHex = (hex, target, f) =>
|
||
`#${hexToRgb(hex)
|
||
.map((c, i) => Math.round(c + (target[i] - c) * f).toString(16).padStart(2, "0"))
|
||
.join("")}`;
|
||
const tint = (hex, f) => mixHex(hex, [255, 250, 240], f);
|
||
const shade = (hex, f) => mixHex(hex, [30, 18, 10], f);
|
||
|
||
let gradSeq = 0;
|
||
/** Per-instance radial gradient for a body color; returns [defNode, fillUrl].
|
||
* gradientUnits stay objectBoundingBox, so one def can shade many siblings. */
|
||
function radialFill(fill, opts = {}) {
|
||
const id = `ac-rg-${gradSeq++}`;
|
||
const node = el(
|
||
"radialGradient",
|
||
{ id, cx: opts.cx ?? "36%", cy: opts.cy ?? "30%", r: opts.r ?? "85%" },
|
||
el("stop", { offset: "0%", "stop-color": tint(fill, opts.tint ?? 0.3) }),
|
||
el("stop", { offset: "55%", "stop-color": fill }),
|
||
el("stop", { offset: "100%", "stop-color": shade(fill, opts.shade ?? 0.3) }),
|
||
);
|
||
return [node, `url(#${id})`];
|
||
}
|
||
|
||
/** Shared gradient/glow definitions every scene can reference by id.
|
||
* Appended once per slide render (the SVG is rebuilt each slide). */
|
||
export function sceneDefs() {
|
||
return el(
|
||
"defs",
|
||
{},
|
||
el(
|
||
"radialGradient",
|
||
{ id: "ac-ground", cx: "50%", cy: "50%", r: "50%" },
|
||
el("stop", { offset: "0%", "stop-color": "#1b1614", "stop-opacity": 0.16 }),
|
||
el("stop", { offset: "100%", "stop-color": "#1b1614", "stop-opacity": 0 }),
|
||
),
|
||
el(
|
||
"radialGradient",
|
||
{ id: "ac-halo", cx: "50%", cy: "50%", r: "50%" },
|
||
el("stop", { offset: "0%", "stop-color": "#e8892c", "stop-opacity": 0.38 }),
|
||
el("stop", { offset: "100%", "stop-color": "#e8892c", "stop-opacity": 0 }),
|
||
),
|
||
el(
|
||
"linearGradient",
|
||
{ id: "ac-flame-grad", x1: "0", y1: "0", x2: "0", y2: "1" },
|
||
el("stop", { offset: "0%", "stop-color": "#e8952f" }),
|
||
el("stop", { offset: "100%", "stop-color": "#9c3a10" }),
|
||
),
|
||
el(
|
||
"linearGradient",
|
||
{ id: "ac-flame-core", x1: "0", y1: "0", x2: "0", y2: "1" },
|
||
el("stop", { offset: "0%", "stop-color": "#f7e3a0" }),
|
||
el("stop", { offset: "100%", "stop-color": "#e0a83c" }),
|
||
),
|
||
);
|
||
}
|
||
const text = (x, y, str, size = 12.5, fill = C.ink2, anchor = "middle", weight = "400") =>
|
||
el("text", { x, y, fill, "font-size": size, "text-anchor": anchor, "font-weight": weight }, str);
|
||
const caption = (str, fill = C.ink2) => text(280, 286, str, 12.5, fill);
|
||
|
||
export const sceneFallbackText = (title) => text(280, 150, title, 16, C.ink2);
|
||
|
||
/** Coffee bean, side profile with centre cut, centred on (x,y). Height ≈ 72·scale.
|
||
* CRITICAL: the positional transform lives on the OUTER group and any animated class on an
|
||
* INNER group — a CSS transform animation on the same element would silently override the
|
||
* SVG transform attribute and throw the bean to the canvas origin. */
|
||
function bean(x, y, scale = 1, fill = C.green, opts = {}) {
|
||
const [grad, gradUrl] = radialFill(fill);
|
||
const inner = el("g", { class: opts.class ?? "" });
|
||
inner.append(el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill: gradUrl }));
|
||
// Fill-shift animations (drying, Maillard, …) animate a flat overlay riding at partial
|
||
// opacity ABOVE the gradient, so the color changes while the 3-D shading survives.
|
||
if (/ac-(dry-shift|maillard-shift|grass-fade)/.test(opts.class ?? ""))
|
||
inner.append(el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill, class: "ac-tint" }));
|
||
inner.append(
|
||
el("path", { d: "M0 -34 C 6 -16, 6 16, 0 34 C -3 16, -3 -16, 0 -34 Z", fill: "rgba(0,0,0,0.26)" }),
|
||
el("ellipse", { cx: -11, cy: -16, rx: 6.5, ry: 11, fill: "rgba(255,252,245,0.16)", transform: "rotate(-16 -11 -16)" }),
|
||
);
|
||
if (opts.crack)
|
||
inner.append(
|
||
el("path", {
|
||
d: "M-2 -30 L 5 -12 L -4 4 L 4 20 L -1 32",
|
||
fill: "none",
|
||
stroke: C.paper,
|
||
"stroke-width": 2.4,
|
||
class: "ac-crackline",
|
||
}),
|
||
);
|
||
const outer = el("g", { transform: `translate(${x} ${y}) scale(${scale})` });
|
||
if (opts.shadow !== false)
|
||
outer.append(el("ellipse", { cx: 0, cy: 45, rx: 24, ry: 6, fill: "url(#ac-ground)" }));
|
||
outer.append(grad, inner);
|
||
return outer;
|
||
}
|
||
|
||
function steamWisps(x, y, n = 3) {
|
||
const g = el("g");
|
||
for (let i = 0; i < n; i++)
|
||
g.append(
|
||
el("path", {
|
||
d: `M${x + (i - (n - 1) / 2) * 18} ${y} c -5 -10, 5 -16, 0 -26 c -4 -8, 4 -14, 0 -22`,
|
||
fill: "none",
|
||
stroke: C.ink2,
|
||
"stroke-width": 2.4,
|
||
"stroke-linecap": "round",
|
||
class: `ac-steam ac-delay-${i}`,
|
||
opacity: 0.5,
|
||
}),
|
||
);
|
||
return g;
|
||
}
|
||
|
||
function flame(x, y, scale = 1) {
|
||
// Same outer/inner split as bean(): position on the outer, animation class on the inner.
|
||
// A soft radial halo behind the flame reads as radiant heat.
|
||
return el("g", { transform: `translate(${x} ${y}) scale(${scale})` },
|
||
el("ellipse", { cx: 0, cy: -14, rx: 22, ry: 26, fill: "url(#ac-halo)" }),
|
||
el("g", { class: "ac-flame" },
|
||
el("path", { d: "M0 0 C 11 -12, 4 -22, 0 -32 C -4 -22, -11 -12, 0 0 Z", fill: "url(#ac-flame-grad)" }),
|
||
el("path", { d: "M0 -3 C 5 -10, 2 -15, 0 -21 C -2 -15, -5 -10, 0 -3 Z", fill: "url(#ac-flame-core)" }),
|
||
),
|
||
);
|
||
}
|
||
|
||
let halfBeanClipSeq = 0;
|
||
/** Cross-section "cut bean" — outer shell path + a clipped inner area that can hold cell
|
||
* texture, moisture dots, gradient fills, etc. Same outer/inner split as bean(): the
|
||
* positional transform lives on the OUTER group; opts.class (if any) animates the INNER group. */
|
||
function halfBean(x, y, scale = 1, opts = {}) {
|
||
const shapeD =
|
||
"M0,-58 C 28,-58 44,-32 44,0 C 44,32 28,58 0,58 C -28,58 -44,32 -44,0 C -44,-32 -28,-58 0,-58 Z";
|
||
const id = `hb-clip-${halfBeanClipSeq++}`;
|
||
const [grad, gradUrl] = radialFill(opts.shellFill ?? C.tan);
|
||
const shell = el("path", {
|
||
d: shapeD,
|
||
fill: gradUrl,
|
||
stroke: opts.stroke ?? C.dark,
|
||
"stroke-width": opts.strokeWidth ?? 3,
|
||
});
|
||
const clip = el("clipPath", { id }, el("path", { d: shapeD }));
|
||
const content = el("g", { "clip-path": `url(#${id})` }, ...(opts.children ?? []));
|
||
const inner = el("g", { class: opts.class ?? "" }, shell, content);
|
||
return el(
|
||
"g",
|
||
{ transform: `translate(${x} ${y}) scale(${scale})` },
|
||
el("ellipse", { cx: 0, cy: 66, rx: 44, ry: 8, fill: "url(#ac-ground)" }),
|
||
clip,
|
||
grad,
|
||
inner,
|
||
);
|
||
}
|
||
|
||
/** Small semi-circle dial gauge with a needle that sweeps toward the reading on a loop.
|
||
* Positional transform stays on the outer group; the needle's own transform is purely the
|
||
* rotation animation (no static transform competing on the same element), so no override risk. */
|
||
function gauge(x, y, label, valueLabel, opts = {}) {
|
||
const r = opts.r ?? 42;
|
||
const color = opts.color ?? C.ember;
|
||
const children = [
|
||
el("path", {
|
||
d: `M ${-r} 0 A ${r} ${r} 0 0 1 ${r} 0`,
|
||
fill: "none",
|
||
stroke: C.line,
|
||
"stroke-width": 8,
|
||
"stroke-linecap": "round",
|
||
}),
|
||
el("line", {
|
||
x1: 0, y1: 0, x2: 0, y2: -r + 10,
|
||
stroke: color, "stroke-width": 4, "stroke-linecap": "round",
|
||
class: opts.cls ?? "ac-gauge-needle",
|
||
"transform-origin": "0px 0px",
|
||
}),
|
||
el("circle", { cx: 0, cy: 0, r: 6, fill: color }),
|
||
valueLabel ? text(0, -16, valueLabel, 12.5, C.ink, "middle", "700") : null,
|
||
label ? text(0, r + 20, label, 11.5, C.ink2, "middle") : null,
|
||
].filter(Boolean);
|
||
return el("g", { transform: `translate(${x} ${y})` }, ...children);
|
||
}
|
||
|
||
/** Straight-segment curve through points, optionally drawn live via the ac-draw class. */
|
||
const arcCurve = (points, color, cls = "") =>
|
||
el("path", {
|
||
d: points.map(([px, py], i) => `${i === 0 ? "M" : "L"}${px} ${py}`).join(" "),
|
||
fill: "none",
|
||
stroke: color,
|
||
"stroke-width": 2.5,
|
||
"stroke-linecap": "round",
|
||
"stroke-linejoin": "round",
|
||
class: cls,
|
||
});
|
||
|
||
/** Horizontal phase strip, reused from beanJourney's inline segments. `segments` is
|
||
* [{x,w,color,label}]; an optional marker dot can ride a CSS translate class across it. */
|
||
function stageStrip(segments, opts = {}) {
|
||
const y = opts.y ?? 236, h = opts.h ?? 12;
|
||
const out = [];
|
||
for (const s of segments) {
|
||
out.push(el("rect", { x: s.x, y, width: s.w, height: h, rx: 3, fill: s.color, opacity: 0.55 }));
|
||
if (s.label) out.push(text(s.x + s.w / 2, y + 28, s.label, 11, C.ink2));
|
||
}
|
||
if (opts.markerX != null)
|
||
out.push(el("circle", { cx: opts.markerX, cy: y + h / 2, r: 6, fill: opts.markerColor ?? C.ember, class: opts.markerCls ?? "" }));
|
||
return out;
|
||
}
|
||
|
||
/** Simple line-glyph for the nose / eye / ear senses, ~24px across, centred on (x,y). */
|
||
function senseIcon(kind, x, y, color = C.ink2) {
|
||
const g = el("g", { transform: `translate(${x} ${y})`, stroke: color, "stroke-width": 2, fill: "none", "stroke-linecap": "round" });
|
||
if (kind === "nose")
|
||
g.append(el("path", { d: "M-3 -12 C 8 -10, 10 4, 2 10 C -2 13, -8 12, -10 8", }), el("circle", { cx: 1, cy: 10, r: 1.6, fill: color }));
|
||
if (kind === "eye")
|
||
g.append(el("path", { d: "M-13 0 C -6 -9, 6 -9, 13 0 C 6 9, -6 9, -13 0 Z" }), el("circle", { cx: 0, cy: 0, r: 4, fill: color }));
|
||
if (kind === "ear")
|
||
g.append(el("path", { d: "M-4 -12 C 8 -12, 12 -2, 8 6 C 5 11, -2 11, -3 5 C -4 0, 3 1, 2 -4" }));
|
||
return g;
|
||
}
|
||
|
||
const SIL_SCALE = 2.6;
|
||
const SIL_X = 280 - 32 * SIL_SCALE; // silhouette 64-box centred horizontally
|
||
const SIL_Y = 34;
|
||
function silhouette(methodKey) {
|
||
const g = el("g", { transform: `translate(${SIL_X} ${SIL_Y}) scale(${SIL_SCALE})`, fill: C.ink2 });
|
||
for (const d of BREW_SILHOUETTES[methodKey] ?? []) g.append(el("path", { d }));
|
||
return g;
|
||
}
|
||
/** Convert silhouette-local (0..64) coords to scene coords. */
|
||
const sil = (lx, ly) => [SIL_X + lx * SIL_SCALE, SIL_Y + ly * SIL_SCALE];
|
||
// Where each brewer holds its slurry / receives its water, in silhouette-local coords.
|
||
const BOWL = {
|
||
"french-press": [32, 34], aeropress: [32, 34], clever: [30, 22], "hario-switch": [32, 20],
|
||
"cold-brew": [32, 32], cupping: [32, 36], v60: [31, 20], chemex: [32, 16], kalita: [32, 22],
|
||
batch: [37, 33], percolator: [31, 30], espresso: [32, 38], moka: [32, 16],
|
||
};
|
||
|
||
// ── Scene builders (each returns children for the 560×300 canvas) ─────────
|
||
|
||
export const SCENES = {
|
||
/** The hero: one large bean living the whole roast — heat in, water out, color, swell,
|
||
* crack — synced to a stage timeline underneath. All keyframes share one 14s loop. */
|
||
beanJourney() {
|
||
const cx = 190, cy = 120;
|
||
const [jbGrad, jbUrl] = radialFill(C.green);
|
||
const seg = (x, w, color, label) => [
|
||
el("rect", { x, y: 236, width: w, height: 12, rx: 3, fill: color, opacity: 0.55 }),
|
||
text(x + w / 2, 264, label, 11.5, C.ink2),
|
||
];
|
||
return [
|
||
// Heat under the bean, always on
|
||
flame(cx - 34, 226, 0.85), flame(cx, 232, 1.05), flame(cx + 34, 226, 0.85),
|
||
...[0, 1, 2].map((i) =>
|
||
el("path", { d: `M${cx - 26 + i * 26} 214 v -12`, stroke: C.ember, "stroke-width": 3, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i}` }),
|
||
),
|
||
text(cx, 228, "", 1),
|
||
// The bean itself: color, swell, crack all on the 14s journey clock. The gradient
|
||
// base carries the shading; the animated flat overlay above it carries the color.
|
||
el("g", { transform: `translate(${cx} ${cy}) scale(2.15)` },
|
||
jbGrad,
|
||
el("g", { class: "ac-jb-grow" },
|
||
el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill: jbUrl }),
|
||
el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill: C.green, class: "ac-jb-color" }),
|
||
el("path", { d: "M0 -34 C 6 -16, 6 16, 0 34 C -3 16, -3 -16, 0 -34 Z", fill: "rgba(0,0,0,0.26)" }),
|
||
el("ellipse", { cx: -11, cy: -16, rx: 6.5, ry: 11, fill: "rgba(255,252,245,0.16)", transform: "rotate(-16 -11 -16)" }),
|
||
el("path", { d: "M-2 -30 L 5 -12 L -4 4 L 4 20 L -1 32", fill: "none", stroke: C.paper, "stroke-width": 2.4, class: "ac-jb-crack" }),
|
||
),
|
||
),
|
||
// Moisture leaving — fades out as drying ends
|
||
el("g", { class: "ac-jb-steam" }, steamWisps(cx, 46, 3)),
|
||
// Physics callouts
|
||
text(322, 60, "water out early —", 12.5, C.water, "start", "700"),
|
||
text(322, 78, "the drying sweat", 12, C.ink2, "start"),
|
||
text(322, 116, "then browning:", 12.5, C.tan, "start", "700"),
|
||
text(322, 134, "Maillard aromas build", 12, C.ink2, "start"),
|
||
text(322, 172, "pressure swells the bean", 12.5, C.ink2, "start"),
|
||
text(322, 190, "until it cracks — then drop", 12.5, C.ember, "start", "700"),
|
||
// Stage timeline with a marker riding the same clock
|
||
...seg(80, 188, C.yellow, "drying"),
|
||
...seg(270, 144, C.tan, "Maillard"),
|
||
...seg(416, 64, C.brown, "development"),
|
||
el("circle", { cx: 80, cy: 242, r: 7, fill: C.ember, class: "ac-jb-marker" }),
|
||
caption("one bean, one loop: green → yellow → brown → crack → drop"),
|
||
];
|
||
},
|
||
beanAnatomy() {
|
||
const cx = 180, cy = 145;
|
||
const [outerGrad, outerUrl] = radialFill(C.green);
|
||
const [innerGrad, innerUrl] = radialFill("#8ea06f", { tint: 0.2, shade: 0.18 });
|
||
return [
|
||
outerGrad,
|
||
innerGrad,
|
||
el("ellipse", { cx, cy, rx: 108, ry: 95, fill: outerUrl, class: "ac-breathe" }),
|
||
el("ellipse", { cx, cy, rx: 92, ry: 80, fill: innerUrl }),
|
||
el("path", { d: `M${cx} ${cy - 86} C ${cx + 27} ${cy - 36}, ${cx + 27} ${cy + 36}, ${cx} ${cy + 86} C ${cx - 18} ${cy + 36}, ${cx - 18} ${cy - 36}, ${cx} ${cy - 86} Z`, fill: "#6a7a50" }),
|
||
...[[cx - 55, cy - 45, 0], [cx + 48, cy - 30, 1], [cx - 30, cy + 50, 2], [cx + 40, cy + 42, 0], [cx + 4, cy - 4, 1]].map(([x, y, d]) =>
|
||
el("circle", { cx: x, cy: y, r: 5, fill: C.water, class: `ac-pulse ac-delay-${d}` }),
|
||
),
|
||
// Legend, leader lines rooted on the feature they name
|
||
el("line", { x1: cx + 96, y1: cy - 62, x2: 366, y2: 70, stroke: C.line, "stroke-width": 1.5 }),
|
||
text(374, 74, "silverskin — the chaff", 12.5, C.ink2, "start"),
|
||
el("line", { x1: cx + 88, y1: cy, x2: 366, y2: 140, stroke: C.line, "stroke-width": 1.5 }),
|
||
text(374, 136, "endosperm — the pantry:", 12.5, C.ink2, "start"),
|
||
text(374, 154, "sugars, acids, proteins", 12.5, C.ink2, "start"),
|
||
el("line", { x1: cx + 60, y1: cy + 52, x2: 366, y2: 212, stroke: C.line, "stroke-width": 1.5 }),
|
||
text(374, 210, "≈11% water", 12.5, C.water, "start", "700"),
|
||
text(374, 228, "(it all must leave)", 11.5, C.ink2, "start"),
|
||
caption("the seed of a coffee cherry, in cross-section"),
|
||
];
|
||
},
|
||
composition() {
|
||
const parts = [
|
||
["Cellulose", 48, C.ink2],
|
||
["Sugars", 12, C.tan],
|
||
["Lipids", 12, C.yellow],
|
||
["Water", 11, C.water],
|
||
["Proteins", 9, C.brown],
|
||
["Acids", 8, C.ember],
|
||
];
|
||
const out = [bean(110, 140, 1.9, C.green, { class: "ac-breathe" })];
|
||
parts.forEach(([name, pct, color], i) => {
|
||
const y = 52 + i * 34;
|
||
const w = pct * 4.4;
|
||
out.push(
|
||
text(248, y + 16, name, 12.5, C.ink2, "end"),
|
||
el("rect", { x: 258, y, width: 0, height: 21, rx: 4, fill: color, class: "ac-grow-w", style: `--w:${w}px` }),
|
||
text(266 + w, y + 16, `${pct}%`, 12, C.ink2, "start"),
|
||
);
|
||
});
|
||
out.push(caption("half scaffolding, half flavor pantry — plus the water"));
|
||
return out;
|
||
},
|
||
charge() {
|
||
return [
|
||
// Drum with tumbling beans
|
||
el("circle", { cx: 150, cy: 138, r: 76, fill: "none", stroke: C.ink2, "stroke-width": 4 }),
|
||
el("g", { class: "ac-tumble" },
|
||
bean(126, 118, 0.5, C.green, { shadow: false }), bean(172, 132, 0.5, C.green, { shadow: false }),
|
||
bean(140, 162, 0.5, "#8ea06f", { shadow: false }), bean(182, 168, 0.5, C.green, { shadow: false })),
|
||
flame(150, 250, 1),
|
||
el("line", { x1: 254, y1: 120, x2: 205, y2: 132, stroke: C.ink, "stroke-width": 3, "stroke-linecap": "round" }),
|
||
el("circle", { cx: 205, cy: 132, r: 4, fill: C.ink }),
|
||
text(260, 116, "probe", 11.5, C.ink2, "start"),
|
||
// Temperature dip-and-turn, drawn live
|
||
el("path", { d: "M310 230 H 520 M310 230 V 60", stroke: C.line, "stroke-width": 1.5, fill: "none" }),
|
||
el("path", { d: "M316 78 L 372 196 Q 384 216 398 196 L 512 92", fill: "none", stroke: C.ember, "stroke-width": 3, class: "ac-draw" }),
|
||
el("circle", { cx: 385, cy: 208, r: 5, fill: C.ember, class: "ac-pulse" }),
|
||
text(415, 246, "turning point", 12.5, C.ember, "middle", "700"),
|
||
caption("the probe reads the beans — where the line turns is your machine's fingerprint"),
|
||
];
|
||
},
|
||
steam() {
|
||
return [
|
||
flame(170, 272, 1.1),
|
||
bean(170, 150, 1.9, C.green, { class: "ac-dry-shift" }),
|
||
steamWisps(170, 92, 3),
|
||
text(226, 56, "water out", 12, C.water, "start", "700"),
|
||
text(206, 262, "heat in", 12, C.ember, "start", "700"),
|
||
// Contained thermometer: the slow, stubborn climb
|
||
el("rect", { x: 392, y: 64, width: 18, height: 140, rx: 9, fill: C.paper, stroke: C.ink2, "stroke-width": 2 }),
|
||
el("circle", { cx: 401, cy: 216, r: 14, fill: C.ember }),
|
||
el("rect", { x: 396, y: 74, width: 10, height: 126, rx: 5, fill: C.ember, class: "ac-fill-up" }),
|
||
text(430, 140, "temperature climbs", 12, C.ink2, "start"),
|
||
text(430, 158, "stubbornly — the", 12, C.ink2, "start"),
|
||
text(430, 176, "endothermic phase", 12, C.ember, "start", "700"),
|
||
text(430, 216, "43–51% of the roast", 12, C.ink2, "start"),
|
||
caption("evaporation soaks up energy while the bean fades green → yellow"),
|
||
];
|
||
},
|
||
maillard() {
|
||
return [
|
||
bean(160, 148, 1.9, C.yellow, { class: "ac-maillard-shift ac-breathe" }),
|
||
// Chaff flaking off the bean, drifting up-right
|
||
...[0, 1, 2].map((i) =>
|
||
el("path", {
|
||
d: `M${208 + i * 6} ${104 - i * 10} q 10 4 16 -4`,
|
||
stroke: C.yellow, "stroke-width": 3, fill: "none",
|
||
class: `ac-drift ac-delay-${i}`, "stroke-linecap": "round",
|
||
}),
|
||
),
|
||
// Aroma compounds appearing
|
||
...[[320, 92, 0], [356, 120, 1], [336, 156, 2], [386, 88, 1], [372, 178, 0], [402, 140, 2]].map(([x, y, d]) =>
|
||
el("circle", { cx: x, cy: y, r: 4.5, fill: d === 1 ? C.ember : C.tan, class: `ac-sparkle ac-delay-${d}` }),
|
||
),
|
||
text(452, 116, "amino acids", 12.5, C.ink2, "start"),
|
||
text(452, 134, "+ sugars →", 12.5, C.ink2, "start"),
|
||
text(452, 152, "aroma", 12.5, C.ember, "start", "700"),
|
||
text(160, 250, "≈150 °C — browning begins", 12),
|
||
caption("the bean swells, chaff lifts away, hundreds of new compounds appear"),
|
||
];
|
||
},
|
||
crack({ phase = "during" } = {}) {
|
||
const during = phase === "during";
|
||
return [
|
||
bean(280, 140, 2, during ? C.brown : C.tan, { crack: true, class: during ? "ac-shake" : "" }),
|
||
...[0, 1, 2].map((i) =>
|
||
el("circle", { cx: 280, cy: 140, r: 58, fill: "none", stroke: C.ember, "stroke-width": 2, class: `ac-ring ac-delay-${i}` }),
|
||
),
|
||
// Pressure pushing outward (left), heat released (right)
|
||
...[[-118, "≈8 bar", "of steam inside"], [118, "the exotherm —", "heat released"]].map(([dx, l1, l2], side) =>
|
||
el("g", {},
|
||
text(280 + dx, 118, l1, 12.5, side ? C.ember : C.water, "middle", "700"),
|
||
text(280 + dx, 136, l2, 12, C.ink2),
|
||
),
|
||
),
|
||
...[0, 1, 2].map((i) =>
|
||
el("path", { d: `M${332 + i * 12} 156 l 12 8`, stroke: C.ember, "stroke-width": 2.5, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i}` }),
|
||
),
|
||
text(280, 244, "≈196 °C — the pop, like popcorn", 12.5, C.ink, "middle", "700"),
|
||
caption("the heat cut happens BEFORE this moment — gently, a minute upstream"),
|
||
];
|
||
},
|
||
dtr() {
|
||
return [
|
||
text(280, 60, "one roast, on a timeline", 12.5),
|
||
el("rect", { x: 70, y: 110, width: 420, height: 44, rx: 8, fill: C.line }),
|
||
el("rect", { x: 70, y: 110, width: 280, height: 44, rx: 8, fill: C.tan, opacity: 0.45 }),
|
||
el("rect", { x: 350, y: 110, width: 0, height: 44, rx: 8, fill: C.ember, class: "ac-grow-w", style: "--w:140px" }),
|
||
el("line", { x1: 350, y1: 92, x2: 350, y2: 172, stroke: C.ink, "stroke-width": 2, "stroke-dasharray": "4 3" }),
|
||
text(350, 84, "first crack", 12, C.ink, "middle", "700"),
|
||
text(210, 138, "drying + Maillard", 12.5, C.ink2),
|
||
text(420, 138, "development", 12.5, C.paper, "middle", "700"),
|
||
text(70, 172, "0:00", 11.5, C.ink2, "start"),
|
||
text(490, 172, "drop", 11.5, C.ink2, "end"),
|
||
text(280, 214, "development ÷ total time = 12–20%", 14, C.ember, "middle", "700"),
|
||
caption("the strongest single lever on the cup — the app computes it from every log"),
|
||
];
|
||
},
|
||
weightLoss() {
|
||
return [
|
||
bean(150, 128, 1.5, C.green),
|
||
text(150, 208, "250 g green", 13, C.ink, "middle", "700"),
|
||
el("line", { x1: 226, y1: 128, x2: 302, y2: 128, stroke: C.ink2, "stroke-width": 2.5, class: "ac-draw" }),
|
||
el("path", { d: "M300 121 l 12 7 l -12 7 Z", fill: C.ink2 }),
|
||
bean(390, 128, 1.62, C.brown, { crack: true }),
|
||
text(390, 208, "≈220 g roasted", 13, C.ink, "middle", "700"),
|
||
text(280, 244, "−11–13% for a light roast", 13.5, C.ember, "middle", "700"),
|
||
caption("time, temperature and development — measured by a kitchen scale"),
|
||
];
|
||
},
|
||
curve({ focus = "all" } = {}) {
|
||
const band = (x, w, color, key) =>
|
||
el("rect", { x, y: 62, width: w, height: 168, fill: color, opacity: focus === "all" || focus === key ? 0.16 : 0.05 });
|
||
return [
|
||
band(60, 150, C.yellow, "drying"),
|
||
band(210, 158, C.tan, "maillard"),
|
||
band(368, 132, C.brown, "development"),
|
||
el("path", { d: "M60 230 H 500 M60 230 V 62", stroke: C.line, "stroke-width": 1.5, fill: "none" }),
|
||
el("path", { d: "M64 96 L 96 198 Q 104 216 116 198 C 180 108, 300 96, 494 72", fill: "none", stroke: C.ember, "stroke-width": 3, class: "ac-draw" }),
|
||
el("circle", { cx: 100, cy: 207, r: 4.5, fill: C.ember }),
|
||
text(100, 250, "TP", 11.5, C.ink2),
|
||
el("circle", { cx: 375, cy: 86, r: 5, fill: C.ink, class: "ac-pulse" }),
|
||
text(375, 56, "first crack", 12, C.ink, "middle", "700"),
|
||
text(165, 250, "drying", 12), text(289, 250, "Maillard", 12), text(434, 250, "development", 12),
|
||
caption("bean temperature — dip, turn, climb, crack, drop: the story of the roast"),
|
||
];
|
||
},
|
||
ror() {
|
||
return [
|
||
el("path", { d: "M60 230 H 500 M60 230 V 62", stroke: C.line, "stroke-width": 1.5, fill: "none" }),
|
||
text(52, 76, "RoR", 11.5, C.ink2, "end"),
|
||
// The ideal: steady decline
|
||
el("path", { d: "M64 92 C 160 128, 300 168, 494 194", fill: "none", stroke: C.pass, "stroke-width": 2.5, "stroke-dasharray": "6 5" }),
|
||
text(216, 90, "ideal: always falling", 12, C.pass, "middle", "700"),
|
||
// The failure: crash, then flick
|
||
el("path", { d: "M64 92 C 150 130, 230 152, 292 162 C 330 168, 336 224, 366 226 C 392 228, 398 176, 442 184 L 494 194", fill: "none", stroke: C.fail, "stroke-width": 3, class: "ac-draw" }),
|
||
el("circle", { cx: 352, cy: 224, r: 5.5, fill: C.fail, class: "ac-pulse" }),
|
||
text(340, 252, "crash", 12, C.fail, "middle", "700"),
|
||
el("circle", { cx: 420, cy: 179, r: 5.5, fill: C.fail, class: "ac-pulse ac-delay-1" }),
|
||
text(432, 160, "flick", 12, C.fail, "middle", "700"),
|
||
// The stall
|
||
el("line", { x1: 200, y1: 148, x2: 300, y2: 148, stroke: C.warnColor ?? "#a8741a", "stroke-width": 3, "stroke-dasharray": "2 4" }),
|
||
text(250, 136, "stall = baking", 12, "#a8741a", "middle", "700"),
|
||
caption("crash bakes out sweetness · flick tastes roasty · the fix is always upstream"),
|
||
];
|
||
},
|
||
oneChange() {
|
||
const cards = [
|
||
["batch 1", "baseline", false],
|
||
["batch 2", "FC +0:30", false],
|
||
["batch 3", "dev +0:15", true],
|
||
];
|
||
return [
|
||
...cards.map(([label, change, active], i) =>
|
||
el("g", { class: active ? "ac-pulse-soft" : "" },
|
||
el("rect", { x: 84 + i * 146, y: 78, width: 116, height: 76, rx: 8, fill: active ? C.emberSoft : C.paper, stroke: active ? C.ember : C.line, "stroke-width": 1.6 }),
|
||
text(142 + i * 146, 106, label, 12.5, C.ink2),
|
||
text(142 + i * 146, 134, change, 13.5, active ? C.ember : C.ink, "middle", "700"),
|
||
),
|
||
),
|
||
el("path", { d: "M202 116 h 24 M348 116 h 24", stroke: C.ember, "stroke-width": 2.5, class: "ac-draw" }),
|
||
text(280, 196, "papery → FC +0:30 · savory → FC −0:30 · sharp → dev +0:15", 12.5, C.ink),
|
||
text(280, 224, "one variable per batch — the only experiment you can afford", 13, C.ember, "middle", "700"),
|
||
caption("your note rides into the next plan's ± Refine automatically"),
|
||
];
|
||
},
|
||
|
||
// ── Brewing scenes ──
|
||
dissolve() {
|
||
const [grad, gradUrl] = radialFill(C.brown);
|
||
return [
|
||
grad,
|
||
el("circle", { cx: 150, cy: 140, r: 52, fill: gradUrl }),
|
||
el("ellipse", { cx: 150, cy: 206, rx: 48, ry: 9, fill: "url(#ac-ground)" }),
|
||
text(150, 218, "ground coffee", 12, C.ink2),
|
||
...[0, 1, 2, 3, 4, 5].map((i) =>
|
||
el("circle", {
|
||
cx: 196, cy: 112 + (i % 3) * 26, r: 5,
|
||
fill: [C.yellow, C.tan, C.dark][i % 3],
|
||
class: `ac-dissolve ac-delay-${i % 3}`,
|
||
}),
|
||
),
|
||
...[
|
||
["1. acids — bright, sour", C.yellow],
|
||
["2. sugars — sweet", C.tan],
|
||
["3. bitters — heavy, dry", C.dark],
|
||
].map(([label, color], i) =>
|
||
el("g", {},
|
||
el("circle", { cx: 312, cy: 96 + i * 44, r: 8, fill: color }),
|
||
text(330, 101 + i * 44, label, 13, C.ink2, "start"),
|
||
),
|
||
),
|
||
caption("hot water dissolves them in this order — brewing is choosing when to stop"),
|
||
];
|
||
},
|
||
yieldMap() {
|
||
return [
|
||
text(150, 92, "sour · thin", 12.5, C.fail, "middle", "700"),
|
||
text(280, 78, "sweet spot", 13, C.pass, "middle", "700"),
|
||
text(280, 96, "18–22%", 13, C.pass, "middle", "700"),
|
||
text(412, 92, "bitter · drying", 12.5, C.fail, "middle", "700"),
|
||
el("rect", { x: 90, y: 112, width: 380, height: 56, rx: 10, fill: C.paper, stroke: C.line }),
|
||
el("rect", { x: 90, y: 112, width: 128, height: 56, rx: 10, fill: C.fail, opacity: 0.1 }),
|
||
el("rect", { x: 342, y: 112, width: 128, height: 56, rx: 10, fill: C.fail, opacity: 0.1 }),
|
||
el("rect", { x: 218, y: 112, width: 124, height: 56, fill: C.pass, opacity: 0.16 }),
|
||
el("circle", { cx: 280, cy: 140, r: 7, fill: C.ember, class: "ac-slide-x" }),
|
||
text(280, 194, "extraction yield →", 12, C.ink2),
|
||
text(280, 230, "strength = coffee in the water", 12.5, C.ink2),
|
||
text(280, 248, "extraction = what you took from the grounds", 12.5, C.ink2),
|
||
caption("aim the dot at the middle: every dial below exists to move it"),
|
||
];
|
||
},
|
||
grind() {
|
||
// One gradient def serves every particle (objectBoundingBox units shade each circle).
|
||
const [grindGrad, grindUrl] = radialFill(C.brown);
|
||
const pile = (x, label, sub, circles) => {
|
||
const g = el("g", {});
|
||
g.append(el("ellipse", { cx: x, cy: 172, rx: 52, ry: 8, fill: "url(#ac-ground)" }));
|
||
for (const [dx, dy, r] of circles) g.append(el("circle", { cx: x + dx, cy: 128 + dy, r, fill: grindUrl }));
|
||
g.append(text(x, 196, label, 13, C.ink, "middle", "700"), text(x, 216, sub, 11.5, C.ink2));
|
||
return g;
|
||
};
|
||
return [
|
||
grindGrad,
|
||
text(280, 62, "the same 20 grams of coffee", 12.5),
|
||
pile(130, "coarse", "less surface · fast flow", [[-16, -10, 19], [16, -8, 19], [0, 18, 19]]),
|
||
pile(280, "medium", "the middle path", [[-24, -14, 10], [0, -18, 10], [24, -14, 10], [-14, 4, 10], [12, 4, 10], [-2, 22, 10], [22, 20, 10]]),
|
||
pile(430, "fine", "more surface · slow flow", [...Array(16)].map((_, i) => [((i % 4) - 1.5) * 16, (Math.floor(i / 4) - 1.5) * 15, 5])),
|
||
caption("grind is the master dial — it moves extraction and contact time together"),
|
||
];
|
||
},
|
||
tempTime() {
|
||
return [
|
||
// Temperature: contained thermometer
|
||
el("rect", { x: 108, y: 68, width: 18, height: 118, rx: 9, fill: C.paper, stroke: C.ink2, "stroke-width": 2 }),
|
||
el("circle", { cx: 117, cy: 198, r: 13, fill: C.ember }),
|
||
el("rect", { x: 112, y: 76, width: 10, height: 106, rx: 5, fill: C.ember, class: "ac-fill-up" }),
|
||
text(117, 240, "90–96 °C", 12.5, C.ink, "middle", "700"),
|
||
// Time: a gauge whose needle sweeps
|
||
el("path", { d: "M220 190 A 62 62 0 0 1 340 190", fill: "none", stroke: C.line, "stroke-width": 10, "stroke-linecap": "round" }),
|
||
el("line", { x1: 280, y1: 190, x2: 280, y2: 138, stroke: C.ink, "stroke-width": 4, class: "ac-needle", "transform-origin": "280px 190px" }),
|
||
el("circle", { cx: 280, cy: 190, r: 8, fill: C.ink }),
|
||
text(280, 240, "time = how far down the list", 12.5, C.ink, "middle", "700"),
|
||
// Ratio: one bean to many drops
|
||
bean(420, 110, 0.62, C.brown, { shadow: false }),
|
||
text(446, 116, "1 :", 15, C.ink, "start", "700"),
|
||
...[0, 1, 2].map((i) =>
|
||
el("path", { d: `M${480 + i * 18} 104 c 5 8 5 14 0 18 c -5 -4 -5 -10 0 -18 Z`, fill: C.water, class: `ac-pulse ac-delay-${i}` }),
|
||
),
|
||
text(466, 240, "1:15–1:17", 12.5, C.ink, "middle", "700"),
|
||
caption("three dials on one cup — and grind, from the previous slide, is the fourth"),
|
||
];
|
||
},
|
||
tasteDial() {
|
||
return [
|
||
el("path", { d: "M130 198 A 156 156 0 0 1 430 198", fill: "none", stroke: C.line, "stroke-width": 15, "stroke-linecap": "round" }),
|
||
el("path", { d: "M130 198 A 156 156 0 0 1 232 72", fill: "none", stroke: C.fail, "stroke-width": 15, "stroke-linecap": "round", opacity: 0.5 }),
|
||
el("path", { d: "M328 72 A 156 156 0 0 1 430 198", fill: "none", stroke: C.fail, "stroke-width": 15, "stroke-linecap": "round", opacity: 0.5 }),
|
||
text(126, 232, "sour · sharp", 12.5, C.fail, "middle", "700"),
|
||
text(280, 48, "sweet & balanced", 13, C.pass, "middle", "700"),
|
||
text(434, 232, "bitter · drying", 12.5, C.fail, "middle", "700"),
|
||
el("line", { x1: 280, y1: 198, x2: 280, y2: 92, stroke: C.ink, "stroke-width": 4, class: "ac-dial", "transform-origin": "280px 198px" }),
|
||
el("circle", { cx: 280, cy: 198, r: 10, fill: C.ink }),
|
||
text(280, 246, "sour? extract more · bitter? extract less", 13, C.ink, "middle", "700"),
|
||
caption("finer, hotter, longer ← the dial → coarser, cooler, shorter"),
|
||
];
|
||
},
|
||
bloom() {
|
||
const [bx, by] = [280, 118];
|
||
return [
|
||
silhouette("v60"),
|
||
// CO₂ bubbles rising out of the cone
|
||
...[0, 1, 2, 3].map((i) =>
|
||
el("circle", { cx: bx - 18 + i * 12, cy: by - i * 3, r: 4 - i * 0.5, fill: C.tan, class: `ac-rise ac-delay-${i}` }),
|
||
),
|
||
text(430, 96, "CO₂ escaping —", 12.5, C.tan, "start", "700"),
|
||
text(430, 114, "the bed swells", 12.5, C.ink2, "start"),
|
||
text(430, 132, "and bubbles", 12.5, C.ink2, "start"),
|
||
text(130, 96, "2× the coffee's", 12.5, C.ink2, "end"),
|
||
text(130, 114, "weight in water", 12.5, C.ink2, "end"),
|
||
text(130, 132, "wait 30–45 s", 12.5, C.ember, "end", "700"),
|
||
caption("gas pushes water away from coffee — bloom first, then brew"),
|
||
];
|
||
},
|
||
tamp() {
|
||
return [
|
||
// Tamper descending onto the basket
|
||
el("g", { class: "ac-tamp" },
|
||
el("rect", { x: 262, y: 44, width: 16, height: 26, rx: 5, fill: C.ink }),
|
||
el("rect", { x: 238, y: 70, width: 64, height: 20, rx: 5, fill: C.ink })),
|
||
el("rect", { x: 206, y: 118, width: 148, height: 54, rx: 8, fill: C.ink2 }),
|
||
el("rect", { x: 216, y: 128, width: 128, height: 36, rx: 4, fill: C.brown }),
|
||
el("line", { x1: 216, y1: 128, x2: 344, y2: 128, stroke: C.paper, "stroke-width": 1.5, "stroke-dasharray": "4 4" }),
|
||
text(390, 140, "level, even,", 12.5, C.ink2, "start"),
|
||
text(390, 158, "no gaps", 12.5, C.ink2, "start"),
|
||
text(170, 140, "dose to 0.1 g", 12.5, C.ink2, "end"),
|
||
text(280, 220, "distribute · tamp level · no ritual, just channel prevention", 13, C.ink, "middle", "700"),
|
||
caption("pressurized water exploits any weakness in the puck"),
|
||
];
|
||
},
|
||
shot() {
|
||
return [
|
||
el("rect", { x: 230, y: 56, width: 100, height: 32, rx: 6, fill: C.ink }),
|
||
el("line", { x1: 268, y1: 88, x2: 268, y2: 122, stroke: C.brown, "stroke-width": 5, class: "ac-drip" }),
|
||
el("line", { x1: 292, y1: 88, x2: 292, y2: 122, stroke: C.brown, "stroke-width": 5, class: "ac-drip ac-delay-1" }),
|
||
el("path", { d: "M248 130 h 64 l -7 40 h -50 Z", fill: C.paper, stroke: C.ink2, "stroke-width": 2 }),
|
||
el("rect", { x: 252, y: 146, width: 56, height: 0, fill: C.tan, class: "ac-grow-h-down" }),
|
||
text(392, 108, "18 g in → 36 g out", 13, C.ink, "start", "700"),
|
||
text(392, 130, "in 25–32 seconds", 12.5, C.ink2, "start"),
|
||
text(392, 152, "flows like warm honey", 12.5, C.tan, "start"),
|
||
text(168, 130, "9 bar, above", 12.5, C.ink2, "end"),
|
||
text(280, 220, "blonde & gushing → grind finer · choked drips → coarser", 12.5, C.ink),
|
||
caption("the stream itself tells you which way the grind is wrong"),
|
||
];
|
||
},
|
||
brewAnim({ method, mode }) {
|
||
const m = findBrewMethod(method);
|
||
const [bx, by] = sil(...(BOWL[method] ?? [32, 30]));
|
||
const out = [silhouette(method), text(280, 262, m?.name ?? method, 13.5, C.ink, "middle", "700")];
|
||
const hint = (lines, color = C.ink2) =>
|
||
lines.forEach((s, i) => out.push(text(432, 100 + i * 19, s, 12.5, color, "start", i === 0 ? "700" : "400")));
|
||
|
||
if (mode === "steep" || mode === "valve") {
|
||
out.push(
|
||
...[...Array(6)].map((_, i) =>
|
||
el("circle", {
|
||
cx: bx - 22 + (i % 3) * 22, cy: by - 8 + Math.floor(i / 3) * 16, r: 4,
|
||
fill: C.brown, class: `ac-float ac-delay-${i % 3}`,
|
||
}),
|
||
),
|
||
);
|
||
hint(mode === "valve" ? ["steep closed…", "then open the", "valve and drain"] : ["all the water,", "all the grounds,", "the whole time"]);
|
||
}
|
||
if (mode === "plunge" || mode === "press") {
|
||
out.push(
|
||
el("g", { class: "ac-tamp" },
|
||
el("path", { d: `M${bx} ${by - 52} v 30`, stroke: C.ember, "stroke-width": 5, "stroke-linecap": "round" }),
|
||
el("path", { d: `M${bx - 9} ${by - 20} h 18`, stroke: C.ember, "stroke-width": 4, "stroke-linecap": "round" })),
|
||
);
|
||
hint(mode === "press" ? ["steep 1:30,", "press 0:30,", "gently"] : ["press slowly,", "stop above the", "grounds"]);
|
||
}
|
||
if (mode === "pour" || mode === "spiral" || mode === "drip") {
|
||
out.push(
|
||
...[0, 1, 2].map((i) =>
|
||
el("line", { x1: bx - 10 + i * 10, y1: by - 34, x2: bx - 10 + i * 10, y2: by - 14, stroke: C.water, "stroke-width": 2.5, class: `ac-drip ac-delay-${i}` }),
|
||
),
|
||
);
|
||
if (mode === "spiral")
|
||
out.push(el("path", { d: `M${bx} ${by} m -16 0 a 16 16 0 1 1 32 0 a 12 12 0 1 1 -24 0`, fill: "none", stroke: C.water, "stroke-width": 2, class: "ac-pourline" }));
|
||
hint(mode === "spiral" ? ["slow spirals,", "centre out,", "keep it level"] : mode === "drip" ? ["the robot pours;", "you choose grind", "and ratio"] : ["fresh water,", "always passing", "through the bed"]);
|
||
}
|
||
if (mode === "pressure") {
|
||
out.push(
|
||
...[0, 1, 2].map((i) =>
|
||
el("path", { d: `M${bx - 26 + i * 26} ${by - 26} v 14`, stroke: C.ember, "stroke-width": 3.5, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i}` }),
|
||
),
|
||
);
|
||
hint(["9 bar pushes", "water through", "the puck"], C.ember);
|
||
}
|
||
if (mode === "flame") {
|
||
out.push(flame(bx, sil(32, 74)[1], 1.2));
|
||
hint(["≈1.5 bar of", "boiler steam", "pushes upward"]);
|
||
}
|
||
return out;
|
||
},
|
||
|
||
// ── Inside the bean ──
|
||
ibCells() {
|
||
const bx = 120, by = 140;
|
||
const cx = 400, cy = 140, cr = 90;
|
||
const hexPositions = [[0, -40], [38, -20], [38, 20], [0, 40], [-38, 20], [-38, -20], [0, 0]];
|
||
const hexPath = (x, y, r) => {
|
||
const pts = [...Array(6)].map((_, i) => {
|
||
const a = Math.PI / 6 + (i * Math.PI) / 3;
|
||
return `${x + r * Math.cos(a)} ${y + r * Math.sin(a)}`;
|
||
});
|
||
return `M${pts.join(" L")} Z`;
|
||
};
|
||
const cells = hexPositions.map(([dx, dy], i) => {
|
||
const hx = cx + dx * 0.9, hy = cy + dy * 0.9;
|
||
return el("g", {},
|
||
el("path", { d: hexPath(hx, hy, 21), fill: "none", stroke: C.ink2, "stroke-width": 1.3 }),
|
||
el("circle", { cx: hx - 7, cy: hy - 4, r: 3, fill: C.water, class: `ac-sparkle ac-delay-${i % 3}` }),
|
||
el("circle", { cx: hx + 6, cy: hy - 4, r: 3, fill: C.yellow, class: `ac-sparkle ac-delay-${(i + 1) % 3}` }),
|
||
el("circle", { cx: hx, cy: hy + 7, r: 3, fill: C.tan, class: `ac-sparkle ac-delay-${(i + 2) % 3}` }),
|
||
);
|
||
});
|
||
return [
|
||
bean(bx, by, 1.85, C.green),
|
||
el("circle", { cx, cy, r: cr, fill: C.paper, stroke: C.line, "stroke-width": 2 }),
|
||
...cells,
|
||
el("line", { x1: bx + 50, y1: by - 28, x2: cx - cr + 8, y2: cy - cr + 18, stroke: C.line, "stroke-width": 1.5, "stroke-dasharray": "3 3" }),
|
||
el("line", { x1: bx + 50, y1: by + 28, x2: cx - cr + 8, y2: cy + cr - 18, stroke: C.line, "stroke-width": 1.5, "stroke-dasharray": "3 3" }),
|
||
text(cx, cy - cr - 10, "one cell, magnified", 12, C.ink2, "middle"),
|
||
caption("one bean ≈ a few million sealed cells — oil, sugar, and water inside each"),
|
||
];
|
||
},
|
||
ibSeal() {
|
||
const cx = 280, cy = 140, half = 64;
|
||
const pores = [
|
||
[cx, cy - half, 0, 28],
|
||
[cx, cy + half, 0, -28],
|
||
[cx - half, cy, 28, 0],
|
||
[cx + half, cy, -28, 0],
|
||
];
|
||
return [
|
||
el("rect", { x: cx - half, y: cy - half, width: half * 2, height: half * 2, rx: 16, fill: "rgba(0,0,0,0.04)", stroke: C.dark, "stroke-width": 4 }),
|
||
...pores.map(([px, py, dx, dy], i) =>
|
||
el("g", {},
|
||
el("rect", { x: px - 9, y: py - 9, width: 18, height: 18, fill: C.paper }),
|
||
el("circle", { cx: px - dx, cy: py - dy, r: 9, fill: C.yellow, class: `ac-plug ac-delay-${i}`, style: `--dx:${dx}px;--dy:${dy}px` }),
|
||
),
|
||
),
|
||
...[[-1, -1], [1, -1], [-1, 1], [1, 1]].map(([sx, sy], i) =>
|
||
el("path", {
|
||
d: `M${cx + sx * (half + 12)} ${cy + sy * (half + 12)} l ${sx * 10} ${sy * 10}`,
|
||
stroke: C.ember, "stroke-width": 3, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i}`,
|
||
}),
|
||
),
|
||
text(cx, cy - half - 18, "cell wall", 12, C.ink2, "middle"),
|
||
text(cx, cy - 4, "pressure", 12.5, C.ember, "middle", "700"),
|
||
text(cx, cy + 14, "building", 12.5, C.ember, "middle", "700"),
|
||
caption("early heat drives oil into the pores — each cell seals into a pressure vessel"),
|
||
];
|
||
},
|
||
ibGlass() {
|
||
const lx = 140, rx = 420, cy = 150;
|
||
return [
|
||
bean(lx, cy, 1.6, C.tan),
|
||
el("path", { d: `M${lx - 14} ${cy - 24} l 8 14 l -8 10 l 8 12`, stroke: C.paper, "stroke-width": 1.8, fill: "none" }),
|
||
text(lx, cy + 92, "glassy", 13, C.ink, "middle", "700"),
|
||
text(lx, cy + 108, "hard, locked", 11.5, C.ink2, "middle"),
|
||
bean(rx, cy, 1.6, C.yellow, { class: "ac-wobble" }),
|
||
text(rx, cy + 92, "rubbery", 13, C.ink, "middle", "700"),
|
||
text(rx, cy + 108, "soft, stretchy", 11.5, C.ink2, "middle"),
|
||
el("path", { d: `M${lx + 46} ${cy - 50} Q 280 ${cy - 92} ${rx - 46} ${cy - 50}`, fill: "none", stroke: C.ember, "stroke-width": 2.5, class: "ac-draw" }),
|
||
text(280, cy - 98, "heat", 12.5, C.ember, "middle", "700"),
|
||
el("path", { d: `M${rx - 46} ${cy + 50} Q 280 ${cy + 92} ${lx + 46} ${cy + 50}`, fill: "none", stroke: C.water, "stroke-width": 2.5, class: "ac-draw" }),
|
||
text(280, cy + 100, "cool + dry", 12.5, C.water, "middle", "700"),
|
||
caption("warm + wet = rubbery and inflatable · cool + dry = glassy and locked"),
|
||
];
|
||
},
|
||
ibFront() {
|
||
const x = 190, y = 160, scale = 1.55;
|
||
return [
|
||
halfBean(x, y, scale, {
|
||
shellFill: C.tan,
|
||
children: [el("circle", { cx: 0, cy: 0, r: 30, fill: C.water, class: "ac-shrink" })],
|
||
}),
|
||
steamWisps(x, 74, 3),
|
||
text(x, 50, "water leaves the surface", 12, C.water, "middle"),
|
||
el("line", { x1: x + 50, y1: y - 30, x2: 356, y2: 70, stroke: C.line, "stroke-width": 1.5 }),
|
||
text(364, 66, "dry shell — hot, reacting", 12, C.tan, "start", "700"),
|
||
el("line", { x1: x + 26, y1: y + 12, x2: 356, y2: 180, stroke: C.line, "stroke-width": 1.5 }),
|
||
text(364, 176, "wet core — pinned", 12, C.water, "start", "700"),
|
||
text(364, 194, "near boiling", 12, C.ink2, "start"),
|
||
caption("water leaves outside-in — a wet core retreats behind a hot, dry shell"),
|
||
];
|
||
},
|
||
ibPressure() {
|
||
const bars = [["car tire", 2, C.ink2], ["espresso", 9, C.water], ["first crack", 15, C.ember]];
|
||
const baseY = 232, maxH = 130, maxV = 15;
|
||
return [
|
||
gauge(140, 150, null, "≈15 bar", { r: 46 }),
|
||
text(140, 216, "pressure follows", 11, C.ink2, "middle"),
|
||
text(140, 230, "temperature", 11, C.ink2, "middle"),
|
||
...bars.map(([label, val, color], i) => {
|
||
const x = 300 + i * 76;
|
||
const h = (val / maxV) * maxH;
|
||
return el("g", {},
|
||
el("rect", { x: x - 20, y: baseY - h, width: 40, height: h, rx: 6, fill: color, opacity: i === 2 ? 1 : 0.55 }),
|
||
text(x, baseY + 18, label, 11, C.ink2, "middle"),
|
||
text(x, baseY - h - 8, `${val} bar`, 11.5, C.ink, "middle", "700"),
|
||
);
|
||
}),
|
||
caption("while water remains, physics pins the pressure to the temperature"),
|
||
];
|
||
},
|
||
ibCrack() {
|
||
const x = 280, y = 150, scale = 1.7;
|
||
return [
|
||
halfBean(x, y, scale, {
|
||
shellFill: C.brown, strokeWidth: 5, stroke: C.dark,
|
||
children: [
|
||
el("circle", { cx: 0, cy: 0, r: 26, fill: C.tan }),
|
||
...[0, 1, 2, 3].map((i) => {
|
||
const a = i * (Math.PI / 2) + Math.PI / 4;
|
||
const x1 = Math.cos(a) * 30, y1 = Math.sin(a) * 30, x2 = Math.cos(a) * 40, y2 = Math.sin(a) * 40;
|
||
return el("path", { d: `M${x1} ${y1} L${x2} ${y2}`, stroke: C.ember, "stroke-width": 3, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i}` });
|
||
}),
|
||
...[[-30, -46], [30, -46], [-36, 40], [36, 40]].map(([sx, sy]) =>
|
||
el("path", { d: `M${sx - 4} ${sy} l 8 0`, stroke: C.paper, "stroke-width": 1.6 }),
|
||
),
|
||
],
|
||
}),
|
||
el("g", { transform: `translate(${x} ${y}) scale(${scale})` },
|
||
el("path", { d: "M-2 -50 L 6 -20 L -6 6 L 6 30 L -2 52", fill: "none", stroke: C.paper, "stroke-width": 2.6, class: "ac-crackline" }),
|
||
),
|
||
text(x, y - scale * 58 - 14, "stiff, dried shell", 12, C.ink2, "middle"),
|
||
text(x, 250, "fewer than 1 in 10 beans pops audibly", 12, C.ember, "middle", "700"),
|
||
caption("a rubbery, pressurized core strains against a stiff shell — until it fails"),
|
||
];
|
||
},
|
||
ibFoam() {
|
||
const y = 150, leftX = 160, rightX = 400;
|
||
const pores = (n, seed) => [...Array(n)].map((_, i) => {
|
||
const ang = (i / n) * Math.PI * 2 + seed;
|
||
const rad = 14 + (i % 3) * 8;
|
||
return el("circle", { cx: Math.cos(ang) * rad, cy: Math.sin(ang) * rad, r: 3 + (i % 2), fill: C.paper, opacity: 0.85 });
|
||
});
|
||
return [
|
||
halfBean(leftX, y, 1.3, { shellFill: C.green, children: pores(5, 0) }),
|
||
text(leftX, y + 80, "green", 12.5, C.ink, "middle", "700"),
|
||
text(leftX, y + 98, "14% air · density 1.1", 11, C.ink2, "middle"),
|
||
halfBean(rightX, y, 1.3, { shellFill: C.tan, class: "ac-grow-foam", children: pores(11, 1) }),
|
||
text(rightX, y + 80, "roasted", 12.5, C.ink, "middle", "700"),
|
||
text(rightX, y + 98, "~46% air · density 0.65", 11, C.ink2, "middle"),
|
||
el("path", { d: `M${leftX + 70} ${y} L ${rightX - 70} ${y}`, stroke: C.ember, "stroke-width": 2, "stroke-dasharray": "4 4" }),
|
||
text(280, y - 6, "≈1.5×", 12.5, C.ember, "middle", "700"),
|
||
text(280, 244, "expansion is steady — not a sudden pop", 12, C.ink2, "middle"),
|
||
caption("half the roasted bean is air — and it swelled steadily, not in one bang"),
|
||
];
|
||
},
|
||
ibMortar() {
|
||
const gx = 150, gy = 56, cw = 100, ch = 58;
|
||
const bricks = [];
|
||
for (let r = 0; r < 2; r++)
|
||
for (let c = 0; c < 3; c++)
|
||
bricks.push(el("rect", { x: gx + c * cw, y: gy + r * ch, width: cw - 10, height: ch - 10, rx: 6, fill: C.tan, class: `ac-mortar-fade ac-delay-${(r + c) % 3}` }));
|
||
const rebarXs = [gx + 8, gx + 8 + cw, gx + 8 + cw * 2];
|
||
const rebar = rebarXs.map((rx, i) =>
|
||
el("g", {},
|
||
el("rect", { x: rx, y: gy - 16, width: 9, height: ch * 2 + 30, rx: 4, fill: C.dark }),
|
||
i === 1 ? el("path", { d: `M${rx + 4} ${gy + ch * 2 - 2} l 6 10 l -6 8 l 6 10`, stroke: C.paper, "stroke-width": 2, fill: "none", class: "ac-rebar-snap" }) : null,
|
||
),
|
||
);
|
||
return [
|
||
text(280, 34, "cell wall, close up", 12, C.ink2, "middle"),
|
||
...bricks, ...rebar,
|
||
text(280, 210, "mortar: soft sugars & gums", 12, C.tan, "middle", "700"),
|
||
text(280, 226, "— up to 60% dissolves away", 11, C.ink2, "middle"),
|
||
text(280, 244, "rebar: cellulose barely reacts", 12, C.dark, "middle", "700"),
|
||
text(280, 260, "chars & snaps only very dark — 2nd crack", 10.5, C.ink2, "middle"),
|
||
caption("the wall's soft mortar dissolves so the bean stretches — cellulose rebar holds"),
|
||
];
|
||
},
|
||
ibCo2() {
|
||
const beanX = 130, groundX = 380, y = 130;
|
||
return [
|
||
bean(beanX, y, 1.7, C.brown, { crack: true }),
|
||
...[0, 1, 2].map((i) => el("circle", { cx: beanX - 14 + i * 14, cy: y - 40 - i * 6, r: 3, fill: C.tan, class: `ac-rise ac-delay-${i}` })),
|
||
text(beanX, y + 70, "whole bean", 12.5, C.ink, "middle", "700"),
|
||
text(beanX, y + 88, "~a month to degas", 10.5, C.ink2, "middle"),
|
||
...[...Array(9)].map((_, i) => el("circle", { cx: groundX - 30 + (i % 3) * 30, cy: y + 10 + Math.floor(i / 3) * 18, r: 8, fill: C.brown })),
|
||
...[0, 1, 2, 3, 4].map((i) => el("circle", { cx: groundX - 24 + i * 12, cy: y - 30 - i * 4, r: 3.5, fill: C.tan, class: `ac-burst ac-delay-${i % 3}` })),
|
||
text(groundX, y + 70, "ground", 12.5, C.ink, "middle", "700"),
|
||
text(groundX, y + 88, "grinding: half escapes fast", 10.5, C.ink2, "middle"),
|
||
el("path", { d: "M255 220 h 50 l -6 26 h -38 Z", fill: C.paper, stroke: C.ink2, "stroke-width": 2 }),
|
||
el("rect", { x: 259, y: 220, width: 42, height: 6, fill: C.tan }),
|
||
text(280, 258, "bloom & crema — CO₂ escaping into the cup", 11, C.ink2, "middle"),
|
||
caption("a dark roast holds ~8 liters of CO₂ per kilo — bloom and crema are its escape"),
|
||
];
|
||
},
|
||
ibColor() {
|
||
const leftX = 150, rightX = 410, beanY = 96;
|
||
const bars = (cx, vol, den) => {
|
||
const hMax = 40, y0 = 196;
|
||
const volH = (vol / 1.8) * hMax, denH = (den / 0.9) * hMax;
|
||
return [
|
||
el("rect", { x: cx - 34, y: y0 + hMax - volH, width: 22, height: volH, rx: 3, fill: C.ember }),
|
||
text(cx - 23, y0 + hMax + 14, `${vol}×`, 10.5, C.ink2, "middle"),
|
||
el("rect", { x: cx + 12, y: y0 + hMax - denH, width: 22, height: denH, rx: 3, fill: C.water }),
|
||
text(cx + 23, y0 + hMax + 14, `${den}`, 10.5, C.ink2, "middle"),
|
||
];
|
||
};
|
||
return [
|
||
bean(leftX, beanY, 1.4, C.brown),
|
||
bean(rightX, beanY, 1.4, C.brown),
|
||
text(280, beanY + 6, "=", 26, C.pass, "middle", "700"),
|
||
text(leftX, beanY + 62, "fast & hot", 12.5, C.ink, "middle", "700"),
|
||
text(rightX, beanY + 62, "slow & low", 12.5, C.ink, "middle", "700"),
|
||
text(leftX - 23, 190, "vol", 10, C.ink2, "middle"),
|
||
text(leftX + 23, 190, "den", 10, C.ink2, "middle"),
|
||
text(rightX - 23, 190, "vol", 10, C.ink2, "middle"),
|
||
text(rightX + 23, 190, "den", 10, C.ink2, "middle"),
|
||
...bars(leftX, 1.7, 0.62),
|
||
...bars(rightX, 1.4, 0.75),
|
||
text(280, 220, "≠", 22, C.fail, "middle", "700"),
|
||
caption("equal color can hide different chemistry — that's why we log the whole curve"),
|
||
];
|
||
},
|
||
|
||
// ── Roasting by the senses ──
|
||
snInstruments() {
|
||
return [
|
||
senseIcon("nose", 160, 70, C.ink2),
|
||
senseIcon("eye", 280, 70, C.ink2),
|
||
senseIcon("ear", 400, 70, C.ink2),
|
||
text(160, 100, "smell", 11, C.ink2, "middle"),
|
||
text(280, 100, "sight", 11, C.ink2, "middle"),
|
||
text(400, 100, "hearing", 11, C.ink2, "middle"),
|
||
el("rect", { x: 271, y: 122, width: 18, height: 66, rx: 9, fill: C.paper, stroke: C.ink2, "stroke-width": 2 }),
|
||
el("circle", { cx: 280, cy: 196, r: 13, fill: C.ember }),
|
||
el("rect", { x: 276, y: 130, width: 8, height: 56, rx: 4, fill: C.ember, class: "ac-fill-up" }),
|
||
el("rect", { x: 330, y: 150, width: 150, height: 30, rx: 8, fill: C.emberSoft, stroke: C.ember, "stroke-width": 1.4 }),
|
||
text(405, 170, "±50°F between machines", 11, C.ember, "middle", "700"),
|
||
...stageStrip([
|
||
{ x: 60, w: 140, color: C.yellow, label: "drying" },
|
||
{ x: 210, w: 140, color: C.tan, label: "Maillard" },
|
||
{ x: 360, w: 140, color: C.brown, label: "development" },
|
||
]),
|
||
caption("probes disagree machine to machine — your senses read the beans directly"),
|
||
];
|
||
},
|
||
snGrass() {
|
||
return [
|
||
bean(170, 150, 1.9, C.green, { class: "ac-grass-fade" }),
|
||
steamWisps(170, 92, 3),
|
||
text(170, 60, "steam — not smoke", 11.5, C.ink2, "middle"),
|
||
text(360, 110, "cut grass", 13, C.pass, "start", "700"),
|
||
text(360, 150, "wet hay,", 13, C.tan, "start", "700"),
|
||
text(360, 170, "damp grain", 13, C.tan, "start", "700"),
|
||
caption("early aromas ride on visible steam: cut grass, then wet hay and damp grain"),
|
||
];
|
||
},
|
||
snHinge() {
|
||
return [
|
||
senseIcon("nose", 280, 66, C.ink2),
|
||
bean(280, 150, 1.7, C.yellow, { class: "ac-breathe" }),
|
||
el("g", { class: "ac-cross-a" }, text(280, 222, "drying hay", 14, C.ink2, "middle", "700")),
|
||
el("g", { class: "ac-cross-b" }, text(280, 222, "baking biscuits", 14, C.ember, "middle", "700")),
|
||
text(150, 150, "≈330 °F", 12.5, C.ink, "middle", "700"),
|
||
...stageStrip(
|
||
[
|
||
{ x: 90, w: 190, color: C.yellow, label: "drying" },
|
||
{ x: 280, w: 190, color: C.tan, label: "Maillard" },
|
||
],
|
||
{ markerX: 280, markerColor: C.ember, markerCls: "ac-marker-cross" },
|
||
),
|
||
caption("the ten-second moment barn smell turns bakery — roasters mark it by nose"),
|
||
];
|
||
},
|
||
snToast() {
|
||
const items = [["toast", 210, 0], ["nuts", 170, 1], ["caramel", 130, 2]];
|
||
return [
|
||
bean(160, 190, 1.7, C.tan, { class: "ac-maillard-shift" }),
|
||
steamWisps(160, 128, 2),
|
||
text(160, 92, "the smell dries out", 11.5, C.ink2, "middle"),
|
||
...items.map(([label, y, i]) =>
|
||
el("g", { class: `ac-rise ac-delay-${i}` }, text(360, y, label, 14, i === 2 ? C.ember : C.tan, "middle", "700")),
|
||
),
|
||
el("line", { x1: 300, y1: 230, x2: 420, y2: 230, stroke: C.line, "stroke-width": 1.5 }),
|
||
text(360, 246, "≈370 °F — sugars melt", 11.5, C.ink2, "middle"),
|
||
caption("toast, then nuts, then caramel — and the smell itself turns from damp to dry"),
|
||
];
|
||
},
|
||
snSight() {
|
||
const stages = [
|
||
["green", C.green, false],
|
||
["pale yellow", C.yellow, false],
|
||
["marbled tan", "#c9a468", false],
|
||
["brown", C.brown, false],
|
||
["expanded", "#9c6a3a", true],
|
||
];
|
||
const xs = [90, 190, 290, 390, 490];
|
||
const out = [];
|
||
stages.forEach(([label, color, cracked], i) => {
|
||
out.push(bean(xs[i], 130, 1.05, color, cracked ? { crack: true } : {}));
|
||
out.push(text(xs[i], 190, label, 10.5, C.ink2, "middle"));
|
||
});
|
||
out.push(...[0, 1].map((j) => el("path", { d: `M${xs[4] + 22 + j * 6} ${118 - j * 8} q 8 3 12 -3`, stroke: C.tan, "stroke-width": 2, fill: "none", class: `ac-drift ac-delay-${j}` })));
|
||
out.push(text(290, 208, "mottled at light roast = normal", 11.5, C.ember, "middle", "700"));
|
||
out.push(caption("yellow, marbled, brown; the crease stays shut until first crack opens it"));
|
||
return out;
|
||
},
|
||
snFirstcrack() {
|
||
const baseY = 170;
|
||
const spikes = [80, 150, 230, 340, 420];
|
||
return [
|
||
senseIcon("ear", 280, 60, C.ink2),
|
||
el("line", { x1: 50, y1: baseY, x2: 510, y2: baseY, stroke: C.line, "stroke-width": 1.5 }),
|
||
...spikes.map((x, i) =>
|
||
el("line", { x1: x, y1: baseY, x2: x, y2: baseY - 46 - (i % 2) * 10, stroke: C.ink, "stroke-width": 3, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i % 3}` }),
|
||
),
|
||
text(280, baseY + 24, "~800 Hz, loud, discrete — popcorn", 11.5, C.ink2, "middle"),
|
||
el("rect", { x: 55, y: 200, width: 450, height: 30, rx: 8, fill: C.paper, stroke: C.line }),
|
||
text(280, 219, "call it on 3–5 rapid pops, not the first tick", 10, C.ink, "middle", "700"),
|
||
text(280, 248, "fewer than 1 in 10 beans pops audibly", 11.5, C.ember, "middle", "700"),
|
||
caption("sharp, low, popcorn pops — mark first crack at 3–5 in quick succession"),
|
||
];
|
||
},
|
||
snSecondcrack() {
|
||
const baseY = 170;
|
||
const spikes = [...Array(9)].map((_, i) => 60 + i * 32);
|
||
return [
|
||
bean(430, 150, 1.4, C.dark, { crack: true }),
|
||
el("circle", { cx: 424, cy: 140, r: 4, fill: "#f0d9a0", opacity: 0.85 }),
|
||
text(430, 210, "dry, brittle, oily", 11, C.ink2, "middle"),
|
||
el("line", { x1: 40, y1: baseY, x2: 340, y2: baseY, stroke: C.line, "stroke-width": 1.5 }),
|
||
...spikes.map((x, i) =>
|
||
el("line", { x1: x, y1: baseY, x2: x, y2: baseY - 18 - (i % 2) * 6, stroke: C.ember, "stroke-width": 2, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i % 3}` }),
|
||
),
|
||
text(220, baseY + 24, "~19× higher, 5× faster, quieter", 10, C.ink2, "middle"),
|
||
text(220, baseY + 40, "rice krispies · electrical sparking", 10.5, C.ink2, "middle"),
|
||
caption("a fast, shallow crackle from a dry, brittle bean — gas fracture, not steam pop"),
|
||
];
|
||
},
|
||
snArcs() {
|
||
const X0 = 60, X1 = 500, Y0 = 70, YB = 210;
|
||
return [
|
||
el("path", { d: `M${X0} ${YB} H ${X1} M${X0} ${YB} V ${Y0}`, stroke: C.line, "stroke-width": 1.5, fill: "none" }),
|
||
arcCurve([[X0, Y0 + 10], [180, 90], [340, 140], [X1, 170]], C.water, "ac-draw"),
|
||
text(70, 82, "acidity", 11, C.water, "start", "700"),
|
||
arcCurve([[X0, YB - 20], [220, Y0 + 5], [320, Y0 + 15], [X1, YB - 40]], C.tan, "ac-draw"),
|
||
text(210, Y0 - 6, "sweetness", 11, C.tan, "middle", "700"),
|
||
arcCurve([[X0, YB - 60], [220, YB - 100], [360, YB - 90], [X1, YB - 130]], C.brown, "ac-draw"),
|
||
text(360, YB - 96, "body", 11, C.brown, "start", "700"),
|
||
arcCurve([[X0, YB - 4], [300, YB - 10], [400, YB - 40], [X1, Y0 + 4]], C.fail, "ac-draw"),
|
||
text(430, YB - 34, "bitterness", 11, C.fail, "start", "700"),
|
||
...stageStrip([
|
||
{ x: X0, w: 146, color: C.yellow, label: "drying" },
|
||
{ x: X0 + 146, w: 146, color: C.tan, label: "Maillard" },
|
||
{ x: X0 + 292, w: 148, color: C.brown, label: "development" },
|
||
], { markerX: 300, markerColor: C.ember, markerCls: "ac-slide-x" }),
|
||
caption("acidity only falls · sweetness peaks · body rounds over · bitterness arrives"),
|
||
];
|
||
},
|
||
snDroplevels() {
|
||
const stages = [
|
||
["City", "#c9a468", "bright·juicy"],
|
||
["City+", "#b98d54", "balance point"],
|
||
["Full City", "#96652f", "bittersweet"],
|
||
["FC+", "#7d4f26", "espresso"],
|
||
["Vienna", "#5f3a1c", "eclipses origin"],
|
||
["French", "#3c2413", "thin & smoky"],
|
||
];
|
||
const xs = [72, 158, 244, 330, 416, 502];
|
||
const out = [];
|
||
stages.forEach(([label, color, tag], i) => {
|
||
out.push(bean(xs[i], 120, 0.85, color));
|
||
out.push(text(xs[i], 168, label, 10.5, C.ink, "middle", "700"));
|
||
out.push(text(xs[i], 182, tag, 9.5, C.ink2, "middle"));
|
||
});
|
||
out.push(caption("the same coffee, six different cups — the drop decides which one you drink"));
|
||
return out;
|
||
},
|
||
snSmoke() {
|
||
const stages = [
|
||
[110, 0.4, C.ink2, "steam"],
|
||
[280, 0.6, "#a89e8f", "thin smoke"],
|
||
[450, 0.9, "#3a332c", "heavy smoke"],
|
||
];
|
||
const out = [];
|
||
stages.forEach(([x, op, color, label], i) => {
|
||
out.push(...[0, 1, 2].map((j) =>
|
||
el("path", {
|
||
d: `M${x + (j - 1) * 16} 200 c -6 -14, 6 -22, 0 -36 c -5 -12, 5 -20, 0 -34`,
|
||
fill: "none", stroke: color, "stroke-width": 3 + i * 1.5, "stroke-linecap": "round",
|
||
class: `ac-steam ac-delay-${j}`, opacity: op,
|
||
}),
|
||
));
|
||
out.push(text(x, 224, label, 12, color === C.ink2 ? C.ink2 : color, "middle", "700"));
|
||
});
|
||
out.push(text(80, 250, "pre-smoke = ~30s warn", 10, C.ink2, "start"));
|
||
out.push(text(280, 250, "heavy smoke = stop", 10.5, C.fail, "middle", "700"));
|
||
out.push(text(500, 250, "acrid next day = too hot", 10, C.ink2, "end"));
|
||
out.push(caption("the whole aroma timeline in four words: steam slowly becomes smoke"));
|
||
return out;
|
||
},
|
||
|
||
// ── When roasts go wrong ──
|
||
wwGood() {
|
||
return [
|
||
el("path", { d: "M60 220 H 500 M60 220 V 60", stroke: C.line, "stroke-width": 1.5, fill: "none" }),
|
||
text(52, 70, "RoR", 11, C.ink2, "end"),
|
||
arcCurve([[64, 84], [160, 118], [280, 150], [400, 176], [494, 196]], C.pass, "ac-draw"),
|
||
...["even color", "interior matches exterior", "weight loss on target", "sweetness survives cooling"].map((item, i) =>
|
||
el("g", {},
|
||
el("circle", { cx: 340, cy: 60 + i * 28, r: 7, fill: C.pass }),
|
||
el("path", { d: `M336 ${60 + i * 28} l 3 3 l 5 -6`, stroke: C.paper, "stroke-width": 1.6, fill: "none" }),
|
||
text(354, 65 + i * 28, item, 11, C.ink2, "start"),
|
||
),
|
||
),
|
||
caption("a smoothly falling RoR, even color, matched core & surface, a cup that lasts"),
|
||
];
|
||
},
|
||
wwUnderdev() {
|
||
return [
|
||
halfBean(160, 150, 1.7, {
|
||
shellFill: C.brown, strokeWidth: 3,
|
||
children: [el("circle", { cx: 0, cy: 6, r: 26, fill: "#e8d9b0" })],
|
||
}),
|
||
text(160, 232, "dark outside, pale inside", 11.5, C.ink2, "middle"),
|
||
el("path", { d: "M340 220 H 500 M340 220 V 100", stroke: C.line, "stroke-width": 1.3, fill: "none" }),
|
||
arcCurve([[344, 130], [400, 170], [440, 196]], C.fail, "ac-draw"),
|
||
el("line", { x1: 440, y1: 100, x2: 440, y2: 220, stroke: C.ink2, "stroke-width": 1.4, "stroke-dasharray": "3 3" }),
|
||
text(440, 92, "dropped early", 10.5, C.ink2, "middle"),
|
||
text(420, 240, "grassy · sour · papery", 12, C.fail, "middle", "700"),
|
||
caption("brown outside, raw inside — heat never reached the core"),
|
||
];
|
||
},
|
||
wwBaked() {
|
||
return [
|
||
bean(150, 140, 1.8, C.brown),
|
||
text(150, 210, "looks fine!", 12.5, C.pass, "middle", "700"),
|
||
el("path", { d: "M330 220 H 500 M330 220 V 90", stroke: C.line, "stroke-width": 1.3, fill: "none" }),
|
||
arcCurve([[334, 110], [380, 140], [420, 148], [470, 150], [496, 152]], C.fail, "ac-draw"),
|
||
el("path", { d: "M334 110 L400 140 L496 175", fill: "none", stroke: C.pass, "stroke-width": 2.2, "stroke-dasharray": "6 5" }),
|
||
text(415, 100, "stalled — flat", 11, C.fail, "middle", "700"),
|
||
text(280, 240, "flavor fades as it cools", 12.5, C.ink2, "middle"),
|
||
caption("a stalled roast spends the sugars without buying the flavor — and looks normal"),
|
||
];
|
||
},
|
||
wwCrashflick() {
|
||
return [
|
||
el("path", { d: "M60 230 H 500 M60 230 V 70", stroke: C.line, "stroke-width": 1.5, fill: "none" }),
|
||
text(52, 84, "RoR", 11, C.ink2, "end"),
|
||
arcCurve([[64, 96], [180, 140], [260, 158], [300, 210], [340, 168], [400, 176], [494, 200]], C.fail, "ac-draw"),
|
||
el("circle", { cx: 300, cy: 210, r: 5.5, fill: C.fail, class: "ac-pulse" }),
|
||
text(300, 228, "crash", 11.5, C.fail, "middle", "700"),
|
||
text(280, 248, "steam dump + heat cut too late", 10.5, C.ink2, "middle"),
|
||
el("circle", { cx: 340, cy: 168, r: 5.5, fill: C.fail, class: "ac-pulse ac-delay-1" }),
|
||
text(360, 150, "flick", 11.5, C.fail, "middle", "700"),
|
||
text(420, 96, "gas added in a panic", 10.5, C.ink2, "middle"),
|
||
text(190, 96, "fix: smaller cuts,", 10.5, C.ember, "middle", "700"),
|
||
text(190, 110, "~45s BEFORE crack", 10.5, C.ember, "middle", "700"),
|
||
caption("the V-shape around first crack — crash bakes the cup, flick sears it roasty"),
|
||
];
|
||
},
|
||
wwScorch() {
|
||
const items = [
|
||
["scorch", 130, C.tan, "early, drum too hot"],
|
||
["tipping", 280, "#6b4423", "too much heat, soft"],
|
||
["facing", 430, C.dark, "late, held on metal"],
|
||
];
|
||
const out = [];
|
||
items.forEach(([label, x, color, sub]) => {
|
||
out.push(bean(x, 130, 1.3, color));
|
||
if (label === "scorch")
|
||
out.push(...[0, 1, 2].map((j) => el("circle", { cx: x - 10 + j * 10, cy: 118 + (j % 2) * 6, r: 2.2, fill: C.dark })));
|
||
if (label === "tipping")
|
||
out.push(el("path", { d: `M${x} 96 q 6 -10 0 -18 q -6 8 0 18`, fill: C.dark }));
|
||
if (label === "facing")
|
||
out.push(el("ellipse", { cx: x, cy: 130, rx: 22, ry: 30, fill: C.dark, opacity: 0.75 }));
|
||
out.push(text(x, 190, label, 12.5, C.ink, "middle", "700"));
|
||
out.push(text(x, 206, sub, 10, C.ink2, "middle"));
|
||
});
|
||
out.push(text(280, 240, "burnt · ashy · smoky over a hollow cup", 12, C.fail, "middle", "700"));
|
||
out.push(caption("three marks, one story: the surface got hotter than the bean could carry"));
|
||
return out;
|
||
},
|
||
wwFast() {
|
||
return [
|
||
halfBean(160, 150, 1.8, {
|
||
shellFill: C.dark, strokeWidth: 3,
|
||
children: [el("circle", { cx: 4, cy: 10, r: 24, fill: "#dccfa8" })],
|
||
}),
|
||
text(160, 236, "char over raw", 11.5, C.ink2, "middle"),
|
||
el("path", { d: "M330 220 H 500 M330 220 V 70", stroke: C.line, "stroke-width": 1.3, fill: "none" }),
|
||
arcCurve([[334, 210], [360, 90], [390, 130], [430, 170], [470, 196], [496, 206]], C.fail, "ac-draw"),
|
||
text(360, 78, "towering, early", 10.5, C.ink2, "middle"),
|
||
text(460, 240, "burnt AND grassy", 12, C.fail, "middle", "700"),
|
||
caption("the one defect no drop change fixes — outside charred while inside dried"),
|
||
];
|
||
},
|
||
wwQuakers() {
|
||
const xs = [90, 150, 210, 270, 330, 390, 450];
|
||
const paleIdx = new Set([2, 5]);
|
||
const out = [];
|
||
xs.forEach((x, i) => {
|
||
const pale = paleIdx.has(i);
|
||
out.push(bean(x, 150, 0.95, pale ? "#d8c58c" : C.brown, {}));
|
||
if (pale) out.push(el("circle", { cx: x, cy: 150, r: 30, fill: "none", stroke: C.ember, "stroke-width": 2, class: "ac-ring" }));
|
||
});
|
||
out.push(
|
||
el("g", { class: "ac-tamp" },
|
||
el("path", { d: "M210 70 L 200 118 M210 70 L 220 118", stroke: C.ink2, "stroke-width": 3, "stroke-linecap": "round", fill: "none" }),
|
||
),
|
||
);
|
||
out.push(text(280, 200, "unripe: no sugar = no browning fuel", 11.5, C.ink2, "middle"));
|
||
out.push(caption("no sugars, no Maillard — color is chemistry, and these beans came without fuel"));
|
||
return out;
|
||
},
|
||
wwUneven() {
|
||
const colors = ["#cdbd8f", "#c2a468", "#b98d54", "#a5713a", "#8a5a30", "#6b4423", "#54371f"];
|
||
const xs = colors.map((_, i) => 80 + i * 62);
|
||
const out = colors.map((c, i) => bean(xs[i], 120, 0.9, c));
|
||
out.push(
|
||
text(150, 190, "few pale outliers", 11.5, C.ink2, "middle", "700"),
|
||
text(150, 206, "→ quakers (green's fault)", 10.5, C.ink2, "middle"),
|
||
text(430, 190, "continuous spread", 11.5, C.ink2, "middle", "700"),
|
||
text(430, 206, "→ moisture, drum, airflow", 10.5, C.ink2, "middle"),
|
||
caption("read the pattern: outliers are the green's fault, a spread is the roast's"),
|
||
);
|
||
return out;
|
||
},
|
||
wwAirflow() {
|
||
return [
|
||
el("circle", { cx: 200, cy: 130, r: 74, fill: "none", stroke: C.ink2, "stroke-width": 4 }),
|
||
...[0, 1, 2].map((i) => el("path", { d: `M${140 + i * 60} 210 v 30`, stroke: C.water, "stroke-width": 3, "stroke-linecap": "round", class: `ac-rise ac-delay-${i}` })),
|
||
text(200, 258, "airflow", 11, C.water, "middle"),
|
||
el("g", { class: "ac-tumble" }, bean(180, 116, 0.5, C.tan, { shadow: false }), bean(220, 132, 0.5, C.brown, { shadow: false }), bean(196, 150, 0.5, C.tan, { shadow: false })),
|
||
text(400, 90, "too little →", 11.5, C.fail, "start", "700"),
|
||
text(400, 106, "smoke settles, ashy,", 10.5, C.ink2, "start"),
|
||
text(400, 120, "chaff fire risk", 10.5, C.ink2, "start"),
|
||
text(400, 150, "too much →", 11.5, C.fail, "start", "700"),
|
||
text(400, 166, "strips aromatics,", 10.5, C.ink2, "start"),
|
||
text(400, 180, "stalls the roast", 10.5, C.ink2, "start"),
|
||
...stageStrip([
|
||
{ x: 60, w: 150, color: C.yellow, label: "low: drying" },
|
||
{ x: 214, w: 150, color: C.tan, label: "medium: browning" },
|
||
{ x: 368, w: 132, color: C.ember, label: "high: crack→drop" },
|
||
]),
|
||
caption("most drum heat rides the air — low drying, medium browning, high through crack"),
|
||
];
|
||
},
|
||
wwDiagnose() {
|
||
const rows = [
|
||
["sour & thin", "→ more development", 0],
|
||
["flat & cardboard", "→ keep momentum, don't stall", 1],
|
||
["smoky & dull", "→ fix crash-flick, add air", 2],
|
||
];
|
||
const out = [];
|
||
rows.forEach(([taste, fix, i], idx) => {
|
||
const y = 76 + idx * 46;
|
||
out.push(
|
||
el("rect", { x: 60, y: y - 22, width: 440, height: 38, rx: 8, fill: C.emberSoft, class: `ac-row-glow ac-delay-${i}` }),
|
||
text(80, y, taste, 12.5, C.ink, "start", "700"),
|
||
text(280, y, fix, 12, C.ember, "start"),
|
||
);
|
||
});
|
||
out.push(text(280, 236, "one change per batch — and check your brew first", 12.5, C.ink, "middle", "700"));
|
||
out.push(caption("the cup names the fix — change one thing, rule out the brew before the roast"));
|
||
return out;
|
||
},
|
||
};
|
||
|
||
|