Skip to content

Commit

Permalink
add Map page with some filters and simple stats
Browse files Browse the repository at this point in the history
  • Loading branch information
vsimakhin committed Feb 14, 2025
1 parent de485ca commit 1c2b2d5
Show file tree
Hide file tree
Showing 19 changed files with 399 additions and 47 deletions.
4 changes: 2 additions & 2 deletions app/ui/src/components/Aircrafts/EditCategoriesModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ import { useMutation } from '@tanstack/react-query';
import Dialog from '@mui/material/Dialog';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardHeader from "../UIElements/CardHeader";
import Tooltip from "@mui/material/Tooltip";
import IconButton from "@mui/material/IconButton";
import Grid from "@mui/material/Grid2";
// MUI Icons
import DisabledByDefaultOutlinedIcon from '@mui/icons-material/DisabledByDefaultOutlined';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
// Custom
import CardHeader from "../UIElements/CardHeader";
import TextField from "../UIElements/TextField";
import AircraftCategories from './AircraftCategories';
import AircraftCategories from '../UIElements/AircraftCategories';
import { updateAircraftModelsCategories } from '../../util/http/aircraft';
import { queryClient } from '../../util/http/http';
import { useErrorNotification, useSuccessNotification } from '../../hooks/useAppNotifications';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,17 @@ import { fetchAirport } from '../../util/http/airport';

