Test and deploy / test-and-deploy (push) Successful in 55s
- New flavor-wheel.js: two-ring sunburst (families inner, descriptors outer) in SCA hue families; tapping a wedge toggles the same flavor tag as the checkbox list; selections render bold with a dark outline; respects the tag limit; labels drawn above all wedges so long names never clip (screenshot-verified in headless Chromium) - Cupping radar: one hue per attribute on labels + vertex dots Co-Authored-By: Claude Fable 5 <[email protected]>
137 lines
4.6 KiB
JavaScript
137 lines
4.6 KiB
JavaScript
// SCA-style two-ring flavor wheel: inner wedges are flavor families, outer wedges are the
|
|
// individual descriptors (clickable — they toggle the same flavor tags as the checkbox
|
|
// list). Pure SVG, no dependencies; colors follow the familiar SCA hue families.
|
|
|
|
const NS = "http://www.w3.org/2000/svg";
|
|
const CX = 170, CY = 170;
|
|
const R_HUB = 34, R_FAM = 92, R_OUT = 168;
|
|
|
|
export const FAMILY_COLORS = {
|
|
fruity: "#c94a53",
|
|
floral: "#d3799f",
|
|
sweet: "#dd8f4c",
|
|
nutty_cocoa: "#a06a3f",
|
|
spice: "#b0442e",
|
|
roasted: "#7a4a28",
|
|
green_vegetal: "#5c8a4e",
|
|
fermented_sour: "#bba033",
|
|
other: "#71879c",
|
|
};
|
|
|
|
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;
|
|
}
|
|
const pt = (r, a) => [CX + r * Math.cos(a), CY + r * Math.sin(a)];
|
|
|
|
function sector(r0, r1, a0, a1) {
|
|
const large = a1 - a0 > Math.PI ? 1 : 0;
|
|
const [x0, y0] = pt(r0, a0), [x1, y1] = pt(r1, a0);
|
|
const [x2, y2] = pt(r1, a1), [x3, y3] = pt(r0, a1);
|
|
return `M${x0} ${y0} L${x1} ${y1} A${r1} ${r1} 0 ${large} 1 ${x2} ${y2} L${x3} ${y3} A${r0} ${r0} 0 ${large} 0 ${x0} ${y0} Z`;
|
|
}
|
|
|
|
/** Radial label that always reads outward and stays upright on the left half. */
|
|
function radialText(r, angle, str, size, fill, weight = "400") {
|
|
const deg = (angle * 180) / Math.PI;
|
|
const flip = deg > 90 && deg < 270;
|
|
const [x, y] = pt(r, angle);
|
|
return el(
|
|
"text",
|
|
{
|
|
x, y,
|
|
fill,
|
|
"font-size": size,
|
|
"font-weight": weight,
|
|
"text-anchor": flip ? "end" : "start",
|
|
"dominant-baseline": "middle",
|
|
transform: `rotate(${flip ? deg + 180 : deg} ${x} ${y})`,
|
|
},
|
|
str,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {SVGElement} svg mount (viewBox 0 0 340 340 recommended)
|
|
* @param {object} taxonomy FLAVOR_TAXONOMY from shared/cupping.js
|
|
* @param {string[]} selected currently selected tags (family.sub.descriptor)
|
|
* @param {(tag: string, on: boolean) => void} onToggle
|
|
* @param {boolean} atLimit when true, unselected wedges don't toggle on
|
|
*/
|
|
export function renderFlavorWheel(svg, taxonomy, selected, onToggle, atLimit = false) {
|
|
svg.setAttribute("viewBox", "0 0 340 340");
|
|
svg.replaceChildren();
|
|
const entries = Object.entries(taxonomy).map(([familyId, family]) => ({
|
|
familyId,
|
|
family,
|
|
descriptors: Object.entries(family.subgroups).flatMap(([subId, list]) =>
|
|
list.map((d) => ({ tag: `${familyId}.${subId}.${d}`, name: d.replaceAll("_", " ") })),
|
|
),
|
|
}));
|
|
const total = entries.reduce((s, e) => s + e.descriptors.length, 0);
|
|
// Short names for the narrow inner ring — the checkbox list keeps the full labels.
|
|
const SHORT = {
|
|
nutty_cocoa: "Nutty",
|
|
green_vegetal: "Green",
|
|
fermented_sour: "Sour",
|
|
};
|
|
let angle = -Math.PI / 2;
|
|
const labels = []; // drawn after every wedge so no wedge can clip a label
|
|
|
|
for (const { familyId, family, descriptors } of entries) {
|
|
const span = (descriptors.length / total) * Math.PI * 2;
|
|
const color = FAMILY_COLORS[familyId] ?? "#888";
|
|
|
|
const famSelected = selected.some((t) => t.startsWith(`${familyId}.`));
|
|
svg.append(
|
|
el("path", {
|
|
d: sector(R_HUB, R_FAM, angle, angle + span),
|
|
fill: color,
|
|
opacity: famSelected ? 0.95 : 0.75,
|
|
stroke: "#fbf8f4",
|
|
"stroke-width": 1.5,
|
|
}),
|
|
);
|
|
labels.push(
|
|
radialText(R_HUB + 8, angle + span / 2, SHORT[familyId] ?? family.label, 11, "#fff", "700"),
|
|
);
|
|
|
|
descriptors.forEach(({ tag, name }, i) => {
|
|
const a0 = angle + (i / descriptors.length) * span;
|
|
const a1 = angle + ((i + 1) / descriptors.length) * span;
|
|
const on = selected.includes(tag);
|
|
const wedge = el("path", {
|
|
d: sector(R_FAM + 2, R_OUT, a0, a1),
|
|
fill: color,
|
|
opacity: on ? 1 : 0.32,
|
|
stroke: on ? "#1b1614" : "#fbf8f4",
|
|
"stroke-width": on ? 2 : 1,
|
|
cursor: "pointer",
|
|
role: "button",
|
|
"aria-pressed": String(on),
|
|
"aria-label": `${family.label}: ${name}`,
|
|
});
|
|
wedge.append(el("title", {}, `${family.label} — ${name}`));
|
|
wedge.addEventListener("click", () => {
|
|
if (!on && atLimit) return;
|
|
onToggle(tag, !on);
|
|
});
|
|
svg.append(wedge);
|
|
labels.push(
|
|
radialText(R_FAM + 8, (a0 + a1) / 2, name, 8.5, on ? "#1b1614" : "#5b524b", on ? "700" : "400"),
|
|
);
|
|
});
|
|
angle += span;
|
|
}
|
|
// Descriptor labels must not swallow wedge clicks.
|
|
for (const label of labels) label.setAttribute("pointer-events", "none");
|
|
svg.append(...labels);
|
|
svg.append(
|
|
el("circle", { cx: CX, cy: CY, r: R_HUB - 2, fill: "#fbf8f4" }),
|
|
el("text", { x: CX, y: CY - 4, "text-anchor": "middle", "font-size": 10, fill: "#5b524b" }, "tap a"),
|
|
el("text", { x: CX, y: CY + 9, "text-anchor": "middle", "font-size": 10, fill: "#5b524b" }, "flavor"),
|
|
);
|
|
}
|