maps.nyc.gov

Source: ol/tracker.js

var nyc = nyc || {};
nyc.ol = nyc.ol || {};

/**
 * @desc A class for tracking position from device Geolocation
 * @public
 * @class
 * @extends {ol.Geolocation}
 * @see http://openlayers.org/en/latest/apidoc/ol.Geolocation.html
 * @constructor
 * @param {nyc.ol.Tracker.Options} options Constructor options
 * @fires nyc.ol.Tracker#updated
 */
nyc.ol.Tracker = function(options){
	/**
	 * @public
	 * @member {ol.geom.LineString}
	 */
	 this.track = new ol.geom.LineString([], 'XYZM');
	/**
	 * @public
	 * @member {Array<ol.Feature>}
	 */
	 this.positions = [];
	/**
	 * @public
	 * @member {number}
	 */
	this.maxPoints = options.maxPoints;
	/**
	 * @public
	 * @member {number}
	 */
	this.accuracyLimit = options.accuracyLimit === undefined ? 0 : options.accuracyLimit;
	/**
	 * @public
	 * @member {boolean}
	 */
	this.recenter = options.recenter === undefined ? true : options.recenter;
	/**
	 * @public
	 * @member {boolean}
	 */
	this.rotate = options.rotate === undefined ? true : options.rotate;
	/**
	 * @public
	 * @member {number}
	 */
	this.skipViewUpdate = false;
	/**
	 * @public
	 * @member {number}
	 */
	this.startingZoomLevel = options.startingZoomLevel === undefined ? 16 : options.startingZoomLevel;
	/**
	 * @public
	 * @member {boolean}
	 */
	this.currentZoomLevel = options.currentZoomLevel === undefined ? false : options.currentZoomLevel;
	/**
	 * @private
	 * @member {ol.Map}
	 */
	this.map = options.map;
	/**
	 * @private
	 * @member {ol.View}
	 */
	this.view = this.map.getView();
	/**
	 * @private
	 * @member {nyc.Dialog}
	 */
	this.dia = new nyc.Dialog();
	/**
	 * @private
	 * @member {number}
	 */
	this.deltaMean = 500;
	/**
	 * @private
	 * @member {number}
	 */
	this.previousM = 0;
	/**
	 * @private
	 * @member {boolean}
	 */
	this.firstRun = true;
	/**
	 * @private
	 * @member {JQuery}
	 */
	this.img = $('<img class="current-position">').hide();
	/**
	 * @private
	 * @member {number}
	 */
	this.animationInterval;
	/**
	 * @dec For testing only
	 * @private
	 * @member {number}
	 */
	this.animationStep;
	/**
	 * @dec For testing only
	 * @private
	 * @member {Array<ol.Coordinate>}
	 */
	this.animatedPositions = [];
	/**
	 * @private
	 * @member {boolean}
	 */
	this.animating = false;
	/**
	 * @private
	 * @member {ol.format.GeoJSON}
	 */
	this.geoJson = new ol.format.GeoJSON();
	/**
	 * @private
	 * @member {nyc.ol.storage.Local}
	 */
	this.storage = new nyc.ol.storage.Local();
	/**
	 * @private
	 * @member {nyc.ol.NorthArrow}
	 */
	this.northArrow = new nyc.ol.NorthArrow(this.map);
	this.showNorth(options.northArrow);

	var appUrl = document.location.href.replace(document.location.search, '');
	/**
	 * @private
	 * @member {string}
	 */
	this.trackStore = appUrl + 'nyc.ol.Tracker.track';
	/**
	 * @private
	 * @member {string}
	 */
	this.positionsStore = appUrl + 'nyc.ol.Tracker.positions';

	ol.Geolocation.call(this, {
		projection: this.view.getProjection(),
		trackingOptions: this.createTrackOpts(options.trackingOptions)
	});

	$('body').append(this.img);

	var overlayOpts = {
		positioning: 'center-center',
		element: this.img.get(0),
		stopEvent: false
	};
	this.markerOverlay = new ol.Overlay(overlayOpts);
	this.markerOverlay.setProperties(overlayOpts); // for testing to ensure proper args were used
	this.map.addOverlay(this.markerOverlay);

	this.on('change', this.updatePosition, this);
	this.on('error', function(error){
		console.error(error.message, arguments);
	});
};

