Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0321b03
Fix issue where random grey segments were shown between stations
cosminpolifronie Sep 30, 2026
a078309
Do not display grey segments if they are at the end of the route, as …
cosminpolifronie Sep 30, 2026
4f97103
Re-introduce backtracking removal algorithm to prevent loops
cosminpolifronie Sep 30, 2026
7db3b3f
Remove grey segments at the beginning of the route - previous commit …
cosminpolifronie Sep 30, 2026
b69bfc7
Add playable/unplayable/remote stations generator based on the SimRai…
cosminpolifronie Sep 30, 2026
b9a936f
Massively rework and simplify the route generation algorithm so now l…
cosminpolifronie Sep 30, 2026
ce890ca
Remove annoying map moving functionality whenever the hover popup doe…
cosminpolifronie Sep 30, 2026
b430938
Fix review findings
cosminpolifronie Sep 30, 2026
136693d
Rename "Unplayable dispatch stations" layer to "Unplayable stations"
cosminpolifronie Sep 30, 2026
88294e9
Revert "Rename "Unplayable dispatch stations" layer to "Unplayable st…
cosminpolifronie Sep 30, 2026
27da7f7
Split passenger stops from unplayable dispatch stations and replace L…
cosminpolifronie Sep 30, 2026
407061b
Make railData.json content stable - no difference should be visible i…
cosminpolifronie Sep 30, 2026
ad7a076
Keep availability colours on station joins
cosminpolifronie Oct 1, 2026
5477184
Add playable area feature with hover details
cosminpolifronie Sep 30, 2026
9b825ec
Show overlapping lines as dotted and show line details as sorted so w…
cosminpolifronie Sep 30, 2026
e9ac429
Add layers menu toggle button
cosminpolifronie Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions packages/map/components/Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import "leaflet/dist/leaflet.css";
import {
MdFullscreen,
MdFullscreenExit,
MdLayers,
MdLayersClear,
MdOutlineTraffic,
MdSatellite,
MdSatelliteAlt,
Expand All @@ -32,11 +34,13 @@ import {
import Control from "react-leaflet-custom-control";

import NonPlayableStations from "@/components/NonPlayableStations";
import PassengerStations from "@/components/PassengerStations";
import RemoteStations from "@/components/RemoteStations";
import { TrainsList } from "@/components/TrainsList";

import { useSelectedTrain } from "../contexts/SelectedTrainContext";
import { StationMarker } from "./Markers/StationMarker";
import PlayableArea from "./PlayableArea";
import SelectedTrainPopup from "./SelectedTrainPopup";
import { MainlineSignals, OtherSignals } from "./Signals";
import SneakpeekMarkers from "./Sneakpeeks";
Expand Down Expand Up @@ -103,6 +107,11 @@ const LeaftletMap = ({ serverId }: MapProps) => {
key: "isSatellite",
defaultValue: false,
});

const [showLayersMenu, setShowLayersMenu] = useLocalStorage({
key: "showLayersMenu",
defaultValue: true,
});
const {
selectedTrain,
setSelectedTrain,
Expand All @@ -119,6 +128,16 @@ const LeaftletMap = ({ serverId }: MapProps) => {
const RenderPopupIcon =
renderPopup === true ? MdSpeakerNotes : MdSpeakerNotesOff;
const SatelliteIcon = isSatellite === true ? MdSatellite : MdSatelliteAlt;
const LayersMenuIcon = showLayersMenu === true ? MdLayers : MdLayersClear;

// The layers menu is hidden with CSS rather than unmounted: unmounting
// LayersControl would also remove every layer it controls from the map.
useEffect(() => {
if (!map) return;
const container = map.getContainer();
if (showLayersMenu) delete container.dataset.layersMenuHidden;
else container.dataset.layersMenuHidden = "";
}, [map, showLayersMenu]);

const [stations, setStations] = useState<Station[] | null>(null);
const [stoppedTrainsSince, setStoppedTrainsSince] = useState<
Expand Down Expand Up @@ -414,6 +433,21 @@ const LeaftletMap = ({ serverId }: MapProps) => {
</button>
</Tooltip>

<Tooltip
label={`${showLayersMenu ? "Hide" : "Show"} layers menu`}
position="right"
>
<button
type="button"
className={style.icon}
onClick={() => setShowLayersMenu((prev) => !prev)}
aria-label={`${showLayersMenu ? "Hide" : "Show"} layers menu`}
aria-pressed={showLayersMenu}
>
<LayersMenuIcon size={24} />
</button>
</Tooltip>

<Tooltip
label={fullscreen ? "Exit fullscreen" : "Enter fullscreen"}
position="right"
Expand Down Expand Up @@ -523,6 +557,18 @@ const LeaftletMap = ({ serverId }: MapProps) => {
</LayerGroup>
</LayersControl.Overlay>

<LayersControl.Overlay
checked={
localStorage.getItem("layer-passenger stations") === null ||
localStorage.getItem("layer-passenger stations") === "true"
}
name="Passenger stations"
>
<LayerGroup>
<PassengerStations />
</LayerGroup>
</LayersControl.Overlay>

<LayersControl.Overlay
checked={
localStorage.getItem("layer-signalling (not 100% accurate)") ===
Expand Down Expand Up @@ -553,6 +599,15 @@ const LeaftletMap = ({ serverId }: MapProps) => {
</LayerGroup>
</LayersControl.Overlay>

<LayersControl.Overlay
checked={localStorage.getItem("layer-playable area") === "true"}
name="Playable area"
>
<LayerGroup>
<PlayableArea />
</LayerGroup>
</LayersControl.Overlay>

<LayersControl.Overlay
checked={localStorage.getItem("layer-sneakpeeks") === "true"}
name="Sneakpeeks"
Expand Down
108 changes: 108 additions & 0 deletions packages/map/components/Markers/HoverPopup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import L from "leaflet";
import { useEffect, useRef } from "react";
import { Popup, type PopupProps } from "react-leaflet";

const POPUP_TIP_HEIGHT = 20;
const POPUP_EDGE_PADDING = 10;
// Keeps the tip from sliding past the rounded corners of the popup body.
const POPUP_TIP_INSET = 30;

type PopupInternals = {
_map?: L.Map;
_getAnchor: () => L.Point;
};

// Places the popup above or below its anchor (whichever fits) and shifts it
// horizontally to stay inside the map, instead of letting Leaflet pan the map.
const placePopupInsideMap = (popup: L.Popup) => {
const internals = popup as unknown as PopupInternals;
const map = internals._map;
const container = popup.getElement();
const latLng = popup.getLatLng();
if (!map || !container || !latLng) return;

const wrapper = container.querySelector<HTMLElement>(
".leaflet-popup-content-wrapper",
);
const tipContainer = container.querySelector<HTMLElement>(
".leaflet-popup-tip-container",
);
if (!wrapper) return;

const width = wrapper.offsetWidth;
const height = wrapper.offsetHeight;
const mapSize = map.getSize();
// The popup anchor (e.g. the icon's popupAnchor) is where the tip points
// when the popup sits above the marker; mirror it when placing below.
const anchor = internals._getAnchor();
const point = map.latLngToContainerPoint(latLng).add([anchor.x, 0]);

const spaceAbove = point.y + anchor.y - POPUP_TIP_HEIGHT - height;
const spaceBelow =
mapSize.y - (point.y - anchor.y + POPUP_TIP_HEIGHT + height);
const placeBelow = spaceAbove < POPUP_EDGE_PADDING && spaceBelow > spaceAbove;

const minShift = POPUP_EDGE_PADDING - (point.x - width / 2);
const maxShift = mapSize.x - POPUP_EDGE_PADDING - (point.x + width / 2);
// Prefer the left edge when the popup is wider than the map.
const fitShift =
minShift > maxShift ? minShift : Math.min(Math.max(0, minShift), maxShift);
const shiftX = Math.round(fitShift);
// The tip stays over the anchor unless that would put it past the body's
// rounded corners (anchor right at the map edge); then it stops there.
const maxTipShift = Math.max(0, width / 2 - POPUP_TIP_INSET);
const tipShift = Math.min(Math.max(shiftX, -maxTipShift), maxTipShift);
const shiftY = placeBelow ? -2 * anchor.y + 2 * POPUP_TIP_HEIGHT + height : 0;

container.classList.toggle("popup-below", placeBelow);
if (tipContainer) tipContainer.style.marginLeft = `${-20 - tipShift}px`;

const current = L.point(popup.options.offset ?? [0, 0]);
if (current.x === shiftX && current.y === shiftY) return;
popup.options.offset = L.point(shiftX, shiftY);
popup.update();
};

// A popup that repositions itself to fit inside the map instead of panning it.
const HoverPopup = (props: PopupProps) => {
const popupRef = useRef<L.Popup | null>(null);

useEffect(() => {
const popup = popupRef.current;
if (!popup) return;

let observer: ResizeObserver | null = null;
let map: L.Map | null = null;
const place = () => placePopupInsideMap(popup);
const onAdd = () => {
const wrapper = popup
.getElement()
?.querySelector(".leaflet-popup-content-wrapper");
if (!wrapper) return;
// Also fires once the content renders or changes size (e.g. images).
observer = new ResizeObserver(place);
observer.observe(wrapper);
// The anchor moves within the map when it's panned, zoomed or resized.
map = (popup as unknown as PopupInternals)._map ?? null;
map?.on("resize moveend zoomend", place);
};
const onRemove = () => {
observer?.disconnect();
observer = null;
map?.off("resize moveend zoomend", place);
map = null;
};

popup.on("add", onAdd);
popup.on("remove", onRemove);
return () => {
popup.off("add", onAdd);
popup.off("remove", onRemove);
onRemove();
};
}, []);

return <Popup ref={popupRef} autoPan={false} {...props} />;
};

export default HoverPopup;
8 changes: 5 additions & 3 deletions packages/map/components/Markers/NonPlayableStationMarker.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Station } from "@simrail/types";
import L from "leaflet";
import { Marker, Popup } from "react-leaflet";
import { Marker } from "react-leaflet";

import HoverPopup from "./HoverPopup";

import styles from "../../styles/MarkerPopup.module.css";

Expand All @@ -26,12 +28,12 @@ export const NonPlayableStationMarker = ({ station }: StationMarkerProps) => {
mouseout: (event) => event.target.closePopup(),
}}
>
<Popup className="simple-map-popup">
<HoverPopup className="simple-map-popup">
<div className={styles.simpleCard}>
<small>Unplayable station</small>
<strong>{station.Name}</strong>
</div>
</Popup>
</HoverPopup>
</Marker>
);
};
39 changes: 39 additions & 0 deletions packages/map/components/Markers/PassengerStationMarker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Station } from "@simrail/types";
import L from "leaflet";
import { Marker } from "react-leaflet";

import HoverPopup from "./HoverPopup";

import styles from "../../styles/MarkerPopup.module.css";

type StationMarkerProps = {
station: Station;
};

export const PassengerStationMarker = ({ station }: StationMarkerProps) => {
const icon = L.icon({
iconUrl: "/markers/icon-station-passenger.png",
iconSize: [16, 16],
popupAnchor: [0, -16],
});

return (
<Marker
key={station.id}
icon={icon}
position={[station.Latititude, station.Longitude]}
zIndexOffset={30}
eventHandlers={{
mouseover: (event) => event.target.openPopup(),
mouseout: (event) => event.target.closePopup(),
}}
>
<HoverPopup className="simple-map-popup">
<div className={styles.simpleCard}>
<small>Passenger station</small>
<strong>{station.Name}</strong>
</div>
</HoverPopup>
</Marker>
);
};
8 changes: 5 additions & 3 deletions packages/map/components/Markers/SignalMarker.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Signal } from "@simrail/types";
import { memo } from "react";
import { CircleMarker, Popup } from "react-leaflet";
import { CircleMarker } from "react-leaflet";

import HoverPopup from "./HoverPopup";

import styles from "../../styles/MarkerPopup.module.css";

Expand All @@ -26,12 +28,12 @@ export const SignalMarker = memo(({ signal }: SignalMarkerProps) => {
mouseout: (event) => event.target.closePopup(),
}}
>
<Popup className="simple-map-popup">
<HoverPopup className="simple-map-popup">
<div className={styles.simpleCard}>
<small>Signal</small>
<strong>{signal.Name}</strong>
</div>
</Popup>
</HoverPopup>
</CircleMarker>
);
});
Expand Down
7 changes: 4 additions & 3 deletions packages/map/components/Markers/StationMarker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import type { Station } from "@simrail/types";
import L from "leaflet";
import { usePathname, useRouter } from "next/navigation";
import { memo, useEffect, useState } from "react";
import { Marker, Popup, Tooltip } from "react-leaflet";
import { Marker, Tooltip } from "react-leaflet";

