Source: jxg-geomap.js

/*
    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));