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