Initial UI

This commit is contained in:
2026-03-27 20:12:57 -04:00
parent c4e6c634d1
commit 15faf07691
9 changed files with 3039 additions and 5 deletions

View File

@@ -0,0 +1,465 @@
/* ============================================================
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 };
})();