Rewrite @turf/isolines (#2918)

* Reimplement @turf/isolines to clear up the licensing concerns around marchingsquares.

---------

Co-authored-by: James Beard <james@smallsaucepan.com>
This commit is contained in:
mfedderly 2025-11-07 22:54:10 -05:00 committed by GitHub
parent e352195aa1
commit f4dea9ca0d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 839 additions and 796 deletions

View File

@ -71,17 +71,5 @@ export default tsEslint.config(
},
},
},
{
files: [
"packages/turf-isobands/lib/marchingsquares-isobands.js",
"packages/turf-isolines/lib/marchingsquares-isocontours.js",
],
languageOptions: {
globals: {
...globals.browser,
},
},
},
prettierRecommended
);

View File

@ -4,7 +4,8 @@
"description": "Takes a grid of values (GeoJSON format) and a set of threshold ranges. It outputs polygons that group areas within those ranges, effectively creating filled contour isobands.",
"author": "Turf Authors",
"contributors": [
"Stefano Borghi <@stebogit>"
"Stefano Borghi <@stebogit>",
"Matt Fedderly <@mfedderly>"
],
"license": "MIT",
"bugs": {

View File

@ -2,8 +2,6 @@ import { bbox } from "@turf/bbox";
import { coordEach } from "@turf/meta";
import { collectionOf } from "@turf/invariant";
import { multiLineString, featureCollection, isObject } from "@turf/helpers";
// @ts-expect-error Legacy JS library with no types defined
import { isoContours } from "marchingsquares";
import { gridToMatrix } from "./lib/grid-to-matrix.js";
import {
FeatureCollection,
@ -11,6 +9,7 @@ import {
MultiLineString,
Feature,
GeoJsonProperties,
Position,
} from "geojson";
/**
@ -70,6 +69,10 @@ function isolines(
// Isoline methods
const matrix = gridToMatrix(pointGrid, { zProperty: zProperty, flip: true });
// A quick note on what 'top' and 'bottom' mean in coordinate system of `matrix`:
// Remember that the southern hemisphere is represented by negative numbers,
// so a matrix Y of 0 is actually the *bottom*, and a Y of dy - 1 is the *top*.
// check that the resulting matrix has consistent x and y dimensions and
// has at least a 2x2 size so that we can actually build grid squares
const dx = matrix[0].length;
@ -122,18 +125,226 @@ function createIsoLines(
const properties = { ...commonProperties, ...breaksProperties[i] };
properties[zProperty] = threshold;
// Pass options to marchingsquares lib to reproduce historical turf
// behaviour.
const isoline = multiLineString(
isoContours(matrix, threshold, { linearRing: false, noFrame: true }),
properties
);
const isoline = multiLineString(isoContours(matrix, threshold), properties);
results.push(isoline);
}
return results;
}
function isoContours(
matrix: ReadonlyArray<ReadonlyArray<number>>,
threshold: number
): Position[][] {
// see https://en.wikipedia.org/wiki/Marching_squares
const segments: [Position, Position][] = [];
const dy = matrix.length;
const dx = matrix[0].length;
for (let y = 0; y < dy - 1; y++) {
for (let x = 0; x < dx - 1; x++) {
const tr = matrix[y + 1][x + 1];
const br = matrix[y][x + 1];
const bl = matrix[y][x];
const tl = matrix[y + 1][x];
let grid =
(tl >= threshold ? 8 : 0) |
(tr >= threshold ? 4 : 0) |
(br >= threshold ? 2 : 0) |
(bl >= threshold ? 1 : 0);
switch (grid) {
case 0:
continue;
case 1:
segments.push([
[x + frac(bl, br), y],
[x, y + frac(bl, tl)],
]);
break;
case 2:
segments.push([
[x + 1, y + frac(br, tr)],
[x + frac(bl, br), y],
]);
break;
case 3:
segments.push([
[x + 1, y + frac(br, tr)],
[x, y + frac(bl, tl)],
]);
break;
case 4:
segments.push([
[x + frac(tl, tr), y + 1],
[x + 1, y + frac(br, tr)],
]);
break;
case 5: {
// use the average of the 4 corners to differentiate the saddle case and correctly honor the counter-clockwise winding
const avg = (tl + tr + br + bl) / 4;
const above = avg >= threshold;
if (above) {
segments.push(
[
[x + frac(tl, tr), y + 1],
[x, y + frac(bl, tl)],
],
[
[x + frac(bl, br), y],
[x + 1, y + frac(br, tr)],
]
);
} else {
segments.push(
[
[x + frac(tl, tr), y + 1],
[x + 1, y + frac(br, tr)],
],
[
[x + frac(bl, br), y],
[x, y + frac(bl, tl)],
]
);
}
break;
}
case 6:
segments.push([
[x + frac(tl, tr), y + 1],
[x + frac(bl, br), y],
]);
break;
case 7:
segments.push([
[x + frac(tl, tr), y + 1],
[x, y + frac(bl, tl)],
]);
break;
case 8:
segments.push([
[x, y + frac(bl, tl)],
[x + frac(tl, tr), y + 1],
]);
break;
case 9:
segments.push([
[x + frac(bl, br), y],
[x + frac(tl, tr), y + 1],
]);
break;
case 10: {
const avg = (tl + tr + br + bl) / 4;
const above = avg >= threshold;
if (above) {
segments.push(
[
[x, y + frac(bl, tl)],
[x + frac(bl, br), y],
],
[
[x + 1, y + frac(br, tr)],
[x + frac(tl, tr), y + 1],
]
);
} else {
segments.push(
[
[x, y + frac(bl, tl)],
[x + frac(tl, tr), y + 1],
],
[
[x + 1, y + frac(br, tr)],
[x + frac(bl, br), y],
]
);
}
break;
}
case 11:
segments.push([
[x + 1, y + frac(br, tr)],
[x + frac(tl, tr), y + 1],
]);
break;
case 12:
segments.push([
[x, y + frac(bl, tl)],
[x + 1, y + frac(br, tr)],
]);
break;
case 13:
segments.push([
[x + frac(bl, br), y],
[x + 1, y + frac(br, tr)],
]);
break;
case 14:
segments.push([
[x, y + frac(bl, tl)],
[x + frac(bl, br), y],
]);
break;
case 15:
// all above
continue;
}
}
}
const contours: Position[][] = [];
while (segments.length > 0) {
const contour: Position[] = [...segments.shift()!];
contours.push(contour);
let found: boolean;
do {
found = false;
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
// add the segment's end point to the end of the contour
if (
segment[0][0] === contour[contour.length - 1][0] &&
segment[0][1] === contour[contour.length - 1][1]
) {
found = true;
contour.push(segment[1]);
segments.splice(i, 1);
break;
}
// add the segment's start point to the start of the contour
if (
segment[1][0] === contour[0][0] &&
segment[1][1] === contour[0][1]
) {
found = true;
contour.unshift(segment[0]);
segments.splice(i, 1);
break;
}
}
} while (found);
}
return contours;
// get the linear interpolation fraction of how far z is between z0 and z1
// See https://github.com/fschutt/marching-squares/blob/master/src/lib.rs
function frac(z0: number, z1: number): number {
if (z0 === z1) {
return 0.5;
}
let t = (threshold - z0) / (z1 - z0);
return t > 1 ? 1 : t < 0 ? 0 : t;
}
}
/**
* Translates and scales isolines
*

View File

@ -4,7 +4,8 @@
"description": "Generate contour lines from a grid of data.",
"author": "Turf Authors",
"contributors": [
"Stefano Borghi <@stebogit>"
"Stefano Borghi <@stebogit>",
"Matt Fedderly <@mfedderly>"
],
"license": "MIT",
"bugs": {
@ -79,7 +80,6 @@
"@turf/invariant": "workspace:*",
"@turf/meta": "workspace:*",
"@types/geojson": "^7946.0.10",
"marchingsquares": "^1.3.3",
"tslib": "^2.8.1"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -3,11 +3,21 @@
"features": [
{
"type": "Feature",
"properties": { "temperature": 2 },
"properties": {
"temperature": 2
},
"geometry": {
"type": "MultiLineString",
"coordinates": [
[
[12.071816, 44.954354],
[11.817453, 44.954354],
[11.56309, 44.954354],
[11.308726, 44.954354],
[11.054363, 44.954354],
[10.863591, 44.819456],
[10.863591, 44.639592],
[10.863591, 44.459728],
[10.863591, 44.279864],
[11.054363, 44.144966],
[11.308726, 44.144966],
@ -18,26 +28,28 @@
[12.262588, 44.459728],
[12.262588, 44.639592],
[12.262588, 44.819456],
[12.071816, 44.954354],
[11.817453, 44.954354],
[11.56309, 44.954354],
[11.308726, 44.954354],
[11.054363, 44.954354],
[10.863591, 44.819456],
[10.863591, 44.639592],
[10.863591, 44.459728],
[10.863591, 44.279864]
[12.071816, 44.954354]
]
]
}
},
{
"type": "Feature",
"properties": { "temperature": 4 },
"properties": {
"temperature": 4
},
"geometry": {
"type": "MultiLineString",
"coordinates": [
[
[12.071816, 44.864422],
[11.817453, 44.864422],
[11.56309, 44.864422],
[11.308726, 44.864422],
[11.054363, 44.864422],
[10.990772, 44.819456],
[10.990772, 44.639592],
[10.990772, 44.459728],
[10.990772, 44.279864],
[11.054363, 44.234898],
[11.308726, 44.234898],
@ -48,57 +60,53 @@
[12.135407, 44.459728],
[12.135407, 44.639592],
[12.135407, 44.819456],
[12.071816, 44.864422],
[11.817453, 44.864422],
[11.56309, 44.864422],
[11.308726, 44.864422],
[11.054363, 44.864422],
[10.990772, 44.819456],
[10.990772, 44.639592],
[10.990772, 44.459728],
[10.990772, 44.279864]
[12.071816, 44.864422]
]
]
}
},
{
"type": "Feature",
"properties": { "temperature": 8 },
"properties": {
"temperature": 8
},
"geometry": {
"type": "MultiLineString",
"coordinates": [
[
[11.817453, 44.765497],
[11.56309, 44.765497],
[11.308726, 44.765497],
[11.130672, 44.639592],
[11.206981, 44.459728],
[11.308726, 44.387783],
[11.56309, 44.387783],
[11.817453, 44.387783],
[11.919198, 44.459728],
[11.995507, 44.639592],
[11.817453, 44.765497],
[11.56309, 44.765497],
[11.308726, 44.765497],
[11.130672, 44.639592],
[11.206981, 44.459728]
[11.817453, 44.765497]
]
]
}
},
{
"type": "Feature",
"properties": { "temperature": 12 },
"properties": {
"temperature": 12
},
"geometry": {
"type": "MultiLineString",
"coordinates": [
[
[11.817453, 44.693551],
[11.56309, 44.693551],
[11.308726, 44.693551],
[11.232417, 44.639592],
[11.308726, 44.531674],
[11.56309, 44.531674],
[11.817453, 44.531674],
[11.893762, 44.639592],
[11.817453, 44.693551],
[11.56309, 44.693551],
[11.308726, 44.693551],
[11.232417, 44.639592]
[11.817453, 44.693551]
]
]
}

View File

@ -14,14 +14,6 @@
"type": "MultiLineString",
"coordinates": [
[
[11.106337, 44.629524],
[11.080704, 44.719456],
[11.106337, 44.809388],
[11.14906, 44.89932],
[11.14906, 45.079184],
[11.362675, 45.229071],
[11.490843, 45.259049],
[11.619012, 45.277035],
[11.747181, 45.259049],
[11.87535, 45.229071],
[12.088964, 45.079184],
@ -38,14 +30,22 @@
[11.362675, 44.209841],
[11.14906, 44.359728],
[11.14906, 44.539592],
[11.106337, 44.629524]
[11.106337, 44.629524],
[11.080704, 44.719456],
[11.106337, 44.809388],
[11.14906, 44.89932],
[11.14906, 45.079184],
[11.362675, 45.229071],
[11.490843, 45.259049],
[11.619012, 45.277035],
[11.747181, 45.259049]
],
[
[11.619012, 44.842091],
[11.444237, 44.719456],
[11.619012, 44.596822],
[11.793788, 44.719456],
[11.619012, 44.842091],
[11.444237, 44.719456]
[11.619012, 44.842091]
]
]
}
@ -63,6 +63,17 @@
"type": "MultiLineString",
"coordinates": [
[
[12.182954, 45.259049],
[12.336757, 45.079184],
[12.336757, 44.89932],
[12.388024, 44.719456],
[12.336757, 44.539592],
[12.336757, 44.359728],
[12.182954, 44.179864],
[12.131687, 44.143891],
[11.87535, 44.035973],
[11.619012, 44],
[11.362675, 44.035973],
[11.106337, 44.143891],
[11.05507, 44.179864],
[10.901267, 44.359728],
@ -76,25 +87,14 @@
[11.619012, 45.438913],
[11.87535, 45.40294],
[12.131687, 45.295021],
[12.182954, 45.259049],
[12.336757, 45.079184],
[12.336757, 44.89932],
[12.388024, 44.719456],
[12.336757, 44.539592],
[12.336757, 44.359728],
[12.182954, 44.179864],
[12.131687, 44.143891],
[11.87535, 44.035973],
[11.619012, 44],
[11.362675, 44.035973],
[11.106337, 44.143891]
[12.182954, 45.259049]
],
[
[11.619012, 44.76851],
[11.549102, 44.719456],
[11.619012, 44.670402],
[11.688922, 44.719456],
[11.619012, 44.76851],
[11.549102, 44.719456]
[11.619012, 44.76851]
]
]
}
@ -115,14 +115,14 @@
[11.080704, 44],
[10.85, 44.161878]
],
[
[10.85, 45.277035],
[11.080704, 45.438913]
],
[
[12.388024, 44.161878],
[12.157321, 44]
],
[
[10.85, 45.277035],
[11.080704, 45.438913]
],
[
[12.157321, 45.438913],
[12.388024, 45.277035]
@ -146,14 +146,14 @@
[10.85, 44],
[10.85, 44]
],
[
[10.85, 45.438913],
[10.85, 45.438913]
],
[
[12.388024, 44],
[12.388024, 44]
],
[
[10.85, 45.438913],
[10.85, 45.438913]
],
[
[12.388024, 45.438913],
[12.388024, 45.438913]

View File

@ -3,7 +3,10 @@
"features": [
{
"type": "Feature",
"properties": { "fill-opacity": 0.5, "population": 20 },
"properties": {
"fill-opacity": 0.5,
"population": 20
},
"geometry": {
"type": "MultiLineString",
"coordinates": [
@ -25,8 +28,9 @@
[-70.645198, -33.553984]
],
[
[-70.769424, -33.443779],
[-70.823364, -33.429795]
[-70.671006, -33.553984],
[-70.715483, -33.518772],
[-70.764237, -33.553984]
],
[
[-70.823364, -33.406542],
@ -34,36 +38,26 @@
[-70.769424, -33.351019],
[-70.742454, -33.374176],
[-70.731216, -33.419128],
[-70.769424, -33.443779]
],
[
[-70.715483, -33.518772],
[-70.764237, -33.553984]
],
[
[-70.671006, -33.553984],
[-70.715483, -33.518772]
],
[
[-70.607603, -33.337216],
[-70.629732, -33.329224]
],
[
[-70.553662, -33.335329],
[-70.607603, -33.337216]
[-70.769424, -33.443779],
[-70.823364, -33.429795]
],
[
[-70.499722, -33.442285],
[-70.514512, -33.419128],
[-70.511237, -33.374176],
[-70.553662, -33.335329]
[-70.553662, -33.335329],
[-70.607603, -33.337216],
[-70.629732, -33.329224]
]
]
}
},
{
"type": "Feature",
"properties": { "fill-opacity": 0.6, "population": 40 },
"properties": {
"fill-opacity": 0.6,
"population": 40
},
"geometry": {
"type": "MultiLineString",
"coordinates": [
@ -74,14 +68,21 @@
[-70.823364, -33.445033]
],
[
[-70.823364, -33.388561],
[-70.80708, -33.374176],
[-70.771237, -33.329224]
],
[
[-70.689933, -33.553984],
[-70.715483, -33.533756],
[-70.743491, -33.553984]
],
[
[-70.499722, -33.468361],
[-70.527801, -33.509032],
[-70.553662, -33.539288],
[-70.607603, -33.54859],
[-70.612506, -33.553984]
],
[
[-70.515455, -33.553984],
[-70.499722, -33.546991]
],
[
[-70.764287, -33.329224],
[-70.715483, -33.370887],
@ -99,43 +100,26 @@
[-70.657394, -33.329224]
],
[
[-70.689933, -33.553984],
[-70.715483, -33.533756]
],
[
[-70.607603, -33.54859],
[-70.612506, -33.553984]
],
[
[-70.553662, -33.539288],
[-70.607603, -33.54859]
],
[
[-70.515455, -33.553984],
[-70.499722, -33.546991]
],
[
[-70.527801, -33.509032],
[-70.553662, -33.539288]
],
[
[-70.499722, -33.468361],
[-70.527801, -33.509032]
[-70.823364, -33.388561],
[-70.80708, -33.374176],
[-70.771237, -33.329224]
]
]
}
},
{
"type": "Feature",
"properties": { "fill-opacity": 0.7, "population": 80 },
"properties": {
"fill-opacity": 0.7,
"population": 80
},
"geometry": {
"type": "MultiLineString",
"coordinates": [
[
[-70.823364, -33.363279],
[-70.789368, -33.329224]
],
[
[-70.553662, -33.368626],
[-70.607603, -33.367184],
[-70.661543, -33.369791],
[-70.666134, -33.374176],
[-70.661543, -33.379976],
[-70.626021, -33.419128],
@ -143,18 +127,25 @@
[-70.581563, -33.419128],
[-70.553662, -33.392157],
[-70.547602, -33.374176],
[-70.553662, -33.368626],
[-70.607603, -33.367184],
[-70.661543, -33.369791],
[-70.666134, -33.374176]
[-70.553662, -33.368626]
],
[
[-70.823364, -33.363279],
[-70.789368, -33.329224]
]
]
}
},
{
"type": "Feature",
"properties": { "fill-opacity": 0.8, "population": 160 },
"geometry": { "type": "MultiLineString", "coordinates": [] }
"properties": {
"fill-opacity": 0.8,
"population": 160
},
"geometry": {
"type": "MultiLineString",
"coordinates": []
}
},
{
"type": "Feature",

3
pnpm-lock.yaml generated
View File

@ -3576,9 +3576,6 @@ importers:
'@types/geojson':
specifier: ^7946.0.10
version: 7946.0.14
marchingsquares:
specifier: ^1.3.3
version: 1.3.3
tslib:
specifier: ^2.8.1
version: 2.8.1