feat(ui): redesign target pipeline as compact strip + chip row

Drops the legacy "Pipeline details" collapsible block on running
LED target cards. Instead:

- Always-visible 4px segmented timing bar (extract / map / smooth /
  send for video, read / fft / render / send for audio) — same
  stage colors as before, scaled by per-segment ms cost
- One chip row beneath it: total ms / frames count / keepalive
  count, using a new .chip--inline variant (display-weighted number
  + tiny mono-caps unit)
- _patchTargetMetrics now writes only the bar's segments and the
  data-tm spans — bar wrapper survives across polls so the
  flex-transition animates smoothly between samples
- _buildLedTimingHTML replaced by _buildLedTimingSegments (no more
  header / total / legend wrappers — those live in the chip row)

Cleanup
- Drop .target-metrics-collapse / -toggle / -animate / -expanded
  CSS — no callers remain
- Drop targets.metrics.pipeline from en/ru/zh locales — toggle
  label is gone
This commit is contained in:
2026-04-27 01:52:24 +03:00
parent 9067db2639
commit 51eebf21d5
5 changed files with 102 additions and 85 deletions
+59 -33
View File
@@ -1253,39 +1253,8 @@ ul.section-tip li {
align-items: center; align-items: center;
} }
/* Collapsible target pipeline metrics */ /* Timing breakdown bar — used by graph-editor and the api-input test
.target-metrics-collapse { modal; the targets card uses its own .target-pipeline wrapper. */
margin-top: 4px;
}
.target-metrics-toggle {
cursor: pointer;
font-size: 0.75rem;
color: var(--text-secondary);
padding: 4px 0;
user-select: none;
background: none;
border: none;
font-family: inherit;
}
.target-metrics-toggle::before {
content: '▸ ';
}
.target-metrics-collapse.open .target-metrics-toggle::before {
content: '▾ ';
}
.target-metrics-animate {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.25s ease;
}
.target-metrics-collapse.open .target-metrics-animate {
grid-template-rows: 1fr;
}
.target-metrics-animate > .target-metrics-expanded {
overflow: hidden;
}
/* Timing breakdown bar */
.timing-breakdown { .timing-breakdown {
margin-top: 8px; margin-top: 8px;
padding: 6px 8px; padding: 6px 8px;
@@ -2188,6 +2157,63 @@ ul.section-tip li {
background: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 10%, transparent); background: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 10%, transparent);
} }
/* Inline chip variant — used by the pipeline strip on running target
cards. A bigger numeric (display-font-ish) sits beside a small mono
caps unit, so a chip can carry "127K · frames" without looking like
a label-shaped pill. The data values inside still update via
_patchTargetMetrics. */
.mod-card .chip--inline {
gap: 4px;
padding: 3px 8px;
}
.mod-card .chip--inline > span {
font-family: var(--font-mono, monospace);
font-size: 0.78rem;
font-weight: 700;
color: var(--lux-ink, var(--text-color));
font-variant-numeric: tabular-nums;
letter-spacing: 0;
}
.mod-card .chip--inline > small {
font-family: var(--font-mono, monospace);
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
}
/* ── Pipeline strip — replaces the old target-metrics-collapse. A
thin always-visible segmented timing bar plus a tight chip row
below it (timing total / frames / keepalive). The bar is the
diagnostic; the chips are the counters. */
.mod-card .target-pipeline {
display: flex;
flex-direction: column;
gap: 8px;
}
.mod-card .target-pipeline .timing-bar {
height: 4px;
border-radius: 2px;
background: var(--lux-bg-0, var(--bg-color));
box-shadow: inset 0 0 0 1px var(--lux-line, var(--border-color));
margin: 0;
overflow: hidden;
gap: 0;
}
.mod-card .target-pipeline .timing-seg {
transition: flex 0.3s ease;
opacity: 0.85;
}
.mod-card .target-pipeline .timing-seg:hover {
opacity: 1;
}
.mod-card .target-pipeline-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
/* ── Brightness fader (mod-card variant) ────────────────────────── */ /* ── Brightness fader (mod-card variant) ────────────────────────── */
.mod-fader { .mod-fader {
@@ -874,37 +874,27 @@ function _cssSourceName(cssId: any, colorStripSourceMap: any) {
// ─── In-place metric patching (avoids full card replacement on polls) ─── // ─── In-place metric patching (avoids full card replacement on polls) ───
function _buildLedTimingHTML(state: any) { /** Builds just the inner segments of the timing bar — no wrapper.
* Each segment is flex-sized by its ms cost and tooltipped with its
* stage label. Used by both the initial render and the patcher.
* The wrapper `.timing-bar` lives in the card markup so the bar
* itself doesn't get replaced on every poll (preserves transitions). */
function _buildLedTimingSegments(state: any): string {
const isAudio = state.timing_audio_read_ms != null; const isAudio = state.timing_audio_read_ms != null;
const send = `<span class="timing-seg timing-send" style="flex:${state.timing_send_ms || 0.1}" title="send ${state.timing_send_ms}ms"></span>`;
if (isAudio) {
return ` return `
<div class="timing-header"> <span class="timing-seg timing-audio-read" style="flex:${state.timing_audio_read_ms || 0.1}" title="read ${state.timing_audio_read_ms}ms"></span>
<div class="metric-label">${t('device.metrics.timing')}</div> <span class="timing-seg timing-audio-fft" style="flex:${state.timing_audio_fft_ms || 0.1}" title="fft ${state.timing_audio_fft_ms}ms"></span>
<div class="timing-total"><strong>${state.timing_total_ms}ms</strong></div>
</div>
<div class="timing-bar">
${isAudio ? `
<span class="timing-seg timing-audio-read" style="flex:${state.timing_audio_read_ms}" title="read ${state.timing_audio_read_ms}ms"></span>
<span class="timing-seg timing-audio-fft" style="flex:${state.timing_audio_fft_ms}" title="fft ${state.timing_audio_fft_ms}ms"></span>
<span class="timing-seg timing-audio-render" style="flex:${state.timing_audio_render_ms || 0.1}" title="render ${state.timing_audio_render_ms}ms"></span> <span class="timing-seg timing-audio-render" style="flex:${state.timing_audio_render_ms || 0.1}" title="render ${state.timing_audio_render_ms}ms"></span>
` : ` ${send}`;
${state.timing_extract_ms != null ? `<span class="timing-seg timing-extract" style="flex:${state.timing_extract_ms}" title="extract ${state.timing_extract_ms}ms"></span>` : ''} }
${state.timing_map_leds_ms != null ? `<span class="timing-seg timing-map" style="flex:${state.timing_map_leds_ms}" title="map ${state.timing_map_leds_ms}ms"></span>` : ''} const segs: string[] = [];
${state.timing_smooth_ms != null ? `<span class="timing-seg timing-smooth" style="flex:${state.timing_smooth_ms || 0.1}" title="smooth ${state.timing_smooth_ms}ms"></span>` : ''} if (state.timing_extract_ms != null) segs.push(`<span class="timing-seg timing-extract" style="flex:${state.timing_extract_ms || 0.1}" title="extract ${state.timing_extract_ms}ms"></span>`);
`} if (state.timing_map_leds_ms != null) segs.push(`<span class="timing-seg timing-map" style="flex:${state.timing_map_leds_ms || 0.1}" title="map ${state.timing_map_leds_ms}ms"></span>`);
<span class="timing-seg timing-send" style="flex:${state.timing_send_ms}" title="send ${state.timing_send_ms}ms"></span> if (state.timing_smooth_ms != null) segs.push(`<span class="timing-seg timing-smooth" style="flex:${state.timing_smooth_ms || 0.1}" title="smooth ${state.timing_smooth_ms}ms"></span>`);
</div> segs.push(send);
<div class="timing-legend"> return segs.join('');
${isAudio ? `
<span class="timing-legend-item"><span class="timing-dot timing-audio-read"></span>read ${state.timing_audio_read_ms}ms</span>
<span class="timing-legend-item"><span class="timing-dot timing-audio-fft"></span>fft ${state.timing_audio_fft_ms}ms</span>
<span class="timing-legend-item"><span class="timing-dot timing-audio-render"></span>render ${state.timing_audio_render_ms}ms</span>
` : `
${state.timing_extract_ms != null ? `<span class="timing-legend-item"><span class="timing-dot timing-extract"></span>extract ${state.timing_extract_ms}ms</span>` : ''}
${state.timing_map_leds_ms != null ? `<span class="timing-legend-item"><span class="timing-dot timing-map"></span>map ${state.timing_map_leds_ms}ms</span>` : ''}
${state.timing_smooth_ms != null ? `<span class="timing-legend-item"><span class="timing-dot timing-smooth"></span>smooth ${state.timing_smooth_ms}ms</span>` : ''}
`}
<span class="timing-legend-item"><span class="timing-dot timing-send"></span>send ${state.timing_send_ms}ms</span>
</div>`;
} }
function _patchTargetMetrics(target: any) { function _patchTargetMetrics(target: any) {
@@ -942,8 +932,11 @@ function _patchTargetMetrics(target: any) {
} }
} }
const timing = card.querySelector('[data-tm="timing"]') as HTMLElement | null; const timingBar = card.querySelector('[data-tm="timing"]') as HTMLElement | null;
if (timing && state.timing_total_ms != null) timing.innerHTML = _buildLedTimingHTML(state); if (timingBar && state.timing_total_ms != null) timingBar.innerHTML = _buildLedTimingSegments(state);
const timingTotal = card.querySelector('[data-tm="timing-total"]') as HTMLElement | null;
if (timingTotal && state.timing_total_ms != null) timingTotal.textContent = String(state.timing_total_ms);
const frames = card.querySelector('[data-tm="frames"]') as HTMLElement | null; const frames = card.querySelector('[data-tm="frames"]') as HTMLElement | null;
if (frames) { frames.textContent = formatCompact(metrics.frames_processed || 0); frames.title = String(metrics.frames_processed || 0); } if (frames) { frames.textContent = formatCompact(metrics.frames_processed || 0); frames.title = String(metrics.frames_processed || 0); }
@@ -1102,25 +1095,26 @@ export function createTargetCard(target: LedOutputTarget & { state?: any; metric
}); });
} }
// ── Live-update raw HTML: collapsible pipeline detail (frames / // ── Pipeline strip — a single segmented timing bar plus one chip
// keepalive / timing breakdown). The FPS sparkline now lives // row underneath. Replaces the legacy collapsible block: the
// inside the FPS metric cell, not in a separate row. ── // bar is the headline diagnostic, the chips carry the rest. ──
const showKeepalive = state.needs_keepalive !== false;
const extraHtml = isProcessing ? ` const extraHtml = isProcessing ? `
<div class="target-metrics-collapse"> <div class="target-pipeline" title="${escapeHtml(t('device.metrics.timing'))}">
<button type="button" class="target-metrics-toggle" onclick="this.parentElement.classList.toggle('open')">${escapeHtml(t('targets.metrics.pipeline'))}</button> <div class="timing-bar" data-tm="timing"></div>
<div class="target-metrics-animate"> <div class="target-pipeline-chips">
<div class="metrics-grid target-metrics-expanded"> <span class="chip chip--inline" title="${escapeHtml(t('device.metrics.timing'))}">
<div class="timing-breakdown" data-tm="timing" style="grid-column:1/-1"></div> ${ICON_CLOCK}<span data-tm="timing-total">--</span><small>ms</small>
<div class="metric"> </span>
<div class="metric-label">${escapeHtml(t('device.metrics.frames'))}</div> <span class="chip chip--inline" title="${escapeHtml(t('device.metrics.frames'))}">
<div class="metric-value" data-tm="frames">---</div> <span data-tm="frames">--</span>
</div> <small>${escapeHtml(t('device.metrics.frames'))}</small>
${state.needs_keepalive !== false ? ` </span>
<div class="metric"> ${showKeepalive ? `
<div class="metric-label">${escapeHtml(t('device.metrics.keepalive'))}</div> <span class="chip chip--inline" title="${escapeHtml(t('device.metrics.keepalive'))}">
<div class="metric-value" data-tm="keepalive">---</div> <span data-tm="keepalive">--</span>
</div>` : ''} <small>${escapeHtml(t('device.metrics.keepalive'))}</small>
</div> </span>` : ''}
</div> </div>
</div>` : ''; </div>` : '';
@@ -702,7 +702,6 @@
"targets.source": "Source:", "targets.source": "Source:",
"targets.source.hint": "Which picture source to capture and process", "targets.source.hint": "Which picture source to capture and process",
"targets.source.none": "-- No source assigned --", "targets.source.none": "-- No source assigned --",
"targets.metrics.pipeline": "Pipeline details",
"targets.fps": "Target FPS:", "targets.fps": "Target FPS:",
"targets.fps.hint": "Target frames per second for capture and LED updates (1-90)", "targets.fps.hint": "Target frames per second for capture and LED updates (1-90)",
"targets.fps.rec": "Hardware max ≈ {fps} fps ({leds} LEDs)", "targets.fps.rec": "Hardware max ≈ {fps} fps ({leds} LEDs)",
@@ -686,7 +686,6 @@
"targets.source": "Источник:", "targets.source": "Источник:",
"targets.source.hint": "Какой источник изображения захватывать и обрабатывать", "targets.source.hint": "Какой источник изображения захватывать и обрабатывать",
"targets.source.none": "-- Источник не назначен --", "targets.source.none": "-- Источник не назначен --",
"targets.metrics.pipeline": "Детали конвейера",
"targets.fps": "Целевой FPS:", "targets.fps": "Целевой FPS:",
"targets.fps.hint": "Целевая частота кадров для захвата и обновления LED (1-90)", "targets.fps.hint": "Целевая частота кадров для захвата и обновления LED (1-90)",
"targets.fps.rec": "Макс. аппаратный ≈ {fps} fps ({leds} LED)", "targets.fps.rec": "Макс. аппаратный ≈ {fps} fps ({leds} LED)",
@@ -686,7 +686,6 @@
"targets.source": "源:", "targets.source": "源:",
"targets.source.hint": "要采集和处理的图片源", "targets.source.hint": "要采集和处理的图片源",
"targets.source.none": "-- 未分配源 --", "targets.source.none": "-- 未分配源 --",
"targets.metrics.pipeline": "管线详情",
"targets.fps": "目标 FPS", "targets.fps": "目标 FPS",
"targets.fps.hint": "采集和 LED 更新的目标帧率(1-90)", "targets.fps.hint": "采集和 LED 更新的目标帧率(1-90)",
"targets.fps.rec": "硬件最大 ≈ {fps} fps{leds} 个 LED", "targets.fps.rec": "硬件最大 ≈ {fps} fps{leds} 个 LED",