mirror of
https://github.com/XFox111/MuiCharts.git
synced 2026-04-22 06:51:05 +03:00
@@ -1,2 +1,51 @@
|
||||
# MuiCharts
|
||||
Small demo app on .NET/React. Job entry challenge
|
||||
|
||||
## Devcontainers
|
||||
This repository is equipped with configuration files for Dev Containers feature which allows you to develop this project in a containerized environment. You can use VS Code with Dev Containers extension and Docker or you can use GitHub Codespaces. Read more at [Developing inside a Container](https://code.visualstudio.com/docs/remote/containers).
|
||||
|
||||
## Backend
|
||||
Path: `/backend`
|
||||
|
||||
Backend is a simple ASP.NET Core Web API project with EF Core and SQLite. It provides a RESTful API for the frontend to consume and incorportaes DDD principles.
|
||||
|
||||
### Projects
|
||||
- `MuiCharts.Api` - ASP.NET Core Web API project
|
||||
- `MuiCharts.Contracts` - Shared Web API contracts that can be extracted into a separate package and shared between the client and the server
|
||||
- `MuiCharts.Infrastructure` - Infrastructure layer with EF Core and SQLite
|
||||
- `MuiCharts.Domain` - Domain layer with business logic and models
|
||||
|
||||
### Essential variables
|
||||
Use these properties as environmental variables or CLI arguments to configure the backend:
|
||||
#### HTTPS
|
||||
If you want to use Kestrel as your primary web server (with no reverse proxy), you can use the following properties to configure HTTPS with Let's Encrypt certificate:
|
||||
- `HTTPS_PORTS=443` - Listen for HTTPS requests on port 443
|
||||
- `LettuceEncrypt:AcceptTermsOfService=true` - bypass interactive prompt
|
||||
- `LettuceEncrypt:DomainNames:0=example.com` - domain name for the certificate (use `:1`, `:2`, `:3`, etc. to add more)
|
||||
- `LettuceEncrypt:EmailAddress=eugene@xfox111.net` - email address for certificate issuer
|
||||
|
||||
> **Note**: you need to have either a public IP address or a domain name to use Let's Encrypt certificates. Otherwise, use `dotnet dev-certs https` to generate a self-signed certificate.
|
||||
|
||||
#### Data persistence
|
||||
Configure these options if you want to change default paths for data persistence:
|
||||
- `ConnectionStrings:DataContext=Data Source=/persistence/data.db` - SQLite DB connection string (default: `/persistence/data.db`)
|
||||
- `LettuceEncrypt:CertificatesPath=/persistence` - path to store Let's Encrypt certificates (default: `/persistence` for `Production` and `data.db` for `Development`)
|
||||
|
||||
> **IMPORTANT**: default persistence paths are configured to be used in a Docker container, where the user is `root`. `/persistence` is not writtable by a non-root user, so you need either to change the paths if you want to run the app outside of a container without root privileges or run app as `sudo`.
|
||||
|
||||
## Frontend
|
||||
Path: `/frontend`
|
||||
|
||||
Frontend is a simple React app with Material-UI. It consumes the RESTful API provided by the backend (or uses its emulation) and visualizes the data.
|
||||
|
||||
> 🚧 WIP
|
||||
|
||||
## Docker
|
||||
Use sample `docker-compose.yml` file to deploy the project on one server using Nginx
|
||||
|
||||
## GitHub Actions
|
||||
Path: `.github/workflows`
|
||||
|
||||
There are two GitHub Actions workflows:
|
||||
- `backend.yml` - CI/CD for the backend. Deploys the app to a remote server using Docker and SSH
|
||||
- `frontend.yml` - CI/CD for the frontend. Deploys the app to GitHub Pages
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
node_modules/
|
||||
.devcontainer/
|
||||
.github/
|
||||
@@ -0,0 +1,43 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/strict-type-checked",
|
||||
"plugin:@typescript-eslint/stylistic-type-checked",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react/jsx-runtime",
|
||||
],
|
||||
ignorePatterns: ["dist", ".eslintrc.cjs"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["react-refresh"],
|
||||
rules: {
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-confusing-void-expression": "off",
|
||||
"@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: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
project: ["./tsconfig.json", "./tsconfig.node.json"],
|
||||
tsConfigRootDir: __dirname,
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,26 @@
|
||||
# Use the official Node.js 20 image as the base image
|
||||
FROM node:20 as builder
|
||||
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the package.json and package-lock.json files to the working directory
|
||||
COPY package*.json ./
|
||||
|
||||
# Install the app dependencies
|
||||
RUN yarn install
|
||||
|
||||
# Copy the app source code to the working directory
|
||||
COPY . .
|
||||
|
||||
# Build the app
|
||||
RUN yarn build
|
||||
|
||||
# Use the official Nginx image as the base image
|
||||
FROM nginx:latest as prod
|
||||
|
||||
# Copy the build output to replace the default Nginx contents
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Expose port 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Material UI fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" />
|
||||
|
||||
|
||||
<title>ChartRace</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "frontend-app",
|
||||
"author": {
|
||||
"email": "eugene@xfox111.net",
|
||||
"name": "Eugene Fox",
|
||||
"url": "https://xfox111.net"
|
||||
},
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "eslint . --ext ts,tsx --report-unused-disable-directives && tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.10",
|
||||
"@mui/material": "^5.15.10",
|
||||
"@mui/system": "^5.15.9",
|
||||
"@mui/x-charts": "^6.19.4",
|
||||
"@mui/x-data-grid": "^6.19.4",
|
||||
"d3-scale": "^4.0.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-scale": "^4.0.8",
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,23 @@
|
||||
import { makeStyles } from "./Utils/makeStyles";
|
||||
|
||||
/**
|
||||
* Stylesheet for the App component.
|
||||
*/
|
||||
const AppStyles = makeStyles(theme => ({
|
||||
root:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
height: "100%",
|
||||
userSelect: "none",
|
||||
},
|
||||
controls:
|
||||
{
|
||||
marginBottom: theme.spacing(4),
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing(4),
|
||||
},
|
||||
}));
|
||||
|
||||
export default AppStyles;
|
||||
@@ -0,0 +1,88 @@
|
||||
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();
|
||||
|
||||
/**
|
||||
* The main application component.
|
||||
*/
|
||||
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 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 } } } />
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -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],
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SxProps, Theme, useTheme } from "@mui/material";
|
||||
|
||||
/**
|
||||
* Utility function that allows to create SxProps stylesheets outside of the component.
|
||||
* @param styles Your stylesheet. A function that takes a theme and returns a SxProps object.
|
||||
* @returns A react hook that returns the SxProps object.
|
||||
*/
|
||||
const makeStyles = <T>(styles: (theme: Theme) => Record<keyof T, SxProps>) =>
|
||||
(): Record<keyof T, SxProps> =>
|
||||
{
|
||||
const theme = useTheme();
|
||||
return styles(theme);
|
||||
};
|
||||
|
||||
export { makeStyles };
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
+3015
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user