Files
LEDMatrix/web_interface/static/v3/js/pages/display.js
T
ChuckandClaude Sonnet 5.5 1bcb524fc3 feat(scroll): report a panel that cannot reach its refresh cap, and suggest one it can hold (#759)
* feat(scroll): report a panel that cannot reach its refresh cap, and suggest one it can hold

Scroll speeds are solved against display.hardware.limit_refresh_rate_hz,
which is only a ceiling. A panel that cannot reach it still moves whole
pixels per frame, but every scroll runs slow by the shortfall and the
"smooth" ladder is the cap's, not the panel's. A user rig (Pi 4, 2x128x64,
adafruit-hat-pwm, pwm_bits 9, gpio_slowdown 5) measured 107.6-113.1 Hz under
a 120 Hz cap: 60 px/s ran at 55, and nothing said why.

- scroll_config: refresh_shortfall() (more than 3% under the planned rate),
  holdable_cap() (a multiple of 10, 5% under the measurement, since the
  measurement is the fast end of an uncapped panel's drift), and
  describe_refresh_shortfall().
- FrameTimingRecorder.plan_refresh(): once the measured period has held for
  three trusted windows, a shortfall is logged once as a warning naming the
  cap to use. DisplayManager calls it only for a real panel, not the
  emulator or the fallback canvas. The stats file records
  planned_refresh_hz (additive).
- GET /api/v3/config/refresh-rate, plus a hint under the Display tab's
  Limit Refresh Rate field (js/pages/display.js) with a button that fills in
  the suggested cap.
- _panel_refresh_hz (behind the Vegas slider's advice) ignores a measurement
  written under a different cap, so a changed cap stops being advised from
  the old rate before the display restarts.

Verified on ledpi with a temporary 200 Hz cap: the warning logged about a
minute after the restart ("about 132 Hz ... Set Limit Refresh Rate to
120 Hz"), the endpoint returned the same shortfall, and the Display tab
showed the hint; its button filled in 120. ledpi was restored afterwards.
Rebased onto main after the Display tab became an ES-module page (#771); the
hint moved from inline script into display.js, with a jsdom test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(scroll): count only agreeing windows toward the refresh shortfall; ignore a null planned rate

Review fixes on #759.

- A window the period estimate rejects (more than MAX_REFRESH_DROP faster
  than the adopted period) no longer counts toward the shortfall check, and
  it restarts the run. After a loaded start fixed a slow period, later
  windows at the real, faster rate were rejected yet still counted, so the
  warning could name the slow rate against a cap the panel was meeting. It
  now needs REFRESH_CHECK_WINDOWS consecutive windows that agree with the
  period.
- _panel_refresh_hz treats a stats file whose planned_refresh_hz key is
  present but null as no measurement. The emulator and the fallback canvas
  write it that way (DisplayManager never plans a refresh for them), and
  their frame rate says nothing about the cap. A file with no such key (an
  older display) keeps the old behaviour.

Three new tests fail on the previous code and pass now.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 10:40:08 -04:00

365 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* pages/display.js -- the Display tab (templates/v3/partials/display.html):
* the panel hardware, Vegas scroll mode, double-sided and multi-display
* sync settings.
*
* The form itself stays plain htmx (hx-post with json-enc, and its hx-on
* call to the shared showSaveResult, as the Rotation and General forms
* keep). This module does what the partial's inline scripts did:
* - the brightness value next to its slider
* - the live "Your display: W x H pixels" readout
* - showing the Vegas and double-sided settings only while each is on
* - the scroll-speed hint (what the panel really does with a speed), asked
* of the server 150 ms after the slider stops; a newer answer wins
* - the Vegas plugin order (the shared plugin-order-list widget, as the
* Rotation tab uses; its plugin-list request carries ctx.signal)
* - the Advanced section's toggle button (data-action="toggle-section",
* calling the shared window.toggleSection the settings search also uses)
* - the multi-display sync role, and its status, polled every 5 s through
* ctx.visibility: only while the Display tab is on screen and the
* browser tab is visible, and never after the partial is swapped out
*
* The old global updateSyncUI is a deprecated alias made in core/boot.js; it
* forwards to the export of the same name at the bottom of this file.
*/
const SYNC_POLL_MS = 5000;
const HINT_DELAY_MS = 150;
const RETRY_MS = 100;
const MAX_TRIES = 50;
const RESOLUTION_INPUTS = ['rows', 'cols', 'chain_length', 'parallel'];
// The mounted page, for the deprecated alias (boot.js).
let active = null;
function quiet(error) {
return !!error && (error.name === 'AbortError' || error.loginRequired);
}
// ── the "Your display" readout ───────────────────────────────────────────────
// width = cols x chain_length, height = rows x parallel (the chain-length
// tooltip's math), swapped for a 90/270 orientation. A custom pixel mapper
// can change it further; src/display_geometry.py models those.
function showResolution(root) {
const out = root.querySelector('#display-resolution-value');
if (!out) return;
const read = function(id) {
const el = root.querySelector('#' + id);
return el ? parseInt(el.value, 10) : NaN;
};
const rows = read('rows');
const cols = read('cols');
const chain = read('chain_length');
const parallel = read('parallel');
if ([rows, cols, chain, parallel].some(function(n) { return !Number.isFinite(n) || n <= 0; })) {
out.textContent = '—';
return;
}
let w = cols * chain;
let h = rows * parallel;
const orientation = root.querySelector('#orientation');
if (orientation && (orientation.value === '90' || orientation.value === '270')) {
[w, h] = [h, w];
}
out.textContent = w + ' × ' + h + ' pixels';
}
// ── the scroll-speed hint ────────────────────────────────────────────────────
// Only speeds that advance a whole number of pixels per refresh look smooth,
// and which those are depends on the panel, so the server works it out.
function refreshScrollSpeedHint(root, ctx) {
const win = root.ownerDocument.defaultView;
const slider = root.querySelector('#vegas_scroll_speed');
win.clearTimeout(ctx.state.hintTimer);
ctx.state.hintTimer = win.setTimeout(function() {
ctx.state.hintTimer = null;
const hint = root.querySelector('#vegas_scroll_speed_hint');
if (!hint || !slider || ctx.signal.aborted) return;
ctx.state.hintSeq = (ctx.state.hintSeq || 0) + 1;
const seq = ctx.state.hintSeq;
const q = new win.URLSearchParams({ speed: slider.value, min: slider.min, max: slider.max });
ctx.api.get('/api/v3/config/scroll-speed-advice?' + q, { signal: ctx.signal })
.then(function(body) {
if (seq !== ctx.state.hintSeq || body.status !== 'success') return;
renderScrollSpeedHint(root, hint, slider, body.data);
})
.catch(function(error) {
// A refused request keeps the last hint, as before; a failed
// or unreadable one clears it.
if (quiet(error) || error.body) return;
hint.textContent = '';
});
}, HINT_DELAY_MS);
}
// ── the refresh-cap hint ─────────────────────────────────────────────────────
// Scroll speeds are worked out against the cap, so a panel that cannot reach
// it runs every scroll slow. The display has measured a cap it can hold.
function showRefreshRateHint(root, ctx) {
const hint = root.querySelector('#limit_refresh_rate_hz_hint');
const input = root.querySelector('#limit_refresh_rate_hz');
if (!hint || !input) return;
const doc = root.ownerDocument;
const win = doc.defaultView;
ctx.api.get('/api/v3/config/refresh-rate', { signal: ctx.signal })
.then(function(body) {
const s = body.status === 'success' && body.data.shortfall;
hint.textContent = '';
if (!s) return;
hint.appendChild(doc.createTextNode(
'This panel refreshes at about ' + Math.round(s.measured_hz) +
' Hz, below this ' + Math.round(s.planned_hz) + ' Hz cap, so scrolls run about ' +
s.slow_percent + '% slower than set.' + (s.suggested_cap_hz ? ' ' : '')));
if (!s.suggested_cap_hz) return;
const btn = doc.createElement('button');
btn.type = 'button';
btn.className = 'underline font-medium';
btn.textContent = 'Use ' + s.suggested_cap_hz + ' Hz';
btn.addEventListener('click', function() {
input.value = s.suggested_cap_hz;
input.dispatchEvent(new win.Event('input', { bubbles: true }));
input.dispatchEvent(new win.Event('change', { bubbles: true }));
hint.textContent = 'Save, then restart the display, to apply ' +
s.suggested_cap_hz + ' Hz.';
});
hint.appendChild(btn);
hint.appendChild(doc.createTextNode(', a cap it can hold.'));
})
.catch(function(error) {
if (quiet(error) || error.body) return;
hint.textContent = '';
});
}
function renderScrollSpeedHint(root, hint, slider, a) {
const doc = root.ownerDocument;
const win = doc.defaultView;
const ap = a.applied;
const motion = ap.pixels_per_frame + ' px every ' + ap.frame_hold +
' refresh' + (ap.frame_hold === 1 ? '' : 'es');
hint.textContent = '';
hint.className = 'mt-1 text-xs ' + (a.smooth && a.exact ? 'text-green-700' : 'text-amber-700');
const line = doc.createElement('span');
if (a.smooth && a.exact) {
line.textContent = 'Smooth on this panel (' + motion + ', ' + a.refresh_hz + ' Hz).';
} else {
line.textContent = a.requested + ' px/s will run as ' + ap.pixels_per_second +
' px/s (' + motion + ', ' + ap.steppiness + ') on this ' + a.refresh_hz +
' Hz panel.' + (a.alternatives.length ? ' Smooth speeds: ' : '');
}
hint.appendChild(line);
a.alternatives.forEach(function(alt, i) {
if (i > 0) hint.appendChild(doc.createTextNode(' '));
const value = Math.round(alt.pixels_per_second);
const btn = doc.createElement('button');
btn.type = 'button';
btn.className = 'underline font-medium';
btn.textContent = value + ' px/s';
btn.addEventListener('click', function() {
slider.value = value;
slider.dispatchEvent(new win.Event('input', { bubbles: true }));
});
hint.appendChild(btn);
});
}
// ── the Vegas plugin order ───────────────────────────────────────────────────
// The widget is a classic deferred script and normally loads before this
// module runs; the short retry covers a page that arrives first anyway.
function startPluginOrder(root, ctx) {
const container = root.querySelector('#vegas_plugin_order');
if (!container) return;
const win = root.ownerDocument.defaultView;
let tries = 0;
function attempt() {
ctx.state.orderTimer = null;
if (ctx.signal.aborted) return;
const widget = win.PluginOrderList;
if (!widget) {
if (++tries < MAX_TRIES) {
ctx.state.orderTimer = win.setTimeout(attempt, RETRY_MS);
} else {
container.textContent = 'Could not load the reorder widget — reload the page to try again.';
container.className = 'text-sm text-red-500';
}
return;
}
widget.init({
containerId: 'vegas_plugin_order',
orderInputId: 'vegas_plugin_order_value',
excludedInputId: 'vegas_excluded_plugins_value',
showVegasModeBadge: true,
signal: ctx.signal,
});
}
attempt();
}
// ── multi-display sync ───────────────────────────────────────────────────────
function syncRole(root) {
const select = root.querySelector('#sync_role');
return select ? select.value : null;
}
/** Show the status bar and the follower's Position field for the chosen role. */
function showSyncRole(root) {
const role = syncRole(root);
const bar = root.querySelector('#sync_status_bar');
const position = root.querySelector('#setting-display-sync_follower_position');
const errorDetail = root.querySelector('#sync_error_detail');
if (!bar || !position) return role;
if (role === 'standalone') {
bar.classList.add('hidden');
if (errorDetail) errorDetail.classList.add('hidden');
position.style.display = 'none';
} else {
bar.classList.remove('hidden');
position.style.display = role === 'follower' ? '' : 'none';
}
return role;
}
function pollSyncStatus(root, ctx) {
const role = syncRole(root);
if (!role || role === 'standalone' || ctx.signal.aborted) return;
ctx.api.get('/api/v3/sync/status', { signal: ctx.signal })
.then(function(body) { renderSyncStatus(root, body.data || {}); })
.catch(function(error) {
if (!quiet(error)) renderSyncStatus(root, { state: 'unknown' });
});
}
function renderSyncStatus(root, d) {
const doc = root.ownerDocument;
const content = root.querySelector('#sync_status_content');
const errorDetail = root.querySelector('#sync_error_detail');
const errorText = root.querySelector('#sync_error_text');
if (!content) return;
const state = d.state || 'unknown';
const role = d.role || 'unknown';
let icon, colorClass, text;
if (state === 'connected' || state === 'follower') {
icon = '●';
colorClass = 'bg-green-50 border-green-200 text-green-800';
const peer = d.peer_ip || d.leader_ip || 'peer';
text = role === 'leader'
? 'Follower connected — ' + peer + ' (chain ' + (d.peer_chain || '?') + ')'
: 'Receiving from leader — ' + peer;
errorDetail.classList.add('hidden');
} else if (state === 'incompatible') {
icon = '⚠';
colorClass = 'bg-yellow-50 border-yellow-200 text-yellow-800';
text = 'Follower connected but incompatible panels';
if (d.error) {
errorText.textContent = d.error;
errorDetail.classList.remove('hidden');
}
} else if (state === 'no_peer' || state === 'standalone') {
icon = '○';
colorClass = 'bg-gray-50 border-gray-200 text-gray-600';
text = role === 'leader' ? 'No follower detected' : 'Searching for leader…';
errorDetail.classList.add('hidden');
} else if (state === 'starting') {
icon = '○';
colorClass = 'bg-gray-50 border-gray-200 text-gray-500';
text = 'Display process starting…';
errorDetail.classList.add('hidden');
} else {
icon = '✕';
colorClass = 'bg-red-50 border-red-200 text-red-700';
text = 'Sync status unavailable';
errorDetail.classList.add('hidden');
}
content.className = 'flex items-start space-x-2 p-3 rounded-lg border text-sm ' + colorClass;
content.textContent = '';
const iconSpan = doc.createElement('span');
iconSpan.className = 'font-bold text-lg leading-none';
iconSpan.textContent = icon;
const textSpan = doc.createElement('span');
textSpan.textContent = text;
content.appendChild(iconSpan);
content.appendChild(textSpan);
}
/** The Role menu changed: show what goes with the new role, and ask for its status now. */
function changeSyncRole(root, ctx) {
if (showSyncRole(root) !== 'standalone') pollSyncStatus(root, ctx);
}
// ── lifecycle ────────────────────────────────────────────────────────────────
export function init(root, ctx) {
const win = root.ownerDocument.defaultView;
const on = { signal: ctx.signal };
const $ = function(id) { return root.querySelector('#' + id); };
const brightness = $('brightness');
const brightnessValue = $('brightness-value');
if (brightness && brightnessValue) {
brightness.addEventListener('input', function() { brightnessValue.textContent = brightness.value; }, on);
}
RESOLUTION_INPUTS.forEach(function(id) {
const el = $(id);
if (el) el.addEventListener('input', function() { showResolution(root); }, on);
});
const orientation = $('orientation');
if (orientation) orientation.addEventListener('change', function() { showResolution(root); }, on);
showResolution(root);
// Hidden rather than disabled, so the fields keep submitting and the
// server still sees an "off" state to persist.
[['vegas_scroll_enabled', 'vegas_scroll_settings', 'block'],
['double_sided_enabled', 'double_sided_settings', 'grid']].forEach(function(spec) {
const box = $(spec[0]);
const settings = $(spec[1]);
if (!box || !settings) return;
box.addEventListener('change', function() { settings.style.display = box.checked ? spec[2] : 'none'; }, on);
});
const speed = $('vegas_scroll_speed');
const speedValue = $('vegas_scroll_speed_value');
if (speed && speedValue) {
speed.addEventListener('input', function() {
speedValue.textContent = speed.value;
refreshScrollSpeedHint(root, ctx);
}, on);
refreshScrollSpeedHint(root, ctx);
}
root.addEventListener('click', function(event) {
const button = event.target && event.target.closest ? event.target.closest('[data-action="toggle-section"]') : null;
if (!button || !root.contains(button)) return;
if (typeof win.toggleSection === 'function') win.toggleSection(button.getAttribute('data-section'));
}, on);
const role = $('sync_role');
if (role) role.addEventListener('change', function() { changeSyncRole(root, ctx); }, on);
showSyncRole(root);
// The first request goes out as soon as the tab is on screen (at once,
// when it already is), then every 5 s while it stays there.
ctx.visibility.every(SYNC_POLL_MS, function() { pollSyncStatus(root, ctx); });
showRefreshRateHint(root, ctx);
startPluginOrder(root, ctx);
active = ctx;
}
export function destroy(root, ctx) {
const win = root.ownerDocument.defaultView;
win.clearTimeout(ctx.state.hintTimer);
win.clearTimeout(ctx.state.orderTimer);
ctx.state.hintTimer = null;
ctx.state.orderTimer = null;
// The sync poll is ctx.visibility's: it stops when ctx.signal aborts.
if (active === ctx) active = null;
}
// ── the old global, kept as a deprecated alias (boot.js) ─────────────────────
/** window.updateSyncUI(): the Role menu's old onchange handler. */
export function updateSyncUI() {
if (active) changeSyncRole(active.root, active);
}