diff --git a/examples/picking/main.js b/examples/picking/main.js index 7f1122afc6..ceee663fa5 100644 --- a/examples/picking/main.js +++ b/examples/picking/main.js @@ -1,7 +1,11 @@ +/* globals utils */ + // Run after the DOM loads $(function () { 'use strict'; + var query = utils.getQuery(); + // Create a map object with the OpenStreetMaps base layer. var map = geo.map({ node: '#map', @@ -62,9 +66,10 @@ $(function () { vglLayer.createFeature('line', {selectionAPI: true}) .data([window.randomPath(1000, 0.1, -88, 30), window.randomPath(500, 0.05, -110, 40)]) .style({ - 'strokeColor': function (d, i, e, j) { return (j % 2) ? color(0) : color(1); }, - 'strokeWidth': 5, - 'strokeOpacity': function (d, i, e) { return e.clicked ? 1 : 0.5; } + strokeColor: function (d, i, e, j) { return (j % 2) ? color(0) : color(1); }, + strokeWidth: 5, + strokeOpacity: function (d, i, e) { return e.clicked ? 1 : 0.5; }, + closed: query.closed === 'true' }) .geoOn(geo.event.feature.mouseover, handleMouseOver) .geoOn(geo.event.feature.mouseout, handleMouseOut) @@ -74,9 +79,10 @@ $(function () { svgLayer.createFeature('line', {selectionAPI: true}) .data([window.randomPath(1000, 0.1, -108, 30), window.randomPath(500, 0.05, -88, 40)]) .style({ - 'strokeColor': function (d, i, l, j) { return (j % 2) ? color(2) : color(3); }, - 'strokeWidth': 5, - 'strokeOpacity': function (d, i, e) { return e.clicked ? 1 : 0.5; } + strokeColor: function (d, i, l, j) { return (j % 2) ? color(2) : color(3); }, + strokeWidth: 5, + strokeOpacity: function (d, i, e) { return e.clicked ? 1 : 0.5; }, + closed: query.closed === 'true' }) .geoOn(geo.event.feature.mouseover, handleMouseOver) .geoOn(geo.event.feature.mouseout, handleMouseOut) diff --git a/src/d3/lineFeature.js b/src/d3/lineFeature.js index 8eb5dd9dc1..0c101c3670 100644 --- a/src/d3/lineFeature.js +++ b/src/d3/lineFeature.js @@ -59,9 +59,7 @@ var d3_lineFeature = function (arg) { s_style = m_this.style(), m_renderer = m_this.renderer(), pos_func = m_this.position(), - line = d3.svg.line() - .x(function (d) { return m_this.featureGcsToDisplay(d).x; }) - .y(function (d) { return m_this.featureGcsToDisplay(d).y; }); + line; s_update.call(m_this); s_style.fill = function () { return false; }; @@ -86,6 +84,11 @@ var d3_lineFeature = function (arg) { } } + line = d3.svg.line() + .x(function (d) { return m_this.featureGcsToDisplay(d).x; }) + .y(function (d) { return m_this.featureGcsToDisplay(d).y; }) + .interpolate(m_this.style.get('closed')(item, idx) && ln.length > 2 ? + 'linear-closed' : 'linear'); // item is an object representing a single line // m_this.line()(item) is an array of coordinates m_style = { diff --git a/src/gl/lineFeature.js b/src/gl/lineFeature.js index c801398a41..89bbf969d6 100644 --- a/src/gl/lineFeature.js +++ b/src/gl/lineFeature.js @@ -127,11 +127,11 @@ var gl_lineFeature = function (arg) { function createGLLines() { var data = m_this.data(), - i, j, k, v, + i, j, k, v, lidx, numSegments = 0, len, lineItem, lineItemData, vert = [{}, {}], vertTemp, - pos, posIdx3, + pos, posIdx3, firstpos, firstPosIdx3, position = [], posFunc = m_this.position(), strkWidthFunc = m_this.style.get('strokeWidth'), @@ -141,7 +141,8 @@ var gl_lineFeature = function (arg) { posBuf, nextBuf, prevBuf, offsetBuf, indicesBuf, strokeWidthBuf, strokeColorBuf, strokeOpacityBuf, dest, dest3, - geom = m_mapper.geometryData(); + geom = m_mapper.geometryData(), + closedFunc = m_this.style.get('closed'), closed = []; for (i = 0; i < data.length; i += 1) { lineItem = m_this.line()(data[i], i); @@ -151,6 +152,19 @@ var gl_lineFeature = function (arg) { position.push(pos.x); position.push(pos.y); position.push(pos.z || 0.0); + if (!j) { + firstpos = pos; + } + } + if (lineItem.length > 2 && closedFunc(data[i], i)) { + /* line is closed */ + if (pos.x !== firstpos.x || pos.y !== firstpos.y || + pos.z !== firstpos.z) { + numSegments += 1; + closed[i] = 2; /* first and last points are distinct */ + } else { + closed[i] = 1; /* first point is repeated as last point */ + } } } @@ -174,8 +188,14 @@ var gl_lineFeature = function (arg) { for (i = posIdx3 = dest = dest3 = 0; i < data.length; i += 1) { lineItem = m_this.line()(data[i], i); - for (j = 0; j < lineItem.length; j += 1, posIdx3 += 3) { - lineItemData = lineItem[j]; + firstPosIdx3 = posIdx3; + for (j = 0; j < lineItem.length + (closed[i] === 2 ? 1 : 0); j += 1, posIdx3 += 3) { + lidx = j; + if (j === lineItem.length) { + lidx = 0; + posIdx3 -= 3; + } + lineItemData = lineItem[lidx]; /* swap entries in vert so that vert[0] is the first vertex, and * vert[1] will be reused for the second vertex */ if (j) { @@ -183,12 +203,15 @@ var gl_lineFeature = function (arg) { vert[0] = vert[1]; vert[1] = vertTemp; } - vert[1].pos = posIdx3; - vert[1].prev = posIdx3 - (j ? 3 : 0); - vert[1].next = posIdx3 + (j + 1 < lineItem.length ? 3 : 0); - vert[1].strokeWidth = strkWidthFunc(lineItemData, j, lineItem, i); - vert[1].strokeColor = strkColorFunc(lineItemData, j, lineItem, i); - vert[1].strokeOpacity = strkOpacityFunc(lineItemData, j, lineItem, i); + vert[1].pos = j === lidx ? posIdx3 : firstPosIdx3; + vert[1].prev = lidx ? posIdx3 - 3 : (closed[i] ? + firstPosIdx3 + (lineItem.length - 3 + closed[i]) * 3 : posIdx3); + vert[1].next = j + 1 < lineItem.length ? posIdx3 + 3 : (closed[i] ? + (j !== lidx ? firstPosIdx3 + 3 : firstPosIdx3 + 6 - closed[i] * 3) : + posIdx3); + vert[1].strokeWidth = strkWidthFunc(lineItemData, lidx, lineItem, i); + vert[1].strokeColor = strkColorFunc(lineItemData, lidx, lineItem, i); + vert[1].strokeOpacity = strkOpacityFunc(lineItemData, lidx, lineItem, i); if (j) { for (k = 0; k < order.length; k += 1, dest += 1, dest3 += 3) { v = vert[order[k][0]]; @@ -384,6 +407,7 @@ var gl_lineFeature = function (arg) { //////////////////////////////////////////////////////////////////////////// this._exit = function () { m_this.renderer().contextRenderer().removeActor(m_actor); + m_actor = null; s_exit(); }; diff --git a/src/lineFeature.js b/src/lineFeature.js index 752e363293..0d352daf80 100644 --- a/src/lineFeature.js +++ b/src/lineFeature.js @@ -69,7 +69,12 @@ var lineFeature = function (arg) { /** * Returns an array of datum indices that contain the given point. * This is a slow implementation with runtime order of the number of - * vertices. + * vertices. A point is considered on a line segment if it is close to the + * line or either end point. Closeness is based on the maximum width of the + * line segement, and is ceil(maxwidth / 2) + 2 pixels. This means that + * corner extensions due to mitering may be outside of the selection area and + * that variable width lines will have a greater selection region than their + * visual size at the narrow end. */ //////////////////////////////////////////////////////////////////////////// this.pointSearch = function (p) { @@ -118,7 +123,8 @@ var lineFeature = function (arg) { // for each line data.forEach(function (d, index) { - var last = null; + var closed = m_this.style.get('closed')(d, index), + last, lastr, first; try { line(d, index).forEach(function (current, j) { @@ -127,19 +133,25 @@ var lineFeature = function (arg) { var p = pos(current, j, d, index); var s = m_this.featureGcsToDisplay(p); var r = Math.ceil(width(p, j, d, index) / 2) + 2; - r = r * r; if (last) { + var r2 = lastr > r ? lastr * lastr : r * r; // test the line segment s -> last - if (lineDist2(pt, s, last) <= r) { - + if (lineDist2(pt, s, last) <= r2) { // short circuit the loop here throw 'found'; } } last = s; + lastr = r; + if (!first && closed) { + first = {s: s, r: r}; + } }); + if (closed && lineDist2(pt, last, first.s) <= first.r) { + throw 'found'; + } } catch (err) { if (err !== 'found') { throw err; @@ -150,7 +162,7 @@ var lineFeature = function (arg) { }); return { - data: found, + found: found, index: indices }; }; @@ -168,7 +180,7 @@ var lineFeature = function (arg) { opts = opts || {}; opts.partial = opts.partial || false; if (opts.partial) { - throw 'Unimplemented query method.'; + throw new Error('Unimplemented query method.'); } m_this.data().forEach(function (d, i) { @@ -197,18 +209,20 @@ var lineFeature = function (arg) { */ //////////////////////////////////////////////////////////////////////////// this._init = function (arg) { + arg = arg || {}; s_init.call(m_this, arg); var defaultStyle = $.extend( {}, { - 'strokeWidth': 1.0, + strokeWidth: 1.0, // Default to gold color for lines - 'strokeColor': { r: 1.0, g: 0.8431372549, b: 0.0 }, - 'strokeStyle': 'solid', - 'strokeOpacity': 1.0, - 'line': function (d) { return d; }, - 'position': function (d) { return d; } + strokeColor: { r: 1.0, g: 0.8431372549, b: 0.0 }, + strokeStyle: 'solid', + strokeOpacity: 1.0, + closed: false, + line: function (d) { return d; }, + position: function (d) { return d; } }, arg.style === undefined ? {} : arg.style ); @@ -240,6 +254,7 @@ var lineFeature = function (arg) { lineFeature.create = function (layer, spec) { 'use strict'; + spec = spec || {}; spec.type = 'line'; return feature.create(layer, spec); }; diff --git a/src/pointFeature.js b/src/pointFeature.js index 5999d13d0e..c92b8c097b 100644 --- a/src/pointFeature.js +++ b/src/pointFeature.js @@ -282,7 +282,7 @@ var pointFeature = function (arg) { }); return { - data: found, + found: found, index: ifound }; }; diff --git a/src/polygonFeature.js b/src/polygonFeature.js index 6fc2c9f03c..a964429a6c 100644 --- a/src/polygonFeature.js +++ b/src/polygonFeature.js @@ -247,24 +247,15 @@ var polygonFeature = function (arg) { * @param {object} item: the polygon. * @param {number} itemIndex: the index of the polygon * @param {Array} loop: the inner or outer loop. - * @param {function} posFunc: a function that gets the coordinates of a - * vertex. Used to compare the first and last vertices of the polygon. - * If they do not match exactly, the first vertex is added at the end to - * close the polyline. * @returns {Array} the loop with the data necessary to send to the position * function for each vertex. */ - this._getLoopData = function (item, itemIndex, loop, posFunc) { - var line = [], i, startpos, endpos; + this._getLoopData = function (item, itemIndex, loop) { + var line = [], i; for (i = 0; i < loop.length; i += 1) { line.push([loop[i], i, item, itemIndex]); } - startpos = posFunc(loop[0], 0, item, itemIndex); - endpos = posFunc(loop[loop.length - 1], loop.length - 1, item, itemIndex); - if (startpos.x !== endpos.x || startpos.y !== endpos.y || startpos.z !== endpos.z) { - line.push([loop[0], 0, item, itemIndex]); - } return line; }; @@ -291,6 +282,7 @@ var polygonFeature = function (arg) { } var polyStyle = m_this.style(); m_lineFeature.style({ + closed: true, strokeWidth: polyStyle.strokeWidth, strokeStyle: polyStyle.strokeStyle, strokeColor: polyStyle.strokeColor, @@ -306,10 +298,10 @@ var polygonFeature = function (arg) { for (i = 0; i < data.length; i += 1) { polygon = m_this.polygon()(data[i], i); loop = polygon.outer || (polygon instanceof Array ? polygon : []); - lineData.push(m_this._getLoopData(data[i], i, loop, posFunc)); + lineData.push(m_this._getLoopData(data[i], i, loop)); if (polygon.inner) { polygon.inner.forEach(function (loop) { - lineData.push(m_this._getLoopData(data[i], i, loop, posFunc)); + lineData.push(m_this._getLoopData(data[i], i, loop)); }); } } diff --git a/tests/cases/lineFeature.js b/tests/cases/lineFeature.js new file mode 100644 index 0000000000..73cf745d38 --- /dev/null +++ b/tests/cases/lineFeature.js @@ -0,0 +1,259 @@ +// Test geo.lineFeature, geo.d3.lineFeature, and geo.gl.lineFeature + +var geo = require('../test-utils').geo; +var $ = require('jquery'); +var mockAnimationFrame = require('../test-utils').mockAnimationFrame; +var stepAnimationFrame = require('../test-utils').stepAnimationFrame; +var unmockAnimationFrame = require('../test-utils').unmockAnimationFrame; +var vgl = require('vgl'); +var mockVGLRenderer = require('../test-utils').mockVGLRenderer; +var restoreVGLRenderer = require('../test-utils').restoreVGLRenderer; +var waitForIt = require('../test-utils').waitForIt; + +describe('geo.lineFeature', function () { + 'use strict'; + + var testLines = [ + { + coord: [{x: 20, y: 10}, {x: 25, y: 10}], + closed: true + }, { + coord: [{x: 30, y: 10}, {x: 35, y: 12}, {x: 32, y: 15}], + closed: true + }, { + coord: [{x: 30, y: 20}, {x: 35, y: 22}, {x: 32, y: 25}] + }, { + coord: [{x: 30, y: 30}, {x: 35, y: 32}, {x: 32, y: 35}, {x: 30, y: 30}], + closed: true + }, { + coord: [ + {x: 40, y: 20, width: 10}, + {x: 42, y: 20, width: 5}, + {x: 44, y: 20, width: 2}, + {x: 46, y: 20, width: 2} + ] + }, { + coord: [{x: 50, y: 10}, {x: 50, y: 10}] + } + ]; + + function create_map(opts) { + var node = $('
').css({width: '640px', height: '360px'}); + $('#map').remove(); + $('body').append(node); + opts = $.extend({}, opts); + opts.node = node; + return geo.map(opts); + } + + describe('create', function () { + it('create function', function () { + var map, layer, line; + map = create_map(); + layer = map.createLayer('feature', {renderer: 'd3'}); + line = geo.lineFeature.create(layer); + expect(line instanceof geo.lineFeature).toBe(true); + }); + }); + + describe('Check class accessors', function () { + var map, layer, line; + var pos = [[[0, 0], [10, 5], [5, 10]]]; + it('position', function () { + map = create_map(); + layer = map.createLayer('feature', {renderer: null}); + line = geo.lineFeature({layer: layer}); + expect(line.position()('a')).toBe('a'); + line.position(pos); + expect(line.position()).toEqual(pos); + line.position(function () { return 'b'; }); + expect(line.position()('a')).toEqual('b'); + + line = geo.lineFeature({layer: layer, position: pos}); + expect(line.position()).toEqual(pos); + }); + + it('line', function () { + map = create_map(); + layer = map.createLayer('feature', {renderer: null}); + line = geo.lineFeature({layer: layer}); + expect(line.line()('a')).toBe('a'); + line.line(pos); + expect(line.line()).toEqual(pos); + line.line(function () { return 'b'; }); + expect(line.line()('a')).toEqual('b'); + + line = geo.lineFeature({layer: layer, line: pos}); + expect(line.line()).toEqual(pos); + }); + }); + + describe('Public utility methods', function () { + it('pointSearch', function () { + var map, layer, line, pt, p, data = testLines; + map = create_map(); + layer = map.createLayer('feature', {renderer: 'd3'}); + line = layer.createFeature('line', {selectionAPI: true}); + line.data(data) + .line(function (item) { + return item.coord; + }) + .style({ + strokeWidth: function (d) { + return d.width ? d.width : 5; + }, + closed: function (item) { + return item.closed; + } + }); + pt = line.pointSearch({x: 22, y: 10}); + expect(pt.index).toEqual([0]); + expect(pt.found.length).toBe(1); + expect(pt.found[0].coord[0]).toEqual(data[0].coord[0]); + p = line.featureGcsToDisplay({x: 22, y: 10}); + /* We should land on the line if we are near the specified width. The + * search is generous -- ceil(w / 2) + 2 from the center line. */ + pt = line.pointSearch(map.displayToGcs({x: p.x, y: p.y})); + expect(pt.found.length).toBe(1); + pt = line.pointSearch(map.displayToGcs({x: p.x, y: p.y + 4.95})); + expect(pt.found.length).toBe(1); + pt = line.pointSearch(map.displayToGcs({x: p.x, y: p.y + 5.05})); + expect(pt.found.length).toBe(0); + /* We should find a point between the last and first points on a closed + * line, but not on an open line */ + pt = line.pointSearch({x: 31, y: 12.5}); + expect(pt.found.length).toBe(1); + pt = line.pointSearch({x: 31, y: 22.5}); + expect(pt.found.length).toBe(0); + pt = line.pointSearch({x: 31, y: 32.5}); + expect(pt.found.length).toBe(1); + /* Variable width should match the widest of either end point */ + p = line.featureGcsToDisplay({x: 40, y: 20}); + pt = line.pointSearch(map.displayToGcs({x: p.x, y: p.y + 6.95})); + expect(pt.found.length).toBe(1); + pt = line.pointSearch(map.displayToGcs({x: p.x, y: p.y + 7.05})); + expect(pt.found.length).toBe(0); + p = line.featureGcsToDisplay({x: 42, y: 20}); + pt = line.pointSearch(map.displayToGcs({x: p.x, y: p.y + 6.95})); + expect(pt.found.length).toBe(1); + pt = line.pointSearch(map.displayToGcs({x: p.x, y: p.y + 7.05})); + expect(pt.found.length).toBe(0); + p = line.featureGcsToDisplay({x: 44, y: 20}); + pt = line.pointSearch(map.displayToGcs({x: p.x, y: p.y + 4.95})); + expect(pt.found.length).toBe(1); + pt = line.pointSearch(map.displayToGcs({x: p.x, y: p.y + 5.05})); + expect(pt.found.length).toBe(0); + p = line.featureGcsToDisplay({x: 46, y: 20}); + pt = line.pointSearch(map.displayToGcs({x: p.x, y: p.y + 2.95})); + expect(pt.found.length).toBe(1); + pt = line.pointSearch(map.displayToGcs({x: p.x, y: p.y + 3.05})); + expect(pt.found.length).toBe(0); + /* We should have match line that is two duplicate points */ + pt = line.pointSearch({x: 50, y: 10}); + expect(pt.found.length).toBe(1); + /* If we have zero-length data, we get no matches */ + line.data([]); + pt = line.pointSearch({x: 22, y: 10}); + expect(pt.found.length).toBe(0); + /* Exceptions will be returned properly */ + line.data(data).style('strokeWidth', function (d, idx) { + throw new Error('no width'); + }); + expect(function () { + line.pointSearch({x: 22, y: 10}); + }).toThrow(new Error('no width')); + /* Stop throwing the exception */ + line.style('strokeWidth', 5); + }); + it('boxSearch', function () { + var map, layer, line, idx, data = testLines; + map = create_map(); + layer = map.createLayer('feature', {renderer: 'd3'}); + line = layer.createFeature('line', {selectionAPI: true}); + line.data(data) + .line(function (item, itemIdx) { + return item.coord; + }); + /* The partial flag is intended to eventually return the lines that have + * any part of them in a region. This hasn't been implemented yet. */ + expect(function () { + line.boxSearch({}, {}, {partial: true}); + }).toThrow(new Error('Unimplemented query method.')); + /* Otherwise, all points of the line are expected to be in the bounding + * box. */ + idx = line.boxSearch({x: 19, y: 9}, {x: 26, y: 11}); + expect(idx).toEqual([0]); + idx = line.boxSearch({x: 19, y: 9}, {x: 24, y: 11}); + expect(idx.length).toBe(0); + idx = line.boxSearch({x: 29, y: 9}, {x: 36, y: 22}); + expect(idx).toEqual([1]); + idx = line.boxSearch({x: 29, y: 9}, {x: 36, y: 26}); + expect(idx).toEqual([1, 2]); + }); + }); + + /* This is a basic integration test of geo.d3.lineFeature. */ + describe('geo.d3.lineFeature', function () { + var map, layer, line; + it('basic usage', function () { + mockAnimationFrame(); + map = create_map(); + layer = map.createLayer('feature', {renderer: 'd3'}); + line = layer.createFeature('line', { + line: function (item) { + return item.coord; + }, + style: { + strokeWidth: function (d) { + return d.width ? d.width : 5; + }, + closed: function (item) { + return item.closed; + } + } + }).data(testLines); + line.draw(); + stepAnimationFrame(); + expect(layer.node().find('path').length).toBe(6); + unmockAnimationFrame(); + }); + }); + + /* This is a basic integration test of geo.gl.lineFeature. */ + describe('geo.gl.lineFeature', function () { + var map, layer, line, glCounts; + it('basic usage', function () { + + mockVGLRenderer(); + map = create_map(); + layer = map.createLayer('feature', {renderer: 'vgl'}); + line = layer.createFeature('line', { + line: function (item) { + return item.coord; + }, + style: { + strokeWidth: function (d) { + return d.width ? d.width : 5; + }, + closed: function (item) { + return item.closed; + } + } + }).data(testLines); + line.draw(); + expect(line.verticesPerFeature()).toBe(6); + glCounts = $.extend({}, vgl.mockCounts()); + }); + waitForIt('next render gl A', function () { + return vgl.mockCounts().createProgram >= (glCounts.createProgram || 0) + 1; + }); + it('_exit', function () { + expect(line.actors().length).toBe(1); + layer.deleteFeature(line); + expect(line.actors().length).toBe(0); + line.data(testLines); + map.draw(); + restoreVGLRenderer(); + }); + }); +}); diff --git a/tests/cases/polygonFeature.js b/tests/cases/polygonFeature.js index c1c73a2ef4..8cc4964a94 100644 --- a/tests/cases/polygonFeature.js +++ b/tests/cases/polygonFeature.js @@ -191,7 +191,7 @@ describe('geo.polygonFeature', function () { glCounts = $.extend({}, vgl.mockCounts()); }); waitForIt('next render gl A', function () { - return vgl.mockCounts().createProgram === (glCounts.createProgram || 0) + 2; + return vgl.mockCounts().createProgram >= (glCounts.createProgram || 0) + 2; }); it('update the style', function () { polygons.style('fillColor', function (d) {