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 9f9941cadc..f03fd85e79 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._subfeatureStyles = {}; + //////////////////////////////////////////////////////////////////////////// /** * Private method to bind mouse handlers on the map element. @@ -354,6 +358,68 @@ 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 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 + * 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 { + /* 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}; + } + if (!Array.isArray(styleArray)) { + return m_this; + } + 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]; + 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 62e3236b64..8e4107010f 100644 --- a/src/lineFeature.js +++ b/src/lineFeature.js @@ -72,6 +72,14 @@ var lineFeature = function (arg) { m_pointSearchInfo; this.featureType = 'line'; + this._subfeatureStyles = { + lineCap: true, + lineJoin: true, + strokeColor: true, + strokeOffset: true, + strokeOpacity: true, + strokeWidth: true + }; //////////////////////////////////////////////////////////////////////////// /** diff --git a/src/polygonFeature.js b/src/polygonFeature.js index 3b5bc753af..36940193a6 100644 --- a/src/polygonFeature.js +++ b/src/polygonFeature.js @@ -68,6 +68,16 @@ var polygonFeature = function (arg) { m_coordinates = []; this.featureType = 'polygon'; + this._subfeatureStyles = { + 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 304f9974bc..522610dae9 100644 --- a/tests/cases/feature.js +++ b/tests/cases/feature.js @@ -213,6 +213,40 @@ describe('geo.feature', function () { expect(feat.visible(true, true)).toBe(feat); expect(feat.visible()).toBe(true); }); + it('updateStyleFromArray', function () { + var count = 0; + feat.draw = function () { + count += 1; + }; + feat._subfeatureStyles.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 16c3701124..3af2f8ef50 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);