[frontend]: Add services heatmap
This commit is contained in:
15
frontend/package-lock.json
generated
15
frontend/package-lock.json
generated
@ -8,16 +8,19 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-leaflet/core": "^3.0.0",
|
||||||
"@tailwindcss/vite": "^4.0.9",
|
"@tailwindcss/vite": "^4.0.9",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"heatmap.js": "^2.0.5",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet.heat": "^0.2.0",
|
||||||
"leaflet.markercluster": "^1.5.3",
|
"leaflet.markercluster": "^1.5.3",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-helmet": "^6.1.0",
|
"react-helmet": "^6.1.0",
|
||||||
"react-leaflet": "^5.0.0-rc.2",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-leaflet-markercluster": "^5.0.0-rc.0",
|
"react-leaflet-markercluster": "^5.0.0-rc.0",
|
||||||
"react-router-dom": "^7.3.0",
|
"react-router-dom": "^7.3.0",
|
||||||
"recharts": "^2.15.1",
|
"recharts": "^2.15.1",
|
||||||
@ -3094,6 +3097,11 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/heatmap.js": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/heatmap.js/-/heatmap.js-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-CG2gYFP5Cv9IQCXEg3ZRxnJDyAilhWnQlAuHYGuWVzv6mFtQelS1bR9iN80IyDmFECbFPbg6I0LR5uAFHgCthw=="
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@ -3645,6 +3653,11 @@
|
|||||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet.heat": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ=="
|
||||||
|
},
|
||||||
"node_modules/leaflet.markercluster": {
|
"node_modules/leaflet.markercluster": {
|
||||||
"version": "1.5.3",
|
"version": "1.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
|
||||||
|
@ -10,16 +10,19 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-leaflet/core": "^3.0.0",
|
||||||
"@tailwindcss/vite": "^4.0.9",
|
"@tailwindcss/vite": "^4.0.9",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"heatmap.js": "^2.0.5",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet.heat": "^0.2.0",
|
||||||
"leaflet.markercluster": "^1.5.3",
|
"leaflet.markercluster": "^1.5.3",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-helmet": "^6.1.0",
|
"react-helmet": "^6.1.0",
|
||||||
"react-leaflet": "^5.0.0-rc.2",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-leaflet-markercluster": "^5.0.0-rc.0",
|
"react-leaflet-markercluster": "^5.0.0-rc.0",
|
||||||
"react-router-dom": "^7.3.0",
|
"react-router-dom": "^7.3.0",
|
||||||
"recharts": "^2.15.1",
|
"recharts": "^2.15.1",
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import ObjectTypeProportionPieChart from "./charts/ObjectTypeProportionPieChart";
|
import ObjectTypeProportionPieChart from "./charts/ObjectTypeProportionPieChart";
|
||||||
import LoadingOverlay from "./LoadingOverlay.jsx";
|
import LoadingOverlay from "./LoadingOverlay.jsx";
|
||||||
|
import HeatmapContainer from "./charts/HeatmapContainer";
|
||||||
|
|
||||||
const Statistics = () => {
|
const Statistics = () => {
|
||||||
const [transientTypes, setTransientTypes] = useState([]);
|
const [transientTypes, setTransientTypes] = useState([]);
|
||||||
const [trainTypes, setTrainTypes] = useState([]);
|
const [trainTypes, setTrainTypes] = useState([]);
|
||||||
const [trainStatuses, setTrainStatuses] = useState([]);
|
const [trainStatuses, setTrainStatuses] = useState([]);
|
||||||
|
const [coordinates, setCoordinates] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
@ -20,55 +22,27 @@ const Statistics = () => {
|
|||||||
let transientTypes = [];
|
let transientTypes = [];
|
||||||
let trainTypes = [];
|
let trainTypes = [];
|
||||||
let trainStatuses = [];
|
let trainStatuses = [];
|
||||||
|
let coords = [];
|
||||||
|
|
||||||
for (const item of transientData) {
|
for (const item of transientData) {
|
||||||
transientTypes.push(item.objectType.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z])([A-Z][a-z])/g, '$1 $2'));
|
transientTypes.push(item.objectType.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z])([A-Z][a-z])/g, '$1 $2'));
|
||||||
|
if (item.latitude && item.longitude) {
|
||||||
switch (item.objectType) {
|
coords.push([item.latitude, item.longitude]);
|
||||||
case "IrishRailTrain":
|
|
||||||
let trainType;
|
|
||||||
switch (item.trainType) {
|
|
||||||
case "M":
|
|
||||||
trainType = "Mainline";
|
|
||||||
break;
|
|
||||||
case "S":
|
|
||||||
trainType = "Suburban";
|
|
||||||
break;
|
|
||||||
case "D":
|
|
||||||
trainType = "DART";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
trainType = "Unknown";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.objectType === "IrishRailTrain") {
|
||||||
|
let trainType = item.trainType === "M" ? "Mainline" : item.trainType === "S" ? "Suburban" : item.trainType === "D" ? "DART" : "Unknown";
|
||||||
trainTypes.push(trainType);
|
trainTypes.push(trainType);
|
||||||
|
|
||||||
let trainStatus;
|
let trainStatus = item.trainStatus === "R" ? "Running" : item.trainStatus === "T" ? "Terminated" : item.trainStatus === "N" ? "Not yet running" : "Unknown";
|
||||||
switch (item.trainStatus) {
|
|
||||||
case "R":
|
|
||||||
trainStatus = "Running";
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "T":
|
|
||||||
trainStatus = "Terminated";
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "N":
|
|
||||||
trainStatus = "Not yet running";
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
trainStatus = "Unknown";
|
|
||||||
}
|
|
||||||
trainStatuses.push(trainStatus);
|
trainStatuses.push(trainStatus);
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setTransientTypes(transientTypes);
|
setTransientTypes(transientTypes);
|
||||||
setTrainStatuses(trainStatuses);
|
setTrainStatuses(trainStatuses);
|
||||||
setTrainTypes(trainTypes);
|
setTrainTypes(trainTypes);
|
||||||
|
setCoordinates(coords);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Failed to fetch data");
|
setError("Failed to fetch data");
|
||||||
} finally {
|
} finally {
|
||||||
@ -98,23 +72,28 @@ const Statistics = () => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="mx-auto px-4 flex flex-wrap gap-4 pt-[4vh] justify-center">
|
className="mx-auto px-4 flex flex-wrap gap-4 pt-[4vh] justify-center">
|
||||||
|
|
||||||
|
<div className="bg-white shadow-md rounded-lg p-4">
|
||||||
|
<HeatmapContainer coordinates={coordinates}/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-white shadow-md rounded-lg p-4">
|
<div className="bg-white shadow-md rounded-lg p-4">
|
||||||
<ObjectTypeProportionPieChart
|
<ObjectTypeProportionPieChart
|
||||||
label={`Transport Types`}
|
label={`Live Transport Types`}
|
||||||
dataList={transientTypes}
|
dataList={transientTypes}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white shadow-md rounded-lg p-4">
|
<div className="bg-white shadow-md rounded-lg p-4">
|
||||||
<ObjectTypeProportionPieChart
|
<ObjectTypeProportionPieChart
|
||||||
label={`Train Types`}
|
label={`Live Train Types`}
|
||||||
dataList={trainTypes}
|
dataList={trainTypes}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white shadow-md rounded-lg p-4">
|
<div className="bg-white shadow-md rounded-lg p-4">
|
||||||
<ObjectTypeProportionPieChart
|
<ObjectTypeProportionPieChart
|
||||||
label={`Train Statuses`}
|
label={`Live Train Statuses`}
|
||||||
dataList={trainStatuses}
|
dataList={trainStatuses}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
45
frontend/src/components/charts/HeatmapContainer.jsx
Normal file
45
frontend/src/components/charts/HeatmapContainer.jsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { MapContainer, TileLayer, useMap } from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import "leaflet.heat";
|
||||||
|
|
||||||
|
const Heatmap = ({ data }) => {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map || data.length === 0) return;
|
||||||
|
|
||||||
|
const heatmapLayer = L.heatLayer(data, {
|
||||||
|
radius: 20,
|
||||||
|
blur: 15,
|
||||||
|
maxZoom: 17,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.removeLayer(heatmapLayer);
|
||||||
|
};
|
||||||
|
}, [map, data]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HeatmapContainer = ({ coordinates }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center p-4 bg-white shadow-lg rounded-lg w-full max-w-md">
|
||||||
|
<h2 className="text-xl font-semibold text-center mb-4">Service Density Heatmap</h2>
|
||||||
|
<MapContainer
|
||||||
|
center={[53.4494762, -7.5029786]}
|
||||||
|
zoom={6}
|
||||||
|
minZoom={4}
|
||||||
|
maxBounds={[[150, -50], [0, 50]]}
|
||||||
|
maxBoundsViscosity={0.3}
|
||||||
|
style={{ height: "400px", width: "400px" }}>
|
||||||
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||||
|
<Heatmap data={coordinates} />
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeatmapContainer;
|
@ -18,7 +18,7 @@ const ObjectTypeProportionPieChart = ({ label, dataList }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center p-4 bg-white shadow-lg rounded-lg w-full max-w-md">
|
<div className="flex flex-col items-center p-4 bg-white shadow-lg rounded-lg w-full max-w-md">
|
||||||
<h2 className="text-xl font-semibold text-center mb-4">{label}</h2>
|
<h2 className="text-xl font-semibold text-center mb-4">{label}</h2>
|
||||||
<ResponsiveContainer width={350} height={300}>
|
<ResponsiveContainer width={400} height={400}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={chartData}
|
data={chartData}
|
||||||
|
Reference in New Issue
Block a user