first commit

This commit is contained in:
Christian Lawson-Perfect 2025-02-09 20:35:44 +00:00
commit 3f68f9ae72
25 changed files with 8803 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.make.*
app.js
elm-stuff/
error.txt
platform_data/

2
.watchmakerc Normal file
View file

@ -0,0 +1,2 @@
extensions:
- .elm

11
Makefile Normal file
View file

@ -0,0 +1,11 @@
DIRNAME=$(notdir $(CURDIR))
ELMS=$(wildcard src/*.elm)
app.js: src/App.elm $(ELMS)
-elm make $< --output=$@ 2> error.txt
@cat error.txt
upload: app.js index.html style.css
rsync -avz . clpland:~/domains/somethingorotherwhatever.com/html/$(DIRNAME)
@echo "Uploaded to https://somethingorotherwhatever.com/$(DIRNAME)"

22
README.txt Normal file
View file

@ -0,0 +1,22 @@
JSON files:
lines.json
Maps line names ("GREEN" or "YELLOW") to a list of stations along that line, with properties "key" (string) and "position" (list of two floats).
platforms.json
From https://github.com/danielgjackson/metro-rti/blob/main/data/platforms.json
Maps station keys to objects:
"platformNumber": int
"direction": string, "IN" or "OUT"
"helperText": string, readable description of the direction of travel
station-directions.json
Maps station keys to a dictionary mapping platform numbers to the angle of that platform on the map.
station-positions.json
Maps line names ("green" or "yellow") to a list of pairs of floats, giving the position on the map of each station on the line.
stations.json
From https://github.com/danielgjackson/metro-rti/blob/main/data/stations.json
Maps station keys to readable station names.

BIN
Sanchez-Regular.otf Normal file

Binary file not shown.

BIN
Sanchez-Regular.ttf Normal file

Binary file not shown.

31
cgi-bin/platform_data.py Executable file
View file

@ -0,0 +1,31 @@
#!/srv/think.somethingorotherwhatever.com/venv/bin/python
import cgi
import cgitb
import requests
from pathlib import Path
from datetime import datetime, timedelta
import getpass
cgitb.enable()
form = cgi.FieldStorage()
print('Content-Type: application/json\n')
station = form['station'].value
platform = form['platform'].value
data_root = Path('..') / 'platform_data'
data_root.mkdir(exist_ok=True)
fname = data_root / f'{station}-{platform}.json'
if (not fname.exists()) or (datetime.now() - datetime.fromtimestamp(fname.stat().st_mtime) > timedelta(minutes=1)):
req = requests.get(f'https://metro-rti.nexus.org.uk/api/times/{station}/{platform}')
with open(fname, 'w') as f:
f.write(req.text)
with open(fname) as f:
print(f.read())

25
elm.json Normal file
View file

@ -0,0 +1,25 @@
{
"type": "application",
"source-directories": [
"src"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/json": "1.1.3",
"elm/svg": "1.0.1",
"elm/time": "1.0.0"
},
"indirect": {
"elm/url": "1.0.0",
"elm/virtual-dom": "1.0.3"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}

25
index.html Normal file
View file

@ -0,0 +1,25 @@
<!DOCTYPE HTML>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Metro info</title>
<link rel="stylesheet" href="style.css">
<link rel="icon" href="metro_logo.svg" />
<link rel="manifest" href="manifest.json" />
</head>
<body>
<header>
<h1>Elm app by clp</h1>
</header>
<main>
<p>This is an app which will either load succesfully, and you'll wonder whether you saw this text at all, or fail ignominiously, showing you only this text.</p>
<p>On balance of probabilities: I'm sorry I couldn't be bothered to make this work for you.</p>
</main>
<footer>Made by <a href="https://somethingorotherwhatever.com">clp</a></footer>
<script src="app.js"></script>
<script src="load-app.js" type="module"></script>
</body>
</html>

1
lines.json Normal file

File diff suppressed because one or more lines are too long

61
load-app.js Normal file
View file

@ -0,0 +1,61 @@
import show_error from './show-error.mjs';
async function init_app() {
const params = new URLSearchParams(location.search);
const station = params.get('station');
const station_names = await (await fetch('stations.json')).json();
const station_directions = await (await fetch('station-directions.json')).json();
const station_latlons = await (await fetch('station-latlons.json')).json();
const platforms = await (await fetch('platforms.json')).json();
station_names['MON'] = 'Monument';
station_latlons['MON'] = station_latlons['MTW'];
delete station_names['MTW'];
delete station_names['MTS'];
delete station_latlons['MTW'];
delete station_latlons['MTS'];
Object.entries(platforms).forEach(([station_key,platforms]) => {
platforms.forEach(p => {
p.station_key = station_key;
});
})
platforms['MON'] = platforms['MTS'].concat(platforms['MTW']);
station_directions['MON'] = Object.assign({}, station_directions['MTS'], station_directions['MTW']);
const stations = Object.keys(station_names).map(key => {
return {key, name: station_names[key], platforms: platforms[key], directions: station_directions[key], latlon: station_latlons[key]};
});
const lines = await (await fetch('lines.json')).json();
Object.values(lines).map(stops => stops.forEach(stop => {
stop.key = (stop.key == 'MTS' || stop.key == 'MTW') ? 'MON' : stop.key;
}))
const compilation_error = await show_error;
if(compilation_error) {
return;
}
const app = Elm.App.init({node: document.body, flags: {stations, lines, station}});
app.ports.request_data.subscribe(async ([station,platformNumber]) => {
const url = `cgi-bin/platform_data.py?station=${station}&platform=${platformNumber}`;
const data = await (await fetch(url)).json();
station = (station == 'MTS' || station == 'MTW') ? 'MON' : station;
const out = {time: (new Date()).toISOString(), station, platformNumber, data}
app.ports.receive_platform_data.send(out);
});
app.ports.request_location.subscribe(async () => {
navigator.geolocation.getCurrentPosition(
(r) => {
console.log(r);
app.ports.receive_location.send(r);
},
(e) => console.error(e)
);
});
}
init_app();

25
manifest.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "Metro info",
"description": "See the next trains from Metro stations",
"start_url": "https://metro-info.think.somethingorotherwhatever.com/",
"display": "standalone",
"background_color": "#feb300",
"theme_color": "#feb300",
"icons": [
{
"src": "metro_logo.svg",
"type": "image/svg+xml",
"sizes": "any"
},
{
"src": "metro_logo.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "metro_logo_192.png",
"type": "image/png",
"sizes": "192x192"
}
]
}

BIN
metro_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

55
metro_logo.svg Normal file
View file

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="512"
height="512"
version="1.1"
id="svg138"
sodipodi:docname="Tyne_Wear_Metro_logo.svg"
inkscape:version="1.2.1 (9c6d41e, 2022-07-14)"
inkscape:export-filename="Tyne_Wear_Metro_logo.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs142" />
<sodipodi:namedview
id="namedview140"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="0.46365422"
inkscape:cx="129.40678"
inkscape:cy="255.57839"
inkscape:window-width="1379"
inkscape:window-height="847"
inkscape:window-x="61"
inkscape:window-y="25"
inkscape:window-maximized="1"
inkscape:current-layer="svg138" />
<path
d="M 0,383.75406 H 512 V 0 H 0 Z"
fill="#fdb826"
id="path132"
style="stroke-width:1.0906" />
<path
style="stroke-width:1.0906"
d="M 0,512 H 512 V 383.75406 H 0 Z"
id="path421" />
<path
style="stroke-width:1.00294"
d="m 343.682,111.49184 -63.92,216.73307 h -47.754 l -64.38,-216.87792 h -0.03 v 216.87792 h -75.34 v -45.11115 h 25.861 V 96.879274 H 92.258 V 51.795286 H 199.99 l 28.738,100.380774 27.559,94.50638 26.725,-96.0977 27.53,-98.788448 H 418.274 V 96.879274 H 392.009 V 283.11376 h 26.265 v 45.11115 H 343.768 V 111.49284"
id="path134" />
<path
d="m 390.543,427.39057 c -8.889,0 -15.592,5.67121 -15.592,19.41569 0,8.94236 3.596,19.41669 15.592,19.41669 11.305,0 15.592,-9.98345 15.592,-19.41669 0,-9.63542 -4.287,-19.41569 -15.592,-19.41569 m 0,51.73795 c -19.446,0 -30.867,-15.13361 -30.867,-32.32226 0,-17.18764 11.42,-32.32126 30.867,-32.32126 19.475,0 30.867,15.13362 30.867,32.32126 0,17.18865 -11.392,32.32226 -30.867,32.32226 m -80.145,-35.07035 h 13.981 c 4.89,-0.0875 8.141,-2.57508 8.141,-8.2483 0,-5.6702 -3.25,-8.15877 -8.141,-8.27445 h -13.98 z M 295.152,416.192 h 30.493 c 13.722,0 22.15,6.36628 22.15,20.13993 0,7.89925 -4.976,14.23636 -12.628,16.49358 l 13.98,24.59602 H 332.09 l -11.996,-22.02095 h -9.695 v 22.02095 H 295.153 M 233.994,416.192 h 47.178 v 11.86347 h -15.966 v 49.36606 H 249.96 v -49.36606 h -15.966 m -58.195,49.36606 V 416.192 h 44.934 v 11.86347 h -29.659 v 11.1694 h 27.674 v 11.89364 h -27.674 v 14.41038 h 29.515 v 11.89264 M 89.469,416.192 h 22.38 l 10.788,45.48835 h 0.173 L 133.971,416.192 h 22.122 v 61.22852 H 141.68 v -47.57055 h -0.172 l -12.082,47.57156 h -13.463 l -12.428,-47.57156 h -0.172 v 47.57156 H 89.47"
fill="#ffffff"
id="path136"
style="stroke-width:1.00294" />
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
metro_logo_192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

7504
metro_map.svg Normal file

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 316 KiB

1
platforms.json Normal file

File diff suppressed because one or more lines are too long

21
show-error.mjs Normal file
View file

@ -0,0 +1,21 @@
export default fetch('/error.txt').then(r=>{
if(r.ok) {
return r.text();
} else {
throw('');
}
}).then(text => {
if(!text) {
return false;
}
document.body.innerHTML = '';
const error_show = document.createElement('pre');
error_show.setAttribute('id','build-error');
error_show.style.background = 'black';
error_show.style.color = 'white';
error_show.style.padding = '1em';
error_show.style['font-size'] = '16px';
error_show.textContent = text;
document.body.appendChild(error_show);
return true;
}).catch(e => false);

610
src/App.elm Normal file
View file

@ -0,0 +1,610 @@
port module App exposing (..)
import Browser
import Browser.Events exposing (Visibility(..))
import Dict exposing (Dict)
import Html as H exposing (Html)
import Html.Attributes as HA
import Html.Events as HE
import Json.Decode as JD
import LatLonDistance exposing (lat_lon_distance, LatLon)
import Svg as Svg
import Svg.Attributes as SA
import Svg.Events as SE
import Task
import Time
import Tuple exposing (pair, first, second)
port request_data : (String, Int) -> Cmd msg
port request_location : () -> Cmd msg
port receive_platform_data : (JD.Value -> msg) -> Sub msg
port receive_location : (JD.Value -> msg) -> Sub msg
main = Browser.document
{ init = init
, update = update
, subscriptions = subscriptions
, view = view
}
type alias Model =
{ stations : (List Station)
, lines : Dict String (List Stop)
, error : Maybe JD.Error
, current_station : Maybe String
, time : Time.Posix
, time_zone : Time.Zone
, window_visible : Visibility
, current_position : Maybe LatLon
}
type alias Stop =
{ station : String
, position: (Float, Float)
, latlon : (Float, Float)
}
type alias Station =
{ key : String
, name : String
, platforms : List Platform
, latlon : LatLon
}
blank_station =
{ key = "???"
, name = "Unknown station"
, platforms = []
, latlon = {lat = 0, lon = 0}
}
type alias Platform =
{ last_checked : Time.Posix
, platformNumber : Int
, direction : PlatformDirection
, angle : Float
, helperText : String
, trains : List TrainInfo
, station_key : String
}
type alias TrainInfo =
{ train : String
, last_event : TrainEvent
, last_event_location : (String, Int)
, last_event_time : String
, destination : String
, due_in : Int
, line : String
}
type alias PlatformDataResponse =
{ time : String
, station : String
, platformNumber : Int
, data : List TrainInfo
}
type TrainEvent
= Departed
| Arrived
| ReadyToStart
| Approaching
event_description : TrainEvent -> String
event_description event = case event of
Departed -> "Departed"
Arrived -> "Arrived"
ReadyToStart -> "Starting"
Approaching -> "Approaching"
type PlatformDirection
= In
| Out
dir_arrow : PlatformDirection -> String
dir_arrow d = case d of
In -> ""
Out -> ""
decode_station : JD.Decoder Station
decode_station =
JD.map4 Station
(JD.field "key" JD.string)
(JD.field "name" JD.string)
(JD.field "directions" (JD.dict JD.float) |> JD.andThen
(\directions -> (JD.field "platforms" (JD.list (decode_platform directions))))
)
(JD.field "latlon" decode_latlon)
decode_platform : Dict String Float -> JD.Decoder Platform
decode_platform directions =
JD.map4
(\platformNumber direction helperText station_key ->
let
angle = Dict.get (String.fromInt platformNumber) directions |> Maybe.withDefault 0
in
{ last_checked = (Time.millisToPosix 0)
, platformNumber = platformNumber
, direction = direction
, helperText = helperText
, station_key = station_key
, trains = []
, angle = angle
}
)
(JD.field "platformNumber" JD.int)
(JD.field "direction" decode_direction)
(JD.field "helperText" JD.string)
(JD.field "station_key" JD.string)
decode_direction : JD.Decoder PlatformDirection
decode_direction =
JD.string |> JD.andThen
(\s -> case s of
"IN" -> JD.succeed In
"OUT" -> JD.succeed Out
_ -> JD.fail <| "Unrecognised direction " ++ s
)
decode_stop : JD.Decoder Stop
decode_stop =
JD.map3 Stop
(JD.field "key" JD.string)
(JD.field "map_position" (JD.map2 pair (JD.field "0" JD.float) (JD.field "1" JD.float)))
(JD.field "latlon" (JD.map2 pair (JD.field "0" JD.float) (JD.field "1" JD.float)))
decode_train_info : Model -> JD.Decoder TrainInfo
decode_train_info model =
JD.map7 TrainInfo
(JD.field "trn" JD.string)
(JD.field "lastEvent" decode_train_event)
(JD.field "lastEventLocation" (decode_event_location model))
(JD.field "lastEventTime" JD.string)
(JD.field "destination" JD.string)
(JD.field "dueIn" JD.int)
(JD.field "line" JD.string)
decode_event_location : Model -> JD.Decoder (String, Int)
decode_event_location model =
JD.string
|> JD.map (\s ->
let
i = String.indices "Platform" s |> List.head |> Maybe.withDefault 0
name = String.slice 0 (i-1) s |> station_name_to_key model |> Maybe.withDefault ("???" ++ s)
platform_number = String.dropLeft (i+9) s |> String.toInt |> Maybe.withDefault 0
in
(name, platform_number)
)
decode_train_event : JD.Decoder TrainEvent
decode_train_event =
JD.string |> JD.andThen
(\s -> case s of
"ARRIVED" -> JD.succeed Arrived
"DEPARTED" -> JD.succeed Departed
"READY_TO_START" -> JD.succeed ReadyToStart
"APPROACHING" -> JD.succeed Approaching
_ -> JD.fail <| "Unrecognised train event " ++ s
)
decode_platform_data_response model =
JD.map4 PlatformDataResponse
(JD.field "time" JD.string)
(JD.field "station" JD.string)
(JD.field "platformNumber" JD.int)
(JD.field "data" (JD.list (decode_train_info model)))
decode_geolocation : JD.Decoder LatLon
decode_geolocation =
JD.map2 LatLon
(JD.at ["coords", "latitude"] JD.float)
(JD.at ["coords", "longitude"] JD.float)
decode_latlon : JD.Decoder LatLon
decode_latlon =
JD.map2 LatLon
(JD.field "0" JD.float)
(JD.field "1" JD.float)
type Msg
= ReceivePlatformData JD.Value
| ReceiveLocation JD.Value
| TriggerTrainInfoRequest Station
| SetCurrentStation Station
| ClearCurrentStation
| UpdatePlatformData
| Tick Time.Posix
| SetTimeZone Time.Zone
| VisibilityChange Visibility
| RequestLocation
init : (JD.Value) -> (Model, Cmd Msg)
init flags =
let
model = init_model flags
in
( model
, Cmd.batch
[ request_all_platform_data (station_with_key model (model.current_station |> Maybe.withDefault "MSN"))
, Task.perform SetTimeZone Time.here
]
)
blank_model =
{ stations = []
, lines = Dict.empty
, error = Nothing
, current_station = Just "MSN"
, time = Time.millisToPosix 0
, time_zone = Time.utc
, window_visible = Visible
, current_position = Nothing
}
type alias Flags =
{ stations : List Station
, lines : Dict String (List Stop)
, station : String
}
decode_flags =
JD.map3 Flags
(JD.field "stations" (JD.list decode_station))
(JD.field "lines" (JD.dict (JD.list decode_stop)))
(JD.oneOf
[ JD.field "station" JD.string
, JD.succeed "MSN"
]
)
init_model : JD.Value -> Model
init_model flags =
flags
|> JD.decodeValue decode_flags
|> (\r -> case r of
Ok flagdata -> { blank_model | stations = flagdata.stations, lines = flagdata.lines, current_station = Just flagdata.station }
Err err -> { blank_model | error = Just (Debug.log "error" err) }
)
nocmd model = (model, Cmd.none)
request_all_platform_data station =
Cmd.batch (List.map (\p -> request_data (p.station_key, p.platformNumber)) station.platforms)
update msg model = case msg of
TriggerTrainInfoRequest station ->
( model
, request_all_platform_data station
)
ReceivePlatformData data ->
case JD.decodeValue (decode_platform_data_response model) data of
Err err -> {model | error = Just err } |> nocmd
Ok d -> add_platform_data d model |> nocmd
RequestLocation -> (model, request_location ())
ReceiveLocation data ->
case JD.decodeValue (decode_geolocation) data of
Err err -> {model | error = Just (Debug.log "err" err) } |> nocmd
Ok latlon ->
let
closest_stations = geo_closest_stations model latlon
nstation = case List.head closest_stations of
Nothing -> model.current_station
Just (s,_) -> Just s.key
in
{ model | current_position = Just latlon, current_station = nstation } |> nocmd
SetCurrentStation station -> { model | current_station = Just station.key } |> update (TriggerTrainInfoRequest station)
ClearCurrentStation ->
{ model | current_station = Nothing } |> nocmd
UpdatePlatformData ->
( model
, case (model.window_visible, model.current_station) of
(Visible, Just s) -> station_with_key model s |> request_all_platform_data
_ -> Cmd.none
)
Tick time -> { model | time = time } |> nocmd
SetTimeZone zone -> { model | time_zone = zone } |> nocmd
VisibilityChange visible -> { model | window_visible = visible } |> nocmd
add_platform_data d model =
let
nstations =
model.stations
|> List.map (\s ->
if s.key == d.station then
{ s | platforms = List.map (\p -> if p.platformNumber == d.platformNumber then { p | trains = d.data, last_checked = model.time } else p) s.platforms}
else s
)
in
{ model | stations = nstations }
subscriptions model =
Sub.batch
[ receive_platform_data ReceivePlatformData
, receive_location ReceiveLocation
, Time.every (1000 * 60) (\_ -> UpdatePlatformData)
, Time.every 100 Tick
, Browser.Events.onVisibilityChange VisibilityChange
]
view : Model -> Browser.Document Msg
view model =
let
current_station = model.current_station |> Maybe.map (station_with_key model)
next_trains =
current_station
|> Maybe.map (\s ->
s.platforms
|> List.filterMap (\p -> p.trains |> List.head |> Maybe.map (\t -> "P" ++ (String.fromInt p.platformNumber) ++ ": " ++ (String.fromInt t.due_in)))
|> String.join ", "
|> \times -> times ++ " @ " ++ s.name
)
|> Maybe.withDefault ""
view_station open station =
H.article
[ HE.on "toggle" (
JD.succeed (TriggerTrainInfoRequest station)
)
, HA.class "station"
]
[ H.ol
[ HA.class "platforms"]
(List.map (view_platform station) station.platforms)
]
format_time t =
let
zone = model.time_zone
hour = Time.toHour zone t
minute = Time.toMinute zone t
pad = String.fromInt >> String.padLeft 2 '0'
in
(pad hour) ++ ":" ++ (pad minute)
closest_stations = case model.current_position of
Nothing -> []
Just pos -> geo_closest_stations model pos
view_platform : Station -> Platform -> Html Msg
view_platform station platform =
H.li
[ HA.value <| String.fromInt platform.platformNumber
]
[ table
[ H.a
[ HA.href <| "https://metro-rti.nexus.org.uk/api/times/" ++ station.key ++ "/" ++ (String.fromInt platform.platformNumber)
, HA.target "_blank"
]
[H.text <|
"Platform "
++ (String.fromInt platform.platformNumber)
++ ": "
++ platform.helperText
]
, H.small [ HA.class "last-checked" ] [ H.text <| format_time platform.last_checked]
]
["Due", "Destination", "Last seen", "Event", "Train"]
(List.map (\t ->
let
last_station = station_with_key model (first t.last_event_location)
time_expected =
(Time.posixToMillis model.time) + (1000 * 60 * t.due_in)
|> Time.millisToPosix
due_string =
if t.due_in > 1 then
(String.fromInt t.due_in) ++ " (" ++ format_time time_expected ++ ")"
else
"here"
in
[ due_string
, t.destination
, last_station.name
, event_description t.last_event
, t.train
]
) platform.trains
)
]
in
{
title = "Metro info (" ++ next_trains ++ ")",
body =
[ H.div
[ HA.id "controls" ]
[ H.label
[ HA.for "station" ]
[ H.text "Trains from"]
, H.select
[ HA.id "station"
, HA.value <| Maybe.withDefault "" <| model.current_station
, HE.on "input"
( JD.field "target" (JD.field "value" JD.string)
|> JD.andThen
( station_with_key model
>> SetCurrentStation >> JD.succeed
)
)
]
(List.map
(\s ->
H.option
[ HA.value s.key]
[H.text s.name]
)
(List.sortBy .name model.stations)
)
, H.button
[ HA.type_ "button"
, HE.onClick UpdatePlatformData
]
[ H.text "" ]
, H.button
[ HA.type_ "button"
, HE.onClick RequestLocation
]
[ H.text "🧭" ]
, H.a
[ HA.href "https://www.nexus.org.uk/metro/updates"
, HA.target "_blank"
]
[ H.text "Disruptions" ]
]
, case current_station of
Nothing -> H.text ""
Just station -> view_station True station
, view_map model
, H.footer
[]
[ H.p
[]
[ H.text "Made unofficially by "
, H.a [ HA.href "https://somethingorotherwhatever.com" ] [ H.text "clp" ]
, H.text ". Uses data from the "
, H.a [ HA.href "https://github.com/danielgjackson/metro-rti" ] [ H.text "Tyne and Wear Metro Real-Time Information API" ]
, H.text "."
]
]
]
}
station_position : Model -> String -> String -> Maybe (Float, Float)
station_position model line station_key =
Dict.get line model.lines
|> Maybe.andThen (List.filter (\s -> s.station == station_key) >> List.head >> Maybe.map .position)
platform_direction : Model -> String -> Int -> Maybe Float
platform_direction model station_key platform_number =
model.stations |> List.filter (.key >> (==) station_key) |> List.head |> Maybe.andThen (.platforms >> List.filter (.platformNumber >> (==) platform_number) >> List.head) |> Maybe.map (.angle)
station_name_to_key : Model -> String -> Maybe String
station_name_to_key model name =
model.stations
|> List.map (\s -> (s.name, s.key))
|> Dict.fromList
|> Dict.get name
station_with_key : Model -> String -> Station
station_with_key model key = model.stations |> List.filter (.key >> (==) key) |> List.head |> Maybe.withDefault { blank_station | key = key }
geo_closest_stations model pos = model.stations |> List.map (\s -> (s, lat_lon_distance pos s.latlon)) |> List.sortBy second
all_trains : Model -> List TrainInfo
all_trains model =
List.concatMap (.platforms >> List.concatMap (.trains)) (List.filter (\s -> model.current_station == Just s.key) model.stations)
|> List.sortBy (\t -> (t.train, t.last_event_time))
view_map model =
let
trains =
all_trains model |> List.filterMap (\t ->
let
(key, platformNumber) = t.last_event_location
position = station_position model t.line key
direction = platform_direction model key platformNumber |> Maybe.withDefault 0
in
position
|> Maybe.map (\(x,y) ->
Svg.g
[ SA.class "train"
, SA.transform <| "translate(" ++ (String.fromFloat x)++" "++(String.fromFloat y)++")"
]
[ Svg.circle
[ SA.r "5"
]
[]
, Svg.path
[ SA.d "M 0 0 L 8 0 M 5 2 L 8 0 L 5 -2"
, SA.fill "none"
, SA.stroke "black"
, SA.strokeWidth "5"
, SA.transform <| "rotate(" ++ (String.fromFloat direction) ++ ")"
]
[]
, Svg.text_
[ SA.fontSize "4"
, SA.fill "white"
, SA.textAnchor "middle"
, SA.dominantBaseline "middle"
]
[ Svg.text t.train ]
]
)
)
lines : List (String, List Stop)
lines =
model.lines
|> Dict.toList
stations =
lines
|> List.concatMap (\(line,stops) ->
stops
|> List.map (\stop -> station_with_key model stop.station |> (\s ->
Svg.circle
[ SA.r "6"
, SA.cx <| String.fromFloat (first stop.position)
, SA.cy <| String.fromFloat (second stop.position)
, SA.fillOpacity <| if model.current_station == Just stop.station then "0.5" else "0.01"
, SA.fill "blue"
, SE.onClick (SetCurrentStation s)
]
[]
))
)
in
H.div
[ HA.id "map-container" ]
[ Svg.svg
[ HA.attribute "viewBox" "0 0 595.275 280.63"
, HA.id "map"
]
[ Svg.use
[ HA.attribute "href" "metro_map.svg#map"
]
[]
, Svg.g [] (trains)
, Svg.g [] (stations)
]
]
table : List (Html Msg) -> List String -> List (List String) -> Html Msg
table caption headers rows =
H.table
[]
[ H.caption
[]
caption
, H.thead
[]
[H.tr [] (List.map (\h -> H.th [] [H.text h]) headers)]
, H.tbody
[]
( (List.map (\row -> H.tr [] (List.map (\c -> H.td [] [H.text c]) row)) rows)
|> pad_list 4 (H.tr [HA.class "empty"] (List.map (\_ -> H.td [] [ H.text "-"]) headers))
)
]
pad_list : Int -> a -> List a -> List a
pad_list n blank list =
List.append list (List.repeat (n - (List.length list)) blank)

50
src/LatLonDistance.elm Normal file
View file

@ -0,0 +1,50 @@
module LatLonDistance exposing (lat_lon_distance, LatLon)
type alias LatLon = { lat : Float, lon : Float }
lat_lon_distance : LatLon -> LatLon -> Float
lat_lon_distance p1 p2 =
let
a = 6378137.0
b = 6356752.314245
f = 1 / 298.257223563
lat1 = degrees p1.lat
lat2 = degrees p2.lat
lon1 = degrees p1.lon
lon2 = degrees p2.lon
dlon = (lon2 - lon1)
tanU1 = (1-f) * (tan lat1)
cosU1 = 1 / (sqrt (1 + tanU1 * tanU1))
sinU1 = tanU1 * cosU1
tanU2 = (1-f) * (tan lat2)
cosU2 = 1 / (sqrt (1 + tanU2*tanU2))
sinU2 = tanU2 * cosU2
approx lon =
let
sinlon = sin lon
coslon = cos lon
sinSqsigma = (cosU2*sinlon) * (cosU2*sinlon) + (cosU1*sinU2-sinU1*cosU2*coslon) ^ 2
sinsigma = sqrt sinSqsigma
cossigma = sinU1*sinU2 + cosU1*cosU2*coslon
sigma = atan2 sinsigma cossigma
sinalpha = cosU1 * cosU2 * sinlon / sinsigma
cosSqalpha = 1 - sinalpha*sinalpha
cos2sigma_m = cossigma - 2*sinU1*sinU2/cosSqalpha
c = f/16*cosSqalpha*(4+f*(4-3*cosSqalpha))
lon_ = dlon + (1-c) * f * sinalpha * (sigma + c*sinsigma*(cos2sigma_m+c*cossigma*(-1+2*cos2sigma_m*cos2sigma_m)))
uSq = cosSqalpha * (a*a - b*b) / (b*b)
biga = 1 + uSq/16384*(4096+uSq*(-768+uSq*(320-175*uSq)))
bigb = uSq/1024 * (256+uSq*(-128+uSq*(74-47*uSq)))
deltasigma = bigb*sinsigma*(cos2sigma_m+bigb/4*(cossigma*(-1+2*cos2sigma_m*cos2sigma_m)-bigb/6*cos2sigma_m*(-3+4*sinsigma*sinsigma)*(-3+4*cos2sigma_m*cos2sigma_m)))
s = b*biga*(sigma-deltasigma)
in
if abs (lon - lon_) > 1e-12 then (approx lon_) else s
in
approx dlon

63
station-directions.json Normal file
View file

@ -0,0 +1,63 @@
{
"APT": {"1": 0, "2": 180},
"CAL": {"1": 0, "2": 180},
"BFT": {"1": 0, "2": 180},
"KSP": {"1": 0, "2": 180},
"FAW": {"1": 0, "2": 180},
"WBR": {"1": 0, "2": 180},
"RGC": {"1": 0, "2": 180},
"SGF": {"1": 60, "2": 240},
"ILF": {"1": 60, "2": 240},
"WJS": {"1": 60, "2": 240},
"JES": {"1": 60, "2": 240},
"HAY": {"1": 60, "2": 240},
"MTS": {"1": 60, "2": 240},
"MTW": {"3": 0, "4": 180},
"CEN": {"1": 60, "2": 240},
"GHD": {"1": 0, "2": 180},
"GST": {"1": 0, "2": 180},
"FEL": {"1": 0, "2": 180},
"HTH": {"1": 0, "2": 180},
"PLW": {"1": 0, "2": 180},
"HEB": {"1": 0, "2": 180},
"JAR": {"1": 0, "2": 180},
"BDE": {"1": 0, "2": 180},
"SMD": {"1": 0, "2": 180},
"TDK": {"1": 0, "2": 180},
"CHI": {"1": 0, "2": 180},
"SSS": {"1": 0, "2": 180},
"FGT": {"1": 0, "2": 180},
"BYW": {"1": 0, "2": 180},
"EBO": {"1": 0, "2": 180},
"SBN": {"1": 90, "2": 270},
"SFC": {"1": 90, "2": 270},
"MSP": {"1": 90, "2": 270},
"SUN": {"1": 90, "2": 270},
"PLI": {"1": 180, "2": 0},
"UNI": {"1": 180, "2": 0},
"MLF": {"1": 180, "2": 0},
"PAL": {"1": 180, "2": 0},
"SHL": {"1": 180, "2": 0},
"SJM": {"1": 0, "2": 180},
"MAN": {"1": 0, "2": 180},
"BYK": {"1": 0, "2": 180},
"CRD": {"1": 0, "2": 180},
"WKG": {"1": 0, "2": 180},
"WSD": {"1": 0, "2": 180},
"HDR": {"1": 0, "2": 180},
"HOW": {"1": 0, "2": 180},
"PCM": {"1": 0, "2": 180},
"MWL": {"1": 0, "2": 180},
"NSH": {"1": 0, "2": 180},
"TYN": {"1": 270, "2": 90},
"CUL": {"1": 270, "2": 90},
"WTL": {"1": 270, "2": 90},
"MSN": {"1": 180, "2": 0},
"WMN": {"1": 180, "2": 0},
"SMR": {"1": 180, "2": 0},
"NPK": {"1": 180, "2": 0},
"PMV": {"1": 180, "2": 0},
"BTN": {"1": 180, "2": 0},
"FLE": {"1": 180, "2": 0},
"LBN": {"1": 180, "2": 0}
}

1
station-latlons.json Normal file
View file

@ -0,0 +1 @@
{"APT": [55.03577004909116, -1.7111265174504924], "CAL": [55.02800273311085, -1.7036043935197853], "BFT": [55.013882728284536, -1.6781041455925452], "KSP": [55.01438146469378, -1.6652818995828114], "FAW": [55.013643304033884, -1.644878111966044], "WBR": [55.01415869459053, -1.6350085965165628], "RGC": [55.011944179047475, -1.6217731502181474], "SGF": [55.00614726487779, -1.6082661067096817], "ILF": [55.00023693236148, -1.6108770317154648], "WJS": [54.99364830600748, -1.6098926355353478], "JES": [54.982881393953804, -1.6060319576069106], "HAY": [54.97749613672549, -1.6140033882511216], "MTS": [54.973896759913636, -1.6132420712024815], "CEN": [54.96915879926773, -1.6162313682444793], "GHD": [54.96180675090266, -1.6040450471579535], "GST": [54.957727365407564, -1.5886927377600415], "FEL": [54.95315392240506, -1.5720446784266642], "HTH": [54.9515340871179, -1.5555837237871162], "PLW": [54.95254705425207, -1.5420476251169053], "FGT": [54.95263065551402, -1.5420148963164952], "BYW": [54.957372994523446, -1.4860452243342233], "EBO": [54.959403577124995, -1.4612794918514254], "SBN": [54.946410152193415, -1.420438895595007], "SFC": [54.92919863560131, -1.3864468319158785], "MSP": [54.91811279473953, -1.3830167967642568], "SUN": [54.91100033399111, -1.3842235196597212], "PLI": [54.90232769869331, -1.3848277824608908], "UNI": [54.90289486139476, -1.3923841760816358], "MLF": [54.90651021486448, -1.4008007892610852], "PAL": [54.91279641288439, -1.4178747825280822], "SHL": [54.90420705667461, -1.4479615115961655], "SJM": [54.97421054355723, -1.6210669283330124], "MTW": [54.97384152715182, -1.6131521777521154], "MAN": [54.974003328855375, -1.6048376504104354], "BYK": [54.97612749380666, -1.5799477880096582], "CRD": [54.98285607730816, -1.5719087070215574], "WKG": [54.98545678531661, -1.559210279263152], "WSD": [54.98953748564867, -1.5325426692860964], "HDR": [54.99225461830697, -1.515811836724129], "HOW": [54.99569609087364, -1.4945280145610538], "PCM": [54.99947831652944, -1.4751452540962111], "MWL": [55.001529323712575, -1.4659424709549598], "NSH": [55.00840630699378, -1.4485778014421224], "TYN": [55.017274418617184, -1.4282651033617741], "CUL": [55.03511093879241, -1.4365420359749002], "WTL": [55.039919289125834, -1.4429108035106921], "MSN": [55.04230033202882, -1.45816343817505], "WMN": [55.04027612958929, -1.4767711487297275], "SMR": [55.03689435582033, -1.504929962316373], "NPK": [55.03308042684014, -1.5196901723469216], "PMV": [55.02386950238466, -1.5406811448399973], "BTN": [55.013811103383524, -1.567622747731078], "FLE": [55.01011405563572, -1.5786328955533446], "LBN": [55.00873413367473, -1.5914776477978119], "HEB": [54.97561399582167, -1.520598379333017], "JAR": [54.97942548654144, -1.4938358063276782], "BDE": [54.97439112480099, -1.4660181960559018], "SMD": [54.971690254811655, -1.4553294024831502], "TDK": [54.976276978091505, -1.4415400265511848], "CHI": [54.98635066064746, -1.4318251855177448], "SSS": [54.99763337335481, -1.433002848691932]}

79
station-positions.json Normal file
View file

@ -0,0 +1,79 @@
{
"green": [
{"map": [51.80868911743164, 44.98532485961914], "latlon": [55.03577004909116, -1.7111265174504924]},
{"map": [71.1099624633789, 44.98532485961914], "latlon": [55.02800273311085, -1.7036043935197853]},
{"map": [90.9191665649414, 44.477394104003906], "latlon": [55.013882728284536, -1.6781041455925452]},
{"map": [109.71251678466797, 44.98532485961914], "latlon": [55.01438146469378, -1.6652818995828114]},
{"map": [129.0137939453125, 44.98532485961914], "latlon": [55.013643304033884, -1.644878111966044]},
{"map": [148.822998046875, 44.477394104003906], "latlon": [55.01415869459053, -1.6350085965165628]},
{"map": [167.61634826660156, 44.98532485961914], "latlon": [55.011944179047475, -1.6217731502181474]},
{"map": [181.33041381835938, 54.12803268432617], "latlon": [55.00606753485614, -1.6082308131224372]},
{"map": [187.42555236816406, 65.30245971679688], "latlon": [55.000116635459875, -1.6112647564719849]},
{"map": [193.52069091796875, 76.98480987548828], "latlon": [54.99361592984736, -1.6098224294795975]},
{"map": [200.63168334960938, 87.65130615234375], "latlon": [54.982874914767116, -1.605933105358663]},
{"map": [206.21888732910156, 98.31779479980469], "latlon": [54.97752938692041, -1.6139804105070166]},
{"map": [214.8536834716797, 110.5080795288086], "latlon": [54.973881889528585, -1.6132900680751794]},
{"map": [221.9646759033203, 124.73007202148438], "latlon": [54.96909836537041, -1.6163186051663263]},
{"map": [254.4720916748047, 152.66612243652344], "latlon": [54.969052114768495, -1.6164731976943978]},
{"map": [282.91607666015625, 152.66612243652344], "latlon": [54.96188025721301, -1.6041557776219313]},
{"map": [309.8362731933594, 152.66612243652344], "latlon": [54.957693981514865, -1.5893810383609956]},
{"map": [339.8040466308594, 152.66612243652344], "latlon": [54.95309923554773, -1.5722545639671113]},
{"map": [364.9465026855469, 152.158203125], "latlon": [54.95151785157374, -1.555949157488558]},
{"map": [396.94598388671875, 173.49119567871094], "latlon": [54.95263065551402, -1.5420148963164952]},
{"map": [434.0247497558594, 173.49119567871094], "latlon": [54.957372994523446, -1.4860452243342233]},
{"map": [466.53216552734375, 173.49119567871094], "latlon": [54.959403577124995, -1.4612794918514254]},
{"map": [517.8328857421875, 182.1259765625], "latlon": [54.946410152193415, -1.420438895595007]},
{"map": [518.3408203125, 191.77662658691406], "latlon": [54.92919863560131, -1.3864468319158785]},
{"map": [518.3408203125, 203.45896911621094], "latlon": [54.91811279473953, -1.3830167967642568]},
{"map": [518.8487548828125, 226.01097106933594], "latlon": [54.91100033399111, -1.3842235196597212]},
{"map": [509.7060546875, 239.21710205078125], "latlon": [54.90232769869331, -1.3848277824608908]},
{"map": [484.81756591796875, 239.21710205078125], "latlon": [54.90289486139476, -1.3923841760816358]},
{"map": [459.4211730957031, 239.21710205078125], "latlon": [54.90651021486448, -1.4008007892610852]},
{"map": [434.0247497558594, 239.21710205078125], "latlon": [54.91279641288439, -1.4178747825280822]},
{"map": [408.6283264160156, 238.7091827392578], "latlon": [54.90420705667461, -1.4479615115961655]}
],
"yellow": [
{"map": [199.61582946777344, 111.52393341064453], "latlon": [54.97421054355723, -1.6210669283330124]},
{"map": [218.4091796875, 111.52393341064453], "latlon": [54.97384152715182, -1.6131521777521154]},
{"map": [267.67822265625, 111.52393341064453], "latlon": [54.974003328855375, -1.6048376504104354]},
{"map": [294.0904846191406, 112.0318603515625], "latlon": [54.97612749380666, -1.5799477880096582]},
{"map": [320.5027770996094, 111.01600646972656], "latlon": [54.98285607730816, -1.5719087070215574]},
{"map": [347.5245666503906, 111.01600646972656], "latlon": [54.98545678531661, -1.559210279263152]},
{"map": [374.44476318359375, 111.52393341064453], "latlon": [54.98953748564867, -1.5325426692860964]},
{"map": [400.8570251464844, 111.52393341064453], "latlon": [54.99225461830697, -1.515811836724129]},
{"map": [428.28515625, 111.52393341064453], "latlon": [54.99569609087364, -1.4945280145610538]},
{"map": [455.7132873535156, 111.01600646972656], "latlon": [54.99947831652944, -1.4751452540962111]},
{"map": [482.63348388671875, 111.52393341064453], "latlon": [55.001529323712575, -1.4659424709549598]},
{"map": [508.537841796875, 111.52393341064453], "latlon": [55.00840630699378, -1.4485778014421224]},
{"map": [518.6964111328125, 98.82572174072266], "latlon": [55.017274418617184, -1.4282651033617741]},
{"map": [518.1884765625, 78.00066375732422], "latlon": [55.03511093879241, -1.4365420359749002]},
{"map": [518.6964111328125, 57.175601959228516], "latlon": [55.039919289125834, -1.4429108035106921]},
{"map": [485.1731262207031, 44.477394104003906], "latlon": [55.04230033202882, -1.45816343817505]},
{"map": [447.0784912109375, 43.96946716308594], "latlon": [55.04027612958929, -1.4767711487297275]},
{"map": [408.98388671875, 43.96946716308594], "latlon": [55.03689435582033, -1.504929962316373]},
{"map": [370.3813171386719, 44.477394104003906], "latlon": [55.03308042684014, -1.5196901723469216]},
{"map": [331.2708435058594, 43.46154022216797], "latlon": [55.02386950238466, -1.5406811448399973]},
{"map": [293.1762390136719, 43.96946716308594], "latlon": [55.013811103383524, -1.567622747731078]},
{"map": [254.87841796875, 43.96946716308594], "latlon": [55.01011405563572, -1.5786328955533446]},
{"map": [216.27586364746094, 43.96946716308594], "latlon": [55.00873413367473, -1.5914776477978119]},
{"map": [186.30810546875, 54.12803268432617], "latlon": [55.00614726487779, -1.6082661067096817]},
{"map": [191.3873748779297, 64.79452514648438], "latlon": [55.00023693236148, -1.6108770317154648]},
{"map": [197.99044799804688, 76.98480987548828], "latlon": [54.99364830600748, -1.6098926355353478]},
{"map": [204.59352111816406, 87.14337158203125], "latlon": [54.982881393953804, -1.6060319576069106]},
{"map": [210.68865966796875, 97.80986785888672], "latlon": [54.97749613672549, -1.6140033882511216]},
{"map": [218.8155059814453, 112.0318603515625], "latlon": [54.973896759913636, -1.6132420712024815]},
{"map": [225.926513671875, 124.22213745117188], "latlon": [54.96915879926773, -1.6162313682444793]},
{"map": [254.37049865722656, 148.6027069091797], "latlon": [54.96180675090266, -1.6040450471579535]},
{"map": [283.830322265625, 147.5868377685547], "latlon": [54.957727365407564, -1.5886927377600415]},
{"map": [309.73468017578125, 147.5868377685547], "latlon": [54.95315392240506, -1.5720446784266642]},
{"map": [339.70245361328125, 147.5868377685547], "latlon": [54.9515340871179, -1.5555837237871162]},
{"map": [365.098876953125, 148.6027069091797], "latlon": [54.95254705425207, -1.5420476251169053]},
{"map": [391.10479736328125, 148.0947723388672], "latlon": [54.97561399582167, -1.520598379333017]},
{"map": [409.8981628417969, 148.0947723388672], "latlon": [54.97942548654144, -1.4938358063276782]},
{"map": [429.7073669433594, 148.0947723388672], "latlon": [54.97439112480099, -1.4660181960559018]},
{"map": [449.5165710449219, 147.5868377685547], "latlon": [54.971690254811655, -1.4553294024831502]},
{"map": [469.83367919921875, 148.0947723388672], "latlon": [54.976276978091505, -1.4415400265511848]},
{"map": [489.1349792480469, 148.0947723388672], "latlon": [54.98635066064746, -1.4318251855177448]},
{"map": [509.96002197265625, 148.0947723388672], "latlon": [54.99763337335481, -1.433002848691932]}
]
}

63
stations.json Normal file
View file

@ -0,0 +1,63 @@
{
"APT":"Airport",
"BDE":"Bede",
"BFT":"Bank Foot",
"BTN":"Benton",
"BYK":"Byker",
"BYW":"Brockley Whins",
"CAL":"Callerton Parkway",
"CEN":"Central",
"CHI":"Chichester",
"CRD":"Chillingham Road",
"CUL":"Cullercoats",
"EBO":"East Boldon",
"FAW":"Fawdon",
"FEL":"Felling",
"FGT":"Fellgate",
"FLE":"Four Lane Ends",
"GHD":"Gateshead",
"GST":"Gateshead Stadium",
"HAY":"Haymarket",
"HDR":"Hadrian Road",
"HEB":"Hebburn",
"HOW":"Howdon",
"HTH":"Heworth",
"ILF":"Ilford Road",
"JAR":"Jarrow",
"JES":"Jesmond",
"KSP":"Kingston Park",
"LBN":"Longbenton",
"MAN":"Manors",
"MLF":"Millfield",
"MSN":"Monkseaton",
"MSP":"St Peters",
"MTS":"Monument N-S",
"MTW":"Monument W-E",
"MWL":"Meadow Well",
"NPK":"Northumberland Park",
"NSH":"North Shields",
"PAL":"Pallion",
"PCM":"Percy Main",
"PLI":"Park Lane",
"PLW":"Pelaw",
"PMV":"Palmersville",
"RGC":"Regent Centre",
"SBN":"Seaburn",
"SFC":"Stadium of Light",
"SGF":"South Gosforth",
"SHL":"South Hylton",
"SJM":"St James",
"SMD":"Simonside",
"SMR":"Shiremoor",
"SSS":"South Shields",
"SUN":"Sunderland",
"TDK":"Tyne Dock",
"TYN":"Tynemouth",
"UNI":"University",
"WBR":"Wansbeck Road",
"WJS":"West Jesmond",
"WKG":"Walkergate",
"WMN":"West Monkseaton",
"WSD":"Wallsend",
"WTL":"Whitley Bay"
}

148
style.css Normal file
View file

@ -0,0 +1,148 @@
:root {
--brand-color: #feb300;
--bg-color: white;
--text-color: black;
--link-color: #551a8b;
}
@font-face {
font-family: 'Sanchez';
src: local('Calvert'),
url('Sanchez-Regular.otf') format('opentype');
}
@media (prefers-color-scheme: dark) {
:root {
--bg-color: black;
--text-color: white;
--link-color: #6666ff;
}
#map-container {
filter: invert(100%) hue-rotate(180deg);
}
}
input, select {
font-family: inherit;
font-size: 1em;
}
html {
font-size: min(16px,4svw);
font-family: Sanchez, sans-serif;
}
body {
margin: 0;
background-color: var(--bg-color);
color: var(--text-color);
line-height: 1.5;
}
a {
color: var(--link-color);
}
#controls {
display: flex;
gap: 0.5em;
color: black;
flex-wrap: wrap;
justify-content: space-evenly;
font-size: 1.5rem;
background: var(--brand-color);
padding: 0.5em;
font-weight: bold;
& :is(select, input, button) {
font-size: 1em;
}
& select {
background: white;
border: thin solid black;
}
& button {
background: none;
border: none;
cursor: pointer;
&:active {
opacity: 0.2;
}
}
& a {
color: inherit;
}
}
.platforms {
display: flex;
flex-wrap: wrap;
gap: 4em;
list-style: none;
justify-content: center;
& table {
width: 36em;
max-width: 95svw;
font-variant: tabular-nums;
border-collapse: collapse;
& caption {
font-size: 1.1em;
font-weight: bold;
padding-bottom: 0.5rem;
& .last-checked {
margin-left: 1em;
}
}
& tr:not(:first-child) {
border-top: thin dashed currentColor;
}
& :is(td,th) {
padding: 0.2em 0.5em;
border: thin dashed currentColor;
}
& td {
text-align: end;
}
& th {
text-align: center;
border-bottom-width: medium;
border-bottom-style: solid;
}
}
padding-left: 0;
}
#map-container {
overflow: auto;
background: white;
}
#map {
min-width: 1000px;
}
footer {
margin: 5em 1em 0 1em;
}
@media (max-width: 37em) {
#controls {
font-size: 1.2rem;
}
table :is(td,th):is(:nth-child(4),:nth-child(5)) {
display: none;
}
}