1
0
mirror of https://github.com/XFox111/MuiCharts.git synced 2026-04-22 06:51:05 +03:00

- Implemented actual API for frontend

- Reorganized and refactored frontend project
This commit is contained in:
2024-02-23 13:06:16 +00:00
parent c27fce37c6
commit 94711bb78d
23 changed files with 459 additions and 90 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_URL=http://localhost:5152
+1 -1
View File
@@ -32,7 +32,7 @@ module.exports = {
"@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",
"@typescript-eslint/no-unused-vars": "warn"
},
parserOptions: {
ecmaVersion: "latest",
+4
View File
@@ -1,6 +1,8 @@
# Use the official Node.js 20 image as the base image
FROM node:20 as builder
ARG API_URL=http://localhost:5152
# Set the working directory inside the container
WORKDIR /app
@@ -13,6 +15,8 @@ RUN yarn install
# Copy the app source code to the working directory
COPY . .
RUN echo "VITE_API_URL=${API_URL}" > .env.production
# Build the app
RUN yarn build
+1
View File
@@ -28,6 +28,7 @@
},
"devDependencies": {
"@types/d3-scale": "^4.0.8",
"@types/node": "^20.11.20",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@typescript-eslint/eslint-plugin": "^6.21.0",
+70 -23
View File
@@ -4,9 +4,10 @@ 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";
import ApiEndpoints from "./Data/Api/ApiEndpoints";
import IPoint from "./Data/Api/Models/IPoint";
import ITrack from "./Data/Api/Models/ITrack";
import { GeneratePoints, GenerateTracks } from "./Data/MockDataGenerator";
const theme: Theme = createTheme();
@@ -20,25 +21,68 @@ function App(): JSX.Element
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();
void LoadDataAsync();
}, []);
const handleZoomChange = (_: unknown, newValue: number | number[]) =>
async function LoadDataAsync(): Promise<void>
{
setLoading(true);
const { Points, Tracks } = new ApiEndpoints();
try
{
const tracks: ITrack[] = await Tracks.GetAllTracksAsync();
let points: IPoint[] = [];
if (tracks.length > 0)
points = await Points.GetPointsArrayAsync([
...tracks.map(t => t.firstId),
tracks[tracks.length - 1].secondId
]);
setData({ tracks, points });
setZoom([0, Math.min(tracks.length + 1, 20)]);
}
catch (error)
{
console.error("Failed to load data:", error);
}
finally
{
console.log("Data loaded")
setLoading(false);
}
}
async function RecreateDataAsync(): Promise<void>
{
setLoading(true);
const { Points, Tracks } = new ApiEndpoints();
try
{
const points: IPoint[] = GeneratePoints(120);
const tracks: ITrack[] = GenerateTracks(points);
await Points.ImportPointsAsync(points);
await Tracks.ImportTracksAsync(tracks);
await LoadDataAsync();
}
catch (error)
{
console.error("Failed to recreate data:", error);
}
finally
{
console.log("Data recreated")
setLoading(false);
}
}
function OnZoomChange(_: unknown, newValue: number | number[]): void
{
const value: number[] = newValue as number[];
@@ -46,10 +90,13 @@ function App(): JSX.Element
return;
if (value[1] - value[0] > 50)
{
setLoading(true);
setTimeout(() => setLoading(false), 500);
}
setZoom(value);
};
}
return (
<ThemeProvider theme={ theme }>
@@ -58,21 +105,21 @@ function App(): JSX.Element
<Box sx={ sx.root }>
<TrackChart
tracks={ data.tracks } points={ data.points }
zoom={ zoom } onProcessingComplete={ () => setLoading(false) } />
zoom={ zoom } />
<Container sx={ sx.controls }>
{ !isLoading &&
<Slider
min={ 0 } max={ data.tracks.length + 1 }
defaultValue={ zoom } onChangeCommitted={ handleZoomChange }
defaultValue={ zoom } onChangeCommitted={ OnZoomChange }
valueLabelDisplay="auto" />
}
<Button
variant="contained" color="inherit" endIcon={ <RefreshIcon /> }
onClick={ loadData } disabled={ isLoading }>
onClick={ () => void RecreateDataAsync() } disabled={ isLoading }>
Refresh
Recreate
</Button>
</Container>
</Box>
+5 -10
View File
@@ -1,10 +1,10 @@
import { SxProps } from "@mui/system";
import * as xc from "@mui/x-charts";
import { useEffect, useState } from "react";
import IPoint from "../Data/Api/Models/IPoint";
import MaxSpeed from "../Data/Api/Models/MaxSpeed";
import IChartPoint from "../Data/IChartPoint";
import IPoint from "../Data/IPoint";
import ITrack from "../Data/ITrack";
import MaxSpeed from "../Data/MaxSpeed";
import ITrack from "../Data/Api/Models/ITrack";
import { TooltipProps, TracklineSeries } from "../Data/TrackChartDataProps";
import CartesianGrid from "./CartesianGrid";
import TrackLinePlot from "./TrackLinePlot";
@@ -18,14 +18,12 @@ interface IProps
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
function TrackChart({ tracks, points, zoom }: IProps): JSX.Element
{
const [dataset, setDataset] = useState<IChartPoint[]>([]);
const [xTicks, setXTicks] = useState<number[]>([]);
@@ -84,10 +82,7 @@ function TrackChart({ tracks, points, zoom, ...props }: IProps): JSX.Element
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]);
}, [tracks, points]);
useEffect(() =>
{
+1 -1
View File
@@ -2,7 +2,7 @@ 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 MaxSpeed from "../Data/Api/Models/MaxSpeed";
import IChartPoint from "../Data/IChartPoint";
// Remarks:
+16
View File
@@ -0,0 +1,16 @@
import Points from "./Points";
import Tracks from "./Tracks";
const apiUrl: string = import.meta.env.VITE_API_URL as string;
export default class ApiEndpoints
{
public Points: Points;
public Tracks: Tracks;
constructor()
{
this.Points = new Points(apiUrl);
this.Tracks = new Tracks(apiUrl);
}
}
@@ -0,0 +1,9 @@
import IPoint from "../../Models/IPoint";
export default interface IGetPointsResponse
{
points: IPoint[],
totalCount: number,
count: number,
page: number
}
@@ -0,0 +1,17 @@
export default class UpsertPointRequest
{
public name: string;
public height: number;
constructor(name: string, height: number)
{
if (name.length < 1)
throw new Error("Name must be at least 1 character long");
if (height !== Math.floor(height))
throw new Error("Height must be an integer");
this.name = name;
this.height = height;
}
}
@@ -0,0 +1,29 @@
import MaxSpeed from "../../Models/MaxSpeed";
import Surface from "../../Models/Surface";
export default class UpsertTrackRequest
{
public firstId: number;
public secondId: number;
public distance: number;
public surface: Surface;
public maxSpeed: MaxSpeed;
constructor(firstId: number, secondId: number, distance: number, surface: number, maxSpeed: number)
{
if (firstId < 0)
throw new Error("First ID must be at least 0");
if (secondId < 0)
throw new Error("Second ID must be at least 0");
if (distance < 1)
throw new Error("Distance must be greater than 0");
this.firstId = firstId;
this.secondId = secondId;
this.distance = distance;
this.surface = surface;
this.maxSpeed = maxSpeed;
}
}
+105
View File
@@ -0,0 +1,105 @@
import IGetPointsResponse from "./Contracts/Point/IGetPointsResponse";
import UpsertPointRequest from "./Contracts/Point/UpsertPointRequest";
import IPoint from "./Models/IPoint";
export default class Points
{
private apiUrl: string;
constructor(apiUrl: string)
{
this.apiUrl = apiUrl;
}
public async CreatePointAsync(request: UpsertPointRequest): Promise<IPoint>
{
const response: Response = await fetch(this.apiUrl + "/points", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(request)
});
const data: IPoint = await response.json() as IPoint;
return data;
}
public async GetPointsArrayAsync(ids: number[]): Promise<IPoint[]>
{
const response: Response = await fetch(this.apiUrl + "/points/array", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(ids)
});
const data: IPoint[] = await response.json() as IPoint[];
return data;
}
public async GetPointsAsync(page?: number, count?: number): Promise<IGetPointsResponse>
{
const params = new URLSearchParams();
if (page)
params.append("page", page.toString());
if (count)
params.append("count", count.toString());
const response: Response = await fetch(this.apiUrl + `/points?${params.toString()}`);
const data: IGetPointsResponse = await response.json() as IGetPointsResponse;
return data;
}
public async GetPointAsync(id: number): Promise<IPoint>
{
const response: Response = await fetch(this.apiUrl + `/points/${id}`);
const data: IPoint = await response.json() as IPoint;
return data;
}
public async UpsertPointAsync(id: number, request: UpsertPointRequest): Promise<IPoint | null>
{
const response: Response = await fetch(this.apiUrl + `/points/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(request)
});
if (response.status === 204)
return null;
const data: IPoint = await response.json() as IPoint;
return data;
}
public async DeletePointAsync(id: number): Promise<void>
{
await fetch(this.apiUrl + `/points/${id}`, { method: "DELETE" });
}
public async ImportPointsAsync(points: IPoint[]): Promise<IPoint[]>
{
const response: Response = await fetch(this.apiUrl + "/points/import", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(points)
});
const data: IPoint[] = await response.json() as IPoint[];
return data;
}
}
+87
View File
@@ -0,0 +1,87 @@
import UpsertTrackRequest from "./Contracts/Track/UpsertTrackRequest";
import ITrack from "./Models/ITrack";
export default class Tracks
{
private apiUrl: string;
constructor(apiUrl: string)
{
this.apiUrl = apiUrl;
}
public async CreateTrackAsync(request: UpsertTrackRequest): Promise<ITrack>
{
const response: Response = await fetch(this.apiUrl + "/tracks", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(request)
});
const data: ITrack = await response.json() as ITrack;
return data;
}
public async GetTrackAsync(firstId: number, secondId: number): Promise<ITrack>
{
const response: Response = await fetch(this.apiUrl + `/tracks/${firstId}/${secondId}`);
const data: ITrack = await response.json() as ITrack;
return data;
}
public async GetAllTracksAsync(): Promise<ITrack[]>
{
const response: Response = await fetch(this.apiUrl + "/tracks");
const data: ITrack[] = await response.json() as ITrack[];
return data;
}
public async UpsertTrackAsync(
firstId: number,
secondId: number,
request: UpsertTrackRequest
): Promise<ITrack | null>
{
const response: Response = await fetch(this.apiUrl + `/tracks/${firstId}/${secondId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(request)
});
if (response.status === 204)
return null;
const data: ITrack = await response.json() as ITrack;
return data;
}
public async DeleteTrackAsync(firstId: number, secondId: number): Promise<void>
{
await fetch(this.apiUrl + `/tracks/${firstId}/${secondId}`, {
method: "DELETE"
});
}
public async ImportTracksAsync(tracks: ITrack[]): Promise<ITrack[]>
{
const response: Response = await fetch(this.apiUrl + "/tracks/import", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(tracks)
});
const data: ITrack[] = await response.json() as ITrack[];
return data;
}
}
+48
View File
@@ -0,0 +1,48 @@
/**
* A class that generates random numbers from a Gaussian distribution.
*/
export default class GaussianGenerator
{
private mean: number;
private stdDev: number;
/**
* Constructs a GaussianGenerator object with the specified mean and standard deviation.
* @param mean The mean of the Gaussian distribution.
* @param stdDev The standard deviation of the Gaussian distribution.
*/
constructor(mean: number, stdDev: number)
{
this.mean = mean;
this.stdDev = stdDev;
}
/**
* Generates the next random number from the Gaussian distribution.
* @returns The next random number from the Gaussian distribution.
*/
NextGaussian(): number
{
let u1 = 0, u2 = 0;
while (u1 === 0) u1 = Math.random(); // Converting [0,1) to (0,1)
while (u2 === 0) u2 = Math.random();
const randStdNormal = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2);
const randNormal = this.mean + this.stdDev * randStdNormal; // random normal(mean,stdDev^2)
return Math.floor(randNormal);
}
/**
* Generates an array of random numbers from the Gaussian distribution.
* @param n The number of random numbers to generate.
* @returns An array of random numbers from the Gaussian distribution.
*/
Generate(n: number): number[]
{
const arr = [];
for (let i = 0; i < n; i++)
{
arr.push(this.NextGaussian());
}
return arr;
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import MaxSpeed from "./MaxSpeed";
import Surface from "./Surface";
import MaxSpeed from "./Api/Models/MaxSpeed";
import Surface from "./Api/Models/Surface";
/** Represents an aggregated point on the chart. */
export default interface IChartPoint
-51
View File
@@ -1,51 +0,0 @@
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;
+49
View File
@@ -0,0 +1,49 @@
import IPoint from "./Api/Models/IPoint";
import ITrack from "./Api/Models/ITrack";
import MaxSpeed from "./Api/Models/MaxSpeed";
import Surface from "./Api/Models/Surface";
import GaussianGenerator from "./GaussianGenerator";
export function GeneratePoints(count: number): IPoint[]
{
const generator = new GaussianGenerator(160, 10);
const points: IPoint[] = generator.Generate(count).map((height, index) => ({
id: index + 1,
name: `${pointNames[index % pointNames.length]}-${index + 1}`,
height
}));
return points;
}
export function GenerateTracks(points: IPoint[]): ITrack[]
{
const generator = new GaussianGenerator(2000, 500);
const tracks: ITrack[] = generator.Generate(points.length - 1).map((distance, index) => ({
firstId: points[index].id,
secondId: points[index + 1].id,
distance,
surface: getRandom(0, 3) as Surface,
maxSpeed: getRandom(0, 3) as MaxSpeed
}));
return tracks;
}
/**
* 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"
];
+2 -2
View File
@@ -1,8 +1,8 @@
import { green, grey, orange, red, yellow } from "@mui/material/colors";
import { AllSeriesType, ChartsAxisContentProps } from "@mui/x-charts";
import MaxSpeed from "./Api/Models/MaxSpeed";
import Surface from "./Api/Models/Surface";
import IChartPoint from "./IChartPoint";
import MaxSpeed from "./MaxSpeed";
import Surface from "./Surface";
/** Props for rendering trackline segments. */
export const TracklineSeries: AllSeriesType[] = [
+12
View File
@@ -859,6 +859,13 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
"@types/node@^20.11.20":
version "20.11.20"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.20.tgz#f0a2aee575215149a62784210ad88b3a34843659"
integrity sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==
dependencies:
undici-types "~5.26.4"
"@types/parse-json@^4.0.0":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239"
@@ -2906,6 +2913,11 @@ unbox-primitive@^1.0.2:
has-symbols "^1.0.3"
which-boxed-primitive "^1.0.2"
undici-types@~5.26.4:
version "5.26.5"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
update-browserslist-db@^1.0.13:
version "1.0.13"
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4"