diff --git a/examples/deepzoom/thumb.png b/examples/deepzoom/thumb.png deleted file mode 100644 index 5581b85822..0000000000 Binary files a/examples/deepzoom/thumb.png and /dev/null differ diff --git a/examples/lines/example.json b/examples/lines/example.json index c8d23e6279..c04bd3b649 100644 --- a/examples/lines/example.json +++ b/examples/lines/example.json @@ -2,7 +2,7 @@ "title": "Line Feature", "exampleCss": ["main.css"], "exampleJs": ["main.js"], - "thumbquery": "lines=250000", + "thumbquery": "lines=250000&lineCap=round&strokeWidth=%7B%22residential%22%3A1%2C%22service%22%3A0.25%2C%22other%22%3A3%7D", "about": { "text": "This example shows the variations that are available for line features." } diff --git a/examples/lines/index.pug b/examples/lines/index.pug index da614711aa..96f48e9147 100644 --- a/examples/lines/index.pug +++ b/examples/lines/index.pug @@ -36,6 +36,15 @@ block append mainContent .form-group(title="Show an OSM map underneath the lines.") label(for="showmap") Show Map input#showmap(type="checkbox", placeholder="true", checked="checked") + .form-group(title="Reduce line complexity based on zoom level.") + label(for="simplify") Simplify Lines + input#simplify(type="checkbox", placeholder="false") + input#simplify_tolerance(placeholder="0.5", title="Maximum change in pixels at the current zoom level") + span#simple-lines-shown + .form-group(title="When simplification is turned on, update it when zooming the map.") + label(for="resimplify") Zoom Resimplifies + input#resimplify(type="checkbox", placeholder="false") + input#resimplify_delay(placeholder="500", title="Delay in milliseconds before resimplifying lines after a change in zoom level") .form-group .shortlabel Presets button.preset(strokeColor='{"residential":"blue","service":"green","other":"black"}', strokeWidth='{"residential":4,"service":1,"other":8}', lineCap='round', showmap='true', title='Properties based on road category') Categories diff --git a/examples/lines/main.css b/examples/lines/main.css index d6a1cd06b8..6902a1884a 100644 --- a/examples/lines/main.css +++ b/examples/lines/main.css @@ -29,7 +29,7 @@ #controls #lines { width: 100px; } -#controls #lines-loaded,#controls #lines-shown { +#controls #lines-loaded,#controls #lines-shown,#controls #simple-lines-shown { display: inline-block; font-size: 11px; padding-left: 5px; @@ -58,3 +58,7 @@ font-size: 12px; color: black; } +#controls #simplify_tolerance,#controls #resimplify_delay { + width: 80px; +} + diff --git a/examples/lines/main.js b/examples/lines/main.js index 9cae11654f..7f990eaacd 100644 --- a/examples/lines/main.js +++ b/examples/lines/main.js @@ -4,6 +4,14 @@ $(function () { 'use strict'; + function lineAccessor(data, index) { + return data.data; + } + + function positionAccessor(data, index) { + return {x: data[0], y: data[1]}; + } + // Get query parameters var query = utils.getQuery(); @@ -16,7 +24,8 @@ $(function () { }, zoom: 11 }); - var osm, mapUrl, layer, lineFeature, lines, rawdata, skipdraw; + var osm, mapUrl, layer, lineFeature, lines, rawdata, reduceddata, skipdraw, + lastSimplifyZoom, resimplifyTimeout; // By default, use the best renderer that supports lines. This can be // changed on with the 'renderer' query parameter to force a particular @@ -80,15 +89,23 @@ $(function () { lines = ctlvalue = parseInt(value, 10); } break; - case 'showmap': - ctlvalue = value !== 'false'; - break; case 'miterLimit': value = value.length ? parseFloat(value) : undefined; if (!isNaN(value) && value > 0 && value !== undefined) { lineOptions.style[key] = ctlvalue = value; } break; + case 'resimplify': + case 'showmap': + case 'simplify': + ctlvalue = value !== 'false'; + break; + case 'resimplify_delay': + ctlvalue = value.length ? parseInt(value) : undefined; + break; + case 'simplify_tolerance': + ctlvalue = value.length ? parseFloat(value) : undefined; + break; } if (ctlvalue !== undefined) { if ($('#' + ctlkey).is('[type="checkbox"]')) { @@ -124,11 +141,13 @@ $(function () { * Given a set of lines, optionally truncate or expand it, then show it as a * lineFeature. * - * @param {array} rawdata: an array of lines to show. Each entry contains an + * @param {array} rawdata An array of lines to show. Each entry contains an * object that has a 'data' element which is an array of points that form * the line. + * @param {boolean} [simplify] If present, use this as the flag to decide if + * lines should be simplified. */ - function show_lines(rawdata) { + function show_lines(rawdata, simplify) { $('#map').removeClass('ready').attr('segments', ''); if (!rawdata) { return; @@ -140,9 +159,21 @@ $(function () { for (numlines = 0; numlines < rawdata.length && segments < maxsegments; numlines += 1) { segments += rawdata[numlines].data.length - 1; } - var data = rawdata.slice(0, numlines); - lineFeature.data(data); - lineFeature.draw(); + reduceddata = rawdata.slice(0, numlines); + if (simplify === undefined) { + simplify = query.simplify; + } + if (!simplify || simplify === 'false') { + // set the data, and reset the line and position accessors in case we + // changed them with the line simplication + lineFeature.data(reduceddata) + .line(lineAccessor) + .position(positionAccessor); + lineFeature.draw(); + $('#simple-lines-shown').text('').attr('title', ''); + } else { + simplify_lines(reduceddata); + } var text = 'Shown: ' + segments; $('#lines-shown').text(text).attr('title', text); map.onIdle(function () { @@ -150,13 +181,74 @@ $(function () { }); } + /** + * When the map is zoomed, if resimplifying lines is active, set a timer to + * do so after the currently specified delay. + * + * @param {geo.event} evt geojs event that triggered this call. + */ + function resimplifyOnZoom(evt) { + if (resimplifyTimeout) { + window.cancelTimeout(resimplifyTimeout); + resimplifyTimeout = null; + } + if (!query.simplify || query.simplify === 'false' || + !query.resimplify || query.resimplify === 'false') { + return; + } + window.setTimeout(function () { + resimplifyTimeout = null; + if (map.zoom() !== lastSimplifyZoom) { + simplify_lines(reduceddata); + } + }, parseInt(query.resimplify_delay, 10) || 500); + } + + /** + * Simplify lines based on the current zoom level. + * + * @param {array} data The data to simplify. + * @param {number} [tolerance=query.simplify_tolerance] The tolerance in + * pixels at the current map zoom level. + */ + function simplify_lines(data, tolerance) { + if (!data) { + return; + } + lastSimplifyZoom = map.zoom(); + if (resimplifyTimeout) { + window.cancelTimeout(resimplifyTimeout); + resimplifyTimeout = null; + } + tolerance = parseFloat(tolerance !== undefined ? tolerance : query.simplify_tolerance || 0.5); + lineFeature.rdpSimplifyData( + data, + map.unitsPerPixel(map.zoom()) * tolerance, + positionAccessor, + lineAccessor); + lineFeature.draw(); + var segments = 0, + lineFunc = lineFeature.line(); + lineFeature.data().forEach(function (d, i) { + var len = lineFunc(d, i).length; + segments += len >= 2 ? len - 1 : 0; + }); + var text = 'Shown: ' + segments; + $('#simple-lines-shown').text(text).attr('title', text); + text = $('#lines-shown').text(); + if (text.indexOf('Shown') === 0) { + text = 'Used' + text.substr(5); + $('#lines-shown').text(text).attr('title', text); + } + } + /** * For styles that can vary, parse the string and either return a simple * string or a function that computes the value. * - * @param {string} key: the property key. Used to get a default value if + * @param {string} key the property key. Used to get a default value if * needed. - * @param {string} value: the string form of the value. If this has a { in + * @param {string} value the string form of the value. If this has a { in * it, it is parsed as a JSON dictionary, and expects to be a list of * category names which are used to determine the values. These values * applied uniformly per line. Otherwise, if this has a , in it, it is a @@ -204,7 +296,7 @@ $(function () { /** * Handle changes to our controls. * - * @param {object} evt jquery evt that triggered this call. + * @param {object} evt jquery event that triggered this call. */ function change_controls(evt) { var ctl = $(evt.target), @@ -257,9 +349,6 @@ $(function () { lines = parseInt(value); show_lines(rawdata); break; - case 'showmap': - set_osm_url(value); - break; case 'miterLimit': value = value.length ? parseFloat(value) : undefined; if (isNaN(value) || value <= 0 || value === undefined) { @@ -271,6 +360,30 @@ $(function () { lineFeature.draw(); } break; + case 'showmap': + set_osm_url(value); + break; + case 'simplify': + if (!processedValue) { + show_lines(rawdata, false); + } else { + simplify_lines(reduceddata); + } + break; + case 'simplify_tolerance': + processedValue = value.length ? parseFloat(value) : undefined; + if (query.simplify) { + simplify_lines(reduceddata, processedValue); + } + break; + case 'resimplify': + if (processedValue && query.simplify && query.simplify !== 'false' && + map.zoom() !== lastSimplifyZoom) { + simplify_lines(reduceddata); + } + break; + case 'resimplify_delay': + break; } // Update the url to reflect the changes query[param] = value; @@ -286,7 +399,7 @@ $(function () { /** * Handle selecting a preset button. * - * @param {object} evt: jquery event with the triggered button. + * @param {object} evt jquery event with the triggered button. */ function select_preset(evt) { var update; @@ -315,7 +428,7 @@ $(function () { /** * Set the map to either use the original default url or a blank white image. * - * @param {string} value: 'false' to use a white image, anything else to use + * @param {string} value 'false' to use a white image, anything else to use * the original url. */ function set_osm_url(value) { @@ -341,12 +454,8 @@ $(function () { 'hidden'); // Ceate a line feature lineFeature = layer.createFeature('line', lineOptions) - .line(function (d) { - return d.data; - }) - .position(function (d) { - return {x: d[0], y: d[1]}; - }) + .line(lineAccessor) + .position(positionAccessor) // add hover events -- use mouseon and mouseoff, since we only show one // tootip. If we showed one tooltip per item we were over, use mouseover // and mouseout. @@ -361,7 +470,8 @@ $(function () { }) .geoOn(geo.event.feature.mouseoff, function (evt) { tooltipElem.addClass('hidden'); - }); + }) + .geoOn(geo.event.zoom, resimplifyOnZoom); // Make some values available in the global context so curious people can // play with them. diff --git a/examples/lines/thumb.jpg b/examples/lines/thumb.jpg index 68292c72f7..00c9527377 100755 Binary files a/examples/lines/thumb.jpg and b/examples/lines/thumb.jpg differ diff --git a/examples/polygons/main.js b/examples/polygons/main.js index 5b44ababb3..5800b1b84e 100644 --- a/examples/polygons/main.js +++ b/examples/polygons/main.js @@ -26,6 +26,7 @@ $(function () { var hoverColor = query.hover || 'blue'; var polyColor = query.color ? geo.util.convertColor(query.color) : undefined; $.getJSON(query.url || '../../data/land_polygons.json').done(function (data) { + polygonDebug.data = data; polygons /* This is the default accessor, so we don't have to define it ourselves. .polygon(function (d) { diff --git a/src/gl/polygonFeature.js b/src/gl/polygonFeature.js index 9c6df95027..060f21809a 100644 --- a/src/gl/polygonFeature.js +++ b/src/gl/polygonFeature.js @@ -128,6 +128,9 @@ var gl_polygonFeature = function (arg) { return; } outer = polygon.outer || (Array.isArray(polygon) ? polygon : []); + if (outer.length < 3) { + return; + } /* expand to an earcut polygon geometry. We had been using a map call, * but using loops is much faster in Chrome (4 versus 33 ms for one @@ -144,6 +147,9 @@ var gl_polygonFeature = function (arg) { if (polygon.inner) { polygon.inner.forEach(function (hole) { + if (hole.length < 3) { + return; + } original = original.concat(hole); geometry.holes.push(d3 / 3); for (i = 0; i < hole.length; i += 1, d3 += 3) { diff --git a/src/lineFeature.js b/src/lineFeature.js index 20eda96e98..e3efbd554c 100644 --- a/src/lineFeature.js +++ b/src/lineFeature.js @@ -2,6 +2,7 @@ var inherit = require('./inherit'); var feature = require('./feature'); var timestamp = require('./timestamp'); var transform = require('./transform'); +var util = require('./util'); /** * Line feature specification. @@ -199,33 +200,10 @@ var lineFeature = function (arg) { pt = transform.transformCoordinates(map.ingcs(), map.gcs(), p), i, j, record; - // minimum l2 distance squared from - // q -> line(u, v) - function lineDist2(q, u, v) { - var t, vux = v.x - u.x, vuy = v.y - u.y, l2 = vux * vux + vuy * vuy; - - if (l2 < 1) { - // u, v are within 1 pixel - return dist2(q, u); - } - - t = ((q.x - u.x) * vux + (q.y - u.y) * vuy) / l2; - if (t < 0) { return dist2(q, u); } - if (t > 1) { return dist2(q, v); } - return dist2(q, {x: u.x + t * vux, y: u.y + t * vuy}); - } - - // l2 distance squared from u to v - function dist2(u, v) { - var dx = u.x - v.x, - dy = u.y - v.y; - return dx * dx + dy * dy; - } - for (i = 0; i < m_pointSearchInfo.length; i += 1) { record = m_pointSearchInfo[i]; for (j = 0; j < record.length; j += 1) { - if (lineDist2(pt, record[j].u, record[j].v) <= record[j].r2 * scale2) { + if (util.distance2dToLineSquared(pt, record[j].u, record[j].v) <= record[j].r2 * scale2) { found.push(data[i]); indices.push(i); break; @@ -280,6 +258,62 @@ var lineFeature = function (arg) { return idx; }; + /** + * Take a set of data, reduce the number of vertices per linen using the + * Ramer–Douglas–Peucker algorithm, and use the result as the new data. + * This changes the instance's data, the position accessor, and the line + * accessor. + * + * @param {array} data A new data array. + * @param {number} [tolerance] The maximum variation allowed in map.gcs + * units. A value of zero will only remove perfectly colinear points. If + * not specified, this is set to a half display pixel at the map's current + * zoom level. + * @param {function} [posFunc=this.style.get('position')] The function to + * get the position of each vertex. + * @param {function} [lineFunc=this.style.get('line')] The function to get + * each line. + * @returns {this} + */ + this.rdpSimplifyData = function (data, tolerance, posFunc, lineFunc) { + data = data || m_this.data(); + posFunc = posFunc || m_this.style.get('position'); + lineFunc = lineFunc || m_this.style.get('line'); + var map = m_this.layer().map(), + mapgcs = map.gcs(), + featuregcs = m_this.gcs(), + closedFunc = m_this.style.get('closed'); + if (tolerance === undefined) { + tolerance = map.unitsPerPixel(map.zoom()) * 0.5; + } + + /* transform the coordinates to the map gcs */ + data = data.map(function (d, idx) { + var lineItem = lineFunc(d, idx), + pts = transform.transformCoordinates(featuregcs, mapgcs, lineItem.map(function (ld, lidx) { + return posFunc(ld, lidx, d, idx); + })), + elem = util.rdpLineSimplify(pts, tolerance, closedFunc(d, idx), []); + if (elem.length < 2 || (elem.length === 2 && util.distance2dSquared(elem[0], elem[1]) < tolerance * tolerance)) { + elem = []; + } + elem = transform.transformCoordinates(mapgcs, featuregcs, elem); + /* Copy element properties, as they might be used by styles */ + for (var key in d) { + if (d.hasOwnProperty(key) && !(Array.isArray(d) && key >= 0 && key < d.length)) { + elem[key] = d[key]; + } + } + return elem; + }); + + /* Set the reduced lines as the data and use simple accessors. */ + m_this.style('position', function (d) { return d; }); + m_this.style('line', function (d) { return d; }); + m_this.data(data); + return m_this; + }; + /** * Initialize. * diff --git a/src/polygonFeature.js b/src/polygonFeature.js index f22eae0cf0..4b2874b92a 100644 --- a/src/polygonFeature.js +++ b/src/polygonFeature.js @@ -90,24 +90,32 @@ var polygonFeature = function (arg) { this.data = function (arg) { var ret = s_data(arg); if (arg !== undefined) { - getCoordinates(); + m_coordinates = getCoordinates(); this._checkForStroke(); } return ret; }; /** - * Get the internal coordinates whenever the data changes. For now, we do - * the computation in world coordinates, but we will need to work in GCS - * for other projections. Also compute the extents of the outside of each - * polygon for faster checking if points are in the polygon. + * Get the internal coordinates whenever the data changes. Also compute the + * extents of the outside of each polygon for faster checking if points are + * in the polygon. + * * @private + * @param {object[]} [data=this.data()] The data to process. + * @param {function} [posFunc=this.style.get('position')] The function to + * get the position of each vertex. + * @param {function} [polyFunc=this.style.get('polygon')] The function to + * get each polygon. + * @returns {object[]} An array of polygon positions. Each has `outer` and + * `inner` if it has any coordinates, or is undefined. */ - function getCoordinates() { - var posFunc = m_this.style.get('position'), - polyFunc = m_this.style.get('polygon'); - m_coordinates = m_this.data().map(function (d, i) { - var poly = polyFunc(d); + function getCoordinates(data, posFunc, polyFunc) { + data = data || m_this.data(); + posFunc = posFunc || m_this.style.get('position'); + polyFunc = polyFunc || m_this.style.get('polygon'); + var coordinates = data.map(function (d, i) { + var poly = polyFunc(d, i); if (!poly) { return; } @@ -142,6 +150,7 @@ var polygonFeature = function (arg) { range: range }; }); + return coordinates; } /** @@ -182,7 +191,7 @@ var polygonFeature = function (arg) { m_this.style('polygon', val); m_this.dataTime().modified(); m_this.modified(); - getCoordinates(); + m_coordinates = getCoordinates(); } return m_this; }; @@ -202,7 +211,7 @@ var polygonFeature = function (arg) { m_this.style('position', val); m_this.dataTime().modified(); m_this.modified(); - getCoordinates(); + m_coordinates = getCoordinates(); } return m_this; }; @@ -392,6 +401,89 @@ var polygonFeature = function (arg) { return result; }; + /** + * Take a set of data, reduce the number of vertices per polygon using the + * Ramer–Douglas–Peucker algorithm, and use the result as the new data. + * This changes the instance's data, the position accessor, and the polygon + * accessor. + * + * @param {array} data A new data array. + * @param {number} [tolerance] The maximum variation allowed in map.gcs + * units. A value of zero will only remove perfectly colinear points. If + * not specified, this is set to a half display pixel at the map's current + * zoom level. + * @param {function} [posFunc=this.style.get('position')] The function to + * get the position of each vertex. + * @param {function} [polyFunc=this.style.get('polygon')] The function to + * get each polygon. + * @returns {this} + */ + this.rdpSimplifyData = function (data, tolerance, posFunc, polyFunc) { + var map = m_this.layer().map(), + mapgcs = map.gcs(), + featuregcs = m_this.gcs(), + coordinates = getCoordinates(data, posFunc, polyFunc); + if (tolerance === undefined) { + tolerance = map.unitsPerPixel(map.zoom()) * 0.5; + } + + /* transform the coordinates to the map gcs */ + coordinates = coordinates.map(function (poly) { + return { + outer: transform.transformCoordinates(featuregcs, mapgcs, poly.outer), + inner: poly.inner.map(function (hole) { + return transform.transformCoordinates(featuregcs, mapgcs, hole); + }) + }; + }); + data = data.map(function (d, idx) { + var poly = coordinates[idx], + elem = {}; + /* Copy element properties, as they might be used by styles */ + for (var key in d) { + if (d.hasOwnProperty(key) && !(Array.isArray(d) && key >= 0 && key < d.length)) { + elem[key] = d[key]; + } + } + if (poly && poly.outer.length >= 3) { + // discard degenerate holes before anything else + elem.inner = poly.inner.filter(function (hole) { + return hole.length >= 3; + }); + // simplify the outside of the polygon without letting it cross holes + elem.outer = util.rdpLineSimplify(poly.outer, tolerance, true, elem.inner); + if (elem.outer.length >= 3) { + var allButSelf = elem.inner.slice(); + // simplify holes without crossing other holes or the outside + elem.inner.map(function (hole, idx) { + allButSelf[idx] = elem.outer; + var result = util.rdpLineSimplify(hole, tolerance, true, allButSelf); + allButSelf[idx] = result; + return result; + }).filter(function (hole) { + return hole.length >= 3; + }); + // transform coordinates back to the feature gcs + elem.outer = transform.transformCoordinates(mapgcs, featuregcs, elem.outer); + elem.inner = elem.inner.map(function (hole) { + return transform.transformCoordinates(mapgcs, featuregcs, hole); + }); + } else { + elem.outer = elem.inner = []; + } + } else { + elem.outer = []; + } + return elem; + }); + + /* Set the reduced polgons as the data and use simple accessors. */ + m_this.style('position', function (d) { return d; }); + m_this.style('polygon', function (d) { return d; }); + m_this.data(data); + return m_this; + }; + /** * Destroy. */ diff --git a/src/util/index.js b/src/util/index.js index 0188e4f1b6..985e3f7cdb 100644 --- a/src/util/index.js +++ b/src/util/index.js @@ -781,6 +781,202 @@ var util = module.exports = { return sumw ? position : {x: p0.x, y: p0.y}; }, + /** + * Get the square of the Euclidean 2D distance between two points. + * + * @param {geo.geoPosition} pt1 The first point. + * @param {geo.geoPosition} pt2 The second point. + * @returns {number} The distance squared. + */ + distance2dSquared: function (pt1, pt2) { + var dx = pt1.x - pt2.x, + dy = pt1.y - pt2.y; + return dx * dx + dy * dy; + }, + + /** + * Get the square of the Euclidean 2D distance between a point and a line + * segment. + * + * @param {geo.geoPosition} pt The point. + * @param {geo.geoPosition} line1 One end of the line. + * @param {geo.geoPosition} line2 The other end of the line. + * @returns {number} The distance squared. + */ + distance2dToLineSquared: function (pt, line1, line2) { + var dx = line2.x - line1.x, + dy = line2.y - line1.y, + // we could get the line length from the distance2dSquared function, + // but since we need dx and dy in this function, it is faster to just + // compute it here. + lengthSquared = dx * dx + dy * dy, + t = 0; + if (lengthSquared) { + t = ((pt.x - line1.x) * dx + (pt.y - line1.y) * dy) / lengthSquared; + t = Math.max(0, Math.min(1, t)); + } + return util.distance2dSquared(pt, { + x: line1.x + t * dx, + y: line1.y + t * dy + }); + }, + + /** + * Get twice the signed area of a 2d triangle. + * + * @param {geo.geoPosition} pt1 A vertex. + * @param {geo.geoPosition} pt2 A vertex. + * @param {geo.geoPosition} pt3 A vertex. + * @returns {number} Twice the signed area. + */ + triangleTwiceSignedArea2d: function (pt1, pt2, pt3) { + return (pt2.y - pt1.y) * (pt3.x - pt2.x) - (pt2.x - pt1.x) * (pt3.y - pt2.y); + }, + + /** + * Determine if two line segments cross. They are not considered crossing if + * they share a vertex. They are crossing if either of one segment's + * vertices are colinear with the other segment. + * + * @param {geo.geoPosition} seg1pt1 One endpoint of the first segment. + * @param {geo.geoPosition} seg1pt2 The other endpoint of the first segment. + * @param {geo.geoPosition} seg2pt1 One endpoint of the second segment. + * @param {geo.geoPosition} seg2pt2 The other endpoint of the second segment. + * @returns {boolean} True if the segments cross. + */ + crossedLineSegments2d: function (seg1pt1, seg1pt2, seg2pt1, seg2pt2) { + /* If the segments don't have any overlap in x or y, they can't cross */ + if ((seg1pt1.x > seg2pt1.x && seg1pt1.x > seg2pt2.x && + seg1pt2.x > seg2pt1.x && seg1pt2.x > seg2pt2.x) || + (seg1pt1.x < seg2pt1.x && seg1pt1.x < seg2pt2.x && + seg1pt2.x < seg2pt1.x && seg1pt2.x < seg2pt2.x) || + (seg1pt1.y > seg2pt1.y && seg1pt1.y > seg2pt2.y && + seg1pt2.y > seg2pt1.y && seg1pt2.y > seg2pt2.y) || + (seg1pt1.y < seg2pt1.y && seg1pt1.y < seg2pt2.y && + seg1pt2.y < seg2pt1.y && seg1pt2.y < seg2pt2.y)) { + return false; + } + /* If any vertex is in common, it is not considered crossing */ + if ((seg1pt1.x === seg2pt1.x && seg1pt1.y === seg2pt1.y) || + (seg1pt1.x === seg2pt2.x && seg1pt1.y === seg2pt2.y) || + (seg1pt2.x === seg2pt1.x && seg1pt2.y === seg2pt1.y) || + (seg1pt2.x === seg2pt2.x && seg1pt2.y === seg2pt2.y)) { + return false; + } + /* If the lines cross, the signed area of the triangles formed between one + * segment and the other's vertices will have different signs. By using + * > 0, colinear points are crossing. */ + if (util.triangleTwiceSignedArea2d(seg1pt1, seg1pt2, seg2pt1) * + util.triangleTwiceSignedArea2d(seg1pt1, seg1pt2, seg2pt2) > 0 || + util.triangleTwiceSignedArea2d(seg2pt1, seg2pt2, seg1pt1) * + util.triangleTwiceSignedArea2d(seg2pt1, seg2pt2, seg1pt2) > 0) { + return false; + } + return true; + }, + + /** + * Check if a line segment crosses any segment from a list of lines. The + * segment is considered crossing it it touches a line segment, unless that + * line segment shares a vertex with the segment. + * + * @param {geo.geoPosition} pt1 One end of the line segment. + * @param {geo.geoPosition} pt2 The other end of the line segment. + * @param {Array.} lineList A list of open lines. Each + * line is a list of vertices. The line segment is checked against each + * segment of each line in this list. + * @returns {boolean} True if the segment crosses any line segment. + */ + segmentCrossesLineList2d: function (pt1, pt2, lineList) { + var result = lineList.some(function (line) { + return line.some(function (linePt, idx) { + if (idx) { + return util.crossedLineSegments2d(pt1, pt2, line[idx - 1], linePt); + } + }); + }); + return result; + }, + + /** + * Remove vertices from a chain of 2d line segments so that it is simpler but + * is close to the original overall shape within some tolerance limit. This + * is the Ramer–Douglas–Peucker algorithm. The first and last points will + * always remain the same for open lines. For closed lines (polygons), this + * picks an point that likely to be significant and then reduces it, possibly + * returning a single point. + * + * @param {geo.geoPosition[]} pts A list of points forming the line or + * polygon. + * @param {number} tolerance The maximum variation allowed. A value of zero + * will only remove perfectly colinear points. + * @param {boolean} [closed] If true, this is a polygon rather than an open + * line. In this case, it is possible to get back a single point. + * @param {Array.?} [noCrossLines] A falsy value to allow + * the resultant line to cross itself, an empty array (`[]`) to prevent + * self-crossing, or an array of line segments to prevent self-crossing + * and disallow crossing any line segment in the list. Each entry in the + * list is an open line (with one segment less than the number of + * vertices). If self-crossing is prohibited, the resultant point set + * might not be as simplified as it could be. + * @returns {geo.geoPosition[]} The new point set. + */ + rdpLineSimplify: function (pts, tolerance, closed, noCrossLines) { + if (pts.length <= 2 || tolerance < 0) { + return pts; + } + var i, distSq, maxDistSq = -1, index, toleranceSq = tolerance * tolerance; + if (closed) { + /* If this is closed, find the point that is furthest from the first + * point. ideally, one would find a point that is guaranteed to be on + * the diameter of the convex hull, but doing so is an O(n^2) operation, + * whereas this is sufficient and only O(n). The chosen point is + * duplicated at the start and end of the chain. */ + for (i = 1; i < pts.length; i += 1) { + distSq = util.distance2dSquared(pts[0], pts[i]); + if (distSq > maxDistSq) { + maxDistSq = distSq; + index = i; + } + } + /* Points could be on any side of the start point, so if all points are + * within 1/2 of the tolerance of the start point, we know all points are + * within the tolerance of each other and therefore this polygon or + * closed line can be simplified to a point. */ + if (maxDistSq * 4 <= toleranceSq) { + return pts.slice(index, index + 1); + } + pts = pts.slice(index).concat(pts.slice(0, index + 1)); + pts = util.rdpLineSimplify(pts, tolerance, false, noCrossLines); + /* Removed the duplicated first point */ + pts.splice(pts.length - 1); + return pts; + } + for (i = 1; i < pts.length - 1; i += 1) { + distSq = util.distance2dToLineSquared(pts[i], pts[0], pts[pts.length - 1]); + if (distSq > maxDistSq) { + maxDistSq = distSq; + index = i; + } + } + /* We can collapse this to a single line if it is within the tolerance and + * we are either allowed to self-cross or it does not self-cross the rest + * of the line. */ + if (maxDistSq <= toleranceSq && (!noCrossLines || !util.segmentCrossesLineList2d( + pts[0], pts[pts.length - 1], noCrossLines))) { + return [pts[0], pts[pts.length - 1]]; + } + var left = pts.slice(0, index + 1), + right = pts.slice(index), + leftSide = util.rdpLineSimplify( + left, tolerance, false, + noCrossLines ? noCrossLines.concat([right]) : null), + rightSide = util.rdpLineSimplify( + right, tolerance, false, + noCrossLines ? noCrossLines.concat([left]) : null); + return leftSide.slice(0, leftSide.length - 1).concat(rightSide); + }, + /** * Escape any character in a string that has a code point >= 127. * diff --git a/testing/test-data/base-images.tgz.md5 b/testing/test-data/base-images.tgz.md5 index 3923d4e6fe..b0a4cb7bba 100644 --- a/testing/test-data/base-images.tgz.md5 +++ b/testing/test-data/base-images.tgz.md5 @@ -1 +1 @@ -db9d881cbba3139e98d026c7c823c3c8 \ No newline at end of file +2333a69e96ce063d10862bd8d563eb3d \ No newline at end of file diff --git a/testing/test-data/base-images.tgz.url b/testing/test-data/base-images.tgz.url index bd31e78cde..0107cbdd36 100644 --- a/testing/test-data/base-images.tgz.url +++ b/testing/test-data/base-images.tgz.url @@ -1 +1 @@ -https://data.kitware.com/api/v1/file/5a3c0f7b8d777f5e872f7508/download \ No newline at end of file +https://data.kitware.com/api/v1/file/5a9ed5838d777f0685786207/download \ No newline at end of file diff --git a/tests/cases/lineFeature.js b/tests/cases/lineFeature.js index 16991bfa28..cf0f0f1685 100644 --- a/tests/cases/lineFeature.js +++ b/tests/cases/lineFeature.js @@ -197,6 +197,60 @@ describe('geo.lineFeature', function () { idx = line.boxSearch({x: 29, y: 9}, {x: 36, y: 26}); expect(idx).toEqual([1, 2]); }); + + describe('rdpSimplifyData', function () { + function countLines(data) { + var counts = { + lines: data.length, + vertices: 0 + }; + data.forEach(function (line) { + if (Array.isArray(line) || !line.coord) { + counts.vertices += line.length; + } else { + counts.vertices += line.coord.length; + } + }); + return counts; + } + + function lineFunc(item, itemIdx) { + return item.coord; + } + + it('basic usage', function () { + var map, layer, line, counts; + + map = createMap(); + layer = map.createLayer('feature', {renderer: 'vgl'}); + line = geo.lineFeature({layer: layer}); + line._init(); + line.data(testLines).line(lineFunc); + counts = countLines(line.data().map(line.style.get('line'))); + expect(counts).toEqual({lines: 8, vertices: 23}); + line.rdpSimplifyData(testLines, undefined, undefined, lineFunc); + counts = countLines(line.data().map(line.style.get('line'))); + expect(counts).toEqual({lines: 8, vertices: 18}); + + // use pixel space for ease of picking tolerance values in tests + map.gcs('+proj=longlat +axis=enu'); + map.ingcs('+proj=longlat +axis=esu'); + line.rdpSimplifyData(testLines, 2, undefined, lineFunc); + counts = countLines(line.data().map(line.style.get('line'))); + expect(counts).toEqual({lines: 8, vertices: 17}); + line.rdpSimplifyData(testLines, 5, undefined, lineFunc); + counts = countLines(line.data().map(line.style.get('line'))); + expect(counts).toEqual({lines: 8, vertices: 11}); + line.rdpSimplifyData(testLines, 20, undefined, lineFunc); + counts = countLines(line.data().map(line.style.get('line'))); + expect(counts).toEqual({lines: 8, vertices: 0}); + line.rdpSimplifyData(testLines, 0.4, function (d) { + return {x: d.x * 0.2, y: d.y * 0.2}; + }, lineFunc); + counts = countLines(line.data().map(line.style.get('line'))); + expect(counts).toEqual({lines: 8, vertices: 17}); + }); + }); }); /* This is a basic integration test of geo.d3.lineFeature. */ diff --git a/tests/cases/polygonFeature.js b/tests/cases/polygonFeature.js index dd18992258..c9c7d2bd86 100644 --- a/tests/cases/polygonFeature.js +++ b/tests/cases/polygonFeature.js @@ -34,9 +34,12 @@ describe('geo.polygonFeature', function () { outer: [{x: 50, y: 8}, {x: 70, y: 8}, {x: 70, y: 12}, {x: 50, y: 12}], inner: [ - [{x: 58, y: 10}, {x: 60, y: 15}, {x: 62, y: 10}, {x: 60, y: 5}] + [{x: 58, y: 10}, {x: 60, y: 15}, {x: 62, y: 10}, {x: 60, y: 5}], + [] // degenerate hole should be ignored ], uniformPolygon: true + }, { + outer: [] // degenerate polygon should be ignored } ]; var stylesVisited = []; @@ -183,6 +186,65 @@ describe('geo.polygonFeature', function () { restoreVGLRenderer(); }); }); + + describe('rdpSimplifyData', function () { + function countPolygons(data) { + var counts = { + polygons: data.length, + holes: 0, + vertices: 0 + }; + data.forEach(function (poly) { + if (poly.outer) { + counts.vertices += poly.outer.length; + } else { + counts.vertices += poly.length; + } + if (poly.inner) { + counts.holes += poly.inner.length; + poly.inner.forEach(function (hole) { + counts.vertices += hole.length; + }); + } + }); + return counts; + } + + it('basic usage', function () { + mockVGLRenderer(); + var map, layer, polygon, counts; + + map = createMap(); + layer = map.createLayer('feature', {renderer: 'vgl'}); + polygon = geo.polygonFeature({layer: layer}); + polygon._init(); + polygon.data(testPolygons); + counts = countPolygons(polygon.data().map(polygon.style.get('polygon'))); + expect(counts).toEqual({polygons: 5, holes: 4, vertices: 27}); + polygon.rdpSimplifyData(testPolygons); + counts = countPolygons(polygon.data().map(polygon.style.get('polygon'))); + expect(counts).toEqual({polygons: 5, holes: 3, vertices: 27}); + + // use pixel space for ease of picking tolerance values in tests + map.gcs('+proj=longlat +axis=enu'); + map.ingcs('+proj=longlat +axis=esu'); + polygon.rdpSimplifyData(testPolygons, 10); + counts = countPolygons(polygon.data().map(polygon.style.get('polygon'))); + expect(counts).toEqual({polygons: 5, holes: 3, vertices: 24}); + polygon.rdpSimplifyData(testPolygons, 20); + counts = countPolygons(polygon.data().map(polygon.style.get('polygon'))); + expect(counts).toEqual({polygons: 5, holes: 1, vertices: 8}); + polygon.rdpSimplifyData(testPolygons, 50); + counts = countPolygons(polygon.data().map(polygon.style.get('polygon'))); + expect(counts).toEqual({polygons: 5, holes: 0, vertices: 0}); + polygon.rdpSimplifyData(testPolygons, 2, function (d) { + return {x: d.x * 0.2, y: d.y * 0.2}; + }); + counts = countPolygons(polygon.data().map(polygon.style.get('polygon'))); + expect(counts).toEqual({polygons: 5, holes: 3, vertices: 24}); + restoreVGLRenderer(); + }); + }); }); describe('Private utility methods', function () { diff --git a/tests/cases/util.js b/tests/cases/util.js index 20cda76e5b..abcf9dcfce 100644 --- a/tests/cases/util.js +++ b/tests/cases/util.js @@ -45,4 +45,70 @@ describe('geo.util', function () { {x: 1, y: 1}, {x: 3, y: 1}, {x: 5, y: 1}, {x: 5, y: 3}, {x: 1, y: 3} ])).toEqual({x: 3, y: 2}); }); + + it('rdpLineSimplify', function () { + var dataset1 = [ + {x: 10, y: 10}, {x: 20, y: 11}, {x: 30, y: 13}, {x: 40, y: 10}]; + var dataset2 = [ + {x: 84, y: 1}, {x: 57, y: 0}, {x: 44, y: 4}, {x: 33, y: 10}, + {x: 21, y: 20}, {x: 8, y: 40}, {x: 2, y: 56}, {x: 0, y: 105}, + {x: 5, y: 123}, {x: 15, y: 140}, {x: 26, y: 154}, {x: 40, y: 164}, + {x: 60, y: 169}, {x: 92, y: 168}, {x: 104, y: 165}, {x: 115, y: 161}, + {x: 127, y: 154}, {x: 135, y: 146}, {x: 143, y: 136}, {x: 149, y: 124}, + {x: 152, y: 114}, {x: 155, y: 103}, {x: 156, y: 56}, {x: 151, y: 44}, + {x: 141, y: 24}, {x: 129, y: 8}, {x: 118, y: 3}, {x: 106, y: 1}]; + var dataset3 = [ + {x: 10, y: 5}, {x: 30, y: 3}, {x: 50, y: 5}, {x: 40, y: 30}, + {x: 30, y: 4}, {x: 20, y: 30}]; + var dataset4 = [ + {x: 10, y: 5}, {x: 30, y: 3}, {x: 50, y: 5}, {x: 50, y: 10}, + {x: 40, y: 10}, {x: 40, y: 6}, {x: 30, y: 4}, {x: 20, y: 6}, + {x: 20, y: 10}, {x: 10, y: 10}]; + expect(util.rdpLineSimplify(dataset1, 1)).toEqual([ + {x: 10, y: 10}, {x: 30, y: 13}, {x: 40, y: 10} + ]); + expect(util.rdpLineSimplify(dataset1, 0.25)).toEqual([ + {x: 10, y: 10}, {x: 20, y: 11}, {x: 30, y: 13}, {x: 40, y: 10} + ]); + expect(util.rdpLineSimplify(dataset1, 3)).toEqual([ + {x: 10, y: 10}, {x: 40, y: 10} + ]); + expect(util.rdpLineSimplify(dataset1, 1, true)).toEqual([ + {x: 40, y: 10}, {x: 10, y: 10}, {x: 30, y: 13} + ]); + expect(util.rdpLineSimplify(dataset1, 31, true)).toEqual([ + {x: 40, y: 10} + ]); + expect(util.rdpLineSimplify(dataset1, 80, true)).toEqual([ + {x: 40, y: 10} + ]); + expect(util.rdpLineSimplify(dataset2, 5)).toEqual([ + {x: 84, y: 1}, {x: 57, y: 0}, {x: 33, y: 10}, {x: 2, y: 56}, + {x: 5, y: 123}, {x: 26, y: 154}, {x: 60, y: 169}, {x: 92, y: 168}, + {x: 127, y: 154}, {x: 152, y: 114}, {x: 156, y: 56}, {x: 129, y: 8}, + {x: 106, y: 1} + ]); + expect(util.rdpLineSimplify(dataset2, 20, true)).toEqual([ + {x: 60, y: 169}, {x: 149, y: 124}, {x: 156, y: 56}, {x: 118, y: 3}, + {x: 44, y: 4}, {x: 2, y: 56}, {x: 5, y: 123} + ]); + // test that when we allow lines to cross they can + expect(util.rdpLineSimplify(dataset3, 3, true)).toEqual([ + {x: 50, y: 5}, {x: 40, y: 30}, {x: 30, y: 4}, {x: 20, y: 30}, + {x: 10, y: 5} + ]); + // test that when we disallow lines to cross they don't + expect(util.rdpLineSimplify(dataset3, 3, true, [])).toEqual([ + {x: 50, y: 5}, {x: 40, y: 30}, {x: 30, y: 4}, {x: 20, y: 30}, + {x: 10, y: 5}, {x: 30, y: 3} + ]); + // Some more complicated tests can still be reduced + expect(util.rdpLineSimplify(dataset4, 3, true, [])).toEqual([ + {x: 50, y: 10}, {x: 30, y: 4}, {x: 10, y: 10}, {x: 10, y: 5}, + {x: 30, y: 3}, {x: 50, y: 5} + ]); + expect(util.rdpLineSimplify(dataset4, 6, true, [])).toEqual([ + {x: 50, y: 10}, {x: 10, y: 5}, {x: 30, y: 3}, {x: 50, y: 5} + ]); + }); });