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