diff --git a/apps/backend/src/runs/dto/update-run.dto.ts b/apps/backend/src/runs/dto/update-run.dto.ts index 9a54169..f6678a5 100644 --- a/apps/backend/src/runs/dto/update-run.dto.ts +++ b/apps/backend/src/runs/dto/update-run.dto.ts @@ -1,5 +1,5 @@ // apps/backend/src/runs/dto/update-run.dto.ts -import { IsString, IsOptional, IsNumber } from 'class-validator'; +import { IsString, IsOptional, IsNumber, IsInt, Min } from 'class-validator'; export class UpdateRunDto { @IsOptional() @@ -13,4 +13,14 @@ export class UpdateRunDto { @IsOptional() @IsString() location?: string; + + @IsOptional() + @IsInt() + @Min(0) + lower_bound_idx?: number; + + @IsOptional() + @IsInt() + @Min(0) + upper_bound_idx?: number; } \ No newline at end of file diff --git a/apps/backend/src/runs/runs.service.spec.ts b/apps/backend/src/runs/runs.service.spec.ts index f6526ec..150edfc 100644 --- a/apps/backend/src/runs/runs.service.spec.ts +++ b/apps/backend/src/runs/runs.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, ConflictException } from '@nestjs/common'; import { RunsService } from './runs.service'; describe('RunsService', () => { @@ -84,11 +84,11 @@ describe('RunsService', () => { expect(res2).toBeNull(); }); - it('createRun throws when run with srcPath exists', async () => { + it('createRun throws ConflictException when run with srcPath exists', async () => { const data = { srcPath: 'x', length: 10 } as any; jest.spyOn(service, 'findBySrcPath').mockResolvedValue({ id: 1 } as any); - await expect(service.createRun(data)).rejects.toThrow('Run with this srcPath already exists'); + await expect(service.createRun(data)).rejects.toBeInstanceOf(ConflictException); }); it('createRun inserts and returns created row', async () => { @@ -104,6 +104,21 @@ describe('RunsService', () => { expect(res).toEqual(inserted); }); + it('createRun defaults bounds to full run range when omitted', async () => { + const data = { srcPath: 'bounds-default', length: 10 } as any; + jest.spyOn(service, 'findBySrcPath').mockResolvedValue(null); + mockDb.insert().values().returning.mockResolvedValue([{ id: 7, ...data }]); + + await service.createRun(data); + + expect(mockDb.insert().values).toHaveBeenCalledWith( + expect.objectContaining({ + lower_bound_idx: 0, + upper_bound_idx: 9, + }), + ); + }); + it('updateRun throws BadRequestException when updates empty', async () => { await expect(service.updateRun(1, {} as any)).rejects.toBeInstanceOf(BadRequestException); }); @@ -117,6 +132,38 @@ describe('RunsService', () => { expect(res).toEqual(updated); }); + it('updateRun rejects invalid trim bounds', async () => { + mockDb.query.runs.findFirst.mockResolvedValue({ + id: 1, + length: 10, + lower_bound_idx: 0, + upper_bound_idx: 9, + }); + + await expect( + service.updateRun(1, { lower_bound_idx: 8, upper_bound_idx: 4 } as any), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('updateRun allows valid trim bounds', async () => { + mockDb.query.runs.findFirst.mockResolvedValue({ + id: 1, + length: 10, + lower_bound_idx: 0, + upper_bound_idx: 9, + }); + mockDb.update().set().where().returning.mockResolvedValue([ + { id: 1, lower_bound_idx: 2, upper_bound_idx: 8 }, + ]); + + const res = await service.updateRun(1, { + lower_bound_idx: 2, + upper_bound_idx: 8, + } as any); + + expect(res).toEqual({ id: 1, lower_bound_idx: 2, upper_bound_idx: 8 }); + }); + it('updateRun returns null when no rows updated', async () => { mockDb.update().set().where().returning.mockResolvedValue([]); diff --git a/apps/backend/src/runs/runs.service.ts b/apps/backend/src/runs/runs.service.ts index 0183e81..3bcd0d1 100644 --- a/apps/backend/src/runs/runs.service.ts +++ b/apps/backend/src/runs/runs.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { Injectable, Inject, BadRequestException, ConflictException } from "@nestjs/common"; import { eq } from "drizzle-orm"; import { DATABASE_CONNECTION } from "../database/database.module"; import { runs } from "@repo/database"; @@ -39,19 +39,26 @@ export class RunsService { date?: Date; location?: string; profile?: number; + lower_bound_idx?: number; + upper_bound_idx?: number; front_freq?: number; rear_freq?: number; }) { // enforce uniqueness at service level to avoid duplicate runs const existing = await this.findBySrcPath(data.srcPath); if (existing) { - throw new Error("Run with this srcPath already exists"); + throw new ConflictException("Run with this srcPath already exists"); } + const resolvedLowerBound = data.lower_bound_idx ?? 0; + const resolvedUpperBound = data.upper_bound_idx ?? Math.max(data.length - 1, 0); + const inserted = await this.db .insert(runs) .values({ ...data, + lower_bound_idx: resolvedLowerBound, + upper_bound_idx: resolvedUpperBound, date: data.date ?? new Date(), // default to now if not provided }) .returning(); @@ -60,11 +67,58 @@ export class RunsService { } // Partial update for a run (e.g., update comments) - async updateRun(id: number, updates: Partial<{ comments: string; length: number; location: string }>) { + async updateRun( + id: number, + updates: Partial<{ + comments: string; + length: number; + location: string; + lower_bound_idx: number; + upper_bound_idx: number; + }>, + ) { if (Object.keys(updates).length === 0) { throw new BadRequestException("No updates provided for the run."); } + const hasBoundsUpdate = + updates.lower_bound_idx !== undefined || + updates.upper_bound_idx !== undefined || + updates.length !== undefined; + + if (hasBoundsUpdate) { + const currentRun = await this.getRunById(id); + if (!currentRun) { + return null; + } + + const effectiveLength = updates.length ?? currentRun.length; + const effectiveLowerBound = + updates.lower_bound_idx ?? currentRun.lower_bound_idx ?? 0; + const effectiveUpperBound = + updates.upper_bound_idx ?? currentRun.upper_bound_idx ?? Math.max(effectiveLength - 1, 0); + + if (!Number.isInteger(effectiveLowerBound) || !Number.isInteger(effectiveUpperBound)) { + throw new BadRequestException("Trim bounds must be integers."); + } + + if (effectiveLength <= 0) { + throw new BadRequestException("Run length must be greater than zero."); + } + + if (effectiveLowerBound < 0) { + throw new BadRequestException("lower_bound_idx must be greater than or equal to zero."); + } + + if (effectiveUpperBound < effectiveLowerBound) { + throw new BadRequestException("upper_bound_idx must be greater than or equal to lower_bound_idx."); + } + + if (effectiveUpperBound >= effectiveLength) { + throw new BadRequestException("upper_bound_idx must be less than run length."); + } + } + const updated = await this.db .update(runs) .set(updates) diff --git a/apps/frontend/app/api/runs.ts b/apps/frontend/app/api/runs.ts index 3b263d3..42fc8ae 100644 --- a/apps/frontend/app/api/runs.ts +++ b/apps/frontend/app/api/runs.ts @@ -1,6 +1,14 @@ import { apiClient } from "./client"; import { Run } from "@repo/database"; +export interface RunUpdatePayload { + comments?: string; + length?: number; + location?: string; + lower_bound_idx?: number; + upper_bound_idx?: number; +} + export function getRuns() { return apiClient.get("/runs"); } @@ -9,6 +17,6 @@ export function getRunById(id: number) { return apiClient.get(`/runs/${id}`); } -export function updateRun(id: number, payload: Partial>) { +export function updateRun(id: number, payload: RunUpdatePayload) { return apiClient.patch(`/runs/${id}`, payload); } diff --git a/apps/frontend/app/components/graphs/base/Histogram.tsx b/apps/frontend/app/components/graphs/base/Histogram.tsx index 6589859..3fe235c 100644 --- a/apps/frontend/app/components/graphs/base/Histogram.tsx +++ b/apps/frontend/app/components/graphs/base/Histogram.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import * as d3 from 'd3'; -import { html } from 'd3'; import { getSeriesColor } from '../../../lib/graphColors'; export interface HistogramBin { @@ -53,8 +52,8 @@ export const Histogram: React.FC = ({ }, []); // Main D3 rendering - const finalSeries: HistogramSeries[] = useMemo( - () => + const finalSeries: HistogramSeries[] = useMemo(() => + // If series prop provided map each series item series && series.length > 0 ? series .filter((s) => Array.isArray(s.data)) @@ -68,14 +67,17 @@ export const Histogram: React.FC = ({ ); useEffect(() => { + // Missing container guard if (!containerRef.current || width === 0) return; + // Mising data guard const hasData = finalSeries.some((s) => s.data.length > 0); if (!hasData) { d3.select(containerRef.current).selectAll("svg").remove(); return; } + // Dimensions const margin = { top: 20, right: 20, bottom: 40, left: 50 }; const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; @@ -89,15 +91,13 @@ export const Histogram: React.FC = ({ .thresholds(binCount); const binsBySeries = finalSeries.map((s) => binGenerator(s.data)); - if (binsBySeries.length === 0 || binsBySeries[0]?.length === 0) return; - const firstSeriesBins = binsBySeries[0]; if (!firstSeriesBins) return; const firstBin = firstSeriesBins[0]; const lastBin = firstSeriesBins[firstSeriesBins.length - 1]; if (!firstBin || !lastBin || firstBin.x0 == null || lastBin.x1 == null) return; - // Build scales for bars and axes + // Scales const x = d3 .scaleLinear() .domain([firstBin.x0, lastBin.x1]) @@ -116,6 +116,7 @@ export const Histogram: React.FC = ({ .domain([0, yMax]) .range([innerHeight, 0]); + // SVG setup const svg = d3 .select(containerRef.current) .selectAll("svg") @@ -147,6 +148,7 @@ export const Histogram: React.FC = ({ label: string; }; + // Prepare data for rendering const seriesCount = Math.max(1, finalSeries.length); const bars: RenderBar[] = binsBySeries.flatMap((seriesBins, seriesIndex) => { const seriesConfig = finalSeries[seriesIndex]; diff --git a/apps/frontend/app/components/graphs/base/LineHistogram.tsx b/apps/frontend/app/components/graphs/base/LineHistogram.tsx new file mode 100644 index 0000000..bfe6043 --- /dev/null +++ b/apps/frontend/app/components/graphs/base/LineHistogram.tsx @@ -0,0 +1,229 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import * as d3 from 'd3'; +import { getSeriesColor } from '../../../lib/graphColors'; + +export interface LineHistogramBin { + x0: number; + x1: number; + percent: number; +} + +export interface LineHistogramSeries { + label: string; + data: number[]; + color?: string; +} + +export interface LineHistogramProps { + data?: number[]; + series?: LineHistogramSeries[]; + height?: number; + xDomain?: [number, number]; + className?: string; + fillColor?: string; + title?: string; + binCount?: number; +} + +export const LineHistogram: React.FC = ({ + data = [], + series, + height = 500, + xDomain, + className = "", + fillColor = "hsl(var(--chart-1))", + title, + binCount = 20 +}) => { + const containerRef = useRef(null); + const tooltipRef = useRef(null); + const [width, setWidth] = useState(0); + const [hoveredSeriesIndex, setHoveredSeriesIndex] = useState(null); + + // Responsive resize observer + useEffect(() => { + if (!containerRef.current) return; + const resizeObserver = new ResizeObserver(entries => { + const entry = entries[0]; + if (entry) setWidth(entry.contentRect.width); + }); + resizeObserver.observe(containerRef.current); + return () => resizeObserver.disconnect(); + }, []); + + // Main D3 rendering + const finalSeries: LineHistogramSeries[] = useMemo(() => + // If series prop provided map each series item + series && series.length > 0 + ? series + .filter((s) => Array.isArray(s.data)) + .map((s, index) => ({ + label: s.label || `Series ${index + 1}`, + data: s.data, + color: getSeriesColor(index, s.color, fillColor), + })) + : [{ label: title || 'Distribution', data, color: fillColor }], + [series, title, data, fillColor], + ); + + useEffect(() => { + // Missing container guard + if (!containerRef.current || width === 0) return; + + // Mising data guard + const hasData = finalSeries.some((s) => s.data.length > 0); + if (!hasData) { + d3.select(containerRef.current).selectAll("svg").remove(); + return; + } + + // Dimensions + const margin = { top: 20, right: 20, bottom: 40, left: 50 }; + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + if (innerWidth <= 0 || innerHeight <= 0) return; + + // Bin each series + const finalDomain: [number, number] = xDomain ?? [0, 100]; + const binGenerator = d3 + .bin() + .domain(finalDomain) + .thresholds(binCount); + const binsBySeries = finalSeries.map((s) => binGenerator(s.data)); + + // Build line points for each series + const linePointsBySeries = binsBySeries.map((seriesBins, seriesIndex) => { + const totalPoints = finalSeries[seriesIndex]?.data.length ?? 0; + return seriesBins.map((bin) => ({ + x: ((bin.x0 ?? 0) + (bin.x1 ?? 0)) / 2, + y: totalPoints > 0 ? (bin.length / totalPoints) * 100 : 0, + })); + } + ); + + // Scales + const x = d3 + .scaleLinear() + .domain(finalDomain) + .range([0, innerWidth]); + + const maxPercent = + d3.max(binsBySeries, (seriesBins, seriesIndex) => { + const totalPoints = finalSeries[seriesIndex]?.data.length ?? 0; + if (totalPoints === 0) return 0; + return d3.max(seriesBins, (bin) => (bin.length / totalPoints) * 100) ?? 0; + }) ?? 0; + + const yMax = Math.max(1, maxPercent * 1.05); + const y = d3 + .scaleLinear() + .domain([0, yMax]) + .range([innerHeight, 0]); + + // SVG setup + const svg = d3 + .select(containerRef.current) + .selectAll("svg") + .data([null]) + .join("svg") + .attr("width", width) + .attr("height", height) + .attr("class", "overflow-hidden"); + + const g = svg + .selectAll("g.plot") + .data([null]) + .join("g") + .attr("class", "plot") + .attr("transform", `translate(${margin.left},${margin.top})`); + + const tooltip = d3.select(tooltipRef.current); + tooltip.style("opacity", 0); + + type RenderSeries = { + seriesIndex: number; + color: string; + label: string; + points: { x: number; y: number }[]; + }; + + const renderSeries: RenderSeries[] = linePointsBySeries.map((points, seriesIndex) => ({ + seriesIndex, + color: finalSeries[seriesIndex]?.color ?? fillColor, + label: finalSeries[seriesIndex]?.label ?? `Series ${seriesIndex + 1}`, + points, + })); + + // Draw axis + g.selectAll("g.x-axis") + .data([null]) + .join("g") + .attr("class", "x-axis") + .attr("transform", `translate(0,${innerHeight})`) + .call(d3.axisBottom(x).ticks(Math.max(2, Math.floor(innerWidth / 80))).tickSizeOuter(0)) + .call((axisGroup) => axisGroup.selectAll(".domain, .tick line").attr("class", "stroke-border")) + .call((axisGroup) => axisGroup.selectAll(".tick text").attr("class", "text-muted-foreground text-xs")); + + const yTickFormat = d3.format(".0f"); + g.selectAll("g.y-axis") + .data([null]) + .join("g") + .attr("class", "y-axis") + .call(d3.axisLeft(y).ticks(5).tickFormat((value) => `${yTickFormat(Number(value))}%`)) + .call((axisGroup) => axisGroup.selectAll(".domain, .tick line").attr("class", "stroke-border")) + .call((axisGroup) => axisGroup.selectAll(".tick text").attr("class", "text-muted-foreground text-xs")); + + g.selectAll("line.zero-line") + .data([null]) + .join("line") + .attr("class", "zero-line") + .attr("x1", x(0)).attr("x2", x(0)) + .attr("y1", 0).attr("y2", innerHeight) + .attr("stroke", "hsl(var(--border))") + .attr("stroke-dasharray", "4,4"); + + // Draw lines + const lineGenerator = d3.line<{ x: number; y: number }>() + .x((point) => x(point.x)) + .y((point) => y(point.y)) + .curve(d3.curveMonotoneX); + + g.selectAll("path.series-line") + .data(renderSeries, (s) => s.seriesIndex) + .join("path") + .attr("d", (s) => lineGenerator(s.points)) + .attr("fill", "none") + .attr("stroke", (s) => s.color) + .attr("stroke-width", 2) + .attr("opacity", (d) => (hoveredSeriesIndex === null || hoveredSeriesIndex === d.seriesIndex ? 1 : 0.3)); + + // Tooltip setup + // (no tooltip as of yet) + + } , [finalSeries, width, height, xDomain, binCount]); // add hoveredSeriesIndex back for tooltip + + // JSX return + return ( +
+
+ {title &&

{title}

} + {finalSeries.length > 1 && ( +
+ {finalSeries.map((s, index) => ( +
setHoveredSeriesIndex(index)} + onMouseLeave={() => setHoveredSeriesIndex(null)} + > +
+ {s.label} +
+ ))} +
+ )} +
+
+
+ ); +}; diff --git a/apps/frontend/app/components/graphs/base/LinePlot.tsx b/apps/frontend/app/components/graphs/base/LinePlot.tsx index e9ab24b..d7a567e 100644 --- a/apps/frontend/app/components/graphs/base/LinePlot.tsx +++ b/apps/frontend/app/components/graphs/base/LinePlot.tsx @@ -14,27 +14,40 @@ interface LinePlotProps { height?: number; className?: string; styleForSeries?: (index: number) => React.CSSProperties | undefined; + brushSelection?: [number, number] | null; + onBrushSelection?: (selection: [number, number] | null) => void; } -export const LinePlot: React.FC = ({ +export const LinePlot: React.FC = React.memo(({ data, xDomain, yDomain = [0, 100], height = 400, className = "", styleForSeries, + brushSelection, + onBrushSelection, }) => { const containerRef = useRef(null); const svgRef = useRef(null); const clipPathId = useId(); const [width, setWidth] = useState(0); + + // Performance / Downsampling State const innerWidthForDownsample = Math.max(0, width); const fullDataRef = useRef([]); const rafRef = useRef(null); const latestDomainRef = useRef<[number, number] | null>(null); const selectedDomainRef = useRef<[number, number] | null>(null); - // Threshold for downsampling + // Brush / Trim State + const brushRef = useRef | null>(null); + const brushGroupRef = useRef | null>(null); + const onBrushSelectionRef = useRef(onBrushSelection); + const isApplyingExternalSelectionRef = useRef(false); + const latestBrushSelectionRef = useRef<[number, number] | null>(null); + + // Threshold for downsampling const downsampleThreshold = Math.max(500, Math.floor(innerWidthForDownsample)); const focusData = useMemo( () => data.map((series) => lttbDownsample(series, downsampleThreshold)), @@ -44,7 +57,7 @@ export const LinePlot: React.FC = ({ () => data.map((series) => lttbDownsample(series, 500)), [data], ); - + // Persist scales for brush const scalesRef = useRef({ x: d3.scaleLinear(), @@ -52,7 +65,11 @@ export const LinePlot: React.FC = ({ x2: d3.scaleLinear(), }); - // Resize observer (under graph) + useEffect(() => { + onBrushSelectionRef.current = onBrushSelection; + }, [onBrushSelection]); + + // Resize observer useEffect(() => { if (!containerRef.current) return; const resizeObserver = new ResizeObserver((entries) => { @@ -90,7 +107,8 @@ export const LinePlot: React.FC = ({ } else { const allPoints = focusData.flat(); const xExtent = d3.extent(allPoints, (d) => d.x); - finalXDomain = xExtent[0] !== undefined ? xExtent as [number, number] : [0, 100]; + finalXDomain = + xExtent[0] !== undefined ? (xExtent as [number, number]) : [0, 100]; } // Update scales @@ -116,14 +134,17 @@ export const LinePlot: React.FC = ({ const activeDomain = selectedDomainRef.current ?? finalXDomain; x.domain(activeDomain); - // Brushed interaction handler + // Brushed interaction handler (Downsampling version) const brushed = (event: d3.D3BrushEvent) => { if (event.sourceEvent?.type === "zoom") return; - if (event.selection) { - latestDomainRef.current = (event.selection as [number, number]).map(x2.invert, x2) as [number, number]; + + const s = event.selection as [number, number] | null; + if (s) { + latestDomainRef.current = s.map(x2.invert, x2) as [number, number]; } else { latestDomainRef.current = null; } + if (rafRef.current !== null) return; rafRef.current = requestAnimationFrame(() => { @@ -146,24 +167,44 @@ export const LinePlot: React.FC = ({ svg.select(".focus .x-axis").call(d3.axisBottom(x)); }); + }; - // Clip path (scoped per component instance) + // Emit selection to React only when brushing ends + const brushEnded = (event: d3.D3BrushEvent) => { + if (isApplyingExternalSelectionRef.current) return; + if (!event.sourceEvent) return; + + const s = event.selection as [number, number] | null; + if (!s) { + latestBrushSelectionRef.current = null; + onBrushSelectionRef.current?.(null); + return; + } + + const nextSelection = s.map(x2.invert, x2) as [number, number]; + latestBrushSelectionRef.current = nextSelection; + onBrushSelectionRef.current?.(nextSelection); + }; + + // Clip path svg.selectAll("defs").data([null]).join("defs") .selectAll("clipPath").data([null]).join("clipPath") .attr("id", clipPathId) - .selectAll("rect").data([null]).join("rect") + .selectAll("rect") + .data([null]) + .join("rect") .attr("width", innerWidth) .attr("height", innerHeight); - // Focus group (main chart) + // Focus group const focus = svg.selectAll(".focus") .data([null]) .join("g") .attr("class", "focus") .attr("transform", `translate(${margin.left},${margin.top})`); - // Context group (brush area) + // Context group const context = svg.selectAll(".context") .data([null]) .join("g") @@ -171,7 +212,8 @@ export const LinePlot: React.FC = ({ .attr("transform", `translate(${margin2.left},${margin2.top})`); // Focus X Axis - focus.selectAll(".x-axis") + focus + .selectAll(".x-axis") .data([null]) .join("g") .attr("class", "x-axis axis text-muted-foreground text-xs") @@ -179,37 +221,44 @@ export const LinePlot: React.FC = ({ .call(d3.axisBottom(x) as d3.Axis); // Focus Y Axis - focus.selectAll(".y-axis") + focus + .selectAll(".y-axis") .data([null]) .join("g") .attr("class", "y-axis axis text-muted-foreground text-xs") .call(d3.axisLeft(y).ticks(5) as d3.Axis); // Context X Axis - context.selectAll(".x-axis") + context + .selectAll(".x-axis") .data([null]) .join("g") .attr("class", "x-axis axis text-muted-foreground text-xs") .attr("transform", `translate(0,${innerHeight2})`) .call(d3.axisBottom(x2) as d3.Axis); - // Line generators with curve smoothing + // Line generators const lineGenerator = d3.line() .x((d) => x(d.x)) .y((d) => y(d.y)) .curve(d3.curveMonotoneX); - const lineGenerator2 = d3.line() + const lineGenerator2 = d3 + .line() .x((d) => x2(d.x)) .y((d) => y2(d.y)) .curve(d3.curveMonotoneX); // Focus lines + focus.selectAll(".line-path") .data(buildVisibleData(selectedDomainRef.current)) .join("path") .attr("clip-path", `url(#${clipPathId})`) .attr("class", "line-path") - .style("stroke", (_, i) => styleForSeries?.(i)?.stroke?.toString() ?? null) + .style( + "stroke", + (_, i) => styleForSeries?.(i)?.stroke?.toString() ?? null, + ) .style("opacity", (_, i) => { const opacity = styleForSeries?.(i)?.opacity; return typeof opacity === "number" ? opacity : null; @@ -218,6 +267,14 @@ export const LinePlot: React.FC = ({ const strokeWidth = styleForSeries?.(i)?.strokeWidth; return strokeWidth !== undefined ? strokeWidth.toString() : null; }) + .style("stroke-dasharray", (_, i) => { + const dash = styleForSeries?.(i)?.strokeDasharray; + return dash !== undefined ? dash.toString() : null; + }) + .style("stroke-linecap", (_, i) => { + const linecap = styleForSeries?.(i)?.strokeLinecap; + return linecap !== undefined ? linecap.toString() : null; + }) .attr("d", lineGenerator); // Context lines @@ -225,7 +282,10 @@ export const LinePlot: React.FC = ({ .data(contextData) .join("path") .attr("class", "line-context") - .style("stroke", (_, i) => styleForSeries?.(i)?.stroke?.toString() ?? null) + .style( + "stroke", + (_, i) => styleForSeries?.(i)?.stroke?.toString() ?? null, + ) .style("opacity", (_, i) => { const opacity = styleForSeries?.(i)?.opacity; return typeof opacity === "number" ? opacity : null; @@ -234,14 +294,24 @@ export const LinePlot: React.FC = ({ const strokeWidth = styleForSeries?.(i)?.strokeWidth; return strokeWidth !== undefined ? strokeWidth.toString() : null; }) + .style("stroke-dasharray", (_, i) => { + const dash = styleForSeries?.(i)?.strokeDasharray; + return dash !== undefined ? dash.toString() : null; + }) + .style("stroke-linecap", (_, i) => { + const linecap = styleForSeries?.(i)?.strokeLinecap; + return linecap !== undefined ? linecap.toString() : null; + }) .attr("d", lineGenerator2); // Brush - const brush = d3.brushX() + const brush = d3.brushX() .extent([[0, 0], [innerWidth, innerHeight2]]) - .on("brush end", brushed); + .on("brush", brushed) + .on("end", brushEnded); - context.selectAll(".brush") + context + .selectAll(".brush") .data([null]) .join("g") .attr("class", "brush") @@ -249,13 +319,20 @@ export const LinePlot: React.FC = ({ .selectAll(".selection") .attr("class", "selection fill-muted-foreground/30 stroke-border"); - // Keep brush UI in sync - context.select(".brush").call( - brush.move, - selectedDomainRef.current - ? (selectedDomainRef.current.map(x2) as [number, number]) - : null, - ); + brushRef.current = brush; + brushGroupRef.current = context.select(".brush"); + + // Sync initial brush selection + if (brushSelection) { + isApplyingExternalSelectionRef.current = true; + brushGroupRef.current?.call( + brush.move, + brushSelection.map((value) => x2(value)) as [number, number], + ); + isApplyingExternalSelectionRef.current = false; + latestBrushSelectionRef.current = brushSelection; + selectedDomainRef.current = brushSelection; + } return () => { if (rafRef.current !== null) { @@ -264,11 +341,54 @@ export const LinePlot: React.FC = ({ } }; - }, [focusData, contextData, width, height, xDomain, yDomain, styleForSeries, clipPathId, downsampleThreshold]); + }, [focusData, contextData, width, height, xDomain, yDomain, styleForSeries, clipPathId, downsampleThreshold, brushSelection]); + + // External brush sync + useEffect(() => { + if (!brushRef.current || !brushGroupRef.current || data.length === 0 || width === 0) return; + + const { x2 } = scalesRef.current; + const hasLatest = latestBrushSelectionRef.current !== null; + const hasIncoming = brushSelection !== null && brushSelection !== undefined; + + if (!hasIncoming) { + if (!hasLatest) return; + isApplyingExternalSelectionRef.current = true; + brushGroupRef.current.call(brushRef.current.move, null); + isApplyingExternalSelectionRef.current = false; + latestBrushSelectionRef.current = null; + selectedDomainRef.current = null; + return; + } + + const next = brushSelection as [number, number]; + const prev = latestBrushSelectionRef.current; + + if (prev && prev[0] === next[0] && prev[1] === next[1]) return; + + isApplyingExternalSelectionRef.current = true; + brushGroupRef.current.call( + brushRef.current.move, + next.map((value) => x2(value)) as [number, number], + ); + isApplyingExternalSelectionRef.current = false; + latestBrushSelectionRef.current = next; + selectedDomainRef.current = next; + }, [brushSelection, data.length, width]); return ( -
- {width > 0 && } +
+ {width > 0 && ( + + )}
); -}; \ No newline at end of file +}); diff --git a/apps/frontend/app/components/graphs/base/ScatterPlot.tsx b/apps/frontend/app/components/graphs/base/ScatterPlot.tsx new file mode 100644 index 0000000..fd5a10e --- /dev/null +++ b/apps/frontend/app/components/graphs/base/ScatterPlot.tsx @@ -0,0 +1,324 @@ +import React, { useEffect, useId, useMemo, useRef, useState } from "react"; +import * as d3 from "d3"; + +export interface ScatterPoint { + x: number; + y: number; + id?: string; + meta?: Record; +} + +export interface ScatterSeries { + label: string; + color?: string; + points: ScatterPoint[]; + pointRadius?: number; + opacity?: number; +} + +export interface ScatterBand { + start: number; + end: number; + color: string; + label?: string; +} + +export interface ScatterLine { + label?: string; + color?: string; + points: ScatterPoint[]; + strokeWidth?: number; + strokeDasharray?: string; + opacity?: number; +} + +interface ScatterPlotProps { + series: ScatterSeries[]; + lines?: ScatterLine[]; + bands?: ScatterBand[]; + height?: number; + xDomain?: [number, number]; + yDomain?: [number, number]; + xLabel?: string; + yLabel?: string; + onPointClick?: (point: ScatterPoint, seriesIndex: number) => void; +} + +export const ScatterPlot: React.FC = ({ + series, + lines = [], + bands = [], + height = 320, + xDomain, + yDomain, + xLabel, + yLabel, + onPointClick, +}) => { + const containerRef = useRef(null); + const svgRef = useRef(null); + const tooltipRef = useRef(null); + const [width, setWidth] = useState(0); + const clipPathId = useId(); + + const allPoints = useMemo(() => series.flatMap((s) => s.points), [series]); + const hasData = allPoints.some( + (p) => Number.isFinite(p.x) && Number.isFinite(p.y), + ); + + useEffect(() => { + if (!containerRef.current) return; + const resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + setWidth(entry.contentRect.width); + }); + resizeObserver.observe(containerRef.current); + return () => resizeObserver.disconnect(); + }, []); + + useEffect(() => { + if (!svgRef.current || width === 0 || !hasData) return; + + const svg = d3.select(svgRef.current); + const tooltip = d3.select(tooltipRef.current); + tooltip.style("opacity", 0); + + const margin = { top: 20, right: 30, bottom: 46, left: 56 }; + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + if (innerWidth <= 0 || innerHeight <= 0) return; + + const xExtent = d3.extent(allPoints, (d) => d.x) as [number, number]; + const yExtent = d3.extent(allPoints, (d) => d.y) as [number, number]; + + const finalXDomain: [number, number] = xDomain ?? [ + Number.isFinite(xExtent?.[0]) ? xExtent[0] : 0, + Number.isFinite(xExtent?.[1]) ? xExtent[1] : 1, + ]; + + const finalYDomain: [number, number] = yDomain ?? [ + Number.isFinite(yExtent?.[0]) ? yExtent[0] : 0, + Number.isFinite(yExtent?.[1]) ? yExtent[1] : 1, + ]; + + const x = d3.scaleLinear().domain(finalXDomain).range([0, innerWidth]); + const y = d3.scaleLinear().domain(finalYDomain).range([innerHeight, 0]); + + svg + .selectAll("defs") + .data([null]) + .join("defs") + .selectAll("clipPath") + .data([null]) + .join("clipPath") + .attr("id", clipPathId) + .selectAll("rect") + .data([null]) + .join("rect") + .attr("width", innerWidth) + .attr("height", innerHeight); + + const g = svg + .selectAll("g.plot") + .data([null]) + .join("g") + .attr("class", "plot") + .attr("transform", `translate(${margin.left},${margin.top})`); + + const bandData = bands.filter( + (b) => + Number.isFinite(b.start) && Number.isFinite(b.end) && b.end > b.start, + ); + + g.selectAll("rect.band") + .data(bandData) + .join("rect") + .attr("class", "band") + .attr("x", (d) => x(d.start)) + .attr("y", 0) + .attr("width", (d) => Math.max(0, x(d.end) - x(d.start))) + .attr("height", innerHeight) + .attr("fill", (d) => d.color) + .attr("opacity", 0.18); + + const lineGenerator = d3 + .line() + .x((d) => x(d.x)) + .y((d) => y(d.y)) + .curve(d3.curveLinear); + + g.selectAll("path.trendline") + .data(lines) + .join("path") + .attr("class", "trendline") + .attr("clip-path", `url(#${clipPathId})`) + .attr("fill", "none") + .attr("stroke", (d) => d.color ?? "#333") + .attr("stroke-width", (d) => d.strokeWidth ?? 2) + .attr("stroke-dasharray", (d) => d.strokeDasharray ?? null) + .attr("opacity", (d) => d.opacity ?? 0.9) + .attr("d", (d) => lineGenerator(d.points)); + + const seriesGroups = g + .selectAll("g.series") + .data(series) + .join("g") + .attr("class", "series") + .attr("clip-path", `url(#${clipPathId})`); + + seriesGroups + .selectAll("circle.point") + .data((d, seriesIndex) => + d.points.map((p) => ({ point: p, series: d, seriesIndex })), + ) + .join("circle") + .attr("class", "point") + .attr("cx", (d) => x(d.point.x)) + .attr("cy", (d) => y(d.point.y)) + .attr("r", (d) => d.series.pointRadius ?? 2.5) + .attr("fill", (d) => d.series.color ?? "#666") + .attr("opacity", (d) => d.series.opacity ?? 0.7) + .attr("cursor", onPointClick ? "pointer" : "default") + .on("mouseenter", function (event: MouseEvent, d) { + d3.select(this).attr("r", (d.series.pointRadius ?? 2.5) + 1); + if (tooltipRef.current) { + const [xPos, yPos] = d3.pointer(event, containerRef.current); + tooltip.style("opacity", 1); + tooltip.selectAll("*").remove(); + const speed = d.point.meta?.speed; + const displacement = d.point.meta?.displacement; + const unit = d.point.meta?.unit; + if (typeof speed === "number") { + tooltip + .append("div") + .attr("class", "text-xs text-gray-600") + .text(`Speed: ${speed.toFixed(1)} mm/s`); + } + if (typeof displacement === "number") { + tooltip + .append("div") + .attr("class", "text-xs text-gray-600") + .text( + `Disp: ${displacement.toFixed(1)}${unit === "percent" ? "%" : " mm"}`, + ); + } + tooltip.style("left", `${xPos}px`).style("top", `${yPos - 12}px`); + } + }) + .on("mousemove", (event: MouseEvent) => { + const [xPos, yPos] = d3.pointer(event, containerRef.current); + tooltip.style("left", `${xPos}px`).style("top", `${yPos - 12}px`); + }) + .on("mouseleave", function (_, d) { + d3.select(this).attr("r", d.series.pointRadius ?? 2.5); + tooltip.style("opacity", 0); + }) + .on("click", (_, d) => { + if (!onPointClick) return; + onPointClick(d.point, d.seriesIndex ?? 0); + }); + + g.selectAll("g.x-axis") + .data([null]) + .join("g") + .attr("class", "x-axis") + .attr("transform", `translate(0,${innerHeight})`) + .call( + d3 + .axisBottom(x) + .ticks(Math.max(2, Math.floor(innerWidth / 80))) as d3.Axis, + ) + .call((axisGroup) => + axisGroup + .selectAll(".domain, .tick line") + .attr("class", "stroke-border"), + ) + .call((axisGroup) => + axisGroup + .selectAll(".tick text") + .attr("class", "text-muted-foreground text-xs"), + ); + + g.selectAll("g.y-axis") + .data([null]) + .join("g") + .attr("class", "y-axis") + .call(d3.axisLeft(y).ticks(5) as d3.Axis) + .call((axisGroup) => + axisGroup + .selectAll(".domain, .tick line") + .attr("class", "stroke-border"), + ) + .call((axisGroup) => + axisGroup + .selectAll(".tick text") + .attr("class", "text-muted-foreground text-xs"), + ); + + if (xLabel) { + g.selectAll("text.x-label") + .data([null]) + .join("text") + .attr("class", "x-label text-xs text-muted-foreground") + .attr("x", innerWidth / 2) + .attr("y", innerHeight + 34) + .attr("text-anchor", "middle") + .text(xLabel); + } + + if (yLabel) { + g.selectAll("text.y-label") + .data([null]) + .join("text") + .attr("class", "y-label text-xs text-muted-foreground") + .attr("x", -innerHeight / 2) + .attr("y", -42) + .attr("transform", "rotate(-90)") + .attr("text-anchor", "middle") + .text(yLabel); + } + }, [ + series, + lines, + bands, + width, + height, + xDomain, + yDomain, + xLabel, + yLabel, + hasData, + onPointClick, + clipPathId, + allPoints, + ]); + + if (!hasData) { + return ( +
+ No data +
+ ); + } + + return ( +
+ {width > 0 && ( + + )} +
+
+ ); +}; diff --git a/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx b/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx index ea23a7f..e18124f 100644 --- a/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx +++ b/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx @@ -16,13 +16,24 @@ export interface SeriesConfig { freq: number; min?: number; max?: number; + length?: number; dynamicSag?: boolean; + indexOffset?: number; +} + +export interface LineHighlight { + seriesIndex: number; + startIndex: number; + endIndex: number; } interface DisplacementPlotProps { title?: string; series: SeriesConfig[]; height?: number; + highlight?: LineHighlight | null; + brushSelection?: [number, number] | null; + onBrushSelection?: (selection: [number, number] | null) => void; } interface LineMetadata { @@ -31,10 +42,13 @@ interface LineMetadata { } // DisplacementPlot renders suspension displacement -export const DisplacementPlot: React.FC = ({ +export const DisplacementPlot: React.FC = React.memo(({ title = "Displacement", series, height = 300, + highlight, + brushSelection, + onBrushSelection, }) => { const isMobile = useIsMobile(); @@ -49,6 +63,7 @@ export const DisplacementPlot: React.FC = ({ seriesItem.freq, seriesItem.min, seriesItem.max, + seriesItem.indexOffset, ); lines.push(processed); @@ -66,10 +81,38 @@ export const DisplacementPlot: React.FC = ({ return { chartData: lines, lineMetadata: metadata }; }, [series]); + const highlightLine = useMemo(() => { + if (!highlight) return null; + const dataIndex = lineMetadata.findIndex( + (m) => m.seriesIndex === highlight.seriesIndex && !m.isSag, + ); + const seriesLine = dataIndex >= 0 ? chartData[dataIndex] : undefined; + if (!seriesLine || seriesLine.length === 0) return null; + + const start = Math.max( + 0, + Math.min(seriesLine.length - 1, highlight.startIndex), + ); + const end = Math.max( + 0, + Math.min(seriesLine.length - 1, highlight.endIndex), + ); + if (start === end) return [seriesLine[start]!]; + + const sliceStart = Math.min(start, end); + const sliceEnd = Math.max(start, end) + 1; + const segment = seriesLine.slice(sliceStart, sliceEnd); + return segment.length > 0 ? segment : null; + }, [highlight, chartData]); + // No chart if every generated line is empty. const hasAnyData = chartData.some((line) => line.length > 0); if (!hasAnyData) { - return
No data available for {title}
; + return ( +
+ No data available for {title} +
+ ); } return ( @@ -79,8 +122,14 @@ export const DisplacementPlot: React.FC = ({ {/* Legend for primary series (sag overlays inherit the same color). */}
{series.map((s, index) => ( -
-
+
+
{s.label} {s.dynamicSag ? " (sag)" : ""} @@ -92,12 +141,23 @@ export const DisplacementPlot: React.FC = ({
{ const meta = lineMetadata[i]; const strokeWidth = isMobile ? 0.75 : 1.5; + if (highlightLine && i === chartData.length) { + return { + stroke: "#111827", + strokeWidth: 3, + strokeDasharray: "6 4", + strokeLinecap: "round", + opacity: 0.95, + }; + } if (!meta) { return { stroke: getSeriesColor(i), @@ -106,7 +166,10 @@ export const DisplacementPlot: React.FC = ({ } return { - stroke: getSeriesColor(meta.seriesIndex, series[meta.seriesIndex]?.color), + stroke: getSeriesColor( + meta.seriesIndex, + series[meta.seriesIndex]?.color, + ), opacity: meta.isSag ? 0.35 : 1, strokeWidth, }; @@ -115,4 +178,4 @@ export const DisplacementPlot: React.FC = ({
); -}; \ No newline at end of file +}); diff --git a/apps/frontend/app/components/graphs/domain/ReboundCompressionPlot.tsx b/apps/frontend/app/components/graphs/domain/ReboundCompressionPlot.tsx new file mode 100644 index 0000000..a175986 --- /dev/null +++ b/apps/frontend/app/components/graphs/domain/ReboundCompressionPlot.tsx @@ -0,0 +1,403 @@ +import React, { useMemo } from "react"; +import { + ScatterPlot, + ScatterSeries, + ScatterLine, + ScatterBand, +} from "../base/ScatterPlot"; +import { + RawSuspensionData, + buildLineFromPoints, + LinePoint, +} from "app/lib/telemetryUtils"; +import { + processCompressions, + SuspensionActivity, +} from "app/lib/run-analysis"; +import { getSeriesColor } from "app/lib/graphColors"; + +export type SpeedRegion = { + low: number; + high: number; +}; + +interface ReboundCompressionPlotProps { + title: string; + series: { + label: string; + color?: string; + rawData: RawSuspensionData[]; + freq: number; + min?: number; + max?: number; + length?: number; + }[]; + height?: number; + speedRegion: SpeedRegion; + onPointSelect?: (selection: { + seriesIndex: number; + startIndex: number; + endIndex: number; + }) => void; +} + +type PreparedSeries = { + label: string; + color: string; + freq: number; + compressionScatterPoints: { + x: number; + y: number; + id: string; + meta: { + startIndex: number; + endIndex: number; + speed: number; + displacement: number; + }; + }[]; + reboundScatterPoints: { + x: number; + y: number; + id: string; + meta: { + startIndex: number; + endIndex: number; + speed: number; + displacement: number; + }; + }[]; + compressionPoints: LinePoint[]; + compressionLowPoints: LinePoint[]; + compressionHighPoints: LinePoint[]; + reboundPoints: LinePoint[]; + reboundLowPoints: LinePoint[]; + reboundHighPoints: LinePoint[]; +}; + +const flipY = (pt: LinePoint): LinePoint => ({ x: pt.x, y: Math.abs(pt.y) }); + +function filterOutliers( + suspensionActivity: SuspensionActivity[], +): SuspensionActivity[] { + if (suspensionActivity.length === 0) return suspensionActivity; + + const velocities = suspensionActivity.map((a) => a.velocity); + const displacements = suspensionActivity.map((a) => a.displacement); + + const mean = (arr: number[]) => arr.reduce((s, v) => s + v, 0) / arr.length; + const std = (arr: number[], m: number) => + Math.sqrt(arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length); + + const vMean = mean(velocities); + const vStd = std(velocities, vMean); + const dMean = mean(displacements); + const dStd = std(displacements, dMean); + + return suspensionActivity.filter( + (a) => + Math.abs(a.velocity - vMean) < 5 * vStd && + Math.abs(a.displacement - dMean) < 5 * dStd, + ); +} + +export const ReboundCompressionPlot: React.FC = ({ + title, + series, + height = 320, + speedRegion, + onPointSelect, +}) => { + const prepared = useMemo(() => { + return series.map((s, index) => { + const length = s.length ?? 220; + const min = s.min ?? 0; + const max = s.max ?? 4096; + + const activities = processCompressions( + s.rawData, + s.freq, + length, + min, + max, + ); + + const toPoint = (a: SuspensionActivity): LinePoint => ({ + x: Math.abs(a.velocity), + y: a.displacement, + }); + + const compressions = filterOutliers( + activities.filter((a) => a.type === "compression"), + ); + const rebounds = filterOutliers( + activities.filter((a) => a.type === "rebound"), + ); + + const compressionPoints = compressions.map(toPoint); + const reboundPoints = rebounds.map(toPoint).map(flipY); + + const compressionLowPoints = compressions + .filter((a) => Math.abs(a.velocity) <= speedRegion.low) + .map(toPoint); + const compressionHighPoints = compressions + .filter((a) => Math.abs(a.velocity) >= speedRegion.high) + .map(toPoint); + + const reboundLowPoints = rebounds + .filter((a) => Math.abs(a.velocity) <= speedRegion.low) + .map(toPoint) + .map(flipY); + const reboundHighPoints = rebounds + .filter((a) => Math.abs(a.velocity) >= speedRegion.high) + .map(toPoint) + .map(flipY); + + const makeScatterPoints = ( + acts: SuspensionActivity[], + flip: boolean, + ) => + acts.map((a, idx) => ({ + x: Math.abs(a.velocity), + y: flip ? Math.abs(a.displacement) : a.displacement, + id: `${index}-${flip ? "r" : "c"}-${idx}`, + meta: { + startIndex: Math.round(a.time.start * s.freq), + endIndex: Math.round(a.time.end * s.freq), + speed: Math.abs(a.velocity), + displacement: a.displacement, + }, + })); + + return { + label: s.label, + color: getSeriesColor(index, s.color), + freq: s.freq, + compressionScatterPoints: makeScatterPoints(compressions, false), + reboundScatterPoints: makeScatterPoints(rebounds, true), + compressionPoints, + compressionLowPoints, + compressionHighPoints, + reboundPoints, + reboundLowPoints, + reboundHighPoints, + }; + }); + }, [series, speedRegion.low, speedRegion.high]); + + const maxSpeed = useMemo(() => { + let max = 0; + prepared.forEach((p) => { + [...p.compressionScatterPoints, ...p.reboundScatterPoints].forEach( + (pt) => { + if (pt.x > max) max = pt.x; + }, + ); + }); + return max > 0 ? max : null; + }, [prepared]); + + const xMax = maxSpeed + ? Math.max(speedRegion.high, maxSpeed) * 1.05 + : speedRegion.high * 1.3; + + const compressionScatterSeries = useMemo(() => { + return prepared.map((p) => ({ + label: p.label, + color: p.color, + points: p.compressionScatterPoints, + pointRadius: 2.5, + opacity: 0.7, + })); + }, [prepared]); + + const reboundScatterSeries = useMemo(() => { + return prepared.map((p) => ({ + label: p.label, + color: p.color, + points: p.reboundScatterPoints, + pointRadius: 2.5, + opacity: 0.7, + })); + }, [prepared]); + + const buildTrendLines = ( + type: "compression" | "rebound", + ): ScatterLine[] => { + const lines: ScatterLine[] = []; + + prepared.forEach((p) => { + const allPts = + type === "compression" ? p.compressionPoints : p.reboundPoints; + const lowPts = + type === "compression" + ? p.compressionLowPoints + : p.reboundLowPoints; + const highPts = + type === "compression" + ? p.compressionHighPoints + : p.reboundHighPoints; + const prefix = type === "compression" ? "comp" : "reb"; + + const allLine = buildLineFromPoints(allPts); + if (allLine.length > 0) { + lines.push({ + label: `${p.label} ${type}`, + color: p.color, + points: allLine, + strokeWidth: 2.5, + opacity: 0.9, + }); + } + + const lowLine = buildLineFromPoints(lowPts); + if (lowLine.length > 0) { + lines.push({ + label: `${p.label} ${prefix} low-speed`, + color: p.color, + points: lowLine, + strokeWidth: 2, + strokeDasharray: "4 4", + opacity: 0.9, + }); + } + + const highLine = buildLineFromPoints(highPts); + if (highLine.length > 0) { + lines.push({ + label: `${p.label} ${prefix} high-speed`, + color: p.color, + points: highLine, + strokeWidth: 2, + strokeDasharray: "2 6", + opacity: 0.9, + }); + } + }); + + return lines; + }; + + const compressionTrendLines = useMemo( + () => buildTrendLines("compression"), + [prepared, maxSpeed, speedRegion.high], + ); + + const reboundTrendLines = useMemo( + () => buildTrendLines("rebound"), + [prepared, maxSpeed, speedRegion.high], + ); + + const bands = useMemo(() => { + const maxBand = + maxSpeed && Number.isFinite(maxSpeed) + ? maxSpeed * 1.05 + : speedRegion.high * 1.4; + return [ + { + start: 0, + end: speedRegion.low, + color: "#a7f3d0", + label: "Low-speed", + }, + { + start: speedRegion.high, + end: Math.max(maxBand, speedRegion.high + 20), + color: "#fde68a", + label: "High-speed", + }, + ]; + }, [speedRegion, maxSpeed]); + + const xLabel = "Speed (mm/s)"; + const yLabel = "Displacement (mm)"; + + const handlePointClick = + (filterType: "compression" | "rebound") => + ( + point: { meta?: Record }, + seriesIndex: number, + ) => { + if (!onPointSelect) return; + const startIndex = + typeof point.meta?.startIndex === "number" + ? (point.meta.startIndex as number) + : 0; + const endIndex = + typeof point.meta?.endIndex === "number" + ? (point.meta.endIndex as number) + : 0; + onPointSelect({ seriesIndex, startIndex, endIndex }); + }; + + const plotHeight = Math.round(height * 0.75); + + return ( +
+
+
+

{title}

+

+ Low: 0–{speedRegion.low} mm/s · High: {speedRegion.high}+ mm/s +

+
+
+ +
+ {prepared.map((p) => ( +
+ + {p.label} +
+ ))} +
+ + Best fit +
+
+ + Low speed fit +
+
+ + High speed fit +
+
+ +
+
+

+ Compression +

+ +
+ +
+

Rebound

+ +
+
+
+ ); +}; diff --git a/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx b/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx new file mode 100644 index 0000000..3aac247 --- /dev/null +++ b/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx @@ -0,0 +1,74 @@ +import React, { useMemo } from "react"; +import { + LineHistogram, + LineHistogramSeries } from "../base/LineHistogram"; +import { + buildVelocitySamples, + RawSuspensionData} from '../../../lib/telemetryUtils'; +import { getSeriesColor } from "app/lib/graphColors"; + +interface VelocityHistogramProps { + rawData?: RawSuspensionData[]; + freq?: number; + series?: { + label: string; + rawData: RawSuspensionData[]; + freq: number; + fillColor?: string; + min?: number; + max?: number; + }[]; + title?: string; + fillColor?: string; + height?: number; + min?: number; + max?: number; +} + +// Renders a histogram of velocity values +export const VelocityHistogram: React.FC = ({ + rawData = [], + freq = 100, + series, + title = "Suspension Velocity", + fillColor = "hsl(var(--chart-1))", + height = 200, + min, + max, +}) => { + // buildVelocitySamples on rawData to get velocity samples + const histogramSeries = useMemo(() => { + if (series && series.length > 0) { + return series.map((seriesItem, index) => ({ + label: seriesItem.label, + color: getSeriesColor(index, seriesItem.fillColor, fillColor), + data: buildVelocitySamples(seriesItem.rawData, seriesItem.freq, seriesItem.min, seriesItem.max).map(s => s.velocity), + })); + } + + return [ + { + label: title, + color: fillColor, + data: buildVelocitySamples(rawData, freq, min, max).map(s => s.velocity), + }, + ]; + }, [series, rawData, freq, min, max, fillColor, title]); + + // Keep layout stable + if (!histogramSeries.some((seriesItem) => seriesItem.data.length > 0)) { + return
No data
; + } + + return ( +
+ +
+ ); +}; diff --git a/apps/frontend/app/components/runs/chart-sections.tsx b/apps/frontend/app/components/runs/chart-sections.tsx index 19d8e92..1cfc407 100644 --- a/apps/frontend/app/components/runs/chart-sections.tsx +++ b/apps/frontend/app/components/runs/chart-sections.tsx @@ -2,12 +2,21 @@ import { RunJson } from "app/types/runs"; import { DisplacementPlot, SeriesConfig, + LineHighlight, } from "app/components/graphs/domain/DisplacementPlot"; import { TravelHistogram } from "app/components/graphs/domain/TravelHistogram"; +import { VelocityHistogram } from "app/components/graphs/domain/VelocityHistogram"; +import { ReboundCompressionPlot } from "app/components/graphs/domain/ReboundCompressionPlot"; import { SectionHeader } from "app/components/ui/run-elements"; import { Run } from "@repo/database"; import { getSeriesColor } from "app/lib/graphColors"; -import { getProfileFromRun } from "app/lib/telemetryUtils"; +import { + getProfileFromRun, + RawSuspensionData, + resolveTrimBounds, + trimRawDataByBounds, +} from "app/lib/telemetryUtils"; +import { useState } from "react"; interface ChartSectionProps { selected: Run[]; @@ -15,6 +24,14 @@ interface ChartSectionProps { isCompareMode: boolean; } +function toRawSuspensionArray(value: unknown): RawSuspensionData[] { + if (!Array.isArray(value)) { + return []; + } + + return value as RawSuspensionData[]; +} + function getSeriesConfig( run: Run, index: number, @@ -28,16 +45,21 @@ function getSeriesConfig( const isError = !data || data.error; const rawData = isError ? [] - : type === "front" - ? data.data.suspension.front_sus - : data.data.suspension.rear_sus; + : toRawSuspensionArray( + type === "front" + ? data.data.suspension.front_sus + : data.data.suspension.rear_sus, + ); + const bounds = resolveTrimBounds(run, rawData.length); + const offset = bounds?.lowerBoundIdx ?? 0; + const trimmedRawData = trimRawDataByBounds(run, rawData); const freq = isError ? 100 : type === "front" ? run.front_freq || 100 : run.rear_freq || 100; - // Apply rider profile min/max range + // Apply rider profile min/max range and suspension travel const profile = getProfileFromRun(run); const min = profile ? type === "front" @@ -49,25 +71,34 @@ function getSeriesConfig( ? profile.front_max : profile.back_max : undefined; + const length = profile + ? type === "front" + ? profile.front_travel + : profile.back_travel + : undefined; // Build unified series config for displacement rendering return { label: customLabel ?? run.title ?? `Run ${run.id}`, color: getSeriesColor(index), - rawData, + rawData: trimmedRawData, freq, min, max, + length, dynamicSag, + indexOffset: offset, }; } -// ---------------------- Displacement Plot ---------------------- +// ---------------------- Line Plots ---------------------- export function DisplacementSection({ selected, jsonData, isCompareMode, }: ChartSectionProps) { + const [highlight, setHighlight] = useState(null); + if (!selected || selected.length === 0 || !selected[0]) { return No run selected; } @@ -77,40 +108,108 @@ export function DisplacementSection({ return (
- Displacement Plot + Line Plots {isCompareMode ? (
- getSeriesConfig(run, i, jsonData, "front", undefined, true) + getSeriesConfig(run, i, jsonData, "front", undefined, true), )} + highlight={highlight} /> + getSeriesConfig(run, i, jsonData, "rear", undefined, true), + )} + highlight={highlight} + /> + + + getSeriesConfig(run, i, jsonData, "front", undefined, true), + )} + speedRegion={{ low: 50, high: 150 }} + onPointSelect={({ seriesIndex, startIndex, endIndex }) => { + setHighlight({ seriesIndex, startIndex, endIndex }); + }} + /> + + - getSeriesConfig(run, i, jsonData, "rear", undefined, true) + getSeriesConfig(run, i, jsonData, "rear", undefined, true), )} + speedRegion={{ low: 50, high: 150 }} + onPointSelect={({ seriesIndex, startIndex, endIndex }) => { + setHighlight({ seriesIndex, startIndex, endIndex }); + }} />
) : ( firstData && !firstData.error && ( - +
+ + + { + setHighlight({ seriesIndex, startIndex, endIndex }); + }} + /> + + { + setHighlight({ seriesIndex: 1, startIndex, endIndex }); + }} + /> +
) )}
); } -// ---------------------- Histogram Plot ---------------------- +// ---------------------- Histogram Plots ---------------------- export function HistogramSection({ selected, jsonData, @@ -125,27 +224,27 @@ export function HistogramSection({ } const profile = getProfileFromRun(run); - const min = - type === "front" - ? profile?.front_min - : profile?.back_min; - const max = - type === "front" - ? profile?.front_max - : profile?.back_max; + const min = type === "front" ? profile?.front_min : profile?.back_min; + const max = type === "front" ? profile?.front_max : profile?.back_max; return { label: run.title ?? `Run ${run.id}`, - rawData: - type === "front" - ? data.data.suspension.front_sus - : data.data.suspension.rear_sus, + rawData: trimRawDataByBounds( + run, + toRawSuspensionArray( + type === "front" + ? data.data.suspension.front_sus + : data.data.suspension.rear_sus, + ), + ), fillColor: getSeriesColor(runIndex), min, max, }; }) - .filter((seriesItem): seriesItem is NonNullable => Boolean(seriesItem)); + .filter((seriesItem): seriesItem is NonNullable => + Boolean(seriesItem), + ); }; const frontSeries = buildSeries("front"); @@ -161,28 +260,30 @@ export function HistogramSection({ if (!data || data.error) { return (
- Travel Histogram -
No histogram data available
+ Histogram Plots +
+ No histogram data available +
); } return (
- Travel Histogram -
+ Histogram Plots +
+
); @@ -197,17 +305,31 @@ export function HistogramSection({ return (
- Travel Histogram -
+ Histogram Plots +
+ + + getSeriesConfig(run, i, jsonData, "front"), + )} + /> + + getSeriesConfig(run, i, jsonData, "rear"), + )} + /> +
); -} +} \ No newline at end of file diff --git a/apps/frontend/app/components/runs/main-content.tsx b/apps/frontend/app/components/runs/main-content.tsx index ea3d50f..03bea2f 100644 --- a/apps/frontend/app/components/runs/main-content.tsx +++ b/apps/frontend/app/components/runs/main-content.tsx @@ -7,16 +7,17 @@ import { EmptyState, LoadingState, SectionDivider, -} from "app/components/ui/run-elements"; +} from "app/components/ui/run-elements"; import { Run } from "@repo/database"; import { useState } from "react"; import { ProfilePopup } from "../profiles/profilePopUp"; -import { UserIcon } from "lucide-react"; +import { UserIcon, Scissors } from "lucide-react"; import { RunsMetadata } from "./runs-metadata"; -import { useOptimisticRuns } from "app/hooks/useOptimisticRuns"; +import { useOptimisticRuns } from "app/hooks/useOptimisticRuns"; import { CommentsPopup } from "./commentsPopup"; import { updateRun } from "app/api/runs"; import { SummarySection } from "./summary-section"; +import { TrimPopup } from "./trimPopup"; @@ -25,6 +26,7 @@ interface MainContentProps { jsonData: Record; loadingJson: boolean; isCompareMode: boolean; + onRunUpdate?: (id: number, updates: Partial) => void; } export function MainContent({ @@ -32,14 +34,16 @@ export function MainContent({ jsonData, loadingJson, isCompareMode, + onRunUpdate, }: MainContentProps) { - + const { runs, handleProfileUpdate, handleRunUpdate } = useOptimisticRuns(initialSelected); const [isPopupOpen, setIsPopupOpen] = useState(false); // Comments popup state const [commentsOpen, setCommentsOpen] = useState(false); const [commentsRun, setCommentsRun] = useState(null); + const [trimOpen, setTrimOpen] = useState(false); // 2. NOW you can do conditional returns @@ -98,7 +102,7 @@ export function MainContent({ setIsPopupOpen(false)} - selected={runs} + selected={runs} onProfileUpdate={handleProfileUpdate} /> )} @@ -116,6 +120,7 @@ export function MainContent({ await updateRun(id, { comments: newComments }); // update local state on success handleRunUpdate(id, { comments: newComments }); + onRunUpdate?.(id, { comments: newComments }); } catch (e) { console.error("Failed to persist comments", e); // Optionally, add user-facing error handling here. @@ -123,16 +128,39 @@ export function MainContent({ }} /> + setTrimOpen(false)} + run={!isCompareMode ? runs[0] ?? null : null} + runJson={!isCompareMode && runs[0] ? jsonData[runs[0].id] : undefined} + onSave={async (id, payload) => { + await updateRun(id, payload); + handleRunUpdate(id, payload); + onRunUpdate?.(id, payload); + }} + /> +

{isCompareMode ? "Run Comparison" : runs[0]?.title || "Run Details"}

- +
+ {!isCompareMode && ( + + )} + +
{/* Metadata section (extracted to RunsMetadata component) */} @@ -141,17 +169,15 @@ export function MainContent({ jsonData={jsonData} onOpenComments={(r) => { setCommentsRun(r); setCommentsOpen(true); }} /> + - - - - {/* Always visible: Histogram */} - - - )}
diff --git a/apps/frontend/app/components/runs/sidebar.tsx b/apps/frontend/app/components/runs/sidebar.tsx index e7b742f..ec66618 100644 --- a/apps/frontend/app/components/runs/sidebar.tsx +++ b/apps/frontend/app/components/runs/sidebar.tsx @@ -7,28 +7,28 @@ import { useState, useMemo } from "react"; // ----------------- Sidebar Menu Button ----------------- interface SidebarMenuButtonProps { run: Run; - selected: Run[]; - setSelected: (runs: Run[]) => void; + selectedRunIds: number[]; + setSelectedRunIds: (ids: number[]) => void; } function SidebarMenuButton({ run, - selected, - setSelected, + selectedRunIds, + setSelectedRunIds, }: SidebarMenuButtonProps) { - const isSelected = selected.some((r) => r.id === run.id); + const isSelected = selectedRunIds.includes(run.id); const toggle = () => { if (isSelected) { - setSelected(selected.filter((r) => r.id !== run.id)); - } else if (selected.length < 2) { - setSelected([...selected, run]); + setSelectedRunIds(selectedRunIds.filter((id) => id !== run.id)); + } else if (selectedRunIds.length < 2) { + setSelectedRunIds([...selectedRunIds, run.id]); } }; return ( +
+ + {!hasData ? ( +
+ No telemetry data available for trimming this run. +
+ ) : ( + <> + +

+ Drag the shaded selection in the lower subgraph to set trim bounds. + Current selection: {safeLowerBound} to {safeUpperBound} + {' '}({selectedSamples} / {sampleLength} samples) +

+ + {saveError && ( +
+ {saveError} +
+ )} + + )} + +
+ + +
+
+
+ ); +} diff --git a/apps/frontend/app/hooks/useOptimisticRuns.ts b/apps/frontend/app/hooks/useOptimisticRuns.ts index 27d314b..0ed568d 100644 --- a/apps/frontend/app/hooks/useOptimisticRuns.ts +++ b/apps/frontend/app/hooks/useOptimisticRuns.ts @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { Run } from "@repo/database"; import { Profile } from "app/components/profiles/profileRow"; +import { RunUpdatePayload } from 'app/api/runs'; export function useOptimisticRuns(initialRuns: Run[]) { const queryClient = useQueryClient(); @@ -41,7 +42,7 @@ export function useOptimisticRuns(initialRuns: Run[]) { queryClient.invalidateQueries({ queryKey: ['runs'] }); }, [queryClient]); - const handleRunUpdate = useCallback((id: number, updates: Partial>) => { + const handleRunUpdate = useCallback((id: number, updates: RunUpdatePayload) => { setRuns((prevRuns) => prevRuns.map((run) => (run.id === id ? { ...run, ...updates } : run))); queryClient.invalidateQueries({ queryKey: ['runs'] }); }, [queryClient]); diff --git a/apps/frontend/app/hooks/useRunMetrics.ts b/apps/frontend/app/hooks/useRunMetrics.ts index 79d2893..8aa4f69 100644 --- a/apps/frontend/app/hooks/useRunMetrics.ts +++ b/apps/frontend/app/hooks/useRunMetrics.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { Run } from "@repo/database"; import { RunJson } from "app/types/runs"; -import { RawSuspensionData, normalizeToPercentage, getProfileFromRun } from "app/lib/telemetryUtils"; +import { RawSuspensionData, normalizeToPercentage, getProfileFromRun, trimRawDataByBounds } from "app/lib/telemetryUtils"; export const DYNAMIC_SAG_IDEAL_MIN_FRONT = 25; export const DYNAMIC_SAG_IDEAL_MAX_FRONT = 35; @@ -11,7 +11,7 @@ export const BOTTOM_OUT_TRAVEL_MIN = 95; export const BOTTOM_OUT_COUNT_THRESHOLD = 3; const OFF_GROUND_TRAVEL_MAX = 5; -function getNormalizedSuspensionData( +export function getNormalizedSuspensionData( run: Run, jsonData: Record, type: 'front' | 'rear', @@ -20,11 +20,14 @@ function getNormalizedSuspensionData( jsonData[run.id]?.data?.suspension?.[type === 'front' ? 'front_sus' : 'rear_sus']; if (!suspensionData || suspensionData.length === 0) return null; + const trimmedSuspensionData = trimRawDataByBounds(run, suspensionData); + if (trimmedSuspensionData.length === 0) return null; + const profile = getProfileFromRun(run); const min = profile ? (type === 'front' ? profile.front_min : profile.back_min) : undefined; const max = profile ? (type === 'front' ? profile.front_max : profile.back_max) : undefined; - const normalized = suspensionData + const normalized = trimmedSuspensionData .map(p => { const val = typeof p === 'number' ? p : Number(p.displacement ?? 0); return normalizeToPercentage(val, min, max); @@ -34,7 +37,7 @@ function getNormalizedSuspensionData( return normalized.length === 0 ? null : normalized; } -function dynamicSag(norm: number[] | null): number | null { +export function dynamicSag(norm: number[] | null): number | null { if (!norm) return null; const sorted = [...norm].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); @@ -43,12 +46,12 @@ function dynamicSag(norm: number[] | null): number | null { : sorted[mid]!; } -function zonePercent(norm: number[] | null, lo: number, hi: number): number | null { +export function zonePercent(norm: number[] | null, lo: number, hi: number): number | null { if (!norm) return null; return (norm.filter(v => v >= lo && v <= hi).length / norm.length) * 100; } -function zoneSeconds( +export function zoneSeconds( norm: number[] | null, freq: number | null, lo: number, @@ -58,12 +61,12 @@ function zoneSeconds( return norm.filter(v => v >= lo && v <= hi).length / freq; } -function maxTravel(norm: number[] | null): number | null { +export function maxTravel(norm: number[] | null): number | null { if (!norm || norm.length === 0) return null; return norm.reduce((max, v) => (v > max ? v : max), -Infinity); } -function countZoneEntries(norm: number[] | null, lo: number, hi: number): number | null { +export function countZoneEntries(norm: number[] | null, lo: number, hi: number): number | null { if (!norm || norm.length === 0) return null; let count = 0; let inZone = norm[0]! >= lo && norm[0]! <= hi; @@ -84,37 +87,41 @@ function calculateRebound(_run: Run, _jsonData: Record, _type: return 0; } -export function useRunMetrics(run: Run, jsonData: Record) { - return useMemo(() => { - const frontNorm = getNormalizedSuspensionData(run, jsonData, 'front'); - const rearNorm = getNormalizedSuspensionData(run, jsonData, 'rear'); - const frontFreq = run.front_freq ?? null; - const rearFreq = run.rear_freq ?? null; - - const frontSag = dynamicSag(frontNorm); - const rearSag = dynamicSag(rearNorm); - - return { - frontSag, - rearSag, - frontSagInRange: frontSag === null ? null : frontSag >= DYNAMIC_SAG_IDEAL_MIN_FRONT && frontSag <= DYNAMIC_SAG_IDEAL_MAX_FRONT, - rearSagInRange: rearSag === null ? null : rearSag >= DYNAMIC_SAG_IDEAL_MIN_REAR && rearSag <= DYNAMIC_SAG_IDEAL_MAX_REAR, - frontBottomOutPct: zonePercent(frontNorm, BOTTOM_OUT_TRAVEL_MIN, 100), - frontBottomOutSec: zoneSeconds(frontNorm, frontFreq, BOTTOM_OUT_TRAVEL_MIN, 100), - frontBottomOutCount: countZoneEntries(frontNorm, BOTTOM_OUT_TRAVEL_MIN, 100), - frontMaxTravel: maxTravel(frontNorm), - frontOffGroundPct: zonePercent(frontNorm, 0, OFF_GROUND_TRAVEL_MAX), - frontOffGroundSec: zoneSeconds(frontNorm, frontFreq, 0, OFF_GROUND_TRAVEL_MAX), - rearBottomOutPct: zonePercent(rearNorm, BOTTOM_OUT_TRAVEL_MIN, 100), - rearBottomOutSec: zoneSeconds(rearNorm, rearFreq, BOTTOM_OUT_TRAVEL_MIN, 100), - rearBottomOutCount: countZoneEntries(rearNorm, BOTTOM_OUT_TRAVEL_MIN, 100), - rearMaxTravel: maxTravel(rearNorm), - rearOffGroundPct: zonePercent(rearNorm, 0, OFF_GROUND_TRAVEL_MAX), - rearOffGroundSec: zoneSeconds(rearNorm, rearFreq, 0, OFF_GROUND_TRAVEL_MAX), - frontCompression: calculateCompression(run, jsonData, 'front'), - rearCompression: calculateCompression(run, jsonData, 'rear'), - frontRebound: calculateRebound(run, jsonData, 'front'), - rearRebound: calculateRebound(run, jsonData, 'rear'), - }; - }, [run, jsonData]); +export function computeRunMetrics(run: Run, jsonData: Record) { + const frontNorm = getNormalizedSuspensionData(run, jsonData, 'front'); + const rearNorm = getNormalizedSuspensionData(run, jsonData, 'rear'); + const frontFreq = run.front_freq ?? null; + const rearFreq = run.rear_freq ?? null; + + const frontSag = dynamicSag(frontNorm); + const rearSag = dynamicSag(rearNorm); + + return { + frontSag, + rearSag, + frontSagInRange: frontSag === null ? null : frontSag >= DYNAMIC_SAG_IDEAL_MIN_FRONT && frontSag <= DYNAMIC_SAG_IDEAL_MAX_FRONT, + rearSagInRange: rearSag === null ? null : rearSag >= DYNAMIC_SAG_IDEAL_MIN_REAR && rearSag <= DYNAMIC_SAG_IDEAL_MAX_REAR, + frontBottomOutPct: zonePercent(frontNorm, BOTTOM_OUT_TRAVEL_MIN, 100), + frontBottomOutSec: zoneSeconds(frontNorm, frontFreq, BOTTOM_OUT_TRAVEL_MIN, 100), + frontBottomOutCount: countZoneEntries(frontNorm, BOTTOM_OUT_TRAVEL_MIN, 100), + frontMaxTravel: maxTravel(frontNorm), + frontOffGroundPct: zonePercent(frontNorm, 0, OFF_GROUND_TRAVEL_MAX), + frontOffGroundSec: zoneSeconds(frontNorm, frontFreq, 0, OFF_GROUND_TRAVEL_MAX), + rearBottomOutPct: zonePercent(rearNorm, BOTTOM_OUT_TRAVEL_MIN, 100), + rearBottomOutSec: zoneSeconds(rearNorm, rearFreq, BOTTOM_OUT_TRAVEL_MIN, 100), + rearBottomOutCount: countZoneEntries(rearNorm, BOTTOM_OUT_TRAVEL_MIN, 100), + rearMaxTravel: maxTravel(rearNorm), + rearOffGroundPct: zonePercent(rearNorm, 0, OFF_GROUND_TRAVEL_MAX), + rearOffGroundSec: zoneSeconds(rearNorm, rearFreq, 0, OFF_GROUND_TRAVEL_MAX), + frontCompression: calculateCompression(run, jsonData, 'front'), + rearCompression: calculateCompression(run, jsonData, 'rear'), + frontRebound: calculateRebound(run, jsonData, 'front'), + rearRebound: calculateRebound(run, jsonData, 'rear'), + }; +} + +export type RunMetrics = ReturnType; + +export function useRunMetrics(run: Run, jsonData: Record): RunMetrics { + return useMemo(() => computeRunMetrics(run, jsonData), [run, jsonData]); } diff --git a/apps/frontend/app/lib/run-analysis.ts b/apps/frontend/app/lib/run-analysis.ts index 2c17b9f..e0b5eb4 100644 --- a/apps/frontend/app/lib/run-analysis.ts +++ b/apps/frontend/app/lib/run-analysis.ts @@ -24,7 +24,7 @@ interface VelocityReading { } } -interface SuspensionActivity extends VelocityReading { +export interface SuspensionActivity extends VelocityReading { type: "rebound" | "compression" } diff --git a/apps/frontend/app/lib/telemetryUtils.ts b/apps/frontend/app/lib/telemetryUtils.ts index a9afa67..f4bfb5e 100644 --- a/apps/frontend/app/lib/telemetryUtils.ts +++ b/apps/frontend/app/lib/telemetryUtils.ts @@ -3,15 +3,44 @@ import { Profile, Run } from "@repo/database"; export const MAX_TRAVEL = 4096; const WINDOWMS = 600; -export type RawSuspensionData = number | { displacement: number; timebase?: number }; +export type RawSuspensionData = + | number + | { displacement: number; timebase?: number }; export interface StandardizedPoint { time: number; - val: number; + val: number; } export interface NormalizedPoint { - x: number; - y: number; + x: number; + y: number; +} + +export interface VelocitySample { + index: number; + time: number; + displacement: number; + normalized: number; + velocity: number; + speed: number; +} + +export interface LinePoint { + x: number; + y: number; +} + +export interface RunTrimBounds { + lowerBoundIdx: number; + upperBoundIdx: number; +} + +function parseBound(value: unknown): number | null { + if (typeof value !== "number" || !Number.isSafeInteger(value)) { + return null; + } + + return value; } export function getProfileFromRun(run: Run): Profile | null { @@ -23,13 +52,61 @@ export function getProfileFromRun(run: Run): Profile | null { !("front_max" in profileCandidate) || !("back_min" in profileCandidate) || !("back_max" in profileCandidate) - ) return null; + ) + return null; return profileCandidate as Profile; } +export function resolveTrimBounds(run: Run, sampleLength: number): RunTrimBounds | null { + if (!Number.isFinite(sampleLength) || sampleLength <= 0) { + return null; + } + + if (!Number.isFinite(run.length) || run.length <= 0) { + return { lowerBoundIdx: 0, upperBoundIdx: sampleLength - 1 }; + } + + const runWithBounds = run as Run & { + lower_bound_idx?: unknown; + upper_bound_idx?: unknown; + }; + + const lastIndex = sampleLength - 1; + const scale = sampleLength / run.length; + const rawLower = runWithBounds.lower_bound_idx; + const rawUpper = runWithBounds.upper_bound_idx; + + const parsedLower = parseBound(rawLower) ?? 0; + const parsedUpper = parseBound(rawUpper) ?? (run.length - 1); + + const lowerBoundIdx = Math.min(Math.max(Math.round(parsedLower * scale), 0), lastIndex); + const clampedUpper = Math.min(Math.max(Math.round(parsedUpper * scale), 0), lastIndex); + const upperBoundIdx = Math.max(clampedUpper, lowerBoundIdx); + + return { lowerBoundIdx, upperBoundIdx }; +} + +export function trimRawDataByBounds(run: Run, dataArr: T[]): T[] { + if (!Array.isArray(dataArr) || dataArr.length === 0) { + return []; + } + + const bounds = resolveTrimBounds(run, dataArr.length); + if (!bounds) { + return []; + } + + return dataArr.slice(bounds.lowerBoundIdx, bounds.upperBoundIdx + 1); +} + export function normalizeToPercentage(val: number, min?: number, max?: number): number { // If caller provides a valid min/max range, use it; otherwise fall back to 0..MAX_TRAVEL - const hasValidRange = typeof min === 'number' && typeof max === 'number' && isFinite(min) && isFinite(max) && max > min; + const hasValidRange = + typeof min === "number" && + typeof max === "number" && + isFinite(min) && + isFinite(max) && + max > min; if (!hasValidRange) { const result = (val / MAX_TRAVEL) * 100; @@ -41,14 +118,18 @@ export function normalizeToPercentage(val: number, min?: number, max?: number): return Number.isFinite(result) ? Math.min(100, Math.max(0, result)) : 0; } -export function standardizeData(dataArr: RawSuspensionData[], freq: number): StandardizedPoint[] { +export function standardizeData( + dataArr: RawSuspensionData[], + freq: number, + indexOffset: number = 0, +): StandardizedPoint[] { if (!Array.isArray(dataArr)) return []; - + return dataArr.map((p, i) => { let val = 0; - let time = i / freq; + let time = (i + indexOffset) / freq; - if (typeof p === 'number') { + if (typeof p === "number") { val = p; } else { val = Number(p.displacement ?? 0); @@ -56,47 +137,57 @@ export function standardizeData(dataArr: RawSuspensionData[], freq: number): Sta time = Number(p.timebase); } } - + return { time, val }; }); } - // DisplacementPlot - Standardizes raw suspension data and maps it into normalized time-series points. -export function processLinePlotData(dataArr: RawSuspensionData[], freq: number, min?: number, max?: number): NormalizedPoint[] { - const cleanData = standardizeData(dataArr, freq); +export function processLinePlotData( + dataArr: RawSuspensionData[], + freq: number, + min?: number, + max?: number, + indexOffset: number = 0, +): NormalizedPoint[] { + const cleanData = standardizeData(dataArr, freq, indexOffset); - return cleanData.map(point => ({ + return cleanData.map((point) => ({ x: point.time, - y: normalizeToPercentage(point.val, min, max) + y: normalizeToPercentage(point.val, min, max), })); } // TravelHistogram - Normalise displacement values for histogram distribution -export const processHistogramData = (dataArr: RawSuspensionData[], min?: number, max?: number): number[] => { +export const processHistogramData = ( + dataArr: RawSuspensionData[], + min?: number, + max?: number, +): number[] => { if (!Array.isArray(dataArr)) return []; - return dataArr.map(p => { - let val = 0; - if (typeof p === 'number') { - val = p; - } else { - val = Number(p.displacement ?? 0); - } - return normalizeToPercentage(val, min, max); - }).filter(v => !isNaN(v) && isFinite(v)); + return dataArr + .map((p) => { + let val = 0; + if (typeof p === "number") { + val = p; + } else { + val = Number(p.displacement ?? 0); + } + return normalizeToPercentage(val, min, max); + }) + .filter((v) => !isNaN(v) && isFinite(v)); }; // DynamicSagPlot - Moving average (over set time window) export function calculateMovingAverage( - data: NormalizedPoint[], - freq: number, + data: NormalizedPoint[], + freq: number, ): NormalizedPoint[] { - const windowSize = Math.max(1, Math.floor((WINDOWMS / 1000) * freq)); if (data.length < windowSize) return []; - const halfWindowX = (WINDOWMS / 1000) / 2; // centre x axis offset + const halfWindowX = WINDOWMS / 1000 / 2; // centre x axis offset const result: NormalizedPoint[] = []; // Initial sum @@ -109,7 +200,7 @@ export function calculateMovingAverage( // First centered point result.push({ x: data[windowSize - 1]!.x - halfWindowX, - y: currentSum / windowSize + y: currentSum / windowSize, }); // Sliding window @@ -122,13 +213,104 @@ export function calculateMovingAverage( result.push({ x: inPt.x - halfWindowX, - y: currentSum / windowSize + y: currentSum / windowSize, }); } return result; } + +// Velocity samples (mm/s) derived from displacement time-series. +export function buildVelocitySamples( + dataArr: RawSuspensionData[], + freq: number, + min?: number, + max?: number, +): VelocitySample[] { + const cleanData = standardizeData(dataArr, freq); + if (cleanData.length < 2) return []; + + const samples: VelocitySample[] = []; + + for (let i = 1; i < cleanData.length; i++) { + const prev = cleanData[i - 1]; + const curr = cleanData[i]; + if (!prev || !curr) continue; + + const dt = curr.time - prev.time; + if (!Number.isFinite(dt) || dt <= 0) continue; + + const displacement = curr.val; + const velocity = (curr.val - prev.val) / dt; + if (!Number.isFinite(velocity)) continue; + + const normalized = normalizeToPercentage(displacement, min, max); + + samples.push({ + index: i, + time: curr.time, + displacement, + normalized, + velocity, + speed: Math.abs(velocity), + }); + } + + return samples; +} + +export function fitLine( + points: LinePoint[], +): { slope: number; intercept: number } | null { + if (!points || points.length < 2) return null; + + let sumX = 0; + let sumY = 0; + let sumXY = 0; + let sumXX = 0; + const n = points.length; + + for (const pt of points) { + if (!Number.isFinite(pt.x) || !Number.isFinite(pt.y)) continue; + sumX += pt.x; + sumY += pt.y; + sumXY += pt.x * pt.y; + sumXX += pt.x * pt.x; + } + + const denominator = n * sumXX - sumX * sumX; + if (!Number.isFinite(denominator) || denominator === 0) return null; + + const slope = (n * sumXY - sumX * sumY) / denominator; + const intercept = (sumY - slope * sumX) / n; + if (!Number.isFinite(slope) || !Number.isFinite(intercept)) return null; + + return { slope, intercept }; +} + +export function buildLineFromPoints(points: LinePoint[]): LinePoint[] { + const fit = fitLine(points); + if (!fit) return []; + + let minX = Infinity; + let maxX = -Infinity; + + for (const pt of points) { + if (!Number.isFinite(pt.x)) continue; + if (pt.x < minX) minX = pt.x; + if (pt.x > maxX) maxX = pt.x; + } + + if (!Number.isFinite(minX) || !Number.isFinite(maxX) || minX === maxX) + return []; + + return [ + { x: minX, y: fit.slope * minX + fit.intercept }, + { x: maxX, y: fit.slope * maxX + fit.intercept }, + ]; +} + /** * Largest-Triangle-Three-Buckets downsampling. * Reduces `data` to at most `threshold` points. @@ -179,4 +361,4 @@ export function lttbDownsample( sampled.push(data[data.length - 1]!); return sampled; -} \ No newline at end of file +} diff --git a/apps/frontend/app/routes/index.tsx b/apps/frontend/app/routes/index.tsx index 12d940a..c5190d7 100644 --- a/apps/frontend/app/routes/index.tsx +++ b/apps/frontend/app/routes/index.tsx @@ -1,6 +1,5 @@ -import { useLoaderData } from "react-router-dom"; -import { redirect } from "react-router"; -import { useState, useEffect } from "react"; +import { redirect, useLoaderData } from "react-router"; +import { useState, useEffect, useMemo } from "react"; import { Sidebar } from "../components/runs/sidebar"; import { MainContent } from "app/components/runs/main-content"; import { RunJson } from "app/types/runs"; @@ -31,20 +30,41 @@ clientLoader.hydrate = true as const; // ---------------------- MAIN PAGE COMPONENT ---------------------- export default function Runs() { - const { runs } = useLoaderData(); - - const [selected, setSelected] = useState([]); + const { runs: initialRuns } = useLoaderData(); + const [runs, setRuns] = useState(initialRuns); + const [selectedRunIds, setSelectedRunIds] = useState([]); const [jsonData, setJsonData] = useState>({}); const [loadingRuns, setLoadingRuns] = useState>(new Set()); + const runsById = useMemo( + () => new Map(runs.map((run) => [run.id, run])), + [runs], + ); + const selected = useMemo( + () => selectedRunIds.map((id) => runsById.get(id)).filter((run): run is Run => Boolean(run)), + [selectedRunIds, runsById], + ); const isCompareMode = selected.length > 1; const loadingJson = loadingRuns.size > 0; + useEffect(() => { + const selectedIdSet = new Set(selectedRunIds); + setJsonData((prev) => { + const nextEntries = Object.entries(prev).filter(([id]) => selectedIdSet.has(Number(id))); + return Object.fromEntries(nextEntries); + }); + }, [selectedRunIds]); + // Auth check is handled in the loader above — no need to repeat it here. // ----------------- Fetch JSON for selected runs ----------------- useEffect(() => { const fetchJson = async (run: Run) => { + // Check if already loading or already fetched (double-check inside async) + if (loadingRuns.has(run.id) || jsonData[run.id]) { + return; + } + setLoadingRuns((prev) => new Set(prev).add(run.id)); try { const files = await getFile(run.srcPath); @@ -66,18 +86,25 @@ export default function Runs() { }; selected.forEach((run) => { - if (!jsonData[run.id]) fetchJson(run); + if (!jsonData[run.id] && !loadingRuns.has(run.id)) { + fetchJson(run); + } }); - }, [selected, jsonData]); + }, [selected, jsonData, loadingRuns]); return (
- + runs.find((r) => r.id === s.id) || s)} jsonData={jsonData} loadingJson={loadingJson} isCompareMode={isCompareMode} + onRunUpdate={(id, updates) => { + setRuns((prevRuns) => + prevRuns.map((r) => (r.id === id ? { ...r, ...updates } : r)) + ); + }} />
); diff --git a/apps/frontend/app/routes/login.tsx b/apps/frontend/app/routes/login.tsx index d1303a1..e945b6e 100644 --- a/apps/frontend/app/routes/login.tsx +++ b/apps/frontend/app/routes/login.tsx @@ -108,7 +108,7 @@ export default function Login() { diff --git a/apps/frontend/app/tailwind.css b/apps/frontend/app/tailwind.css index c0a006b..5e8ffee 100644 --- a/apps/frontend/app/tailwind.css +++ b/apps/frontend/app/tailwind.css @@ -18,6 +18,13 @@ --muted-foreground: 240 5% 64.9%; --border: 240 3.7% 15.9%; } + + button:not(:disabled) { + @apply cursor-pointer; + } + button:disabled { + @apply cursor-not-allowed; + } } @theme { diff --git a/packages/database/drizzle/0008_shiny_iceman.sql b/packages/database/drizzle/0008_shiny_iceman.sql new file mode 100644 index 0000000..7da9723 --- /dev/null +++ b/packages/database/drizzle/0008_shiny_iceman.sql @@ -0,0 +1,5 @@ + +ALTER TABLE "runs" ADD COLUMN "lower_bound_idx" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "runs" ADD COLUMN "upper_bound_idx" integer;--> statement-breakpoint +UPDATE "runs" SET "upper_bound_idx" = "length" - 1;--> statement-breakpoint +ALTER TABLE "runs" ALTER COLUMN "upper_bound_idx" SET NOT NULL; \ No newline at end of file diff --git a/packages/database/drizzle/meta/0008_snapshot.json b/packages/database/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..ff2ca43 --- /dev/null +++ b/packages/database/drizzle/meta/0008_snapshot.json @@ -0,0 +1,269 @@ +{ + "id": "698f7546-0c8c-494f-974e-064fd640c18f", + "prevId": "831fe362-0f33-49dd-9ea8-2a29da4f10c7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "front_min": { + "name": "front_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "front_max": { + "name": "front_max", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4096 + }, + "front_travel": { + "name": "front_travel", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 220 + }, + "back_min": { + "name": "back_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "back_max": { + "name": "back_max", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4096 + }, + "back_travel": { + "name": "back_travel", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 220 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.runs": { + "name": "runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "src_path": { + "name": "src_path", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "comments": { + "name": "comments", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "length": { + "name": "length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "profile": { + "name": "profile", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lower_bound_idx": { + "name": "lower_bound_idx", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "upper_bound_idx": { + "name": "upper_bound_idx", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "front_freq": { + "name": "front_freq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rear_freq": { + "name": "rear_freq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "runs_profile_profiles_id_fk": { + "name": "runs_profile_profiles_id_fk", + "tableFrom": "runs", + "tableTo": "profiles", + "columnsFrom": [ + "profile" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "runs_src_path_unique": { + "name": "runs_src_path_unique", + "nullsNotDistinct": false, + "columns": [ + "src_path" + ] + } + } + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json index d83c42e..36a6772 100644 --- a/packages/database/drizzle/meta/_journal.json +++ b/packages/database/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1773772326843, "tag": "0007_illegal_killmonger", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1777391980743, + "tag": "0008_shiny_iceman", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/scripts/seed.ts b/packages/database/scripts/seed.ts index 50b2abc..cc8b7e0 100644 --- a/packages/database/scripts/seed.ts +++ b/packages/database/scripts/seed.ts @@ -200,18 +200,22 @@ async function seed() { ]; for (const runItem of initialRuns) { + const runLength = Number.isFinite(runItem.length) ? runItem.length : 0; + const upperBoundIdx = Math.max(runLength - 1, 0); await db .insert(runs) .values({ srcPath: runItem.srcPath, title: runItem.title ?? null, comments: runItem.comments ?? null, - length: Number.isFinite(runItem.length) ? runItem.length : 0, + length: runLength, date: runItem.date ? new Date(runItem.date) : new Date(), location: runItem.location ?? null, profile: runItem.profile ?? null, front_freq: runItem.front_freq ?? 250, rear_freq: runItem.rear_freq ?? 250, + lower_bound_idx: 0, + upper_bound_idx: upperBoundIdx, createdAt: runItem.createdAt ? new Date(runItem.createdAt) : new Date(), }) .onConflictDoUpdate({ @@ -219,12 +223,14 @@ async function seed() { set: { title: runItem.title ?? null, comments: runItem.comments ?? null, - length: Number.isFinite(runItem.length) ? runItem.length : 0, + length: runLength, date: runItem.date ? new Date(runItem.date) : new Date(), location: runItem.location ?? null, profile: runItem.profile ?? null, front_freq: runItem.front_freq ?? 250, rear_freq: runItem.rear_freq ?? 250, + lower_bound_idx: 0, + upper_bound_idx: upperBoundIdx, createdAt: runItem.createdAt ? new Date(runItem.createdAt) : new Date(), diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index f685be6..ec12be9 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -47,6 +47,8 @@ export const runs = pgTable("runs", { date: timestamp("date").defaultNow().notNull(), // run date/time location: varchar("location", { length: 255 }), // place or tag for run profile: integer("profile").references(() => profiles.id), // associated profile + lower_bound_idx: integer("lower_bound_idx").notNull().default(0), // inclusive trim start index (default: 0) + upper_bound_idx: integer("upper_bound_idx").notNull(), // inclusive trim end index (set by service layer to length-1) front_freq: integer("front_freq"), // front suspension sample frequency rear_freq: integer("rear_freq"), // rear suspension sample frequency createdAt: timestamp("created_at").defaultNow(),