Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

feat: Switch geodata providers #7393

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 47 commits into
base: master
Choose a base branch
Loading
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
e8d763d
Add new dev dependencies
camdecoster Mar 20, 2025
7f7b690
Add build scripts
camdecoster Mar 20, 2025
6b596c0
Update some topojson references
camdecoster Mar 20, 2025
353bf94
Add npm build scripts
camdecoster Mar 20, 2025
fc6022f
Add 'usa' config
camdecoster Mar 20, 2025
2910eea
Add clipping bounds for Antarctica
camdecoster Mar 20, 2025
5d66dad
Apply suggestions from code review
camdecoster Mar 20, 2025
3b47c67
Add geodata archive, update get_geodata script paths
camdecoster Mar 21, 2025
b434656
Switch config file to JS
camdecoster Mar 21, 2025
a4d0b21
Switch coastlines layer source
camdecoster Mar 21, 2025
e257b7b
Add Oceania clipping bounds
camdecoster Mar 21, 2025
1154ff1
Switch layer name to 'ocean'
camdecoster Mar 21, 2025
86d5b00
Update test config
camdecoster Mar 26, 2025
86f048c
Switch to UN/NE geodata hybrid
camdecoster Mar 30, 2025
ac61095
Merge remote-tracking branch 'origin/master' into cam/7334/switch-geo…
camdecoster Apr 6, 2025
f4c3d48
Fix zip/unzip of UN geojson
camdecoster Apr 7, 2025
22a8e0a
Fix saving 110m maps
camdecoster Apr 7, 2025
247f925
Update dist with new maps
camdecoster Apr 8, 2025
3cf9730
Update geo tests
camdecoster Apr 8, 2025
24e8558
Update cibuild npm script
camdecoster Apr 8, 2025
b2ae532
Add draftlog
camdecoster Apr 8, 2025
85ad69c
Remove sane-topojson package
camdecoster Apr 15, 2025
b903b9f
Add small fixes
camdecoster Apr 15, 2025
e74c65c
Remove log statements
camdecoster Apr 15, 2025
33a2e00
add some mocks to blacklist
emilykl Apr 22, 2025
4868250
update baselines for new geodata
emilykl Apr 22, 2025
bce8d21
update blacklist in compare_pixels_test
emilykl Apr 22, 2025
da44084
Save UN geodata in build folder
camdecoster Apr 22, 2025
69f104e
make sure slash is added when concatenating topojson path
emilykl Apr 23, 2025
c684b72
use local topojson for image tests
emilykl Apr 23, 2025
362233a
update paths in jasmine geo_test
emilykl Apr 23, 2025
4d37b8c
filter out extra features
emilykl Apr 23, 2025
bb8377b
update image baselines for new geodata
emilykl Apr 23, 2025
6356355
Merge pull request #7406 from plotly/cam/7334/switch-geodata-provider…
emilykl Apr 23, 2025
5482adf
Filter out extra Hawaii feature during processing
camdecoster Apr 24, 2025
2710e7c
Revert "filter out extra features"
camdecoster Apr 24, 2025
b8bfea6
Adjust world rectangle to account for coordinate issues
camdecoster May 6, 2025
d6ea8ab
Fix Anarctica geometry and simplification
camdecoster May 6, 2025
3ec56ab
Fix land layer, clean up comments
camdecoster May 6, 2025
79017d5
Remove unused filter
camdecoster May 6, 2025
0f9049c
Remove turf simplify
camdecoster May 6, 2025
4a6fb9d
Update topojson
camdecoster May 6, 2025
33d3425
Filter Greenland out of Europe
camdecoster May 6, 2025
c74f711
Erase Caspian Sea
camdecoster May 6, 2025
f7cdc5f
Update topojson
camdecoster May 7, 2025
9711ef7
Update country codes for disputed territories at Egypt/Sudan border
camdecoster May 21, 2025
51a596e
Update topojson
camdecoster May 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Add build scripts
  • Loading branch information
