Speed history: rework chart and add live stats

- Replace 8-point scatter chart with proper 60-point line chart
  - Blue gradient fill (rgba 35%→2%), tension 0.4 smooth curve
  - No legend (obvious), no X axis grid, soft Y axis gridlines
  - Y axis auto-scales: max(60, sessionMax*1.2) rounded to 10s
  - Ticks: '0 km/h' labels on Y, time strings on X (max 6)
  - chart.update('none') for live updates (no animation stutter)
- Speed history card header now shows current speed + session max
  even when the graph is collapsed
- Footer bar inside expanded graph: session avg, point count, last reading time
- makeGradient() refreshed on every update in case canvas was resized
- obd_rpm now appends ' rpm' to the displayed value

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-09 23:14:32 -04:00
co-authored by Copilot
parent beddfec8a5
commit e2f7fa88df
2 changed files with 143 additions and 68 deletions
+126 -59
View File
@@ -1,120 +1,187 @@
const socket = io(); const socket = io();
// map
// Offline tiles pre-downloaded via scripts/download_map_tiles.py (see PLAN.md // ── Map ────────────────────────────────────────────────────────────────────
// Phase 3). Only zoom 8-15 is cached by default - matches DEFAULT_MIN_ZOOM/
// DEFAULT_MAX_ZOOM in that script.
// Generic placeholder center (not a real location) - re-centers automatically
// to the real live GPS fix once one comes in (see gps_location handler below).
var map = L.map('map', { minZoom: 8, maxZoom: 15 }).setView([48.505, -0.09], 12); var map = L.map('map', { minZoom: 8, maxZoom: 15 }).setView([48.505, -0.09], 12);
const mapTiles = L.tileLayer('/static/vendor/tiles/{z}/{x}/{y}.png', { const mapTiles = L.tileLayer('/static/vendor/tiles/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.maptiler.com/copyright/" target="_blank">MapTiler</a> &copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors', attribution: '&copy; <a href="https://www.maptiler.com/copyright/" target="_blank">MapTiler</a> &copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors',
minZoom: 8, minZoom: 8,
maxZoom: 15 maxZoom: 15,
}); });
var carMarker = L.marker(map.getCenter()).addTo(map) var carMarker = L.marker(map.getCenter()).addTo(map).bindPopup('Your Yaris');
.bindPopup('your car... soon(tm)');
// charts // ── Speed history chart ────────────────────────────────────────────────────
const MAX_SPEED_POINTS = 60; // ~5 min at typical OBD poll rate
const ctx = document.getElementById('speedGraph').getContext('2d'); const ctx = document.getElementById('speedGraph').getContext('2d');
chart = new Chart(ctx, {
// Gradient fill (created after canvas is sized)
function makeGradient() {
const grad = ctx.createLinearGradient(0, 0, 0, ctx.canvas.clientHeight || 200);
grad.addColorStop(0, 'rgba(78,115,223,0.35)');
grad.addColorStop(1, 'rgba(78,115,223,0.02)');
return grad;
}
const chart = new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
labels: [],
datasets: [{ datasets: [{
label: 'Vehicle speed', label: 'Speed (km/h)',
borderColor: 'rgba(75, 192, 192, 1)', data: [],
fill: false, borderColor: '#4e73df',
backgroundColor: makeGradient(),
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: '#4e73df',
tension: 0.4,
fill: true,
}] }]
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false,
animation: { duration: 200 },
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function (ctx) {
return ctx.parsed.y != null ? ctx.parsed.y + ' km/h' : '';
}
}
}
},
scales: { scales: {
x: { x: {
reverse: true, grid: { display: false },
title: { ticks: {
display: true, maxTicksLimit: 6,
text: 'Time', color: '#adb5bd',
font: { size: 10 },
maxRotation: 0,
} }
}, },
y: { y: {
title: { beginAtZero: true,
display: true, suggestedMax: 60,
text: 'Speed' grid: { color: 'rgba(0,0,0,.05)' },
ticks: {
color: '#adb5bd',
font: { size: 10 },
callback: function (v) { return v + ' km/h'; },
maxTicksLimit: 5,
} }
} }
} }
} }
}); });
// websockets // Session stats
let sessionMaxSpeed = 0;
let sessionSpeedSum = 0;
let sessionSpeedCount = 0;
socket.on('connect', function(data) { function updateSpeedStats(speed) {
console.log('socket.io is connected') if (speed == null || typeof speed !== 'number') return;
// Update session max
if (speed > sessionMaxSpeed) {
sessionMaxSpeed = speed;
const maxEl = document.getElementById('speed-max-badge');
if (maxEl) maxEl.textContent = speed.toFixed(0) + ' km/h';
}
// Update session avg
sessionSpeedSum += speed;
sessionSpeedCount++;
const avgEl = document.getElementById('speed-avg-badge');
if (avgEl) avgEl.textContent = (sessionSpeedSum / sessionSpeedCount).toFixed(1) + ' km/h';
// Points count
const ptEl = document.getElementById('speed-points-badge');
if (ptEl) ptEl.textContent = sessionSpeedCount;
}
// ── WebSocket events ──────────────────────────────────────────────────────
socket.on('connect', function () {
console.log('socket.io connected');
}); });
socket.on('obd_status', function(data) { socket.on('obd_status', function (data) {
console.log(data); const el = document.getElementById('obd-status');
const status = document.getElementById('obd-status'); if (el) el.innerHTML = data;
status.innerHTML = data;
}); });
socket.on('obd_speed', function(data) { socket.on('obd_speed', function (data) {
const status = document.getElementById('obd-speed'); const statusEl = document.getElementById('obd-speed');
const nowBadge = document.getElementById('speed-now-badge');
const lastTime = document.getElementById('speed-last-time');
if (!Array.isArray(data)) { if (!Array.isArray(data)) {
status.textContent = data; if (statusEl) statusEl.textContent = data;
if (nowBadge) nowBadge.textContent = '—';
return; return;
} }
const time = data[0]; // Assuming data[0] is the time value const time = data[0]; // "HH:MM:SS"
const speed = data[1]; // Assuming data[1] is the speed value const speed = data[1]; // number, km/h
const maxDataPoints = 8; // Adjust this value as needed
status.innerHTML = speed; // Update stat cards and badges
if (statusEl) statusEl.textContent = speed != null ? speed + ' km/h' : '—';
if (nowBadge) nowBadge.textContent = speed != null ? speed + ' km/h' : '—';
if (lastTime) lastTime.textContent = 'Last: ' + time;
// Get a reference to the chart's data updateSpeedStats(speed);
const chartData = chart.data.datasets[0].data;
// Unshift the new data points to the beginning of the chart dataset // Push into chart (oldest at left, newest at right)
chartData.unshift({ x: time, y: speed }); const labels = chart.data.labels;
const points = chart.data.datasets[0].data;
// Remove data points if the number of data points exceeds maxDataPoints labels.push(time);
while (chartData.length > maxDataPoints) { points.push(speed);
chartData.pop(); // Remove the oldest data point from the end
if (labels.length > MAX_SPEED_POINTS) {
labels.shift();
points.shift();
} }
// Update the chart // Refresh gradient in case canvas resized
chart.update(); chart.data.datasets[0].backgroundColor = makeGradient();
// Dynamic Y max: 20% headroom above session max, min 60
const displayMax = Math.max(60, Math.ceil((sessionMaxSpeed * 1.2) / 10) * 10);
chart.options.scales.y.suggestedMax = displayMax;
chart.update('none'); // skip animation for live updates
}); });
socket.on('obd_rpm', function(data) { socket.on('obd_rpm', function (data) {
console.log("obd rpm "+data); const el = document.getElementById('obd-rpm');
const status = document.getElementById('obd-rpm'); if (el) el.textContent = data != null ? data + ' rpm' : '—';
status.innerHTML = data;
}); });
socket.on('obd_coolant_temp', function(data) { socket.on('obd_coolant_temp', function (data) {
const el = document.getElementById('obd-coolant'); const el = document.getElementById('obd-coolant');
if (el) el.textContent = data != null ? data + ' °C' : '—'; if (el) el.textContent = data != null ? data + ' °C' : '—';
}); });
socket.on('obd_fuel_level', function(data) { socket.on('obd_fuel_level', function (data) {
const el = document.getElementById('obd-fuel'); const el = document.getElementById('obd-fuel');
if (el) el.textContent = data != null ? data + ' %' : '—'; if (el) el.textContent = data != null ? data + ' %' : '—';
}); });
socket.on('obd_throttle', function(data) { socket.on('obd_throttle', function (data) {
const el = document.getElementById('obd-throttle'); const el = document.getElementById('obd-throttle');
if (el) el.textContent = data != null ? data + ' %' : '—'; if (el) el.textContent = data != null ? data + ' %' : '—';
}); });
socket.on('obd_error', function(data) { socket.on('obd_error', function (data) {
console.log("obd errors "+data); const el = document.getElementById('obd-error');
const status = document.getElementById('obd-error'); if (el) el.innerHTML = data;
if (status) {
status.innerHTML = data;
}
}); });
let mapCentered = false; let mapCentered = false;
+14 -6
View File
@@ -115,18 +115,26 @@
</div> </div>
<div class="col-lg-7 col-xl-8"> <div class="col-lg-7 col-xl-8">
<div class="card shadow-sm h-100"> <div class="card shadow-sm h-100">
<div class="card-header py-2 d-flex align-items-center justify-content-between"> <div class="card-header py-2 d-flex align-items-center gap-3">
<h6 class="text-primary fw-bold m-0">Speed history</h6> <h6 class="text-primary fw-bold m-0 me-auto">Speed history</h6>
<button class="btn btn-link btn-sm p-0 text-muted" id="speed-graph-toggle" type="button" {# Live speed badge — visible even when graph is collapsed #}
<span class="text-xs text-muted fw-bold text-uppercase">Now</span>
<span id="speed-now-badge" class="fw-bold text-dark" style="min-width:3.5rem;text-align:right;">—</span>
<span class="text-xs text-muted fw-bold text-uppercase ms-2">Max</span>
<span id="speed-max-badge" class="fw-bold text-danger" style="min-width:3.5rem;text-align:right;">—</span>
<button class="btn btn-link btn-sm p-0 text-muted ms-2" id="speed-graph-toggle" type="button"
aria-expanded="false" aria-controls="speed-graph-body"> aria-expanded="false" aria-controls="speed-graph-body">
<i class="fas fa-chevron-down"></i> <i class="fas fa-chevron-down"></i>
</button> </button>
</div> </div>
<div class="collapse" id="speed-graph-body"> <div class="collapse" id="speed-graph-body">
<div class="card-body"> <div class="card-body pb-2 pt-3 px-3">
<div class="chart-container" style="position:relative;height:14rem;"> <canvas id="speedGraph" style="height:13rem;"></canvas>
<canvas id="speedGraph"></canvas>
</div> </div>
<div class="card-footer py-2 d-flex gap-4 bg-white border-top small text-muted">
<div><span class="fw-bold text-uppercase" style="font-size:.65rem;">Avg (session)</span> <span id="speed-avg-badge" class="text-dark fw-semibold">—</span></div>
<div><span class="fw-bold text-uppercase" style="font-size:.65rem;">Points</span> <span id="speed-points-badge" class="text-dark fw-semibold">0</span></div>
<div class="ms-auto"><span id="speed-last-time" class="text-muted" style="font-size:.7rem;"></span></div>
</div> </div>
</div> </div>
</div> </div>