ol.inherits(nyc.ol.Tracker, ol.Geolocation);

/**
 * @desc Restore previous tracking data if available
 * @public
 * @method
 */
nyc.ol.Tracker.prototype.restore = function(){
	var me = this;
	var track = me.storage.getItem(me.trackStore);
	var positions = me.storage.getItem(me.positionsStore);
	if (track){
		me.dia.yesNo({
			message: 'Restore previous tracking data?',
			callback: function(yesNo){
				if (yesNo){
					var opts = {
						dataProjection: 'EPSG:4326',
						featureProjection: me.view.getProjection()
					};
					me.track = me.geoJson.readGeometry(track, opts);
					me.positions = me.geoJson.readFeatures(positions, opts);
					me.updateView(me.positions[me.positions.length - 1]);
				}else{
					me.reset();
				}
			}
		});
	}else{
		this.reset();
	}
	me.updatePosition();
};

/**
 * @desc Enable or disable tracking
 * @public
 * @override
 * @see http://openlayers.org/en/latest/apidoc/ol.Geolocation.html#setTracking
 * @method
 * @param {boolean} tracking Whether or not to track position
 */
nyc.ol.Tracker.prototype.setTracking = function(tracking){
	if (tracking){
		var wasTracking = this.getTracking();
		if (this.firstRun){
			this.firstRun = false;
			this.restore();
		}else if (!wasTracking){
			this.reset();
		}
		this.showNorth(true);
		this.img.show();
	}else{
		this.showNorth(false);
		this.img.hide();
		if (!this.firstRun){
			this.storage.removeItem(this.trackStore);
			this.storage.removeItem(this.positionsStore);
		}
	}
	ol.Geolocation.prototype.setTracking.call(this, tracking);
};

/**
 * @desc Orient the view to the current position
 * @public
 * @method
 * @param {ol.Coordinate|ol.Feature} position
 */
nyc.ol.Tracker.prototype.updateView = function(position){
	if (!this.skipViewUpdate){
		var pIdx = this.positions.length - 1;
		if (pIdx % 2 == 0){
			var options;
			if ('getGeometry' in position){
				position = position.getGeometry().getCoordinates();
				options = {zoom: this.startingZoomLevel};
			}else if (!this.currentZoomLevel && pIdx == 0){
				options = {zoom: this.startingZoomLevel};
			}
			if (this.recenter){
				options = options || {};
				options.center = this.getCenterWithHeading(position, -position[2], options.zoom);
			}
			if (this.rotate){
				options = options || {};
				options.rotation = -position[2];
			}
			if (options){
				this.view.cancelAnimations();
				this.view.animate(options);
			}
		}
	}
};

/**
 * @private
 * @method
 * @param {boolean} showNorth
 */
nyc.ol.Tracker.prototype.showNorth = function(show){
	if (show === undefined || show){
		this.northArrow.show();
	}else{
		this.northArrow.hide();
	}
};

/**
 * @private
 * @method
 * @param {GeolocationPositionOptions=} options
 * @return {GeolocationPositionOptions}
 */
nyc.ol.Tracker.prototype.createTrackOpts = function(options){
	var trackOpts = options || {};
	trackOpts.maximumAge = trackOpts.maximumAge === undefined ? 10000 : trackOpts.maximumAge;
	trackOpts.enableHighAccuracy = trackOpts.enableHighAccuracy === undefined ? true : trackOpts.enableHighAccuracy;
	trackOpts.timeout = trackOpts.timeout === undefined ? 600000 : trackOpts.timeout;
	return trackOpts;
};

/**
 * @private
 * @method
 */