camdecoster committed Mar 20, 2025
commit 7f7b69054226f1dd9d45fec502038deca1c2fb39
129 changes: 129 additions & 0 deletions 129 tasks/topojson/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
{
"resolutions": [110, 50],
"regionMapping": {
"AFE": "africa",
"AFW": "africa",
"AFR": "africa",
"AME": "americas",
"NAM": "north-america",
"LAC": "south-america",
"ASI": "asia",
"EUR": "europe",
"OCE": "oceania",
"ANT": "antarctica",
"WORLD": "world"
},
"scopes": [
{
"name": "africa",
"specs": {
"source": "BNDA_simplified",
"acceptedFeatures": [
{
"key": "georeg",
"values": ["AFE", "AFR", "AFW"]
}
],
"bounds": [-30, -50, 60, 50]
}
},
{
"name": "antarctica",
"specs": {
"source": "BNDA_simplified",
"acceptedFeatures": [
{
"key": "georeg",
"values": ["ANT"]
}
],
"bounds": []
}
},
{
"name": "asia",
"specs": {
"source": "BNDA_simplified",
"acceptedFeatures": [
{
"key": "georeg",
"values": ["ASI"]
}
],
"bounds": [15, -90, 180, 85]
}
},
{
"name": "europe",
"specs": {
"source": "BNDA_simplified",
"acceptedFeatures": [
{
"key": "georeg",
"values": ["EUR"]
}
],
"bounds": [-30, 0, 60, 90]
}
},
{
"name": "north-america",
"specs": {
"source": "BNDA_simplified",
"acceptedFeatures": [
{
"key": "georeg",
"values": ["AME"]
}
],
"excludedFeatures": [
{
"key": "intreg",
"values": ["South America"]
}
],
"bounds": [-180, 0, -45, 85]
}
},
{
"name": "oceania",
"specs": {
"source": "BNDA_simplified",
"acceptedFeatures": [
{
"key": "georeg",
"values": ["OCE"]
}
],
"bounds": []
}
},
{
"name": "south-america",
"specs": {
"source": "BNDA_simplified",
"acceptedFeatures": [
{
"key": "intreg",
"values": ["South America"]
}
],
"bounds": [-100, -70, -30, 25]
}
},
{
"name": "world",
"specs": {
"source": "BNDA_simplified",
"acceptedFeatures": [],
"bounds": []
}
}
],
"simplifyTolerance": 0.1,
"outputDirGeojson": "./build/geodata/geojson",
"outputDirTopojson": "./dist/topojson",
"inputDir": "./build/geodata",
"shapefiles": ["BNDA_simplified", "GEOA_simplified", "WBYA_simplified"],
"downloadUrl": "https://geoportal.un.org/arcgis/sharing/rest/content/items/f86966528d5943efbdb83fd521dc0943/data"
}
37 changes: 37 additions & 0 deletions 37 tasks/topojson/get_geodata.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { exec } from 'child_process';
import fs from 'fs';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import config from './config.json' assert { type: 'json' };

