Files
box-box/internal/web/assets/charts.js
2026-03-27 20:12:57 -04:00

466 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
box-box — D3 visualization library
All functions: Charts.renderX(containerId, data)
Charts re-render on ResizeObserver via stored callbacks.
============================================================ */
const Charts = (() => {
// Compound → fill colour
const COMPOUND_COLOR = {
SOFT: '#FF3333',
MEDIUM: '#FFD700',
HARD: '#CCCCCC',
INTERMEDIATE: '#39B54A',
WET: '#0080FF',
UNKNOWN: '#666688',
};
const MARGIN = { top: 32, right: 24, bottom: 32, left: 60 };
const LABEL_W = 48; // width reserved for driver labels
// Tooltip element (shared)
let tooltip = null;
function getTooltip() {
if (!tooltip) {
tooltip = document.createElement('div');
tooltip.className = 'chart-tooltip';
tooltip.style.display = 'none';
document.body.appendChild(tooltip);
}
return tooltip;
}
function showTooltip(html, e) {
const t = getTooltip();
t.innerHTML = html;
t.style.display = 'block';
t.style.left = (e.clientX + 12) + 'px';
t.style.top = (e.clientY - 8) + 'px';
}
function hideTooltip() {
getTooltip().style.display = 'none';
}
// ResizeObserver registry: containerId → callback
const observers = {};
function observeResize(id, fn) {
const el = document.getElementById(id);
if (!el) return;
if (observers[id]) observers[id].disconnect();
const obs = new ResizeObserver(() => fn());
obs.observe(el);
observers[id] = obs;
}
// ---- Strategy Gantt chart ----
function renderStrategy(containerId, data) {
const el = document.getElementById(containerId);
if (!el || !data || !data.drivers) return;
el.innerHTML = '';
const drivers = (data.drivers || []).filter(d => d.stints && d.stints.length > 0 || d.dns);
if (drivers.length === 0) { el.textContent = 'No strategy data.'; return; }
const totalLaps = data.total_laps || 60;
const rowH = 28;
const padding = 4;
const height = MARGIN.top + drivers.length * (rowH + padding) + MARGIN.bottom;
const width = Math.max(el.clientWidth || 800, 500);
const svg = d3.select(el).append('svg')
.attr('width', width).attr('height', height);
const xScale = d3.scaleLinear()
.domain([1, totalLaps])
.range([MARGIN.left + LABEL_W, width - MARGIN.right]);
const yScale = d3.scaleBand()
.domain(drivers.map(d => d.driver_number))
.range([MARGIN.top, height - MARGIN.bottom])
.padding(0.15);
// SC/VSC zones
(data.sc_periods || []).forEach(p => {
svg.append('rect')
.attr('x', xScale(p.lap_start))
.attr('y', MARGIN.top)
.attr('width', xScale(p.lap_end) - xScale(p.lap_start))
.attr('height', height - MARGIN.top - MARGIN.bottom)
.attr('fill', p.type === 'VSC' ? 'rgba(80,130,255,0.12)' : 'rgba(255,200,0,0.12)')
.attr('pointer-events', 'none');
});
// Grid lines
for (let lap = 5; lap <= totalLaps; lap += 5) {
svg.append('line')
.attr('x1', xScale(lap)).attr('x2', xScale(lap))
.attr('y1', MARGIN.top).attr('y2', height - MARGIN.bottom)
.attr('stroke', '#2D2D44').attr('stroke-width', 1);
}
// X-axis
const xAxis = d3.axisBottom(xScale).ticks(Math.min(20, Math.floor(totalLaps / 5))).tickFormat(d3.format('d'));
svg.append('g').attr('transform', `translate(0,${height - MARGIN.bottom})`).call(xAxis);
// Driver rows
drivers.forEach(driver => {
const y = yScale(driver.driver_number);
const rh = yScale.bandwidth();
// Label
svg.append('text')
.attr('x', MARGIN.left + LABEL_W - 6)
.attr('y', y + rh / 2 + 4)
.attr('text-anchor', 'end')
.attr('fill', `#${driver.team_colour || '888888'}`)
.attr('font-size', 11)
.attr('font-weight', '700')
.text(driver.name_acronym || driver.driver_number);
// DNS: single gray bar
if (driver.dns) {
svg.append('rect')
.attr('x', xScale(1)).attr('y', y + 2)
.attr('width', xScale(totalLaps) - xScale(1))
.attr('height', rh - 4)
.attr('fill', '#333355').attr('rx', 3);
svg.append('text')
.attr('x', (xScale(1) + xScale(totalLaps)) / 2).attr('y', y + rh / 2 + 4)
.attr('text-anchor', 'middle').attr('fill', '#8888aa').attr('font-size', 10)
.text('DNS');
return;
}
// Stints
(driver.stints || []).forEach(stint => {
const x1 = xScale(stint.lap_start);
const x2 = xScale(stint.lap_end);
const color = COMPOUND_COLOR[stint.compound] || COMPOUND_COLOR.UNKNOWN;
const bar = svg.append('rect')
.attr('x', x1).attr('y', y + 2)
.attr('width', Math.max(2, x2 - x1))
.attr('height', rh - 4)
.attr('fill', color).attr('rx', 3)
.attr('cursor', 'pointer');
// DNF: diagonal stripe overlay
if (driver.dnf && stint === driver.stints[driver.stints.length - 1]) {
bar.attr('opacity', 0.7);
// Add simple DNF label
svg.append('text')
.attr('x', x2 - 14).attr('y', y + rh / 2 + 4)
.attr('text-anchor', 'end').attr('fill', '#cc4422')
.attr('font-size', 9).attr('font-weight', '700').text('DNF');
}
bar.on('mousemove', e => {
const isNew = stint.is_new ? ' (new)' : ` (+${stint.tyre_age_at_start})`;
showTooltip(
`<strong>${driver.name_acronym}</strong> — Stint ${stint.stint_number}<br>` +
`${stint.compound}${isNew}<br>` +
`Laps ${stint.lap_start}${stint.lap_end} (${stint.lap_count} laps)`, e
);
}).on('mouseleave', hideTooltip);
});
// Pit stops
(driver.pit_stops || []).forEach(pit => {
svg.append('circle')
.attr('cx', xScale(pit.lap_number))
.attr('cy', y + rh / 2)
.attr('r', 4)
.attr('fill', '#ffffff').attr('stroke', '#000').attr('stroke-width', 1)
.attr('cursor', 'pointer')
.on('mousemove', e => {
showTooltip(
`Pit lap ${pit.lap_number}<br>` +
`Stop: ${pit.stop_duration?.toFixed(2)}s &nbsp; Lane: ${pit.lane_duration?.toFixed(2)}s`, e
);
})
.on('mouseleave', hideTooltip);
});
});
observeResize(containerId, () => renderStrategy(containerId, data));
}
// ---- Lap time progression chart ----
function renderLapTimes(containerId, data) {
const el = document.getElementById(containerId);
if (!el || !data || !data.drivers) return;
el.innerHTML = '';
const drivers = data.drivers || [];
if (!drivers.length) { el.textContent = 'No lap time data.'; return; }
const ML = MARGIN.left + 10;
const MT = MARGIN.top;
const MB = MARGIN.bottom + 20;
const MR = MARGIN.right;
const width = Math.max(el.clientWidth || 800, 400);
const height = 300;
const svg = d3.select(el).append('svg')
.attr('width', width).attr('height', height);
// Collect all valid laps for domain calculation
const allLaps = drivers.flatMap(d =>
(d.laps || []).filter(l => l.lap_duration && !l.is_pit_out_lap)
.map(l => ({ lapNum: l.lap_number, t: l.lap_duration }))
);
if (!allLaps.length) { el.textContent = 'No lap data.'; return; }
const maxLap = d3.max(allLaps, l => l.lapNum) || 1;
const yMin = d3.min(allLaps, l => l.t);
const yMax = d3.max(allLaps, l => l.t);
const xScale = d3.scaleLinear().domain([1, maxLap]).range([ML, width - MR]);
const yScale = d3.scaleLinear()
.domain([yMin * 0.995, yMax * 1.005])
.range([height - MB, MT]);
// SC zones
(data.sc_periods || []).forEach(p => {
svg.append('rect')
.attr('x', xScale(p.lap_start)).attr('y', MT)
.attr('width', xScale(p.lap_end) - xScale(p.lap_start))
.attr('height', height - MT - MB)
.attr('fill', 'rgba(255,200,0,0.10)').attr('pointer-events', 'none');
});
// Axes
svg.append('g').attr('transform', `translate(0,${height - MB})`).call(
d3.axisBottom(xScale).ticks(Math.min(20, maxLap)).tickFormat(d3.format('d'))
);
svg.append('g').attr('transform', `translate(${ML},0)`).call(
d3.axisLeft(yScale).ticks(6).tickFormat(t => {
const m = Math.floor(t / 60);
const s = (t - m * 60).toFixed(1);
return `${m}:${s.padStart(4, '0')}`;
})
);
// Per-driver lines
const driverVisible = {};
drivers.forEach(d => { driverVisible[d.driver_number] = true; });
const lineGen = d3.line()
.defined(l => l.lap_duration != null && !l.is_pit_out_lap)
.x(l => xScale(l.lap_number))
.y(l => yScale(l.lap_duration));
const paths = {};
drivers.forEach(driver => {
const color = '#' + (driver.team_colour || '888888');
const path = svg.append('path')
.datum(driver.laps || [])
.attr('fill', 'none')
.attr('stroke', color)
.attr('stroke-width', 2)
.attr('d', lineGen);
paths[driver.driver_number] = path;
// Pit lap circles
const pitNums = new Set((data.pit_laps || {})[String(driver.driver_number)] || []);
(driver.laps || []).filter(l => pitNums.has(l.lap_number) && l.lap_duration).forEach(l => {
svg.append('circle')
.attr('cx', xScale(l.lap_number)).attr('cy', yScale(l.lap_duration))
.attr('r', 4).attr('fill', 'none').attr('stroke', color).attr('stroke-width', 2);
});
});
// Crosshair tooltip
const crosshair = svg.append('line')
.attr('y1', MT).attr('y2', height - MB)
.attr('stroke', '#8888aa').attr('stroke-width', 1).attr('display', 'none');
svg.append('rect')
.attr('x', ML).attr('y', MT)
.attr('width', width - ML - MR).attr('height', height - MT - MB)
.attr('fill', 'none').attr('pointer-events', 'all')
.on('mousemove', e => {
const [mx] = d3.pointer(e);
const lapNum = Math.round(xScale.invert(mx));
crosshair.attr('x1', xScale(lapNum)).attr('x2', xScale(lapNum)).attr('display', null);
const tips = drivers.map(d => {
const lap = (d.laps || []).find(l => l.lap_number === lapNum);
if (!lap || !lap.lap_duration) return '';
const m = Math.floor(lap.lap_duration / 60);
const s = (lap.lap_duration - m * 60).toFixed(3);
return `<span style="color:#${d.team_colour||'888888'}">${d.name_acronym}</span>: ${m}:${s.padStart(6,'0')}`;
}).filter(Boolean).join('<br>');
if (tips) showTooltip(`<strong>Lap ${lapNum}</strong><br>${tips}`, e);
})
.on('mouseleave', () => { crosshair.attr('display', 'none'); hideTooltip(); });
// Legend with toggle
const legendG = svg.append('g').attr('transform', `translate(${ML},${MT - 20})`);
let lx = 0;
drivers.forEach(driver => {
const color = '#' + (driver.team_colour || '888888');
const g = legendG.append('g').attr('transform', `translate(${lx},0)`).attr('cursor', 'pointer');
g.append('rect').attr('width', 12).attr('height', 12).attr('fill', color).attr('rx', 2);
g.append('text').attr('x', 15).attr('y', 10).attr('fill', color).attr('font-size', 11)
.text(driver.name_acronym || driver.driver_number);
lx += 60;
g.on('click', () => {
const visible = !driverVisible[driver.driver_number];
driverVisible[driver.driver_number] = visible;
paths[driver.driver_number].attr('opacity', visible ? 1 : 0.15);
g.attr('opacity', visible ? 1 : 0.4);
});
});
observeResize(containerId, () => renderLapTimes(containerId, data));
}
// ---- Telemetry 4-panel chart ----
function renderTelemetry(containerId, driversData) {
const el = document.getElementById(containerId);
if (!el || !driversData || !driversData.length) return;
el.innerHTML = '';
const panelConfigs = [
{ key: 'speed', label: 'Speed (km/h)', height: 120, yKey: 'Speed' },
{ key: 'throttle', label: 'Throttle (%)', height: 80, yKey: 'Throttle' },
{ key: 'brake', label: 'Brake (%)', height: 80, yKey: 'Brake' },
{ key: 'gear', label: 'Gear', height: 60, yKey: 'NGear' },
];
const GAP = 10;
const totalHeight = panelConfigs.reduce((s, p) => s + p.height, 0) + GAP * (panelConfigs.length - 1) + MARGIN.top + MARGIN.bottom;
const width = Math.max(el.clientWidth || 800, 400);
const ML = MARGIN.left + 10;
const MR = MARGIN.right;
const svg = d3.select(el).append('svg').attr('width', width).attr('height', totalHeight);
// Compute distances from speed + time for all drivers, pick max dist
const driverSeries = driversData.map(d => {
const pts = computeDistanceSeries(d.data || []);
return { ...d, pts };
});
const maxDist = d3.max(driverSeries, d => d.pts.length > 0 ? d.pts[d.pts.length - 1].dist : 0) || 1;
const xScale = d3.scaleLinear().domain([0, maxDist]).range([ML, width - MR]);
let yOffset = MARGIN.top;
panelConfigs.forEach((panel, pi) => {
const ph = panel.height;
const g = svg.append('g').attr('transform', `translate(0,${yOffset})`);
// Y domain
const allVals = driverSeries.flatMap(d => d.pts.map(p => p[panel.yKey] ?? 0));
let yMin = d3.min(allVals) ?? 0;
let yMax = d3.max(allVals) ?? 1;
if (panel.key === 'throttle' || panel.key === 'brake') { yMin = 0; yMax = 100; }
if (panel.key === 'gear') { yMin = 0; yMax = 8; }
const yScale = d3.scaleLinear().domain([yMin, yMax]).range([ph, 0]);
// Panel background
g.append('rect').attr('x', ML).attr('y', 0)
.attr('width', width - ML - MR).attr('height', ph)
.attr('fill', '#1B1B2F').attr('rx', 4);
// Label
g.append('text').attr('x', ML - 8).attr('y', ph / 2 + 4)
.attr('text-anchor', 'end').attr('fill', '#8888aa').attr('font-size', 10)
.text(panel.label);
// Y axis (right side of first panel)
if (pi === 0) {
g.append('g').attr('transform', `translate(${width - MR},0)`)
.call(d3.axisRight(yScale).ticks(4));
}
// Lines
const lineGen = d3.line()
.x(p => xScale(p.dist))
.y(p => yScale(p[panel.yKey] ?? 0))
.defined(p => p[panel.yKey] != null);
driverSeries.forEach(driver => {
if (!driver.pts.length) return;
g.append('path')
.datum(driver.pts)
.attr('fill', 'none')
.attr('stroke', '#' + (driver.teamColour || '888888'))
.attr('stroke-width', 1.5)
.attr('d', lineGen);
});
// X axis only on last panel
if (pi === panelConfigs.length - 1) {
g.append('g').attr('transform', `translate(0,${ph})`).call(
d3.axisBottom(xScale).ticks(8).tickFormat(d => `${Math.round(d / 1000)}k`)
);
}
yOffset += ph + GAP;
});
// Shared crosshair
const crosslines = panelConfigs.map((panel, pi) => {
const g = svg.select(`g:nth-of-type(${pi + 1})`);
return svg.append('line')
.attr('y1', MARGIN.top + panelConfigs.slice(0, pi).reduce((s, p) => s + p.height + GAP, 0))
.attr('y2', MARGIN.top + panelConfigs.slice(0, pi + 1).reduce((s, p) => s + p.height + GAP, 0) - GAP)
.attr('stroke', '#8888aa').attr('stroke-width', 1).attr('display', 'none');
});
svg.append('rect')
.attr('x', ML).attr('y', MARGIN.top)
.attr('width', width - ML - MR).attr('height', totalHeight - MARGIN.top - MARGIN.bottom)
.attr('fill', 'none').attr('pointer-events', 'all')
.on('mousemove', e => {
const [mx] = d3.pointer(e);
crosslines.forEach(l => l.attr('x1', mx).attr('x2', mx).attr('display', null));
const dist = xScale.invert(mx);
const tips = driverSeries.map(d => {
const pt = d.pts.find(p => p.dist >= dist);
if (!pt) return '';
return `<span style="color:#${d.teamColour||'888888'}">${d.nameAcronym}</span>: ` +
`${pt.Speed}km/h T${pt.Throttle}% B${pt.Brake}% G${pt.NGear}`;
}).filter(Boolean).join('<br>');
if (tips) showTooltip(tips, e);
})
.on('mouseleave', () => {
crosslines.forEach(l => l.attr('display', 'none'));
hideTooltip();
});
observeResize(containerId, () => renderTelemetry(containerId, driversData));
}
// Compute cumulative distance from car data samples.
function computeDistanceSeries(samples) {
if (!samples.length) return [];
const pts = [];
let dist = 0;
for (let i = 0; i < samples.length; i++) {
const s = samples[i];
if (i > 0) {
const prev = samples[i - 1];
const dt = (new Date(s.date) - new Date(prev.date)) / 1000; // seconds
if (dt > 0 && dt < 5) { // ignore large gaps
dist += (s.speed / 3.6) * dt; // speed in km/h → m/s * dt → metres
}
}
pts.push({
dist,
Speed: s.speed ?? 0,
Throttle: s.throttle ?? 0,
Brake: s.brake ?? 0,
NGear: s.n_gear ?? 0,
});
}
return pts;
}
return { renderStrategy, renderLapTimes, renderTelemetry };
})();