Source: jxg-globe3d.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 — globe3d element.
 *
 * A globe is a camera plus a body, so this inherits from view3d rather than
 * from sphere3d: the axes, planes and sliders are switched off once here
 * instead of in every call, and lookAt / spin / project3DTo2D sit where they
 * belong.
 *
 *     var globe = board.create('globe3d', [], {
 *         data: JXG.Geography.datasets.naturalEarth110,
 *         countries: { visible: true },
 *         highlight: { DEU: 'green' }
 *     });
 *
 * Requires jxg-geography.js.
 *
 *
 * CONVENTIONS
 *
 * Positions are [lon, lat] in degrees, longitude first, and the body is the
 * unit sphere scaled by `scale`. `at(bx, by)` and `fromGeo(lon, lat)` are
 * inverses: measured over 289 points of the visible face, the worst
 * round-trip error is 7e-14 degrees. `at` answers null where the ray misses
 * the sphere; `fromGeo` always answers, and says with `front` whether the
 * point faces the camera.
 *
 * `lookAt` is the only supported way to aim. Slider.setValue clamps hard and
 * updateAngleSliderBounds picks different ranges depending on the trackball,
 * so a raw setView latches at 90 degrees east or anywhere in the southern
 * hemisphere.
 *
 * Every ring layer states its winding, and the globe uses the stated value as
 * given — unlike the map, which imposes its own. The measure is
 * `orientationOf`, read on the sphere: the planar shoelace misreads Tuvalu at
 * 179.2 degrees and five of the forty Tissot circles, all of them on the
 * antimeridian.
 *
 * A selection follows one rule whichever way it is made. A tap goes through
 * `select` and `deselect`, so those and `setSelection` agree about what
 * `selectMode` means, refuse an id nothing answers to, and say nothing when
 * nothing changed.
 *
 * Sizes follow the drawn size. `pixelRadius()` reports the sphere's radius in
 * device pixels, and everything sampled — layers, and the geo elements above
 * them — derives its resolution from that rather than from a fixed count.
 *
 * `destroy()` gives back every listener the globe installed: five per globe,
 * and there was no way back before. Anything that animates checks for a frame
 * timer first and does the change at once where there is none.
 *
 * Known limits: the far side is drawn by a second pass rather than by depth
 * sorting, so two layers behind the body do not occlude one another. The
 * trackball is JSXGraph's, and `keepUpright` corrects the bank after the
 * fact rather than constraining the gesture.
 */
/**
 * A globe: a camera and a body, inheriting from `view3d`. Nothing is
 * projected — the geometry stays on the sphere and the horizon does the
 * clipping.
 *
 * Created with `board.create('globe3d', [corner, size], attributes)`.
 *
 * @namespace Globe3D
 */
