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 { 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>
|
||||
|
||||
@@ -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='© <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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user