// 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)); }