mirror of
https://github.com/XFox111/MuiCharts.git
synced 2026-04-22 06:51:05 +03:00
Static preview done
This commit is contained in:
+9
-1
@@ -22,9 +22,17 @@ module.exports = {
|
|||||||
"warn",
|
"warn",
|
||||||
{ allowConstantExport: true },
|
{ allowConstantExport: true },
|
||||||
],
|
],
|
||||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
"@typescript-eslint/no-non-null-assertion": "off",
|
||||||
"@typescript-eslint/no-confusing-void-expression": "off",
|
"@typescript-eslint/no-confusing-void-expression": "off",
|
||||||
"@typescript-eslint/no-unused-vars": "warn",
|
"@typescript-eslint/no-unused-vars": "warn",
|
||||||
|
"@typescript-eslint/prefer-for-of": "warn",
|
||||||
|
"no-empty": "warn",
|
||||||
|
"@typescript-eslint/no-inferrable-types": "off",
|
||||||
|
"@typescript-eslint/consistent-indexed-object-style": "warn",
|
||||||
|
"@typescript-eslint/no-unused-vars": "warn",
|
||||||
|
"@typescript-eslint/no-unnecessary-type-assertion": "warn",
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
"@typescript-eslint/no-unused-vars": "warn",
|
||||||
},
|
},
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
ecmaVersion: "latest",
|
ecmaVersion: "latest",
|
||||||
|
|||||||
@@ -22,10 +22,12 @@
|
|||||||
"@mui/system": "^5.15.9",
|
"@mui/system": "^5.15.9",
|
||||||
"@mui/x-charts": "^6.19.4",
|
"@mui/x-charts": "^6.19.4",
|
||||||
"@mui/x-data-grid": "^6.19.4",
|
"@mui/x-data-grid": "^6.19.4",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/d3-scale": "^4.0.8",
|
||||||
"@types/react": "^18.2.55",
|
"@types/react": "^18.2.55",
|
||||||
"@types/react-dom": "^18.2.19",
|
"@types/react-dom": "^18.2.19",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||||
|
|||||||
+6
-4
@@ -9,12 +9,14 @@ const AppStyles = makeStyles(theme => ({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
flexFlow: "column",
|
flexFlow: "column",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
alignItems: "center",
|
userSelect: "none",
|
||||||
justifyContent: "center"
|
|
||||||
},
|
},
|
||||||
caption:
|
controls:
|
||||||
{
|
{
|
||||||
marginTop: theme.spacing(2),
|
marginBottom: theme.spacing(4),
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing(4),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
+68
-8
@@ -1,6 +1,12 @@
|
|||||||
import { Button, Container, GlobalStyles, Theme, ThemeProvider, Typography, createTheme } from "@mui/material";
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
import { useState } from "react";
|
import { Box, Button, Container, GlobalStyles, Slider, Theme, ThemeProvider, createTheme } from "@mui/material";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import useStyles from "./App.styles";
|
import useStyles from "./App.styles";
|
||||||
|
import ChartSkeleton from "./Components/ChartSkeleton";
|
||||||
|
import TrackChart from "./Components/TrackChart";
|
||||||
|
import IPoint from "./Data/IPoint";
|
||||||
|
import ITrack from "./Data/ITrack";
|
||||||
|
import LoadMockData from "./Data/LoadMockData";
|
||||||
|
|
||||||
const theme: Theme = createTheme();
|
const theme: Theme = createTheme();
|
||||||
|
|
||||||
@@ -9,18 +15,72 @@ const theme: Theme = createTheme();
|
|||||||
*/
|
*/
|
||||||
function App(): JSX.Element
|
function App(): JSX.Element
|
||||||
{
|
{
|
||||||
|
const [data, setData] = useState<{ tracks: ITrack[], points: IPoint[]; }>({ tracks: [], points: [] });
|
||||||
|
const [isLoading, setLoading] = useState<boolean>(true);
|
||||||
|
const [zoom, setZoom] = useState<number[]>([0, 1]);
|
||||||
const sx = useStyles();
|
const sx = useStyles();
|
||||||
const [clicks, setClicks] = useState<number>(0);
|
|
||||||
|
const loadData = () =>
|
||||||
|
{
|
||||||
|
setLoading(true);
|
||||||
|
console.log("Loading data...");
|
||||||
|
const newData = LoadMockData(true);
|
||||||
|
setData(newData);
|
||||||
|
setZoom([0, Math.min(newData.tracks.length + 1, 20)]);
|
||||||
|
|
||||||
|
new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
.then(() => setLoading(false))
|
||||||
|
.catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleZoomChange = (_: unknown, newValue: number | number[]) =>
|
||||||
|
{
|
||||||
|
const value: number[] = newValue as number[];
|
||||||
|
|
||||||
|
if ((value[0] === zoom[0] && value[1] === zoom[1]) || value[1] - value[0] < 2)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (value[1] - value[0] > 50)
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
setZoom(value);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={ theme }>
|
<ThemeProvider theme={ theme }>
|
||||||
<GlobalStyles styles={ { "body, #root": { height: "100vh", margin: 0 } } } />
|
<GlobalStyles styles={ { "body, #root": { height: "100vh", margin: 0 } } } />
|
||||||
|
|
||||||
<Container sx={ sx.root }>
|
<Box sx={ sx.root }>
|
||||||
<Typography variant="h1" gutterBottom>Hello World!</Typography>
|
<TrackChart
|
||||||
<Button variant="contained" color="primary" onClick={ () => setClicks(clicks + 1) }>Click me</Button>
|
tracks={ data.tracks } points={ data.points }
|
||||||
<Typography variant="body1" sx={ sx.caption }>Times clicked: { clicks }</Typography>
|
zoom={ zoom } onProcessingComplete={ () => setLoading(false) } />
|
||||||
</Container>
|
|
||||||
|
<Container sx={ sx.controls }>
|
||||||
|
{ !isLoading &&
|
||||||
|
<Slider
|
||||||
|
min={ 0 } max={ data.tracks.length + 1 }
|
||||||
|
defaultValue={ zoom } onChangeCommitted={ handleZoomChange }
|
||||||
|
valueLabelDisplay="auto" />
|
||||||
|
}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained" color="inherit" endIcon={ <RefreshIcon /> }
|
||||||
|
onClick={ loadData } disabled={ isLoading }>
|
||||||
|
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{ isLoading &&
|
||||||
|
<ChartSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { grey } from "@mui/material/colors";
|
||||||
|
import { styled } from "@mui/system";
|
||||||
|
import { useDrawingArea, useXScale, useYScale } from "@mui/x-charts";
|
||||||
|
import { ScaleLinear } from "d3-scale";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
|
interface IProps
|
||||||
|
{
|
||||||
|
/** The maximum Y value. */
|
||||||
|
maxY: number;
|
||||||
|
/** The X ticks. */
|
||||||
|
xTicks: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Cartesian grid. */
|
||||||
|
function CartesianGrid(props: IProps): JSX.Element
|
||||||
|
{
|
||||||
|
const [yTicks, setYTicks] = React.useState<number[]>([]);
|
||||||
|
const xTicks = props.xTicks;
|
||||||
|
|
||||||
|
// Get the drawing area bounding box
|
||||||
|
const { left, top, width, height } = useDrawingArea();
|
||||||
|
|
||||||
|
// Get the two scale
|
||||||
|
const yAxisScale = useYScale() as ScaleLinear<unknown, number>;
|
||||||
|
const xAxisScale = useXScale() as ScaleLinear<unknown, number>;
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
const ticks: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i <= Math.ceil(props.maxY / 20) + 1; i++)
|
||||||
|
ticks.push(i * 20);
|
||||||
|
|
||||||
|
setYTicks(ticks);
|
||||||
|
}, [props.maxY]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
|
||||||
|
{ yTicks.map((value) =>
|
||||||
|
<StyledPath key={ value } d={ `M ${left} ${yAxisScale(value)} l ${width} 0` } />
|
||||||
|
) }
|
||||||
|
|
||||||
|
{ xTicks.map((value) =>
|
||||||
|
<StyledPath key={ value } d={ `M ${xAxisScale(value)} ${top} l 0 ${height}` } />
|
||||||
|
) }
|
||||||
|
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledPath = styled("path")(
|
||||||
|
() => ({
|
||||||
|
fill: "none",
|
||||||
|
stroke: grey[500],
|
||||||
|
strokeWidth: 1,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CartesianGrid;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { makeStyles } from "../Utils/makeStyles";
|
||||||
|
|
||||||
|
export const useStyles = makeStyles(theme => ({
|
||||||
|
root:
|
||||||
|
{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateRows: "1fr auto",
|
||||||
|
gap: theme.spacing(4),
|
||||||
|
backgroundColor: theme.palette.background.default,
|
||||||
|
},
|
||||||
|
chart:
|
||||||
|
{
|
||||||
|
paddingLeft: theme.spacing(2),
|
||||||
|
paddingRight: theme.spacing(6),
|
||||||
|
paddingTop: theme.spacing(6),
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "auto 1fr",
|
||||||
|
gridTemplateRows: "1fr auto",
|
||||||
|
gap: theme.spacing(2),
|
||||||
|
},
|
||||||
|
xAxis:
|
||||||
|
{
|
||||||
|
gridColumn: 2,
|
||||||
|
gridRow: 2,
|
||||||
|
},
|
||||||
|
grid:
|
||||||
|
{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(10, 1fr)",
|
||||||
|
gridTemplateRows: "repeat(10, 1fr)",
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
},
|
||||||
|
controls:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing(4),
|
||||||
|
marginBottom: theme.spacing(4),
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Box, Container, Skeleton } from "@mui/material";
|
||||||
|
import { useStyles } from "./ChartSkeleton.styles";
|
||||||
|
|
||||||
|
function ChartSkeleton(): JSX.Element
|
||||||
|
{
|
||||||
|
const sx = useStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={ sx.root }>
|
||||||
|
<Box sx={ sx.chart }>
|
||||||
|
<Skeleton variant="rounded" width="24px" height="100%" />
|
||||||
|
<Skeleton sx={ sx.xAxis } variant="rounded" width="100%" height="24px" />
|
||||||
|
|
||||||
|
<Box sx={ sx.grid }>
|
||||||
|
{ Array.from({ length: 100 }).map((_, i) =>
|
||||||
|
<Skeleton key={ i } variant="rounded" width="100%" height="100%" />
|
||||||
|
) }
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Container sx={ sx.controls }>
|
||||||
|
<Skeleton variant="rounded" width="100%" height="24px" />
|
||||||
|
<Skeleton variant="rounded" width="150px" height="38px" />
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChartSkeleton;
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { SxProps } from "@mui/system";
|
||||||
|
import * as xc from "@mui/x-charts";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import IChartPoint from "../Data/IChartPoint";
|
||||||
|
import IPoint from "../Data/IPoint";
|
||||||
|
import ITrack from "../Data/ITrack";
|
||||||
|
import MaxSpeed from "../Data/MaxSpeed";
|
||||||
|
import { TooltipProps, TracklineSeries } from "../Data/TrackChartDataProps";
|
||||||
|
import CartesianGrid from "./CartesianGrid";
|
||||||
|
import TrackLinePlot from "./TrackLinePlot";
|
||||||
|
import TrackSurfacePlot from "./TrackSurfacePlot";
|
||||||
|
|
||||||
|
interface IProps
|
||||||
|
{
|
||||||
|
/** The tracks data. */
|
||||||
|
tracks: ITrack[];
|
||||||
|
/** The points data. */
|
||||||
|
points: IPoint[];
|
||||||
|
/** The zoom levels (start, end). */
|
||||||
|
zoom: number[];
|
||||||
|
/** A callback for when the processing is complete. */
|
||||||
|
onProcessingComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A chart of the track.
|
||||||
|
*/
|
||||||
|
function TrackChart({ tracks, points, zoom, ...props }: IProps): JSX.Element
|
||||||
|
{
|
||||||
|
const [dataset, setDataset] = useState<IChartPoint[]>([]);
|
||||||
|
const [xTicks, setXTicks] = useState<number[]>([]);
|
||||||
|
const [zoomedDataset, setZoomedDataset] = useState<IChartPoint[]>([]);
|
||||||
|
const [zoomedXTicks, setZoomedXTicks] = useState<number[]>([]);
|
||||||
|
const [maskStyles, setMasks] = useState<SxProps>({});
|
||||||
|
const maxPointHeight: number = Math.max(...points.map(i => i.height));
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
for (let i = 0; i < dataset.length; i++)
|
||||||
|
{
|
||||||
|
const selector: string = `.MuiMarkElement-series-${MaxSpeed[dataset[i].maxSpeed]}`;
|
||||||
|
const element: SVGPathElement | undefined = document.querySelectorAll<SVGPathElement>(selector)[i];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
element?.style.setProperty("display", "inline");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if (tracks.length < 1 || points.length < 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const data: IChartPoint[] = [];
|
||||||
|
let currentDistance: number = 0;
|
||||||
|
|
||||||
|
for (const track of tracks)
|
||||||
|
{
|
||||||
|
const firstPoint = points.find(p => p.id === track.firstId)!;
|
||||||
|
|
||||||
|
data.push({
|
||||||
|
distance: currentDistance,
|
||||||
|
length: track.distance,
|
||||||
|
surface: track.surface,
|
||||||
|
maxSpeed: track.maxSpeed,
|
||||||
|
name: `${firstPoint.name} (ID#${firstPoint.id})`,
|
||||||
|
height: firstPoint.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentDistance += track.distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastPoint = points.find(p => p.id === tracks[tracks.length - 1].secondId)!;
|
||||||
|
|
||||||
|
data.push({
|
||||||
|
distance: currentDistance,
|
||||||
|
length: -1,
|
||||||
|
surface: tracks[tracks.length - 1].surface,
|
||||||
|
maxSpeed: tracks[tracks.length - 1].maxSpeed,
|
||||||
|
name: `${lastPoint.name} (ID#${lastPoint.id})`,
|
||||||
|
height: lastPoint.height
|
||||||
|
});
|
||||||
|
|
||||||
|
setDataset(data);
|
||||||
|
setXTicks(data.map(i => i.distance));
|
||||||
|
|
||||||
|
console.warn("Reflow!");
|
||||||
|
|
||||||
|
setTimeout(() => props.onProcessingComplete?.(), 500);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [tracks, points, props.onProcessingComplete]);
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
setZoomedDataset(dataset.slice(zoom[0], zoom[1]));
|
||||||
|
setZoomedXTicks(xTicks.slice(zoom[0], zoom[1]));
|
||||||
|
}, [dataset, xTicks, zoom]);
|
||||||
|
|
||||||
|
const getSx = (): SxProps => ({
|
||||||
|
"& .MuiMarkElement-root":
|
||||||
|
{
|
||||||
|
display: "none",
|
||||||
|
},
|
||||||
|
...maskStyles,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<xc.ResponsiveChartContainer
|
||||||
|
dataset={ zoomedDataset }
|
||||||
|
sx={ getSx() }
|
||||||
|
yAxis={ [{ max: (Math.ceil(maxPointHeight / 20) + 1) * 20, min: 0 }] }
|
||||||
|
xAxis={ [{ dataKey: "distance", scaleType: "point" }] }
|
||||||
|
series={ TracklineSeries }>
|
||||||
|
|
||||||
|
<TrackSurfacePlot dataset={ zoomedDataset } />
|
||||||
|
<CartesianGrid maxY={ maxPointHeight } xTicks={ zoomedXTicks } />
|
||||||
|
<TrackLinePlot dataset={ zoomedDataset } onStylesUpdated={ styles => setMasks(styles) } strokeWidth={ 4 } />
|
||||||
|
|
||||||
|
<xc.ChartsXAxis />
|
||||||
|
<xc.ChartsYAxis />
|
||||||
|
<xc.ChartsAxisHighlight x="line" />
|
||||||
|
<xc.MarkPlot />
|
||||||
|
<xc.LineHighlightPlot />
|
||||||
|
|
||||||
|
<xc.ChartsTooltip
|
||||||
|
slotProps={ {
|
||||||
|
axisContent: TooltipProps(zoomedDataset)
|
||||||
|
} } />
|
||||||
|
|
||||||
|
</xc.ResponsiveChartContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrackChart;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { SxProps } from "@mui/system";
|
||||||
|
import { LinePlot, useDrawingArea, useXScale } from "@mui/x-charts";
|
||||||
|
import { ScaleLinear } from "d3-scale";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import MaxSpeed from "../Data/MaxSpeed";
|
||||||
|
import IChartPoint from "../Data/IChartPoint";
|
||||||
|
|
||||||
|
// Remarks:
|
||||||
|
// Even though this component adds nothing to the chart and only calculates masks,
|
||||||
|
// it is still required since a) I can't use `useDrawingArea` inside `TrackChart`
|
||||||
|
// and b) I can't pass calculates styles here
|
||||||
|
|
||||||
|
interface IProps
|
||||||
|
{
|
||||||
|
/** Data to plot. */
|
||||||
|
dataset: IChartPoint[];
|
||||||
|
/** Callback to update the styles. */
|
||||||
|
onStylesUpdated: (styles: SxProps) => void;
|
||||||
|
/** The width of the line. */
|
||||||
|
strokeWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A plot of the track line. */
|
||||||
|
function TrackLinePlot({ dataset, onStylesUpdated, strokeWidth }: IProps): JSX.Element
|
||||||
|
{
|
||||||
|
const area = useDrawingArea();
|
||||||
|
const xAxisScale = useXScale() as ScaleLinear<unknown, number>;
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
const masks: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < Object.keys(MaxSpeed).length / 2; i++)
|
||||||
|
masks.push("M 0 0 ");
|
||||||
|
|
||||||
|
for (const point of dataset)
|
||||||
|
{
|
||||||
|
if (point.length < 1)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const xStart: number = xAxisScale(point.distance) - area.left;
|
||||||
|
const xEnd: number = xAxisScale(point.distance + point.length) - area.left;
|
||||||
|
|
||||||
|
masks[point.maxSpeed] += `H ${xStart} V ${area.height} H ${isNaN(xEnd) ? area.width : xEnd} V 0 `;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sx: SxProps = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < masks.length; i++)
|
||||||
|
sx = {
|
||||||
|
...sx,
|
||||||
|
[`& .MuiLineElement-series-${MaxSpeed[i]}`]:
|
||||||
|
{
|
||||||
|
clipPath: `path("${masks[i]} Z")`,
|
||||||
|
strokeWidth: strokeWidth ?? 2,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onStylesUpdated(sx);
|
||||||
|
|
||||||
|
// Suppressing warning, since adding useXScale to the dependency array would cause an infinite component reflow
|
||||||
|
// `area` dependency is sufficient.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [area, dataset, strokeWidth]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LinePlot />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrackLinePlot;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { styled } from "@mui/material";
|
||||||
|
import { useDrawingArea, useXScale } from "@mui/x-charts";
|
||||||
|
import { ScaleLinear } from "d3-scale";
|
||||||
|
import React from "react";
|
||||||
|
import IChartPoint from "../Data/IChartPoint";
|
||||||
|
import { SurfaceColors } from "../Data/TrackChartDataProps";
|
||||||
|
|
||||||
|
interface IProps
|
||||||
|
{
|
||||||
|
/** Data to plot. */
|
||||||
|
dataset: IChartPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A plot of the track surface. */
|
||||||
|
function TrackSurfacePlot({ dataset }: IProps): JSX.Element
|
||||||
|
{
|
||||||
|
// Get the drawing area bounding box
|
||||||
|
const { top, height } = useDrawingArea();
|
||||||
|
const xAxisScale = useXScale() as ScaleLinear<unknown, number>;
|
||||||
|
|
||||||
|
// Calculate the width of each track
|
||||||
|
const getWidth = (item: IChartPoint): number =>
|
||||||
|
{
|
||||||
|
const width = xAxisScale(item.distance + item.length) - xAxisScale(item.distance);
|
||||||
|
return isNaN(width) ? 0 : width;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
{ dataset.map((i, index) => i.length > 0 &&
|
||||||
|
<StyledRect key={ index }
|
||||||
|
x={ xAxisScale(i.distance) } y={ top }
|
||||||
|
width={ getWidth(i) } height={ height }
|
||||||
|
color={ SurfaceColors[i.surface] } />
|
||||||
|
) }
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledRect = styled("rect")<{ color: string; }>(
|
||||||
|
({ color }) => ({
|
||||||
|
fill: color,
|
||||||
|
pointerEvents: "none"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default TrackSurfacePlot;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import MaxSpeed from "./MaxSpeed";
|
||||||
|
import Surface from "./Surface";
|
||||||
|
|
||||||
|
/** Represents an aggregated point on the chart. */
|
||||||
|
export default interface IChartPoint
|
||||||
|
{
|
||||||
|
/** The distance from the start of the track. */
|
||||||
|
distance: number;
|
||||||
|
/** The distance until the next point. */
|
||||||
|
length: number;
|
||||||
|
/** The type of track surface */
|
||||||
|
surface: Surface;
|
||||||
|
/** The maximum speed of the point. */
|
||||||
|
maxSpeed: MaxSpeed;
|
||||||
|
/** The name of the point. */
|
||||||
|
name: string;
|
||||||
|
/** The height of the point. */
|
||||||
|
height: number;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** Represents a point with an ID, name, and height. */
|
||||||
|
export default interface IPoint
|
||||||
|
{
|
||||||
|
/** The unique identifier. */
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
/** The name of the point. */
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** The height of the point. */
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import MaxSpeed from "./MaxSpeed";
|
||||||
|
import Surface from "./Surface";
|
||||||
|
|
||||||
|
/** Represents a track segment */
|
||||||
|
export default interface ITrack
|
||||||
|
{
|
||||||
|
/** First point ID */
|
||||||
|
firstId: number;
|
||||||
|
|
||||||
|
/** Second (last) point ID */
|
||||||
|
secondId: number;
|
||||||
|
|
||||||
|
/** Distance between points */
|
||||||
|
distance: number;
|
||||||
|
|
||||||
|
/** Surface type */
|
||||||
|
surface: Surface;
|
||||||
|
|
||||||
|
/** Maximum speed */
|
||||||
|
maxSpeed: MaxSpeed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import IPoint from "./IPoint";
|
||||||
|
import ITrack from "./ITrack";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a random number between min and max
|
||||||
|
* @param min The minimum value (inclusive)
|
||||||
|
* @param max The maximum value (exclusive)
|
||||||
|
* @returns A random number between min and max
|
||||||
|
*/
|
||||||
|
const getRandom = (min: number, max: number): number =>
|
||||||
|
Math.floor(Math.random() * (max - min)) + min;
|
||||||
|
|
||||||
|
const pointNames: string[] =
|
||||||
|
[
|
||||||
|
"Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel", "India",
|
||||||
|
"Juliett", "Kilo", "Lima", "Mike", "November", "Oscar", "Papa", "Quebec", "Romeo",
|
||||||
|
"Sierra", "Tango", "Uniform", "Victor", "Whiskey", "X-ray", "Yankee", "Zulu"
|
||||||
|
];
|
||||||
|
|
||||||
|
function LoadMockData(largeCount?: boolean): { tracks: ITrack[], points: IPoint[]; }
|
||||||
|
{
|
||||||
|
const count: number = getRandom(10, 20) * (largeCount ? 10 : 1);
|
||||||
|
const points: IPoint[] = [];
|
||||||
|
const tracks: ITrack[] = [];
|
||||||
|
|
||||||
|
// Generate random data
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
points.push({
|
||||||
|
id: i,
|
||||||
|
name: `${pointNames[getRandom(0, pointNames.length)]}-${i}`,
|
||||||
|
height: getRandom(50, 200)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < count - 1; i++)
|
||||||
|
{
|
||||||
|
tracks.push({
|
||||||
|
firstId: points[i].id,
|
||||||
|
secondId: points[i + 1].id,
|
||||||
|
distance: getRandom(1000, 2000),
|
||||||
|
surface: getRandom(0, 3),
|
||||||
|
maxSpeed: getRandom(0, 3)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tracks, points };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoadMockData;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** Represents the maximum speed options. */
|
||||||
|
enum MaxSpeed
|
||||||
|
{
|
||||||
|
FAST,
|
||||||
|
NORMAL,
|
||||||
|
SLOW
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MaxSpeed;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** Represents the type of surface. */
|
||||||
|
enum Surface
|
||||||
|
{
|
||||||
|
SAND,
|
||||||
|
ASPHALT,
|
||||||
|
GROUND
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Surface;
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { green, grey, orange, red, yellow } from "@mui/material/colors";
|
||||||
|
import { AllSeriesType, ChartsAxisContentProps } from "@mui/x-charts";
|
||||||
|
import IChartPoint from "./IChartPoint";
|
||||||
|
import MaxSpeed from "./MaxSpeed";
|
||||||
|
import Surface from "./Surface";
|
||||||
|
|
||||||
|
/** Props for rendering trackline segments. */
|
||||||
|
export const TracklineSeries: AllSeriesType[] = [
|
||||||
|
// There're three of them, because there are.
|
||||||
|
// Instead of trying to split signle line into different segments,
|
||||||
|
// I instead apply a mask to each line, which is a bit more efficient.
|
||||||
|
{
|
||||||
|
type: "line",
|
||||||
|
dataKey: "height",
|
||||||
|
curve: "catmullRom",
|
||||||
|
id: MaxSpeed[0],
|
||||||
|
color: green[500],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "line",
|
||||||
|
dataKey: "height",
|
||||||
|
curve: "catmullRom",
|
||||||
|
id: MaxSpeed[1],
|
||||||
|
color: orange[500],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "line",
|
||||||
|
dataKey: "height",
|
||||||
|
curve: "catmullRom",
|
||||||
|
id: MaxSpeed[2],
|
||||||
|
color: red[500],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Props for the chart tooltip. */
|
||||||
|
export const TooltipProps = (dataset: IChartPoint[]): Partial<ChartsAxisContentProps> => ({
|
||||||
|
// @ts-expect-error - The type definition is incorrect
|
||||||
|
axis:
|
||||||
|
{
|
||||||
|
valueFormatter: (value: number): string => `${value} mi`
|
||||||
|
},
|
||||||
|
series:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "line",
|
||||||
|
data: dataset.map((_, index) => index),
|
||||||
|
label: "Point name",
|
||||||
|
id: "name",
|
||||||
|
valueFormatter: (value: number) => dataset[value].name,
|
||||||
|
color: green[500],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "line",
|
||||||
|
data: dataset.map(i => i.height),
|
||||||
|
label: "Height ASL",
|
||||||
|
id: "height",
|
||||||
|
valueFormatter: (value: number) => `${value} ft`,
|
||||||
|
color: green[500],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "line",
|
||||||
|
data: dataset.map(i => i.surface),
|
||||||
|
label: "Segment type",
|
||||||
|
id: "surface",
|
||||||
|
valueFormatter: (value: number) => Surface[value],
|
||||||
|
color: green[700],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "line",
|
||||||
|
data: dataset.map(i => i.length),
|
||||||
|
id: "length",
|
||||||
|
label: "Next point",
|
||||||
|
valueFormatter: (value: number) => value < 1 ? "FINISH" : `${value} mi`,
|
||||||
|
color: yellow[200],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "line",
|
||||||
|
data: dataset.map(i => i.maxSpeed),
|
||||||
|
id: "maxSpeed",
|
||||||
|
label: "Speed caution",
|
||||||
|
valueFormatter: (value: number) => MaxSpeed[value],
|
||||||
|
color: red[500],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Colors for each surface type. */
|
||||||
|
export const SurfaceColors: Record<Surface, string> = {
|
||||||
|
[Surface.ASPHALT]: grey[400],
|
||||||
|
[Surface.SAND]: yellow[100],
|
||||||
|
[Surface.GROUND]: green[100],
|
||||||
|
};
|
||||||
@@ -837,6 +837,18 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@babel/types" "^7.20.7"
|
"@babel/types" "^7.20.7"
|
||||||
|
|
||||||
|
"@types/d3-scale@^4.0.8":
|
||||||
|
version "4.0.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.8.tgz#d409b5f9dcf63074464bf8ddfb8ee5a1f95945bb"
|
||||||
|
integrity sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==
|
||||||
|
dependencies:
|
||||||
|
"@types/d3-time" "*"
|
||||||
|
|
||||||
|
"@types/d3-time@*":
|
||||||
|
version "3.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.3.tgz#3c186bbd9d12b9d84253b6be6487ca56b54f88be"
|
||||||
|
integrity sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==
|
||||||
|
|
||||||
"@types/estree@1.0.5":
|
"@types/estree@1.0.5":
|
||||||
version "1.0.5"
|
version "1.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4"
|
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4"
|
||||||
@@ -2528,7 +2540,7 @@ react-transition-group@^4.4.5:
|
|||||||
loose-envify "^1.4.0"
|
loose-envify "^1.4.0"
|
||||||
prop-types "^15.6.2"
|
prop-types "^15.6.2"
|
||||||
|
|
||||||
react@^18.0.0, react@^18.2.0:
|
react@^18.2.0:
|
||||||
version "18.2.0"
|
version "18.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
|
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
|
||||||
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
|
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
|
||||||
|
|||||||
Reference in New Issue
Block a user