diff --git a/public/logs/longest.mlg b/public/logs/longest.mlg new file mode 100644 index 0000000..d13d82b Binary files /dev/null and b/public/logs/longest.mlg differ diff --git a/src/App.less b/src/App.less index cf1502e..95769fb 100644 --- a/src/App.less +++ b/src/App.less @@ -113,8 +113,6 @@ html, body { .plot { &:active { - cursor: move; + cursor: grab; } - - border: 1px solid @border-color-split; } diff --git a/src/components/Dialog/Map.tsx b/src/components/Dialog/Map.tsx index 7a0bb81..b51ddd4 100644 --- a/src/components/Dialog/Map.tsx +++ b/src/components/Dialog/Map.tsx @@ -26,6 +26,7 @@ import { isReplace, } from '../../utils/keyboard/shortcuts'; import LandscapeNotice from './LandscapeNotice'; +import { colorHsl } from '../../utils/number'; type CellsType = boolean[][]; type DataType = number[][]; @@ -35,7 +36,6 @@ enum Operations { DEC, REPLACE, } -type HslType = [number, number, number]; const { useBreakpoint } = Grid; @@ -175,23 +175,6 @@ const Map = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const colorHsl = (min: number, max: number, value: number): HslType => { - const saturation = 60; - const lightness = 40; - const coldDeg = 220; - const hotDeg = 0; - const remap = (x: number, inMin: number, inMax: number, outMin: number, outMax: number) => (x - inMin) * (outMax - outMin) / (inMax - inMin) + outMin; - - let hue = remap(value, min, max, coldDeg, hotDeg); - - // fallback to cold temp - if (Number.isNaN(hue)) { - hue = coldDeg; - } - - return [hue, saturation, lightness]; - }; - const min = Math.min(...data.map((row) => Math.min(...row))); const max = Math.max(...data.map((row) => Math.max(...row))); diff --git a/src/components/Dialog/Table.tsx b/src/components/Dialog/Table.tsx index 0868c6f..3836be0 100644 --- a/src/components/Dialog/Table.tsx +++ b/src/components/Dialog/Table.tsx @@ -25,6 +25,7 @@ import { isIncrement, isReplace, } from '../../utils/keyboard/shortcuts'; +import { colorHsl } from '../../utils/number'; type AxisType = 'x' | 'y'; type CellsType = boolean[][]; @@ -35,7 +36,6 @@ enum Operations { DEC, REPLACE, } -type HslType = [number, number, number]; const Table = ({ name, @@ -193,23 +193,6 @@ const Table = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const colorHsl = (min: number, max: number, value: number): HslType => { - const saturation = 60; - const lightness = 40; - const coldDeg = 220; - const hotDeg = 0; - const remap = (x: number, inMin: number, inMax: number, outMin: number, outMax: number) => (x - inMin) * (outMax - outMin) / (inMax - inMin) + outMin; - - let hue = remap(value, min, max, coldDeg, hotDeg); - - // fallback to cold temp - if (Number.isNaN(hue)) { - hue = coldDeg; - } - - return [hue, saturation, lightness]; - }; - const renderRow = (axis: AxisType, input: number[]) => input .map((value, index) => { const [hue, sat, light] = colorHsl(Math.min(...input), Math.max(...input), value); diff --git a/src/components/Log/Canvas.tsx b/src/components/Log/Canvas.tsx index 05348f5..e2c0f2f 100644 --- a/src/components/Log/Canvas.tsx +++ b/src/components/Log/Canvas.tsx @@ -1,3 +1,5 @@ +/* eslint-disable no-bitwise */ + import { useCallback, useEffect, @@ -8,9 +10,19 @@ import { TouchEvent, Touch, } from 'react'; +import { + isDown, + isLeft, + isRight, + isUp, +} from '../../utils/keyboard/shortcuts'; +import { + colorHsl, + msToTime, +} from '../../utils/number'; export interface LogEntry { - [id: string]: number + [id: string]: number | string, } enum Colors { @@ -22,6 +34,7 @@ enum Colors { BLUE = '#2fe3ff', GREY = '#334455', WHITE = '#fff', + BG = '#222629', } const Canvas = ({ @@ -53,14 +66,34 @@ const Canvas = ({ const plot = useCallback(() => { const canvas = canvasRef.current!; + const fieldsToPlot = [ + { name: 'RPM', scale: 0.1 }, + { name: 'TPS', scale: 5 }, + { name: 'AFR Target', scale: 2 }, + { name: 'AFR', scale: 2 }, + { name: 'MAP', scale: 5 }, + ]; const ctx = canvas.getContext('2d')!; const lastEntry = data[data.length - 1]; - const maxTime = lastEntry.Time / (zoom < 1 ? 1 : zoom); - const xScale = canvas.width / maxTime; + const maxTime = (lastEntry.Time as number) / (zoom < 1 ? 1 : zoom); + const areaWidth = canvas.width; + const areaHeight = canvas.height - 30; // leave some space in the bottom + const xScale = areaWidth / maxTime; const firstEntry = data[0]; - const scaledWidth = canvas.width * zoom / 1; + const scaledWidth = areaWidth * zoom / 1; const start = pan; - setRightBoundary(-(scaledWidth - canvas.width)); + // TODO: adjust this based on FPS / preference + const resolution = Math.round(data.length / 1000 / zoom) || 1; // 1..x where 1 is max + setRightBoundary(-(scaledWidth - areaWidth)); + + const hsl = (fieldIndex: number) => { + const [hue] = colorHsl(0, fieldsToPlot.length - 1, fieldIndex); + return `hsl(${hue}, 80%, 50%)`; + }; + + // basic settings + ctx.font = '14px Arial'; + ctx.lineWidth = Math.max(1.25, areaHeight / 400); if (zoom < 1) { setZoom(1); @@ -72,17 +105,39 @@ const Canvas = ({ return; } - const plotEntry = (field: string, yScale: number, color: string) => { + const drawText = (left: number, top: number, text: string, color: string, textAlign = 'left') => { + ctx.textAlign = textAlign as any; + ctx.fillStyle = Colors.BG; + ctx.fillText(text, left + 2, top + 2); + ctx.fillStyle = color; + ctx.fillText(text, left, top); + }; + + const drawMarker = (left: number) => { + // TODO + }; + + const plotField = (field: string, yScale: number, color: string) => { ctx.strokeStyle = color; ctx.beginPath(); // initial value - ctx.moveTo(start + firstEntry.Time, canvas.height - (firstEntry[field] * yScale)); + ctx.moveTo(start, areaHeight - (firstEntry[field] as number * yScale)); - // TODO: slice array according to the visible part + let index = 0; data.forEach((entry) => { - const time = entry.Time * xScale; // scale time to max width - const value = canvas.height - (entry[field] * yScale); // scale the value + index++; + if (index % resolution !== 0) { + return; + } + + // draw marker on top of the record + if (entry.type === 'marker') { + return; + } + + const time = (entry.Time as number) * xScale; // scale time to max width + const value = areaHeight - (entry[field] as number * yScale); // scale the value ctx.lineTo(start + time, value); }); @@ -90,26 +145,51 @@ const Canvas = ({ ctx.stroke(); }; - const plotIndicator = () => { + const drawIndicator = () => { ctx.setLineDash([5]); ctx.strokeStyle = Colors.WHITE; ctx.beginPath(); + // switch to time + let index = Math.round(indicatorPos * (data.length - 1) / areaWidth); + if (index < 0) { + index = 0; + } + ctx.moveTo(indicatorPos, 0); + + let left = indicatorPos + 10; + let textAlign = 'left'; + if (indicatorPos > areaWidth / 2) { + // flip text to the left side of the indicator + textAlign = 'right'; + left = indicatorPos - 10; + } + + let top = 0; + fieldsToPlot.forEach(({ name }, fieldIndex) => { + top += 20; + drawText(left, top, `${name}: ${data[index][name]}`, hsl(fieldIndex), textAlign); + }); + + // draw Time + drawText( + left, + areaHeight + 20, + msToTime(Math.round(data[index].Time as number * 1000)), + Colors.GREY, textAlign, + ); + ctx.lineTo(indicatorPos, canvas.height); ctx.stroke(); ctx.setLineDash([]); }; + // clear ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.lineWidth = Math.max(1.25, canvas.height / 400); - plotIndicator(); - plotEntry('RPM', 0.16, Colors.RED); - plotEntry('TPS', 20, Colors.BLUE); - plotEntry('AFR Target', 4, Colors.YELLOW); - plotEntry('AFR', 4, Colors.GREEN); - plotEntry('MAP', 5, Colors.GREY); + fieldsToPlot.forEach(({ name, scale }, fieldIndex) => plotField(name, scale, hsl(fieldIndex))); + drawIndicator(); }, [data, zoom, pan, rightBoundary, indicatorPos]); const onWheel = (e: WheelEvent) => { @@ -145,9 +225,36 @@ const Canvas = ({ setPreviousTouch(touch); }; + const keyboardListener = useCallback((e: KeyboardEvent) => { + if (isUp(e)) { + setZoom((current) => current + 0.1); + } + if (isDown(e)) { + setZoom((current) => { + if (current < 1) { + setPan(0); + return 1; + } + return current - 0.1; + }); + } + if (isLeft(e)) { + setPan((current) => checkPan(current, current + 20)); + } + if (isRight(e)) { + setPan((current) => checkPan(current, current - 20)); + } + }, [checkPan]); + useEffect(() => { plot(); - }, [plot, width, height]); + document.addEventListener('keydown', keyboardListener); + + // TODO: crate custom hook + return () => { + document.removeEventListener('keydown', keyboardListener); + }; + }, [plot, width, height, keyboardListener]); return ( { P }> -