mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* perf(timing): say which render-thread work a late frame followed The soak already says how often a moving frame reached the panel late, but not what the render thread was doing just before it. Vegas does two kinds of work there between frames -- building its strip (compose, extend) and, with live elements, patching changed pixels into it -- and deciding whether either is affordable needs their own numbers. - FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame. Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind; aggregate() still takes frames without ops. The file schema is unchanged. - Vegas tags compose and every strip extension (with the bytes it copied). - frame_soak prints an "after work" table: frames, late %, freezes and MB moved per kind, only when something tagged its work. - render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes / --patch-every / --patch-where (in-place column writes, as a live element update does) and --extend-every-screens / --extend-width (append + trim on a fixed cadence that holds the strip's width). No runtime behaviour changes: this is the measurement gate for live Vegas elements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note the frame-op attribution and bench modes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(scroll): build the strip's PIL image only when something reads it Every Vegas strip extension rebuilt ScrollHelper.cached_image from cached_array in full, twice (append, then trim), on the render thread: Image.fromarray is 1.7ms for an 8,000px strip and 3.8ms for 20,000px on a Pi 4 (measured on ledpi), about two thirds of an extension's render-thread cost. Nothing on the frame path reads the image's pixels; every frame is cut from the array. cached_image is now a property. append_content and drop_scrolled_prefix defer it; the first read builds it from the array it started with and keeps it only if the strip has not changed meanwhile, so a sync push racing an extension cannot leave a stale image cached. Assigning cached_image stores exactly what was assigned, as before. has_strip() says whether there is a strip without building its image; the helper's frame path, Vegas and the adapter's scroll-cache invalidation use it. The strip is also no longer held in memory twice. In Vegas the image is now built only by a multi-display sync push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(vegas): live elements -- a plugin API for content that changes while it scrolls Vegas bakes each plugin's pictures into one strip, so a card already on its way across the panel keeps what it showed when it was drawn. This adds the API and bookkeeping for content that can be updated in place; the worker that redraws and swaps it follows separately. No shipped plugin implements the hook yet, so nothing changes for users. Plugin API (core 3.8.0), all no-ops by default: - BasePlugin.get_vegas_elements() -> [VegasElement(key, image, version, live, refresh_hz)]: named, fixed-width pieces of Vegas content. - BasePlugin.redraw_vegas_element(key, width, height, at): a lock-free redraw for content that changes with time. - BasePlugin.notify_vegas_data_changed(): data that lands outside update(). - src/plugin_system/vegas_elements.py (VegasElement, re-exported from base_plugin). Core: - PluginAdapter asks a plugin that implements the hook for elements on the background fetch only (under its lock, on its own canvas); every other path keeps get_vegas_content(). Live elements are pinned (padded with content_padding, never trimmed), tagged with their key, digest and data epoch in Image.info so the existing cache and group plumbing carry them unchanged, and untagged if a width budget crops them. - RenderPipeline records where each live element lands (ElementRecord), in absolute strip columns a trim does not move; the block-start arithmetic is shared with the STATIC markers. - PluginManager update listeners (add/remove_update_listener, notify_data_changed): told the moment update() completes, not at the next ~4s Vegas poll. The coordinator uses one to move each plugin's data epoch on. - vegas_scroll.live_refresh (kill switch), live_max_hz, live_min_interval, live_lead_screens; per-plugin core-owned vegas_live. Live elements are off under multi-display sync, in swap mode and with offscreen_prefetch off. - scripts/check_plugin.py checks the element contract (src/plugin_system/testing/vegas.py); test/fixtures/plugins/vegas-live-stub is a working example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(vegas): live elements update in place while they scroll One background worker (src/vegas_mode/live_worker.py) redraws a plugin's live elements when its data epoch moves on (update listener) or on their refresh_hz, nearest the screen first, and hands changed pixels lock-free to the render thread, which copies them into the strip between frames (RenderPipeline.apply_live_patches, ScrollHelper.patch_columns): at most four patches or two screens of bytes a frame, no drawing or locks there. The worker takes over group prefetch once a live element is placed, runs inside the render gate, and is supervised. Update tick 1s while live elements exist. Web UI switch for live_refresh. OFFSCREEN_RENDERING.md describes what was built and why SegmentStrip was not needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(sports): live Vegas cards for the scoreboards (shared layer) One live element per game, drawn only when what the card shows changes, so a score changes on a card already crossing the panel. The shared part, so each scoreboard adopts it in a few lines: - src/common/sports_vegas.py: game_key, game_fingerprint (the whole game dict, frozen: no drawn field can be missed), dedupe_games, VegasCardCache, StickyOdds (odds a live poll left out stay drawn), finished_games / with_finished_games (a game that just went final keeps its card, after its league's live games; one a heuristic only judged over keeps its live state, so a tied end of regulation never shows FINAL early). - SportsScrollDisplay.make_vegas_renderer() is the override point; build_vegas_elements() and SportsScrollDisplayManager .get_vegas_elements_for() do the rest. A card's version includes its teams' ranks, which the renderer draws from the rankings cache. - SportsLiveSharedMixin._record_finished_game() / finished_games_snapshot(): held for FINISHED_GAME_TTL after it leaves the live list. A sport that does not implement make_vegas_renderer keeps its ordinary Vegas content, so no scoreboard changes until it opts in. scripts/render_plugin.py --vegas renders a plugin's Vegas block as the ticker lays it out, and --timeline stacks it at successive moments as the ticker would update it in place; the join is now render_pipeline.join_plugin_rows(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(vegas): keep live games in the ticker by default display.vegas_scroll.live_in_ticker now defaults to true: through a live game the marquee keeps running and the live scoreboard takes extra turns in it -- its cards updating in place while they scroll -- instead of the ticker giving way to the full-screen scoreboard. The new default would reach nobody on its own: every existing config holds an explicit false copied from the template (there was no control for it), and the template merge only adds missing keys. ConfigManager therefore turns a stored false on once, with a backup, and records live_in_ticker_migrated so a false chosen afterwards stays. The marker is never in the template. A "Keep live games in the ticker" checkbox under Vegas mode sets it. Tests that pin the full-screen takeover now say live_in_ticker=false. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(dev): preview a plugin's Vegas strip in the dev server The dev server's View selector gains "Vegas strip (live elements)" and "Vegas strip (plain Vegas content)": the plugin's block of the Vegas ticker, laid out by the ticker's own code (render_vegas_strip, as render_plugin.py --vegas uses), with its live elements listed. /api/render takes "vegas": "live" | "plain"; the display view is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(sports): a default _determine_game_type on SportsScrollDisplay render_vegas_card looked the method up with getattr and a None default, which static analysis (Codacy) reports as calling something that may not be callable. The base class now has the default -- the card type from the game's state -- and the plugins that define their own override it as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: review follow-ups on the shared live-card layer - The reused Vegas renderer always gets the current rankings, empty included, so ranks cleared since are not kept drawn. - render_plugin.py: --timeline refuses --no-live (a timeline shows live elements changing), --timeline/--no-live need --vegas, and the Vegas paths create the output's directory like the display path does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
737 lines
31 KiB
HTML
737 lines
31 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en" data-theme="dark">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>LEDMatrix Dev Preview</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script>
|
|
<style>
|
|
:root {
|
|
--bg-primary: #0f172a;
|
|
--bg-secondary: #1e293b;
|
|
--bg-tertiary: #334155;
|
|
--text-primary: #f1f5f9;
|
|
--text-secondary: #94a3b8;
|
|
--border-color: #475569;
|
|
--accent: #3b82f6;
|
|
}
|
|
|
|
[data-theme="light"] {
|
|
--bg-primary: #f8fafc;
|
|
--bg-secondary: #ffffff;
|
|
--bg-tertiary: #f1f5f9;
|
|
--text-primary: #1e293b;
|
|
--text-secondary: #64748b;
|
|
--border-color: #e2e8f0;
|
|
--accent: #3b82f6;
|
|
}
|
|
|
|
body {
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|
}
|
|
|
|
.panel {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 0.75rem;
|
|
}
|
|
|
|
/* JSON Editor theme overrides */
|
|
.je-object__container, .je-indented-panel {
|
|
background: var(--bg-tertiary) !important;
|
|
border-color: var(--border-color) !important;
|
|
border-radius: 0.5rem !important;
|
|
padding: 0.75rem !important;
|
|
margin-bottom: 0.5rem !important;
|
|
}
|
|
|
|
.je-header, .je-object__title {
|
|
color: var(--text-primary) !important;
|
|
font-size: 0.875rem !important;
|
|
}
|
|
|
|
.je-form-input-label {
|
|
color: var(--text-secondary) !important;
|
|
font-size: 0.8rem !important;
|
|
}
|
|
|
|
div[data-schematype] input[type="text"],
|
|
div[data-schematype] input[type="number"],
|
|
div[data-schematype] select,
|
|
div[data-schematype] textarea {
|
|
background: var(--bg-primary) !important;
|
|
color: var(--text-primary) !important;
|
|
border: 1px solid var(--border-color) !important;
|
|
border-radius: 0.375rem !important;
|
|
padding: 0.375rem 0.5rem !important;
|
|
font-size: 0.8rem !important;
|
|
}
|
|
|
|
div[data-schematype] input[type="text"]:focus,
|
|
div[data-schematype] input[type="number"]:focus,
|
|
div[data-schematype] select:focus,
|
|
div[data-schematype] textarea:focus {
|
|
outline: none !important;
|
|
border-color: var(--accent) !important;
|
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3) !important;
|
|
}
|
|
|
|
/* Hide JSON Editor action buttons we don't need */
|
|
.je-object__controls .json-editor-btn-collapse,
|
|
.je-object__controls .json-editor-btn-edit_properties,
|
|
.json-editor-btn-edit {
|
|
display: none !important;
|
|
}
|
|
|
|
.json-editor-btn-add, .json-editor-btn-delete,
|
|
.json-editor-btn-moveup, .json-editor-btn-movedown {
|
|
background: var(--bg-tertiary) !important;
|
|
color: var(--text-secondary) !important;
|
|
border: 1px solid var(--border-color) !important;
|
|
border-radius: 0.25rem !important;
|
|
padding: 0.125rem 0.375rem !important;
|
|
font-size: 0.7rem !important;
|
|
}
|
|
|
|
/* Display preview */
|
|
#displayPreview {
|
|
image-rendering: pixelated;
|
|
image-rendering: -moz-crisp-edges;
|
|
image-rendering: crisp-edges;
|
|
background: #000;
|
|
}
|
|
|
|
.preview-container {
|
|
background: repeating-conic-gradient(#1a1a2e 0% 25%, #16162a 0% 50%) 50% / 20px 20px;
|
|
border-radius: 0.5rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 200px;
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
/* Grid overlay */
|
|
#gridCanvas {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* Toggle switch */
|
|
.toggle-switch {
|
|
position: relative;
|
|
width: 2.5rem;
|
|
height: 1.25rem;
|
|
background: var(--bg-tertiary);
|
|
border-radius: 9999px;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.toggle-switch.active {
|
|
background: var(--accent);
|
|
}
|
|
|
|
.toggle-switch::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 2px;
|
|
left: 2px;
|
|
width: 1rem;
|
|
height: 1rem;
|
|
background: white;
|
|
border-radius: 9999px;
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.toggle-switch.active::after {
|
|
transform: translateX(1.25rem);
|
|
}
|
|
|
|
/* Scrollbar */
|
|
::-webkit-scrollbar { width: 6px; }
|
|
::-webkit-scrollbar-track { background: var(--bg-primary); }
|
|
::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
|
|
</style>
|
|
</head>
|
|
<body class="min-h-screen">
|
|
<!-- Header -->
|
|
<header class="border-b" style="border-color: var(--border-color); background: var(--bg-secondary);">
|
|
<div class="max-w-[1800px] mx-auto px-4 py-3 flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-3 h-3 rounded-full bg-green-500"></div>
|
|
<h1 class="text-lg font-semibold" style="color: var(--text-primary);">LEDMatrix Dev Preview</h1>
|
|
</div>
|
|
<div class="flex items-center gap-4">
|
|
<span class="text-xs" style="color: var(--text-secondary);" id="statusText">Ready</span>
|
|
<button onclick="toggleTheme()" class="px-3 py-1.5 rounded-lg text-xs font-medium"
|
|
style="background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border-color);">
|
|
Theme
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Main layout -->
|
|
<div class="max-w-[1800px] mx-auto px-4 py-4 flex gap-4" style="height: calc(100vh - 57px);">
|
|
|
|
<!-- Left panel: Plugin selection + Config -->
|
|
<div class="w-[420px] flex-shrink-0 flex flex-col gap-4 overflow-y-auto" style="max-height: 100%;">
|
|
|
|
<!-- Plugin selector -->
|
|
<div class="panel p-4">
|
|
<label class="block text-xs font-medium mb-2" style="color: var(--text-secondary);">Plugin</label>
|
|
<select id="pluginSelect" onchange="onPluginChange()"
|
|
class="w-full px-3 py-2 rounded-lg text-sm"
|
|
style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);">
|
|
<option value="">Select a plugin...</option>
|
|
</select>
|
|
<p id="pluginDescription" class="mt-2 text-xs" style="color: var(--text-secondary);"></p>
|
|
</div>
|
|
|
|
<!-- Dimensions -->
|
|
<div class="panel p-4">
|
|
<label class="block text-xs font-medium mb-2" style="color: var(--text-secondary);">Display Dimensions</label>
|
|
<div class="flex gap-2 items-center">
|
|
<input type="number" id="displayWidth" value="128" min="1" max="512"
|
|
class="w-20 px-2 py-1.5 rounded text-sm text-center"
|
|
style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);"
|
|
onchange="onConfigChange()">
|
|
<span class="text-sm" style="color: var(--text-secondary);">x</span>
|
|
<input type="number" id="displayHeight" value="32" min="1" max="256"
|
|
class="w-20 px-2 py-1.5 rounded text-sm text-center"
|
|
style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);"
|
|
onchange="onConfigChange()">
|
|
<span class="text-xs ml-2" style="color: var(--text-secondary);">px</span>
|
|
</div>
|
|
<select id="sizePreset" onchange="applySizePreset()"
|
|
class="w-full mt-2 px-2 py-1.5 rounded text-xs"
|
|
style="background: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border-color);">
|
|
<option value="">Preset sizes…</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Config form -->
|
|
<div class="panel p-4 flex-1">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<label class="text-xs font-medium" style="color: var(--text-secondary);">Configuration</label>
|
|
<button onclick="resetConfig()" class="px-2 py-1 rounded text-xs"
|
|
style="background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border-color);">
|
|
Reset
|
|
</button>
|
|
</div>
|
|
<div id="configEditor"></div>
|
|
<p id="configPlaceholder" class="text-xs italic" style="color: var(--text-secondary);">
|
|
Select a plugin to load its configuration.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Mock data -->
|
|
<details class="panel">
|
|
<summary class="px-4 py-3 cursor-pointer text-xs font-medium" style="color: var(--text-secondary);">
|
|
Mock Data (for API-dependent plugins)
|
|
</summary>
|
|
<div class="px-4 pb-4">
|
|
<textarea id="mockDataInput" rows="6" placeholder='{"cache_key": {"data": "value"}}'
|
|
class="w-full px-3 py-2 rounded-lg text-xs font-mono"
|
|
style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); resize: vertical;"
|
|
onchange="onConfigChange()"></textarea>
|
|
<p class="mt-1 text-xs" style="color: var(--text-secondary);">
|
|
JSON object with cache keys. Find keys by searching plugin's manager.py for cache_manager.set() calls.
|
|
</p>
|
|
</div>
|
|
</details>
|
|
|
|
<!-- What to render: the plugin's screen, or its block of the Vegas strip -->
|
|
<div class="flex items-center gap-2">
|
|
<label for="viewSelect" class="text-xs whitespace-nowrap" style="color: var(--text-secondary);">View</label>
|
|
<select id="viewSelect" onchange="onConfigChange()"
|
|
class="flex-1 px-2 py-1.5 rounded-lg text-xs"
|
|
style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);"
|
|
title="Vegas strip: the plugin's block of the Vegas ticker, laid out as the ticker lays it out">
|
|
<option value="">Display</option>
|
|
<option value="live">Vegas strip (live elements)</option>
|
|
<option value="plain">Vegas strip (plain Vegas content)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Render buttons -->
|
|
<div class="flex gap-2">
|
|
<button onclick="renderPlugin()" id="renderBtn"
|
|
class="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
|
style="background: var(--accent);">
|
|
Render
|
|
</button>
|
|
<button onclick="renderAllSizes()" id="renderAllBtn" title="Render at every harness test size"
|
|
class="px-4 py-2.5 rounded-lg text-sm font-medium"
|
|
style="background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border-color);">
|
|
All Sizes
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right panel: Display preview -->
|
|
<div class="flex-1 flex flex-col gap-4 min-w-0">
|
|
<!-- Preview -->
|
|
<div class="panel p-4 flex-1 flex flex-col">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<span class="text-xs font-medium" style="color: var(--text-secondary);">Display Preview</span>
|
|
<div class="flex items-center gap-4">
|
|
<span class="text-xs" style="color: var(--text-secondary);" id="renderTimeText"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Preview image -->
|
|
<div class="flex-1 flex items-center justify-center">
|
|
<div class="preview-container w-full" id="previewWrapper">
|
|
<div style="position: relative; display: inline-block;" id="previewFrame">
|
|
<img id="displayPreview" alt="Plugin display preview"
|
|
style="display: none; border: 1px solid var(--border-color);">
|
|
<canvas id="gridCanvas" style="display: none;"></canvas>
|
|
<p id="previewPlaceholder" class="text-sm" style="color: var(--text-secondary);">
|
|
Select a plugin and click Render to preview.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Controls -->
|
|
<div class="flex items-center gap-6 mt-4 pt-3" style="border-top: 1px solid var(--border-color);">
|
|
<!-- Zoom -->
|
|
<div class="flex items-center gap-2 flex-1">
|
|
<label class="text-xs whitespace-nowrap" style="color: var(--text-secondary);">Zoom</label>
|
|
<input type="range" id="zoomSlider" min="1" max="16" value="8" step="1"
|
|
oninput="updateZoom()" class="flex-1" style="accent-color: var(--accent);">
|
|
<span class="text-xs w-8 text-right" style="color: var(--text-primary);" id="zoomLabel">8x</span>
|
|
</div>
|
|
|
|
<!-- Grid toggle -->
|
|
<div class="flex items-center gap-2">
|
|
<label class="text-xs" for="gridToggle" style="color: var(--text-secondary);">Grid</label>
|
|
<button role="switch" aria-checked="false" aria-label="Toggle grid overlay"
|
|
class="toggle-switch" id="gridToggle"
|
|
onclick="toggleGrid()"
|
|
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleGrid();}"></button>
|
|
</div>
|
|
|
|
<!-- Auto-refresh toggle -->
|
|
<div class="flex items-center gap-2">
|
|
<label class="text-xs" for="autoRefreshToggle" style="color: var(--text-secondary);">Auto</label>
|
|
<button role="switch" aria-checked="true" aria-label="Toggle auto-refresh"
|
|
class="toggle-switch active" id="autoRefreshToggle"
|
|
onclick="toggleAutoRefresh()"
|
|
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleAutoRefresh();}"></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Warnings/Errors -->
|
|
<div id="messagesPanel" class="panel p-3 hidden">
|
|
<div id="messagesList" class="text-xs font-mono space-y-1"></div>
|
|
</div>
|
|
|
|
<!-- Multi-size gallery -->
|
|
<div id="galleryPanel" class="panel p-4 hidden">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<span class="text-xs font-medium" style="color: var(--text-secondary);">All Sizes</span>
|
|
<span class="text-xs" style="color: var(--text-secondary);" id="galleryStatus"></span>
|
|
</div>
|
|
<div id="galleryGrid" class="flex flex-wrap gap-4 items-start"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ---------- State ----------
|
|
let jsonEditor = null;
|
|
let currentPluginId = null;
|
|
let autoRefresh = true;
|
|
let showGrid = false;
|
|
let debounceTimer = null;
|
|
let currentImageWidth = 128;
|
|
let currentImageHeight = 32;
|
|
|
|
// ---------- Init ----------
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
// Load theme
|
|
const saved = localStorage.getItem('devPreviewTheme');
|
|
if (saved) document.documentElement.dataset.theme = saved;
|
|
|
|
// Load plugins
|
|
const res = await fetch('/api/plugins');
|
|
const data = await res.json();
|
|
const select = document.getElementById('pluginSelect');
|
|
data.plugins.forEach(p => {
|
|
const opt = document.createElement('option');
|
|
opt.value = p.id;
|
|
opt.textContent = `${p.name} (${p.id})`;
|
|
select.appendChild(opt);
|
|
});
|
|
|
|
// Load harness size presets
|
|
try {
|
|
const sizesRes = await fetch('/api/sizes');
|
|
const sizesData = await sizesRes.json();
|
|
const preset = document.getElementById('sizePreset');
|
|
(sizesData.sizes || []).forEach(([w, h]) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = `${w}x${h}`;
|
|
opt.textContent = `${w} x ${h}`;
|
|
preset.appendChild(opt);
|
|
});
|
|
} catch (e) { /* presets are a convenience; ignore */ }
|
|
});
|
|
|
|
function applySizePreset() {
|
|
const value = document.getElementById('sizePreset').value;
|
|
if (!value) return;
|
|
const [w, h] = value.split('x');
|
|
document.getElementById('displayWidth').value = w;
|
|
document.getElementById('displayHeight').value = h;
|
|
onConfigChange();
|
|
}
|
|
|
|
// ---------- Plugin selection ----------
|
|
async function onPluginChange() {
|
|
const pluginId = document.getElementById('pluginSelect').value;
|
|
if (!pluginId) {
|
|
if (jsonEditor) { jsonEditor.destroy(); jsonEditor = null; }
|
|
document.getElementById('configPlaceholder').style.display = 'block';
|
|
document.getElementById('pluginDescription').textContent = '';
|
|
currentPluginId = null;
|
|
return;
|
|
}
|
|
currentPluginId = pluginId;
|
|
|
|
// Load schema and defaults
|
|
const [schemaRes, defaultsRes, pluginsRes] = await Promise.all([
|
|
fetch(`/api/plugins/${pluginId}/schema`),
|
|
fetch(`/api/plugins/${pluginId}/defaults`),
|
|
fetch('/api/plugins'),
|
|
]);
|
|
|
|
const schemaData = await schemaRes.json();
|
|
const defaultsData = await defaultsRes.json();
|
|
const pluginsData = await pluginsRes.json();
|
|
|
|
// Show description
|
|
const plugin = pluginsData.plugins.find(p => p.id === pluginId);
|
|
document.getElementById('pluginDescription').textContent =
|
|
plugin ? plugin.description : '';
|
|
|
|
// Build config editor
|
|
document.getElementById('configPlaceholder').style.display = 'none';
|
|
if (jsonEditor) jsonEditor.destroy();
|
|
|
|
const schema = schemaData.schema || { type: 'object', properties: {} };
|
|
|
|
// Remove properties we don't want in the dev form
|
|
const excluded = ['enabled', 'update_interval', 'display_duration'];
|
|
excluded.forEach(k => { if (schema.properties) delete schema.properties[k]; });
|
|
|
|
jsonEditor = new JSONEditor(document.getElementById('configEditor'), {
|
|
schema: schema,
|
|
startval: defaultsData.defaults || {},
|
|
theme: 'barebones',
|
|
iconlib: null,
|
|
disable_collapse: true,
|
|
disable_edit_json: true,
|
|
disable_properties: true,
|
|
disable_array_reorder: false,
|
|
no_additional_properties: true,
|
|
show_errors: 'change',
|
|
compact: true,
|
|
});
|
|
|
|
jsonEditor.on('change', () => {
|
|
if (autoRefresh) onConfigChange();
|
|
});
|
|
|
|
// Auto-render on plugin change
|
|
if (autoRefresh) renderPlugin();
|
|
}
|
|
|
|
// ---------- Config change (debounced) ----------
|
|
function onConfigChange() {
|
|
if (!autoRefresh) return;
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(renderPlugin, 500);
|
|
}
|
|
|
|
function resetConfig() {
|
|
if (!currentPluginId) return;
|
|
onPluginChange(); // Reload defaults
|
|
}
|
|
|
|
// ---------- Render ----------
|
|
async function renderPlugin() {
|
|
if (!currentPluginId) return;
|
|
|
|
const btn = document.getElementById('renderBtn');
|
|
const statusText = document.getElementById('statusText');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Rendering...';
|
|
statusText.textContent = 'Rendering...';
|
|
|
|
const config = jsonEditor ? jsonEditor.getValue() : {};
|
|
config.enabled = true;
|
|
|
|
// Parse mock data
|
|
let mockData = {};
|
|
const mockInput = document.getElementById('mockDataInput').value.trim();
|
|
if (mockInput) {
|
|
try { mockData = JSON.parse(mockInput); }
|
|
catch (e) { showMessages([], [`Mock data JSON error: ${e.message}`]); }
|
|
}
|
|
|
|
const width = parseInt(document.getElementById('displayWidth').value) || 128;
|
|
const height = parseInt(document.getElementById('displayHeight').value) || 32;
|
|
|
|
try {
|
|
const res = await fetch('/api/render', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
plugin_id: currentPluginId,
|
|
config: config,
|
|
width: width,
|
|
height: height,
|
|
mock_data: mockData,
|
|
vegas: document.getElementById('viewSelect').value || null,
|
|
}),
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (data.error) {
|
|
showMessages([data.error], []);
|
|
statusText.textContent = 'Error';
|
|
return;
|
|
}
|
|
|
|
// Update preview
|
|
const img = document.getElementById('displayPreview');
|
|
img.src = data.image;
|
|
img.style.display = 'block';
|
|
document.getElementById('previewPlaceholder').style.display = 'none';
|
|
currentImageWidth = data.width;
|
|
currentImageHeight = data.height;
|
|
updateZoom();
|
|
|
|
// Show render time
|
|
const live = data.live_elements;
|
|
document.getElementById('renderTimeText').textContent =
|
|
`${data.render_time_ms}ms` + (live ? ` · ${data.width}px strip, ` +
|
|
`${live.length} live element(s)` +
|
|
(live.length ? `: ${live.map(e => e.key).join(', ')}` : '') : '');
|
|
|
|
// Show warnings/errors
|
|
showMessages(data.errors || [], data.warnings || []);
|
|
statusText.textContent = data.errors?.length ? 'Errors' : 'Rendered';
|
|
|
|
} catch (e) {
|
|
showMessages([`Network error: ${e.message}`], []);
|
|
statusText.textContent = 'Error';
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Render';
|
|
}
|
|
}
|
|
|
|
// ---------- Multi-size gallery ----------
|
|
async function renderAllSizes() {
|
|
if (!currentPluginId) return;
|
|
|
|
const btn = document.getElementById('renderAllBtn');
|
|
const panel = document.getElementById('galleryPanel');
|
|
const grid = document.getElementById('galleryGrid');
|
|
const status = document.getElementById('galleryStatus');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Rendering…';
|
|
panel.classList.remove('hidden');
|
|
grid.innerHTML = '';
|
|
status.textContent = 'Rendering at all harness sizes…';
|
|
|
|
const config = jsonEditor ? jsonEditor.getValue() : {};
|
|
config.enabled = true;
|
|
let mockData = {};
|
|
const mockInput = document.getElementById('mockDataInput').value.trim();
|
|
if (mockInput) {
|
|
try { mockData = JSON.parse(mockInput); }
|
|
catch (e) { showMessages([], [`Mock data JSON error: ${e.message}`]); }
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/api/render-matrix', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
plugin_id: currentPluginId,
|
|
config: config,
|
|
mock_data: mockData,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (data.error) {
|
|
status.textContent = data.error;
|
|
return;
|
|
}
|
|
|
|
let failures = 0;
|
|
(data.results || []).forEach(r => {
|
|
const cell = document.createElement('div');
|
|
cell.style.cssText = 'display:flex;flex-direction:column;gap:4px;';
|
|
const failed = (r.errors || []).length > 0 || !r.image;
|
|
if (failed) failures++;
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'text-xs font-mono';
|
|
label.style.color = failed ? '#f87171' : 'var(--text-secondary)';
|
|
label.textContent = `${r.width}x${r.height} · ${r.render_time_ms}ms`;
|
|
cell.appendChild(label);
|
|
|
|
if (r.image) {
|
|
const img = document.createElement('img');
|
|
img.src = r.image;
|
|
// Small panels get 2x zoom so they stay legible in the grid
|
|
const zoom = r.height >= 128 ? 1 : 2;
|
|
img.style.cssText =
|
|
`image-rendering: pixelated; width:${r.width * zoom}px; ` +
|
|
`height:${r.height * zoom}px; ` +
|
|
`border:1px solid ${failed ? '#f87171' : 'var(--border-color)'};`;
|
|
cell.appendChild(img);
|
|
}
|
|
if (failed) {
|
|
const err = document.createElement('span');
|
|
err.className = 'text-xs font-mono';
|
|
err.style.color = '#f87171';
|
|
err.textContent = (r.errors || ['render failed']).join('; ');
|
|
cell.appendChild(err);
|
|
}
|
|
grid.appendChild(cell);
|
|
});
|
|
status.textContent = failures
|
|
? `${failures} size(s) failed`
|
|
: `${(data.results || []).length} sizes rendered`;
|
|
} catch (e) {
|
|
status.textContent = `Network error: ${e.message}`;
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'All Sizes';
|
|
}
|
|
}
|
|
|
|
// ---------- Zoom ----------
|
|
function updateZoom() {
|
|
const zoom = parseInt(document.getElementById('zoomSlider').value);
|
|
document.getElementById('zoomLabel').textContent = `${zoom}x`;
|
|
|
|
const img = document.getElementById('displayPreview');
|
|
if (img.style.display !== 'none') {
|
|
img.style.width = `${currentImageWidth * zoom}px`;
|
|
img.style.height = `${currentImageHeight * zoom}px`;
|
|
}
|
|
updateGrid();
|
|
}
|
|
|
|
// ---------- Grid overlay ----------
|
|
function toggleGrid() {
|
|
showGrid = !showGrid;
|
|
const btn = document.getElementById('gridToggle');
|
|
btn.classList.toggle('active', showGrid);
|
|
btn.setAttribute('aria-checked', showGrid ? 'true' : 'false');
|
|
updateGrid();
|
|
}
|
|
|
|
function updateGrid() {
|
|
const canvas = document.getElementById('gridCanvas');
|
|
const img = document.getElementById('displayPreview');
|
|
|
|
if (!showGrid || img.style.display === 'none') {
|
|
canvas.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const zoom = parseInt(document.getElementById('zoomSlider').value);
|
|
const w = currentImageWidth * zoom;
|
|
const h = currentImageHeight * zoom;
|
|
|
|
canvas.width = w;
|
|
canvas.height = h;
|
|
canvas.style.display = 'block';
|
|
canvas.style.width = `${w}px`;
|
|
canvas.style.height = `${h}px`;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, w, h);
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
|
|
ctx.lineWidth = 0.5;
|
|
|
|
// Vertical lines
|
|
for (let x = 0; x <= currentImageWidth; x++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x * zoom, 0);
|
|
ctx.lineTo(x * zoom, h);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Horizontal lines
|
|
for (let y = 0; y <= currentImageHeight; y++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y * zoom);
|
|
ctx.lineTo(w, y * zoom);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
// ---------- Auto-refresh toggle ----------
|
|
function toggleAutoRefresh() {
|
|
autoRefresh = !autoRefresh;
|
|
const btn = document.getElementById('autoRefreshToggle');
|
|
btn.classList.toggle('active', autoRefresh);
|
|
btn.setAttribute('aria-checked', autoRefresh ? 'true' : 'false');
|
|
}
|
|
|
|
// ---------- Theme ----------
|
|
function toggleTheme() {
|
|
const html = document.documentElement;
|
|
const current = html.dataset.theme || 'dark';
|
|
const next = current === 'dark' ? 'light' : 'dark';
|
|
html.dataset.theme = next;
|
|
localStorage.setItem('devPreviewTheme', next);
|
|
}
|
|
|
|
// ---------- Messages ----------
|
|
function showMessages(errors, warnings) {
|
|
const panel = document.getElementById('messagesPanel');
|
|
const list = document.getElementById('messagesList');
|
|
list.innerHTML = '';
|
|
|
|
if (!errors.length && !warnings.length) {
|
|
panel.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
panel.classList.remove('hidden');
|
|
errors.forEach(msg => {
|
|
const div = document.createElement('div');
|
|
div.className = 'text-red-400';
|
|
div.textContent = `Error: ${msg}`;
|
|
list.appendChild(div);
|
|
});
|
|
warnings.forEach(msg => {
|
|
const div = document.createElement('div');
|
|
div.className = 'text-yellow-400';
|
|
div.textContent = `Warning: ${msg}`;
|
|
list.appendChild(div);
|
|
});
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|