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

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