commit 3f68f9ae72e1e51b44f135bd2dd578e24d77c723 Author: Christian Lawson-Perfect Date: Sun Feb 9 20:35:44 2025 +0000 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bfcec8d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.make.* +app.js +elm-stuff/ +error.txt +platform_data/ \ No newline at end of file diff --git a/.watchmakerc b/.watchmakerc new file mode 100644 index 0000000..285f521 --- /dev/null +++ b/.watchmakerc @@ -0,0 +1,2 @@ +extensions: + - .elm \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..092ea03 --- /dev/null +++ b/Makefile @@ -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)" \ No newline at end of file diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..4c486ea --- /dev/null +++ b/README.txt @@ -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. \ No newline at end of file diff --git a/Sanchez-Regular.otf b/Sanchez-Regular.otf new file mode 100644 index 0000000..6cbd80f Binary files /dev/null and b/Sanchez-Regular.otf differ diff --git a/Sanchez-Regular.ttf b/Sanchez-Regular.ttf new file mode 100644 index 0000000..3c6c134 Binary files /dev/null and b/Sanchez-Regular.ttf differ diff --git a/cgi-bin/platform_data.py b/cgi-bin/platform_data.py new file mode 100755 index 0000000..b1a1c14 --- /dev/null +++ b/cgi-bin/platform_data.py @@ -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()) \ No newline at end of file diff --git a/elm.json b/elm.json new file mode 100644 index 0000000..3bcf1ee --- /dev/null +++ b/elm.json @@ -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": {} + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..959fccf --- /dev/null +++ b/index.html @@ -0,0 +1,25 @@ + + + + + + Metro info + + + + + + +
+

Elm app by clp

+
+
+

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.

+

On balance of probabilities: I'm sorry I couldn't be bothered to make this work for you.