try {
// Download data from UN
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As these origin URLs may change (link rot), I worry that this piece of the script could become out of date quickly. I'm not sure there's too much we can do here but I might recommend committing the current source file (but not adding it to the dist/. Maybe someone else has a better idea.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems reasonable. The script could be updated to look for the file first and only download if it doesn't exist.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@marthacryan do you have an opinion or ideas on this? What's the ideal dev UX here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, in 3b47c67 I added the archive from the UN.

const dataPath = './build/geodata';
const outputPath = dataPath;
const filePath = `${outputPath}/geodata.zip`;

if (fs.existsSync(filePath)) {
console.log('Data file already exists. Skipping download.');
} else {
console.log(`Downloading data from ${config.downloadUrl}`);
const unResponse = await fetch(config.downloadUrl);
if (!unResponse.ok || !unResponse.body) throw new Error(`Bad response: ${unResponse.status}`);

console.log('Processing data');
if (!fs.existsSync(dataPath)) fs.mkdirSync(dataPath, { recursive: true });
const file = fs.createWriteStream(filePath);
await pipeline(Readable.fromWeb(unResponse.body), file);

console.log(`Download complete. File saved to: ${filePath}`);
}

// Unzip archive
console.log('Unzipping shapefiles', `unzip -o ${filePath} -d ${outputPath}`);
// Use the shell to handle unzipping
if (!fs.existsSync(outputPath)) fs.mkdirSync(outputPath, { recursive: true });
exec(`unzip -o ${filePath} -d ${outputPath}`);

console.log(`Shapefiles unzipped to ${outputPath}`);
} catch (error) {
console.error(`Error when downloading file!: ${error}`);
}
174 changes: 174 additions & 0 deletions 174 tasks/topojson/process_geodata.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { simplify } from '@turf/simplify';
import fs from 'fs';
import mapshaper from 'mapshaper';
import path from 'path';
import config from './config.json' assert { type: 'json' };

const { inputDir, resolutions, scopes, shapefiles, simplifyTolerance } = config;

// Create output directories
const outputDirGeojson = path.resolve(config.outputDirGeojson);
if (!fs.existsSync(outputDirGeojson)) fs.mkdirSync(outputDirGeojson, { recursive: true });
const outputDirTopojson = path.resolve(config.outputDirTopojson);
if (!fs.existsSync(outputDirTopojson)) fs.mkdirSync(outputDirTopojson, { recursive: true });

async function convertShpToGeo(filename) {
const inputFilePath = `${inputDir}/${filename}.shp`;
const outputFilePath = `${outputDirGeojson}/${filename}.geojson`;
const commands = `-i ${inputFilePath} -proj wgs84 -o format=geojson ${outputFilePath}`;
await mapshaper.runCommands(commands);

console.log(`GeoJSON saved to ${outputFilePath}`);
}

function getGeojsonFile(filename) {
console.log('📂 Loading GeoJSON file...');
let geojson;
try {
geojson = JSON.parse(fs.readFileSync(filename, 'utf8'));
} catch (err) {
console.error(`❌ Failed to load GeoJSON input file '${filename}':`, err.message);
process.exit(1);
}

return geojson;
}

function cleanGeojson(geojson) {
return geojson.features.filter((feature) => {
const hasValidProperties = feature.properties != null;
const hasValidIso3 = feature.properties?.iso3cd !== null;
camdecoster marked this conversation as resolved.
Show resolved Hide resolved
const hasValidGeometry = feature.geometry != null;

// Remove Hawaii with specific globalid
const isHawaiiOverlap = feature.properties?.globalid === '{8B42E894-6AF5-4236-B04D-8F634A159724}';
camdecoster marked this conversation as resolved.
Show resolved Hide resolved

return hasValidProperties && hasValidIso3 && hasValidGeometry && !isHawaiiOverlap;
});
}

function getScopedFeatures(geojson, acceptedFeatures, excludedFeatures) {
if (!acceptedFeatures.length) return geojson;

return geojson.filter((feature) => {
const hasAcceptedValue = acceptedFeatures.some(({ key, values }) =>
values.includes(feature?.properties?.[key])
);
const hasExcludedValue = excludedFeatures.some(({ key, values }) =>
values.includes(feature?.properties?.[key])
);

return hasAcceptedValue && !hasExcludedValue;
});
}

function saveGeojson(region, resolution, layer, geojson) {
try {
const regionDir = path.join(outputDirGeojson, `${region}_${resolution}m`);
const filePath = path.join(regionDir, `${layer}.geojson`);
if (!fs.existsSync(regionDir)) fs.mkdirSync(regionDir, { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(geojson));
console.log(`🌐 Saved: ${filePath}`);
return filePath;
} catch (err) {
console.error(`❌ Failed to save Geojson for ${region} (${resolution}m):`, err.message);
}
}

async function createCountriesLayer({ acceptedFeatures, excludedFeatures, name, source }) {
console.log(`Building countries layer for '${name}'`);
const geojson = getGeojsonFile(`${outputDirGeojson}/${source}.geojson`);
const cleanedFeatures = cleanGeojson(geojson);
const scopedFeatures = getScopedFeatures(cleanedFeatures, acceptedFeatures, excludedFeatures);

const featureCollection = { type: 'FeatureCollection', features: scopedFeatures };
const layer = 'countries';
// TODO: Verify resolution of UN geodata
// Save 50m resolution (raw)
saveGeojson(name, 50, layer, featureCollection);

// TODO: Verify tolerance to match 110m resolution
// Save 110m resolution (simplified)
const simplifiedFeatures = featureCollection.features.map((f) =>
simplify(f, { tolerance: simplifyTolerance, highQuality: true })
);
const simplifiedCollection = { type: 'FeatureCollection', features: simplifiedFeatures };
saveGeojson(name, 110, layer, simplifiedCollection);
}

async function createLandLayer(name) {
console.log(`Building land layer for '${name}'`);
for (const resolution of resolutions) {
const inputFilePath = `${outputDirGeojson}/${name}_${resolution}m/countries.geojson`;
const outputFilePath = `${outputDirGeojson}/${name}_${resolution}m/land.geojson`;
const commands = `${inputFilePath} -dissolve -o ${outputFilePath}`;
await mapshaper.runCommands(commands);
}
}

async function createCoastlinesLayer(name) {
console.log(`Building coastlines layer for '${name}'`);
for (const resolution of resolutions) {
const inputFilePath = `${outputDirGeojson}/${name}_${resolution}m/countries.geojson`;
const outputFilePath = `${outputDirGeojson}/${name}_${resolution}m/coastlines.geojson`;
const commands = `${inputFilePath} -dissolve -lines -o ${outputFilePath}`;
await mapshaper.runCommands(commands);
}
}

async function createOceansLayer({ bounds, name }) {
console.log(`Building oceans layer for '${name}'`);
for (const resolution of resolutions) {
const inputFilePath = `./tasks/topojson/world_rectangle.geojson`;
const outputFilePath = `${outputDirGeojson}/${name}_${resolution}m/oceans.geojson`;
const eraseFilePath = `${outputDirGeojson}/GEOA_simplified.geojson`;
const commands = `${inputFilePath} ${bounds.length ? `-clip bbox=${bounds.join(',')}` : ''} -erase ${eraseFilePath} -o ${outputFilePath}`;
await mapshaper.runCommands(commands);
}
}

async function createWaterbodiesLayer({ bounds, name }) {
// Clip the waterbodies shapefile to each continent
console.log(`Building waterbodies layer for '${name}'`);
for (const resolution of resolutions) {
const inputFilePath = `${outputDirGeojson}/WBYA_simplified.geojson`;
const outputFilePath = `${outputDirGeojson}/${name}_${resolution}m/waterbodies.geojson`;
const commands = `${inputFilePath} ${bounds.length ? `-clip bbox=${bounds.join(',')}` : ''} -o ${outputFilePath}`;
await mapshaper.runCommands(commands);
}
}

async function combineFiles() {
for (const resolution of resolutions) {
for (const { name } of scopes) {
const regionDir = path.join(outputDirGeojson, `${name}_${resolution}m`);
if (!fs.existsSync(regionDir)) {
console.log(`Couldn't find ${regionDir}`);
continue;
}

const outputFile = `${outputDirTopojson}/${name}_${resolution}m.json`;
// Layer names default to file names
const commands = `-i ${regionDir}/*.geojson combine-files -o format=topojson ${outputFile}`;
await mapshaper.runCommands(commands);

console.log(`Topojson saved to: ${outputFile}`);
}
}
}

for (const shapefile of shapefiles) {
await convertShpToGeo(shapefile);
}
for (const {
name,
specs: { acceptedFeatures, bounds, excludedFeatures = [], source }
} of scopes) {
await createCountriesLayer({ acceptedFeatures, excludedFeatures, name, source });
await createLandLayer(name);
await createCoastlinesLayer(name);
await createOceansLayer({ bounds, name });
await createWaterbodiesLayer({ bounds, name });
}

await combineFiles();
21 changes: 21 additions & 0 deletions 21 tasks/topojson/world_rectangle.geojson
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[-180, -90],
[180, -90],
[180, 90],
[-180, 90],
[-180,-90]
]
]
}
}
]
}
Morty Proxy This is a proxified and sanitized view of the page, visit original site.