geogrid 1.1.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.min.js +1 -1
- package/package.json +8 -2
- package/src/grid/square_sph.js +95 -0
- package/src/helpers/sphere.js +41 -0
- package/src/helpers/stitchmerge.js +144 -0
- package/src/helpers/unstitch.js +31 -0
- package/src/index.js +10 -0
- package/src/operator/linestogrid.js +104 -44
- package/src/operator/pointstogrid.js +15 -2
- package/src/operator/polygonstogrid.js +39 -26
- package/src/operator/linestogrid_save.js +0 -124
- package/src/operator/polygonstogrid_save.js +0 -144
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "geogrid",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "Regular and irregular geoJSON grids ",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"module": "src/index.js",
|
|
@@ -41,20 +41,26 @@
|
|
|
41
41
|
"@turf/boolean-intersects": "^7.2.0",
|
|
42
42
|
"@turf/boolean-point-in-polygon": "^7.1.0",
|
|
43
43
|
"@turf/boolean-within": "^7.2.0",
|
|
44
|
+
"@turf/centroid": "^7.2.0",
|
|
44
45
|
"@turf/helpers": "^7.1.0",
|
|
45
46
|
"@turf/intersect": "^7.1.0",
|
|
46
47
|
"@turf/length": "^7.2.0",
|
|
47
48
|
"@turf/line-split": "^7.2.0",
|
|
49
|
+
"@turf/rewind": "^7.2.0",
|
|
48
50
|
"d3-array": "^3.2.4",
|
|
49
51
|
"d3-delaunay": "^6.0.4",
|
|
50
52
|
"d3-geo": "^3.1.1",
|
|
51
53
|
"d3-geo-projection": "^4.0.0",
|
|
54
|
+
"d3-geo-voronoi": "^2.1.0",
|
|
52
55
|
"docs": "^0.3.2-canary.0",
|
|
53
56
|
"documentation": "^14.0.3",
|
|
54
57
|
"geojson2h3": "^1.2.0",
|
|
55
58
|
"h3-js": "^4.1.0",
|
|
59
|
+
"martinez-polygon-clipping": "^0.7.4",
|
|
56
60
|
"rbush": "^4.0.1",
|
|
57
|
-
"rollup": "^4.10.0"
|
|
61
|
+
"rollup": "^4.10.0",
|
|
62
|
+
"s2-geometry": "^1.2.10",
|
|
63
|
+
"s2js": "^1.43.6"
|
|
58
64
|
},
|
|
59
65
|
"devDependencies": {
|
|
60
66
|
"@jood/jsdoc-theme": "^0.1.14",
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import booleanIntersects from "@turf/boolean-intersects";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @function square_sph
|
|
5
|
+
* @summary Build a global square grid in lon/lat.
|
|
6
|
+
* @description
|
|
7
|
+
* Generates a grid of square polygons covering the whole globe in WGS84 coordinates.
|
|
8
|
+
* - Longitudes: start at -180, add `step` until reaching +180 (last cell may be smaller)
|
|
9
|
+
* - Latitudes: start at +90, subtract `step` until reaching -90 (last cell may be smaller)
|
|
10
|
+
* - Cells never overlap; borders align exactly
|
|
11
|
+
* - EPS is used to avoid coordinates exactly at ±180 and ±90 to prevent rewind/topology issues
|
|
12
|
+
*
|
|
13
|
+
* @param {object} options
|
|
14
|
+
* @param {number} [options.step=1] - Grid cell size in degrees
|
|
15
|
+
* @param {GeoJSON} [options.domain=null] - Optional GeoJSON mask; only intersecting cells are kept
|
|
16
|
+
* @returns {GeoJSON.FeatureCollection} A FeatureCollection of square polygons
|
|
17
|
+
*/
|
|
18
|
+
export function square_sph({ step = 1, domain = null } = {}) {
|
|
19
|
+
const LON_MIN = -180;
|
|
20
|
+
const LON_MAX = 180;
|
|
21
|
+
const LAT_MIN = -90;
|
|
22
|
+
const LAT_MAX = 90;
|
|
23
|
+
const EPS = 1e-2; // small offset to avoid exact ±180/±90 coordinates
|
|
24
|
+
|
|
25
|
+
if (step <= 0) throw new Error("step must be > 0");
|
|
26
|
+
|
|
27
|
+
const features = [];
|
|
28
|
+
let index = 0;
|
|
29
|
+
|
|
30
|
+
// --- Longitudes: from -180 (west) to +180 (east), incrementing by step ---
|
|
31
|
+
let lonWest = LON_MIN;
|
|
32
|
+
while (lonWest < LON_MAX - 1e-12) {
|
|
33
|
+
let lonEast = lonWest + step;
|
|
34
|
+
if (lonEast > LON_MAX) lonEast = LON_MAX;
|
|
35
|
+
|
|
36
|
+
// Apply EPS to avoid exact ±180
|
|
37
|
+
const lonW = Math.max(lonWest, LON_MIN + EPS);
|
|
38
|
+
const lonE = Math.min(lonEast, LON_MAX - EPS);
|
|
39
|
+
|
|
40
|
+
// --- Latitudes: from +90 (north) to -90 (south), decrementing by step ---
|
|
41
|
+
let latNorth = LAT_MAX;
|
|
42
|
+
while (latNorth > LAT_MIN + 1e-12) {
|
|
43
|
+
let latSouth = latNorth - step;
|
|
44
|
+
if (latSouth < LAT_MIN) latSouth = LAT_MIN;
|
|
45
|
+
|
|
46
|
+
// Apply EPS to avoid exact ±90
|
|
47
|
+
const latN = Math.min(latNorth, LAT_MAX - EPS);
|
|
48
|
+
const latS = Math.max(latSouth, LAT_MIN + EPS);
|
|
49
|
+
|
|
50
|
+
// Skip degenerate cells (can happen with EPS near poles)
|
|
51
|
+
if (lonE - lonW > 1e-12 && latN - latS > 1e-12) {
|
|
52
|
+
features.push({
|
|
53
|
+
type: "Feature",
|
|
54
|
+
geometry: {
|
|
55
|
+
type: "Polygon",
|
|
56
|
+
coordinates: [
|
|
57
|
+
[
|
|
58
|
+
[lonW, latN],
|
|
59
|
+
[lonE, latN],
|
|
60
|
+
[lonE, latS],
|
|
61
|
+
[lonW, latS],
|
|
62
|
+
[lonW, latN],
|
|
63
|
+
],
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
properties: { index: index++ },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Move to the next row (southward)
|
|
71
|
+
latNorth = latSouth;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Move to the next column (eastward)
|
|
75
|
+
lonWest = lonEast;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- Optional filtering by domain ---
|
|
79
|
+
const filtered = domain
|
|
80
|
+
? features.filter((f) => {
|
|
81
|
+
try {
|
|
82
|
+
return booleanIntersects(f, domain);
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
: features;
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
type: "FeatureCollection",
|
|
91
|
+
grid: "square_sph",
|
|
92
|
+
geo: true,
|
|
93
|
+
features: filtered,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { polygon, featureCollection } from "@turf/helpers";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Crée un polygone approximatif de la sphère projetée dans le plan SVG
|
|
5
|
+
* et le retourne dans une FeatureCollection
|
|
6
|
+
* @param {d3.GeoProjection} projection - La projection D3 utilisée pour le SVG
|
|
7
|
+
* @param {number} [stepDeg=5] - Pas en degrés pour approximer la sphère
|
|
8
|
+
* @returns {GeoJSON.FeatureCollection} - FeatureCollection contenant le polygone Turf de la sphère projetée
|
|
9
|
+
*/
|
|
10
|
+
export function sphere(projection, stepDeg = 5) {
|
|
11
|
+
const coords = [];
|
|
12
|
+
|
|
13
|
+
// contour supérieur (latitude 90°)
|
|
14
|
+
for (let lon = -180; lon <= 180; lon += stepDeg) {
|
|
15
|
+
const pt = projection([lon, 90]);
|
|
16
|
+
if (pt) coords.push(pt);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// contour droit (longitude 180°)
|
|
20
|
+
for (let lat = 90; lat >= -90; lat -= stepDeg) {
|
|
21
|
+
const pt = projection([180, lat]);
|
|
22
|
+
if (pt) coords.push(pt);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// contour inférieur (latitude -90°)
|
|
26
|
+
for (let lon = 180; lon >= -180; lon -= stepDeg) {
|
|
27
|
+
const pt = projection([lon, -90]);
|
|
28
|
+
if (pt) coords.push(pt);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// contour gauche (longitude -180°)
|
|
32
|
+
for (let lat = -90; lat <= 90; lat += stepDeg) {
|
|
33
|
+
const pt = projection([-180, lat]);
|
|
34
|
+
if (pt) coords.push(pt);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const poly = polygon([coords]);
|
|
38
|
+
|
|
39
|
+
// Retourner une FeatureCollection pour être compatible avec Turf intersect
|
|
40
|
+
return featureCollection([poly]);
|
|
41
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Takes a GeoJSON and processes each MultiPolygon feature that
|
|
3
|
+
* crosses the antimeridian, converting it into a continuous Polygon feature.
|
|
4
|
+
*/
|
|
5
|
+
export function stitchmerge(geojson) {
|
|
6
|
+
// Helper: adjust longitude relative to previous one to handle wrapping around ±180°
|
|
7
|
+
function adjustLonToPrev(lon, prevLon) {
|
|
8
|
+
if (prevLon === null || prevLon === undefined) return lon;
|
|
9
|
+
// Normalize angular difference to be within [-180, 180]
|
|
10
|
+
while (lon - prevLon > 180) lon -= 360;
|
|
11
|
+
while (prevLon - lon > 180) lon += 360;
|
|
12
|
+
return lon;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Helper: remove consecutive duplicate points (with a small tolerance)
|
|
16
|
+
function dedupeConsecutive(points) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (let i = 0; i < points.length; i++) {
|
|
19
|
+
const p = points[i];
|
|
20
|
+
const prev = out.length ? out[out.length - 1] : null;
|
|
21
|
+
if (
|
|
22
|
+
!prev ||
|
|
23
|
+
Math.abs(prev[0] - p[0]) > 1e-12 ||
|
|
24
|
+
Math.abs(prev[1] - p[1]) > 1e-12
|
|
25
|
+
) {
|
|
26
|
+
out.push(p);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Helper: ensure the ring is closed (first == last point)
|
|
33
|
+
function closeRing(ring) {
|
|
34
|
+
if (!ring.length) return ring;
|
|
35
|
+
const first = ring[0];
|
|
36
|
+
const last = ring[ring.length - 1];
|
|
37
|
+
if (
|
|
38
|
+
Math.abs(first[0] - last[0]) > 1e-12 ||
|
|
39
|
+
Math.abs(first[1] - last[1]) > 1e-12
|
|
40
|
+
) {
|
|
41
|
+
ring.push([first[0], first[1]]);
|
|
42
|
+
}
|
|
43
|
+
return ring;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Process one MultiPolygon geometry -> returns a merged Polygon geometry
|
|
47
|
+
function processMultiPolygonGeometry(multiCoords) {
|
|
48
|
+
// multiCoords: Array of polygons; each polygon is an array of rings.
|
|
49
|
+
// We treat the outer ring (index 0) of each polygon as a possible fragment
|
|
50
|
+
// of a single continuous outer ring that needs to be reconnected.
|
|
51
|
+
// Additional rings (holes) are collected and preserved as holes.
|
|
52
|
+
const exteriorRings = [];
|
|
53
|
+
const interiorRings = [];
|
|
54
|
+
|
|
55
|
+
for (let p = 0; p < multiCoords.length; p++) {
|
|
56
|
+
const polygon = multiCoords[p];
|
|
57
|
+
if (!polygon || !polygon.length) continue;
|
|
58
|
+
// polygon[0] is the exterior ring
|
|
59
|
+
exteriorRings.push(polygon[0].slice());
|
|
60
|
+
// if polygon has holes, collect them
|
|
61
|
+
for (let r = 1; r < polygon.length; r++) {
|
|
62
|
+
interiorRings.push(polygon[r].slice());
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Unwrap all exterior rings so that longitudes are continuous
|
|
67
|
+
const unwrappedExteriors = [];
|
|
68
|
+
let prevLon = null;
|
|
69
|
+
for (let i = 0; i < exteriorRings.length; i++) {
|
|
70
|
+
const ring = exteriorRings[i];
|
|
71
|
+
const newRing = [];
|
|
72
|
+
for (let j = 0; j < ring.length; j++) {
|
|
73
|
+
let [lon, lat] = ring[j];
|
|
74
|
+
lon = adjustLonToPrev(lon, prevLon);
|
|
75
|
+
newRing.push([lon, lat]);
|
|
76
|
+
prevLon = lon;
|
|
77
|
+
}
|
|
78
|
+
const trimmed = dedupeConsecutive(newRing);
|
|
79
|
+
unwrappedExteriors.push(trimmed);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Merge all unwrapped exterior rings into a single continuous ring
|
|
83
|
+
// We remove internal closures and only close at the end
|
|
84
|
+
let mergedExterior = [];
|
|
85
|
+
for (let i = 0; i < unwrappedExteriors.length; i++) {
|
|
86
|
+
const ring = unwrappedExteriors[i].slice();
|
|
87
|
+
// remove last point if it's equal to the first (closed ring)
|
|
88
|
+
if (ring.length > 1) {
|
|
89
|
+
const f = ring[0];
|
|
90
|
+
const l = ring[ring.length - 1];
|
|
91
|
+
if (Math.abs(f[0] - l[0]) < 1e-12 && Math.abs(f[1] - l[1]) < 1e-12) {
|
|
92
|
+
ring.pop();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
mergedExterior = mergedExterior.concat(ring);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Deduplicate consecutive identical points
|
|
99
|
+
mergedExterior = dedupeConsecutive(mergedExterior);
|
|
100
|
+
// Close the final merged ring
|
|
101
|
+
mergedExterior = closeRing(mergedExterior);
|
|
102
|
+
|
|
103
|
+
// Unwrap interior rings (holes) so they match the unwrapped longitude system
|
|
104
|
+
const unwrappedInteriors = interiorRings.map((ring) => {
|
|
105
|
+
const out = [];
|
|
106
|
+
for (let i = 0; i < ring.length; i++) {
|
|
107
|
+
const anchorLon = mergedExterior.length ? mergedExterior[0][0] : null;
|
|
108
|
+
let lon = ring[i][0];
|
|
109
|
+
if (anchorLon !== null) {
|
|
110
|
+
while (lon - anchorLon > 180) lon -= 360;
|
|
111
|
+
while (anchorLon - lon > 180) lon += 360;
|
|
112
|
+
}
|
|
113
|
+
out.push([lon, ring[i][1]]);
|
|
114
|
+
}
|
|
115
|
+
return closeRing(dedupeConsecutive(out));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Return a single Polygon geometry: outer + holes
|
|
119
|
+
const coords = [mergedExterior].concat(unwrappedInteriors);
|
|
120
|
+
return { type: "Polygon", coordinates: coords };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// MAIN: process all features
|
|
124
|
+
const out = {
|
|
125
|
+
...geojson,
|
|
126
|
+
features: (geojson.features || []).map((feature) => {
|
|
127
|
+
if (!feature || !feature.geometry) return feature;
|
|
128
|
+
const geom = feature.geometry;
|
|
129
|
+
if (geom.type === "MultiPolygon") {
|
|
130
|
+
try {
|
|
131
|
+
const newGeom = processMultiPolygonGeometry(geom.coordinates);
|
|
132
|
+
return { ...feature, geometry: newGeom };
|
|
133
|
+
} catch (e) {
|
|
134
|
+
console.warn("Error stitching a MultiPolygon:", e);
|
|
135
|
+
return feature; // fallback to original geometry
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Leave other geometries unchanged
|
|
139
|
+
return feature;
|
|
140
|
+
}),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { geoProject } from "d3-geo-projection";
|
|
2
|
+
import { rewind } from "geotoolbox";
|
|
3
|
+
import { geoEquirectangular } from "d3-geo";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @function unstitch
|
|
7
|
+
* @description After Matthieu Viry: https://observablehq.com/@mthh/unstitch
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function unstitch(a) {
|
|
11
|
+
a = JSON.parse(JSON.stringify(a));
|
|
12
|
+
a = geoProject(
|
|
13
|
+
a,
|
|
14
|
+
geoEquirectangular()
|
|
15
|
+
.scale(180 / Math.PI)
|
|
16
|
+
.translate([0, 0])
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
for (const f of a.features) {
|
|
20
|
+
if (f.geometry.type === "Polygon")
|
|
21
|
+
f.geometry.coordinates.forEach((ring) =>
|
|
22
|
+
ring.forEach((point) => (point[1] *= -1))
|
|
23
|
+
);
|
|
24
|
+
else if (f.geometry.type === "MultiPolygon")
|
|
25
|
+
f.geometry.coordinates.forEach((poly) =>
|
|
26
|
+
poly.forEach((ring) => ring.forEach((point) => (point[1] *= -1)))
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return rewind(a, { simple: true });
|
|
31
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
|
+
// Planar grids
|
|
1
2
|
export { square } from "./grid/square.js";
|
|
2
3
|
export { triangle } from "./grid/triangle.js";
|
|
3
4
|
export { dot } from "./grid/dot.js";
|
|
4
5
|
export { random } from "./grid/random.js";
|
|
5
6
|
export { diamond } from "./grid/diamond.js";
|
|
6
7
|
export { hexbin } from "./grid/hexbin.js";
|
|
8
|
+
|
|
9
|
+
// Spherical grids
|
|
10
|
+
export { square_sph } from "./grid/square_sph.js";
|
|
7
11
|
export { h3 } from "./grid/h3.js";
|
|
12
|
+
|
|
13
|
+
// Operators
|
|
8
14
|
export { pointstogrid } from "./operator/pointstogrid.js";
|
|
9
15
|
export { polygonstogrid } from "./operator/polygonstogrid.js";
|
|
10
16
|
export { linestogrid } from "./operator/linestogrid.js";
|
|
17
|
+
|
|
18
|
+
// Helpers
|
|
11
19
|
export { project } from "./helpers/project.js";
|
|
20
|
+
export { unstitch } from "./helpers/unstitch.js";
|
|
21
|
+
export { sphere } from "./helpers/sphere.js";
|
|
@@ -1,54 +1,67 @@
|
|
|
1
1
|
import { bbox } from "@turf/bbox";
|
|
2
2
|
import { lineSplit } from "@turf/line-split";
|
|
3
3
|
import { length } from "@turf/length";
|
|
4
|
+
import { booleanWithin } from "@turf/boolean-within";
|
|
5
|
+
import { stitchmerge } from "../helpers/stitchmerge.js";
|
|
6
|
+
import { unstitch } from "../helpers/unstitch.js";
|
|
4
7
|
import RBush from "rbush";
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* @function linestogrid
|
|
8
|
-
* @description Assign lines to a grid and compute weighted sums per cell.
|
|
9
|
-
*
|
|
10
|
-
* Optimized and removes cells with count = 0.
|
|
11
|
-
* Supports multiple variables in varField (string or array of strings)
|
|
12
|
-
* Treats undefined or NaN as zero when summing
|
|
13
|
-
* If `values` is true, stores an array of intersected line properties
|
|
14
|
-
* @param {object} opts
|
|
15
|
-
* @property {object} [lines] - GeoJSON lines to assign
|
|
16
|
-
* @property {object} [grid] - GeoJSON grid
|
|
17
|
-
* @property {string|Array} [var] - Field(s) to compute weighted sums (optional)
|
|
18
|
-
* @property {boolean} [values=false] - Include array of raw lines properties
|
|
11
|
+
* @description Assign lines (LineString or MultiLineString) to a grid and compute weighted sums per cell.
|
|
12
|
+
* MultiLineStrings are treated as a single feature for count and proportional distribution.
|
|
19
13
|
*/
|
|
14
|
+
|
|
20
15
|
export function linestogrid(opts = {}) {
|
|
21
|
-
|
|
16
|
+
let {
|
|
22
17
|
grid,
|
|
23
18
|
lines,
|
|
24
|
-
grid_id = "index",
|
|
25
19
|
var: varField,
|
|
26
20
|
values: includeValues = false,
|
|
21
|
+
spherical = false,
|
|
22
|
+
debug = false,
|
|
27
23
|
} = opts;
|
|
28
24
|
|
|
29
25
|
const t0 = performance.now();
|
|
30
26
|
|
|
31
|
-
|
|
27
|
+
if (spherical) grid = unstitch(grid);
|
|
28
|
+
|
|
32
29
|
const varFields = varField
|
|
33
30
|
? Array.isArray(varField)
|
|
34
31
|
? varField
|
|
35
32
|
: [varField]
|
|
36
33
|
: [];
|
|
37
34
|
|
|
38
|
-
// ---
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
// --- Add stable ID to grid cells ---
|
|
36
|
+
grid.features.forEach((cell, i) => (cell._id = i));
|
|
37
|
+
|
|
38
|
+
// --- Compute total length per line feature ---
|
|
39
|
+
for (const f of lines.features) {
|
|
40
|
+
if (f.geometry.type === "LineString") {
|
|
41
|
+
f.properties.length_total = length(f, { units: "meters" });
|
|
42
|
+
} else if (f.geometry.type === "MultiLineString") {
|
|
43
|
+
let total = 0;
|
|
44
|
+
for (const coords of f.geometry.coordinates) {
|
|
45
|
+
const part = {
|
|
46
|
+
type: "Feature",
|
|
47
|
+
geometry: { type: "LineString", coordinates: coords },
|
|
48
|
+
};
|
|
49
|
+
total += length(part, { units: "meters" });
|
|
50
|
+
}
|
|
51
|
+
f.properties.length_total = total;
|
|
52
|
+
} else {
|
|
53
|
+
f.properties.length_total = 0;
|
|
54
|
+
}
|
|
41
55
|
}
|
|
42
56
|
|
|
57
|
+
// --- Initialize grid cells ---
|
|
43
58
|
for (const cell of grid.features) {
|
|
44
59
|
cell.properties.count = 0;
|
|
45
|
-
for (const v of varFields)
|
|
46
|
-
cell.properties[v] = 0;
|
|
47
|
-
}
|
|
60
|
+
for (const v of varFields) cell.properties[v] = 0;
|
|
48
61
|
if (includeValues) cell.properties.values = [];
|
|
49
62
|
}
|
|
50
63
|
|
|
51
|
-
// ---
|
|
64
|
+
// --- Build spatial index ---
|
|
52
65
|
const tree = new RBush();
|
|
53
66
|
const items = grid.features.map((cell) => {
|
|
54
67
|
const [minX, minY, maxX, maxY] = bbox(cell);
|
|
@@ -56,53 +69,100 @@ export function linestogrid(opts = {}) {
|
|
|
56
69
|
});
|
|
57
70
|
tree.load(items);
|
|
58
71
|
|
|
59
|
-
// ---
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
// --- Track counted features for debug ---
|
|
73
|
+
const countedLines = new Set();
|
|
74
|
+
|
|
75
|
+
// --- Process each line feature ---
|
|
76
|
+
for (const f of lines.features) {
|
|
77
|
+
const totalLength = f.properties.length_total || 0;
|
|
78
|
+
if (totalLength === 0) continue;
|
|
79
|
+
|
|
80
|
+
const [minX, minY, maxX, maxY] = bbox(f);
|
|
62
81
|
const candidates = tree.search({ minX, minY, maxX, maxY });
|
|
82
|
+
const touched = new Set();
|
|
83
|
+
let totalSegLen = 0;
|
|
63
84
|
|
|
64
85
|
for (const cand of candidates) {
|
|
65
86
|
const cell = cand.cell;
|
|
87
|
+
let segLen = 0;
|
|
88
|
+
|
|
89
|
+
if (f.geometry.type === "LineString") {
|
|
90
|
+
segLen = safeSegLength(f, cell);
|
|
91
|
+
} else if (f.geometry.type === "MultiLineString") {
|
|
92
|
+
for (const coords of f.geometry.coordinates) {
|
|
93
|
+
const part = {
|
|
94
|
+
type: "Feature",
|
|
95
|
+
geometry: { type: "LineString", coordinates: coords },
|
|
96
|
+
};
|
|
97
|
+
segLen += safeSegLength(part, cell);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
66
100
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (!splitLines.features.length) continue;
|
|
101
|
+
if (segLen === 0) continue;
|
|
102
|
+
totalSegLen += segLen;
|
|
70
103
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
totalSegLength += length(seg, { units: "meters" });
|
|
104
|
+
if (!touched.has(cell._id)) {
|
|
105
|
+
cell.properties.count += 1;
|
|
106
|
+
touched.add(cell._id);
|
|
75
107
|
}
|
|
76
|
-
if (totalSegLength === 0) continue;
|
|
77
|
-
|
|
78
|
-
// --- update cell statistics ---
|
|
79
|
-
cell.properties.count += 1; // one line per cell
|
|
80
108
|
|
|
81
109
|
for (const v of varFields) {
|
|
82
|
-
const value = parseFloat(
|
|
110
|
+
const value = parseFloat(f.properties[v]);
|
|
83
111
|
cell.properties[v] += !isNaN(value)
|
|
84
|
-
? value * (
|
|
112
|
+
? value * (segLen / totalLength)
|
|
85
113
|
: 0;
|
|
86
114
|
}
|
|
87
115
|
|
|
88
|
-
if (includeValues) {
|
|
89
|
-
cell.properties.values.push({ ...line.properties });
|
|
90
|
-
}
|
|
116
|
+
if (includeValues) cell.properties.values.push({ ...f.properties });
|
|
91
117
|
}
|
|
118
|
+
|
|
119
|
+
if (totalSegLen > 0) countedLines.add(f.properties.id || f.id || f);
|
|
92
120
|
}
|
|
93
121
|
|
|
94
|
-
// ---
|
|
122
|
+
// --- Filter empty cells ---
|
|
95
123
|
const filteredGrid = {
|
|
96
124
|
...grid,
|
|
97
|
-
features: grid.features.filter((
|
|
125
|
+
features: grid.features.filter((c) => c.properties.count > 0),
|
|
98
126
|
};
|
|
99
127
|
|
|
100
128
|
const t1 = performance.now();
|
|
101
129
|
console.log(
|
|
102
|
-
`Line
|
|
130
|
+
`Line aggregation completed for ${filteredGrid.features.length} cells — ${(
|
|
103
131
|
t1 - t0
|
|
104
132
|
).toFixed(2)} ms`
|
|
105
133
|
);
|
|
106
134
|
|
|
107
|
-
|
|
135
|
+
// --- Debug report ---
|
|
136
|
+
if (debug) {
|
|
137
|
+
const totalLines = lines.features.length;
|
|
138
|
+
const missing = totalLines - countedLines.size;
|
|
139
|
+
console.log(
|
|
140
|
+
`Counted ${countedLines.size}/${totalLines} line features (${missing} missing)`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return spherical ? stitchmerge(filteredGrid) : filteredGrid;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @function safeSegLength
|
|
149
|
+
* @description Returns total segment length of intersection between a line and polygon cell,
|
|
150
|
+
* including case where line is entirely contained in the cell.
|
|
151
|
+
*/
|
|
152
|
+
function safeSegLength(line, cell) {
|
|
153
|
+
try {
|
|
154
|
+
if (booleanWithin(line, cell)) {
|
|
155
|
+
return length(line, { units: "meters" });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const split = lineSplit(line, cell);
|
|
159
|
+
let sum = 0;
|
|
160
|
+
for (const s of split.features) {
|
|
161
|
+
const l = length(s, { units: "meters" });
|
|
162
|
+
if (l > 0.001) sum += l; // ignore sub-mm artifacts
|
|
163
|
+
}
|
|
164
|
+
return sum;
|
|
165
|
+
} catch (e) {
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
108
168
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import RBush from "rbush";
|
|
2
2
|
import { bbox } from "@turf/bbox";
|
|
3
3
|
import booleanPointInPolygon from "@turf/boolean-point-in-polygon";
|
|
4
|
+
import { stitchmerge } from "../helpers/stitchmerge.js";
|
|
5
|
+
import { unstitch } from "../helpers/unstitch.js";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* @function pointstogrid
|
|
@@ -11,12 +13,23 @@ import booleanPointInPolygon from "@turf/boolean-point-in-polygon";
|
|
|
11
13
|
* @property {object} [grid] - GeoJSON grid (polygons)
|
|
12
14
|
* @property {string|Array} [var] - Field(s) for summing values
|
|
13
15
|
* @property {boolean} [values=false] - Include array of raw point properties
|
|
16
|
+
* @property {boolean} [spherical=false] - Use true if you use a spherical coordinate system
|
|
14
17
|
*/
|
|
15
18
|
export function pointstogrid(opts = {}) {
|
|
16
|
-
|
|
19
|
+
let {
|
|
20
|
+
points,
|
|
21
|
+
grid,
|
|
22
|
+
var: varField,
|
|
23
|
+
values: includeValues = false,
|
|
24
|
+
spherical = false,
|
|
25
|
+
} = opts;
|
|
17
26
|
|
|
18
27
|
const t0 = performance.now();
|
|
19
28
|
|
|
29
|
+
if (spherical) {
|
|
30
|
+
grid = unstitch(grid);
|
|
31
|
+
}
|
|
32
|
+
|
|
20
33
|
const gridFeatures = grid.features;
|
|
21
34
|
const pointFeatures = points.features;
|
|
22
35
|
|
|
@@ -105,5 +118,5 @@ export function pointstogrid(opts = {}) {
|
|
|
105
118
|
const t1 = performance.now();
|
|
106
119
|
console.log(`Execution time: ${(t1 - t0).toFixed(2)} ms`);
|
|
107
120
|
|
|
108
|
-
return result;
|
|
121
|
+
return spherical ? stitchmerge(result) : result;
|
|
109
122
|
}
|