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 { useEffect, useMemo, useState } from 'react';
import { LoginForm } from './components/auth/LoginForm';
import { NetworkMap } from './components/map/NetworkMap';
import { useNetworkSnapshot } from './hooks/useNetworkSnapshot';
@@ -9,6 +11,28 @@ function App(): JSX.Element {
const { token, user, status: authStatus, logout } = useAuth();
const isAuthenticated = authStatus === 'authenticated' && 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 (
<div className="app-shell">
@@ -39,7 +63,11 @@ function App(): JSX.Element {
{status === 'success' && data && (
<div className="snapshot-layout">
<div className="map-wrapper">
<NetworkMap snapshot={data} />
<NetworkMap
snapshot={data}
selectedStationId={selectedStationId}
onSelectStation={(id) => setSelectedStationId(id)}
/>
</div>
<div className="grid">
<div>
@@ -47,8 +75,24 @@ function App(): JSX.Element {
<ul>
{data.stations.map((station) => (
<li key={station.id}>
{station.name} ({station.latitude.toFixed(3)},{' '}
{station.longitude.toFixed(3)})
<button
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>
))}
</ul>
@@ -76,6 +120,48 @@ function App(): JSX.Element {
</ul>
</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>
)}
</section>

View File

@@ -1,6 +1,13 @@
import type { LatLngBoundsExpression, LatLngExpression } from 'leaflet';
import { useMemo } from 'react';
import { CircleMarker, MapContainer, Polyline, TileLayer, Tooltip } from 'react-leaflet';
import { useEffect, useMemo } from 'react';
import {
CircleMarker,
MapContainer,
Polyline,
TileLayer,
Tooltip,
useMap,
} from 'react-leaflet';
import type { NetworkSnapshot } from '../../services/api';
@@ -8,6 +15,8 @@ import 'leaflet/dist/leaflet.css';
interface NetworkMapProps {
readonly snapshot: NetworkSnapshot;
readonly selectedStationId?: string | null;
readonly onSelectStation?: (stationId: string) => void;
}
interface StationPosition {
@@ -18,7 +27,11 @@ interface StationPosition {
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[]>(() => {
return snapshot.stations.map((station) => ({
id: station.id,
@@ -73,6 +86,13 @@ export function NetworkMap({ snapshot }: NetworkMapProps): JSX.Element {
] as LatLngBoundsExpression;
}, [stationPositions]);
const selectedPosition = useMemo(() => {
if (!selectedStationId) {
return null;
}
return stationLookup.get(selectedStationId) ?? null;
}, [selectedStationId, stationLookup]);
return (
<MapContainer
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'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{selectedPosition ? <StationFocus position={selectedPosition} /> : null}
{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) => (
<CircleMarker
key={station.id}
center={station.position}
radius={6}
pathOptions={{ color: '#f97316', fillColor: '#fed7aa', fillOpacity: 0.9 }}
radius={station.id === selectedStationId ? 9 : 6}
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>
))}
</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;
}
.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 {
margin-bottom: 0.5rem;
font-size: 1.1rem;
@@ -109,6 +151,45 @@ body {
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) {
.snapshot-layout {
gap: 2rem;

View File

@@ -11,6 +11,10 @@ export interface Station extends Identified {
readonly name: string;
readonly latitude: number;
readonly longitude: number;
readonly code?: string | null;
readonly osmId?: string | null;
readonly elevationM?: number | null;
readonly isActive?: boolean;
}
export interface Track extends Identified {