From ec078116b49132c79c332e3bca728796da96895b Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Sun, 9 Aug 2026 10:11:31 -0400 Subject: [PATCH] Add colored SCA-style flavor wheel to cupping; colorize the radar chart - 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 --- public/app.css | 10 +++ public/cupping.html | 7 ++ public/js/cupping.js | 36 +++++++++- public/js/flavor-wheel.js | 136 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 public/js/flavor-wheel.js diff --git a/public/app.css b/public/app.css index ca9e797..0261ad0 100644 --- a/public/app.css +++ b/public/app.css @@ -3300,3 +3300,13 @@ select.f { to { transform: translateX(400px); } } .ac-jb-marker { animation: ac-jb-marker-kf 14s linear infinite; } + +/* ── Flavor wheel (cupping) ───────────────────────────────────────────── */ +.flavor-wheel-wrap { + max-width: 480px; + margin: 0 auto 14px; +} +.flavor-wheel-wrap svg { + width: 100%; + display: block; +} diff --git a/public/cupping.html b/public/cupping.html index 784c42b..4b5186f 100644 --- a/public/cupping.html +++ b/public/cupping.html @@ -177,6 +177,13 @@ +
+ +
diff --git a/public/js/cupping.js b/public/js/cupping.js index 29e3859..8538d8e 100644 --- a/public/js/cupping.js +++ b/public/js/cupping.js @@ -2,6 +2,7 @@ import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__"; import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__"; import { showToast } from "./toast.js?v=__ASSET_VERSION__"; import { wireWhyPanels } from "./why-panels.js?v=__ASSET_VERSION__"; +import { renderFlavorWheel } from "./flavor-wheel.js?v=__ASSET_VERSION__"; import { SCORE_ATTRS, SCORE_LABELS, @@ -392,11 +393,24 @@ function toggleFlavorTag(tag, checked) { } else { data.flavor_tags = data.flavor_tags.filter((t) => t !== tag); } + renderWheel(); renderFlavorFamilies(); renderFlavorChips(); scheduleSave(); } +function renderWheel() { + const svg = document.getElementById("flavor-wheel"); + if (!svg) return; + renderFlavorWheel( + svg, + FLAVOR_TAXONOMY, + data.flavor_tags, + (tag, on) => toggleFlavorTag(tag, on), + data.flavor_tags.length >= MAX_FLAVOR_TAGS, + ); +} + function renderFlavorFamilies() { const wrap = document.getElementById("flavor-families"); const atLimit = data.flavor_tags.length >= MAX_FLAVOR_TAGS; @@ -438,6 +452,8 @@ function renderFlavorFamilies() { // ── Radar chart (pure SVG, no dependency) ── const RADAR_CENTER = 120; const RADAR_RADIUS = 90; +// One hue per attribute so the wheel reads as ten distinct spokes, not a grey web. +const AXIS_COLOR = (i) => `hsl(${(i * 36 + 8) % 360} 46% 40%)`; function radarPoints() { const n = SCORE_ATTRS.length; @@ -498,7 +514,8 @@ function initRadarStatic() { "text-anchor": "middle", "dominant-baseline": "middle", "font-size": "9", - fill: "var(--ink-2)", + "font-weight": "600", + fill: AXIS_COLOR(i), }); label.textContent = SCORE_LABELS[attr].split("/")[0]; svg.append(label); @@ -516,7 +533,21 @@ function initRadarStatic() { function renderRadar() { const shape = document.getElementById("radar-shape"); if (!shape) return; - shape.setAttribute("points", radarPoints().map((p) => `${p.x},${p.y}`).join(" ")); + const points = radarPoints(); + shape.setAttribute("points", points.map((p) => `${p.x},${p.y}`).join(" ")); + // Colored vertex dots matching each axis label. + const svg = document.getElementById("cup-radar"); + for (const old of svg.querySelectorAll("[data-vertex]")) old.remove(); + points.forEach((p, i) => { + const dot = svgEl("circle", { + cx: p.x, + cy: p.y, + r: 3.2, + fill: AXIS_COLOR(i), + "data-vertex": "1", + }); + svg.append(dot); + }); } function wireNotes() { @@ -553,6 +584,7 @@ async function initSessionView(user) { renderScoreRows(); renderTickRows(); renderDefectRows(); + renderWheel(); renderFlavorFamilies(); renderFlavorChips(); wireNotes(); diff --git a/public/js/flavor-wheel.js b/public/js/flavor-wheel.js new file mode 100644 index 0000000..006fdeb --- /dev/null +++ b/public/js/flavor-wheel.js @@ -0,0 +1,136 @@ +// 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"), + ); +}