Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
79 lines
5.8 KiB
JavaScript
79 lines
5.8 KiB
JavaScript
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
|