mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 21:01:40 +00:00
Merge pull request #674 from got-feedback/feat/3d-wide-pane-tuner-per-panel
3D highway wide-pane tuner: dismiss + per-pane targeting
This commit is contained in:
commit
7e977b9572
@ -1607,37 +1607,32 @@
|
||||
ss.isCanvasFocused(highwayCanvas));
|
||||
}
|
||||
|
||||
// A/B toggle for the wide-pane horizontal-FOV-hold. Flips
|
||||
// window.__h3dAspectTune.enabled so the running app can switch between the
|
||||
// current framing (off, the baseline) and the Hor+ framing (on) with one
|
||||
// keypress, across all panes at once. Registered once per session via a
|
||||
// module-level guard (it toggles a shared global, so per-instance
|
||||
// registration would stack duplicate handlers and cancel itself out); it's
|
||||
// a harmless debug control, so it is never unregistered. No-ops where the
|
||||
// core shortcut API isn't present (older core / borrowed contexts).
|
||||
let _abShortcutRegistered = false;
|
||||
function _registerAspectAbShortcut() {
|
||||
if (_abShortcutRegistered) return;
|
||||
// Shortcut for the wide-pane framing tuner. Opens/closes the floating panel
|
||||
// (the A/B on/off and the per-pane target live inside it now). Registered
|
||||
// once per session via a module-level guard (it drives shared module state,
|
||||
// so per-instance registration would stack duplicate handlers and cancel
|
||||
// itself out); it's a harmless debug control, so it is never unregistered.
|
||||
// No-ops where the core shortcut API isn't present (older core / borrowed
|
||||
// contexts).
|
||||
let _tunerShortcutRegistered = false;
|
||||
function _registerTunerShortcut() {
|
||||
if (_tunerShortcutRegistered) return;
|
||||
if (typeof window.registerShortcut !== 'function') return;
|
||||
_abShortcutRegistered = true;
|
||||
_tunerShortcutRegistered = true;
|
||||
try {
|
||||
window.registerShortcut({
|
||||
key: 'A', // uppercase e.key → produced with Shift held (Shift+A)
|
||||
description: '3D Highway: toggle wide-pane framing A/B (Shift+A)',
|
||||
description: '3D Highway: open/close wide-pane framing tuner (Shift+A)',
|
||||
scope: 'player',
|
||||
handler: () => {
|
||||
const t = _aspectTune();
|
||||
t.enabled = !t.enabled;
|
||||
try { console.log('[h3d] wide-pane framing', t.enabled ? 'ON' : 'OFF'); } catch (e) {}
|
||||
// Surface the live tuner panel whenever the feature is on,
|
||||
// hide it when off. Built lazily on first use.
|
||||
_ensureAspectPanel();
|
||||
_setAspectPanelVisible(t.enabled);
|
||||
_syncAspectPanel();
|
||||
// Open/close the live tuner panel. The A/B on/off and the
|
||||
// per-pane target now live in the panel itself, so the
|
||||
// shortcut is just a dismiss/reveal.
|
||||
_toggleAspectPanel();
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
_abShortcutRegistered = false; // allow a later retry if it threw
|
||||
_tunerShortcutRegistered = false; // allow a later retry if it threw
|
||||
}
|
||||
}
|
||||
|
||||
@ -1685,8 +1680,42 @@
|
||||
let _aspectPanelEl = null; // the floating panel root (built once)
|
||||
let _aspectPanelRO = null; // readout <div>
|
||||
let _aspectPanelRAF = 0; // readout poll handle
|
||||
let _aspectTargetSel = null; // the "Target" <select>
|
||||
let _aspectTgtRow = null; // the Target row (hidden when only one pane)
|
||||
let _aspectHfovCb = null; // hfov-override checkbox (synced explicitly)
|
||||
let _aspectHfovSl = null; // hfov-override slider
|
||||
// Which pane the panel edits. '' = all panes (writes the shared base object);
|
||||
// a pane key ('arr:<name>' or the fallback 'pane:<uid>') writes that pane's
|
||||
// sparse override, so one split pane can be framed independently.
|
||||
let _aspectEditTarget = '';
|
||||
// Bumped when the SET of live panes changes (add/prune) so the panel rebuilds
|
||||
// the Target dropdown — never on a per-frame label re-report, which would
|
||||
// flicker the <select>.
|
||||
let _aspectPanesDirty = true;
|
||||
// Monotonic counter for the per-instance fallback key (when a pane has no
|
||||
// arrangement name to key by).
|
||||
let _aspectPaneCounter = 0;
|
||||
function _aspectNowMs() {
|
||||
try { if (performance && performance.now) return performance.now(); } catch (e) {}
|
||||
try { return Date.now(); } catch (e) { return 0; } // keep pruning functional
|
||||
}
|
||||
// Pane key: prefer the arrangement name ('arr:Bass') so a pane's framing is
|
||||
// stable across songs AND distinct between split panes, with no dependency on
|
||||
// the external splitscreen panel index (which isn't always available). Fall
|
||||
// back to a per-instance id ('pane:3') when there's no arrangement.
|
||||
function _aspectPaneKey(arrangement, uid) {
|
||||
const a = (typeof arrangement === 'string') ? arrangement.trim() : '';
|
||||
return a ? ('arr:' + a) : ('pane:' + uid);
|
||||
}
|
||||
// Human label derived from the key.
|
||||
function _aspectPaneLabel(paneKey) {
|
||||
if (paneKey.slice(0, 4) === 'arr:') return paneKey.slice(4);
|
||||
if (paneKey.slice(0, 5) === 'pane:') return 'Pane ' + paneKey.slice(5);
|
||||
return paneKey;
|
||||
}
|
||||
|
||||
// Get-or-create the live bridge object, seeded from defaults + localStorage.
|
||||
// Get-or-create the shared bridge object, seeded from defaults + localStorage.
|
||||
// May carry a sparse `__panels` map of per-pane overrides.
|
||||
function _aspectTune() {
|
||||
let t = window.__h3dAspectTune;
|
||||
if (!t || typeof t !== 'object') {
|
||||
@ -1699,48 +1728,196 @@
|
||||
}
|
||||
return t;
|
||||
}
|
||||
// Bumped on every tune mutation (all writes funnel through _aspectPersist) so
|
||||
// the per-pane resolve cache below can invalidate cheaply.
|
||||
let _aspectRev = 0;
|
||||
function _aspectPersist() {
|
||||
_aspectRev++;
|
||||
try {
|
||||
const t = _aspectTune(), out = {};
|
||||
Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t[k]; });
|
||||
// Persist per-pane overrides keyed by arrangement ('arr:*') only, so a
|
||||
// pane's framing carries across songs. Instance-id fallback keys
|
||||
// ('pane:*') are session-only — persisting them would leak a new key
|
||||
// every reload.
|
||||
if (t.__panels) {
|
||||
const p = {}; let any = false;
|
||||
Object.keys(t.__panels).forEach((k) => {
|
||||
if (k.slice(0, 4) === 'arr:') { p[k] = t.__panels[k]; any = true; }
|
||||
});
|
||||
if (any) out.__panels = p;
|
||||
}
|
||||
localStorage.setItem(_ASPECT_LS, JSON.stringify(out));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Resolve the effective tune for a pane: the shared base, with that pane's
|
||||
// override keys (if any) laid on top. Called every frame per renderer, so the
|
||||
// merged object is memoized per pane and only rebuilt when the tune mutates
|
||||
// (_aspectRev changes). Panes with no override return the base directly (no
|
||||
// allocation).
|
||||
const _aspectResolveCache = new Map(); // paneKey -> { rev, obj }
|
||||
function _resolveTuneFor(paneKey) {
|
||||
const base = _aspectTune();
|
||||
const ov = base.__panels && base.__panels[paneKey];
|
||||
if (!ov) return base;
|
||||
const c = _aspectResolveCache.get(paneKey);
|
||||
if (c && c.rev === _aspectRev) return c.obj;
|
||||
const out = {};
|
||||
Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = (k in ov) ? ov[k] : base[k]; });
|
||||
_aspectResolveCache.set(paneKey, { rev: _aspectRev, obj: out });
|
||||
return out;
|
||||
}
|
||||
// Record a live pane so the Target dropdown can list it. Called every frame
|
||||
// by each renderer with its pane key. `seen` is refreshed each call for
|
||||
// pruning; the dropdown is only marked dirty when a pane is newly added — not
|
||||
// on every re-report, which would flicker the <select>.
|
||||
function _aspectRegisterPane(paneKey) {
|
||||
const reg = window.__h3dAspectPanes || (window.__h3dAspectPanes = {});
|
||||
const label = _aspectPaneLabel(paneKey);
|
||||
let e = reg[paneKey];
|
||||
if (!e) { e = reg[paneKey] = { label, seen: 0 }; _aspectPanesDirty = true; }
|
||||
else if (e.label !== label) { e.label = label; _aspectPanesDirty = true; }
|
||||
e.seen = _aspectNowMs();
|
||||
}
|
||||
// Drop panes not reported recently (song change, split teardown, pane close).
|
||||
function _aspectPrunePanes() {
|
||||
const reg = window.__h3dAspectPanes;
|
||||
if (!reg) return;
|
||||
const now = _aspectNowMs();
|
||||
const ro = window.__h3dAspectReadout;
|
||||
Object.keys(reg).forEach((k) => {
|
||||
if (now - (reg[k].seen || 0) > 1500) {
|
||||
delete reg[k];
|
||||
// Prune the matching readout slot so it can't grow unbounded as
|
||||
// songs/arrangements churn, and drop a dangling __last pointer.
|
||||
if (ro) { delete ro[k]; if (ro.__last === k) delete ro.__last; }
|
||||
_aspectPanesDirty = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// True while _syncAspectPanel is programmatically refreshing controls, so the
|
||||
// synthetic 'input' events it dispatches to update labels don't write back
|
||||
// into the tune (which would populate a full override for every field and
|
||||
// spam localStorage). Real user input runs with this false.
|
||||
let _aspectSyncing = false;
|
||||
// Read/write against the current edit target ('' → base, else pane override).
|
||||
function _aspectReadVal(k) {
|
||||
const base = _aspectTune();
|
||||
if (!_aspectEditTarget) return base[k];
|
||||
const ov = base.__panels && base.__panels[_aspectEditTarget];
|
||||
return (ov && (k in ov)) ? ov[k] : base[k];
|
||||
}
|
||||
function _aspectWriteVal(k, v) {
|
||||
const base = _aspectTune();
|
||||
if (!_aspectEditTarget) { base[k] = v; }
|
||||
else {
|
||||
const m = base.__panels || (base.__panels = {});
|
||||
(m[_aspectEditTarget] || (m[_aspectEditTarget] = {}))[k] = v;
|
||||
}
|
||||
_aspectPersist();
|
||||
}
|
||||
// Clear a field: for the base target set the explicit auto value (null); for a
|
||||
// pane target delete the override key so the pane re-inherits the base value
|
||||
// (and drop the pane's override object once it's empty).
|
||||
function _aspectClearVal(k) {
|
||||
const base = _aspectTune();
|
||||
if (!_aspectEditTarget) { base[k] = null; }
|
||||
else {
|
||||
const m = base.__panels, ov = m && m[_aspectEditTarget];
|
||||
if (ov) { delete ov[k]; if (!Object.keys(ov).length) delete m[_aspectEditTarget]; }
|
||||
}
|
||||
_aspectPersist();
|
||||
}
|
||||
|
||||
// (Re)build the Target dropdown from the live pane registry, preserving the
|
||||
// current selection when it's still valid.
|
||||
function _aspectBuildTargets() {
|
||||
if (!_aspectTargetSel) return;
|
||||
// Don't yank a dropdown the user is actively interacting with — leave it
|
||||
// dirty and rebuild on a later tick once it's no longer focused.
|
||||
if (document.activeElement === _aspectTargetSel) return;
|
||||
const reg = window.__h3dAspectPanes || {};
|
||||
const keys = Object.keys(reg).sort();
|
||||
_aspectTargetSel.innerHTML = '';
|
||||
const all = document.createElement('option');
|
||||
all.value = ''; all.textContent = keys.length > 1 ? 'All panes' : 'All';
|
||||
_aspectTargetSel.appendChild(all);
|
||||
keys.forEach((pk) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = pk; o.textContent = reg[pk].label;
|
||||
_aspectTargetSel.appendChild(o);
|
||||
});
|
||||
// Force the edit target back to "All" when the Target row is hidden
|
||||
// (single pane) or the selected pane is gone — otherwise a stale pane
|
||||
// target would silently route edits into a hidden (and persistent
|
||||
// arr:*) override in single-player.
|
||||
if (keys.length <= 1 || (_aspectEditTarget && !reg[_aspectEditTarget])) {
|
||||
_aspectEditTarget = '';
|
||||
}
|
||||
_aspectTargetSel.value = _aspectEditTarget;
|
||||
// The Target row only matters with more than one pane (a split). With a
|
||||
// single pane there's nothing to disambiguate, so hide it.
|
||||
if (_aspectTgtRow) _aspectTgtRow.style.display = keys.length > 1 ? '' : 'none';
|
||||
_aspectPanesDirty = false;
|
||||
}
|
||||
|
||||
function _ensureAspectPanel() {
|
||||
if (_aspectPanelEl || typeof document === 'undefined') return;
|
||||
const t = _aspectTune();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.id = 'h3d-aspect-tuner';
|
||||
wrap.style.cssText = [
|
||||
'position:fixed', 'top:64px', 'right:12px', 'z-index:99999',
|
||||
'width:230px', 'padding:10px 12px', 'border-radius:8px',
|
||||
'width:236px', 'padding:10px 12px', 'border-radius:8px',
|
||||
'background:rgba(12,18,28,0.92)', 'border:1px solid rgba(120,150,200,0.35)',
|
||||
'box-shadow:0 6px 24px rgba(0,0,0,0.5)', 'color:#cfe0f5',
|
||||
'font:11px/1.35 system-ui,sans-serif', 'user-select:none',
|
||||
'pointer-events:auto',
|
||||
].join(';');
|
||||
|
||||
// Header: title + close (×). Close hides the panel; the feature keeps
|
||||
// whatever enabled state it had — this is a dismiss, not an A/B toggle.
|
||||
const hdr = document.createElement('div');
|
||||
hdr.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;';
|
||||
const title = document.createElement('div');
|
||||
title.textContent = 'Wide-pane framing (A/B)';
|
||||
title.style.cssText = 'font-weight:700;margin-bottom:6px;color:#e8c040;';
|
||||
wrap.appendChild(title);
|
||||
title.textContent = 'Wide-pane framing';
|
||||
title.style.cssText = 'font-weight:700;color:#e8c040;';
|
||||
const close = document.createElement('button');
|
||||
close.type = 'button'; // never submit if nested in a <form>
|
||||
close.textContent = '×';
|
||||
close.title = 'Close (Shift+A)';
|
||||
close.setAttribute('aria-label', 'Close');
|
||||
close.style.cssText = 'border:none;background:transparent;color:#cfe0f5;font-size:17px;line-height:1;cursor:pointer;padding:0 2px;';
|
||||
close.addEventListener('click', () => _setAspectPanelVisible(false));
|
||||
hdr.appendChild(title); hdr.appendChild(close); wrap.appendChild(hdr);
|
||||
|
||||
// enabled + splitOnly checkboxes
|
||||
[['enabled', 'Enabled (Shift+A)'], ['splitOnly', 'Split panes only']].forEach(([k, lbl]) => {
|
||||
// Target selector — which pane the controls below edit.
|
||||
const tgtRow = document.createElement('div'); tgtRow.style.cssText = 'margin:2px 0 7px;';
|
||||
_aspectTgtRow = tgtRow;
|
||||
const tgtLab = document.createElement('div');
|
||||
tgtLab.textContent = 'Target'; tgtLab.style.cssText = 'color:#9fb0c8;margin-bottom:2px;';
|
||||
_aspectTargetSel = document.createElement('select');
|
||||
_aspectTargetSel.setAttribute('aria-label', 'Target pane');
|
||||
_aspectTargetSel.style.cssText = 'width:100%;background:rgba(30,44,66,0.9);color:#cfe0f5;border:1px solid rgba(120,150,200,0.4);border-radius:4px;padding:3px;';
|
||||
_aspectTargetSel.addEventListener('change', () => {
|
||||
_aspectEditTarget = _aspectTargetSel.value; _syncAspectPanel();
|
||||
});
|
||||
tgtRow.appendChild(tgtLab); tgtRow.appendChild(_aspectTargetSel); wrap.appendChild(tgtRow);
|
||||
_aspectBuildTargets();
|
||||
|
||||
// enabled + splitOnly checkboxes (per-target)
|
||||
[['enabled', 'Enabled'], ['splitOnly', 'Split panes only']].forEach(([k, lbl]) => {
|
||||
const row = document.createElement('label');
|
||||
row.style.cssText = 'display:flex;align-items:center;gap:6px;margin:2px 0;cursor:pointer;';
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox'; cb.checked = !!t[k]; cb.dataset.k = k;
|
||||
cb.addEventListener('change', () => {
|
||||
_aspectTune()[k] = cb.checked; _aspectPersist();
|
||||
if (k === 'enabled') _setAspectPanelVisible(cb.checked);
|
||||
});
|
||||
cb.type = 'checkbox'; cb.checked = !!_aspectReadVal(k); cb.dataset.k = k;
|
||||
cb.addEventListener('change', () => { _aspectWriteVal(k, cb.checked); });
|
||||
const span = document.createElement('span'); span.textContent = lbl;
|
||||
row.appendChild(cb); row.appendChild(span); wrap.appendChild(row);
|
||||
});
|
||||
|
||||
// numeric sliders
|
||||
// numeric sliders (per-target)
|
||||
_ASPECT_FIELDS.forEach((f) => {
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = 'margin:5px 0;';
|
||||
@ -1752,13 +1929,15 @@
|
||||
head.appendChild(lab); head.appendChild(val); row.appendChild(head);
|
||||
const sl = document.createElement('input');
|
||||
sl.type = 'range'; sl.min = f.min; sl.max = f.max; sl.step = f.step;
|
||||
sl.value = Number.isFinite(t[f.k]) ? t[f.k] : _ASPECT_DEFAULTS[f.k];
|
||||
const rv = _aspectReadVal(f.k);
|
||||
sl.value = Number.isFinite(rv) ? rv : _ASPECT_DEFAULTS[f.k];
|
||||
sl.dataset.k = f.k;
|
||||
sl.style.cssText = 'width:100%;';
|
||||
const show = () => { val.textContent = (+sl.value).toFixed(f.step < 1 ? 2 : 0); };
|
||||
show();
|
||||
sl.addEventListener('input', () => {
|
||||
_aspectTune()[f.k] = parseFloat(sl.value); show(); _aspectPersist();
|
||||
show(); // label always refreshes
|
||||
if (!_aspectSyncing) _aspectWriteVal(f.k, parseFloat(sl.value));
|
||||
});
|
||||
row.appendChild(sl); wrap.appendChild(row);
|
||||
});
|
||||
@ -1769,23 +1948,26 @@
|
||||
const head = document.createElement('label');
|
||||
head.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;';
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox'; cb.checked = Number.isFinite(t.hfovDeg);
|
||||
cb.type = 'checkbox'; cb.checked = Number.isFinite(_aspectReadVal('hfovDeg'));
|
||||
const lbl = document.createElement('span'); lbl.textContent = 'Override held hFOV°';
|
||||
head.appendChild(cb); head.appendChild(lbl); row.appendChild(head);
|
||||
const sl = document.createElement('input');
|
||||
sl.type = 'range'; sl.min = 40; sl.max = 160; sl.step = 1;
|
||||
sl.value = Number.isFinite(t.hfovDeg) ? t.hfovDeg : 102;
|
||||
const hv = _aspectReadVal('hfovDeg');
|
||||
sl.value = Number.isFinite(hv) ? hv : 102;
|
||||
sl.disabled = !cb.checked;
|
||||
sl.style.cssText = 'width:100%;';
|
||||
cb.addEventListener('change', () => {
|
||||
if (_aspectSyncing) return;
|
||||
sl.disabled = !cb.checked;
|
||||
_aspectTune().hfovDeg = cb.checked ? parseFloat(sl.value) : null;
|
||||
_aspectPersist();
|
||||
if (cb.checked) _aspectWriteVal('hfovDeg', parseFloat(sl.value));
|
||||
else _aspectClearVal('hfovDeg'); // base → auto (null); pane → re-inherit base
|
||||
});
|
||||
sl.addEventListener('input', () => {
|
||||
if (cb.checked) { _aspectTune().hfovDeg = parseFloat(sl.value); _aspectPersist(); }
|
||||
if (!_aspectSyncing && cb.checked) _aspectWriteVal('hfovDeg', parseFloat(sl.value));
|
||||
});
|
||||
row.appendChild(sl); wrap.appendChild(row);
|
||||
_aspectHfovCb = cb; _aspectHfovSl = sl;
|
||||
}
|
||||
|
||||
// live readout
|
||||
@ -1799,22 +1981,31 @@
|
||||
btnRow.style.cssText = 'display:flex;gap:6px;margin-top:8px;';
|
||||
const mkBtn = (txt, fn) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button'; // never submit if nested in a <form>
|
||||
b.textContent = txt;
|
||||
b.style.cssText = 'flex:1;padding:4px 0;border-radius:5px;border:1px solid rgba(120,150,200,0.4);background:rgba(40,60,90,0.6);color:#cfe0f5;cursor:pointer;font:11px system-ui;';
|
||||
b.addEventListener('click', fn);
|
||||
return b;
|
||||
};
|
||||
// Reset: for "All" restores the shared defaults exactly; for a pane
|
||||
// clears that pane's override so it inherits the shared base again. Panel
|
||||
// visibility is independent (Shift+A / ×), so Reset doesn't force it open.
|
||||
btnRow.appendChild(mkBtn('Reset', () => {
|
||||
const t2 = _aspectTune();
|
||||
Object.keys(_ASPECT_DEFAULTS).forEach((k) => { t2[k] = _ASPECT_DEFAULTS[k]; });
|
||||
t2.enabled = true; // keep panel up after reset
|
||||
const base = _aspectTune();
|
||||
if (!_aspectEditTarget) {
|
||||
Object.keys(_ASPECT_DEFAULTS).forEach((k) => { base[k] = _ASPECT_DEFAULTS[k]; });
|
||||
} else if (base.__panels) {
|
||||
delete base.__panels[_aspectEditTarget];
|
||||
}
|
||||
_aspectPersist(); _syncAspectPanel();
|
||||
}));
|
||||
// Copy: the resolved values for the current target, as JSON.
|
||||
btnRow.appendChild(mkBtn('Copy', () => {
|
||||
const t2 = _aspectTune(), out = {};
|
||||
Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t2[k]; });
|
||||
const r = _aspectEditTarget ? _resolveTuneFor(_aspectEditTarget) : _aspectTune();
|
||||
const out = {};
|
||||
Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = r[k]; });
|
||||
const json = JSON.stringify(out, null, 2);
|
||||
try { console.log('[h3d] wide-pane framing values:\n' + json); } catch (e) {}
|
||||
try { console.log('[h3d] wide-pane framing values (' + (_aspectEditTarget || 'all') + '):\n' + json); } catch (e) {}
|
||||
try { if (navigator.clipboard) navigator.clipboard.writeText(json); } catch (e) {}
|
||||
}));
|
||||
wrap.appendChild(btnRow);
|
||||
@ -1824,19 +2015,33 @@
|
||||
_aspectPanelEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// Push current bridge values back into the panel controls (after Reset or an
|
||||
// external edit). Cheap; only runs on demand.
|
||||
// Push the current target's values back into the panel controls (after Reset,
|
||||
// a target switch, or an external edit). Cheap; only runs on demand.
|
||||
function _syncAspectPanel() {
|
||||
if (!_aspectPanelEl) return;
|
||||
const t = _aspectTune();
|
||||
_aspectPanelEl.querySelectorAll('input[type=checkbox][data-k]').forEach((cb) => {
|
||||
cb.checked = !!t[cb.dataset.k];
|
||||
});
|
||||
_aspectPanelEl.querySelectorAll('input[type=range][data-k]').forEach((sl) => {
|
||||
const k = sl.dataset.k;
|
||||
if (Number.isFinite(t[k])) sl.value = t[k];
|
||||
sl.dispatchEvent(new Event('input')); // refresh the value label
|
||||
});
|
||||
_aspectBuildTargets();
|
||||
// Guard so the synthetic 'input' events below only refresh labels and
|
||||
// don't write the read-back values into the target (which would turn a
|
||||
// sparse pane override into a full one and spam localStorage).
|
||||
_aspectSyncing = true;
|
||||
try {
|
||||
_aspectPanelEl.querySelectorAll('input[type=checkbox][data-k]').forEach((cb) => {
|
||||
cb.checked = !!_aspectReadVal(cb.dataset.k);
|
||||
});
|
||||
_aspectPanelEl.querySelectorAll('input[type=range][data-k]').forEach((sl) => {
|
||||
const v = _aspectReadVal(sl.dataset.k);
|
||||
if (Number.isFinite(v)) sl.value = v;
|
||||
sl.dispatchEvent(new Event('input')); // refresh the value label only
|
||||
});
|
||||
if (_aspectHfovCb) {
|
||||
const hv = _aspectReadVal('hfovDeg');
|
||||
_aspectHfovCb.checked = Number.isFinite(hv);
|
||||
_aspectHfovSl.disabled = !_aspectHfovCb.checked;
|
||||
if (Number.isFinite(hv)) _aspectHfovSl.value = hv;
|
||||
}
|
||||
} finally {
|
||||
_aspectSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function _setAspectPanelVisible(on) {
|
||||
@ -1844,19 +2049,35 @@
|
||||
if (!_aspectPanelEl) return;
|
||||
_aspectPanelEl.style.display = on ? 'block' : 'none';
|
||||
window.__h3dAspectPanelOpen = !!on; // gates the per-frame readout publish
|
||||
// Prune before the first build so panes from a prior song/split don't
|
||||
// flash in the dropdown until the first RAF tick.
|
||||
if (on) { _aspectPrunePanes(); _aspectBuildTargets(); }
|
||||
if (on && !_aspectPanelRAF) {
|
||||
const tick = () => {
|
||||
if (!window.__h3dAspectPanelOpen) { _aspectPanelRAF = 0; return; }
|
||||
_aspectPrunePanes();
|
||||
if (_aspectPanesDirty) _aspectBuildTargets();
|
||||
const ro = window.__h3dAspectReadout;
|
||||
if (_aspectPanelRO && ro && Number.isFinite(ro.aspect)) {
|
||||
_aspectPanelRO.textContent =
|
||||
'aspect ' + ro.aspect.toFixed(2) + ' · vFOV ' + ro.vfov.toFixed(1) + '°';
|
||||
if (_aspectPanelRO && ro) {
|
||||
const key = _aspectEditTarget || ro.__last;
|
||||
const e = key && ro[key];
|
||||
if (e && Number.isFinite(e.aspect)) {
|
||||
_aspectPanelRO.textContent =
|
||||
'aspect ' + e.aspect.toFixed(2) + ' · vFOV ' + e.vfov.toFixed(1) + '°';
|
||||
}
|
||||
}
|
||||
_aspectPanelRAF = requestAnimationFrame(tick);
|
||||
};
|
||||
_aspectPanelRAF = requestAnimationFrame(tick);
|
||||
}
|
||||
}
|
||||
// Toggle the panel open/closed (the Shift+A dismiss/reveal).
|
||||
function _toggleAspectPanel() {
|
||||
_ensureAspectPanel();
|
||||
const open = !(_aspectPanelEl && _aspectPanelEl.style.display !== 'none');
|
||||
_setAspectPanelVisible(open);
|
||||
if (open) _syncAspectPanel();
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Background animations (issue #13)
|
||||
@ -3770,6 +3991,11 @@
|
||||
// __h3dAspectTune edits) without waiting for a resize. 0 until first
|
||||
// applySize().
|
||||
let _paneAspect = 0;
|
||||
// Per-instance fallback id for the wide-pane tuner's pane key, used only
|
||||
// when this pane has no arrangement name to key by. Assigned once in
|
||||
// init(); overrides keyed off arrangement persist across songs, this
|
||||
// fallback is session-only.
|
||||
let _paneUid = 0;
|
||||
// True once applySize() has pinned the .h3d-wrap overlay to the
|
||||
// highway canvas's offset box. Stays false while the canvas has no
|
||||
// layout yet (init() can run before #highway has a real box, where
|
||||
@ -14222,14 +14448,23 @@
|
||||
|
||||
// ── Horizontal-FOV-hold + optional wide-pane pose nudges ──
|
||||
// Driven by window.__h3dAspectTune (default off → exact no-op).
|
||||
// _aspectTune() returns the live bridge object, seeded from defaults
|
||||
// + localStorage on first read so a persisted tuning session applies
|
||||
// on load without opening the panel. Every field is finite-coerced.
|
||||
// When disabled (or splitOnly and not in a split) the tune is treated
|
||||
// as null, so effectiveVfov returns the base vertical fov and cam.fov
|
||||
// is restored to it. The fov write is guarded on an actual change so
|
||||
// a steady pane costs nothing.
|
||||
const _aspTune = _aspectTune();
|
||||
// _resolveTuneFor(paneKey) returns the shared base with THIS pane's
|
||||
// overrides (if any) laid on top, so a single split pane can be framed
|
||||
// independently. The base is seeded from defaults + localStorage on
|
||||
// first read, so a persisted tuning session applies on load without
|
||||
// opening the panel. Every field is finite-coerced. When disabled (or
|
||||
// splitOnly and not in a split) the tune is treated as null, so
|
||||
// effectiveVfov returns the base vertical fov and cam.fov is restored
|
||||
// to it. The fov write is guarded on an actual change so a steady pane
|
||||
// costs nothing.
|
||||
const _paneKey = _aspectPaneKey(
|
||||
bundle && bundle.songInfo && bundle.songInfo.arrangement, _paneUid);
|
||||
// Only feed the Target-picker registry while the tuner is open (same
|
||||
// gate as the readout). Closed → nothing is registered, so the registry
|
||||
// can't grow for users who never open the panel; the key is still
|
||||
// resolved below so any saved overrides keep applying.
|
||||
if (window.__h3dAspectPanelOpen) _aspectRegisterPane(_paneKey);
|
||||
const _aspTune = _resolveTuneFor(_paneKey);
|
||||
const _aspActive = !!(_aspTune && _aspTune.enabled
|
||||
&& !(_aspTune.splitOnly && !_ssActive()));
|
||||
const _tune = _aspActive ? _aspTune : null;
|
||||
@ -14238,12 +14473,14 @@
|
||||
cam.fov = _vfov;
|
||||
cam.updateProjectionMatrix();
|
||||
}
|
||||
// Publish a live readout for the tuner panel (only while it's open,
|
||||
// so the steady path stays allocation-free). Last pane to render wins
|
||||
// the slot — fine, all panes share the same aspect in a split layout.
|
||||
// Publish a per-pane live readout for the tuner panel (only while it's
|
||||
// open, so the steady path stays allocation-free). Keyed by pane so
|
||||
// the panel can show the reading for whichever target is selected.
|
||||
if (window.__h3dAspectPanelOpen) {
|
||||
const _ro = window.__h3dAspectReadout || (window.__h3dAspectReadout = {});
|
||||
_ro.aspect = _paneAspect; _ro.vfov = _vfov;
|
||||
const _slot = _ro[_paneKey] || (_ro[_paneKey] = {});
|
||||
_slot.aspect = _paneAspect; _slot.vfov = _vfov;
|
||||
_ro.__last = _paneKey;
|
||||
}
|
||||
// Optional pose nudges (height / dolly / pitch) to chase a low-flat
|
||||
// wide-pane look if fov alone isn't enough. Gated to wide panes and
|
||||
@ -14678,7 +14915,8 @@
|
||||
}
|
||||
_destroyed = _isReady = false;
|
||||
_isFocused = true;
|
||||
_registerAspectAbShortcut(); // session-global A/B toggle (self-guarded)
|
||||
if (!_paneUid) _paneUid = ++_aspectPaneCounter; // fallback pane id (no-arrangement panes)
|
||||
_registerTunerShortcut(); // session-global tuner shortcut (self-guarded)
|
||||
const myToken = ++_initToken;
|
||||
highwayCanvas = canvas;
|
||||
_invertedCached = !!(bundle && bundle.inverted);
|
||||
|
||||
@ -120,11 +120,11 @@ test('applySize caches the pane aspect for camUpdate', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('camUpdate reads the live tune bridge and respects splitOnly', () => {
|
||||
test('camUpdate resolves a per-pane tune and respects splitOnly', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+_aspTune\s*=\s*_aspectTune\(\)\s*;[\s\S]*?_aspTune\.splitOnly\s*&&\s*!_ssActive\(\)/,
|
||||
'camUpdate must read the bridge via _aspectTune() and gate splitOnly on _ssActive()',
|
||||
/const\s+_aspTune\s*=\s*_resolveTuneFor\(\s*_paneKey\s*\)\s*;[\s\S]*?_aspTune\.splitOnly\s*&&\s*!_ssActive\(\)/,
|
||||
'camUpdate must resolve the tune per pane via _resolveTuneFor(_paneKey) and gate splitOnly',
|
||||
);
|
||||
});
|
||||
|
||||
@ -136,11 +136,151 @@ test('the tune bridge seeds from localStorage (persisted sessions apply on load)
|
||||
);
|
||||
});
|
||||
|
||||
test('a floating tuner panel is built and toggled with the A/B state', () => {
|
||||
test('a floating tuner panel is built and can be shown/hidden', () => {
|
||||
assert.match(src, /function\s+_ensureAspectPanel\s*\(\)/,
|
||||
'_ensureAspectPanel() must exist to build the live panel');
|
||||
assert.match(src, /function\s+_setAspectPanelVisible\s*\(/,
|
||||
'_setAspectPanelVisible() must show/hide the panel with the feature');
|
||||
'_setAspectPanelVisible() must show/hide the panel');
|
||||
});
|
||||
|
||||
// ── Per-pane targeting ────────────────────────────────────────────────────────
|
||||
|
||||
test('the tune resolves per pane with a sparse override map', () => {
|
||||
// _resolveTuneFor overlays a pane's __panels[key] overrides onto the base so
|
||||
// one split pane can be framed independently of the others.
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_resolveTuneFor\s*\(\s*paneKey\s*\)[\s\S]*?base\.__panels\s*&&\s*base\.__panels\[\s*paneKey\s*\]/,
|
||||
'_resolveTuneFor must overlay per-pane overrides from base.__panels',
|
||||
);
|
||||
});
|
||||
|
||||
test('panel writes route to the selected target (base or a pane override)', () => {
|
||||
// _aspectWriteVal writes to the base when target is empty, else into the
|
||||
// pane override sub-object; camUpdate consumes it via _resolveTuneFor.
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_aspectWriteVal\s*\([\s\S]*?if\s*\(\s*!_aspectEditTarget\s*\)[\s\S]*?base\.__panels\b[\s\S]*?\[\s*_aspectEditTarget\s*\]/,
|
||||
'_aspectWriteVal must target base for "all" and __panels[target] for a pane',
|
||||
);
|
||||
});
|
||||
|
||||
test('a Target select and pane registry drive the per-pane picker', () => {
|
||||
assert.match(src, /_aspectTargetSel\s*=\s*document\.createElement\(\s*'select'\s*\)/,
|
||||
'the panel must build a Target <select>');
|
||||
assert.match(src, /function\s+_aspectRegisterPane\s*\(/,
|
||||
'_aspectRegisterPane must record live panes for the picker');
|
||||
assert.match(src, /if\s*\(\s*window\.__h3dAspectPanelOpen\s*\)\s*_aspectRegisterPane\(\s*_paneKey\s*\)/,
|
||||
'camUpdate must register its pane only while the tuner panel is open');
|
||||
});
|
||||
|
||||
test('panes are keyed by arrangement (stable across songs, no split-API dep)', () => {
|
||||
// 'arr:<name>' keys are distinct between split panes AND stable across
|
||||
// songs, without depending on the external splitscreen panel index (which
|
||||
// isn't always available). A per-instance id is the no-arrangement fallback.
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_aspectPaneKey\s*\(\s*arrangement\s*,\s*uid\s*\)[\s\S]*?'arr:'\s*\+\s*a[\s\S]*?'pane:'\s*\+\s*uid/,
|
||||
'_aspectPaneKey must prefer arr:<name> and fall back to pane:<uid>',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+_paneKey\s*=\s*_aspectPaneKey\(\s*[\s\S]*?songInfo[\s\S]*?arrangement\s*,\s*_paneUid\s*\)\s*;/,
|
||||
'camUpdate must key the pane by arrangement (with the uid fallback)',
|
||||
);
|
||||
});
|
||||
|
||||
test('arrangement-keyed overrides persist; instance-id keys stay session-only', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_aspectPersist\s*\(\)[\s\S]*?k\.slice\(0,\s*4\)\s*===\s*'arr:'[\s\S]*?out\.__panels\s*=\s*p/,
|
||||
'_aspectPersist must persist only arr:* overrides so they carry across songs',
|
||||
);
|
||||
});
|
||||
|
||||
test('the target dropdown prunes dead panes and does not rebuild while focused', () => {
|
||||
assert.match(src, /function\s+_aspectPrunePanes\s*\(\)[\s\S]*?delete\s+reg\[k\]/,
|
||||
'_aspectPrunePanes must drop panes not seen recently');
|
||||
assert.match(src, /_aspectPrunePanes\(\)\s*;[\s\S]*?if\s*\(\s*_aspectPanesDirty\s*\)\s*_aspectBuildTargets\(\)/,
|
||||
'the readout tick must prune then rebuild only when dirty');
|
||||
assert.match(src, /function\s+_aspectBuildTargets\s*\(\)[\s\S]*?document\.activeElement\s*===\s*_aspectTargetSel[\s\S]*?return/,
|
||||
'_aspectBuildTargets must skip rebuilding while the select is focused');
|
||||
});
|
||||
|
||||
test('programmatic sync does not write back into the tune', () => {
|
||||
// _syncAspectPanel dispatches synthetic input events to refresh labels; the
|
||||
// slider handler must skip the write while syncing, else opening/switching a
|
||||
// target would populate a full override for every field.
|
||||
assert.match(src, /_aspectSyncing\s*=\s*true[\s\S]*?finally[\s\S]*?_aspectSyncing\s*=\s*false/,
|
||||
'_syncAspectPanel must set/reset the _aspectSyncing guard');
|
||||
assert.match(src, /if\s*\(\s*!_aspectSyncing\s*\)\s*_aspectWriteVal\(\s*f\.k\s*,/,
|
||||
'the slider input handler must skip the write while syncing');
|
||||
});
|
||||
|
||||
test('unchecking hfov override clears a pane override key (re-inherits base)', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_aspectClearVal\s*\(\s*k\s*\)[\s\S]*?delete\s+ov\[k\][\s\S]*?delete\s+m\[\s*_aspectEditTarget\s*\]/,
|
||||
'_aspectClearVal must delete the pane override key (and empty object)',
|
||||
);
|
||||
assert.match(src, /else\s+_aspectClearVal\(\s*'hfovDeg'\s*\)/,
|
||||
'unchecking the hfov override must call _aspectClearVal');
|
||||
});
|
||||
|
||||
test('pruning drops the matching readout slot and a dangling __last', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/delete\s+reg\[k\]\s*;[\s\S]*?delete\s+ro\[k\]\s*;\s*if\s*\(\s*ro\.__last\s*===\s*k\s*\)\s*delete\s+ro\.__last/,
|
||||
'_aspectPrunePanes must prune the readout cache alongside the registry',
|
||||
);
|
||||
});
|
||||
|
||||
test('single-pane forces the edit target back to All (no hidden pane edits)', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*keys\.length\s*<=\s*1\s*\|\|\s*\(\s*_aspectEditTarget\s*&&\s*!reg\[_aspectEditTarget\]\s*\)\s*\)\s*\{\s*_aspectEditTarget\s*=\s*''/,
|
||||
'_aspectBuildTargets must reset the edit target to "" when the Target row is hidden',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolved per-pane tune is memoized and invalidated by a revision', () => {
|
||||
assert.match(src, /_aspectRev\s*\+\+/, 'a mutation revision must be bumped on persist');
|
||||
assert.match(
|
||||
src,
|
||||
/_aspectResolveCache\.get\(\s*paneKey\s*\)[\s\S]*?c\.rev\s*===\s*_aspectRev[\s\S]*?return\s+c\.obj/,
|
||||
'_resolveTuneFor must return a cached object when the revision is unchanged',
|
||||
);
|
||||
});
|
||||
|
||||
test('the pane clock falls back to Date.now so pruning keeps working', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_aspectNowMs\s*\(\)[\s\S]*?performance\.now\(\)[\s\S]*?return\s+Date\.now\(\)/,
|
||||
'_aspectNowMs must fall back to Date.now() when the Performance API is absent',
|
||||
);
|
||||
});
|
||||
|
||||
test('opening the panel prunes before the first dropdown build', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*on\s*\)\s*\{\s*_aspectPrunePanes\(\)\s*;\s*_aspectBuildTargets\(\)/,
|
||||
'_setAspectPanelVisible must prune stale panes before building the dropdown',
|
||||
);
|
||||
});
|
||||
|
||||
test('Reset on All restores defaults exactly (no forced enabled)', () => {
|
||||
// Panel visibility is independent of the enabled flag now, so Reset must not
|
||||
// force enabled true — it should restore _ASPECT_DEFAULTS verbatim.
|
||||
assert.doesNotMatch(src, /Object\.keys\(_ASPECT_DEFAULTS\)[\s\S]*?base\.enabled\s*=\s*true/,
|
||||
'Reset must not override the default enabled state');
|
||||
});
|
||||
|
||||
test('the panel has a dismiss (close) control', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/close\.textContent\s*=\s*'×'[\s\S]*?_setAspectPanelVisible\(\s*false\s*\)/,
|
||||
'the panel header must have a × button that hides the panel',
|
||||
);
|
||||
});
|
||||
|
||||
test('camUpdate only writes cam.fov when it actually changes', () => {
|
||||
@ -153,14 +293,16 @@ test('camUpdate only writes cam.fov when it actually changes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// ── A/B toggle + lifecycle reset ──────────────────────────────────────────────
|
||||
// ── Shortcut (open/close) + lifecycle reset ───────────────────────────────────
|
||||
|
||||
test('an A/B toggle shortcut flips the tune enabled flag', () => {
|
||||
test('the shortcut opens/closes the tuner panel', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/registerShortcut\(\{[\s\S]*?const\s+t\s*=\s*_aspectTune\(\)\s*;[\s\S]*?t\.enabled\s*=\s*!\s*t\.enabled/,
|
||||
'a registerShortcut handler must toggle the bridge enabled flag',
|
||||
/registerShortcut\(\{[\s\S]*?_toggleAspectPanel\(\)/,
|
||||
'a registerShortcut handler must toggle the tuner panel',
|
||||
);
|
||||
assert.match(src, /function\s+_toggleAspectPanel\s*\(\)/,
|
||||
'_toggleAspectPanel() must exist to reveal/dismiss the panel');
|
||||
});
|
||||
|
||||
test('destroy() resets the pane aspect and restores the base fov', () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user