import { $, state, toast } from "../core.js"; import { KEYS, drawCover, hash32, keyHue } from "../covers.js"; import { db } from "../store.js"; import { listStations } from "../ui/library.js"; /* ---------- taste: every track as a point (tempo, energy; colour = key), every reaction a signed weight on it. The shading is a kernel estimate of "would like" from the reactions, huebrain-style: lit where the warm ones outweigh the cold ones, dim where the cold ones win, dark where nothing has been heard yet. ---------- */ export function reactionWeight(spec) { const r = String(spec.reaction || ""); let w = 0; for (const ch of r.replace(/"[^"]*"/g, "")) { if ("🔥❤️⚡🎸🎹".includes(ch) && ch !== "️") w += 1; if ("🚫😴".includes(ch)) w -= 1; } if (/\[skipped\]/.test(r)) w -= 1.5; if (/"[^"]*"/.test(r)) w += 0.3; // words mean engagement, mildly warm return w; } export function drawTaste(rows, title) { const c = $("#tasteMap"), ctx = c.getContext("2d"), W = c.width, H = c.height, pad = 70; const X = (bpm) => pad + (Math.min(180, Math.max(60, bpm)) - 60) / 120 * (W - 2 * pad); const Y = (en) => H - pad - Math.min(1, Math.max(0, en)) * (H - 2 * pad); const pts = rows.map((t) => ({ x: X(+t.spec.bpm || 100), y: Y(+t.spec.energy || 0.5), w: reactionWeight(t.spec) + (t.kept ? 1.5 : 0), hue: keyHue(t.spec.key), minor: /m(in|inor)?\b/i.test(t.spec.key || "") && !/maj/i.test(t.spec.key || ""), t })); ctx.fillStyle = "#121216"; ctx.fillRect(0, 0, W, H); // the field: kernel-weighted reactions, with every played track counting a little as "heard" const cell = 18, sig = 90; for (let gy = pad; gy < H - pad; gy += cell) for (let gx = pad; gx < W - pad; gx += cell) { let like = 0, heard = 0; for (const p of pts) { const d2 = (p.x - gx) ** 2 + (p.y - gy) ** 2, k = Math.exp(-d2 / (2 * sig * sig)); like += k * p.w; heard += k; } if (heard < 0.02) continue; const v = Math.tanh(like / 1.5); // -1..1; 0 means heard but nothing said const a = Math.min(0.5, heard * 0.3); if (Math.abs(v) < 0.05) ctx.fillStyle = `hsl(240 6% 30% / ${a * 0.5})`; // heard, neutral else ctx.fillStyle = v > 0 ? `hsl(44 60% 60% / ${a * Math.abs(v)})` : `hsl(220 30% 45% / ${a * Math.abs(v)})`; ctx.fillRect(gx, gy, cell, cell); } // axes ctx.strokeStyle = "#232329"; ctx.lineWidth = 2; ctx.strokeRect(pad, pad, W - 2 * pad, H - 2 * pad); ctx.fillStyle = "#8a8a96"; ctx.font = `600 ${W * 0.018}px ui-monospace, Menlo, monospace`; ctx.textAlign = "center"; for (const b of [60, 90, 120, 150, 180]) ctx.fillText(b + "", X(b), H - pad + 34); ctx.textAlign = "right"; for (const e of [0, 0.5, 1]) ctx.fillText(e === 0 ? "calm" : e === 1 ? "wild" : "·", pad - 14, Y(e) + 6); // the points: colour is the key, size is how much was said about it, ring warm or cold for (const p of pts) { const r = (W * 0.012) * (1 + Math.min(3, Math.abs(p.w)) * 0.35); ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fillStyle = `hsl(${p.hue} ${p.minor ? 55 : 80}% ${p.minor ? 48 : 62}% / .9)`; ctx.fill(); if (p.w !== 0) { ctx.lineWidth = 3; ctx.strokeStyle = p.w > 0 ? "hsl(44 80% 70%)" : "hsl(220 40% 60%)"; ctx.stroke(); } if (p.t.kept) { ctx.beginPath(); ctx.arc(p.x, p.y, r + 7, 0, Math.PI * 2); ctx.lineWidth = 1.5; ctx.strokeStyle = "hsl(44 80% 70% / .7)"; ctx.stroke(); } } const warm = pts.filter((p) => p.w > 0).length, cold = pts.filter((p) => p.w < 0).length; const mean = (f) => pts.length ? pts.reduce((s, p) => s + f(p), 0) / pts.length : 0; const liked = pts.filter((p) => p.w > 0); const lb = liked.length ? Math.round(liked.reduce((s, p) => s + (+p.t.spec.bpm || 100), 0) / liked.length) : null; const le = liked.length ? (liked.reduce((s, p) => s + (+p.t.spec.energy || 0.5), 0) / liked.length) : null; const minors = liked.filter((p) => p.minor).length; $("#tasteNote").textContent = !pts.length ? "Nothing heard here yet." : `${title}: ${pts.length} tracks · ${warm} warm, ${cold} cold · ` + (liked.length ? `what you warmed to sits around ${lb} bpm, ${le < 0.4 ? "calm" : le < 0.7 ? "mid" : "high"} energy, ${minors > liked.length / 2 ? "mostly minor keys" : "mostly major keys"}.` : "react to a few tracks and the lit region appears."); } export async function openTaste() { const rows = await db.all(), stations = await db.all("stations"), byId = Object.fromEntries(stations.map((s) => [s.id, s])); const tabs = $("#tasteTabs"); tabs.innerHTML = ""; const groups = [["all", "ALL", "", rows]]; const seen = new Map(); for (const t of rows) { const k = t.spec.stationId || "seed:" + t.spec.seed; if (!seen.has(k)) seen.set(k, []); seen.get(k).push(t); } for (const [k, list] of seen) { const s = byId[k]; groups.push([k, ((s && s.name) || list[0].spec.seed).toUpperCase(), (s && s.seed) || list[0].spec.seed, list]); } let pick = groups.find((g) => g[0] === state.stationId) || groups[0]; const show = () => { for (const b of tabs.children) b.classList.toggle("on", b.dataset.k === pick[0]); drawTaste(pick[3], pick[1]); }; for (const g of groups) { const b = document.createElement("button"); b.dataset.k = g[0]; if (g[2]) { const c = document.createElement("canvas"); c.width = c.height = 44; drawCover(c, { seed: g[2], title: "", tags: "", bpm: 100, key: KEYS[hash32(g[2]) % 12], energy: 0.5 }); b.append(c); } b.append(document.createTextNode(g[1] + " · " + g[3].length)); b.addEventListener("click", () => { pick = g; show(); }); tabs.append(b); } show(); $("#taste").classList.remove("hidden"); } $("#tasteBtn").addEventListener("click", () => openTaste().catch((e) => toast(e.message))); $("#tasteClose").addEventListener("click", () => $("#taste").classList.add("hidden")); listStations().catch(() => {}); // a shared track (?t=): open its station and render that song first; a hash (#rainy+trains) just fills the seed