geogrid 1.2.0 → 1.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "geogrid",
3
- "version": "1.2.0",
3
+ "version": "1.2.4",
4
4
  "description": "Regular and irregular geoJSON grids ",
5
5
  "main": "src/index.js",
6
6
  "module": "src/index.js",
@@ -52,9 +52,8 @@
52
52
  "d3-geo": "^3.1.1",
53
53
  "d3-geo-projection": "^4.0.0",
54
54
  "d3-geo-voronoi": "^2.1.0",
55
- "docs": "^0.3.2-canary.0",
56
- "documentation": "^14.0.3",
57
- "geojson2h3": "^1.2.0",
55
+ "geojson2h3": "1.2.0",
56
+ "geotoolbox": "^3.2.0",
58
57
  "h3-js": "^4.1.0",
59
58
  "martinez-polygon-clipping": "^0.7.4",
60
59
  "rbush": "^4.0.1",
@@ -63,11 +62,13 @@
63
62
  "s2js": "^1.43.6"
64
63
  },
65
64
  "devDependencies": {
65
+ "@babel/preset-env": "^7.29.2",
66
66
  "@jood/jsdoc-theme": "^0.1.14",
67
+ "@rollup/plugin-alias": "^6.0.0",
67
68
  "@rollup/plugin-babel": "^6.0.4",
68
69
  "@rollup/plugin-commonjs": "^25.0.7",
69
70
  "@rollup/plugin-node-resolve": "^15.2.3",
70
- "@rollup/plugin-terser": "^0.4.3",
71
+ "@rollup/plugin-terser": "^1.0.0",
71
72
  "clean-jsdoc-theme": "^4.2.17",
72
73
  "docdash": "^2.0.1",
73
74
  "jsdoc": "^4.0.2",
@@ -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 Compute a square grid in WGS84 degrees.
7
- * @description Builds a square grid in latitude/longitude degrees,
8
- * avoids coordinates exactly at ±180 or ±90 to prevent rewind issues.
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[]} [options.start=[-180, -90]] - Starting coordinates [lon, lat].
11
- * @param {number} [options.width=360] - Width of the grid in degrees (longitude span).
12
- * @param {number} [options.height=180] - Height of the grid in degrees (latitude span).
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; // petit décalage pour éviter ±180 et ±90 exacts
23
+ const EPS = 1e-2; // small offset to avoid exact ±18090 coordinates
30
24
 
31
- const lonStart = Math.max(LON_MIN, start[0]);
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 i = 0;
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
- for (const lon of x) {
48
- for (const lat of y) {
49
- // coins initiaux
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
- // remplacer ±180 et ±90 par des valeurs légèrement inférieures/supérieures
56
- if (lonWest <= LON_MIN) lonWest = LON_MIN + EPS;
57
- if (lonEast >= LON_MAX) lonEast = LON_MAX - EPS;
58
- if (latSouth <= LAT_MIN) latSouth = LAT_MIN + EPS;
59
- if (latNorth >= LAT_MAX) latNorth = LAT_MAX - EPS;
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
- features.push({
62
- type: "Feature",
63
- geometry: {
64
- type: "Polygon",
65
- coordinates: [
66
- [
67
- [lonWest, latNorth],
68
- [lonEast, latNorth],
69
- [lonEast, latSouth],
70
- [lonWest, latSouth],
71
- [lonWest, latNorth],
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
- properties: { index: i++ },
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
- // Domain
81
- let filtered = features;
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
- * Uses a spatial index to speed up calculations.
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
- // Unstitch grids if needed
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
- // --- 1. Compute total lengths for lines and initialize grid cells ---
46
- for (const line of lines.features) {
47
- line.properties.length_total = length(line, { units: "meters" });
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
- // --- 2. Build spatial index (RBush) on the grid ---
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
- // --- 3. Loop over lines ---
67
- for (const line of lines.features) {
68
- const [minX, minY, maxX, maxY] = bbox(line);
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
- // split line by cell polygon
75
- const splitLines = lineSplit(line, cell);
76
- if (!splitLines.features.length) continue;
101
+ if (segLen === 0) continue;
102
+ totalSegLen += segLen;
77
103
 
78
- // compute total length of segments inside the cell
79
- let totalSegLength = 0;
80
- for (const seg of splitLines.features) {
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(line.properties[v]);
110
+ const value = parseFloat(f.properties[v]);
90
111
  cell.properties[v] += !isNaN(value)
91
- ? value * (totalSegLength / line.properties.length_total)
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
- // --- 4. Filter out cells with count == 0 ---
122
+ // --- Filter empty cells ---
102
123
  const filteredGrid = {
103
124
  ...grid,
104
- features: grid.features.filter((cell) => cell.properties.count > 0),
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 intersection completed for ${filteredGrid.features.length} cells — ${(
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
+ }