/*
Copyright 2026
JXG.Geography contributors
This file is part of JXG.Geography, an extension for JSXGraph.
JXG.Geography is free software dual licensed under the GNU LGPL or MIT
License, at your choice — the same terms JSXGraph itself is offered under.
* https://www.gnu.org/licenses/lgpl-3.0.html
* https://opensource.org/licenses/MIT
Geographic data: Natural Earth (naturalearthdata.com), public domain.
*/
/**
* JXG.Geography — geomap element.
*
* A map is not a view, so this is a composite over GeometryElement: it owns a
* bundle of plain JXG.Curve children and maps projected coordinates into its
* own drawing rectangle, rather than commandeering the board's bounding box.
*
* var map = board.create('geomap', [[-2.9, -1.5], [5.8, 3.0]], {
* data: JXG.Geography.datasets.naturalEarth110,
* projection: 'mollweide',
* center: [10, 0]
* });
*
* Requires jxg-geography.js.
*/
/**
* A map: the Earth on a sheet, with a projection that can be exchanged while
* it is running.
*
* Created with `board.create('geomap', [corner, size], attributes)`.
*
* @namespace Geomap
*/
(function (root) {
'use strict';
var JXG = root.JXG;
var G = JXG.Geography;
function clamp(v, a, b) {
if (v < a) { return a; }
if (v > b) { return b; }
return v;
}
var LAYER_STYLE = {
visible: false, strokeColor: '#000000', strokeWidth: 1, strokeOpacity: 1,
fillColor: 'none', fillOpacity: 1, dash: 0, layer: 5,
highlight: false, fixed: true
};
/**
* Attributes are merged here rather than through JXG.copyAttributes.
* That helper runs keysToLowerCase() recursively over the caller's object,
* so structured attributes would silently arrive as body.fillcolor and
* smallcountryradius. Style sub-objects are handed to create() untouched,
* where JSXGraph applies its own normalisation.
*/
/**
* A plain object, as against an array or a DOM node.
*
* @param {*} v Anything.
* @returns {boolean} True for a plain object.
*/
function isPlain(v) {
return v !== null && typeof v === 'object' && !Array.isArray(v) &&
v.constructor === Object;
}
function mergeAttr(base, over) {
var out = {}, k;
for (k in base) {
if (!base.hasOwnProperty(k)) { continue; }
if (isPlain(base[k])) {
out[k] = mergeAttr(base[k], {});
} else if (Array.isArray(base[k])) {
out[k] = base[k].slice();
} else {
out[k] = base[k];
}
}
for (k in (over || {})) {
if (!over.hasOwnProperty(k)) { continue; }
if (isPlain(out[k]) && isPlain(over[k])) {
out[k] = mergeAttr(out[k], over[k]);
} else {
out[k] = over[k];
}
}
return out;
}
/**
* @class A map: a bundle of curves drawn through a chosen projection.
* @pseudo
* @name Geomap
* @augments JXG.Composition
* @constructor
* @type JXG.Composition
* @throws {Error} If the element cannot be constructed with the given parent
* objects an exception is thrown.
* @param {Array_Array} lowerLeft,size Position and size of the drawing
* rectangle in board coordinates. Omit both to fill the board.
*
* @example
* var map = board.create('geomap', [[-2.9, -1.5], [5.8, 3.0]], {
* data: JXG.Geography.datasets.naturalEarth110,
* projection: 'mollweide'
* });
*/
JXG.Options.geomap = {
/**#@+
* @visprop
*/
data: null,
extent: null, // [[lonW, latS], [lonE, latN]] or null for all
smallCountryRadius: 1.15,
showSmallCountries: true, // stand-in circles for countries with no outline
projection: 'equalearth',
center: [0, 0],
oblique: false,
fit: 'contain',
padding: 0.045,
ocean: { visible: true, fillColor: '#0e2739', fillOpacity: 1,
strokeColor: '#2a5c7d', strokeWidth: 1.3, layer: 2 },
land: { visible: true, fillColor: '#dcd3bf', fillOpacity: 1,
strokeColor: 'inherit', strokeWidth: 0.8, layer: 4 },
coast: { visible: true, strokeColor: '#8d8471', strokeWidth: 0.9, layer: 5 },
countries: { visible: false, strokeColor: '#a89a7d', strokeWidth: 0.55,
strokeOpacity: 0.75, layer: 5, lod: 'still' },
capitals: { visible: false, fillColor: '#cf9b3f', strokeColor: '#071018',
strokeWidth: 0.5, size: 0.62, layer: 6, lod: 'still' },
graticule: { visible: true, strokeColor: '#1b4059', strokeWidth: 0.7,
strokeOpacity: 0.8, step: [30, 30], layer: 6 },
highlight: {},
highlightStyle: { fillColor: '#cf9b3f', fillOpacity: 0.42,
strokeColor: '#cf9b3f', strokeWidth: 1.2, layer: 7 },
features: { show: [], strokeColor: '#5fb0c6', strokeWidth: 1.1,
strokeOpacity: 0.9, layer: 7 },
distortion:{ mode: 'none', step: 30, radius: 6, latMax: 60,
strokeColor: '#cf9b3f', strokeWidth: 1,
fillColor: '#cf9b3f', fillOpacity: 0.16, layer: 7 },
morph: { enabled: true, duration: 620 },
selectMode: 'none', // 'none' | 'single' | 'multiple'
hoverCountries: false,
picking: true, // false skips the country lookup altogether
recenterOn: 'none',
hitTolerance: 3, // device px a click may sit outside the frame
clickTolerance: 12 // px a pointer may travel and still count as a tap;
// a finger is far less steady than a mouse
/**#@-*/
};
JXG.createGeomap = function (board, parents, attributes) {
var attr = mergeAttr(JXG.Options.geomap, attributes);
var ds = attr.data;
if (!ds) { G.warn('geomap needs a data attribute'); }
// The clip state is shared between maps on the same centre, and its memo
// is keyed by layer name. Names must therefore be unique per map, or one
// map's layer hands back another map's geometry.
G.mapCount = (G.mapCount || 0) + 1;
var mapId = 'm' + G.mapCount;
var pos, size, bb;
if (parents && parents[0] && parents[1]) { pos = parents[0]; size = parents[1]; } else {
bb = board.getBoundingBox();
pos = [bb[0], bb[3]];
size = [bb[2] - bb[0], bb[1] - bb[3]];
}
/**
* A composition, not a bare object inheriting GeometryElement.prototype.
* board.create() ends with
* if (el.prepareUpdate && el.update && el.updateRenderer) el.fullUpdate();
* and an object that borrowed the prototype without running the
* constructor has no visPropCalc, so fullUpdate() dies on the first call.
* Composition supplies those three methods and forwards them to its
* members, which is exactly what a composite element needs.
*/
var members = {};
var map = JXG.Composition ? new JXG.Composition(members) : {};
map.elType = 'geomap';
map.board = board;
map.dataset = ds;
map.visProp = map.visProp || {};
// eventify(), not extend(): JXG.EventEmitter carries trigger/on/off, and
// the alias triggerEventHandlers is only created by eventify. Mixing the
// object in directly leaves that name undefined.
if (JXG.EventEmitter && typeof JXG.EventEmitter.eventify === 'function') {
JXG.EventEmitter.eventify(map);
}
if (typeof map.triggerEventHandlers !== 'function') {
map.eventHandlers = map.eventHandlers || {};
map.on = function (n, f) {
this.eventHandlers[n] = this.eventHandlers[n] || [];
this.eventHandlers[n].push(f);
return this;
};
map.off = function (n) { delete this.eventHandlers[n]; return this; };
map.triggerEventHandlers = function (names, args) {
var self = this;
names.forEach(function (n) {
(self.eventHandlers[n] || []).forEach(function (f) { f.apply(self, args); });
});
return this;
};
}
// ------------------------------------------------------------ projection
var current = G.projection(attr.projection) || G.projections.equalearth;
var centre = [attr.center[0], attr.center[1]];
var obliqueOn = !!attr.oblique;
var state = null; // shared clip state, see clipState()
function effLat() {
return (current.oblique && obliqueOn) ? centre[1] : 0;
}
function acquireState() {
// An azimuthal projection needs the circular cut, and with it a
// different rotation; the clip state is keyed on that too. `current` is
// the projection object, not its name.
var rho = (current && current.clip === 'circle') ? current.rhoMax : 0;
var lat = effLat();
// Take the new one before letting the old one go. Releasing first drops
// the last reference, the cache deletes the entry, and the identical
// state is then rebuilt from nothing — which is what happened on every
// rebuildLL: 4 ms of clipping thrown away and redone for a centre that
// had not moved.
var next = G.clipCache.acquire(ds, [centre[0], lat],
lat !== 0 || rho > 0, rho);
// The old reference goes back whatever happens, or the count climbs by
// one on every rebuild. When it is the same entry the count returns to
// where it was — and, crucially, never touches zero in between, so the
// memo survives.
if (state) { G.clipCache.release(state); }
state = next;
return state;
}
/**
* Projected extent of the current projection, brought to the aspect ratio
* of the drawing rectangle. Letting keepaspectratio crop the wider axis
* instead is what clips the left and right edge off Equal Earth.
*/
var tx = { sx: 1, sy: 1, ox: 0, oy: 0 }, halfExtent = Math.PI;
/**
* The outline whose projected bounds the drawing rectangle is fitted to.
*
* Without a viewport that is the whole sheet, as before. With one it is
* the box in longitude and latitude the caller asked for — sampled along
* its edges, not just at its corners, because every projection bends a
* straight edge. The box is given in geographic coordinates and rotated
* like everything else, so a viewport survives a change of centre.
*/
function fitOutline() {
if (!attr.extent) { return G.frameLL(current, 120); }
// The first refit runs before the clip state exists, so the rotation is
// taken from the namespace rather than through `state`. A map given an
// extent in its attributes used to throw here.
var rot = state ? state.rot : G.rotator(centre[0], effLat());
return G.boxLL(attr.extent, 60).map(function (p) {
return rot(p[0], p[1]);
});
}
function refit() {
var P = current, x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity, s;
fitOutline().forEach(function (p) {
var f = P.forward(p[0], clamp(p[1], -P.latMax, P.latMax));
if (f[0] < x0) { x0 = f[0]; } if (f[0] > x1) { x1 = f[0]; }
if (f[1] < y0) { y0 = f[1]; } if (f[1] > y1) { y1 = f[1]; }
});
var W = (x1 - x0) * (1 + 2 * attr.padding), H = (y1 - y0) * (1 + 2 * attr.padding);
halfExtent = (x1 - x0) / 2; // projected half-width
if (attr.fit === 'cover') {
s = Math.max(size[0] / W, size[1] / H);
} else if (attr.fit === 'none') {
s = 1;
} else {
s = Math.min(size[0] / W, size[1] / H);
}
// The curves receive user coordinates, not screen pixels, and on a
// JSXGraph board y grows upward. Negating it here put the map on its head.
tx = { sx: s, sy: s,
ox: pos[0] + size[0] / 2 - (x0 + x1) / 2 * s,
oy: pos[1] + size[1] / 2 - (y0 + y1) / 2 * s };
}
refit();
function toBoard(u, v) { return [u * tx.sx + tx.ox, v * tx.sy + tx.oy]; }
function fromBoard(x, y) { return [(x - tx.ox) / tx.sx, (y - tx.oy) / tx.sy]; }
// ---------------------------------------------------------------- shapes
/**
* What has to be undone when the map is destroyed.
*
* Listeners outlive the element that installed them: after destroy the
* handlers kept running, and the next pointer move reached a map whose
* clip state was already gone.
*/
var undoListeners = [];
var shapes = [], layers = {}, destroyed = false, hovered = null;
function project(ll, P, X, Y) {
var i, p, f, b;
X.length = 0; Y.length = 0;
for (i = 0; i < ll.length; i++) {
p = ll[i];
if (p[0] !== p[0]) { X.push(NaN); Y.push(NaN); continue; } // NaN separator
f = P.forward(p[0], clamp(p[1], -P.latMax, P.latMax));
b = toBoard(f[0], f[1]);
X.push(b[0]); Y.push(b[1]);
}
}
/**
* Settings a caller may want once for the whole map rather than layer by
* layer. They are passed to every curve, and a layer that names one for
* itself still wins.
*
* `tabindex` is the one that matters in practice: the default of -1 leaves
* an element mouse-focusable, so clicking the ocean — a path spanning the
* whole map — draws the browser's focus ring around it. Setting it on the
* map had no effect at all before, because only attr[name] was read.
*/
var PASSED_THROUGH = ['tabindex', 'cssClass', 'highlightCssClass',
'needsRegularUpdate', 'nonReflexive'];
function styleOf(name, extra) {
var a = attr[name] || {}, o = {}, k, i2;
for (k in LAYER_STYLE) { if (LAYER_STYLE.hasOwnProperty(k)) { o[k] = LAYER_STYLE[k]; } }
for (i2 = 0; i2 < PASSED_THROUGH.length; i2++) {
k = PASSED_THROUGH[i2];
if (attr[k] !== undefined) { o[k] = attr[k]; }
}
for (k in a) { if (a.hasOwnProperty(k)) { o[k] = a[k]; } }
if (extra) { for (k in extra) { if (extra.hasOwnProperty(k)) { o[k] = extra[k]; } } }
if (o.strokeColor === 'inherit') { o.strokeColor = o.fillColor; }
delete o.lod; delete o.step; delete o.size; delete o.show;
delete o.mode; delete o.latMax; delete o.radius;
return o;
}
function addShape(name, kind, src, extra) {
var X = [], Y = [], st = styleOf(name, extra);
var c = board.create('curve', [X, Y], st);
var sh = { name: name, kind: kind, src: src, ll: [], X: X, Y: Y, curve: c,
active: st.visible !== false, lod: (attr[name] && attr[name].lod) || 'always' };
shapes.push(sh);
layers[name] = sh;
return sh;
}
/**
* Rotation and antimeridian clipping depend on the centre only, never on
* the projection. That is what makes the morph possible: with the centre
* unchanged the point counts match, so the two coordinate sets can be
* interpolated. Disabled layers are skipped entirely, not merely hidden.
*/
// Only layers whose geometry comes from the dataset alone may share a
// cache entry. The graticule depends on `step`, the capitals on their
// size and the countries on smallCountryRadius — two maps with different
// attributes would otherwise hand each other the wrong geometry.
var SHARED = { land: 1, coast: 1 };
function rebuildLL() {
var st = acquireState(), i, sh;
for (i = 0; i < shapes.length; i++) {
sh = shapes[i];
if (sh.kind === 'frame') { continue; }
if (!sh.active) { sh.ll = []; continue; }
// shared layers keep their plain name so the work is shared; the ones
// that differ per map carry the map's own prefix
var key = SHARED[sh.name] ? sh.name : mapId + sh.name;
if (sh.kind === 'rings') {
sh.ll = st.rings(key, sh.src());
} else {
sh.ll = st.lines(key, sh.src());
}
}
}
/** Drop the memo of every layer whose content depends on the viewport. */
function forgetViewportLayers() {
if (!state) { return; }
shapes.forEach(function (sh) {
if (sh.viewportDependent) {
state.forget(SHARED[sh.name] ? sh.name : mapId + sh.name);
}
});
}
function llOf(sh, P) { return sh.kind === 'frame' ? G.frameLL(P, 120) : sh.ll; }
function redraw(P) {
var i;
board.suspendUpdate();
for (i = 0; i < shapes.length; i++) {
project(llOf(shapes[i], P), P, shapes[i].X, shapes[i].Y);
// While a globe on the same board is being dragged, prepareUpdate
// refreshes only 3D elements; a map that recentres in step would
// compute new coordinates and never show them.
if (typeof shapes[i].curve.fullUpdate === 'function') {
shapes[i].curve.fullUpdate();
}
}
board.unsuspendUpdate();
}
// sources ------------------------------------------------------------
var srcLand = [], srcCoast = [], srcCountries = [], srcCaps = [], srcGrat = [];
function rebuildCountries() {
srcCountries.length = 0;
if (!ds) { return; }
ds.countries.forEach(function (c) {
G.countryRings(c, attr.smallCountryRadius,
attr.showSmallCountries).forEach(function (r) {
srcCountries.push(r.concat([r[0]]));
});
});
if (state) { state.forget(mapId + 'countries'); }
}
if (ds) {
ds.land.polys.forEach(function (pl) { pl.forEach(function (r) { srcLand.push(r); }); });
srcCoast = ds.coast.rings.map(function (r) { return r.concat([r[0]]); });
rebuildCountries();
srcCaps = ds.capitals.map(function (c) {
return G.smallCircle(c.lon, c.lat, attr.capitals.size, 10);
});
}
(function () {
var st = attr.graticule.step, lon, lat;
for (lon = -180; lon < 180; lon += st[0]) { srcGrat.push(G.meridianLL(lon, 120)); }
for (lat = -90 + st[1]; lat < 90; lat += st[1]) { srcGrat.push(G.parallelLL(lat, 240)); }
}());
addShape('ocean', 'frame', null);
addShape('land', 'rings', function () { return srcLand; });
addShape('coast', 'lines', function () { return srcCoast; });
addShape('countries', 'lines', function () { return srcCountries; });
addShape('capitals', 'rings', function () { return srcCaps; });
addShape('graticule', 'lines', function () { return srcGrat; });
var picked = {}, byId = {}, srcPick = [];
if (ds) { ds.countries.forEach(function (c) { byId[c.id] = c; }); }
function rebuildPick() {
srcPick = [];
Object.keys(picked).forEach(function (id) {
var c = byId[id];
if (!c) { G.warn('unknown id "' + id + '"'); return; }
G.countryRings(c, attr.smallCountryRadius, attr.showSmallCountries)
.forEach(function (r) { srcPick.push(r); });
});
var sh = layers.highlight;
sh.active = srcPick.length > 0;
sh.curve.setAttribute({ visible: sh.active });
// Guarded like rebuildCountries: at construction the shapes exist before
// the clip state does.
if (state) { state.forget(mapId + 'highlight'); }
}
addShape('highlightStyle', 'rings', function () { return srcPick; }, { visible: false });
layers.highlight = layers.highlightStyle;
layers.highlight.name = 'highlight';
delete layers.highlightStyle;
var srcFeat = [];
addShape('features', 'lines', function () { return srcFeat; }, { visible: false });
var srcTissot = [];
addShape('distortion', 'rings', function () { return srcTissot; }, { visible: false });
// ----------------------------------------------------------- public API
map.layers = layers;
/**
* Public layer factory. The source is a function so a moving marker only
* has to invalidate its cached clip, not rebuild the map.
*
* @param {string} kind "rings" for filled shapes, anything else for
* lines. A line layer is forced unfilled, whatever its style asks.
* @param {function(): Array} source Yields the geometry, as rings or
* polylines of [lon, lat] in degrees.
* @param {Object} [style] Curve attributes. Settings made once on the
* map — `tabindex` above all — reach it too, and this overrides them.
* @returns {Object} The layer: `refresh()`, `setVisible(on)`, and the
* board coordinate arrays `X` and `Y`.
* @alias Geomap#addGeoLayer
*/
var geoLayerNo = 0;
map.addGeoLayer = function (kind, source, style) {
// No orients here: the map clips in lon/lat and reads each ring's
// winding from the ring itself. The globe needs to be told because the
// terminator rejoin picks its arc direction from it.
var name = mapId + 'geo' + (++geoLayerNo);
// The same merge the built-in layers get, so a setting made once on the
// map — tabindex above all — reaches a layer added later too. Building
// the style separately here is why it did not.
var st = styleOf(name, style);
// visible unless asked otherwise; the internal default is the opposite
st.visible = (style && style.visible === false) ? false : true;
// A line layer is never filled. Stating it in every style was a request;
// enforcing it here is a guarantee, and a stray fill on a path that
// spans the map is very loud.
if (kind !== 'rings') { st.fillColor = 'none'; st.fillOpacity = 0; }
var X = [], Y = [];
var c = board.create('curve', [X, Y], st);
var sh = { name: name, kind: kind === 'rings' ? 'rings' : 'lines',
src: source, ll: [], X: X, Y: Y, curve: c,
active: st.visible, lod: 'always' };
shapes.push(sh);
layers[name] = sh;
sh.refresh = function () {
// The same key rebuildLL stores under. Forgetting the plain name
// instead leaves the memo in place, and the layer keeps redrawing the
// geometry it had when it was created — the source function is called
// but its result is never used.
if (state) { state.forget(SHARED[sh.name] ? sh.name : mapId + sh.name); }
rebuildLL(); redraw(current); board.update();
return sh;
};
sh.setVisible = function (on) {
sh.active = on; c.setAttribute({ visible: on }); sh.refresh(); return sh;
};
sh.refresh();
return sh;
};
/**
* The projection in force.
*
* @returns {Object} The projection object, not its name.
* @alias Geomap#projection
*/
map.projection = function () { return current; };
/**
* The map centre.
*
* @returns {Array<number>} A copy, as [lon, lat] in degrees.
* @alias Geomap#centre
*/
map.centre = function () { return [centre[0], centre[1]]; };
map.center = map.centre;
/**
* The shared clip state this map is using.
*
* Two maps with the same centre share one, and with it the work of
* clipping. Of interest mainly to a test that wants to check they do.
*
* @returns {?Object} The state, or null after `destroy`.
* @alias Geomap#clipState
*/
map.clipState = function () { return state; };
/**
* Half the width the projection occupies, in device pixels.
*
* What the sampling functions need: how large the thing is actually
* drawn, at the resolution the screen has rather than in CSS pixels. The
* globe answers the same question the same way, so both hosts sample by
* size on screen.
*
* @returns {number} Device pixels.
* @alias Geomap#pixelRadius
*/
map.pixelRadius = function () {
// half the width the projection actually occupies, in device pixels
return Math.abs(halfExtent * tx.sx * (board.unitX || 1)) * G.devicePixels();
};
/**
* Where a position lands on the board.
*
* @param {number} lon Longitude in degrees.
* @param {number} lat Latitude in degrees.
* @returns {?{x: number, y: number}} Board coordinates, or null once the
* map has been destroyed and given its clip state back.
* @alias Geomap#fromGeo
*/
map.fromGeo = function (lon, lat) {
// A destroyed map has given its clip state back. Answering null is the
// honest response; reaching through it threw, and a stale listener on
// the board was enough to reach here.
if (!state) { return null; }
var g = state.rot(lon, lat);
var f = current.forward(g[0], clamp(g[1], -current.latMax, current.latMax));
var b = toBoard(f[0], f[1]);
return { x: b[0], y: b[1] };
};
/**
* What lies at a board position — the inverse of `fromGeo`.
*
* A pointer may sit a few pixels outside the sheet and still count as on
* the map; `hitTolerance` says how many, and it is converted through the
* current scale so it means the same on a phone and on a large screen.
*
* @param {number} x Board x.
* @param {number} y Board y.
* @returns {?Array<number>} [lon, lat] in degrees, or null when the point
* is off the sheet or the map has been destroyed.
* @alias Geomap#toGeo
*/
map.toGeo = function (x, y) {
if (!state) { return null; }
var u = fromBoard(x, y);
// How far outside the frame a pointer may sit and still count as on the
// map, expressed in pixels and converted through the current scale.
var perUnit = Math.abs(tx.sx * (board.unitX || 1)) * G.devicePixels();
var tol = (attr.hitTolerance || 3) / Math.max(perUnit, 1e-6);
var r = G.invert(current, u[0], u[1], null, tol);
if (!r.ok) { return null; }
var t = state.unrot(r.lon, r.lat);
return [t[0], t[1]];
};
/**
* Move the map centre.
*
* A viewport is given in geographic coordinates and rotated with
* everything else, so moving the centre moves where it lands; the fit is
* taken again when one is set.
*
* @param {number} lon New centre longitude in degrees.
* @param {number} [lat] New centre latitude; unchanged when omitted.
* @returns {Object} The map, for chaining.
* @alias Geomap#setCenter
*/
map.setCenter = function (lon, lat) {
centre = [lon, lat === undefined ? centre[1] : lat];
rebuildLL();
// A viewport is given in geographic coordinates and rotated with
// everything else, so moving the centre moves where it lands. Without
// this the box drifts off the drawing rectangle entirely.
if (attr.extent) { refit(); }
redraw(current);
map.triggerEventHandlers(['centerchange'], [{ lon: centre[0], lat: centre[1] }]);
board.update();
return map;
};
/**
* Draw a feature layer, culled to the viewport.
*
* The layer is re-read on every refresh, so changing the viewport changes
* what is drawn — which is the point: at country scale nothing outside the
* view is clipped, projected or drawn. `minRank` leaves out what the scale
* does not justify.
*
* @param {Object} layer A feature layer, as `G.asLayer` returns.
* @param {Object} [style] Curve attributes.
* @param {Object} [opts] `kind` "rings" or "lines"; `minRank` leaves
* out what the scale does not justify; `cull: false` draws every
* feature whatever the viewport says.
* @returns {Object} The layer, with `features` reporting what survived
* the culling and `at(lon, lat)` naming what lies at a position.
* @alias Geomap#addFeatureLayer
*/
map.addFeatureLayer = function (layer, style, opts) {
var o = opts || {};
// addGeoLayer draws at once, so the source runs before its own shape
// exists. What it needs to report is kept beside it instead.
var state2 = { features: [] };
var sh = map.addGeoLayer(o.kind || 'rings', function () {
var seen = G.featuresIn(layer, o.cull === false ? null : attr.extent,
o.minRank);
state2.features = seen;
return o.kind === 'lines'
? G.ringsOf(seen).map(function (r) { return r.concat([r[0]]); })
: G.ringsOf(seen);
}, style);
Object.defineProperty(sh, 'features',
{ get: function () { return state2.features; } });
sh.layerSource = layer;
// Its geometry depends on the viewport, not on the dataset alone, so
// the memo has to be dropped when the viewport moves.
sh.viewportDependent = o.cull !== false;
/** Which feature of this layer is at that position, if any. */
sh.at = function (lon, lat) { return G.featureAt(layer, lon, lat); };
return sh;
};
/**
* Show only this box, given in longitude and latitude, or the whole sheet
* again with null. The clipping does not change — a viewport is a matter
* of what is fitted into the rectangle, not of what is computed.
*
* @param {?Array<Array<number>>} box [[lonW, latS], [lonE, latN]] in
* degrees, or null for the whole sheet.
* @returns {Object} The map, for chaining.
* @alias Geomap#setExtent
*/
map.setExtent = function (box) {
attr.extent = box || null;
// A culled layer draws something different now, so its memo is dropped;
// the built-in layers are not culled and theirs stands. This used to
// work by accident, because taking the clip state discarded every memo
// on the way — once that stopped, the culling stopped showing.
forgetViewportLayers();
rebuildLL(); refit(); redraw(current); board.update();
return map;
};
/**
* The viewport in force, or null when the whole sheet is shown.
*
* @returns {?Array<Array<number>>} [[lonW, latS], [lonE, latN]] in degrees.
* @alias Geomap#extent
*/
map.extent = function () { return attr.extent; };
/**
* Fit the view to a group of rings, with a margin in degrees.
*
* @param {Array<Array<Array<number>>>} rings Rings of [lon, lat].
* @param {number} [margin=2] Room around them, in degrees.
* @returns {Object} The map, for chaining.
* @alias Geomap#zoomTo
*/
map.zoomTo = function (rings, margin) {
var b = G.bboxOf(rings);
if (!b) { return map; }
var m = margin === undefined ? 2 : margin;
return map.setExtent([[b[0][0] - m, Math.max(-90, b[0][1] - m)],
[b[1][0] + m, Math.min(90, b[1][1] + m)]]);
};
/**
* Tilt the axis, or put it back on the equator.
*
* Only an oblique-capable projection can take a tilted axis; asking any
* other for one warns and leaves the map upright.
*
* @param {boolean} on Whether the centre's latitude tilts the axis.
* @returns {Object} The map, for chaining.
* @alias Geomap#setOblique
*/
map.setOblique = function (on) {
obliqueOn = !!on;
if (obliqueOn && !current.oblique) {
G.warn('projection "' + current.id + '" is not oblique-capable');
}
rebuildLL();
// Tilting the axis changes the rotation, and with it where a viewport
// lands, so the fit has to be taken again.
if (attr.extent) { refit(); }
redraw(current); board.update();
return map;
};
var morphRaf = null;
function ease(u) { return u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2; }
/**
* Change the projection, optionally morphing into it.
*
* The morph interpolates point for point, so the two coordinate sets have
* to match. Anything that changes the clipping changes the point counts —
* the kind of cut, and its radius — and those switches are made outright.
* A morph already running is cancelled first, or the frame that arrives
* afterwards undoes the switch.
*
* @param {(string|Object)} name A registered projection, or its name.
* @param {boolean} [animate] Morph into it, if `morph.enabled` allows.
* @returns {Object} The map, for chaining.
* @alias Geomap#setProjection
*/
map.setProjection = function (name, animate) {
var P = G.projection(name);
if (!P) { return map; }
var from = current, A, B, t0, s;
if (obliqueOn && !P.oblique) {
G.warn('projection "' + P.id + '" is not oblique-capable');
}
// The morph interpolates point for point, which needs the two coordinate
// sets to match. Anything that changes the clipping changes the point
// counts: the kind of cut — a disc against a circle, a sheet against a
// meridian — but also its radius, since two discs of different rhoMax
// cut different rings at different places. Both cases switch outright.
if (from.clip !== P.clip || from.rhoMax !== P.rhoMax) { animate = false; }
if (!attr.morph.enabled || !animate) {
// A morph already under way would otherwise keep writing and undo
// this switch a frame later.
if (morphRaf) { root.cancelAnimationFrame(morphRaf); morphRaf = null; }
current = P;
if (from.clip !== P.clip || from.rhoMax !== P.rhoMax) { acquireState(); }
refit(); rebuildLL(); redraw(P);
map.triggerEventHandlers(['projectionchange'], [{ from: from.id, to: P.id }]);
board.update();
return map;
}
A = shapes.map(function (sh) { return [sh.X.slice(), sh.Y.slice()]; });
current = P; refit(); rebuildLL();
B = shapes.map(function (sh) {
var X = [], Y = [];
project(llOf(sh, P), P, X, Y);
return [X, Y];
});
if (morphRaf) { root.cancelAnimationFrame(morphRaf); }
t0 = root.performance ? root.performance.now() : Date.now();
(function step(now) {
var u = Math.min(1, ((now || t0) - t0) / attr.morph.duration), e = ease(u), k, n;
board.suspendUpdate();
for (s = 0; s < shapes.length; s++) {
n = B[s][0].length;
shapes[s].X.length = 0; shapes[s].Y.length = 0;
for (k = 0; k < n; k++) {
shapes[s].X.push(A[s][0][k] + (B[s][0][k] - A[s][0][k]) * e);
shapes[s].Y.push(A[s][1][k] + (B[s][1][k] - A[s][1][k]) * e);
}
}
board.unsuspendUpdate();
if (u < 1) { morphRaf = root.requestAnimationFrame(step); } else {
morphRaf = null;
map.triggerEventHandlers(['projectionchange'], [{ from: from.id, to: P.id }]);
}
}(t0));
return map;
};
// overrides Composition.select(name), which a geomap does not need
/**
* Add a country to the selection.
*
* A click goes through this same method, so both routes agree about what
* `selectMode` means: 'single' replaces, 'multiple' accumulates. An id
* nothing answers to is refused rather than carried along unseen, and
* selecting what is already selected changes nothing and announces
* nothing.
*
* @param {string} id A country id from the dataset.
* @returns {Object} The map, for chaining.
* @alias Geomap#select
*/
map.select = function (id) {
// An id nothing answers to would be carried in the selection for ever:
// rebuildPick warns about it and draws nothing, and `selected()` would
// keep reporting a country that does not exist.
if (ds && !byId[id]) { G.warn('unknown id "' + id + '"'); return map; }
if (picked[id]) { return map; }
// 'single' means one at a time however the selection is made. Clicking
// already replaced; calling select() used to accumulate, so the two
// routes disagreed about what the mode meant.
if (attr.selectMode === 'single') { picked = {}; }
picked[id] = true;
rebuildPick();
rebuildLL();
redraw(current);
board.update();
announceSelection(id, true);
return map;
};
/**
* Take a country out of the selection. Deselecting what is not selected
* changes nothing and announces nothing.
*
* @param {string} id A country id.
* @returns {Object} The map, for chaining.
* @alias Geomap#deselect
*/
map.deselect = function (id) {
// Nothing changed, so nothing is announced.
if (!picked[id]) { return map; }
delete picked[id];
rebuildPick();
rebuildLL();
redraw(current);
board.update();
announceSelection(id, false);
return map;
};
/**
* The current selection.
*
* @returns {Array<string>} Country ids, in the order they were added.
* @alias Geomap#selected
*/
map.selected = function () { return Object.keys(picked); };
/**
* Empty the selection. Announces once, and only if it was not empty.
*
* @returns {Object} The map, for chaining.
* @alias Geomap#clearSelection
*/
map.clearSelection = function () {
if (!Object.keys(picked).length) { return map; } // nothing to announce
picked = {};
rebuildPick();
rebuildLL();
redraw(current);
board.update();
announceSelection(null, false);
return map;
};
/**
* Draw a set of named features: equator, tropics, polar circles, the
* prime meridian, the date line, the nominal time zones, the terminator.
*
* They replace whatever was shown before, so an empty list clears them.
*
* @param {Array<string>} list Keys of `G.features`.
* @returns {Object} The map, for chaining.
* @alias Geomap#showFeatures
*/
map.showFeatures = function (list) {
srcFeat = [];
(list || []).forEach(function (k) {
var gen = G.features[k];
if (!gen) { G.warn('unknown feature "' + k + '"'); return; }
gen().forEach(function (seg) { srcFeat.push(seg); });
});
layers.features.active = srcFeat.length > 0;
layers.features.curve.setAttribute({ visible: layers.features.active });
state.forget(mapId + 'features');
rebuildLL(); redraw(current); board.update();
return map;
};
/**
* Show what the projection does to shape and size.
*
* 'tissot' draws circles of equal angular radius on a lattice: whatever
* the projection does to them, it does to everything there. 'none' clears.
*
* @param {string} mode 'tissot' or 'none'.
* @returns {Object} The map, for chaining.
* @alias Geomap#showDistortion
*/
map.showDistortion = function (mode) {
srcTissot = [];
if (mode === 'tissot') {
srcTissot = G.tissot({ step: attr.distortion.step, radius: attr.distortion.radius,
latMax: attr.distortion.latMax });
} else if (mode && mode !== 'none') { G.warn('unknown distortion mode "' + mode + '"'); }
layers.distortion.active = srcTissot.length > 0;
layers.distortion.curve.setAttribute({ visible: layers.distortion.active });
state.forget(mapId + 'distortion');
rebuildLL(); redraw(current); board.update();
return map;
};
/**
* Switching a layer off empties its arrays and redraws it once, so no
* stale geometry can survive in the renderer: a curve that keeps its old
* points would still be drawn even though the layer reports none.
*
* @param {string} name A layer name: ocean, land, coast, countries,
* capitals, graticule, or one returned by addGeoLayer.
* @param {boolean} on Whether to draw it.
* @returns {Object} The map, for chaining.
* @alias Geomap#setLayer
*/
map.setLayer = function (name, on) {
if (!layers[name]) { G.warn('unknown layer "' + name + '"'); return map; }
var sh = layers[name];
sh.active = on;
sh.curve.setAttribute({ visible: on });
if (!on) {
// Empty the arrays and push that through the renderer. A curve that
// keeps its old points would still be drawn, even though the layer
// reports none.
sh.ll = [];
sh.X.length = 0;
sh.Y.length = 0;
if (typeof sh.curve.fullUpdate === 'function') { sh.curve.fullUpdate(); }
}
rebuildLL(); redraw(current); board.update();
return map;
};
/**
* Selection and hovering are switchable at any time. selectMode 'none'
* still fires countryclick and geoclick — the events are information, the
* mode only decides whether the element keeps a selection of its own.
* Set picking to false to suppress the point-in-polygon lookup entirely.
*
* @param {?string} id What changed, or null for a wholesale change.
* @param {?boolean} added True when it entered the selection, false
* when it left, null when the whole set was replaced.
* @returns {void}
*/
function announceSelection(id, added) {
map.triggerEventHandlers(['selectionchange'],
[{ selected: Object.keys(picked), id: id, added: added }]);
}
/**
* Show or hide the stand-in circles for countries with no outline. They
* are symbols at the wrong size and shape, so a map meant to be measured
* from should not carry them.
*
* @param {boolean} on Whether to draw them.
* @returns {Object} The map, for chaining.
* @alias Geomap#setSmallCountries
*/
map.setSmallCountries = function (on) {
attr.showSmallCountries = !!on;
rebuildCountries();
// The highlight is built from the same rings, so it has to follow. It
// did not, and a selected micro-state kept its circle after the layer
// beneath had dropped it.
rebuildPick();
rebuildLL(); redraw(current); board.update();
return map;
};
/**
* Replace the whole selection in one step, then announce it once.
*
* @param {Array<string>} ids Country ids. Unknown ones are refused,
* and under `selectMode: single` the last one named wins.
* @returns {Object} The map, for chaining.
* @alias Geomap#setSelection
*/
map.setSelection = function (ids) {
var next = {};
(ids || []).forEach(function (i) {
if (ds && !byId[i]) { G.warn('unknown id "' + i + '"'); return; }
next[i] = true;
});
// 'single' means one at a time here too, and the last one named wins —
// the same rule a click follows.
if (attr.selectMode === 'single') {
var keys = Object.keys(next);
if (keys.length > 1) { next = {}; next[keys[keys.length - 1]] = true; }
}
// Nothing changed, so nothing is announced. Without this a pair of
// hosts kept in step through selectionchange feed each other for ever:
// each announcement calls the other's setSelection, which announces
// again. Measured before the guard: a stack overflow.
var before = Object.keys(picked).sort().join(',');
var after = Object.keys(next).sort().join(',');
if (before === after) { return map; }
picked = next;
rebuildPick(); rebuildLL(); redraw(current); board.update();
announceSelection(null, null);
return map;
};
/**
* How a click builds a selection: 'none', 'single' or 'multiple'.
*
* @param {string} mode One of the three; anything else warns and is
* ignored.
* @returns {Object} The map, for chaining.
* @alias Geomap#setSelectMode
*/
map.setSelectMode = function (mode) {
if (['none', 'single', 'multiple'].indexOf(mode) < 0) {
G.warn('unknown selectMode "' + mode + '"');
return map;
}
attr.selectMode = mode;
if (mode === 'none') { map.clearSelection(); }
return map;
};
/**
* The selection mode in force.
*
* @returns {string} 'none', 'single' or 'multiple'.
* @alias Geomap#selectMode
*/
map.selectMode = function () { return attr.selectMode; };
/**
* Whether moving the pointer sends countryover and countryout.
*
* Switching it off says goodbye to the country under the pointer first,
* so a listener is not left believing the pointer never moved away.
*
* @param {boolean} on Whether to report hovering.
* @returns {Object} The map, for chaining.
* @alias Geomap#setHoverCountries
*/
map.setHoverCountries = function (on) {
// Leaving `hovered` set means no countryout is ever sent for it, and
// switching back on over the same country sends no countryover either:
// the map thinks the pointer never left.
if (!on && hovered) {
map.triggerEventHandlers(['countryout'], [{ id: hovered }]);
hovered = null;
}
attr.hoverCountries = !!on;
return map;
};
/**
* Whether a click looks up which country was hit at all. With it off the
* map still reports geoclick, but neither countryclick nor the selection.
*
* @param {boolean} on Whether to identify countries.
* @returns {Object} The map, for chaining.
* @alias Geomap#setPicking
*/
map.setPicking = function (on) { attr.picking = !!on; return map; };
/**
* How far the pointer may travel between press and release and still
* count as a click rather than a drag.
*
* @param {number} px Distance in pixels.
* @returns {Object} The map, for chaining.
* @alias Geomap#destroy
* @alias Geomap#setClickTolerance
*/
map.setClickTolerance = function (px) { attr.clickTolerance = px; return map; };
// Composition already owns remove(what); this one tears the map down.
map.destroy = function () {
if (destroyed) { return map; }
destroyed = true;
// A board without `off` cannot take a listener back, so the guard above
// is what stops a destroyed map from answering a pointer move.
undoListeners.forEach(function (undo) { undo(); });
undoListeners.length = 0;
if (state) { G.clipCache.release(state); state = null; }
shapes.forEach(function (sh) { if (board.removeObject) { board.removeObject(sh.curve); } });
return map;
};
// ------------------------------------------------------------ interaction
/**
* Tap detection.
*
* The board's own 'up' event is fed by touchend, which carries no touch
* points, so its coordinates are NaN. Pointer events on the container do
* carry clientX/clientY on release, so they are used where available and
* the board events serve as the fallback (and keep the tests working).
*/
var tapInfo = { down: 0, move: 0, up: 0, mode: '?', lastMoved: null,
lastCoords: null, rejected: 0, fired: 0 };
/**
* What the tap detector has seen: counts of down, move and up, whether it
* is reading pointer events or board events, and how far the last gesture
* travelled.
*
* For diagnosis. A tap that does not register is nearly impossible to
* investigate from the outside, and on touch devices the board's own 'up'
* carries no coordinates at all.
*
* @returns {Object} A live view of the counters.
* @alias Geomap#tapState
*/
map.tapState = function () { return tapInfo; };
function tapTracker(onTap) {
var downAt = null, lastAt = null, travelled = 0;
var el = board.containerObj, useDom = !!(el && el.addEventListener &&
typeof root.PointerEvent === 'function');
tapInfo.mode = useDom ? 'dom' : 'board';
function coords(e) {
var c = board.getUsrCoordsOfMouse(e);
return (isFinite(c[0]) && isFinite(c[1])) ? { x: c[0], y: c[1] } : null;
}
function down(e) {
var c = coords(e);
tapInfo.down++;
tapInfo.lastCoords = c ? [+c.x.toFixed(3), +c.y.toFixed(3)] : 'NaN';
if (!c) { return; }
downAt = c; lastAt = c; travelled = 0;
}
function move(e) {
if (!downAt) { return; }
tapInfo.move++;
var c = coords(e);
if (!c) { return; }
travelled = Math.max(travelled,
Math.hypot(c.x - downAt.x, c.y - downAt.y) * (board.unitX || 1));
lastAt = c;
}
function up(e) {
tapInfo.up++;
if (!downAt) { tapInfo.lastMoved = 'no down'; return; }
var c = coords(e) || lastAt, moved;
moved = Math.max(travelled,
Math.hypot(c.x - downAt.x, c.y - downAt.y) * (board.unitX || 1));
downAt = null; lastAt = null;
tapInfo.lastMoved = +moved.toFixed(1);
if (moved > attr.clickTolerance) { tapInfo.rejected++; return; } // a drag
tapInfo.fired++;
onTap(c, e);
}
if (useDom) {
var cancel = function () { downAt = null; };
el.addEventListener('pointerdown', down);
el.addEventListener('pointermove', move);
el.addEventListener('pointerup', up);
el.addEventListener('pointercancel', cancel);
undoListeners.push(function () {
el.removeEventListener('pointerdown', down);
el.removeEventListener('pointermove', move);
el.removeEventListener('pointerup', up);
el.removeEventListener('pointercancel', cancel);
});
} else {
board.on('down', down);
board.on('move', move);
board.on('up', up);
undoListeners.push(function () {
if (!board.off) { return; }
board.off('down', down); board.off('move', move); board.off('up', up);
});
}
}
tapTracker(function (pt, e) {
if (destroyed) { return; }
var g = map.toGeo(pt.x, pt.y), id;
if (!g) { return; }
map.triggerEventHandlers(['geoclick'], [{ lon: g[0], lat: g[1], originalEvent: e }]);
if (attr.recenterOn === 'click') { map.setCenter(g[0], g[1]); }
if (!ds || !attr.picking) { return; }
id = G.countryAt(ds, g[0], g[1]);
if (!id) { return; }
map.triggerEventHandlers(['countryclick'],
[{ id: id, name: byId[id] && byId[id].name, lon: g[0], lat: g[1], originalEvent: e }]);
// Through the public methods, not beside them. The two used to hold
// their own copies of what a mode means, and they drifted apart: a click
// replaced the selection in 'single' while select() accumulated.
if (attr.selectMode === 'single') {
map.select(id);
} else if (attr.selectMode === 'multiple') {
if (picked[id]) { map.deselect(id); } else { map.select(id); }
}
});
// Attached unconditionally so hovering can be switched on later.
if (ds) {
var onHover = function (e) {
if (destroyed) { return; }
if (!attr.hoverCountries || !attr.picking) { return; }
var c = board.getUsrCoordsOfMouse(e), g = map.toGeo(c[0], c[1]);
var id = g ? G.countryAt(ds, g[0], g[1]) : null;
if (id === hovered) { return; }
if (hovered) { map.triggerEventHandlers(['countryout'], [{ id: hovered }]); }
hovered = id;
if (id) {
map.triggerEventHandlers(['countryover'],
[{ id: id, name: byId[id] && byId[id].name, lon: g[0], lat: g[1] }]);
}
};
board.on('move', onHover);
undoListeners.push(function () {
if (board.off) { board.off('move', onHover); }
});
}
// ------------------------------------------------------------------ init
// Members, named sub-elements and parents, following the pattern JSXGraph
// uses for its own composite elements. dump = false keeps the internal
// curves out of a construction dump.
map.subs = {};
shapes.forEach(function (sh) {
members[sh.name] = sh.curve;
if (map.add) { map.add(sh.name, sh.curve); }
map.subs[sh.name] = sh.curve;
sh.curve.dump = false;
});
if (typeof map.setParents === 'function') { map.setParents([]); }
acquireState();
Object.keys(attr.highlight || {}).forEach(function (id) { picked[id] = attr.highlight[id]; });
rebuildPick();
if (attr.features.show && attr.features.show.length) { map.showFeatures(attr.features.show); }
if (attr.distortion.mode && attr.distortion.mode !== 'none') { map.showDistortion(attr.distortion.mode); }
rebuildLL();
redraw(current);
return map;
};
JXG.registerElement('geomap', JXG.createGeomap);
if (typeof module === 'object' && module.exports) { module.exports = JXG.createGeomap; }
}(typeof globalThis !== 'undefined' ? globalThis : this));