Files
Phoenix/www/meters/stopwatch.js
T
2026-06-02 20:56:19 +02:00

694 lines
25 KiB
JavaScript

// meters/stopwatch.js — Stoppuhr als Slot-Meter (Start/Stop, Reset, Auto)
// Auto: startet bei Signal > -70 dBFS und stoppt, wenn >=1s lang <= -70 dBFS.
import { CONFIG } from '../core/config.js';
import { HEADER_BG, MID_COLOR } from '../core/theme.js';
export const id = 'stopwatch';
export const disableCache = true;
const AUTO_STOP_AFTER_MS = 1000;
function isExternalClient() {
const host = String(globalThis?.location?.hostname || '').trim().toLowerCase();
return !!host && host !== 'localhost' && host !== '127.0.0.1' && host !== '::1' && host !== '[::1]';
}
function getSilenceDb() {
const raw = Number(CONFIG?.STOPWATCH_AUTO_THRESHOLD_DBFS);
return Math.max(-120, Math.min(20, Number.isFinite(raw) ? raw : -70));
}
export function initShared() {
const now = performance.now();
return {
auto: false,
entries: [], // { elapsedMs, startTs, running }
scrollOffset: 0, // 0 = neueste; größer = weiter zurück
_dragScroll: null, // { startY, startOffset }
_lastNow: now,
_silenceStartTs: null,
_lastLevelDb: -120,
_buttonRects: null,
_deleteVisible: false,
_deleteRects: [],
_scrollInfo: null,
};
}
export function update(packet, shared) {
const now = performance.now();
shared._lastNow = now;
const rmsL = Number.isFinite(packet?.rmsL) ? packet.rmsL : -Infinity;
const rmsR = Number.isFinite(packet?.rmsR) ? packet.rmsR : -Infinity;
const levelDb = Number.isFinite(rmsL) || Number.isFinite(rmsR) ? Math.max(rmsL, rmsR) : -Infinity;
shared._lastLevelDb = Number.isFinite(levelDb) ? levelDb : -120;
if (!shared.auto) return;
const silenceDb = getSilenceDb();
const hasSignal = Number.isFinite(levelDb) && levelDb > silenceDb;
if (hasSignal) {
shared._silenceStartTs = null;
if (!isRunning(shared)) startNew(shared, now);
return;
}
if (shared._silenceStartTs === null) shared._silenceStartTs = now;
if (isRunning(shared) && (now - shared._silenceStartTs) >= AUTO_STOP_AFTER_MS) {
stop(shared, now);
}
}
function stop(shared, now) {
const entry = getActiveEntry(shared);
if (!entry || !entry.running) return;
const startTs = Number.isFinite(entry.startTs) ? entry.startTs : now;
entry.elapsedMs = (entry.elapsedMs || 0) + Math.max(0, now - startTs);
entry.running = false;
entry.startTs = 0;
}
function startNew(shared, now) {
const entries = Array.isArray(shared.entries) ? shared.entries : [];
shared.entries = entries;
if (isRunning(shared)) return;
// Neueste Einträge immer sichtbar halten: Liste "scrollt" automatisch nach oben.
shared.scrollOffset = 0;
entries.push({ elapsedMs: 0, startTs: now, running: true });
if (entries.length > 50) entries.shift();
shared._silenceStartTs = null;
}
function reset(shared, now) {
shared.entries = [];
shared.scrollOffset = 0;
shared._dragScroll = null;
shared._deleteRects = [];
shared._silenceStartTs = null;
}
function formatTime(ms) {
const clamped = Math.max(0, ms | 0);
const totalSeconds = Math.floor(clamped / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const tenths = Math.floor((clamped % 1000) / 100);
const pad2 = (n) => String(n).padStart(2, '0');
if (hours > 0) return `${hours}:${pad2(minutes)}:${pad2(seconds)}`;
return `${pad2(minutes)}:${pad2(seconds)}.${tenths}`;
}
// Orientiert am Layout-Ansatz der anderen Meter (z.B. PPM DIN):
// innerhalb eines festen innerPad wird "extra/2" genutzt, sodass bei ungerader Restbreite
// eine stabile .5-Zentrierung entsteht (wirkt über Browser/Displays konsistenter).
function computeContentRect(rect, padX = 8) {
const innerPad = padX;
const avail = Math.max(1, rect.w - innerPad * 2);
const w = (avail > 2) ? (avail - 1) : avail; // erzwingt i.d.R. extra=1 → +0.5 Zentrierung
const extra = Math.max(0, avail - w);
const x = rect.x + innerPad + extra / 2;
return { x, w, cx: x + w / 2 };
}
function fitFontSizeToWidth(g, texts, maxWidth, {
min = 10,
max = 96,
family = 'ui-monospace, monospace',
weight = 'bold',
} = {}) {
const safeTexts = (Array.isArray(texts) ? texts : []).filter((t) => typeof t === 'string' && t.length);
const samples = safeTexts.length ? safeTexts : ['00:00.0'];
const width = Math.max(1, maxWidth);
let lo = Math.max(1, min | 0);
let hi = Math.max(lo, max | 0);
let best = lo;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
g.font = `${weight} ${mid}px ${family}`;
let widest = 0;
for (const t of samples) widest = Math.max(widest, g.measureText(t).width);
if (widest <= width) {
best = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return best;
}
function sevenSegCharRatio(ch) {
if (ch >= '0' && ch <= '9') return 0.64;
if (ch === ':') return 0.26;
if (ch === '.') return 0.24;
if (ch === '-') return 0.45;
return 0.5;
}
function sevenSegTotalRatio(text, gapRatio = 0.14) {
const s = String(text ?? '');
if (!s.length) return 1;
let sum = 0;
for (let i = 0; i < s.length; i++) sum += sevenSegCharRatio(s[i]);
sum += gapRatio * (s.length - 1);
return Math.max(1e-6, sum);
}
function drawSevenSegString(g, text, xCenter, yTop, maxWidth, height, color, {
gapRatio = 0.14,
dimAlpha = 0.25,
} = {}) {
const s = String(text ?? '');
const h = Math.max(6, height);
const ratio = sevenSegTotalRatio(s, gapRatio);
const wTotal = Math.min(maxWidth, ratio * h);
const scale = wTotal / (ratio * h);
const hUsed = h * scale;
const gap = gapRatio * hUsed;
const x0 = xCenter - wTotal / 2;
let x = x0;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
const cw = sevenSegCharRatio(ch) * hUsed;
if (ch >= '0' && ch <= '9') {
drawSevenSegDigit(g, ch, x, yTop, cw, hUsed, color);
} else if (ch === ':') {
drawSevenSegColon(g, x, yTop, cw, hUsed, color);
} else if (ch === '.') {
drawSevenSegDot(g, x, yTop, cw, hUsed, color);
} else if (ch === '-') {
drawSevenSegMinus(g, x, yTop, cw, hUsed, color);
} else {
g.save();
g.globalAlpha = dimAlpha;
drawSevenSegMinus(g, x, yTop, cw, hUsed, color);
g.restore();
}
x += cw + gap;
}
return { width: wTotal, height: hUsed };
}
function drawSevenSegDigit(g, ch, x, y, w, h, color) {
const digit = Number(ch);
const on = SEGMENTS_BY_DIGIT[digit] || SEGMENTS_BY_DIGIT[0];
const t = Math.max(2, Math.round(h * 0.14));
const r = Math.max(1, Math.round(t * 0.35));
const pad = Math.max(1, Math.round(t * 0.45));
const halfH = h / 2;
const vSegH = Math.max(2, Math.floor(halfH - pad - t / 2));
const seg = {
A: { x: x + pad, y: y, w: Math.max(2, w - 2 * pad), h: t },
D: { x: x + pad, y: y + h - t, w: Math.max(2, w - 2 * pad), h: t },
G: { x: x + pad, y: y + halfH - t / 2, w: Math.max(2, w - 2 * pad), h: t },
F: { x: x, y: y + pad, w: t, h: vSegH },
B: { x: x + w - t, y: y + pad, w: t, h: vSegH },
E: { x: x, y: y + halfH + t / 2, w: t, h: vSegH },
C: { x: x + w - t, y: y + halfH + t / 2, w: t, h: vSegH },
};
g.save();
g.fillStyle = color;
for (const key of Object.keys(seg)) {
const s = seg[key];
const alpha = on.has(key) ? 1 : 0.12;
g.globalAlpha = alpha;
g.beginPath();
roundedRect(g, s.x, s.y, s.w, s.h, r);
g.fill();
}
g.restore();
}
function drawSevenSegColon(g, x, y, w, h, color) {
const r = Math.max(2, Math.round(h * 0.06));
const cx = x + w / 2;
const y1 = y + h * 0.33;
const y2 = y + h * 0.67;
g.save();
g.fillStyle = color;
g.globalAlpha = 0.95;
g.beginPath(); g.arc(cx, y1, r, 0, Math.PI * 2); g.fill();
g.beginPath(); g.arc(cx, y2, r, 0, Math.PI * 2); g.fill();
g.restore();
}
function drawSevenSegDot(g, x, y, w, h, color) {
const r = Math.max(2, Math.round(h * 0.06));
const cx = x + w / 2;
const cy = y + h * 0.82;
g.save();
g.fillStyle = color;
g.globalAlpha = 0.95;
g.beginPath(); g.arc(cx, cy, r, 0, Math.PI * 2); g.fill();
g.restore();
}
function drawSevenSegMinus(g, x, y, w, h, color) {
const t = Math.max(2, Math.round(h * 0.14));
const r = Math.max(1, Math.round(t * 0.35));
const pad = Math.max(1, Math.round(t * 0.45));
const seg = { x: x + pad, y: y + h / 2 - t / 2, w: Math.max(2, w - 2 * pad), h: t };
g.save();
g.fillStyle = color;
g.globalAlpha = 0.95;
g.beginPath();
roundedRect(g, seg.x, seg.y, seg.w, seg.h, r);
g.fill();
g.restore();
}
const SEGMENTS_BY_DIGIT = [
new Set(['A','B','C','D','E','F']),
new Set(['B','C']),
new Set(['A','B','G','E','D']),
new Set(['A','B','G','C','D']),
new Set(['F','G','B','C']),
new Set(['A','F','G','C','D']),
new Set(['A','F','G','E','C','D']),
new Set(['A','B','C']),
new Set(['A','B','C','D','E','F','G']),
new Set(['A','B','C','D','F','G']),
];
function computeButtons(rect) {
const padX = 8;
const padY = 0;
const external = isExternalClient();
let gap = external ? 6 : 8;
let btnH = external ? 32 : 39;
const content = computeContentRect(rect, padX);
const btnW = Math.max(60, content.w);
const availableH = Math.max(84, rect.h - 18);
const wantedH = btnH * 3 + gap * 2;
if (wantedH > availableH) {
const scale = availableH / wantedH;
btnH = Math.max(external ? 18 : 24, Math.floor(btnH * scale));
gap = Math.max(external ? 2 : 3, Math.floor(gap * scale));
}
const x = content.x;
const totalH = btnH * 3 + gap * 2;
const yBottom = rect.y + rect.h - padY;
const y0 = yBottom - totalH;
const r1 = { x, y: y0, w: btnW, h: btnH };
const deleteToggleW = Math.max(btnH, Math.min(Math.floor(btnW * 0.28), external ? 42 : 52));
const resetW = Math.max(1, btnW - gap - deleteToggleW);
const r2 = { x, y: y0 + btnH + gap, w: resetW, h: btnH };
const rDelete = { x: x + resetW + gap, y: r2.y, w: deleteToggleW, h: btnH };
const r3 = { x, y: y0 + (btnH + gap) * 2, w: btnW, h: btnH };
return { startStop: r1, reset: r2, deleteToggle: rDelete, auto: r3, yTop: y0 };
}
function pointInRect(x, y, r) {
return x >= r.x && x <= (r.x + r.w) && y >= r.y && y <= (r.y + r.h);
}
function getActiveEntry(shared) {
const entries = Array.isArray(shared.entries) ? shared.entries : [];
if (!entries.length) return null;
return entries[entries.length - 1] || null;
}
function isRunning(shared) {
const entry = getActiveEntry(shared);
return !!entry?.running;
}
function elapsedForEntry(entry, now) {
const base = Number.isFinite(entry?.elapsedMs) ? entry.elapsedMs : 0;
if (!entry?.running) return base;
const startTs = Number.isFinite(entry?.startTs) ? entry.startTs : now;
return base + Math.max(0, now - startTs);
}
function sumAllEntriesMs(entries, now) {
const list = Array.isArray(entries) ? entries : [];
let sum = 0;
for (const entry of list) sum += elapsedForEntry(entry, now);
return sum;
}
function deleteEntry(shared, index) {
const entries = Array.isArray(shared.entries) ? shared.entries : [];
if (index < 0 || index >= entries.length) return false;
const removed = entries.splice(index, 1)[0];
if (removed?.running) shared._silenceStartTs = null;
shared.scrollOffset = Math.max(0, shared.scrollOffset || 0);
shared._dragScroll = null;
return true;
}
export function pointer(evt, rect, _CONFIG, shared) {
if (!evt) return false;
const x = Number(evt.x);
const y = Number(evt.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
const now = shared._lastNow || performance.now();
const btns = shared._buttonRects || computeButtons(rect);
const scroll = shared._scrollInfo;
if (evt.type === 'pointerdown') {
if (shared._deleteVisible) {
const deleteRects = Array.isArray(shared._deleteRects) ? shared._deleteRects : [];
for (const hit of deleteRects) {
if (hit?.rect && pointInRect(x, y, hit.rect)) {
return deleteEntry(shared, hit.index);
}
}
}
}
if (evt.type === 'wheel') {
if (!scroll || !scroll.scrollable || !pointInRect(x, y, scroll.listRect)) return false;
const dy = Number(evt.deltaY);
if (!Number.isFinite(dy) || dy === 0) return false;
const sign = Math.sign(dy);
const step = Math.max(1, Math.round(Math.abs(dy) / 100));
// "Natural": scroll down => Richtung neueste (Offset ↓), scroll up => Richtung ältere (Offset ↑)
shared.scrollOffset = clampInt((shared.scrollOffset || 0) - sign * step, 0, scroll.maxOffset);
return true;
}
if (evt.type === 'pointerdown' && pointInRect(x, y, btns.startStop)) {
// Manuelles Bedienen schaltet Auto aus, damit die Uhr nicht sofort wieder "zurückspringt".
shared.auto = false;
if (isRunning(shared)) stop(shared, now);
else startNew(shared, now);
return true;
}
if (evt.type === 'pointerdown' && pointInRect(x, y, btns.reset)) {
reset(shared, now);
return true;
}
if (evt.type === 'pointerdown' && btns.deleteToggle && pointInRect(x, y, btns.deleteToggle)) {
shared._deleteVisible = !shared._deleteVisible;
shared._deleteRects = [];
return true;
}
if (evt.type === 'pointerdown' && pointInRect(x, y, btns.auto)) {
shared.auto = !shared.auto;
shared._silenceStartTs = null;
if (shared.auto) {
const levelDb = Number.isFinite(shared._lastLevelDb) ? shared._lastLevelDb : -120;
if (levelDb > getSilenceDb()) startNew(shared, now);
}
return true;
}
if (!scroll || !scroll.scrollable) return false;
if (evt.type === 'pointerdown') {
if (pointInRect(x, y, scroll.listRect)) {
shared._dragScroll = { startY: y, startOffset: shared.scrollOffset || 0 };
return true;
}
return false;
}
if (evt.type === 'pointermove') {
if (!shared._dragScroll) return false;
const dy = y - shared._dragScroll.startY;
const stepPx = Math.max(6, Number(scroll.lineStepPx) || 24);
const deltaLines = Math.round(dy / stepPx);
// "Natural": Finger nach unten => ältere Einträge (Offset ↑), Finger nach oben => neuere (Offset ↓)
shared.scrollOffset = clampInt((shared._dragScroll.startOffset || 0) + deltaLines, 0, scroll.maxOffset);
return true;
}
if (evt.type === 'pointerup' || evt.type === 'pointercancel') {
if (!shared._dragScroll) return false;
shared._dragScroll = null;
return true;
}
return false;
}
export function draw(g, rect, _CONFIG, shared) {
g.save();
try {
const now = performance.now();
const entries = Array.isArray(shared.entries) ? shared.entries : [];
const style = (_CONFIG?.STOPWATCH_DISPLAY_STYLE === 'seven') ? 'seven' : 'mono';
const external = isExternalClient();
const padX = 8;
const content = computeContentRect(rect, padX);
// Header-Hintergrund säubern (wie andere Meter)
g.save();
g.fillStyle = HEADER_BG;
g.fillRect(rect.x, rect.y - 24, rect.w, 26);
g.restore();
// Titel / Status
g.save();
g.textAlign = 'center';
g.fillStyle = CONFIG.HEADER_TEXT_COLOR || MID_COLOR;
g.font = 'bold 12px ui-monospace, monospace';
const status = shared.auto ? 'AUTO' : (isRunning(shared) ? 'RUN' : 'STOP');
g.fillText(`Stoppuhr (${status})`, content.cx, rect.y - 12);
g.restore();
// Zeit-Anzeige (oben) + Historie darunter (jede Start-Aktion erzeugt eine neue Zeile)
const btns = computeButtons(rect);
shared._buttonRects = btns;
const contentTop = rect.y + 10;
const contentBottom = btns.yTop - 4;
const contentH = Math.max(0, contentBottom - contentTop);
const timeW = content.w;
const listRect = { x: content.x, y: contentTop, w: timeW, h: contentH };
const deleteBtnW = Math.max(external ? 18 : 22, Math.min(external ? 24 : 28, Math.floor(content.w * 0.16)));
const deleteGap = Math.max(external ? 4 : 6, Math.round(deleteBtnW * 0.28));
const canDeleteRows = content.w >= (external ? 88 : 112);
const showDeleteRows = !!shared._deleteVisible && canDeleteRows;
const rowTimeW = showDeleteRows ? Math.max(1, content.w - deleteBtnW - deleteGap) : content.w;
const rowTimeCx = showDeleteRows ? content.x + rowTimeW / 2 : content.cx;
const deleteX = content.x + content.w - deleteBtnW;
const deleteRects = [];
const drawDeleteBtn = (r, active = false) => {
g.save();
g.fillStyle = active ? 'rgba(255,59,59,0.18)' : 'rgba(255,59,59,0.1)';
g.strokeStyle = active ? '#ff3b3b' : 'rgba(255,120,120,0.65)';
g.lineWidth = 1.2;
g.beginPath();
roundedRect(g, r.x, r.y, r.w, r.h, 5);
g.fill();
g.stroke();
g.fillStyle = '#ffd6d6';
const labelSize = Math.max(9, Math.min(13, Math.floor(r.h * 0.56)));
g.font = `bold ${labelSize}px ui-monospace, monospace`;
g.textAlign = 'center';
g.textBaseline = 'middle';
g.fillText('X', r.x + r.w / 2, r.y + r.h / 2 + 0.5);
g.restore();
};
if (style === 'seven') {
const lineGap = 8;
const totalText = formatTime(sumAllEntriesMs(entries, now));
const entrySizingTexts = entries.length
? entries.map((e) => formatTime(elapsedForEntry(e, now)))
: [formatTime(0)];
const entryRatioMax = Math.max(...entrySizingTexts.map((t) => sevenSegTotalRatio(t)));
const totalRatio = sevenSegTotalRatio(totalText);
// Einzelzeiten und Summe getrennt skalieren: nur die Zeilen reservieren Platz für die X-Buttons.
let segH = Math.floor(rowTimeW / Math.max(1e-6, entryRatioMax));
let totalSegH = Math.floor(timeW / Math.max(1e-6, totalRatio));
segH = Math.max(external ? 10 : 12, Math.min(external ? 108 : 140, segH));
totalSegH = Math.max(external ? 10 : 12, Math.min(external ? 108 : 140, totalSegH));
// Höhe begrenzen: mindestens 1 Eintrag + Summenzeile muss passen.
while (segH > (external ? 10 : 12) || totalSegH > (external ? 10 : 12)) {
const refH = Math.max(segH, totalSegH);
const topPad = Math.max(external ? 6 : 10, Math.round(refH * (external ? 0.18 : 0.28)));
const bottomPad = Math.max(external ? 5 : 8, Math.round(refH * (external ? 0.12 : 0.18)));
const needed = segH + totalSegH + lineGap + topPad + bottomPad;
if (needed <= contentH) break;
if (totalSegH > segH) totalSegH -= 1;
else segH -= 1;
}
const refH = Math.max(segH, totalSegH);
const topPad = Math.max(external ? 6 : 10, Math.round(refH * (external ? 0.18 : 0.28)));
const bottomPad = Math.max(external ? 5 : 8, Math.round(refH * (external ? 0.12 : 0.18)));
const stepY = segH + (external ? 5 : lineGap);
const totalY = Math.floor(contentBottom - bottomPad - totalSegH);
const ySep = Math.round(totalY - topPad) + 0.5;
const entryAreaH = Math.max(0, ySep - contentTop);
const maxEntryByHeight = Math.max(1, Math.floor((entryAreaH + lineGap) / Math.max(1, stepY)));
const displayEntryLines = maxEntryByHeight;
const maxOffset = Math.max(0, entries.length - displayEntryLines);
shared.scrollOffset = clampInt(shared.scrollOffset || 0, 0, maxOffset);
const start = Math.max(0, entries.length - displayEntryLines - (shared.scrollOffset || 0));
const visible = entries.length ? entries.slice(start, start + displayEntryLines) : [];
for (let i = 0; i < visible.length; i++) {
const entry = visible[i];
const running = !!entry?.running;
const y = contentTop + i * stepY;
const col = running ? '#00e7ff' : 'rgba(255,255,255,0.9)';
drawSevenSegString(g, formatTime(elapsedForEntry(entry, now)), rowTimeCx, y, rowTimeW, segH, col);
if (showDeleteRows) {
const r = { x: deleteX, y: y + Math.max(0, (segH - deleteBtnW) / 2), w: deleteBtnW, h: Math.min(deleteBtnW, segH) };
deleteRects.push({ index: start + i, rect: r });
drawDeleteBtn(r, running);
}
}
// Trennlinie über der Summenzeile (10. Zeile bleibt unten fix)
g.save();
g.strokeStyle = 'rgba(0,231,255,0.55)';
g.setLineDash([6, 5]);
g.beginPath();
g.moveTo(content.x, ySep);
g.lineTo(content.x + timeW, ySep);
g.stroke();
g.setLineDash([]);
g.restore();
drawSevenSegString(g, totalText, content.cx, totalY, timeW, totalSegH, '#34d399');
const scrollable = maxOffset > 0;
const entryRect = { x: content.x, y: contentTop, w: timeW, h: entryAreaH };
shared._scrollInfo = { scrollable, maxLines: displayEntryLines, maxOffset, listRect: entryRect, lineStepPx: stepY };
} else {
const totalText = formatTime(sumAllEntriesMs(entries, now));
const sampleEntries = entries;
const sampleTexts = sampleEntries.length ? sampleEntries.map((e) => formatTime(elapsedForEntry(e, now))) : [formatTime(0)];
let fontSize = fitFontSizeToWidth(g, sampleTexts, rowTimeW, { min: external ? 12 : 14, max: external ? 108 : 140 });
let totalFontSize = fitFontSizeToWidth(g, [totalText], timeW, { min: external ? 12 : 14, max: external ? 108 : 140 });
fontSize = Math.max(external ? 12 : 14, Math.min(external ? 108 : 140, fontSize));
totalFontSize = Math.max(external ? 12 : 14, Math.min(external ? 108 : 140, totalFontSize));
// Höhe begrenzen: mindestens 1 Eintrag + Summenzeile muss passen
while (fontSize > (external ? 12 : 14) || totalFontSize > (external ? 12 : 14)) {
const refSize = Math.max(fontSize, totalFontSize);
const lineGap = Math.max(external ? 2 : 4, Math.round(refSize * (external ? 0.12 : 0.18)));
const topPad = Math.max(external ? 6 : 10, Math.round(refSize * (external ? 0.18 : 0.28)));
const bottomPad = Math.max(external ? 5 : 8, Math.round(refSize * (external ? 0.12 : 0.18)));
const needed = fontSize + totalFontSize + lineGap + topPad + bottomPad;
if (needed <= contentH) break;
if (totalFontSize > fontSize) totalFontSize -= 1;
else fontSize -= 1;
}
const refSize = Math.max(fontSize, totalFontSize);
const lineGap = Math.max(external ? 2 : 4, Math.round(refSize * (external ? 0.12 : 0.18)));
const topPad = Math.max(external ? 6 : 10, Math.round(refSize * (external ? 0.18 : 0.28)));
const bottomPad = Math.max(external ? 5 : 8, Math.round(refSize * (external ? 0.12 : 0.18)));
const stepY = fontSize + lineGap;
const totalY = Math.floor(contentBottom - bottomPad - totalFontSize);
const ySep = Math.round(totalY - topPad) + 0.5;
const entryAreaH = Math.max(0, ySep - contentTop);
const maxEntryByHeight = Math.max(1, Math.floor((entryAreaH + lineGap) / Math.max(1, stepY)));
const displayEntryLines = maxEntryByHeight;
const maxOffset = Math.max(0, entries.length - displayEntryLines);
shared.scrollOffset = clampInt(shared.scrollOffset || 0, 0, maxOffset);
const start = Math.max(0, entries.length - displayEntryLines - (shared.scrollOffset || 0));
const visible = entries.slice(start, start + displayEntryLines);
g.save();
g.textAlign = 'center';
g.textBaseline = 'top';
g.font = `bold ${fontSize}px ui-monospace, monospace`;
for (let i = 0; i < visible.length; i++) {
const entry = visible[i];
const running = !!entry?.running;
const y = contentTop + i * stepY;
const ms = elapsedForEntry(entry, now);
g.fillStyle = running ? '#00e7ff' : 'rgba(255,255,255,0.9)';
g.fillText(formatTime(ms), rowTimeCx, y);
if (showDeleteRows) {
const btnH = Math.max(16, Math.min(deleteBtnW, fontSize));
const r = { x: deleteX, y: y + Math.max(0, (fontSize - btnH) / 2), w: deleteBtnW, h: btnH };
deleteRects.push({ index: start + i, rect: r });
drawDeleteBtn(r, running);
}
}
g.save();
g.strokeStyle = 'rgba(0,231,255,0.55)';
g.setLineDash([6, 5]);
g.beginPath();
g.moveTo(content.x, ySep);
g.lineTo(content.x + timeW, ySep);
g.stroke();
g.setLineDash([]);
g.restore();
g.fillStyle = '#34d399';
g.font = `bold ${totalFontSize}px ui-monospace, monospace`;
g.fillText(totalText, content.cx, totalY);
g.textBaseline = 'alphabetic';
g.restore();
const scrollable = maxOffset > 0;
const entryRect = { x: content.x, y: contentTop, w: timeW, h: entryAreaH };
shared._scrollInfo = { scrollable, maxLines: displayEntryLines, maxOffset, listRect: entryRect, lineStepPx: stepY };
}
shared._deleteRects = deleteRects;
// Buttons
const drawBtn = (r, label, active = false, warn = false, { blink = false } = {}) => {
g.save();
if (active && blink) {
const phase = (now % 800) / 800; // 0..1
const pulse = 0.08 + 0.22 * (0.5 + 0.5 * Math.sin(phase * Math.PI * 2));
g.fillStyle = `rgba(0,231,255,${pulse.toFixed(3)})`;
} else {
g.fillStyle = active ? 'rgba(0,231,255,0.18)' : 'rgba(180,200,220,0.08)';
}
g.strokeStyle = warn ? '#ff3b3b' : (active ? '#00e7ff' : 'rgba(0,231,255,0.55)');
g.lineWidth = 1.5;
g.beginPath();
const rad = 6;
roundedRect(g, r.x, r.y, r.w, r.h, rad);
g.fill();
g.stroke();
g.fillStyle = warn ? '#ffdddd' : '#e7f6ff';
const labelSize = Math.max(external ? 8 : 10, Math.min(external ? 11 : 12, Math.floor(r.h * (external ? 0.32 : 0.34))));
g.font = `bold ${labelSize}px ui-monospace, monospace`;
g.textAlign = 'center';
g.fillText(label, r.x + r.w / 2, r.y + r.h / 2 + Math.max(3, Math.round(labelSize * 0.32)));
g.restore();
};
drawBtn(btns.startStop, isRunning(shared) ? 'Stop' : 'Start', isRunning(shared));
drawBtn(btns.reset, 'Reset', false, true);
drawBtn(btns.deleteToggle, 'X', !!shared._deleteVisible, true);
drawBtn(btns.auto, 'Auto', shared.auto, false, { blink: true });
} finally {
g.restore();
}
}
function roundedRect(g, x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, Math.min(w, h) / 2));
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function clampInt(v, lo, hi) {
const n = Math.round(Number(v));
if (!Number.isFinite(n)) return lo;
return Math.max(lo, Math.min(hi, n));
}