Implement code changes to enhance functionality and improve performance
All checks were successful
Frontend CI / lint-and-build (push) Successful in 16s
All checks were successful
Frontend CI / lint-and-build (push) Successful in 16s
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
import './styles/global.css';
|
import './styles/global.css';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { LoginForm } from './components/auth/LoginForm';
|
import { LoginForm } from './components/auth/LoginForm';
|
||||||
import { NetworkMap } from './components/map/NetworkMap';
|
import { NetworkMap } from './components/map/NetworkMap';
|
||||||
import { useNetworkSnapshot } from './hooks/useNetworkSnapshot';
|
import { useNetworkSnapshot } from './hooks/useNetworkSnapshot';
|
||||||
@@ -9,6 +11,28 @@ function App(): JSX.Element {
|
|||||||
const { token, user, status: authStatus, logout } = useAuth();
|
const { token, user, status: authStatus, logout } = useAuth();
|
||||||
const isAuthenticated = authStatus === 'authenticated' && token !== null;
|
const isAuthenticated = authStatus === 'authenticated' && token !== null;
|
||||||
const { data, status, error } = useNetworkSnapshot(isAuthenticated ? token : null);
|
const { data, status, error } = useNetworkSnapshot(isAuthenticated ? token : null);
|
||||||
|
const [selectedStationId, setSelectedStationId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 'success' || !data?.stations.length) {
|
||||||
|
setSelectedStationId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!selectedStationId ||
|
||||||
|
!data.stations.some((station) => station.id === selectedStationId)
|
||||||
|
) {
|
||||||
|
setSelectedStationId(data.stations[0].id);
|
||||||
|
}
|
||||||
|
}, [status, data, selectedStationId]);
|
||||||
|
|
||||||
|
const selectedStation = useMemo(() => {
|
||||||
|
if (!data || !selectedStationId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data.stations.find((station) => station.id === selectedStationId) ?? null;
|
||||||
|
}, [data, selectedStationId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
@@ -39,7 +63,11 @@ function App(): JSX.Element {
|
|||||||
{status === 'success' && data && (
|
{status === 'success' && data && (
|
||||||
<div className="snapshot-layout">
|
<div className="snapshot-layout">
|
||||||
<div className="map-wrapper">
|
<div className="map-wrapper">
|
||||||
<NetworkMap snapshot={data} />
|
<NetworkMap
|
||||||
|
snapshot={data}
|
||||||
|
selectedStationId={selectedStationId}
|
||||||
|
onSelectStation={(id) => setSelectedStationId(id)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
<div>
|
<div>
|
||||||
@@ -47,8 +75,24 @@ function App(): JSX.Element {
|
|||||||
<ul>
|
<ul>
|
||||||
{data.stations.map((station) => (
|
{data.stations.map((station) => (
|
||||||
<li key={station.id}>
|
<li key={station.id}>
|
||||||
{station.name} ({station.latitude.toFixed(3)},{' '}
|
<button
|
||||||
{station.longitude.toFixed(3)})
|
type="button"
|
||||||
|
className={`station-list-item${
|
||||||
|
station.id === selectedStationId
|
||||||
|
? ' station-list-item--selected'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
aria-pressed={station.id === selectedStationId}
|
||||||
|
onClick={() => setSelectedStationId(station.id)}
|
||||||
|
>
|
||||||
|
<span className="station-list-item__name">
|
||||||
|
{station.name}
|
||||||
|
</span>
|
||||||
|
<span className="station-list-item__coords">
|
||||||
|
{station.latitude.toFixed(3)},{' '}
|
||||||
|
{station.longitude.toFixed(3)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -76,6 +120,48 @@ function App(): JSX.Element {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{selectedStation && (
|
||||||
|
<div className="selected-station">
|
||||||
|
<h3>Selected Station</h3>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Name</dt>
|
||||||
|
<dd>{selectedStation.name}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Coordinates</dt>
|
||||||
|
<dd>
|
||||||
|
{selectedStation.latitude.toFixed(5)},{' '}
|
||||||
|
{selectedStation.longitude.toFixed(5)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{selectedStation.code && (
|
||||||
|
<div>
|
||||||
|
<dt>Code</dt>
|
||||||
|
<dd>{selectedStation.code}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{typeof selectedStation.elevationM === 'number' && (
|
||||||
|
<div>
|
||||||
|
<dt>Elevation</dt>
|
||||||
|
<dd>{selectedStation.elevationM.toFixed(1)} m</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedStation.osmId && (
|
||||||
|
<div>
|
||||||
|
<dt>OSM ID</dt>
|
||||||
|
<dd>{selectedStation.osmId}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<dt>Status</dt>
|
||||||
|
<dd>
|
||||||
|
{(selectedStation.isActive ?? true) ? 'Active' : 'Inactive'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import type { LatLngBoundsExpression, LatLngExpression } from 'leaflet';
|
import type { LatLngBoundsExpression, LatLngExpression } from 'leaflet';
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { CircleMarker, MapContainer, Polyline, TileLayer, Tooltip } from 'react-leaflet';
|
import {
|
||||||
|
CircleMarker,
|
||||||
|
MapContainer,
|
||||||
|
Polyline,
|
||||||
|
TileLayer,
|
||||||
|
Tooltip,
|
||||||
|
useMap,
|
||||||
|
} from 'react-leaflet';
|
||||||
|
|
||||||
import type { NetworkSnapshot } from '../../services/api';
|
import type { NetworkSnapshot } from '../../services/api';
|
||||||
|
|
||||||
@@ -8,6 +15,8 @@ import 'leaflet/dist/leaflet.css';
|
|||||||
|
|
||||||
interface NetworkMapProps {
|
interface NetworkMapProps {
|
||||||
readonly snapshot: NetworkSnapshot;
|
readonly snapshot: NetworkSnapshot;
|
||||||
|
readonly selectedStationId?: string | null;
|
||||||
|
readonly onSelectStation?: (stationId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StationPosition {
|
interface StationPosition {
|
||||||
@@ -18,7 +27,11 @@ interface StationPosition {
|
|||||||
|
|
||||||
const DEFAULT_CENTER: LatLngExpression = [51.505, -0.09];
|
const DEFAULT_CENTER: LatLngExpression = [51.505, -0.09];
|
||||||
|
|
||||||
export function NetworkMap({ snapshot }: NetworkMapProps): JSX.Element {
|
export function NetworkMap({
|
||||||
|
snapshot,
|
||||||
|
selectedStationId,
|
||||||
|
onSelectStation,
|
||||||
|
}: NetworkMapProps): JSX.Element {
|
||||||
const stationPositions = useMemo<StationPosition[]>(() => {
|
const stationPositions = useMemo<StationPosition[]>(() => {
|
||||||
return snapshot.stations.map((station) => ({
|
return snapshot.stations.map((station) => ({
|
||||||
id: station.id,
|
id: station.id,
|
||||||
@@ -73,6 +86,13 @@ export function NetworkMap({ snapshot }: NetworkMapProps): JSX.Element {
|
|||||||
] as LatLngBoundsExpression;
|
] as LatLngBoundsExpression;
|
||||||
}, [stationPositions]);
|
}, [stationPositions]);
|
||||||
|
|
||||||
|
const selectedPosition = useMemo(() => {
|
||||||
|
if (!selectedStationId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return stationLookup.get(selectedStationId) ?? null;
|
||||||
|
}, [selectedStationId, stationLookup]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapContainer
|
<MapContainer
|
||||||
className="network-map"
|
className="network-map"
|
||||||
@@ -84,19 +104,51 @@ export function NetworkMap({ snapshot }: NetworkMapProps): JSX.Element {
|
|||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
/>
|
/>
|
||||||
|
{selectedPosition ? <StationFocus position={selectedPosition} /> : null}
|
||||||
{trackSegments.map((segment, index) => (
|
{trackSegments.map((segment, index) => (
|
||||||
<Polyline key={`track-${index}`} positions={segment} pathOptions={{ color: '#38bdf8', weight: 4 }} />
|
<Polyline
|
||||||
|
key={`track-${index}`}
|
||||||
|
positions={segment}
|
||||||
|
pathOptions={{ color: '#38bdf8', weight: 4 }}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{stationPositions.map((station) => (
|
{stationPositions.map((station) => (
|
||||||
<CircleMarker
|
<CircleMarker
|
||||||
key={station.id}
|
key={station.id}
|
||||||
center={station.position}
|
center={station.position}
|
||||||
radius={6}
|
radius={station.id === selectedStationId ? 9 : 6}
|
||||||
pathOptions={{ color: '#f97316', fillColor: '#fed7aa', fillOpacity: 0.9 }}
|
pathOptions={{
|
||||||
|
color: station.id === selectedStationId ? '#34d399' : '#f97316',
|
||||||
|
fillColor: station.id === selectedStationId ? '#6ee7b7' : '#fed7aa',
|
||||||
|
fillOpacity: 0.95,
|
||||||
|
weight: station.id === selectedStationId ? 3 : 1,
|
||||||
|
}}
|
||||||
|
eventHandlers={{
|
||||||
|
click: () => {
|
||||||
|
onSelectStation?.(station.id);
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip direction="top" offset={[0, -8]}>{station.name}</Tooltip>
|
<Tooltip
|
||||||
|
direction="top"
|
||||||
|
offset={[0, -8]}
|
||||||
|
permanent={station.id === selectedStationId}
|
||||||
|
sticky
|
||||||
|
>
|
||||||
|
{station.name}
|
||||||
|
</Tooltip>
|
||||||
</CircleMarker>
|
</CircleMarker>
|
||||||
))}
|
))}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StationFocus({ position }: { position: LatLngExpression }): null {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
map.panTo(position, { animate: true, duration: 0.6 });
|
||||||
|
}, [map, position]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,6 +86,48 @@ body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.station-list-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
background: rgba(15, 23, 42, 0.65);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
color: rgba(226, 232, 240, 0.9);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.18s ease,
|
||||||
|
border-color 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-list-item:hover,
|
||||||
|
.station-list-item:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(94, 234, 212, 0.7);
|
||||||
|
background: rgba(15, 118, 110, 0.3);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-list-item--selected {
|
||||||
|
border-color: rgba(45, 212, 191, 0.9);
|
||||||
|
background: rgba(13, 148, 136, 0.4);
|
||||||
|
box-shadow: 0 8px 18px -10px rgba(45, 212, 191, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-list-item__name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-list-item__coords {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(226, 232, 240, 0.7);
|
||||||
|
font-family: 'Fira Code', 'Source Code Pro', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.grid h3 {
|
.grid h3 {
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
@@ -109,6 +151,45 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selected-station {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(45, 212, 191, 0.35);
|
||||||
|
background: rgba(13, 148, 136, 0.18);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-station h3 {
|
||||||
|
color: rgba(226, 232, 240, 0.9);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-station dl {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-station dl > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-station dt {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: rgba(226, 232, 240, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-station dd {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: rgba(226, 232, 240, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.snapshot-layout {
|
.snapshot-layout {
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export interface Station extends Identified {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly latitude: number;
|
readonly latitude: number;
|
||||||
readonly longitude: number;
|
readonly longitude: number;
|
||||||
|
readonly code?: string | null;
|
||||||
|
readonly osmId?: string | null;
|
||||||
|
readonly elevationM?: number | null;
|
||||||
|
readonly isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Track extends Identified {
|
export interface Track extends Identified {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user