Source: jxg-geoelements.js

/*
    Copyright 2026
        JXG.Geography contributors

    Part of JXG.Geography, an extension for JSXGraph.
    Dual licensed under the GNU LGPL or the MIT License.
*/

/**
 * Geographic elements that work on both hosts.
 *
 * globe3d and geomap are different classes, but they expose the same contract
 * — toGeo, fromGeo, centre, addGeoLayer — so everything here is written once
 * and attached to whichever host it is given.
 *
 *     var fra   = host.geoPoint(8.57, 50.03, { label: 'FRA' });
 *     var jfk   = host.geoPoint(-73.78, 40.64);
 *     var route = host.geoPath(fra, jfk, { mode: 'greatcircle', dash: 2 });
 *     var plane = host.geoMarker(route);
 *     route.animate({ duration: 8000, loop: true });
 *
 * Requires jxg-geography.js and one of the two host elements.
 *
 *
 * CONVENTIONS
 *
 * Positions are [lon, lat] in degrees, longitude first, and `pos()` will take
 * a geoPoint, a pair or an {lon, lat} object wherever a position is wanted.
 * Distances are kilometres, areas square kilometres, angles degrees, times
 * seconds. A ring is open: the closing point is added when it is drawn, not
 * stored.
 *
 * Every element states the winding of its rings with `orientationOf`, which
 * reads the sphere. The planar shoelace is wrong for exactly the rings that
 * matter — a circle at a pole, or one across the antimeridian — and it was
 * used here until it was measured: 24 disagreements in 112 small circles.
 *
 * Sizes follow the drawn size. `pxOf(host)` asks the host how large it is on
 * screen and the sample counts come from that, so the same element is smooth
 * on a phone and on a wall. A count taken once at construction would be wrong
 * as soon as the host was scaled.
 *
 * An element that registers with something — a follower on a geoPoint, an
 * update listener on a board — offers `remove()` to give it back. Hiding is
 * not removing: a hidden element goes on refreshing, and five of them leave
 * five registrations behind. Seven elements carry `remove()`; the rest hold
 * on to nothing.
 *
 * Nothing here reaches for a board unless it needs one. geoLabel, geoHandle
 * and the screen reticle do, and each warns and returns null without one.
 *
 * Known limits: a region larger than a hemisphere that does not enclose a
 * pole is measured as its complement, which is inherited from `G.area`. A
 * rhumb line is held a tenth of a degree short of the poles, where it has no
 * defined bearing. Orbits are Keplerian: a circle, no drag, no oblateness, so
 * the ground track is right for a demonstration and not for a rendezvous.
 */
/**
 * The elements that jxg-geoelements.js hangs on a host. A host is a map or a
 * globe — anything that answers `addGeoLayer` — and every one of these works
 * on either.
 *
 * @namespace GeoHost
 */
