// 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"), ); }