Implement code changes to enhance functionality and improve performance
All checks were successful
Frontend CI / lint-and-build (push) Successful in 16s

This commit is contained in:
2025-10-11 19:07:12 +02:00
parent a488a385ad
commit 92d19235d8
5 changed files with 234 additions and 11 deletions

View File

@@ -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>

View File

@@ -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='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' attribution='&copy; <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;
}

View File

@@ -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;

View File

@@ -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