Initial Phoenix analyzer
This commit is contained in:
@@ -0,0 +1,642 @@
|
||||
// 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})`;
|
||||
}
|
||||
Reference in New Issue
Block a user