geogrid 1.2.0 → 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 +1 -1
- package/src/grid/square_sph.js +67 -70
- package/src/operator/linestogrid.js +98 -45
package/package.json
CHANGED
package/src/grid/square_sph.js
CHANGED
|
@@ -1,94 +1,91 @@
|
|
|
1
|
-
import { createSteppedArray } from "../helpers/createSteppedArray.js";
|
|
2
1
|
import booleanIntersects from "@turf/boolean-intersects";
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* @function square_sph
|
|
6
|
-
* @summary
|
|
7
|
-
* @description
|
|
8
|
-
*
|
|
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
|
+
*
|
|
9
13
|
* @param {object} options
|
|
10
|
-
* @param {number
|
|
11
|
-
* @param {
|
|
12
|
-
* @
|
|
13
|
-
* @param {number} [options.step=1] - Step size in degrees.
|
|
14
|
-
* @param {GeoJSON.Feature|GeoJSON.FeatureCollection|GeoJSON.Geometry} [options.domain] -
|
|
15
|
-
* Optional GeoJSON object. Only cells that intersect this domain are kept.
|
|
16
|
-
* @returns {GeoJSON.FeatureCollection} A GeoJSON FeatureCollection of polygons.
|
|
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
17
|
*/
|
|
18
|
-
export function square_sph({
|
|
19
|
-
start = [-180, -90],
|
|
20
|
-
width = 360,
|
|
21
|
-
height = 180,
|
|
22
|
-
step = 1,
|
|
23
|
-
domain = null,
|
|
24
|
-
} = {}) {
|
|
18
|
+
export function square_sph({ step = 1, domain = null } = {}) {
|
|
25
19
|
const LON_MIN = -180;
|
|
26
20
|
const LON_MAX = 180;
|
|
27
21
|
const LAT_MIN = -90;
|
|
28
22
|
const LAT_MAX = 90;
|
|
29
|
-
const EPS = 1e-2; //
|
|
23
|
+
const EPS = 1e-2; // small offset to avoid exact ±180/±90 coordinates
|
|
30
24
|
|
|
31
|
-
|
|
32
|
-
const latStart = Math.max(LAT_MIN, start[1]);
|
|
33
|
-
const lonEnd = Math.min(LON_MAX, start[0] + width);
|
|
34
|
-
const latEnd = Math.min(LAT_MAX, start[1] + height);
|
|
35
|
-
|
|
36
|
-
let x = createSteppedArray(lonStart + step / 2, lonEnd - step / 2, step);
|
|
37
|
-
let y = createSteppedArray(
|
|
38
|
-
latStart + step / 2,
|
|
39
|
-
latEnd - step / 2,
|
|
40
|
-
step,
|
|
41
|
-
true
|
|
42
|
-
);
|
|
25
|
+
if (step <= 0) throw new Error("step must be > 0");
|
|
43
26
|
|
|
44
27
|
const features = [];
|
|
45
|
-
let
|
|
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;
|
|
46
35
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
let lonWest = lon - step / 2;
|
|
51
|
-
let lonEast = lon + step / 2;
|
|
52
|
-
let latSouth = lat - step / 2;
|
|
53
|
-
let latNorth = lat + step / 2;
|
|
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);
|
|
54
39
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (
|
|
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;
|
|
60
45
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
+
],
|
|
72
64
|
],
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
65
|
+
},
|
|
66
|
+
properties: { index: index++ },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Move to the next row (southward)
|
|
71
|
+
latNorth = latSouth;
|
|
77
72
|
}
|
|
78
|
-
}
|
|
79
73
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (domain) {
|
|
83
|
-
filtered = features.filter((f) => {
|
|
84
|
-
try {
|
|
85
|
-
return booleanIntersects(f, domain);
|
|
86
|
-
} catch {
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
});
|
|
74
|
+
// Move to the next column (eastward)
|
|
75
|
+
lonWest = lonEast;
|
|
90
76
|
}
|
|
91
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
|
+
|
|
92
89
|
return {
|
|
93
90
|
type: "FeatureCollection",
|
|
94
91
|
grid: "square_sph",
|
|
@@ -1,24 +1,17 @@
|
|
|
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";
|
|
4
5
|
import { stitchmerge } from "../helpers/stitchmerge.js";
|
|
5
6
|
import { unstitch } from "../helpers/unstitch.js";
|
|
6
7
|
import RBush from "rbush";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* @function linestogrid
|
|
10
|
-
* @description Assign lines to a grid and compute weighted sums per cell.
|
|
11
|
-
*
|
|
12
|
-
* Optimized and removes cells with count = 0.
|
|
13
|
-
* Supports multiple variables in varField (string or array of strings)
|
|
14
|
-
* Treats undefined or NaN as zero when summing
|
|
15
|
-
* If `values` is true, stores an array of intersected line properties
|
|
16
|
-
* @param {object} opts
|
|
17
|
-
* @property {object} [lines] - GeoJSON lines to assign
|
|
18
|
-
* @property {object} [grid] - GeoJSON grid
|
|
19
|
-
* @property {string|Array} [var] - Field(s) to compute weighted sums (optional)
|
|
20
|
-
* @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.
|
|
21
13
|
*/
|
|
14
|
+
|
|
22
15
|
export function linestogrid(opts = {}) {
|
|
23
16
|
let {
|
|
24
17
|
grid,
|
|
@@ -26,36 +19,49 @@ export function linestogrid(opts = {}) {
|
|
|
26
19
|
var: varField,
|
|
27
20
|
values: includeValues = false,
|
|
28
21
|
spherical = false,
|
|
22
|
+
debug = false,
|
|
29
23
|
} = opts;
|
|
30
24
|
|
|
31
25
|
const t0 = performance.now();
|
|
32
26
|
|
|
33
|
-
|
|
34
|
-
if (spherical) {
|
|
35
|
-
grid = unstitch(grid);
|
|
36
|
-
}
|
|
27
|
+
if (spherical) grid = unstitch(grid);
|
|
37
28
|
|
|
38
|
-
// --- Normalize varField to array ---
|
|
39
29
|
const varFields = varField
|
|
40
30
|
? Array.isArray(varField)
|
|
41
31
|
? varField
|
|
42
32
|
: [varField]
|
|
43
33
|
: [];
|
|
44
34
|
|
|
45
|
-
// ---
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
}
|
|
48
55
|
}
|
|
49
56
|
|
|
57
|
+
// --- Initialize grid cells ---
|
|
50
58
|
for (const cell of grid.features) {
|
|
51
59
|
cell.properties.count = 0;
|
|
52
|
-
for (const v of varFields)
|
|
53
|
-
cell.properties[v] = 0;
|
|
54
|
-
}
|
|
60
|
+
for (const v of varFields) cell.properties[v] = 0;
|
|
55
61
|
if (includeValues) cell.properties.values = [];
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
// ---
|
|
64
|
+
// --- Build spatial index ---
|
|
59
65
|
const tree = new RBush();
|
|
60
66
|
const items = grid.features.map((cell) => {
|
|
61
67
|
const [minX, minY, maxX, maxY] = bbox(cell);
|
|
@@ -63,53 +69,100 @@ export function linestogrid(opts = {}) {
|
|
|
63
69
|
});
|
|
64
70
|
tree.load(items);
|
|
65
71
|
|
|
66
|
-
// ---
|
|
67
|
-
|
|
68
|
-
|
|
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);
|
|
69
81
|
const candidates = tree.search({ minX, minY, maxX, maxY });
|
|
82
|
+
const touched = new Set();
|
|
83
|
+
let totalSegLen = 0;
|
|
70
84
|
|
|
71
85
|
for (const cand of candidates) {
|
|
72
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
|
+
}
|
|
73
100
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (!splitLines.features.length) continue;
|
|
101
|
+
if (segLen === 0) continue;
|
|
102
|
+
totalSegLen += segLen;
|
|
77
103
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
totalSegLength += length(seg, { units: "meters" });
|
|
104
|
+
if (!touched.has(cell._id)) {
|
|
105
|
+
cell.properties.count += 1;
|
|
106
|
+
touched.add(cell._id);
|
|
82
107
|
}
|
|
83
|
-
if (totalSegLength === 0) continue;
|
|
84
|
-
|
|
85
|
-
// --- update cell statistics ---
|
|
86
|
-
cell.properties.count += 1; // one line per cell
|
|
87
108
|
|
|
88
109
|
for (const v of varFields) {
|
|
89
|
-
const value = parseFloat(
|
|
110
|
+
const value = parseFloat(f.properties[v]);
|
|
90
111
|
cell.properties[v] += !isNaN(value)
|
|
91
|
-
? value * (
|
|
112
|
+
? value * (segLen / totalLength)
|
|
92
113
|
: 0;
|
|
93
114
|
}
|
|
94
115
|
|
|
95
|
-
if (includeValues) {
|
|
96
|
-
cell.properties.values.push({ ...line.properties });
|
|
97
|
-
}
|
|
116
|
+
if (includeValues) cell.properties.values.push({ ...f.properties });
|
|
98
117
|
}
|
|
118
|
+
|
|
119
|
+
if (totalSegLen > 0) countedLines.add(f.properties.id || f.id || f);
|
|
99
120
|
}
|
|
100
121
|
|
|
101
|
-
// ---
|
|
122
|
+
// --- Filter empty cells ---
|
|
102
123
|
const filteredGrid = {
|
|
103
124
|
...grid,
|
|
104
|
-
features: grid.features.filter((
|
|
125
|
+
features: grid.features.filter((c) => c.properties.count > 0),
|
|
105
126
|
};
|
|
106
127
|
|
|
107
128
|
const t1 = performance.now();
|
|
108
129
|
console.log(
|
|
109
|
-
`Line
|
|
130
|
+
`Line aggregation completed for ${filteredGrid.features.length} cells — ${(
|
|
110
131
|
t1 - t0
|
|
111
132
|
).toFixed(2)} ms`
|
|
112
133
|
);
|
|
113
134
|
|
|
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
|
+
|
|
114
144
|
return spherical ? stitchmerge(filteredGrid) : filteredGrid;
|
|
115
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
|
+
}
|
|
168
|
+
}
|