From 81ff9d9e4107318224897865af37484509db5ea3 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 22 Feb 2026 15:04:37 +0000 Subject: [PATCH] Move hexgrid heatmap computation to Web Worker R-tree building, hex grid generation, and percentile sorting now run off the main thread, eliminating 20s+ UI freezes on large datasets. The old bundled HexgridHeatmap.js is replaced by a typed worker + main-thread client with dual R-trees (worker for grid gen, main thread for synchronous click queries). --- frontend/index.html | 1 - frontend/package-lock.json | 18 + frontend/package.json | 2 + frontend/public/HexgridHeatmap.js | 2491 ------------------ frontend/src/components/Map.tsx | 73 +- frontend/src/workers/HexgridHeatmapClient.ts | 317 +++ frontend/src/workers/hexgrid.worker.ts | 229 ++ frontend/src/workers/types.ts | 115 + frontend/tsconfig.app.json | 1 - frontend/tsconfig.app.tsbuildinfo | 2 +- 10 files changed, 708 insertions(+), 2541 deletions(-) delete mode 100644 frontend/public/HexgridHeatmap.js create mode 100644 frontend/src/workers/HexgridHeatmapClient.ts create mode 100644 frontend/src/workers/hexgrid.worker.ts create mode 100644 frontend/src/workers/types.ts diff --git a/frontend/index.html b/frontend/index.html index 97b6bf6..a312492 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,7 +6,6 @@ Wrongmove - diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 252f416..0dc84c3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -36,6 +36,7 @@ "lucide-react": "^0.515.0", "mapbox-gl": "^3.12.0", "oidc-client-ts": "^3.2.1", + "rbush": "^4.0.1", "react": "^19.1.0", "react-day-picker": "^9.7.0", "react-dom": "^19.1.0", @@ -55,6 +56,7 @@ "@types/d3": "^7.4.3", "@types/mapbox-gl": "^3.4.1", "@types/node": "^24.0.1", + "@types/rbush": "^4.0.0", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", "@types/turf": "^3.5.32", @@ -3627,6 +3629,13 @@ "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", "license": "MIT" }, + "node_modules/@types/rbush": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz", + "integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", @@ -7031,6 +7040,15 @@ "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", "license": "ISC" }, + "node_modules/rbush": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", + "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", + "license": "MIT", + "dependencies": { + "quickselect": "^3.0.0" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 49b3a76..97a1656 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -41,6 +41,7 @@ "lucide-react": "^0.515.0", "mapbox-gl": "^3.12.0", "oidc-client-ts": "^3.2.1", + "rbush": "^4.0.1", "react": "^19.1.0", "react-day-picker": "^9.7.0", "react-dom": "^19.1.0", @@ -60,6 +61,7 @@ "@types/d3": "^7.4.3", "@types/mapbox-gl": "^3.4.1", "@types/node": "^24.0.1", + "@types/rbush": "^4.0.0", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", "@types/turf": "^3.5.32", diff --git a/frontend/public/HexgridHeatmap.js b/frontend/public/HexgridHeatmap.js deleted file mode 100644 index e801a9d..0000000 --- a/frontend/public/HexgridHeatmap.js +++ /dev/null @@ -1,2491 +0,0 @@ -(function e(t, n, r) { function s(o, u) { if (!n[o]) { if (!t[o]) { var a = typeof require == "function" && require; if (!u && a) return a(o, !0); if (i) return i(o, !0); var f = new Error("Cannot find module '" + o + "'"); throw f.code = "MODULE_NOT_FOUND", f } var l = n[o] = { exports: {} }; t[o][0].call(l.exports, function (e) { var n = t[o][1][e]; return s(n ? n : e) }, l, l.exports, e, t, n, r) } return n[o].exports } var i = typeof require == "function" && require; for (var o = 0; o < r.length; o++)s(r[o]); return s })({ - 1: [function (require, module, exports) { - var rbush = require('rbush'); - var turf = { - center: require('@turf/center'), - hexGrid: require('@turf/hex-grid'), - destination: require('@turf/destination'), - distance: require('@turf/distance'), - }; - /** - * Creates a hexgrid-based vector heatmap on the specified map. - * @constructor - * @param {Map} map - The map object that this heatmap should add itself to and track. - * @param {string} [layername=hexgrid-heatmap] - The layer name to use for the heatmap. - * @param {string} [addBefore] - Name of a layer to insert this heatmap underneath. - */ - function HexgridHeatmap(map, layername, addBefore) { - if (layername === undefined) layername = "hexgrid-heatmap"; - this.map = map; - this.layername = layername; - this._setupLayers(layername, addBefore); - this._setupEvents(); - // Set up an R-tree to look for coordinates as they are stored in GeoJSON Feature objects - this._tree = rbush(9, ['["geometry"]["coordinates"][0]', '["geometry"]["coordinates"][1]', '["geometry"]["coordinates"][0]', '["geometry"]["coordinates"][1]']); - - this._intensity = 8; - this._spread = 0.1; - this._minCellIntensity = 0; // Drop out cells that have less than this intensity - this._maxPointIntensity = 20; // Don't let a single point have a greater weight than this - this._cellDensity = 1; - - var thisthis = this; - this._checkUpdateCompleteClosure = function (e) { thisthis._checkUpdateComplete(e); } - this._calculatingGrid = false; - this._recalcWhenReady = false; - this._pendingSource = null; // tracks which source has a pending 'data' event - this._clearTimeout = null; // deferred cleanup of old layer data - this._lastZoom = null; - - this._reduceFunction = function (data) { - var sum = 0; - var count = 0; - data.forEach(function (d) { - if (!isNaN(d)) { - sum += d; - count += 1; - } - }); - return count > 0 ? sum / count : NaN; - }; - - } - - HexgridHeatmap.prototype = { - _setupLayers: function (layername, addBefore) { - var defaultPaint = { - 'fill-opacity': 0, - 'fill-opacity-transition': { duration: 200, delay: 0 }, - 'fill-color': { - property: 'count', - stops: [ - [0, "rgba(0,185,243,0)"], - [50, "rgba(0,185,243,0.24)"], - [130, "rgba(255,223,0,0.3)"], - [200, "rgba(255,105,0,0.3)"], - ] - } - }; - var emptyData = { type: "FeatureCollection", features: [] }; - - // Layer A: primary layer, starts active at opacity 1 - this.map.addLayer({ - 'id': layername, - 'type': 'fill', - 'source': { type: 'geojson', data: emptyData }, - 'paint': Object.assign({}, defaultPaint, { 'fill-opacity': 1.0 }) - }, addBefore); - - // Layer B: back layer, starts inactive at opacity 0, renders below A - this.map.addLayer({ - 'id': layername + '-back', - 'type': 'fill', - 'source': { type: 'geojson', data: emptyData }, - 'paint': Object.assign({}, defaultPaint) - }, layername); - - this._layerA = layername; - this._layerB = layername + '-back'; - this._sourceA = this.map.getSource(layername); - this._sourceB = this.map.getSource(layername + '-back'); - this._activeIsA = true; - }, - _setupEvents: function () { - var thisthis = this; - this.map.on("moveend", function () { - thisthis._updateGrid(); - }); - }, - - setReduceFunction: function (f) { - this._reduceFunction = f; - }, - - setPropertyName: function (propertyName) { - this._propertyName = propertyName; - }, - - - /** - * Set the data to visualize with this heatmap layer - * @param {FeatureCollection} data - A GeoJSON FeatureCollection containing data to visualize with this heatmap - * @public - */ - setData: function (data) { - // Re-build R-tree index - this._tree.clear(); - this._tree.load(data.features); - }, - - - /** - * Set how widely points affect their neighbors - * @param {number} spread - A good starting point is 0.1. Higher values will result in more blurred heatmaps, lower values will highlight individual points more strongly. - * @public - */ - setSpread: function (spread) { - this._spread = spread; - }, - - - /** - * Set the intensity value for all points. - * @param {number} intensity - Setting this too low will result in no data displayed, setting it too high will result in an oversaturated map. The default is 8 so adjust up or down from there according to the density of your data. - * @public - */ - setIntensity: function (intensity) { - this._intensity = intensity; - }, - - - /** - * Set custom stops for the heatmap color schem - * @param {array} stops - An array of `stops` in the format of the Mapbox GL Style Spec. Values should range from 0 to about 200, though you can control saturation by setting different values here. - */ - setColorStops: function (stops) { - var colorProp = { property: "count", stops: stops }; - this.map.setPaintProperty(this._layerA, 'fill-color', colorProp); - this.map.setPaintProperty(this._layerB, 'fill-color', colorProp); - }, - - - /** - * Set the hexgrid cell density - * @param {number} density - Values less than 1 will result in a decreased cell density from the default, values greater than 1 will result in increaded density/higher resolution. Setting this value too high will result in slow performance. - * @public - */ - setCellDensity: function (density) { - this._cellDensity = density; - }, - - - /** - * Manually force an update to the heatmap - * You can call this method to manually force the heatmap to be redrawn. Use this after calling `setData()`, `setSpread()`, or `setIntensity()` - */ - update: function () { - this._updateGrid(); - }, - - - _generateGrid: function () { - // Fix: quantize zoom to integers so hex pattern only changes at discrete levels - var zoom = Math.floor(this.map.transform.zoom); - var cellSize = Math.max(500 / Math.pow(2, zoom) / this._cellDensity, 0.01); - - var extents = this.map.getBounds().toArray(); - extents = [extents[0][0], extents[0][1], extents[1][0], extents[1][1]]; - - // Convert cell size from km to degrees - var centerLat = (extents[1] + extents[3]) / 2; - var kmPerDegLon = 111.32 * Math.cos(centerLat * Math.PI / 180); - var kmPerDegLat = 110.574; - var rx = cellSize / kmPerDegLon; // hex x-radius in degrees - var ry = cellSize / kmPerDegLat * Math.sqrt(3) / 2; // hex y-radius in degrees - - // Flat-top hex grid spacing - var xStep = rx * 1.5; // horizontal center-to-center - var yStep = ry * 2; // vertical center-to-center - - // Fix: compute grid indices from fixed origin (0,0) — hex positions - // are deterministic regardless of viewport position - var xStart = Math.floor(extents[0] / xStep) - 1; - var xEnd = Math.ceil(extents[2] / xStep) + 1; - var yStart = Math.floor(extents[1] / yStep) - 1; - var yEnd = Math.ceil(extents[3] / yStep) + 1; - - // Fix: scale search radius with cell size so all points in a cell are found - var searchDegLon = Math.max(cellSize * 0.75, this._spread * 4) / kmPerDegLon; - var searchDegLat = Math.max(cellSize * 0.75, this._spread * 4) / kmPerDegLat; - - var cellsToSave = []; - var thisthis = this; - - for (var xi = xStart; xi <= xEnd; xi++) { - for (var yi = yStart; yi <= yEnd; yi++) { - var cx = xi * xStep; - var cy = yi * yStep; - // Odd columns offset by half a row - if (xi % 2 !== 0) { - cy -= yStep / 2; - } - - // Search for data points near this cell center - var pois = thisthis._tree.search({ - minX: cx - searchDegLon, - minY: cy - searchDegLat, - maxX: cx + searchDegLon, - maxY: cy + searchDegLat - }); - - if (pois.length > 0) { - var values = pois.map(function (d) { - return d['properties'][thisthis._propertyName]; - }); - var strength = thisthis._reduceFunction(values); - if (!isNaN(strength)) { - // Build hexagon polygon - var coords = [ - [cx + rx, cy], - [cx + rx / 2, cy + ry], - [cx - rx / 2, cy + ry], - [cx - rx, cy], - [cx - rx / 2, cy - ry], - [cx + rx / 2, cy - ry], - [cx + rx, cy] - ]; - cellsToSave.push({ - type: "Feature", - geometry: { type: "Polygon", coordinates: [coords] }, - properties: { - count: strength, - searchMinX: cx - searchDegLon, - searchMinY: cy - searchDegLat, - searchMaxX: cx + searchDegLon, - searchMaxY: cy + searchDegLat - } - }); - } - } - } - } - - return { type: "FeatureCollection", features: cellsToSave }; - }, - _updateGrid: function () { - if (!this._calculatingGrid) { - this._calculatingGrid = true; - var currentZoom = Math.floor(this.map.transform.zoom); - var zoomChanged = this._lastZoom !== null && this._lastZoom !== currentZoom; - this._lastZoom = currentZoom; - var hexgrid = this._generateGrid(); - if (hexgrid != null) { - var thisthis = this; - var activeSource = this._activeIsA ? this._sourceA : this._sourceB; - var activeLayer = this._activeIsA ? this._layerA : this._layerB; - var inactiveSource = this._activeIsA ? this._sourceB : this._sourceA; - var inactiveLayer = this._activeIsA ? this._layerB : this._layerA; - - // Cancel any pending cleanup timeout - if (this._clearTimeout) { - clearTimeout(this._clearTimeout); - this._clearTimeout = null; - } - - if (zoomChanged) { - // Zoom changed: cross-fade between layers - // Move outgoing layer below incoming so queryRenderedFeatures - // returns the new (active) layer's features first - this.map.moveLayer(activeLayer, inactiveLayer); - // Set new data on the inactive layer - this._pendingSource = inactiveSource; - inactiveSource.on("data", this._checkUpdateCompleteClosure); - inactiveSource.setData(hexgrid); - // Fade inactive layer in, active layer out - this.map.setPaintProperty(inactiveLayer, 'fill-opacity', 1.0); - this.map.setPaintProperty(activeLayer, 'fill-opacity', 0); - // Flip active flag - this._activeIsA = !this._activeIsA; - // Clear old layer data after transition completes - this._clearTimeout = setTimeout(function () { - thisthis._clearTimeout = null; - activeSource.setData({ type: "FeatureCollection", features: [] }); - }, 250); - } else { - // Pan only: swap data directly on the active source - this._pendingSource = activeSource; - activeSource.on("data", this._checkUpdateCompleteClosure); - activeSource.setData(hexgrid); - } - } - else { - this._calculatingGrid = false; - } - } - else { - this._recalcWhenReady = true; - } - }, - _checkUpdateComplete: function (e) { - if (e.dataType == "source") { - if (this._pendingSource) { - this._pendingSource.off("data", this._checkUpdateCompleteClosure); - this._pendingSource = null; - } - // Ensure the now-active layer is at full opacity - var activeLayer = this._activeIsA ? this._layerA : this._layerB; - this.map.setPaintProperty(activeLayer, 'fill-opacity', 1.0); - this._calculatingGrid = false; - if (this._recalcWhenReady) { - this._recalcWhenReady = false; - this._updateGrid(); - } - } - } - }; - - module.exports = exports = HexgridHeatmap; - }, { "@turf/center": 4, "@turf/destination": 5, "@turf/distance": 6, "@turf/hex-grid": 8, "rbush": 12 }], 2: [function (require, module, exports) { - window.HexgridHeatmap = require('./HexgridHeatmap'); - }, { "./HexgridHeatmap": 1 }], 3: [function (require, module, exports) { - var each = require('@turf/meta').coordEach; - - /** - * Takes a set of features, calculates the bbox of all input features, and returns a bounding box. - * - * @name bbox - * @param {(Feature|FeatureCollection)} geojson input features - * @returns {Array} bbox extent in [minX, minY, maxX, maxY] order - * @addToMap features, bboxPolygon - * @example - * var pt1 = turf.point([114.175329, 22.2524]) - * var pt2 = turf.point([114.170007, 22.267969]) - * var pt3 = turf.point([114.200649, 22.274641]) - * var pt4 = turf.point([114.200649, 22.274641]) - * var pt5 = turf.point([114.186744, 22.265745]) - * var features = turf.featureCollection([pt1, pt2, pt3, pt4, pt5]) - * - * var bbox = turf.bbox(features); - * - * var bboxPolygon = turf.bboxPolygon(bbox); - * - * //=bbox - * - * //=bboxPolygon - */ - module.exports = function (geojson) { - var bbox = [Infinity, Infinity, -Infinity, -Infinity]; - each(geojson, function (coord) { - if (bbox[0] > coord[0]) bbox[0] = coord[0]; - if (bbox[1] > coord[1]) bbox[1] = coord[1]; - if (bbox[2] < coord[0]) bbox[2] = coord[0]; - if (bbox[3] < coord[1]) bbox[3] = coord[1]; - }); - return bbox; - }; - - }, { "@turf/meta": 10 }], 4: [function (require, module, exports) { - var bbox = require('@turf/bbox'), - point = require('@turf/helpers').point; - - /** - * Takes a {@link Feature} or {@link FeatureCollection} and returns the absolute center point of all features. - * - * @name center - * @param {(Feature|FeatureCollection)} layer input features - * @return {Feature} a Point feature at the absolute center point of all input features - * @addToMap features, centerPt - * @example - * var features = { - * "type": "FeatureCollection", - * "features": [ - * { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [-97.522259, 35.4691] - * } - * }, { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [-97.502754, 35.463455] - * } - * }, { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [-97.508269, 35.463245] - * } - * }, { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [-97.516809, 35.465779] - * } - * }, { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [-97.515372, 35.467072] - * } - * }, { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [-97.509363, 35.463053] - * } - * }, { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [-97.511123, 35.466601] - * } - * }, { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [-97.518547, 35.469327] - * } - * }, { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [-97.519706, 35.469659] - * } - * }, { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [-97.517839, 35.466998] - * } - * }, { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [-97.508678, 35.464942] - * } - * }, { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [-97.514914, 35.463453] - * } - * } - * ] - * }; - * - * var centerPt = turf.center(features); - * centerPt.properties['marker-size'] = 'large'; - * centerPt.properties['marker-color'] = '#000'; - * - * var resultFeatures = features.features.concat(centerPt); - * var result = { - * "type": "FeatureCollection", - * "features": resultFeatures - * }; - * - * //=result - */ - - module.exports = function (layer) { - var ext = bbox(layer); - var x = (ext[0] + ext[2]) / 2; - var y = (ext[1] + ext[3]) / 2; - return point([x, y]); - }; - - }, { "@turf/bbox": 3, "@turf/helpers": 7 }], 5: [function (require, module, exports) { - //http://en.wikipedia.org/wiki/Haversine_formula - //http://www.movable-type.co.uk/scripts/latlong.html - var getCoord = require('@turf/invariant').getCoord; - var helpers = require('@turf/helpers'); - var point = helpers.point; - var distanceToRadians = helpers.distanceToRadians; - - /** - * Takes a {@link Point} and calculates the location of a destination point given a distance in degrees, radians, miles, or kilometers; and bearing in degrees. This uses the [Haversine formula](http://en.wikipedia.org/wiki/Haversine_formula) to account for global curvature. - * - * @name destination - * @param {Feature} from starting point - * @param {number} distance distance from the starting point - * @param {number} bearing ranging from -180 to 180 - * @param {string} [units=kilometers] miles, kilometers, degrees, or radians - * @returns {Feature} destination point - * @example - * var point = { - * "type": "Feature", - * "properties": { - * "marker-color": "#0f0" - * }, - * "geometry": { - * "type": "Point", - * "coordinates": [-75.343, 39.984] - * } - * }; - * var distance = 50; - * var bearing = 90; - * var units = 'miles'; - * - * var destination = turf.destination(point, distance, bearing, units); - * destination.properties['marker-color'] = '#f00'; - * - * var result = { - * "type": "FeatureCollection", - * "features": [point, destination] - * }; - * - * //=result - */ - module.exports = function (from, distance, bearing, units) { - var degrees2radians = Math.PI / 180; - var radians2degrees = 180 / Math.PI; - var coordinates1 = getCoord(from); - var longitude1 = degrees2radians * coordinates1[0]; - var latitude1 = degrees2radians * coordinates1[1]; - var bearing_rad = degrees2radians * bearing; - - var radians = distanceToRadians(distance, units); - - var latitude2 = Math.asin(Math.sin(latitude1) * Math.cos(radians) + - Math.cos(latitude1) * Math.sin(radians) * Math.cos(bearing_rad)); - var longitude2 = longitude1 + Math.atan2(Math.sin(bearing_rad) * - Math.sin(radians) * Math.cos(latitude1), - Math.cos(radians) - Math.sin(latitude1) * Math.sin(latitude2)); - - return point([radians2degrees * longitude2, radians2degrees * latitude2]); - }; - - }, { "@turf/helpers": 7, "@turf/invariant": 9 }], 6: [function (require, module, exports) { - var getCoord = require('@turf/invariant').getCoord; - var radiansToDistance = require('@turf/helpers').radiansToDistance; - //http://en.wikipedia.org/wiki/Haversine_formula - //http://www.movable-type.co.uk/scripts/latlong.html - - /** - * Calculates the distance between two {@link Point|points} in degrees, radians, - * miles, or kilometers. This uses the - * [Haversine formula](http://en.wikipedia.org/wiki/Haversine_formula) - * to account for global curvature. - * - * @name distance - * @param {Feature} from origin point - * @param {Feature} to destination point - * @param {string} [units=kilometers] can be degrees, radians, miles, or kilometers - * @returns {number} distance between the two points - * @example - * var from = { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [-75.343, 39.984] - * } - * }; - * var to = { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [-75.534, 39.123] - * } - * }; - * var units = "miles"; - * - * var points = { - * "type": "FeatureCollection", - * "features": [from, to] - * }; - * - * //=points - * - * var distance = turf.distance(from, to, units); - * - * //=distance - */ - module.exports = function (from, to, units) { - var degrees2radians = Math.PI / 180; - var coordinates1 = getCoord(from); - var coordinates2 = getCoord(to); - var dLat = degrees2radians * (coordinates2[1] - coordinates1[1]); - var dLon = degrees2radians * (coordinates2[0] - coordinates1[0]); - var lat1 = degrees2radians * coordinates1[1]; - var lat2 = degrees2radians * coordinates2[1]; - - var a = Math.pow(Math.sin(dLat / 2), 2) + - Math.pow(Math.sin(dLon / 2), 2) * Math.cos(lat1) * Math.cos(lat2); - - return radiansToDistance(2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)), units); - }; - - }, { "@turf/helpers": 7, "@turf/invariant": 9 }], 7: [function (require, module, exports) { - /** - * Wraps a GeoJSON {@link Geometry} in a GeoJSON {@link Feature}. - * - * @name feature - * @param {Geometry} geometry input geometry - * @param {Object} properties properties - * @returns {FeatureCollection} a FeatureCollection of input features - * @example - * var geometry = { - * "type": "Point", - * "coordinates": [ - * 67.5, - * 32.84267363195431 - * ] - * } - * - * var feature = turf.feature(geometry); - * - * //=feature - */ - function feature(geometry, properties) { - if (!geometry) throw new Error('No geometry passed'); - - return { - type: 'Feature', - properties: properties || {}, - geometry: geometry - }; - } - module.exports.feature = feature; - - /** - * Takes coordinates and properties (optional) and returns a new {@link Point} feature. - * - * @name point - * @param {Array} coordinates longitude, latitude position (each in decimal degrees) - * @param {Object=} properties an Object that is used as the {@link Feature}'s - * properties - * @returns {Feature} a Point feature - * @example - * var pt1 = turf.point([-75.343, 39.984]); - * - * //=pt1 - */ - module.exports.point = function (coordinates, properties) { - if (!coordinates) throw new Error('No coordinates passed'); - if (coordinates.length === undefined) throw new Error('Coordinates must be an array'); - if (coordinates.length < 2) throw new Error('Coordinates must be at least 2 numbers long'); - if (typeof coordinates[0] !== 'number' || typeof coordinates[1] !== 'number') throw new Error('Coordinates must numbers'); - - return feature({ - type: 'Point', - coordinates: coordinates - }, properties); - }; - - /** - * Takes an array of LinearRings and optionally an {@link Object} with properties and returns a {@link Polygon} feature. - * - * @name polygon - * @param {Array>>} coordinates an array of LinearRings - * @param {Object=} properties a properties object - * @returns {Feature} a Polygon feature - * @throws {Error} throw an error if a LinearRing of the polygon has too few positions - * or if a LinearRing of the Polygon does not have matching Positions at the - * beginning & end. - * @example - * var polygon = turf.polygon([[ - * [-2.275543, 53.464547], - * [-2.275543, 53.489271], - * [-2.215118, 53.489271], - * [-2.215118, 53.464547], - * [-2.275543, 53.464547] - * ]], { name: 'poly1', population: 400}); - * - * //=polygon - */ - module.exports.polygon = function (coordinates, properties) { - if (!coordinates) throw new Error('No coordinates passed'); - - for (var i = 0; i < coordinates.length; i++) { - var ring = coordinates[i]; - if (ring.length < 4) { - throw new Error('Each LinearRing of a Polygon must have 4 or more Positions.'); - } - for (var j = 0; j < ring[ring.length - 1].length; j++) { - if (ring[ring.length - 1][j] !== ring[0][j]) { - throw new Error('First and last Position are not equivalent.'); - } - } - } - - return feature({ - type: 'Polygon', - coordinates: coordinates - }, properties); - }; - - /** - * Creates a {@link LineString} based on a - * coordinate array. Properties can be added optionally. - * - * @name lineString - * @param {Array>} coordinates an array of Positions - * @param {Object=} properties an Object of key-value pairs to add as properties - * @returns {Feature} a LineString feature - * @throws {Error} if no coordinates are passed - * @example - * var linestring1 = turf.lineString([ - * [-21.964416, 64.148203], - * [-21.956176, 64.141316], - * [-21.93901, 64.135924], - * [-21.927337, 64.136673] - * ]); - * var linestring2 = turf.lineString([ - * [-21.929054, 64.127985], - * [-21.912918, 64.134726], - * [-21.916007, 64.141016], - * [-21.930084, 64.14446] - * ], {name: 'line 1', distance: 145}); - * - * //=linestring1 - * - * //=linestring2 - */ - module.exports.lineString = function (coordinates, properties) { - if (!coordinates) throw new Error('No coordinates passed'); - - return feature({ - type: 'LineString', - coordinates: coordinates - }, properties); - }; - - /** - * Takes one or more {@link Feature|Features} and creates a {@link FeatureCollection}. - * - * @name featureCollection - * @param {Feature[]} features input features - * @returns {FeatureCollection} a FeatureCollection of input features - * @example - * var features = [ - * turf.point([-75.343, 39.984], {name: 'Location A'}), - * turf.point([-75.833, 39.284], {name: 'Location B'}), - * turf.point([-75.534, 39.123], {name: 'Location C'}) - * ]; - * - * var fc = turf.featureCollection(features); - * - * //=fc - */ - module.exports.featureCollection = function (features) { - if (!features) throw new Error('No features passed'); - - return { - type: 'FeatureCollection', - features: features - }; - }; - - /** - * Creates a {@link Feature} based on a - * coordinate array. Properties can be added optionally. - * - * @name multiLineString - * @param {Array>>} coordinates an array of LineStrings - * @param {Object=} properties an Object of key-value pairs to add as properties - * @returns {Feature} a MultiLineString feature - * @throws {Error} if no coordinates are passed - * @example - * var multiLine = turf.multiLineString([[[0,0],[10,10]]]); - * - * //=multiLine - * - */ - module.exports.multiLineString = function (coordinates, properties) { - if (!coordinates) throw new Error('No coordinates passed'); - - return feature({ - type: 'MultiLineString', - coordinates: coordinates - }, properties); - }; - - /** - * Creates a {@link Feature} based on a - * coordinate array. Properties can be added optionally. - * - * @name multiPoint - * @param {Array>} coordinates an array of Positions - * @param {Object=} properties an Object of key-value pairs to add as properties - * @returns {Feature} a MultiPoint feature - * @throws {Error} if no coordinates are passed - * @example - * var multiPt = turf.multiPoint([[0,0],[10,10]]); - * - * //=multiPt - * - */ - module.exports.multiPoint = function (coordinates, properties) { - if (!coordinates) throw new Error('No coordinates passed'); - - return feature({ - type: 'MultiPoint', - coordinates: coordinates - }, properties); - }; - - - /** - * Creates a {@link Feature} based on a - * coordinate array. Properties can be added optionally. - * - * @name multiPolygon - * @param {Array>>>} coordinates an array of Polygons - * @param {Object=} properties an Object of key-value pairs to add as properties - * @returns {Feature} a multipolygon feature - * @throws {Error} if no coordinates are passed - * @example - * var multiPoly = turf.multiPolygon([[[[0,0],[0,10],[10,10],[10,0],[0,0]]]]); - * - * //=multiPoly - * - */ - module.exports.multiPolygon = function (coordinates, properties) { - if (!coordinates) throw new Error('No coordinates passed'); - - return feature({ - type: 'MultiPolygon', - coordinates: coordinates - }, properties); - }; - - /** - * Creates a {@link Feature} based on a - * coordinate array. Properties can be added optionally. - * - * @name geometryCollection - * @param {Array<{Geometry}>} geometries an array of GeoJSON Geometries - * @param {Object=} properties an Object of key-value pairs to add as properties - * @returns {Feature} a GeoJSON GeometryCollection Feature - * @example - * var pt = { - * "type": "Point", - * "coordinates": [100, 0] - * }; - * var line = { - * "type": "LineString", - * "coordinates": [ [101, 0], [102, 1] ] - * }; - * var collection = turf.geometryCollection([pt, line]); - * - * //=collection - */ - module.exports.geometryCollection = function (geometries, properties) { - if (!geometries) throw new Error('No geometries passed'); - - return feature({ - type: 'GeometryCollection', - geometries: geometries - }, properties); - }; - - var factors = { - miles: 3960, - nauticalmiles: 3441.145, - degrees: 57.2957795, - radians: 1, - inches: 250905600, - yards: 6969600, - meters: 6373000, - metres: 6373000, - kilometers: 6373, - kilometres: 6373, - feet: 20908792.65 - }; - - /* - * Convert a distance measurement from radians to a more friendly unit. - * - * @name radiansToDistance - * @param {number} distance in radians across the sphere - * @param {string} [units=kilometers] can be degrees, radians, miles, or kilometers - * inches, yards, metres, meters, kilometres, kilometers. - * @returns {number} distance - */ - module.exports.radiansToDistance = function (radians, units) { - var factor = factors[units || 'kilometers']; - if (factor === undefined) throw new Error('Invalid unit'); - - return radians * factor; - }; - - /* - * Convert a distance measurement from a real-world unit into radians - * - * @name distanceToRadians - * @param {number} distance in real units - * @param {string} [units=kilometers] can be degrees, radians, miles, or kilometers - * inches, yards, metres, meters, kilometres, kilometers. - * @returns {number} radians - */ - module.exports.distanceToRadians = function (distance, units) { - var factor = factors[units || 'kilometers']; - if (factor === undefined) throw new Error('Invalid unit'); - - return distance / factor; - }; - - /* - * Convert a distance measurement from a real-world unit into degrees - * - * @name distanceToRadians - * @param {number} distance in real units - * @param {string} [units=kilometers] can be degrees, radians, miles, or kilometers - * inches, yards, metres, meters, kilometres, kilometers. - * @returns {number} degrees - */ - module.exports.distanceToDegrees = function (distance, units) { - var factor = factors[units || 'kilometers']; - if (factor === undefined) throw new Error('Invalid unit'); - - return (distance / factor) * 57.2958; - }; - - }, {}], 8: [function (require, module, exports) { - var point = require('@turf/helpers').point; - var polygon = require('@turf/helpers').polygon; - var distance = require('@turf/distance'); - var featurecollection = require('@turf/helpers').featureCollection; - - //Precompute cosines and sines of angles used in hexagon creation - // for performance gain - var cosines = []; - var sines = []; - for (var i = 0; i < 6; i++) { - var angle = 2 * Math.PI / 6 * i; - cosines.push(Math.cos(angle)); - sines.push(Math.sin(angle)); - } - - /** - * Takes a bounding box and a cell size in degrees and returns a {@link FeatureCollection} of flat-topped - * hexagons ({@link Polygon} features) aligned in an "odd-q" vertical grid as - * described in [Hexagonal Grids](http://www.redblobgames.com/grids/hexagons/). - * - * @name hexGrid - * @param {Array} bbox extent in [minX, minY, maxX, maxY] order - * @param {number} cellSize dimension of cell in specified units - * @param {string} [units=kilometers] used in calculating cellSize, can be degrees, radians, miles, or kilometers - * @param {boolean} [triangles=false] whether to return as triangles instead of hexagons - * @returns {FeatureCollection} a hexagonal grid - * @example - * var bbox = [-96,31,-84,40]; - * var cellSize = 50; - * var units = 'miles'; - * - * var hexgrid = turf.hexGrid(bbox, cellSize, units); - * - * //=hexgrid - */ - module.exports = function hexGrid(bbox, cellSize, units, triangles) { - var xFraction = cellSize / (distance(point([bbox[0], bbox[1]]), point([bbox[2], bbox[1]]), units)); - var cellWidth = xFraction * (bbox[2] - bbox[0]); - var yFraction = cellSize / (distance(point([bbox[0], bbox[1]]), point([bbox[0], bbox[3]]), units)); - var cellHeight = yFraction * (bbox[3] - bbox[1]); - var radius = cellWidth / 2; - - var hex_width = radius * 2; - var hex_height = Math.sqrt(3) / 2 * cellHeight; - - var box_width = bbox[2] - bbox[0]; - var box_height = bbox[3] - bbox[1]; - - var x_interval = 3 / 4 * hex_width; - var y_interval = hex_height; - - var x_span = box_width / (hex_width - radius / 2); - var x_count = Math.ceil(x_span); - if (Math.round(x_span) === x_count) { - x_count++; - } - - var x_adjust = ((x_count * x_interval - radius / 2) - box_width) / 2 - radius / 2; - - var y_count = Math.ceil(box_height / hex_height); - - var y_adjust = (box_height - y_count * hex_height) / 2; - - var hasOffsetY = y_count * hex_height - box_height > hex_height / 2; - if (hasOffsetY) { - y_adjust -= hex_height / 4; - } - - var fc = featurecollection([]); - for (var x = 0; x < x_count; x++) { - for (var y = 0; y <= y_count; y++) { - - var isOdd = x % 2 === 1; - if (y === 0 && isOdd) { - continue; - } - - if (y === 0 && hasOffsetY) { - continue; - } - - var center_x = x * x_interval + bbox[0] - x_adjust; - var center_y = y * y_interval + bbox[1] + y_adjust; - - if (isOdd) { - center_y -= hex_height / 2; - } - if (triangles) { - fc.features.push.apply(fc.features, hexTriangles([center_x, center_y], cellWidth / 2, cellHeight / 2)); - } else { - fc.features.push(hexagon([center_x, center_y], cellWidth / 2, cellHeight / 2)); - } - } - } - - return fc; - }; - - //Center should be [x, y] - function hexagon(center, rx, ry) { - var vertices = []; - for (var i = 0; i < 6; i++) { - var x = center[0] + rx * cosines[i]; - var y = center[1] + ry * sines[i]; - vertices.push([x, y]); - } - //first and last vertex must be the same - vertices.push(vertices[0]); - return polygon([vertices]); - } - - //Center should be [x, y] - function hexTriangles(center, rx, ry) { - var triangles = []; - for (var i = 0; i < 6; i++) { - var vertices = []; - vertices.push(center); - vertices.push([ - center[0] + rx * cosines[i], - center[1] + ry * sines[i] - ]); - vertices.push([ - center[0] + rx * cosines[(i + 1) % 6], - center[1] + ry * sines[(i + 1) % 6] - ]); - vertices.push(center); - triangles.push(polygon([vertices])); - } - return triangles; - } - - }, { "@turf/distance": 6, "@turf/helpers": 7 }], 9: [function (require, module, exports) { - /** - * Unwrap a coordinate from a Point Feature, Geometry or a single coordinate. - * - * @param {Array|Geometry|Feature} obj any value - * @returns {Array} coordinates - */ - function getCoord(obj) { - if (!obj) throw new Error('No obj passed'); - - var coordinates = getCoords(obj); - - // getCoord() must contain at least two numbers (Point) - if (coordinates.length > 1 && - typeof coordinates[0] === 'number' && - typeof coordinates[1] === 'number') { - return coordinates; - } else { - throw new Error('Coordinate is not a valid Point'); - } - } - - /** - * Unwrap coordinates from a Feature, Geometry Object or an Array of numbers - * - * @param {Array|Geometry|Feature} obj any value - * @returns {Array} coordinates - */ - function getCoords(obj) { - if (!obj) throw new Error('No obj passed'); - var coordinates; - - // Array of numbers - if (obj.length) { - coordinates = obj; - - // Geometry Object - } else if (obj.coordinates) { - coordinates = obj.coordinates; - - // Feature - } else if (obj.geometry && obj.geometry.coordinates) { - coordinates = obj.geometry.coordinates; - } - // Checks if coordinates contains a number - if (coordinates) { - containsNumber(coordinates); - return coordinates; - } - throw new Error('No valid coordinates'); - } - - /** - * Checks if coordinates contains a number - * - * @private - * @param {Array} coordinates GeoJSON Coordinates - * @returns {boolean} true if Array contains a number - */ - function containsNumber(coordinates) { - if (coordinates.length > 1 && - typeof coordinates[0] === 'number' && - typeof coordinates[1] === 'number') { - return true; - } - if (coordinates[0].length) { - return containsNumber(coordinates[0]); - } - throw new Error('coordinates must only contain numbers'); - } - - /** - * Enforce expectations about types of GeoJSON objects for Turf. - * - * @alias geojsonType - * @param {GeoJSON} value any GeoJSON object - * @param {string} type expected GeoJSON type - * @param {string} name name of calling function - * @throws {Error} if value is not the expected type. - */ - function geojsonType(value, type, name) { - if (!type || !name) throw new Error('type and name required'); - - if (!value || value.type !== type) { - throw new Error('Invalid input to ' + name + ': must be a ' + type + ', given ' + value.type); - } - } - - /** - * Enforce expectations about types of {@link Feature} inputs for Turf. - * Internally this uses {@link geojsonType} to judge geometry types. - * - * @alias featureOf - * @param {Feature} feature a feature with an expected geometry type - * @param {string} type expected GeoJSON type - * @param {string} name name of calling function - * @throws {Error} error if value is not the expected type. - */ - function featureOf(feature, type, name) { - if (!feature) throw new Error('No feature passed'); - if (!name) throw new Error('.featureOf() requires a name'); - if (!feature || feature.type !== 'Feature' || !feature.geometry) { - throw new Error('Invalid input to ' + name + ', Feature with geometry required'); - } - if (!feature.geometry || feature.geometry.type !== type) { - throw new Error('Invalid input to ' + name + ': must be a ' + type + ', given ' + feature.geometry.type); - } - } - - /** - * Enforce expectations about types of {@link FeatureCollection} inputs for Turf. - * Internally this uses {@link geojsonType} to judge geometry types. - * - * @alias collectionOf - * @param {FeatureCollection} featureCollection a FeatureCollection for which features will be judged - * @param {string} type expected GeoJSON type - * @param {string} name name of calling function - * @throws {Error} if value is not the expected type. - */ - function collectionOf(featureCollection, type, name) { - if (!featureCollection) throw new Error('No featureCollection passed'); - if (!name) throw new Error('.collectionOf() requires a name'); - if (!featureCollection || featureCollection.type !== 'FeatureCollection') { - throw new Error('Invalid input to ' + name + ', FeatureCollection required'); - } - for (var i = 0; i < featureCollection.features.length; i++) { - var feature = featureCollection.features[i]; - if (!feature || feature.type !== 'Feature' || !feature.geometry) { - throw new Error('Invalid input to ' + name + ', Feature with geometry required'); - } - if (!feature.geometry || feature.geometry.type !== type) { - throw new Error('Invalid input to ' + name + ': must be a ' + type + ', given ' + feature.geometry.type); - } - } - } - - module.exports.geojsonType = geojsonType; - module.exports.collectionOf = collectionOf; - module.exports.featureOf = featureOf; - module.exports.getCoord = getCoord; - module.exports.getCoords = getCoords; - - }, {}], 10: [function (require, module, exports) { - /** - * Callback for coordEach - * - * @private - * @callback coordEachCallback - * @param {[number, number]} currentCoords The current coordinates being processed. - * @param {number} currentIndex The index of the current element being processed in the - * array.Starts at index 0, if an initialValue is provided, and at index 1 otherwise. - */ - - /** - * Iterate over coordinates in any GeoJSON object, similar to Array.forEach() - * - * @name coordEach - * @param {Object} layer any GeoJSON object - * @param {Function} callback a method that takes (currentCoords, currentIndex) - * @param {boolean} [excludeWrapCoord=false] whether or not to include - * the final coordinate of LinearRings that wraps the ring in its iteration. - * @example - * var features = { - * "type": "FeatureCollection", - * "features": [ - * { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [26, 37] - * } - * }, - * { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [36, 53] - * } - * } - * ] - * }; - * turf.coordEach(features, function (currentCoords, currentIndex) { - * //=currentCoords - * //=currentIndex - * }); - */ - function coordEach(layer, callback, excludeWrapCoord) { - var i, j, k, g, l, geometry, stopG, coords, - geometryMaybeCollection, - wrapShrink = 0, - currentIndex = 0, - isGeometryCollection, - isFeatureCollection = layer.type === 'FeatureCollection', - isFeature = layer.type === 'Feature', - stop = isFeatureCollection ? layer.features.length : 1; - - // This logic may look a little weird. The reason why it is that way - // is because it's trying to be fast. GeoJSON supports multiple kinds - // of objects at its root: FeatureCollection, Features, Geometries. - // This function has the responsibility of handling all of them, and that - // means that some of the `for` loops you see below actually just don't apply - // to certain inputs. For instance, if you give this just a - // Point geometry, then both loops are short-circuited and all we do - // is gradually rename the input until it's called 'geometry'. - // - // This also aims to allocate as few resources as possible: just a - // few numbers and booleans, rather than any temporary arrays as would - // be required with the normalization approach. - for (i = 0; i < stop; i++) { - - geometryMaybeCollection = (isFeatureCollection ? layer.features[i].geometry : - (isFeature ? layer.geometry : layer)); - isGeometryCollection = geometryMaybeCollection.type === 'GeometryCollection'; - stopG = isGeometryCollection ? geometryMaybeCollection.geometries.length : 1; - - for (g = 0; g < stopG; g++) { - geometry = isGeometryCollection ? - geometryMaybeCollection.geometries[g] : geometryMaybeCollection; - coords = geometry.coordinates; - - wrapShrink = (excludeWrapCoord && - (geometry.type === 'Polygon' || geometry.type === 'MultiPolygon')) ? - 1 : 0; - - if (geometry.type === 'Point') { - callback(coords, currentIndex); - currentIndex++; - } else if (geometry.type === 'LineString' || geometry.type === 'MultiPoint') { - for (j = 0; j < coords.length; j++) { - callback(coords[j], currentIndex); - currentIndex++; - } - } else if (geometry.type === 'Polygon' || geometry.type === 'MultiLineString') { - for (j = 0; j < coords.length; j++) - for (k = 0; k < coords[j].length - wrapShrink; k++) { - callback(coords[j][k], currentIndex); - currentIndex++; - } - } else if (geometry.type === 'MultiPolygon') { - for (j = 0; j < coords.length; j++) - for (k = 0; k < coords[j].length; k++) - for (l = 0; l < coords[j][k].length - wrapShrink; l++) { - callback(coords[j][k][l], currentIndex); - currentIndex++; - } - } else if (geometry.type === 'GeometryCollection') { - for (j = 0; j < geometry.geometries.length; j++) - coordEach(geometry.geometries[j], callback, excludeWrapCoord); - } else { - throw new Error('Unknown Geometry Type'); - } - } - } - } - module.exports.coordEach = coordEach; - - /** - * Callback for coordReduce - * - * The first time the callback function is called, the values provided as arguments depend - * on whether the reduce method has an initialValue argument. - * - * If an initialValue is provided to the reduce method: - * - The previousValue argument is initialValue. - * - The currentValue argument is the value of the first element present in the array. - * - * If an initialValue is not provided: - * - The previousValue argument is the value of the first element present in the array. - * - The currentValue argument is the value of the second element present in the array. - * - * @private - * @callback coordReduceCallback - * @param {*} previousValue The accumulated value previously returned in the last invocation - * of the callback, or initialValue, if supplied. - * @param {[number, number]} currentCoords The current coordinate being processed. - * @param {number} currentIndex The index of the current element being processed in the - * array.Starts at index 0, if an initialValue is provided, and at index 1 otherwise. - */ - - /** - * Reduce coordinates in any GeoJSON object, similar to Array.reduce() - * - * @name coordReduce - * @param {Object} layer any GeoJSON object - * @param {Function} callback a method that takes (previousValue, currentCoords, currentIndex) - * @param {*} [initialValue] Value to use as the first argument to the first call of the callback. - * @param {boolean} [excludeWrapCoord=false] whether or not to include - * the final coordinate of LinearRings that wraps the ring in its iteration. - * @returns {*} The value that results from the reduction. - * @example - * var features = { - * "type": "FeatureCollection", - * "features": [ - * { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [26, 37] - * } - * }, - * { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [36, 53] - * } - * } - * ] - * }; - * turf.coordReduce(features, function (previousValue, currentCoords, currentIndex) { - * //=previousValue - * //=currentCoords - * //=currentIndex - * return currentCoords; - * }); - */ - function coordReduce(layer, callback, initialValue, excludeWrapCoord) { - var previousValue = initialValue; - coordEach(layer, function (currentCoords, currentIndex) { - if (currentIndex === 0 && initialValue === undefined) { - previousValue = currentCoords; - } else { - previousValue = callback(previousValue, currentCoords, currentIndex); - } - }, excludeWrapCoord); - return previousValue; - } - module.exports.coordReduce = coordReduce; - - /** - * Callback for propEach - * - * @private - * @callback propEachCallback - * @param {*} currentProperties The current properties being processed. - * @param {number} currentIndex The index of the current element being processed in the - * array.Starts at index 0, if an initialValue is provided, and at index 1 otherwise. - */ - - /** - * Iterate over properties in any GeoJSON object, similar to Array.forEach() - * - * @name propEach - * @param {Object} layer any GeoJSON object - * @param {Function} callback a method that takes (currentProperties, currentIndex) - * @example - * var features = { - * "type": "FeatureCollection", - * "features": [ - * { - * "type": "Feature", - * "properties": {"foo": "bar"}, - * "geometry": { - * "type": "Point", - * "coordinates": [26, 37] - * } - * }, - * { - * "type": "Feature", - * "properties": {"hello": "world"}, - * "geometry": { - * "type": "Point", - * "coordinates": [36, 53] - * } - * } - * ] - * }; - * turf.propEach(features, function (currentProperties, currentIndex) { - * //=currentProperties - * //=currentIndex - * }); - */ - function propEach(layer, callback) { - var i; - switch (layer.type) { - case 'FeatureCollection': - for (i = 0; i < layer.features.length; i++) { - callback(layer.features[i].properties, i); - } - break; - case 'Feature': - callback(layer.properties, 0); - break; - } - } - module.exports.propEach = propEach; - - - /** - * Callback for propReduce - * - * The first time the callback function is called, the values provided as arguments depend - * on whether the reduce method has an initialValue argument. - * - * If an initialValue is provided to the reduce method: - * - The previousValue argument is initialValue. - * - The currentValue argument is the value of the first element present in the array. - * - * If an initialValue is not provided: - * - The previousValue argument is the value of the first element present in the array. - * - The currentValue argument is the value of the second element present in the array. - * - * @private - * @callback propReduceCallback - * @param {*} previousValue The accumulated value previously returned in the last invocation - * of the callback, or initialValue, if supplied. - * @param {*} currentProperties The current properties being processed. - * @param {number} currentIndex The index of the current element being processed in the - * array.Starts at index 0, if an initialValue is provided, and at index 1 otherwise. - */ - - /** - * Reduce properties in any GeoJSON object into a single value, - * similar to how Array.reduce works. However, in this case we lazily run - * the reduction, so an array of all properties is unnecessary. - * - * @name propReduce - * @param {Object} layer any GeoJSON object - * @param {Function} callback a method that takes (previousValue, currentProperties, currentIndex) - * @param {*} [initialValue] Value to use as the first argument to the first call of the callback. - * @returns {*} The value that results from the reduction. - * @example - * var features = { - * "type": "FeatureCollection", - * "features": [ - * { - * "type": "Feature", - * "properties": {"foo": "bar"}, - * "geometry": { - * "type": "Point", - * "coordinates": [26, 37] - * } - * }, - * { - * "type": "Feature", - * "properties": {"hello": "world"}, - * "geometry": { - * "type": "Point", - * "coordinates": [36, 53] - * } - * } - * ] - * }; - * turf.propReduce(features, function (previousValue, currentProperties, currentIndex) { - * //=previousValue - * //=currentProperties - * //=currentIndex - * return currentProperties - * }); - */ - function propReduce(layer, callback, initialValue) { - var previousValue = initialValue; - propEach(layer, function (currentProperties, currentIndex) { - if (currentIndex === 0 && initialValue === undefined) { - previousValue = currentProperties; - } else { - previousValue = callback(previousValue, currentProperties, currentIndex); - } - }); - return previousValue; - } - module.exports.propReduce = propReduce; - - /** - * Callback for featureEach - * - * @private - * @callback featureEachCallback - * @param {Feature} currentFeature The current feature being processed. - * @param {number} currentIndex The index of the current element being processed in the - * array.Starts at index 0, if an initialValue is provided, and at index 1 otherwise. - */ - - /** - * Iterate over features in any GeoJSON object, similar to - * Array.forEach. - * - * @name featureEach - * @param {Object} layer any GeoJSON object - * @param {Function} callback a method that takes (currentFeature, currentIndex) - * @example - * var features = { - * "type": "FeatureCollection", - * "features": [ - * { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [26, 37] - * } - * }, - * { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [36, 53] - * } - * } - * ] - * }; - * turf.featureEach(features, function (currentFeature, currentIndex) { - * //=currentFeature - * //=currentIndex - * }); - */ - function featureEach(layer, callback) { - if (layer.type === 'Feature') { - callback(layer, 0); - } else if (layer.type === 'FeatureCollection') { - for (var i = 0; i < layer.features.length; i++) { - callback(layer.features[i], i); - } - } - } - module.exports.featureEach = featureEach; - - /** - * Callback for featureReduce - * - * The first time the callback function is called, the values provided as arguments depend - * on whether the reduce method has an initialValue argument. - * - * If an initialValue is provided to the reduce method: - * - The previousValue argument is initialValue. - * - The currentValue argument is the value of the first element present in the array. - * - * If an initialValue is not provided: - * - The previousValue argument is the value of the first element present in the array. - * - The currentValue argument is the value of the second element present in the array. - * - * @private - * @callback featureReduceCallback - * @param {*} previousValue The accumulated value previously returned in the last invocation - * of the callback, or initialValue, if supplied. - * @param {Feature} currentFeature The current Feature being processed. - * @param {number} currentIndex The index of the current element being processed in the - * array.Starts at index 0, if an initialValue is provided, and at index 1 otherwise. - */ - - /** - * Reduce features in any GeoJSON object, similar to Array.reduce(). - * - * @name featureReduce - * @param {Object} layer any GeoJSON object - * @param {Function} callback a method that takes (previousValue, currentFeature, currentIndex) - * @param {*} [initialValue] Value to use as the first argument to the first call of the callback. - * @returns {*} The value that results from the reduction. - * @example - * var features = { - * "type": "FeatureCollection", - * "features": [ - * { - * "type": "Feature", - * "properties": {"foo": "bar"}, - * "geometry": { - * "type": "Point", - * "coordinates": [26, 37] - * } - * }, - * { - * "type": "Feature", - * "properties": {"hello": "world"}, - * "geometry": { - * "type": "Point", - * "coordinates": [36, 53] - * } - * } - * ] - * }; - * turf.featureReduce(features, function (previousValue, currentFeature, currentIndex) { - * //=previousValue - * //=currentFeature - * //=currentIndex - * return currentFeature - * }); - */ - function featureReduce(layer, callback, initialValue) { - var previousValue = initialValue; - featureEach(layer, function (currentFeature, currentIndex) { - if (currentIndex === 0 && initialValue === undefined) { - previousValue = currentFeature; - } else { - previousValue = callback(previousValue, currentFeature, currentIndex); - } - }); - return previousValue; - } - module.exports.featureReduce = featureReduce; - - /** - * Get all coordinates from any GeoJSON object. - * - * @name coordAll - * @param {Object} layer any GeoJSON object - * @returns {Array>} coordinate position array - * @example - * var features = { - * "type": "FeatureCollection", - * "features": [ - * { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [26, 37] - * } - * }, - * { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [36, 53] - * } - * } - * ] - * }; - * var coords = turf.coordAll(features); - * //=coords - */ - function coordAll(layer) { - var coords = []; - coordEach(layer, function (coord) { - coords.push(coord); - }); - return coords; - } - module.exports.coordAll = coordAll; - - /** - * Iterate over each geometry in any GeoJSON object, similar to Array.forEach() - * - * @name geomEach - * @param {Object} layer any GeoJSON object - * @param {Function} callback a method that takes (currentGeometry, currentIndex) - * @example - * var features = { - * "type": "FeatureCollection", - * "features": [ - * { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [26, 37] - * } - * }, - * { - * "type": "Feature", - * "properties": {}, - * "geometry": { - * "type": "Point", - * "coordinates": [36, 53] - * } - * } - * ] - * }; - * turf.geomEach(features, function (currentGeometry, currentIndex) { - * //=currentGeometry - * //=currentIndex - * }); - */ - function geomEach(layer, callback) { - var i, j, g, geometry, stopG, - geometryMaybeCollection, - isGeometryCollection, - currentIndex = 0, - isFeatureCollection = layer.type === 'FeatureCollection', - isFeature = layer.type === 'Feature', - stop = isFeatureCollection ? layer.features.length : 1; - - // This logic may look a little weird. The reason why it is that way - // is because it's trying to be fast. GeoJSON supports multiple kinds - // of objects at its root: FeatureCollection, Features, Geometries. - // This function has the responsibility of handling all of them, and that - // means that some of the `for` loops you see below actually just don't apply - // to certain inputs. For instance, if you give this just a - // Point geometry, then both loops are short-circuited and all we do - // is gradually rename the input until it's called 'geometry'. - // - // This also aims to allocate as few resources as possible: just a - // few numbers and booleans, rather than any temporary arrays as would - // be required with the normalization approach. - for (i = 0; i < stop; i++) { - - geometryMaybeCollection = (isFeatureCollection ? layer.features[i].geometry : - (isFeature ? layer.geometry : layer)); - isGeometryCollection = geometryMaybeCollection.type === 'GeometryCollection'; - stopG = isGeometryCollection ? geometryMaybeCollection.geometries.length : 1; - - for (g = 0; g < stopG; g++) { - geometry = isGeometryCollection ? - geometryMaybeCollection.geometries[g] : geometryMaybeCollection; - - if (geometry.type === 'Point' || - geometry.type === 'LineString' || - geometry.type === 'MultiPoint' || - geometry.type === 'Polygon' || - geometry.type === 'MultiLineString' || - geometry.type === 'MultiPolygon') { - callback(geometry, currentIndex); - currentIndex++; - } else if (geometry.type === 'GeometryCollection') { - for (j = 0; j < geometry.geometries.length; j++) { - callback(geometry.geometries[j], currentIndex); - currentIndex++; - } - } else { - throw new Error('Unknown Geometry Type'); - } - } - } - } - module.exports.geomEach = geomEach; - - /** - * Callback for geomReduce - * - * The first time the callback function is called, the values provided as arguments depend - * on whether the reduce method has an initialValue argument. - * - * If an initialValue is provided to the reduce method: - * - The previousValue argument is initialValue. - * - The currentValue argument is the value of the first element present in the array. - * - * If an initialValue is not provided: - * - The previousValue argument is the value of the first element present in the array. - * - The currentValue argument is the value of the second element present in the array. - * - * @private - * @callback geomReduceCallback - * @param {*} previousValue The accumulated value previously returned in the last invocation - * of the callback, or initialValue, if supplied. - * @param {*} currentGeometry The current Feature being processed. - * @param {number} currentIndex The index of the current element being processed in the - * array.Starts at index 0, if an initialValue is provided, and at index 1 otherwise. - */ - - /** - * Reduce geometry in any GeoJSON object, similar to Array.reduce(). - * - * @name geomReduce - * @param {Object} layer any GeoJSON object - * @param {Function} callback a method that takes (previousValue, currentGeometry, currentIndex) - * @param {*} [initialValue] Value to use as the first argument to the first call of the callback. - * @returns {*} The value that results from the reduction. - * @example - * var features = { - * "type": "FeatureCollection", - * "features": [ - * { - * "type": "Feature", - * "properties": {"foo": "bar"}, - * "geometry": { - * "type": "Point", - * "coordinates": [26, 37] - * } - * }, - * { - * "type": "Feature", - * "properties": {"hello": "world"}, - * "geometry": { - * "type": "Point", - * "coordinates": [36, 53] - * } - * } - * ] - * }; - * turf.geomReduce(features, function (previousValue, currentGeometry, currentIndex) { - * //=previousValue - * //=currentGeometry - * //=currentIndex - * return currentGeometry - * }); - */ - function geomReduce(layer, callback, initialValue) { - var previousValue = initialValue; - geomEach(layer, function (currentGeometry, currentIndex) { - if (currentIndex === 0 && initialValue === undefined) { - previousValue = currentGeometry; - } else { - previousValue = callback(previousValue, currentGeometry, currentIndex); - } - }); - return previousValue; - } - module.exports.geomReduce = geomReduce; - - }, {}], 11: [function (require, module, exports) { - 'use strict'; - - module.exports = partialSort; - - // Floyd-Rivest selection algorithm: - // Rearrange items so that all items in the [left, k] range are smaller than all items in (k, right]; - // The k-th element will have the (k - left + 1)th smallest value in [left, right] - - function partialSort(arr, k, left, right, compare) { - left = left || 0; - right = right || (arr.length - 1); - compare = compare || defaultCompare; - - while (right > left) { - if (right - left > 600) { - var n = right - left + 1; - var m = k - left + 1; - var z = Math.log(n); - var s = 0.5 * Math.exp(2 * z / 3); - var sd = 0.5 * Math.sqrt(z * s * (n - s) / n) * (m - n / 2 < 0 ? -1 : 1); - var newLeft = Math.max(left, Math.floor(k - m * s / n + sd)); - var newRight = Math.min(right, Math.floor(k + (n - m) * s / n + sd)); - partialSort(arr, k, newLeft, newRight, compare); - } - - var t = arr[k]; - var i = left; - var j = right; - - swap(arr, left, k); - if (compare(arr[right], t) > 0) swap(arr, left, right); - - while (i < j) { - swap(arr, i, j); - i++; - j--; - while (compare(arr[i], t) < 0) i++; - while (compare(arr[j], t) > 0) j--; - } - - if (compare(arr[left], t) === 0) swap(arr, left, j); - else { - j++; - swap(arr, j, right); - } - - if (j <= k) left = j + 1; - if (k <= j) right = j - 1; - } - } - - function swap(arr, i, j) { - var tmp = arr[i]; - arr[i] = arr[j]; - arr[j] = tmp; - } - - function defaultCompare(a, b) { - return a < b ? -1 : a > b ? 1 : 0; - } - - }, {}], 12: [function (require, module, exports) { - 'use strict'; - - module.exports = rbush; - - var quickselect = require('quickselect'); - - function rbush(maxEntries, format) { - if (!(this instanceof rbush)) return new rbush(maxEntries, format); - - // max entries in a node is 9 by default; min node fill is 40% for best performance - this._maxEntries = Math.max(4, maxEntries || 9); - this._minEntries = Math.max(2, Math.ceil(this._maxEntries * 0.4)); - - if (format) { - this._initFormat(format); - } - - this.clear(); - } - - rbush.prototype = { - - all: function () { - return this._all(this.data, []); - }, - - search: function (bbox) { - - var node = this.data, - result = [], - toBBox = this.toBBox; - - if (!intersects(bbox, node)) return result; - - var nodesToSearch = [], - i, len, child, childBBox; - - while (node) { - for (i = 0, len = node.children.length; i < len; i++) { - - child = node.children[i]; - childBBox = node.leaf ? toBBox(child) : child; - - if (intersects(bbox, childBBox)) { - if (node.leaf) result.push(child); - else if (contains(bbox, childBBox)) this._all(child, result); - else nodesToSearch.push(child); - } - } - node = nodesToSearch.pop(); - } - - return result; - }, - - collides: function (bbox) { - - var node = this.data, - toBBox = this.toBBox; - - if (!intersects(bbox, node)) return false; - - var nodesToSearch = [], - i, len, child, childBBox; - - while (node) { - for (i = 0, len = node.children.length; i < len; i++) { - - child = node.children[i]; - childBBox = node.leaf ? toBBox(child) : child; - - if (intersects(bbox, childBBox)) { - if (node.leaf || contains(bbox, childBBox)) return true; - nodesToSearch.push(child); - } - } - node = nodesToSearch.pop(); - } - - return false; - }, - - load: function (data) { - if (!(data && data.length)) return this; - - if (data.length < this._minEntries) { - for (var i = 0, len = data.length; i < len; i++) { - this.insert(data[i]); - } - return this; - } - - // recursively build the tree with the given data from stratch using OMT algorithm - var node = this._build(data.slice(), 0, data.length - 1, 0); - - if (!this.data.children.length) { - // save as is if tree is empty - this.data = node; - - } else if (this.data.height === node.height) { - // split root if trees have the same height - this._splitRoot(this.data, node); - - } else { - if (this.data.height < node.height) { - // swap trees if inserted one is bigger - var tmpNode = this.data; - this.data = node; - node = tmpNode; - } - - // insert the small tree into the large tree at appropriate level - this._insert(node, this.data.height - node.height - 1, true); - } - - return this; - }, - - insert: function (item) { - if (item) this._insert(item, this.data.height - 1); - return this; - }, - - clear: function () { - this.data = createNode([]); - return this; - }, - - remove: function (item, equalsFn) { - if (!item) return this; - - var node = this.data, - bbox = this.toBBox(item), - path = [], - indexes = [], - i, parent, index, goingUp; - - // depth-first iterative tree traversal - while (node || path.length) { - - if (!node) { // go up - node = path.pop(); - parent = path[path.length - 1]; - i = indexes.pop(); - goingUp = true; - } - - if (node.leaf) { // check current node - index = findItem(item, node.children, equalsFn); - - if (index !== -1) { - // item found, remove the item and condense tree upwards - node.children.splice(index, 1); - path.push(node); - this._condense(path); - return this; - } - } - - if (!goingUp && !node.leaf && contains(node, bbox)) { // go down - path.push(node); - indexes.push(i); - i = 0; - parent = node; - node = node.children[0]; - - } else if (parent) { // go right - i++; - node = parent.children[i]; - goingUp = false; - - } else node = null; // nothing found - } - - return this; - }, - - toBBox: function (item) { return item; }, - - compareMinX: compareNodeMinX, - compareMinY: compareNodeMinY, - - toJSON: function () { return this.data; }, - - fromJSON: function (data) { - this.data = data; - return this; - }, - - _all: function (node, result) { - var nodesToSearch = []; - while (node) { - if (node.leaf) result.push.apply(result, node.children); - else nodesToSearch.push.apply(nodesToSearch, node.children); - - node = nodesToSearch.pop(); - } - return result; - }, - - _build: function (items, left, right, height) { - - var N = right - left + 1, - M = this._maxEntries, - node; - - if (N <= M) { - // reached leaf level; return leaf - node = createNode(items.slice(left, right + 1)); - calcBBox(node, this.toBBox); - return node; - } - - if (!height) { - // target height of the bulk-loaded tree - height = Math.ceil(Math.log(N) / Math.log(M)); - - // target number of root entries to maximize storage utilization - M = Math.ceil(N / Math.pow(M, height - 1)); - } - - node = createNode([]); - node.leaf = false; - node.height = height; - - // split the items into M mostly square tiles - - var N2 = Math.ceil(N / M), - N1 = N2 * Math.ceil(Math.sqrt(M)), - i, j, right2, right3; - - multiSelect(items, left, right, N1, this.compareMinX); - - for (i = left; i <= right; i += N1) { - - right2 = Math.min(i + N1 - 1, right); - - multiSelect(items, i, right2, N2, this.compareMinY); - - for (j = i; j <= right2; j += N2) { - - right3 = Math.min(j + N2 - 1, right2); - - // pack each entry recursively - node.children.push(this._build(items, j, right3, height - 1)); - } - } - - calcBBox(node, this.toBBox); - - return node; - }, - - _chooseSubtree: function (bbox, node, level, path) { - - var i, len, child, targetNode, area, enlargement, minArea, minEnlargement; - - while (true) { - path.push(node); - - if (node.leaf || path.length - 1 === level) break; - - minArea = minEnlargement = Infinity; - - for (i = 0, len = node.children.length; i < len; i++) { - child = node.children[i]; - area = bboxArea(child); - enlargement = enlargedArea(bbox, child) - area; - - // choose entry with the least area enlargement - if (enlargement < minEnlargement) { - minEnlargement = enlargement; - minArea = area < minArea ? area : minArea; - targetNode = child; - - } else if (enlargement === minEnlargement) { - // otherwise choose one with the smallest area - if (area < minArea) { - minArea = area; - targetNode = child; - } - } - } - - node = targetNode || node.children[0]; - } - - return node; - }, - - _insert: function (item, level, isNode) { - - var toBBox = this.toBBox, - bbox = isNode ? item : toBBox(item), - insertPath = []; - - // find the best node for accommodating the item, saving all nodes along the path too - var node = this._chooseSubtree(bbox, this.data, level, insertPath); - - // put the item into the node - node.children.push(item); - extend(node, bbox); - - // split on node overflow; propagate upwards if necessary - while (level >= 0) { - if (insertPath[level].children.length > this._maxEntries) { - this._split(insertPath, level); - level--; - } else break; - } - - // adjust bboxes along the insertion path - this._adjustParentBBoxes(bbox, insertPath, level); - }, - - // split overflowed node into two - _split: function (insertPath, level) { - - var node = insertPath[level], - M = node.children.length, - m = this._minEntries; - - this._chooseSplitAxis(node, m, M); - - var splitIndex = this._chooseSplitIndex(node, m, M); - - var newNode = createNode(node.children.splice(splitIndex, node.children.length - splitIndex)); - newNode.height = node.height; - newNode.leaf = node.leaf; - - calcBBox(node, this.toBBox); - calcBBox(newNode, this.toBBox); - - if (level) insertPath[level - 1].children.push(newNode); - else this._splitRoot(node, newNode); - }, - - _splitRoot: function (node, newNode) { - // split root node - this.data = createNode([node, newNode]); - this.data.height = node.height + 1; - this.data.leaf = false; - calcBBox(this.data, this.toBBox); - }, - - _chooseSplitIndex: function (node, m, M) { - - var i, bbox1, bbox2, overlap, area, minOverlap, minArea, index; - - minOverlap = minArea = Infinity; - - for (i = m; i <= M - m; i++) { - bbox1 = distBBox(node, 0, i, this.toBBox); - bbox2 = distBBox(node, i, M, this.toBBox); - - overlap = intersectionArea(bbox1, bbox2); - area = bboxArea(bbox1) + bboxArea(bbox2); - - // choose distribution with minimum overlap - if (overlap < minOverlap) { - minOverlap = overlap; - index = i; - - minArea = area < minArea ? area : minArea; - - } else if (overlap === minOverlap) { - // otherwise choose distribution with minimum area - if (area < minArea) { - minArea = area; - index = i; - } - } - } - - return index; - }, - - // sorts node children by the best axis for split - _chooseSplitAxis: function (node, m, M) { - - var compareMinX = node.leaf ? this.compareMinX : compareNodeMinX, - compareMinY = node.leaf ? this.compareMinY : compareNodeMinY, - xMargin = this._allDistMargin(node, m, M, compareMinX), - yMargin = this._allDistMargin(node, m, M, compareMinY); - - // if total distributions margin value is minimal for x, sort by minX, - // otherwise it's already sorted by minY - if (xMargin < yMargin) node.children.sort(compareMinX); - }, - - // total margin of all possible split distributions where each node is at least m full - _allDistMargin: function (node, m, M, compare) { - - node.children.sort(compare); - - var toBBox = this.toBBox, - leftBBox = distBBox(node, 0, m, toBBox), - rightBBox = distBBox(node, M - m, M, toBBox), - margin = bboxMargin(leftBBox) + bboxMargin(rightBBox), - i, child; - - for (i = m; i < M - m; i++) { - child = node.children[i]; - extend(leftBBox, node.leaf ? toBBox(child) : child); - margin += bboxMargin(leftBBox); - } - - for (i = M - m - 1; i >= m; i--) { - child = node.children[i]; - extend(rightBBox, node.leaf ? toBBox(child) : child); - margin += bboxMargin(rightBBox); - } - - return margin; - }, - - _adjustParentBBoxes: function (bbox, path, level) { - // adjust bboxes along the given tree path - for (var i = level; i >= 0; i--) { - extend(path[i], bbox); - } - }, - - _condense: function (path) { - // go through the path, removing empty nodes and updating bboxes - for (var i = path.length - 1, siblings; i >= 0; i--) { - if (path[i].children.length === 0) { - if (i > 0) { - siblings = path[i - 1].children; - siblings.splice(siblings.indexOf(path[i]), 1); - - } else this.clear(); - - } else calcBBox(path[i], this.toBBox); - } - }, - - _initFormat: function (format) { - // data format (minX, minY, maxX, maxY accessors) - - // uses eval-type function compilation instead of just accepting a toBBox function - // because the algorithms are very sensitive to sorting functions performance, - // so they should be dead simple and without inner calls - - var compareArr = ['return a', ' - b', ';']; - - this.compareMinX = new Function('a', 'b', compareArr.join(format[0])); - this.compareMinY = new Function('a', 'b', compareArr.join(format[1])); - - this.toBBox = new Function('a', - 'return {minX: a' + format[0] + - ', minY: a' + format[1] + - ', maxX: a' + format[2] + - ', maxY: a' + format[3] + '};'); - } - }; - - function findItem(item, items, equalsFn) { - if (!equalsFn) return items.indexOf(item); - - for (var i = 0; i < items.length; i++) { - if (equalsFn(item, items[i])) return i; - } - return -1; - } - - // calculate node's bbox from bboxes of its children - function calcBBox(node, toBBox) { - distBBox(node, 0, node.children.length, toBBox, node); - } - - // min bounding rectangle of node children from k to p-1 - function distBBox(node, k, p, toBBox, destNode) { - if (!destNode) destNode = createNode(null); - destNode.minX = Infinity; - destNode.minY = Infinity; - destNode.maxX = -Infinity; - destNode.maxY = -Infinity; - - for (var i = k, child; i < p; i++) { - child = node.children[i]; - extend(destNode, node.leaf ? toBBox(child) : child); - } - - return destNode; - } - - function extend(a, b) { - a.minX = Math.min(a.minX, b.minX); - a.minY = Math.min(a.minY, b.minY); - a.maxX = Math.max(a.maxX, b.maxX); - a.maxY = Math.max(a.maxY, b.maxY); - return a; - } - - function compareNodeMinX(a, b) { return a.minX - b.minX; } - function compareNodeMinY(a, b) { return a.minY - b.minY; } - - function bboxArea(a) { return (a.maxX - a.minX) * (a.maxY - a.minY); } - function bboxMargin(a) { return (a.maxX - a.minX) + (a.maxY - a.minY); } - - function enlargedArea(a, b) { - return (Math.max(b.maxX, a.maxX) - Math.min(b.minX, a.minX)) * - (Math.max(b.maxY, a.maxY) - Math.min(b.minY, a.minY)); - } - - function intersectionArea(a, b) { - var minX = Math.max(a.minX, b.minX), - minY = Math.max(a.minY, b.minY), - maxX = Math.min(a.maxX, b.maxX), - maxY = Math.min(a.maxY, b.maxY); - - return Math.max(0, maxX - minX) * - Math.max(0, maxY - minY); - } - - function contains(a, b) { - return a.minX <= b.minX && - a.minY <= b.minY && - b.maxX <= a.maxX && - b.maxY <= a.maxY; - } - - function intersects(a, b) { - return b.minX <= a.maxX && - b.minY <= a.maxY && - b.maxX >= a.minX && - b.maxY >= a.minY; - } - - function createNode(children) { - return { - children: children, - height: 1, - leaf: true, - minX: Infinity, - minY: Infinity, - maxX: -Infinity, - maxY: -Infinity - }; - } - - // sort an array so that items come in groups of n unsorted items, with groups sorted between each other; - // combines selection algorithm with binary divide & conquer approach - - function multiSelect(arr, left, right, n, compare) { - var stack = [left, right], - mid; - - while (stack.length) { - right = stack.pop(); - left = stack.pop(); - - if (right - left <= n) continue; - - mid = left + Math.ceil((right - left) / n / 2) * n; - quickselect(arr, mid, left, right, compare); - - stack.push(left, mid, mid, right); - } - } - - }, { "quickselect": 11 }] -}, {}, [2]); diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx index 751b12e..fc1dd5a 100644 --- a/frontend/src/components/Map.tsx +++ b/frontend/src/components/Map.tsx @@ -8,25 +8,11 @@ import "../assets/Map.css"; import { Metric, type ParameterValues } from "./FilterPanel"; import { PropertyCard } from "./PropertyCard"; import { ScrollArea } from "./ui/scroll-area"; -import type { GeoJSONFeatureCollection, PropertyFeature, PropertyProperties, POI } from "@/types"; +import type { GeoJSONFeatureCollection, PropertyProperties, POI } from "@/types"; import { MAP_CONFIG, HEATMAP_CONFIG, PERCENTILE_CONFIG } from "@/constants"; import { getColorSchemeForMetric, getMetricInterpretation } from "@/constants/colorSchemes"; -import { percentile, calculateColorStops } from "@/utils/mapUtils"; - -// Type declaration for the external HexgridHeatmap library -declare class HexgridHeatmap { - _tree: { - search: (bounds: { minX: number; maxX: number; minY: number; maxY: number }) => PropertyWithCoords[]; - }; - constructor(map: mapboxgl.Map, id: string, beforeLayer: string); - setIntensity(value: number): void; - setSpread(value: number): void; - setCellDensity(value: number): void; - setPropertyName(name: string): void; - setData(data: GeoJSONFeatureCollection): void; - setColorStops(stops: [number, string][]): void; - update(): void; -} +import { calculateColorStops } from "@/utils/mapUtils"; +import { HexgridHeatmapClient } from "@/workers/HexgridHeatmapClient"; interface PropertyWithCoords { properties: PropertyProperties; @@ -48,7 +34,7 @@ export function Map(props: MapProps) { const mapRef = useRef(null); const mapContainerRef = useRef(null); - const heatmapRef = useRef(null); + const heatmapRef = useRef(null); const updateTimeoutRef = useRef(null); const isMapLoadedRef = useRef(false); const lastDataLengthRef = useRef(0); @@ -77,12 +63,12 @@ export function Map(props: MapProps) { : 0; }, [data]); - const updateHeatmap = useCallback(() => { + const updateHeatmap = useCallback(async () => { if (!mapRef.current || !isMapLoadedRef.current) return; // Create heatmap if it doesn't exist if (!heatmapRef.current) { - heatmapRef.current = new HexgridHeatmap(mapRef.current, "hexgrid-heatmap", "waterway-label"); + heatmapRef.current = new HexgridHeatmapClient(mapRef.current, "hexgrid-heatmap", "waterway-label"); heatmapRef.current.setIntensity(HEATMAP_CONFIG.INTENSITY); heatmapRef.current.setSpread(HEATMAP_CONFIG.SPREAD); heatmapRef.current.setCellDensity(HEATMAP_CONFIG.CELL_DENSITY); @@ -94,23 +80,15 @@ export function Map(props: MapProps) { // Pass all features to the heatmap — filtering is done server-side heatmap.setData(data); - // Compute color scale from valid metric values only - const values = data.features - .map(function (d: PropertyFeature) { - return (d.properties as unknown as Record)[metricMode] as number; - }) - .filter(function (v: number) { return typeof v === 'number' && isFinite(v) && v > 0; }) - .sort(function (a: number, b: number) { return a - b; }); + // Compute color scale in worker (sorts + percentiles off main thread) + const colorResult = await heatmap.computeColorScale(metricMode, { + minBound: PERCENTILE_CONFIG.MIN_BOUND, + maxBound: PERCENTILE_CONFIG.MAX_BOUND, + }); - if (values.length > 0) { - const minIndex = Math.min(Math.round(values.length * PERCENTILE_CONFIG.MIN_BOUND), values.length - 1); - const maxIndex = Math.min(Math.round(values.length * PERCENTILE_CONFIG.MAX_BOUND), values.length - 1); - const min = values[minIndex]; - // Ensure max > min so color stops are strictly monotonic - const max = Math.max(values[maxIndex], min + 1); - - makeLegend(colorScheme, min, max); - const colorStopsValue = calculateColorStops(colorScheme, min, max); + if (colorResult.hasValues) { + makeLegend(colorScheme, colorResult.min, colorResult.max); + const colorStopsValue = calculateColorStops(colorScheme, colorResult.min, colorResult.max); heatmap.setColorStops(colorStopsValue); } else { // Set safe default stops so stale stops from a previous metric don't cause @@ -123,16 +101,14 @@ export function Map(props: MapProps) { // Fit bounds only on first load or significant data change if (lastDataLengthRef.current === 0 && data.features.length > 0) { - const longitudes = data.features.map(function (d: PropertyFeature) { return d.geometry.coordinates[0]; }).sort(function (a: number, b: number) { return a - b; }); - const latitudes = data.features.map(function (d: PropertyFeature) { return d.geometry.coordinates[1]; }).sort(function (a: number, b: number) { return a - b; }); - const minlng = percentile(longitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MIN); - const maxlng = percentile(longitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MAX); - const minlat = percentile(latitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MIN); - const maxlat = percentile(latitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MAX); + const boundsResult = await heatmap.computeBounds({ + clipMin: PERCENTILE_CONFIG.BOUNDS_CLIP_MIN, + clipMax: PERCENTILE_CONFIG.BOUNDS_CLIP_MAX, + }); mapRef.current?.fitBounds([ - [minlng, minlat], - [maxlng, maxlat] + [boundsResult.minLng, boundsResult.minLat], + [boundsResult.maxLng, boundsResult.maxLat] ], { duration: 0 }); } @@ -194,8 +170,11 @@ export function Map(props: MapProps) { if (updateTimeoutRef.current) { clearTimeout(updateTimeoutRef.current); } - // Remove heatmap layers and sources before destroying the map - if (heatmapRef.current && mapRef.current) { + // Destroy worker and remove heatmap layers/sources before destroying the map + if (heatmapRef.current) { + heatmapRef.current.destroy(); + } + if (mapRef.current) { for (const layerId of ['hexgrid-heatmap', 'hexgrid-heatmap-back']) { if (mapRef.current.getLayer(layerId)) { mapRef.current.removeLayer(layerId); @@ -370,7 +349,7 @@ export function Map(props: MapProps) { function openListingsDialog(longitude: number, latitude: number, searchBounds: { minX: number; minY: number; maxX: number; maxY: number }) { if (!heatmapRef.current || !mapRef.current) return; - const properties = heatmapRef.current._tree.search(searchBounds); + const properties = heatmapRef.current.searchTree(searchBounds) as unknown as PropertyWithCoords[]; if (properties.length > 0) { const container = document.createElement('div'); const root = createRoot(container); diff --git a/frontend/src/workers/HexgridHeatmapClient.ts b/frontend/src/workers/HexgridHeatmapClient.ts new file mode 100644 index 0000000..01fe7d5 --- /dev/null +++ b/frontend/src/workers/HexgridHeatmapClient.ts @@ -0,0 +1,317 @@ +import RBush from 'rbush'; +import type { + FeatureItem, + WorkerResponse, + ColorScaleResultMessage, + BoundsResultMessage, + GridResultMessage, +} from './types'; +import { toFeatureItem } from './types'; + +interface HexgridConfig { + intensity: number; + spread: number; + cellDensity: number; + propertyName: string; +} + +interface PendingPromise { + resolve: (value: T) => void; + reject: (reason: unknown) => void; +} + +export class HexgridHeatmapClient { + private map: mapboxgl.Map; + private worker: Worker; + private mainTree = new RBush(); + private requestCounter = 0; + + // Layer swap state (matches original HexgridHeatmap) + private layerA: string; + private layerB: string; + private sourceA: mapboxgl.GeoJSONSource | null = null; + private sourceB: mapboxgl.GeoJSONSource | null = null; + private activeIsA = true; + private lastZoom: number | null = null; + private clearTimeout: ReturnType | null = null; + + // Grid calculation coalescing + private calculatingGrid = false; + private recalcWhenReady = false; + + // Pending grid result tracking + private pendingGridRequestId: number | null = null; + + // Pending promises for async operations + private pendingColorScale = new Map>(); + private pendingBounds = new Map>(); + + private config: HexgridConfig = { + intensity: 8, + spread: 0.1, + cellDensity: 1, + propertyName: 'count', + }; + + private moveEndHandler: () => void; + + constructor(map: mapboxgl.Map, layername: string, addBefore?: string) { + this.map = map; + this.layerA = layername; + this.layerB = layername + '-back'; + + this.setupLayers(layername, addBefore); + + // Create worker + this.worker = new Worker( + new URL('./hexgrid.worker.ts', import.meta.url), + { type: 'module' } + ); + this.worker.onmessage = (e: MessageEvent) => { + this.handleWorkerMessage(e.data); + }; + + // Bind moveend + this.moveEndHandler = () => this.updateGrid(); + this.map.on('moveend', this.moveEndHandler); + } + + private setupLayers(layername: string, addBefore?: string): void { + const defaultPaint: mapboxgl.FillPaint = { + 'fill-opacity': 0, + 'fill-opacity-transition': { duration: 200, delay: 0 }, + 'fill-color': { + property: 'count', + stops: [ + [0, 'rgba(0,185,243,0)'], + [50, 'rgba(0,185,243,0.24)'], + [130, 'rgba(255,223,0,0.3)'], + [200, 'rgba(255,105,0,0.3)'], + ], + }, + }; + const emptyData: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] }; + + // Layer A: primary, starts active at opacity 1 + this.map.addLayer({ + id: layername, + type: 'fill', + source: { type: 'geojson', data: emptyData }, + paint: { ...defaultPaint, 'fill-opacity': 1.0 }, + }, addBefore); + + // Layer B: back layer, starts inactive at opacity 0, renders below A + this.map.addLayer({ + id: layername + '-back', + type: 'fill', + source: { type: 'geojson', data: emptyData }, + paint: { ...defaultPaint }, + }, layername); + + this.sourceA = this.map.getSource(layername) as mapboxgl.GeoJSONSource; + this.sourceB = this.map.getSource(layername + '-back') as mapboxgl.GeoJSONSource; + } + + private nextRequestId(): number { + return ++this.requestCounter; + } + + private handleWorkerMessage(msg: WorkerResponse): void { + switch (msg.type) { + case 'DATA_READY': + // Data loaded in worker, nothing to do on main thread + break; + + case 'GRID_RESULT': + this.handleGridResult(msg); + break; + + case 'COLOR_SCALE_RESULT': { + const pending = this.pendingColorScale.get(msg.requestId); + if (pending) { + this.pendingColorScale.delete(msg.requestId); + pending.resolve(msg); + } + break; + } + + case 'BOUNDS_RESULT': { + const pending = this.pendingBounds.get(msg.requestId); + if (pending) { + this.pendingBounds.delete(msg.requestId); + pending.resolve(msg); + } + break; + } + } + } + + private handleGridResult(msg: GridResultMessage): void { + // Discard stale grid results + if (msg.requestId !== this.pendingGridRequestId) return; + this.pendingGridRequestId = null; + + const hexgrid = msg.hexgrid as unknown as GeoJSON.FeatureCollection; + const currentZoom = Math.floor(this.map.getZoom()); + const zoomChanged = this.lastZoom !== null && this.lastZoom !== currentZoom; + this.lastZoom = currentZoom; + + const activeSource = this.activeIsA ? this.sourceA! : this.sourceB!; + const activeLayer = this.activeIsA ? this.layerA : this.layerB; + const inactiveSource = this.activeIsA ? this.sourceB! : this.sourceA!; + const inactiveLayer = this.activeIsA ? this.layerB : this.layerA; + + // Cancel any pending cleanup timeout + if (this.clearTimeout) { + clearTimeout(this.clearTimeout); + this.clearTimeout = null; + } + + if (zoomChanged) { + // Cross-fade between layers + this.map.moveLayer(activeLayer, inactiveLayer); + inactiveSource.setData(hexgrid); + this.map.setPaintProperty(inactiveLayer, 'fill-opacity', 1.0); + this.map.setPaintProperty(activeLayer, 'fill-opacity', 0); + this.activeIsA = !this.activeIsA; + + // Clear old layer data after transition + const src = activeSource; + this.clearTimeout = setTimeout(() => { + this.clearTimeout = null; + src.setData({ type: 'FeatureCollection', features: [] }); + }, 250); + } else { + // Pan only: swap data directly on active source + activeSource.setData(hexgrid); + } + + this.calculatingGrid = false; + if (this.recalcWhenReady) { + this.recalcWhenReady = false; + this.updateGrid(); + } + } + + // --- Public API --- + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setData(data: { features: Array<{ properties: any; geometry: { type: 'Point'; coordinates: [number, number] } }> }): void { + // Build main-thread R-tree for click queries + const items = data.features.map(toFeatureItem); + this.mainTree.clear(); + this.mainTree.load(items); + + // Post to worker for grid generation tree + const requestId = this.nextRequestId(); + this.worker.postMessage({ + type: 'SET_DATA', + requestId, + features: data.features, + }); + } + + setPropertyName(name: string): void { + this.config.propertyName = name; + } + + setSpread(v: number): void { + this.config.spread = v; + } + + setCellDensity(v: number): void { + this.config.cellDensity = v; + } + + setIntensity(v: number): void { + this.config.intensity = v; + } + + setColorStops(stops: [number, string][]): void { + const colorProp = { property: 'count', stops }; + this.map.setPaintProperty(this.layerA, 'fill-color', colorProp); + this.map.setPaintProperty(this.layerB, 'fill-color', colorProp); + } + + update(): void { + this.updateGrid(); + } + + searchTree(bounds: { minX: number; minY: number; maxX: number; maxY: number }): FeatureItem[] { + return this.mainTree.search(bounds); + } + + computeColorScale( + metricMode: string, + percentileConfig: { minBound: number; maxBound: number } + ): Promise { + const requestId = this.nextRequestId(); + return new Promise((resolve, reject) => { + this.pendingColorScale.set(requestId, { resolve, reject }); + this.worker.postMessage({ + type: 'COMPUTE_COLOR_SCALE', + requestId, + metricMode, + percentileConfig, + }); + }); + } + + computeBounds( + boundsPercentileConfig: { clipMin: number; clipMax: number } + ): Promise { + const requestId = this.nextRequestId(); + return new Promise((resolve, reject) => { + this.pendingBounds.set(requestId, { resolve, reject }); + this.worker.postMessage({ + type: 'COMPUTE_BOUNDS', + requestId, + boundsPercentileConfig, + }); + }); + } + + destroy(): void { + this.map.off('moveend', this.moveEndHandler); + if (this.clearTimeout) { + clearTimeout(this.clearTimeout); + } + // Reject any pending promises + for (const [, p] of this.pendingColorScale) { + p.reject(new Error('HexgridHeatmapClient destroyed')); + } + for (const [, p] of this.pendingBounds) { + p.reject(new Error('HexgridHeatmapClient destroyed')); + } + this.pendingColorScale.clear(); + this.pendingBounds.clear(); + this.worker.terminate(); + } + + // --- Private --- + + private updateGrid(): void { + if (!this.calculatingGrid) { + this.calculatingGrid = true; + + const zoom = this.map.getZoom(); + const b = this.map.getBounds()!; + const bounds: [number, number, number, number] = [ + b.getWest(), b.getSouth(), b.getEast(), b.getNorth(), + ]; + + const requestId = this.nextRequestId(); + this.pendingGridRequestId = requestId; + + this.worker.postMessage({ + type: 'GENERATE_GRID', + requestId, + zoom, + bounds, + config: this.config, + }); + } else { + this.recalcWhenReady = true; + } + } +} diff --git a/frontend/src/workers/hexgrid.worker.ts b/frontend/src/workers/hexgrid.worker.ts new file mode 100644 index 0000000..71c5d65 --- /dev/null +++ b/frontend/src/workers/hexgrid.worker.ts @@ -0,0 +1,229 @@ +/// + +import RBush from 'rbush'; +import type { + FeatureItem, + WorkerRequest, + GenerateGridMessage, + ComputeColorScaleMessage, + ComputeBoundsMessage, +} from './types'; +import { toFeatureItem } from './types'; + +const tree = new RBush(); +let features: FeatureItem[] = []; + +// Track latest requestId per message type to discard stale results +const latestRequestId: Record = {}; + +function isStale(type: string, requestId: number): boolean { + return (latestRequestId[type] ?? 0) > requestId; +} + +// Average non-NaN values (same reduce function as original HexgridHeatmap) +function reduceAverage(data: number[]): number { + let sum = 0; + let count = 0; + for (let i = 0; i < data.length; i++) { + if (!isNaN(data[i])) { + sum += data[i]; + count++; + } + } + return count > 0 ? sum / count : NaN; +} + +function generateGrid(msg: GenerateGridMessage): void { + const { requestId, zoom, bounds, config } = msg; + const { cellDensity, spread, propertyName } = config; + + const quantizedZoom = Math.floor(zoom); + const cellSize = Math.max(500 / Math.pow(2, quantizedZoom) / cellDensity, 0.01); + + const extents = bounds; // [minLng, minLat, maxLng, maxLat] + + // Convert cell size from km to degrees + const centerLat = (extents[1] + extents[3]) / 2; + const kmPerDegLon = 111.32 * Math.cos(centerLat * Math.PI / 180); + const kmPerDegLat = 110.574; + const rx = cellSize / kmPerDegLon; + const ry = cellSize / kmPerDegLat * Math.sqrt(3) / 2; + + // Flat-top hex grid spacing + const xStep = rx * 1.5; + const yStep = ry * 2; + + // Compute grid indices from fixed origin (0,0) + const xStart = Math.floor(extents[0] / xStep) - 1; + const xEnd = Math.ceil(extents[2] / xStep) + 1; + const yStart = Math.floor(extents[1] / yStep) - 1; + const yEnd = Math.ceil(extents[3] / yStep) + 1; + + // Scale search radius with cell size + const searchDegLon = Math.max(cellSize * 0.75, spread * 4) / kmPerDegLon; + const searchDegLat = Math.max(cellSize * 0.75, spread * 4) / kmPerDegLat; + + const cellsToSave = []; + + for (let xi = xStart; xi <= xEnd; xi++) { + for (let yi = yStart; yi <= yEnd; yi++) { + const cx = xi * xStep; + let cy = yi * yStep; + // Odd columns offset by half a row + if (xi % 2 !== 0) { + cy -= yStep / 2; + } + + const pois = tree.search({ + minX: cx - searchDegLon, + minY: cy - searchDegLat, + maxX: cx + searchDegLon, + maxY: cy + searchDegLat, + }); + + if (pois.length > 0) { + const values = pois.map(d => d.properties[propertyName] as number); + const strength = reduceAverage(values); + if (!isNaN(strength)) { + const coords = [ + [cx + rx, cy], + [cx + rx / 2, cy + ry], + [cx - rx / 2, cy + ry], + [cx - rx, cy], + [cx - rx / 2, cy - ry], + [cx + rx / 2, cy - ry], + [cx + rx, cy], + ]; + cellsToSave.push({ + type: 'Feature' as const, + geometry: { type: 'Polygon' as const, coordinates: [coords] }, + properties: { + count: strength, + searchMinX: cx - searchDegLon, + searchMinY: cy - searchDegLat, + searchMaxX: cx + searchDegLon, + searchMaxY: cy + searchDegLat, + }, + }); + } + } + } + } + + if (isStale('GENERATE_GRID', requestId)) return; + + self.postMessage({ + type: 'GRID_RESULT', + requestId, + hexgrid: { type: 'FeatureCollection', features: cellsToSave }, + }); +} + +function computeColorScale(msg: ComputeColorScaleMessage): void { + const { requestId, metricMode, percentileConfig } = msg; + + const values: number[] = []; + for (let i = 0; i < features.length; i++) { + const v = features[i].properties[metricMode]; + if (typeof v === 'number' && isFinite(v) && v > 0) { + values.push(v); + } + } + + values.sort((a, b) => a - b); + + if (isStale('COMPUTE_COLOR_SCALE', requestId)) return; + + if (values.length > 0) { + const minIndex = Math.min(Math.round(values.length * percentileConfig.minBound), values.length - 1); + const maxIndex = Math.min(Math.round(values.length * percentileConfig.maxBound), values.length - 1); + const min = values[minIndex]; + const max = Math.max(values[maxIndex], min + 1); + + self.postMessage({ + type: 'COLOR_SCALE_RESULT', + requestId, + min, + max, + hasValues: true, + }); + } else { + self.postMessage({ + type: 'COLOR_SCALE_RESULT', + requestId, + min: 0, + max: 1, + hasValues: false, + }); + } +} + +function computeBounds(msg: ComputeBoundsMessage): void { + const { requestId, boundsPercentileConfig } = msg; + + const lngs: number[] = []; + const lats: number[] = []; + for (let i = 0; i < features.length; i++) { + lngs.push(features[i].geometry.coordinates[0]); + lats.push(features[i].geometry.coordinates[1]); + } + + lngs.sort((a, b) => a - b); + lats.sort((a, b) => a - b); + + if (isStale('COMPUTE_BOUNDS', requestId)) return; + + const { clipMin, clipMax } = boundsPercentileConfig; + + self.postMessage({ + type: 'BOUNDS_RESULT', + requestId, + minLng: percentile(lngs, clipMin), + maxLng: percentile(lngs, clipMax), + minLat: percentile(lats, clipMin), + maxLat: percentile(lats, clipMax), + }); +} + +function percentile(arr: number[], p: number): number { + if (arr.length === 0) return 0; + if (p <= 0) return arr[0]; + if (p >= 1) return arr[arr.length - 1]; + + const index = arr.length * p; + const lower = Math.floor(index); + const upper = lower + 1; + const weight = index % 1; + + if (upper >= arr.length) return arr[lower]; + return arr[lower] * (1 - weight) + arr[upper] * weight; +} + +self.onmessage = (e: MessageEvent) => { + const msg = e.data; + latestRequestId[msg.type] = msg.requestId; + + switch (msg.type) { + case 'SET_DATA': { + features = msg.features.map(toFeatureItem); + tree.clear(); + tree.load(features); + + self.postMessage({ + type: 'DATA_READY', + requestId: msg.requestId, + featureCount: features.length, + }); + break; + } + case 'GENERATE_GRID': + generateGrid(msg); + break; + case 'COMPUTE_COLOR_SCALE': + computeColorScale(msg); + break; + case 'COMPUTE_BOUNDS': + computeBounds(msg); + break; + } +}; diff --git a/frontend/src/workers/types.ts b/frontend/src/workers/types.ts new file mode 100644 index 0000000..0f08155 --- /dev/null +++ b/frontend/src/workers/types.ts @@ -0,0 +1,115 @@ +import type { BBox } from 'rbush'; + +// RBush item adapter for GeoJSON Point features +export interface FeatureItem extends BBox { + properties: Record; + geometry: { type: 'Point'; coordinates: [number, number] }; +} + +export function toFeatureItem(feature: { + properties: Record; + geometry: { type: 'Point'; coordinates: [number, number] }; +}): FeatureItem { + const [x, y] = feature.geometry.coordinates; + return { + minX: x, + minY: y, + maxX: x, + maxY: y, + properties: feature.properties, + geometry: feature.geometry, + }; +} + +// --- Main → Worker messages --- + +export interface SetDataMessage { + type: 'SET_DATA'; + requestId: number; + features: Array<{ + properties: Record; + geometry: { type: 'Point'; coordinates: [number, number] }; + }>; +} + +export interface GenerateGridMessage { + type: 'GENERATE_GRID'; + requestId: number; + zoom: number; + bounds: [number, number, number, number]; // [minLng, minLat, maxLng, maxLat] + config: { + cellDensity: number; + spread: number; + intensity: number; + propertyName: string; + }; +} + +export interface ComputeColorScaleMessage { + type: 'COMPUTE_COLOR_SCALE'; + requestId: number; + metricMode: string; + percentileConfig: { minBound: number; maxBound: number }; +} + +export interface ComputeBoundsMessage { + type: 'COMPUTE_BOUNDS'; + requestId: number; + boundsPercentileConfig: { clipMin: number; clipMax: number }; +} + +export type WorkerRequest = + | SetDataMessage + | GenerateGridMessage + | ComputeColorScaleMessage + | ComputeBoundsMessage; + +// --- Worker → Main messages --- + +export interface DataReadyMessage { + type: 'DATA_READY'; + requestId: number; + featureCount: number; +} + +export interface GridResultMessage { + type: 'GRID_RESULT'; + requestId: number; + hexgrid: { + type: 'FeatureCollection'; + features: Array<{ + type: 'Feature'; + geometry: { type: 'Polygon'; coordinates: number[][][] }; + properties: { + count: number; + searchMinX: number; + searchMinY: number; + searchMaxX: number; + searchMaxY: number; + }; + }>; + }; +} + +export interface ColorScaleResultMessage { + type: 'COLOR_SCALE_RESULT'; + requestId: number; + min: number; + max: number; + hasValues: boolean; +} + +export interface BoundsResultMessage { + type: 'BOUNDS_RESULT'; + requestId: number; + minLng: number; + maxLng: number; + minLat: number; + maxLat: number; +} + +export type WorkerResponse = + | DataReadyMessage + | GridResultMessage + | ColorScaleResultMessage + | BoundsResultMessage; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 729c059..77ea20c 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -33,7 +33,6 @@ }, "include": [ "src", - "public/HexgridHeatmap.js", ], "exclude": [ "src/**/__tests__/**", diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index b683588..6d0ac29 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/appsidebar.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/auth/authservice.ts","./src/auth/config.ts","./src/auth/errors.ts","./src/auth/passkeyservice.ts","./src/auth/types.ts","./src/components/activequery.tsx","./src/components/alerterror.tsx","./src/components/authcallback.tsx","./src/components/filterpanel.tsx","./src/components/header.tsx","./src/components/healthindicator.tsx","./src/components/listview.tsx","./src/components/loginmodal.tsx","./src/components/map.tsx","./src/components/poimanager.tsx","./src/components/propertycard.tsx","./src/components/spinner.tsx","./src/components/statsbar.tsx","./src/components/streamingprogressbar.tsx","./src/components/taskindicator.tsx","./src/components/taskprogressdrawer.tsx","./src/components/visualizationcard.tsx","./src/components/ui/datepicker.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/range-slider-field.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tooltip.tsx","./src/constants/colorschemes.ts","./src/constants/index.ts","./src/hooks/use-mobile.ts","./src/hooks/usetaskprogress.ts","./src/lib/utils.ts","./src/services/apiclient.ts","./src/services/healthservice.ts","./src/services/index.ts","./src/services/listingservice.ts","./src/services/poiservice.ts","./src/services/streamingservice.ts","./src/services/taskservice.ts","./src/types/index.ts","./src/utils/maputils.ts","./src/utils/poiutils.ts"],"version":"5.8.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/appsidebar.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/auth/authservice.ts","./src/auth/config.ts","./src/auth/errors.ts","./src/auth/passkeyservice.ts","./src/auth/types.ts","./src/components/activequery.tsx","./src/components/alerterror.tsx","./src/components/authcallback.tsx","./src/components/favoritesview.tsx","./src/components/filterpanel.tsx","./src/components/header.tsx","./src/components/healthindicator.tsx","./src/components/listview.tsx","./src/components/listingdetail.tsx","./src/components/listingdetailsheet.tsx","./src/components/loginmodal.tsx","./src/components/map.tsx","./src/components/mobilebottomsheet.tsx","./src/components/mobilemenu.tsx","./src/components/poimanager.tsx","./src/components/photocarousel.tsx","./src/components/propertycard.tsx","./src/components/propertycardcompact.tsx","./src/components/savedview.tsx","./src/components/spinner.tsx","./src/components/statsbar.tsx","./src/components/streamingprogressbar.tsx","./src/components/swipecard.tsx","./src/components/swipereviewmode.tsx","./src/components/swipeablecardrow.tsx","./src/components/swipeablepropertycard.tsx","./src/components/taskindicator.tsx","./src/components/taskprogressdrawer.tsx","./src/components/visualizationcard.tsx","./src/components/ui/datepicker.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/range-slider-field.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tooltip.tsx","./src/constants/colorschemes.ts","./src/constants/index.ts","./src/hooks/use-mobile.ts","./src/hooks/usedecisions.ts","./src/hooks/uselistingdetail.ts","./src/hooks/usetaskprogress.ts","./src/lib/utils.ts","./src/services/apiclient.ts","./src/services/decisionservice.ts","./src/services/healthservice.ts","./src/services/index.ts","./src/services/listingdetailservice.ts","./src/services/listingservice.ts","./src/services/poiservice.ts","./src/services/streamingservice.ts","./src/services/taskservice.ts","./src/types/index.ts","./src/utils/maputils.ts","./src/utils/poiutils.ts","./src/workers/hexgridheatmapclient.ts","./src/workers/hexgrid.worker.ts","./src/workers/types.ts"],"version":"5.8.3"} \ No newline at end of file