+
+ + + + + + \ No newline at end of file diff --git a/lines.json b/lines.json new file mode 100644 index 0000000..ec7cee3 --- /dev/null +++ b/lines.json @@ -0,0 +1 @@ +{"GREEN": [{"key": "APT", "map_position": [51.80868911743164, 44.98532485961914], "latlon": [55.03577004909116, -1.7111265174504924]}, {"key": "CAL", "map_position": [71.1099624633789, 44.98532485961914], "latlon": [55.02800273311085, -1.7036043935197853]}, {"key": "BFT", "map_position": [90.9191665649414, 44.477394104003906], "latlon": [55.013882728284536, -1.6781041455925452]}, {"key": "KSP", "map_position": [109.71251678466797, 44.98532485961914], "latlon": [55.01438146469378, -1.6652818995828114]}, {"key": "FAW", "map_position": [129.0137939453125, 44.98532485961914], "latlon": [55.013643304033884, -1.644878111966044]}, {"key": "WBR", "map_position": [148.822998046875, 44.477394104003906], "latlon": [55.01415869459053, -1.6350085965165628]}, {"key": "RGC", "map_position": [167.61634826660156, 44.98532485961914], "latlon": [55.011944179047475, -1.6217731502181474]}, {"key": "SGF", "map_position": [181.33041381835938, 54.12803268432617], "latlon": [55.00606753485614, -1.6082308131224372]}, {"key": "ILF", "map_position": [187.42555236816406, 65.30245971679688], "latlon": [55.000116635459875, -1.6112647564719849]}, {"key": "WJS", "map_position": [193.52069091796875, 76.98480987548828], "latlon": [54.99361592984736, -1.6098224294795975]}, {"key": "JES", "map_position": [200.63168334960938, 87.65130615234375], "latlon": [54.982874914767116, -1.605933105358663]}, {"key": "HAY", "map_position": [206.21888732910156, 98.31779479980469], "latlon": [54.97752938692041, -1.6139804105070166]}, {"key": "MTS", "map_position": [214.8536834716797, 110.5080795288086], "latlon": [54.973881889528585, -1.6132900680751794]}, {"key": "CEN", "map_position": [221.9646759033203, 124.73007202148438], "latlon": [54.96909836537041, -1.6163186051663263]}, {"key": "GHD", "map_position": [254.4720916748047, 152.66612243652344], "latlon": [54.969052114768495, -1.6164731976943978]}, {"key": "GST", "map_position": [282.91607666015625, 152.66612243652344], "latlon": [54.96188025721301, -1.6041557776219313]}, {"key": "FEL", "map_position": [309.8362731933594, 152.66612243652344], "latlon": [54.957693981514865, -1.5893810383609956]}, {"key": "HTH", "map_position": [339.8040466308594, 152.66612243652344], "latlon": [54.95309923554773, -1.5722545639671113]}, {"key": "PLW", "map_position": [364.9465026855469, 152.158203125], "latlon": [54.95151785157374, -1.555949157488558]}, {"key": "FGT", "map_position": [396.94598388671875, 173.49119567871094], "latlon": [54.95263065551402, -1.5420148963164952]}, {"key": "BYW", "map_position": [434.0247497558594, 173.49119567871094], "latlon": [54.957372994523446, -1.4860452243342233]}, {"key": "EBO", "map_position": [466.53216552734375, 173.49119567871094], "latlon": [54.959403577124995, -1.4612794918514254]}, {"key": "SBN", "map_position": [517.8328857421875, 182.1259765625], "latlon": [54.946410152193415, -1.420438895595007]}, {"key": "SFC", "map_position": [518.3408203125, 191.77662658691406], "latlon": [54.92919863560131, -1.3864468319158785]}, {"key": "MSP", "map_position": [518.3408203125, 203.45896911621094], "latlon": [54.91811279473953, -1.3830167967642568]}, {"key": "SUN", "map_position": [518.8487548828125, 226.01097106933594], "latlon": [54.91100033399111, -1.3842235196597212]}, {"key": "PLI", "map_position": [509.7060546875, 239.21710205078125], "latlon": [54.90232769869331, -1.3848277824608908]}, {"key": "UNI", "map_position": [484.81756591796875, 239.21710205078125], "latlon": [54.90289486139476, -1.3923841760816358]}, {"key": "MLF", "map_position": [459.4211730957031, 239.21710205078125], "latlon": [54.90651021486448, -1.4008007892610852]}, {"key": "PAL", "map_position": [434.0247497558594, 239.21710205078125], "latlon": [54.91279641288439, -1.4178747825280822]}, {"key": "SHL", "map_position": [408.6283264160156, 238.7091827392578], "latlon": [54.90420705667461, -1.4479615115961655]}], "YELLOW": [{"key": "SJM", "map_position": [199.61582946777344, 111.52393341064453], "latlon": [54.97421054355723, -1.6210669283330124]}, {"key": "MTW", "map_position": [218.4091796875, 111.52393341064453], "latlon": [54.97384152715182, -1.6131521777521154]}, {"key": "MAN", "map_position": [267.67822265625, 111.52393341064453], "latlon": [54.974003328855375, -1.6048376504104354]}, {"key": "BYK", "map_position": [294.0904846191406, 112.0318603515625], "latlon": [54.97612749380666, -1.5799477880096582]}, {"key": "CRD", "map_position": [320.5027770996094, 111.01600646972656], "latlon": [54.98285607730816, -1.5719087070215574]}, {"key": "WKG", "map_position": [347.5245666503906, 111.01600646972656], "latlon": [54.98545678531661, -1.559210279263152]}, {"key": "WSD", "map_position": [374.44476318359375, 111.52393341064453], "latlon": [54.98953748564867, -1.5325426692860964]}, {"key": "HDR", "map_position": [400.8570251464844, 111.52393341064453], "latlon": [54.99225461830697, -1.515811836724129]}, {"key": "HOW", "map_position": [428.28515625, 111.52393341064453], "latlon": [54.99569609087364, -1.4945280145610538]}, {"key": "PCM", "map_position": [455.7132873535156, 111.01600646972656], "latlon": [54.99947831652944, -1.4751452540962111]}, {"key": "MWL", "map_position": [482.63348388671875, 111.52393341064453], "latlon": [55.001529323712575, -1.4659424709549598]}, {"key": "NSH", "map_position": [508.537841796875, 111.52393341064453], "latlon": [55.00840630699378, -1.4485778014421224]}, {"key": "TYN", "map_position": [518.6964111328125, 98.82572174072266], "latlon": [55.017274418617184, -1.4282651033617741]}, {"key": "CUL", "map_position": [518.1884765625, 78.00066375732422], "latlon": [55.03511093879241, -1.4365420359749002]}, {"key": "WTL", "map_position": [518.6964111328125, 57.175601959228516], "latlon": [55.039919289125834, -1.4429108035106921]}, {"key": "MSN", "map_position": [485.1731262207031, 44.477394104003906], "latlon": [55.04230033202882, -1.45816343817505]}, {"key": "WMN", "map_position": [447.0784912109375, 43.96946716308594], "latlon": [55.04027612958929, -1.4767711487297275]}, {"key": "SMR", "map_position": [408.98388671875, 43.96946716308594], "latlon": [55.03689435582033, -1.504929962316373]}, {"key": "NPK", "map_position": [370.3813171386719, 44.477394104003906], "latlon": [55.03308042684014, -1.5196901723469216]}, {"key": "PMV", "map_position": [331.2708435058594, 43.46154022216797], "latlon": [55.02386950238466, -1.5406811448399973]}, {"key": "BTN", "map_position": [293.1762390136719, 43.96946716308594], "latlon": [55.013811103383524, -1.567622747731078]}, {"key": "FLE", "map_position": [254.87841796875, 43.96946716308594], "latlon": [55.01011405563572, -1.5786328955533446]}, {"key": "LBN", "map_position": [216.27586364746094, 43.96946716308594], "latlon": [55.00873413367473, -1.5914776477978119]}, {"key": "SGF", "map_position": [186.30810546875, 54.12803268432617], "latlon": [55.00614726487779, -1.6082661067096817]}, {"key": "ILF", "map_position": [191.3873748779297, 64.79452514648438], "latlon": [55.00023693236148, -1.6108770317154648]}, {"key": "WJS", "map_position": [197.99044799804688, 76.98480987548828], "latlon": [54.99364830600748, -1.6098926355353478]}, {"key": "JES", "map_position": [204.59352111816406, 87.14337158203125], "latlon": [54.982881393953804, -1.6060319576069106]}, {"key": "HAY", "map_position": [210.68865966796875, 97.80986785888672], "latlon": [54.97749613672549, -1.6140033882511216]}, {"key": "MTS", "map_position": [218.8155059814453, 112.0318603515625], "latlon": [54.973896759913636, -1.6132420712024815]}, {"key": "CEN", "map_position": [225.926513671875, 124.22213745117188], "latlon": [54.96915879926773, -1.6162313682444793]}, {"key": "GHD", "map_position": [254.37049865722656, 148.6027069091797], "latlon": [54.96180675090266, -1.6040450471579535]}, {"key": "GST", "map_position": [283.830322265625, 147.5868377685547], "latlon": [54.957727365407564, -1.5886927377600415]}, {"key": "FEL", "map_position": [309.73468017578125, 147.5868377685547], "latlon": [54.95315392240506, -1.5720446784266642]}, {"key": "HTH", "map_position": [339.70245361328125, 147.5868377685547], "latlon": [54.9515340871179, -1.5555837237871162]}, {"key": "PLW", "map_position": [365.098876953125, 148.6027069091797], "latlon": [54.95254705425207, -1.5420476251169053]}, {"key": "HEB", "map_position": [391.10479736328125, 148.0947723388672], "latlon": [54.97561399582167, -1.520598379333017]}, {"key": "JAR", "map_position": [409.8981628417969, 148.0947723388672], "latlon": [54.97942548654144, -1.4938358063276782]}, {"key": "BDE", "map_position": [429.7073669433594, 148.0947723388672], "latlon": [54.97439112480099, -1.4660181960559018]}, {"key": "SMD", "map_position": [449.5165710449219, 147.5868377685547], "latlon": [54.971690254811655, -1.4553294024831502]}, {"key": "TDK", "map_position": [469.83367919921875, 148.0947723388672], "latlon": [54.976276978091505, -1.4415400265511848]}, {"key": "CHI", "map_position": [489.1349792480469, 148.0947723388672], "latlon": [54.98635066064746, -1.4318251855177448]}, {"key": "SSS", "map_position": [509.96002197265625, 148.0947723388672], "latlon": [54.99763337335481, -1.433002848691932]}]} \ No newline at end of file diff --git a/load-app.js b/load-app.js new file mode 100644 index 0000000..c00b567 --- /dev/null +++ b/load-app.js @@ -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(); \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..b476133 --- /dev/null +++ b/manifest.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/metro_logo.png b/metro_logo.png new file mode 100644 index 0000000..0c6bc28 Binary files /dev/null and b/metro_logo.png differ diff --git a/metro_logo.svg b/metro_logo.svg new file mode 100644 index 0000000..edff2a7 --- /dev/null +++ b/metro_logo.svg @@ -0,0 +1,55 @@ + + + + + + + + + diff --git a/metro_logo_192.png b/metro_logo_192.png new file mode 100644 index 0000000..672c83c Binary files /dev/null and b/metro_logo_192.png differ diff --git a/metro_map.svg b/metro_map.svg new file mode 100644 index 0000000..3712e34 --- /dev/null +++ b/metro_map.svg @@ -0,0 +1,7504 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/platforms.json b/platforms.json new file mode 100644 index 0000000..c3005f5 --- /dev/null +++ b/platforms.json @@ -0,0 +1 @@ +{"APT":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport"}],"BDE":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James via Whitley Bay"}],"BFT":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport"}],"BTN":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James via Whitley Bay"}],"BYK":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James"}],"BYW":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport"}],"CAL":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport"}],"CEN":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton and South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport and St. James via Whitley Bay"}],"CHI":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James via Whitley Bay"}],"CRD":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James"}],"CUL":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James"}],"EBO":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport"}],"FAW":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport"}],"FEL":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton and South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport and St. James via Whitley Bay"}],"FGT":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport"}],"FLE":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James via Whitley Bay"}],"GHD":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton and South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport and St. James via Whitley Bay"}],"GST":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton and South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport and St. James via Whitley Bay"}],"HAY":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton and South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport and St. James via Whitley Bay"}],"HDR":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James"}],"HEB":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James via Whitley Bay"}],"HOW":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James"}],"HTH":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton and South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport and St. James via Whitley Bay"}],"ILF":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton and South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport and St. James via Whitley Bay"}],"JAR":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James via Whitley Bay"}],"JES":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton and South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport and St. James via Whitley Bay"}],"KSP":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport"}],"LBN":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James via Whitley Bay"}],"MAN":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James"}],"MLF":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport"}],"MSN":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James via Whitley Bay"}],"MSP":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport"}],"MTS":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton and South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport and St. James via Whitley Bay"}],"MTW":[{"platformNumber":3,"direction":"IN","helperText":"Towards South Shields via Whitley Bay"},{"platformNumber":4,"direction":"OUT","helperText":"Towards St. James"}],"MWL":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James"}],"NPK":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James via Whitley Bay"}],"NSH":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James"}],"PAL":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport"}],"PCM":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James"}],"PLI":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport"}],"PLW":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton and South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport and St. James via Whitley Bay"}],"PMV":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James via Whitley Bay"}],"RGC":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport"}],"SBN":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport"}],"SFC":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport"}],"SGF":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton and South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport and St. James via Whitley Bay"}],"SHL":[{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport"}],"SJM":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards South Shields"}],"SMD":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James via Whitley Bay"}],"SMR":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James via Whitley Bay"}],"SSS":[{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James via Whitley Bay"}],"SUN":[{"platformNumber":2,"direction":"IN","helperText":"Towards South Hylton"},{"platformNumber":3,"direction":"OUT","helperText":"Towards Airport"}],"TDK":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James via Whitley Bay"}],"TYN":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James"}],"UNI":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport"}],"WBR":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport"}],"WJS":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Hylton and South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards Airport and St. James via Whitley Bay"}],"WKG":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James"}],"WMN":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James via Whitley Bay"}],"WSD":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James"}],"WTL":[{"platformNumber":1,"direction":"IN","helperText":"Towards South Shields"},{"platformNumber":2,"direction":"OUT","helperText":"Towards St. James"}]} \ No newline at end of file diff --git a/show-error.mjs b/show-error.mjs new file mode 100644 index 0000000..0c9d53e --- /dev/null +++ b/show-error.mjs @@ -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); diff --git a/src/App.elm b/src/App.elm new file mode 100644 index 0000000..c6589c5 --- /dev/null +++ b/src/App.elm @@ -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) \ No newline at end of file diff --git a/src/LatLonDistance.elm b/src/LatLonDistance.elm new file mode 100644 index 0000000..aea69bf --- /dev/null +++ b/src/LatLonDistance.elm @@ -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 \ No newline at end of file diff --git a/station-directions.json b/station-directions.json new file mode 100644 index 0000000..fe7bf4d --- /dev/null +++ b/station-directions.json @@ -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} +} \ No newline at end of file diff --git a/station-latlons.json b/station-latlons.json new file mode 100644 index 0000000..257b28d --- /dev/null +++ b/station-latlons.json @@ -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]} \ No newline at end of file diff --git a/station-positions.json b/station-positions.json new file mode 100644 index 0000000..fdee053 --- /dev/null +++ b/station-positions.json @@ -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]} + ] +} \ No newline at end of file diff --git a/stations.json b/stations.json new file mode 100644 index 0000000..a6bab4f --- /dev/null +++ b/stations.json @@ -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" +} \ No newline at end of file diff --git a/style.css b/style.css new file mode 100644 index 0000000..49a7716 --- /dev/null +++ b/style.css @@ -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; + } +} \ No newline at end of file