From 0530e3621b4682a43d26bfc93a4cf321da667b71 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Thu, 30 Mar 2017 13:05:11 -0400 Subject: [PATCH 1/3] Update styles from arrays. This allows a convenient way to animate across styles, and also provides a way where, if appropriate, rerendering can be optimized based on just what has changed. Per-vertex styles can be set on lines and polygons by passing an array of arrays. Some styles on points and contours were being queried via functions of the form styleFunc(data) where they should have been queried as styleFunc(data, index). These have been changed. Speed up paths for other features will be added later. --- examples/animation/example.json | 9 + examples/animation/index.jade | 60 +++++ examples/animation/main.css | 57 ++++ examples/animation/main.js | 449 ++++++++++++++++++++++++++++++++ src/contourFeature.js | 12 +- src/feature.js | 65 +++++ src/gl/pointFeature.js | 111 ++++++-- src/lineFeature.js | 8 + src/polygonFeature.js | 15 ++ tests/cases/feature.js | 34 +++ tests/cases/pointFeature.js | 37 ++- 11 files changed, 833 insertions(+), 24 deletions(-) create mode 100644 examples/animation/example.json create mode 100644 examples/animation/index.jade create mode 100644 examples/animation/main.css create mode 100644 examples/animation/main.js diff --git a/examples/animation/example.json b/examples/animation/example.json new file mode 100644 index 0000000000..f0b5164922 --- /dev/null +++ b/examples/animation/example.json @@ -0,0 +1,9 @@ +{ + "path": "animation", + "title": "Animate Features", + "exampleCss": ["main.css"], + "exampleJs": ["main.js"], + "about": { + "text": "This example shows how to animate features by updating styles." + } +} diff --git a/examples/animation/index.jade b/examples/animation/index.jade new file mode 100644 index 0000000000..da9482c5f8 --- /dev/null +++ b/examples/animation/index.jade @@ -0,0 +1,60 @@ +extends ../common/templates/index.jade + +block append mainContent + div#controls + .form-group(title="The data set to plot.") + label(for="dataset") Data Set + select#dataset(param-name="dataset", placeholder="Activity") + option(value="adderall", url="AdderallCities2015.csv" title="9555 points") Adderall + option(value="cities", url="cities.csv" title="30101 points") U.S. Cities + option(value="earthquakes", url="earthquakes.json" title="1.3 million points") Earthquakes + span#points-loaded + .form-group(title="Number of points. Leave blank for the entire original data set. If a smaller number, only a subset of points will be shown. If a larger number, some of the data will be duplicated with random offsets.") + label(for="points") Number of Points + input#points(type="number" min="1" step="100") + span#points-shown + .form-group.style-list + label Styles to animate: + .form-group.style-list + span + label(for="fill") Fill + input#fill(type="checkbox", placeholder="false") + span + label(for="fillColor") Fill Color + input#fillColor(type="checkbox", placeholder="false") + span + label(for="fillOpacity") Fill Opacity + input#fillOpacity(type="checkbox", placeholder="true", checked="checked") + span + label(for="radius") Radius + input#radius(type="checkbox", placeholder="true", checked="checked") + span + label(for="stroke") Stroke + input#stroke(type="checkbox", placeholder="false") + span + label(for="strokeColor") Stroke Color + input#strokeColor(type="checkbox", placeholder="false") + span + label(for="strokeOpacity") Stroke Opacity + input#strokeOpacity(type="checkbox", placeholder="true", checked="checked") + span + label(for="strokeWidth") Stroke Width + input#strokeWidth(type="checkbox", placeholder="true", checked="checked") + .form-group + button#play Play + button#pause Pause + button#stop Stop + .form-group + span.timing-group + span Framerate  + span#timing-framerate 0 + span  fps + span.timing-group + span Avg. Proc.  + span#timing-generate 0 + span  ms + span.timing-group + span Avg. Update  + span#timing-update 0 + span  ms + diff --git a/examples/animation/main.css b/examples/animation/main.css new file mode 100644 index 0000000000..9fa96a2b6d --- /dev/null +++ b/examples/animation/main.css @@ -0,0 +1,57 @@ +#controls { + overflow-x: hidden; + overflow-y: auto; + position: absolute; + left: 10px; + top: 80px; + z-index: 1; + border-radius: 5px; + border: 1px solid grey; + box-shadow: 1px 1px 3px black; + opacity: 0.5; + transition: opacity 250ms ease; + background: #CCC; + color: black; + padding: 4px; + font-size: 14px; + max-height: calc(100% - 100px); + min-width: 310px; +} +#controls:hover { + opacity: 1; +} +#controls .form-group { + margin-bottom: 0; +} +#controls label { + min-width: 120px; +} +#controls #points { + width: 100px; +} +#controls #points-loaded,#controls #points-shown { + display: inline-block; + font-size: 11px; + padding-left: 5px; +} +#controls table input { + width: 55px; +} +#controls table th { + text-align: center; +} +span.timing-group { + font-size: 11px; + padding-right: 10px; +} +#controls .style-list { + max-width: 320px; + font-size: 12px; +} +#controls .style-list span { + padding-right: 5px; +} +#controls .style-list label { + min-width: 0; + max-width: none; +} diff --git a/examples/animation/main.js b/examples/animation/main.js new file mode 100644 index 0000000000..9abfa76251 --- /dev/null +++ b/examples/animation/main.js @@ -0,0 +1,449 @@ +// Run after the DOM loads +$(function () { + 'use strict'; + + var layer, pointFeature, points, datapoints, + timeRecords = {generate: [], update: [], frames: []}, + animationState = {}; + + var map = geo.map({ + node: '#map', + center: { + x: -98, + y: 39 + }, + zoom: 3 + }); + var layerOptions = { + features: ['point'] + }; + + var animationStyles = { + fill: false, + fillColor: false, + fillOpacity: true, + stroke: false, + strokeColor: false, + strokeOpacity: true, + strokeWidth: true, + radius: true + }; + + // Parse query parameters into an object for ease of access + var query = document.location.search.replace(/(^\?)/, '').split( + '&').map(function (n) { + n = n.split('='); + if (n[0]) { + this[decodeURIComponent(n[0])] = decodeURIComponent(n[1]); + } + return this; + }.bind({}))[0]; + $.each(query, function (key, value) { + var ctlvalue, ctlkey = key, ctl; + switch (key) { + case 'dataset': + ctlvalue = value ? value : 'adderall'; + break; + case 'points': + if (value.length) { + points = ctlvalue = parseInt(value, 10); + } + break; + default: + if (animationStyles[key] !== undefined) { + animationStyles[key] = ctlvalue = value === 'true'; + } + } + if (ctlvalue !== undefined) { + ctl = $('#' + ctlkey); + if (ctl.is('[type="checkbox"]')) { + ctl.prop('checked', value === 'true' || value === true); + } else { + ctl.val(ctlvalue); + } + } + }); + + /* Based on the current controls, fetch a data set and show it as a heatmap. + */ + function fetch_data() { + var dataset = $('#dataset').val(), + url = '../../data/' + $('#dataset option:selected').attr('url'); + $.ajax(url, { + success: function (resp) { + var wasPlaying = animationState.mode === 'play'; + animation_pause(); + animationState = {}; + var rows; + switch (dataset) { + case 'adderall': + rows = resp.split(/\r\n|\n|\r/); + rows.splice(0, 1); + rows = rows.map(function (r) { + var fields = r.split(','); + return [fields[12], fields[24], fields[25]].map(parseFloat); + }); + break; + case 'cities': + rows = resp.split(/\r\n|\n|\r/); + rows.splice(rows.length - 1, 1); + rows = rows.map(function (r) { + var fields = r.split('","'); + return ['' + fields[0].replace(/(^\s+|\s+$|^"|"$)/g, '').length, fields[2].replace(/(^\s+|\s+$|^"|"$)/g, ''), fields[3].replace(/(^\s+|\s+$|^"|"$)/g, '')].map(parseFloat); + }); + break; + case 'earthquakes': + animationState.orderedData = true; + rows = resp; + break; + } + datapoints = rows; + var text = 'Loaded: ' + datapoints.length; + $('#points-loaded').text(text).attr('title', text); + show_points(datapoints); + reset_styles(); + if (wasPlaying) { + animation_play(); + } + } + }); + } + + /* Given a set of datapoints, optionally truncate or expand it, then show it + * as a heatmap. + * + * @param {array} datapoints: an array of points to show. + */ + function show_points(datapoints) { + var rows = datapoints; + var maxrows = parseInt(points, 10) || rows.length; + if (rows.length > maxrows) { + rows = rows.slice(0, maxrows); + } else if (rows.length < maxrows) { + rows = rows.slice(); + while (rows.length < maxrows) { + for (var i = rows.length - 1; i >= 0 && rows.length < maxrows; i -= 1) { + rows.push([ + rows[i][0] + Math.random() * 0.1 - 0.05, + rows[i][1] + Math.random() * 0.1 - 0.05, + rows[i][2] + Math.random() * 0.1 - 0.05]); + } + } + } + pointFeature.data(rows); + pointFeature.draw(); + var text = 'Shown: ' + rows.length; + $('#points-shown').text(text).attr('title', text); + } + + /** + * Handle changes to our controls. + * + * @param {object} evt jquery evt that triggered this call. + */ + function change_controls(evt) { + var ctl = $(evt.target), + param = ctl.attr('id'), + value = ctl.val(); + if (ctl.is('[type="checkbox"]')) { + value = ctl.is(':checked') ? 'true' : 'false'; + } + if (value === '' && ctl.attr('placeholder')) { + value = ctl.attr('placeholder'); + } + if (!param || value === query[param]) { + return; + } + var processedValue = (ctl.is('[type="checkbox"]') ? + (value === 'true') : value); + if (ctl.closest('table.gradient').length) { + param = 'gradient'; + } + switch (param) { + case 'dataset': + fetch_data(); + break; + case 'points': + points = parseInt(value); + var wasPlaying = animationState.mode === 'play'; + animation_pause(); + show_points(datapoints); + reset_styles(); + if (wasPlaying) { + animation_play(); + } + break; + default: + if (animationStyles[param] !== undefined) { + animationStyles[param] = processedValue; + reset_styles(); + } + break; + } + // update the url to reflect the changes + query[param] = value; + if (value === '' || (ctl.attr('placeholder') && + value === ctl.attr('placeholder'))) { + delete query[param]; + } + var newurl = window.location.protocol + '//' + window.location.host + + window.location.pathname + '?' + $.param(query); + window.history.replaceState(query, '', newurl); + } + + /** + * Render a frame of the animation and then request another frame as soon as + * possible. + */ + function animation_frame() { + var datalen = animationState.order.length, + styles = animationState.styleArrays, + curTime = new Date().getTime(), genTime, updateTime, + position, i, idx, p; + timeRecords.frames.push(curTime); + animationState.raf = null; + position = ((curTime - animationState.startTime) / animationState.duration) % 1; + if (position < 0) { + position += 1; + } + animationState.position = position; + + for (idx = 0; idx < datalen; idx += 1) { + i = animationState.order[idx]; + p = idx / datalen + position; + if (p > 1) { + p -= 1; + } + styles.p[i] = p; + } + if (animationStyles.fill) { + for (i = 0; i < datalen; i += 1) { + styles.fill[i] = styles.p[i] >= 0.1 ? false : true; + } + } + if (animationStyles.fillColor) { + for (i = 0; i < datalen; i += 1) { + p = styles.p[i]; + if (p >= 0.1) { + styles.fillColor[i].r = 0; + styles.fillColor[i].g = 0; + styles.fillColor[i].b = 0; + } else { + styles.fillColor[i].r = p * 10; + styles.fillColor[i].g = p * 8.39; + styles.fillColor[i].b = p * 4.39; + } + } + } + if (animationStyles.fillOpacity) { + for (i = 0; i < datalen; i += 1) { + p = styles.p[i]; + styles.fillOpacity[i] = p >= 0.1 ? 0 : 1.0 - p * 10; // 1 - 0 + } + } + if (animationStyles.radius) { + for (i = 0; i < datalen; i += 1) { + p = styles.p[i]; + styles.radius[i] = p >= 0.1 ? 0 : 2 + 100 * p; // 2 - 12 + } + } + if (animationStyles.stroke) { + for (i = 0; i < datalen; i += 1) { + styles.stroke[i] = styles.p[i] >= 0.1 ? false : true; + } + } + if (animationStyles.strokeColor) { + for (i = 0; i < datalen; i += 1) { + p = styles.p[i]; + if (p >= 0.1) { + styles.strokeColor[i].r = 0; + styles.strokeColor[i].g = 0; + styles.strokeColor[i].b = 0; + } else { + styles.strokeColor[i].r = p * 8.51; + styles.strokeColor[i].g = p * 6.04; + styles.strokeColor[i].b = 0; + } + } + } + if (animationStyles.strokeOpacity) { + for (i = 0; i < datalen; i += 1) { + p = styles.p[i]; + styles.strokeOpacity[i] = p >= 0.1 ? 0 : 1.0 - p * p * 100; // (1 - 0) ^ 2 + } + } + if (animationStyles.strokeWidth) { + for (i = 0; i < datalen; i += 1) { + p = styles.p[i]; + styles.strokeWidth[i] = p >= 0.1 ? 0 : 3 - 30 * p; // 3 - 0 + } + } + var updateStyles = {}; + $.each(animationStyles, function (key, use) { + if (use) { + updateStyles[key] = styles[key]; + } + }); + genTime = new Date().getTime(); + pointFeature.updateStyleFromArray(updateStyles, null, true); + updateTime = new Date().getTime(); + timeRecords.generate.push(genTime - curTime); + timeRecords.update.push(updateTime - genTime); + show_framerate(); + if (animationState.mode === 'play') { + animationState.raf = window.requestAnimationFrame(animation_frame); + } + } + + /** + * Stop any animation timeout, but don't do anything else. + */ + function animation_pause() { + if (animationState.mode && animationState.mode !== 'pause' && animationState.mode !== 'stop') { + if (animationState.raf) { + window.cancelAnimationFrame(animationState.raf); + animationState.raf = null; + } + animationState.mode = 'pause'; + } + } + + /** + * Start playing an animation. If we haven't played it yet, create some + * arrays used for the animation. + */ + function animation_play() { + if (animationState.mode === 'play' || !pointFeature.data()) { + return; + } + var data = pointFeature.data(), + datalen = data.length; + if (!datalen) { + return; + } + animationState.duration = 15000; // in milliseconds + if (animationState.position === undefined || animationState.position === null) { + animationState.position = 0; + } + animationState.startTime = new Date().getTime() - animationState.duration * animationState.position; + if (!animationState.styleArrays || datalen !== animationState.order.length) { + animationState.order = new Array(datalen); + if (!animationState.orderedData) { + var posFunc = pointFeature.position(), posVal, i; + // sort our data by x so we get a visual ripple across it + for (i = 0; i < datalen; i += 1) { + posVal = posFunc(data[i], i); + animationState.order[i] = {i: i, x: posVal.x, y: posVal.y}; + } + animationState.order = animationState.order.sort(function (a, b) { + if (a.x !== b.x) { return b.x - a.x; } + if (a.y !== b.y) { return b.y - a.y; } + return b.i - a.i; + }).map(function (val) { + return val.i; + }); + } else { + for (i = 0; i < datalen; i += 1) { + animationState.order[i] = i; + } + } + animationState.styleArrays = { + p: new Array(datalen), + radius: new Array(datalen), + fill: new Array(datalen), + fillColor: new Array(datalen), + fillOpacity: new Array(datalen), + stroke: new Array(datalen), + strokeColor: new Array(datalen), + strokeOpacity: new Array(datalen), + strokeWidth: new Array(datalen) + }; + for (i = 0; i < datalen; i += 1) { + animationState.styleArrays.fillColor[i] = {r: 0, g: 0, b: 0}; + animationState.styleArrays.strokeColor[i] = {r: 0, g: 0, b: 0}; + } + } + animationState.mode = 'play'; + animation_frame(); + } + + /** + * Clear any animation timeout and reset the styles to the original values. + */ + function animation_stop() { + if (animationState.mode && animationState.mode !== 'stop') { + if (animationState.raf) { + window.cancelAnimationFrame(animationState.raf); + animationState.raf = null; + } + reset_styles(); + animationState.position = null; + animationState.mode = 'stop'; + } + } + + /** + * Reset all of the styles to the defaults and redraw the feature. + */ + function reset_styles() { + pointFeature.style({ + fill: true, + fillColor: { r: 1.0, g: 0.839, b: 0.439 }, + fillOpacity: 0.8, + radius: 5.0, + stroke: true, + strokeColor: { r: 0.851, g: 0.604, b: 0.0 }, + strokeWidth: 1.25, + strokeOpacity: 1.0 + }); + pointFeature.draw(); + } + + /** + * Show the framerate averaged over the last five seconds. + */ + function show_framerate() { + if (timeRecords.frames.length < 2) { + return; + } + var timeSpan = 5000, + endPos = timeRecords.frames.length - 1, + endTime = timeRecords.frames[endPos], + startPos, startTime, fps, generate = 0, update = 0; + for (startPos = endPos; startPos > 0; startPos -= 1) { + if (endTime - timeRecords.frames[startPos] > timeSpan) { + break; + } + generate += timeRecords.generate[startPos]; + update += timeRecords.update[startPos]; + } + startTime = timeRecords.frames[startPos]; + timeSpan = endTime - startTime; + fps = (endPos - startPos) * 1000 / timeSpan; + generate /= (endPos - startPos); + update /= (endPos - startPos); + $('#timing-framerate').text(fps.toFixed(1)); + $('#timing-generate').text(generate.toFixed(1)); + $('#timing-update').text(update.toFixed(1)); + if (startPos > 1000) { + timeRecords.frames.splice(0, startPos); + timeRecords.generate.splice(0, startPos); + timeRecords.update.splice(0, startPos); + } + } + + map.createLayer('osm'); + layer = map.createLayer('feature', layerOptions); + pointFeature = layer.createFeature('point', { + primitiveShape: query.primitive ? query.primitive : 'sprite' + }) + .position(function (d) { + return {x: d[2], y: d[1]}; + }); + + fetch_data(); + $('#controls').on('change', change_controls); + $('button#play').on('click', animation_play); + $('button#pause').on('click', animation_pause); + $('button#stop').on('click', animation_stop); +}); diff --git a/src/contourFeature.js b/src/contourFeature.js index efba6fa6cc..fe7dee4cce 100644 --- a/src/contourFeature.js +++ b/src/contourFeature.js @@ -197,9 +197,9 @@ var contourFeature = function (arg) { result.maxColor = $.extend({a: contour.get('maxOpacity')() || 0}, util.convertColor(contour.get('maxColor')())); contour.get('colorRange')().forEach(function (clr, idx) { - result.colorMap.push($.extend( - {a: opacityRange && opacityRange[idx] !== undefined ? - opacityRange[idx] : 1}, util.convertColor(clr))); + result.colorMap.push($.extend({ + a: opacityRange && opacityRange[idx] !== undefined ? opacityRange[idx] : 1 + }, util.convertColor(clr))); }); /* Determine which values are usable */ if (gridW * gridH > data.length) { @@ -243,7 +243,7 @@ var contourFeature = function (arg) { numPts = gridW * gridH; for (i = 0; i < numPts; i += 1) { if (skipColumn === undefined) { - val = parseFloat(valueFunc(data[i])); + val = parseFloat(valueFunc(data[i], i)); } else { j = Math.floor(i / gridW); origI = i - j * gridW; @@ -252,7 +252,7 @@ var contourFeature = function (arg) { origI -= gridWorig; } origI += j * gridWorig; - val = parseFloat(valueFunc(data[origI])); + val = parseFloat(valueFunc(data[origI], origI)); } values[i] = isNaN(val) ? null : val; if (values[i] !== null) { @@ -338,7 +338,7 @@ var contourFeature = function (arg) { result.pos[i3 + 1] = y0 + dy * Math.floor(j / gridW); result.pos[i3 + 2] = 0; } - result.opacity[i] = opacityFunc(item); + result.opacity[i] = opacityFunc(item, j); if (rangeValues && val >= result.minValue && val <= result.maxValue) { for (k = 1; k < rangeValues.length; k += 1) { if (val <= rangeValues[k]) { diff --git a/src/feature.js b/src/feature.js index a0f48ef41a..b68e02e61e 100644 --- a/src/feature.js +++ b/src/feature.js @@ -44,6 +44,10 @@ var feature = function (arg) { m_dependentFeatures = [], m_selectedFeatures = []; + // subclasses can add keys to this for styles that apply to subcomponents of + // data items, such as individual vertices on lines or polygons. + this._subcomponentStyles = {}; + //////////////////////////////////////////////////////////////////////////// /** * Private method to bind mouse handlers on the map element. @@ -354,6 +358,67 @@ var feature = function (arg) { return out; }; + //////////////////////////////////////////////////////////////////////////// + /** + * Set style(s) from array(s). For each style, the array should have one + * value per data item. The values are not converted or validated. Color + * values should be objects with r, g, b values on a scale of [0, 1]. If + * invalidate values are given the behavior is undefined. + * For features where this._subcomponentStyles is an object and a style + * name is a key in that object, and the first entry of the styleArray is + * itself an array, then each entry of the array is expected to be an array, + * and values are used from these subarrays. This allows a style to apply, + * for instance, per vertex of a data item rather than per data item. + * + * @param {string|object} keyOrObject: either the name of a single style or + * an object where the keys are the names of styles and the values are + * each arrays. + * @param {array} styleArray: if keyOrObject is a string, an array of values + * for the style. If keyOrObject is an object, this parameter is ignored. + * @param {boolean} refresh: true to redraw the feature when it has been + * updated. If an object with styles is passed, the redraw is only done + * once. + * @returns {object} the feature + */ + //////////////////////////////////////////////////////////////////////////// + this.updateStyleFromArray = function (keyOrObject, styleArray, refresh) { + if (typeof keyOrObject !== 'string') { + $.each(keyOrObject, function (key, value) { + m_this.updateStyleFromArray(key, value); + }); + } else { + var fallback; + if (keyOrObject.toLowerCase().match(/color$/)) { + fallback = {r: 0, g: 0, b: 0}; + } + if (!Array.isArray(styleArray)) { + return m_this; + } + if (m_this._subcomponentStyles[keyOrObject]) { + if (styleArray.length && Array.isArray(styleArray[0])) { + m_this.style(keyOrObject, function (v, j, d, i) { + var val = (styleArray[i] || [])[j]; + return val !== undefined ? val : fallback; + }); + } else { + m_this.style(keyOrObject, function (v, j, d, i) { + var val = styleArray[i]; + return val !== undefined ? val : fallback; + }); + } + } else { + m_this.style(keyOrObject, function (d, i) { + var val = styleArray[i]; + return val !== undefined ? val : fallback; + }); + } + } + if (refresh && m_this.visible()) { + m_this.draw(); + } + return m_this; + }; + //////////////////////////////////////////////////////////////////////////// /** * Get layer referenced by the feature diff --git a/src/gl/pointFeature.js b/src/gl/pointFeature.js index e7e9daf81c..1e2b54bf06 100644 --- a/src/gl/pointFeature.js +++ b/src/gl/pointFeature.js @@ -1,3 +1,4 @@ +var $ = require('jquery'); var inherit = require('../inherit'); var registerFeature = require('../registry').registerFeature; var pointFeature = require('../pointFeature'); @@ -46,6 +47,7 @@ var gl_pointFeature = function (arg) { m_primitiveShape = 'sprite', // arg can change this, below s_init = this._init, s_update = this._update, + s_updateStyleFromArray = this.updateStyleFromArray, vertexShaderSource = null, fragmentShaderSource = null; @@ -60,7 +62,7 @@ var gl_pointFeature = function (arg) { ' precision highp float;', '#endif', 'attribute vec3 pos;', - 'attribute float rad;', + 'attribute float radius;', 'attribute vec3 fillColor;', 'attribute vec3 strokeColor;', 'attribute float fillOpacity;', @@ -98,7 +100,7 @@ var gl_pointFeature = function (arg) { ' }', ' else', ' strokeVar = 1.0;', - ' if (fill < 1.0 || rad <= 0.0 || fillOpacity <= 0.0)', + ' if (fill < 1.0 || radius <= 0.0 || fillOpacity <= 0.0)', ' fillVar = 0.0;', ' else', ' fillVar = 1.0;', @@ -109,13 +111,13 @@ var gl_pointFeature = function (arg) { ' }', ' fillColorVar = vec4 (fillColor, fillOpacity);', ' strokeColorVar = vec4 (strokeColor, strokeOpacity);', - ' radiusVar = rad;' + ' radiusVar = radius;' ]); if (m_primitiveShape === 'sprite') { vertexShaderSource.push.apply(vertexShaderSource, [ ' gl_Position = (projectionMatrix * modelViewMatrix * vec4(pos, 1.0)).xyzw;', - ' gl_PointSize = 2.0 * (rad + strokeWidthVar); ', + ' gl_PointSize = 2.0 * (radius + strokeWidthVar); ', '}' ]); } else { @@ -125,7 +127,7 @@ var gl_pointFeature = function (arg) { ' if (p.w != 0.0) {', ' p = p / p.w;', ' }', - ' p += (rad + strokeWidthVar) * ', + ' p += (radius + strokeWidthVar) * ', ' vec4 (unit.x * pixelWidth, unit.y * pixelWidth * aspect, 0.0, 1.0);', ' gl_Position = vec4(p.xyz, 1.0);', '}' @@ -273,7 +275,7 @@ var gl_pointFeature = function (arg) { /* It is more efficient to do a transform on a single array rather than on * an array of arrays or an array of objects. */ for (i = i3 = 0; i < numPts; i += 1, i3 += 3) { - posVal = posFunc(data[i]); + posVal = posFunc(data[i], i); position[i3] = posVal.x; position[i3 + 1] = posVal.y; position[i3 + 2] = posVal.z || 0; @@ -297,7 +299,7 @@ var gl_pointFeature = function (arg) { unitBuf = util.getGeomBuffer(geom, 'unit', vpf * numPts * 2); } - radius = util.getGeomBuffer(geom, 'rad', vpf * numPts); + radius = util.getGeomBuffer(geom, 'radius', vpf * numPts); stroke = util.getGeomBuffer(geom, 'stroke', vpf * numPts); strokeWidth = util.getGeomBuffer(geom, 'strokeWidth', vpf * numPts); strokeOpacity = util.getGeomBuffer(geom, 'strokeOpacity', vpf * numPts); @@ -319,14 +321,14 @@ var gl_pointFeature = function (arg) { } } /* We can ignore the indicies (they will all be zero) */ - radiusVal = radFunc(item); - strokeVal = strokeFunc(item) ? 1.0 : 0.0; - strokeWidthVal = strokeWidthFunc(item); - strokeOpacityVal = strokeOpacityFunc(item); - strokeColorVal = strokeColorFunc(item); - fillVal = fillFunc(item) ? 1.0 : 0.0; - fillOpacityVal = fillOpacityFunc(item); - fillColorVal = fillColorFunc(item); + radiusVal = radFunc(item, i); + strokeVal = strokeFunc(item, i) ? 1.0 : 0.0; + strokeWidthVal = strokeWidthFunc(item, i); + strokeOpacityVal = strokeOpacityFunc(item, i); + strokeColorVal = strokeColorFunc(item, i); + fillVal = fillFunc(item, i) ? 1.0 : 0.0; + fillOpacityVal = fillOpacityFunc(item, i); + fillColorVal = fillColorFunc(item, i); for (j = 0; j < vpf; j += 1, ivpf += 1, ivpf3 += 3) { posBuf[ivpf3] = position[i3]; posBuf[ivpf3 + 1] = position[i3 + 1]; @@ -377,6 +379,81 @@ var gl_pointFeature = function (arg) { return unit.length / 2; }; + this.updateStyleFromArray = function (keyOrObject, styleArray, refresh) { + var bufferedKeys = { + fill: 'bool', + fillColor: 3, + fillOpacity: 1, + radius: 1, + stroke: 'bool', + strokeColor: 3, + strokeOpacity: 1, + strokeWidth: 1 + }; + var needsRefresh, needsRender; + if (typeof keyOrObject === 'string') { + var obj = {}; + obj[keyOrObject] = styleArray; + keyOrObject = obj; + } + $.each(keyOrObject, function (key, styleArray) { + if (m_this.visible() && m_actor && bufferedKeys[key] && !needsRefresh && !m_this.clustering()) { + var vpf, mapper, buffer, numPts, value, i, j, v, bpv; + bpv = bufferedKeys[key] === 'bool' ? 1 : bufferedKeys[key]; + numPts = m_this.data().length; + mapper = m_actor.mapper(); + buffer = mapper.getSourceBuffer(key); + vpf = m_this.verticesPerFeature(); + if (!buffer || !numPts || numPts * vpf * bpv !== buffer.length) { + needsRefresh = true; + } else { + switch (bufferedKeys[key]) { + case 1: + for (i = 0, v = 0; i < numPts; i += 1) { + value = styleArray[i]; + for (j = 0; j < vpf; j += 1, v += 1) { + buffer[v] = value; + } + } + break; + case 3: + for (i = 0, v = 0; i < numPts; i += 1) { + value = styleArray[i]; + for (j = 0; j < vpf; j += 1, v += 3) { + buffer[v] = value.r; + buffer[v + 1] = value.g; + buffer[v + 2] = value.b; + } + } + break; + case 'bool': + for (i = 0, v = 0; i < numPts; i += 1) { + value = styleArray[i] ? 1.0 : 0.0; + for (j = 0; j < vpf; j += 1, v += 1) { + buffer[v] = value; + } + } + break; + } + mapper.updateSourceBuffer(key); + /* This could probably be even faster than calling _render after + * updating the buffer, if the context's buffer was bound and + * updated. This would requiring knowing the webgl context and + * probably the source to buffer mapping. */ + needsRender = true; + } + } else { + needsRefresh = true; + } + s_updateStyleFromArray(key, styleArray, false); + }); + if (m_this.visible() && needsRefresh) { + m_this.draw(); + } else if (needsRender) { + m_this.renderer()._render(); + } + }; + //////////////////////////////////////////////////////////////////////////// /** * Initialize @@ -388,7 +465,7 @@ var gl_pointFeature = function (arg) { fragmentShader = createFragmentShader(), posAttr = vgl.vertexAttribute('pos'), unitAttr = vgl.vertexAttribute('unit'), - radAttr = vgl.vertexAttribute('rad'), + radAttr = vgl.vertexAttribute('radius'), strokeWidthAttr = vgl.vertexAttribute('strokeWidth'), fillColorAttr = vgl.vertexAttribute('fillColor'), fillAttr = vgl.vertexAttribute('fill'), @@ -405,7 +482,7 @@ var gl_pointFeature = function (arg) { sourceUnits = vgl.sourceDataAnyfv( 2, vgl.vertexAttributeKeysIndexed.One, {'name': 'unit'}), sourceRadius = vgl.sourceDataAnyfv( - 1, vgl.vertexAttributeKeysIndexed.Two, {'name': 'rad'}), + 1, vgl.vertexAttributeKeysIndexed.Two, {'name': 'radius'}), sourceStrokeWidth = vgl.sourceDataAnyfv( 1, vgl.vertexAttributeKeysIndexed.Three, {'name': 'strokeWidth'}), sourceFillColor = vgl.sourceDataAnyfv( diff --git a/src/lineFeature.js b/src/lineFeature.js index 57513bab13..f725306eb2 100644 --- a/src/lineFeature.js +++ b/src/lineFeature.js @@ -72,6 +72,14 @@ var lineFeature = function (arg) { m_pointSearchInfo; this.featureType = 'line'; + this._subcomponentStyles = { + lineCap: true, + lineJoin: true, + strokeColor: true, + strokeOffset: true, + strokeOpacity: true, + strokeWidth: true + }; //////////////////////////////////////////////////////////////////////////// /** diff --git a/src/polygonFeature.js b/src/polygonFeature.js index f745177bf4..5844c673e4 100644 --- a/src/polygonFeature.js +++ b/src/polygonFeature.js @@ -68,6 +68,16 @@ var polygonFeature = function (arg) { m_coordinates = []; this.featureType = 'polygon'; + this._subcomponentStyles = { + fillColor: true, + fillOpacity: true, + lineCap: true, + lineJoin: true, + strokeColor: true, + strokeOffset: true, + strokeOpacity: true, + strokeWidth: true + }; //////////////////////////////////////////////////////////////////////////// /** @@ -277,10 +287,15 @@ var polygonFeature = function (arg) { } var polyStyle = m_this.style(); m_lineFeature.style({ + antialiasing: polyStyle.antialiasing, closed: true, + lineCap: polyStyle.lineCap, + lineJoin: polyStyle.lineJoin, + miterLimit: polyStyle.miterLimit, strokeWidth: polyStyle.strokeWidth, strokeStyle: polyStyle.strokeStyle, strokeColor: polyStyle.strokeColor, + strokeOffset: polyStyle.strokeOffset, strokeOpacity: function (d) { return m_this.style.get('stroke')(d[2], d[3]) ? m_this.style.get('strokeOpacity')(d[0], d[1], d[2], d[3]) : 0; } diff --git a/tests/cases/feature.js b/tests/cases/feature.js index 1360d89205..61c32956eb 100644 --- a/tests/cases/feature.js +++ b/tests/cases/feature.js @@ -199,6 +199,40 @@ describe('geo.feature', function () { expect(feat.visible(true)).toBe(feat); expect(depFeat.visible()).toBe(false); }); + it('updateStyleFromArray', function () { + var count = 0; + feat.draw = function () { + count += 1; + }; + feat._subcomponentStyles.opacity = true; + feat.data([1, 2, 3, 4]); + feat.style({radius: 10, strokeColor: 'white', opacity: 0.5}); + feat.style('radius', 10); + expect(feat.style.get('radius')(2, 1)).toBe(10); + expect(feat.style.get('strokeColor')(2, 1)).toEqual({r: 1, g: 1, b: 1}); + expect(feat.style.get('opacity')(0, 0, 2, 1)).toBe(0.5); + expect(feat.updateStyleFromArray('radius', [11, 12, 13, 14])).toBe(feat); + expect(feat.style.get('radius')(2, 1)).toBe(12); + feat.updateStyleFromArray({radius: [21, 22, 23, 24]}); + expect(feat.style.get('radius')(2, 1)).toBe(22); + feat.updateStyleFromArray('strokeColor', [{r: 1, g: 0, b: 0}]); + expect(feat.style.get('strokeColor')(1, 0)).toEqual({r: 1, g: 0, b: 0}); + expect(feat.style.get('strokeColor')(2, 1)).toEqual({r: 0, g: 0, b: 0}); + feat.updateStyleFromArray('opacity', [0.1, 0.2, 0.3, 0.4]); + expect(feat.style.get('opacity')(0, 0, 2, 1)).toBe(0.2); + expect(feat.style.get('opacity')(0, 1, 2, 1)).toBe(0.2); + feat.updateStyleFromArray('opacity', [[0.11, 0.12], [0.21, 0.22], [0.31, 0.32], [0.41, 0.42]]); + expect(feat.style.get('opacity')(0, 0, 2, 1)).toBe(0.21); + expect(feat.style.get('opacity')(0, 1, 2, 1)).toBe(0.22); + expect(feat.updateStyleFromArray('opacity', 0.5)).toBe(feat); + expect(feat.style.get('opacity')(0, 0, 2, 1)).toBe(0.21); + expect(count).toBe(0); + feat.updateStyleFromArray('opacity', [0.1, 0.2, 0.3, 0.4], true); + expect(count).toBe(1); + feat.visible(false); + feat.updateStyleFromArray('radius', [11, 12, 13, 14], true); + expect(count).toBe(1); + }); }); describe('Check class accessors', function () { var map, layer, feat; diff --git a/tests/cases/pointFeature.js b/tests/cases/pointFeature.js index 52ea343559..8a7b74cfb6 100644 --- a/tests/cases/pointFeature.js +++ b/tests/cases/pointFeature.js @@ -254,7 +254,15 @@ describe('geo.pointFeature', function () { /* This is a basic integration test of geo.gl.pointFeature. */ describe('geo.gl.pointFeature', function () { - var map, layer, point, point2, glCounts; + var map, layer, point, point2, glCounts, i, count = 0; + var array1 = new Array(testPoints.length), + array2 = new Array(testPoints.length), + array3 = new Array(testPoints.length); + for (i = 0; i < testPoints.length; i += 1) { + array1[i] = i + 1; + array2[i] = i % 2 ? true : false; + array3[i] = {r: 0.5, g: 0.5, b: 0.5}; + } it('basic usage', function () { mockVGLRenderer(); map = create_map(); @@ -290,6 +298,33 @@ describe('geo.pointFeature', function () { waitForIt('next render gl B', function () { return vgl.mockCounts().drawArrays >= (glCounts.drawArrays || 0) + 1; }); + it('updateStyleFromArray single', function () { + point.draw = function () { + count += 1; + }; + glCounts = $.extend({}, vgl.mockCounts()); + point.updateStyleFromArray('radius', array1, true); + expect(count).toBe(0); + }); + waitForIt('next render gl C', function () { + return vgl.mockCounts().drawArrays >= (glCounts.drawArrays || 0) + 1; + }); + it('updateStyleFromArray multiple', function () { + glCounts = $.extend({}, vgl.mockCounts()); + point.updateStyleFromArray({stroke: array2, strokeColor: array3}, null, true); + expect(count).toBe(0); + }); + waitForIt('next render gl D', function () { + return vgl.mockCounts().drawArrays >= (glCounts.drawArrays || 0) + 1; + }); + it('updateStyleFromArray non-optimized', function () { + point.updateStyleFromArray('unknown', array1, true); + expect(count).toBe(1); + // a different length array will trigger a slow draw, too. + point.data(testPoints.slice(0, 18)); + point.updateStyleFromArray('radius', array1, true); + expect(count).toBe(2); + }); it('_exit', function () { expect(point.actors().length).toBe(1); layer.deleteFeature(point); From b2d48dc1ccd26c0c493fdde4a77efa55d6e8bfd6 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Mon, 1 May 2017 11:01:15 -0400 Subject: [PATCH 2/3] Improve documentation. --- src/feature.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/feature.js b/src/feature.js index b68e02e61e..b87c06f5de 100644 --- a/src/feature.js +++ b/src/feature.js @@ -364,11 +364,10 @@ var feature = function (arg) { * value per data item. The values are not converted or validated. Color * values should be objects with r, g, b values on a scale of [0, 1]. If * invalidate values are given the behavior is undefined. - * For features where this._subcomponentStyles is an object and a style - * name is a key in that object, and the first entry of the styleArray is - * itself an array, then each entry of the array is expected to be an array, - * and values are used from these subarrays. This allows a style to apply, - * for instance, per vertex of a data item rather than per data item. + * For some feature styles, if the first entry of an array is itself an + * array, then each entry of the array is expected to be an array, and values + * are used from these subarrays. This allows a style to apply, for + * instance, per vertex of a data item rather than per data item. * * @param {string|object} keyOrObject: either the name of a single style or * an object where the keys are the names of styles and the values are @@ -387,6 +386,8 @@ var feature = function (arg) { m_this.updateStyleFromArray(key, value); }); } else { + /* colors area lways expected to be objects with r, g, b values, so for + * any color, make sure we don't have undefined entries. */ var fallback; if (keyOrObject.toLowerCase().match(/color$/)) { fallback = {r: 0, g: 0, b: 0}; From 4ebe9ba3664c2c95803b60191d70e7781715991b Mon Sep 17 00:00:00 2001 From: David Manthey Date: Thu, 4 May 2017 11:25:17 -0400 Subject: [PATCH 3/3] Rename _subcomponentStyles to _subfeatureStyles. --- src/feature.js | 4 ++-- src/lineFeature.js | 2 +- src/polygonFeature.js | 2 +- tests/cases/feature.js | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/feature.js b/src/feature.js index c4ea8f58ac..f03fd85e79 100644 --- a/src/feature.js +++ b/src/feature.js @@ -46,7 +46,7 @@ var feature = function (arg) { // subclasses can add keys to this for styles that apply to subcomponents of // data items, such as individual vertices on lines or polygons. - this._subcomponentStyles = {}; + this._subfeatureStyles = {}; //////////////////////////////////////////////////////////////////////////// /** @@ -395,7 +395,7 @@ var feature = function (arg) { if (!Array.isArray(styleArray)) { return m_this; } - if (m_this._subcomponentStyles[keyOrObject]) { + if (m_this._subfeatureStyles[keyOrObject]) { if (styleArray.length && Array.isArray(styleArray[0])) { m_this.style(keyOrObject, function (v, j, d, i) { var val = (styleArray[i] || [])[j]; diff --git a/src/lineFeature.js b/src/lineFeature.js index af38732f05..8e4107010f 100644 --- a/src/lineFeature.js +++ b/src/lineFeature.js @@ -72,7 +72,7 @@ var lineFeature = function (arg) { m_pointSearchInfo; this.featureType = 'line'; - this._subcomponentStyles = { + this._subfeatureStyles = { lineCap: true, lineJoin: true, strokeColor: true, diff --git a/src/polygonFeature.js b/src/polygonFeature.js index 823cc80d20..36940193a6 100644 --- a/src/polygonFeature.js +++ b/src/polygonFeature.js @@ -68,7 +68,7 @@ var polygonFeature = function (arg) { m_coordinates = []; this.featureType = 'polygon'; - this._subcomponentStyles = { + this._subfeatureStyles = { fillColor: true, fillOpacity: true, lineCap: true, diff --git a/tests/cases/feature.js b/tests/cases/feature.js index 94bde6f4b4..522610dae9 100644 --- a/tests/cases/feature.js +++ b/tests/cases/feature.js @@ -218,7 +218,7 @@ describe('geo.feature', function () { feat.draw = function () { count += 1; }; - feat._subcomponentStyles.opacity = true; + feat._subfeatureStyles.opacity = true; feat.data([1, 2, 3, 4]); feat.style({radius: 10, strokeColor: 'white', opacity: 0.5}); feat.style('radius', 10);