import stationsList from "../EDR_station.json";
import { getSteamProfileOrBot } from "../steam";
import HoverPopup from "./HoverPopup";

import styles from "../../styles/MarkerPopup.module.css";

Expand Down Expand Up @@ -94,7 +95,7 @@ export const StationMarker = memo(({ station }: StationMarkerProps) => {
},
}}
>
<Popup className="station-map-popup" minWidth={250}>
<HoverPopup className="station-map-popup" minWidth={250}>
<div className={styles.stationCard}>
<div className={styles.stationImage}>
<img src={station.MainImageURL} alt="" width={250} height={104} />
Expand All @@ -113,7 +114,7 @@ export const StationMarker = memo(({ station }: StationMarkerProps) => {
<span className={styles.openHint}>Open station in EDR ↗</span>
</div>
</div>
</Popup>
</HoverPopup>
<Tooltip
className="station-name-tooltip"
offset={[0, 20]}
Expand Down
8 changes: 5 additions & 3 deletions packages/map/components/Markers/StationRemoteMarker.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Station } from "@simrail/types";
import L from "leaflet";
import { Marker, Popup } from "react-leaflet";
import { Marker } from "react-leaflet";

import HoverPopup from "./HoverPopup";

import styles from "../../styles/MarkerPopup.module.css";

Expand All @@ -27,13 +29,13 @@ export const RemoteStationMarker = ({ station }: StationMarkerProps) => {
mouseout: (event) => event.target.closePopup(),
}}
>
<Popup className="simple-map-popup">
<HoverPopup className="simple-map-popup">
<div className={styles.simpleCard}>
<small>Remote station</small>
<strong>{station.Name}</strong>
<span>Controlled from {station.id}</span>
</div>
</Popup>
</HoverPopup>
</Marker>
);
};
7 changes: 4 additions & 3 deletions packages/map/components/Markers/TrainMarker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { useMantineColorScheme } from "@mantine/core";
import type { Train } from "@simrail/types";
import L from "leaflet";
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { Popup, Tooltip } from "react-leaflet";
import { Tooltip } from "react-leaflet";
import ReactLeafletDriftMarker from "react-leaflet-drift-marker";

import { getSteamProfileOrBot } from "@/components/steam";

import TrainText from "../TrainText";
import HoverPopup from "./HoverPopup";

type TrainMarkerProps = {
train: Train;
Expand Down Expand Up @@ -132,7 +133,7 @@ const TrainMarker = ({
popupclose: () => setIsPopupOpen(false),
}}
>
<Popup className="train-map-popup" minWidth={280}>
<HoverPopup className="train-map-popup" minWidth={280}>
{isPopupOpen && (
<TrainText
train={train}
Expand All @@ -142,7 +143,7 @@ const TrainMarker = ({
stoppedSince={stoppedSince}
/>
)}
</Popup>
</HoverPopup>

<Tooltip
className="train-number-tooltip"
Expand Down
Loading
Loading