commit 86c3fb053745245b33678fbe499084d506d94e0e Author: Juho Teperi Date: Mon Oct 22 22:48:26 2012 +0300 Initial commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0fcb107 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.sublime-workspace +.cache +*node_modules* diff --git a/.meteor/.gitignore b/.meteor/.gitignore new file mode 100644 index 0000000..4083037 --- /dev/null +++ b/.meteor/.gitignore @@ -0,0 +1 @@ +local diff --git a/.meteor/packages b/.meteor/packages new file mode 100644 index 0000000..f95eb97 --- /dev/null +++ b/.meteor/packages @@ -0,0 +1,13 @@ +# Meteor packages used by this project, one per line. +# +# 'meteor add' and 'meteor remove' will edit this file for you, +# but you can also edit it by hand. + +autopublish +preserve-inputs +jquery +backbone +stylus +accounts-google +underscore +accounts-ui diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..600fdff --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) 2012 Juho Teperi + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..eb7b1ab --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Bicyclesim + +This is a prototype web app developed to be demonstrated at Energia 2012 expo +(http://www.expomark.fi/fi/messut/energia2012). + +This application is built using Meteor JavaScript platform (http://meteor.com/). + +## Usage + +You can try the app at http://bicyclesim.ekokumppanit.fi/. Instructions there are +currently on finnish so here's a short version: + +Select a route from "Simulaatio"-tab and start hitting the space key. +For the best experience connect a bicycle with a trainer to computer using +a speed sensor. + +## Speed sensor + +Most basic speed sensor would be "Keyboard" that sends a keypress whenever +the wheel has turned one revolution. +This kind of sensor can be built from a old keyboard by soldering a reed switch +to right conductors at keyboard circuit board. Some instructions here: +http://www.instructables.com/id/Hacking-a-USB-Keyboard/. + +It is also possible to built equivalent device from Arduino Uno: +http://mitchtech.net/arduino-usb-hid-keyboard/. +Or more easily with the new Arduino Due: +http://www.i-programmer.info/news/91-hardware/4965-new-powerful-arduino-due-.html. diff --git a/bicyclesim.js b/bicyclesim.js new file mode 100644 index 0000000..e6d9125 --- /dev/null +++ b/bicyclesim.js @@ -0,0 +1,40 @@ +var Routes = new Meteor.Collection('routes'); +var Points = new Meteor.Collection('points'); + +Routes.allow({ + insert: function (userId, doc) { + // logged in + owner of route + return (userId && doc.owner === userId); + }, + update: function (userId, docs, fields, modifier) { + return _.all(docs, function (doc) { + return doc.owner === userId; + }); + }, + remove: function (userId, docs) { + return _.all(docs, function (doc) { + return doc.owner === userId; + }); + }, + fetch: ['owner'] +}); + +Points.allow({ + insert: function (userId, doc) { + var route = Routes.findOne({_id: doc.route}); + return (userId && route && route.owner === userId); + }, + update: function (userId, docs, fields, modifier) { + return _.all(docs, function (doc) { + var route = Routes.findOne({_id: doc.route}); + return (route && route.owner === userId); + }); + }, + remove: function (userId, docs) { + return _.all(docs, function (doc) { + var route = Routes.findOne({_id: doc.route}); + return (route && route.owner === userId); + }); + }, + fetch: ['route'] +}); diff --git a/bicyclesim.sublime-project b/bicyclesim.sublime-project new file mode 100644 index 0000000..d2dd325 --- /dev/null +++ b/bicyclesim.sublime-project @@ -0,0 +1,20 @@ +{ + "folders": + [ + { + "file_exclude_patterns": + [ + "*.sublime-workspace" + ], + "folder_exclude_patterns": + [ + ".cache", + ".meteor" + ], + "path": "/home/juho/Source/bicyclesim" + }, + { + "path": "/home/juho/Source/bicyclesim-bundle" + } + ] +} diff --git a/client/bicyclesim-meteor.html b/client/bicyclesim-meteor.html new file mode 100644 index 0000000..bc76884 --- /dev/null +++ b/client/bicyclesim-meteor.html @@ -0,0 +1,197 @@ + + Polkupyöräsimulaattori + + + + + +
+
+ {{> main}} + + + + + + + + + + + + + + + + + + + diff --git a/client/bicyclesim-meteor.styl b/client/bicyclesim-meteor.styl new file mode 100644 index 0000000..6874fe9 --- /dev/null +++ b/client/bicyclesim-meteor.styl @@ -0,0 +1,216 @@ +// --- Variables --- + +$sidebar_width = 300px + +// --- Functions --- + +filter(args ...) + -webkit-filter args + -moz-filter args + filter args + +// --- + +body + overflow hidden + +.navbar-inverse .brand span + color #FFB0B0 + font-size 0.8em + +.navbar-fixed-bottom + position absolute + .navbar-inner + padding-left 15px !important + padding-right 15px !important + i + color #fff + +.sidebar + li + i + color #000 + +a:hover i + text-decoration none +.icon-remove, .icon-remove-sign + &:hover + color #FFAFAF + + +.btn.btn-huge + font-size 34px + line-height 34px + padding 12px 20px + margin-right 30px + +.nav + .divider + color #666 + padding 0 10px + .login + padding-top 6px + + +div.input + padding-right 14px + & > input + margin-bottom 0 + width 100% + +.thumbnail + background #fff + & > span + display block + background #000 + background rgba(0, 0, 0, 0.7) + color #fff + padding 2px 10px + line-height 32px + font-weight bold + & > a + float right + & > span + font-weight normal + color #C8C8C8 + float right + &:hover + text-decoration none + i + color #fff + +a.thumbnail + &:hover + border-color #fff !important + box-shadow 0 0 25px #fff !important + + +.sidebar + position absolute + width $sidebar_width + top 0 + bottom 42px + left 0 + overflow-y auto + +.sidebar-inner + padding 20px + +.street, .map + img + max-width none + border none !important + +.street + position absolute + left 0 + right 0 + bottom 42px + top 0 + z-index 0 + +.map + position absolute + width 300px + height 250px + left 0 + top 0 + opacity 0.8 + display none + +.street + filter(blur(4px)) + +.show-map + .street + filter(blur(0)) + .map + display block + +.show-sidebar + .street, .map + left $sidebar_width + +.frontpage, .help + z-index 100 + position absolute + top 0 + bottom 42px + left 0 + right 0 + background rgba(0, 0, 0, 0.5) + padding-top 30px + overflow-y auto + +.help + background #fff + font-size 1.5em + line-height 1.5em + h1 + margin-top 40px + +.sidebar + h3 + span + float right + +ol.points + margin 0 + li + font-size 0.9em + border-top 1px solid #ccc + padding 2px 0px + list-style-position inside + &:first-child, &.new-point + border-top 0 + &.new-point + list-style none + span.location + font-size 0.8em + a.remove + float right + +.speedometer + position absolute + top 0 + right 0 + background rgba(0, 0, 0, 0.6) + width 190px + padding 15px + border-bottom-left-radius 25px + li + list-style none + text-align right + font-size 2.5em + color #fff + font-weight bold + line-height 150% + +::-webkit-scrollbar + background transparent + border none + +::-webkit-scrollbar:vertical + border-width 0 0 0 1px + width 11px + +::-webkit-scrollbar-corner + background transparent + +/* These rules are for scrollbar draggable panel */ +::-webkit-scrollbar-thumb + background-color rgba(0, 0, 0, 0.6) + box-shadow 0 0 2px rgba(0, 0, 0, 0.9) inset + +::-webkit-scrollbar-thumb:hover + background-color rgba(0, 0, 0, 0.8) + +::-webkit-scrollbar-thumb:active + background-color rgba(0, 0, 0, 1) + +/* These rules are for buttons */ +::-webkit-scrollbar-button:start + display none + +::-webkit-scrollbar-button:end + display none diff --git a/client/edit.js b/client/edit.js new file mode 100644 index 0000000..b6f4095 --- /dev/null +++ b/client/edit.js @@ -0,0 +1,188 @@ +Session.set('points-autoadd', false); +var full_clear_required = true; + +function add_point(latlng, num, trigger) { + maps.lines.route.add(latlng); + maps.markers.add(latlng, {type: 'icon', text: num}); +} + +var num = 1; +function new_point(latlng) { + Meteor.call('insert_point', latlng, Session.get('route'), function () { + add_point(latlng, num, true); + ++num; + $('.sidebar').animate({scrollTop: $('.sidebar-inner').height()}, 'fast'); + }); +} + +// --- Route management --- + +Template.frontpage.editing_route_name = function () { + return Session.equals('editing_route_name', this._id); +}; + +Template.frontpage.can_edit = function () { + var route = Routes.findOne({_id: this._id}); + return (route && Meteor.user() && route.owner === Meteor.user()._id); +}; + +Template.frontpage.logged_in = function () { + return Meteor.user(); +}; + +Template.frontpage.events({ + 'click div.thumbnail img': function (event, template) { + Router.navigate('/edit/' + this._id, {trigger: true}); + }, + 'click a.remove': function () { + Points.remove({route: this._id}); + Routes.remove({_id: this._id}); + }, + 'dblclick div.thumbnail span': function (event, template) { + if (Meteor.user() && this.owner === Meteor.user()._id) { + Session.set('editing_route_name', this._id); + Meteor.flush(); + activateInput(template.find('#route-name')); + } + } +}); + +Template.frontpage.events( + okCancelEvents('#new-route', { + ok: function (text, event) { + Routes.insert({owner: Meteor.user()._id, name: text, first: null}); + event.target.value = ""; + } + }) +); + +Template.frontpage.events( + okCancelEvents('#route-name', { + ok: function (text) { + Routes.update({_id: this._id}, {$set: {name: text}}); + Session.set('editing_route_name', null); + }, + cancel: function () { + Session.set('editing_route_name', null); + } + }) +); + +// --- Points --- + +Template.points.points = function () { + var points = []; + var route = Routes.findOne({_id: Session.get('route')}); + if (route) { + var point = Points.findOne({_id: route.first}); + while (point !== undefined) { + points.push(point); + point = Points.findOne({_id: point.next}); + } + } + return points; +}; + +Template.points.autoadd = function () { + return Session.get('points-autoadd'); +}; + +Template.points.can_edit = function () { + var route = Routes.findOne({_id: Session.get('route')}); + return (route && Meteor.user() && route.owner === Meteor.user()._id); +}; + +Template.points.helpers(helpers); + +Template.points.events({ + 'click li.point': function () { + maps.travel(this._id, {center: true}); + }, + 'click a.remove': function () { + Meteor.call('remove_point', this._id, function () { + full_clear_required = true; + }); + }, + 'click .new-point button': function () { + new_point(maps.getLatLng()); + }, + 'change #points-autoadd': function (event) { + Session.set('points-autoadd', event.currentTarget.checked); + } +}); + +Template.editing.owner = function () { + var route = Routes.findOne({_id: Session.get('route')}); + var user = Meteor.users.findOne({_id: route.owner}); + if (!user) return; + return user.profile.name; +}; + +function init_edit() { + +Meteor.autorun(function () { + if (Session.equals('page', 'edit') && Session.get('route')) { + debug('sivu tai route vaihtui, route: ' + Session.get('route')); + full_clear_required = true; + + Meteor.deps.isolate(function () { + var route = Routes.findOne({_id: Session.get('route')}); + if (route) { + maps.travel(route.first, {route: true}); + } else { + maps.default_pos(); + } + }); + } +}); + +Meteor.autosubscribe(function () { + Session.set('points-autoadd', false); + + if (Session.equals('page', 'edit') && Session.get('route')) { + if (full_clear_required) { + Meteor.deps.isolate(function () { + debug('full clear'); + + maps.lines.route.clear(); + maps.markers.clear(); + num = 1; + + var route = Routes.findOne({_id: Session.get('route')}); + if (route) { + var point = Points.findOne({_id: route.first}); + while (point !== undefined) { + add_point(point.latlng, num, false); + + point = Points.findOne({_id: point.next}); + ++num; + } + full_clear_required = false; + } + }); + } + } +}); + +function autoadd_listener() { + var route = Routes.findOne({_id: Session.get('route')}); + if (Session.get('points-autoadd') && route && Meteor.user() && Meteor.user()._id === route.owner) { + var latlng = maps.getLatLng(); + + // Do not add duplicate points. + if (Points.findOne({route: route._id, latlng: latlng}) !== undefined) return; + + new_point(latlng); + } +} + +window.autoadd_listener = null; +Meteor.autosubscribe(function () { + if (Session.get('points-autoadd')) { + maps.listeners.add('points-autoadd', 'street', 'position_changed', autoadd_listener); + } else { + maps.listeners.remove('points-autoadd'); + } +}); + +} diff --git a/client/jquery.hotkeys.js b/client/jquery.hotkeys.js new file mode 100644 index 0000000..d046a71 --- /dev/null +++ b/client/jquery.hotkeys.js @@ -0,0 +1,106 @@ +/* + * jQuery Hotkeys Plugin + * Copyright 2010, John Resig + * Dual licensed under the MIT or GPL Version 2 licenses. + * + * Based upon the plugin by Tzury Bar Yochay: + * http://github.com/tzuryby/hotkeys + * + * Original idea by: + * Binny V A, http://www.openjs.com/scripts/events/keyboard_shortcuts/ +*/ + +(function(jQuery){ + + jQuery.hotkeys = { + version: "0.8+", + + specialKeys: { + 8: "backspace", 9: "tab", 13: "return", 16: "shift", 17: "ctrl", 18: "alt", 19: "pause", + 20: "capslock", 27: "esc", 32: "space", 33: "pageup", 34: "pagedown", 35: "end", 36: "home", + 37: "left", 38: "up", 39: "right", 40: "down", 45: "insert", 46: "del", + 96: "0", 97: "1", 98: "2", 99: "3", 100: "4", 101: "5", 102: "6", 103: "7", + 104: "8", 105: "9", 106: "*", 107: "+", 109: "-", 110: ".", 111 : "/", + 112: "f1", 113: "f2", 114: "f3", 115: "f4", 116: "f5", 117: "f6", 118: "f7", 119: "f8", + 120: "f9", 121: "f10", 122: "f11", 123: "f12", 144: "numlock", 145: "scroll", 188: ",", 190: ".", + 191: "/", 224: "meta" + }, + + shiftNums: { + "`": "~", "1": "!", "2": "@", "3": "#", "4": "$", "5": "%", "6": "^", "7": "&", + "8": "*", "9": "(", "0": ")", "-": "_", "=": "+", ";": ": ", "'": "\"", ",": "<", + ".": ">", "/": "?", "\\": "|" + } + }; + + function keyHandler( handleObj ) { + + var origHandler = handleObj.handler, + //use namespace as keys so it works with event delegation as well + //will also allow removing listeners of a specific key combination + //and support data objects + keys = (handleObj.namespace || "").toLowerCase().split(" "); + keys = jQuery.map(keys, function(key) { return key.split("."); }); + + //no need to modify handler if no keys specified + if (keys.length === 1 && (keys[0] === "" || keys[0] === "autocomplete")) { + return; + } + + handleObj.handler = function( event ) { + // Don't fire in text-accepting inputs that we didn't directly bind to + // important to note that $.fn.prop is only available on jquery 1.6+ + if ( this !== event.target && (/textarea|select/i.test( event.target.nodeName ) || + event.target.type === "text" || $(event.target).prop('contenteditable') == 'true' )) { + return; + } + + // Keypress represents characters, not special keys + var special = event.type !== "keypress" && jQuery.hotkeys.specialKeys[ event.which ], + character = String.fromCharCode( event.which ).toLowerCase(), + key, modif = "", possible = {}; + + // check combinations (alt|ctrl|shift+anything) + if ( event.altKey && special !== "alt" ) { + modif += "alt_"; + } + + if ( event.ctrlKey && special !== "ctrl" ) { + modif += "ctrl_"; + } + + // TODO: Need to make sure this works consistently across platforms + if ( event.metaKey && !event.ctrlKey && special !== "meta" ) { + modif += "meta_"; + } + + if ( event.shiftKey && special !== "shift" ) { + modif += "shift_"; + } + + if ( special ) { + possible[ modif + special ] = true; + + } else { + possible[ modif + character ] = true; + possible[ modif + jQuery.hotkeys.shiftNums[ character ] ] = true; + + // "$" can be triggered as "Shift+4" or "Shift+$" or just "$" + if ( modif === "shift_" ) { + possible[ jQuery.hotkeys.shiftNums[ character ] ] = true; + } + } + + for ( var i = 0, l = keys.length; i < l; i++ ) { + if ( possible[ keys[i] ] ) { + return origHandler.apply( this, arguments ); + } + } + }; + } + + jQuery.each([ "keydown", "keyup", "keypress" ], function() { + jQuery.event.special[ this ] = { add: keyHandler }; + }); + +})( jQuery ); diff --git a/client/lib/helpers.js b/client/lib/helpers.js new file mode 100644 index 0000000..a1e13bc --- /dev/null +++ b/client/lib/helpers.js @@ -0,0 +1,46 @@ +helpers = { + round: function (num, dig) { + return parseFloat(num).toFixed(dig); + }, + lat: function (latlng) { + return latlng[0].toFixed(5); + }, + lng: function (latlng) { + return latlng[1].toFixed(5); + } +}; + +var activateInput = function (input) { + input.focus(); + input.select(); +}; + +var okCancelEvents = function (selector, callbacks) { + var ok = callbacks.ok || function () {}; + var cancel = callbacks.cancel || function () {}; + + var events = {}; + events['keyup '+selector+', keydown '+selector+', focusout '+selector] = + function (evt) { + if (evt.type === "keydown" && evt.which === 27) { + // escape = cancel + cancel.call(this, evt); + + } else if (evt.type === "keyup" && evt.which === 13 || + evt.type === "focusout") { + // blur/return/enter = ok/submit if non-empty + var value = String(evt.target.value || ""); + if (value) + ok.call(this, value, evt); + else + cancel.call(this, evt); + } + }; + return events; +}; + +// From: https://github.com/tmeasday/meteor-deps-extensions +Meteor.deps.isolate = function(fn) { + var context = new Meteor.deps.Context(); + return context.run(fn); +}; diff --git a/client/lib/maps.js b/client/lib/maps.js new file mode 100644 index 0000000..d0c86ee --- /dev/null +++ b/client/lib/maps.js @@ -0,0 +1,180 @@ +function maps_loaded() { + +window.markers = []; // fuu + +var Maps = function () { + function glatlng(latlng) { + return new google.maps.LatLng(latlng[0], latlng[1]); + } + + var Line = function (attr) { + attr = attr || {}; + + var data = new google.maps.Polyline(attr); + + return { + clear: function () { + data.setPath([]); + }, + add: function (latlng) { + data.getPath().push(glatlng(latlng)); + } + }; + }; + + var Markers = function (map) { + var data = []; + + return { + clear: function () { + for (var i = data.length - 1; i >= 0; --i) { + data[i].setMap(null); + } + data.length = 0; + }, + add: function (latlng, arg) { + var attr = { + map: map, + position: glatlng(latlng) + }; + + if (arg.type == 'icon') { + attr['icon'] = 'http://chart.apis.google.com/chart?chst=d_map_pin_letter&chld=' + arg.text + '|FF0000|000000'; + } + + data.push(new google.maps.Marker(attr)); + } + }; + }; + + var Listeners = function (instances) { + var handles = {}; + + return { + add: function (id, instance, event, cb) { + if (_.has(handles, id)) { + google.maps.event.removeListener(handles[id]); + } + handles[id] = google.maps.event.addListener(instances[instance], event, cb); + }, + remove: function (id) { + google.maps.event.removeListener(handles[id]); + } + }; + }; + + var street = new google.maps.StreetViewPanorama(document.getElementById("street"), { + position: glatlng(settings.default_latlng), + pov: settings.default_pov, + panControl: false, + imageDateControl: false, + scrollwheel: false, + zoomControl: false, + addressControl: false + }); + + var map = new google.maps.Map(document.getElementById("map"), { + center: glatlng(settings.default_latlng), + mapTypeId: google.maps.MapTypeId.ROADMAP, + zoom: 15, + mapTypeControl: false, + draggable: false, + streetView: street, + streetViewControl: false, + scrollwheel: false, + zoomControl: false + }); + window.map = map; + + var bicycling = new google.maps.BicyclingLayer(); + bicycling.setMap(map); + + var lines = { + 'route': new Line({ + map: map, + strokeColor: '#51B5FF' + }), + 'traveled': new Line({ + map: map + }) + }; + + var markers = new Markers(map); + + var listeners = new Listeners({ + map: map, + street: street + }); + + return { + lines: lines, + markers: markers, + listeners: listeners, + getLatLng: function () { + return [street.getPosition().lat(), street.getPosition().lng()]; + }, + mode: function (mode) { + if (mode == 'edit') { + map.setOptions({ + scrollwheel: true, + draggable: true, + streetViewControl: true + }); + street.setOptions({ + clickToGo: true, + linksControl: true + }); + } else if (mode == 'sim') { + map.setOptions({ + scrollwheel: false, + draggable: false, + streetViewControl: false + }); + street.setOptions({ + clickToGo: false, + linksControl: false + }); + } + }, + default_pos: function () { + street.setPosition(glatlng(settings.default_latlng)); + street.setPov(settings.default_pov); + }, + /* + * Travel to a location. + * Updates streetview. + * Optionally centers the map. + */ + travel: function (point_id, arg) { + arg = arg || {}; + _.defaults(arg, { + route: false + }); + + var point = Points.findOne({_id: point_id}); + if (point) { + street.setPosition(glatlng(point.latlng)); + map.setCenter(glatlng(point.latlng)); + + if (point.heading) street.setPov({zoom: 1, pitch: 0, heading: point.heading}); + if (arg.route) lines.traveled.add(point.latlng); + } + } + }; +}; + +$(document).ready(function () { + window.maps = new Maps(); + init_main(); + init_sim(); + init_edit(); +}); + +} + +window.onload = function () { + var script = document.createElement("script"); + script.type = "text/javascript"; + script.src = "http://maps.googleapis.com/maps/api/js?key=" + settings.maps_key + "&sensor=false&callback=maps_loaded"; + document.body.appendChild(script); +}; diff --git a/client/lib/settings.js b/client/lib/settings.js new file mode 100644 index 0000000..9abd16e --- /dev/null +++ b/client/lib/settings.js @@ -0,0 +1,35 @@ +window.settings = { + 'default_latlng': [61.501043, 23.763035], + 'default_pov': { + heading: 200, + pitch: 0, + zoom: 1 + }, + 'debug': false, + 'maps_key': 'AIzaSyDtGhiAnSdg9TaGZC_daNcQe43BS8Ws7Iw', + 'staticmaps_key': 'AIzaSyDtGhiAnSdg9TaGZC_daNcQe43BS8Ws7Iw' +}; + +window.c = function () { + return Math.PI * localStorage.diameter * 2.54 / 100; +}; + +if (_.has(localStorage, 'diameter') && !_.isNumber(localStorage.diameter)) { + delete localStorage.diameter; +} + + +if (_.has(localStorage, 'multiplier') && !_.isNumber(localStorage.multiplier)) { + delete localStorage.multiplier; +} + +_.defaults(localStorage, { + diameter: 28, + multiplier: 2.5 +}); + +window.debug = function () { + if (this.console && settings.debug) { + console.log(Array.prototype.slice.call(arguments)); + } +}; diff --git a/client/main.js b/client/main.js new file mode 100644 index 0000000..50e73ac --- /dev/null +++ b/client/main.js @@ -0,0 +1,185 @@ +// --- Body classes --- +Meteor.autosubscribe(function () { + if (Session.equals('page', 'edit')) { + $("body").addClass('show-sidebar'); + } else { + $("body").removeClass('show-sidebar'); + } + + if (Session.equals('page', 'sim') || Session.equals('page', 'edit')) { + $("body").addClass('show-map'); + } else { + $("body").removeClass('show-map'); + } +}); + +$(document).ready(function () { + if (Session.equals('page', 'edit')) { + $("body").addClass('show-sidebar'); + } + + if (Session.equals('page', 'sim') || Session.equals('page', 'edit')) { + $("body").addClass('show-map'); + } +}); + +// --- Main --- + +Template.main.frontpage = function () { + return Session.equals('page', 'frontpage'); +}; + +Template.main.frontpage_edit = function () { + return Session.equals('page', 'frontpage_edit'); +}; + +Template.main.editing = function () { + if (Session.equals('page', 'edit')) { + return Routes.findOne({_id: Session.get('route')}); + } + return false; +}; + +Template.main.help = function () { + return Session.equals('page', 'help'); +}; + +Template.main.settings = function () { + return Session.equals('page', 'settings'); +}; + +Template.main.sim = function () { + if (Session.equals('page', 'sim')) { + return Routes.findOne({_id: Session.get('route')}); + } + return false; +}; + +// --- Frontpage --- + +Template.frontpage.routes = function () { + return Routes.find(); +}; + +Template.frontpage.editing = function () { + return Session.equals('page', 'frontpage_edit'); +}; + +Template.frontpage.staticmap = function () { + return 'http://maps.googleapis.com/maps/api/staticmap?path=enc:' + this.path + '&size=360x270&key=' + settings.staticmaps_key + '&sensor=false'; +}; + +Template.frontpage.helpers({ + 'km': function (m) { + return (m / 1000).toFixed(2); + } +}); + +// --- Settings --- +Template.settings.settings = function () { + return localStorage; +}; + +Template.settings.events({ + 'change #settings_multiplier': function (event) { + localStorage.setItem('multiplier', parseFloat(event.target.value)); + }, + 'change #settings_diameter': function (event) { + localStorage.setItem('diameter', parseInt(event.target.value, 10)); + } +}); + +// --- Keybinds --- + +$(document).bind('keydown.esc', function () { + Router.navigate('/', {trigger: true}); +}); + +// --- Routes --- + +$(document).on("click", "a[href^='/']", function (event) { + var href = $(event.currentTarget).attr('href'); + + // chain 'or's for other black list routes + var passThrough = href.indexOf('sign_out') >= 0; + + // Allow shift+click for new tabs, etc. + if (!passThrough && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) { + event.preventDefault(); + + // Remove leading slashes and hash bangs (backward compatablility) + var url = href.replace('/^\//', '').replace('#!', ''); + + // Instruct Backbone to trigger routing events + Router.navigate(url, {trigger: true}); + + return false; + } +}); + +var RoutesRouter = Backbone.Router.extend({ + routes: { + "": "index", + "help": "help", + "settings": "settings", + "edit": "index_edit", + ":route_id": "sim", + "edit/:route_id": "edit" + }, + index: function () { + debug('frontpage'); + Session.set('page', 'frontpage'); + Session.set('route', null); + }, + help: function () { + debug('frontpage'); + Session.set('page', 'help'); + Session.set('route', null); + }, + settings: function () { + debug('frontpage'); + Session.set('page', 'settings'); + Session.set('route', null); + }, + index_edit: function () { + debug('frontpage edit'); + Session.set('page', 'frontpage_edit'); + Session.set('route', null); + }, + sim: function (route_id) { + debug('simulation, route: ' + route_id); + Session.set('page', 'sim'); + Session.set('route', route_id); + }, + edit: function (route_id) { + debug('edit, route: ' + route_id); + Session.set('page', 'edit'); + Session.set('route', route_id); + } +}); + +Router = new RoutesRouter(); + +Meteor.startup(function () { + Backbone.history.start({pushState: true}); +}); + +function init_main() { + Meteor.autosubscribe(function () { + // When changing page + + maps.markers.clear(); + maps.lines.traveled.clear(); + maps.lines.route.clear(); + + if (Session.equals('page', 'frontpage') || Session.equals('page', 'frontpage_edit')) { + maps.default_pos(); + } + + if (Session.equals('page', 'edit')) { + maps.mode('edit'); + } else { + maps.mode('sim'); + } + }); +} diff --git a/client/main.sim.js b/client/main.sim.js new file mode 100644 index 0000000..6441696 --- /dev/null +++ b/client/main.sim.js @@ -0,0 +1,101 @@ +window.point = null; +window.traveled = 0; + +var createRingBuffer = function (length){ + var pointer = 0, buffer = [], sum = 0; + + return { + push: function (item) { + if (buffer[pointer] > 0) { + sum -= buffer[pointer]; + if (sum <= 0) sum = 0; + } + buffer[pointer] = item; + sum += item; + pointer = (length + pointer + 1) % length; + }, + sum: function () { + return sum; + } + }; +}; + +var revs = 0; +$(document).bind('keydown.space', function () { + revs += 1; + + var dist = localStorage['multiplier'] * c(); + Session.set('distance', Session.get('distance') + dist); + window.traveled += dist; + + if (window.traveled >= window.point.distance) { + var next = Points.findOne({_id: window.point.next}); + + // Go to next point, if one exists + if (next) { + window.traveled -= next.distance; + + maps.travel(next._id, {route: true}); + window.point = next; + } + } +}); + +function speedo() { + speed_buffer.push(revs * c()); + revs = 0; + + Session.set('speed', speed_buffer.sum() / 5); +} + +setInterval(speedo, 500); + +// 5sec, 2 values / sec. +var speed_buffer = createRingBuffer(5 * 2); +var speed_sum = 0; +var speed_avg = 0; + +Template.sim.speed = function () { + return Session.get('speed'); +}; + +Template.sim.distance = function () { + return Session.get('distance'); +}; + +Template.sim.helpers({ + kmh: function (ms) { + return (ms * 60 * 60 / 1000).toFixed(1); + }, + km: function (m) { + return (m / 1000).toFixed(2); + } +}); + +function init_sim() { + +Meteor.autosubscribe(function () { + if (Session.equals('page', 'sim')) { + var route = Routes.findOne({_id: Session.get('route')}); + if (route) { + Session.set('distance', 0); + Session.set('speed', 0); + window.traveled = 0; + maps.lines.traveled.clear(); + maps.lines.route.clear(); + + // Siirytään reitin alkuun + window.point = Points.findOne({_id: route.first}); + maps.travel(route.first, {route: true}); + + // Show full route on map + var p = window.point; + while (p !== undefined) { + maps.lines.route.add(p.latlng); + p = Points.findOne({_id: p.next}); + } + } + } +}); + +} diff --git a/server/lib/maps.js b/server/lib/maps.js new file mode 100644 index 0000000..9c55c45 --- /dev/null +++ b/server/lib/maps.js @@ -0,0 +1,94 @@ +/* + * Latitude/longitude spherical geodesy formulae & scripts (c) Chris Veness 2002-2012 + * From: http://www.movable-type.co.uk/scripts/latlong.html + */ + +if (typeof Number.prototype.toRad == 'undefined') { + Number.prototype.toRad = function() { + return this * Math.PI / 180; + }; +} + +if (typeof Number.prototype.toDeg == 'undefined') { + Number.prototype.toDeg = function() { + return this * 180 / Math.PI; + }; +} + +function computeDistance(from, to) { + // Equirectangular approximation, should be enough as distances are small + var R = 6371000; // km + var x = (to[1].toRad() - from[1].toRad()) * Math.cos((from[0].toRad() + to[0].toRad()) / 2); + var y = (from[0].toRad() - to[0].toRad()); + return Math.sqrt(x * x + y * y) * R; +} + +function computeInitialBearing(from, to) { + var dLat = (to[0] - from[0]).toRad(); + var dLon = (to[1] - from[1]).toRad(); + var lat1 = from[0].toRad(); + var lat2 = to[0].toRad(); + + var y = Math.sin(dLon) * Math.cos(lat2); + var x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon); + return (Math.atan2(y, x).toDeg() + 360) % 360; +} + +function computeFinalBearing(from, to) { + return (computeInitialBearing(to, from) + 180) % 360; +} + +// Rest are copied from: https://github.com/moshen/node-googlemaps/blob/master/lib/googlemaps.js +function createEncodedPolyline(points) { + // Dear maintainer: + // + // Once you are done trying to 'optimize' this routine, + // and have realized what a terrible mistake that was, + // please increment the following counter as a warning + // to the next guy: + // + // total_hours_wasted_here = 11 + // + var i, dlat, dlng; + var plat = 0; + var plng = 0; + var encoded_points = ""; + if(typeof points === 'string'){ + points = points.split('|'); + } + + for(i = 0; i < points.length; i++) { + var point = points[i];//.split(','); + var lat = point[0]; + var lng = point[1]; + var late5 = Math.round(lat * 1e5); + var lnge5 = Math.round(lng * 1e5); + dlat = late5 - plat; + dlng = lnge5 - plng; + plat = late5; + plng = lnge5; + encoded_points += encodeSignedNumber(dlat) + encodeSignedNumber(dlng); + } + return encoded_points; +} + +function encodeNumber(num) { + var encodeString = ""; + var nextValue, finalValue; + while (num >= 0x20) { + nextValue = (0x20 | (num & 0x1f)) + 63; + encodeString += (String.fromCharCode(nextValue)); + num >>= 5; + } + finalValue = num + 63; + encodeString += (String.fromCharCode(finalValue)); + return encodeString; +} + +function encodeSignedNumber(num) { + var sgn_num = num << 1; + if (num < 0) { + sgn_num = ~(sgn_num); + } + return(encodeNumber(sgn_num)); +} diff --git a/server/main.js b/server/main.js new file mode 100644 index 0000000..15fccab --- /dev/null +++ b/server/main.js @@ -0,0 +1,66 @@ + +function update_route(route_id) { + var path = []; + var route = Routes.findOne({_id: route_id}); + var len = 0; + if (route) { + var point = Points.findOne({_id: route.first}); + var next = null; + var heading = 0; + while (point !== undefined) { + path.push(point.latlng); + + next = Points.findOne({_id: point.next}); + + var distance = 0; + // Uses previous heading if no next point + if (next) { + heading = computeFinalBearing(point.latlng, next.latlng); + distance = computeDistance(point.latlng, next.latlng); + } + + len += distance; + Points.update({_id: point._id}, {$set: { + heading: heading, + distance: distance + }}); + + point = next; + } + } + + Routes.update({_id: route_id}, {$set: { + path: createEncodedPolyline(path), + route_length: len + }}); +} + + +Meteor.methods({ + insert_point: function (latlng, route_id) { + var route = Routes.findOne({_id: route_id}); + if (!route) return; + + var point_id = Points.insert({latlng: latlng, route: route_id, distance: 0, next: null}); + + // Add new point after last point. + Points.update({route: route_id, next: null, _id: {$ne: point_id}}, {$set: {next: point_id}}); + + // If route didn't have any point, update first point, + Routes.update({_id: route_id, first: null}, {$set: {first: point_id}}); + + update_route(route_id); + }, + remove_point: function (point_id) { + var point = Points.findOne({_id: point_id}); + // Update previous point to point to point after removed one + Points.update({next: point._id}, {$set: {next: point.next}}); + + // Point was first of route, update routes first to removed points next. + Routes.update({first: point._id}, {$set: {first: point.next}}); + + Points.remove({_id: point_id}); + + update_route(point.route); + } +});