diff --git a/examples/isoline/example.json b/examples/isoline/example.json new file mode 100644 index 0000000000..fd2bc9fee5 --- /dev/null +++ b/examples/isoline/example.json @@ -0,0 +1,7 @@ +{ + "title": "Isolines", + "exampleJs": ["main.js"], + "about": { + "text": "This example shows how to add isolines to a map." + } +} diff --git a/examples/isoline/main.js b/examples/isoline/main.js new file mode 100644 index 0000000000..610e295dc8 --- /dev/null +++ b/examples/isoline/main.js @@ -0,0 +1,63 @@ +// Run after the DOM loads +$(function () { + 'use strict'; + + // Create a map object with the OpenStreetMaps base layer. + var map = geo.map({ + node: '#map', + center: { + x: -157.965, + y: 21.482 + }, + zoom: 11 + }); + + // Add a faint osm layer + map.createLayer('osm', {opacity: 0.5}); + + // Create a feature layer that supports contours + var isolineLayer = map.createLayer('feature', { + features: ['isoline'] + }); + + // Load the data + $.get('../../data/oahu-dense.json').done(function (data) { + // Create an isoline feature + var iso = isolineLayer.createFeature('isoline', { + isoline: { + // Specify our grid data + gridWidth: data.gridWidth, + gridHeight: data.gridHeight, + x0: data.x0, + y0: data.y0, + dx: data.dx, + dy: data.dy, + // Don't plot any values less than zero + min: 0, + // Create a contour line every 50 meters + spacing: 50, + // Make every 4th line heavier and every 4*5 = 20th line heavier yet + levels: [4, 5] + }, + style: { + // The data uses -9999 to represent no value; modify it to return null + // instead. + value: function (d) { return d > -9999 ? d : null; }, + // level relates to the isoline importance, with 0 being the most + // common and, using the levels specified, a level of 1 being every + // fourth, and 2 every twentieth line. Color the lines differently + // depending on the level + strokeColor: function (v, vi, d) { + return ['grey', 'mediumblue', 'blue'][d.level]; + } + } + }).data(data.values).draw(); + // Make some values available in the global context so curious people can + // play with them. + window.example = { + map: map, + isolineLayer: isolineLayer, + iso: iso + }; + }); +}); diff --git a/examples/isoline/thumb.jpg b/examples/isoline/thumb.jpg new file mode 100644 index 0000000000..eb7d124f13 Binary files /dev/null and b/examples/isoline/thumb.jpg differ diff --git a/karma-base.js b/karma-base.js index a310ba409f..a471a49884 100644 --- a/karma-base.js +++ b/karma-base.js @@ -321,7 +321,7 @@ module.exports = function (config) { }, FirefoxPrefs) } }, - browserNoActivityTimeout: 30000, + browserNoActivityTimeout: 300000, reporters: [ 'spec', // we had used the 'progress' reporter in the past. 'kjhtml' diff --git a/src/canvas/index.js b/src/canvas/index.js index 0332013d93..747b8692db 100644 --- a/src/canvas/index.js +++ b/src/canvas/index.js @@ -4,6 +4,7 @@ module.exports = { canvasRenderer: require('./canvasRenderer'), heatmapFeature: require('./heatmapFeature'), + isolineFeature: require('./isolineFeature'), lineFeature: require('./lineFeature'), pixelmapFeature: require('./pixelmapFeature'), quadFeature: require('./quadFeature'), diff --git a/src/canvas/isolineFeature.js b/src/canvas/isolineFeature.js new file mode 100644 index 0000000000..09e7cb446b --- /dev/null +++ b/src/canvas/isolineFeature.js @@ -0,0 +1,34 @@ +var inherit = require('../inherit'); +var registerFeature = require('../registry').registerFeature; +var isolineFeature = require('../isolineFeature'); + +/** + * Create a new instance of class isolineFeature. + * + * @class + * @alias geo.canvas.isolineFeature + * @extends geo.isolineFeature + * @param {geo.isolineFeature.spec} arg + * @returns {geo.canvas.isolineFeature} + */ +var canvas_isolineFeature = function (arg) { + 'use strict'; + if (!(this instanceof canvas_isolineFeature)) { + return new canvas_isolineFeature(arg); + } + + arg = arg || {}; + isolineFeature.call(this, arg); + + var object = require('./object'); + object.call(this); + + this._init(arg); + return this; +}; + +inherit(canvas_isolineFeature, isolineFeature); + +// Now register it +registerFeature('canvas', 'isoline', canvas_isolineFeature); +module.exports = canvas_isolineFeature; diff --git a/src/contourFeature.js b/src/contourFeature.js index ed6aabb590..6295bc5597 100644 --- a/src/contourFeature.js +++ b/src/contourFeature.js @@ -117,11 +117,12 @@ var contourFeature = function (arg) { this._createContours = function () { var contour = m_this.contour, valueFunc = m_this.style.get('value'), + usedFunc = m_this.style('used') !== undefined ? + m_this.style.get('used') : + function (d, i) { return util.isNonNullFinite(valueFunc(d, i)); }, minmax, val, range, i, k; var result = this._createMesh({ - used: function (d, i) { - return util.isNonNullFinite(valueFunc(d, i)); - }, + used: usedFunc, opacity: m_this.style.get('opacity'), value: valueFunc }); diff --git a/src/gl/index.js b/src/gl/index.js index 0f7eb3ab98..f1c0013a8c 100644 --- a/src/gl/index.js +++ b/src/gl/index.js @@ -4,6 +4,7 @@ module.exports = { choroplethFeature: require('./choroplethFeature'), contourFeature: require('./contourFeature'), + isolineFeature: require('./isolineFeature'), lineFeature: require('./lineFeature'), pointFeature: require('./pointFeature'), polygonFeature: require('./polygonFeature'), diff --git a/src/gl/isolineFeature.js b/src/gl/isolineFeature.js new file mode 100644 index 0000000000..50ec9ba3e9 --- /dev/null +++ b/src/gl/isolineFeature.js @@ -0,0 +1,33 @@ +var inherit = require('../inherit'); +var registerFeature = require('../registry').registerFeature; +var isolineFeature = require('../isolineFeature'); + +/** + * Create a new instance of isolineFeature. + * + * @class + * @alias geo.gl.isolineFeature + * @extends geo.isolineFeature + * @param {geo.isolineFeature.spec} arg + * @returns {geo.gl.isolineFeature} + */ +var gl_isolineFeature = function (arg) { + 'use strict'; + if (!(this instanceof gl_isolineFeature)) { + return new gl_isolineFeature(arg); + } + arg = arg || {}; + isolineFeature.call(this, arg); + + var object = require('./object'); + object.call(this); + + this._init(arg); + return this; +}; + +inherit(gl_isolineFeature, isolineFeature); + +// Now register it +registerFeature('vgl', 'isoline', gl_isolineFeature); +module.exports = gl_isolineFeature; diff --git a/src/index.js b/src/index.js index 0521bb5f27..7ab6906b65 100644 --- a/src/index.js +++ b/src/index.js @@ -53,6 +53,7 @@ module.exports = $.extend({ graphFeature: require('./graphFeature'), heatmapFeature: require('./heatmapFeature'), imageTile: require('./imageTile'), + isolineFeature: require('./isolineFeature'), jsonReader: require('./jsonReader'), layer: require('./layer'), lineFeature: require('./lineFeature'), diff --git a/src/isolineFeature.js b/src/isolineFeature.js new file mode 100644 index 0000000000..b6012e300e --- /dev/null +++ b/src/isolineFeature.js @@ -0,0 +1,891 @@ +var inherit = require('./inherit'); +var meshFeature = require('./meshFeature'); +var registry = require('./registry'); +var util = require('./util'); + +/** + * Isoline feature specification. + * + * @typedef {geo.feature.spec} geo.isolineFeature.spec + * @property {object[]} [data=[]] An array of arbitrary objects used to + * construct the feature. + * @property {object} [style] An object that contains style values for the + * feature. This includes `geo.lineFeature` and `geo.textFeature` style + * values. + * @property {number|function} [style.opacity=1] Overall opacity on a scale of + * 0 to 1. + * @property {geo.geoPosition|function} [style.position=data] The position of + * each data element. This defaults to just using `x`, `y`, and `z` + * properties of the data element itself. The position is in thea feature's + * gcs coordinates. + * @property {number|function} [style.value=data.z] The value of each data + * element. This defaults `z` properties of the data element. If the value + * of a grid point is `null` or `undefined`, that point and elements that + * use that point won't be included in the results. + * @property {geo.geoColor|function} [style.strokeColor='black'] Color to + * stroke each line. + * @property {number|function} [style.strokeWidth] The weight of the line + * stroke in pixels. This defaults to the line value's level + 0.5. + * @property {boolean|function} [style.rotateWithMap=true] Rotate label text + * when the map rotates. + * @property {number|function} [style.rotation] Text rotation in radians. This + * defaults to the label oriented so that top of the text is toward the + * higher value. There is a utility function that can be used for common + * rotation preferences. See {@link geo.isolineFeature#rotationFunction}. + * For instance, `rotation=geo.isolineFeature.rotationFunction('map')`. + * @property {string|function} [style.fontSize='12px'] The font size. + * @property {geo.geoColor|function} [style.textStrokeColor='white'] Text + * stroke color. This adds contrast between the label and the isoline. + * @property {geo.geoColor|function} [style.textStrokeWidth=2] Text stroke + * width in pixels. + * @property {geo.isolineFeature.isolineSpec} [isoline] The isoline + * specification for the feature. + */ + +/** + * Isoline specification. All of these properties can be functions, which get + * passed the `geo.meshFeature.meshInfo` object. + * + * @typedef {geo.meshFeature.meshSpec} geo.isolineFeature.isolineSpec + * @property {number} [min] Minimum isoline value. If unspecified, taken from + * the computed minimum of the `value` style. + * @property {number} [max] Maximum isoline value. If unspecified, taken from + * the computed maximum of the `value` style. + * @property {number} [count=15] Approximate number of isolines shown through + * the value range. Used if `spacing` or `values` is not specified. + * @property {boolean} [autofit=true] If `count` is used to determine the + * isolines, and this is truthy, the isoline values will be round numbers. + * If falsy, they will include the exact minimum and maximum values. + * @property {number} [spacing] Distance in value units between isolines. + * Used if specified and `values` is not specified. + * @property {number[]|geo.isolineFeature.valueEntry[]} [values] An array of + * explicit values for isolines. + * @property {number[]} [levels=[5, 5]] If `values` is not used to explicitly + * set isoline levels, this determines the spacing of levels which can be + * used to style lines distinctly. Most isolines will be level 0. If + * `levels` is an array of [`n0`, `n1`, ...], every `n0`th line will be + * level 1, every `n0 * n1`th line will be level 2, etc. + * @property {boolean|function} [label] Truthy if a label should be shown for a + * isoline value. If a function, this is called with + * `(geo.isolineFeature.valueEntry, index)`. This defaults to + * `valueEntry.level >= 1`. + * @property {string|function} [labelText] Text for a label. If a function, + * this is called with `(geo.isolineFeature.valueEntry, index)`. This + * defaults to `valueEntry.value`. + * @property {number|function} [labelSpacing=200] Minimum distance between + * labels on an isoline in screen pixels. If a function, this is called + * with `(geo.isolineFeature.valueEntry, index)`. + * @property {number|function} [labelOffset=0] Offset for labels along an + * isoline relative to where they would be placed by default on a scale of + * [-0.5, 0.5]. +/- 1 would move the text to the next repeated occurance of + * the label. If a function, this is called with + * `(geo.isolineFeature.valueEntry, index)`. + * @property {number|function} [labelViewport=10000] If the main position of a + * label would be further than this many pixels from the current viewport, + * don't create it. This prevents creating an excessive number of labels + * when zoomed in, but requires regenerating labels occasionally when + * panning. If <= 0, all labels are generated regardless of location. + * @property {boolean|function} [labelAutoUpdate=true] If truthy, when the map + * is panned (including zoom, rotation, etc.), periodically regenerate + * labels. This uses an internal function that has a threshold based on a + * fixed change in zoom, size, and other parameters. Set `labelAutoUpdate` + * to `false` and handle the `geo.event.pan` elsewhere. + */ + +/** + * Isoline value entry. + * + * @typedef {object} geo.isolineFeature.valueEntry + * @property {number} value The value of the isoline. + * @property {number} level The level of the isoline. + * @property {number} [position] An index of the position of the isoline. For + * evenly spaced or autofit values, this is the value modulo the spacing. + * Otherwise, this is the index position within the list of values. This is + * computed when calculating isolines. + * @property {string} [label] The label to display on this value. This is + * computed from the `label` and `labelText` styles when calculating + * isolines. + */ + +/** + * Computed isoline information. + * + * @typedef {object} geo.isolineFeature.isolineInfo + * @property {geo.isolineFeature.valueEntry[]} values The values used to + * produce the isolines. + * @property {geo.meshFeature.meshInfo} mesh The normalized mesh. + * @property {array[]} lines An array of arrays. Each entry is a list of + * vertices that also have a `value` property with the appropriate entry in + * `values`. If the line should show a label, it will also have a `label` + * property with the text of the label. + * @property {boolean} hasLabels `true` if there are any lines that have + * labels that need to be shown if there is enough resolution. + */ + +/* This includes both the marching triangles and marching squares conditions. + * The triangle pattern has three values, where 0 is less below the threshold + * and 1 is above it. The square pattern has four values in the order + * ul-ur-ll-lr. For each line a pattern produces, the line is created with a + * low and high vertex from each of two edges. Additionally, the create line + * is such that the low value is outside of a clockwise winding. + * + * Performance note: Initially this table used string keys (e.g., '0001'), but + * the string lookup was vastly slower than an integer lookup. + */ +var patternLineTable = { + /* triangles with one high vertex */ + 17 /* 001 */: [{l0: 1, h0: 2, l1: 0, h1: 2}], + 18 /* 010 */: [{l0: 0, h0: 1, l1: 2, h1: 1}], + 20 /* 100 */: [{l0: 2, h0: 0, l1: 1, h1: 0}], + /* triangles with one low vertex */ + 22 /* 110 */: [{l0: 2, h0: 0, l1: 2, h1: 1}], + 21 /* 101 */: [{l0: 1, h0: 2, l1: 1, h1: 0}], + 19 /* 011 */: [{l0: 0, h0: 1, l1: 0, h1: 2}], + /* squares with one high vertex */ + 1 /* 0001 */: [{l0: 2, h0: 3, l1: 1, h1: 3}], + 2 /* 0010 */: [{l0: 0, h0: 2, l1: 3, h1: 2}], + 4 /* 0100 */: [{l0: 3, h0: 1, l1: 0, h1: 1}], + 8 /* 1000 */: [{l0: 1, h0: 0, l1: 2, h1: 0}], + /* squares with one low vertex */ + 14 /* 1110 */: [{l0: 3, h0: 1, l1: 3, h1: 2}], + 13 /* 1101 */: [{l0: 2, h0: 3, l1: 2, h1: 0}], + 11 /* 1011 */: [{l0: 1, h0: 0, l1: 1, h1: 3}], + 7 /* 0111 */: [{l0: 0, h0: 2, l1: 0, h1: 1}], + /* squares with two low vertices sharing a side */ + 3 /* 0011 */: [{l0: 0, h0: 2, l1: 1, h1: 3}], + 10 /* 1010 */: [{l0: 1, h0: 0, l1: 3, h1: 2}], + 12 /* 1100 */: [{l0: 3, h0: 1, l1: 2, h1: 0}], + 5 /* 0101 */: [{l0: 2, h0: 3, l1: 0, h1: 1}], + /* squares with two low vertices on opposite corners. These could generate + * a different pair of lines each. */ + 6 /* 0110 */: [{l0: 0, h0: 2, l1: 0, h1: 1}, {l0: 3, h0: 1, l1: 3, h1: 2}], + 9 /* 1001 */: [{l0: 1, h0: 0, l1: 1, h1: 3}, {l0: 2, h0: 3, l1: 2, h1: 0}] +}; + +/** + * Create a new instance of class isolineFeature. + * + * @class + * @alias geo.isolineFeature + * @extends geo.meshFeature + * + * @borrows geo.isolineFeature#mesh as geo.isolineFeature#contour + * @borrows geo.isolineFeature#mesh as geo.isolineFeature#isoline + * + * @param {geo.isolineFeature.spec} arg + * @returns {geo.isolineFeature} + */ +var isolineFeature = function (arg) { + 'use strict'; + if (!(this instanceof isolineFeature)) { + return new isolineFeature(arg); + } + + var $ = require('jquery'); + var transform = require('./transform'); + var geo_event = require('./event'); + var textFeature = require('./textFeature'); + + arg = arg || {}; + meshFeature.call(this, arg); + + /** + * @private + */ + var m_this = this, + m_isolines, + m_lastLabelPositions, + m_lineFeature, + m_labelLayer, + m_labelFeature, + s_draw = this.draw, + s_exit = this._exit, + s_init = this._init, + s_modified = this.modified, + s_update = this._update; + + this.contour = m_this.mesh; + this.isoline = m_this.mesh; + + /** + * Create a set of isolines. This is a set of lines that could be used for a + * line feature and to inform a text feature. + * + * @returns {geo.isolineFeature.isolineInfo} An object with the isoline + * information. + */ + this._createIsolines = function () { + var valueFunc = m_this.style.get('value'), + usedFunc = m_this.style('used') !== undefined ? + m_this.style.get('used') : + function (d, i) { return util.isNonNullFinite(valueFunc(d, i)); }, + values, + hasLabels = false, + lines = []; + var mesh = this._createMesh({ + used: usedFunc, + value: valueFunc + }); + values = this._getValueList(mesh); + if (!values.length) { + return {}; + } + values.forEach(function (value) { + var valueLines = m_this._isolinesForValue(mesh, value); + if (valueLines.length) { + lines = lines.concat(valueLines); + hasLabels = hasLabels || !!value.label; + } + }); + /* We may want to rdpSimplify the result to remove very small segments, but + * if we do, it must NOT change the winding direction. */ + return { + lines: lines, + mesh: mesh, + values: values, + hasLabels: hasLabels + }; + }; + + /** + * Generate an array of values for which isolines will be generated. + * + * @param {geo.meshFeature.meshInfo} mesh The normalized mesh. + * @returns {geo.isolineFeature.valueEntry[]} The values in ascending order. + */ + this._getValueList = function (mesh) { + var isoline = m_this.isoline, + values = isoline.get('values')(mesh), + spacing = isoline.get('spacing')(mesh), + count = isoline.get('count')(mesh), + autofit = isoline.get('autofit')(mesh), + levels = isoline.get('levels')(mesh), + minmax, delta, step, steppow, steplog10, fixedDigits, i; + if (!mesh.numVertices || !mesh.numElements) { + return []; + } + minmax = util.getMinMaxValues(mesh.value, isoline.get('min')(mesh), isoline.get('max')(mesh), true); + mesh.minValue = minmax.min; + mesh.maxValue = minmax.max; + delta = mesh.maxValue - mesh.minValue; + if (delta <= 0) { + return []; + } + /* Determine values for which we need to generate isolines. */ + if (Array.isArray(values)) { + /* if the caller specified values, use them. Each can either be a number + * or an object with `value` and optionally `level`. If it doesn't have + * level, the position is just the index in the array. */ + values = values.map(function (val, idx) { + return { + value: val.value !== undefined ? val.value : val, + position: idx, + level: val.level + }; + }); + /* Remove any values that are outside of the data range. */ + values = values.filter(function (val) { + return val.value >= mesh.minValue && val.value <= mesh.maxValue; + }); + } else if (!spacing && !autofit) { + /* If no values or spacing are specified and autofit is falsy, then + * use uniform spacing across the value range. The max and min won't + * produce contours (since they are exact values), so there range is + * divided into `count + 1` sections to get `count` visible lines. */ + values = Array(count); + for (i = 0; i < count; i += 1) { + values[i] = { + value: mesh.minValue + delta * (i + 1) / (count + 1), + position: i + 1 + }; + } + } else { + if (!spacing) { + /* If no spacing is specfied, then this has a count with autofit. + * Generate at least 2/3rds as many lines as the count, but it could be + * 5/2 of that when adjusted to "nice values" (so between 2/3 and 5/3 + * of the specified count). */ + step = delta / (count * 2 / 3); + steplog10 = Math.floor(Math.log10(step)); + fixedDigits = Math.max(0, -steplog10); + steppow = Math.pow(10, steplog10); + step /= steppow; // will now be in range [1, 10) + step = step >= 5 ? 5 : step >= 2 ? 2 : 1; // now 1, 2, or 5 + spacing = step * steppow; + } + /* Generate the values based on a spacing. The `position` is used for + * figuring out level further on and is based so that 0 will be the + * maximum level. */ + values = []; + for (i = Math.ceil(mesh.minValue / spacing); i <= Math.floor(mesh.maxValue / spacing); i += 1) { + values.push({value: i * spacing, position: i, fixedDigits: fixedDigits}); + } + } + /* Mark levels for each value. These are intended for styling. All values + * will have a `value` and `position` attribute at this point. */ + if (levels.length) { + values.forEach(function (val, idx) { + if (val.level === undefined) { + val.level = 0; + for (var i = 0, basis = levels[0]; i < levels.length && !(val.position % basis); i += 1, basis *= levels[i]) { + val.level = i + 1; + } + } + if (isoline.get('label')(val, val.position)) { + var label = isoline.get('labelText')(val, val.position); + if (label === undefined) { + if (val.fixedDigits !== undefined) { + label = '' + parseFloat(val.value.toFixed(val.fixedDigits)); + } else { + label = '' + val.value; + } + } + if (label) { + val.label = label; + } + } + }); + } + return values; + }; + + /** + * Add a new segment to a list of chains. Each chain is a list of vertices, + * each of which is an array of two values with the low/high mesh vertices + * for that chain vertex. There are then three possibilities: (a) The + * segment forms a new chain that doesn't attch to an existing chain. (b) + * One endpoint of the segment matches the endpoint of an existing chain, and + * it gets added to that chain. (c) Both endpoints of the segment match + * endpoints of two different chains, and those two chains are combined via + * the segment. A chain may represent a loop, in which case its two + * endpoints will match. This function does not join the loop. + * + * @param {array} chains An array of existing chains. + * @param {array} first One endpoint of the new segment. This is an array of + * two numbers defining the mesh vertices used for the endpoint. + * @param {array} last The second endpoint of the new segment. + * @returns {array} The modified chains array. + */ + this._addSegment = function (chains, first, last) { + var chain = [first, last], + idx = chains.length, + i, iter, check, checkFirst, checkLast, combine; + /* Add the segment as a new chain by itself. */ + chains.push(chain); + for (iter = 0; iter < 2; iter += 1) { + /* Check if the new chain can attach to an existing chain */ + for (i = idx - 1; i >= 0; i -= 1) { + check = chains[i]; + checkFirst = check[0]; + checkLast = check[check.length - 1]; + /* The segment can be inserted at the start of this chain */ + if (last[0] === checkFirst[0] && last[1] === checkFirst[1]) { + combine = chain.concat(check.slice(1)); + /* The segment can be inserted at the end of this chain */ + } else if (first[0] === checkLast[0] && first[1] === checkLast[1]) { + combine = check.concat(chain.slice(1)); + /* These two conditions should never be required, as we generate + * segments with a consistent winding direction. + } else if (first[0] === checkFirst[0] && first[1] === checkFirst[1]) { + combine = chain.slice(1).reverse().concat(check); + } else if (last[0] === checkLast[0] && last[1] === checkLast[1]) { + combine = check.concat(chain.slice(0, chain.length - 1).reverse()); + */ + /* The segment doesn't match this chain, so keep scanning chains */ + } else { + continue; + } + /* The segment matched and `combine` contains the chain it has been + * merged with. */ + chains.splice(idx, 1); + chains[i] = chain = combine; + idx = i; + first = chain[0]; + last = chain[chain.length - 1]; + break; + } + /* If we didn't combine the new chain to any existing chains, then don't + * check if the other end also joins an existing chain. */ + if (i < 0) { + break; + } + } + return chains; + }; + + /** + * Given a vertex of the form [low vertex index, high vertex index], compute + * the coordinates of the vertex. + * + * @param {geo.meshFeature.meshInfo} mesh The normalized mesh. + * @param {geo.isolineFeature.valueEntry} value The value for which to + * generate the vertex. + * @param {number[]} vertex The low vertex index and high vertex index. + * @returns {geo.geoPosition} The calculated coordinate. + */ + this._chainVertex = function (mesh, value, vertex) { + var v0 = vertex[0], v1 = vertex[1], + v03 = v0 * 3, v13 = v1 * 3, + f = (value.value - mesh.value[v0]) / (mesh.value[v1] - mesh.value[v0]), + g = 1 - f; + return { + x: mesh.pos[v03] * g + mesh.pos[v13] * f, + y: mesh.pos[v03 + 1] * g + mesh.pos[v13 + 1] * f, + z: mesh.pos[v03 + 2] * g + mesh.pos[v13 + 2] * f + }; + }; + + /** + * Generate the lines for associated with a particular value. This performs + * either marching triangles or marching squares. + * + * @param {geo.meshFeature.meshInfo} mesh The normalized mesh. + * @param {geo.isolineFeature.valueEntry} value The value for which to + * generate the isolines. + * @returns {geo.isolineFeature.line[]} An array of lines. + */ + this._isolinesForValue = function (mesh, value) { + var val = value.value, + lowhigh = Array(mesh.value.length), + chains = [], + i, v, pattern, lines; + /* Determine if each vertex is above or below the value. It is faster to + * use a for loop than map since it avoids function calls. */ + for (i = lowhigh.length - 1; i >= 0; i -= 1) { + lowhigh[i] = mesh.value[i] <= val ? 0 : 1; + } + var vpe = mesh.verticesPerElement, + square = mesh.shape === 'square', + elem = mesh.elements, + elemLen = elem.length; + for (v = 0; v < elemLen; v += vpe) { + if (square) { + pattern = lowhigh[elem[v]] * 8 + lowhigh[elem[v + 1]] * 4 + + lowhigh[elem[v + 2]] * 2 + lowhigh[elem[v + 3]]; + if (pattern === 0 || pattern === 15) { + continue; + } + } else { + pattern = 16 + lowhigh[elem[v]] * 4 + lowhigh[elem[v + 1]] * 2 + + lowhigh[elem[v + 2]]; + if (pattern === 16 || pattern === 23) { + continue; + } + } + patternLineTable[pattern].forEach(function (lineEntry) { + chains = m_this._addSegment( + chains, + [elem[v + lineEntry.l0], elem[v + lineEntry.h0]], + [elem[v + lineEntry.l1], elem[v + lineEntry.h1]] + ); + }); + } + /* convert chains to lines */ + lines = chains.map(function (chain) { + var line = []; + chain.forEach(function (vertex) { + var v = m_this._chainVertex(mesh, value, vertex); + if (!line.length || v.x !== line[line.length - 1].x || + v.y !== line[line.length - 1].y) { + line.push(v); + } + }); + line.closed = (line[0].x === line[line.length - 1].x && + line[0].y === line[line.length - 1].y); + /* Add value, level, position, and label information to the line. */ + line.value = value.value; + line.level = value.level; + line.position = value.position; + line.label = value.label; + return line; + }).filter(function (line) { return line.length > 1; }); + return lines; + }; + + /** + * When the feature is marked as modified, mark our sub-feature as + * modified, too. + * + * @returns {object} The results of the superclass modified function. + */ + this.modified = function () { + var result = s_modified(); + if (m_lineFeature) { + m_lineFeature.modified(); + } + if (m_labelFeature) { + m_labelFeature.modified(); + } + return result; + }; + + /** + * Compute the positions for labels on each line. This can be called to + * recompute label positions without needign to recompute isolines, for + * instance when the zoom level changes. Label positions are computed in the + * map gcs coordinates, not interface gcs coordinates, since the interface + * gcs may not be linear with the display space. + * + * @returns {this} + */ + this.labelPositions = function () { + if (m_this.dataTime().getMTime() >= m_this.buildTime().getMTime()) { + m_this._build(); + } + m_lastLabelPositions = null; + if (!m_labelFeature) { + return m_this; + } + if (!m_isolines || !m_isolines.hasLabels || !m_isolines.lines || !m_isolines.lines.length) { + m_labelFeature.data([]); + return m_this; + } + var isoline = m_this.isoline, + spacingFunc = isoline.get('labelSpacing'), + offsetFunc = isoline.get('labelOffset'), + labelViewport = isoline.get('labelViewport')(m_isolines.mesh), + gcs = m_this.gcs(), + map = m_this.layer().map(), + mapgcs = map.gcs(), + mapRotation = map.rotation(), + mapSize = map.size(), + labelData = [], + maxSpacing = 0; + m_isolines.lines.forEach(function (line, idx) { + if (!line.label) { + return; + } + var spacing = spacingFunc(line.value, line.value.position), + offset = offsetFunc(line.value, line.value.position) || 0, + dispCoor = map.gcsToDisplay(line, gcs), + totalDistance = 0, + dist, count, localSpacing, next, lineDistance, i, i2, f, g, pos, + mapCoor; + if (spacing <= 0 || isNaN(spacing)) { + return; + } + maxSpacing = Math.max(spacing, maxSpacing); + /* make offset in the range of [0, 1) with the default at 0.5 */ + offset = (offset + 0.5) - Math.floor(offset + 0.5); + dist = dispCoor.map(function (pt1, coorIdx) { + if (!line.closed && coorIdx + 1 === dispCoor.length) { + return 0; + } + var val = Math.sqrt(util.distance2dSquared(pt1, dispCoor[coorIdx + 1 < dispCoor.length ? coorIdx + 1 : 0])); + totalDistance += val; + return val; + }); + count = Math.floor(totalDistance / spacing); + if (!count) { + return; + } + /* If we have any labels, compute map coordinates of the line and use + * those for interpolating label positions */ + mapCoor = transform.transformCoordinates(gcs, mapgcs, line); + localSpacing = totalDistance / count; + next = localSpacing * offset; + lineDistance = 0; + for (i = 0; i < dispCoor.length; i += 1) { + while (lineDistance + dist[i] >= next) { + i2 = i + 1 === dispCoor.length ? 0 : i + 1; + f = (next - lineDistance) / dist[i]; + g = 1 - f; + next += localSpacing; + if (labelViewport > 0) { + pos = { + x: dispCoor[i].x * g + dispCoor[i2].x * f, + y: dispCoor[i].y * g + dispCoor[i2].y * f + }; + if (pos.x < -labelViewport || pos.x > mapSize.width + labelViewport || + pos.y < -labelViewport || pos.y > mapSize.height + labelViewport) { + continue; + } + } + labelData.push({ + x: mapCoor[i].x * g + mapCoor[i2].x * f, + y: mapCoor[i].y * g + mapCoor[i2].y * f, + z: mapCoor[i].z * g + mapCoor[i2].z * f, + line: line, + rotation: Math.atan2(dispCoor[i].y - dispCoor[i2].y, dispCoor[i].x - dispCoor[i2].x) - mapRotation + }); + } + lineDistance += dist[i]; + } + }); + m_labelFeature.gcs(mapgcs); + m_labelFeature.data(labelData); + m_labelFeature.style('renderThreshold', maxSpacing * 2); + m_lastLabelPositions = { + zoom: map.zoom(), + center: map.center(), + rotation: mapRotation, + size: mapSize, + labelViewport: labelViewport, + maxSpacing: maxSpacing, + labelAutoUpdate: isoline.get('labelAutoUpdate')(m_isolines.mesh) + }; + return m_this; + }; + + /** + * Get the last map position that was used for generating labels. + * + * @returns {object} An object with the map `zoom` and `center` and the + * `labelViewport` used in generating labels. The object may have no + * properties if there are no labels. + */ + this.lastLabelPositions = function () { + return $.extend({}, m_lastLabelPositions); + }; + + /** + * On a pan event, if labels exist and are set to autoupdate, recalculate + * their positions and redraw them as needed. Labels are redrawn if the + * zoom level changes by at least 2 levels, or the map's center is moved + * enough that there is a chance that the viewport is nearing the extent of + * the generated labels. The viewport calculation is conservative, as the + * map could be rotated, changed size, or have other modifications. + * + * @returns {exit} + */ + this._updateLabelPositions = function () { + var last = m_lastLabelPositions; + if (!last || !last.labelAutoUpdate) { + return m_this; + } + var map = m_this.layer().map(), + zoom = map.zoom(), + mapSize = map.size(), + update = !!(Math.abs(zoom - last.zoom) >= 2); + if (!update && last.labelViewport > 0) { + /* Distance in scaled pixels between the map's current center and the + * center when the labels were computed. */ + var lastDelta = Math.sqrt(util.distance2dSquared( + map.gcsToDisplay(map.center()), map.gcsToDisplay(last.center))) * + Math.pow(2, last.zoom - zoom); + /* Half the viewport, less twice the maxSpacing, less any expansion of + * the map. */ + var threshold = last.labelViewport / 2 - last.maxSpacing * 2 - Math.max( + mapSize.width - last.size.width, mapSize.height - last.size.height, 0); + update = update || (lastDelta >= threshold); + } + if (update) { + m_this.labelPositions().draw(); + } + return m_this; + }; + + /** + * Build. Generate the isolines. Create a line feature if necessary and + * update it. + * + * @returns {this} + */ + this._build = function () { + m_isolines = m_this._createIsolines(); + if (m_isolines && m_isolines.lines && m_isolines.lines.length) { + if (!m_lineFeature) { + m_lineFeature = m_this.layer().createFeature('line', { + selectionAPI: false, + gcs: m_this.gcs(), + visible: m_this.visible(undefined, true), + style: { + closed: function (d) { return d.closed; } + } + }); + m_this.dependentFeatures([m_lineFeature]); + } + var style = m_this.style(); + m_lineFeature.data(m_isolines.lines).style({ + antialiasing: style.antialiasing, + lineCap: style.lineCap, + lineJoin: style.lineJoin, + miterLimit: style.miterLimit, + strokeWidth: style.strokeWidth, + strokeStyle: style.strokeStyle, + strokeColor: style.strokeColor, + strokeOffset: style.strokeOffset, + strokeOpacity: style.strokeOpacity + }); + if (m_isolines.hasLabels) { + if (!m_labelFeature) { + if (!(registry.registries.features[m_this.layer().rendererName()] || {}).text) { + var renderer = registry.rendererForFeatures(['text']); + m_labelLayer = registry.createLayer('feature', m_this.layer().map(), {renderer: renderer}); + m_this.layer().addChild(m_labelLayer); + m_this.layer().node().append(m_labelLayer.node()); + } + m_labelFeature = (m_labelLayer || m_this.layer()).createFeature('text', { + selectionAPI: false, + gcs: m_this.gcs(), + visible: m_this.visible(undefined, true), + style: { + text: function (d) { return d.line.label; } + } + }).geoOn(geo_event.pan, m_this._updateLabelPositions); + } + textFeature.usedStyles.forEach(function (styleName) { + if (styleName !== 'visible') { + m_labelFeature.style(styleName, style[styleName]); + } + }); + m_this.dependentFeatures([m_lineFeature, m_labelFeature]); + } + } else if (m_lineFeature) { + m_lineFeature.data([]); + } + m_this.buildTime().modified(); + /* Update label positions after setting the build time. The labelPositions + * method will build if necessary, and this prevents it from looping. */ + m_this.labelPositions(); + return m_this; + }; + + /** + * Update. Rebuild if necessary. + * + * @returns {this} + */ + this._update = function () { + s_update.call(m_this); + + if (m_this.dataTime().getMTime() >= m_this.buildTime().getMTime() || + m_this.updateTime().getMTime() <= m_this.getMTime()) { + m_this._build(); + } + m_this.updateTime().modified(); + return m_this; + }; + + /** + * Redraw the object. + * + * @returns {object} The results of the superclass draw function. + */ + this.draw = function () { + var result = s_draw(); + if (m_lineFeature) { + m_lineFeature.draw(); + } + if (m_labelFeature) { + m_labelFeature.draw(); + } + return result; + }; + + /** + * Destroy. + */ + this._exit = function () { + if (m_labelFeature) { + if (m_labelLayer || m_this.layer()) { + (m_labelLayer || m_this.layer()).deleteFeature(m_labelFeature); + } + if (m_labelLayer && m_this.layer()) { + m_this.layer().removeChild(m_labelLayer); + } + } + if (m_lineFeature && m_this.layer()) { + m_this.layer().deleteFeature(m_lineFeature); + } + m_labelFeature = null; + m_labelLayer = null; + m_lineFeature = null; + m_this.dependentFeatures([]); + + s_exit(); + }; + + /** + * Initialize. + * + * @param {geo.isolineFeature.spec} arg The isoline feature specification. + */ + this._init = function (arg) { + arg = arg || {}; + s_init.call(m_this, arg); + + var defaultStyle = $.extend( + {}, + { + opacity: 1.0, + value: function (d, i) { + return m_this.position()(d, i).z; + }, + rotateWithMap: true, + rotation: isolineFeature.rotationFunction(), + strokeWidth: function (v, vi, d, di) { return d.level + 0.5; }, + strokeColor: {r: 0, g: 0, b: 0}, + textStrokeColor: {r: 1, g: 1, b: 1, a: 0.75}, + textStrokeWidth: 2, + fontSize: '12px' + }, + arg.style === undefined ? {} : arg.style + ); + + m_this.style(defaultStyle); + + m_this.isoline($.extend({}, { + count: 15, + autofit: true, + levels: [5, 5], + label: function (value) { + return value.level >= 1; + }, + labelSpacing: 200, + labelViewport: 10000, + labelAutoUpdate: true + }, arg.mesh || {}, arg.contour || {}, arg.isoline || {})); + + if (arg.mesh || arg.contour || arg.isoline) { + m_this.dataTime().modified(); + } + }; + + return this; +}; + +/** + * Return a function that will rotate text labels in a specified orientation. + * The results of this are intended to be used as the value of the `rotation` + * style. + * + * @param {string} [mode='higher'] The rotation mode. `higher` orients the top + * of the text to high values. `lower` orients the top of the text to lower + * values. `map` orients the top of the text so it is aligned to the isoline + * and biased toward the top of the map. `screen` orients the top of the + * text so it is aligned to the isoline and biased toward the top of the + * display screen. + * @param {geo.map} [map] The parent map. Required for `screen` mode. + * @returns {function} A function for the rotation style. + */ +isolineFeature.rotationFunction = function (mode, map) { + var functionList = { + 'higher': function (d) { + return d.rotation; + }, + 'lower': function (d) { + return d.rotation + Math.PI; + }, + 'map': function (d) { + var r = d.rotation, + rt = util.wrapAngle(r, true); + if (rt > Math.PI / 2 || rt < -Math.PI / 2) { + r += Math.PI; + } + return r; + }, + 'screen': function (d) { + var r = d.rotation, + rt = util.wrapAngle(r + map.rotation(), true); + if (rt > Math.PI / 2 || rt < -Math.PI / 2) { + r += Math.PI; + } + return r; + } + }; + return functionList[mode] || functionList.higher; +}; + +inherit(isolineFeature, meshFeature); +module.exports = isolineFeature; diff --git a/src/layer.js b/src/layer.js index 18d0bd8506..b032b01e0f 100644 --- a/src/layer.js +++ b/src/layer.js @@ -90,6 +90,19 @@ var layer = function (arg) { throw new Error('Layers must be initialized on a map.'); } + /** + * Get a list of sibling layers. If no parent has been assigned to this + * layer, assume that the map will be the parent. This gets all of the + * parent's children that are layer instances. + * + * @returns {geo.layer[]} A list of sibling layers. + */ + function _siblingLayers() { + return (m_this.parent() || m_this.map()).children().filter(function (child) { + return child instanceof layer; + }); + } + /** * Get the name of the renderer. * @@ -118,8 +131,8 @@ var layer = function (arg) { // if any extant layer has the same index, then we move all of those // layers up. We do this in reverse order since, if two layers above // this one share a z-index, they will resolve to the layer insert order. - m_map.children().reverse().forEach(function (child) { - if (child.zIndex && child !== this && child.zIndex() === zIndex) { + _siblingLayers().reverse().forEach(function (child) { + if (child !== this && child.zIndex() === zIndex) { child.zIndex(zIndex + 1); } }); @@ -153,7 +166,7 @@ var layer = function (arg) { } // get a sorted list of layers - order = m_this.map().layers().sort( + order = _siblingLayers().sort( function (a, b) { return sign * (a.zIndex() - b.zIndex()); } ); @@ -196,7 +209,7 @@ var layer = function (arg) { * @returns {this} */ this.moveToTop = function () { - return m_this.moveUp(m_this.map().children().length - 1); + return m_this.moveUp(_siblingLayers().length - 1); }; /** @@ -205,7 +218,7 @@ var layer = function (arg) { * @returns {this} */ this.moveToBottom = function () { - return m_this.moveDown(m_this.map().children().length - 1); + return m_this.moveDown(_siblingLayers().length - 1); }; /** @@ -537,25 +550,23 @@ var layer = function (arg) { return m_opacity; }; + // Create top level div for the layer + m_node = $(document.createElement('div')); + m_node.addClass('geojs-layer'); + m_node.attr('id', m_name); + m_this.opacity(m_opacity); + + // set the z-index (this prevents duplication) if (arg.zIndex === undefined) { var maxZ = -1; - m_map.children().forEach(function (child) { - if (child.zIndex) { + _siblingLayers().forEach(function (child) { + if (child.zIndex() !== undefined) { maxZ = Math.max(maxZ, child.zIndex()); } }); arg.zIndex = maxZ + 1; } - m_zIndex = arg.zIndex; - - // Create top level div for the layer - m_node = $(document.createElement('div')); - m_node.addClass('geojs-layer'); - m_node.attr('id', m_name); - m_this.opacity(m_opacity); - - // set the z-index - m_this.zIndex(m_zIndex); + m_this.zIndex(arg.zIndex); return m_this; }; diff --git a/src/main.styl b/src/main.styl index c424fb567a..c4c14b0712 100644 --- a/src/main.styl +++ b/src/main.styl @@ -6,8 +6,8 @@ .geo-attribution position absolute - right 0px - bottom 0px + right 0 + bottom 0 padding-right 5px cursor auto font 11px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif @@ -30,6 +30,8 @@ position absolute width 100% height 100% + left 0 + top 0 pointer-events none &.active > * diff --git a/src/meshFeature.js b/src/meshFeature.js index 55971a23f2..5b2ef63120 100644 --- a/src/meshFeature.js +++ b/src/meshFeature.js @@ -204,9 +204,10 @@ var meshFeature = function (arg) { * @param {object} [vertexValueFuncs] A dictionary where the keys are the * names of properties to include in the results and the values are * functions that are evaluated at each vertex with the arguments - * (data[idx], idx). If a key is named `used`, then if its function - * returns a falsy value for a data point, the vertex associated with that - * data point is removed from the resultant mesh. + * `(data[idx], idx, position)`. If a key is named `used`, then its + * function is passed `(data[idx], idx)` and if it returns a falsy value + * for a data point, the vertex associated with that data point is removed + * from the resultant mesh. * @returns {geo.meshFeature.meshInfo} An object with the mesh information. */ this._createMesh = function (vertexValueFuncs) { @@ -242,7 +243,7 @@ var meshFeature = function (arg) { wrapLongitude = !!(wrapLongitude === undefined || wrapLongitude); if (!usePos && wrapLongitude && (x0 < -180 || x0 > 180 || x0 + dx * (gridW - 1) < -180 || x0 + dx * (gridW - 1) > 180) && - dx > -180 && dx < 180) { + dx > -180 && dx < 180 && dx * (gridW - 1) < 360 + 1e-4) { calcX = []; for (i = 0; i < gridW; i += 1) { x = x0 + i * dx; @@ -271,10 +272,9 @@ var meshFeature = function (arg) { } /* Calculate the value for point */ numPts = gridW * gridH; - result.index = new Array(numPts); - for (i = 0; i < numPts; i += 1) { - origI = i; - if (skipColumn !== undefined) { + if (skipColumn !== undefined) { + result.index = new Array(numPts); + for (i = 0; i < numPts; i += 1) { j = Math.floor(i / gridW); origI = i - j * gridW; origI += (origI > skipColumn ? -2 : 0); @@ -282,8 +282,8 @@ var meshFeature = function (arg) { origI -= gridWorig; } origI += j * gridWorig; + result.index[i] = origI; } - result.index[i] = origI; } /* Create triangles */ for (j = idx = 0; j < gridH - 1; j += 1, idx += 1) { @@ -336,10 +336,6 @@ var meshFeature = function (arg) { result.elements = elements.slice(0, elements.length - (elements.length % 3)); } } - result.index = new Array(data.length); - for (i = 0; i < data.length; i += 1) { - result.index[i] = i; - } numPts = data.length; usePos = true; } @@ -349,20 +345,37 @@ var meshFeature = function (arg) { * used. This could leave vertices that are unused by any element, but * removing those is expensive so it is not done. */ if (vertexValueFuncs.used) { - var remap = new Array(numPts), - vpe = result.verticesPerElement; - for (i = usedPts = 0; i < numPts; i += 1) { - idx = result.index[i]; - if (vertexValueFuncs.used(data[idx], idx)) { - remap[i] = usedPts; - result.index[usedPts] = result.index[i]; - usedPts += 1; - } else { - remap[i] = -1; + for (i = 0; i < numPts; i += 1) { + idx = result.index ? result.index[i] : i; + if (!vertexValueFuncs.used(data[idx], idx)) { + break; } } - result.index.splice(usedPts); - if (usedPts !== numPts) { + if (i !== numPts) { + usedPts = i; + var remap = new Array(numPts), + vpe = result.verticesPerElement; + for (j = 0; j < usedPts; j += 1) { + remap[j] = j; + } + remap[usedPts] = -1; + if (!result.index) { + result.index = new Array(data.length); + for (j = 0; j < data.length; j += 1) { + result.index[j] = j; + } + } + for (i = usedPts + 1; i < numPts; i += 1) { + idx = result.index[i]; + if (vertexValueFuncs.used(data[idx], idx)) { + remap[i] = usedPts; + result.index[usedPts] = result.index[i]; + usedPts += 1; + } else { + remap[i] = -1; + } + } + result.index.splice(usedPts); for (i = k = 0; i < result.elements.length; i += vpe) { for (j = 0; j < vpe; j += 1) { if (remap[result.elements[i + j]] < 0) { @@ -375,8 +388,8 @@ var meshFeature = function (arg) { } } result.elements.splice(k); + numPts = usedPts; } - numPts = usedPts; } /* Get point locations and store them in a packed array */ result.pos = new Array(numPts * 3); @@ -386,7 +399,7 @@ var meshFeature = function (arg) { } } for (i = i3 = 0; i < numPts; i += 1, i3 += 3) { - idx = result.index[i]; + idx = result.index ? result.index[i] : i; item = data[idx]; if (usePos) { posVal = posFunc(item, idx); @@ -401,10 +414,11 @@ var meshFeature = function (arg) { } result.pos[i3 + 1] = y0 + dy * Math.floor(idx / gridW); result.pos[i3 + 2] = 0; + posVal = {x: result.pos[i3], y: result.pos[i3 + 1], z: result.pos[i3 + 2]}; } for (key in vertexValueFuncs) { if (key !== 'used' && vertexValueFuncs.hasOwnProperty(key)) { - result[key][i] = vertexValueFuncs[key](item, idx); + result[key][i] = vertexValueFuncs[key](item, idx, posVal); } } } diff --git a/src/polyfills.js b/src/polyfills.js index 22c62d5315..61c3cab01a 100644 --- a/src/polyfills.js +++ b/src/polyfills.js @@ -23,14 +23,16 @@ if (!window.requestAnimationFrame) { } // Add a polyfill for Math.log2 -if (!Math.log2) { +if (!('log2' in Math)) { Math.log2 = function () { return Math.log.apply(Math, arguments) / Math.LN2; }; } -// Add a polyfill for Math.sinh -Math.sinh = Math.sinh || function (x) { - var y = Math.exp(x); - return (y - 1 / y) / 2; -}; +// Add a polyfill for Math.log10 +if (!('log10' in Math)) { + Math.log10 = function () { + return Math.log.apply(Math, arguments) / Math.LN10; + }; + Math.log10.polyfilled = true; +} diff --git a/testing/test-data/oahu-medium.json.md5 b/testing/test-data/oahu-medium.json.md5 new file mode 100644 index 0000000000..1068442d41 --- /dev/null +++ b/testing/test-data/oahu-medium.json.md5 @@ -0,0 +1 @@ +1f7c4324577dcc314dad02e48f6077b0 \ No newline at end of file diff --git a/testing/test-data/oahu-medium.json.url b/testing/test-data/oahu-medium.json.url new file mode 100644 index 0000000000..c8fad67ac7 --- /dev/null +++ b/testing/test-data/oahu-medium.json.url @@ -0,0 +1 @@ +https://data.kitware.com/api/v1/file/5b16c6918d777f15ebe1ffe7/download diff --git a/tests/cases/isolineFeature.js b/tests/cases/isolineFeature.js new file mode 100644 index 0000000000..9d606a6dc8 --- /dev/null +++ b/tests/cases/isolineFeature.js @@ -0,0 +1,505 @@ +describe('Isoline Feature', function () { + 'use strict'; + + var geo = require('../test-utils').geo; + var createMap = require('../test-utils').createMap; + var destroyMap = require('../test-utils').destroyMap; + var closeToEqual = require('../test-utils').closeToEqual; + var mockVGLRenderer = geo.util.mockVGLRenderer; + var restoreVGLRenderer = geo.util.restoreVGLRenderer; + var mockAnimationFrame = require('../test-utils').mockAnimationFrame; + var stepAnimationFrame = require('../test-utils').stepAnimationFrame; + var unmockAnimationFrame = require('../test-utils').unmockAnimationFrame; + var map, layer, canvasLayer; + var vertexList = [ + {x: 0, y: 0, z: 0}, + {x: 0, y: 1, z: 1}, + {x: 0, y: 3, z: 2}, + {x: 1, y: 0, z: 3}, + {x: 2, y: 2, z: 4}, + {x: 3, y: 3, z: 5}, + {x: 4, y: 0, z: null}, + {x: 4, y: 1, z: 7}, + {x: 4, y: 3, z: 8}, + {x: 5, y: 0, z: 7}, + {x: 5, y: 2, z: 6}, + {x: 5, y: 3, z: 5} + ]; + var squareElements = [ + [1, 2, 5, 4], [0, 1, 4, 3], + [3, 4, 7, 6], [4, 5, 8, 7], + [6, 7, 10, 9], [7, 8, 11, 10]]; + var triangleElements = [ + [0, 1, 4], [1, 2, 5], [3, 4, 7], [4, 5, 8], + [4, 3, 0], [5, 4, 1], [7, 6, 3], [8, 7, 4], + [6, 7, 9], [10, 9, 7], [7, 8, 10], [11, 10, 8]]; + + beforeEach(function () { + mockVGLRenderer(); + mockAnimationFrame(); + map = createMap({ + 'center': [2.5, 1.5], + 'zoom': 9 + }, {width: '500px', height: '300px'}); + layer = map.createLayer('feature', {'renderer': 'vgl'}); + canvasLayer = map.createLayer('feature', {'renderer': 'canvas'}); + }); + + afterEach(function () { + destroyMap(); + unmockAnimationFrame(); + restoreVGLRenderer(); + }); + + describe('create', function () { + it('direct create', function () { + var isoline = geo.isolineFeature({layer: layer}); + expect(isoline instanceof geo.isolineFeature).toBe(true); + expect(isoline instanceof geo.meshFeature).toBe(true); + var mesh = geo.meshFeature({layer: layer}); + expect(mesh instanceof geo.meshFeature).toBe(true); + }); + }); + + describe('Check private class methods', function () { + it('_addSegment', function () { + var isoline = geo.isolineFeature({layer: layer}); + var chains = []; + isoline._addSegment(chains, [0, 1], [2, 3]); + expect(chains.length).toBe(1); + expect(chains[0].length).toBe(2); + expect(chains[0][0]).toEqual([0, 1]); + expect(chains[0][1]).toEqual([2, 3]); + isoline._addSegment(chains, [2, 3], [4, 5]); + expect(chains.length).toBe(1); + expect(chains[0].length).toBe(3); + expect(chains[0][0]).toEqual([0, 1]); + expect(chains[0][2]).toEqual([4, 5]); + isoline._addSegment(chains, [6, 7], [0, 1]); + expect(chains.length).toBe(1); + expect(chains[0].length).toBe(4); + expect(chains[0][0]).toEqual([6, 7]); + expect(chains[0][3]).toEqual([4, 5]); + isoline._addSegment(chains, [8, 9], [10, 11]); + expect(chains.length).toBe(2); + expect(chains[0].length).toBe(4); + expect(chains[1].length).toBe(2); + isoline._addSegment(chains, [12, 13], [8, 9]); + expect(chains.length).toBe(2); + expect(chains[0].length).toBe(4); + expect(chains[1].length).toBe(3); + isoline._addSegment(chains, [10, 11], [6, 7]); + expect(chains.length).toBe(1); + expect(chains[0].length).toBe(7); + expect(chains[0][0]).toEqual([12, 13]); + expect(chains[0][6]).toEqual([4, 5]); + isoline._addSegment(chains, [4, 5], [12, 13]); + expect(chains.length).toBe(1); + expect(chains[0].length).toBe(8); + expect(chains[0][0]).toEqual([4, 5]); + expect(chains[0][7]).toEqual([4, 5]); + }); + describe('_build', function () { + it('vgl', function () { + var isoline = layer.createFeature('isoline', { + isoline: {elements: squareElements}}).data(vertexList); + expect(layer.features().length).toBe(1); + expect(layer.children().length).toBe(1); + expect(isoline._build()).toBe(isoline); + expect(layer.features().length).toBe(2); + expect(layer.children().length).toBe(3); + // number of lines + expect(layer.features()[1].data().length).toBe(18); + // number of labels + expect(layer.children()[2].features()[0].data().length).toBe(10); + isoline.draw(); + stepAnimationFrame(); + isoline.isoline('values', []); + expect(isoline._build()).toBe(isoline); + expect(layer.features()[1].data().length).toBe(0); + expect(layer.children()[2].features()[0].data().length).toBe(0); + }); + it('canvas', function () { + var isoline = canvasLayer.createFeature('isoline', { + isoline:{elements: squareElements}}).data(vertexList); + expect(canvasLayer.features().length).toBe(1); + expect(canvasLayer.children().length).toBe(1); + expect(isoline._build()).toBe(isoline); + expect(canvasLayer.features().length).toBe(3); + expect(canvasLayer.children().length).toBe(3); + // number of lines + expect(canvasLayer.features()[1].data().length).toBe(18); + // number of labels + expect(canvasLayer.features()[2].data().length).toBe(10); + isoline.draw(); + stepAnimationFrame(); + isoline.isoline('values', []); + expect(isoline._build()).toBe(isoline); + expect(canvasLayer.features()[1].data().length).toBe(0); + expect(canvasLayer.features()[2].data().length).toBe(0); + }); + }); + it('_chainVertex', function () { + var isoline = geo.isolineFeature({layer: layer}); + expect(closeToEqual(isoline._chainVertex( + {value: [10, 15], pos: [1, 2, 3, 5, 4, 1]}, {value: 11}, [0, 1]), + {x: 1.8, y: 2.4, z: 2.6})).toBe(true); + expect(closeToEqual(isoline._chainVertex( + {value: [10, 15], pos: [1, 2, 3, 5, 4, 1]}, {value: 11}, [1, 0]), + {x: 1.8, y: 2.4, z: 2.6})).toBe(true); + expect(closeToEqual(isoline._chainVertex( + {value: [15, 10], pos: [1, 2, 3, 5, 4, 1]}, {value: 11}, [0, 1]), + {x: 4.2, y: 3.6, z: 1.4})).toBe(true); + expect(closeToEqual(isoline._chainVertex( + {value: [10, 15], pos: [1, 2, 3, 5, 4, 1]}, {value: 15}, [0, 1]), + {x: 5, y: 4, z: 1})).toBe(true); + expect(closeToEqual(isoline._chainVertex( + {value: [10, 15], pos: [1, 2, 3, 5, 4, 1]}, {value: 15}, [1, 0]), + {x: 5, y: 4, z: 1})).toBe(true); + }); + describe('_createIsolines', function () { + it('square mesh', function () { + var isoline = layer.createFeature('isoline', { + isoline: {elements: squareElements}}).data(vertexList); + var result = isoline._createIsolines(); + expect(result.lines.length).toBe(18); + expect(result.lines[10].length).toBe(2); + expect(result.values.length).toBe(17); + expect(result.values[2].value).toBe(1); + expect(result.values[2].level).toBe(0); + expect(result.values[5].level).toBe(1); + expect(result.hasLabels).toBe(true); + }); + it('triangle mesh', function () { + var isoline = layer.createFeature('isoline', { + isoline: {elements: triangleElements}}).data(vertexList); + var result = isoline._createIsolines(); + expect(result.lines.length).toBe(18); + expect(result.lines[10].length).toBe(4); + expect(result.values.length).toBe(17); + expect(result.values[2].value).toBe(1); + expect(result.values[2].level).toBe(0); + expect(result.values[5].level).toBe(1); + expect(result.hasLabels).toBe(true); + }); + it('no results', function () { + var isoline = layer.createFeature('isoline', {isoline: { + elements: squareElements, + values: [-10, -20] + }}).data(vertexList); + var result = isoline._createIsolines(); + expect(result).toEqual({}); + }); + }); + it('_exit', function () { + var isoline = layer.createFeature('isoline', { + isoline: {elements: squareElements}}).data(vertexList); + isoline._build(); + expect(layer.features().length).toBe(2); + expect(layer.children().length).toBe(3); + isoline._exit(); + expect(layer.features().length).toBe(1); + expect(layer.children().length).toBe(1); + }); + it('_getValueList', function () { + var isoline = layer.createFeature('isoline', { + isoline: {elements: squareElements}}).data(vertexList); + expect(isoline._getValueList({})).toEqual([]); + var mesh = isoline._createIsolines().mesh; + var result; + result = isoline._getValueList(mesh); + expect(result.length).toBe(17); + expect(result[0].position).toBe(0); + expect(result[0].level).toBe(2); + expect(result[1].level).toBe(0); + expect(result[15].value).toBe(7.5); + expect(result[15].position).toBe(15); + expect(result[15].level).toBe(1); + isoline.isoline({min: 10, max: 5}); + expect(isoline._getValueList(mesh)).toEqual([]); + // restrictive max + isoline.isoline({min: null, max: 5}); + result = isoline._getValueList(mesh); + expect(result.length).toBe(11); + expect(result[9].value).toBe(4.5); + // non-restrictive max + isoline.isoline({min: null, max: 20}); + result = isoline._getValueList(mesh); + expect(result.length).toBe(17); + expect(result[15].value).toBe(7.5); + // position should be based on round numbers + isoline.isoline({min: 1, max: 5}); + result = isoline._getValueList(mesh); + expect(result.length).toBe(21); + expect(result[0].position).toBe(5); + expect(result[19].value).toBeCloseTo(4.8); + expect(result[19].position).toBe(24); + // autofit + isoline.isoline('autofit', false); + result = isoline._getValueList(mesh); + expect(result.length).toBe(15); + // count + isoline.isoline('count', 50); + result = isoline._getValueList(mesh); + expect(result.length).toBe(50); + // levels + isoline.isoline({levels: [2, 3, 4], autofit: true}); + result = isoline._getValueList(mesh); + expect(result[38].level).toBe(3); + expect(result[14].level).toBe(3); + expect(result[8].level).toBe(2); + expect(result[6].level).toBe(1); + expect(result[5].level).toBe(0); + // spacing + isoline.isoline({min: null, max: null, spacing: 0.2}); + result = isoline._getValueList(mesh); + expect(result.length).toBe(41); + expect(result[0].value).toBeCloseTo(0); + expect(result[39].value).toBeCloseTo(7.8); + // values + isoline.isoline('values', [4, 3, 2, 2.5]); + result = isoline._getValueList(mesh); + expect(result.length).toBe(4); + expect(result[0].value).toBe(4); + expect(result[0].level).toBe(3); + expect(result[3].value).toBe(2.5); + expect(result[3].level).toBe(0); + // values with some levels + isoline.isoline('values', [{value: 4, level: 0}, {value: 3, level: 1}, {value: 2}, 2.5]); + result = isoline._getValueList(mesh); + expect(result.length).toBe(4); + expect(result[0].value).toBe(4); + expect(result[0].level).toBe(0); + expect(result[1].level).toBe(1); + expect(result[2].level).toBe(1); + expect(result[3].value).toBe(2.5); + expect(result[3].level).toBe(0); + }); + it('_init', function () { + var isoline = geo.isolineFeature({layer: layer}); + expect(isoline.isoline('count')).toBe(undefined); + expect(isoline._init()).toBe(undefined); + expect(isoline.isoline('count')).toBe(15); + isoline._init({isoline: {count: 20}}); + expect(isoline.isoline('count')).toBe(20); + }); + describe('_isolinesForValue', function () { + it('marching squares', function () { + var squares = { + gridWidth: 18, + x0: 0, + y0: 0, + dx: 2, + dy: 2, + values: [ + // this will exercise all conditions of marching squartes. + 0, 0, 1, 0, 0, 0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 0, 0, 1, + 0, 0, 0, 0, 1, 0, 1, 0, 1, 1, 1, 1, 0, 0, 0, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ] + }; + var isoline = layer.createFeature('isoline', { + isoline: squares, style: {value: function (d) { return d; }} + }).data(squares.values); + var mesh = isoline._createIsolines().mesh; + var result = isoline._isolinesForValue(mesh, {value: 0.5}); + // we slice each element of the result to ignore the properties that + // are added to it. + expect(result.map(function (d) { return d.slice(); })).toEqual([[ + {x: 5, y: 0, z: 0}, {x: 4, y: 1, z: 0}, {x: 3, y: 0, z: 0} + ], [ + {x: 9, y: 2, z: 0}, {x: 8, y: 3, z: 0}, {x: 7, y: 2, z: 0}, + {x: 8, y: 1, z: 0}, {x: 9, y: 2, z: 0} + ], [ + {x: 23, y: 0, z: 0}, {x: 23, y: 2, z: 0}, {x: 22, y: 3, z: 0}, + {x: 20, y: 3, z: 0}, {x: 18, y: 3, z: 0}, {x: 16, y: 3, z: 0}, + {x: 15, y: 2, z: 0}, {x: 14, y: 1, z: 0}, {x: 13, y: 2, z: 0}, + {x: 12, y: 3, z: 0}, {x: 11, y: 2, z: 0}, {x: 11, y: 0, z: 0} + ], [ + {x: 17, y: 0, z: 0}, {x: 18, y: 1, z: 0}, {x: 19, y: 0, z: 0} + ], [ + {x: 34, y: 1, z: 0}, {x: 33, y: 2, z: 0}, {x: 32, y: 3, z: 0}, + {x: 30, y: 3, z: 0}, {x: 29, y: 2, z: 0}, {x: 28, y: 1, z: 0}, + {x: 26, y: 1, z: 0}, {x: 25, y: 0, z: 0} + ], [ + {x: 29, y: 0, z: 0}, {x: 30, y: 1, z: 0}, {x: 32, y: 1, z: 0}, + {x: 33, y: 0, z: 0} + ]]); + expect(result.map(function (d) { return d.closed; })).toEqual([ + false, true, false, false, false, false]); + }); + it('marching triangles', function () { + var vertices = [ + {x: 2, y: 2, value: 0}, + {x: 6, y: 2, value: 1}, + {x: 10, y: 2, value: 1}, + {x: 14, y: 2, value: 0}, + {x: 18, y: 2, value: 1}, + {x: 22, y: 2, value: 1}, + {x: 26, y: 2, value: 0}, + + {x: 0, y: 0, value: 0}, + {x: 4, y: 0, value: 0}, + {x: 8, y: 0, value: 0}, + {x: 12, y: 0, value: 1}, + {x: 16, y: 0, value: 0}, + {x: 20, y: 0, value: 1}, + {x: 24, y: 0, value: 0}, + {x: 28, y: 0, value: 1} + // : 0 1 1 0 1 1 0 + // : 0 0 0 1 0 1 0 1 + ]; + var elements = [ + [0, 8, 7], [0, 1, 8], [1, 9, 8], [1, 2, 9], [2, 10, 9], [2, 3, 10], + [3, 11, 10], [3, 4, 11], [4, 12, 11], [4, 5, 12], [5, 13, 12], + [5, 6, 13], [6, 14, 13] + ]; + var isoline = layer.createFeature('isoline', { + isoline: {elements: elements}, + style: {value: function (d) { return d.value; }} + }).data(vertices); + var mesh = isoline._createIsolines().mesh; + var result = isoline._isolinesForValue(mesh, {value: 0.5}); + // we slice each element of the result to ignore the properties that + // are added to it. + expect(result.map(function (d) { return d.slice(); })).toEqual([[ + {x: 4, y: 2, z: 0}, {x: 5, y: 1, z: 0}, {x: 7, y: 1, z: 0}, + {x: 9, y: 1, z: 0}, {x: 10, y: 0, z: 0} + ], [ + {x: 14, y: 0, z: 0}, {x: 13, y: 1, z: 0}, {x: 12, y: 2, z: 0} + ], [ + {x: 16, y: 2, z: 0}, {x: 17, y: 1, z: 0}, {x: 18, y: 0, z: 0} + ], [ + {x: 22, y: 0, z: 0}, {x: 23, y: 1, z: 0}, {x: 24, y: 2, z: 0} + ], [ + {x: 27, y: 1, z: 0}, {x: 26, y: 0, z: 0} + ]]); + }); + }); + it('_update', function () { + var updateTime, buildTime; + var isoline = layer.createFeature('isoline', { + isoline: {elements: squareElements}}).data(vertexList); + isoline.modified(); + updateTime = isoline.updateTime().getMTime(); + buildTime = isoline.buildTime().getMTime(); + expect(isoline._update()).toBe(isoline); + expect(isoline.updateTime().getMTime()).toBeGreaterThan(updateTime); + expect(isoline.buildTime().getMTime()).toBeGreaterThan(buildTime); + updateTime = isoline.updateTime().getMTime(); + buildTime = isoline.buildTime().getMTime(); + expect(isoline._update()).toBe(isoline); + expect(isoline.updateTime().getMTime()).toBeGreaterThan(updateTime); + expect(isoline.buildTime().getMTime()).toBe(buildTime); + }); + it('_updateLabelPositions', function () { + var isoline = layer.createFeature('isoline', { + isoline: {elements: squareElements}}).data(vertexList); + var labelPos; + expect(isoline._updateLabelPositions()).toBe(isoline); + expect(isoline.lastLabelPositions()).toEqual({}); + isoline._build(); + labelPos = isoline.lastLabelPositions(); + expect(labelPos).not.toEqual({}); + expect(isoline._updateLabelPositions()).toBe(isoline); + expect(isoline.lastLabelPositions()).toEqual(labelPos); + map.zoom(map.zoom() + 2); + expect(isoline._updateLabelPositions()).toBe(isoline); + expect(isoline.lastLabelPositions()).not.toEqual(labelPos); + labelPos = isoline.lastLabelPositions(); + map.center({x: 2, y: 1.5}); + expect(isoline._updateLabelPositions()).toBe(isoline); + expect(isoline.lastLabelPositions()).toEqual(labelPos); + map.center({x: -80, y: 1.5}); + expect(isoline._updateLabelPositions()).toBe(isoline); + expect(isoline.lastLabelPositions()).not.toEqual(labelPos); + }); + }); + + describe('Check public class methods', function () { + it('isoline/mesh get and set', function () { + var isoline = geo.isolineFeature({layer: layer}); + isoline._init(); + expect(isoline.isoline().labelSpacing).toEqual(200); + expect(isoline.mesh().labelSpacing).toEqual(200); + expect(isoline.isoline('labelSpacing')).toEqual(200); + expect(isoline.isoline.get('labelSpacing')()).toEqual(200); + expect(isoline.isoline.get().labelSpacing()).toEqual(200); + expect(isoline.isoline('labelSpacing', 150)).toBe(isoline); + expect(isoline.isoline('labelSpacing')).toEqual(150); + expect(isoline.isoline({labelSpacing: 250})).toBe(isoline); + expect(isoline.isoline('labelSpacing')).toEqual(250); + }); + it('draw', function () { + var isoline = layer.createFeature('isoline', { + isoline: {elements: squareElements}}).data(vertexList); + isoline._build(); + sinon.stub(layer.features()[1], 'draw', function () {}); + sinon.stub(layer.children()[2].features()[0], 'draw', function () {}); + isoline.draw(); + expect(layer.features()[1].draw.calledOnce).toBe(true); + expect(layer.children()[2].features()[0].draw.calledOnce).toBe(true); + layer.features()[1].draw.restore(); + layer.children()[2].features()[0].draw.restore(); + }); + it('labelPositions', function () { + var isoline = layer.createFeature('isoline', { + isoline: {elements: squareElements}}).data(vertexList); + expect(isoline.labelPositions()).toBe(isoline); + isoline._build(); + expect(isoline.labelPositions()).toBe(isoline); + expect(layer.children()[2].features()[0].data().length).toBe(10); + // make sure label positions are in map gcs + expect(layer.children()[2].features()[0].data()[0].x).toBeCloseTo(96566.47); + expect(layer.children()[2].features()[0].data()[0].y).toBeCloseTo(34205.96); + isoline.isoline({labelSpacing: -1}); + expect(isoline.labelPositions()).toBe(isoline); + expect(layer.children()[2].features()[0].data().length).toBe(0); + isoline.isoline({labelSpacing: 50}); + expect(isoline.labelPositions()).toBe(isoline); + expect(layer.children()[2].features()[0].data().length).toBe(46); + isoline.isoline({labelSpacing: 5000}); + expect(isoline.labelPositions()).toBe(isoline); + expect(layer.children()[2].features()[0].data().length).toBe(0); + }); + it('lastLabelPositions', function () { + var isoline = layer.createFeature('isoline', { + isoline: {elements: squareElements}}).data(vertexList); + expect(isoline.lastLabelPositions()).toEqual({}); + isoline._update(); + expect(isoline.lastLabelPositions()).not.toEqual({}); + expect(isoline.lastLabelPositions().zoom).toBe(9); + map.zoom(10); + expect(isoline.lastLabelPositions().zoom).toBe(9); + isoline.labelPositions(); + expect(isoline.lastLabelPositions().zoom).toBe(10); + }); + it('modified', function () { + var isoline = layer.createFeature('isoline', { + isoline: {elements: squareElements}}).data(vertexList); + isoline._build(); + sinon.stub(layer.features()[1], 'modified', function () {}); + sinon.stub(layer.children()[2].features()[0], 'modified', function () {}); + isoline.modified(); + expect(layer.features()[1].modified.calledOnce).toBe(true); + expect(layer.children()[2].features()[0].modified.calledOnce).toBe(true); + layer.features()[1].modified.restore(); + layer.children()[2].features()[0].modified.restore(); + }); + }); + + describe('Check public static methods', function () { + it('rotationFunction', function () { + map.rotation(1); + expect(geo.isolineFeature.rotationFunction()({rotation: 2})).toBe(2); + expect(geo.isolineFeature.rotationFunction('higher')({rotation: 1})).toBe(1); + expect(geo.isolineFeature.rotationFunction('higher')({rotation: 2})).toBe(2); + expect(geo.isolineFeature.rotationFunction('lower')({rotation: 1})).toBeCloseTo(1 + Math.PI); + expect(geo.isolineFeature.rotationFunction('lower')({rotation: 2})).toBeCloseTo(2 + Math.PI); + expect(geo.isolineFeature.rotationFunction('map')({rotation: 1})).toBe(1); + expect(geo.isolineFeature.rotationFunction('map')({rotation: 2})).toBeCloseTo(2 + Math.PI); + expect(geo.isolineFeature.rotationFunction('screen', map)({rotation: 1})).toBeCloseTo(1 + Math.PI); + expect(geo.isolineFeature.rotationFunction('screen', map)({rotation: 2})).toBeCloseTo(2 + Math.PI); + }); + }); +}); diff --git a/tests/test-utils.js b/tests/test-utils.js index 4690ae7e0d..befcf8321a 100644 --- a/tests/test-utils.js +++ b/tests/test-utils.js @@ -311,6 +311,7 @@ module.exports.createMap = function (opts, css) { * @returns {boolean} */ module.exports.isPhantomJS = function () { - // PhantomJS doesn't have Math.log10, but Chrome and Firefox do - return !Math.log10; + /* PhantomJS doesn't have Math.log10, but Chrome and Firefox do. If we + * polyfilled it, we will have marked it as such. */ + return !Math.log10 || Math.log10.polyfilled; }; diff --git a/tutorials/isoline/index.pug b/tutorials/isoline/index.pug new file mode 100644 index 0000000000..1b0090b4b6 --- /dev/null +++ b/tutorials/isoline/index.pug @@ -0,0 +1,204 @@ +extends ../common/index.pug + +block mainTutorial + :markdown-it + # Tutorial - Isolines + Given data on a mesh or grid, plot isolines (lines of constant value). + + First, we create a base map tile layer for reference and load some grid data. + The data is grid data describing geospatial point elevation, a record of z value -9999 means there is no land data. + Load the data asynchronously, and use a [promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises) when ready. + + +codeblock('javascript', 1). + var map = geo.map({ + node: "#map", + center: { + x: -157.965, + y: 21.482 + }, + zoom: 11 + }); + // create a tile layer with a low opacity so the isolines are easier to see + map.createLayer('osm', {opacity: 0.5}); + // load some data and store in in a variable we can access. Add the + // promise for this load to the map's idle handler, allowing map.onIdle() + // to be used when everything is ready. We could have also created the + // isoline feature in the `then` function. + var data; + map.addPromise($.get('../../data/oahu-medium.json').then(function (loadedData) { + data = loadedData; + })); + + :markdown-it + Once the data is loaded, create an isoline feature with default options. + + +codeblock('javascript', 2, undefined, true). + var layer, iso; + map.onIdle(function () { + // create a feature layer for the isoline + layer = map.createLayer('feature', {features: ['isoline']}); + iso = layer.createFeature('isoline', { + isoline: { + gridWidth: data.gridWidth, + gridHeight: data.gridHeight, + x0: data.x0, + y0: data.y0, + dx: data.dx, + dy: data.dy + }, + style: { + // return null for values that shouldn't be used. + value: function (d) { return d > -9999 ? d : null; }, + } + }).data(data.values).draw(); + }); + +codeblock_test('isoline feature exists', [ + 'map.layers().length === 2', + 'map.layers()[1].features()[0] instanceof geo.isolineFeature', + 'iso.data().length === 110010' + ]) + + :markdown-it + The spacing between isolines can be set in a variety of ways. By default, a "nice" interval is chosen to have approximately `count` lines, where `count` equals 15. This number can be changed, but if it is only changed a little, the same isolines will still be shown since they round to even values. + +codeblock('javascript', 30, 2, false, 'Step 3-A'). + map.onIdle(function () { + iso.isoline('count', 10).draw(); + }); + +codeblock_test('isoline count', [ + 'map.layers()[1].features()[0]._createIsolines().values.length === 13' + ]) + + :markdown-it + If the count is changed enough, a different number of isolines will be shown. + +codeblock('javascript', 31, 2, false, 'Step 3-B'). + map.onIdle(function () { + iso.isoline('count', 8).draw(); + }); + +codeblock_test('isoline count', [ + 'map.layers()[1].features()[0]._createIsolines().values.length === 7' + ]) + + :markdown-it + You can disable rounding to even values. In this case, there specified count will be exact. Since the isoline values are no longer round numbers, it is useful to limit the precision shown on the text labels. + +codeblock('javascript', 32, 2, false, 'Step 3-C'). + map.onIdle(function () { + iso.isoline({ + count: 20, + autofit: false, + labelText: function (value) { + return value.value.toFixed(2); + } + }).draw(); + }); + +codeblock_test('isoline autofit false', [ + 'map.layers()[1].features()[0]._createIsolines().values.length === 20' + ]) + + :markdown-it + You can specify a spacing in the units used for the isoline values. In this example, this is the altitude in meters. + +codeblock('javascript', 33, 2, false, 'Step 3-D'). + map.onIdle(function () { + iso.isoline('spacing', 125).draw(); + }); + +codeblock_test('isoline spacing', [ + 'map.layers()[1].features()[0]._createIsolines().values.length === 10' + ]) + + :markdown-it + You can also specify exactly which values should have isolines and which ones should be more significant. + +codeblock('javascript', 34, 2, false, 'Step 3-E'). + map.onIdle(function () { + iso.isoline('values', [ + {value: 10, level: 0}, // level 0 is the least significant + {value: 25, level: 0}, + {value: 50, level: 1}, + {value: 75, level: 0}, + {value: 100, level: 1}, + {value: 250, level: 1}, + {value: 500, level: 2}, + {value: 750, level: 1}, + {value: 1000, level: 2}, + {value: 1125, level: 1} + ]).draw(); + }); + +codeblock_test('isoline values', [ + 'map.layers()[1].features()[0]._createIsolines().values.length === 10' + ]) + + :markdown-it + The label text spacing can be changed so that there or more or fewer labels per line. The spacing is the minimum distance between labels in pixels. Labels are placed uniformly along an isoline, so the labels could be further apart to make them even. + +codeblock('javascript', 40, 30, false, 'Step 4-A'). + map.onIdle(function () { + iso.isoline('labelSpacing', 125).draw(); + }); + +codeblock_test('isoline label spacing', [ + 'iso.isoline.get("labelSpacing")({}) === 125' + ]) + + :markdown-it + Labels can be added to any of the isolines and have different spacing per line. + +codeblock('javascript', 41, 30, false, 'Step 4-B'). + map.onIdle(function () { + iso.isoline({ + label: function (value) { + // labels will be on darker lines or every other line + return value.level > 0 || !(value.position % 2); + }, + labelSpacing: function (value) { + // labels on darker lines are twice as common as on faint lines. + return value.level > 0 ? 125 : 250 + }}).draw(); + }); + +codeblock_test('isoline label spacing', [ + 'iso.isoline.get("labelSpacing")({level: 0}) === 250', + 'iso.isoline.get("labelSpacing")({level: 1}) === 125' + ]) + + :markdown-it + The label text can be modified to include the units. The data has altitude in meters. + +codeblock('javascript', 50, 40, false, 'Step 5-A'). + map.onIdle(function () { + iso.isoline('labelText', function (value) { + return value.value + ' m'; + }).draw(); + }); + +codeblock_test('isoline text includes units', [ + 'iso.isoline.get("labelText")({value: 0}) === "0 m"' + ]) + + :markdown-it + Although the data has altitude in meters, it can be converted to different units. For instance, we can convert it to statute (U.S. Survey) feet and change the label text appropriately. + + +codeblock('javascript', 51, 40, false, 'Step 5-B'). + map.onIdle(function () { + iso.style('value', function (d) { + return d > -9999 ? d * 3937 / 1200 : null; + }) + .isoline('labelText', function (value) { + return value.value + ' ft'; + }).draw(); + }); + +codeblock_test('isoline text include ft', [ + 'iso.isoline.get("labelText")({value: 0}) === "0 ft"' + ]) + + :markdown-it + By defaults, labels are shown so that the top of the text is toward higher values. The `geo.isolineFeature.rotationFunction` can be used to choose other orientations. The available modes are: + + - `higher`: the top of the label text is toward higher values. This is the default. + - `lower`: the top of the label text is toward lower values. + - `map`: the top of the label text is toward the top of the map. + - `screen`: the top of the label text is toward the top of the display screen. + + The map has to be rotated to see the difference between `screen` and `map` modes. + + +codeblock('javascript', 60, 50, false, 'Step 6'). + // change this to try out different rotation modes. + var rotationMode = 'screen'; + map.onIdle(function () { + iso.style('rotation', geo.isolineFeature.rotationFunction(rotationMode, map)) + .draw(); + }); + +codeblock_test('isoline text rotation', [ + 'iso.style.get("rotation")({rotation: 2}) === 2 + Math.PI' + ]) diff --git a/tutorials/isoline/thumb.jpg b/tutorials/isoline/thumb.jpg new file mode 100755 index 0000000000..865034a3aa Binary files /dev/null and b/tutorials/isoline/thumb.jpg differ diff --git a/tutorials/isoline/tutorial.json b/tutorials/isoline/tutorial.json new file mode 100644 index 0000000000..e804df29f2 --- /dev/null +++ b/tutorials/isoline/tutorial.json @@ -0,0 +1,8 @@ +{ + "title": "Isolines", + "hideNavbar": true, + "level": 1, + "about": { + "text": "Draw isolines based on scalar grid values." + } +}