src/view/plotting.js
- import { INSTRUMENTS } from "../model/instruments.js";
- import { chordName } from "../model/theory.js";
- export const PLOTTING = {
- wireMidiToPlayer,
- buildResultTable,
- applyVoiceStyles,
- plotChordGraph,
- };
- function wireMidiToPlayer({ combinedB64, voicesB64 }) {
- const player = document.getElementById("midi-player");
- const visualizer = document.getElementById("midi-visualizer");
- const dataUrl = `data:audio/midi;base64,${combinedB64}`;
- if (visualizer) {
- visualizer.src = dataUrl;
- }
- if (player) {
- player.src = dataUrl;
- if (!player._vizBound && visualizer) {
- player.addVisualizer(visualizer);
- player._vizBound = true;
- }
- }
- document.querySelectorAll(".midi-visualizer").forEach((vis, idx) => {
- if (voicesB64?.[idx]) {
- vis.src = `data:audio/midi;base64,${voicesB64[idx]}`;
- }
- });
- }
- function buildResultTable(result, voiceConfig) {
- const { parts, labelCache } = result;
- const { voices, voice } = voiceConfig;
- const labelOf = (p, i) => p.label || `Part ${i + 1}`;
- const cached = (k, p, i) => labelCache[labelOf(p, i)]?.[k];
- const names = Object.values(INSTRUMENTS).flat();
- return {
- headers: parts.map(labelOf),
- cells: Array.from({ length: voices }, (_, v) =>
- parts.map((p, i) => cached("voicesB64", p, i)?.[v] || null),
- ),
- voices,
- voiceLabels: voice.slice(0, voices).map((vc, i) => ({
- num: i + 1,
- instrument: names[vc?.instrument ?? 0],
- color: vc?.color || "#888888",
- })),
- partChordNames: parts.map(
- (p, i) => cached("concreteProgression", p, i) || "",
- ),
- };
- }
- function applyVoiceStyles(voiceLabels) {
- let el = document.getElementById("voice-colors");
- if (!el) {
- el = document.head.appendChild(document.createElement("style"));
- el.id = "voice-colors";
- }
- const sel = (i) => `midi-visualizer svg rect.note[data-instrument="${i}"]`;
- const rule = (vl, i) =>
- `${sel(i)} { stroke: ${vl.color}; fill: ${vl.color}; }`;
- el.textContent = voiceLabels.map(rule).join("\n");
- }
- function plotChordGraph(graph, path, keyIndex = 0, mode = "major") {
- const { nodes, edges, costs } = graph;
- const costMap = costs instanceof Map ? costs : new Map(Object.entries(costs));
- const { positions, colorMap, diatonic, chromatic } = layoutFor(nodes, mode);
- const pathEdges = path?.edges ?? new Set();
- const pathNodeSet = new Set(path?.nodes ?? []);
- const edgeSvg = renderEdges(edges, positions, costMap, pathEdges);
- const gradientDefs = buildGradients(edges, positions, colorMap, pathEdges);
- const nodeSvg = [...diatonic, ...chromatic]
- .map((node) => {
- const pos = positions[node];
- const color = colorMap[node] || LAYOUT.general.fallbackColor;
- const op = pathNodeSet.has(node) ? 1 : 0.5;
- const hasLoop = edges.some(([u, v]) => u === node && v === node);
- const loop = hasLoop
- ? renderSelfLoop(pos, color, pathEdges.has(`${node}->${node}`))
- : "";
- const subLabel =
- diatonic.includes(node) && node !== "NC"
- ? chordName(node, keyIndex)
- : "";
- return `${nodeCircle(pos, color, op)}${loop}${renderLabel(node, pos, op, subLabel)}`;
- })
- .join("");
- const viewH = chromatic.length > 0 ? 440 : 300;
- return [
- `<svg viewBox="0 0 860 ${viewH}" xmlns="http://www.w3.org/2000/svg" style="max-width:860px; width:100%;">`,
- `<defs>`,
- `<marker id="ah" markerWidth="6" markerHeight="5" refX="5" refY="2.5" orient="auto" overflow="visible">`,
- `<polygon points="0,0 6,2.5 0,5" fill="context-stroke"/>`,
- `</marker>`,
- gradientDefs,
- `</defs>`,
- edgeSvg,
- nodeSvg,
- `</svg>`,
- ].join("");
- }
- function layoutFor(nodes, mode) {
- const layout = LAYOUT[mode];
- const diatonic = layout.map((s) => s.chord);
- const darken = (rgb) =>
- rgb.replace(/\d+/g, (v) => `${Math.round(Number(v) * 0.6)}`);
- const positions = {};
- const colorMap = {};
- for (const { chord, x, y, color } of layout) {
- positions[chord] = { x, y };
- colorMap[chord] = y > 150 ? darken(color) : color;
- }
- const chromatic = nodes.filter((n) => !positions[n]);
- const row3Y = 360;
- const row3Left = 130;
- const row3Right = 730;
- chromatic.forEach((name, i) => {
- const x =
- chromatic.length === 1
- ? (row3Left + row3Right) / 2
- : row3Left + (i * (row3Right - row3Left)) / (chromatic.length - 1);
- positions[name] = { x, y: row3Y };
- });
- return { positions, colorMap, diatonic, chromatic };
- }
- function renderEdges(edges, positions, costMap, pathEdges) {
- let edgeIdx = 0;
- let svg = "";
- for (const [u, v] of edges) {
- if (u === v) {
- continue;
- }
- const from = positions[u];
- const to = positions[v];
- const isPath = pathEdges.has(`${u}->${v}`);
- const attrs = {
- stroke: isPath ? `url(#eg${edgeIdx})` : "#999",
- width: (6 / costMap.get(`${u}->${v}`)) * (isPath ? 1.5 : 1),
- opacity: isPath ? 1 : 0.3,
- };
- svg +=
- from.y === to.y ? edgeCurve(from, to, attrs) : edgeLine(from, to, attrs);
- edgeIdx += isPath ? 1 : 0;
- }
- return svg;
- }
- function edgeCurve(from, to, attrs) {
- const xGap = Math.abs(to.x - from.x);
- const isNeighbor = xGap <= 200;
- const curveDir = isNeighbor
- ? to.x > from.x
- ? -1
- : 1
- : from.y <= 150
- ? -1
- : 1;
- const cx = (from.x + to.x) / 2;
- const cy = from.y + curveDir * xGap * 0.15;
- const x1 =
- from.x +
- (to.x > from.x ? LAYOUT.general.nodeRadius : -LAYOUT.general.nodeRadius);
- const y1 = from.y + curveDir * 4;
- const x2 =
- to.x +
- (from.x > to.x ? LAYOUT.general.nodeRadius : -LAYOUT.general.nodeRadius);
- const y2 = to.y + curveDir * 4;
- return `<path
- d="M${x1.toFixed(1)},${y1.toFixed(1)} Q${cx.toFixed(1)},${cy.toFixed(1)} ${x2.toFixed(1)},${y2.toFixed(1)}"
- fill="none"
- stroke="${attrs.stroke}"
- stroke-width="${attrs.width}"
- marker-end="url(#ah)"
- opacity="${attrs.opacity}"/>`;
- }
- function edgeLine(from, to, attrs) {
- const dx = to.x - from.x;
- const dy = to.y - from.y;
- const dist = Math.sqrt(dx * dx + dy * dy);
- const shrink = (LAYOUT.general.nodeRadius + 4) / dist;
- const x1 = from.x + dx * shrink;
- const y1 = from.y + dy * shrink;
- const x2 = to.x - dx * shrink;
- const y2 = to.y - dy * shrink;
- return `<line
- x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}"
- x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}"
- stroke="${attrs.stroke}"
- stroke-width="${attrs.width}"
- marker-end="url(#ah)"
- opacity="${attrs.opacity}"/>`;
- }
- const buildGradients = (edges, positions, colorMap, pathEdges) =>
- edges
- .filter(([u, v]) => u !== v && pathEdges.has(`${u}->${v}`))
- .map(
- ([u, v], i) => `<linearGradient id="eg${i}" gradientUnits="userSpaceOnUse"
- x1="${positions[u].x}" y1="${positions[u].y}" x2="${positions[v].x}" y2="${positions[v].y}">
- <stop offset="0%" stop-color="${colorMap[u] || LAYOUT.general.fallbackColor}"/>
- <stop offset="100%" stop-color="${colorMap[v] || LAYOUT.general.fallbackColor}"/>
- </linearGradient>`,
- )
- .join("");
- function renderSelfLoop(pos, color, isLit) {
- const a1 = (23 * Math.PI) / 180;
- const a2 = (67 * Math.PI) / 180;
- const ac = (45 * Math.PI) / 180;
- const p1x = pos.x + LAYOUT.general.nodeRadius * Math.sin(a1);
- const p1y = pos.y - LAYOUT.general.nodeRadius * Math.cos(a1);
- const p2x = pos.x + LAYOUT.general.nodeRadius * Math.sin(a2);
- const p2y = pos.y - LAYOUT.general.nodeRadius * Math.cos(a2);
- const cx = pos.x + (LAYOUT.general.nodeRadius + 22) * Math.sin(ac);
- const cy = pos.y - (LAYOUT.general.nodeRadius + 22) * Math.cos(ac);
- const stroke = isLit ? color : "#999";
- const opacity = isLit ? 1 : 0.3;
- return `<path
- d="M${p1x.toFixed(1)},${p1y.toFixed(1)} Q${cx.toFixed(1)},${cy.toFixed(1)} ${p2x.toFixed(1)},${p2y.toFixed(1)}"
- fill="none"
- stroke="${stroke}"
- stroke-width="2"
- marker-end="url(#ah)"
- opacity="${opacity}"/>`;
- }
- const nodeCircle = (pos, color, op) => `<circle
- cx="${pos.x}" cy="${pos.y}" r="${LAYOUT.general.nodeRadius}"
- fill="#fff"
- stroke="${color}"
- stroke-width="4"
- opacity="${op}"/>`;
- const renderLabel = (node, pos, op, subLabel) =>
- subLabel
- ? `<text
- x="${pos.x}" y="${pos.y - 7}"
- text-anchor="middle" dominant-baseline="central"
- font-size="13" font-family="serif" font-weight="bold"
- fill="#000" opacity="${op}">${node}</text>
- <text
- x="${pos.x}" y="${pos.y + 10}"
- text-anchor="middle" dominant-baseline="central"
- font-size="12" font-family="sans-serif"
- fill="#000" opacity="${op * 0.7}">${subLabel}</text>`
- : `<text
- x="${pos.x}" y="${pos.y}"
- text-anchor="middle" dominant-baseline="central"
- font-size="11" font-family="serif"
- fill="#000" opacity="${op}">${node}</text>`;
- const LAYOUT = {
- general: { nodeRadius: 30, fallbackColor: "rgb(120,120,120)" },
- major: [
- { chord: "IV", x: 130, y: 80, color: "rgb(0,0,255)" },
- { chord: "I", x: 330, y: 80, color: "rgb(0,255,0)" },
- { chord: "V", x: 530, y: 80, color: "rgb(255,0,0)" },
- { chord: "ii", x: 130, y: 220, color: "rgb(0,0,191)" },
- { chord: "vi", x: 330, y: 220, color: "rgb(0,191,0)" },
- { chord: "iii", x: 530, y: 220, color: "rgb(191,191,0)" },
- { chord: "viio", x: 730, y: 220, color: "rgb(191,0,191)" },
- { chord: "NC", x: 730, y: 80, color: "rgb(0,0,0)" },
- ],
- minor: [
- { chord: "bVI", x: 130, y: 80, color: "rgb(0,0,255)" },
- { chord: "III", x: 330, y: 80, color: "rgb(0,255,0)" },
- { chord: "bVII", x: 530, y: 80, color: "rgb(255,0,0)" },
- { chord: "iv", x: 130, y: 220, color: "rgb(0,0,191)" },
- { chord: "i", x: 330, y: 220, color: "rgb(0,191,0)" },
- { chord: "V", x: 530, y: 220, color: "rgb(191,191,0)" },
- { chord: "iio", x: 730, y: 220, color: "rgb(191,0,191)" },
- { chord: "NC", x: 730, y: 80, color: "rgb(0,0,0)" },
- ],
- };