Browse Source

First commit

master
Stéphane Bérubé 1 year ago
commit
a4dd737f24

+ 2
- 0
.gitignore View File

@@ -0,0 +1,2 @@
config.json
*.swp

+ 15
- 0
README.md View File

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


+ 49
- 0
classes/Api.php View File

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


+ 58
- 0
classes/Navitia.php View File

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


+ 81
- 0
classes/Octranspo.php View File

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


+ 13
- 0
config.json.dist View File

@@ -0,0 +1,13 @@
{
"octranspo": {
"appId": "",
"apiKey": "",
"busNo": 0,
"stopNo": 0
},
"navitia": {
"token": "",
"stopId": ""
}
}


+ 77
- 0
public/css/styles.css View File

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


+ 11
- 0
public/extlib/ionicons/css/ionicons.min.css
File diff suppressed because it is too large
View File


BIN
public/extlib/ionicons/fonts/ionicons.eot View File


+ 2230
- 0
public/extlib/ionicons/fonts/ionicons.svg
File diff suppressed because it is too large
View File


BIN
public/extlib/ionicons/fonts/ionicons.ttf View File


BIN
public/extlib/ionicons/fonts/ionicons.woff View File


BIN
public/extlib/leaflet.awesome-markers/images/markers-matte.png View File


BIN
public/extlib/leaflet.awesome-markers/images/markers-matte@2x.png View File


BIN
public/extlib/leaflet.awesome-markers/images/markers-plain.png View File


BIN
public/extlib/leaflet.awesome-markers/images/markers-shadow.png View File


BIN
public/extlib/leaflet.awesome-markers/images/markers-shadow@2x.png View File


BIN
public/extlib/leaflet.awesome-markers/images/markers-soft.png View File


BIN
public/extlib/leaflet.awesome-markers/images/markers-soft@2x.png View File


+ 124
- 0
public/extlib/leaflet.awesome-markers/leaflet.awesome-markers.css View File

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

+ 7
- 0
public/extlib/leaflet.awesome-markers/leaflet.awesome-markers.min.js View File

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

+ 632
- 0
public/extlib/leaflet/leaflet.css View File

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

+ 5
- 0
public/extlib/leaflet/leaflet.js
File diff suppressed because it is too large
View File


+ 33
- 0
public/index.html View File

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


+ 350
- 0
public/js/custom.js View File

@@ -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: "&copy; <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;
}() );


+ 20
- 0
public/live.php View File

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


BIN
public/ping.ogg View File


+ 18
- 0
public/times.php View File

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


Loading…
Cancel
Save