Initial commit.

This commit is contained in:
Juho Teperi
2012-10-22 22:48:26 +03:00
commit 86c3fb0537
18 changed files with 1526 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*.sublime-workspace
.cache
*node_modules*

1
.meteor/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
local

13
.meteor/packages Normal file
View File

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

7
LICENSE Normal file
View File

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

28
README.md Normal file
View File

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

40
bicyclesim.js Normal file
View File

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

View File

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

View File

@@ -0,0 +1,197 @@
<head>
<title>Polkupyöräsimulaattori</title>
<link href="//netdna.bootstrapcdn.com/twitter-bootstrap/2.1.1/css/bootstrap.no-icons.min.css" rel="stylesheet">
<link href="//netdna.bootstrapcdn.com/font-awesome/2.0/css/font-awesome.css" rel="stylesheet">
</head>
<body>
<div id="street" class="street"></div>
<div id="map" class="map"></div>
{{> main}}
</body>
<template name="main">
{{#if editing}}
{{> editing}}
{{/if}}
{{#if sim}}
{{> sim}}
{{/if}}
{{#if frontpage}}
{{> frontpage}}
{{/if}}
{{#if frontpage_edit}}
{{> frontpage}}
{{/if}}
{{#if help}}
{{> help}}
{{/if}}
{{#if settings}}
{{> settings}}
{{/if}}
<div class="navbar navbar-inverse navbar-fixed-bottom">
<div class="navbar-inner">
<a class="brand" href="/">Bicycle sim <span>v0.1</span></a>
<ul class="nav">
<li{{#if frontpage}} class="active"{{/if}}{{#if sim}} class="active"{{/if}}>
<a href="/">
<i class="icon-road"></i> Simulaatio
{{#if sim}} <span class="divider">/</span> {{sim.name}}{{/if}}
</a>
</li>
<li{{#if frontpage_edit}} class="active"{{/if}}{{#if editing}} class="active"{{/if}}>
<a href="/edit">
<i class="icon-wrench"></i> Muokkaa
{{#if editing}} <span class="divider">/</span> {{editing.name}}{{/if}}
</a>
</li>
<li{{#if settings}} class="active"{{/if}}><a href="/settings"><i class="icon-cogs"></i> Asetukset</a></li>
<li{{#if help}} class="active"{{/if}}><a href="/help"><i class="icon-question-sign"></i> Ohjeet</a></li>
</ul>
<ul class="nav pull-right">
<li class="login">{{loginButtons}}</li>
<li><a href="http://github.com/Ekokumppanit/Bicyclesim" target="new"><i class="icon-github"></i> Github</a></li>
</ul>
</div>
</div>
</template>
<template name="help">
<div class="help">
<div class="container">
<div class="fluid-row">
<h1>Polkupyöräsimulaattori Kuka, Mitä?</h1>
<p>
<a href="http://www.ekokumppanit.fi" target="new">Ekokumppanit Oy:n</a> työntekijä Juho Teperi on toteuttanut tämän prototyyppi web-sovelluksen
<a href="http://www.expomark.fi/fi/messut/energia2012" target="new">Energia 2012</a> messuja varten.
Ohjelmassa voit liikkua (polkea) ympäri maailmaa (tai ainakin valmiita reittejä).
</p>
<h1>Käyttö</h1>
<p>
Omalla koneellasi voit kokeilla toimintaa valitsemalla <em>Simulaatio</em>-välilehdeltä reitin ja hakkaamalla välilyöntiä.
Parhaan kokemuksen saat kun liität tietokoneeseen polkupyörän telineen <small>(traineri tai rolleri)</small> ja nopeusanturin <small>(laite joka lähettää näppäinpainalluksen kun rengas on pyörähtänyt kierroksen)</small> avulla.
</p>
<h1>Toteutus</h1>
<p>
Ohjelma on toteutettu käyttäen <a href="http://meteor.com" target="new">Meteor</a> JavaSript sovelluskehystä <small>(käyttää palvelinpuolella Node.js)</small>.
Kuvat tulevat Google Streetview palvelusta Googlen tarjoaman JavaScript rajapinnan kautta.
</p>
<h1>Vähän siistiä, mistä saan tämän itselleni?</h1>
<p>
Voit joko käyttää valmista ohjelmaa osoitteessa <a href="http://bicyclesim.ekokumppanit.fi">bicyclesim.ekokumppanit.fi</a> tai
hakea ohjelman lähdekoodin <a href="http://github.com/Ekokumppanit/Bicyclesim" target="new">Githubista</a>,
voit myös tehdä vapaasti muutoksia koodiin ja lähettää parannuksesi viralliseen versioon jotta muutkin hyötyvät niistä.
</p>
</div>
</div>
</div>
</template>
<template name="settings">
<div class="help">
<div class="container">
<div class="fluid-row">
<form>
<legend>Asetukset</legend>
<label>Liikekerroin. Vaikuttaa nopeuteen jolla reitillä liikutaan. Ei vaikuta nopeuslukemaan tai kuljettuun matkaan.</label>
<input type="text" value="{{settings.multiplier}}" id="settings_multiplier"/>
<label>Renkaan koko, tuumia</label>
<input type="text" value="{{settings.diameter}}" id="settings_diameter"/>
</form>
</div>
</div>
</div>
</template>
<template name="frontpage">
<div class="frontpage">
<div class="container">
<div class="fluid-row">
<ul class="thumbnails">
{{#each routes}}
<li class="span3">
{{#if editing}}
<div class="thumbnail">
<img src="{{staticmap}}" alt=""/>
<span>
{{#if editing_route_name}}
<div class="input"><input type="text" id="route-name" value="{{name}}"/></div>
{{else}}
{{name}}
{{#if can_edit}}<a href="#" class="remove"><i class="icon-remove-sign"></i></a>{{/if}}
{{/if}}
</span>
</div>
{{else}}
<a href="/{{_id}}" class="thumbnail">
<img src="{{staticmap}}" alt=""/>
<span>{{name}} <span>{{km route_length}}km</span></span>
</a>
{{/if}}
</li>
{{/each}}
{{#if editing}}
<li class="span3 new-route">
{{#if logged_in}}<div class="input"><input type="text" id="new-route" placeholder="New route"/></div>{{/if}}
</li>
{{/if}}
</ul>
</div>
<div class="fluid-row">
<a href="/help" class="btn btn-huge"><i class="icon-question-sign"></i> Ohjeet</a>
<a href="http://github.com/Ekokumppanit/Bicyclesim" class="btn btn-huge" target="new"><i class="icon-github"></i> Projekti Githubissa</a>
</div>
</div>
</div>
</template>
<template name="editing">
<div class="sidebar">
<div class="sidebar-inner">
<h3>Owner</h3>
<p>{{owner}}</p>
{{> points}}
</div>
</div>
</template>
<template name="sim">
<ul class="speedometer">
<li class="distance">{{km distance}} km</li>
<li class="speed">{{kmh speed}} <sup>km</sup>/<sub>h<sub></li>
<li class="calories"></li>
</ul>
</template>
<template name="distance">
<span>{{distance}}</span> km
</template>
<template name="speed">
<span>{{speed}}</span> <sup>km</sup>/<sub>h</sub>
</template>
<template name="calories">
<span>{{calories}}</span> cal
</template>
<template name="points">
<h3>Points
<span><label class="checkbox inline"><input type="checkbox" id="points-autoadd" {{#if autoadd}}checked="checked"{{/if}}/>Auto add points</label></span>
</h3>
<ol class="points">
{{#each points}}
<li class="point{{#if active}} active{{/if}}">
<span class="location">{{lat latlng}}, {{lng latlng}}</span>,
<span class="heading">{{round heading 2}}°</span>,
<span class="distance">{{round distance 2}}m</span>
{{#if can_edit}}<a href="#" class="remove"><i class="icon-remove-sign"></i></a>{{/if}}
</li>
{{/each}}
{{#if can_edit}}<li class="new-point"><button type="button" class="btn"><i class="icon-plus-sign"></i></button></li>{{/if}}
</ol>
</template>

View File

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

188
client/edit.js Normal file
View File

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

106
client/jquery.hotkeys.js Normal file
View File

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

46
client/lib/helpers.js Normal file
View File

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

180
client/lib/maps.js Normal file
View File

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

35
client/lib/settings.js Normal file
View File

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

185
client/main.js Normal file
View File

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

101
client/main.sim.js Normal file
View File

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

94
server/lib/maps.js Normal file
View File

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

66
server/main.js Normal file
View File

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