implementing animation, color picker, and markers
diff --git a/js/app.js b/js/app.js index 03b9fb6..f60ccf9 100644 --- a/js/app.js +++ b/js/app.js
@@ -47,6 +47,12 @@ timeRange.domElement.style.display = 'none'; timeRange.domElement.className = 'time-range'; + // Set the default range colors. + overlay.setStartColor(controls.state.style.startColor); + overlay.setEndColor(controls.state.style.endColor); + timeRange.setStartColor(controls.state.style.startColor); + timeRange.setEndColor(controls.state.style.endColor); + // Update the Overlay viewport when the map bounds change. map.setOverlayElement(overlay.domElement); map.onBoundsChanged(overlay.setBounds.bind(overlay)); @@ -60,16 +66,50 @@ // Listen for settings and data events from the controls. controls.domElement.addEventListener('settings-changed', function(event) { var setting = event.detail.folderName + '/' + event.detail.optionName; - if (setting === 'data/multiplier') { - overlay.setMultiplier(event.detail.value); - } else if (setting === 'data/medianCorrection') { - overlay.setMedianCorrection(event.detail.value); + var value = event.detail.value; + switch (setting) { + case 'data/multiplier': + overlay.setMultiplier(value); + break; + case 'data/medianCorrection': + overlay.setMedianCorrection(value); + break; + case 'data/showMarkers': + map.setMarkerVisibility(value); + break; + case 'style/startColor': + overlay.setStartColor(value); + timeRange.setStartColor(value); + break; + case 'style/endColor': + overlay.setEndColor(value); + timeRange.setEndColor(value); + break; + case 'style/lineWidth': + overlay.setLineWidth(value); + break; + case 'animation/enabled': + value ? overlay.startAnimation() : overlay.stopAnimation(); + break; + case 'animation/duration': + overlay.setAnimationDuration(value); + break; + case 'animation/delay': + overlay.setAnimationDelay(value); + break; + case 'animation/showStats': + value ? overlay.showStats() : overlay.hideStats(); + break; + default: + throw Error('Unrecogrized setting: ' + setting); + break; } }, false); // Listen for data-ready events from the controls element, feed to components // that need to know. controls.domElement.addEventListener('data-ready', function(event) { + map.setData(event.detail); overlay.setData(event.detail); }, false);
diff --git a/js/controls.js b/js/controls.js index 9c54a20..48bedff 100644 --- a/js/controls.js +++ b/js/controls.js
@@ -89,7 +89,12 @@ Object.keys(folderSettings.options).forEach(function(optionName) { var option = folderSettings.options[optionName]; folderState[optionName] = option.defaultValue; - var ctrl = folder.add(folderState, optionName); + var ctrl; + if (option.type === 'color') { + ctrl = folder.addColor(folderState, optionName); + } else { + ctrl = folder.add(folderState, optionName); + } if ('min' in option) { ctrl = ctrl.min(option.min); }
diff --git a/js/map.js b/js/map.js index 4cbeebe..4a18cac 100644 --- a/js/map.js +++ b/js/map.js
@@ -16,10 +16,13 @@ * limitations under the License. */ -// Map requires google maps. +// Map requires Google Maps and d3. if (typeof google === 'undefined' || typeof google.maps === 'undefined') { throw Error('Google Maps is required but missing.'); } +if (typeof d3 === 'undefined') { + throw Error('d3 is required but missing.'); +} var geovelo; geovelo = geovelo || {}; @@ -41,14 +44,20 @@ containerElement.appendChild(this.domElement); } + // Array of Google Maps Markers. Will be created when data arrives. + this.markers = null; + + // Whether markers should be visible. + this.markerVisibility = false; + // Callback handlers for when the bounds change. this.boundsChangedHandlers = []; - // Initial coordinates. + // Initial coordinates. Will center on data once loaded. var initial = { - zoom: 5, - lon: 139.7667, - lat: 35.6833 + zoom: 2, + lon: 0, + lat: 0, }; // Google Map Stylers. @@ -82,7 +91,7 @@ // Create the Google Map var map = this.map = new google.maps.Map(this.domElement, { - zoom: 5, + zoom: initial.zoom, center: new google.maps.LatLng(initial.lat, initial.lon), mapTypeControl: false, mapTypeId: google.maps.MapTypeId.TERRAIN, @@ -109,6 +118,137 @@ //map.addListener('center_changed', emit); map.addListener('zoom_changed', emit); map.addListener('idle', emit); + + // D3 selection which will be host to the clicked beacon's info. + this.infoContent = d3.select(document.createElement('div')).html(` + <h2 class="name"></h2> + <p> + Start: <span class="start"></span> + </p> + <p> + Lat, Lon: <span class="lat"></span>, <span class="lon"></span> + </p> + `); + + // Info window to show data about a particular marker. Content is bound to + // the infoContent div. + this.infoWindow = new google.maps.InfoWindow({ + content: this.infoContent[0][0] + }); +}; + +/** + * New beacon data is available. Set up markers and zoom over there. + * + * @param {Array} beacons An array of data for the beacons. + */ +geovelo.Map.prototype.setData = function(beacons) { + // Desired map bounds based on min/max of east/west and south/north. + var bounds = { + east: -Infinity, + west: Infinity, + north: -90, + south: 90, + }; + + var markers = []; + + // Create a marker for each beacon. + for (var i = 0; i < beacons.length; i++) { + var marker = this.createMarker(beacons[i]); + markers.push(marker); + + var pos = marker.getPosition(); + var lat = pos.lat(); + var lon = pos.lng(); + + bounds.east = Math.max(bounds.east, lon); + bounds.west = Math.min(bounds.west, lon); + bounds.north = Math.max(bounds.north, lat); + bounds.south = Math.min(bounds.south, lat); + } + + this.map.fitBounds(bounds); + + this.markers = markers; +}; + +/** + * Helper function for creating a Google Maps marker from a beacon. + * + * @param {Object} beacon Data object representing a beacon. + */ +geovelo.Map.prototype.createMarker = function(beacon) { + + // Keep track of the first, max and min lat and lon values. + var lat = null; + var lon = null; + var minLat = 90; + var maxLat = -90; + var minLon = Infinity; + var maxLon = -Infinity; + + // Roll through the beacon's lat/lon pairs and take note. + for (var i = 0; i < beacon.lat.length; i++) { + var currentLat = beacon.lat[i]; + var currentLon = beacon.lon[i]; + if (lat === null && currentLat && currentLon) { + lat = currentLat; + lon = currentLon; + } + minLat = Math.min(minLat, currentLat); + minLat = Math.min(minLat, currentLat); + } + + // If a non-missing lat/lon pair couldn't be found, that's an error. + if (!lat || !lon) { + throw Error('Beacon has no non-zero coordinates: ' + beacon.name); + } + + var marker = new google.maps.Marker({ + position: { lng: lon, lat: lat }, + map: this.map, + title: beacon.name, + label: beacon.name, + visible: this.markerVisibility, + }); + + // Derived values from the beacon. + var startDate = new Date(beacon.start * 1000); + + // When marker is clicked, update the Info Window content and show it. + marker.addListener('click', function() { + // This code makes heavy use of the d3 join/enter/update pattern. + // JOIN. + var content = this.infoContent.data([beacon]); + + content.select('.name').text(beacon.name); + content.select('.start').text(startDate.toDateString()); + content.select('.lat').text(lat.toFixed(3)); + content.select('.lon').text(lon.toFixed(3)); + + this.infoWindow.open(this.map, marker); + }.bind(this)); + + return marker; +}; + +/** + * Show or hide beacon markers by setting their visibility. + * + * @param {boolean} visibility Whether to show (true) or hide (false) markers. + */ +geovelo.Map.prototype.setMarkerVisibility = function(visibility) { + this.markerVisibility = !!visibility; + if (!this.markers) { + return; + } + for (var i = 0; i < this.markers.length; i++) { + this.markers[i].setVisible(this.markerVisibility); + } + if (!this.markerVisibility) { + this.infoWindow.close(); + } }; /**
diff --git a/js/overlay.js b/js/overlay.js index 1a45bd1..2337e60 100644 --- a/js/overlay.js +++ b/js/overlay.js
@@ -34,6 +34,12 @@ */ geovelo.Overlay = function(containerElement) { + // Local reference to the geovelo settings object, throw if missing. + var settings = geovelo.settings; + if (!settings) { + throw Error('geovelo.settings is missing!'); + } + // DOM Element into which to insert content. this.domElement = document.createElement('div'); this.domElement.style.pointerEvents = 'none'; @@ -83,12 +89,18 @@ stats.domElement.style.position = 'absolute'; stats.domElement.style.top = 0; stats.domElement.style.left = 0; + stats.domElement.style.display = 'none'; this.domElement.appendChild(stats.domElement); } + // Whether to show stats. + if (settings.animation.options.showStats.defaultValue) { + this.showStats(); + } + // Set up the custom line shader material to use for drawing lines. this.material = new geovelo.LineShaderMaterial({ - linewidth: 1 + linewidth: settings.style.options.lineWidth.defaultValue }); // Time in ms to allow processing to hold the thread before ceding to the UI. @@ -99,6 +111,104 @@ // Time in ms to wait before invoking functions in need of debouncing. this.debounceTimeout = 50; + + // Animation duration and delay. + this.animationDuration = settings.animation.options.duration.defaultValue; + this.animationDelay = settings.animation.options.delay.defaultValue; + + // Whether we're currently animating. + this.animating = false; + if (settings.animation.options.enabled.defaultValue) { + this.startAnimation(); + } +}; + +/** + * Start animating. + */ +geovelo.Overlay.prototype.startAnimation = function() { + this.animating = true; + + // The start timestamp for the current animation loop. + var start = null; + + var animate = (function() { + // Short-circuit if animation has been turned off. + if (!this.animating) { + return; + } + + var now = Date.now(); + + if (start === null) { + start = now; + } + + var startTimestamp = this.material.uniforms.startTimestamp.value; + var endTimestamp = this.material.uniforms.endTimestamp.value; + var diff = endTimestamp - startTimestamp; + + // If there's any time between the start and end timestamps, set the + // end animation clamp and render the scene. + if (diff) { + this.material.uniforms.endAnimationClamp.value = + startTimestamp + diff * (now - start) / this.animationDuration; + this.render(); + } + + // If we've finished the loop, pause for the delay, otherwise queue up the + // next frame. + if (now > start + this.animationDuration) { + start = null; + setTimeout(animate, this.animationDelay); + } else { + requestAnimationFrame(animate); + } + }).bind(this); + + // Kick off animation loop. + requestAnimationFrame(animate); +}; + +/** + * Stop animating. + */ +geovelo.Overlay.prototype.stopAnimation = function() { + this.animating = false; + this.material.uniforms.endAnimationClamp.value = Infinity; + this.queueRender(); +}; + +/** + * Set animation duration. + */ +geovelo.Overlay.prototype.setAnimationDuration = function(duration) { + this.animationDuration = duration; +}; + +/** + * Set animation delay. + */ +geovelo.Overlay.prototype.setAnimationDelay = function(delay) { + this.animationDelay = delay; +}; + +/** + * Show the FPS stats display. + */ +geovelo.Overlay.prototype.showStats = function() { + if (this.stats) { + this.stats.domElement.style.display = null; + } +}; + +/** + * Hide the FPS stats display. + */ +geovelo.Overlay.prototype.hideStats = function() { + if (this.stats) { + this.stats.domElement.style.display = 'none'; + } }; /** @@ -175,7 +285,7 @@ /** * New beacon data is available. Preprocess the data and reconstruct the scene. - * @param {Array} beacons An array of data for the beacons. + * @param {Array} rawBeacons An array of data for the beacons. */ geovelo.Overlay.prototype.setData = function(rawBeacons) { @@ -533,6 +643,38 @@ }; /** + * Set the color to use for the start of the range. + * + * @param {string} startColor The color to set for the start of the range. + */ +geovelo.Overlay.prototype.setStartColor = function(startColor) { + var color = d3.rgb(startColor); + this.material.uniforms.startColor.value + .set(color.r / 255, color.g / 255, color.b / 255, 1); + this.queueRender(); +}; + +/** + * Set the color to use for the end of the range. + * + * @param {string} endColor The color to set for the end of the range. + */ +geovelo.Overlay.prototype.setEndColor = function(endColor) { + var color = d3.rgb(endColor); + this.material.uniforms.endColor.value + .set(color.r / 255, color.g / 255, color.b / 255, 1); + this.queueRender(); +}; + +/** + * Set the line width. + */ +geovelo.Overlay.prototype.setLineWidth = function(lineWidth) { + this.material.linewidth = lineWidth; + this.queueRender(); +}; + +/** * Set the starting timestamp of the line shader material. This will map to the * start color and start opacity. *
diff --git a/js/settings.js b/js/settings.js index b555a65..0343e37 100644 --- a/js/settings.js +++ b/js/settings.js
@@ -31,6 +31,7 @@ * - description - Opitonal string describing this folder or option. * - defaultValue - The starting value to use for this setting. * - min, max - The smallest and largest allowed values. + * - type - Number (default) or color picker. */ geovelo.settings = { @@ -47,7 +48,7 @@ defaultValue: 5.5, min: 1, max: 8, - step: 0.01 + step: 0.01, }, medianCorrection: { displayName: 'median correction', @@ -55,9 +56,76 @@ 'How much of the cumulative median movement to subtract out.', defaultValue: 1, min: 0, - max: 1 - } - } - } + max: 1, + }, + showMarkers: { + displayName: 'show markers', + description: 'Whether to show a Google Maps marker for each beacon.', + defaultValue: false, + }, + }, + }, + + style: { + displayName: 'Style', + description: 'Settings for the style and behavior of the visualization.', + open: true, + options: { + startColor: { + displayName: 'start color', + description: 'Color to use for the start of the time range.', + defaultValue: '#0000ff', + type: 'color', + }, + endColor: { + displayName: 'end color', + description: 'Color to use for the end of the time range.', + defaultValue: '#ff0000', + type: 'color', + }, + lineWidth: { + displayName: 'line width', + description: 'Width of line when rendering.', + defaultValue: 1, + min: 1, + max: 10, + step: 0.1, + }, + }, + }, + + animation: { + displayName: 'Animation', + description: 'Settings for the looping animation of lines.', + open: true, + options: { + enabled: { + displayName: 'enabled', + description: 'Whether animation is enabled.', + defaultValue: false, + }, + duration: { + displayName: 'duration (ms)', + description: 'How long the animation loop takes to complete.', + defaultValue: 4000, + min: 500, + max: 10000, + step: 500, + }, + delay: { + displayName: 'delay (ms)', + description: 'How long to wait before restarting a finished loop.', + defaultValue: 1000, + min: 0, + max: 3000, + step: 100, + }, + showStats: { + displayName: 'show FPS', + description: 'Whether to show the FPS stats meter.', + defaultValue: false, + }, + }, + }, };
diff --git a/js/timerange.js b/js/timerange.js index 363ab7f..92ebfe3 100644 --- a/js/timerange.js +++ b/js/timerange.js
@@ -53,6 +53,12 @@ // Date object representing the position of the right (end) nub. this.rangeEnd = null; + // String representing the color to fill the start nub. + this.startColor = null; + + // String representing the color to fill the end nub. + this.endColor = null; + // D3 scale for the time range. Will be updated on draw to match parameters. var timeScale = this.timeScale = d3.time.scale(); @@ -68,7 +74,7 @@ this.dragBehavior = d3.behavior.drag() .origin(function (d) { return { - x: timeScale(d), + x: timeScale(d.time), y: geovelo.TimeRange.margin.top }; }) @@ -107,6 +113,30 @@ }; /** + * Set the color to use for the start of the range. + * + * @param {string} startColor The color to set for the start of the range. + */ +geovelo.TimeRange.prototype.setStartColor = function(startColor) { + this.startColor = startColor; + if (this.extentStart && this.extentEnd) { + this.drawNubs(); + } +}; + +/** + * Set the color to use for the end of the range. + * + * @param {string} endColor The color to set for the end of the range. + */ +geovelo.TimeRange.prototype.setEndColor = function(endColor) { + this.endColor = endColor; + if (this.extentStart && this.extentEnd) { + this.drawNubs(); + } +}; + +/** * Set the extent of the time range selector. If range has not been set, this * will initialize the range to match. * @@ -274,7 +304,13 @@ // Get the draggable nub elements. var nubs = this.svg.selectAll('.nub') - .data([this.rangeStart, this.rangeEnd]); + .data([{ + time: this.rangeStart, + color: this.startColor, + }, { + time: this.rangeEnd, + color: this.endColor, + }]); // Insert DOM elements for the nubs if this is the first time. nubs.enter().append('g') @@ -284,12 +320,13 @@ .append('path') .attr('d', 'M -4,-16 v 8 l 4,6 l 4,-6 v -8 z'); - // Move nubs into their correct positions. + // Move nubs into their correct positions and set color. var timeScale = this.timeScale; nubs.attr('transform', function(d) { - return 'translate(' + timeScale(d) + ',' + + return 'translate(' + timeScale(d.time) + ',' + geovelo.TimeRange.margin.top + ')'; - }); + }).select('.nub path') + .attr('fill', function(d) { return d.color; }); }; /**
diff --git a/js/web-mercator.js b/js/web-mercator.js index 61376fa..5a9da99 100644 --- a/js/web-mercator.js +++ b/js/web-mercator.js
@@ -25,7 +25,7 @@ (function() { -// Computational contstants. +// Computational constants. var DEG_TO_RAD = Math.PI / 180; var ZOOM_FACTOR = 128 / Math.PI; var PI_OVER_FOUR = Math.PI / 4;