diff --git a/config.go b/config.go index 23d0695..e22ca68 100644 --- a/config.go +++ b/config.go @@ -9,24 +9,64 @@ var serverPort = 8080 var screenDir = "w" -// which routes do you want to show on the screen? add as many or as few as you'd like. -var routes = []Route{ - Route{ - Id: 15280, - Name: "46 WB", - Dir: "n", +// which routes do you want to show on the screen? check out readme.md for an explanation +var query = []Group { + Group { + Name: "34th & 45th Street", + Stations: []Station { + Station { + Id: 15280, + Routes: []Route { + Route { // 46 westbound + Id: 46, + Dir: "Edina", + }, + }, + }, + Station { + Id: 15412, + Routes: []Route { + Route { // 46 eastbound + Id: 46, + Dir: "St Paul - 7th St", + }, + }, + }, + }, }, - Route{ - Id: 15412, - Name: "46 EB", - Dir: "s", - }, - Route{ - Id: 51430, - Name: "Blue line", - }, - Route{ - Id: 51415, - Name: "Blue line", + Group { + Name: "46th Street Station", + Stations: []Station { + Station { + Id: 51430, + Routes: []Route { + Route { // blue line northbound + Id: 901, + Name: "Blue line", + Dir: "Downtown Mpls", + }, + }, + }, + Station { + Id: 51415, + Routes: []Route { + Route { // blue line southbound + Id: 901, + Name: "Blue line", + Dir: "Airport / Mall", + }, + }, + }, + + Station { + Id: 51546, + Routes: []Route { + Route { Id: 7 }, + Route { Id: 9 }, + Route { Id: 74 }, + Route { Id: 921 }, // A line + }, + }, + }, }, } diff --git a/main.go b/main.go index 040f39e..5ba7f1e 100644 --- a/main.go +++ b/main.go @@ -6,80 +6,89 @@ import ( "log" "math" "net/http" + "strconv" "time" ) +// configuration query format +type Group struct { // list related stops under a section title + Name string // friendly readable name for this group + Stations []Station +} +type Station struct { + Id uint // number found on the sign or on nextrip / g00gle maps + Routes []Route +} type Route struct { - Id uint // stop ID shown on sign or google maps - Name string // friendly readable name (optional) - Dir string // cardinal direction the bus or train is going (optional, will use value from API) - // Route string // filter by route name (optional) /!\ not yet implemented /!\ + Id uint // route number (may need to dig through the API for this one) + Name string // friendly readable name (optional) + Dir string // direction or location it's heading (optional) } +// response format +type EtaList struct { + Name string `json:"name"` + Etas []Eta `json:"etas"` +} type Eta struct { - Name string `json:"name"` - Dir string `json:"dir"` - Class string `json:"class"` - Delta string `json:"delta"` -} -var NilEta = Eta{} - -type StrSlice []string -func (slice StrSlice) indexOf(value string) int { - for p, v := range slice { - if (v == value) { - return p - } - } - return -1 + Name string `json:"name"` + Heading string `json:"heading"` + Class string `json:"class"` + Delta string `json:"delta"` } +// TODO? arrows -var cardinals = StrSlice{"n","e","s","w"} -var bounds = StrSlice{"NB","EB","SB","WB"} -var arrows = StrSlice{"↑","->","↓","<-"} -func arrow(dir string) string { - var format *StrSlice - if len(dir) < 2 { - format = &cardinals - } else { - format = &bounds - } - going := (format.indexOf(dir) + cardinals.indexOf(screenDir)) % 4 - return arrows[going] -} +// type StrSlice []string +// func (slice StrSlice) indexOf(value string) int { +// for p, v := range slice { +// if (v == value) { +// return p +// } +// } +// return -1 +// } + +// var cardinals = StrSlice{"n","e","s","w"} +// var bounds = StrSlice{"NB","EB","SB","WB"} +// var arrows = StrSlice{"↑","->","↓","<-"} +// func arrow(dir string) string { +// var format *StrSlice +// if len(dir) < 2 { +// format = &cardinals +// } else { +// format = &bounds +// } +// going := (format.indexOf(dir) + cardinals.indexOf(screenDir)) % 4 +// return arrows[going] +// } -func (r Route) getETA() Eta { - d := fetchRouteData(r.Id) +func (d Departure) parseDeparture(name, dir string) Eta { e := Eta{} - if len(d.Departures) == 0 { - return e - } - next := d.Departures[0] // route name - if r.Name == "" { - e.Name = next.RouteShortName + if name == "" { + e.Name = d.RouteShortName } else { - e.Name = r.Name + e.Name = name } - // direction arrow - if r.Dir == "" { - e.Dir = arrow(next.DirectionText) + // direction + if dir == "" { + e.Heading = d.Heading } else { - e.Dir = arrow(r.Dir) + e.Heading = dir } // time delta - t := time.Unix(next.DepartureTime, 0) + t := time.Unix(d.DepartureTime, 0) dur := time.Until(t) if dur.Minutes() <= 1 { e.Delta = "Due" } else if dur.Hours() < 60 { - e.Delta = fmt.Sprintf("%.0f minutes", dur.Minutes()) + e.Delta = fmt.Sprintf("%.0f min", dur.Minutes()) } else { e.Delta = fmt.Sprintf("%.0fh %.0fm", dur.Hours(), dur.Minutes() - (math.Floor(dur.Hours())*60)) } @@ -95,6 +104,77 @@ func (r Route) getETA() Eta { return e } +func (s Station) getETAs() []Eta { + r := fetchRouteData(s.Id) + e := []Eta{} + if len(r.Departures) == 0 { + return e + } + + if len(s.Routes) == 0 { + e = append(e, r.Departures[0].parseDeparture("","")) + } else { + // create a map for storing an ETA for each requested route + re := make(map[uint]Eta) + for _, reqRoute := range s.Routes { + // loop through r.Departures + for _, d := range r.Departures { + // if departure's route id matches the requested one, + dId, _ := strconv.Atoi(d.RouteId) + // and an ETA hasn't been found yet, + _, has := re[reqRoute.Id] + if dId == int(reqRoute.Id) && !has { + // save it to the map. + re[reqRoute.Id] = d.parseDeparture(reqRoute.Name, reqRoute.Dir) + } + } + } + // convert map to array in the order specified in config + for _, r := range s.Routes { + d, exists := re[r.Id] + if exists { + e = append(e, d) + } + } + } + + return e +} + + +func api(response http.ResponseWriter, request *http.Request){ + maxAge, _ := time.ParseDuration("29s") + fmt.Fprint(response, getCache(maxAge, func() string { + stations := []EtaList{} + + for _, group := range query { + etas := []Eta{} + for _, station := range group.Stations { + etas = append(etas, station.getETAs()...) + } + + stations = append(stations, EtaList{group.Name, etas}) + } + + data, err := json.Marshal(stations) + if err != nil { + return "[]" + } else { + return string(data) + } + })) +} + +var cacheValue string +var cacheUpdated time.Time +func getCache(maxAge time.Duration, f func() string) string { + if time.Since(cacheUpdated) >= maxAge { + cacheValue = f() + cacheUpdated = time.Now() + } + return cacheValue +} + func main(){ http.Handle("/", http.FileServer(http.Dir("./static"))) @@ -104,39 +184,3 @@ func main(){ log.Fatal(err) } } - -func api(response http.ResponseWriter, request *http.Request){ - var etas []Eta - - for _, route := range routes { - eta := route.getETA() - if eta != NilEta { - etas = append(etas, eta) - } - } - - data, err := json.Marshal(etas) - if err != nil { - fmt.Fprint(response, "[]") - } else { - fmt.Fprint(response, string(data)) - } -} - -// TODO: cache the ETA list - -// let etaCache = { -// value: null, -// createdAt: 0, -// } -// let getETAsFromCache = async (maxAge) => { -// if (Date.now() / 1000 - etaCache.createdAt >= maxAge) { -// etaCache.value = [] -// for (let r of conf.routes) { -// let e = await getETA(r) -// if (e !== null) etaCache.value.push(e) -// } -// etaCache.createdAt = Date.now() / 1000 -// } -// return etaCache.value -// } diff --git a/nextrip.go b/nextrip.go index 17e1dbd..094d19a 100644 --- a/nextrip.go +++ b/nextrip.go @@ -7,6 +7,7 @@ import ( "net/http" ) + // type Stop struct { // stop_id uint // latitude float32 @@ -23,11 +24,11 @@ type Departure struct { // stop_id uint DepartureText string `json:"departure_text"` DepartureTime int64 `json:"departure_time"` - // description string - // route_id uint + Heading string `json:"description"` + RouteId string `json:"route_id"` RouteShortName string `json:"route_short_name"` // direction_id uint8 - DirectionText string `json:"direction_text"` + // DirectionText string `json:"direction_text"` // schedule_relationship string } type RouteData struct { @@ -39,20 +40,20 @@ type RouteData struct { func fetchRouteData(id uint) RouteData { resp, err := http.Get(fmt.Sprintf("https://svc.metrotransit.org/nextripv2/%d", id)) if err != nil { - fmt.Printf("Unable to reach NexTrip API:\n%s", err) + fmt.Printf("Unable to reach NexTrip API:\n%s\n", err) return RouteData{} } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - fmt.Printf("Error when reading NexTrip API response:\n%s", err) + fmt.Printf("Error when reading NexTrip API response:\n%s\n", err) return RouteData{} } var data RouteData err = json.Unmarshal(body, &data) if err != nil { - fmt.Printf("Error when parsing NexTrip API data:\n%s", err) + fmt.Printf("Error when parsing NexTrip API data:\n%s\n", err) return RouteData{} } diff --git a/readme.md b/readme.md index 1fed633..201aed0 100644 --- a/readme.md +++ b/readme.md @@ -24,3 +24,60 @@ we're going to put this on a display in the window of layer zero so that people ### what's it look like? ![screenshot](screenshot.png) + +### request data format + +```js +groups: [ + { + name: "34th & 45th Street", + stations: [ + { + id: 15280, + routes: [ + { + id: 46, + name?: "46", + dir?: "WB" + } + ] + }, + { + id: 15412, + routes: [ + { + id: 46, + name?: "46", + dir?: "EB" + } + ] + } + ] + }, + { + name: "46th Street Station", + stations: [ + { + id: 51430, + routes: [ + { + id: 901, + name?: "Blue line", + dir?: "to Mpls-Target Field" + } + ] + }, + { + id: 51415, + routes: [ + { + id: 901, + name?: "Blue line", + dir?: "to Mall of America" + } + ] + } + ] + } +] +``` diff --git a/sass.sh b/sass.sh new file mode 100755 index 0000000..f86290c --- /dev/null +++ b/sass.sh @@ -0,0 +1,2 @@ +#!/bin/sh +sass --watch --no-source-map --style=compressed static/style.scss static/style.css diff --git a/screenshot.png b/screenshot.png index bc4266c..c6d3aa5 100644 Binary files a/screenshot.png and b/screenshot.png differ diff --git a/screenshot2.png b/screenshot2.png deleted file mode 100644 index 526f294..0000000 Binary files a/screenshot2.png and /dev/null differ diff --git a/static/FiraCode-Regular.woff b/static/FiraCode-Regular.woff deleted file mode 100644 index 8816b69..0000000 Binary files a/static/FiraCode-Regular.woff and /dev/null differ diff --git a/static/FiraCode-Regular.woff2 b/static/FiraCode-Regular.woff2 deleted file mode 100644 index f8b63fb..0000000 Binary files a/static/FiraCode-Regular.woff2 and /dev/null differ diff --git a/static/Inconsolata-Regular.ttf b/static/Inconsolata-Regular.ttf new file mode 100644 index 0000000..0d879bf Binary files /dev/null and b/static/Inconsolata-Regular.ttf differ diff --git a/static/Inconsolata-SemiBold.ttf b/static/Inconsolata-SemiBold.ttf new file mode 100644 index 0000000..71ba4f9 Binary files /dev/null and b/static/Inconsolata-SemiBold.ttf differ diff --git a/static/index.html b/static/index.html index 1f1733b..6f27092 100644 --- a/static/index.html +++ b/static/index.html @@ -9,14 +9,28 @@ let getETAs = async () => { let response = await fetch('/etas') let data = await response.json() - console.dir(data) - let tbody = document.getElementById('etas') - tbody.innerHTML = '' + let main = document.getElementById('etas') + let innerHTML = '' - for (let eta of data){ - tbody.innerHTML += `${eta.name}${eta.dir}${eta.delta}` + for (let station of data){ + innerHTML += ` + + + + + + + + + + \n` + for (let eta of station.etas){ + innerHTML += `` + } + innerHTML += `
${station.name}
routeheadingeta
${eta.name}${eta.heading}${eta.delta}
\n` } + main.innerHTML = innerHTML } let main = () => { @@ -30,16 +44,7 @@

Transit schedule

- - - -
+
diff --git a/static/style.css b/static/style.css index fcc10d9..d3415cc 100644 --- a/static/style.css +++ b/static/style.css @@ -1,70 +1 @@ -@font-face { - font-family: 'Fira Code'; - src: url('FiraCode-Regular.woff2') format('woff2'), - url('FiraCode-Regular.woff') format('woff'); - font-weight: 400; - font-style: normal; -} - - -html { - margin: 0; - padding: 0; - - background: #000; - color: #fff; - - font-family: 'Fira Code', monospace; - font-feature-settings: 'cv12', 'cv14'; - font-size: 32px; -} -@media screen and (max-width: 550px) { - html { - font-size: 18px; - } -} - - -body { - margin: 0; - padding: 0; - text-align: center; -} - -h1 { - font-size: 1.25rem; - margin: 1.5rem 0; -} - -table { - width: 90vw; - margin: 0 5vw; - border: none; - - font-size: 1rem; - text-align: center; -} -tr:nth-child(2n-1) { - background-color: rgba(255,255,255,0.1); -} -td { - padding: .1em; -} -/*td:not(:last-child) { - padding-right: 32px; -}*/ - -.soon { - color: #fc8; -} -.now { - color: #8f8; -} - -img#qrcode { - height: 5vh; - width: auto; - position: fixed; - bottom: 16px; - right: 16px; -} +@font-face{font-family:"Inconsolata";src:url("Inconsolata-Regular.ttf") format("truetype");font-weight:400;font-style:normal}@font-face{font-family:"Inconsolata";src:url("Inconsolata-SemiBold.ttf") format("truetype");font-weight:600;font-style:normal}html{margin:0;padding:0;font-family:"Inconsolata",monospace;font-weight:400;font-size:32px}@media(prefers-color-scheme: light){html{background:#fff;color:#000}}@media(prefers-color-scheme: dark){html{background:#000;color:#fff}}@media screen and (max-width: 550px){html{font-size:18px}}body{margin:0;padding:0;text-align:center}h1{font-size:1.25rem;font-weight:600;margin:1rem 0}h2{font-size:1.125rem;font-weight:600;margin:.375rem 0}table{width:90vw;margin:0 5vw;font-size:1rem;text-align:center}table:not(:last-child){margin-bottom:1.5em}table thead{line-height:1.3}table thead tr:nth-child(2){font-size:.9rem}table:nth-child(1) thead{background-color:rgba(169,124,183,.5)}table:nth-child(2) thead{background-color:rgba(123,207,220,.5)}table tbody tr{font-size:1.125rem}@media(prefers-color-scheme: light){table tbody tr:nth-child(2n){background:rgba(0,0,0,.1)}}@media(prefers-color-scheme: dark){table tbody tr:nth-child(2n){background:rgba(255,255,255,.2)}}table th{font-weight:600}table td{padding:.125em}@media(prefers-color-scheme: light){.soon{color:#e19d31}.now{color:#42bd4c}}@media(prefers-color-scheme: dark){.soon{color:#fc6}.now{color:#6f8}}img#qrcode{height:5vh;width:auto;position:fixed;bottom:16px;right:16px} diff --git a/static/style.scss b/static/style.scss new file mode 100644 index 0000000..1b8d870 --- /dev/null +++ b/static/style.scss @@ -0,0 +1,152 @@ +@font-face { + font-family: 'Inconsolata'; + src: url('Inconsolata-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; +} +@font-face { + font-family: 'Inconsolata'; + src: url('Inconsolata-SemiBold.ttf') format('truetype'); + font-weight: 600; + font-style: normal; +} + +$cyb-black: #353535; +$cyb-white: #eaeaea; +$cyb-purple: #a97cb7; +$cyb-blue: #7bcfdc; + +html { + margin: 0; + padding: 0; + + @media (prefers-color-scheme: light) { + background: #fff; + color: #000; + } + @media (prefers-color-scheme: dark) { + background: #000; + color: #fff; + } + + font-family: 'Inconsolata', monospace; + font-weight: 400; + font-size: 32px; + + @media screen and (max-width: 550px) { + font-size: 18px; + } +} + +body { + margin: 0; + padding: 0; + text-align: center; +} + +h1 { + font-size: 1.25rem; + font-weight: 600; + margin: 1rem 0; +} +h2 { + font-size: 1.125rem; + font-weight: 600; + margin: 0.375rem 0; +} + +table { + width: 90vw; + margin: 0 5vw; + &:not(:last-child) { + margin-bottom: 1.5em; + } + + font-size: 1rem; + text-align: center; + + thead { + line-height: 1.3; + + tr:nth-child(2) { + font-size: 0.9rem; + // th { font-weight: normal; } + } + } + + // colored table headers + &:nth-child(1) { + thead { + background-color: rgba($cyb-purple, .5); + // tr:nth-child(2) { + // background-color: rgba($cyb-purple, .3) + // } + } + } + &:nth-child(2) { + thead { + background-color: rgba($cyb-blue, .5); + // tr:nth-child(2) { + // background-color: rgba($cyb-blue, .3) + // } + } + } + + tbody { + tr { + font-size: 1.125rem; + + // highlight every other row to aid in horizontal scanning + &:nth-child(2n) { + @media (prefers-color-scheme: light) { + background: rgba(black, .1); + } + @media (prefers-color-scheme: dark) { + background: rgba(white, .2); + } + } + } + } + + th { + font-weight: 600; + } + td { + padding: .125em; + } + + // round corners of backgrounds + // tr:first-child { + // td:first-child, th:first-child { + // border-top-left-radius: 10px; + // } + // td:last-child, th:last-child { + // border-top-right-radius: 10px; + // } + // } + // tr:last-child { + // td:first-child, th:first-child { + // border-bottom-left-radius: 10px; + // } + // td:last-child, th:last-child { + // border-bottom-right-radius: 10px; + // } + // } +} + +@media (prefers-color-scheme: light) { + .soon { color: #e19d31; } + .now { color: #42bd4c; } +} +@media (prefers-color-scheme: dark) { + .soon { color: #fc6; } + .now { color: #6f8; } +} + +img#qrcode { + height: 5vh; + width: auto; + position: fixed; + bottom: 16px; + right: 16px; +}