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 @@
32-tag limit reached.
+
+
+
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"),
+ );
+}