nyc.ol.Tracker.prototype.updatePosition = function(){
	var position = this.getPosition();
	if (position){
		var accuracy =  this.getAccuracy();
		var heading =  this.getHeading() || 0;
		var speed =  this.getSpeed() || 0;
		if (this.addPosition(position, accuracy, heading, Date.now(), speed)){
			var positions = this.track.getCoordinates();
			var len = positions.length;
			if (len >= 2){
				this.deltaMean = (positions[len - 1][3] - positions[0][3]) / (len - 1);
			}
			this.animate();
		}
	}
};

/**
 * @private
 * @method
 * @param {ol.Coordinate} position
 * @param {number} accuracy
 * @param {number} heading
 * @param {number} m
 * @param {number} speed
 * @return {boolean}
 */
nyc.ol.Tracker.prototype.addPosition = function(position, accuracy, heading, m, speed){
	if (!this.accuracyLimit || accuracy <= this.accuracyLimit){
		heading = this.determineHeading(position, heading);
		this.updateGeometries(position, accuracy, heading, m);
		this.marker(speed, heading);
		this.dispatchEvent({type: nyc.ol.Tracker.EventType.UPDATED, target: this});
		return true;
	}
};

/**
 * @private
 * @method
 * @param {ol.Coordinate} position
 * @param {number} accuracy
 * @param {number} m
 */
nyc.ol.Tracker.prototype.updateGeometries = function(position, accuracy, heading, m){
	position = [position[0], position[1], heading, m];
	this.positions.push(new ol.Feature({
		id: this.positions.length,
		geometry: new ol.geom.Point(position),
		accuracy: accuracy,
		timestamp: new Date(m).toISOString()
	}));
	this.track.appendCoordinate(position);
	if (this.maxPoints){
		this.track.setCoordinates(this.track.getCoordinates().slice(-this.maxPoints));
		this.positions = this.positions.slice(-this.maxPoints);
	}
	this.store();
};

/**
 * @private
 * @method
 * @param {ol.Coordinate} position
 * @param {number} heading
 * @return {number}
 */
nyc.ol.Tracker.prototype.determineHeading = function(position, heading){
	var coords = this.track.getCoordinates();
	var previous = coords[coords.length - 1];
	var prevHeading = previous && previous[2];
	if (prevHeading){
		var headingDiff = heading - this.mod(prevHeading);
		// force the rotation change to be less than 180°
		if (Math.abs(headingDiff) > Math.PI){
			var sign = (headingDiff >= 0) ? 1 : -1;
			headingDiff = -sign * (2 * Math.PI - Math.abs(headingDiff));
		}
		heading = prevHeading + headingDiff;
	}
	return heading;
};

/**
 * @private
 * @method
 */
nyc.ol.Tracker.prototype.store = function(){
	this.storage.setItem(
		this.trackStore,
		this.geoJson.writeGeometry(
			this.track,
			{featureProjection: this.view.getProjection()}
		)
	);
	this.storage.setItem(
		this.positionsStore,
		this.geoJson.writeFeatures(
			this.positions,
			{featureProjection: this.view.getProjection()}
		)
	);
};

/**
 * @private
 * @method
 */
nyc.ol.Tracker.prototype.reset = function(){
	this.track = new ol.geom.LineString([], 'XYZM');
	this.positions = [];
	this.store();
};

/**
 * @private
 * @method
 * @param {number} heading
 * @param {number} speed
 */
nyc.ol.Tracker.prototype.marker = function(speed, heading){
	if (speed){
		this.img.attr('src', nyc.ol.Tracker.LOCATION_HEADING_IMG);
		var transform = 'rotate(0rad)';
		if (!this.rotate || this.skipViewUpdate){
			// not rotating view so let's rotate marker
			heading += this.view.getRotation();
			transform = 'rotate(' + heading + 'rad)';
		}
		this.img.css({
			transform: transform,
			'-webkit-transform': transform,
			'-ms-transform': transform
		});
	}else{
		this.img.attr('src', nyc.ol.Tracker.LOCATION_IMG);
	}
};

/**
 * @private
 * @method
 * @param {ol.Coordinate} position
 * @param {number} rotation
 * @param {number} zoom
 */
