geogrid 1.1.1 → 1.2.0

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.1.1",
3
+ "version": "1.2.0",
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,98 @@
1
+ import { createSteppedArray } from "../helpers/createSteppedArray.js";
2
+ import booleanIntersects from "@turf/boolean-intersects";
3
+
4
+ /**
5
+ * @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.
9
+ * @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.
17
+ */
18
+ export function square_sph({
19
+ start = [-180, -90],
20
+ width = 360,
21
+ height = 180,
22
+ step = 1,
23
+ domain = null,
24
+ } = {}) {
25
+ const LON_MIN = -180;
26
+ const LON_MAX = 180;
27
+ const LAT_MIN = -90;
28
+ const LAT_MAX = 90;
29
+ const EPS = 1e-2; // petit décalage pour éviter ±180 et ±90 exacts
30
+
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
+ );
43
+
44
+ const features = [];
45
+ let i = 0;
46
+
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;
54
+
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;
60
+
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],
72
+ ],
73
+ ],
74
+ },
75
+ properties: { index: i++ },
76
+ });
77
+ }
78
+ }
79
+
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
+ });
90
+ }
91
+
92
+ return {
93
+ type: "FeatureCollection",
94
+ grid: "square_sph",
95
+ geo: true,
96
+ features: filtered,
97
+ };
98
+ }
@@ -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,6 +1,8 @@
1
1
  import { bbox } from "@turf/bbox";
2
2
  import { lineSplit } from "@turf/line-split";
3
3
  import { length } from "@turf/length";
4
+ import { stitchmerge } from "../helpers/stitchmerge.js";
5
+ import { unstitch } from "../helpers/unstitch.js";
4
6
  import RBush from "rbush";
5
7
 
6
8
  /**
@@ -18,16 +20,21 @@ import RBush from "rbush";
18
20
  * @property {boolean} [values=false] - Include array of raw lines properties
19
21
  */