const getAirportData = async (id, navigate) => {
try {
// Check cache first
const cachedData = queryClient.getQueryData(["airport", id]);
if (cachedData) {
return cachedData;
}

const response = await queryClient.fetchQuery({
queryKey: ["airport", id],
queryFn: ({ signal }) => fetchAirport({ signal, id, navigate }),
staleTime: 86400000, // 24 hours
cacheTime: 86400000, // 24 hours
});

return response;
Expand All @@ -45,6 +53,10 @@ const getAirportData = async (id, navigate) => {
const addMarker = (features, airport) => {
const code = `${airport.icao}${airport.iata ? '/' + airport.iata : ''}`;

// Check if marker already exists
const exists = features.find(f => f.get('code') === code);
if (exists) return;

const feature = new Feature({
geometry: new Point([airport.lon, airport.lat]).transform('EPSG:4326', 'EPSG:3857'),
code: code,
Expand Down Expand Up @@ -72,7 +84,36 @@ const addMarker = (features, airport) => {
features.push(feature);
}

export const FlightMap = ({ data, title = "Flight Map", sx }) => {
const addGreatCircleLine = (departure, arrival, vectorSource) => {
// Create unique key for the line
const airports = [departure.icao, arrival.icao].sort();
const lineKey = `line-${airports[0]}-${airports[1]}`;

// Check if line already exists
const exists = vectorSource.getFeatures().some(f => f.get('lineKey') === lineKey);
if (exists) return;

const arcGenerator = new GreatCircle(
{ x: departure.lon, y: departure.lat },
{ x: arrival.lon, y: arrival.lat }
);

const arcLine = arcGenerator.Arc(100, { offset: 10 });
if (arcLine.geometries.length > 0) {
const coordinates = arcLine.geometries[0].coords.map((geometry) =>
transform([geometry[0], geometry[1]], 'EPSG:4326', 'EPSG:3857')
);

const lineFeature = new LineString(coordinates);
const feature = new Feature({
geometry: lineFeature,
lineKey: lineKey
});
vectorSource.addFeature(feature);
}
}

export const FlightMap = ({ data, routes = true, title = "Flight Map", sx }) => {
const navigate = useNavigate();
const mapRef = useRef(null);
const containerRef = useRef(null);
Expand Down Expand Up @@ -146,22 +187,9 @@ export const FlightMap = ({ data, title = "Flight Map", sx }) => {

addMarker(features, departure);
addMarker(features, arrival);

// Add line between departure and arrival (great circle)
const arcGenerator = new GreatCircle(
{ x: departure.lon, y: departure.lat },
{ x: arrival.lon, y: arrival.lat }
);
const arcLine = arcGenerator.Arc(100, { offset: 10 });
if (arcLine.geometries.length > 0) {
const coordinates = arcLine.geometries[0].coords.map((geometry) =>
transform([geometry[0], geometry[1]], 'EPSG:4326', 'EPSG:3857')
);

const lineFeature = new LineString(coordinates);
vectorSource.addFeature(new Feature({ geometry: lineFeature }));
if (routes) {
addGreatCircleLine(departure, arrival, vectorSource);
}

return { departure, arrival };
});

Expand Down Expand Up @@ -195,10 +223,14 @@ export const FlightMap = ({ data, title = "Flight Map", sx }) => {
<>
{data && (
<>
<Card variant="outlined" sx={sx}>
<CardContent>
<Card variant="outlined" sx={{
...sx,
height: '85vh',
position: 'relative'
}}>
<CardContent sx={{ height: '100%' }}>
<CardHeader title={title} />
<div ref={mapRef} style={{ width: '100%', height: '500px', borderRadius: '4px', overflow: 'hidden' }}></div>
<div ref={mapRef} style={{ width: '100%', height: 'calc(100% - 30px)', borderRadius: '4px', overflow: 'hidden' }}></div>
</CardContent>
</Card>
<Card ref={containerRef} className="ol-popup">
Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion app/ui/src/components/FlightRecord/FlightRecord.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import FlightRecordDetails from "./FlightRecordDetails";
import { fetchFlightData } from "../../util/http/logbook";
import { useErrorNotification } from "../../hooks/useAppNotifications";
import { FLIGHT_INITIAL_STATE } from "../../constants/constants";
import FlightMap from "../Map/FlightMap";
import FlightMap from "../FlightMap/FlightMap";
import HelpButton from "./HelpButton";
import NewFlightRecordButton from "./NewFlightRecordButton";
import CopyFlightRecordButton from "./CopyFlightRecordButton";
Expand Down
4 changes: 2 additions & 2 deletions app/ui/src/components/FlightRecord/FlightRecordDetails.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import Divider from '@mui/material/Divider';
// Custom
import DatePicker from '../UIElements/DatePicker';
import TextField from '../UIElements/TextField';
import AircraftType from './AircraftType';
import AircraftReg from './AircraftReg';
import AircraftType from '../UIElements/AircraftType';
import AircraftReg from '../UIElements/AircraftReg';
import TimeField from './TimeField';
import PlaceField from './PlaceField';
import LandingFields from './LandingFields';
Expand Down
2 changes: 1 addition & 1 deletion app/ui/src/components/Logbook/Logbook.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const Logbook = () => {
queryKey: ['logbook'],
queryFn: ({ signal }) => fetchLogbookData({ signal, navigate }),
});
useErrorNotification({ isError, error, fallbackMessage: 'Failed to load aircrafts list' });
useErrorNotification({ isError, error, fallbackMessage: 'Failed to load logbook' });

return (
<>
Expand Down
128 changes: 128 additions & 0 deletions app/ui/src/components/Map/Filters.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import dayjs from "dayjs";
// MUI UI elements
import Grid from "@mui/material/Grid2";
import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
// Custom
import DatePicker from "../UIElements/DatePicker";
import AircraftReg from "../UIElements/AircraftReg";
import AircraftType from "../UIElements/AircraftType";
import AircraftCategories from "../UIElements/AircraftCategories";
import TextField from "../UIElements/TextField";
import Select from "../UIElements/Select";

const dateRanges = [
{
label: 'Last 7 days',
fn: () => ({ start: dayjs().subtract(7, 'day'), end: dayjs() })
},
{
label: 'Last 30 days',
fn: () => ({ start: dayjs().subtract(30, 'day'), end: dayjs() })
},
{
label: 'Last 90 days',
fn: () => ({ start: dayjs().subtract(90, 'day'), end: dayjs() })
},
{
label: 'This Month',
fn: () => ({ start: dayjs().startOf('month'), end: dayjs().endOf('month') })
},
{
label: 'Last Month',
fn: () => ({ start: dayjs().subtract(1, 'month').startOf('month'), end: dayjs().subtract(1, 'month').endOf('month') })
},
{
label: 'Last 3 Months',
fn: () => ({ start: dayjs().subtract(3, 'month').startOf('month'), end: dayjs().endOf('month') })
},
{
label: 'This Year',
fn: () => ({ start: dayjs().startOf('year'), end: dayjs().endOf('year') })
},
{
label: 'Last Year',
fn: () => ({ start: dayjs().subtract(1, 'year').startOf('year'), end: dayjs().subtract(1, 'year').endOf('year') })
},
];

export const Filters = ({ filter, handleChange }) => {
const handleQuickSelect = (_, value) => {
const range = dateRanges.find(r => r.label === value);
if (range) {
const { start, end } = range.fn();
handleChange('start_date', start);
handleChange('end_date', end);
}
};

return (
<>
<Grid container spacing={1}>
<Select gsize={{ xs: 12, sm: 12, md: 12, lg: 12, xl: 12 }}
label="Quick Date Range"
onChange={handleQuickSelect}
defaultValue="This Year"
options={dateRanges.map((range) => (range.label))}
/>
<DatePicker
gsize={{ xs: 12, sm: 6, md: 6, lg: 6, xl: 6 }}
id="start_date"
label="Start Date"
handleChange={handleChange}
value={filter?.start_date ? dayjs(filter?.start_date, "DD/MM/YYYY") : null}
tooltip="Start Date"
/>
<DatePicker
gsize={{ xs: 12, sm: 6, md: 6, lg: 6, xl: 6 }}
id="end_date"
label="End Date"
handleChange={handleChange}
value={filter?.end_date ? dayjs(filter?.end_date, "DD/MM/YYYY") : null}
tooltip="End Date"
/>
<AircraftReg
gsize={{ xs: 12, sm: 12, md: 12, lg: 12, xl: 12 }}
id="aircraft_reg"
handleChange={handleChange}
value={filter?.aircraft_reg}
last={false} disableClearable={false}
/>
<AircraftType
gsize={{ xs: 12, sm: 12, md: 12, lg: 12, xl: 12 }}
id="aircraft_model"
handleChange={handleChange}
value={filter?.aircraft_model}
disableClearable={false}
/>
<AircraftCategories
gsize={{ xs: 12, sm: 12, md: 12, lg: 12, xl: 12 }}
id="aircraft_category"
multiple={false}
handleChange={handleChange}
value={filter.aircraft_category}
disableClearable={false}
/>
<TextField
gsize={{ xs: 12, sm: 12, md: 12, lg: 12, xl: 12 }}
id="place"
label="Departure/Arrival"
handleChange={handleChange}
tooltip="Departure/Arrival"
value={filter?.place}
/>
<FormControlLabel
control={
<Switch
checked={filter?.no_routes ?? false}
onChange={(event) => handleChange("no_routes", event.target.checked)}
/>
}
label="No Route Lines"
/>
</Grid>
</>
);
}

export default Filters;
107 changes: 107 additions & 0 deletions app/ui/src/components/Map/SummaryFlightMap.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import dayjs from "dayjs";
// MUI
import Grid from "@mui/material/Grid2";
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import LinearProgress from "@mui/material/LinearProgress";
// Custom
import CardHeader from "../UIElements/CardHeader";
import { MAP_FILTER_INITIAL_STATE } from "../../constants/constants";
import Filters from "./Filters";
import FlightMap from "../FlightMap/FlightMap";
import { useErrorNotification } from "../../hooks/useAppNotifications";
import { fetchLogbookData } from "../../util/http/logbook";
import { fetchAircraftModelsCategories } from "../../util/http/aircraft";
import SummaryStats from "./SummaryStats";

const getModelsByCategory = (modelsData, category) => {
if (!category || !modelsData) return [];
return modelsData
.filter(item => item.category.split(',').map(c => c.trim()).includes(category))
.map(item => item.model);
};

const filterData = (data, filter, modelsData) => {
// Filter data
const filteredData = data.filter((flight) => {
// filter by date
const flightDate = dayjs(flight.date, 'DD/MM/YYYY');
const matchesDate = flightDate.isBetween(filter.start_date, filter.end_date, null, '[]');

// filter registration
const matchesReg = filter.aircraft_reg ? flight.aircraft.reg_name === filter.aircraft_reg : true;

// filter type
const matchesType = filter.aircraft_model ? flight.aircraft.model === filter.aircraft_model : true;

// filter category
const matchesCategory = filter.aircraft_category ?
getModelsByCategory(modelsData, filter.aircraft_category).includes(flight.aircraft.model) : true;

// filter arrival and departure place
const matchesArrival = filter.place ? flight.arrival.place.toUpperCase().includes(filter.place.toUpperCase()) : true;
const matchesDeparture = filter.place ? flight.departure.place.toUpperCase().includes(filter.place.toUpperCase()) : true;

return matchesDate & matchesReg && matchesType && matchesCategory && (matchesArrival || matchesDeparture);
});

return filteredData;
}

export const SummaryFlightMap = () => {
const [filter, setFilter] = useState(MAP_FILTER_INITIAL_STATE);
const [mapData, setMapData] = useState([]);
const navigate = useNavigate();

const { data, isLoading, isError, error } = useQuery({
queryKey: ['logbook'],
queryFn: ({ signal }) => fetchLogbookData({ signal, navigate }),
});
useErrorNotification({ isError, error, fallbackMessage: 'Failed to load logbook' });

const { data: modelsData } = useQuery({
queryKey: ['models-categories'],
queryFn: ({ signal }) => fetchAircraftModelsCategories({ signal, navigate }),
});

const handleFilterChange = (key, value) => {
setFilter(prev => ({ ...prev, [key]: value }));
};

useEffect(() => {
if (!data) return;
const filteredData = filterData(data, filter, modelsData);
setMapData(filteredData);
}, [data, filter, modelsData]);

return (
<>
{isLoading && <LinearProgress />}
<Grid container spacing={1} >
<Grid size={{ xs: 12, sm: 12, md: 3, lg: 3, xl: 3 }}>
<Card variant="outlined" sx={{ mb: 1 }}>
<CardContent>
<CardHeader title="Filters" />
<Filters filter={filter} handleChange={handleFilterChange} />
</CardContent>
</Card >
<Card variant="outlined" sx={{ mb: 1 }}>
<CardContent>
<CardHeader title="Stats" />
<SummaryStats data={mapData} />
</CardContent>
</Card >
</Grid>

<Grid size={{ xs: 12, sm: 12, md: 9, lg: 9, xl: 9 }}>
<FlightMap data={mapData} routes={!filter.no_routes} />
</Grid>
</Grid>
</>
);
}

export default SummaryFlightMap;
Loading

0 comments on commit 1c2b2d5

Please sign in to comment.