nyc.ol.Tracker.prototype.getCenterWithHeading = function(position, rotation, zoom){
	var size = this.map.getSize();
	var resolution = zoom ? nyc.ol.TILE_GRID.getResolution(zoom) : this.view.getResolution();
	var height = size[1];
	return [
		position[0] - Math.sin(rotation) * height * resolution / 4,
		position[1] + Math.cos(rotation) * height * resolution / 4
	];
};

/**
 * @private
 * @method
 */
nyc.ol.Tracker.prototype.animate = function(){
	var me = this;
	var positions = me.track.getCoordinates();
	var end = positions[positions.length - 1];
	var start = positions[positions.length - 2];
	var marker = me.markerOverlay;

	me.animatedPositions = [];

	if (start){
		if (me.animating){
			me.animating = false;
			clearInterval(me.animationInterval);
			me.updateView(end);
		}
		var m = start[3];
		var mEnd = end[3];
		var step = (mEnd - m)/10;
		me.animating = true;
		me.animationInterval = setInterval(function(){
			var p = me.track.getCoordinateAtM(m, true);
			if (m >= mEnd){
				me.animating = false;
				clearInterval(me.animationInterval);
				p = end;
				me.updateView(p);
			}
			me.animatedPositions.push(p);
			marker.setPosition(p);
			m += step;
		}, me.animationStep || 100);
	}else{
		marker.setPosition(end);
		me.updateView(end);
	}
};

/**
 * @private
 * @method
 * @param {number} n
 */
nyc.ol.Tracker.prototype.mod = function(n){
	return ((n % (2 * Math.PI)) + (2 * Math.PI)) % (2 * Math.PI);
};

/**
 * @desc Object type to hold constructor options for {@link nyc.ol.Tracker}
 * @public
 * @typedef {Object}
 * @property {ol.Map} map The map on which to track locations
 * @property {GeolocationPositionOptions=} trackingOptions Tracking options @see http://www.w3.org/TR/geolocation-API/#position_options_interface
 * @property {boolean} [recenter=true] Recenter the view on location change
 * @property {boolean} [rotate=true] Rotate the view on location change
 * @property {boolean} [showNorth=true] Show a north arrow on the map
 * @property {number} [maxPoints=0] The maximum number of points to retain in the track (0 = unlimited)
 * @property {number} [startingZoomLevel=16] The zoom for the view when tracking begins
 * @property {boolean} [currentZoomLevel=false] Use the current zoom level of the view when tracking begins and ignore startingZoomLevel
 * @property {number} [accuracyLimit=0] The maximum accuracy distance for an acceptable position (0 = unlimited)
 */
nyc.ol.Tracker.Options;

/**
 * @desc The updated event
 * @event nyc.ol.Tracker#updated
 * @type {ol.events.Event}
 */

/**
 * @desc Enumeration for tracker event types
 * @public
 * @enum {string}
 */
nyc.ol.Tracker.EventType = {
	/**
	 * @desc The updated event type
	 */
	UPDATED: 'updated'
};

nyc.ol.Tracker.LOCATION_IMG = 'data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3Asvg%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22256%22%20height%3D%22256%22%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%22128%22%20r%3D%2264%22%20style%3D%22fill%3A%2338c%3Bstroke%3A%23fff%3Bstroke-width%3A15%22%2F%3E%3C%2Fsvg%3E';

nyc.ol.Tracker.LOCATION_HEADING_IMG = 'data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3Asvg%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22256%22%20height%3D%22256%22%3E%3Cpath%20stroke-linejoin%3D%22round%22%20stroke-linecap%3D%22round%22%20style%3D%22fill%3A%2338c%3Bstroke-width%3A15%3Bstroke%3A%23fff%22%20d%3D%22M128%208%20L%2055%2080%20L%20128%2060%20L%20201%2080%20L%20128%208%22%2F%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%22128%22%20r%3D%2264%22%20style%3D%22fill%3A%2338c%3Bstroke%3A%23fff%3Bstroke-width%3A15%22%2F%3E%3C%2Fsvg%3E';