From f24c8f9fc089bbecdd346f144f5c362f864aeaa4 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Fri, 13 May 2016 14:45:15 -0400 Subject: [PATCH 1/2] Add controls to the heatmap example. Move the example data set to data.kitware.com. Allow selecting the cities and earthquake data sets. Fix an issue with updating the radius or blurRadius on the fly (the circle wasn't being recomputed). Fix an issue with changing the color gradient on the fly (the gradient wasn't being recomputed). --- examples/heatmap/index.jade | 108 +++++++ examples/heatmap/main.css | 42 +++ examples/heatmap/main.js | 318 +++++++++++++++++-- src/canvas/heatmapFeature.js | 14 +- testing/test-data/AdderallCities2015.csv.md5 | 1 + testing/test-data/AdderallCities2015.csv.url | 1 + testing/test-data/earthquakes.json.md5 | 1 + testing/test-data/earthquakes.json.url | 1 + 8 files changed, 450 insertions(+), 36 deletions(-) create mode 100644 testing/test-data/AdderallCities2015.csv.md5 create mode 100644 testing/test-data/AdderallCities2015.csv.url create mode 100644 testing/test-data/earthquakes.json.md5 create mode 100644 testing/test-data/earthquakes.json.url diff --git a/examples/heatmap/index.jade b/examples/heatmap/index.jade index a2432ab791..27e1bf8b55 100644 --- a/examples/heatmap/index.jade +++ b/examples/heatmap/index.jade @@ -1 +1,109 @@ 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") + span#points-shown + .form-group(title="Delay between movement and heatmap recalculation in milliseconds.") + label(for="updateDelay") Update Delay (ms) + input#updateDelay(type="number" placeholder="50") + .form-group(title="Opacity of heatmap layer (0 to 1).") + label(for="opacity") Opacity + input#opacity(type="number" placeholder="0.75") + .form-group(title="Value to map to minimum intensity. Leave blank to auto calculate.") + label(for="minIntensity") Min. Intensity + input#minIntensity(type="number") + .form-group(title="Value to map to maximum intensity. Leave blank to auto calculate.") + label(for="maxIntensity") Max. Intensity + input#maxIntensity(type="number") + .form-group(title="Radius of center of points in pixels.") + label(for="radius") Radius + input#radius(type="number" placeholder="25") + .form-group(title="Radius of blur around points in pixels.") + label(for="blurRadius") Blur Radius + input#blurRadius(type="number" placeholder="15") + .form-group(title="Color Gradient. Entries with intensities of 0 and 1 are needed to form a valid color gradient.") + label Color Gradient + table.gradient + tr + th(title="Intensity. 0 is minimum, 1 is maximum") I + th(title="Red channel, 0 to 255") R + th(title="Green channel, 0 to 255") G + th(title="Blue channel, 0 to 255") B + th(title="Alpha channel, 0 to 1") A + tr + td + input#gradI1(type="number") + td + input#gradR1(type="number") + td + input#gradG1(type="number") + td + input#gradB1(type="number") + td + input#gradA1(type="number") + tr + td + input#gradI2(type="number") + td + input#gradR2(type="number") + td + input#gradG2(type="number") + td + input#gradB2(type="number") + td + input#gradA2(type="number") + tr + td + input#gradI3(type="number") + td + input#gradR3(type="number") + td + input#gradG3(type="number") + td + input#gradB3(type="number") + td + input#gradA3(type="number") + tr + td + input#gradI4(type="number") + td + input#gradR4(type="number") + td + input#gradG4(type="number") + td + input#gradB4(type="number") + td + input#gradA4(type="number") + tr + td + input#gradI5(type="number") + td + input#gradR5(type="number") + td + input#gradG5(type="number") + td + input#gradB5(type="number") + td + input#gradA5(type="number") + tr + td + input#gradI6(type="number") + td + input#gradR6(type="number") + td + input#gradG6(type="number") + td + input#gradB6(type="number") + td + input#gradA6(type="number") + diff --git a/examples/heatmap/main.css b/examples/heatmap/main.css index e69de29bb2..21b9a57f2a 100644 --- a/examples/heatmap/main.css +++ b/examples/heatmap/main.css @@ -0,0 +1,42 @@ +#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; +} diff --git a/examples/heatmap/main.js b/examples/heatmap/main.js index 8b5cd5945a..9a7209af7d 100644 --- a/examples/heatmap/main.js +++ b/examples/heatmap/main.js @@ -1,3 +1,5 @@ +/* globals geo, $ */ + // Run after the DOM loads $(function () { 'use strict'; @@ -10,40 +12,294 @@ $(function () { }, zoom: 3 }); + var layer, heatmap, points, datapoints; + + var layerOptions = { + renderer: 'canvas', + opacity: 0.75 + }; + var heatmapOptions = { + // binned: 'auto', + minIntensity: null, + maxIntensity: null, + style: { + blurRadius: 15, + color: { + 0.00: {r: 0, g: 0, b: 0, a: 0.0}, + 0.25: {r: 0, g: 1, b: 0, a: 0.5}, + 0.50: {r: 1, g: 1, b: 0, a: 0.8}, + 1.00: {r: 1, g: 0, b: 0, a: 1.0} + }, + radius: 25 + }, + updateDelay: 50 + }; - $.ajax('https://s3.amazonaws.com/uploads.hipchat.com/446632/3114847/4dZfl0YfZpTfYzq/AdderallCities2015.csv', { - success: function (resp) { - var rows = resp.split(/\r\n|\n|\r/); - rows = rows.map( function (r) { - var fields = r.split(','); - return [fields[12], fields[24], fields[25]].map(parseFloat); - }); - rows.splice(0, 1); - - var layer = map.createLayer('feature', {renderer: 'canvas'}); - var heatmap = layer.createFeature('heatmap') - .data(rows) - .intensity(function (d) { - return d[0]; - }) - .position(function (d) { - return { - x: d[2], - y: d[1] - }; - }) - .style('radius', 10) - .style('blurRadius', 30) - .style('opacity', 1.0) - .style('color', - {0: {r: 0, g: 0, b: 0, a: 0.0}, - 0.25: {r: 0, g: 1, b: 0, a: 0.5}, - 0.5: {r: 1, g: 1, b: 0, a: 0.8}, - 1: {r: 1, g: 0, b: 0, a: 1.0}}); - map.draw(); + // 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; + switch (key) { + case 'dataset': + ctlvalue = value ? value : 'adderall'; + break; + case 'gradient': + var parts = value.split(',').map(parseFloat); + if (parts.length >= 5) { + var gradient = {0: {r: 0, g: 0, b: 0, a: 0}}; + for (var i = 0; i < parts.length - 4; i += 5) { + gradient[parts[i]] = { + r: parts[i + 1] / 255, + g: parts[i + 2] / 255, + b: parts[i + 3] / 255, + a: parts[i + 4] + }; + } + heatmapOptions.style.color = gradient; + } + break; + case 'minIntensity': case 'maxIntensity': + value = value.length ? parseFloat(value) : null; + if (!isNaN(value)) { + heatmapOptions[key] = ctlvalue = value; + } + break; + case 'opacity': + value = value.length ? parseFloat(value) : 0.75; + if (!isNaN(value)) { + layerOptions[key] = ctlvalue = value; + } + break; + case 'points': + if (value.length) { + points = ctlvalue = parseInt(value, 10); + } + break; + case 'updateDelay': + if (value.length) { + heatmapOptions[key] = ctlvalue = parseInt(value, 10); + } + break; + case 'radius': case 'blurRadius': + if (value.length) { + value = parseFloat(value); + if (!isNaN(value)) { + heatmapOptions.style[key] = ctlvalue = value; + } + } + break; + // add gaussian and binning when they are added as features + } + if (ctlvalue !== undefined) { + $('#' + ctlkey).val(ctlvalue); } }); + /* Set gradient controls */ + var gradkeys = Object.keys(heatmapOptions.style.color).sort(); + $.each(gradkeys, function (idx, key) { + var entry = heatmapOptions.style.color[key]; + $('#gradI' + (idx + 1)).val(key); + $('#gradR' + (idx + 1)).val(Math.round(entry.r * 255)); + $('#gradG' + (idx + 1)).val(Math.round(entry.g * 255)); + $('#gradB' + (idx + 1)).val(Math.round(entry.b * 255)); + $('#gradA' + (idx + 1)).val(entry.a); + }); + + /* 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) { + window.heatmap.datapoints = null; + 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': + rows = resp; + break; + } + datapoints = rows; + window.heatmap.datapoints = datapoints; + var text = 'Loaded: ' + datapoints.length; + $('#points-loaded').text(text).attr('title', text); + show_points(datapoints); + } + }); + } + + /* 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) { + window.heatmap.rows = null; + 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]); + } + } + } + heatmap.data(rows); + window.heatmap.rows = rows; + map.draw(); + var text = 'Shown: ' + rows.length; + $('#points-shown').text(text).attr('title', text); + } - var base = map.createLayer('osm'); + /** + * 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 'blurRadius': case 'radius': + processedValue = value.length ? parseFloat(value) : undefined; + if (isNaN(processedValue) || processedValue === undefined || + processedValue < 0) { + return; + } + heatmapOptions.style[param] = processedValue; + heatmap.style(param, processedValue); + map.draw(); + break; + case 'dataset': + fetch_data(); + break; + case 'gradient': + var gradient = {}; + for (var idx = 1; idx <= 6; idx += 1) { + var gradkey = parseFloat($('#gradI' + idx).val()); + if (isNaN(gradkey)) { + continue; + } + gradient[gradkey] = { + r: parseInt($('#gradR' + idx).val() || 0) / 255, + g: parseInt($('#gradG' + idx).val() || 0) / 255, + b: parseInt($('#gradB' + idx).val() || 0) / 255, + a: parseFloat($('#gradA' + idx).val() || 0) + }; + } + if (!(0 in gradient && 1 in gradient)) { + value = ''; + break; + } + heatmapOptions.style.color = gradient; + heatmap.style('color', gradient); + map.draw(); + var gradkeys = Object.keys(heatmapOptions.style.color).sort(); + value = gradkeys.map(function (key) { + return [key, Math.round(gradient[key].r * 255), Math.round(gradient[key].g * 255), Math.round(gradient[key].b * 255), gradient[key].a].join(','); + }).join(','); + break; + case 'minIntensity': case 'maxIntensity': + processedValue = value.length ? parseFloat(value) : null; + if (isNaN(processedValue)) { + return; + } + heatmapOptions[param] = processedValue; + heatmap[param](processedValue); + map.draw(); + break; + case 'opacity': + processedValue = value.length ? parseFloat(value) : undefined; + if (isNaN(processedValue) || processedValue === undefined) { + return; + } + layerOptions[param] = processedValue; + layer[param](processedValue); + break; + case 'points': + points = parseInt(value); + show_points(datapoints); + break; + case 'updateDelay': + processedValue = value.length ? parseInt(value) : 50; + heatmapOptions[param] = processedValue; + heatmap[param](processedValue); + 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); + } + + map.createLayer('osm'); map.draw(); + layer = map.createLayer('feature', layerOptions); + heatmap = layer.createFeature('heatmap', heatmapOptions) + .intensity(function (d) { + return d[0]; + }) + .position(function (d) { + return {x: d[2], y: d[1]}; + }); + /* Make some values available in the global context so curious people can + * play with them. */ + window.heatmap = { + map: map, + layer: layer, + layerOptions: layerOptions, + heatmap: heatmap, + heatmapOptions: heatmapOptions + }; + + fetch_data(); + $('#controls').on('change', change_controls); }); diff --git a/src/canvas/heatmapFeature.js b/src/canvas/heatmapFeature.js index db80c6ef49..310376ce89 100644 --- a/src/canvas/heatmapFeature.js +++ b/src/canvas/heatmapFeature.js @@ -67,11 +67,11 @@ var canvas_heatmapFeature = function (arg) { this._computeGradient = function () { var canvas, stop, context2d, gradient, colors; - if (!m_this._grad) { + colors = m_this.style('color'); + if (!m_this._grad || m_this._gradColors !== colors) { canvas = document.createElement('canvas'); context2d = canvas.getContext('2d'); gradient = context2d.createLinearGradient(0, 0, 0, 256); - colors = m_this.style('color'); canvas.width = 1; canvas.height = 256; @@ -83,6 +83,7 @@ var canvas_heatmapFeature = function (arg) { context2d.fillStyle = gradient; context2d.fillRect(0, 0, 1, 256); m_this._grad = context2d.getImageData(0, 0, 1, 256).data; + m_this._gradColors = colors; } return m_this; @@ -96,11 +97,12 @@ var canvas_heatmapFeature = function (arg) { //////////////////////////////////////////////////////////////////////////// this._createCircle = function () { var circle, ctx, r, r2, blur; - if (!m_this._circle) { + r = m_this.style('radius'); + blur = m_this.style('blurRadius'); + if (!m_this._circle || m_this._circle.radius !== r || + m_this._circle.blurRadius !== blur) { circle = m_this._circle = document.createElement('canvas'); ctx = circle.getContext('2d'); - r = m_this.style('radius'); - blur = m_this.style('blurRadius'); r2 = blur + r; @@ -113,6 +115,8 @@ var canvas_heatmapFeature = function (arg) { ctx.arc(-r2, -r2, r, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); + circle.radius = r; + circle.blurRadius = blur; m_this._circle = circle; } return m_this; diff --git a/testing/test-data/AdderallCities2015.csv.md5 b/testing/test-data/AdderallCities2015.csv.md5 new file mode 100644 index 0000000000..6ae14cdcfe --- /dev/null +++ b/testing/test-data/AdderallCities2015.csv.md5 @@ -0,0 +1 @@ +6f92a0e2346c48aad2c7cd5e7aa0005f \ No newline at end of file diff --git a/testing/test-data/AdderallCities2015.csv.url b/testing/test-data/AdderallCities2015.csv.url new file mode 100644 index 0000000000..b2c213d895 --- /dev/null +++ b/testing/test-data/AdderallCities2015.csv.url @@ -0,0 +1 @@ +https://data.kitware.com/api/v1/file/57360bda8d777f68be8f3eac/download diff --git a/testing/test-data/earthquakes.json.md5 b/testing/test-data/earthquakes.json.md5 new file mode 100644 index 0000000000..0d74c5552d --- /dev/null +++ b/testing/test-data/earthquakes.json.md5 @@ -0,0 +1 @@ +9247895af38e2faa36474fe72e02967e \ No newline at end of file diff --git a/testing/test-data/earthquakes.json.url b/testing/test-data/earthquakes.json.url new file mode 100644 index 0000000000..7ef66001f7 --- /dev/null +++ b/testing/test-data/earthquakes.json.url @@ -0,0 +1 @@ +https://data.kitware.com/api/v1/file/57360a968d777f68be8f3ea9/download From e7a7c80eca1bd808a3111dd32af8db07fcf741ab Mon Sep 17 00:00:00 2001 From: David Manthey Date: Mon, 16 May 2016 14:36:05 -0400 Subject: [PATCH 2/2] Use min, max, and steps on the html number inputs. --- examples/heatmap/index.jade | 70 ++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/examples/heatmap/index.jade b/examples/heatmap/index.jade index 27e1bf8b55..4243e2b192 100644 --- a/examples/heatmap/index.jade +++ b/examples/heatmap/index.jade @@ -11,14 +11,14 @@ block append mainContent 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") + input#points(type="number" min="1" step="100") span#points-shown .form-group(title="Delay between movement and heatmap recalculation in milliseconds.") label(for="updateDelay") Update Delay (ms) - input#updateDelay(type="number" placeholder="50") + input#updateDelay(type="number" placeholder="50" min=0) .form-group(title="Opacity of heatmap layer (0 to 1).") label(for="opacity") Opacity - input#opacity(type="number" placeholder="0.75") + input#opacity(type="number" placeholder="0.75" min=0 max=1 step=0.05) .form-group(title="Value to map to minimum intensity. Leave blank to auto calculate.") label(for="minIntensity") Min. Intensity input#minIntensity(type="number") @@ -27,10 +27,10 @@ block append mainContent input#maxIntensity(type="number") .form-group(title="Radius of center of points in pixels.") label(for="radius") Radius - input#radius(type="number" placeholder="25") + input#radius(type="number" placeholder="25" min=1) .form-group(title="Radius of blur around points in pixels.") label(for="blurRadius") Blur Radius - input#blurRadius(type="number" placeholder="15") + input#blurRadius(type="number" placeholder="15" min=0) .form-group(title="Color Gradient. Entries with intensities of 0 and 1 are needed to form a valid color gradient.") label Color Gradient table.gradient @@ -42,68 +42,68 @@ block append mainContent th(title="Alpha channel, 0 to 1") A tr td - input#gradI1(type="number") + input#gradI1(type="number" min=0 max=1 step=0.01) td - input#gradR1(type="number") + input#gradR1(type="number" min=0 max=255 step=1) td - input#gradG1(type="number") + input#gradG1(type="number" min=0 max=255 step=1) td - input#gradB1(type="number") + input#gradB1(type="number" min=0 max=255 step=1) td - input#gradA1(type="number") + input#gradA1(type="number" min=0 max=1 step=0.01) tr td - input#gradI2(type="number") + input#gradI2(type="number" min=0 max=1 step=0.01) td - input#gradR2(type="number") + input#gradR2(type="number" min=0 max=255 step=1) td - input#gradG2(type="number") + input#gradG2(type="number" min=0 max=255 step=1) td - input#gradB2(type="number") + input#gradB2(type="number" min=0 max=255 step=1) td - input#gradA2(type="number") + input#gradA2(type="number" min=0 max=1 step=0.01) tr td - input#gradI3(type="number") + input#gradI3(type="number" min=0 max=1 step=0.01) td - input#gradR3(type="number") + input#gradR3(type="number" min=0 max=255 step=1) td - input#gradG3(type="number") + input#gradG3(type="number" min=0 max=255 step=1) td - input#gradB3(type="number") + input#gradB3(type="number" min=0 max=255 step=1) td - input#gradA3(type="number") + input#gradA3(type="number" min=0 max=1 step=0.01) tr td - input#gradI4(type="number") + input#gradI4(type="number" min=0 max=1 step=0.01) td - input#gradR4(type="number") + input#gradR4(type="number" min=0 max=255 step=1) td - input#gradG4(type="number") + input#gradG4(type="number" min=0 max=255 step=1) td - input#gradB4(type="number") + input#gradB4(type="number" min=0 max=255 step=1) td - input#gradA4(type="number") + input#gradA4(type="number" min=0 max=1 step=0.01) tr td - input#gradI5(type="number") + input#gradI5(type="number" min=0 max=1 step=0.01) td - input#gradR5(type="number") + input#gradR5(type="number" min=0 max=255 step=1) td - input#gradG5(type="number") + input#gradG5(type="number" min=0 max=255 step=1) td - input#gradB5(type="number") + input#gradB5(type="number" min=0 max=255 step=1) td - input#gradA5(type="number") + input#gradA5(type="number" min=0 max=1 step=0.01) tr td - input#gradI6(type="number") + input#gradI6(type="number" min=0 max=1 step=0.01) td - input#gradR6(type="number") + input#gradR6(type="number" min=0 max=255 step=1) td - input#gradG6(type="number") + input#gradG6(type="number" min=0 max=255 step=1) td - input#gradB6(type="number") + input#gradB6(type="number" min=0 max=255 step=1) td - input#gradA6(type="number") + input#gradA6(type="number" min=0 max=1 step=0.01)