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