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
+128 -61
View File
@@ -1,120 +1,187 @@
const socket = io();
// map
// Offline tiles pre-downloaded via scripts/download_map_tiles.py (see PLAN.md
// 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).
// ── Map ────────────────────────────────────────────────────────────────────
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', {
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,
maxZoom: 15
});
var carMarker = L.marker(map.getCenter()).addTo(map)
.bindPopup('your car... soon(tm)');
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',
minZoom: 8,
maxZoom: 15,
});
var carMarker = L.marker(map.getCenter()).addTo(map).bindPopup('Your Yaris');
// charts
// ── Speed history chart ────────────────────────────────────────────────────
const MAX_SPEED_POINTS = 60; // ~5 min at typical OBD poll rate
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',
data: {
labels: [],
datasets: [{
label: 'Vehicle speed',
borderColor: 'rgba(75, 192, 192, 1)',
fill: false,
label: 'Speed (km/h)',
data: [],
borderColor: '#4e73df',
backgroundColor: makeGradient(),
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: '#4e73df',
tension: 0.4,
fill: true,
}]
},
options: {
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: {
x: {
reverse: true,
title: {
display: true,
text: 'Time',
grid: { display: false },
ticks: {
maxTicksLimit: 6,
color: '#adb5bd',
font: { size: 10 },
maxRotation: 0,
}
},
y: {
title: {
display: true,
text: 'Speed'
beginAtZero: true,
suggestedMax: 60,
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) {
console.log('socket.io is connected')
function updateSpeedStats(speed) {
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) {
console.log(data);
const status = document.getElementById('obd-status');
status.innerHTML = data;
socket.on('obd_status', function (data) {
const el = document.getElementById('obd-status');
if (el) el.innerHTML = data;
});
socket.on('obd_speed', function(data) {
const status = document.getElementById('obd-speed');
socket.on('obd_speed', function (data) {
const statusEl = document.getElementById('obd-speed');
const nowBadge = document.getElementById('speed-now-badge');
const lastTime = document.getElementById('speed-last-time');
if (!Array.isArray(data)) {
status.textContent = data;
if (statusEl) statusEl.textContent = data;
if (nowBadge) nowBadge.textContent = '—';
return;
}
const time = data[0]; // Assuming data[0] is the time value
const speed = data[1]; // Assuming data[1] is the speed value
const maxDataPoints = 8; // Adjust this value as needed
const time = data[0]; // "HH:MM:SS"
const speed = data[1]; // number, km/h
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
const chartData = chart.data.datasets[0].data;
updateSpeedStats(speed);
// Unshift the new data points to the beginning of the chart dataset
chartData.unshift({ x: time, y: speed });
// Push into chart (oldest at left, newest at right)
const labels = chart.data.labels;
const points = chart.data.datasets[0].data;
// Remove data points if the number of data points exceeds maxDataPoints
while (chartData.length > maxDataPoints) {
chartData.pop(); // Remove the oldest data point from the end
labels.push(time);
points.push(speed);
if (labels.length > MAX_SPEED_POINTS) {
labels.shift();
points.shift();
}
// Update the chart
chart.update();
// Refresh gradient in case canvas resized
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) {
console.log("obd rpm "+data);
const status = document.getElementById('obd-rpm');
status.innerHTML = data;
socket.on('obd_rpm', function (data) {
const el = document.getElementById('obd-rpm');
if (el) el.textContent = data != null ? data + ' rpm' : '—';
});
socket.on('obd_coolant_temp', function(data) {
socket.on('obd_coolant_temp', function (data) {
const el = document.getElementById('obd-coolant');
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');
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');
if (el) el.textContent = data != null ? data + ' %' : '—';
});
socket.on('obd_error', function(data) {
console.log("obd errors "+data);
const status = document.getElementById('obd-error');
if (status) {
status.innerHTML = data;
}
socket.on('obd_error', function (data) {
const el = document.getElementById('obd-error');
if (el) el.innerHTML = data;
});
let mapCentered = false;