Add FPS sparkline chart to target cards, move timing breakdown inline

Replace the three FPS text labels (actual/current/target) with a
Chart.js sparkline chart + compact label, matching the dashboard style.
FPS history (30 samples) persists across poll rebuilds. Pipeline timing
breakdown moved inside the metrics grid directly under the FPS chart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 03:06:18 +03:00
parent 67d141b75b
commit 6d33686b79
2 changed files with 115 additions and 30 deletions

View File

@@ -519,6 +519,36 @@ ul.section-tip li {
color: #999;
}
/* Target FPS sparkline row */
.target-fps-row {
display: flex;
align-items: center;
gap: 8px;
grid-column: 1 / -1;
padding: 3px 8px;
background: var(--bg-color);
border-radius: 4px;
}
.target-fps-sparkline {
position: relative;
flex: 1;
min-width: 0;
overflow: hidden;
height: 36px;
}
.target-fps-label {
flex-shrink: 0;
text-align: right;
}
.target-fps-target {
font-weight: 400;
opacity: 0.5;
font-size: 0.75rem;
}
/* Timing breakdown bar */
.timing-breakdown {
margin-top: 8px;

View File

@@ -22,6 +22,46 @@ import { createColorStripCard } from './color-strips.js';
// Re-render targets tab when language changes
document.addEventListener('languageChanged', () => { if (apiKey) loadTargetsTab(); });
// --- FPS sparkline history for target cards ---
const _TARGET_MAX_FPS_SAMPLES = 30;
const _targetFpsHistory = {};
function _pushTargetFps(targetId, value) {
if (!_targetFpsHistory[targetId]) _targetFpsHistory[targetId] = [];
const h = _targetFpsHistory[targetId];
h.push(value);
if (h.length > _TARGET_MAX_FPS_SAMPLES) h.shift();
}
function _createTargetFpsChart(canvasId, history, fpsTarget) {
const canvas = document.getElementById(canvasId);
if (!canvas) return null;
return new Chart(canvas, {
type: 'line',
data: {
labels: history.map(() => ''),
datasets: [{
data: [...history],
borderColor: '#2196F3',
backgroundColor: 'rgba(33,150,243,0.12)',
borderWidth: 1.5,
tension: 0.3,
fill: true,
pointRadius: 0,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
animation: false,
plugins: { legend: { display: false }, tooltip: { display: false } },
scales: {
x: { display: false },
y: { display: false, min: 0, max: fpsTarget * 1.15 },
},
},
});
}
class TargetEditorModal extends Modal {
constructor() {
super('target-editor-modal');
@@ -410,6 +450,25 @@ export async function loadTargetsTab() {
if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
});
// FPS sparkline charts: push samples and init charts after HTML rebuild
const allTargets = [...ledTargets, ...kcTargets];
const runningIds = new Set();
allTargets.forEach(target => {
if (target.state && target.state.processing) {
runningIds.add(target.id);
if (target.state.fps_actual != null) {
_pushTargetFps(target.id, target.state.fps_actual);
}
const history = _targetFpsHistory[target.id] || [];
const fpsTarget = target.state.fps_target || 30;
_createTargetFpsChart(`target-fps-${target.id}`, history, fpsTarget);
}
});
// Clean up history for targets no longer running
Object.keys(_targetFpsHistory).forEach(id => {
if (!runningIds.has(id)) delete _targetFpsHistory[id];
});
} catch (error) {
if (error.isAuth) return;
console.error('Failed to load targets tab:', error);
@@ -454,18 +513,34 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
<div class="card-content">
${isProcessing ? `
<div class="metrics-grid">
<div class="metric">
<div class="metric-label">${t('device.metrics.actual_fps')}</div>
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
<div class="target-fps-row">
<div class="target-fps-sparkline">
<canvas id="target-fps-${target.id}" data-fps-target="${state.fps_target || 30}"></canvas>
</div>
<div class="target-fps-label">
<span class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}<span class="target-fps-target">/${state.fps_target || 0}</span></span>
</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.current_fps')}</div>
<div class="metric-value">${state.fps_current ?? '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.target_fps')}</div>
<div class="metric-value">${state.fps_target || 0}</div>
${state.timing_total_ms != null ? `
<div class="timing-breakdown" style="grid-column:1/-1">
<div class="timing-header">
<div class="metric-label">${t('device.metrics.timing')}</div>
<div class="timing-total"><strong>${state.timing_total_ms}ms</strong></div>
</div>
<div class="timing-bar">
${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>` : ''}
${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>` : ''}
<span class="timing-seg timing-send" style="flex:${state.timing_send_ms}" title="send ${state.timing_send_ms}ms"></span>
</div>
<div class="timing-legend">
${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>
</div>
` : ''}
<div class="metric">
<div class="metric-label">${t('device.metrics.frames')}</div>
<div class="metric-value">${metrics.frames_processed || 0}</div>
@@ -479,26 +554,6 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
<div class="metric-value">${metrics.errors_count || 0}</div>
</div>
</div>
${state.timing_total_ms != null ? `
<div class="timing-breakdown">
<div class="timing-header">
<div class="metric-label">${t('device.metrics.timing')}</div>
<div class="timing-total"><strong>${state.timing_total_ms}ms</strong></div>
</div>
<div class="timing-bar">
${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>` : ''}
${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>` : ''}
<span class="timing-seg timing-send" style="flex:${state.timing_send_ms}" title="send ${state.timing_send_ms}ms"></span>
</div>
<div class="timing-legend">
${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>
</div>
` : ''}
` : ''}
</div>
<div class="card-actions">