20
22
  export function linestogrid(opts = {}) {
21
- const {
23
+ let {
22
24
  grid,
23
25
  lines,
24
- grid_id = "index",
25
26
  var: varField,
26
27
  values: includeValues = false,
28
+ spherical = false,
27
29
  } = opts;
28
30
 
29
31
  const t0 = performance.now();
30
32
 
33
+ // Unstitch grids if needed
34
+ if (spherical) {
35
+ grid = unstitch(grid);
36
+ }
37
+
31
38
  // --- Normalize varField to array ---
32
39
  const varFields = varField
33
40
  ? Array.isArray(varField)
@@ -104,5 +111,5 @@ export function linestogrid(opts = {}) {
104
111
  ).toFixed(2)} ms`
105
112
  );
106
113
 
107
- return filteredGrid;
114
+ return spherical ? stitchmerge(filteredGrid) : filteredGrid;
108
115
  }
@@ -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
- const { points, grid, var: varField, values: includeValues = false } = opts;
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
  }
@@ -1,34 +1,43 @@
1
1
  import { bbox } from "@turf/bbox";
2
2
  import { intersect } from "@turf/intersect";
3
+ import { area } from "@turf/area";
3
4
  import { geoPath } from "d3-geo";
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 polygonstogrid
8
11
  * @description Assign polygons to a grid and compute weighted sums per cell.
9
- * Uses a spatial index to speed up calculations
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 polygon properties
12
+ * Supports planar (path.area) or spherical (Turf area) surface calculation.
13
+ * Optimized and removes cells with count = 0.
14
+ * Supports multiple variables in varField (string or array of strings).
15
+ * Treats undefined or NaN as zero when summing.
16
+ * If `values` is true, stores an array of intersected polygon properties.
14
17
  * @param {object} opts
15
- * @property {object} [polygons] - GeoJSON polygons or multi polygons to assign
18
+ * @property {object} [polygons] - GeoJSON polygons or multipolygons to assign
16
19
  * @property {object} [grid] - GeoJSON grid
17
- * @property {string|Array} [var] - Field(s) ton compute weighted sums (optional)
18
- * @property {boolean} [values=false] - Include array of raw polygons properties
20
+ * @property {string|Array} [var] - Field(s) to compute weighted sums (optional)
21
+ * @property {boolean} [values=false] - Include array of raw polygon properties
22
+ * @property {boolean} [spherical=false] - Compute areas on the sphere (Turf) instead of planar (D3)
19
23
  */
20
24
  export function polygonstogrid(opts = {}) {
21
- const {
25
+ let {
22
26
  grid,
23
27
  polygons,
24
- grid_id = "index",
25
28
  var: varField,
26
29
  values: includeValues = false,
30
+ spherical = false,
27
31
  } = opts;
28
32
 
29
33
  const t0 = performance.now();
30
34
  const path = geoPath();
31
35
 
36
+ // Unstitch grids if needed
37
+ if (spherical) {
38
+ grid = unstitch(grid);
39
+ }
40
+
32
41
  // --- Normalize varField to array ---
33
42
  const varFields = varField
34
43
  ? Array.isArray(varField)
@@ -36,18 +45,18 @@ export function polygonstogrid(opts = {}) {
36
45
  : [varField]
37
46
  : [];
38
47
 
39
- // --- 1. Compute planar areas for polygons and grid cells ---
48
+ // --- 1. Compute areas for polygons and grid cells ---
40
49
  for (const poly of polygons.features) {
41
- poly.properties.area_plan = path.area(poly);
50
+ if (spherical) poly.properties.area_spherical = area(poly);
51
+ else poly.properties.area_plan = path.area(poly);
42
52
  }
53
+
43
54
  for (const cell of grid.features) {
44
- cell.properties.area_plan = path.area(cell);
55
+ if (spherical) cell.properties.area_spherical = area(cell);
56
+ else cell.properties.area_plan = path.area(cell);
45
57
 
46
- // initialize statistics
47
58
  cell.properties.count = 0;
48
- for (const v of varFields) {
49
- cell.properties[v] = 0;
50
- }
59
+ for (const v of varFields) cell.properties[v] = 0;
51
60
  if (includeValues) cell.properties.values = [];
52
61
  }
53
62
 
@@ -67,27 +76,31 @@ export function polygonstogrid(opts = {}) {
67
76
  for (const cand of candidates) {
68
77
  const cell = cand.cell;
69
78
 
79
+ // vérifier que les deux features existent
80
+ if (!poly || !cell) continue;
81
+
82
+ // Turf v7: intersect via FeatureCollection contenant exactement deux géométries
70
83
  const inter = intersect({
71
84
  type: "FeatureCollection",
72
85
  features: [poly, cell],
73
86
  });
74
87
  if (!inter) continue;
75
88
 
76
- const areaPlan = path.area(inter);
77
- const pctAreaPlan = areaPlan / poly.properties.area_plan;
89
+ const areaVal = spherical ? area(inter) : path.area(inter);
90
+ const pctArea =
91
+ areaVal /
92
+ (spherical
93
+ ? poly.properties.area_spherical
94
+ : poly.properties.area_plan);
78
95
 
79
96
  // update cell statistics
80
97
  cell.properties.count += 1;
81
-
82
98
  for (const v of varFields) {
83
99
  const value = parseFloat(poly.properties[v]);
84
- cell.properties[v] += !isNaN(value) ? value * pctAreaPlan : 0;
100
+ cell.properties[v] += !isNaN(value) ? value * pctArea : 0;
85
101
  }
86
102
 
87
- // add properties of intersected polygons
88
- if (includeValues) {
89
- cell.properties.values.push({ ...poly.properties });
90
- }
103
+ if (includeValues) cell.properties.values.push({ ...poly.properties });
91
104
  }
92
105
  }
93
106
 
@@ -104,5 +117,5 @@ export function polygonstogrid(opts = {}) {
104
117
  ).toFixed(2)} ms`
105
118
  );
106
119
 
107
- return filteredGrid;
120
+ return spherical ? stitchmerge(filteredGrid) : filteredGrid;
108
121
  }