mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-23 03:18:15 +00:00
Three review findings in the composer's JavaScript, all confirmed against the
code.
Stroke widths did not scale with SCALE
--------------------------------------
_drawElement scales all geometry by `s`, but left ctx.lineWidth in canvas
pixels, so at SCALE>1 every outline rendered thinner than one LED pixel and
the preview stopped matching the panel it is previewing. Fixed for rectangle,
ellipse, arc, rounded_rectangle, line, divider and progress_bar. Ellipse and
arc also inset their radii by half the scaled width -- a stroke straddles its
path, so without the inset the outline spills outside the element's bounds.
The gauge branch already did this; the rest now match it.
Selection handles and the grid stay in canvas pixels deliberately: they are
editor chrome, not LED geometry, and live in other functions.
`line` ignored anchors
----------------------
_drawElement resolves ax/ay for every element, but the line branch drew raw
el.x0/el.y0/el.x1/el.y1. Setting xAnchor or yAnchor moved every other element
type and left lines where they were. getBoundingBox had the same omission, so
even once a line moved its hit box would not have. Both now translate by
(ax - el.x0, ay - el.y0); ax resolves from el.x0 for a line, so that is
exactly the anchor offset.
Four state mutations skipped _snapshot
--------------------------------------
_snapshot serialises metadata and currentPreset and is the only caller of
_debouncedAutosave. onBgColorChange, setCustomSize, changePreset and
applyPresetLabel each changed exactly those values without calling it, so the
background colour and the canvas size were lost on reload and could not be
undone. Same defect already fixed in onColorChange.
The review named three; applyPresetLabel has it too -- it is the branch that
handles sizes absent from DISPLAY_PRESETS.
Snapshotting is on the user-driven path only. _applyState and loadTemplate
drive these with {silent: true} while restoring, and snapshotting there would
push restore steps onto the undo stack and re-autosave the state just loaded.
Tests
-----
No JS runner here, so test_composer_js_contracts.py asserts on the parse tree
via tree-sitter: both files parse, no bare `ctx.lineWidth = 1` inside
_drawElement, the line branch and its bounding box carry the anchor offset,
each of the five mutations snapshots, and the two preset paths keep their
!opts.silent guard ahead of the snapshot.
9 of its 11 checks fail against the previous JS. Full suite 3978 passed, the
one failure being test_install_lowmem (pre-existing, awaiting #492).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
805 lines
32 KiB
JavaScript
805 lines
32 KiB
JavaScript
/**
|
||
* ComposerCanvas — stateless LED matrix canvas renderer.
|
||
*
|
||
* Coordinate system: LED pixels (integers). All drawing multiplies by SCALE.
|
||
* PIL draw.text(x,y) is top-left; canvas fillText(x,y) is baseline.
|
||
* → Canvas text cy = (actualY + fontSizePx) * SCALE
|
||
*
|
||
* Anchors: element x/y are offsets from their anchor point:
|
||
* xAnchor=null/'left' → x is fixed offset from left
|
||
* xAnchor='center' → x is offset from width/2
|
||
* xAnchor='right' → x is offset inward from right edge
|
||
* yAnchor follows the same pattern with 'top'/'middle'/'bottom'
|
||
*
|
||
* Breakpoints: elements with minWidth > currentMatrixW are rendered at 25% opacity.
|
||
*
|
||
* Resize handles: drawn on selected rectangles; 8 handles (corners + edge mids).
|
||
*/
|
||
window.ComposerCanvas = (() => {
|
||
'use strict';
|
||
|
||
let _canvas = null;
|
||
let _ctx = null;
|
||
let _showGrid = true;
|
||
|
||
//: Element types the canvas draws resize handles for. Exported because the
|
||
//: editor has to gate its resize and hover behaviour on exactly this list --
|
||
//: the two had drifted, so handles appeared on five shapes that could not
|
||
//: actually be resized.
|
||
const RESIZABLE_TYPES = ['rectangle', 'rounded_rectangle', 'ellipse', 'arc',
|
||
'gauge', 'sparkline'];
|
||
|
||
const DISPLAY_PRESETS = [
|
||
{ label: '64×32', w: 64, h: 32 },
|
||
{ label: '128×32', w: 128, h: 32 },
|
||
{ label: '128×64', w: 128, h: 64 },
|
||
{ label: '256×32', w: 256, h: 32 },
|
||
{ label: '256×64', w: 256, h: 64 },
|
||
];
|
||
|
||
const FONT_MAP = {
|
||
press_start: { family: "'PressStart2P', monospace", sizePx: 8, charW: 8 },
|
||
four_by_six: { family: 'monospace', sizePx: 6, charW: 4 },
|
||
five_by_seven: { family: 'monospace', sizePx: 7, charW: 5 },
|
||
};
|
||
|
||
const ELEMENT_DEFAULTS = {
|
||
text: {
|
||
text: 'Hello', font: 'press_start',
|
||
r: 255, g: 255, b: 255,
|
||
text2: '', lineSpacing: 2, textAlign: 'left',
|
||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||
},
|
||
dynamic_text: {
|
||
binding: { source: 'config', key: '', format: null },
|
||
font: 'press_start', textAlign: 'left',
|
||
r: 255, g: 200, b: 100,
|
||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||
},
|
||
clock: {
|
||
format: '%H:%M', font: 'press_start',
|
||
r: 100, g: 255, b: 100,
|
||
format2: '', lineSpacing: 2, textAlign: 'left',
|
||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||
},
|
||
rectangle: {
|
||
width: 20, height: 8,
|
||
fillR: 0, fillG: 0, fillB: 128, hasFill: true,
|
||
outR: 255, outG: 255, outB: 255, hasOutline: true,
|
||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||
},
|
||
ellipse: {
|
||
width: 24, height: 12,
|
||
fillR: 0, fillG: 100, fillB: 200, hasFill: true,
|
||
outR: 100, outG: 180, outB: 255, hasOutline: true,
|
||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||
},
|
||
arc: {
|
||
width: 24, height: 24,
|
||
startAngle: 0, endAngle: 270, lineWidth: 2,
|
||
r: 255, g: 200, b: 0,
|
||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||
},
|
||
pixel: {
|
||
r: 255, g: 255, b: 255,
|
||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||
},
|
||
rounded_rectangle: {
|
||
width: 24, height: 10, borderRadius: 3,
|
||
fillR: 0, fillG: 80, fillB: 180, hasFill: true,
|
||
outR: 120, outG: 180, outB: 255, hasOutline: true,
|
||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||
},
|
||
line: {
|
||
x0: 0, y0: 16, x1: 63, y1: 16,
|
||
r: 180, g: 180, b: 180, lineWidth: 1,
|
||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||
},
|
||
divider: {
|
||
orientation: 'horizontal', y: 16, x: 64,
|
||
r: 100, g: 100, b: 100,
|
||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||
},
|
||
progress_bar: {
|
||
barWidth: 60, barHeight: 6,
|
||
binding: { source: 'config', key: '', format: null },
|
||
r: 80, g: 200, b: 80,
|
||
bgR: 30, bgG: 30, bgB: 30, hasBg: true,
|
||
outR: 100, outG: 100, outB: 100, hasOutline: true,
|
||
previewPct: 65,
|
||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||
},
|
||
countdown: {
|
||
binding: { source: 'config', key: '', format: null },
|
||
countdownFormat: 'dh',
|
||
font: 'four_by_six', textAlign: 'left',
|
||
r: 255, g: 180, b: 0,
|
||
previewText: '42d 3h',
|
||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||
},
|
||
marquee: {
|
||
text: 'Scrolling text', font: 'press_start',
|
||
r: 255, g: 255, b: 255,
|
||
scrollSpeed: 1, gap: 16, direction: 'left',
|
||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||
},
|
||
section: {
|
||
label: 'Section',
|
||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||
},
|
||
pips: {
|
||
count: 5, filled: 3, pipSize: 4, pipSpacing: 2,
|
||
r: 255, g: 200, b: 0,
|
||
emptyR: 50, emptyG: 50, emptyB: 50, showEmpty: true,
|
||
binding: { source: 'config', key: '', format: null },
|
||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||
},
|
||
sparkline: {
|
||
width: 40, height: 12,
|
||
barCount: 8, barSpacing: 1,
|
||
r: 80, g: 200, b: 120,
|
||
bgR: 30, bgG: 30, bgB: 30, hasBg: false,
|
||
binding: { source: 'config', key: '', format: null },
|
||
previewData: '0.3,0.6,0.4,0.8,0.5,0.9,0.7,0.85',
|
||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||
},
|
||
gauge: {
|
||
width: 32, height: 32,
|
||
startAngle: 135, endAngle: 45, lineWidth: 3,
|
||
binding: { source: 'config', key: '', format: null },
|
||
r: 80, g: 220, b: 80,
|
||
trackR: 40, trackG: 40, trackB: 40, hasTrack: true,
|
||
showLabel: true, font: 'four_by_six', labelR: 200, labelG: 200, labelB: 200,
|
||
previewPct: 65,
|
||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||
},
|
||
};
|
||
|
||
// ── Anchor resolution ────────────────────────────────────────────────
|
||
function resolveAnchor(val, anchor, dim) {
|
||
if (!anchor || anchor === 'left' || anchor === 'top') return val;
|
||
if (anchor === 'center' || anchor === 'middle') return Math.floor(dim / 2) + val;
|
||
if (anchor === 'right' || anchor === 'bottom') return dim - val;
|
||
return val;
|
||
}
|
||
|
||
function computeActualPos(el, matrixW, matrixH) {
|
||
const ax = resolveAnchor(el.x ?? el.x0 ?? 0, el.xAnchor, matrixW);
|
||
const ay = resolveAnchor(el.y ?? el.y0 ?? 0, el.yAnchor, matrixH);
|
||
return { x: ax, y: ay };
|
||
}
|
||
|
||
// ── Bounding box (LED pixel space) ──────────────────────────────────
|
||
function getBoundingBox(el, matrixW, matrixH) {
|
||
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
|
||
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||
|
||
switch (el.type) {
|
||
case 'text': {
|
||
const t1 = el.text || '', t2 = el.text2 || '';
|
||
const w = Math.max(t1.length, t2.length) * finfo.charW;
|
||
const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx;
|
||
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||
return { x: bx, y: ay, w, h };
|
||
}
|
||
case 'dynamic_text': {
|
||
const key = el.binding?.key || '?';
|
||
const w = (`{${key}}`).length * finfo.charW;
|
||
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||
return { x: bx, y: ay, w, h: finfo.sizePx };
|
||
}
|
||
case 'clock': {
|
||
const t1 = el.format || '%H:%M', t2 = el.format2 || '';
|
||
const w = Math.max(t1.length, t2.length) * finfo.charW;
|
||
const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx;
|
||
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||
return { x: bx, y: ay, w, h };
|
||
}
|
||
case 'countdown': {
|
||
const pt = el.previewText || '--d --h';
|
||
const w = pt.length * finfo.charW;
|
||
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||
return { x: bx, y: ay, w, h: finfo.sizePx };
|
||
}
|
||
case 'rectangle':
|
||
case 'rounded_rectangle':
|
||
case 'ellipse':
|
||
case 'arc':
|
||
return { x: ax, y: ay, w: el.width, h: el.height };
|
||
case 'pixel':
|
||
return { x: ax, y: ay, w: 1, h: 1 };
|
||
case 'line': {
|
||
// Same anchor offset the draw path applies, or the hit box sits where
|
||
// the line used to be.
|
||
const dx = ax - el.x0, dy = ay - el.y0;
|
||
return {
|
||
x: Math.min(el.x0, el.x1) + dx, y: Math.min(el.y0, el.y1) + dy,
|
||
w: Math.max(1, Math.abs(el.x1 - el.x0)),
|
||
h: Math.max(1, Math.abs(el.y1 - el.y0)),
|
||
};
|
||
}
|
||
case 'divider':
|
||
return el.orientation === 'horizontal'
|
||
? { x: 0, y: ay, w: matrixW, h: 1 }
|
||
: { x: ax, y: 0, w: 1, h: matrixH };
|
||
case 'progress_bar':
|
||
return { x: ax, y: ay, w: el.barWidth ?? 60, h: el.barHeight ?? 6 };
|
||
case 'marquee': {
|
||
const mfinfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||
return { x: 0, y: ay, w: matrixW, h: mfinfo.sizePx };
|
||
}
|
||
case 'gauge':
|
||
return { x: ax, y: ay, w: el.width ?? 32, h: el.height ?? 32 };
|
||
case 'sparkline':
|
||
return { x: ax, y: ay, w: el.width ?? 40, h: el.height ?? 12 };
|
||
case 'pips': {
|
||
const pc = el.count ?? 5, ps = el.pipSize ?? 4, pg = el.pipSpacing ?? 2;
|
||
return { x: ax, y: ay, w: pc * ps + (pc - 1) * pg, h: ps };
|
||
}
|
||
case 'section': {
|
||
// Was 0x0, so the element was unselectable except through the 3px
|
||
// hit-test padding and drew nothing at all -- a user adding one from
|
||
// the palette saw an empty canvas.
|
||
// Same font resolution as the draw case below, or the box will not
|
||
// match the glyphs: getBoundingBox's shared `finfo` falls back to
|
||
// press_start, and a section has no font of its own.
|
||
const sinfo = FONT_MAP[el.font] || FONT_MAP.four_by_six;
|
||
const label = el.label || 'Section';
|
||
return { x: ax, y: ay, w: label.length * sinfo.charW, h: sinfo.sizePx };
|
||
}
|
||
default:
|
||
return { x: ax, y: ay, w: 4, h: 4 };
|
||
}
|
||
}
|
||
|
||
// ── Resize handle support ─────────────────────────────────────────────
|
||
// Returns 8 handle points for a rectangle in LED pixel space
|
||
function _getRectHandles(el, matrixW, matrixH) {
|
||
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
|
||
const w = el.width, h = el.height;
|
||
const cx = ax + w / 2, cy = ay + h / 2;
|
||
return {
|
||
nw: { x: ax, y: ay },
|
||
n: { x: cx, y: ay },
|
||
ne: { x: ax + w, y: ay },
|
||
w: { x: ax, y: cy },
|
||
e: { x: ax + w, y: cy },
|
||
sw: { x: ax, y: ay + h },
|
||
s: { x: cx, y: ay + h },
|
||
se: { x: ax + w, y: ay + h },
|
||
};
|
||
}
|
||
|
||
// Returns the handle direction under LED-space point (lx, ly), or null
|
||
function getResizeHandle(el, lx, ly, matrixW, matrixH) {
|
||
if (!RESIZABLE_TYPES.includes(el.type)) return null;
|
||
const handles = _getRectHandles(el, matrixW, matrixH);
|
||
const PAD = 4;
|
||
for (const [dir, pt] of Object.entries(handles)) {
|
||
if (Math.abs(lx - pt.x) <= PAD && Math.abs(ly - pt.y) <= PAD) return dir;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const _HANDLE_CURSORS = {
|
||
nw: 'nw-resize', n: 'n-resize', ne: 'ne-resize',
|
||
w: 'w-resize', e: 'e-resize',
|
||
sw: 'sw-resize', s: 's-resize', se: 'se-resize',
|
||
};
|
||
function getCursorForHandle(handle) {
|
||
return _HANDLE_CURSORS[handle] || 'crosshair';
|
||
}
|
||
|
||
// ── Hit test ─────────────────────────────────────────────────────────
|
||
function hitTest(el, lx, ly, matrixW, matrixH) {
|
||
const PAD = 3;
|
||
const bb = getBoundingBox(el, matrixW, matrixH);
|
||
return (
|
||
lx >= bb.x - PAD && lx <= bb.x + bb.w + PAD &&
|
||
ly >= bb.y - PAD && ly <= bb.y + bb.h + PAD
|
||
);
|
||
}
|
||
|
||
// ── Draw a single element ─────────────────────────────────────────────
|
||
function _drawElement(ctx, el, SCALE, matrixW, matrixH, opts = {}) {
|
||
const s = SCALE;
|
||
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
|
||
const belowBreakpoint = el.minWidth > 0 && matrixW < el.minWidth;
|
||
const hidden = el.visible === false;
|
||
|
||
ctx.save();
|
||
if (hidden) ctx.globalAlpha = 0.12;
|
||
else if (belowBreakpoint) ctx.globalAlpha = 0.25;
|
||
|
||
// Blink animation: when blinkOff, fully hide blinking elements
|
||
if (el.blink) {
|
||
if (opts.blinkOff) { ctx.restore(); return; }
|
||
ctx.globalAlpha *= 0.55;
|
||
}
|
||
|
||
// Helper: compute draw X for text alignment
|
||
const _textX = (text, finfo) => {
|
||
const tw = text.length * finfo.charW * s;
|
||
if (el.textAlign === 'center') return ax * s - tw / 2;
|
||
if (el.textAlign === 'right') return ax * s - tw;
|
||
return ax * s;
|
||
};
|
||
|
||
try {
|
||
switch (el.type) {
|
||
case 'section': {
|
||
// A design-time label: it marks a region for the author and is not
|
||
// emitted into the generated plugin. There was no case here at all,
|
||
// so adding "Section Label" from the palette drew nothing and left
|
||
// the user with an apparently broken control.
|
||
const sfinfo = FONT_MAP[el.font] || FONT_MAP.four_by_six;
|
||
ctx.font = `${sfinfo.sizePx * s}px ${sfinfo.family}`;
|
||
ctx.fillStyle = `rgba(${el.r ?? 120},${el.g ?? 120},${el.b ?? 120},0.85)`;
|
||
ctx.textBaseline = 'top';
|
||
ctx.fillText(el.label || 'Section', ax * s, ay * s);
|
||
break;
|
||
}
|
||
case 'text':
|
||
case 'dynamic_text':
|
||
case 'clock': {
|
||
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||
const key = el.binding?.key || '?';
|
||
const pv = opts.previewValues?.[key];
|
||
// Substitute {variable} tokens in text using previewValues
|
||
const _subVars = str => (str || '').replace(/\{(\w+)\}/g, (_, k) => {
|
||
const v = opts.previewValues?.[k];
|
||
return v !== undefined && v !== '' ? String(v) : `{${k}}`;
|
||
});
|
||
const displayText =
|
||
el.type === 'text' ? _subVars(el.text || '')
|
||
: el.type === 'clock' ? (el.format || '%H:%M')
|
||
: (pv !== undefined && pv !== '' ? String(pv) : `{${key}}`);
|
||
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
|
||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||
ctx.fillText(displayText, _textX(displayText, finfo), (ay + finfo.sizePx) * s);
|
||
// Second line (text and clock)
|
||
if (el.type === 'text' && el.text2) {
|
||
const t2 = _subVars(el.text2);
|
||
const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2);
|
||
ctx.fillText(t2, _textX(t2, finfo), (y2 + finfo.sizePx) * s);
|
||
}
|
||
if (el.type === 'clock' && el.format2) {
|
||
const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2);
|
||
ctx.fillText(el.format2, _textX(el.format2, finfo), (y2 + finfo.sizePx) * s);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'countdown': {
|
||
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||
const t = el.previewText || '--d --h';
|
||
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
|
||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||
ctx.fillText(t, _textX(t, finfo), (ay + finfo.sizePx) * s);
|
||
break;
|
||
}
|
||
|
||
case 'rectangle': {
|
||
const rx = ax * s, ry = ay * s;
|
||
const rw = el.width * s, rh = el.height * s;
|
||
if (el.hasFill) {
|
||
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
|
||
ctx.fillRect(rx, ry, rw, rh);
|
||
}
|
||
if (el.hasOutline) {
|
||
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
|
||
// 1 LED pixel, not 1 canvas pixel: at SCALE>1 an unscaled stroke
|
||
// renders thinner than the geometry it outlines.
|
||
ctx.lineWidth = s;
|
||
ctx.strokeRect(rx, ry, rw, rh);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'ellipse': {
|
||
const cx = (ax + el.width / 2) * s;
|
||
const cy = (ay + el.height / 2) * s;
|
||
const lwPx = s; // 1 LED pixel
|
||
// Inset by half the stroke, which straddles the path, so the outline
|
||
// stays within the element's bounds.
|
||
const rx = Math.max(0, (el.width / 2) * s - (el.hasOutline ? lwPx / 2 : 0));
|
||
const ry = Math.max(0, (el.height / 2) * s - (el.hasOutline ? lwPx / 2 : 0));
|
||
ctx.beginPath();
|
||
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||
if (el.hasFill) {
|
||
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
|
||
ctx.fill();
|
||
}
|
||
if (el.hasOutline) {
|
||
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
|
||
ctx.lineWidth = lwPx;
|
||
ctx.stroke();
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'arc': {
|
||
const cx = (ax + el.width / 2) * s;
|
||
const cy = (ay + el.height / 2) * s;
|
||
const lwPx = Math.max(1, el.lineWidth || 2) * s;
|
||
const rx = Math.max(0, (el.width / 2) * s - lwPx / 2);
|
||
const ry = Math.max(0, (el.height / 2) * s - lwPx / 2);
|
||
// PIL: 0°=right, clockwise. Canvas: same with anticlockwise=false
|
||
const startRad = (el.startAngle ?? 0) * Math.PI / 180;
|
||
const endRad = (el.endAngle ?? 270) * Math.PI / 180;
|
||
ctx.beginPath();
|
||
ctx.ellipse(cx, cy, rx, ry, 0, startRad, endRad, false);
|
||
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||
ctx.lineWidth = lwPx;
|
||
ctx.stroke();
|
||
break;
|
||
}
|
||
|
||
case 'pixel': {
|
||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||
ctx.fillRect(ax * s, ay * s, s, s);
|
||
break;
|
||
}
|
||
|
||
case 'rounded_rectangle': {
|
||
const rx = ax * s, ry = ay * s;
|
||
const rw = el.width * s, rh = el.height * s;
|
||
const rad = Math.min((el.borderRadius ?? 3) * s, rw / 2, rh / 2);
|
||
ctx.beginPath();
|
||
ctx.roundRect(rx, ry, rw, rh, rad);
|
||
if (el.hasFill) {
|
||
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
|
||
ctx.fill();
|
||
}
|
||
if (el.hasOutline) {
|
||
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
|
||
ctx.lineWidth = s;
|
||
ctx.stroke();
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'line': {
|
||
// ax/ay resolve from el.x0 for a line, so this is the anchor offset.
|
||
// Without it a line stayed put while every other type moved.
|
||
const dx = ax - el.x0, dy = ay - el.y0;
|
||
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||
ctx.lineWidth = Math.max(1, el.lineWidth || 1) * s;
|
||
ctx.beginPath();
|
||
ctx.moveTo((el.x0 + dx) * s, (el.y0 + dy) * s);
|
||
ctx.lineTo((el.x1 + dx) * s, (el.y1 + dy) * s);
|
||
ctx.stroke();
|
||
break;
|
||
}
|
||
|
||
case 'divider': {
|
||
const isH = (el.orientation || 'horizontal') === 'horizontal';
|
||
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||
ctx.lineWidth = s;
|
||
ctx.beginPath();
|
||
if (isH) {
|
||
ctx.moveTo(0, ay * s + 0.5);
|
||
ctx.lineTo(_canvas.width, ay * s + 0.5);
|
||
} else {
|
||
ctx.moveTo(ax * s + 0.5, 0);
|
||
ctx.lineTo(ax * s + 0.5, _canvas.height);
|
||
}
|
||
ctx.stroke();
|
||
break;
|
||
}
|
||
|
||
case 'pips': {
|
||
const pipCount = Math.max(1, el.count ?? 5);
|
||
const pvPips = opts.previewValues?.[el.binding?.key];
|
||
const filledN = pvPips !== undefined
|
||
? Math.max(0, Math.min(pipCount, Math.round(parseFloat(pvPips) || 0)))
|
||
: Math.max(0, Math.min(pipCount, el.filled ?? 3));
|
||
const ps = Math.max(1, el.pipSize ?? 4);
|
||
const pg = Math.max(0, el.pipSpacing ?? 2);
|
||
for (let i = 0; i < pipCount; i++) {
|
||
const isFilled = i < filledN;
|
||
if (!isFilled && !el.showEmpty) continue;
|
||
ctx.fillStyle = isFilled
|
||
? `rgb(${el.r},${el.g},${el.b})`
|
||
: `rgb(${el.emptyR ?? 50},${el.emptyG ?? 50},${el.emptyB ?? 50})`;
|
||
ctx.fillRect((ax + i * (ps + pg)) * s, ay * s, ps * s, ps * s);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'sparkline': {
|
||
const slW = el.width ?? 40, slH = el.height ?? 12;
|
||
const count = Math.max(1, el.barCount ?? 8);
|
||
const spacing = el.barSpacing ?? 1;
|
||
const barW = Math.max(1, Math.floor((slW - spacing * (count - 1)) / count));
|
||
const rawVals = (el.previewData || '').split(',')
|
||
.map(v => parseFloat(v.trim())).filter(n => !isNaN(n));
|
||
while (rawVals.length < count) rawVals.push(0);
|
||
const maxV = Math.max(...rawVals.slice(0, count), 0.001);
|
||
const rx = ax * s, ry = ay * s;
|
||
if (el.hasBg) {
|
||
ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`;
|
||
ctx.fillRect(rx, ry, slW * s, slH * s);
|
||
}
|
||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||
for (let i = 0; i < count; i++) {
|
||
const norm = Math.max(0, Math.min(1, rawVals[i] / maxV));
|
||
const barH = Math.max(1, Math.round(slH * norm));
|
||
const bx = rx + (barW + spacing) * i * s;
|
||
const by = ry + (slH - barH) * s;
|
||
ctx.fillRect(bx, by, barW * s, barH * s);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'gauge': {
|
||
const gw = (el.width ?? 32), gh = (el.height ?? 32);
|
||
const cx = (ax + gw / 2) * s, cy = (ay + gh / 2) * s;
|
||
const rx = (gw / 2) * s, ry = (gh / 2) * s;
|
||
const lw = Math.max(1, (el.lineWidth ?? 3));
|
||
// rx/ry are canvas pixels ((gw/2)*s) but lw is LED pixels, so
|
||
// insetting by lw/2 under-corrected by the scale factor while the
|
||
// stroke was drawn at lw*s -- the arc spilled outside the element's
|
||
// reported bounding box at any SCALE > 1, and the preview stopped
|
||
// matching the generated PIL output.
|
||
const lwPx = lw * s;
|
||
const startDeg = el.startAngle ?? 135;
|
||
const endDeg = el.endAngle ?? 45;
|
||
// Arc sweep: from startDeg clockwise to endDeg (PIL convention)
|
||
const totalSweep = ((endDeg - startDeg) + 360) % 360 || 360;
|
||
const pvGauge = opts.previewValues?.[el.binding?.key];
|
||
const pct = pvGauge !== undefined
|
||
? Math.max(0, Math.min(100, parseFloat(pvGauge) || 0)) / 100
|
||
: Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100;
|
||
const fillSweep = totalSweep * pct;
|
||
const toRad = deg => (deg - 90) * Math.PI / 180; // canvas 0=top, PIL 0=right → offset -90
|
||
|
||
// Track arc
|
||
if (el.hasTrack !== false) {
|
||
ctx.beginPath();
|
||
ctx.ellipse(cx, cy, rx - lwPx / 2, ry - lwPx / 2, 0, toRad(startDeg), toRad(startDeg + totalSweep), false);
|
||
ctx.strokeStyle = `rgb(${el.trackR ?? 40},${el.trackG ?? 40},${el.trackB ?? 40})`;
|
||
ctx.lineWidth = lwPx;
|
||
ctx.stroke();
|
||
}
|
||
// Fill arc
|
||
if (pct > 0) {
|
||
ctx.beginPath();
|
||
ctx.ellipse(cx, cy, rx - lwPx / 2, ry - lwPx / 2, 0, toRad(startDeg), toRad(startDeg + fillSweep), false);
|
||
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||
ctx.lineWidth = lwPx;
|
||
ctx.stroke();
|
||
}
|
||
// Centre label
|
||
if (el.showLabel) {
|
||
const gfinfo = FONT_MAP[el.font || 'four_by_six'] || FONT_MAP.four_by_six;
|
||
const labelText = Math.round(pct * 100) + '%';
|
||
ctx.font = `${gfinfo.sizePx * s}px ${gfinfo.family}`;
|
||
ctx.fillStyle = `rgb(${el.labelR ?? 200},${el.labelG ?? 200},${el.labelB ?? 200})`;
|
||
const ltw = ctx.measureText(labelText).width;
|
||
ctx.fillText(labelText, cx - ltw / 2, cy + (gfinfo.sizePx * s) / 2);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'marquee': {
|
||
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||
const text = el.text || 'Scrolling text';
|
||
const tw = text.length * finfo.charW * s;
|
||
const gap = (el.gap ?? 16) * s;
|
||
const totalW = tw + gap;
|
||
const tick = opts.animTick ?? 0;
|
||
const speed = (el.scrollSpeed ?? 1) * 2;
|
||
const scrolled = (tick * speed) % totalW;
|
||
// left: text enters from right; right: text enters from left
|
||
const startX = el.direction === 'right'
|
||
? scrolled - tw
|
||
: matrixW * s - scrolled;
|
||
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
|
||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||
// Clip to canvas width so text doesn't bleed outside
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.rect(0, ay * s - 1, matrixW * s, (finfo.sizePx + 2) * s);
|
||
ctx.clip();
|
||
for (let i = -1; i <= 2; i++) {
|
||
ctx.fillText(text, startX + i * totalW, (ay + finfo.sizePx) * s);
|
||
}
|
||
ctx.restore();
|
||
break;
|
||
}
|
||
|
||
case 'progress_bar': {
|
||
const bw = el.barWidth ?? 60, bh = el.barHeight ?? 6;
|
||
const pvPb = opts.previewValues?.[el.binding?.key];
|
||
const pct = pvPb !== undefined
|
||
? Math.max(0, Math.min(100, parseFloat(pvPb) || 0)) / 100
|
||
: Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100;
|
||
const rx = ax * s, ry = ay * s;
|
||
if (el.hasBg) {
|
||
ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`;
|
||
ctx.fillRect(rx, ry, bw * s, bh * s);
|
||
}
|
||
const fillW = Math.max(0, Math.round(bw * pct));
|
||
if (fillW > 0) {
|
||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||
ctx.fillRect(rx, ry, fillW * s, bh * s);
|
||
}
|
||
if (el.hasOutline) {
|
||
ctx.strokeStyle = `rgb(${el.outR ?? 100},${el.outG ?? 100},${el.outB ?? 100})`;
|
||
ctx.lineWidth = s;
|
||
ctx.strokeRect(rx, ry, bw * s, bh * s);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (belowBreakpoint) {
|
||
ctx.globalAlpha = 0.6;
|
||
const bb = getBoundingBox(el, matrixW, matrixH);
|
||
ctx.font = `${Math.max(8, s * 2)}px monospace`;
|
||
ctx.fillStyle = '#facc15';
|
||
ctx.fillText(`≥${el.minWidth}px`, bb.x * s, (bb.y + 4) * s);
|
||
}
|
||
} finally {
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
// ── Selection indicator ──────────────────────────────────────────────
|
||
function _drawSelection(ctx, el, SCALE, matrixW, matrixH) {
|
||
const bb = getBoundingBox(el, matrixW, matrixH);
|
||
const PAD = 2, s = SCALE;
|
||
const rx = bb.x * s - PAD, ry = bb.y * s - PAD;
|
||
const rw = bb.w * s + PAD * 2, rh = bb.h * s + PAD * 2;
|
||
|
||
ctx.save();
|
||
ctx.strokeStyle = '#3b82f6';
|
||
ctx.lineWidth = 1;
|
||
ctx.setLineDash([3, 2]);
|
||
ctx.strokeRect(rx, ry, rw, rh);
|
||
ctx.setLineDash([]);
|
||
|
||
if (el.xAnchor || el.yAnchor) {
|
||
ctx.font = `${Math.max(7, s)}px sans-serif`;
|
||
ctx.fillStyle = '#a78bfa';
|
||
const anchorText = [
|
||
el.xAnchor ? `x:${el.xAnchor[0]}` : '',
|
||
el.yAnchor ? `y:${el.yAnchor[0]}` : '',
|
||
].filter(Boolean).join(' ');
|
||
if (anchorText) ctx.fillText(anchorText, rx + 1, ry - 2);
|
||
}
|
||
|
||
// Resize handles: on rect, rounded rect, ellipse
|
||
if (RESIZABLE_TYPES.includes(el.type)) {
|
||
const handles = _getRectHandles(el, matrixW, matrixH);
|
||
const HS = 5;
|
||
ctx.fillStyle = 'white';
|
||
ctx.strokeStyle = '#2563eb';
|
||
ctx.lineWidth = 1;
|
||
for (const pt of Object.values(handles)) {
|
||
const hx = pt.x * s - HS / 2;
|
||
const hy = pt.y * s - HS / 2;
|
||
ctx.fillRect(hx, hy, HS, HS);
|
||
ctx.strokeRect(hx, hy, HS, HS);
|
||
}
|
||
} else {
|
||
// Corner dots for non-rectangle elements
|
||
ctx.fillStyle = '#3b82f6';
|
||
const HS = 4;
|
||
for (const [hx, hy] of [
|
||
[rx - HS / 2, ry - HS / 2], [rx + rw - HS / 2, ry - HS / 2],
|
||
[rx - HS / 2, ry + rh - HS / 2], [rx + rw - HS / 2, ry + rh - HS / 2],
|
||
]) ctx.fillRect(hx, hy, HS, HS);
|
||
}
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
// ── Dimension tooltip while dragging ─────────────────────────────────
|
||
function drawDragTooltip(ctx, el, SCALE, matrixW, matrixH) {
|
||
const bb = getBoundingBox(el, matrixW, matrixH);
|
||
const label = el.type === 'rectangle'
|
||
? `${el.width}×${el.height}`
|
||
: `${bb.x},${bb.y}`;
|
||
const s = SCALE;
|
||
ctx.save();
|
||
ctx.font = `${Math.max(9, s * 1.5)}px monospace`;
|
||
const tw = ctx.measureText(label).width;
|
||
const tx = bb.x * s, ty = (bb.y - 2) * s;
|
||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||
ctx.fillRect(tx - 2, ty - 10, tw + 4, 12);
|
||
ctx.fillStyle = 'white';
|
||
ctx.fillText(label, tx, ty);
|
||
ctx.restore();
|
||
}
|
||
|
||
// ── Public API ───────────────────────────────────────────────────────
|
||
|
||
function init(canvasEl) {
|
||
_canvas = canvasEl;
|
||
_ctx = canvasEl.getContext('2d');
|
||
}
|
||
|
||
function setGrid(show) { _showGrid = show; }
|
||
|
||
function updateCanvasSize(matrixW, matrixH, SCALE) {
|
||
if (!_canvas) return;
|
||
_canvas.width = matrixW * SCALE;
|
||
_canvas.height = matrixH * SCALE;
|
||
}
|
||
|
||
function render(elements, selectedId, matrixW, matrixH, SCALE, opts = {}) {
|
||
if (!_ctx) return;
|
||
const cW = matrixW * SCALE, cH = matrixH * SCALE;
|
||
|
||
const bg = opts.bgColor;
|
||
_ctx.fillStyle = bg ? `rgb(${bg.r},${bg.g},${bg.b})` : '#000';
|
||
_ctx.fillRect(0, 0, cW, cH);
|
||
|
||
if (_showGrid) {
|
||
_ctx.strokeStyle = 'rgba(255,255,255,0.07)';
|
||
_ctx.lineWidth = 0.5;
|
||
for (let x = SCALE; x < cW; x += SCALE) {
|
||
_ctx.beginPath(); _ctx.moveTo(x, 0); _ctx.lineTo(x, cH); _ctx.stroke();
|
||
}
|
||
for (let y = SCALE; y < cH; y += SCALE) {
|
||
_ctx.beginPath(); _ctx.moveTo(0, y); _ctx.lineTo(cW, y); _ctx.stroke();
|
||
}
|
||
}
|
||
|
||
for (const el of elements) _drawElement(_ctx, el, SCALE, matrixW, matrixH, opts);
|
||
|
||
if (opts.showRuler) {
|
||
_ctx.save();
|
||
_ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
||
_ctx.fillRect(0, 0, cW, SCALE); // top strip
|
||
_ctx.fillRect(0, 0, SCALE, cH); // left strip
|
||
_ctx.strokeStyle = 'rgba(255,255,255,0.5)';
|
||
_ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||
_ctx.font = `${Math.max(5, SCALE - 1)}px monospace`;
|
||
const step = SCALE >= 4 ? 8 : 16;
|
||
for (let px = 0; px <= matrixW; px += step) {
|
||
const cx = px * SCALE;
|
||
const major = px % 32 === 0;
|
||
_ctx.lineWidth = 0.5;
|
||
_ctx.beginPath(); _ctx.moveTo(cx, 0); _ctx.lineTo(cx, major ? SCALE : SCALE * 0.5); _ctx.stroke();
|
||
if (major && px > 0 && px < matrixW - 4) _ctx.fillText(String(px), cx + 1, SCALE - 1);
|
||
}
|
||
for (let py = 0; py <= matrixH; py += step) {
|
||
const cy = py * SCALE;
|
||
const major = py % 32 === 0;
|
||
_ctx.beginPath(); _ctx.moveTo(0, cy); _ctx.lineTo(major ? SCALE : SCALE * 0.5, cy); _ctx.stroke();
|
||
if (major && py > 0 && py < matrixH - 4) _ctx.fillText(String(py), 1, cy + SCALE - 1);
|
||
}
|
||
_ctx.restore();
|
||
}
|
||
|
||
if (opts.showGuides) {
|
||
_ctx.save();
|
||
_ctx.strokeStyle = 'rgba(255,60,60,0.45)';
|
||
_ctx.lineWidth = 1;
|
||
_ctx.setLineDash([4, 3]);
|
||
const mx = Math.floor(cW / 2) + 0.5;
|
||
const my = Math.floor(cH / 2) + 0.5;
|
||
_ctx.beginPath(); _ctx.moveTo(mx, 0); _ctx.lineTo(mx, cH); _ctx.stroke();
|
||
_ctx.beginPath(); _ctx.moveTo(0, my); _ctx.lineTo(cW, my); _ctx.stroke();
|
||
_ctx.setLineDash([]);
|
||
_ctx.restore();
|
||
}
|
||
|
||
const sel = selectedId != null ? elements.find(e => e.id === selectedId) : null;
|
||
if (sel) {
|
||
_drawSelection(_ctx, sel, SCALE, matrixW, matrixH);
|
||
if (opts.showTooltip) drawDragTooltip(_ctx, sel, SCALE, matrixW, matrixH);
|
||
}
|
||
}
|
||
|
||
return {
|
||
init, render, setGrid, updateCanvasSize,
|
||
hitTest, getBoundingBox, computeActualPos, resolveAnchor,
|
||
getResizeHandle, getCursorForHandle,
|
||
ELEMENT_DEFAULTS, FONT_MAP, DISPLAY_PRESETS, RESIZABLE_TYPES,
|
||
};
|
||
})();
|