Refactor GUI rendering and align RTA axis
This commit is contained in:
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { getRtwCenters } from '../www/core/rtw_centers.js';
|
||||
import { selectLocalRtwPacket } from '../www/views/realtime.js';
|
||||
import { buildRtwTickPositions, selectLocalRtwPacket } from '../www/views/realtime.js';
|
||||
|
||||
const source = fs.readFileSync(new URL('../www/core/audio.js', import.meta.url), 'utf8');
|
||||
const functionSource = source.match(/export function buildRtaRuntimeConfig[\s\S]*?\n\}/)?.[0];
|
||||
@@ -87,4 +87,15 @@ assert.equal(selectedSixth.centers.length, getRtwCenters('1_6').length);
|
||||
assert.equal(selectedSixth.bands_avg.length, selectedSixth.centers.length);
|
||||
assert.equal(selectedSixth.bands_peak.length, selectedSixth.centers.length);
|
||||
|
||||
for (const mode of ['1_3', '1_6', '1_12']) {
|
||||
const centers = getRtwCenters(mode);
|
||||
const ticks = [20, 31.5, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
|
||||
const positions = buildRtwTickPositions(centers, ticks);
|
||||
for (const tick of ticks) {
|
||||
const index = centers.indexOf(tick);
|
||||
assert.ok(index >= 0, `${mode} must contain the ${tick} Hz display band`);
|
||||
assert.equal(positions[String(tick)], (index + 0.5) / centers.length);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('RTA profile regression tests passed');
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
<div class="opt-group">
|
||||
<details class="changelog-version" open>
|
||||
<summary>3.8 (Phoenix 1.0)</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 21.07.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li><strong>Analysegrundlage:</strong> Die tiefgehende Code- und DSP-Prüfung, durch die die nachfolgenden Fehler und Verbesserungsmöglichkeiten gefunden werden konnten, wurde durch den Umstieg auf das LLM-Modell GPT-5.6 SOL ermöglicht.</li>
|
||||
<li><strong>Echtzeit-Datenweg:</strong> WebSocket-Verarbeitung auf „latest value wins“ umgestellt. Messwerte, Spektrogramm sowie Goniometer/Waveform verwenden getrennte, begrenzte Datenwege; alte Frames können keine anwachsende Anzeigeverzögerung mehr bilden.</li>
|
||||
<li><strong>Browserlast:</strong> Single-Slot-Puffer und Sequenzprüfung für Mess-, Spektrogramm- und Visualisierungsdaten ergänzt. Große Rohsamplefelder werden nicht mehr in jedem JSON-Messpaket wiederholt.</li>
|
||||
<li><strong>Spektrogramm:</strong> Langzeitstillstand und stotterndes Nachholen beseitigt. Inkrementelles Spaltenzeichnen, Worker-ACK, Watchdog/Neustart und begrenztes Überspringen veralteter Spalten ergänzt. 0,5×, 1×, 2×, 4× und 6× besitzen nun eine feste, FFT- und DPI-unabhängige Zeitbasis.</li>
|
||||
<li><strong>DIN-/EBU-PPM:</strong> Blockunabhängige Quasi-Peak-Detektoren mit bandbegrenzter Interpolation, korrekter Attack- und Rücklaufballistik sowie automatischen Tonburst-, Frequenzgang- und Polaritätstests implementiert. Eine zweite Browser-Anstiegsballistik und der alte nicht normgerechte DIN-Fast-Attack-Sonderweg wurden vollständig entfernt.</li>
|
||||
<li><strong>RTW-RTA:</strong> RTW-Profil verbindlich auf die IIR-Oktavteilband-Filterbank festgelegt. Bis zur Vergleichsmessung am realen RTW-Gerät sind die bisherige Phoenix/RTW-Charakteristik und vollständige Butterworth-Bandpässe sechster Ordnung getrennt auswählbar.</li>
|
||||
<li><strong>RTA-Detektor und Reaktionszeit:</strong> Average (RMS) und Peak (gleitendes 10-ms-Maximum) sind nun getrennt von Fast, Medium, Slow und Impulse wählbar. Fast wurde auf 125 ms korrigiert; Impulse verwendet 35 ms Anstieg und 1,5 s Rücklauf. Die IIR-Detektoren arbeiten samplegenau und unabhängig von der ALSA-Periodengröße. Alte Einstellungen werden automatisch migriert.</li>
|
||||
<li><strong>RTA-Auflösung:</strong> Umschaltung zwischen 1/3, 1/6 und 1/12 repariert. Alle drei Auflösungen bleiben im RTW-Profil aktiv und verwenden dessen IIR-Filterbank, Darstellung und Ballistik.</li>
|
||||
<li><strong>Hardwareparameter:</strong> Tatsächlich von ALSA ausgehandelte Samplerate, Perioden- und Puffergröße werden nun durch DSP, Aufnahme, Status und Browser verwendet; die feste 48-kHz-Annahme wurde entfernt.</li>
|
||||
<li><strong>Goniometer:</strong> Nur neue XY-Samplepaare werden mit einer periodengrößenunabhängigen mittleren Rate von 60 Hz übertragen. Punktbegrenzung wirkt bereits auf den Transport; begrenzte und wiederverwendbare Spurpuffer reduzieren Speicher- und Zeichenlast.</li>
|
||||
<li><strong>Goniometer-Persistenz:</strong> Reproduzierbare Fast-, Medium- und Slow-Profile sowie ein freies Phoenix-Fade ergänzt. Künstliche Bézier-Verformung der Messspur entfernt; AGC und Silence-Gate arbeiten zeit- beziehungsweise fensterbasiert.</li>
|
||||
<li><strong>Korrelation:</strong> Kontinuierliche DSP-Messung aus L², R² und L·R mit wählbaren 1,0/2,5 Sekunden ergänzt. Bildratenabhängige Doppelglättung entfernt; Negative-Peak-Memory, Marker und manueller Reset hinzugefügt.</li>
|
||||
<li><strong>Phasenrad:</strong> Bandpass, Hilbert-Transformation und energiegewichtete L/R-Phasenmittelung laufen nun samplekontinuierlich im Audiokern statt auf ausgedünnten Browser-XY-Punkten. Paketgrenzen beeinflussen den Winkel nicht mehr; Glättung und AGC arbeiten zeitbasiert, und die doppelte AGC-Verstärkung wurde entfernt.</li>
|
||||
<li><strong>True Peak, RMS und VU:</strong> True Peak verwendet den vierphasigen Referenz-FIR aus ITU-R BS.1770, besteht die EBU-Testfälle 15 bis 19 und zeigt Übersteuerungen in allen Ansichten bis +6 dBTP an. True RMS integriert samplekontinuierlich im Leistungsbereich mit Fast (125 ms), Slow (1 s) oder einem gleitenden 300-ms-Fenster; Browser-Doppelglättung und der RMS-fremde Impulse-Modus wurden entfernt. VU verwendet ein Moving-Coil-Modell mit 300-ms-Sprungantwort und 1 bis 1,5 % Überschwingen; der fälschliche Standardoffset von +1,92 dB wurde auf 0 dB korrigiert.</li>
|
||||
<li><strong>Qualitätssicherung:</strong> Automatische Regressionstests für PPM, RTA, A/C/Z, mehrere Sampleraten, Korrelationssignale, Goniometertaktung, Binärprotokolle sowie Spektrogramm-Zeitbasis und Langlauf ergänzt.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>3.3.5 (Phoenix 1.0)</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 28.04.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Stoppuhr: Löschfunktion verbessert. Die X-Buttons neben den einzelnen Zeiten lassen sich jetzt über einen separaten X-Button ein- und ausblenden; Reset und Löschmodus sind optisch getrennt.</li>
|
||||
<li>Online Update: Hinweis ergänzt, dass die Funktion nur für die reine Analyzer-Installation gedacht ist; Volumio-Systeme sollen per Snapshot bzw. manuell aktualisiert werden.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>3.3.4 (Phoenix 1.0)</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 24.04.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Studio-Clock und Clock-Screensaver beim Sekundenblinken angepasst: Der Doppelpunkt läuft jetzt mit exakt 50 % Duty Cycle und schaltet auf der Sekunde an sowie in der zweiten Halbsekunde wieder aus.</li>
|
||||
<li>Online Update robuster gemacht: Update-Skripte laufen jetzt mit sicherer Fallback-Locale, damit fehlendes de_DE.UTF-8 auf Volumio keine bash-setlocale-Warnung mehr als Fehler anzeigt.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>3.3.2 (Phoenix 1.0)</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 21.04.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>INFO: Ein extern beobachtetes Start-/WebSocket-Problem in älteren Safari-Versionen lag nicht an Phoenix selbst, sondern an einem Safari-/WebKit-Bug; nach Safari-Update funktionierte der Analyzer wieder normal.</li>
|
||||
<li>Recorder-Liste auf große Browserfenster verbessert: Die sichtbare Listenhöhe passt sich jetzt dynamisch an die tatsächlich verfügbare Fläche an, statt früh auf eine starre Anzahl sichtbarer Einträge zu begrenzen.</li>
|
||||
<li>Recorder-Layout unten entschärft: Vor Timer und Buttons bleibt nun mehr Reserve, damit der letzte sichtbare Eintrag nicht in den Button-Bereich hineinragt, bevor gescrollt werden muss.</li>
|
||||
<li>Stoppuhr-Liste ebenfalls dynamisiert: Die Anzahl sichtbarer Zeiten wird jetzt direkt aus der verfügbaren Meter-Höhe berechnet und reagiert sauberer auf unterschiedlich große Browserfenster.</li>
|
||||
<li>Stoppuhr-Layout bei kleiner Höhe geglättet: Die unteren Start-/Reset-/Auto-Buttons schrumpfen bei knapper Höhe mit, damit die Liste beim Verkleinern nicht mehr schlagartig verschwindet.</li>
|
||||
<li>Phoenix-Metrics-Stream robuster gemacht: Bei kurzzeitigem Rückstau werden verpasste Frames jetzt verworfen, ohne den WebSocket sofort zu beenden; zusätzlich wurde der interne Metrics-Puffer vergrößert.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>3.3.1 (Phoenix 1.0)</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 20.04.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Layout- und Preset-System weiter ausgebaut: Presets wurden im Optionen-Menü auf eine eigene Ebene gehoben und die Layout-Slots lassen sich zusätzlich direkt aktivieren.</li>
|
||||
<li>Layout-Boot per URL stabilisiert: Toolbar, aktiver View und geladene Ansicht bleiben bei <code>?layout=1</code> und den übrigen Layout-Slots jetzt konsistenter synchron.</li>
|
||||
<li>Layout-Speicherung korrigiert: Die Slot-Belegungen der Views werden beim Speichern eines Layout-Slots jetzt zuverlässig mitgesichert und beim Laden wiederhergestellt.</li>
|
||||
<li>INFO: Ein zwischenzeitlich beobachtetes externes Start-/WebSocket-Problem in älteren Safari-Versionen lag nicht an Phoenix selbst, sondern an einem Safari-/WebKit-Bug; nach einem Safari-Update funktionierte der Analyzer wieder normal.</li>
|
||||
<li>Weitere Anzeigeoptionen globalisiert: RTA-Oktaven, Peak-History-Optionen, Phase-Wheel-Optionen, Classic-Needles-Quelle, historische RTW-Loudness sowie die konfigurierbaren Farben laufen nun browserübergreifend über die globale Phoenix-Konfiguration.</li>
|
||||
<li>Quad-/Registry-Pfad korrigiert: Peak History bleibt auch im Quad-View mit gültigen Live-Daten versorgt und fällt dort nicht mehr scheinbar auf 0 zurück.</li>
|
||||
<li>Online-Update ergänzt: Phoenix kann eine feste Update-ZIP laden, in einen Stage-Ordner vorbereiten und beim Neustart mit Backup, Health-Check und automatischem Rollback auf den alten Stand umschalten.</li>
|
||||
<li>Online-Update weiter gehärtet: Vor dem Neustart der Kiosk-Browser werden nur die Asset-Caches geleert, damit nach einem Update zuverlässig der neue Web-Stand geladen wird, ohne die eigentlichen Einstellungen zu verlieren.</li>
|
||||
<li>Online-Update-Logging ergänzt: Download-, Entpack-, Stage-, Neustart- und Rollback-Schritte werden jetzt in eine gemeinsame Update-Logdatei geschrieben und können zusätzlich über einen Phoenix-Endpunkt direkt abgefragt werden.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>3.3 (Phoenix 1.0)</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 20.04.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Software-Presets weiter ausgebaut: Default, Claus und Michael lassen sich jetzt robuster speichern, laden, exportieren und importieren.</li>
|
||||
<li>Preset-Speicherung bleibt aus Sicherheitsgründen auf den Analyzer direkt beschränkt; externe Browser dürfen Presets laden, aber nicht überschreiben.</li>
|
||||
<li>Phoenix-URL-/Host-Pfad für Presets gehärtet: Wenn eine gespeicherte Loopback-URL für den aktuellen Browser nicht passt, wird sauber auf den aktuellen Analyzer-Host zurückgefallen.</li>
|
||||
<li>Preset-Ladepfad stabilisiert: Benutzer-Presets übernehmen jetzt auch den getrennt gehaltenen UI-/View-Zustand zuverlässiger und laden nach Profil-/Cache-Reset wieder nachvollziehbar.</li>
|
||||
<li>Neue Layout-Slots ergänzt: Vier separate Layout-Presets speichern reine Ansichts-/Fensterbelegungen, unabhängig von globalen Software-Presets.</li>
|
||||
<li>Layout-Slots werden außerhalb des Browser-Profils serverseitig gespeichert und können per URL-Parameter wie <code>?layout=1</code> direkt in einem Fenster geladen werden.</li>
|
||||
<li>Options-Menü neu geordnet: Unter <code>Meters → Presets</code> liegen jetzt sowohl die Software-Presets als auch die neuen Layout-Slots gebündelt an einer Stelle.</li>
|
||||
<li>Erststart-Dialog überarbeitet: statt des alten Kalibrierhinweises erscheint nun eine neutrale Willkommensmeldung zum Phoenix Analyzer.</li>
|
||||
<li>Header-Farbpfad vereinheitlicht: Header-Text/-Zahlen entkoppelt von Balkenfarben und zusätzlich über eine globale Header-Textfarbe einstellbar.</li>
|
||||
<li>Waveform-Farben reagieren jetzt wieder korrekt auf Änderungen in den Optionen, auch im Worker-Pfad.</li>
|
||||
<li>Split-/Quad-Views erweitert: Waveform und Spectrogram können dort als Plot verwendet werden; eingebettete Geometrie und Darstellung wurden dafür nachgezogen.</li>
|
||||
<li>Weitere UI-/Detailkorrekturen: Peak-History-Geometrie, RTA-/Meter-Beschriftungen, PPM-DIN-%-Skala, Recorder-/Stoppuhr-Header sowie diverse kleinere Konsistenzfehler wurden bereinigt.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>3.1 (Phoenix 1.0)</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 18.04.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>GUI-Performance systematisch überarbeitet: statische Layer werden projektweit gecacht statt pro Frame neu gezeichnet.</li>
|
||||
<li>Renderloop bereinigt: Hintergrund-Layer gecacht und Zeichenpfad stärker auf echte Audio-/UI-Änderungen ausgerichtet.</li>
|
||||
<li>Spectrogram deutlich entlastet: Phoenix liefert Spectro-Spalten nur noch bei echten neuen FFT-Schritten, Worker-Takt reduziert und interne Renderauflösung angepasst.</li>
|
||||
<li>Waveform deutlich entlastet: Worker-Takt reduziert, interne Renderauflösung angepasst und Grid-Layer gecacht.</li>
|
||||
<li>Peak History deutlich entlastet: Grid, Rahmen und Zeit-/Werteskalen werden gecacht; Verlauf und Live-Meter bleiben dynamisch.</li>
|
||||
<li>Peak History in der Geometrie nachkorrigiert: `now` rechts sichtbar, linke Endzeit wird explizit gesetzt, Füllung läuft bis zum unteren Rahmen, Verlauf kann unter den letzten Tick weiterlaufen und der Plot beginnt links ohne künstliche Rahmenlücke.</li>
|
||||
<li>Meter-Views weiter entschlackt: feste Skalen, Ticks, Referenzlinien und Labels von VU, PPM DIN, PPM EBU, RMS, TP und LUFS werden nicht mehr jedes Frame neu gerastert.</li>
|
||||
<li>Phase Wheel entlastet: tote Trace-Berechnung entfernt und Phase-Trail intern ausgedünnt.</li>
|
||||
<li>Goniometer/XY weiter bereinigt: RTW-Look beibehalten, Alpha-/Trail-Dichte entschlackt und echte XY-History in Phoenix so angepasst, dass `XY Punkte` wieder sinnvoll wirkt.</li>
|
||||
<li>Split- und Quad-View bereinigt: statische Containerteile, leere Plots und Meterhüllen werden gecacht statt pro Frame neu aufgebaut.</li>
|
||||
<li>Recorder- und Classic-Needles-View weiter entschlackt: statische Bühnen, Rahmen und Instrumentenkörper gecacht; beim Recorder zusätzlich unnötige HUD-Layout-Schreiberei reduziert.</li>
|
||||
<li>RTA/Realtime entlastet: Plot-Grid, Rahmen und rechte Meterhülle werden gecacht, während Balken/Line und Hold/Peak live bleiben.</li>
|
||||
<li>Projektweit weiterer Cleanup: tote Frontend-Restlogik entfernt, Referenz-Configs für Analyzer/Volumio ergänzt und Doku zu Analyzer-vs.-Volumio-Pfaden/Ports nachgezogen.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>3.0.1 (Phoenix 1.0)</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 18.04.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>PPM/VU/RMS-Alignment korrigiert: PPM DIN, PPM EBU, VU und RMS liegen bei den vorgesehenen Referenzpegeln wieder konsistent zueinander.</li>
|
||||
<li>RTW-Loudness-Boxen auf dokumentierbaren Momentary-Pfad umgestellt: 400 ms Integration, K-gewichtete Signalbasis, pro Kanal; keine freie ungefilterte Hauskurve mehr.</li>
|
||||
<li>RTW-Loudness-Offset global gemacht: Der Offset wird jetzt in Phoenix gespeichert und zwischen verbundenen Browsern synchron gehalten.</li>
|
||||
<li>RTW-Oktavwahl lokalisiert: Die sichtbare RTW-Auflösung 1/3, 1/6 und 1/12 ist jetzt browserlokal und schaltet andere Sessions nicht mehr um.</li>
|
||||
<li>Fehler in der lokalen 1/6-Oktavdarstellung behoben: Center-Frequenzen und Bandwerte laufen wieder sauber synchron.</li>
|
||||
<li>Goniometer weiter auf klassische RTW-Anmutung geschärft: dichtere Spur, dickere Linien, geglättete Kurven; dieser Stil ist jetzt fest der Standard.</li>
|
||||
<li>Volumio-/Raspberry-Pi-Startpfad robuster gemacht: Toolchain-, Netzwerk-, Build- und Session-Handling wurden für frische Geräte und getrennte Volumio-Services nachgezogen.</li>
|
||||
<li>Build-Kompatibilität für ältere Zielsysteme nachgezogen: Lockfile-/Toolchain-Themen, ALSA-/pkg-config-Abhängigkeiten und plattformspezifische ALSA-Frame-Typen abgefangen.</li>
|
||||
<li>Volumio-Pfad weiter bereinigt: Analyzer-GUI läuft dort getrennt auf eigenem Port, `toggle-ext` liegt im Phoenix-Baum und der Tab-Umschalter zeigt wieder auf die richtigen Ziele.</li>
|
||||
<li>Projektlayout weiter bereinigt: `toggle-ext` und die Volumio-spezifischen Startdateien liegen jetzt konsistent im Phoenix-Baum.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>3.0 (Phoenix 1.0)</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 18.04.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Kompletter Architektur-Umbau: Der Analyzer läuft jetzt nativ auf Phoenix statt auf WebAudio/AudioWorklet im Browser.</li>
|
||||
<li>Phoenix ist neue Single Source of Truth für Audio, Analyse und globale Gerätekonfiguration; die Web-GUI dient nur noch als Anzeige- und Bedienoberfläche.</li>
|
||||
<li>Audio-Pfad vollständig in Rust/ALSA verlagert: Browser greift nicht mehr selbst auf Audio-Hardware zu.</li>
|
||||
<li>Analysepfade nach Phoenix übertragen: Meter, RMS, VU, PPM DIN/EBU, True Peak, LUFS/LRA, RTW-Loudness-Boxen, RTA, Spectrogram, Waveform, XY und Phase Wheel werden jetzt nativ im Backend erzeugt.</li>
|
||||
<li>Recorder komplett auf Phoenix umgestellt: native Aufnahme in WAV, MP3 und WebM/Opus ohne Browser-Chunking oder nachträgliches Zusammensetzen im Frontend.</li>
|
||||
<li>Direktes Speichern über Phoenix ergänzt: Aufnahmen können ohne Chrome-Download direkt auf dem Gerät im Dateisystem abgelegt werden.</li>
|
||||
<li>Globale Optionen in Phoenix verankert: geräterelevante Parameter wie Kalibrierung, LUFS-/PPM-Verhalten, Recorder-Defaults und Screensaver-Verhalten werden zentral gespeichert und an verbundene Browser synchronisiert.</li>
|
||||
<li>Waveform- und XY-Datenpfad auf native Phoenix-Frames umgestellt; Browser verarbeitet keine eigenen Audio-Samples mehr.</li>
|
||||
<li>Normnähe der Messpfade überarbeitet: True Peak, LUFS, VU sowie mehrere Ballistik- und Bewertungswege rechnerisch an die relevanten Standards angenähert.</li>
|
||||
<li>Komplette Bereinigung des alten WebAudio-Stacks: Browser-Recorder, MediaRecorder-/Chunk-Logik, AudioWorklet-Altpfade und nicht mehr benötigte Umschaltlogik wurden entfernt.</li>
|
||||
<li>Deployment/Boot für Raspberry Pi neu strukturiert: separater Webservice, Kiosk-Startskripte, automatischer Phoenix-Build bei Quellcode-Änderungen sowie Build-Logging beim Start.</li>
|
||||
<li>Projektstruktur auf Phoenix konsolidiert: Runtime-Skripte, `www` und systemd-Units liegen jetzt gemeinsam unter dem Phoenix-Baum.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>2.9</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 09.04.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Recorder: Auto-Split-Threshold jetzt als gekoppelte Zahlenfelder in dBFS und PPM DIN einstellbar.</li>
|
||||
<li>Stoppuhr: Auto-Threshold jetzt ebenfalls als gekoppelte Zahlenfelder in dBFS und PPM DIN einstellbar.</li>
|
||||
<li>Optionen: Zusätzlichen Scroll-Puffer im Optionen-Menü ergänzt, damit untere Eingabefelder trotz On-Screen-Tastatur weiter nach oben gezogen werden können.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>2.8 (HOTFIX)</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 25.03.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Audio/Timing: Zusätzlichen Display-/Interpolationspuffer entfernt; Anzeigen werden wieder direkt aus dem AudioWorklet gespeist.</li>
|
||||
<li>Low Latency: Worklet-UI-Intervall auf 8 ms reduziert und Renderpfad auf 60 FPS gestellt.</li>
|
||||
<li>Performance: Audio- und Analysepfade berechnen nur noch, was im aktuellen View und in den sichtbaren Slots wirklich benötigt wird.</li>
|
||||
<li>Cold Start: XY-, RTA-, Waveform- und Meter-Zustände werden beim Umschalten sauber zurückgesetzt, damit keine alten Daten kurz wieder auftauchen.</li>
|
||||
<li>Performance: FFT- sowie LUFS-/Box-Analyser laufen nur noch bei tatsächlichem Sichtbarkeitsbedarf.</li>
|
||||
<li>RTA: Neue Option für die Balken-Grundfarbe; Peak-Bereiche bleiben weiterhin rot.</li>
|
||||
<li>RTA: „Real Time Hold“ und „Real Time Decay“ wirken jetzt tatsächlich auf die Balkenanzeige; höherer Decay lässt die Balken schneller zurückfallen.</li>
|
||||
<li>UI: Zahlenfelder committen jetzt sauber per Change/Blur/Enter; Eingaben über On-Screen-Keyboard und VNC springen nicht mehr vorzeitig auf Min-/Max-Werte.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>2.5</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 22.03.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Fix: Classic Needles im Quad-View werden nun korrekt angezeigt.</li>
|
||||
<li>Audio/Timing: Umstellung auf Audio-Zeitstempel und frame-synchron berechnete Display-Pakete zur Glättung der Anzeige und Reduktion von Drift/Jitter.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>2.3.7</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 04.02.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Fix: Audio-Pfad bleibt zuverlässig aktiv und verhindert „Stall“ nach langem Idle/Screensaver.</li>
|
||||
<li>Fix: Auto-Recovery bei „Audio lost“ nach Screensaver-Wake und User-Activity.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>2.3.6</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 04.02.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Fix: Loudness/LUFS – rechter Kanal korrekt normalisiert.</li>
|
||||
<li>Optimierung: LRA/Loudness ohne wiederholte Array-Allokationen (Ringpuffer + Scratch-Buffer) für weniger GC/CPU-Spikes.</li>
|
||||
<li>Fix: Config-Load ist jetzt idempotent (verhindert doppeltes Laden beim Start/Options-Init).</li>
|
||||
<li>PPM DIN: Peak-Hold implementiert (rein visuell), steuerbar über PPM_DIN_HOLD_MS und PPM_DIN_HOLD_DECAY_DB_PER_S.</li>
|
||||
<li>Peak History: Historie als Ringpuffer statt copyWithin-Shift (weniger CPU, stabilere Darstellung).</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>2.3.4</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 04.03.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>2.3.2</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 02.02.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Peak History: Nach dem Beenden des Bildschirmschoners wird die History nicht mehr in einem Frame mit einem Wert „aufgefüllt“ (kein Backfill nach Pause); nutzt nur frische Audiodaten.</li>
|
||||
<li>XY / Korrelation: Neue Option „Sofortige Nullstellung wenn kein Pegel“ (setzt den Würfel bei Stille sofort auf 0, ohne Hold/Drift).</li>
|
||||
<li>Stoppuhr (Slot‑Meter): Fix für Canvas‑State/Font‑Leak, der nach Aktivierung die Skalen‑Schrift in anderen Views riesig gemacht hat.</li>
|
||||
<li>Audio/Timing: Anzeige-Update-Intervall reduziert und an die effektive Render‑FPS gekoppelt (verringert Jitter/Drift, verhindert Message‑Backlogs).</li>
|
||||
<li>Default: FFT_SIZE auf 4096 reduziert (weniger CPU; betrifft v.a. Spectrogram/LUFS/FFT‑RTA).</li>
|
||||
<li>Performance: Keine FFT‑Berechnung mehr, wenn kein Spectrogram aktiv ist und der Real Time Analyzer auf IIR läuft.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>2.3.1</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 30.01.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>XY / Korrelation: Nach dem Beenden des Bildschirmschoners wird die Anzeige sauber zurückgesetzt und nur mit frischen Audiodaten berechnet (kein Sprung auf +1 ohne Signal).</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>2.3</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 26.01.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Quad‑View: Klick‑Verhalten wählbar (direkt wechseln oder Popup‑Ansicht).</li>
|
||||
<li>Quad‑View: Popup‑Ansicht rendert exakt wie die Einzel‑View (Layout/Skalen identisch); Tap/ESC schließt.</li>
|
||||
<li>Split‑View: Klick‑Verhalten wählbar (direkt wechseln oder Popup‑Ansicht).</li>
|
||||
<li>Split‑View: Popup‑Ansicht rendert exakt wie die Einzel‑View; Tap/ESC schließt.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>2.1</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 26.01.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Split‑View: kompaktere HUD‑Bedienung (schmalere Plot‑Dropdowns, Trennzeichen „|“).</li>
|
||||
<li>Split‑View: Slot‑Zuordnung über Button + Popup (zeigt nur aktive Slots).</li>
|
||||
<li>Split‑View: Popup schließt per Klick außerhalb oder ESC.</li>
|
||||
<li>Split‑View: „Slot:“ wird nur angezeigt, wenn mindestens ein Slot aktiv ist.</li>
|
||||
<li>Neu: Quad‑View (4 unabhängige Plots im 2×2‑Raster).</li>
|
||||
<li>Quad‑View: Plot‑Auswahl über Popup; eigene Slot‑Konfiguration (unabhängig vom Split‑View).</li>
|
||||
<li>Phase Wheel: Phase‑Box im Split‑View korrekt positioniert (stabil am Plot‑Panel, mit Abstand; Box kleiner).</li>
|
||||
<li>Bildschirmschoner: Recorder‑UI/Modals sowie Slot‑Popup werden zuverlässig ausgeblendet.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>2.0</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 22.01.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Neu: Split‑View (zwei Plots nebeneinander, Links/Rechts wählbar).</li>
|
||||
<li>Split‑View: optionale Meter‑Slots (0–3) mit frei wählbarer Position (links/mitte/rechts).</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>1.8</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 17.01.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>LUFS (Integrated): I‑Zeitfenster wird korrekt gespeichert und angewendet.</li>
|
||||
<li>LUFS (Integrated): I bleibt bei Stille (unter −70 LUFS Gate) stehen und fällt nicht weiter ab.</li>
|
||||
<li>Phasenrad: Zahlenanzeige folgt dem Zeiger besser (ruhiger, ohne Nachkommastelle).</li>
|
||||
<li>Neu: Stoppuhr als auswählbarer Slot (Start/Stop/Reset/Auto, optional 7‑Segment‑Anzeige).</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>1.7.8</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 16.01.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>LUFS (Integrated): Zeitfenster per Slider (1–10 min) einstellbar.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>1.7.7</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 09.01.2026</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Input-Offset: kanalgetrennte Einstellung (L/R).</li>
|
||||
<li>LUFS: Lesbarkeit der Skala angepasst.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>1.7.5</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 31.12.2025</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Phase Wheel: Phasenrad gespiegelt (z. B. +90° liegt rechts).</li>
|
||||
<li>PPM DIN: Ballistik im eigenen AudioWorklet; Anzeige/Ballistik nach IEC 60268-10 Type I (DIN) korrigiert.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>1.7.3</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 29.12.2025</p>
|
||||
<p class="changelog-heading">Neuerungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>LUFS: Skalen-Zahlen & Skalen-Striche in eigener Farbe einstellbar.</li>
|
||||
<li>Phase Wheel: Phasenanzeige (Box links oben) größer und weiter links; Zahl zusätzlich geglättet und Update-Rate begrenzt (max. 10×/s) für bessere Lesbarkeit.</li>
|
||||
<li>PPM DIN: RTW-Loudness-Offset als Schieberegler (−7 … +7 dB).</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>1.7 (Auslieferungsversion)</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 21.12.2025</p>
|
||||
<p class="changelog-heading">Neuerungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Slots können auf „(leer)“ gesetzt werden: der Slot wird ausgeblendet und die verbleibenden Anzeigen rücken nach (z. B. nutzt der Real Time Analyzer dann die volle Breite).</li>
|
||||
<li>Peak History und Classic Needles benötigen einen belegten Slot; dort ist „(leer)“ deaktiviert.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>1.6</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 19.12.2025</p>
|
||||
<p class="changelog-heading">Neuerungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Neuer View: „Classic Needles“.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>1.5.5</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 08.12.2025</p>
|
||||
<p class="changelog-heading">Neuerungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Silence-Gate für Goniometer & Korrelation (RMS-basiert, Threshold einstellbar im XY-Menü).</li>
|
||||
<li>Header-Werte pro Kanal: sauber zentriert über den Balken, Farbumschlag pro Kanal erst ab Rot-Schwelle.</li>
|
||||
<li>Anzeige der vollen Pegel (auch unterhalb der Skala) mit sanfter Glättung der Zahlen.</li>
|
||||
<li>RMS-Meter: hält jetzt den letzten gültigen Wert, kein kurzer Drop mehr auf den Floor bei sporadisch fehlenden RMS-Paketen.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version" open>
|
||||
<summary>1.5</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 05.12.2025</p>
|
||||
<p class="changelog-heading">Peak History – Neu hinzugefügt:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Neuer „Peak History“-View: verlaufende Pegelanzeige mit Meter rechts daneben.</li>
|
||||
<li>Konfigurierbare Scroll-Geschwindigkeit (½×, 1×, 2×, 4×, 6×) wie beim Spectrogram.</li>
|
||||
<li>Farbgenaue Linie: Rot erst ab Warnschwelle (z. B. PPM_DIN_RED_START), Segment-Splitting genau auf der Schwelle.</li>
|
||||
<li>Optionale Flächenfüllung unter/über der Linie in Linienfarbe; invertierbar.</li>
|
||||
<li>Zeitskala passt sich der Scroll-Geschwindigkeit an (länger bei ½×, kürzer bei 2×/4×/6×).</li>
|
||||
<li>Peak-History-Optionen im Menü: Scroll-Speed, Fläche füllen, Fläche invertieren.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version">
|
||||
<summary>1.2</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 28.11.2025</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Studio Clock: neue Stile „Analog + PPM“ und „Digital + PPM“; optional LED-Band für PPM.</li>
|
||||
<li>PPM in der Clock nutzt DIN-Skala, Offsets und Ballistik identisch zum PPM-DIN-Meter.</li>
|
||||
<li>PPM-Skalenfarben vereinheitlicht (DIN-Orange), Warnfarbe nur oberhalb 0 dB.</li>
|
||||
<li>Neue Uhr-Funktion: Studio Clock mit Stilen „Analog“, „Digital“, „Analog + PPM“, „Digital + PPM“, optional LED-Band.</li>
|
||||
<li>Neuer Bildschirmschoner: DVD/Starfield/Clock, Vorschau ignoriert Pegel (endet nur per Klick/Touch), Optionen gebündelt.</li>
|
||||
<li>Clock- und Screensaver-Optionen sind unabhängig im Menü steuerbar.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version">
|
||||
<summary>1.1.4.1</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 26.11.2025</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Goniometer-Korrelationswürfel: Clamping angepasst (bleibt sauber innerhalb des Balkens)</li>
|
||||
<li>PPM DIN: Skala renormiert (Bottom sitzt am Balkenfuß), %-Label korrigiert</li>
|
||||
<li>PPM DIN: Loudness-Boxen verlängerte Skala (bis −70 dB), sichtbarer Teil nur oberhalb −50 dB</li>
|
||||
<li>Input-Offset: Slider auf 0,5-dB-Schritte gestellt</li>
|
||||
<li>Recorder: Einmaliger Hinweis/Bestätigung bei Erstaufruf (WAV-Limit, WebM/Opus-Hinweis, Beta)</li>
|
||||
<li>LUFS: Farben pro I/M/S separat einstellbar</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version">
|
||||
<summary>1.1.4</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 25.11.2025</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>XY-Optionen wiederhergestellt und zugeordnet (Punkte/Stil/Korrelation/Gain/Fade/Gap)</li>
|
||||
<li>XY Default-Punkte auf 1024 gesetzt; Hinweise ergänzt</li>
|
||||
<li>Spectrogram/Waveform/Phase Wheel: Beschreibungen ergänzt</li>
|
||||
<li>Spectrogram: Worker stabilisiert (Ringpuffer, Draw-Loop-Fix)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version">
|
||||
<summary>1.1.3.2</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 25.11.2025</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Fix: Spectrogram-Worker stabilisiert (kein Stoppen nach wenigen Sekunden)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version">
|
||||
<summary>1.1.3.1</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 25.11.2025</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Fix: RTW-Loudness-Boxen im PPM DIN Meter (fehlendes Timestamp-Init behoben)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version">
|
||||
<summary>1.1.3</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 25.11.2025</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Optionen-Überschrift umbenannt & unterstrichen</li>
|
||||
<li>RMS: IEC-Zeitkonstanten (Impulse/Fast/Slow) auswählbar, EWMA entfernt</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version">
|
||||
<summary>1.1.2</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 25.11.2025</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Einführung: Changelog im Optionen-Menü</li>
|
||||
<li>Korrektur: PPM DIN & PPM EBU ohne doppelte Ballistik (UI-Glättung entfernt)</li>
|
||||
<li>Korrektur: RMS-Anzeige ohne zusätzliche Glättung</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version">
|
||||
<summary>1.1</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Veröffentlichung: 25.11.2025</p>
|
||||
<p class="changelog-heading">Änderungen:</p>
|
||||
<ul class="changelog-list">
|
||||
<li>Hinzugefügt: RTW-Style Loudnessanzeige auf PPM DIN Meter</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="changelog-version">
|
||||
<summary>1.0</summary>
|
||||
<div class="opt-group">
|
||||
<div class="opt">
|
||||
<p class="changelog-date">Erste Veröffentlichung: 21.11.2025</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
// Gemeinsame Erzeugung temporärer Canvas-Flächen für Views und Meter.
|
||||
export function createCanvasSurface(width, height) {
|
||||
const w = Math.max(1, Math.ceil(width));
|
||||
const h = Math.max(1, Math.ceil(height));
|
||||
if (typeof OffscreenCanvas === 'function') {
|
||||
const canvas = new OffscreenCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) return { canvas, ctx, width: w, height: h };
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) return { canvas, ctx, width: w, height: h };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+11
-17
@@ -1,5 +1,6 @@
|
||||
// core/registry.js — Lazy Loader + Safe Runner für Meter
|
||||
// Lädt Meter-Module on-demand (oder via registerMeter) und kapselt Fehler.
|
||||
import { createCanvasSurface } from './canvas_surface.js';
|
||||
|
||||
const cache = {
|
||||
meters: new Map(), // id -> module
|
||||
@@ -7,26 +8,12 @@ const cache = {
|
||||
|
||||
const meterRenderCache = new Map(); // key -> surface
|
||||
const METER_CACHE_MARGIN = 32;
|
||||
const METER_RENDER_CACHE_LIMIT = 48;
|
||||
let activeFrameStamp = 0;
|
||||
|
||||
const CAN_USE_OFFSCREEN = typeof OffscreenCanvas === 'function';
|
||||
|
||||
function createCacheSurface(width, height) {
|
||||
const w = Math.max(1, Math.ceil(width));
|
||||
const h = Math.max(1, Math.ceil(height));
|
||||
if (CAN_USE_OFFSCREEN) {
|
||||
const canvas = new OffscreenCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) return { canvas, ctx, width: w, height: h, stamp: -1 };
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) return { canvas, ctx, width: w, height: h, stamp: -1 };
|
||||
}
|
||||
return null;
|
||||
const surface = createCanvasSurface(width, height);
|
||||
return surface ? { ...surface, stamp: -1 } : null;
|
||||
}
|
||||
|
||||
function cacheKeyForMeter(id, rect) {
|
||||
@@ -40,10 +27,17 @@ function cacheKeyForMeter(id, rect) {
|
||||
function ensureCacheSurface(id, rect) {
|
||||
const key = cacheKeyForMeter(id, rect);
|
||||
let surface = meterRenderCache.get(key);
|
||||
if (surface) {
|
||||
meterRenderCache.delete(key);
|
||||
meterRenderCache.set(key, surface);
|
||||
}
|
||||
if (!surface) {
|
||||
surface = createCacheSurface(rect.w + METER_CACHE_MARGIN * 2, rect.h + METER_CACHE_MARGIN * 2);
|
||||
if (!surface) return null;
|
||||
meterRenderCache.set(key, surface);
|
||||
while (meterRenderCache.size > METER_RENDER_CACHE_LIMIT) {
|
||||
meterRenderCache.delete(meterRenderCache.keys().next().value);
|
||||
}
|
||||
}
|
||||
if (!surface.canvas || !surface.ctx) return null;
|
||||
const w = Math.max(1, Math.ceil(rect.w + METER_CACHE_MARGIN * 2));
|
||||
|
||||
+19
-5
@@ -250,7 +250,9 @@ export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) {
|
||||
colorMinor = 'rgba(30,42,53,.55)',
|
||||
labelColor = '#8fd3d4',
|
||||
freqTicks = null,
|
||||
freqTickPositions = null,
|
||||
freqMin = 20,
|
||||
xLeftExtension = 0,
|
||||
refDb = null,
|
||||
refStyle = 'rgba(0,231,255,0.35)',
|
||||
refDash = [4, 3],
|
||||
@@ -261,6 +263,12 @@ export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) {
|
||||
|
||||
const _g = Number(CONFIG && CONFIG.AXIS_GUTTER_LEFT);
|
||||
const gutterL = Number.isFinite(_g) ? Math.max(8, _g) : 14;
|
||||
const requestedExtension = Number(xLeftExtension);
|
||||
const leftExtension = Number.isFinite(requestedExtension)
|
||||
? Math.max(0, Math.min(gutterL, requestedExtension))
|
||||
: 0;
|
||||
const contentX = x0 - leftExtension;
|
||||
const contentW = w + leftExtension;
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#00e7ff'; ctx.lineWidth = 2; ctx.strokeRect(x0 - gutterL, y0, w + gutterL, h);
|
||||
@@ -272,7 +280,7 @@ export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) {
|
||||
const y = yFromDbfs(v, y0, y0 + h, CONFIG);
|
||||
const labelY = Math.min(y0 + h - 4, Math.max(y0 + 12, y + 4));
|
||||
ctx.strokeStyle = colorMajor; ctx.lineWidth = 1.2;
|
||||
ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x0 + w, y); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(contentX, y); ctx.lineTo(contentX + contentW, y); ctx.stroke();
|
||||
ctx.textAlign = 'right'; ctx.fillText(String(v), (x0 - gutterL) - 6, labelY);
|
||||
}
|
||||
|
||||
@@ -282,8 +290,8 @@ export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) {
|
||||
ctx.strokeStyle = refStyle;
|
||||
ctx.setLineDash(Array.isArray(refDash) ? refDash : [4, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x0, yRef);
|
||||
ctx.lineTo(x0 + w, yRef);
|
||||
ctx.moveTo(contentX, yRef);
|
||||
ctx.lineTo(contentX + contentW, yRef);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
@@ -299,9 +307,15 @@ export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) {
|
||||
const logMin = Math.log10(minFreq);
|
||||
const logSpan = Math.max(1e-6, Math.log10(nyq) - logMin);
|
||||
for (const f of ticks) {
|
||||
const x = x0 + ((Math.log10(Math.max(f, minFreq)) - logMin) / logSpan) * w;
|
||||
const requestedPosition = freqTickPositions && typeof freqTickPositions === 'object'
|
||||
? Number(freqTickPositions[String(f)])
|
||||
: NaN;
|
||||
const fraction = Number.isFinite(requestedPosition)
|
||||
? Math.max(0, Math.min(1, requestedPosition))
|
||||
: ((Math.log10(Math.max(f, minFreq)) - logMin) / logSpan);
|
||||
const x = contentX + fraction * contentW;
|
||||
// Linken Rand nicht doppeln: keine Vertikal-Linie auf dem linken Plot-Rand
|
||||
if (x > x0 + 0.75) {
|
||||
if (x > contentX + 0.75) {
|
||||
ctx.strokeStyle = '#131b24';
|
||||
ctx.beginPath(); ctx.moveTo(x, y0); ctx.lineTo(x, y0 + h); ctx.stroke();
|
||||
}
|
||||
|
||||
+103
-690
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,4 @@
|
||||
const CAN_USE_OFFSCREEN = typeof OffscreenCanvas === 'function';
|
||||
|
||||
function createSurface(width, height) {
|
||||
const w = Math.max(1, Math.ceil(width));
|
||||
const h = Math.max(1, Math.ceil(height));
|
||||
if (CAN_USE_OFFSCREEN) {
|
||||
const canvas = new OffscreenCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) return { canvas, ctx, width: w, height: h };
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) return { canvas, ctx, width: w, height: h };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
import { createCanvasSurface } from '../core/canvas_surface.js';
|
||||
|
||||
export function drawCachedStaticLayer(targetCtx, shared, layerId, key, x, y, width, height, build) {
|
||||
if (!targetCtx || !shared || typeof build !== 'function') return false;
|
||||
@@ -30,7 +12,7 @@ export function drawCachedStaticLayer(targetCtx, shared, layerId, key, x, y, wid
|
||||
|| layer.height !== h;
|
||||
|
||||
if (needsRebuild) {
|
||||
layer = createSurface(w, h);
|
||||
layer = createCanvasSurface(w, h);
|
||||
if (!layer) return false;
|
||||
layer.key = key;
|
||||
layer.ctx.save();
|
||||
|
||||
@@ -415,3 +415,15 @@ canvas.waveform-layer{
|
||||
|
||||
#optionsPanelNew summary{font-size:17px;padding:8px 4px;}
|
||||
#optionsPanelNew .opt{font-size:16px;}
|
||||
.changelog-details { margin: 20px 0 0 5px; }
|
||||
.changelog-content > .opt-group { margin-top: 0; }
|
||||
.changelog-version { margin-left: 12px; }
|
||||
.changelog-date { margin: 0; color: #8fd3d4; }
|
||||
.changelog-heading { margin: 4px 0 0 12px; color: #8fd3d4; }
|
||||
.changelog-list { margin: 2px 0 0 24px; color: #b2c7d9; }
|
||||
.opt-w-110 { width: 110px !important; }
|
||||
.opt-w-120 { width: 120px !important; }
|
||||
.opt-w-140 { width: 140px !important; }
|
||||
.opt-w-150 { width: 150px !important; }
|
||||
.opt-w-180 { width: 180px !important; }
|
||||
.opt-w-220 { width: 220px !important; }
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
let loadPromise = null;
|
||||
|
||||
export function setupLazyChangelog(details, content) {
|
||||
if (!details || !content || details.dataset.bound === '1') return;
|
||||
details.dataset.bound = '1';
|
||||
|
||||
const load = async () => {
|
||||
if (content.dataset.loaded === '1') return;
|
||||
if (!loadPromise) {
|
||||
loadPromise = fetch('./changelog.html', { cache: 'no-cache' })
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.text();
|
||||
});
|
||||
}
|
||||
try {
|
||||
content.innerHTML = await loadPromise;
|
||||
content.dataset.loaded = '1';
|
||||
} catch (error) {
|
||||
loadPromise = null;
|
||||
content.textContent = 'Versionsverlauf konnte nicht geladen werden.';
|
||||
console.warn('Changelog load error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
details.addEventListener('toggle', () => {
|
||||
if (details.open) void load();
|
||||
});
|
||||
if (details.open) void load();
|
||||
}
|
||||
+4
-74
@@ -3,6 +3,8 @@
|
||||
|
||||
import { CONFIG, saveConfig, loadConfig, applyAlignmentProfile, applyRtaBpoSelection, applySoftwarePreset, loadSoftwarePreset, saveSoftwarePreset, loadLayoutPreset, saveLayoutPreset, exportSoftwarePreset, importSoftwarePreset, updateVuReference, resetConfigToDefaults, updateInputOffsetGain } from '../core/config.js';
|
||||
import { clamp, clampPow2 } from '../core/utils.js';
|
||||
import { bindOptionsViewportAssist } from './options_viewport.js';
|
||||
import { setupLazyChangelog } from './changelog.js';
|
||||
|
||||
// DOM helper
|
||||
const E = (id) => document.getElementById(id);
|
||||
@@ -86,86 +88,14 @@ export function setupOptions(env) {
|
||||
enforcePhaseAmplitudeConstraints({ persist: true });
|
||||
syncUI();
|
||||
wireHandlers(env);
|
||||
bindOptionsViewportAssist();
|
||||
bindOptionsViewportAssist(E('optionsPanelNew'));
|
||||
setupLazyChangelog(E('changelogDetails'), E('changelogContent'));
|
||||
bindAlignmentToggle(env);
|
||||
bindVuRefToggle(env);
|
||||
// Sichtbarkeit nach View steuern übernimmt main.js — hier nur Export der Sync-Funktion
|
||||
return { syncUI };
|
||||
}
|
||||
|
||||
let optionsViewportAssistBound = false;
|
||||
|
||||
function bindOptionsViewportAssist() {
|
||||
if (optionsViewportAssistBound) return;
|
||||
optionsViewportAssistBound = true;
|
||||
|
||||
const panel = E('optionsPanelNew');
|
||||
if (!panel) return;
|
||||
|
||||
const setKeyboardInset = () => {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) {
|
||||
panel.style.setProperty('--options-kb-inset', '0px');
|
||||
return;
|
||||
}
|
||||
const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop));
|
||||
panel.style.setProperty('--options-kb-inset', `${Math.round(inset)}px`);
|
||||
};
|
||||
|
||||
const revealFocusedField = (target) => {
|
||||
if (!panel || !target || !panel.contains(target)) return;
|
||||
const vv = window.visualViewport;
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const keyboardInset = vv ? Math.max(0, window.innerHeight - (vv.height + vv.offsetTop)) : 0;
|
||||
const visibleTop = panelRect.top + 16;
|
||||
const visibleBottom = panelRect.bottom - keyboardInset - 16;
|
||||
|
||||
if (targetRect.bottom > visibleBottom || targetRect.top < visibleTop) {
|
||||
const targetCenter = targetRect.top + (targetRect.height / 2);
|
||||
const visibleCenter = visibleTop + ((visibleBottom - visibleTop) / 2);
|
||||
const delta = targetCenter - visibleCenter;
|
||||
panel.scrollTop += delta;
|
||||
}
|
||||
};
|
||||
|
||||
const onFocus = (ev) => {
|
||||
const el = ev.target;
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
const type = String(el.getAttribute('type') || '').toLowerCase();
|
||||
if (el.tagName !== 'INPUT' && el.tagName !== 'SELECT' && el.tagName !== 'TEXTAREA') return;
|
||||
if (type === 'checkbox' || type === 'range' || type === 'color') return;
|
||||
setKeyboardInset();
|
||||
requestAnimationFrame(() => revealFocusedField(el));
|
||||
setTimeout(() => revealFocusedField(el), 250);
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
setTimeout(() => {
|
||||
const active = document.activeElement;
|
||||
if (!(active instanceof HTMLElement) || !panel.contains(active)) {
|
||||
panel.style.setProperty('--options-kb-inset', '0px');
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
panel.addEventListener('focusin', onFocus);
|
||||
panel.addEventListener('focusout', onBlur);
|
||||
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', () => {
|
||||
setKeyboardInset();
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLElement && panel.contains(active)) {
|
||||
requestAnimationFrame(() => revealFocusedField(active));
|
||||
}
|
||||
});
|
||||
window.visualViewport.addEventListener('scroll', setKeyboardInset);
|
||||
}
|
||||
|
||||
setKeyboardInset();
|
||||
}
|
||||
|
||||
function syncSoftwarePresetControls() {
|
||||
const presetId = String(E('opt_softwarePreset')?.value || CONFIG.SOFTWARE_PRESET || 'default').toLowerCase();
|
||||
const isDefault = presetId === 'default';
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
let bound = false;
|
||||
|
||||
// Hält fokussierte Eingabefelder auch bei eingeblendeter Bildschirmtastatur sichtbar.
|
||||
export function bindOptionsViewportAssist(panel) {
|
||||
if (bound || !panel) return;
|
||||
bound = true;
|
||||
|
||||
const setKeyboardInset = () => {
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport) {
|
||||
panel.style.setProperty('--options-kb-inset', '0px');
|
||||
return;
|
||||
}
|
||||
const inset = Math.max(0, window.innerHeight - (viewport.height + viewport.offsetTop));
|
||||
panel.style.setProperty('--options-kb-inset', `${Math.round(inset)}px`);
|
||||
};
|
||||
|
||||
const revealFocusedField = (target) => {
|
||||
if (!target || !panel.contains(target)) return;
|
||||
const viewport = window.visualViewport;
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const keyboardInset = viewport ? Math.max(0, window.innerHeight - (viewport.height + viewport.offsetTop)) : 0;
|
||||
const visibleTop = panelRect.top + 16;
|
||||
const visibleBottom = panelRect.bottom - keyboardInset - 16;
|
||||
if (targetRect.bottom > visibleBottom || targetRect.top < visibleTop) {
|
||||
const targetCenter = targetRect.top + targetRect.height / 2;
|
||||
const visibleCenter = visibleTop + (visibleBottom - visibleTop) / 2;
|
||||
panel.scrollTop += targetCenter - visibleCenter;
|
||||
}
|
||||
};
|
||||
|
||||
const onFocus = (event) => {
|
||||
const element = event.target;
|
||||
if (!(element instanceof HTMLElement)) return;
|
||||
const type = String(element.getAttribute('type') || '').toLowerCase();
|
||||
if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(element.tagName)) return;
|
||||
if (type === 'checkbox' || type === 'range' || type === 'color') return;
|
||||
setKeyboardInset();
|
||||
requestAnimationFrame(() => revealFocusedField(element));
|
||||
setTimeout(() => revealFocusedField(element), 250);
|
||||
};
|
||||
|
||||
panel.addEventListener('focusin', onFocus);
|
||||
panel.addEventListener('focusout', () => {
|
||||
setTimeout(() => {
|
||||
const active = document.activeElement;
|
||||
if (!(active instanceof HTMLElement) || !panel.contains(active)) {
|
||||
panel.style.setProperty('--options-kb-inset', '0px');
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', () => {
|
||||
setKeyboardInset();
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLElement && panel.contains(active)) {
|
||||
requestAnimationFrame(() => revealFocusedField(active));
|
||||
}
|
||||
});
|
||||
window.visualViewport.addEventListener('scroll', setKeyboardInset);
|
||||
}
|
||||
setKeyboardInset();
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Gemeinsame, rein interne Bausteine für Split- und Quad-View.
|
||||
import * as viewGoni from './goniometer_rtw.js';
|
||||
import * as viewPhaseWheel from './phase_wheel.js';
|
||||
import * as viewPanel from './panel.js';
|
||||
import * as viewRealtime from './realtime.js';
|
||||
import * as viewClassicNeedles from './classic_needles.js';
|
||||
import * as viewPeakHistory from './peak_history.js';
|
||||
import * as viewClock from './clock.js';
|
||||
import * as viewWaveform from './waveform.js';
|
||||
import * as viewSpectrogram from './spectrogram.js';
|
||||
import { FRAME_COLOR, PANEL_BG } from '../core/theme.js';
|
||||
import { drawCachedStaticLayer } from './static_layer.js';
|
||||
|
||||
export const CHILD_VIEWS = {
|
||||
'realtime': viewRealtime,
|
||||
'classic-needles': viewClassicNeedles,
|
||||
'peak-history': viewPeakHistory,
|
||||
'goniometer-rtw': viewGoni,
|
||||
'phase-wheel': viewPhaseWheel,
|
||||
'panel': viewPanel,
|
||||
'clock': viewClock,
|
||||
'waveform': viewWaveform,
|
||||
'spectrogram': viewSpectrogram,
|
||||
};
|
||||
|
||||
const ALLOWED_CHILD_IDS = new Set(['none', ...Object.keys(CHILD_VIEWS)]);
|
||||
const ALLOWED_METER_IDS = new Set(['none', 'vu', 'ppm-ebu', 'ppm-din', 'tp', 'hifi-peak', 'rms', 'lufs', 'stopwatch']);
|
||||
const ALLOWED_METER_POSITIONS = new Set(['left', 'center', 'right']);
|
||||
const METER_GAP = 12;
|
||||
const METER_PAD_TOP = 15;
|
||||
const METER_PAD_BOTTOM = 5;
|
||||
const METER_SLOT_SHRINK = 24;
|
||||
const METER_EXTRA_BOTTOM_PAD = 6;
|
||||
const OUTER_GAP = 10;
|
||||
const SIDE_INNER_GAP = 8;
|
||||
const METER_W_DEFAULT = 140;
|
||||
const METER_W_MIN = 90;
|
||||
const MIN_PLOT_W = 240;
|
||||
|
||||
export function sanitizeChildId(value, fallback) {
|
||||
return ALLOWED_CHILD_IDS.has(value) ? value : fallback;
|
||||
}
|
||||
|
||||
export function sanitizePlotId(value, fallback) {
|
||||
return ALLOWED_CHILD_IDS.has(value) ? value : fallback;
|
||||
}
|
||||
|
||||
export function sanitizeMeterId(value, fallback) {
|
||||
const id = String(value || '');
|
||||
return ALLOWED_METER_IDS.has(id) ? id : fallback;
|
||||
}
|
||||
|
||||
export function sanitizeMeterPos(value, fallback) {
|
||||
const id = String(value || '');
|
||||
return ALLOWED_METER_POSITIONS.has(id) ? id : fallback;
|
||||
}
|
||||
|
||||
function clampMeterCount(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.min(3, n | 0));
|
||||
}
|
||||
|
||||
export function readMultiViewMeters(config, prefix) {
|
||||
const count = clampMeterCount(config?.[`${prefix}_METER_COUNT`]);
|
||||
const defaults = ['vu', 'ppm-din', 'lufs'];
|
||||
return defaults.map((fallback, index) => {
|
||||
const number = index + 1;
|
||||
return {
|
||||
id: sanitizeMeterId(config?.[`${prefix}_METER_${number}`], fallback),
|
||||
pos: sanitizeMeterPos(config?.[`${prefix}_METER_${number}_POS`], 'right'),
|
||||
};
|
||||
}).slice(0, count);
|
||||
}
|
||||
|
||||
export function groupMetersByPosition(slots) {
|
||||
const out = { left: [], center: [], right: [] };
|
||||
for (const slot of slots || []) {
|
||||
if (!slot) continue;
|
||||
if (slot.pos === 'left') out.left.push(slot.id);
|
||||
else if (slot.pos === 'center') out.center.push(slot.id);
|
||||
else out.right.push(slot.id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function computeMultiViewBaseLayout(rect, slots, contentTop, contentBottom = 0) {
|
||||
const contentH = Math.max(0, rect.h - contentTop - contentBottom);
|
||||
const hasContent = contentH >= 140;
|
||||
let effectiveSlots = hasContent ? (Array.isArray(slots) ? slots.slice(0, 3) : []) : [];
|
||||
|
||||
while (true) {
|
||||
const grouped = groupMetersByPosition(effectiveSlots);
|
||||
const leftCount = grouped.left.length;
|
||||
const centerCount = grouped.center.length;
|
||||
const rightCount = grouped.right.length;
|
||||
const totalSlots = leftCount + centerCount + rightCount;
|
||||
const hasLeftMeters = leftCount > 0;
|
||||
const hasCenterMeters = centerCount > 0;
|
||||
const hasRightMeters = rightCount > 0;
|
||||
const interBlockGaps =
|
||||
(hasLeftMeters ? SIDE_INNER_GAP : 0) +
|
||||
(hasRightMeters ? SIDE_INNER_GAP : 0) +
|
||||
(hasCenterMeters ? 2 * SIDE_INNER_GAP : OUTER_GAP);
|
||||
const internalGaps =
|
||||
Math.max(0, leftCount - 1) * METER_GAP +
|
||||
Math.max(0, centerCount - 1) * METER_GAP +
|
||||
Math.max(0, rightCount - 1) * METER_GAP;
|
||||
const remainingForMeters = rect.w - (2 * MIN_PLOT_W + interBlockGaps + internalGaps);
|
||||
let slotW = totalSlots > 0 ? Math.min(METER_W_DEFAULT, Math.floor(remainingForMeters / totalSlots)) : 0;
|
||||
|
||||
if (totalSlots > 0 && slotW < METER_W_MIN && effectiveSlots.length) {
|
||||
effectiveSlots = effectiveSlots.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
if (totalSlots > 0) slotW = Math.max(METER_W_MIN, Math.min(METER_W_DEFAULT, slotW));
|
||||
|
||||
const groupWidth = (count) => count > 0 ? count * slotW + Math.max(0, count - 1) * METER_GAP : 0;
|
||||
const leftW = groupWidth(leftCount);
|
||||
const centerW = groupWidth(centerCount);
|
||||
const rightW = groupWidth(rightCount);
|
||||
const plotAvail = Math.max(0, rect.w - interBlockGaps - leftW - centerW - rightW);
|
||||
const leftPlotW = Math.floor(plotAvail / 2);
|
||||
const rightPlotW = plotAvail - leftPlotW;
|
||||
if ((leftPlotW < MIN_PLOT_W || rightPlotW < MIN_PLOT_W) && effectiveSlots.length) {
|
||||
effectiveSlots = effectiveSlots.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
|
||||
let x = 0;
|
||||
const leftMetersRect = hasLeftMeters ? { x, y: contentTop, w: leftW, h: contentH } : null;
|
||||
if (leftMetersRect) x += leftW + SIDE_INNER_GAP;
|
||||
const leftPlotRect = { x, y: 0, w: leftPlotW, h: rect.h };
|
||||
x += leftPlotW;
|
||||
let centerMetersRect = null;
|
||||
if (hasCenterMeters) {
|
||||
x += SIDE_INNER_GAP;
|
||||
centerMetersRect = { x, y: contentTop, w: centerW, h: contentH };
|
||||
x += centerW + SIDE_INNER_GAP;
|
||||
} else {
|
||||
x += OUTER_GAP;
|
||||
}
|
||||
const rightPlotRect = { x, y: 0, w: rightPlotW, h: rect.h };
|
||||
x += rightPlotW;
|
||||
let rightMetersRect = null;
|
||||
if (hasRightMeters) {
|
||||
x += SIDE_INNER_GAP;
|
||||
rightMetersRect = { x, y: contentTop, w: rightW, h: contentH };
|
||||
}
|
||||
return {
|
||||
plots: { left: leftPlotRect, right: rightPlotRect },
|
||||
meters: { left: leftMetersRect, center: centerMetersRect, right: rightMetersRect },
|
||||
meterIds: grouped,
|
||||
slotW,
|
||||
effectiveSlots,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function withClippedSubRect(g, rect, fn) {
|
||||
g.save();
|
||||
g.translate(rect.x, rect.y);
|
||||
g.beginPath();
|
||||
g.rect(0, 0, rect.w, rect.h);
|
||||
g.clip();
|
||||
try { return await fn(); } finally { g.restore(); }
|
||||
}
|
||||
|
||||
export function drawSubframe(g, rect) {
|
||||
g.save();
|
||||
g.strokeStyle = FRAME_COLOR;
|
||||
g.lineWidth = 2;
|
||||
g.strokeRect(rect.x + 0.5, rect.y + 0.5, Math.max(0, rect.w - 1), Math.max(0, rect.h - 1));
|
||||
g.restore();
|
||||
}
|
||||
|
||||
export function drawEmptyPlot(state, g, rect, label) {
|
||||
drawCachedStaticLayer(state, g, 'empty-plot', String(label || '(leer)'), rect, (lg) => {
|
||||
lg.fillStyle = PANEL_BG;
|
||||
lg.fillRect(0, 0, rect.w, rect.h);
|
||||
lg.fillStyle = '#9aa';
|
||||
lg.textAlign = 'left';
|
||||
lg.font = 'bold 14px ui-monospace, monospace';
|
||||
lg.fillText(label || '(leer)', 12, 32);
|
||||
lg.textAlign = 'start';
|
||||
});
|
||||
}
|
||||
|
||||
export async function drawMetersPanel(env, state, rect, meterIds, slotW, ownerLabel) {
|
||||
const { ctx: g, meters, config } = env;
|
||||
if (!rect || rect.w <= 0 || rect.h <= 0) return;
|
||||
const ids = Array.isArray(meterIds) ? meterIds.slice(0, 3) : [];
|
||||
const count = ids.length;
|
||||
if (!count) return;
|
||||
|
||||
drawCachedStaticLayer(state, g, 'meter-panel-shell', 'bg-frame', rect, (lg) => {
|
||||
lg.fillStyle = PANEL_BG;
|
||||
lg.fillRect(0, 0, rect.w, rect.h);
|
||||
drawSubframe(lg, { x: 0, y: 0, w: rect.w, h: rect.h });
|
||||
});
|
||||
|
||||
const n = Math.max(1, Math.min(3, count));
|
||||
const usedW = n * slotW + (n - 1) * METER_GAP;
|
||||
const startX = rect.x + Math.max(0, Math.floor((rect.w - usedW) / 2));
|
||||
const innerHeight = Math.max(40, rect.h - METER_PAD_TOP - METER_PAD_BOTTOM - METER_SLOT_SHRINK - METER_EXTRA_BOTTOM_PAD);
|
||||
const slotY = rect.y + METER_PAD_TOP + METER_SLOT_SHRINK / 2;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const id = sanitizeMeterId(ids[i], 'none');
|
||||
const r = { x: startX + i * (slotW + METER_GAP), y: slotY, w: slotW, h: innerHeight };
|
||||
if (id === 'none') {
|
||||
drawCachedStaticLayer(state, g, 'meter-slot-empty', `${slotW}x${innerHeight}`, r, (lg) => {
|
||||
lg.strokeStyle = 'rgba(0,231,255,0.25)';
|
||||
lg.setLineDash([6, 5]);
|
||||
lg.strokeRect(0.5, 0.5, Math.max(0, r.w - 1), Math.max(0, r.h - 1));
|
||||
lg.setLineDash([]);
|
||||
lg.fillStyle = '#9aa';
|
||||
lg.textAlign = 'center';
|
||||
lg.font = '12px ui-monospace, monospace';
|
||||
lg.fillText('(leer)', r.w / 2, 22);
|
||||
lg.textAlign = 'start';
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(rect.x + 1, rect.y + 1, Math.max(0, rect.w - 2), Math.max(0, rect.h - 2));
|
||||
g.clip();
|
||||
await meters.draw(g, r, id, config);
|
||||
g.restore();
|
||||
} catch (error) {
|
||||
g.restore();
|
||||
console.warn(`${ownerLabel} meter draw error:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-284
@@ -1,246 +1,29 @@
|
||||
// views/quad_view.js — Quad-View: vier Plots (2x2) + optionales Meter-Panel (wie Split-View)
|
||||
|
||||
import * as viewGoni from './goniometer_rtw.js';
|
||||
import * as viewPhaseWheel from './phase_wheel.js';
|
||||
import * as viewPanel from './panel.js';
|
||||
import * as viewRealtime from './realtime.js';
|
||||
import * as viewClassicNeedles from './classic_needles.js';
|
||||
import * as viewPeakHistory from './peak_history.js';
|
||||
import * as viewClock from './clock.js';
|
||||
import * as viewWaveform from './waveform.js';
|
||||
import * as viewSpectrogram from './spectrogram.js';
|
||||
import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js';
|
||||
import { DEFAULT_TOP_INSET } from '../core/theme.js';
|
||||
import {
|
||||
CHILD_VIEWS,
|
||||
computeMultiViewBaseLayout,
|
||||
drawEmptyPlot,
|
||||
drawMetersPanel,
|
||||
readMultiViewMeters,
|
||||
sanitizeChildId,
|
||||
sanitizePlotId,
|
||||
withClippedSubRect,
|
||||
} from './multi_view_shared.js';
|
||||
|
||||
export const id = 'quad-view';
|
||||
|
||||
const CONTENT_TOP = DEFAULT_TOP_INSET;
|
||||
const CONTENT_BOTTOM = 0;
|
||||
|
||||
const CHILD_VIEWS = {
|
||||
'realtime': viewRealtime,
|
||||
'classic-needles': viewClassicNeedles,
|
||||
'peak-history': viewPeakHistory,
|
||||
'goniometer-rtw': viewGoni,
|
||||
'phase-wheel': viewPhaseWheel,
|
||||
'panel': viewPanel,
|
||||
'clock': viewClock,
|
||||
'waveform': viewWaveform,
|
||||
'spectrogram': viewSpectrogram,
|
||||
};
|
||||
const ALLOWED_CHILD_VIEW_IDS = new Set(['none', ...Object.keys(CHILD_VIEWS)]);
|
||||
const ALLOWED_PLOT_IDS = new Set(['none', 'phase-wheel', 'realtime', 'goniometer-rtw', 'peak-history', 'classic-needles', 'panel', 'clock', 'waveform', 'spectrogram']);
|
||||
const ALLOWED_METER_IDS = new Set(['none', 'vu', 'ppm-ebu', 'ppm-din', 'tp', 'hifi-peak', 'rms', 'lufs', 'stopwatch']);
|
||||
const ALLOWED_METER_POSITIONS = new Set(['left', 'center', 'right']);
|
||||
|
||||
const OUTER_GAP = 10;
|
||||
const SIDE_INNER_GAP = 8;
|
||||
const ROW_GAP = 10;
|
||||
const METER_GAP = 12;
|
||||
const METER_W_DEFAULT = 140;
|
||||
const METER_W_MIN = 90;
|
||||
const MIN_PLOT_W = 240;
|
||||
const METER_PAD_TOP = 15;
|
||||
const METER_PAD_BOTTOM = 5;
|
||||
const METER_SLOT_SHRINK = 24;
|
||||
const METER_EXTRA_BOTTOM_PAD = 6;
|
||||
|
||||
function sanitizeChildId(val, fallback) {
|
||||
return ALLOWED_CHILD_VIEW_IDS.has(val) ? val : fallback;
|
||||
}
|
||||
|
||||
function sanitizePlotId(val, fallback) {
|
||||
return ALLOWED_PLOT_IDS.has(val) ? val : fallback;
|
||||
}
|
||||
|
||||
function sanitizeMeterId(val, fallback) {
|
||||
const id = String(val || '');
|
||||
return ALLOWED_METER_IDS.has(id) ? id : fallback;
|
||||
}
|
||||
|
||||
function sanitizeMeterPos(val, fallback) {
|
||||
const id = String(val || '');
|
||||
return ALLOWED_METER_POSITIONS.has(id) ? id : fallback;
|
||||
}
|
||||
|
||||
function clampMeterCount(val) {
|
||||
const n = Number(val);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.min(3, n | 0));
|
||||
}
|
||||
|
||||
async function withClippedSubRect(g, rect, fn) {
|
||||
g.save();
|
||||
g.translate(rect.x, rect.y);
|
||||
g.beginPath();
|
||||
g.rect(0, 0, rect.w, rect.h);
|
||||
g.clip();
|
||||
try { return await fn(); } finally { g.restore(); }
|
||||
}
|
||||
|
||||
function drawSubframe(g, rect) {
|
||||
g.save();
|
||||
g.strokeStyle = FRAME_COLOR;
|
||||
g.lineWidth = 2;
|
||||
g.strokeRect(rect.x + 0.5, rect.y + 0.5, Math.max(0, rect.w - 1), Math.max(0, rect.h - 1));
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function createStaticLayerCanvas(width, height) {
|
||||
const w = Math.max(1, width | 0);
|
||||
const h = Math.max(1, height | 0);
|
||||
if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h);
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
return c;
|
||||
}
|
||||
|
||||
function drawCachedStaticLayer(state, g, layerId, key, rect, build) {
|
||||
if (!state || !rect || rect.w <= 0 || rect.h <= 0) return;
|
||||
if (!state.staticLayers) state.staticLayers = new Map();
|
||||
const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`;
|
||||
let layer = state.staticLayers.get(fullKey);
|
||||
if (!layer) {
|
||||
const canvas = createStaticLayerCanvas(rect.w, rect.h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
build(ctx, rect);
|
||||
layer = { canvas };
|
||||
state.staticLayers.set(fullKey, layer);
|
||||
}
|
||||
g.drawImage(layer.canvas, rect.x, rect.y);
|
||||
}
|
||||
|
||||
function drawEmptyPlot(state, g, rect, label) {
|
||||
drawCachedStaticLayer(state, g, 'empty-plot', String(label || '(leer)'), rect, (lg) => {
|
||||
lg.fillStyle = PANEL_BG;
|
||||
lg.fillRect(0, 0, rect.w, rect.h);
|
||||
lg.fillStyle = '#9aa';
|
||||
lg.textAlign = 'left';
|
||||
lg.font = 'bold 14px ui-monospace, monospace';
|
||||
lg.fillText(label || '(leer)', 12, 32);
|
||||
lg.textAlign = 'start';
|
||||
});
|
||||
}
|
||||
|
||||
function readQuadMeters(CONFIG) {
|
||||
const count = clampMeterCount(CONFIG?.QUAD_VIEW_METER_COUNT);
|
||||
const slots = [
|
||||
{
|
||||
id: sanitizeMeterId(CONFIG?.QUAD_VIEW_METER_1, 'vu'),
|
||||
pos: sanitizeMeterPos(CONFIG?.QUAD_VIEW_METER_1_POS, 'right'),
|
||||
},
|
||||
{
|
||||
id: sanitizeMeterId(CONFIG?.QUAD_VIEW_METER_2, 'ppm-din'),
|
||||
pos: sanitizeMeterPos(CONFIG?.QUAD_VIEW_METER_2_POS, 'right'),
|
||||
},
|
||||
{
|
||||
id: sanitizeMeterId(CONFIG?.QUAD_VIEW_METER_3, 'lufs'),
|
||||
pos: sanitizeMeterPos(CONFIG?.QUAD_VIEW_METER_3_POS, 'right'),
|
||||
},
|
||||
].slice(0, count);
|
||||
return slots;
|
||||
}
|
||||
|
||||
function groupMetersByPosition(slots) {
|
||||
const out = { left: [], center: [], right: [] };
|
||||
for (const s of slots || []) {
|
||||
if (!s) continue;
|
||||
if (s.pos === 'left') out.left.push(s.id);
|
||||
else if (s.pos === 'center') out.center.push(s.id);
|
||||
else out.right.push(s.id);
|
||||
}
|
||||
return out;
|
||||
return readMultiViewMeters(CONFIG, 'QUAD_VIEW');
|
||||
}
|
||||
|
||||
function computeBaseLayout(rect, slots) {
|
||||
const contentH = Math.max(0, rect.h - CONTENT_TOP - CONTENT_BOTTOM);
|
||||
const hasContent = contentH >= 140;
|
||||
const BLOCK_GAP = SIDE_INNER_GAP;
|
||||
|
||||
let effectiveSlots = hasContent ? (Array.isArray(slots) ? slots.slice(0, 3) : []) : [];
|
||||
while (true) {
|
||||
const grouped = groupMetersByPosition(effectiveSlots);
|
||||
const leftCount = grouped.left.length;
|
||||
const centerCount = grouped.center.length;
|
||||
const rightCount = grouped.right.length;
|
||||
const totalSlots = leftCount + centerCount + rightCount;
|
||||
const hasLeftMeters = leftCount > 0;
|
||||
const hasCenterMeters = centerCount > 0;
|
||||
const hasRightMeters = rightCount > 0;
|
||||
|
||||
const interBlockGaps =
|
||||
(hasLeftMeters ? BLOCK_GAP : 0) +
|
||||
(hasRightMeters ? BLOCK_GAP : 0) +
|
||||
(hasCenterMeters ? (2 * BLOCK_GAP) : OUTER_GAP);
|
||||
|
||||
const internalGaps =
|
||||
Math.max(0, leftCount - 1) * METER_GAP +
|
||||
Math.max(0, centerCount - 1) * METER_GAP +
|
||||
Math.max(0, rightCount - 1) * METER_GAP;
|
||||
|
||||
const minRequired = 2 * MIN_PLOT_W + interBlockGaps + internalGaps;
|
||||
const remainingForMeters = rect.w - minRequired;
|
||||
|
||||
let slotW = 0;
|
||||
if (totalSlots > 0) {
|
||||
slotW = Math.floor(remainingForMeters / totalSlots);
|
||||
slotW = Math.min(METER_W_DEFAULT, slotW);
|
||||
}
|
||||
|
||||
if (totalSlots > 0 && slotW < METER_W_MIN && effectiveSlots.length) {
|
||||
effectiveSlots = effectiveSlots.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
if (totalSlots > 0) slotW = Math.max(METER_W_MIN, Math.min(METER_W_DEFAULT, slotW));
|
||||
|
||||
const leftW = hasLeftMeters ? (leftCount * slotW + Math.max(0, leftCount - 1) * METER_GAP) : 0;
|
||||
const centerW = hasCenterMeters ? (centerCount * slotW + Math.max(0, centerCount - 1) * METER_GAP) : 0;
|
||||
const rightW = hasRightMeters ? (rightCount * slotW + Math.max(0, rightCount - 1) * METER_GAP) : 0;
|
||||
|
||||
const metersTotalW = leftW + centerW + rightW;
|
||||
const plotAvail = Math.max(0, rect.w - interBlockGaps - metersTotalW);
|
||||
const leftPlotW = Math.floor(plotAvail / 2);
|
||||
const rightPlotW = plotAvail - leftPlotW;
|
||||
|
||||
const plotFits = leftPlotW >= MIN_PLOT_W && rightPlotW >= MIN_PLOT_W;
|
||||
if (!plotFits && effectiveSlots.length) {
|
||||
effectiveSlots = effectiveSlots.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
|
||||
let x = 0;
|
||||
const leftMetersRect = hasLeftMeters ? { x, y: CONTENT_TOP, w: leftW, h: contentH } : null;
|
||||
if (leftMetersRect) x += leftW + BLOCK_GAP;
|
||||
|
||||
const leftPlotRect = { x, y: 0, w: leftPlotW, h: rect.h };
|
||||
x += leftPlotW;
|
||||
|
||||
let centerMetersRect = null;
|
||||
if (hasCenterMeters) {
|
||||
x += BLOCK_GAP;
|
||||
centerMetersRect = { x, y: CONTENT_TOP, w: centerW, h: contentH };
|
||||
x += centerW + BLOCK_GAP;
|
||||
} else {
|
||||
x += OUTER_GAP;
|
||||
}
|
||||
|
||||
const rightPlotRect = { x, y: 0, w: rightPlotW, h: rect.h };
|
||||
x += rightPlotW;
|
||||
|
||||
let rightMetersRect = null;
|
||||
if (hasRightMeters) {
|
||||
x += BLOCK_GAP;
|
||||
rightMetersRect = { x, y: CONTENT_TOP, w: rightW, h: contentH };
|
||||
}
|
||||
|
||||
return {
|
||||
plots: { left: leftPlotRect, right: rightPlotRect },
|
||||
meters: { left: leftMetersRect, center: centerMetersRect, right: rightMetersRect },
|
||||
meterIds: grouped,
|
||||
slotW,
|
||||
effectiveSlots,
|
||||
};
|
||||
}
|
||||
return computeMultiViewBaseLayout(rect, slots, CONTENT_TOP, CONTENT_BOTTOM);
|
||||
}
|
||||
|
||||
function splitRectToRows(rect) {
|
||||
@@ -274,60 +57,6 @@ function ensureChildRectSize(env, rect, child) {
|
||||
resizeChild(env, { x: 0, y: 0, w: rect.w, h: rect.h }, child);
|
||||
}
|
||||
|
||||
async function drawMetersPanel(env, state, rect, meterIds, slotW) {
|
||||
const { ctx: g, meters, config: CONFIG } = env;
|
||||
if (!rect || rect.w <= 0 || rect.h <= 0) return;
|
||||
const ids = Array.isArray(meterIds) ? meterIds.slice(0, 3) : [];
|
||||
const count = ids.length;
|
||||
if (!count) return;
|
||||
|
||||
drawCachedStaticLayer(state, g, 'meter-panel-shell', 'bg-frame', rect, (lg) => {
|
||||
lg.fillStyle = PANEL_BG;
|
||||
lg.fillRect(0, 0, rect.w, rect.h);
|
||||
drawSubframe(lg, { x: 0, y: 0, w: rect.w, h: rect.h });
|
||||
});
|
||||
|
||||
const gap = METER_GAP;
|
||||
const n = Math.max(1, Math.min(3, count));
|
||||
const usedW = n * slotW + (n - 1) * gap;
|
||||
const startX = rect.x + Math.max(0, Math.floor((rect.w - usedW) / 2));
|
||||
const shrink = Math.max(0, METER_SLOT_SHRINK);
|
||||
const innerHeight = Math.max(40, rect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD);
|
||||
const innerOffset = shrink / 2;
|
||||
const slotY = rect.y + METER_PAD_TOP + innerOffset;
|
||||
const slotH = innerHeight;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const id = sanitizeMeterId(ids[i], 'none');
|
||||
const r = { x: startX + i * (slotW + gap), y: slotY, w: slotW, h: slotH };
|
||||
if (id === 'none') {
|
||||
drawCachedStaticLayer(state, g, 'meter-slot-empty', `${slotW}x${slotH}`, r, (lg) => {
|
||||
lg.strokeStyle = 'rgba(0,231,255,0.25)';
|
||||
lg.setLineDash([6, 5]);
|
||||
lg.strokeRect(0.5, 0.5, Math.max(0, r.w - 1), Math.max(0, r.h - 1));
|
||||
lg.setLineDash([]);
|
||||
lg.fillStyle = '#9aa';
|
||||
lg.textAlign = 'center';
|
||||
lg.font = '12px ui-monospace, monospace';
|
||||
lg.fillText('(leer)', r.w / 2, 22);
|
||||
lg.textAlign = 'start';
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(rect.x + 1, rect.y + 1, Math.max(0, rect.w - 2), Math.max(0, rect.h - 2));
|
||||
g.clip();
|
||||
await meters.draw(g, r, id, CONFIG);
|
||||
g.restore();
|
||||
} catch (e) {
|
||||
g.restore();
|
||||
console.warn('Quad meter draw error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function destroyChild(child) {
|
||||
if (!child || child.id === 'none') return;
|
||||
const mod = CHILD_VIEWS[child.id];
|
||||
|
||||
+57
-31
@@ -1,5 +1,6 @@
|
||||
import { getRtwCenters, resolveRtwBpoValue } from '../core/rtw_centers.js';
|
||||
import { DEFAULT_TOP_INSET, FRAME_COLOR, MID_COLOR, PANEL_BG, WARN_COLOR } from '../core/theme.js';
|
||||
import { drawCachedStaticLayer } from './static_layer.js';
|
||||
|
||||
// views/realtime.js — IEC-konformer Real-Time Analyzer
|
||||
|
||||
@@ -115,6 +116,23 @@ export async function render(env, state) {
|
||||
const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT)
|
||||
? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT))
|
||||
: 14;
|
||||
const rtwBarGrid = CONFIG.RTA_BAR_LAYOUT === 'rtw'
|
||||
&& (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars';
|
||||
const rtwGridCenters = rtwBarGrid
|
||||
? (isVectorLike(displayRtaPacket?.centers)
|
||||
? Array.from(displayRtaPacket.centers)
|
||||
: getRtwCenters(CONFIG.RTA_BPO_MODE || '1_3'))
|
||||
.filter((center) => Number.isFinite(center)
|
||||
&& center >= freqBounds.min
|
||||
&& center <= freqBounds.max)
|
||||
: null;
|
||||
const freqTickPositions = rtwGridCenters
|
||||
? buildRtwTickPositions(rtwGridCenters, freqTicks)
|
||||
: null;
|
||||
const gridLeftExtension = rtwBarGrid ? gutterL : 0;
|
||||
const tickPositionKey = freqTickPositions
|
||||
? freqTicks.map((f) => `${f}:${freqTickPositions[String(f)] ?? 'log'}`).join(',')
|
||||
: 'log';
|
||||
const plotPadLeft = gutterL + STATIC_LABEL_PAD_LEFT;
|
||||
const plotPadBottom = STATIC_LABEL_PAD_BOTTOM;
|
||||
|
||||
@@ -133,6 +151,8 @@ export async function render(env, state) {
|
||||
refDb ?? 'none',
|
||||
hide20HzTick ? 1 : 0,
|
||||
plotPadLeft,
|
||||
gridLeftExtension,
|
||||
tickPositionKey,
|
||||
plotPadBottom,
|
||||
].join('|'),
|
||||
{ x: plotX - plotPadLeft, y: plotY, w: plotW + plotPadLeft, h: plotH + plotPadBottom },
|
||||
@@ -147,7 +167,9 @@ export async function render(env, state) {
|
||||
freqBounds.max,
|
||||
{
|
||||
freqTicks,
|
||||
freqTickPositions,
|
||||
freqMin: freqBounds.min,
|
||||
xLeftExtension: gridLeftExtension,
|
||||
refDb,
|
||||
refStyle: 'rgba(0,231,255,0.35)',
|
||||
refDash: [3, 4],
|
||||
@@ -197,7 +219,8 @@ export async function render(env, state) {
|
||||
range,
|
||||
freqBounds,
|
||||
ballisticsData?.overlay || null,
|
||||
ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average'
|
||||
ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average',
|
||||
gridLeftExtension,
|
||||
);
|
||||
}
|
||||
} else if (useNativeFftEngine) {
|
||||
@@ -230,7 +253,8 @@ export async function render(env, state) {
|
||||
range,
|
||||
freqBounds,
|
||||
ballisticsData?.overlay || null,
|
||||
ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average'
|
||||
ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average',
|
||||
gridLeftExtension,
|
||||
);
|
||||
}
|
||||
} else if (!analyser || !buf) {
|
||||
@@ -259,7 +283,8 @@ export async function render(env, state) {
|
||||
range,
|
||||
freqBounds,
|
||||
null,
|
||||
CONFIG.RTA_BALLISTICS_MODE || 'average'
|
||||
CONFIG.RTA_BALLISTICS_MODE || 'average',
|
||||
gridLeftExtension,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -552,7 +577,33 @@ function applyRealtimeBarBallistics(state, levels, CONFIG, range) {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function renderSpectrum(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlayPeaks, ballisticsMode) {
|
||||
export function buildRtwTickPositions(centers, ticks = RTA_X_TICKS) {
|
||||
if (!isVectorLike(centers) || !centers.length) return {};
|
||||
const normalizedCenters = Array.from(centers, Number)
|
||||
.filter((value) => Number.isFinite(value) && value > 0);
|
||||
if (!normalizedCenters.length) return {};
|
||||
const positions = {};
|
||||
const count = normalizedCenters.length;
|
||||
for (const tick of ticks || []) {
|
||||
const frequency = Number(tick);
|
||||
if (!Number.isFinite(frequency) || frequency <= 0) continue;
|
||||
let bestIndex = -1;
|
||||
let bestRelativeError = Infinity;
|
||||
for (let index = 0; index < count; index++) {
|
||||
const relativeError = Math.abs(normalizedCenters[index] - frequency) / frequency;
|
||||
if (relativeError < bestRelativeError) {
|
||||
bestRelativeError = relativeError;
|
||||
bestIndex = index;
|
||||
}
|
||||
}
|
||||
if (bestIndex >= 0 && bestRelativeError <= 0.02) {
|
||||
positions[String(tick)] = (bestIndex + 0.5) / count;
|
||||
}
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
function renderSpectrum(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlayPeaks, ballisticsMode, rtwLeftExtension = 0) {
|
||||
const layout = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec';
|
||||
const overlay = (ballisticsMode === 'both' && isVectorLike(overlayPeaks) && overlayPeaks.length === state.mapping.length)
|
||||
? overlayPeaks
|
||||
@@ -560,7 +611,8 @@ function renderSpectrum(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, ra
|
||||
if ((CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'line') {
|
||||
renderLine(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlay);
|
||||
} else if (layout === 'rtw') {
|
||||
renderRtwBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, overlay);
|
||||
const extension = Math.max(0, Number(rtwLeftExtension) || 0);
|
||||
renderRtwBars(g, state, levels, plotX - extension, plotY, plotW + extension, plotH, CONFIG, range, overlay);
|
||||
} else {
|
||||
renderIecBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlay);
|
||||
}
|
||||
@@ -742,32 +794,6 @@ async function drawMeter(env, state, plotX, plotY, plotW, plotH) {
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function createStaticLayerCanvas(width, height) {
|
||||
const w = Math.max(1, width | 0);
|
||||
const h = Math.max(1, height | 0);
|
||||
if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h);
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
return c;
|
||||
}
|
||||
|
||||
function drawCachedStaticLayer(state, g, layerId, key, rect, build) {
|
||||
if (!state || !rect || rect.w <= 0 || rect.h <= 0) return;
|
||||
if (!state.staticLayers) state.staticLayers = new Map();
|
||||
const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`;
|
||||
let layer = state.staticLayers.get(fullKey);
|
||||
if (!layer) {
|
||||
const canvas = createStaticLayerCanvas(rect.w, rect.h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
build(ctx);
|
||||
layer = { canvas };
|
||||
state.staticLayers.set(fullKey, layer);
|
||||
}
|
||||
g.drawImage(layer.canvas, rect.x, rect.y);
|
||||
}
|
||||
|
||||
|
||||
function mapLogX(freq, x0, w, logMin, logSpan) {
|
||||
const clamped = Math.max(5, freq);
|
||||
const frac = (Math.log10(clamped) - logMin) / (logSpan || 1);
|
||||
|
||||
+13
-285
@@ -1,300 +1,28 @@
|
||||
// views/split_view.js — Split-View: zwei nebeneinander gerenderte Views (links/rechts)
|
||||
|
||||
import * as viewGoni from './goniometer_rtw.js';
|
||||
import * as viewPhaseWheel from './phase_wheel.js';
|
||||
import * as viewPanel from './panel.js';
|
||||
import * as viewRealtime from './realtime.js';
|
||||
import * as viewClassicNeedles from './classic_needles.js';
|
||||
import * as viewPeakHistory from './peak_history.js';
|
||||
import * as viewClock from './clock.js';
|
||||
import * as viewWaveform from './waveform.js';
|
||||
import * as viewSpectrogram from './spectrogram.js';
|
||||
import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js';
|
||||
import { DEFAULT_TOP_INSET } from '../core/theme.js';
|
||||
import {
|
||||
CHILD_VIEWS,
|
||||
computeMultiViewBaseLayout,
|
||||
drawEmptyPlot,
|
||||
drawMetersPanel,
|
||||
readMultiViewMeters,
|
||||
sanitizeChildId,
|
||||
sanitizePlotId,
|
||||
withClippedSubRect,
|
||||
} from './multi_view_shared.js';
|
||||
|
||||
export const id = 'split-view';
|
||||
|
||||
const CONTENT_TOP = DEFAULT_TOP_INSET;
|
||||
const CONTENT_BOTTOM = 0;
|
||||
|
||||
const CHILD_VIEWS = {
|
||||
'realtime': viewRealtime,
|
||||
'classic-needles': viewClassicNeedles,
|
||||
'peak-history': viewPeakHistory,
|
||||
'goniometer-rtw': viewGoni,
|
||||
'phase-wheel': viewPhaseWheel,
|
||||
'panel': viewPanel,
|
||||
'clock': viewClock,
|
||||
'waveform': viewWaveform,
|
||||
'spectrogram': viewSpectrogram,
|
||||
};
|
||||
const ALLOWED_CHILD_VIEW_IDS = new Set(['none', ...Object.keys(CHILD_VIEWS)]);
|
||||
const ALLOWED_SPLIT_PLOT_IDS = new Set(['none', 'phase-wheel', 'realtime', 'goniometer-rtw', 'peak-history', 'classic-needles', 'panel', 'clock', 'waveform', 'spectrogram']);
|
||||
const ALLOWED_METER_IDS = new Set(['none', 'vu', 'ppm-ebu', 'ppm-din', 'tp', 'hifi-peak', 'rms', 'lufs', 'stopwatch']);
|
||||
const ALLOWED_METER_POSITIONS = new Set(['left', 'center', 'right']);
|
||||
|
||||
const OUTER_GAP = 10;
|
||||
const SIDE_INNER_GAP = 8;
|
||||
const METER_GAP = 12;
|
||||
const METER_W_DEFAULT = 140;
|
||||
const METER_W_MIN = 90;
|
||||
const MIN_PLOT_W = 240;
|
||||
const METER_PAD_TOP = 15;
|
||||
const METER_PAD_BOTTOM = 5;
|
||||
const METER_SLOT_SHRINK = 24;
|
||||
const METER_EXTRA_BOTTOM_PAD = 6;
|
||||
|
||||
function sanitizeChildId(val, fallback) {
|
||||
return ALLOWED_CHILD_VIEW_IDS.has(val) ? val : fallback;
|
||||
}
|
||||
|
||||
function sanitizePlotId(val, fallback) {
|
||||
return ALLOWED_SPLIT_PLOT_IDS.has(val) ? val : fallback;
|
||||
}
|
||||
|
||||
function sanitizeMeterId(val, fallback) {
|
||||
const id = String(val || '');
|
||||
return ALLOWED_METER_IDS.has(id) ? id : fallback;
|
||||
}
|
||||
|
||||
function sanitizeMeterPos(val, fallback) {
|
||||
const id = String(val || '');
|
||||
return ALLOWED_METER_POSITIONS.has(id) ? id : fallback;
|
||||
}
|
||||
|
||||
function clampMeterCount(val) {
|
||||
const n = Number(val);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.min(3, n | 0));
|
||||
}
|
||||
|
||||
async function withClippedSubRect(g, rect, fn) {
|
||||
g.save();
|
||||
g.translate(rect.x, rect.y);
|
||||
g.beginPath();
|
||||
g.rect(0, 0, rect.w, rect.h);
|
||||
g.clip();
|
||||
try { return await fn(); } finally { g.restore(); }
|
||||
}
|
||||
|
||||
function drawSubframe(g, rect) {
|
||||
g.save();
|
||||
g.strokeStyle = FRAME_COLOR;
|
||||
g.lineWidth = 2;
|
||||
g.strokeRect(rect.x + 0.5, rect.y + 0.5, Math.max(0, rect.w - 1), Math.max(0, rect.h - 1));
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function createStaticLayerCanvas(width, height) {
|
||||
const w = Math.max(1, width | 0);
|
||||
const h = Math.max(1, height | 0);
|
||||
if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h);
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
return c;
|
||||
}
|
||||
|
||||
function drawCachedStaticLayer(state, g, layerId, key, rect, build) {
|
||||
if (!state || !rect || rect.w <= 0 || rect.h <= 0) return;
|
||||
if (!state.staticLayers) state.staticLayers = new Map();
|
||||
const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`;
|
||||
let layer = state.staticLayers.get(fullKey);
|
||||
if (!layer) {
|
||||
const canvas = createStaticLayerCanvas(rect.w, rect.h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
build(ctx, rect);
|
||||
layer = { canvas };
|
||||
state.staticLayers.set(fullKey, layer);
|
||||
}
|
||||
g.drawImage(layer.canvas, rect.x, rect.y);
|
||||
}
|
||||
|
||||
function drawEmptyPlot(state, g, rect, label) {
|
||||
drawCachedStaticLayer(state, g, 'empty-plot', String(label || '(leer)'), rect, (lg) => {
|
||||
lg.fillStyle = PANEL_BG;
|
||||
lg.fillRect(0, 0, rect.w, rect.h);
|
||||
lg.fillStyle = '#9aa';
|
||||
lg.textAlign = 'left';
|
||||
lg.font = 'bold 14px ui-monospace, monospace';
|
||||
lg.fillText(label || '(leer)', 12, 32);
|
||||
lg.textAlign = 'start';
|
||||
});
|
||||
}
|
||||
|
||||
function readSplitMeters(CONFIG) {
|
||||
const count = clampMeterCount(CONFIG?.SPLIT_VIEW_METER_COUNT);
|
||||
const slots = [
|
||||
{
|
||||
id: sanitizeMeterId(CONFIG?.SPLIT_VIEW_METER_1, 'vu'),
|
||||
pos: sanitizeMeterPos(CONFIG?.SPLIT_VIEW_METER_1_POS, 'right'),
|
||||
},
|
||||
{
|
||||
id: sanitizeMeterId(CONFIG?.SPLIT_VIEW_METER_2, 'ppm-din'),
|
||||
pos: sanitizeMeterPos(CONFIG?.SPLIT_VIEW_METER_2_POS, 'right'),
|
||||
},
|
||||
{
|
||||
id: sanitizeMeterId(CONFIG?.SPLIT_VIEW_METER_3, 'lufs'),
|
||||
pos: sanitizeMeterPos(CONFIG?.SPLIT_VIEW_METER_3_POS, 'right'),
|
||||
},
|
||||
].slice(0, count);
|
||||
return slots;
|
||||
}
|
||||
|
||||
function groupMetersByPosition(slots) {
|
||||
const out = { left: [], center: [], right: [] };
|
||||
for (const s of slots || []) {
|
||||
if (!s) continue;
|
||||
if (s.pos === 'left') out.left.push(s.id);
|
||||
else if (s.pos === 'center') out.center.push(s.id);
|
||||
else out.right.push(s.id);
|
||||
}
|
||||
return out;
|
||||
return readMultiViewMeters(CONFIG, 'SPLIT_VIEW');
|
||||
}
|
||||
|
||||
function computeSplitLayout(rect, slots) {
|
||||
const contentH = Math.max(0, rect.h - CONTENT_TOP - CONTENT_BOTTOM);
|
||||
const hasContent = contentH >= 140;
|
||||
const BLOCK_GAP = SIDE_INNER_GAP;
|
||||
|
||||
let effectiveSlots = hasContent ? (Array.isArray(slots) ? slots.slice(0, 3) : []) : [];
|
||||
while (true) {
|
||||
const grouped = groupMetersByPosition(effectiveSlots);
|
||||
const leftCount = grouped.left.length;
|
||||
const centerCount = grouped.center.length;
|
||||
const rightCount = grouped.right.length;
|
||||
const totalSlots = leftCount + centerCount + rightCount;
|
||||
|
||||
const hasLeftMeters = hasContent && leftCount > 0;
|
||||
const hasCenterMeters = hasContent && centerCount > 0;
|
||||
const hasRightMeters = hasContent && rightCount > 0;
|
||||
|
||||
const interBlockGaps =
|
||||
(hasLeftMeters ? BLOCK_GAP : 0) +
|
||||
(hasRightMeters ? BLOCK_GAP : 0) +
|
||||
(hasCenterMeters ? (2 * BLOCK_GAP) : OUTER_GAP);
|
||||
|
||||
const internalGaps =
|
||||
Math.max(0, leftCount - 1) * METER_GAP +
|
||||
Math.max(0, centerCount - 1) * METER_GAP +
|
||||
Math.max(0, rightCount - 1) * METER_GAP;
|
||||
|
||||
const minRequired = 2 * MIN_PLOT_W + interBlockGaps + internalGaps;
|
||||
const remainingForMeters = rect.w - minRequired;
|
||||
|
||||
let slotW = 0;
|
||||
if (totalSlots > 0) {
|
||||
slotW = Math.floor(remainingForMeters / totalSlots);
|
||||
slotW = Math.min(METER_W_DEFAULT, slotW);
|
||||
}
|
||||
|
||||
if (totalSlots > 0 && slotW < METER_W_MIN && effectiveSlots.length) {
|
||||
effectiveSlots = effectiveSlots.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
if (totalSlots > 0) slotW = Math.max(METER_W_MIN, Math.min(METER_W_DEFAULT, slotW));
|
||||
|
||||
const leftW = hasLeftMeters ? (leftCount * slotW + Math.max(0, leftCount - 1) * METER_GAP) : 0;
|
||||
const centerW = hasCenterMeters ? (centerCount * slotW + Math.max(0, centerCount - 1) * METER_GAP) : 0;
|
||||
const rightW = hasRightMeters ? (rightCount * slotW + Math.max(0, rightCount - 1) * METER_GAP) : 0;
|
||||
|
||||
const metersTotalW = leftW + centerW + rightW;
|
||||
const plotAvail = Math.max(0, rect.w - interBlockGaps - metersTotalW);
|
||||
const leftPlotW = Math.floor(plotAvail / 2);
|
||||
const rightPlotW = plotAvail - leftPlotW;
|
||||
|
||||
const plotFits = leftPlotW >= MIN_PLOT_W && rightPlotW >= MIN_PLOT_W;
|
||||
if (!plotFits && effectiveSlots.length) {
|
||||
effectiveSlots = effectiveSlots.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
|
||||
let x = 0;
|
||||
const leftMetersRect = hasLeftMeters ? { x, y: CONTENT_TOP, w: leftW, h: contentH } : null;
|
||||
if (leftMetersRect) x += leftW + BLOCK_GAP;
|
||||
|
||||
const leftPlotRect = { x, y: 0, w: leftPlotW, h: rect.h };
|
||||
x += leftPlotW;
|
||||
|
||||
let centerMetersRect = null;
|
||||
if (hasCenterMeters) {
|
||||
x += BLOCK_GAP;
|
||||
centerMetersRect = { x, y: CONTENT_TOP, w: centerW, h: contentH };
|
||||
x += centerW + BLOCK_GAP;
|
||||
} else {
|
||||
x += OUTER_GAP;
|
||||
}
|
||||
|
||||
const rightPlotRect = { x, y: 0, w: rightPlotW, h: rect.h };
|
||||
x += rightPlotW;
|
||||
|
||||
let rightMetersRect = null;
|
||||
if (hasRightMeters) {
|
||||
x += BLOCK_GAP;
|
||||
rightMetersRect = { x, y: CONTENT_TOP, w: rightW, h: contentH };
|
||||
}
|
||||
|
||||
return {
|
||||
plots: { left: leftPlotRect, right: rightPlotRect },
|
||||
meters: { left: leftMetersRect, center: centerMetersRect, right: rightMetersRect },
|
||||
meterIds: grouped,
|
||||
slotW,
|
||||
effectiveSlots,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function drawMetersPanel(env, state, rect, meterIds, slotW) {
|
||||
const { ctx: g, meters, config: CONFIG } = env;
|
||||
if (!rect || rect.w <= 0 || rect.h <= 0) return;
|
||||
const ids = Array.isArray(meterIds) ? meterIds.slice(0, 3) : [];
|
||||
const count = ids.length;
|
||||
if (!count) return;
|
||||
|
||||
drawCachedStaticLayer(state, g, 'meter-panel-shell', 'bg-frame', rect, (lg) => {
|
||||
lg.fillStyle = PANEL_BG;
|
||||
lg.fillRect(0, 0, rect.w, rect.h);
|
||||
drawSubframe(lg, { x: 0, y: 0, w: rect.w, h: rect.h });
|
||||
});
|
||||
|
||||
const gap = METER_GAP;
|
||||
const n = Math.max(1, Math.min(3, count));
|
||||
const usedW = n * slotW + (n - 1) * gap;
|
||||
const startX = rect.x + Math.max(0, Math.floor((rect.w - usedW) / 2));
|
||||
const shrink = Math.max(0, METER_SLOT_SHRINK);
|
||||
const innerHeight = Math.max(40, rect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD);
|
||||
const innerOffset = shrink / 2;
|
||||
const slotY = rect.y + METER_PAD_TOP + innerOffset;
|
||||
const slotH = innerHeight;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const id = sanitizeMeterId(ids[i], 'none');
|
||||
const r = { x: startX + i * (slotW + gap), y: slotY, w: slotW, h: slotH };
|
||||
if (id === 'none') {
|
||||
drawCachedStaticLayer(state, g, 'meter-slot-empty', `${slotW}x${slotH}`, r, (lg) => {
|
||||
lg.strokeStyle = 'rgba(0,231,255,0.25)';
|
||||
lg.setLineDash([6, 5]);
|
||||
lg.strokeRect(0.5, 0.5, Math.max(0, r.w - 1), Math.max(0, r.h - 1));
|
||||
lg.setLineDash([]);
|
||||
lg.fillStyle = '#9aa';
|
||||
lg.textAlign = 'center';
|
||||
lg.font = '12px ui-monospace, monospace';
|
||||
lg.fillText('(leer)', r.w / 2, 22);
|
||||
lg.textAlign = 'start';
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(rect.x + 1, rect.y + 1, Math.max(0, rect.w - 2), Math.max(0, rect.h - 2));
|
||||
g.clip();
|
||||
await meters.draw(g, r, id, CONFIG);
|
||||
g.restore();
|
||||
} catch (e) {
|
||||
g.restore();
|
||||
console.warn('Split meter draw error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return computeMultiViewBaseLayout(rect, slots, CONTENT_TOP, CONTENT_BOTTOM);
|
||||
}
|
||||
|
||||
function destroyChild(child) {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { createCanvasSurface } from '../core/canvas_surface.js';
|
||||
|
||||
const MAX_STATIC_LAYERS = 24;
|
||||
|
||||
export function createStaticLayerCanvas(width, height) {
|
||||
const w = Math.max(1, width | 0);
|
||||
const h = Math.max(1, height | 0);
|
||||
if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h);
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
return c;
|
||||
return createCanvasSurface(width, height)?.canvas || null;
|
||||
}
|
||||
|
||||
export function drawCachedStaticLayer(state, g, layerId, key, rect, build) {
|
||||
@@ -13,12 +11,21 @@ export function drawCachedStaticLayer(state, g, layerId, key, rect, build) {
|
||||
if (!state.staticLayers) state.staticLayers = new Map();
|
||||
const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`;
|
||||
let layer = state.staticLayers.get(fullKey);
|
||||
if (layer) {
|
||||
// Map-Reihenfolge als kleine LRU-Liste verwenden.
|
||||
state.staticLayers.delete(fullKey);
|
||||
state.staticLayers.set(fullKey, layer);
|
||||
}
|
||||
if (!layer) {
|
||||
const canvas = createStaticLayerCanvas(rect.w, rect.h);
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
build(ctx, rect);
|
||||
layer = { canvas };
|
||||
state.staticLayers.set(fullKey, layer);
|
||||
while (state.staticLayers.size > MAX_STATIC_LAYERS) {
|
||||
state.staticLayers.delete(state.staticLayers.keys().next().value);
|
||||
}
|
||||
}
|
||||
g.drawImage(layer.canvas, rect.x, rect.y);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user