merge v2 into main #2

Merged
reese merged 5 commits from v2 into main 2022-09-25 22:00:43 +00:00
14 changed files with 424 additions and 192 deletions

View file

@ -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
},
},
},
},
}

210
main.go
View file

@ -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
// }

View file

@ -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{}
}

View file

@ -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"
}
]
}
]
}
]
```

2
sass.sh Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
sass --watch --no-source-map --style=compressed static/style.scss static/style.css

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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 += `<tr><td>${eta.name}</td><td>${eta.dir}</td><td class="${eta.class}">${eta.delta}</td></tr>`
for (let station of data){
innerHTML += `
<table cellspacing="0">
<thead>
<tr><th colspan="3">${station.name}</th></tr>
<tr>
<th style="width: 25%;">route</th>
<th style="width: 50%;">heading</th>
<th style="width: 25%;">eta</th>
</tr>
</thead>
<tbody>\n`
for (let eta of station.etas){
innerHTML += `<tr><td>${eta.name}</td><td>${eta.heading}</td><td class="${eta.class}">${eta.delta}</td></tr>`
}
innerHTML += `</tbody></table>\n`
}
main.innerHTML = innerHTML
}
let main = () => {
@ -30,16 +44,7 @@
</head>
<body>
<h1>Transit schedule</h1>
<table cellspacing="0">
<!-- <thead>
<tr>
<th>line</th>
<th>direction</th>
<th>eta</th>
</tr>
</thead> -->
<tbody id="etas"></tbody>
</table>
<main id="etas"></main>
<!-- <img id="qrcode" src="qrcode.png" alt="scan this QR code for the full site"> -->
</body>

View file

@ -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}

152
static/style.scss Normal file
View file

@ -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;
}