gabriel / musehub public
arrange.ts typescript
154 lines 5.8 KB
9236d457 fix: remove all inline scripts, restore strict script-src CSP (#51) Gabriel Cardona <cgcardona@gmail.com> 23h ago
1 /**
2 * Arrange page — progressive enhancement.
3 *
4 * All matrix data is SSR'd by the Python route handler.
5 * This module adds:
6 * 1. Fixed-position tooltip on cell hover.
7 * 2. Row highlight on <tr> hover.
8 * 3. Column highlight on header/cell hover.
9 * 4. Entrance animation: bar fills animate in when the matrix scrolls into view.
10 */
11
12 interface ArrangeCfg {
13 base: string;
14 ref: string;
15 owner: string;
16 repoSlug: string;
17 }
18
19
20 // ── Tooltip ──────────────────────────────────────────────────────────────────
21
22 function setupTooltip(): void {
23 const tip = document.getElementById("ar-tooltip") as HTMLElement | null;
24 const tipTitle = document.getElementById("ar-tip-title") as HTMLElement | null;
25 const tipNotes = document.getElementById("ar-tip-notes") as HTMLElement | null;
26 const tipDens = document.getElementById("ar-tip-density") as HTMLElement | null;
27 const tipBeats = document.getElementById("ar-tip-beats") as HTMLElement | null;
28 if (!tip) return;
29
30 function show(cell: HTMLElement, x: number, y: number): void {
31 const inst = cell.dataset.instrument ?? "";
32 const sec = cell.dataset.section ?? "";
33 const notes = cell.dataset.notes ?? "0";
34 const density = parseFloat(cell.dataset.density ?? "0");
35 const bStart = cell.dataset.beatStart ?? "0";
36 const bEnd = cell.dataset.beatEnd ?? "0";
37
38 if (tipTitle) tipTitle.textContent = `${inst.charAt(0).toUpperCase() + inst.slice(1)} · ${sec.replace(/_/g, " ")}`;
39 if (tipNotes) tipNotes.textContent = notes;
40 if (tipDens) tipDens.textContent = `${(density * 100).toFixed(0)}%`;
41 if (tipBeats) tipBeats.textContent = `${parseFloat(bStart).toFixed(0)}–${parseFloat(bEnd).toFixed(0)}`;
42
43 tip.style.left = `${x + 16}px`;
44 tip.style.top = `${y - 16}px`;
45 tip.classList.add("ar-tooltip--visible");
46 }
47
48 function hide(): void {
49 tip.classList.remove("ar-tooltip--visible");
50 }
51
52 document.querySelectorAll<HTMLElement>(".ar-cell[data-notes]").forEach(cell => {
53 cell.addEventListener("mouseenter", e => {
54 const me = e as MouseEvent;
55 show(cell, me.clientX, me.clientY);
56 });
57 cell.addEventListener("mousemove", e => {
58 const me = e as MouseEvent;
59 tip.style.left = `${me.clientX + 16}px`;
60 tip.style.top = `${me.clientY - 16}px`;
61 });
62 cell.addEventListener("mouseleave", hide);
63 });
64 }
65
66 // ── Row highlight ─────────────────────────────────────────────────────────────
67
68 function setupRowHighlight(): void {
69 const rows = document.querySelectorAll<HTMLTableRowElement>("#ar-matrix tbody tr");
70 rows.forEach(row => {
71 row.addEventListener("mouseenter", () => row.classList.add("ar-row-hover"));
72 row.addEventListener("mouseleave", () => row.classList.remove("ar-row-hover"));
73 });
74 }
75
76 // ── Column highlight ──────────────────────────────────────────────────────────
77
78 function setupColHighlight(): void {
79 const table = document.getElementById("ar-matrix");
80 if (!table) return;
81
82 let activeCol: number | null = null;
83
84 function setColHighlight(colIdx: number | null): void {
85 // Remove all existing highlights
86 table!.querySelectorAll<HTMLElement>(".ar-col-hover").forEach(el =>
87 el.classList.remove("ar-col-hover"),
88 );
89 if (colIdx === null) return;
90 table!.querySelectorAll<HTMLElement>(`[data-col="${colIdx}"]`).forEach(el =>
91 el.classList.add("ar-col-hover"),
92 );
93 }
94
95 table.querySelectorAll<HTMLElement>("[data-col]").forEach(el => {
96 el.addEventListener("mouseenter", () => {
97 const col = parseInt(el.dataset.col ?? "-1", 10);
98 if (col >= 0) {
99 activeCol = col;
100 setColHighlight(col);
101 }
102 });
103 el.addEventListener("mouseleave", () => {
104 activeCol = null;
105 setColHighlight(null);
106 });
107 });
108 }
109
110 // ── Bar fill entrance animation ───────────────────────────────────────────────
111
112 function animatePanelBars(): void {
113 const fills = document.querySelectorAll<HTMLElement>(".ar-panel-bar-fill");
114 if (!fills.length) return;
115
116 const observer = new IntersectionObserver(entries => {
117 entries.forEach(entry => {
118 if (!entry.isIntersecting) return;
119 const el = entry.target as HTMLElement;
120 const target = el.style.width;
121 el.style.width = "0%";
122 requestAnimationFrame(() => {
123 el.style.transition = "width 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)";
124 el.style.width = target;
125 });
126 observer.unobserve(el);
127 });
128 }, { threshold: 0.15 });
129
130 fills.forEach(f => observer.observe(f));
131 }
132
133 // ── Cell density bar animation ────────────────────────────────────────────────
134
135 function animateCellBars(): void {
136 document.querySelectorAll<HTMLElement>(".ar-cell-bar-fill").forEach(el => {
137 const target = el.style.width;
138 el.style.width = "0%";
139 setTimeout(() => {
140 el.style.transition = "width 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94)";
141 el.style.width = target;
142 }, 100 + Math.random() * 200);
143 });
144 }
145
146 // ── Entry point ───────────────────────────────────────────────────────────────
147
148 export function initArrange(_data?: Record<string, unknown>): void {
149 setupTooltip();
150 setupRowHighlight();
151 setupColHighlight();
152 animatePanelBars();
153 animateCellBars();
154 }