643 lines
19 KiB
JavaScript
643 lines
19 KiB
JavaScript
// core/screensaver.js — handles idle-activated screensaver overlay
|
|
|
|
export function createScreensaver({ config, body = (typeof document !== 'undefined' ? document.body : null) } = {}) {
|
|
const state = {
|
|
active: false,
|
|
force: false,
|
|
lastSignalTs: now(),
|
|
ball: { x: 120, y: 120, vx: 180, vy: 140, w: 140, h: 80, color: '#ff0080', tint: '#ff0080', hue: 0 },
|
|
logo: null,
|
|
logoReady: false,
|
|
enabled: true,
|
|
mode: (config?.SCREENSAVER_MODE === 'starfield' || config?.SCREENSAVER_MODE === 'clock' || config?.SCREENSAVER_MODE === 'black') ? config.SCREENSAVER_MODE : 'clock',
|
|
stars: [],
|
|
};
|
|
|
|
function now() {
|
|
return (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
}
|
|
|
|
function getThreshold() {
|
|
const t = config?.SCREENSAVER_ACTIVITY_DB;
|
|
return Number.isFinite(t) ? t : -50;
|
|
}
|
|
|
|
function markActivity(ts) {
|
|
const t = ts || now();
|
|
state.lastSignalTs = t;
|
|
state.force = false;
|
|
if (state.active) setActive(false);
|
|
}
|
|
|
|
function markAudioActivity(levelDb, ts) {
|
|
if (!Number.isFinite(levelDb)) return false;
|
|
if (levelDb > getThreshold()) {
|
|
// Pegel-Trigger nur, wenn kein forcierter Testlauf aktiv ist
|
|
if (!state.force) {
|
|
markActivity(ts);
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function ensureLogo() {
|
|
if (state.logo !== null) return;
|
|
const img = new Image();
|
|
img.src = 'assets/dvdlogo.svg';
|
|
img.onload = () => { state.logoReady = true; };
|
|
img.onerror = () => { state.logoReady = false; };
|
|
state.logo = img;
|
|
}
|
|
|
|
function setActive(next) {
|
|
state.active = !!next;
|
|
if (body) {
|
|
body.classList.toggle('screensaver-active', state.active);
|
|
}
|
|
}
|
|
|
|
function nextLogoColor(prev = '#ff0080') {
|
|
const palette = ['#ff0080', '#00d8ff', '#ffd400', '#00ff88', '#ff6600', '#9b59ff', '#ff2d55'];
|
|
let c = prev;
|
|
for (let i = 0; i < 4 && c === prev; i++) {
|
|
c = palette[Math.floor(Math.random() * palette.length)];
|
|
}
|
|
return c;
|
|
}
|
|
|
|
function randomizeBall(rect) {
|
|
const { ball } = state;
|
|
if (!rect) return;
|
|
const base = Math.min(rect.w, rect.h) * 0.22;
|
|
if (state.logo && state.logoReady && state.logo.width && state.logo.height) {
|
|
const aspect = state.logo.width / state.logo.height;
|
|
ball.w = base;
|
|
ball.h = base / aspect;
|
|
} else {
|
|
ball.w = base;
|
|
ball.h = base * 0.55;
|
|
}
|
|
const halfW = ball.w / 2;
|
|
const halfH = ball.h / 2;
|
|
ball.x = rect.x + halfW + Math.random() * Math.max(1, rect.w - 2 * halfW);
|
|
ball.y = rect.y + halfH + Math.random() * Math.max(1, rect.h - 2 * halfH);
|
|
const speed = 160 + Math.random() * 80;
|
|
const angle = Math.random() * Math.PI * 2;
|
|
ball.vx = Math.cos(angle) * speed;
|
|
ball.vy = Math.sin(angle) * speed;
|
|
ball.color = nextLogoColor();
|
|
ball.tint = ball.color;
|
|
}
|
|
|
|
function updateBall(rect, dtMs) {
|
|
const { ball } = state;
|
|
const dt = Math.max(0.001, dtMs / 1000);
|
|
ball.x += ball.vx * dt;
|
|
ball.y += ball.vy * dt;
|
|
const halfW = ball.w / 2;
|
|
const halfH = ball.h / 2;
|
|
const minX = rect.x + halfW;
|
|
const maxX = rect.x + rect.w - halfW;
|
|
const minY = rect.y + halfH;
|
|
const maxY = rect.y + rect.h - halfH;
|
|
let bounced = false;
|
|
if (ball.x < minX) { ball.x = minX; ball.vx *= -1; bounced = true; }
|
|
if (ball.x > maxX) { ball.x = maxX; ball.vx *= -1; bounced = true; }
|
|
if (ball.y < minY) { ball.y = minY; ball.vy *= -1; bounced = true; }
|
|
if (ball.y > maxY) { ball.y = maxY; ball.vy *= -1; bounced = true; }
|
|
if (bounced) {
|
|
const nxt = nextLogoColor(ball.color);
|
|
ball.color = nxt;
|
|
ball.tint = nxt;
|
|
ball.hue = Math.floor(Math.random() * 360);
|
|
}
|
|
}
|
|
|
|
function draw(ctx, rect) {
|
|
if (state.mode === 'starfield') {
|
|
drawStarfield(ctx, rect);
|
|
return;
|
|
}
|
|
const { ball } = state;
|
|
ctx.save();
|
|
ctx.fillStyle = 'rgba(5,5,10,0.9)';
|
|
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
|
const radius = Math.min(ball.w, ball.h) * 0.18;
|
|
const x = ball.x - ball.w / 2;
|
|
const y = ball.y - ball.h / 2;
|
|
const col = ball.color || '#ff0080';
|
|
// Logo body
|
|
ctx.fillStyle = col;
|
|
ctx.strokeStyle = 'rgba(0,0,0,0.35)';
|
|
ctx.lineWidth = 4;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + radius, y);
|
|
ctx.lineTo(x + ball.w - radius, y);
|
|
ctx.quadraticCurveTo(x + ball.w, y, x + ball.w, y + radius);
|
|
ctx.lineTo(x + ball.w, y + ball.h - radius);
|
|
ctx.quadraticCurveTo(x + ball.w, y + ball.h, x + ball.w - radius, y + ball.h);
|
|
ctx.lineTo(x + radius, y + ball.h);
|
|
ctx.quadraticCurveTo(x, y + ball.h, x, y + ball.h - radius);
|
|
ctx.lineTo(x, y + radius);
|
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
// DVD text
|
|
ctx.fillStyle = '#000';
|
|
ctx.font = `${Math.max(18, ball.h * 0.4)}px 'Arial Black', ui-sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText('DVD', ball.x, ball.y);
|
|
ctx.restore();
|
|
}
|
|
|
|
function updateAndRender(ctx, rect, nowTs, dtMs) {
|
|
const enabled = state.enabled !== false && config?.SCREENSAVER_ENABLED !== false;
|
|
if (!enabled && !state.force) {
|
|
if (state.active) setActive(false);
|
|
return false;
|
|
}
|
|
const idleMin = Math.max(0, Number(config?.SCREENSAVER_IDLE_MIN ?? 0));
|
|
const idleMs = idleMin * 60 * 1000;
|
|
const shouldActivate = state.force || (idleMs > 0 && (nowTs - state.lastSignalTs) >= idleMs);
|
|
if (shouldActivate && !state.active) {
|
|
randomizeBall(rect);
|
|
setActive(true);
|
|
} else if (!shouldActivate && state.active) {
|
|
setActive(false);
|
|
}
|
|
if (!state.active) return false;
|
|
|
|
if (state.mode === 'starfield') {
|
|
updateStars(rect, dtMs);
|
|
drawStarfield(ctx, rect);
|
|
return true;
|
|
}
|
|
if (state.mode === 'black') {
|
|
ctx.save();
|
|
ctx.fillStyle = 'black';
|
|
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
|
ctx.restore();
|
|
return true;
|
|
}
|
|
if (state.mode === 'clock') {
|
|
drawClock(ctx, rect, nowTs);
|
|
return true;
|
|
}
|
|
updateBall(rect, dtMs);
|
|
drawDvd(ctx, rect);
|
|
return true;
|
|
}
|
|
|
|
function ensureStars(rect, count = 160) {
|
|
if (!rect) return;
|
|
if (!state.stars || state.stars.length !== count) {
|
|
state.stars = new Array(count).fill(0).map(() => makeStar(rect));
|
|
}
|
|
}
|
|
|
|
function makeStar(rect) {
|
|
const angle = Math.random() * Math.PI * 2;
|
|
const speed = 0.15 + Math.random() * 0.25;
|
|
const hue = 180 + Math.random() * 120;
|
|
return {
|
|
x: (Math.random() * 2 - 1) * rect.w * 0.35,
|
|
y: (Math.random() * 2 - 1) * rect.h * 0.35,
|
|
z: Math.random() * 1 + 0.3,
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed,
|
|
hue,
|
|
};
|
|
}
|
|
|
|
function updateStars(rect, dtMs) {
|
|
ensureStars(rect);
|
|
const dt = Math.max(0.001, dtMs / 1000);
|
|
for (let i = 0; i < state.stars.length; i++) {
|
|
const s = state.stars[i];
|
|
s.x += s.vx * rect.w * dt;
|
|
s.y += s.vy * rect.h * dt;
|
|
s.z -= dt * 0.35;
|
|
if (s.z <= 0.05 || Math.abs(s.x) > rect.w || Math.abs(s.y) > rect.h) {
|
|
state.stars[i] = makeStar(rect);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function drawStarfield(ctx, rect) {
|
|
ensureStars(rect);
|
|
ctx.save();
|
|
ctx.fillStyle = 'rgba(3,4,8,0.92)';
|
|
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
|
const cx = rect.x + rect.w / 2;
|
|
const cy = rect.y + rect.h / 2;
|
|
for (const s of state.stars) {
|
|
const scale = 280 / (s.z * rect.w);
|
|
const x = cx + s.x * scale;
|
|
const y = cy + s.y * scale;
|
|
const len = Math.max(4, 18 * (1 - s.z));
|
|
const dx = (s.x * scale) * 0.02;
|
|
const dy = (s.y * scale) * 0.02;
|
|
ctx.strokeStyle = `hsl(${s.hue}, 90%, 65%)`;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x - dx, y - dy);
|
|
ctx.lineTo(x + dx, y + dy);
|
|
ctx.stroke();
|
|
ctx.fillStyle = `hsl(${s.hue}, 90%, 70%)`;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, Math.max(1, len * 0.08), 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawDvd(ctx, rect) {
|
|
ensureLogo();
|
|
const { ball } = state;
|
|
ctx.save();
|
|
ctx.fillStyle = 'rgba(5,5,10,0.9)';
|
|
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
|
|
|
if (state.logo && state.logoReady) {
|
|
const x = ball.x - ball.w / 2;
|
|
const y = ball.y - ball.h / 2;
|
|
ctx.filter = `hue-rotate(${ball.hue || 0}deg)`;
|
|
ctx.drawImage(state.logo, x, y, ball.w, ball.h);
|
|
ctx.filter = 'none';
|
|
} else {
|
|
// Fallback: simples Logo
|
|
const radius = Math.min(ball.w, ball.h) * 0.18;
|
|
const x = ball.x - ball.w / 2;
|
|
const y = ball.y - ball.h / 2;
|
|
const col = ball.color || '#ff0080';
|
|
ctx.fillStyle = col;
|
|
ctx.strokeStyle = 'rgba(0,0,0,0.35)';
|
|
ctx.lineWidth = 4;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + radius, y);
|
|
ctx.lineTo(x + ball.w - radius, y);
|
|
ctx.quadraticCurveTo(x + ball.w, y, x + ball.w, y + radius);
|
|
ctx.lineTo(x + ball.w, y + ball.h - radius);
|
|
ctx.quadraticCurveTo(x + ball.w, y + ball.h, x + ball.w - radius, y + ball.h);
|
|
ctx.lineTo(x + radius, y + ball.h);
|
|
ctx.quadraticCurveTo(x, y + ball.h, x, y + ball.h - radius);
|
|
ctx.lineTo(x, y + radius);
|
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.fillStyle = '#000';
|
|
ctx.font = `${Math.max(18, ball.h * 0.4)}px 'Arial Black', ui-sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText('DVD', ball.x, ball.y);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawClock(ctx, rect, nowTs) {
|
|
const d = new Date();
|
|
const hours = d.getHours();
|
|
const mins = d.getMinutes();
|
|
const secs = d.getSeconds() + d.getMilliseconds() / 1000;
|
|
const centerX = rect.x + rect.w / 2;
|
|
const centerY = rect.y + rect.h / 2;
|
|
const radius = Math.min(rect.w, rect.h) * 0.48;
|
|
const baseDot = Math.max(1.5, Math.round(radius * 0.03));
|
|
const ringDot = baseDot * 0.7;
|
|
const glyphDot = baseDot * 1.3;
|
|
ctx.save();
|
|
ctx.fillStyle = 'rgba(8,8,12,0.9)';
|
|
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
|
ctx.translate(centerX, centerY);
|
|
|
|
// seconds ring + 5s markers
|
|
for (let i = 0; i < 60; i++) {
|
|
const ang = (Math.PI * 2 * i) / 60 - Math.PI / 2;
|
|
const r = radius * 0.88;
|
|
const filled = i <= secs;
|
|
const cx = Math.cos(ang) * r;
|
|
const cy = Math.sin(ang) * r;
|
|
const glow = config?.SCREENSAVER_LED_GLOW !== false;
|
|
const color = normalizeColor(config?.SCREENSAVER_LED_COLOR);
|
|
if (filled) drawLed(ctx, cx, cy, ringDot, glow, color);
|
|
else {
|
|
ctx.fillStyle = 'rgba(120,120,120,0.25)';
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, ringDot, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
if (i % 5 === 0) {
|
|
const outerR = r + ringDot * 3.2;
|
|
const ocx = Math.cos(ang) * outerR;
|
|
const ocy = Math.sin(ang) * outerR;
|
|
drawLed(ctx, ocx, ocy, ringDot, glow, color);
|
|
}
|
|
}
|
|
|
|
// center time (HH:MM) as dot-matrix
|
|
const timeStr = `${pad2(hours)}:${pad2(mins)}`;
|
|
const glyphSize = radius * 0.32;
|
|
const timeSpacing = 0.9;
|
|
const timeY = -(glyphSize * 1.2) / 2; // vertikal zentriert auf der Mittelachse
|
|
const blinkOn = isSecondPulseActive(d);
|
|
const extraGaps = [0, 3]; // zusätzliche Spalte zwischen den Ziffern in jedem Block
|
|
const metrics = measureTextLayout(timeStr, glyphSize, timeSpacing, extraGaps);
|
|
const timeX = -metrics.colonCenter; // Doppelpunkt auf Mittelpunkt legen
|
|
const glow = config?.SCREENSAVER_LED_GLOW !== false;
|
|
const color = normalizeColor(config?.SCREENSAVER_LED_COLOR);
|
|
drawDotText(ctx, timeStr, timeX, timeY, glyphSize, glyphDot * 1.1, timeSpacing, blinkOn ? (glyphDot * 1.1) : 0, extraGaps, glow, color);
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawClockDigital(ctx, rect) {
|
|
const d = new Date();
|
|
const hours = d.getHours();
|
|
const mins = d.getMinutes();
|
|
const secs = d.getSeconds() + d.getMilliseconds() / 1000;
|
|
const day = d.getDate();
|
|
const month = d.getMonth() + 1;
|
|
const year = d.getFullYear();
|
|
const centerX = rect.x + rect.w / 2;
|
|
const centerY = rect.y + rect.h / 2;
|
|
const radius = Math.min(rect.w, rect.h) * 0.45;
|
|
const baseDot = Math.max(1.5, Math.round(radius * 0.028));
|
|
const glyphDot = baseDot * 1.3;
|
|
const glyphSize = radius * 0.32;
|
|
const timeSpacing = 0.9;
|
|
const blinkOn = isSecondPulseActive(d);
|
|
const extraGaps = [0, 3];
|
|
const color = normalizeColor(config?.SCREENSAVER_LED_COLOR);
|
|
const glow = config?.SCREENSAVER_LED_GLOW !== false;
|
|
|
|
ctx.save();
|
|
ctx.fillStyle = 'rgba(8,8,12,0.9)';
|
|
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
|
ctx.translate(centerX, centerY);
|
|
|
|
const timeStr = `${pad2(hours)}:${pad2(mins)}:${pad2(Math.floor(secs))}`;
|
|
const dateStr = `${pad2(day)}.${pad2(month)}.${year}`;
|
|
const dateSize = glyphSize * 0.8;
|
|
const dateGaps = [1, 4]; // nach DD und MM
|
|
const timeMetrics = measureTextLayout(timeStr, glyphSize, timeSpacing, extraGaps);
|
|
const dateMetrics = measureTextLayout(dateStr, dateSize, timeSpacing, dateGaps);
|
|
const totalH = glyphSize * 1.2 + dateSize * 1.2 + glyphSize * 0.25;
|
|
const startY = -totalH / 2;
|
|
const timeX = -timeMetrics.colonCenter;
|
|
const timeY = startY;
|
|
drawDotText(ctx, timeStr, timeX, timeY, glyphSize, glyphDot, timeSpacing, blinkOn ? glyphDot : 0, extraGaps, glow, color);
|
|
|
|
const dateX = -dateMetrics.width / 2;
|
|
const dateY = startY + glyphSize * 1.2 + glyphSize * 0.25;
|
|
drawDotText(ctx, dateStr, dateX, dateY, dateSize, glyphDot * 0.9, timeSpacing, 0, dateGaps, glow, color);
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
return {
|
|
markActivity,
|
|
markAudioActivity,
|
|
updateAndRender,
|
|
triggerTest() { state.force = true; state.lastSignalTs = now(); },
|
|
setIdleMinutes(min) { if (Number.isFinite(min)) config.SCREENSAVER_IDLE_MIN = min; },
|
|
setActivityThreshold(db) { if (Number.isFinite(db)) config.SCREENSAVER_ACTIVITY_DB = db; },
|
|
setEnabled(val) {
|
|
state.enabled = !!val;
|
|
config.SCREENSAVER_ENABLED = state.enabled;
|
|
if (!state.enabled && state.active) setActive(false);
|
|
},
|
|
setColor(val) {
|
|
if (typeof val === 'string' && val.trim()) {
|
|
config.SCREENSAVER_LED_COLOR = val;
|
|
}
|
|
},
|
|
setMode(mode) {
|
|
const m = mode === 'starfield'
|
|
? 'starfield'
|
|
: (mode === 'clock'
|
|
? 'clock'
|
|
: (mode === 'black'
|
|
? 'black'
|
|
: (mode === 'lines' ? 'lines' : 'dvd')));
|
|
state.mode = m;
|
|
config.SCREENSAVER_MODE = m;
|
|
if (m === 'starfield') {
|
|
state.stars = [];
|
|
} else if (m === 'clock') {
|
|
// no special init needed
|
|
} else if (m === 'lines') {
|
|
state.lines = [];
|
|
} else if (m === 'dvd') {
|
|
randomizeBall({ x: 0, y: 0, w: 1, h: 1 });
|
|
}
|
|
},
|
|
isEnabled: () => state.enabled !== false && config?.SCREENSAVER_ENABLED !== false,
|
|
isActive: () => state.active,
|
|
};
|
|
}
|
|
|
|
// --- Dot glyph rendering for clock -----------------------------------------
|
|
const DOT_FONT = {
|
|
'0': [
|
|
'01110',
|
|
'10001',
|
|
'10011',
|
|
'10101',
|
|
'11001',
|
|
'10001',
|
|
'01110',
|
|
],
|
|
'1': [
|
|
'00100',
|
|
'01100',
|
|
'00100',
|
|
'00100',
|
|
'00100',
|
|
'00100',
|
|
'01110',
|
|
],
|
|
'2': [
|
|
'01110',
|
|
'10001',
|
|
'00001',
|
|
'00110',
|
|
'01000',
|
|
'10000',
|
|
'11111',
|
|
],
|
|
'3': [
|
|
'11110',
|
|
'00001',
|
|
'00001',
|
|
'01110',
|
|
'00001',
|
|
'00001',
|
|
'11110',
|
|
],
|
|
'4': [
|
|
'00010',
|
|
'00110',
|
|
'01010',
|
|
'10010',
|
|
'11111',
|
|
'00010',
|
|
'00010',
|
|
],
|
|
'5': [
|
|
'11111',
|
|
'10000',
|
|
'11110',
|
|
'00001',
|
|
'00001',
|
|
'10001',
|
|
'01110',
|
|
],
|
|
'6': [
|
|
'00110',
|
|
'01000',
|
|
'10000',
|
|
'11110',
|
|
'10001',
|
|
'10001',
|
|
'01110',
|
|
],
|
|
'7': [
|
|
'11111',
|
|
'00001',
|
|
'00010',
|
|
'00100',
|
|
'01000',
|
|
'01000',
|
|
'01000',
|
|
],
|
|
'8': [
|
|
'01110',
|
|
'10001',
|
|
'10001',
|
|
'01110',
|
|
'10001',
|
|
'10001',
|
|
'01110',
|
|
],
|
|
'9': [
|
|
'01110',
|
|
'10001',
|
|
'10001',
|
|
'01111',
|
|
'00001',
|
|
'00010',
|
|
'01100',
|
|
],
|
|
':': [
|
|
'0',
|
|
'1',
|
|
'0',
|
|
'0',
|
|
'1',
|
|
'0',
|
|
'0',
|
|
],
|
|
};
|
|
const DOT_COLS = DOT_FONT['0'][0].length;
|
|
|
|
function drawDotText(ctx, text, x, y, glyphSize, dotRadius, spacingFactor = 0.9, colonDotRadiusOverride = null, extraGapIndices = [], glow = true, color = 'rgb(255,0,0)') {
|
|
let cursorX = x;
|
|
const glyphW = glyphSize;
|
|
const glyphH = glyphSize * 1.2;
|
|
for (let i = 0; i < text.length; i++) {
|
|
const ch = text[i];
|
|
const useDot = (ch === ':' && colonDotRadiusOverride !== null) ? colonDotRadiusOverride : dotRadius;
|
|
drawDotChar(ctx, ch, cursorX, y, glyphW, glyphH, useDot, glow, color);
|
|
cursorX += glyphW * spacingFactor;
|
|
if (extraGapIndices.includes(i)) {
|
|
cursorX += glyphW / DOT_COLS;
|
|
}
|
|
}
|
|
}
|
|
|
|
function measureTextLayout(text, glyphSize, spacingFactor, extraGapIndices = []) {
|
|
const glyphW = glyphSize;
|
|
const colonIndex = text.indexOf(':');
|
|
let cursor = 0;
|
|
let colonCenter = 0;
|
|
for (let i = 0; i < text.length; i++) {
|
|
if (i === colonIndex) {
|
|
colonCenter = cursor + glyphW * 0.5;
|
|
}
|
|
cursor += glyphW * spacingFactor;
|
|
if (extraGapIndices.includes(i)) cursor += glyphW / DOT_COLS;
|
|
}
|
|
return { width: cursor, colonCenter };
|
|
}
|
|
|
|
function drawDotChar(ctx, ch, x, y, w, h, dotRadius, glow = true, color = 'rgb(255,0,0)') {
|
|
const rows = DOT_FONT[ch] || DOT_FONT['0'];
|
|
const cols = rows[0].length;
|
|
const r = Math.min(dotRadius, Math.max(1, Math.min(w, h) * 0.04));
|
|
const cellW = w / cols;
|
|
const cellH = h / rows.length;
|
|
for (let row = 0; row < rows.length; row++) {
|
|
for (let col = 0; col < cols; col++) {
|
|
if (rows[row][col] === '1') {
|
|
const cx = x + col * cellW + cellW / 2;
|
|
const cy = y + row * cellH + cellH / 2;
|
|
drawLed(ctx, cx, cy, r, glow, color);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawLed(ctx, cx, cy, r, glow = true, color = 'rgb(255,0,0)') {
|
|
if (r <= 0) return;
|
|
if (glow) {
|
|
const haloR = r * 2.2;
|
|
const midR = r * 1.4;
|
|
ctx.fillStyle = colorWithAlpha(color, 0.08);
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, haloR, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.fillStyle = colorWithAlpha(color, 0.35);
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, midR, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.fillStyle = color;
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
function pad2(n) {
|
|
return n < 10 ? `0${n}` : String(n);
|
|
}
|
|
|
|
function isSecondPulseActive(date, pulseMs = 500) {
|
|
return date.getMilliseconds() < pulseMs;
|
|
}
|
|
|
|
function normalizeColor(val) {
|
|
if (typeof val !== 'string' || !val.trim()) return '#ff0000';
|
|
return val.trim();
|
|
}
|
|
|
|
function colorWithAlpha(color, alpha) {
|
|
const c = normalizeColor(color);
|
|
if (/^#([0-9a-fA-F]{6})$/.test(c)) {
|
|
const r = parseInt(c.slice(1, 3), 16);
|
|
const g = parseInt(c.slice(3, 5), 16);
|
|
const b = parseInt(c.slice(5, 7), 16);
|
|
return `rgba(${r},${g},${b},${alpha})`;
|
|
}
|
|
if (/^rgb\(/i.test(c)) {
|
|
const nums = c.match(/(\d+\.?\d*)/g)?.slice(0, 3) || [255, 0, 0];
|
|
return `rgba(${nums[0]},${nums[1]},${nums[2]},${alpha})`;
|
|
}
|
|
return `rgba(255,0,0,${alpha})`;
|
|
}
|