(function (root) {
  'use strict';

  var JXG = root.JXG;
  var G = JXG.Geography;
  var RAD = G.RAD, DEG = G.DEG;

  var STYLE = {
    point:  { fillColor: '#cf9b3f', strokeColor: '#071018', strokeWidth: 0.6,
              size: 0.9, layer: 14 },
    path:   { strokeColor: '#cf9b3f', strokeWidth: 1.6, dash: 0,
              fillColor: 'none', fillOpacity: 0, layer: 13 },
    range:  { strokeColor: '#5fb0c6', strokeWidth: 1, strokeOpacity: 0.8,
              fillColor: 'none', fillOpacity: 0, layer: 13 },
    marker: { fillColor: '#f6eeda', strokeColor: '#071018', strokeWidth: 0.6,
              size: 1.1, layer: 15 },
    // above the land: the globe body sits on layer 10 and the land on 11, so
    // a night cap below them is simply covered over
    night:  { fillColor: '#050b12', fillOpacity: 0.42, strokeColor: '#1d3345',
              strokeWidth: 0.8, layer: 14,
              blur: 0, blurSteps: 5, blurWidth: 9, blurSymmetric: true },
    zone:   { fillColor: '#5fb0c6', fillOpacity: 0.25, strokeColor: '#5fb0c6',
              strokeWidth: 1.1, layer: 9 },
    label:  { fontSize: 12, strokeColor: '#e7eef3', anchorX: 'middle',
              anchorY: 'bottom', offset: [0, 8], fixed: true },
    trail:  { strokeColor: '#f6eeda', strokeWidth: 1.4, fillColor: 'none', fillOpacity: 0,
              segments: 4, length: 0.35, layer: 13 },
    region: { strokeColor: '#cf9b3f', strokeWidth: 1.4,
              fillColor: '#cf9b3f', fillOpacity: 0.22, layer: 12,
              simplify: 0.4, minPoints: 8 },
    orbit:  { strokeColor: '#5fb0c6', strokeWidth: 1.4, fillColor: 'none',
              track: { strokeColor: '#cf9b3f', strokeWidth: 1.2, dash: 2 },
              sat: { fillColor: '#f6eeda', strokeColor: '#071018', size: 1.1 },
              samples: null, tolerance: 0.25, revolutions: 3, layer: 13 },
    triangle:{ strokeColor: '#5fb0c6', strokeWidth: 1.6,
               fillColor: '#5fb0c6', fillOpacity: 0.18,
               samples: null, tolerance: 0.25, layer: 12 },
    reticle:{ strokeColor: '#cf9b3f', strokeWidth: 1.6, fillColor: 'none',
              fillOpacity: 0, units: 'screen',   // 'screen' | 'degrees'
              rings: [2.6, 5.4], ticks: [6.8, 11], dot: 0.8, tolerance: 0.25,
              px: { rings: [9, 19], ticks: [24, 38], dot: 3 }, // screen pixels
              farSide: false,             // also draw it behind the horizon
              farSideStyle: { strokeOpacity: 0.32, strokeWidth: 1.1, dash: 2 },
              layer: 15 }
  };

  /**
   * A shallow copy of `base` with `over` written over it.
   *
   * Own properties only: a style object arriving from a caller may carry
   * whatever its prototype chain holds, and none of that belongs in an
   * element's attributes.
   *
   * @param {Object} base  The defaults.
   * @param {Object} [over]  What the caller asked for.
   * @returns {Object} A new object; neither argument is touched.
   */
  function merge(base, over) {
    var o = {}, k;
    for (k in base) { if (base.hasOwnProperty(k)) { o[k] = base[k]; } }
    for (k in (over || {})) { if (over.hasOwnProperty(k)) { o[k] = over[k]; } }
    return o;
  }

  /**
   * Keep a plain 2D element alive while the 3D view is being dragged.
   *
   * prepareUpdate() marks an element only when it carries visProp.element3d
   * or is the view itself, as long as board._change3DView is set:
   *
   *     pEl.needsUpdate = pEl.visProp.element3d ||
   *                       pEl.elType === 'view3d' || ...
   *
   * A handle or an overlay curve is neither, so during a turn it keeps its
   * old screen position while everything around it moves. Refreshing it
   * explicitly from the board's update event puts it back in step.
   *
   * @param {Object} board  The board to listen to.
   * @param {Object} el  The element to refresh.
   * @param {Array<Function>} [undo]  Collects the function that takes
   *   the listener off again.
   * @returns {Object} The element, for chaining.
   */
  function keepUpdated(board, el, undo) {
    if (!board || typeof board.on !== 'function') { return el; }
    var handler = function () {
      if (typeof el.fullUpdate === 'function') { el.fullUpdate(); }
    };
    board.on('update', handler);
    // A listener outlives the element that installed it. Building a reticle
    // five times left ten of them on the board, each refreshing a curve
    // nobody looks at any more. Whoever installs it collects the undo.
    if (undo) {
      undo.push(function () {
        if (typeof board.off === 'function') { board.off('update', handler); }
      });
    }
    return el;
  }

  /**
   * Screen radius of the host, in pixels; 240 if the host cannot say.
   *
   * @param {Object} host  A map or a globe.
   * @returns {number} Device pixels.
   */
  function pxOf(host) {
    return (typeof host.pixelRadius === 'function' && host.pixelRadius() > 0)
      ? host.pixelRadius() : 240;
  }
  /**
   * Sample count that follows the drawn size.
   *
   * Counting once at construction is wrong whenever the size changes
   * afterwards — switching orbits animates the globe's scale, so an orbit
   * built during that animation would be sampled for the size it had at that
   * instant and keep the corners for good. The count is therefore recomputed
   * whenever the drawn radius has moved by more than a fifth, which is rare
   * enough to be free and often enough never to be visible.
   *
   * @param {Object} host  A map or a globe.
   * @param {function(number): Array} make  Builds the ring at a given
   *   sample count, for the refinement pass to measure.
   * @param {number} [factor=1]  Scales the drawn radius: a ring of
   *   angular radius r occupies sin(r) of it.
   * @param {number} tol  Chord tolerance in pixels.
   * @param {number} [min=24]  Never fewer than this.
   * @returns {function(): number} Asks again only when the size has
   *   moved by more than a fifth.
   */
  function adaptiveCount(host, make, factor, tol, min) {
    var cached = null, atPx = 0;
    return function () {
      var px = pxOf(host) * (factor || 1);
      if (cached === null || px > atPx * 1.2 || px < atPx / 1.2) {
        var base = G.samplesFor(2 * Math.PI, px, tol, min || 24);
        cached = Math.max(base, G.refineSamples(make, base, px, tol, 1024));
        atPx = px;
      }
      return cached;
    };
  }

  /**
   * Make one element follow another when it moves.
   *
   * A follower outlives the element that registered it: five paths built on
   * the same point left five of them behind, and each went on refreshing a
   * path nobody looks at. The returned function takes it off again, and the
   * elements collect those in an `undo` list for their `remove`.
   *
   * @param {Object} target  Anything with a `followers` array — a
   *   geoPoint, in practice.
   * @param {Function} fn  Called when the target moves.
   * @param {Array<Function>} [undo]  Collects the function that takes
   *   the follower off again.
   * @returns {void}
   */
  function follow(target, fn, undo) {
    if (!target || !target.followers) { return; }
    target.followers.push(fn);
    if (undo) {
      undo.push(function () {
        var i = target.followers.indexOf(fn);
        if (i >= 0) { target.followers.splice(i, 1); }
      });
    }
  }

  /**
   * A position, from whatever the caller offered.
   *
   * @param {(Object|Array<number>)} p  A geoPoint, a [lon, lat] pair, or an
   *   object with `lon` and `lat`.
   * @returns {Array<number>} [lon, lat] in degrees; [0, 0] for nothing.
   */
  function pos(p) {
    // accepts a geoPoint, a [lon, lat] pair or {lon, lat}
    if (!p) { return [0, 0]; }
    if (typeof p.lon === 'function') { return [p.lon(), p.lat()]; }
    if (Array.isArray(p)) { return [p[0], p[1]]; }
    return [p.lon, p.lat];
  }

  /**
   * Rhumb line: constant bearing rather than shortest path. Straight on
   * Mercator, curved everywhere else — the great circle behaves the other way
   * round, which is the whole point of showing both.
   *
   * @param {Array<number>} a  Start, as [lon, lat] in degrees.
   * @param {Array<number>} b  End, likewise.
   * @param {number} n  Segments; the result has n + 1 points.
   * @returns {Array<Array<number>>} The line, as [lon, lat] in degrees.
   */
  function rhumbPoints(a, b, n) {
    // A rhumb line has no defined bearing at a pole: every direction there is
    // south, or north. The formula says so by dividing by an infinity — at
    // latitude -90 the tangent is exactly 0, its logarithm -Infinity, and
    // every longitude comes back NaN. (At +90 the tangent merely overflows to
    // a large finite number, so that end happened to survive, which is luck
    // rather than symmetry.) The ends are therefore held a little short of
    // the poles, close enough that nothing else changes.
    var LIMIT = 89.9;
    var p1 = clampLat(a[1]) * RAD, p2 = clampLat(b[1]) * RAD, out = [], i, t, dl;

    function clampLat(v) { return Math.max(-LIMIT, Math.min(LIMIT, v)); }
    var dp = Math.log(Math.tan(Math.PI / 4 + p2 / 2) / Math.tan(Math.PI / 4 + p1 / 2));
    dl = (b[0] - a[0]) * RAD;
    while (dl > Math.PI) { dl -= 2 * Math.PI; }
    while (dl < -Math.PI) { dl += 2 * Math.PI; }
    for (i = 0; i <= n; i++) {
      t = i / n;
      var p = p1 + (p2 - p1) * t;
      var q = Math.abs(dp) > 1e-12
        ? (Math.log(Math.tan(Math.PI / 4 + p / 2)) -
           Math.log(Math.tan(Math.PI / 4 + p1 / 2))) / dp
        : t;
      out.push([((a[0] * RAD + dl * q) * DEG + 540) % 360 - 180, p * DEG]);
    }
    return out;
  }

  /**
   * Hang the drawable elements on a host.
   *
   * The host has to offer `addGeoLayer`; everything else is built on that, so
   * a map and a globe both qualify and neither needs to know these elements
   * exist. Called for you on anything created after this file loads.
   *
   * @param {Object} host  A geomap or a globe3d.
   * @returns {Object} The same host, now carrying geoPoint and the rest.
   */
  function attach(host) {
    if (!host || typeof host.addGeoLayer !== 'function') {
      G.warn('geo elements need a host with addGeoLayer');
      return host;
    }

    /**
     * A position on the sphere, drawn as a small circle.
     *
     * @param {number} lon  Longitude in degrees.
     * @param {number} lat  Latitude in degrees.
     * @param {Object} [attr]  Style, plus `size` for the radius of the
     *   circle in degrees.
     * @returns {Object} The point: `lon()`, `lat()`, `coords()`,
     *   `moveTo(lon, lat)`, `setVisible(on)`, and a `followers` array
     *   that anything drawn from it registers with.
          * @alias GeoHost#geoPoint
     */
    host.geoPoint = function (lon, lat, attr) {
      var st = merge(STYLE.point, attr), at = [lon, lat];
      var nPt = G.samplesFor(2 * Math.PI, pxOf(host) * Math.sin(st.size * RAD), 0.25, 10);
      var ring = [G.smallCircle(at[0], at[1], st.size, nPt)];
      var layer = host.addGeoLayer('rings', function () { return ring; },
        st, function () { return [G.orientationOf(ring[0])]; });
      var el = {
        elType: 'geopoint',
        lon: function () { return at[0]; },
        lat: function () { return at[1]; },
        coords: function () { return [at[0], at[1]]; },
        layer: layer,
        moveTo: function (lo, la) {
          at = [lo, la];
          ring[0] = G.smallCircle(lo, la, st.size, nPt);
          (el.followers || []).forEach(function (f) { f(); });
          layer.refresh();
          return el;
        },
        setVisible: function (on) { layer.setVisible(on); return el; },
        followers: []
      };
      return el;
    };

    /**
     * A path between two positions. Great circle by default; the distance and
     * initial bearing come along, so the element is a measurement as much as
     * a drawing.
     *
     * @param {(Object|Array<number>)} a  Start: a geoPoint or a
     *   [lon, lat] pair. Given a geoPoint, the path follows it.
     * @param {(Object|Array<number>)} b  End, likewise.
     * @param {Object} [attr]  Style, plus `mode` — "greatcircle" or
     *   "rhumb" — and `samples` to fix the count.
     * @returns {Object} The path: `points()`, `distance()` in km,
     *   `bearing()`, `at(t)`, `setMode(m)`, `refresh()`,
     *   `remove()`, and `animate(opts)`.
          * @alias GeoHost#geoPath
     */
    host.geoPath = function (a, b, attr) {
      var st = merge(STYLE.path, attr);
      var mode = st.mode || 'greatcircle';
      var pts = [];
      function rebuild() {
        var p = pos(a), q = pos(b), i;
        // sampled by how long the path is and how large it is drawn
        // A rhumb line is sampled more finely than the chord criterion asks
        // for: each segment is drawn as a great-circle chord, and its bearing
        // drifts from the constant course the line is supposed to hold.
        var n = st.samples || G.samplesFor(G.angle(p, q) * RAD, pxOf(host),
          st.tolerance, mode === 'rhumb' ? 48 : 8);
        pts = [];
        if (mode === 'rhumb') { pts = rhumbPoints(p, q, n); } else {
          for (i = 0; i <= n; i++) { pts.push(G.interpolate(p, q, i / n)); }
        }
      }
      rebuild();
      var layer = host.addGeoLayer('lines', function () { return [pts]; }, st);
      var undo = [];
      var el = {
        elType: 'geopath',
        from: a, to: b, mode: mode, layer: layer,
        points: function () { return pts; },
        /**
         * Length of the path as drawn, in kilometres.
         *
         * For a great circle that is the shortest distance between the ends.
         * A rhumb line is longer — it holds a constant course rather than the
         * shortest way, and Frankfurt to New York costs 310 km for that
         * convenience. Reporting the great-circle figure for both, as this
         * did, made the whole point of showing the two paths invisible.
         */
        distance: function () {
          if (mode !== 'rhumb') { return G.distance(pos(a), pos(b)); }
          var s = 0, i;
          for (i = 1; i < pts.length; i++) { s += G.distance(pts[i - 1], pts[i]); }
          return s;
        },
        bearing: function () { return G.bearing(pos(a), pos(b)); },
        /** Position at fraction t of the way along the path. */
        at: function (t) {
          if (mode === 'rhumb') {
            var i = Math.max(0, Math.min(pts.length - 1, Math.round(t * (pts.length - 1))));
            return pts[i];
          }
          return G.interpolate(pos(a), pos(b), Math.max(0, Math.min(1, t)));
        },
        setMode: function (m) { mode = m; el.mode = m; rebuild(); layer.refresh(); return el; },
        refresh: function () { rebuild(); layer.refresh(); return el; },
        setVisible: function (on) { layer.setVisible(on); return el; },
        /**
         * Take the path off: hide it, and stop following its endpoints.
         * Hiding alone leaves the followers in place, still refreshing.
         */
        remove: function () {
          layer.setVisible(false);
          undo.forEach(function (f) { f(); });
          undo.length = 0;
          return el;
        },
        markers: []
      };
      // follow the endpoints when they move
      [a, b].forEach(function (p) {
        follow(p, function () { el.refresh(); }, undo);
      });
      return el;
    };

    /**
     * Circles of constant distance around a position, in kilometres.
     *
     * @param {(Object|Array<number>)} centre  A geoPoint or a position.
     * @param {(number|Array<number>)} km  One radius or several, in
     *   kilometres. Anything past the antipode at 20015 km is refused.
     * @param {Object} [attr]  Style.
     * @returns {Object} The rings: `setRadii(v)`, `refresh()`,
     *   `setVisible(on)`, `remove()`.
          * @alias GeoHost#geoRange
     */
    host.geoRange = function (centre, km, attr) {
      var st = merge(STYLE.range, attr);
      var list = Array.isArray(km) ? km : [km];
      var rings = [];
      // Half the circumference, 20015 km: the antipode, and the farthest any
      // point on the sphere can be. Past it the circle wraps and draws
      // something real but wrong — 25000 km gave the same ring as 15034, and
      // 40000 km very nearly the centre itself. Silently.
      var MAX_KM = Math.PI * G.EARTH_RADIUS;

      function rebuild() {
        var c = pos(centre);
        rings = list.map(function (d) {
          if (d > MAX_KM) {
            G.warn('geoRange: ' + d + ' km is past the antipode at ' +
                   MAX_KM.toFixed(0) + ' km; drawing that instead');
            d = MAX_KM;
          }
          var rad = d / (G.EARTH_RADIUS * RAD);
          var px = pxOf(host) * Math.sin(rad * RAD);      // radius on screen
          return G.smallCircle(c[0], c[1], rad,
            st.samples || G.samplesFor(2 * Math.PI, px, st.tolerance, 12));
        });
      }
      rebuild();
      var layer = host.addGeoLayer('lines', function () {
        return rings.map(function (r) { return r.concat([r[0]]); });
      }, st);
      var undo = [];
      var el = {
        elType: 'georange', layer: layer,
        setRadii: function (v) { list = Array.isArray(v) ? v : [v]; rebuild(); layer.refresh(); return el; },
        refresh: function () { rebuild(); layer.refresh(); return el; },
        setVisible: function (on) { layer.setVisible(on); return el; },
        /**
         * Take it off: hide it, and give back what it holds on to. Hiding
         * alone leaves the followers registered, still refreshing something
         * nobody looks at.
         */
        remove: function () {
          el.setVisible(false);
          undo.forEach(function (f) { f(); });
          undo.length = 0;
          return el;
        }
      };
      follow(centre, function () { el.refresh(); }, undo);
      return el;
    };

    /**
     * A marker riding on a path. Moving it only refreshes its own layer, so
     * an animation does not touch the map or the globe geometry.
     *
     * @param {Object} path  A geoPath to ride on.
     * @param {Object} [attr]  Style, plus `t` for where to start, from
     *   0 to 1, and `size` for the radius in degrees.
     * @returns {Object} The marker: `t()`, `coords()`, `bearing()`,
     *   `setT(v)`, `setVisible(on)`.
          * @alias GeoHost#geoMarker
     */
    host.geoMarker = function (path, attr) {
      var st = merge(STYLE.marker, attr), t = st.t || 0;
      var at = path.at(t);
      var ring = [G.smallCircle(at[0], at[1], st.size, 16)];
      var layer = host.addGeoLayer('rings', function () { return ring; },
        st, function () { return [G.orientationOf(ring[0])]; });
      var el = {
        elType: 'geomarker', layer: layer, path: path,
        t: function () { return t; },
        coords: function () { return [at[0], at[1]]; },
        /** Heading of the path at the marker, for orienting a symbol. */
        bearing: function () {
          var d = 1e-3;
          return G.bearing(path.at(Math.max(0, t - d)), path.at(Math.min(1, t + d)));
        },
        setT: function (v) {
          t = Math.max(0, Math.min(1, v));
          at = path.at(t);
          ring[0] = G.smallCircle(at[0], at[1], st.size, 16);
          layer.refresh();
          // The trail belongs to the marker, so it follows without being
          // asked. Leaving that to the caller meant a trail that was never
          // refreshed stayed where it started — a streak across the globe
          // with nothing at its end.
          if (el.trail) { el.trail.refresh(); }
          return el;
        },
        setVisible: function (on) { layer.setVisible(on); return el; }
      };
      path.markers.push(el);
      return el;
    };

    /**
     * The night side, as a filled cap around the antisolar point.
     *
     * The terminator already exists as a line; this is the same circle used
     * as a boundary instead of a stroke, which is why it needs no new
     * geometry — only the winding has to be right, or the fill lands on the
     * lit half.
     *
     * @param {Object} [attr]  Style, plus `date` for the moment;
     *   `blur` to grade the edge; `blurWidth` in degrees, held to what
     *   fits inside the cap; `blurSteps` for how many bands; and
     *   `blurSymmetric` to put the grading on both sides of the
     *   terminator rather than on the night side alone.
     * @returns {Object} The night: `setDate(d)`, `subsolar()`,
     *   `antisolar()`, `refresh()`, `setVisible(on)`.
          * @alias GeoHost#geoNight
     */
    host.geoNight = function (attr) {
      var st = merge(STYLE.night, attr);
      var when = (attr && attr.date) || null;
      var ring = [], bands = [];
      function rebuild() {
        var s2 = G.subsolar(when || new Date());
        var anti = [((s2[0] + 360) % 360) - 180, -s2[1]];
        var n = G.samplesFor(2 * Math.PI, pxOf(host), st.tolerance, 48);
        ring = [G.smallCircle(anti[0], anti[1], 90, n)];
        // Nested caps, each adding a little, so the opacity climbs across the
        // boundary instead of jumping.
        //
        // Two readings, and they differ: twilight is *not* symmetric — the sun
        // is below the horizon only on the night side, so the real graded band
        // lies inside the terminator. A blur, on the other hand, is a softening
        // of a line and belongs on both sides of it. `blurSymmetric` chooses;
        // it defaults to the blur reading, because that is what the word says.
        bands = [];
        if (st.blur) {
          // The innermost band sits at `from - blurWidth`. Past a width of
          // 180 that goes negative, and a negative radius is not empty: it
          // draws the same circle the other way round, so -45 came out as the
          // 45-degree cap and -110 with its winding flipped. Neither means
          // anything as twilight. The width is held to what fits.
          var k, r, from = st.blurSymmetric ? 90 + st.blurWidth / 2 : 90;
          var width = st.blurWidth;
          if (width > from) {
            G.warn('geoNight: blurWidth ' + width + '° reaches past the ' +
                   'centre of the cap; using ' + from.toFixed(0) + '°');
            width = from;
          }
          for (k = 0; k <= st.blurSteps; k++) {
            r = from - width * k / st.blurSteps;
            bands.push(G.smallCircle(anti[0], anti[1], r, n));
          }
        }
      }
      rebuild();
      // Known by construction: smallCircle runs counter-clockwise about its
      // centre. Neither ringSign nor orientationOf can answer here: a 90°
      // circle spans every longitude, so the winding says which pole rather
      // than which way round, and the integral comes out at zero.
      //
      // With a blur the cap itself carries only the outline: the fill is the
      // stack of bands, or the two would add up past the intended opacity.
      var layer = host.addGeoLayer('rings', function () { return ring; },
        st.blur ? merge(st, { fillOpacity: 0 }) : st,
        function () { return [1]; });
      /**
       * One twilight band. Each is drawn over the last, so the opacities
       * compound: to reach `fillOpacity` after all of them, each contributes
       * 1 − (1 − fillOpacity)^(1/n).
       */
      var perBand = 1 - Math.pow(1 - st.fillOpacity, 1 / (st.blurSteps + 1));
      function addBand(k) {
        return host.addGeoLayer('rings',
          function () { return bands[k] ? [bands[k]] : []; },
          merge(st, { strokeWidth: 0, fillOpacity: perBand }),
          function () { return [1]; });
      }
      var blurLayers = [], bi;
      if (st.blur) {
        for (bi = 0; bi <= st.blurSteps; bi++) { blurLayers.push(addBand(bi)); }
      }

      var el = {
        elType: 'geonight', layer: layer, blurLayers: blurLayers,
        antisolar: function () {
          var s2 = G.subsolar(when || new Date());
          return [((s2[0] + 360) % 360) - 180, -s2[1]];
        },
        subsolar: function () { return G.subsolar(when || new Date()); },
        setDate: function (d) { when = d; return el.refresh(); },
        refresh: function () {
          rebuild(); layer.refresh();
          blurLayers.forEach(function (l) { l.refresh(); });
          return el;
        },
        setVisible: function (on) {
          layer.setVisible(on);
          blurLayers.forEach(function (l) { l.setVisible(on); });
          return el;
        }
      };
      return el;
    };

    /**
     * A nominal time zone, highlighted, with its local time.
     *
     * The band is the 15-degree slice around the zone's meridian. A country's
     * legal zone follows its border instead and can be offset by 30 or 45
     * minutes, so the two disagree by up to several hundred kilometres —
     * which is exactly what makes the comparison worth drawing.
     *
     * @param {number} offsetHours  The offset from UTC. Fractions are
     *   allowed: India is +5.5, Nepal +5.75.
     * @param {Object} [attr]  Style, plus `date` for the moment and
     *   `rule` for summer time — "none", "eu" or "us".
     * @returns {Object} The zone: `offset()`, `localTime()`,
     *   `label()`, `setOffset(h)`, `setDate(d)`, `setRule(r)`.
          * @alias GeoHost#geoTimeZone
     */
    host.geoTimeZone = function (offsetHours, attr) {
      var st = merge(STYLE.zone, attr);
      var off = offsetHours || 0, rule = (attr && attr.rule) || 'none';
      var when = (attr && attr.date) || null;
      var ring = G.timeZoneBand(off);
      var layer = host.addGeoLayer('rings', function () { return [ring]; }, st,
        function () { return [G.orientationOf(ring)]; });
      var el = {
        elType: 'geotimezone', layer: layer,
        offset: function () { return off; },
        localTime: function () { return G.localTime(off, when || new Date(), rule); },
        label: function () {
          var t = el.localTime();
          return 'UTC' + (t.offset >= 0 ? '+' : '') + t.offset + '  ' +
                 String(t.hours).padStart(2, '0') + ':' +
                 String(t.minutes).padStart(2, '0') +
                 (t.summer ? '  (summer time)' : '');
        },
        setOffset: function (h) {
          off = h; ring = G.timeZoneBand(off); layer.refresh(); return el;
        },
        setDate: function (d) { when = d; return el; },
        setRule: function (r) { rule = r; return el; },
        setVisible: function (on) { layer.setVisible(on); return el; }
      };
      return el;
    };

    /**
     * A text at a geographic position.
     *
     * An ordinary JSXGraph text, moved to wherever its position now projects
     * and hidden when that position goes behind the horizon — the same two
     * jobs geoHandle does for a point.
     *
     * @param {(Object|Array<number>)} position  A geoPoint or a pair.
     * @param {string} text  What to write.
     * @param {Object} [attr]  Attributes for the JSXGraph text.
     * @returns {?Object} The label: `text`, `setText(v)`,
     *   `setVisible(on)`, `remove()`. Null where the host has no board,
     *   or can no longer place a position.
          * @alias GeoHost#geoLabel
     */
    host.geoLabel = function (position, text, attr) {
      var board = host.board;
      if (!board || typeof board.create !== 'function') {
        G.warn('geoLabel needs a host with a board');
        return null;
      }
      var st = merge(STYLE.label, attr);
      // fromGeo answers null once its map has been destroyed, and reading .x
      // off that throws. A label asked for at that point has nowhere to go.
      var p0 = host.fromGeo(pos(position)[0], pos(position)[1]);
      if (!p0) {
        G.warn('geoLabel: the host cannot place a position any more');
        return null;
      }
      var t = board.create('text', [p0.x, p0.y, text], st);
      var wanted = st.visible !== false;
      var undo = [];

      var onUpdate = function () {
        var q = host.fromGeo(pos(position)[0], pos(position)[1]);
        if (!q || !isFinite(q.x) || !isFinite(q.y)) { return; }
        if (typeof t.setPosition === 'function' &&
            (Math.abs(q.x - t.X()) > 1e-9 || Math.abs(q.y - t.Y()) > 1e-9)) {
          t.setPosition(JXG.COORDS_BY_USER, [q.x, q.y]);
          if (typeof t.fullUpdate === 'function') { t.fullUpdate(); }
        }
        var show = wanted && q.front !== false;
        if (!!t.visProp.visible !== show) { t.setAttribute({ visible: show }); }
      };
      board.on('update', onUpdate);
      undo.push(function () {
        if (typeof board.off === 'function') { board.off('update', onUpdate); }
      });

      var el = {
        elType: 'geolabel', text: t,
        setText: function (v) { t.setText(v); return el; },
        setVisible: function (on) { wanted = on; t.setAttribute({ visible: on }); return el; },
        /**
         * Take the label off: hide it, stop following the position, and give
         * back the update listener. Hiding alone leaves both running.
         */
        remove: function () {
          el.setVisible(false);
          undo.forEach(function (f) { f(); });
          undo.length = 0;
          if (board.removeObject) { board.removeObject(t); }
          return el;
        }
      };
      follow(position, function () { board.update(); }, undo);
      return el;
    };

    /**
     * A fading trail behind a marker.
     *
     * SVG cannot fade along a single stroke, so the trail is drawn as a few
     * separate pieces of decreasing opacity. Four is enough to read as a
     * gradient and cheap enough to redraw every frame.
     *
     * @param {Object} marker  The marker to trail.
     * @param {Object} [attr]  Style, plus `length` as a fraction of the
     *   path.
     * @returns {Object} The trail: `refresh()`, `setVisible(on)`.
          * @alias GeoHost#geoTrail
     */
    host.geoTrail = function (marker, attr) {
      var st = merge(STYLE.trail, attr);
      var pieces = [], layers = [], i;
      for (i = 0; i < st.segments; i++) { pieces.push([]); }
      function rebuild() {
        var t1 = marker.t(), span = st.length, k, a, b, j, n;
        for (k = 0; k < st.segments; k++) {
          // oldest piece first, so opacity grows towards the marker
          a = Math.max(0, t1 - span * (st.segments - k) / st.segments);
          b = Math.max(0, t1 - span * (st.segments - k - 1) / st.segments);
          pieces[k] = [];
          n = Math.max(2, Math.round(24 / st.segments));
          for (j = 0; j <= n; j++) { pieces[k].push(marker.path.at(a + (b - a) * j / n)); }
        }
      }
      // filled before the layers exist: adding a layer draws it at once, and
      // an empty piece would have to be handled at that moment
      rebuild();
      for (i = 0; i < st.segments; i++) {
        (function (k) {
          layers.push(host.addGeoLayer('lines', function () { return [pieces[k]]; },
            merge(st, { strokeOpacity: (k + 1) / st.segments, fillColor: 'none', fillOpacity: 0 })));
        }(i));
      }
      var el = {
        elType: 'geotrail', layers: layers,
        refresh: function () { rebuild(); layers.forEach(function (l) { l.refresh(); }); return el; },
        setVisible: function (on) { layers.forEach(function (l) { l.setVisible(on); }); return el; }
      };
      marker.trail = el;
      return el;
    };

    /**
     * A spherical polygon: any number of positions joined by great circles.
     * geoTriangle is the three-cornered case with the angles named.
     *
     * @param {Array} points  Three or more corners, each a geoPoint or
     *   a [lon, lat] pair. Given geoPoints, the polygon follows them.
     * @param {Object} [attr]  Style, plus `samples` and `tolerance`.
     * @returns {Object} The polygon: `area()` in square km, taken over
     *   the sphere rather than the drawing; `perimeter()` in km;
     *   `refresh()`, `setVisible(on)`, `remove()`.
          * @alias GeoHost#geoPolygon
     */
    host.geoPolygon = function (points, attr) {
      var st = merge(STYLE.triangle, attr);
      var ring = [];
      function rebuild() {
        var i, k, p, q, n;
        ring = [];
        for (i = 0; i < points.length; i++) {
          p = pos(points[i]); q = pos(points[(i + 1) % points.length]);
          n = st.samples || G.samplesFor(G.angle(p, q) * RAD, pxOf(host), st.tolerance, 6);
          for (k = 0; k < n; k++) { ring.push(G.interpolate(p, q, k / n)); }
        }
      }
      rebuild();
      var fill = host.addGeoLayer('rings', function () { return [ring]; },
        merge(st, { strokeWidth: 0 }), function () { return [G.orientationOf(ring)]; });
      var edge = host.addGeoLayer('lines', function () { return [ring.concat([ring[0]])]; },
        merge(st, { fillColor: 'none', fillOpacity: 0 }));
      var undo = [];
      var el = {
        elType: 'geopolygon', vertices: points, layers: [fill, edge],
        area: function () { return G.area(ring) * G.EARTH_RADIUS * G.EARTH_RADIUS; },
        perimeter: function () {
          var i, d = 0;
          for (i = 0; i < points.length; i++) {
            d += G.distance(pos(points[i]), pos(points[(i + 1) % points.length]));
          }
          return d;
        },
        refresh: function () { rebuild(); fill.refresh(); edge.refresh(); return el; },
        setVisible: function (on) { fill.setVisible(on); edge.setVisible(on); return el; },
        /**
         * Take it off: hide it, and give back what it holds on to. Hiding
         * alone leaves the followers registered, still refreshing something
         * nobody looks at.
         */
        remove: function () {
          el.setVisible(false);
          undo.forEach(function (f) { f(); });
          undo.length = 0;
          return el;
        }
      };
      points.forEach(function (p) {
        follow(p, function () { el.refresh(); }, undo);
      });
      return el;
    };

    /**
     * A region: a closed geographic ring with its true area.
     *
     * The area comes from the spherical excess, not from the drawing, so it
     * does not depend on the projection the region was drawn in. Outline the
     * same shape over Greenland and over Australia on a Mercator map and the
     * two numbers will differ by a factor of three, while the shapes look
     * alike — which is the whole argument about map projections in one
     * gesture.
     *
     * @param {Array<Array<number>>} ll  The ring, as [lon, lat] in
     *   degrees. Closed for you, and turned counter-clockwise if it
     *   was drawn the other way round.
     * @param {Object} [attr]  Style.
     * @returns {Object} The region: `area()` in square km,
     *   `perimeter()` in km, `centroid()`, `ring()`, `setRing(pts)`,
     *   `setVisible(on)`, `remove()`.
          * @alias GeoHost#geoRegion
     */
    host.geoRegion = function (ll, attr) {
      var st = merge(STYLE.region, attr);
      var ring = normalise(ll);

      function normalise(pts) {
        var r = pts.slice();
        // tolerant, not exact: a ring closed by trigonometry rarely returns
        // to its first point bit for bit, and a leftover duplicate biases
        // every mean taken over the ring
        while (r.length > 3 && G.angle(r[0], r[r.length - 1]) < 1e-6) { r.pop(); }
        // outer rings run counter-clockwise; a ring drawn the other way round
        // would be closed over the wrong arc on the globe
        return G.orientationOf(r) < 0 ? r.reverse() : r;
      }

      var fill = host.addGeoLayer('rings', function () { return [ring]; },
        merge(st, { strokeWidth: 0 }), function () { return [G.orientationOf(ring)]; });
      var edge = host.addGeoLayer('lines', function () { return [ring.concat([ring[0]])]; },
        merge(st, { fillColor: 'none', fillOpacity: 0 }));

      var el = {
        elType: 'georegion',
        layers: [fill, edge],
        ring: function () { return ring; },
        /** Area in square kilometres, from the spherical excess. */
        area: function () { return G.area(ring) * G.EARTH_RADIUS * G.EARTH_RADIUS; },
        perimeter: function () {
          var i, d = 0;
          for (i = 0; i < ring.length; i++) {
            d += G.distance(ring[i], ring[(i + 1) % ring.length]);
          }
          return d;
        },
        centroid: function () {
          var v = [0, 0, 0], i, u;
          for (i = 0; i < ring.length; i++) {
            u = G.toVector(ring[i][0], ring[i][1]);
            v[0] += u[0]; v[1] += u[1]; v[2] += u[2];
          }
          return G.toGeo(v);
        },
        setRing: function (pts) {
          ring = normalise(pts);
          fill.refresh(); edge.refresh();
          return el;
        },
        setVisible: function (on) { fill.setVisible(on); edge.setVisible(on); return el; },
        remove: function () { el.setVisible(false); return el; }
      };
      return el;
    };

    /**
     * Freehand marking, fed by JSXGraph's own sketch recording.
     *
     * The board collects the drag into board.sketches[0].dataX/dataY in user
     * coordinates; all this does is read them on release and hand them to
     * toGeo. The board must be created with sketches: { enabled: true }.
     *
     * @param {Object} [opts]  `style` for the region drawn; `minPoints`
     *   below which a drag is ignored; `onRegion(ring)`, called with the
     *   thinned ring of [lon, lat]; `keepStroke` to leave the raw stroke on
     *   screen. A 'geosketch' event carrying the same ring is fired on the
     *   board either way.
     * @returns {?Object} `start()`, `stop()`, `isActive()`, `clear()` and
     *   `finish()`. The tool begins switched off, so a caller that wants to
     *   draw straight away has to call `start()`. Null where the host has no
     *   board.
          * @alias GeoHost#geoSketch
     */
    host.geoSketch = function (opts) {
      opts = opts || {};
      var board = host.board;
      if (!board || typeof board.on !== 'function') {
        G.warn('geoSketch needs a host with a board');
        return null;
      }
      if (!board.attr || !board.attr.sketches || !board.attr.sketches.enabled) {
        G.warn('geoSketch needs a board created with sketches: { enabled: true }');
      }
      var active = false, minPts = opts.minPoints || STYLE.region.minPoints;
      /**
       * The board records into sketches[0] on its own. Switching the tool
       * therefore has to switch the recording, not just ignore the result —
       * otherwise the stroke stays on screen after the tool is off.
       *
       * @param {boolean} on  Whether the board should collect drags.
       * @returns {void}
     */
      function recording(on) {
        if (board.attr && board.attr.sketches) { board.attr.sketches.enabled = on; }
        var sk = board.sketches && board.sketches[0];
        if (sk) {
          if (!on) { sk.dataX = []; sk.dataY = []; }
          if (sk.setAttribute) { sk.setAttribute({ visible: on }); }
        }
        if (board.update) { board.update(); }
      }
      var simplify = opts.simplify === undefined ? STYLE.region.simplify : opts.simplify;

      /**
       * Drop points closer together than `simplify` degrees.
       *
       * @param {Array<Array<number>>} pts  The raw stroke.
       * @returns {Array<Array<number>>} The stroke with the points that
       *   sit on top of one another dropped.
       */
      function thin(pts) {
        var out = [pts[0]], i;
        for (i = 1; i < pts.length; i++) {
          if (G.angle(out[out.length - 1], pts[i]) >= simplify) { out.push(pts[i]); }
        }
        return out;
      }

      function finish() {
        if (!active) { return; }
        var sk = board.sketches && board.sketches[0];
        if (!sk || !sk.dataX || sk.dataX.length < minPts) { return; }
        var ll = [], i, g;
        for (i = 0; i < sk.dataX.length; i++) {
          g = host.toGeo(sk.dataX[i], sk.dataY[i]);
          if (g) { ll.push(g); }
        }
        if (ll.length < minPts) { return; }
        ll = thin(ll);
        if (ll.length < 3) { return; }
        if (opts.onRegion) { opts.onRegion(ll); }
        board.triggerEventHandlers(['geosketch'], [{ ring: ll }]);
        // the region takes over; the raw stroke has done its job
        if (opts.keepStroke !== true) { recording(true); }
      }
      board.on('up', finish);

      recording(false);
      return {
        start: function () { active = true; recording(true); return this; },
        stop: function () { active = false; recording(false); return this; },
        isActive: function () { return active; },
        /** Wipe the stroke without switching the tool. */
        clear: function () { recording(active); return this; },
        finish: finish
      };
    };

    /**
     * A circular orbit with its ground track.
     *
     * Kepler for the shape, one constant for the rest: the period follows
     * from the semi-major axis alone, T = 2*pi*sqrt(a^3/mu). The ground track
     * is the same motion seen from a turning Earth, which is why it drifts
     * west by one Earth rotation per revolution — the classic sine curve that
     * never closes.
     *
     * @param {Object} [opts]  `altitude` in km above the surface, or
     *   `a` as the semi-major axis from the centre — anything at or
     *   below the surface is refused. `inclination` and `raan` in
     *   degrees, `phase` along the orbit, `samples` to fix the count.
     * @returns {Object} The orbit: `period()` in seconds, `axis()`,
     *   `altitude()`, `speed()` in km/s, `radii()`, `time()`,
     *   `position()`, `setTime(t)`, `setPhase(p)`, `showTrack(on)`.
          * @alias GeoHost#geoOrbit
     */
    host.geoOrbit = function (opts) {
      opts = opts || {};
      var st = merge(STYLE.orbit, opts);
      var MU = 398600.4418;                      // km^3/s^2
      var WE = 360.9856473 / 86400;              // sidereal rotation, deg/s
      var a = opts.a || (G.EARTH_RADIUS + (opts.altitude === undefined ? 400 : opts.altitude));
      // A semi-major axis at or below the surface is not an orbit. The cube
      // root of a negative number is NaN, and that spread quietly: the period,
      // the speed, the position and all 2441 drawn points came back NaN, with
      // nothing said. Held at the surface, which is the limiting case and at
      // least draws.
      if (!(a > G.EARTH_RADIUS)) {
        G.warn('geoOrbit: a semi-major axis of ' + a.toFixed(0) + ' km is ' +
               'inside the Earth; using the surface, ' +
               G.EARTH_RADIUS.toFixed(0) + ' km');
        a = G.EARTH_RADIUS;
      }
      var inc = (opts.inclination || 0) * RAD;
      var raan = (opts.raan || 0) * RAD;
      var phase = (opts.phase || 0) * RAD;
      var T = 2 * Math.PI * Math.sqrt(a * a * a / MU);   // seconds
      var k = a / G.EARTH_RADIUS;                        // Earth radii
      var t = 0;

      /**
       * Direction of the satellite in inertial space at time t.
       *
       * @param {number} tt  Seconds since the epoch of the orbit.
       * @returns {Array<number>} A unit vector in the inertial frame.
       */
      function eci(tt) {
        var nu = 2 * Math.PI * tt / T + phase;
        var cn = Math.cos(nu), sn = Math.sin(nu);
        return [cn * Math.cos(raan) - sn * Math.cos(inc) * Math.sin(raan),
                cn * Math.sin(raan) + sn * Math.cos(inc) * Math.cos(raan),
                sn * Math.sin(inc)];
      }
      function wrap(lon) { return ((lon + 540) % 360) - 180; }
      /**
       * Sub-satellite position, i.e. inertial motion minus Earth rotation.
       *
       * @param {number} tt  Seconds since the epoch.
       * @returns {Array<number>} [lon, lat] in degrees, below the
       *   satellite at that moment.
       */
      function ground(tt) {
        var v = eci(tt);
        return [wrap(Math.atan2(v[1], v[0]) * DEG - WE * tt),
                Math.asin(clampUnit(v[2])) * DEG];
      }
      function clampUnit(x) {
        if (x < -1) { return -1; }
        if (x > 1) { return 1; }
        return x;
      }
      /**
       * The orbit as the turning Earth sees it right now.
       *
       * @param {number} n  How many points to take.
       * @returns {Array<Array<number>>} The orbit as [lon, lat],
       *   frozen at the current rotation of the Earth.
       */
      function ringSamples(n) {
        var out = [], i, v;
        for (i = 0; i <= n; i++) {
          v = eci(i / n * T);
          out.push([wrap(Math.atan2(v[1], v[0]) * DEG), Math.asin(clampUnit(v[2])) * DEG]);
        }
        return out;
      }
      function trackSamples(n) {
        var out = [], i;
        for (i = 0; i <= n; i++) { out.push(ground(i / n * T)); }
        return out;
      }
      // The circle estimate covers the chord error of the arc; the refinement
      // adds points where the curve bends more tightly than that. Neither
      // alone is enough: the first misses the sinusoid of a ground track, the
      // second is blind to the sagitta of a great circle.
      var nRing = st.samples ? function () { return st.samples; }
        : adaptiveCount(host, ringSamples, k, st.tolerance);
      var nTrack = st.samples ? function () { return st.samples; }
        : adaptiveCount(host, trackSamples, 1, st.tolerance);

      function ringAt(tt) {
        var out = [], i, v;
        var n = nRing();
        for (i = 0; i <= n; i++) {
          v = eci(i / n * T);
          out.push([wrap(Math.atan2(v[1], v[0]) * DEG - WE * tt),
                    Math.asin(clampUnit(v[2])) * DEG]);
        }
        return out;
      }
      function trackFrom(tt) {
        var out = [], i;
        var n = nTrack() * st.revolutions;
        for (i = 0; i <= n; i++) { out.push(ground(tt + i / n * T * st.revolutions)); }
        return out;
      }

      var ring = ringAt(0), track = trackFrom(0), sat = [G.smallCircle(0, 0, st.sat.size, 14)];
      function refresh() {
        ring = ringAt(t);
        track = trackFrom(t);
        var g = ground(t);
        sat = [G.smallCircle(g[0], g[1], st.sat.size, 14)];
      }
      refresh();

      var orbitLayer = host.addGeoLayer('lines', function () { return [ring]; },
        merge(st, { geoRadius: k, fillColor: 'none', fillOpacity: 0 }));
      var trackLayer = host.addGeoLayer('lines', function () { return [track]; },
        merge(st, merge(st.track, { fillColor: 'none', fillOpacity: 0 })));
      var satLayer = host.addGeoLayer('rings', function () { return sat; },
        merge(st, merge(st.sat, { geoRadius: k, strokeWidth: 0.5 })),
        function () { return [G.orientationOf(sat[0])]; });

      var el = {
        elType: 'geoorbit',
        layers: [orbitLayer, trackLayer, satLayer],
        /** Orbital period in seconds. */
        period: function () { return T; },
        /** Semi-major axis and altitude, in kilometres. */
        axis: function () { return a; },
        altitude: function () { return a - G.EARTH_RADIUS; },
        /** Orbital speed in km/s. */
        speed: function () { return Math.sqrt(MU / a); },
        radii: function () { return k; },
        time: function () { return t; },
        position: function () { return ground(t); },
        setTime: function (tt) {
          t = tt; refresh();
          orbitLayer.refresh(); trackLayer.refresh(); satLayer.refresh();
          return el;
        },
        /** Advance by a fraction of one revolution. */
        setPhase: function (u) { return el.setTime(u * T); },
        setVisible: function (on) {
          el.layers.forEach(function (l) { l.setVisible(on); });
          return el;
        },
        showTrack: function (on) { trackLayer.setVisible(on); return el; }
      };
      return el;
    };

    /**
     * A spherical triangle: three positions joined by great circles.
     *
     * The reason to build this in a geometry library rather than a map
     * library: the angle sum is never 180 degrees. The excess over 180 is the
     * area, in steradians — measure it, drag a corner, and the number moves
     * with the shape. That is a statement about the sphere, not about
     * cartography.
     *
     * @param {(Object|Array<number>)} a  First corner.
     * @param {(Object|Array<number>)} b  Second corner.
     * @param {(Object|Array<number>)} c  Third corner.
     * @param {Object} [attr]  Style, plus `samples` and `tolerance`.
     * @returns {Object} The triangle: `angles()`, `angleSum()`,
     *   `excess()` in degrees, `area()` in square km straight from the
     *   excess, `sides()` and `perimeter()` in km, `refresh()`,
     *   `setVisible(on)`, `remove()`. Corners in one place give zero
     *   rather than a negative area.
          * @alias GeoHost#geoTriangle
     */
    host.geoTriangle = function (a, b, c, attr) {
      var st = merge(STYLE.triangle, attr);
      var pts = [a, b, c], ring = [];

      function rebuild() {
        var i, k, p, q, n;
        ring = [];
        for (i = 0; i < 3; i++) {
          p = pos(pts[i]); q = pos(pts[(i + 1) % 3]);
          n = st.samples || G.samplesFor(G.angle(p, q) * RAD, pxOf(host), st.tolerance, 6);
          for (k = 0; k < n; k++) { ring.push(G.interpolate(p, q, k / n)); }
        }
      }
      rebuild();

      var fill = host.addGeoLayer('rings', function () { return [ring]; },
        merge(st, { strokeWidth: 0 }), function () { return [G.orientationOf(ring)]; });
      var edge = host.addGeoLayer('lines', function () { return [ring.concat([ring[0]])]; },
        merge(st, { fillColor: 'none', fillOpacity: 0 }));

      /**
       * Is any vertex on top of another?
       *
       * Then there is no triangle, and the angles are not merely inaccurate
       * but meaningless: `bearing` from a point to itself answers 0 for want
       * of anything better, the sum falls below 180, and the excess — and
       * with it the area — comes out negative. Two coincident vertices gave
       * -69.6 million km².
       */
      function degenerate() {
        var i, j;
        for (i = 0; i < 3; i++) {
          for (j = i + 1; j < 3; j++) {
            if (G.angle(pos(pts[i]), pos(pts[j])) < 1e-9) { return true; }
          }
        }
        return false;
      }

      /**
       * Interior angle at vertex i, between the two great circles.
       *
       * @param {number} i  Which vertex, 0 to 2.
       * @returns {number} The angle in degrees. Zero where two corners
       *   coincide, since there is no triangle then.
       */
      function angleAt(i) {
        if (degenerate()) { return i === 0 ? 180 : 0; }
        var v = pos(pts[i]);
        var d = Math.abs(G.bearing(v, pos(pts[(i + 1) % 3])) -
                         G.bearing(v, pos(pts[(i + 2) % 3])));
        return d > 180 ? 360 - d : d;
      }

      var undo = [];
      var el = {
        elType: 'geotriangle',
        vertices: pts, layers: [fill, edge],
        angles: function () { return [angleAt(0), angleAt(1), angleAt(2)]; },
        angleSum: function () { return angleAt(0) + angleAt(1) + angleAt(2); },
        /** Spherical excess in degrees — zero only in the flat limit. */
        excess: function () { return el.angleSum() - 180; },
        /** Area in square kilometres, straight from the excess. */
        area: function () {
          return el.excess() * RAD * G.EARTH_RADIUS * G.EARTH_RADIUS;
        },
        sides: function () {
          return [G.distance(pos(pts[0]), pos(pts[1])),
                  G.distance(pos(pts[1]), pos(pts[2])),
                  G.distance(pos(pts[2]), pos(pts[0]))];
        },
        perimeter: function () {
          return el.sides().reduce(function (x, y) { return x + y; }, 0);
        },
        refresh: function () { rebuild(); fill.refresh(); edge.refresh(); return el; },
        setVisible: function (on) { fill.setVisible(on); edge.setVisible(on); return el; },
        /**
         * Take it off: hide it, and stop following its vertices. Hiding alone
         * leaves the followers registered, still refreshing.
         */
        remove: function () {
          el.setVisible(false);
          undo.forEach(function (f) { f(); });
          undo.length = 0;
          return el;
        }
      };
      pts.forEach(function (p) {
        follow(p, function () { el.refresh(); }, undo);
      });
      return el;
    };

    /**
     * A target reticle: two concentric rings, four ticks and a centre dot.
     *
     * Everything is measured in degrees of arc rather than in screen units,
     * so the same source serves both hosts — on the globe it sits on the
     * surface and turns with it, on the map it goes through the projection
     * like any other geometry, which is why it deforms towards the edges
     * exactly as the map does.
     *
     * @param {(Object|Array<number>)} centre  A geoPoint or a pair.
     * @param {Object} [attr]  Style, plus `units` — "screen" for a fixed
     *   size in pixels, anything else for a size in degrees; `px` for
     *   that size; `farSide` to draw the half behind the globe.
     * @returns {?Object} The reticle: `coords()`, `moveTo(lon, lat)`,
     *   `refresh()`, `setVisible(on)`, `remove()`.
          * @alias GeoHost#geoReticle
     */
    host.geoReticle = function (centre, attr) {
      var st = merge(STYLE.reticle, attr);
      if (st.units === 'screen') { return screenReticle(centre, st); }
      var lines = [], dot = [];

      function rebuild() {
        var c = pos(centre), i, b;
        lines = st.rings.map(function (r) {
          var px = pxOf(host) * Math.sin(r * RAD);
          var ring = G.smallCircle(c[0], c[1], r,
            G.samplesFor(2 * Math.PI, px, st.tolerance, 12));
          return ring.concat([ring[0]]);
        });
        for (i = 0; i < 4; i++) {
          b = i * 90;
          lines.push([G.destination(c[0], c[1], st.ticks[0], b),
                      G.destination(c[0], c[1], (st.ticks[0] + st.ticks[1]) / 2, b),
                      G.destination(c[0], c[1], st.ticks[1], b)]);
        }
        dot = [G.smallCircle(c[0], c[1], st.dot, 12)];
      }
      rebuild();

      var lineStyle = merge(st, { fillColor: 'none', fillOpacity: 0 });
      var dotStyle = merge(st, { fillColor: st.strokeColor, strokeWidth: 0.5,
                                 layer: st.layer });
      var layers = [
        host.addGeoLayer('lines', function () { return lines; }, lineStyle),
        host.addGeoLayer('rings', function () { return dot; }, dotStyle,
          function () { return [G.orientationOf(dot[0])]; })
      ];
      if (st.farSide) {
        var back = merge(lineStyle, merge(st.farSideStyle, { geoSide: -1 }));
        var backDot = merge(dotStyle, merge(st.farSideStyle,
          { geoSide: -1, fillOpacity: 0.3 }));
        layers.push(host.addGeoLayer('lines', function () { return lines; }, back));
        layers.push(host.addGeoLayer('rings', function () { return dot; }, backDot,
          function () { return [G.orientationOf(dot[0])]; }));
      }

      var el = {
        elType: 'georeticle',
        centre: centre,
        layers: layers,
        coords: function () { return pos(centre); },
        refresh: function () {
          rebuild();
          layers.forEach(function (l) { l.refresh(); });
          return el;
        },
        moveTo: function (lo, la) {
          if (centre && centre.moveTo) { centre.moveTo(lo, la); } else { centre = [lo, la]; el.refresh(); }
          return el;
        },
        setVisible: function (on) {
          layers.forEach(function (l) { l.setVisible(on); });
          return el;
        }
      };
      if (centre && centre.followers) { centre.followers.push(function () { el.refresh(); }); }
      return el;
    };

    /**
     * Reticle of constant size on screen.
     *
     * The geographic variant is the honest one — it sits on the surface and
     * deforms with the map, which near a pole means a 5° circle spanning half
     * the sheet. Useful as a distortion gauge, useless as a cursor. This one
     * is drawn in board coordinates around the projected centre instead, so
     * it stays a reticle wherever it goes.
     *
     * @param {(Object|Array<number>)} centre  A geoPoint or a position.
     * @param {Object} st  The merged style.
     * @returns {?Object} The reticle, or null without a board.
     */
    function screenReticle(centre, st) {
      var board = host.board;
      if (!board || typeof board.create !== 'function') {
        G.warn('a screen reticle needs a host with a board');
        return null;
      }
      var px = st.px;
      function unit() { return 1 / (board.unitX || 1); }   // pixels to board units

      function build(withDot, wantBack) {
        var p = host.fromGeo(pos(centre)[0], pos(centre)[1]);
        var X = [], Y = [], u = unit(), i, k, a;
        if (!p || !isFinite(p.x)) { return { X: [], Y: [] }; }
        // On a globe the point may be behind the horizon. front is undefined
        // on a map, where there is no far side, so it counts as front.
        var back = p.front === false;
        if (back !== !!wantBack) { return { X: [], Y: [] }; }
        function circle(rad) {
          for (i = 0; i <= 40; i++) {
            a = 2 * Math.PI * i / 40;
            X.push(p.x + rad * u * Math.cos(a));
            Y.push(p.y + rad * u * Math.sin(a));
          }
          X.push(NaN); Y.push(NaN);
        }
        if (withDot) { circle(px.dot); return { X: X, Y: Y }; }
        for (k = 0; k < px.rings.length; k++) { circle(px.rings[k]); }
        for (k = 0; k < 4; k++) {
          a = k * Math.PI / 2;
          X.push(p.x + px.ticks[0] * u * Math.cos(a), p.x + px.ticks[1] * u * Math.cos(a), NaN);
          Y.push(p.y + px.ticks[0] * u * Math.sin(a), p.y + px.ticks[1] * u * Math.sin(a), NaN);
        }
        return { X: X, Y: Y };
      }

      function makeCurve(withDot, wantBack, style) {
        var c = board.create('curve', [[], []], style);
        c.updateDataArray = function () {
          var d = build(withDot, wantBack);
          this.dataX = d.X; this.dataY = d.Y;
        };
        return c;
      }
      var curves = [
        makeCurve(false, false, merge(st, { fillColor: 'none', fillOpacity: 0 })),
        makeCurve(true, false, merge(st, { fillColor: st.strokeColor, strokeWidth: 0.5 }))
      ];
      // plain 2D curves on a 3D board: they must be refreshed by hand
      var undo = [];
      curves.forEach(function (c) { keepUpdated(board, c, undo); });
      if (st.farSide) {
        curves.push(makeCurve(false, true, merge(merge(st, st.farSideStyle),
          { fillColor: 'none', fillOpacity: 0 })));
        curves.push(makeCurve(true, true, merge(merge(st, st.farSideStyle),
          { fillColor: st.strokeColor, fillOpacity: 0.3,
            strokeWidth: 0.5 })));
        keepUpdated(board, curves[2], undo);
        keepUpdated(board, curves[3], undo);
      }
      var el = {
        elType: 'georeticle', units: 'screen', centre: centre,
        curves: curves,
        coords: function () { return pos(centre); },
        refresh: function () { board.update(); return el; },
        moveTo: function (lo, la) {
          if (centre && centre.moveTo) { centre.moveTo(lo, la); } else { centre = [lo, la]; }
          board.update();
          return el;
        },
        setVisible: function (on) {
          curves.forEach(function (c) { c.setAttribute({ visible: on }); });
          board.update();
          return el;
        },
        /**
         * Take it off the board for good: hide the curves, and give back the
         * update listeners each of them needed. Hiding alone leaves those
         * behind, and they go on refreshing a curve nobody looks at.
         */
        remove: function () {
          el.setVisible(false);
          undo.forEach(function (f) { f(); });
          undo.length = 0;
          if (board.removeObject) {
            curves.forEach(function (c) { board.removeObject(c); });
          }
          return el;
        }
      };
      if (centre && centre.followers) { centre.followers.push(function () { el.refresh(); }); }
      return el;
    }

    /**
     * A draggable JSXGraph point bound to a geoPoint.
     *
     * This is the part a map library cannot offer: the handle is an ordinary
     * JSXGraph point, so it can be constrained, measured, bound to a slider
     * or used as a parent of any other construction — and the geographic
     * position follows it. Dragging updates the geoPoint, which in turn
     * refreshes every path and range that depends on it.
     *
     * @param {Object} gp  The geoPoint to bind to.
     * @param {Object} [attr]  Attributes for the JSXGraph point.
     * @returns {?Object} The handle: `point`, `setVisible(on)`. Null
     *   where the host has no board.
          * @alias GeoHost#geoHandle
     */
    host.geoHandle = function (gp, attr) {
      var board = host.board;
      if (!board || typeof board.create !== 'function') {
        G.warn('geoHandle needs a host with a board');
        return null;
      }
      // Where the host can render a real 3D point, let it: such a point is
      // updated by JSXGraph itself while the view is being dragged, and it
      // sits correctly in the layer order. The 2D point below then only has
      // to carry the dragging, which a point3d on a sphere cannot do yet.
      var shown = null;
      if (typeof host.geoPoint3D === 'function' && !(attr && attr.flat)) {
        shown = host.geoPoint3D(gp.lon.bind(gp), gp.lat.bind(gp),
          { size: (attr && attr.size) || 4,
            fillColor: (attr && attr.fillColor) || '#cf9b3f' });
      }
      var start = host.fromGeo(gp.lon(), gp.lat());
      var pt = board.create('point', [start.x, start.y], merge({
        size: 4, name: '', withLabel: false, showInfobox: false,
        fillColor: '#cf9b3f', strokeColor: '#071018', strokeWidth: 1
      }, shown ? merge(attr, { fillOpacity: 0, strokeOpacity: 0, size: 9 })
               : attr));
      var dragging = false;

      pt.on('down', function () { dragging = true; });
      pt.on('drag', function () {
        var g = host.toGeo(pt.X(), pt.Y());
        if (!g) {
          // The pointer has left the globe. Put the handle back where the
          // position still is, or it stays behind as a stray marker with
          // nothing attached to it.
          snapBack();
          return;
        }
        gp.moveTo(g[0], g[1]);
        if (attr && attr.onMove) { attr.onMove(g[0], g[1]); }
      });
      pt.on('up', function () { dragging = false; snapBack(); });

      function snapBack() {
        var q = host.fromGeo(gp.lon(), gp.lat());
        if (!isFinite(q.x) || !isFinite(q.y)) { return; }
        pt.setPosition(JXG.COORDS_BY_USER, [q.x, q.y]);
        if (typeof pt.fullUpdate === 'function') { pt.fullUpdate(); }
      }

      gp.handle = pt;
      gp.shown = shown;
      pt.geoPoint = gp;
      pt.shown = shown;
      // Kept before the update handler below, which calls it: the original
      // setAttribute has to exist by the time anything reaches for it.
      var origSet = pt.setAttribute.bind(pt);
      pt.setAttribute = function (o) {
        if (o && o.visible !== undefined) {
          // Remember what was asked for. The update handler below hides a
          // handle that has gone behind the horizon and brings it back; without
          // this it would also bring back one that was switched off.
          pt.geoWanted = o.visible;
          if (shown) { shown.setAttribute({ visible: o.visible }); }
        }
        return origSet(o);
      };

      // Follow the view: when the globe turns or the map recentres, the
      // handle has to be put back where its geographic position now lies.
      board.on('update', function () {
        if (dragging) { return; }
        var q = host.fromGeo(gp.lon(), gp.lat());
        if (!isFinite(q.x) || !isFinite(q.y)) { return; }
        if (Math.abs(q.x - pt.X()) > 1e-9 || Math.abs(q.y - pt.Y()) > 1e-9) {
          pt.setPosition(JXG.COORDS_BY_USER, [q.x, q.y]);
          // While the view is being turned the point is not in the update
          // list, so setting the position alone would leave it on screen
          // where it was. Redraw it explicitly.
          if (typeof pt.fullUpdate === 'function') { pt.fullUpdate(); }
        }
        // hide a handle that has gone behind the horizon, and bring it back
        // only if it was not switched off on purpose
        if (pt.geoWanted === false) { return; }
        if (q.front === false && pt.visProp.visible !== false) {
          origSet({ visible: false });
          if (shown) { shown.setAttribute({ visible: false }); }
        } else if (q.front !== false && pt.visProp.visible === false) {
          origSet({ visible: true });
          if (shown) { shown.setAttribute({ visible: true }); }
        }
      });

      return pt;
    };

    /**
     * Fly a marker along a path. Returns a handle; stop() ends it. Without a
     * marker one is created, so a single call is enough to see something move.
     *
     * @param {Object} path  The path to run along.
     * @param {Object} [opts]  `marker` to reuse one; `duration` in ms;
     *   `loop`; `from` and `to` as fractions; `onFrame(marker, u)` and
     *   `onEnd(marker)`.
     * @returns {Object} `marker`, `stop()`, `restart()`. Where the host
     *   has no frame timer nothing is animated and a warning is given.
          * @alias GeoHost#geoAnimate
     */
    host.geoAnimate = function (path, opts) {
      opts = opts || {};
      var marker = opts.marker || (path.markers[0]) || host.geoMarker(path);
      var duration = opts.duration || 6000, loop = !!opts.loop;
      var from = opts.from === undefined ? 0 : opts.from;
      var to = opts.to === undefined ? 1 : opts.to;
      var raf = null, t0 = null, running = true;

      // Not every host has a frame timer: a page rendered on a server, or a
      // test, has none, and asking for one threw. There the animation is
      // simply not run, and the marker is left where it was told to start.
      var haveRaf = typeof root.requestAnimationFrame === 'function';
      if (!haveRaf) {
        G.warn('geoAnimate: no requestAnimationFrame here; not animating');
        running = false;
        marker.setT(from);
      }

      function step(now) {
        if (!running) { return; }
        if (t0 === null) { t0 = now; }
        var u = (now - t0) / duration;
        if (u >= 1) {
          if (loop) { t0 = now; u = 0; } else { u = 1; running = false; }
        }
        marker.setT(from + (to - from) * u);
        if (opts.onFrame) { opts.onFrame(marker, u); }
        if (running) { raf = root.requestAnimationFrame(step); } else if (opts.onEnd) { opts.onEnd(marker); }
      }
      if (haveRaf) { raf = root.requestAnimationFrame(step); }

      return {
        marker: marker,
        stop: function () { running = false; if (raf) { root.cancelAnimationFrame(raf); } return this; },
        /**
         * Start again from the beginning.
         *
         * The running loop is stopped first. Without that every call added a
         * loop of its own — three restarts left four running, all moving the
         * same marker, and the thing juddered.
         */
        restart: function () {
          if (!haveRaf) { return this; }
          if (raf) { root.cancelAnimationFrame(raf); }
          t0 = null;
          running = true;
          raf = root.requestAnimationFrame(step);
          return this;
        }
      };
    };

    // convenience: path.animate(opts)
    var origPath = host.geoPath;
    host.geoPath = function (a, b, attr) {
      var el = origPath(a, b, attr);
      el.animate = function (opts) { return host.geoAnimate(el, opts); };
      return el;
    };

    return host;
  }

  G.attachElements = attach;

  // attach automatically to anything created afterwards
  ['createGlobe3D', 'createGeomap'].forEach(function (fn) {
    if (typeof JXG[fn] !== 'function') { return; }
    var orig = JXG[fn];
    JXG[fn] = function (board, parents, attributes) {
      return attach(orig(board, parents, attributes));
    };
    JXG.registerElement(fn === 'createGlobe3D' ? 'globe3d' : 'geomap', JXG[fn]);
  });

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