(function (root) {
  'use strict';

  var JXG = root.JXG;
  var G = JXG.Geography;
  var RAD = G.RAD, TAU = 2 * Math.PI;

  /**
    * An angle brought into [0, 2pi).
    *
    * @param {number} a  An angle in radians, anywhere on the line.
    * @returns {number} The same angle in [0, 2pi).
    */
  function wrapTau(a) { return ((a % TAU) + TAU) % TAU; }
  /**
    * A value held inside a closed interval.
    *
    * @param {number} v  The value.
    * @param {number} a  Lower bound.
    * @param {number} b  Upper bound.
    * @returns {number} The value, or whichever bound it passed.
    */
  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: 12, lod: 'always',
    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.
   */
  /**
   * Is there a frame timer here?
   *
   * A page rendered on a server, or a test, has none, and asking for one
   * threw. Anything that animates checks first and does the whole change at
   * once instead, which is the honest fallback: the end state is reached,
   * only without the motion.
   *
   * @returns {boolean} True where requestAnimationFrame can be called.
   */
  function canAnimate() {
    return typeof root.requestAnimationFrame === 'function' &&
           typeof root.cancelAnimationFrame === 'function';
  }

  /**
    * 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;
  }
  /**
    * Attributes merged one level deep.
    *
    * A layer's style is a sub-object, so a caller naming one property of it
    * must not lose the rest. Anything that is not a plain object — an array,
    * a colour, a number — replaces rather than merges.
    *
    * @param {Object} base  The defaults.
    * @param {Object} [over]  What the caller asked for.
    * @returns {Object} A new object; neither argument is touched.
    */
  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 globe: a camera and a body, with geographic layers on it.
   * @pseudo
   * @name Globe3D
   * @augments JXG.View3D
   * @constructor
   * @type JXG.View3D
   * @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, as for view3d. Omit both to fill
   *        the board.
   *
   * @example
   *     var globe = board.create('globe3d', [], {
   *         data: JXG.Geography.datasets.naturalEarth110,
   *         farSide: { visible: true }
   *     });
   *     globe.lookAt(10, 48);
   */
  JXG.Options.globe3d = {
    /**#@+
     * @visprop
     */

    data: null,
    smallCountryRadius: 1.15,
    showSmallCountries: true,   // stand-in circles for countries with no outline

    // The globe has no radius attribute. Its size comes from the drawing
    // rectangle [w, h] and from the board's bounding box, exactly like every
    // other JSXGraph element; the world cube is fixed at the unit sphere.
    // `scale` is a pure display factor, not geometry: 0 draws nothing.
    scale: 1,
    body: {
      center: [0, 0, 0],
      fillColor: '#0e2739', fillOpacity: 1, gradient: null,
      strokeColor: '#2a5c7d', strokeWidth: 1.2, layer: 10
    },
    land:      { visible: true,  fillColor: '#dcd3bf', fillOpacity: 1,
                 strokeColor: 'inherit', strokeWidth: 0.8, layer: 11 },
    coast:     { visible: true,  strokeColor: '#8d8471', strokeWidth: 0.9, layer: 12 },
    countries: { visible: false, strokeColor: '#a89a7d', strokeWidth: 0.55,
                 strokeOpacity: 0.75, layer: 12, lod: 'still' },
    capitals:  { visible: false, fillColor: '#cf9b3f', strokeColor: '#071018',
                 strokeWidth: 0.5, size: 0.62, layer: 13, lod: 'still' },
    graticule: { visible: true,  strokeColor: '#1b4059', strokeWidth: 0.7,
                 strokeOpacity: 0.7, step: [30, 30], layer: 12 },

    farSide:   { visible: false, fillColor: '#dcd3bf', fillOpacity: 0.2,
                 strokeColor: '#dcd3bf', strokeOpacity: 0.35, strokeWidth: 0.6, layer: 9 },

    highlight: {},
    highlightStyle: { fillColor: '#cf9b3f', fillOpacity: 0.42,
                      strokeColor: '#cf9b3f', strokeWidth: 1.2, layer: 14 },

    features:  { show: [], strokeColor: '#5fb0c6', strokeWidth: 1.1,
                 strokeOpacity: 0.9, layer: 13 },
    distortion:{ mode: 'none', step: 30, radius: 6, latMax: 60,
                 strokeColor: '#cf9b3f', strokeWidth: 1,
                 fillColor: '#cf9b3f', fillOpacity: 0.16, layer: 13 },

    rotate: 'free',
    keepUpright: false,
    trackballReset: true,
    clickTolerance: 12,       // px a pointer may travel and still count as a tap;
    // a finger is far less steady than a mouse
    selectMode: 'none',       // 'none' | 'single' | 'multiple'
    hoverCountries: false,
    picking: true,            // false skips the country lookup altogether
    lod: { moving: null, still: 'all' },

    // view3d defaults suitable for a globe
    projection: 'parallel',
    trackball: { enabled: true },
    depthOrder: { enabled: false },
    az: { slider: { visible: false } },
    el: { slider: { visible: false } },
    bank: { slider: { visible: false } },
    xAxis: { visible: false }, yAxis: { visible: false }, zAxis: { visible: false },
    xPlaneRear: { visible: false }, yPlaneRear: { visible: false }, zPlaneRear: { visible: false },
    xPlaneFront: { visible: false }, yPlaneFront: { visible: false }, zPlaneFront: { visible: false },
    xAxisBorder: { visible: false }, yAxisBorder: { visible: false }, zAxisBorder: { visible: false },
    infobox: { visible: false }

    /**#@-*/
  };

  JXG.createGlobe3D = function (board, parents, attributes) {
    var attr = mergeAttr(JXG.Options.globe3d, attributes);
    var ds = attr.data;
    if (!ds) { G.warn('globe3d needs a data attribute'); }

    // ---------------------------------------------------------- geometry
    // A display factor on the unit sphere, so it may be animated freely
    // without ever touching the world cube.
    var scaleFn = (typeof attr.scale === 'function')
      ? attr.scale
      : function () { return attr.scale; };
    function scaleNow() { return scaleFn(); }

    var HALF = 1.15;                       // world cube, fixed
    var pos = parents && parents[0] ? parents[0] : null;
    var size = parents && parents[1] ? parents[1] : null;
    if (!pos || !size) {
      var bb = board.getBoundingBox();
      var w = Math.min(bb[2] - bb[0], bb[1] - bb[3]) * 0.94;
      pos = [(bb[0] + bb[2]) / 2 - w / 2, (bb[3] + bb[1]) / 2 - w / 2];
      size = [w, w];
    }
    var cube = [[-HALF, HALF], [-HALF, HALF], [-HALF, HALF]];

    var view = board.create('view3d', [pos, size, cube], attr);

    // ------------------------------------------------------------ camera
    function frame() {
      var m = view.matrix3DRot;
      return { r1: [m[1][1], m[1][2], m[1][3]],
               r2: [m[2][1], m[2][2], m[2][3]],
               cam: [m[3][1], m[3][2], m[3][3]] };
    }
    function cameraVector() { return frame().cam; }
    function project(p) { return view.project3DTo2D(p); }

    /**
     * The only supported way to aim the camera. Slider.setValue clamps hard,
     * and updateAngleSliderBounds() picks different ranges depending on the
     * trackball: azimuth always [0, 2pi], elevation [-pi/2, pi/2] with the
     * trackball and [0, 2pi] without. A raw setView latches at 90 degrees
     * east, or anywhere in the southern hemisphere.
     *
     * @param {number} lon  Longitude to face, in degrees.
     * @param {number} lat  Latitude, held just short of the poles.
     * @returns {Object} The globe, for chaining.
          * @alias Globe3D#lookAt
     */
    view.lookAt = function (lon, lat) {
      var e = clamp(lat, -89.999, 89.999) * RAD;
      view.setView(wrapTau((90 - lon) * RAD),
                   view.el_slide._smin < 0 ? e : wrapTau(e));
      return view;
    };
    /**
        * Where the camera is looking.
        *
        * @returns {Array<number>} [lon, lat] in degrees, read back out of the
        *   rotation matrix.
                * @alias Globe3D#centre
        */
    view.centre = function () { return G.centreFromMatrix(view.matrix3DRot); };
    view.center = view.centre;
    view.scale = scaleFn;
    /**
     * Shrink the Earth so a whole orbit fits inside the world cube.
     *
     * @param {number} f  The new radius in world units. Zero or less is
     *   refused: negative mirrors the body through the centre and
     *   leaves `at` answering null everywhere.
     * @returns {Object} The globe, for chaining.
          * @alias Globe3D#setScale
     */
    view.setScale = function (f) {
      // A scale of zero or less is not a smaller globe. Negative mirrors the
      // body through the centre — measured: every point lands at minus where
      // it was — and it still draws, while `at` answers null everywhere, so
      // the globe cannot be clicked and nothing says why. Zero collapses it
      // to a point.
      var v = f;
      if (!(v > 0)) {
        G.warn('globe3d: a scale of ' + v + ' is not a size; keeping ' +
               scaleNow());
        return view;
      }
      scaleFn = function () { return v; };
      attr.scale = v;
      board.update();
      return view;
    };

    /**
     * Animated change of scale.
     *
     * Cheaper than the projection morph and needing none of its bookkeeping:
     * every layer multiplies by scaleNow() while it draws, so nothing has to
     * be rebuilt, no point counts have to match, and layers above the surface
     * follow by themselves because their factor is relative.
     *
     * @param {number} target  The radius to end at.
     * @param {Object} [opts]  `duration` in ms, `onEnd()`.
     * @returns {Object} The globe, for chaining. Without a frame timer the
     *   target is set at once and `onEnd` still runs.
          * @alias Globe3D#scaleTo
     */
    var scaleRaf = null;
    view.scaleTo = function (target, opts) {
      opts = opts || {};
      var from = scaleNow(), dur = opts.duration || 700, t0 = null;
      function ease(u) { return u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2; }
      view.stopScale();
      // No timer, no animation: the target is set at once, so the caller ends
      // up where it asked to be either way.
      if (!canAnimate()) {
        view.setScale(target);
        if (opts.onEnd) { opts.onEnd(); }
        return view;
      }
      function step(now) {
        if (t0 === null) { t0 = now; }
        var u = Math.min(1, (now - t0) / dur);
        view.setScale(from + (target - from) * ease(u));
        if (u < 1) {
          scaleRaf = root.requestAnimationFrame(step);
        } else {
          scaleRaf = null;
          if (opts.onEnd) { opts.onEnd(); }
        }
      }
      scaleRaf = root.requestAnimationFrame(step);
      return view;
    };
    /**
        * Cut a running scale animation short, leaving the radius where it is.
        *
        * @returns {Object} The globe, for chaining.
                * @alias Globe3D#stopScale
        */
    view.stopScale = function () {
      if (scaleRaf && canAnimate()) { root.cancelAnimationFrame(scaleRaf); }
      scaleRaf = null;
      return view;
    };

    /**
     * Screen point to geographic position, or null beside the globe.
     *
     * @param {number} bx  Board x.
     * @param {number} by  Board y.
     * @returns {?Array<number>} [lon, lat] in degrees, or null where the
     *   point misses the sphere.
          * @alias Globe3D#at
     */
    view.at = function (bx, by) {
      var f = frame(), r = scaleNow();
      if (!(r > 0)) { return null; }
      var o = project([0, 0, 0]);
      var a = project([f.r1[0] * r, f.r1[1] * r, f.r1[2] * r]);
      var b = project([f.r2[0] * r, f.r2[1] * r, f.r2[2] * r]);
      var a11 = a[1] - o[1], a12 = b[1] - o[1], a21 = a[2] - o[2], a22 = b[2] - o[2];
      var det = a11 * a22 - a12 * a21;
      if (Math.abs(det) < 1e-12) { return null; }
      var u = bx - o[1], v = by - o[2];
      var al = (a22 * u - a12 * v) / det, be = (-a21 * u + a11 * v) / det;
      var q = 1 - al * al - be * be;
      if (q < 0) { return null; }
      var g = Math.sqrt(q);
      return G.toGeo([al * f.r1[0] + be * f.r2[0] + g * f.cam[0],
                      al * f.r1[1] + be * f.r2[1] + g * f.cam[1],
                      al * f.r1[2] + be * f.r2[2] + g * f.cam[2]]);
    };
    view.toGeo = view.at;

    /**
     * Geographic position to screen; `front` says whether it faces us.
     *
     * @param {number} lon  Longitude in degrees.
     * @param {number} lat  Latitude in degrees.
     * @returns {{x: number, y: number, front: boolean}} Board position,
     *   and whether the point faces the camera.
          * @alias Globe3D#fromGeo
     */
    view.fromGeo = function (lon, lat) {
      var r = scaleNow(), v = G.toVector(lon, lat);
      var P = project([v[0] * r, v[1] * r, v[2] * r]);
      return { x: P[1], y: P[2], front: G.dot(v, cameraVector()) > 0 };
    };
    /**
        * Present so a host can be asked the same question as a map.
        *
        * A sphere has no strip to clip against — the horizon does that work —
        * so there is no shared state to hand out.
        *
        * @returns {null} Always.
                * @alias Globe3D#clipState
        */
    view.clipState = function () { return null; };
    /**
     * Screen radius of the sphere in pixels. Everything drawn on or above it
     * derives its sampling from this, so the resolution follows the size on
     * screen instead of a fixed count.
          * @alias Globe3D#pixelRadius
     */
    view.pixelRadius = function () {
      // The world cube [-HALF, HALF] is mapped into the drawing rectangle, so
      // one unit-sphere radius is size/(2*HALF) board units before unitX turns
      // it into pixels. Leaving that factor out under-reports the radius by
      // more than a factor of two, and everything sampled from it comes out
      // too coarse.
      var perCube = size[0] / (2 * HALF);
      return Math.abs(scaleNow() * perCube * (board.unitX || 1)) * G.devicePixels();
    };   // no strip clipping on a sphere

    // ------------------------------------------------------------ layers
    var layers = {}, moving = false;

    // Settings that belong to the element as a whole rather than to one
    // layer, and are therefore taken from the globe when a layer does not
    // name them. `tabindex: null` is the reason: the default of -1 leaves a
    // curve focusable by mouse, so clicking the body drew the browser's focus
    // ring around it, and setting it on the globe reached nothing. The map
    // carries the same list.
    var PASSED_THROUGH = ['tabindex', 'cssClass', 'highlightCssClass',
                          'needsRegularUpdate', 'nonReflexive'];

    function styleOf(name) {
      var a = attr[name] || {}, o = {}, k, i;
      for (k in LAYER_STYLE) { if (LAYER_STYLE.hasOwnProperty(k)) { o[k] = LAYER_STYLE[k]; } }
      for (i = 0; i < PASSED_THROUGH.length; i++) {
        k = PASSED_THROUGH[i];
        if (attr.hasOwnProperty(k)) { o[k] = attr[k]; }
      }
      for (k in a) { if (a.hasOwnProperty(k)) { o[k] = a[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 lodOf(name) { return (attr[name] && attr[name].lod) || 'always'; }

    function addLines(name, source, extra) {
      var st = styleOf(name), el;
      if (extra) { for (var k in extra) { if (extra.hasOwnProperty(k)) { st[k] = extra[k]; } } }
      el = view.create('curve3d', [[], [], []], st);
      G.maskedLayer(el, {
        segments: source, camera: cameraVector, project: project,
        radius: scaleNow, side: 1, active: st.visible
      });
      el.geoName = name; el.geoLod = lodOf(name);
      layers[name] = el;
      return el;
    }
    function addRings(name, source, side, extra, orients) {
      var st = styleOf(name), el;
      if (extra) { for (var k in extra) { if (extra.hasOwnProperty(k)) { st[k] = extra[k]; } } }
      el = view.create('curve3d', [[], [], []], st);
      G.maskedRings(el, {
        rings: source, camera: cameraVector, frame: frame, project: project,
        radius: scaleNow, side: side || 1, active: st.visible, orients: orients
      });
      el.geoName = name; el.geoLod = lodOf(name);
      layers[name] = el;
      return el;
    }

    // body ------------------------------------------------------------
    /**
     * What the body's tab index should be.
     *
     * @returns {?number} The globe's own setting where it has one, the body's
     *   where it names one, and JSXGraph's -1 otherwise.
     */
    function bodyTabindex() {
      if (attr.hasOwnProperty('tabindex')) { return attr.tabindex; }
      if (attr.body.tabindex !== undefined) { return attr.body.tabindex; }
      return -1;
    }

    var body = view.create('sphere3d', [attr.body.center, scaleNow], {
      layer: attr.body.layer,
      fillColor: attr.body.fillColor, fillOpacity: attr.body.fillOpacity,
      gradient: attr.body.gradient,
      strokeColor: attr.body.strokeColor, strokeWidth: attr.body.strokeWidth,
      highlight: false, fixed: true,
      // The body is the largest thing on the board and takes most clicks, so
      // it needs the pass-through settings as much as any layer does — with
      // `tabindex: null` first among them, or a click on the sphere draws the
      // browser's focus ring around the whole globe.
      tabindex: bodyTabindex(),
      // providePoints3D turns [0,0,0] into a real, labelled, draggable point;
      // the sub-object key it reads is 'center'
      center: { visible: false, fixed: true, withLabel: false, name: '',
                highlight: false, showInfobox: false }
    });
    view.body = body;

    // geometry sources, all on the unit sphere ---------------------------
    /**
     * Every ring layer carries its winding along. Deriving it here rather
     * than assuming counter-clockwise is what keeps a hole — or any ring a
     * dataset happens to store the other way round — from being closed over
     * the wrong arc, which fills the whole disc and inverts the layer.
     *
     * Read on the sphere, not in the plane. The planar shoelace is wrong for
     * exactly the rings that matter here: measured against the dataset, it
     * misreads Tuvalu at 179.2 degrees and five of the forty Tissot circles,
     * all of them sitting on the antimeridian. Unlike the map, the globe uses
     * the stated value as given.
     *
     * @param {Array<Array<Array<number>>>} llRings  Rings of [lon, lat].
     * @returns {Array<number>} One winding per ring, +1 or -1.
     */
    function signsOf(llRings) {
      return llRings.map(function (r) { return G.orientationOf(r); });
    }
    function toXYZ(rings, close) {
      return rings.map(function (r) {
        var q = r.map(function (p) { return G.toVector(p[0], p[1]); });
        if (close) { q.push(q[0]); }
        return q;
      });
    }
    var srcLand = [], srcLandOrient = [], srcCoast = [], srcCountries = [],
      srcCaps = [], srcCapsOrient = [], 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.map(function (p) { return G.toVector(p[0], p[1]); })
            .concat([G.toVector(r[0][0], r[0][1])]));
        });
      });
    }
    if (ds) {
      ds.land.polys.forEach(function (pl) {
        // first ring of a polygon is the outline, the rest are holes and run
        // the other way round — the clipper has to be told
        pl.forEach(function (r, i) {
          srcLand.push(r.map(function (p) { return G.toVector(p[0], p[1]); }));
          srcLandOrient.push(i === 0 ? 1 : -1);
        });
      });
      srcCoast = toXYZ(ds.coast.rings, true);
      rebuildCountries();
      var capLL = ds.capitals.map(function (c) {
        return G.smallCircle(c.lon, c.lat, attr.capitals.size, 10);
      });
      srcCapsOrient = signsOf(capLL);
      srcCaps = capLL.map(function (r) {
        return r.map(function (p) { return G.toVector(p[0], p[1]); });
      });
    }
    (function () {
      var st = attr.graticule.step, lon, lat, p, i;
      for (lon = -180; lon < 180; lon += st[0]) {
        p = [];
        for (i = -90; i <= 90; i += 2) { p.push(G.toVector(lon, i)); }
        srcGrat.push(p);
      }
      for (lat = -90 + st[1]; lat < 90; lat += st[1]) {
        p = [];
        for (i = -180; i <= 180; i += 2) { p.push(G.toVector(i, lat)); }
        srcGrat.push(p);
      }
    }());

    addRings('land', function () { return srcLand; }, 1, null,
      function () { return srcLandOrient; });
    addRings('farSide', function () { return srcLand; }, -1, null,
      function () { return srcLandOrient; });
    addLines('coast', function () { return srcCoast; });
    addLines('countries', function () { return srcCountries; });
    addRings('capitals', function () { return srcCaps; }, 1, null,
      function () { return srcCapsOrient; });
    addLines('graticule', function () { return srcGrat; });

    // highlight -------------------------------------------------------
    var picked = {}, byId = {};
    if (ds) { ds.countries.forEach(function (c) { byId[c.id] = c; }); }

    var srcPick = [], srcPickOrient = [];
    function rebuildPick() {
      srcPick = []; srcPickOrient = [];
      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.map(function (p) { return G.toVector(p[0], p[1]); }));
          srcPickOrient.push(G.orientationOf(r));
        });
      });
      if (layers.highlight) {
        layers.highlight.geoActive = srcPick.length > 0;
        layers.highlight.setAttribute({ visible: srcPick.length > 0 });
      }
    }
    addRings('highlightStyle', function () { return srcPick; }, 1, { visible: false },
      function () { return srcPickOrient; });
    layers.highlight = layers.highlightStyle;
    delete layers.highlightStyle;
    Object.keys(attr.highlight || {}).forEach(function (id) { picked[id] = attr.highlight[id]; });
    rebuildPick();

    /**
     * Add a country to the selection.
     *
     * A tap goes through this same method, so both routes agree about what
     * `selectMode` means: 'single' replaces, 'multiple' accumulates. The map
     * had all three of the faults below, and so did this.
     *
     * @param {string} id  A country id from the dataset.
     * @returns {Object} The globe, for chaining.
          * @alias Globe3D#select
     */
    view.select = function (id) {
      // An id nothing answers to would sit in the selection for ever:
      // rebuildPick warns and draws nothing, while `selected()` goes on
      // reporting a country that does not exist.
      if (ds && !byId[id]) { G.warn('unknown id "' + id + '"'); return view; }
      // Nothing changed, so nothing is announced.
      if (picked[id]) { return view; }
      if (attr.selectMode === 'single') { picked = {}; }
      picked[id] = true;
      rebuildPick();
      board.update();
      announceSelection(id, true);
      return view;
    };

    /**
     * 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 globe, for chaining.
          * @alias Globe3D#deselect
     */
    view.deselect = function (id) {
      if (!picked[id]) { return view; }
      delete picked[id];
      rebuildPick();
      board.update();
      announceSelection(id, false);
      return view;
    };

    /**
     * The current selection.
     *
     * @returns {Array<string>} Country ids, in the order they were added.
          * @alias Globe3D#selected
     */
    view.selected = function () { return Object.keys(picked); };

    /**
     * Empty the selection. Announces once, and only if it was not empty.
     *
     * @returns {Object} The globe, for chaining.
          * @alias Globe3D#clearSelection
     */
    view.clearSelection = function () {
      if (!Object.keys(picked).length) { return view; }
      picked = {};
      rebuildPick();
      board.update();
      announceSelection(null, false);
      return view;
    };

    // features and distortion ------------------------------------------
    var srcFeat = [];
    function rebuildFeatures(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.map(function (p) { return G.toVector(p[0], p[1]); }));
        });
      });
      if (layers.features) {
        layers.features.geoActive = srcFeat.length > 0;
        layers.features.setAttribute({ visible: srcFeat.length > 0 });
      }
    }
    addLines('features', function () { return srcFeat; }, { visible: false });
    rebuildFeatures(attr.features.show);
    /**
        * 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 globe, for chaining.
                * @alias Globe3D#showFeatures
        */
    view.showFeatures = function (list) { rebuildFeatures(list); board.update(); return view; };

    var srcTissot = [], srcTissotOrient = [];
    function rebuildDistortion(mode) {
      srcTissot = []; srcTissotOrient = [];
      if (mode === 'tissot') {
        G.tissot({ step: attr.distortion.step, radius: attr.distortion.radius,
                   latMax: attr.distortion.latMax }).forEach(function (r) {
          srcTissot.push(r.map(function (p) { return G.toVector(p[0], p[1]); }));
          srcTissotOrient.push(G.orientationOf(r));
        });
      } else if (mode && mode !== 'none') { G.warn('unknown distortion mode "' + mode + '"'); }
      if (layers.distortion) {
        layers.distortion.geoActive = srcTissot.length > 0;
        layers.distortion.setAttribute({ visible: srcTissot.length > 0 });
      }
    }
    addRings('distortion', function () { return srcTissot; }, 1, { visible: false },
      function () { return srcTissotOrient; });
    rebuildDistortion(attr.distortion.mode);
    /**
        * Show what a projection does to shape and size — on the globe, where it
        * does nothing, which is the point of drawing it here as well.
        *
        * @param {string} mode  'tissot' or 'none'.
        * @returns {Object} The globe, for chaining.
                * @alias Globe3D#showDistortion
        */
    view.showDistortion = function (mode) { rebuildDistortion(mode); board.update(); return view; };

    view.layers = layers;

    /**
     * Public layer factory, so geo elements need no private access.
     * kind is 'lines' or 'rings'; source and orients are functions, read on
     * every frame, which is what lets a marker move without a rebuild.
     */
    /**
         * A real point3d at a geographic position, for display.
         *
         * Carrying visProp.element3d it stays in the update list while the view
         * is being dragged, and it takes part in the layer order — neither is
         * true of a plain 2D point, which has to be repositioned and redrawn by
         * hand. Dragging is not offered here: Point3D can glide on a slide
         * element, but sphere3d has projectScreenCoords commented out, and the
         * documentation states that only line3d is supported as a glider host.
         * So display goes through this, dragging through a 2D handle.
         */
    /**
        * A JSXGraph point3d that sits on the sphere and follows two functions.
        *
        * Useful where something else decides where it should be — a slider, a
        * clock, an animation — and the point is to be dragged or measured with
        * the rest of the 3D scene.
        *
        * @param {function(): number} lonFn  Yields the longitude in degrees.
        * @param {function(): number} latFn  Yields the latitude in degrees.
        * @param {Object} [style]  Attributes for the point3d.
        * @returns {Object} The point3d.
                * @alias Globe3D#geoPoint3D
        */
    view.geoPoint3D = function (lonFn, latFn, style) {
      var lo = typeof lonFn === 'function' ? lonFn : function () { return lonFn; };
      var la = typeof latFn === 'function' ? latFn : function () { return latFn; };
      function v() {
        var p = G.toVector(lo(), la()), r = scaleNow();
        return [p[0] * r, p[1] * r, p[2] * r];
      }
      var p3 = view.create('point3d',
        [function () { return v()[0]; },
         function () { return v()[1]; },
         function () { return v()[2]; }],
        Object.assign({ size: 3, withLabel: false, name: '', fixed: true,
                        highlight: false, gradient: null, strokeWidth: 0.8,
                        fillColor: '#cf9b3f', strokeColor: '#071018' }, style || {}));
      p3.isFront = function () { return G.dot(G.toVector(lo(), la()), cameraVector()) > 0; };
      return p3;
    };

    /**
        * A layer of your own geometry, clipped against the horizon like the
        * others.
        *
        * @param {string} kind  "rings" for filled shapes, anything else for
        *   lines.
        * @param {function(): Array} source  Yields the geometry, as rings or
        *   polylines of [lon, lat] in degrees. Read again on every refresh.
        * @param {Object} [style]  Curve attributes. Settings made once on the
        *   globe — `tabindex` above all — reach it too, and this overrides them.
        * @param {function(): Array<number>} [orients]  The winding of each ring,
        *   +1 or -1. Read on the sphere: the planar shoelace misreads a ring at
        *   a pole or across the antimeridian, and this value is used as given.
        * @returns {Object} The layer: `refresh()` and `setVisible(on)`.
                * @alias Globe3D#addGeoLayer
        */
    view.addGeoLayer = function (kind, source, style, orients) {
      var st = {}, k, i;
      for (k in LAYER_STYLE) { if (LAYER_STYLE.hasOwnProperty(k)) { st[k] = LAYER_STYLE[k]; } }
      // Settings made once on the globe reach a layer added from outside as
      // well. They did not, and `tabindex: null` is why that matters: a path
      // or a point drawn this way stayed focusable by mouse, so clicking it
      // drew the browser's ring around the whole curve.
      for (i = 0; i < PASSED_THROUGH.length; i++) {
        k = PASSED_THROUGH[i];
        if (attr.hasOwnProperty(k)) { st[k] = attr[k]; }
      }
      for (k in (style || {})) { if (style.hasOwnProperty(k)) { st[k] = style[k]; } }
      // A layer added from outside is visible unless asked otherwise; the
      // internal default is the opposite, which would hide it silently.
      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; }

      // The contract is lon/lat for both hosts. The globe needs unit vectors,
      // so the conversion happens here rather than in the caller — otherwise
      // the same source would have to be written twice.
      function asVectors() {
        return source().map(function (seg) {
          return seg.map(function (p) { return G.toVector(p[0], p[1]); });
        });
      }
      // geoSide -1 puts the layer behind the horizon, the way farSide does
      // for the land — the caller decides, the mechanism is the same.
      var gs = st.geoSide === -1 ? -1 : 1;
      // geoRadius > 1 lifts the layer off the surface, in Earth radii
      var kr = st.geoRadius > 0 ? st.geoRadius : 1;
      delete st.geoSide; delete st.geoRadius;
      var el = view.create('curve3d', [[], [], []], st);
      if (kind === 'rings') {
        G.maskedRings(el, { rings: asVectors, camera: cameraVector, frame: frame,
                            project: project,
                            radius: function () { return scaleNow() * kr; },
                            side: gs, active: st.visible, orients: orients,
                            occlude: kr });
      } else {
        G.maskedLayer(el, { segments: asVectors, camera: cameraVector,
                            project: project,
                            radius: function () { return scaleNow() * kr; },
                            side: gs, active: st.visible, occlude: kr });
      }
      el.geoLod = 'always';
      el.geoWanted = st.visible;
      // The map redraws when a layer is added; the globe has to be told, or a
      // region only shows up the next time something else touches the board.
      board.update();
      el.refresh = function () { board.update(); return el; };
      el.setVisible = function (on) {
        el.geoActive = on; el.geoWanted = on; el.setAttribute({ visible: on });
        board.update(); return el;
      };
      return el;
    };

    // ------------------------------------------------------- level of detail
    function applyLod() {
      var still = attr.lod.still, keep = attr.lod.moving;
      Object.keys(layers).forEach(function (n) {
        var el = layers[n], on;
        if (!moving || keep === null || still === 'all') {
          on = el.geoWanted !== false;
        } else {
          on = el.geoWanted !== false && (el.geoLod === 'always' || keep.indexOf(n) >= 0);
        }
        el.geoActive = on;
        el.setAttribute({ visible: on });
      });
    }
    /**
        * Switch a layer on or off.
        *
        * @param {string} name  A layer name: land, farSide, coast, countries,
        *   capitals, graticule, or one returned by addGeoLayer.
        * @param {boolean} on  Whether to draw it.
        * @returns {Object} The globe, for chaining.
                * @alias Globe3D#setLayer
        */
    view.setLayer = function (name, on) {
      if (!layers[name]) { G.warn('unknown layer "' + name + '"'); return view; }
      layers[name].geoWanted = on;
      applyLod();
      board.update();
      return view;
    };
    Object.keys(layers).forEach(function (n) { layers[n].geoWanted = layers[n].geoActive; });

    // -------------------------------------------------------- interaction

    /**
         * 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.
         */
    /**
        * Report a change of selection.
        *
        * @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) {
      view.triggerEventHandlers(['selectionchange'],
        [{ selected: Object.keys(picked), id: id, added: added }]);
    }

    /** Replace the whole selection in one step, then announce it once. */
    /**
     * 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.
     */
    /**
     * Whether the 29 countries with no outline are drawn as stand-in circles.
     *
     * @param {boolean} on  Whether to draw them.
     * @returns {Object} The globe, for chaining.
          * @alias Globe3D#setSmallCountries
     */
    view.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. The map had the same fault.
      rebuildPick();
      board.update();
      return view;
    };

    /**
     * Replace the whole selection at once.
     *
     * @param {Array<string>} ids  Country ids. Unknown ones are refused, and
     *   under `selectMode: single` the last one named wins.
     * @returns {Object} The globe, for chaining.
          * @alias Globe3D#setSelection
     */
    view.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 tap follows. This route used to obey neither.
      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 view; }
      picked = next;
      rebuildPick(); board.update();
      announceSelection(null, null);
      return view;
    };

    /**
        * How a tap builds a selection: 'none', 'single' or 'multiple'.
        *
        * @param {string} mode  One of the three; anything else warns and is
        *   ignored.
        * @returns {Object} The globe, for chaining.
                * @alias Globe3D#setSelectMode
        */
    view.setSelectMode = function (mode) {
      if (['none', 'single', 'multiple'].indexOf(mode) < 0) {
        G.warn('unknown selectMode "' + mode + '"');
        return view;
      }
      attr.selectMode = mode;
      if (mode === 'none') { view.clearSelection(); }
      return view;
    };
    /**
        * The selection mode in force.
        *
        * @returns {string} 'none', 'single' or 'multiple'.
                * @alias Globe3D#selectMode
        */
    view.selectMode = function () { return attr.selectMode; };
    /**
        * Whether moving the pointer sends countryover and countryout.
        *
        * @param {boolean} on  Whether to report hovering.
        * @returns {Object} The globe, for chaining.
                * @alias Globe3D#setHoverCountries
        */
    view.setHoverCountries = function (on) { attr.hoverCountries = !!on; return view; };
    /**
        * Whether a tap looks up which country was hit at all. With it off the
        * globe still reports geoclick, but neither countryclick nor the
        * selection.
        *
        * @param {boolean} on  Whether to identify countries.
        * @returns {Object} The globe, for chaining.
                * @alias Globe3D#setPicking
        */
    view.setPicking = function (on) { attr.picking = !!on; return view; };
    /**
        * How far the pointer may travel between press and release and still
        * count as a tap rather than a turn of the globe.
        *
        * @param {number} px  Distance in pixels.
        * @returns {Object} The globe, for chaining.
                * @alias Globe3D#setClickTolerance
        */
    view.setClickTolerance = function (px) { attr.clickTolerance = px; return view; };

    /**
        * Whether the stored pointer delta is cleared on every board update.
        *
        * updateProjectionTrackball re-applies that delta each time, so an extra
        * update from an unrelated handler turns the globe again — several times
        * per pointer move, and between two layer updates within one frame.
        *
        * @param {boolean} on  Whether to clear it.
        * @returns {Object} The globe, for chaining.
                * @alias Globe3D#setTrackballReset
        */
    view.setTrackballReset = function (on) { attr.trackballReset = !!on; return view; };
    /**
        * What the trackball is doing: the board mode, whether a move handler is
        * installed, whether the reset above is on, and the stored delta.
        *
        * For diagnosis. A globe that turns twice per gesture, or not at all, is
        * hard to investigate from the outside.
        *
        * @returns {Object} A snapshot of those five values.
                * @alias Globe3D#trackballState
        */
    view.trackballState = function () {
      return { mode: board.mode, hasMove: !!view._hasMoveTrackball,
               reset: !!attr.trackballReset,
               dx: view._trackball ? view._trackball.dx : null,
               dy: view._trackball ? view._trackball.dy : null };
    };
    /**
        * How the pointer may turn the globe.
        *
        * @param {string} mode  'free' for the trackball, 'azimuth' for spinning
        *   about the axis only, 'none' to fix it.
        * @returns {Object} The globe, for chaining.
                * @alias Globe3D#rotateMode
        */
    view.rotateMode = function (mode) {
      var free = mode === 'free';
      view.setAttribute({ trackball: { enabled: free },
                          az: { pointer: { enabled: mode !== 'none' } },
                          el: { pointer: { enabled: free } } });
      attr.rotate = mode;
      return view;
    };
    if (attr.rotate !== 'free') { view.rotateMode(attr.rotate); }

    var spinRaf = null;
    /**
        * Turn the globe by itself, about its axis.
        *
        * @param {number} [speed=0.0045]  Radians per frame; negative turns the
        *   other way.
        * @returns {Object} The globe, for chaining. A running spin is stopped
        *   first, and without a frame timer nothing is started.
                * @alias Globe3D#spin
        */
    view.spin = function (speed) {
      var s = speed === undefined ? 0.0045 : speed;
      view.stop();
      // Nothing to spin without a frame timer, and asking for one threw.
      if (!canAnimate()) {
        G.warn('globe3d: no requestAnimationFrame here; not spinning');
        return view;
      }
      (function step() {
        view.setView(wrapTau(view.az_slide.Value() - s), view.el_slide.Value());
        spinRaf = root.requestAnimationFrame(step);
      }());
      return view;
    };
    /**
        * Stop a spin, leaving the globe where it is.
        *
        * @returns {Object} The globe, for chaining.
                * @alias Globe3D#stop
        */
    view.stop = function () {
      if (spinRaf && canAnimate()) { root.cancelAnimationFrame(spinRaf); }
      spinRaf = null;
      return view;
    };

    /**
     * What has to be undone when the globe is destroyed.
     *
     * Listeners outlive the element that installed them: building four globes
     * left twenty on the board, each answering every pointer move for a globe
     * nobody is looking at.
     */
    var undoListeners = [];
    var destroyed = false;

    var lastCentre = null, bankFix = false;
    var onBoardUpdate = function () {
      if (destroyed) { return; }
      // updateProjectionTrackball() re-applies the stored pointer delta on
      // every board.update(). Extra updates from unrelated handlers would
      // spin the globe several times per pointer move and shift the camera
      // between two layer updates within one frame.
      if (attr.trackballReset && view._trackball) {
        view._trackball.dx = 0; view._trackball.dy = 0;
      }

      // Optional, and guarded: rebuilding the matrix from the angles discards
      // whatever the trackball just produced, so a wrong method name here
      // would stop the globe from turning at all rather than fail loudly.
      if (attr.keepUpright && !bankFix && view.angles &&
          Math.abs(view.angles.bank) > 1e-6 &&
          typeof view.getRotationFromAngles === 'function' &&
          typeof view.setSlidersFromAngles === 'function') {
        view.angles.bank = 0;
        view.matrix3DRot = view.getRotationFromAngles();
        view.setSlidersFromAngles();
        bankFix = true;
        root.requestAnimationFrame(function () { bankFix = false; board.update(); });
      }

      var c = view.centre();
      if (!lastCentre || Math.abs(c[0] - lastCentre[0]) > 1e-4 ||
          Math.abs(c[1] - lastCentre[1]) > 1e-4) {
        lastCentre = c;
        view.triggerEventHandlers(['viewchange'], [{ lon: c[0], lat: c[1] }]);
      }
    };
    board.on('update', onBoardUpdate);
    undoListeners.push(function () {
      if (board.off) { board.off('update', onBoardUpdate); }
    });

    var container = board.containerObj;
    if (container && container.addEventListener) {
      container.addEventListener('pointerdown', function () {
        if (attr.lod.moving === null) { return; }
        moving = true; applyLod();
      });
      root.addEventListener('pointerup', function () {
        if (!moving) { return; }
        moving = false; applyLod(); board.update();
      });
    }

    var hovered = null;
    function geoAt(e) {
      var c = board.getUsrCoordsOfMouse(e);
      return view.at(c[0], c[1]);
    }

    /**
     * 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. On touch devices the board's own 'up' carries no
        * coordinates at all, which is why there are two paths to begin with.
        *
        * @returns {Object} A live view of the counters.
                * @alias Globe3D#tapState
        */
    view.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, id;
      g = view.at(pt.x, pt.y);
      if (!g) { return; }
      view.triggerEventHandlers(['geoclick'], [{ lon: g[0], lat: g[1], originalEvent: e }]);
      if (!ds || !attr.picking) { return; }
      id = G.countryAt(ds, g[0], g[1]);
      if (id) {
        view.triggerEventHandlers(['countryclick'],
          [{ id: id, name: byId[id] && byId[id].name, lon: g[0], lat: g[1], originalEvent: e }]);
        // countryclick reports the click; selectionchange reports the result,
        // so a listener never has to guess whether the toggle already ran.
        // Through the public methods, not beside them. The two used to hold
        // their own copies of what a mode means and drifted apart: a tap
        // replaced the selection in 'single' while select() accumulated.
        if (attr.selectMode === 'single') {
          view.select(id);
        } else if (attr.selectMode === 'multiple') {
          if (picked[id]) { view.deselect(id); } else { view.select(id); }
        }
      }
    });
    // Attached unconditionally so hovering can be switched on later; the flag
    // is checked inside instead of deciding at construction time.
    if (ds) {
      var onHover = function (e) {
        if (destroyed) { return; }
        if (!attr.hoverCountries || !attr.picking) { return; }
        var g = geoAt(e), id = g ? G.countryAt(ds, g[0], g[1]) : null;
        if (id === hovered) { return; }
        if (hovered) { view.triggerEventHandlers(['countryout'], [{ id: hovered }]); }
        hovered = id;
        if (id) {
          view.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); }
      });
    }

    /**
     * Take the globe off the board.
     *
     * Listeners outlive the element that installed them: four globes left
     * twenty of them behind, each answering every pointer move for something
     * nobody is looking at. A board that cannot take a listener back is why
     * the guard above exists as well.
     *
     * @returns {Object} The globe, for chaining.
          * @alias Globe3D#destroy
     */
    view.destroy = function () {
      if (destroyed) { return view; }
      destroyed = true;
      undoListeners.forEach(function (undo) { undo(); });
      undoListeners.length = 0;
      view.stop();
      view.stopScale();
      return view;
    };

    /**
     * elType must stay 'view3d'. While the view is being dragged JSXGraph sets
     * board._change3DView, and prepareUpdate() then marks an element for update
     * only if it carries visProp.element3d or has exactly that elType:
     *
     *     pEl.needsUpdate = pEl.visProp.element3d ||
     *                       pEl.elType === 'view3d' || ...
     *
     * Renaming it left the view unmarked, so matrix3DRot was never recomputed
     * and the globe could not be turned by hand — while lookAt() still worked,
     * because setView() updates outside that branch.
     */
    view.geoElType = 'globe3d';
    // The elements that need a board reach it through the host, and the map
    // has always carried one. The globe did not, so geoSketch and geoHandle
    // answered null on a globe and their examples simply did nothing.
    view.board = board;
    view.dataset = ds;
    view.subs = { body: body };
    Object.keys(layers).forEach(function (n) {
      view.subs[n] = layers[n];
      layers[n].dump = false;          // internal, not part of a construction
    });
    body.dump = false;
    if (typeof view.setParents === 'function') { view.setParents([]); }
    view.lookAt(0, 20);
    return view;
  };

  JXG.registerElement('globe3d', JXG.createGlobe3D);

  if (typeof module === 'object' && module.exports) { module.exports = JXG.createGlobe3D; }
}(typeof globalThis !== 'undefined' ? globalThis : this));