Academy v4: 3 research-backed lessons (30 slides + audio); new bean logo; custom plan/lot names
Test and deploy / test-and-deploy (push) Successful in 59s
Test and deploy / test-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ec078116b4
commit
8b80c3fa6f
@@ -87,6 +87,89 @@ function flame(x, y, scale = 1) {
|
||||
);
|
||||
}
|
||||
|
||||
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 shell = el("path", {
|
||||
d: shapeD,
|
||||
fill: opts.shellFill ?? C.tan,
|
||||
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})` }, clip, 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,
|
||||
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;
|
||||
@@ -546,6 +629,576 @@ export const SCENES = {
|
||||
}
|
||||
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), bean(220, 132, 0.5, C.brown), bean(196, 150, 0.5, C.tan)),
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user