1
0
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:
2024-02-18 18:52:05 +00:00
parent 26b0ffbf95
commit 704015b53f
18 changed files with 701 additions and 14 deletions
+6 -4
View File
@@ -9,12 +9,14 @@ const AppStyles = makeStyles(theme => ({
display: "flex",
flexFlow: "column",
height: "100%",
alignItems: "center",
justifyContent: "center"
userSelect: "none",
},
caption:
controls:
{
marginTop: theme.spacing(2),
marginBottom: theme.spacing(4),
display: "flex",
alignItems: "center",
gap: theme.spacing(4),
},
}));
+68 -8
View File
@@ -1,6 +1,12 @@
import { Button, Container, GlobalStyles, Theme, ThemeProvider, Typography, createTheme } from "@mui/material";
import { useState } from "react";
import RefreshIcon from "@mui/icons-material/Refresh";
import { Box, Button, Container, GlobalStyles, Slider, Theme, ThemeProvider, createTheme } from "@mui/material";
import { useEffect, useState } from "react";
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();
@@ -9,18 +15,72 @@ const theme: Theme = createTheme();
*/
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 [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 (
<ThemeProvider theme={ theme }>
<GlobalStyles styles={ { "body, #root": { height: "100vh", margin: 0 } } } />
<Container sx={ sx.root }>
<Typography variant="h1" gutterBottom>Hello World!</Typography>
<Button variant="contained" color="primary" onClick={ () => setClicks(clicks + 1) }>Click me</Button>
<Typography variant="body1" sx={ sx.caption }>Times clicked: { clicks }</Typography>
</Container>
<Box sx={ sx.root }>
<TrackChart
tracks={ data.tracks } points={ data.points }
zoom={ zoom } onProcessingComplete={ () => setLoading(false) } />
<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>
);
}
+63
View File
@@ -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;
+45
View File
@@ -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),
},
}));
+29
View File
@@ -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;
+133
View File
@@ -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;
+71
View File
@@ -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;
+47
View File
@@ -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;
+21
View File
@@ -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;
}
+12
View File
@@ -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;
}
+21
View File
@@ -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;
}
+51
View File
@@ -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;
+9
View File
@@ -0,0 +1,9 @@
/** Represents the maximum speed options. */
enum MaxSpeed
{
FAST,
NORMAL,
SLOW
}
export default MaxSpeed;
+9
View File
@@ -0,0 +1,9 @@
/** Represents the type of surface. */
enum Surface
{
SAND,
ASPHALT,
GROUND
}
export default Surface;
+92
View File
@@ -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],
};