// Partial Editor // Property panel (right) and amplitude bezier editor (bottom) for selected partials class PartialEditor { constructor() { // DOM refs this._propPanel = document.getElementById('partialProps'); this._noSelMsg = document.getElementById('noSelMsg'); this._ampPanel = document.getElementById('ampEditPanel'); this._ampCanvas = document.getElementById('ampEditCanvas'); this._ampTitle = document.getElementById('ampEditTitle'); this._freqGrid = document.getElementById('freqCurveGrid'); this._ampGrid = document.getElementById('ampCurveGrid'); this._ampCtx = this._ampCanvas ? this._ampCanvas.getContext('2d') : null; // References set by host this.viewer = null; this.partials = null; // Callback: called after a partial is deleted so the host can update keepCount this.onPartialDeleted = null; // Private state this._selectedIndex = -1; this._dragPointIndex = -1; this._amp = { tMin: 0, tMax: 1, ampTop: 1 }; this._setupButtons(); this._setupAmpDrag(); } // --- Public API --- setViewer(v) { this.viewer = v; } setPartials(p) { this.partials = p; } // Wire to viewer.onPartialSelect onPartialSelect(index) { this._selectedIndex = index; this._updatePropPanel(index); this._showAmpEditor(index); } // Wire to viewer.onRender — keeps amp editor in sync with zoom/scroll onRender() { this._renderAmpEditor(); } // --- Property panel --- _updatePropPanel(index) { if (index < 0 || !this.partials || index >= this.partials.length) { this._propPanel.style.display = 'none'; this._noSelMsg.style.display = 'block'; return; } this._propPanel.style.display = 'block'; this._noSelMsg.style.display = 'none'; const partial = this.partials[index]; const color = this.viewer ? this.viewer.partialColor(index) : '#888'; document.getElementById('propTitle').textContent = 'Partial #' + index; document.getElementById('propSwatch').style.background = color; let peakAmp = 0, peakIdx = 0; for (let i = 0; i < partial.amps.length; ++i) { if (partial.amps[i] > peakAmp) { peakAmp = partial.amps[i]; peakIdx = i; } } document.getElementById('propPeak').textContent = partial.freqs[peakIdx].toFixed(1) + ' Hz ' + peakAmp.toFixed(3); const t0 = partial.freqCurve ? partial.freqCurve.t0 : partial.times[0]; const t3 = partial.freqCurve ? partial.freqCurve.t3 : partial.times[partial.times.length - 1]; document.getElementById('propTime').textContent = t0.toFixed(3) + 's\u2013' + t3.toFixed(3) + 's'; const muteBtn = document.getElementById('mutePartialBtn'); muteBtn.textContent = partial.muted ? 'Unmute' : 'Mute'; muteBtn.style.color = partial.muted ? '#fa4' : ''; this._buildCurveGrid(this._freqGrid, partial, 'freqCurve', 'f', index); this._buildCurveGrid(this._ampGrid, partial, 'ampCurve', 'a', index); } _buildCurveGrid(grid, partial, curveKey, valueLabel, partialIndex) { grid.innerHTML = ''; const curve = partial[curveKey]; if (!curve) { grid.style.color = '#444'; grid.textContent = 'none'; return; } for (let i = 0; i < 4; ++i) { const lbl = document.createElement('span'); lbl.textContent = 'P' + i; const tInput = document.createElement('input'); tInput.type = 'number'; tInput.value = curve['t' + i].toFixed(4); tInput.step = '0.001'; tInput.title = 't' + i; tInput.addEventListener('change', this._makeCurveUpdater(partialIndex, curveKey, 't', i)); const vInput = document.createElement('input'); vInput.type = 'number'; vInput.value = curveKey === 'freqCurve' ? curve['v' + i].toFixed(2) : curve['v' + i].toFixed(4); vInput.step = curveKey === 'freqCurve' ? '1' : '0.0001'; vInput.title = valueLabel + i; vInput.addEventListener('change', this._makeCurveUpdater(partialIndex, curveKey, 'v', i)); grid.appendChild(lbl); grid.appendChild(tInput); grid.appendChild(vInput); } } _makeCurveUpdater(partialIndex, curveKey, field, pointIndex) { return (e) => { if (!this.partials) return; const val = parseFloat(e.target.value); if (isNaN(val)) return; this.partials[partialIndex][curveKey][field + pointIndex] = val; if (this.viewer) this.viewer.render(); }; } _setupButtons() { document.getElementById('mutePartialBtn').addEventListener('click', () => { if (this._selectedIndex < 0 || !this.partials) return; const p = this.partials[this._selectedIndex]; p.muted = !p.muted; if (this.viewer) this.viewer.render(); this._updatePropPanel(this._selectedIndex); }); document.getElementById('deletePartialBtn').addEventListener('click', () => { if (this._selectedIndex < 0 || !this.partials || !this.viewer) return; this.partials.splice(this._selectedIndex, 1); this.viewer.selectPartial(-1); if (this.onPartialDeleted) this.onPartialDeleted(); }); } // --- Amplitude bezier editor --- _showAmpEditor(index) { if (index < 0 || !this.partials || index >= this.partials.length) { this._ampPanel.style.display = 'none'; return; } this._ampPanel.style.display = 'block'; const color = this.viewer ? this.viewer.partialColor(index) : '#888'; this._ampTitle.textContent = 'Partial #' + index; this._ampTitle.style.color = color; this._renderAmpEditor(); } _renderAmpEditor() { if (this._selectedIndex < 0 || !this.partials || !this._ampCtx) return; const partial = this.partials[this._selectedIndex]; if (!partial) return; const canvas = this._ampCanvas; const ctx = this._ampCtx; const W = canvas.width, H = canvas.height; const PADY = 10; // Sync time range with viewer const amp = this._amp; amp.tMin = this.viewer ? this.viewer.t_view_min : 0; amp.tMax = this.viewer ? this.viewer.t_view_max : 1; amp.ampTop = Math.max(partial.amps.reduce((m, v) => Math.max(m, v), 0), 0.001) * 1.3; ctx.fillStyle = '#0e0e0e'; ctx.fillRect(0, 0, W, H); // Horizontal grid (0, 25, 50, 75, 100% of ampTop) ctx.lineWidth = 1; ctx.font = '9px monospace'; for (let k = 0; k <= 4; ++k) { const a = amp.ampTop * k / 4; const y = this._ampToY(a); ctx.strokeStyle = k === 0 ? '#2a2a2a' : '#1a1a1a'; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); ctx.fillStyle = '#383838'; ctx.fillText(a.toFixed(3), W - 40, y - 2); } // Vertical time grid (matching main view step) if (this.viewer) { const step = this.viewer.getAxisStep(amp.tMax - amp.tMin); let t = Math.ceil(amp.tMin / step) * step; ctx.strokeStyle = '#1a1a1a'; ctx.fillStyle = '#383838'; while (t <= amp.tMax) { const x = this._tToX(t); ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); ctx.fillText(t.toFixed(2) + 's', x + 2, H - 2); t += step; } } // Raw amp data (faint dots) ctx.fillStyle = '#2e2e2e'; for (let i = 0; i < partial.times.length; ++i) { const x = this._tToX(partial.times[i]); if (x < -2 || x > W + 2) continue; ctx.fillRect(x - 1, this._ampToY(partial.amps[i]) - 1, 2, 2); } // Bezier curve const curve = partial.ampCurve; if (!curve) return; const color = this.viewer ? this.viewer.partialColor(this._selectedIndex) : '#f44'; ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath(); let started = false; for (let i = 0; i <= 120; ++i) { const t = curve.t0 + (curve.t3 - curve.t0) * i / 120; const x = this._tToX(t); if (x < -1 || x > W + 1) { started = false; continue; } const y = this._ampToY(evalBezier(curve, t)); if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); } ctx.stroke(); // Control points for (let i = 0; i < 4; ++i) { const x = this._tToX(curve['t' + i]); const y = this._ampToY(curve['v' + i]); ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, 6, 0, 2 * Math.PI); ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.fillStyle = '#888'; ctx.font = '9px monospace'; ctx.fillText('P' + i, x + 8, y - 4); } } _setupAmpDrag() { const canvas = this._ampCanvas; if (!canvas) return; canvas.addEventListener('mousedown', (e) => { if (this._selectedIndex < 0 || !this.partials) return; const partial = this.partials[this._selectedIndex]; if (!partial || !partial.ampCurve) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left, y = e.clientY - rect.top; const curve = partial.ampCurve; for (let i = 0; i < 4; ++i) { if (Math.hypot(this._tToX(curve['t' + i]) - x, this._ampToY(curve['v' + i]) - y) <= 8) { this._dragPointIndex = i; canvas.style.cursor = 'grabbing'; e.preventDefault(); return; } } }); canvas.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left, y = e.clientY - rect.top; if (this._dragPointIndex >= 0) { const curve = this.partials[this._selectedIndex].ampCurve; const i = this._dragPointIndex; curve['t' + i] = Math.max(0, Math.min(this.viewer ? this.viewer.t_max : 1e6, this._xToT(x))); curve['v' + i] = Math.max(0, this._yToAmp(y)); this._renderAmpEditor(); if (this.viewer) this.viewer.render(); e.preventDefault(); return; } // Cursor hint if (this._selectedIndex >= 0 && this.partials) { const curve = this.partials[this._selectedIndex]?.ampCurve; if (curve) { let near = false; for (let i = 0; i < 4; ++i) { if (Math.hypot(this._tToX(curve['t' + i]) - x, this._ampToY(curve['v' + i]) - y) <= 8) { near = true; break; } } canvas.style.cursor = near ? 'grab' : 'crosshair'; } } }); canvas.addEventListener('mouseup', () => { if (this._dragPointIndex >= 0) { this._dragPointIndex = -1; canvas.style.cursor = 'crosshair'; this._updatePropPanel(this._selectedIndex); // sync text inputs } }); } // --- Coordinate helpers (amp canvas) --- _tToX(t) { return (t - this._amp.tMin) / (this._amp.tMax - this._amp.tMin) * this._ampCanvas.width; } _xToT(x) { return this._amp.tMin + (x / this._ampCanvas.width) * (this._amp.tMax - this._amp.tMin); } _ampToY(a) { const PADY = 10, H = this._ampCanvas.height; return PADY + (1 - a / this._amp.ampTop) * (H - 2 * PADY); } _yToAmp(y) { const PADY = 10, H = this._ampCanvas.height; return (1 - (y - PADY) / (H - 2 * PADY)) * this._amp.ampTop; } }