@ -0,0 +1,2 @@ | |||
config.json | |||
*.swp |
@ -0,0 +1,15 @@ | |||
# OC Transpo GPS Bus Tracker | |||
Using the [OC Transpo API](http://www.octranspo.com/index.php/developers) to | |||
track the next buses' GPS location at my morning bus stop. This way, I can | |||
spend the least amount of time waiting for the bus outside in the winter. I'm | |||
also using the [navitia API](https://www.navitia.io/) to get the scheduled | |||
times for the upcoming buses. | |||
## Thanks | |||
* [ionicons](https://github.com/ionic-team/ionicons) | |||
* [leaflet](https://github.com/Leaflet/Leaflet) | |||
* [leaflet.awesome-markers](https://github.com/lvoogdt/Leaflet.awesome-markers) | |||
* [OpenStreetMap](https://www.openstreetmap.org) | |||
@ -0,0 +1,49 @@ | |||
<?php | |||
// TODO: Abstract, probably | |||
class Api { | |||
protected $prefix = ''; | |||
protected $dailyLimit = 0; | |||
protected $last_time_we_accessed_api; | |||
protected $nb_requests_made_today; | |||
public function __construct() { | |||
$this->last_time_we_accessed_api = apcu_fetch($this->prefix . 'last_time_we_accessed_api') ?: null; | |||
$this->nb_requests_made_today = apcu_fetch($this->prefix . 'nb_requests_made_today') ?: 0; | |||
// Reset counter if last time we fetch from api wasn't today | |||
// or if we don't know when the last time API was accessed | |||
if ($this->last_time_we_accessed_api === null || !$this->is_today($this->last_time_we_accessed_api)) { | |||
$this->setProp('nb_requests_made_today', 0); | |||
} | |||
} | |||
// TODO: Utils | |||
public function is_today($timestamp) { | |||
return date('Ymd') === date('Ymd', $timestamp); | |||
} | |||
public function apiCallsRemainingToday() { | |||
return $this->dailyLimit - $this->nb_requests_made_today; | |||
} | |||
public function isWaitTimeExpired() { | |||
$last = $this->last_time_we_accessed_api; | |||
$waitTime = $this->waitTime(); | |||
return ($waitTime + $last < time()); | |||
} | |||
public function waitTime() { | |||
$api_connections_left_today = $this->apiCallsRemainingToday(); | |||
$seconds_left_before_midnight = strtotime('tomorrow') - time(); | |||
return max( ($seconds_left_before_midnight / $api_connections_left_today), 1 ); | |||
} | |||
public function setProp($propName, $value) { | |||
apcu_store($this->prefix . $propName, $value); | |||
$this->$propName = $value; | |||
} | |||
} | |||
@ -0,0 +1,58 @@ | |||
<?php | |||
require_once(dirname(__FILE__) . '/Api.php'); | |||
class Navitia extends Api { | |||
// Cache prefix | |||
protected $prefix = 'nav-'; | |||
// 3,000 API calls per day | |||
// The ToS for the free account says 90,000 per month. | |||
// Let's just say it's 3,000 per day for now (assumes a 30-day month) | |||
protected $dailyLimit = 3000; | |||
// Credentials | |||
protected $token; | |||
public function __construct($token) { | |||
parent::__construct(); | |||
$this->token = $token; | |||
} | |||
public function getStopSchedule($stopId) { | |||
if ($this->isWaitTimeExpired()) { | |||
$last_data_received_from_api = $this->_getStopSchedule($stopId); | |||
$this->setProp('last_data_received_from_api', $last_data_received_from_api); | |||
$this->setProp('last_time_we_accessed_api', time()); | |||
$this->setProp('nb_requests_made_today', $this->nb_requests_made_today += 1); | |||
} else { | |||
$last_data_received_from_api = apcu_fetch($this->prefix . 'last_data_received_from_api'); | |||
} | |||
return $last_data_received_from_api; | |||
} | |||
private function _getStopSchedule($stopId) { | |||
// TODO: Error handling | |||
$ch = curl_init(); | |||
curl_setopt($ch, CURLOPT_URL, "https://" . $this->token . "@api.navitia.io/v1/coverage/ca-on/stop_points/stop_point:" . $stopId . "/stop_schedules"); | |||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); | |||
$response = curl_exec($ch); | |||
curl_close($ch); | |||
// TODO: Error handling | |||
$json = json_decode($response); | |||
$tmp_dateTimes = $json->stop_schedules[0]->date_times; | |||
$dateTime = array(); | |||
foreach($tmp_dateTimes as $tmp_dateTime) { | |||
$dateTime[] = strtotime($tmp_dateTime->date_time); | |||
} | |||
return json_encode($dateTime); | |||
} | |||
} | |||
@ -0,0 +1,81 @@ | |||
<?php | |||
require_once(dirname(__FILE__) . '/Api.php'); | |||
class OCTranspo extends Api { | |||
// Cache prefix | |||
protected $prefix = 'oc-'; | |||
// 10,000 API calls per day | |||
// The ToS say 10,000 while the account plan | |||
// says 20,000. Assume it's 10,000 to be safe. | |||
protected $dailyLimit = 10000; | |||
// Credentials | |||
private $app_id; | |||
private $api_key; | |||
public function __construct($appId, $apiKey) { | |||
parent::__construct(); | |||
$this->app_id = $appId; | |||
$this->api_key = $apiKey; | |||
} | |||
public function getNextTrips($stopNo, $routeNo) { | |||
if ($this->isWaitTimeExpired()) { | |||
$last_data_received_from_api = $this->_getNextTrips($stopNo, $routeNo); | |||
$this->setProp('last_data_received_from_api', $last_data_received_from_api); | |||
$this->setProp('last_time_we_accessed_api', time()); | |||
$this->setProp('nb_requests_made_today', $this->nb_requests_made_today += 1); | |||
} else { | |||
$last_data_received_from_api = apcu_fetch($this->prefix . 'last_data_received_from_api'); | |||
} | |||
return $last_data_received_from_api; | |||
} | |||
private function _getNextTrips($stopNo, $routeNo) { | |||
$data = [ | |||
'appID' => $this->app_id, | |||
'apiKey' => $this->api_key, | |||
'routeNo' => $routeNo, | |||
'stopNo' => $stopNo | |||
]; | |||
// TODO: Error handling | |||
$ch = curl_init(); | |||
curl_setopt($ch, CURLOPT_URL, "https://api.octranspo1.com/v1.2/GetNextTripsForStop"); | |||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); | |||
curl_setopt($ch, CURLOPT_POST, true); | |||
curl_setopt($ch, CURLOPT_POSTFIELDS, $data); | |||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); | |||
$response = curl_exec($ch); | |||
curl_close($ch); | |||
$coords = $this->parse_xml_response($response); | |||
return json_encode($coords); | |||
} | |||
// TODO: Utils | |||
private function parse_xml_response($response) { | |||
$coords = []; | |||
// TODO: Error handling | |||
$xmlParser = new SimpleXMLElement($response); | |||
$xmlParser->registerXPathNamespace('tempuri', 'http://tempuri.org/'); | |||
$trips = $xmlParser->xpath('//tempuri:Trip'); | |||
foreach ($trips as $trip) { | |||
$coords[] = [ | |||
'longitude' => (string)$trip->Longitude, | |||
'latitude' => (string)$trip->Latitude | |||
]; | |||
} | |||
return $coords; | |||
} | |||
} | |||
@ -0,0 +1,13 @@ | |||
{ | |||
"octranspo": { | |||
"appId": "", | |||
"apiKey": "", | |||
"busNo": 0, | |||
"stopNo": 0 | |||
}, | |||
"navitia": { | |||
"token": "", | |||
"stopId": "" | |||
} | |||
} | |||
@ -0,0 +1,77 @@ | |||
body, | |||
html { | |||
margin: 0; | |||
padding: 0; | |||
} | |||
summary { | |||
cursor: pointer; | |||
} | |||
.container { | |||
position: relative; | |||
} | |||
.schedule { | |||
background: rgba(255, 255, 255, 0.75); | |||
margin-left: 20px; | |||
padding: 15px; | |||
position: absolute; | |||
z-index: 401; | |||
} | |||
.schedule__title { | |||
font-size: 24px; | |||
font-weight: bold; | |||
margin: 0; | |||
} | |||
.schedule__times { | |||
margin: 15px 0 0 15px; | |||
padding: 0; | |||
} | |||
.schedule__item:nth-child(n+4) { | |||
display: none; | |||
} | |||
.schedule__times li { | |||
margin-bottom: 15px; | |||
} | |||
.schedule__item--expired { | |||
opacity: 0.5; | |||
} | |||
.schedule__time:last-child { | |||
margin-bottom: 0; | |||
} | |||
#map { | |||
height: 100vh; | |||
} | |||
/* https://github.com/twbs/bootstrap/blob/29d58fb758683db42c2d716ac654dea3ab6063c7/scss/mixins/_screen-reader.scss#L6 */ | |||
.custom-checkbox__input { | |||
clip: rect(0, 0, 0, 0); | |||
clip-path: inset(50%); | |||
border: 0; | |||
height: 1px; | |||
overflow: hidden; | |||
padding: 0; | |||
position: absolute; | |||
white-space: nowrap; | |||
width: 1px; | |||
} | |||
.custom-checkbox__label { | |||
border: 1px solid #bee5eb; | |||
background: #d1ecf1; | |||
padding: 10px 5px; | |||
} | |||
.custom-checkbox__input:checked + .custom-checkbox__label, | |||
.custom-checkbox__input:focus + .custom-checkbox__label { | |||
background: #fff; | |||
} | |||
@ -0,0 +1,124 @@ | |||
/* | |||
Author: L. Voogdt | |||
License: MIT | |||
Version: 1.0 | |||
*/ | |||
/* Marker setup */ | |||
.awesome-marker { | |||
background: url('images/markers-soft.png') no-repeat 0 0; | |||
width: 35px; | |||
height: 46px; | |||
position:absolute; | |||
left:0; | |||
top:0; | |||
display: block; | |||
text-align: center; | |||
} | |||
.awesome-marker-shadow { | |||
background: url('images/markers-shadow.png') no-repeat 0 0; | |||
width: 36px; | |||
height: 16px; | |||
} | |||
/* Retina displays */ | |||
@media (min--moz-device-pixel-ratio: 1.5),(-o-min-device-pixel-ratio: 3/2), | |||
(-webkit-min-device-pixel-ratio: 1.5),(min-device-pixel-ratio: 1.5),(min-resolution: 1.5dppx) { | |||
.awesome-marker { | |||
background-image: url('images/markers-soft@2x.png'); | |||
background-size: 720px 46px; | |||
} | |||
.awesome-marker-shadow { | |||
background-image: url('images/markers-shadow@2x.png'); | |||
background-size: 35px 16px; | |||
} | |||
} | |||
.awesome-marker i { | |||
color: #333; | |||
margin-top: 10px; | |||
display: inline-block; | |||
font-size: 14px; | |||
} | |||
.awesome-marker .icon-white { | |||
color: #fff; | |||
} | |||
/* Colors */ | |||
.awesome-marker-icon-red { | |||
background-position: 0 0; | |||
} | |||
.awesome-marker-icon-darkred { | |||
background-position: -180px 0; | |||
} | |||
.awesome-marker-icon-lightred { | |||
background-position: -360px 0; | |||
} | |||
.awesome-marker-icon-orange { | |||
background-position: -36px 0; | |||
} | |||
.awesome-marker-icon-beige { | |||
background-position: -396px 0; | |||
} | |||
.awesome-marker-icon-green { | |||
background-position: -72px 0; | |||
} | |||
.awesome-marker-icon-darkgreen { | |||
background-position: -252px 0; | |||
} | |||
.awesome-marker-icon-lightgreen { | |||
background-position: -432px 0; | |||
} | |||
.awesome-marker-icon-blue { | |||
background-position: -108px 0; | |||
} | |||
.awesome-marker-icon-darkblue { | |||
background-position: -216px 0; | |||
} | |||
.awesome-marker-icon-lightblue { | |||
background-position: -468px 0; | |||
} | |||
.awesome-marker-icon-purple { | |||
background-position: -144px 0; | |||
} | |||
.awesome-marker-icon-darkpurple { | |||
background-position: -288px 0; | |||
} | |||
.awesome-marker-icon-pink { | |||
background-position: -504px 0; | |||
} | |||
.awesome-marker-icon-cadetblue { | |||
background-position: -324px 0; | |||
} | |||
.awesome-marker-icon-white { | |||
background-position: -574px 0; | |||
} | |||
.awesome-marker-icon-gray { | |||
background-position: -648px 0; | |||
} | |||
.awesome-marker-icon-lightgray { | |||
background-position: -612px 0; | |||
} | |||
.awesome-marker-icon-black { | |||
background-position: -682px 0; | |||
} |
@ -0,0 +1,7 @@ | |||
/* | |||
Leaflet.AwesomeMarkers, a plugin that adds colorful iconic markers for Leaflet, based on the Font Awesome icons | |||
(c) 2012-2013, Lennard Voogdt | |||
http://leafletjs.com | |||
https://github.com/lvoogdt | |||
*//*global L*/(function(e,t,n){"use strict";L.AwesomeMarkers={};L.AwesomeMarkers.version="2.0.1";L.AwesomeMarkers.Icon=L.Icon.extend({options:{iconSize:[35,45],iconAnchor:[17,42],popupAnchor:[1,-32],shadowAnchor:[10,12],shadowSize:[36,16],className:"awesome-marker",prefix:"glyphicon",spinClass:"fa-spin",icon:"home",markerColor:"blue",iconColor:"white"},initialize:function(e){e=L.Util.setOptions(this,e)},createIcon:function(){var e=t.createElement("div"),n=this.options;n.icon&&(e.innerHTML=this._createInner());n.bgPos&&(e.style.backgroundPosition=-n.bgPos.x+"px "+ -n.bgPos.y+"px");this._setIconStyles(e,"icon-"+n.markerColor);return e},_createInner:function(){var e,t="",n="",r="",i=this.options;i.icon.slice(0,i.prefix.length+1)===i.prefix+"-"?e=i.icon:e=i.prefix+"-"+i.icon;i.spin&&typeof i.spinClass=="string"&&(t=i.spinClass);i.iconColor&&(i.iconColor==="white"||i.iconColor==="black"?n="icon-"+i.iconColor:r="style='color: "+i.iconColor+"' ");return"<i "+r+"class='"+i.prefix+" "+e+" "+t+" "+n+"'></i>"},_setIconStyles:function(e,t){var n=this.options,r=L.point(n[t==="shadow"?"shadowSize":"iconSize"]),i;t==="shadow"?i=L.point(n.shadowAnchor||n.iconAnchor):i=L.point(n.iconAnchor);!i&&r&&(i=r.divideBy(2,!0));e.className="awesome-marker-"+t+" "+n.className;if(i){e.style.marginLeft=-i.x+"px";e.style.marginTop=-i.y+"px"}if(r){e.style.width=r.x+"px";e.style.height=r.y+"px"}},createShadow:function(){var e=t.createElement("div");this._setIconStyles(e,"shadow");return e}});L.AwesomeMarkers.icon=function(e){return new L.AwesomeMarkers.Icon(e)}})(this,document); |
@ -0,0 +1,632 @@ | |||
/* required styles */ | |||
.leaflet-pane, | |||
.leaflet-tile, | |||
.leaflet-marker-icon, | |||
.leaflet-marker-shadow, | |||
.leaflet-tile-container, | |||
.leaflet-pane > svg, | |||
.leaflet-pane > canvas, | |||
.leaflet-zoom-box, | |||
.leaflet-image-layer, | |||
.leaflet-layer { | |||
position: absolute; | |||
left: 0; | |||
top: 0; | |||
} | |||
.leaflet-container { | |||
overflow: hidden; | |||
} | |||
.leaflet-tile, | |||
.leaflet-marker-icon, | |||
.leaflet-marker-shadow { | |||
-webkit-user-select: none; | |||
-moz-user-select: none; | |||
user-select: none; | |||
-webkit-user-drag: none; | |||
} | |||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ | |||
.leaflet-safari .leaflet-tile { | |||
image-rendering: -webkit-optimize-contrast; | |||
} | |||
/* hack that prevents hw layers "stretching" when loading new tiles */ | |||
.leaflet-safari .leaflet-tile-container { | |||
width: 1600px; | |||
height: 1600px; | |||
-webkit-transform-origin: 0 0; | |||
} | |||
.leaflet-marker-icon, | |||
.leaflet-marker-shadow { | |||
display: block; | |||
} | |||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ | |||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ | |||
.leaflet-container .leaflet-overlay-pane svg, | |||
.leaflet-container .leaflet-marker-pane img, | |||
.leaflet-container .leaflet-shadow-pane img, | |||
.leaflet-container .leaflet-tile-pane img, | |||
.leaflet-container img.leaflet-image-layer { | |||
max-width: none !important; | |||
} | |||
.leaflet-container.leaflet-touch-zoom { | |||
-ms-touch-action: pan-x pan-y; | |||
touch-action: pan-x pan-y; | |||
} | |||
.leaflet-container.leaflet-touch-drag { | |||
-ms-touch-action: pinch-zoom; | |||
} | |||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { | |||
-ms-touch-action: none; | |||
touch-action: none; | |||
} | |||
.leaflet-container { | |||
-webkit-tap-highlight-color: transparent; | |||
} | |||
.leaflet-container a { | |||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); | |||
} | |||
.leaflet-tile { | |||
filter: inherit; | |||
visibility: hidden; | |||
} | |||
.leaflet-tile-loaded { | |||
visibility: inherit; | |||
} | |||
.leaflet-zoom-box { | |||
width: 0; | |||
height: 0; | |||
-moz-box-sizing: border-box; | |||
box-sizing: border-box; | |||
z-index: 800; | |||
} | |||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ | |||
.leaflet-overlay-pane svg { | |||
-moz-user-select: none; | |||
} | |||
.leaflet-pane { z-index: 400; } | |||
.leaflet-tile-pane { z-index: 200; } | |||
.leaflet-overlay-pane { z-index: 400; } | |||
.leaflet-shadow-pane { z-index: 500; } | |||
.leaflet-marker-pane { z-index: 600; } | |||
.leaflet-tooltip-pane { z-index: 650; } | |||
.leaflet-popup-pane { z-index: 700; } | |||
.leaflet-map-pane canvas { z-index: 100; } | |||
.leaflet-map-pane svg { z-index: 200; } | |||
.leaflet-vml-shape { | |||
width: 1px; | |||
height: 1px; | |||
} | |||
.lvml { | |||
behavior: url(#default#VML); | |||
display: inline-block; | |||
position: absolute; | |||
} | |||
/* control positioning */ | |||
.leaflet-control { | |||
position: relative; | |||
z-index: 800; | |||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ | |||
pointer-events: auto; | |||
} | |||
.leaflet-top, | |||
.leaflet-bottom { | |||
position: absolute; | |||
z-index: 1000; | |||
pointer-events: none; | |||
} | |||
.leaflet-top { | |||
top: 0; | |||
} | |||
.leaflet-right { | |||
right: 0; | |||
} | |||
.leaflet-bottom { | |||
bottom: 0; | |||
} | |||
.leaflet-left { | |||
left: 0; | |||
} | |||
.leaflet-control { | |||
float: left; | |||
clear: both; | |||
} | |||
.leaflet-right .leaflet-control { | |||
float: right; | |||
} | |||
.leaflet-top .leaflet-control { | |||
margin-top: 10px; | |||
} | |||
.leaflet-bottom .leaflet-control { | |||
margin-bottom: 10px; | |||
} | |||
.leaflet-left .leaflet-control { | |||
margin-left: 10px; | |||
} | |||
.leaflet-right .leaflet-control { | |||
margin-right: 10px; | |||
} | |||
/* zoom and fade animations */ | |||
.leaflet-fade-anim .leaflet-tile { | |||
will-change: opacity; | |||
} | |||
.leaflet-fade-anim .leaflet-popup { | |||
opacity: 0; | |||
-webkit-transition: opacity 0.2s linear; | |||
-moz-transition: opacity 0.2s linear; | |||
-o-transition: opacity 0.2s linear; | |||
transition: opacity 0.2s linear; | |||
} | |||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { | |||
opacity: 1; | |||
} | |||
.leaflet-zoom-animated { | |||
-webkit-transform-origin: 0 0; | |||
-ms-transform-origin: 0 0; | |||
transform-origin: 0 0; | |||
} | |||
.leaflet-zoom-anim .leaflet-zoom-animated { | |||
will-change: transform; | |||
} | |||
.leaflet-zoom-anim .leaflet-zoom-animated { | |||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); | |||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); | |||
-o-transition: -o-transform 0.25s cubic-bezier(0,0,0.25,1); | |||
transition: transform 0.25s cubic-bezier(0,0,0.25,1); | |||
} | |||
.leaflet-zoom-anim .leaflet-tile, | |||
.leaflet-pan-anim .leaflet-tile { | |||
-webkit-transition: none; | |||
-moz-transition: none; | |||
-o-transition: none; | |||
transition: none; | |||
} | |||
.leaflet-zoom-anim .leaflet-zoom-hide { | |||
visibility: hidden; | |||
} | |||
/* cursors */ | |||
.leaflet-interactive { | |||
cursor: pointer; | |||
} | |||
.leaflet-grab { | |||
cursor: -webkit-grab; | |||
cursor: -moz-grab; | |||
} | |||
.leaflet-crosshair, | |||
.leaflet-crosshair .leaflet-interactive { | |||
cursor: crosshair; | |||
} | |||
.leaflet-popup-pane, | |||
.leaflet-control { | |||
cursor: auto; | |||
} | |||
.leaflet-dragging .leaflet-grab, | |||
.leaflet-dragging .leaflet-grab .leaflet-interactive, | |||
.leaflet-dragging .leaflet-marker-draggable { | |||
cursor: move; | |||
cursor: -webkit-grabbing; | |||
cursor: -moz-grabbing; | |||
} | |||
/* marker & overlays interactivity */ | |||
.leaflet-marker-icon, | |||
.leaflet-marker-shadow, | |||
.leaflet-image-layer, | |||
.leaflet-pane > svg path, | |||
.leaflet-tile-container { | |||
pointer-events: none; | |||
} | |||
.leaflet-marker-icon.leaflet-interactive, | |||
.leaflet-image-layer.leaflet-interactive, | |||
.leaflet-pane > svg path.leaflet-interactive { | |||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ | |||
pointer-events: auto; | |||
} | |||
/* visual tweaks */ | |||
.leaflet-container { | |||
background: #ddd; | |||
outline: 0; | |||
} | |||
.leaflet-container a { | |||
color: #0078A8; | |||
} | |||
.leaflet-container a.leaflet-active { | |||
outline: 2px solid orange; | |||
} | |||
.leaflet-zoom-box { | |||
border: 2px dotted #38f; | |||
background: rgba(255,255,255,0.5); | |||
} | |||
/* general typography */ | |||
.leaflet-container { | |||
font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif; | |||
} | |||
/* general toolbar styles */ | |||
.leaflet-bar { | |||
box-shadow: 0 1px 5px rgba(0,0,0,0.65); | |||
border-radius: 4px; | |||
} | |||
.leaflet-bar a, | |||
.leaflet-bar a:hover { | |||
background-color: #fff; | |||
border-bottom: 1px solid #ccc; | |||
width: 26px; | |||
height: 26px; | |||
line-height: 26px; | |||
display: block; | |||
text-align: center; | |||
text-decoration: none; | |||
color: black; | |||
} | |||
.leaflet-bar a, | |||
.leaflet-control-layers-toggle { | |||
background-position: 50% 50%; | |||
background-repeat: no-repeat; | |||
display: block; | |||
} | |||
.leaflet-bar a:hover { | |||
background-color: #f4f4f4; | |||
} | |||
.leaflet-bar a:first-child { | |||
border-top-left-radius: 4px; | |||
border-top-right-radius: 4px; | |||
} | |||
.leaflet-bar a:last-child { | |||
border-bottom-left-radius: 4px; | |||
border-bottom-right-radius: 4px; | |||
border-bottom: none; | |||
} | |||
.leaflet-bar a.leaflet-disabled { | |||
cursor: default; | |||
background-color: #f4f4f4; | |||
color: #bbb; | |||
} | |||
.leaflet-touch .leaflet-bar a { | |||
width: 30px; | |||
height: 30px; | |||
line-height: 30px; | |||
} | |||
.leaflet-touch .leaflet-bar a:first-child { | |||
border-top-left-radius: 2px; | |||
border-top-right-radius: 2px; | |||
} | |||
.leaflet-touch .leaflet-bar a:last-child { | |||
border-bottom-left-radius: 2px; | |||
border-bottom-right-radius: 2px; | |||
} | |||
/* zoom control */ | |||
.leaflet-control-zoom-in, | |||
.leaflet-control-zoom-out { | |||
font: bold 18px 'Lucida Console', Monaco, monospace; | |||
text-indent: 1px; | |||
} | |||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { | |||
font-size: 22px; | |||
} | |||
/* layers control */ | |||
.leaflet-control-layers { | |||
box-shadow: 0 1px 5px rgba(0,0,0,0.4); | |||
background: #fff; | |||
border-radius: 5px; | |||
} | |||
.leaflet-control-layers-toggle { | |||
background-image: url(images/layers.png); | |||
width: 36px; | |||
height: 36px; | |||
} | |||
.leaflet-retina .leaflet-control-layers-toggle { | |||
background-image: url(images/layers-2x.png); | |||
background-size: 26px 26px; | |||
} | |||
.leaflet-touch .leaflet-control-layers-toggle { | |||
width: 44px; | |||
height: 44px; | |||
} | |||
.leaflet-control-layers .leaflet-control-layers-list, | |||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle { | |||
display: none; | |||
} | |||
.leaflet-control-layers-expanded .leaflet-control-layers-list { | |||
display: block; | |||
position: relative; | |||
} | |||
.leaflet-control-layers-expanded { | |||
padding: 6px 10px 6px 6px; | |||
color: #333; | |||
background: #fff; | |||
} | |||
.leaflet-control-layers-scrollbar { | |||
overflow-y: scroll; | |||
overflow-x: hidden; | |||
padding-right: 5px; | |||
} | |||
.leaflet-control-layers-selector { | |||
margin-top: 2px; | |||
position: relative; | |||
top: 1px; | |||
} | |||
.leaflet-control-layers label { | |||
display: block; | |||
} | |||
.leaflet-control-layers-separator { | |||
height: 0; | |||
border-top: 1px solid #ddd; | |||
margin: 5px -10px 5px -6px; | |||
} | |||
/* Default icon URLs */ | |||
.leaflet-default-icon-path { | |||
background-image: url(images/marker-icon.png); | |||
} | |||
/* attribution and scale controls */ | |||
.leaflet-container .leaflet-control-attribution { | |||
background: #fff; | |||
background: rgba(255, 255, 255, 0.7); | |||
margin: 0; | |||
} | |||
.leaflet-control-attribution, | |||
.leaflet-control-scale-line { | |||
padding: 0 5px; | |||
color: #333; | |||
} | |||
.leaflet-control-attribution a { | |||
text-decoration: none; | |||
} | |||
.leaflet-control-attribution a:hover { | |||
text-decoration: underline; | |||
} | |||
.leaflet-container .leaflet-control-attribution, | |||
.leaflet-container .leaflet-control-scale { | |||
font-size: 11px; | |||
} | |||
.leaflet-left .leaflet-control-scale { | |||
margin-left: 5px; | |||
} | |||
.leaflet-bottom .leaflet-control-scale { | |||
margin-bottom: 5px; | |||
} | |||
.leaflet-control-scale-line { | |||
border: 2px solid #777; | |||
border-top: none; | |||
line-height: 1.1; | |||
padding: 2px 5px 1px; | |||
font-size: 11px; | |||
white-space: nowrap; | |||
overflow: hidden; | |||
-moz-box-sizing: border-box; | |||
box-sizing: border-box; | |||
background: #fff; | |||
background: rgba(255, 255, 255, 0.5); | |||
} | |||
.leaflet-control-scale-line:not(:first-child) { | |||
border-top: 2px solid #777; | |||
border-bottom: none; | |||
margin-top: -2px; | |||
} | |||
.leaflet-control-scale-line:not(:first-child):not(:last-child) { | |||
border-bottom: 2px solid #777; | |||
} | |||
.leaflet-touch .leaflet-control-attribution, | |||
.leaflet-touch .leaflet-control-layers, | |||
.leaflet-touch .leaflet-bar { | |||
box-shadow: none; | |||
} | |||
.leaflet-touch .leaflet-control-layers, | |||
.leaflet-touch .leaflet-bar { | |||
border: 2px solid rgba(0,0,0,0.2); | |||
background-clip: padding-box; | |||
} | |||
/* popup */ | |||
.leaflet-popup { | |||
position: absolute; | |||
text-align: center; | |||
margin-bottom: 20px; | |||
} | |||
.leaflet-popup-content-wrapper { | |||
padding: 1px; | |||
text-align: left; | |||
border-radius: 12px; | |||
} | |||
.leaflet-popup-content { | |||
margin: 13px 19px; | |||
line-height: 1.4; | |||
} | |||
.leaflet-popup-content p { | |||
margin: 18px 0; | |||
} | |||
.leaflet-popup-tip-container { | |||
width: 40px; | |||
height: 20px; | |||
position: absolute; | |||
left: 50%; | |||
margin-left: -20px; | |||
overflow: hidden; | |||
pointer-events: none; | |||
} | |||
.leaflet-popup-tip { | |||
width: 17px; | |||
height: 17px; | |||
padding: 1px; | |||
margin: -10px auto 0; | |||
-webkit-transform: rotate(45deg); | |||
-moz-transform: rotate(45deg); | |||
-ms-transform: rotate(45deg); | |||
-o-transform: rotate(45deg); | |||
transform: rotate(45deg); | |||
} | |||
.leaflet-popup-content-wrapper, | |||
.leaflet-popup-tip { | |||
background: white; | |||
color: #333; | |||
box-shadow: 0 3px 14px rgba(0,0,0,0.4); | |||
} | |||
.leaflet-container a.leaflet-popup-close-button { | |||
position: absolute; | |||
top: 0; | |||
right: 0; | |||
padding: 4px 4px 0 0; | |||
border: none; | |||
text-align: center; | |||
width: 18px; | |||
height: 14px; | |||
font: 16px/14px Tahoma, Verdana, sans-serif; | |||
color: #c3c3c3; | |||
text-decoration: none; | |||
font-weight: bold; | |||
background: transparent; | |||
} | |||
.leaflet-container a.leaflet-popup-close-button:hover { | |||
color: #999; | |||
} | |||
.leaflet-popup-scrolled { | |||
overflow: auto; | |||
border-bottom: 1px solid #ddd; | |||
border-top: 1px solid #ddd; | |||
} | |||
.leaflet-oldie .leaflet-popup-content-wrapper { | |||
zoom: 1; | |||
} | |||
.leaflet-oldie .leaflet-popup-tip { | |||
width: 24px; | |||
margin: 0 auto; | |||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; | |||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); | |||
} | |||
.leaflet-oldie .leaflet-popup-tip-container { | |||
margin-top: -1px; | |||
} | |||
.leaflet-oldie .leaflet-control-zoom, | |||
.leaflet-oldie .leaflet-control-layers, | |||
.leaflet-oldie .leaflet-popup-content-wrapper, | |||
.leaflet-oldie .leaflet-popup-tip { | |||
border: 1px solid #999; | |||
} | |||
/* div icon */ | |||
.leaflet-div-icon { | |||
background: #fff; | |||
border: 1px solid #666; | |||
} | |||
/* Tooltip */ | |||
/* Base styles for the element that has a tooltip */ | |||
.leaflet-tooltip { | |||
position: absolute; | |||
padding: 6px; | |||
background-color: #fff; | |||
border: 1px solid #fff; | |||
border-radius: 3px; | |||
color: #222; | |||
white-space: nowrap; | |||
-webkit-user-select: none; | |||
-moz-user-select: none; | |||
-ms-user-select: none; | |||
user-select: none; | |||
pointer-events: none; | |||
box-shadow: 0 1px 3px rgba(0,0,0,0.4); | |||
} | |||
.leaflet-tooltip.leaflet-clickable { | |||
cursor: pointer; | |||
pointer-events: auto; | |||
} | |||
.leaflet-tooltip-top:before, | |||
.leaflet-tooltip-bottom:before, | |||
.leaflet-tooltip-left:before, | |||
.leaflet-tooltip-right:before { | |||
position: absolute; | |||
pointer-events: none; | |||
border: 6px solid transparent; | |||
background: transparent; | |||
content: ""; | |||
} | |||
/* Directions */ | |||
.leaflet-tooltip-bottom { | |||
margin-top: 6px; | |||
} | |||
.leaflet-tooltip-top { | |||
margin-top: -6px; | |||
} | |||
.leaflet-tooltip-bottom:before, | |||
.leaflet-tooltip-top:before { | |||
left: 50%; | |||
margin-left: -6px; | |||
} | |||
.leaflet-tooltip-top:before { | |||
bottom: 0; | |||
margin-bottom: -12px; | |||
border-top-color: #fff; | |||
} | |||
.leaflet-tooltip-bottom:before { | |||
top: 0; | |||
margin-top: -12px; | |||
margin-left: -6px; | |||
border-bottom-color: #fff; | |||
} | |||
.leaflet-tooltip-left { | |||
margin-left: -6px; | |||
} | |||
.leaflet-tooltip-right { | |||
margin-left: 6px; | |||
} | |||
.leaflet-tooltip-left:before, | |||
.leaflet-tooltip-right:before { | |||
top: 50%; | |||
margin-top: -6px; | |||
} | |||
.leaflet-tooltip-left:before { | |||
right: 0; | |||
margin-right: -12px; | |||
border-left-color: #fff; | |||
} | |||
.leaflet-tooltip-right:before { | |||
left: 0; | |||
margin-left: -12px; | |||
border-right-color: #fff; | |||
} |
@ -0,0 +1,33 @@ | |||
<!DOCTYPE html> | |||
<html> | |||
<head> | |||
<meta charset="utf-8" /> | |||
<title></title> | |||
<link rel="stylesheet" href="extlib/leaflet/leaflet.css" /> | |||
<link rel="stylesheet" href="extlib/ionicons/css/ionicons.min.css" /> | |||
<link rel="stylesheet" href="extlib/leaflet.awesome-markers/leaflet.awesome-markers.css" /> | |||
<link rel="stylesheet" href="css/styles.css" /> | |||
</head> | |||
<body> | |||
<div class="container"> | |||
<details class="schedule" open> | |||
<summary class="schedule__title"> | |||
Next trips | |||
</summary> | |||
<div> | |||
<ul class="schedule__times"> | |||
</ul> | |||
</div> | |||
</details> | |||
<div id="map"></div> | |||
</div> | |||
<script src="extlib/leaflet/leaflet.js"></script> | |||
<script src="extlib/leaflet.awesome-markers/leaflet.awesome-markers.min.js"></script> | |||
<script src="js/custom.js"></script> | |||
</body> | |||
</html> | |||
@ -0,0 +1,350 @@ | |||
/** | |||
* Map, live tracking | |||
*/ | |||
( function() { | |||
"use strict"; | |||
var map, busStopIcon, updateBusMarkers, busStopLocation, | |||
busMarkers = [], busIcons = [], busStopMarker, | |||
audio = new Audio( "../ping.ogg" ); | |||
map = L.map( "map", { | |||
center: [ 45.2449019, -75.7364839 ], | |||
zoom: 18, | |||
zoomControl: false | |||
} ); | |||
L.tileLayer( | |||
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", | |||
{ | |||
attribution: "© <a href='http://osm.org/copyright'>OpenStreetMap</a> contributors" | |||
} | |||
).addTo( map ); | |||
L.AwesomeMarkers.Icon.prototype.options.prefix = "ion"; | |||
/* | |||
* Bus stop | |||
*/ | |||
busStopIcon = L.AwesomeMarkers.icon( { | |||
icon: "ios-location", | |||
markerColor: "red" | |||
} ); | |||
busStopLocation = new L.LatLng( 45.24501, -75.73672 ); | |||
busStopMarker = L.marker( [ 45.24501, -75.73672 ], { icon: busStopIcon } ).addTo( map ); | |||
/* | |||
* Bus icons | |||
*/ | |||
busIcons.push( L.AwesomeMarkers.icon( { | |||
icon: "android-bus", | |||
markerColor: "orange" | |||
} ) ); | |||
busIcons.push( L.AwesomeMarkers.icon( { | |||
icon: "android-bus", | |||
markerColor: "green" | |||
} ) ); | |||
busIcons.push( L.AwesomeMarkers.icon( { | |||
icon: "android-bus", | |||
markerColor: "blue" | |||
} ) ); | |||
/* | |||
* Bus markers | |||
*/ | |||
busMarkers = busIcons.map( ( busIcon ) => { | |||
return L.marker( [ 0, 0 ], { icon: busIcon } ); | |||
} ); | |||
updateBusMarkers = function() { | |||
fetch( "/live.php" ) | |||
.then( ( response ) => { | |||
return response.json(); | |||
} ) | |||
.then( ( coordsArr ) => { | |||
var activeMarkers = [ busStopMarker ]; | |||
coordsArr.forEach( ( coords, i ) => { | |||
var marker = busMarkers[ i ], | |||
busLocation, | |||
distance; | |||
if ( coords.latitude && coords.longitude ) { | |||
busLocation = new L.LatLng( coords.latitude, coords.longitude ); | |||
distance = busLocation.distanceTo( busStopLocation ); | |||
marker.setLatLng( busLocation ); | |||
marker.addTo( map ); | |||
activeMarkers.push( marker ); | |||
// If the bus is between 800 and 500 meters, play | |||
// play audio sound. The > 500 condition is a hack | |||
// since we can't flag that we've played the audio | |||
// for a particular bus at the moment. | |||
// TODO: We could flag a specific bus by catching its | |||
// "TripStartTime", I suppose. We'd need our API | |||
// to return that data along with the coords. | |||
if ( distance <= 800 && distance > 500 ) { | |||
audio.play(); | |||
} | |||
} else { | |||
marker.remove(); | |||
} | |||
} ); | |||
map.fitBounds( new L.featureGroup( activeMarkers ).getBounds() ); | |||
setTimeout( updateBusMarkers, 1000 ); // 1s | |||
} ); | |||
}; | |||
// Kick-off update marker loop | |||
updateBusMarkers(); | |||
}() ); | |||
/** | |||
* Schedule | |||
*/ | |||
( function() { | |||
var updateSchedule, | |||
container = document.querySelector( ".schedule__times" ); | |||
container.addEventListener( "countdown-expired", ( e ) => { | |||
var countdownElm = e.target; | |||
// Apply "expired" styles | |||
countdownElm | |||
.closest( ".schedule__item" ) | |||
.classList.add( "schedule__item--expired" ); | |||
// Change wording | |||
countdownElm | |||
.closest( ".schedule__time-remaining" ) | |||
.textContent = "(passed)"; | |||
// Remove "countdown" object -- this doesn't affect the DOM | |||
e.detail.countdown.remove(); | |||
} ); | |||
updateSchedule = function() { | |||
var rootDocFragment = document.createRange().createContextualFragment( "" ); | |||
fetch( "/times.php" ) | |||
.then( ( response ) => { | |||
return response.json(); | |||
} ) | |||
.then( ( epochs ) => { | |||
// TODO: error handling | |||
epochs.forEach( ( epoch, i ) => { | |||
// * 1000 because PHP's strtotime returns seconds | |||
// and JS needs milliseconds. | |||
// NOTE: We're losing precision. May or may not be an issue. | |||
var epochMs = parseInt( epoch, 10 ) * 1000, | |||
autoPlay = ( i < 3 ), | |||
countdown = new Countdown( epochMs, autoPlay ), | |||
dateTime = new Date( epochMs ), | |||
isoDateTime = dateTime.toISOString(), | |||
time, html, docFragment; | |||
// Force Eastern Timezone | |||
time = dateTime.toLocaleTimeString( | |||
"en-CA", | |||
{ | |||
"timeZone": "America/Toronto", | |||
"hour": "2-digit", | |||
"minute": "2-digit" | |||
} | |||
); | |||
html = "<li class='schedule__item'>" + | |||
"<time class='schedule__time' datetime='" + | |||
isoDateTime + "' title='" + isoDateTime + "'>" + time + | |||
"</time><br /> <span class='schedule__time-remaining'>" + | |||
"(in about <span class='countdown-container'></span>)" + | |||
"</span></li>"; | |||
docFragment = document.createRange().createContextualFragment( html ); | |||
docFragment.querySelector( ".countdown-container" ).appendChild( countdown.elm ); | |||
rootDocFragment.appendChild( docFragment ); | |||
} ); | |||
container.appendChild( rootDocFragment ); | |||
} ); | |||
}; | |||
updateSchedule(); | |||
}() ); | |||
( function() { | |||
var countdowns = { | |||
"active": [], | |||
"inactive": [] | |||
}, | |||
interval = null, | |||
startInterval, stopInterval; | |||
startInterval = function() { | |||
interval = setInterval( () => { | |||
countdowns.active.forEach( ( countdown ) => { | |||
countdown.update(); | |||
} ); | |||
}, 1000 ); | |||
}; | |||
stopInterval = function() { | |||
clearInterval( interval ); | |||
interval = null; | |||
}; | |||
var Countdown = class Countdown { | |||
constructor( target, autoPlay ) { | |||
var elm = document.createElement( "span" ), | |||
activeCountdowns = countdowns.active, | |||
shouldAutoPlay = ( autoPlay === undefined || autoPlay === true ); // cast-to-bool | |||
elm.classList.add( "countdown" ); | |||
this.elm = elm; | |||
this.target = target; | |||
this.expired = false; | |||
this.paused = !shouldAutoPlay; // FIXME: Always set to true and let [1] reset, if needed(?) | |||
this.events = { | |||
"expired": new CustomEvent( | |||
"countdown-expired", | |||
{ | |||
"detail": { | |||
"countdown": this | |||
}, | |||
"bubbles": true | |||
} | |||
) | |||
}; | |||
// Make sure we have data as soon as possible, otherwise we might | |||
// have to wait an entire interval cycle before being able to | |||
// display the countdown | |||
this.update( true ); | |||
// Default to inactive collection | |||
countdowns.inactive.push( this ); | |||
// If auto-play, start now. This will move the countdown to the | |||
// 'active' collection and start the interval, if not already | |||
// running. | |||
if ( shouldAutoPlay ) { | |||
this.play(); | |||
} | |||
} | |||
play() { | |||
var targetCountdown = []; | |||
countdowns.inactive.every( ( countdown, i ) => { | |||
if ( this === countdown ) { | |||
targetCountdown = countdowns.inactive.splice( i, 1 ); | |||
return false; | |||
} | |||
} ); | |||
if ( targetCountdown.length > 0 ) { | |||
targetCountdown = targetCountdown[ 0 ]; | |||
targetCountdown.paused = false; | |||
countdowns.active.push( targetCountdown ); | |||
} | |||
// Start the interval once we have our first active countdown | |||
// Make sure we don't have one that's already running for | |||
// whatever reason. | |||
if ( countdowns.active.length === 1 && interval === null ) { | |||
startInterval(); | |||
} | |||
} | |||
pause() { | |||
var targetCountdown = []; | |||
countdowns.active.every( ( countdown, i ) => { | |||
if ( this === countdown ) { | |||
targetCountdown = countdowns.active.splice( i, 1 ); | |||
return false; | |||
} | |||
} ); | |||
if ( targetCountdown.length > 0 ) { | |||
targetCountdown = targetCountdown[ 0 ]; | |||
targetCountdown.paused = true; | |||
countdowns.inactive.push( targetCountdown ); | |||
} | |||
if ( countdowns.active.length <= 0 ) { | |||
stopInterval(); | |||
} | |||
} | |||
remove() { | |||
countdowns.active.every( ( countdown, i ) => { | |||
if ( this === countdown ) { | |||
countdowns.active.splice( i, 1 ); | |||
return false; | |||
} | |||
} ); | |||
if ( countdowns.active.length <= 0 ) { | |||
stopInterval(); | |||
} | |||
} | |||
// FIXME: The isInit param is a bit of a hack to ensure we don't | |||
// trigger an "expired" event and remove the countdown from the | |||
// 'active' collection before the countdown is attached to the | |||
// DOM (if the countdown `target` is already in the past when | |||
// initializing the countdown. | |||
// | |||
// TODO: We may have to create an init() method that's the same as | |||
// update(); except don't do the "expire" stuff? Seems redundant | |||
update( isInit ) { | |||
var browserTime = new Date(), | |||
x = Math.floor( ( this.target - browserTime ) / 1000 ), | |||
seconds, minutes, hours; | |||
if ( !isInit && this.expired === false && x <= 0 ) { | |||
this.expired = true; | |||
this.elm.dispatchEvent( this.events.expired ); | |||
} | |||
seconds = x % 60; | |||
x = Math.floor( x / 60 ); | |||
minutes = x % 60; | |||
x = Math.floor( x / 60 ); | |||
hours = x % 24; | |||
if ( hours === 0 ) { | |||
hours = ""; | |||
if ( minutes === 0 ) { | |||
minutes = ""; | |||
} else { | |||
minutes += "m"; | |||
} | |||
} else { | |||
hours += "h"; | |||
minutes += "m"; | |||
} | |||
this.elm.textContent = hours + minutes + seconds + "s"; | |||
} | |||
}; | |||
window.Countdown = Countdown; | |||
}() ); | |||
@ -0,0 +1,20 @@ | |||
<?php | |||
require_once('../classes/Octranspo.php'); | |||
$config = json_decode(file_get_contents('../config.json')); | |||
// OCTranspo creds | |||
$appId = $config->octranspo->appId; | |||
$apiKey = $config->octranspo->apiKey; | |||
$busNo = $config->octranspo->busNo; | |||
$stopNo = $config->octranspo->stopNo; | |||
$ocTranspo = new OCTranspo($appId, $apiKey); | |||
$nextTrips = $ocTranspo->getNextTrips($stopNo, $busNo); | |||
header('Content-Type: application/json'); | |||
echo $nextTrips; | |||
@ -0,0 +1,18 @@ | |||
<?php | |||
require_once('../classes/Navitia.php'); | |||
$config = json_decode(file_get_contents('../config.json')); | |||
// Navitia creds | |||
$token = $config->navitia->token; | |||
$stopId = $config->navitia->stopId; | |||
$navitia= new Navitia($token); | |||
$nextTrips = $navitia->getStopSchedule($stopId); | |||
header('Content-Type: application/json'); | |||
echo $nextTrips; | |||