Skip to content

Commit 15dd77a

Browse files
committed
Correct polygon winding directions
1 parent 597ae5c commit 15dd77a

File tree

1 file changed

+110
-117
lines changed

1 file changed

+110
-117
lines changed

tasks/topojson/process_geodata.mjs

Lines changed: 110 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1+
import rewind from '@mapbox/geojson-rewind'
12
import { geoIdentity, geoPath } from 'd3-geo';
2-
import { geoStitch } from 'd3-geo-projection'
33
import fs from 'fs';
44
import mapshaper from 'mapshaper';
55
import path from 'path';
6-
import config, { getNEFilename } from './config.mjs';
7-
import { topology } from 'topojson-server';
86
import topojsonLib from 'topojson';
9-
import rewind from '@mapbox/geojson-rewind'
7+
import config, { getNEFilename } from './config.mjs';
108

119
const { filters, inputDir, layers, resolutions, scopes, unFilename, vectors } = config;
1210

@@ -32,19 +30,6 @@ function getJsonFile(filename) {
3230
}
3331
}
3432

35-
async function createCountriesLayer({ bounds, filter, name, resolution, source }) {
36-
const inputFilePath = `${outputDirGeojson}/${unFilename}_${resolution}m/${source}.geojson`;
37-
const outputFilePath = `${outputDirGeojson}/${name}_${resolution}m/countries.geojson`;
38-
const commands = [
39-
inputFilePath,
40-
bounds.length ? `-clip bbox=${bounds.join(',')}` : '',
41-
filter ? `-filter '${filter}'` : '',
42-
`-o ${outputFilePath}`
43-
].join(' ');
44-
await mapshaper.runCommands(commands);
45-
addCentroidsToGeojson(outputFilePath);
46-
}
47-
4833
function addCentroidsToGeojson(geojsonPath) {
4934
const geojson = getJsonFile(geojsonPath);
5035
if (!geojson.features) return;
@@ -59,6 +44,100 @@ function addCentroidsToGeojson(geojsonPath) {
5944
fs.writeFileSync(geojsonPath, JSON.stringify({ ...geojson, features }));
6045
}
6146

47+
// Wind the polygon rings in the correct direction to indicate what is solid and what is whole
48+
const rewindGeojson = (geojson, clockwise = true) => rewind(geojson, clockwise)
49+
50+
// Snap x-coordinates that are close to be on the antimeridian
51+
function snapToAntimeridian(inputFilepath, outputFilepath) {
52+
outputFilepath ||= inputFilepath
53+
const jsonString = fs.readFileSync(inputFilepath, 'utf8')
54+
const updatedString = jsonString
55+
.replaceAll(/179\.99\d+,/g, '180,')
56+
.replaceAll(/180\.00\d+,/g, '180,')
57+
58+
fs.writeFileSync(outputFilepath, updatedString);
59+
}
60+
61+
function pruneProperties(topojson) {
62+
for (const layer in topojson.objects) {
63+
switch (layer) {
64+
case 'countries':
65+
topojson.objects[layer].geometries = topojson.objects[layer].geometries.map((geometry) => {
66+
const { properties } = geometry;
67+
if (properties) {
68+
geometry.id = properties.iso3cd;
69+
geometry.properties = {
70+
ct: properties.ct
71+
};
72+
}
73+
74+
return geometry;
75+
});
76+
break;
77+
case 'subunits':
78+
topojson.objects[layer].geometries = topojson.objects[layer].geometries.map((geometry) => {
79+
const { properties } = geometry;
80+
if (properties) {
81+
geometry.id = properties.postal;
82+
geometry.properties = {
83+
ct: properties.ct,
84+
gu: properties.gu_a3
85+
};
86+
}
87+
88+
return geometry;
89+
});
90+
91+
break;
92+
default:
93+
topojson.objects[layer].geometries = topojson.objects[layer].geometries.map((geometry) => {
94+
delete geometry.id;
95+
delete geometry.properties;
96+
97+
return geometry;
98+
});
99+
100+
break;
101+
}
102+
}
103+
104+
return topojson;
105+
}
106+
107+
function getCentroid(feature) {
108+
const { type } = feature.geometry;
109+
const projection = geoIdentity();
110+
const path = geoPath(projection);
111+
112+
if (type === 'MultiPolygon') {
113+
let maxArea = -Infinity;
114+
115+
for (const coordinates of feature.geometry.coordinates) {
116+
const polygon = { type: 'Polygon', coordinates };
117+
const area = path.area(polygon);
118+
if (area > maxArea) {
119+
maxArea = area;
120+
feature = polygon;
121+
}
122+
}
123+
}
124+
125+
return path.centroid(feature).map((coord) => +coord.toFixed(2));
126+
}
127+
128+
async function createCountriesLayer({ bounds, filter, name, resolution, source }) {
129+
const inputFilePath = `${outputDirGeojson}/${unFilename}_${resolution}m/${source}.geojson`;
130+
const outputFilePath = `${outputDirGeojson}/${name}_${resolution}m/countries.geojson`;
131+
const commands = [
132+
inputFilePath,
133+
bounds.length ? `-clip bbox=${bounds.join(',')}` : '',
134+
filter ? `-filter '${filter}'` : '',
135+
`-o ${outputFilePath}`
136+
].join(' ');
137+
await mapshaper.runCommands(commands);
138+
addCentroidsToGeojson(outputFilePath);
139+
}
140+
62141
async function createLandLayer({ bounds, name, resolution, source }) {
63142
const inputFilePath = `${outputDirGeojson}/${name}_${resolution}m/countries.geojson`;
64143
const outputFilePath = `${outputDirGeojson}/${name}_${resolution}m/land.geojson`;
@@ -80,9 +159,12 @@ async function createCoastlinesLayer({ bounds, name, resolution, source }) {
80159
'-dissolve',
81160
'-lines',
82161
bounds.length ? `-clip bbox=${bounds.join(',')}` : '',
162+
// Erase outer lines to avoid unpleasant lines through polygons crossing the antimeridian
163+
['antarctica', 'world'].includes(name) ? '-clip bbox=-179.999,-89.999,179.999,89.999' : '',
83164
`-o ${outputFilePath}`
84165
].join(' ');
85166
await mapshaper.runCommands(commands);
167+
if (['antarctica', 'world'].includes(name)) snapToAntimeridian(outputFilePath)
86168
}
87169

88170
async function createOceanLayer({ bounds, name, resolution, source }) {
@@ -133,96 +215,19 @@ async function createSubunitsLayer({ name, resolution, source }) {
133215
addCentroidsToGeojson(outputFilePath);
134216
}
135217

136-
function pruneProperties(topojson) {
137-
for (const layer in topojson.objects) {
138-
switch (layer) {
139-
case 'countries':
140-
topojson.objects[layer].geometries = topojson.objects[layer].geometries.map((geometry) => {
141-
const { properties } = geometry;
142-
if (properties) {
143-
geometry.id = properties.iso3cd;
144-
geometry.properties = {
145-
ct: properties.ct
146-
};
147-
}
148-
149-
return geometry;
150-
});
151-
break;
152-
case 'subunits':
153-
topojson.objects[layer].geometries = topojson.objects[layer].geometries.map((geometry) => {
154-
const { properties } = geometry;
155-
if (properties) {
156-
geometry.id = properties.postal;
157-
geometry.properties = {
158-
ct: properties.ct,
159-
gu: properties.gu_a3
160-
};
161-
}
162-
163-
return geometry;
164-
});
165-
166-
break;
167-
default:
168-
topojson.objects[layer].geometries = topojson.objects[layer].geometries.map((geometry) => {
169-
delete geometry.id;
170-
delete geometry.properties;
171-
172-
return geometry;
173-
});
174-
175-
break;
176-
}
177-
}
178-
179-
return topojson;
180-
}
181-
182-
function getCentroid(feature) {
183-
const { type } = feature.geometry;
184-
const projection = geoIdentity();
185-
const path = geoPath(projection);
186-
187-
if (type === 'MultiPolygon') {
188-
let maxArea = -Infinity;
189-
190-
for (const coordinates of feature.geometry.coordinates) {
191-
const polygon = { type: 'Polygon', coordinates };
192-
const area = path.area(polygon);
193-
if (area > maxArea) {
194-
maxArea = area;
195-
feature = polygon;
196-
}
197-
}
198-
}
199-
200-
return path.centroid(feature).map((coord) => +coord.toFixed(2));
201-
}
202-
203218
async function convertLayersToTopojson({ name, resolution }) {
204219
const regionDir = path.join(outputDirGeojson, `${name}_${resolution}m`);
205220
if (!fs.existsSync(regionDir)) return;
206221

207222
const outputFile = `${outputDirTopojson}/${name}_${resolution}m.json`;
223+
// Scopes with polygons that cross the antimeridian need to be stitched (via the topology call)
208224
if (["antarctica", "world"].includes(name)) {
209-
// if (false) {
210-
const files = fs.readdirSync(regionDir)
211225
const geojsonObjects = {}
212-
for (const file of files) {
213-
const filePath = path.join(regionDir, file)
214-
const layer = file.split(".")[0]
215-
let stitchedGeojson = geoStitch(getJsonFile(filePath))
216-
// stitchedGeojson = rewind(stitchedGeojson, true)
217-
// fs.writeFileSync(filePath, JSON.stringify(stitchedGeojson));
218-
geojsonObjects[layer] = stitchedGeojson
219-
// geojsonObjects[layer] = getJsonFile(filePath)
226+
for (const layer of Object.keys(config.layers)) {
227+
const filePath = path.join(regionDir, `${layer}.geojson`)
228+
geojsonObjects[layer] = rewindGeojson(getJsonFile(filePath))
220229
}
221-
const topojsonTopology = topology(geojsonObjects)
222-
// const topojsonTopology = topojsonLib.topology(geojsonObjects, {
223-
// verbose: true,
224-
// 'property-transform': f => f.properties
225-
// })
230+
const topojsonTopology = topojsonLib.topology(geojsonObjects, { 'property-transform': f => f.properties })
226231
fs.writeFileSync(outputFile, JSON.stringify(topojsonTopology));
227232
} else {
228233
// Layer names default to file names
@@ -231,28 +236,17 @@ async function convertLayersToTopojson({ name, resolution }) {
231236
}
232237

233238
// Remove extra information from features
234-
// const topojson = getJsonFile(outputFile);
235-
// const prunedTopojson = pruneProperties(topojson);
236-
// fs.writeFileSync(outputFile, JSON.stringify(prunedTopojson));
239+
const topojson = getJsonFile(outputFile);
240+
const prunedTopojson = pruneProperties(topojson);
241+
fs.writeFileSync(outputFile, JSON.stringify(prunedTopojson));
237242
}
238243

239244
// Get polygon features from UN GeoJSON and patch Antarctica gap
240245
const inputFilePathUNGeojson = `${inputDir}/${unFilename}.geojson`;
241246
const inputFilePathUNGeojsonCleaned = `${inputDir}/${unFilename}_cleaned.geojson`;
242-
// TODO: Update all x-coords close to 180 to be exactly 180
243-
function snapToAntimeridian(inputFilepath, outputFilepath) {
244-
const jsonString = fs.readFileSync(inputFilepath, 'utf8')
245-
const updatedString = jsonString
246-
.replaceAll(/179\.99\d+,/g, '180,')
247-
.replaceAll(/180\.00\d+,/g, '180,')
248-
249-
fs.writeFileSync(outputFilepath, updatedString);
250-
}
251247
snapToAntimeridian(inputFilePathUNGeojson, inputFilePathUNGeojsonCleaned)
252248
const commandsAllFeaturesCommon = [
253-
// TODO: Should I use the cleaned data or leave as is?
254249
inputFilePathUNGeojsonCleaned,
255-
// inputFilePathUNGeojson,
256250
`-filter 'iso3cd === "ATA"' target=1 + name=antarctica`,
257251
// Use 'snap-interval' to patch gap in Antarctica
258252
'-clean snap-interval=0.015 target=antarctica',
@@ -269,7 +263,8 @@ const commandsAllFeaturesCommon = [
269263
'-erase source=caspian_sea target=all_features',
270264
// Update country codes for disputed territories at Egypt/Sudan border: https://en.wikipedia.org/wiki/Egypt%E2%80%93Sudan_border
271265
`-each 'if (globalid === "{CA12D116-7A19-41D1-9622-17C12CCC720D}") iso3cd = "XHT"'`, // Halaib Triangle
272-
`-each 'if (globalid === "{9FD54A50-0BFB-4385-B342-1C3BDEE5ED9B}") iso3cd = "XBT"'` // Bir Tawil
266+
`-each 'if (globalid === "{9FD54A50-0BFB-4385-B342-1C3BDEE5ED9B}") iso3cd = "XBT"'`, // Bir Tawil
267+
`-each 'FID = iso3cd'`
273268
]
274269

275270
// Process 50m UN geodata
@@ -305,13 +300,11 @@ await mapshaper.runCommands(commandsLand50m);
305300
const inputFilePath110m = outputFilePath50m;
306301
const outputFilePath110m = `${outputDirGeojson}/${unFilename}_110m/all_features.geojson`;
307302
const commandsAllFeatures110m = [
308-
// ...commandsAllFeaturesCommon,
309303
inputFilePath110m,
310-
'-simplify 10% rdp',
304+
'-simplify 20%',
311305
`-o target=1 ${outputFilePath110m}`
312306
].join(" ")
313307
await mapshaper.runCommands(commandsAllFeatures110m);
314-
console.log(commandsAllFeatures110m)
315308

316309
// Get countries from all polygon features
317310
const inputFilePathCountries110m = outputFilePath110m;

0 commit comments

Comments
 (0)