Example of a vector layer in Equal Earth projection with dynamic center meridian.
The countries are loaded from a GeoJSON file. Information about countries is shown on hover and click.
import Map from 'ol/Map.js';
import View from 'ol/View.js';
import GeoJSON from 'ol/format/GeoJSON.js';
import VectorLayer from 'ol/layer/Vector.js';
import {fromLonLat, get as getProjection, toLonLat} from 'ol/proj.js';
import {register} from 'ol/proj/proj4.js';
import RenderFeature from 'ol/render/Feature.js';
import VectorSource from 'ol/source/Vector.js';
import proj4 from 'proj4';
// Equal Earth projection with dynamic center meridian
function dynEqualEarth(center, round = 15) {
const lon0 = Math.round(center[0] / round) * round;
const code = `EqualEarth${lon0}`;
let prj = getProjection(code);
if (!prj) {
proj4.defs(
code,
`+proj=eqearth +lon_0=${lon0} +x_0=0 +y_0=0 +R=6371008.7714 +units=m +no_defs +type=crs`,
);
register(proj4);
prj = getProjection(code);
prj.setGlobal(true);
prj.setExtent([-17243959.06, -8392927.6, 17243959.06, 8392927.6]);
prj.setWorldExtent([-180, -90, 180, 90]);
}
return prj;
}
const initialCenter = [11, 0];
const initialProjection = dynEqualEarth(initialCenter, 1);
(async () => {
const response = await fetch(
'https://openlayers.org/data/vector/ecoregions.json',
// 'https://openlayersbook.github.io/openlayers_book_samples/assets/data/countries.geojson',
);
const geojson = await response.json();
function jsonSource(geojson, projection) {
return new VectorSource({
features: new GeoJSON({
featureProjection: projection,
featureClass: RenderFeature,
}).readFeatures(geojson),
overlaps: false,
});
}
const vectorLayer = new VectorLayer({
source: jsonSource(
clipPolygon(geojson, initialCenter[0]),
initialProjection,
),
extent: initialProjection.getExtent(),
wrapX: false,
style: {
'fill-color': ['string', ['get', 'COLOR'], '#eee'],
},
});
const map = new Map({
layers: [vectorLayer],
target: 'map',
view: new View({
projection: initialProjection,
center: initialCenter,
zoom: 0,
showFullExtent: true,
}),
});
// Event handler for updating view projection
function handleCenterChange() {
const degStep = 5;
const curView = map.getView();
const center = toLonLat(curView.getCenter(), curView.getProjection());
const newProjection = dynEqualEarth(center, degStep);
if (curView.getProjection().getCode() !== newProjection.getCode()) {
curView.un('change:center', handleCenterChange);
const lon0 = Math.round(center[0] / degStep) * degStep;
// Clip polygons at the new antimeridian to avoid rendering artifacts
const clippedJson = clipPolygon(geojson, lon0);
// Reload source to apply the new projection
vectorLayer.setSource(jsonSource(clippedJson, newProjection));
map.setView(
new View({
projection: newProjection,
center: fromLonLat(center, newProjection),
zoom: curView.getZoom(),
rotation: curView.getRotation(),
showFullExtent: true,
}),
);
map.getView().on('change:center', handleCenterChange);
}
}
map.getView().on('change:center', handleCenterChange);
function clipPolygon(geojson, lon0) {
function roundN(num, n = 10) {
return Math.round(num * Math.pow(10, n)) / Math.pow(10, n);
}
const minX = lon0 - 180.0;
const maxX = lon0 + 180.0;
const clippedJson = {type: 'FeatureCollection', features: []};
for (const feature of geojson.features) {
const depth = feature.geometry.type === 'MultiPolygon' ? 2 : 1;
const [featMinX, featMaxX] = feature.geometry.coordinates
.flat(depth)
.reduce(
(minmax, coord) => [
Math.min(minmax[0], coord[0]),
Math.max(minmax[1], coord[0]),
],
[Number.MAX_VALUE, Number.MIN_VALUE],
);
const eps = 0.01;
if (
(featMinX < minX + eps && featMaxX > minX - eps) ||
(featMinX < maxX + eps && featMaxX > maxX - eps)
) {
const offset = featMinX < minX ? 360 : -360;
const feat = structuredClone(feature);
if (feat.geometry.type === 'Polygon') {
feat.geometry.type = 'MultiPolygon';
feat.geometry.coordinates = [feat.geometry.coordinates];
}
const polys = [];
for (const polygon of feat.geometry.coordinates) {
const tpoly = structuredClone(polygon);
const ncoords = polygon.reduce((sum, ring) => sum + ring.length, 0);
let clamped = 0;
for (const ring of polygon) {
for (const coord of ring) {
const x = coord[0];
coord[0] = roundN(Math.min(Math.max(x, minX), maxX));
if (coord[0] !== roundN(x)) {
clamped++;
}
}
}
// Skip possibly degenerated polys with all coords clamped
if (clamped < ncoords) {
polys.push(polygon);
}
// Shift poly by 360° and clamp other part
if (clamped) {
let around180 = false;
for (const ring of tpoly) {
for (const coord of ring) {
const x = coord[0] + offset;
coord[0] = Math.min(Math.max(x, minX + eps), maxX - eps);
// this still creates bad polys when coords are around 180°
if (Math.abs(coord[0]) - 180 < 0.00000001) {
around180 = true;
}
}
}
if (!around180) {
polys.push(tpoly);
}
}
}
feat.geometry.coordinates = polys;
clippedJson.features.push(feat);
} else {
clippedJson.features.push(feature);
}
}
return clippedJson;
}
const featureOverlay = new VectorLayer({
source: new VectorSource(),
map: map,
style: {
'stroke-color': 'rgba(255, 255, 255, 0.7)',
'stroke-width': 2,
},
});
let highlight;
const displayFeatureInfo = function (pixel) {
const feature = map.forEachFeatureAtPixel(pixel, function (feature) {
return feature;
});
const info = document.getElementById('info');
if (feature) {
info.innerHTML =
feature.get('ECO_NAME') || feature.get('name') || ' ';
} else {
info.innerHTML = ' ';
}
if (feature !== highlight) {
if (highlight) {
featureOverlay.getSource().removeFeature(highlight);
}
if (feature) {
featureOverlay.getSource().addFeature(feature);
}
highlight = feature;
}
};
map.on('pointermove', function (evt) {
if (evt.dragging) {
return;
}
displayFeatureInfo(evt.pixel);
});
map.on('click', function (evt) {
displayFeatureInfo(evt.pixel);
});
})();
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Equal Earth projection with dynamic center meridian</title>
<link rel="stylesheet" href="node_modules/ol/ol.css">
<style>
.map {
width: 100%;
height: 400px;
}
</style>
</head>
<body>
<div id="map" class="map"></div>
<div id="info"> </div>
<script type="module" src="main.js"></script>
</body>
</html>
{
"name": "equal-earth-geojson",
"dependencies": {
"ol": "10.9.0",
"proj4": "2.20.8"
},
"devDependencies": {
"vite": "^3.2.3"
},
"scripts": {
"start": "vite",
"build": "vite build"
}
}