Home Manual Reference Source Test

src/view/plotting.js

  1. import { INSTRUMENTS } from "../model/instruments.js";
  2. import { chordName } from "../model/theory.js";
  3. /**
  4. * Bundle of compose-result rendering helpers wired into the Alpine "app" data object via spread.
  5. * @type {Object}
  6. * @property {function(midi: {combinedB64: string, voicesB64: string[]}): void} wireMidiToPlayer
  7. * Wire combined + per-voice MIDI URLs into player and visualizers; caller awaits $nextTick.
  8. * @property {function(result: Object, voiceConfig: {voices: number, voice: Object[]}): Object} buildResultTable
  9. * Shape compose result + voice config into the Alpine-rendered result table.
  10. * @property {function(voiceLabels: {color: string}[]): void} applyVoiceStyles
  11. * Stylesheet that colors midi-visualizer notes by voice index (sigma).
  12. * @property {function(graph: Object, path: ?Object, keyIndex: number, mode: 'major'|'minor'): string} plotChordGraph
  13. * Plots the chord graph as an SVG string; diatonic gray + 30% opacity, path edges gradient + full opacity.
  14. */
  15. export const PLOTTING = {
  16. wireMidiToPlayer,
  17. buildResultTable,
  18. applyVoiceStyles,
  19. plotChordGraph,
  20. };
  21. /** @private */
  22. function wireMidiToPlayer({ combinedB64, voicesB64 }) {
  23. const player = document.getElementById("midi-player");
  24. const visualizer = document.getElementById("midi-visualizer");
  25. const dataUrl = `data:audio/midi;base64,${combinedB64}`;
  26. if (visualizer) {
  27. visualizer.src = dataUrl;
  28. }
  29. if (player) {
  30. player.src = dataUrl;
  31. if (!player._vizBound && visualizer) {
  32. player.addVisualizer(visualizer);
  33. player._vizBound = true;
  34. }
  35. }
  36. document.querySelectorAll(".midi-visualizer").forEach((vis, idx) => {
  37. if (voicesB64?.[idx]) {
  38. vis.src = `data:audio/midi;base64,${voicesB64[idx]}`;
  39. }
  40. });
  41. }
  42. /** @private */
  43. function buildResultTable(result, voiceConfig) {
  44. const { parts, labelCache } = result;
  45. const { voices, voice } = voiceConfig;
  46. const labelOf = (p, i) => p.label || `Part ${i + 1}`;
  47. const cached = (k, p, i) => labelCache[labelOf(p, i)]?.[k];
  48. const names = Object.values(INSTRUMENTS).flat();
  49. return {
  50. headers: parts.map(labelOf),
  51. cells: Array.from({ length: voices }, (_, v) =>
  52. parts.map((p, i) => cached("voicesB64", p, i)?.[v] || null),
  53. ),
  54. voices,
  55. voiceLabels: voice.slice(0, voices).map((vc, i) => ({
  56. num: i + 1,
  57. instrument: names[vc?.instrument ?? 0],
  58. color: vc?.color || "#888888",
  59. })),
  60. partChordNames: parts.map(
  61. (p, i) => cached("concreteProgression", p, i) || "",
  62. ),
  63. };
  64. }
  65. /** @private */
  66. function applyVoiceStyles(voiceLabels) {
  67. let el = document.getElementById("voice-colors");
  68. if (!el) {
  69. el = document.head.appendChild(document.createElement("style"));
  70. el.id = "voice-colors";
  71. }
  72. const sel = (i) => `midi-visualizer svg rect.note[data-instrument="${i}"]`;
  73. const rule = (vl, i) =>
  74. `${sel(i)} { stroke: ${vl.color}; fill: ${vl.color}; }`;
  75. el.textContent = voiceLabels.map(rule).join("\n");
  76. }
  77. /** @private */
  78. function plotChordGraph(graph, path, keyIndex = 0, mode = "major") {
  79. const { nodes, edges, costs } = graph;
  80. const costMap = costs instanceof Map ? costs : new Map(Object.entries(costs));
  81. const { positions, colorMap, diatonic, chromatic } = layoutFor(nodes, mode);
  82. const pathEdges = path?.edges ?? new Set();
  83. const pathNodeSet = new Set(path?.nodes ?? []);
  84. const edgeSvg = renderEdges(edges, positions, costMap, pathEdges);
  85. const gradientDefs = buildGradients(edges, positions, colorMap, pathEdges);
  86. const nodeSvg = [...diatonic, ...chromatic]
  87. .map((node) => {
  88. const pos = positions[node];
  89. const color = colorMap[node] || LAYOUT.general.fallbackColor;
  90. const op = pathNodeSet.has(node) ? 1 : 0.5;
  91. const hasLoop = edges.some(([u, v]) => u === node && v === node);
  92. const loop = hasLoop
  93. ? renderSelfLoop(pos, color, pathEdges.has(`${node}->${node}`))
  94. : "";
  95. const subLabel =
  96. diatonic.includes(node) && node !== "NC"
  97. ? chordName(node, keyIndex)
  98. : "";
  99. return `${nodeCircle(pos, color, op)}${loop}${renderLabel(node, pos, op, subLabel)}`;
  100. })
  101. .join("");
  102. const viewH = chromatic.length > 0 ? 440 : 300;
  103. return [
  104. `<svg viewBox="0 0 860 ${viewH}" xmlns="http://www.w3.org/2000/svg" style="max-width:860px; width:100%;">`,
  105. `<defs>`,
  106. `<marker id="ah" markerWidth="6" markerHeight="5" refX="5" refY="2.5" orient="auto" overflow="visible">`,
  107. `<polygon points="0,0 6,2.5 0,5" fill="context-stroke"/>`,
  108. `</marker>`,
  109. gradientDefs,
  110. `</defs>`,
  111. edgeSvg,
  112. nodeSvg,
  113. `</svg>`,
  114. ].join("");
  115. }
  116. /** @private Per-mode (mu) layout: positions, colors, diatonic and chromatic split. */
  117. function layoutFor(nodes, mode) {
  118. const layout = LAYOUT[mode];
  119. const diatonic = layout.map((s) => s.chord);
  120. const darken = (rgb) =>
  121. rgb.replace(/\d+/g, (v) => `${Math.round(Number(v) * 0.6)}`);
  122. const positions = {};
  123. const colorMap = {};
  124. for (const { chord, x, y, color } of layout) {
  125. positions[chord] = { x, y };
  126. colorMap[chord] = y > 150 ? darken(color) : color;
  127. }
  128. const chromatic = nodes.filter((n) => !positions[n]);
  129. const row3Y = 360;
  130. const row3Left = 130;
  131. const row3Right = 730;
  132. chromatic.forEach((name, i) => {
  133. const x =
  134. chromatic.length === 1
  135. ? (row3Left + row3Right) / 2
  136. : row3Left + (i * (row3Right - row3Left)) / (chromatic.length - 1);
  137. positions[name] = { x, y: row3Y };
  138. });
  139. return { positions, colorMap, diatonic, chromatic };
  140. }
  141. /** @private Render non-loop edges; gradient ids match buildGradients order. */
  142. function renderEdges(edges, positions, costMap, pathEdges) {
  143. let edgeIdx = 0;
  144. let svg = "";
  145. for (const [u, v] of edges) {
  146. if (u === v) {
  147. continue;
  148. }
  149. const from = positions[u];
  150. const to = positions[v];
  151. const isPath = pathEdges.has(`${u}->${v}`);
  152. const attrs = {
  153. stroke: isPath ? `url(#eg${edgeIdx})` : "#999",
  154. width: (6 / costMap.get(`${u}->${v}`)) * (isPath ? 1.5 : 1),
  155. opacity: isPath ? 1 : 0.3,
  156. };
  157. svg +=
  158. from.y === to.y ? edgeCurve(from, to, attrs) : edgeLine(from, to, attrs);
  159. edgeIdx += isPath ? 1 : 0;
  160. }
  161. return svg;
  162. }
  163. /** @private Same-row edge as quadratic Bezier curve to avoid overlap. */
  164. function edgeCurve(from, to, attrs) {
  165. const xGap = Math.abs(to.x - from.x);
  166. const isNeighbor = xGap <= 200;
  167. const curveDir = isNeighbor
  168. ? to.x > from.x
  169. ? -1
  170. : 1
  171. : from.y <= 150
  172. ? -1
  173. : 1;
  174. const cx = (from.x + to.x) / 2;
  175. const cy = from.y + curveDir * xGap * 0.15;
  176. const x1 =
  177. from.x +
  178. (to.x > from.x ? LAYOUT.general.nodeRadius : -LAYOUT.general.nodeRadius);
  179. const y1 = from.y + curveDir * 4;
  180. const x2 =
  181. to.x +
  182. (from.x > to.x ? LAYOUT.general.nodeRadius : -LAYOUT.general.nodeRadius);
  183. const y2 = to.y + curveDir * 4;
  184. return `<path
  185. d="M${x1.toFixed(1)},${y1.toFixed(1)} Q${cx.toFixed(1)},${cy.toFixed(1)} ${x2.toFixed(1)},${y2.toFixed(1)}"
  186. fill="none"
  187. stroke="${attrs.stroke}"
  188. stroke-width="${attrs.width}"
  189. marker-end="url(#ah)"
  190. opacity="${attrs.opacity}"/>`;
  191. }
  192. /** @private Cross-row edge as straight line, shrunk to circle perimeters. */
  193. function edgeLine(from, to, attrs) {
  194. const dx = to.x - from.x;
  195. const dy = to.y - from.y;
  196. const dist = Math.sqrt(dx * dx + dy * dy);
  197. const shrink = (LAYOUT.general.nodeRadius + 4) / dist;
  198. const x1 = from.x + dx * shrink;
  199. const y1 = from.y + dy * shrink;
  200. const x2 = to.x - dx * shrink;
  201. const y2 = to.y - dy * shrink;
  202. return `<line
  203. x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}"
  204. x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}"
  205. stroke="${attrs.stroke}"
  206. stroke-width="${attrs.width}"
  207. marker-end="url(#ah)"
  208. opacity="${attrs.opacity}"/>`;
  209. }
  210. /** @private Linear-gradient defs for path edges in encounter order. */
  211. const buildGradients = (edges, positions, colorMap, pathEdges) =>
  212. edges
  213. .filter(([u, v]) => u !== v && pathEdges.has(`${u}->${v}`))
  214. .map(
  215. ([u, v], i) => `<linearGradient id="eg${i}" gradientUnits="userSpaceOnUse"
  216. x1="${positions[u].x}" y1="${positions[u].y}" x2="${positions[v].x}" y2="${positions[v].y}">
  217. <stop offset="0%" stop-color="${colorMap[u] || LAYOUT.general.fallbackColor}"/>
  218. <stop offset="100%" stop-color="${colorMap[v] || LAYOUT.general.fallbackColor}"/>
  219. </linearGradient>`,
  220. )
  221. .join("");
  222. /** @private Self-loop arc, lit when the loop edge is on the highlighted path. */
  223. function renderSelfLoop(pos, color, isLit) {
  224. const a1 = (23 * Math.PI) / 180;
  225. const a2 = (67 * Math.PI) / 180;
  226. const ac = (45 * Math.PI) / 180;
  227. const p1x = pos.x + LAYOUT.general.nodeRadius * Math.sin(a1);
  228. const p1y = pos.y - LAYOUT.general.nodeRadius * Math.cos(a1);
  229. const p2x = pos.x + LAYOUT.general.nodeRadius * Math.sin(a2);
  230. const p2y = pos.y - LAYOUT.general.nodeRadius * Math.cos(a2);
  231. const cx = pos.x + (LAYOUT.general.nodeRadius + 22) * Math.sin(ac);
  232. const cy = pos.y - (LAYOUT.general.nodeRadius + 22) * Math.cos(ac);
  233. const stroke = isLit ? color : "#999";
  234. const opacity = isLit ? 1 : 0.3;
  235. return `<path
  236. d="M${p1x.toFixed(1)},${p1y.toFixed(1)} Q${cx.toFixed(1)},${cy.toFixed(1)} ${p2x.toFixed(1)},${p2y.toFixed(1)}"
  237. fill="none"
  238. stroke="${stroke}"
  239. stroke-width="2"
  240. marker-end="url(#ah)"
  241. opacity="${opacity}"/>`;
  242. }
  243. /** @private White-fill ring drawn around every node. */
  244. const nodeCircle = (pos, color, op) => `<circle
  245. cx="${pos.x}" cy="${pos.y}" r="${LAYOUT.general.nodeRadius}"
  246. fill="#fff"
  247. stroke="${color}"
  248. stroke-width="4"
  249. opacity="${op}"/>`;
  250. /** @private Roman-numeral label; subLabel adds a chord-name subscript. */
  251. const renderLabel = (node, pos, op, subLabel) =>
  252. subLabel
  253. ? `<text
  254. x="${pos.x}" y="${pos.y - 7}"
  255. text-anchor="middle" dominant-baseline="central"
  256. font-size="13" font-family="serif" font-weight="bold"
  257. fill="#000" opacity="${op}">${node}</text>
  258. <text
  259. x="${pos.x}" y="${pos.y + 10}"
  260. text-anchor="middle" dominant-baseline="central"
  261. font-size="12" font-family="sans-serif"
  262. fill="#000" opacity="${op * 0.7}">${subLabel}</text>`
  263. : `<text
  264. x="${pos.x}" y="${pos.y}"
  265. text-anchor="middle" dominant-baseline="central"
  266. font-size="11" font-family="serif"
  267. fill="#000" opacity="${op}">${node}</text>`;
  268. /** @private Per-mode (mu) layout + general visual constants. */
  269. const LAYOUT = {
  270. general: { nodeRadius: 30, fallbackColor: "rgb(120,120,120)" },
  271. major: [
  272. { chord: "IV", x: 130, y: 80, color: "rgb(0,0,255)" },
  273. { chord: "I", x: 330, y: 80, color: "rgb(0,255,0)" },
  274. { chord: "V", x: 530, y: 80, color: "rgb(255,0,0)" },
  275. { chord: "ii", x: 130, y: 220, color: "rgb(0,0,191)" },
  276. { chord: "vi", x: 330, y: 220, color: "rgb(0,191,0)" },
  277. { chord: "iii", x: 530, y: 220, color: "rgb(191,191,0)" },
  278. { chord: "viio", x: 730, y: 220, color: "rgb(191,0,191)" },
  279. { chord: "NC", x: 730, y: 80, color: "rgb(0,0,0)" },
  280. ],
  281. minor: [
  282. { chord: "bVI", x: 130, y: 80, color: "rgb(0,0,255)" },
  283. { chord: "III", x: 330, y: 80, color: "rgb(0,255,0)" },
  284. { chord: "bVII", x: 530, y: 80, color: "rgb(255,0,0)" },
  285. { chord: "iv", x: 130, y: 220, color: "rgb(0,0,191)" },
  286. { chord: "i", x: 330, y: 220, color: "rgb(0,191,0)" },
  287. { chord: "V", x: 530, y: 220, color: "rgb(191,191,0)" },
  288. { chord: "iio", x: 730, y: 220, color: "rgb(191,0,191)" },
  289. { chord: "NC", x: 730, y: 80, color: "rgb(0,0,0)" },
  290. ],
  291. };