/*
Copyright 2026
JXG.Geography contributors
This file is part of JXG.Geography, an extension for JSXGraph.
JXG.Geography is free software dual licensed under the GNU LGPL or MIT
License, at your choice — the same terms JSXGraph itself is offered under.
* https://www.gnu.org/licenses/lgpl-3.0.html
* https://opensource.org/licenses/MIT
Geographic data: Natural Earth (naturalearthdata.com), public domain.
*/
/**
* JXG.Geography — globe3d element.
*
* A globe is a camera plus a body, so this inherits from view3d rather than
* from sphere3d: the axes, planes and sliders are switched off once here
* instead of in every call, and lookAt / spin / project3DTo2D sit where they
* belong.
*
* var globe = board.create('globe3d', [], {
* data: JXG.Geography.datasets.naturalEarth110,
* countries: { visible: true },
* highlight: { DEU: 'green' }
* });
*
* Requires jxg-geography.js.
*
*
* CONVENTIONS
*
* Positions are [lon, lat] in degrees, longitude first, and the body is the
* unit sphere scaled by `scale`. `at(bx, by)` and `fromGeo(lon, lat)` are
* inverses: measured over 289 points of the visible face, the worst
* round-trip error is 7e-14 degrees. `at` answers null where the ray misses
* the sphere; `fromGeo` always answers, and says with `front` whether the
* point faces the camera.
*
* `lookAt` is the only supported way to aim. Slider.setValue clamps hard and
* updateAngleSliderBounds picks different ranges depending on the trackball,
* so a raw setView latches at 90 degrees east or anywhere in the southern
* hemisphere.
*
* Every ring layer states its winding, and the globe uses the stated value as
* given — unlike the map, which imposes its own. The measure is
* `orientationOf`, read on the sphere: the planar shoelace misreads Tuvalu at
* 179.2 degrees and five of the forty Tissot circles, all of them on the
* antimeridian.
*
* A selection follows one rule whichever way it is made. A tap goes through
* `select` and `deselect`, so those and `setSelection` agree about what
* `selectMode` means, refuse an id nothing answers to, and say nothing when
* nothing changed.
*
* Sizes follow the drawn size. `pixelRadius()` reports the sphere's radius in
* device pixels, and everything sampled — layers, and the geo elements above
* them — derives its resolution from that rather than from a fixed count.
*
* `destroy()` gives back every listener the globe installed: five per globe,
* and there was no way back before. Anything that animates checks for a frame
* timer first and does the change at once where there is none.
*
* Known limits: the far side is drawn by a second pass rather than by depth
* sorting, so two layers behind the body do not occlude one another. The
* trackball is JSXGraph's, and `keepUpright` corrects the bank after the
* fact rather than constraining the gesture.
*/
/**
* A globe: a camera and a body, inheriting from `view3d`. Nothing is
* projected — the geometry stays on the sphere and the horizon does the
* clipping.
*
* Created with `board.create('globe3d', [corner, size], attributes)`.
*
* @namespace Globe3D
*/
(function (root) {
'use strict';
var JXG = root.JXG;
var G = JXG.Geography;
var RAD = G.RAD, TAU = 2 * Math.PI;
/**
* An angle brought into [0, 2pi).
*
* @param {number} a An angle in radians, anywhere on the line.
* @returns {number} The same angle in [0, 2pi).
*/
function wrapTau(a) { return ((a % TAU) + TAU) % TAU; }
/**
* A value held inside a closed interval.
*
* @param {number} v The value.
* @param {number} a Lower bound.
* @param {number} b Upper bound.
* @returns {number} The value, or whichever bound it passed.
*/
function clamp(v, a, b) {
if (v < a) { return a; }
if (v > b) { return b; }
return v;
}
var LAYER_STYLE = {
visible: false, strokeColor: '#000000', strokeWidth: 1, strokeOpacity: 1,
fillColor: 'none', fillOpacity: 1, dash: 0, layer: 12, lod: 'always',
highlight: false, fixed: true
};
/**
* Attributes are merged here rather than through JXG.copyAttributes.
* That helper runs keysToLowerCase() recursively over the caller's object,
* so structured attributes would silently arrive as body.fillcolor and
* smallcountryradius. Style sub-objects are handed to create() untouched,
* where JSXGraph applies its own normalisation.
*/
/**
* Is there a frame timer here?
*
* A page rendered on a server, or a test, has none, and asking for one
* threw. Anything that animates checks first and does the whole change at
* once instead, which is the honest fallback: the end state is reached,
* only without the motion.
*
* @returns {boolean} True where requestAnimationFrame can be called.
*/
function canAnimate() {
return typeof root.requestAnimationFrame === 'function' &&
typeof root.cancelAnimationFrame === 'function';
}
/**
* A plain object, as against an array or a DOM node.
*
* @param {*} v Anything.
* @returns {boolean} True for a plain object.
*/
function isPlain(v) {
return v !== null && typeof v === 'object' && !Array.isArray(v) &&
v.constructor === Object;
}
/**
* Attributes merged one level deep.
*
* A layer's style is a sub-object, so a caller naming one property of it
* must not lose the rest. Anything that is not a plain object — an array,
* a colour, a number — replaces rather than merges.
*
* @param {Object} base The defaults.
* @param {Object} [over] What the caller asked for.
* @returns {Object} A new object; neither argument is touched.
*/
function mergeAttr(base, over) {
var out = {}, k;
for (k in base) {
if (!base.hasOwnProperty(k)) { continue; }
if (isPlain(base[k])) {
out[k] = mergeAttr(base[k], {});
} else if (Array.isArray(base[k])) {
out[k] = base[k].slice();
} else {
out[k] = base[k];
}
}
for (k in (over || {})) {
if (!over.hasOwnProperty(k)) { continue; }
if (isPlain(out[k]) && isPlain(over[k])) {
out[k] = mergeAttr(out[k], over[k]);
} else {
out[k] = over[k];
}
}
return out;
}
/**
* @class A globe: a camera and a body, with geographic layers on it.
* @pseudo
* @name Globe3D
* @augments JXG.View3D
* @constructor
* @type JXG.View3D
* @throws {Error} If the element cannot be constructed with the given parent
* objects an exception is thrown.
* @param {Array_Array} lowerLeft,size Position and size of the drawing
* rectangle in board coordinates, as for view3d. Omit both to fill
* the board.
*
* @example
* var globe = board.create('globe3d', [], {
* data: JXG.Geography.datasets.naturalEarth110,
* farSide: { visible: true }
* });
* globe.lookAt(10, 48);
*/
JXG.Options.globe3d = {
/**#@+
* @visprop
*/
data: null,
smallCountryRadius: 1.15,
showSmallCountries: true, // stand-in circles for countries with no outline
// The globe has no radius attribute. Its size comes from the drawing
// rectangle [w, h] and from the board's bounding box, exactly like every
// other JSXGraph element; the world cube is fixed at the unit sphere.
// `scale` is a pure display factor, not geometry: 0 draws nothing.
scale: 1,
body: {
center: [0, 0, 0],
fillColor: '#0e2739', fillOpacity: 1, gradient: null,
strokeColor: '#2a5c7d', strokeWidth: 1.2, layer: 10
},
land: { visible: true, fillColor: '#dcd3bf', fillOpacity: 1,
strokeColor: 'inherit', strokeWidth: 0.8, layer: 11 },
coast: { visible: true, strokeColor: '#8d8471', strokeWidth: 0.9, layer: 12 },
countries: { visible: false, strokeColor: '#a89a7d', strokeWidth: 0.55,
strokeOpacity: 0.75, layer: 12, lod: 'still' },
capitals: { visible: false, fillColor: '#cf9b3f', strokeColor: '#071018',
strokeWidth: 0.5, size: 0.62, layer: 13, lod: 'still' },
graticule: { visible: true, strokeColor: '#1b4059', strokeWidth: 0.7,
strokeOpacity: 0.7, step: [30, 30], layer: 12 },
farSide: { visible: false, fillColor: '#dcd3bf', fillOpacity: 0.2,
strokeColor: '#dcd3bf', strokeOpacity: 0.35, strokeWidth: 0.6, layer: 9 },
highlight: {},
highlightStyle: { fillColor: '#cf9b3f', fillOpacity: 0.42,
strokeColor: '#cf9b3f', strokeWidth: 1.2, layer: 14 },
features: { show: [], strokeColor: '#5fb0c6', strokeWidth: 1.1,
strokeOpacity: 0.9, layer: 13 },
distortion:{ mode: 'none', step: 30, radius: 6, latMax: 60,
strokeColor: '#cf9b3f', strokeWidth: 1,
fillColor: '#cf9b3f', fillOpacity: 0.16, layer: 13 },
rotate: 'free',
keepUpright: false,
trackballReset: true,
clickTolerance: 12, // px a pointer may travel and still count as a tap;
// a finger is far less steady than a mouse
selectMode: 'none', // 'none' | 'single' | 'multiple'
hoverCountries: false,
picking: true, // false skips the country lookup altogether
lod: { moving: null, still: 'all' },
// view3d defaults suitable for a globe
projection: 'parallel',
trackball: { enabled: true },
depthOrder: { enabled: false },
az: { slider: { visible: false } },
el: { slider: { visible: false } },
bank: { slider: { visible: false } },
xAxis: { visible: false }, yAxis: { visible: false }, zAxis: { visible: false },
xPlaneRear: { visible: false }, yPlaneRear: { visible: false }, zPlaneRear: { visible: false },
xPlaneFront: { visible: false }, yPlaneFront: { visible: false }, zPlaneFront: { visible: false },
xAxisBorder: { visible: false }, yAxisBorder: { visible: false }, zAxisBorder: { visible: false },
infobox: { visible: false }
/**#@-*/
};
JXG.createGlobe3D = function (board, parents, attributes) {
var attr = mergeAttr(JXG.Options.globe3d, attributes);
var ds = attr.data;
if (!ds) { G.warn('globe3d needs a data attribute'); }
// ---------------------------------------------------------- geometry
// A display factor on the unit sphere, so it may be animated freely
// without ever touching the world cube.
var scaleFn = (typeof attr.scale === 'function')
? attr.scale
: function () { return attr.scale; };
function scaleNow() { return scaleFn(); }
var HALF = 1.15; // world cube, fixed
var pos = parents && parents[0] ? parents[0] : null;
var size = parents && parents[1] ? parents[1] : null;
if (!pos || !size) {
var bb = board.getBoundingBox();
var w = Math.min(bb[2] - bb[0], bb[1] - bb[3]) * 0.94;
pos = [(bb[0] + bb[2]) / 2 - w / 2, (bb[3] + bb[1]) / 2 - w / 2];
size = [w, w];
}
var cube = [[-HALF, HALF], [-HALF, HALF], [-HALF, HALF]];
var view = board.create('view3d', [pos, size, cube], attr);
// ------------------------------------------------------------ camera
function frame() {
var m = view.matrix3DRot;
return { r1: [m[1][1], m[1][2], m[1][3]],
r2: [m[2][1], m[2][2], m[2][3]],
cam: [m[3][1], m[3][2], m[3][3]] };
}
function cameraVector() { return frame().cam; }
function project(p) { return view.project3DTo2D(p); }
/**
* The only supported way to aim the camera. Slider.setValue clamps hard,
* and updateAngleSliderBounds() picks different ranges depending on the
* trackball: azimuth always [0, 2pi], elevation [-pi/2, pi/2] with the
* trackball and [0, 2pi] without. A raw setView latches at 90 degrees
* east, or anywhere in the southern hemisphere.
*
* @param {number} lon Longitude to face, in degrees.
* @param {number} lat Latitude, held just short of the poles.
* @returns {Object} The globe, for chaining.
* @alias Globe3D#lookAt
*/
view.lookAt = function (lon, lat) {
var e = clamp(lat, -89.999, 89.999) * RAD;
view.setView(wrapTau((90 - lon) * RAD),
view.el_slide._smin < 0 ? e : wrapTau(e));
return view;
};
/**
* Where the camera is looking.
*
* @returns {Array<number>} [lon, lat] in degrees, read back out of the
* rotation matrix.
* @alias Globe3D#centre
*/
view.centre = function () { return G.centreFromMatrix(view.matrix3DRot); };
view.center = view.centre;
view.scale = scaleFn;
/**
* Shrink the Earth so a whole orbit fits inside the world cube.
*
* @param {number} f The new radius in world units. Zero or less is
* refused: negative mirrors the body through the centre and
* leaves `at` answering null everywhere.
* @returns {Object} The globe, for chaining.
* @alias Globe3D#setScale
*/
view.setScale = function (f) {
// A scale of zero or less is not a smaller globe. Negative mirrors the
// body through the centre — measured: every point lands at minus where
// it was — and it still draws, while `at` answers null everywhere, so
// the globe cannot be clicked and nothing says why. Zero collapses it
// to a point.
var v = f;
if (!(v > 0)) {
G.warn('globe3d: a scale of ' + v + ' is not a size; keeping ' +
scaleNow());
return view;
}
scaleFn = function () { return v; };
attr.scale = v;
board.update();
return view;
};
/**
* Animated change of scale.
*
* Cheaper than the projection morph and needing none of its bookkeeping:
* every layer multiplies by scaleNow() while it draws, so nothing has to
* be rebuilt, no point counts have to match, and layers above the surface
* follow by themselves because their factor is relative.
*
* @param {number} target The radius to end at.
* @param {Object} [opts] `duration` in ms, `onEnd()`.
* @returns {Object} The globe, for chaining. Without a frame timer the
* target is set at once and `onEnd` still runs.
* @alias Globe3D#scaleTo
*/
var scaleRaf = null;
view.scaleTo = function (target, opts) {
opts = opts || {};
var from = scaleNow(), dur = opts.duration || 700, t0 = null;
function ease(u) { return u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2; }
view.stopScale();
// No timer, no animation: the target is set at once, so the caller ends
// up where it asked to be either way.
if (!canAnimate()) {
view.setScale(target);
if (opts.onEnd) { opts.onEnd(); }
return view;
}
function step(now) {
if (t0 === null) { t0 = now; }
var u = Math.min(1, (now - t0) / dur);
view.setScale(from + (target - from) * ease(u));
if (u < 1) {
scaleRaf = root.requestAnimationFrame(step);
} else {
scaleRaf = null;
if (opts.onEnd) { opts.onEnd(); }
}
}
scaleRaf = root.requestAnimationFrame(step);
return view;
};
/**
* Cut a running scale animation short, leaving the radius where it is.
*
* @returns {Object} The globe, for chaining.
* @alias Globe3D#stopScale
*/
view.stopScale = function () {
if (scaleRaf && canAnimate()) { root.cancelAnimationFrame(scaleRaf); }
scaleRaf = null;
return view;
};
/**
* Screen point to geographic position, or null beside the globe.
*
* @param {number} bx Board x.
* @param {number} by Board y.
* @returns {?Array<number>} [lon, lat] in degrees, or null where the
* point misses the sphere.
* @alias Globe3D#at
*/
view.at = function (bx, by) {
var f = frame(), r = scaleNow();
if (!(r > 0)) { return null; }
var o = project([0, 0, 0]);
var a = project([f.r1[0] * r, f.r1[1] * r, f.r1[2] * r]);
var b = project([f.r2[0] * r, f.r2[1] * r, f.r2[2] * r]);
var a11 = a[1] - o[1], a12 = b[1] - o[1], a21 = a[2] - o[2], a22 = b[2] - o[2];
var det = a11 * a22 - a12 * a21;
if (Math.abs(det) < 1e-12) { return null; }
var u = bx - o[1], v = by - o[2];
var al = (a22 * u - a12 * v) / det, be = (-a21 * u + a11 * v) / det;
var q = 1 - al * al - be * be;
if (q < 0) { return null; }
var g = Math.sqrt(q);
return G.toGeo([al * f.r1[0] + be * f.r2[0] + g * f.cam[0],
al * f.r1[1] + be * f.r2[1] + g * f.cam[1],
al * f.r1[2] + be * f.r2[2] + g * f.cam[2]]);
};
view.toGeo = view.at;
/**
* Geographic position to screen; `front` says whether it faces us.
*
* @param {number} lon Longitude in degrees.
* @param {number} lat Latitude in degrees.
* @returns {{x: number, y: number, front: boolean}} Board position,
* and whether the point faces the camera.
* @alias Globe3D#fromGeo
*/
view.fromGeo = function (lon, lat) {
var r = scaleNow(), v = G.toVector(lon, lat);
var P = project([v[0] * r, v[1] * r, v[2] * r]);
return { x: P[1], y: P[2], front: G.dot(v, cameraVector()) > 0 };
};
/**
* Present so a host can be asked the same question as a map.
*
* A sphere has no strip to clip against — the horizon does that work —
* so there is no shared state to hand out.
*
* @returns {null} Always.
* @alias Globe3D#clipState
*/
view.clipState = function () { return null; };
/**
* Screen radius of the sphere in pixels. Everything drawn on or above it
* derives its sampling from this, so the resolution follows the size on
* screen instead of a fixed count.
* @alias Globe3D#pixelRadius
*/
view.pixelRadius = function () {
// The world cube [-HALF, HALF] is mapped into the drawing rectangle, so
// one unit-sphere radius is size/(2*HALF) board units before unitX turns
// it into pixels. Leaving that factor out under-reports the radius by
// more than a factor of two, and everything sampled from it comes out
// too coarse.
var perCube = size[0] / (2 * HALF);
return Math.abs(scaleNow() * perCube * (board.unitX || 1)) * G.devicePixels();
}; // no strip clipping on a sphere
// ------------------------------------------------------------ layers
var layers = {}, moving = false;
// Settings that belong to the element as a whole rather than to one
// layer, and are therefore taken from the globe when a layer does not
// name them. `tabindex: null` is the reason: the default of -1 leaves a
// curve focusable by mouse, so clicking the body drew the browser's focus
// ring around it, and setting it on the globe reached nothing. The map
// carries the same list.
var PASSED_THROUGH = ['tabindex', 'cssClass', 'highlightCssClass',
'needsRegularUpdate', 'nonReflexive'];
function styleOf(name) {
var a = attr[name] || {}, o = {}, k, i;
for (k in LAYER_STYLE) { if (LAYER_STYLE.hasOwnProperty(k)) { o[k] = LAYER_STYLE[k]; } }
for (i = 0; i < PASSED_THROUGH.length; i++) {
k = PASSED_THROUGH[i];
if (attr.hasOwnProperty(k)) { o[k] = attr[k]; }
}
for (k in a) { if (a.hasOwnProperty(k)) { o[k] = a[k]; } }
if (o.strokeColor === 'inherit') { o.strokeColor = o.fillColor; }
delete o.lod; delete o.step; delete o.size; delete o.show; delete o.mode;
delete o.latMax; delete o.radius;
return o;
}
function lodOf(name) { return (attr[name] && attr[name].lod) || 'always'; }
function addLines(name, source, extra) {
var st = styleOf(name), el;
if (extra) { for (var k in extra) { if (extra.hasOwnProperty(k)) { st[k] = extra[k]; } } }
el = view.create('curve3d', [[], [], []], st);
G.maskedLayer(el, {
segments: source, camera: cameraVector, project: project,
radius: scaleNow, side: 1, active: st.visible
});
el.geoName = name; el.geoLod = lodOf(name);
layers[name] = el;
return el;
}
function addRings(name, source, side, extra, orients) {
var st = styleOf(name), el;
if (extra) { for (var k in extra) { if (extra.hasOwnProperty(k)) { st[k] = extra[k]; } } }
el = view.create('curve3d', [[], [], []], st);
G.maskedRings(el, {
rings: source, camera: cameraVector, frame: frame, project: project,
radius: scaleNow, side: side || 1, active: st.visible, orients: orients
});
el.geoName = name; el.geoLod = lodOf(name);
layers[name] = el;
return el;
}
// body ------------------------------------------------------------
/**
* What the body's tab index should be.
*
* @returns {?number} The globe's own setting where it has one, the body's
* where it names one, and JSXGraph's -1 otherwise.
*/
function bodyTabindex() {
if (attr.hasOwnProperty('tabindex')) { return attr.tabindex; }
if (attr.body.tabindex !== undefined) { return attr.body.tabindex; }
return -1;
}
var body = view.create('sphere3d', [attr.body.center, scaleNow], {
layer: attr.body.layer,
fillColor: attr.body.fillColor, fillOpacity: attr.body.fillOpacity,
gradient: attr.body.gradient,
strokeColor: attr.body.strokeColor, strokeWidth: attr.body.strokeWidth,
highlight: false, fixed: true,
// The body is the largest thing on the board and takes most clicks, so
// it needs the pass-through settings as much as any layer does — with
// `tabindex: null` first among them, or a click on the sphere draws the
// browser's focus ring around the whole globe.
tabindex: bodyTabindex(),
// providePoints3D turns [0,0,0] into a real, labelled, draggable point;
// the sub-object key it reads is 'center'
center: { visible: false, fixed: true, withLabel: false, name: '',
highlight: false, showInfobox: false }
});
view.body = body;
// geometry sources, all on the unit sphere ---------------------------
/**
* Every ring layer carries its winding along. Deriving it here rather
* than assuming counter-clockwise is what keeps a hole — or any ring a
* dataset happens to store the other way round — from being closed over
* the wrong arc, which fills the whole disc and inverts the layer.
*
* Read on the sphere, not in the plane. The planar shoelace is wrong for
* exactly the rings that matter here: measured against the dataset, it
* misreads Tuvalu at 179.2 degrees and five of the forty Tissot circles,
* all of them sitting on the antimeridian. Unlike the map, the globe uses
* the stated value as given.
*
* @param {Array<Array<Array<number>>>} llRings Rings of [lon, lat].
* @returns {Array<number>} One winding per ring, +1 or -1.
*/
function signsOf(llRings) {
return llRings.map(function (r) { return G.orientationOf(r); });
}
function toXYZ(rings, close) {
return rings.map(function (r) {
var q = r.map(function (p) { return G.toVector(p[0], p[1]); });
if (close) { q.push(q[0]); }
return q;
});
}
var srcLand = [], srcLandOrient = [], srcCoast = [], srcCountries = [],
srcCaps = [], srcCapsOrient = [], srcGrat = [];
function rebuildCountries() {
srcCountries.length = 0;
if (!ds) { return; }
ds.countries.forEach(function (c) {
G.countryRings(c, attr.smallCountryRadius,
attr.showSmallCountries).forEach(function (r) {
srcCountries.push(r.map(function (p) { return G.toVector(p[0], p[1]); })
.concat([G.toVector(r[0][0], r[0][1])]));
});
});
}
if (ds) {
ds.land.polys.forEach(function (pl) {
// first ring of a polygon is the outline, the rest are holes and run
// the other way round — the clipper has to be told
pl.forEach(function (r, i) {
srcLand.push(r.map(function (p) { return G.toVector(p[0], p[1]); }));
srcLandOrient.push(i === 0 ? 1 : -1);
});
});
srcCoast = toXYZ(ds.coast.rings, true);
rebuildCountries();
var capLL = ds.capitals.map(function (c) {
return G.smallCircle(c.lon, c.lat, attr.capitals.size, 10);
});
srcCapsOrient = signsOf(capLL);
srcCaps = capLL.map(function (r) {
return r.map(function (p) { return G.toVector(p[0], p[1]); });
});
}
(function () {
var st = attr.graticule.step, lon, lat, p, i;
for (lon = -180; lon < 180; lon += st[0]) {
p = [];
for (i = -90; i <= 90; i += 2) { p.push(G.toVector(lon, i)); }
srcGrat.push(p);
}
for (lat = -90 + st[1]; lat < 90; lat += st[1]) {
p = [];
for (i = -180; i <= 180; i += 2) { p.push(G.toVector(i, lat)); }
srcGrat.push(p);
}
}());
addRings('land', function () { return srcLand; }, 1, null,
function () { return srcLandOrient; });
addRings('farSide', function () { return srcLand; }, -1, null,
function () { return srcLandOrient; });
addLines('coast', function () { return srcCoast; });
addLines('countries', function () { return srcCountries; });
addRings('capitals', function () { return srcCaps; }, 1, null,
function () { return srcCapsOrient; });
addLines('graticule', function () { return srcGrat; });
// highlight -------------------------------------------------------
var picked = {}, byId = {};
if (ds) { ds.countries.forEach(function (c) { byId[c.id] = c; }); }
var srcPick = [], srcPickOrient = [];
function rebuildPick() {
srcPick = []; srcPickOrient = [];
Object.keys(picked).forEach(function (id) {
var c = byId[id];
if (!c) { G.warn('unknown id "' + id + '"'); return; }
G.countryRings(c, attr.smallCountryRadius, attr.showSmallCountries).forEach(function (r) {
srcPick.push(r.map(function (p) { return G.toVector(p[0], p[1]); }));
srcPickOrient.push(G.orientationOf(r));
});
});
if (layers.highlight) {
layers.highlight.geoActive = srcPick.length > 0;
layers.highlight.setAttribute({ visible: srcPick.length > 0 });
}
}
addRings('highlightStyle', function () { return srcPick; }, 1, { visible: false },
function () { return srcPickOrient; });
layers.highlight = layers.highlightStyle;
delete layers.highlightStyle;
Object.keys(attr.highlight || {}).forEach(function (id) { picked[id] = attr.highlight[id]; });
rebuildPick();
/**
* Add a country to the selection.
*
* A tap goes through this same method, so both routes agree about what
* `selectMode` means: 'single' replaces, 'multiple' accumulates. The map
* had all three of the faults below, and so did this.
*
* @param {string} id A country id from the dataset.
* @returns {Object} The globe, for chaining.
* @alias Globe3D#select
*/
view.select = function (id) {
// An id nothing answers to would sit in the selection for ever:
// rebuildPick warns and draws nothing, while `selected()` goes on
// reporting a country that does not exist.
if (ds && !byId[id]) { G.warn('unknown id "' + id + '"'); return view; }
// Nothing changed, so nothing is announced.
if (picked[id]) { return view; }
if (attr.selectMode === 'single') { picked = {}; }
picked[id] = true;
rebuildPick();
board.update();
announceSelection(id, true);
return view;
};
/**
* Take a country out of the selection. Deselecting what is not selected
* changes nothing and announces nothing.
*
* @param {string} id A country id.
* @returns {Object} The globe, for chaining.
* @alias Globe3D#deselect
*/
view.deselect = function (id) {
if (!picked[id]) { return view; }
delete picked[id];
rebuildPick();
board.update();
announceSelection(id, false);
return view;
};
/**
* The current selection.
*
* @returns {Array<string>} Country ids, in the order they were added.
* @alias Globe3D#selected
*/
view.selected = function () { return Object.keys(picked); };
/**
* Empty the selection. Announces once, and only if it was not empty.
*
* @returns {Object} The globe, for chaining.
* @alias Globe3D#clearSelection
*/
view.clearSelection = function () {
if (!Object.keys(picked).length) { return view; }
picked = {};
rebuildPick();
board.update();
announceSelection(null, false);
return view;
};
// features and distortion ------------------------------------------
var srcFeat = [];
function rebuildFeatures(list) {
srcFeat = [];
(list || []).forEach(function (k) {
var gen = G.features[k];
if (!gen) { G.warn('unknown feature "' + k + '"'); return; }
gen().forEach(function (seg) {
srcFeat.push(seg.map(function (p) { return G.toVector(p[0], p[1]); }));
});
});
if (layers.features) {
layers.features.geoActive = srcFeat.length > 0;
layers.features.setAttribute({ visible: srcFeat.length > 0 });
}
}
addLines('features', function () { return srcFeat; }, { visible: false });
rebuildFeatures(attr.features.show);
/**
* Draw a set of named features: equator, tropics, polar circles, the
* prime meridian, the date line, the nominal time zones, the terminator.
*
* They replace whatever was shown before, so an empty list clears them.
*
* @param {Array<string>} list Keys of `G.features`.
* @returns {Object} The globe, for chaining.
* @alias Globe3D#showFeatures
*/
view.showFeatures = function (list) { rebuildFeatures(list); board.update(); return view; };
var srcTissot = [], srcTissotOrient = [];
function rebuildDistortion(mode) {
srcTissot = []; srcTissotOrient = [];
if (mode === 'tissot') {
G.tissot({ step: attr.distortion.step, radius: attr.distortion.radius,
latMax: attr.distortion.latMax }).forEach(function (r) {
srcTissot.push(r.map(function (p) { return G.toVector(p[0], p[1]); }));
srcTissotOrient.push(G.orientationOf(r));
});
} else if (mode && mode !== 'none') { G.warn('unknown distortion mode "' + mode + '"'); }
if (layers.distortion) {
layers.distortion.geoActive = srcTissot.length > 0;
layers.distortion.setAttribute({ visible: srcTissot.length > 0 });
}
}
addRings('distortion', function () { return srcTissot; }, 1, { visible: false },
function () { return srcTissotOrient; });
rebuildDistortion(attr.distortion.mode);
/**
* Show what a projection does to shape and size — on the globe, where it
* does nothing, which is the point of drawing it here as well.
*
* @param {string} mode 'tissot' or 'none'.
* @returns {Object} The globe, for chaining.
* @alias Globe3D#showDistortion
*/
view.showDistortion = function (mode) { rebuildDistortion(mode); board.update(); return view; };
view.layers = layers;
/**
* Public layer factory, so geo elements need no private access.
* kind is 'lines' or 'rings'; source and orients are functions, read on
* every frame, which is what lets a marker move without a rebuild.
*/
/**
* A real point3d at a geographic position, for display.
*
* Carrying visProp.element3d it stays in the update list while the view
* is being dragged, and it takes part in the layer order — neither is
* true of a plain 2D point, which has to be repositioned and redrawn by
* hand. Dragging is not offered here: Point3D can glide on a slide
* element, but sphere3d has projectScreenCoords commented out, and the
* documentation states that only line3d is supported as a glider host.
* So display goes through this, dragging through a 2D handle.
*/
/**
* A JSXGraph point3d that sits on the sphere and follows two functions.
*
* Useful where something else decides where it should be — a slider, a
* clock, an animation — and the point is to be dragged or measured with
* the rest of the 3D scene.
*
* @param {function(): number} lonFn Yields the longitude in degrees.
* @param {function(): number} latFn Yields the latitude in degrees.
* @param {Object} [style] Attributes for the point3d.
* @returns {Object} The point3d.
* @alias Globe3D#geoPoint3D
*/
view.geoPoint3D = function (lonFn, latFn, style) {
var lo = typeof lonFn === 'function' ? lonFn : function () { return lonFn; };
var la = typeof latFn === 'function' ? latFn : function () { return latFn; };
function v() {
var p = G.toVector(lo(), la()), r = scaleNow();
return [p[0] * r, p[1] * r, p[2] * r];
}
var p3 = view.create('point3d',
[function () { return v()[0]; },
function () { return v()[1]; },
function () { return v()[2]; }],
Object.assign({ size: 3, withLabel: false, name: '', fixed: true,
highlight: false, gradient: null, strokeWidth: 0.8,
fillColor: '#cf9b3f', strokeColor: '#071018' }, style || {}));
p3.isFront = function () { return G.dot(G.toVector(lo(), la()), cameraVector()) > 0; };
return p3;
};
/**
* A layer of your own geometry, clipped against the horizon like the
* others.
*
* @param {string} kind "rings" for filled shapes, anything else for
* lines.
* @param {function(): Array} source Yields the geometry, as rings or
* polylines of [lon, lat] in degrees. Read again on every refresh.
* @param {Object} [style] Curve attributes. Settings made once on the
* globe — `tabindex` above all — reach it too, and this overrides them.
* @param {function(): Array<number>} [orients] The winding of each ring,
* +1 or -1. Read on the sphere: the planar shoelace misreads a ring at
* a pole or across the antimeridian, and this value is used as given.
* @returns {Object} The layer: `refresh()` and `setVisible(on)`.
* @alias Globe3D#addGeoLayer
*/
view.addGeoLayer = function (kind, source, style, orients) {
var st = {}, k, i;
for (k in LAYER_STYLE) { if (LAYER_STYLE.hasOwnProperty(k)) { st[k] = LAYER_STYLE[k]; } }
// Settings made once on the globe reach a layer added from outside as
// well. They did not, and `tabindex: null` is why that matters: a path
// or a point drawn this way stayed focusable by mouse, so clicking it
// drew the browser's ring around the whole curve.
for (i = 0; i < PASSED_THROUGH.length; i++) {
k = PASSED_THROUGH[i];
if (attr.hasOwnProperty(k)) { st[k] = attr[k]; }
}
for (k in (style || {})) { if (style.hasOwnProperty(k)) { st[k] = style[k]; } }
// A layer added from outside is visible unless asked otherwise; the
// internal default is the opposite, which would hide it silently.
st.visible = (style && style.visible === false) ? false : true;
// A line layer is never filled. Stating it in every style was a request;
// enforcing it here is a guarantee, and a stray fill on a path that
// spans the map is very loud.
if (kind !== 'rings') { st.fillColor = 'none'; st.fillOpacity = 0; }
// The contract is lon/lat for both hosts. The globe needs unit vectors,
// so the conversion happens here rather than in the caller — otherwise
// the same source would have to be written twice.
function asVectors() {
return source().map(function (seg) {
return seg.map(function (p) { return G.toVector(p[0], p[1]); });
});
}
// geoSide -1 puts the layer behind the horizon, the way farSide does
// for the land — the caller decides, the mechanism is the same.
var gs = st.geoSide === -1 ? -1 : 1;
// geoRadius > 1 lifts the layer off the surface, in Earth radii
var kr = st.geoRadius > 0 ? st.geoRadius : 1;
delete st.geoSide; delete st.geoRadius;
var el = view.create('curve3d', [[], [], []], st);
if (kind === 'rings') {
G.maskedRings(el, { rings: asVectors, camera: cameraVector, frame: frame,
project: project,
radius: function () { return scaleNow() * kr; },
side: gs, active: st.visible, orients: orients,
occlude: kr });
} else {
G.maskedLayer(el, { segments: asVectors, camera: cameraVector,
project: project,
radius: function () { return scaleNow() * kr; },
side: gs, active: st.visible, occlude: kr });
}
el.geoLod = 'always';
el.geoWanted = st.visible;
// The map redraws when a layer is added; the globe has to be told, or a
// region only shows up the next time something else touches the board.
board.update();
el.refresh = function () { board.update(); return el; };
el.setVisible = function (on) {
el.geoActive = on; el.geoWanted = on; el.setAttribute({ visible: on });
board.update(); return el;
};
return el;
};
// ------------------------------------------------------- level of detail
function applyLod() {
var still = attr.lod.still, keep = attr.lod.moving;
Object.keys(layers).forEach(function (n) {
var el = layers[n], on;
if (!moving || keep === null || still === 'all') {
on = el.geoWanted !== false;
} else {
on = el.geoWanted !== false && (el.geoLod === 'always' || keep.indexOf(n) >= 0);
}
el.geoActive = on;
el.setAttribute({ visible: on });
});
}
/**
* Switch a layer on or off.
*
* @param {string} name A layer name: land, farSide, coast, countries,
* capitals, graticule, or one returned by addGeoLayer.
* @param {boolean} on Whether to draw it.
* @returns {Object} The globe, for chaining.
* @alias Globe3D#setLayer
*/
view.setLayer = function (name, on) {
if (!layers[name]) { G.warn('unknown layer "' + name + '"'); return view; }
layers[name].geoWanted = on;
applyLod();
board.update();
return view;
};
Object.keys(layers).forEach(function (n) { layers[n].geoWanted = layers[n].geoActive; });
// -------------------------------------------------------- interaction
/**
* Selection and hovering are switchable at any time. selectMode 'none'
* still fires countryclick and geoclick — the events are information, the
* mode only decides whether the element keeps a selection of its own.
* Set picking to false to suppress the point-in-polygon lookup entirely.
*/
/**
* Report a change of selection.
*
* @param {?string} id What changed, or null for a wholesale change.
* @param {?boolean} added True when it entered the selection, false when
* it left, null when the whole set was replaced.
* @returns {void}
*/
function announceSelection(id, added) {
view.triggerEventHandlers(['selectionchange'],
[{ selected: Object.keys(picked), id: id, added: added }]);
}
/** Replace the whole selection in one step, then announce it once. */
/**
* Show or hide the stand-in circles for countries with no outline. They
* are symbols at the wrong size and shape, so a map meant to be measured
* from should not carry them.
*/
/**
* Whether the 29 countries with no outline are drawn as stand-in circles.
*
* @param {boolean} on Whether to draw them.
* @returns {Object} The globe, for chaining.
* @alias Globe3D#setSmallCountries
*/
view.setSmallCountries = function (on) {
attr.showSmallCountries = !!on;
rebuildCountries();
// The highlight is built from the same rings, so it has to follow. It
// did not, and a selected micro-state kept its circle after the layer
// beneath had dropped it. The map had the same fault.
rebuildPick();
board.update();
return view;
};
/**
* Replace the whole selection at once.
*
* @param {Array<string>} ids Country ids. Unknown ones are refused, and
* under `selectMode: single` the last one named wins.
* @returns {Object} The globe, for chaining.
* @alias Globe3D#setSelection
*/
view.setSelection = function (ids) {
var next = {};
(ids || []).forEach(function (i) {
if (ds && !byId[i]) { G.warn('unknown id "' + i + '"'); return; }
next[i] = true;
});
// 'single' means one at a time here too, and the last one named wins —
// the same rule a tap follows. This route used to obey neither.
if (attr.selectMode === 'single') {
var keys = Object.keys(next);
if (keys.length > 1) { next = {}; next[keys[keys.length - 1]] = true; }
}
// Nothing changed, so nothing is announced. Without this a pair of
// hosts kept in step through selectionchange feed each other for ever:
// each announcement calls the other's setSelection, which announces
// again. Measured before the guard: a stack overflow.
var before = Object.keys(picked).sort().join(',');
var after = Object.keys(next).sort().join(',');
if (before === after) { return view; }
picked = next;
rebuildPick(); board.update();
announceSelection(null, null);
return view;
};
/**
* How a tap builds a selection: 'none', 'single' or 'multiple'.
*
* @param {string} mode One of the three; anything else warns and is
* ignored.
* @returns {Object} The globe, for chaining.
* @alias Globe3D#setSelectMode
*/
view.setSelectMode = function (mode) {
if (['none', 'single', 'multiple'].indexOf(mode) < 0) {
G.warn('unknown selectMode "' + mode + '"');
return view;
}
attr.selectMode = mode;
if (mode === 'none') { view.clearSelection(); }
return view;
};
/**
* The selection mode in force.
*
* @returns {string} 'none', 'single' or 'multiple'.
* @alias Globe3D#selectMode
*/
view.selectMode = function () { return attr.selectMode; };
/**
* Whether moving the pointer sends countryover and countryout.
*
* @param {boolean} on Whether to report hovering.
* @returns {Object} The globe, for chaining.
* @alias Globe3D#setHoverCountries
*/
view.setHoverCountries = function (on) { attr.hoverCountries = !!on; return view; };
/**
* Whether a tap looks up which country was hit at all. With it off the
* globe still reports geoclick, but neither countryclick nor the
* selection.
*
* @param {boolean} on Whether to identify countries.
* @returns {Object} The globe, for chaining.
* @alias Globe3D#setPicking
*/
view.setPicking = function (on) { attr.picking = !!on; return view; };
/**
* How far the pointer may travel between press and release and still
* count as a tap rather than a turn of the globe.
*
* @param {number} px Distance in pixels.
* @returns {Object} The globe, for chaining.
* @alias Globe3D#setClickTolerance
*/
view.setClickTolerance = function (px) { attr.clickTolerance = px; return view; };
/**
* Whether the stored pointer delta is cleared on every board update.
*
* updateProjectionTrackball re-applies that delta each time, so an extra
* update from an unrelated handler turns the globe again — several times
* per pointer move, and between two layer updates within one frame.
*
* @param {boolean} on Whether to clear it.
* @returns {Object} The globe, for chaining.
* @alias Globe3D#setTrackballReset
*/
view.setTrackballReset = function (on) { attr.trackballReset = !!on; return view; };
/**
* What the trackball is doing: the board mode, whether a move handler is
* installed, whether the reset above is on, and the stored delta.
*
* For diagnosis. A globe that turns twice per gesture, or not at all, is
* hard to investigate from the outside.
*
* @returns {Object} A snapshot of those five values.
* @alias Globe3D#trackballState
*/
view.trackballState = function () {
return { mode: board.mode, hasMove: !!view._hasMoveTrackball,
reset: !!attr.trackballReset,
dx: view._trackball ? view._trackball.dx : null,
dy: view._trackball ? view._trackball.dy : null };
};
/**
* How the pointer may turn the globe.
*
* @param {string} mode 'free' for the trackball, 'azimuth' for spinning
* about the axis only, 'none' to fix it.
* @returns {Object} The globe, for chaining.
* @alias Globe3D#rotateMode
*/
view.rotateMode = function (mode) {
var free = mode === 'free';
view.setAttribute({ trackball: { enabled: free },
az: { pointer: { enabled: mode !== 'none' } },
el: { pointer: { enabled: free } } });
attr.rotate = mode;
return view;
};
if (attr.rotate !== 'free') { view.rotateMode(attr.rotate); }
var spinRaf = null;
/**
* Turn the globe by itself, about its axis.
*
* @param {number} [speed=0.0045] Radians per frame; negative turns the
* other way.
* @returns {Object} The globe, for chaining. A running spin is stopped
* first, and without a frame timer nothing is started.
* @alias Globe3D#spin
*/
view.spin = function (speed) {
var s = speed === undefined ? 0.0045 : speed;
view.stop();
// Nothing to spin without a frame timer, and asking for one threw.
if (!canAnimate()) {
G.warn('globe3d: no requestAnimationFrame here; not spinning');
return view;
}
(function step() {
view.setView(wrapTau(view.az_slide.Value() - s), view.el_slide.Value());
spinRaf = root.requestAnimationFrame(step);
}());
return view;
};
/**
* Stop a spin, leaving the globe where it is.
*
* @returns {Object} The globe, for chaining.
* @alias Globe3D#stop
*/
view.stop = function () {
if (spinRaf && canAnimate()) { root.cancelAnimationFrame(spinRaf); }
spinRaf = null;
return view;
};
/**
* What has to be undone when the globe is destroyed.
*
* Listeners outlive the element that installed them: building four globes
* left twenty on the board, each answering every pointer move for a globe
* nobody is looking at.
*/
var undoListeners = [];
var destroyed = false;
var lastCentre = null, bankFix = false;
var onBoardUpdate = function () {
if (destroyed) { return; }
// updateProjectionTrackball() re-applies the stored pointer delta on
// every board.update(). Extra updates from unrelated handlers would
// spin the globe several times per pointer move and shift the camera
// between two layer updates within one frame.
if (attr.trackballReset && view._trackball) {
view._trackball.dx = 0; view._trackball.dy = 0;
}
// Optional, and guarded: rebuilding the matrix from the angles discards
// whatever the trackball just produced, so a wrong method name here
// would stop the globe from turning at all rather than fail loudly.
if (attr.keepUpright && !bankFix && view.angles &&
Math.abs(view.angles.bank) > 1e-6 &&
typeof view.getRotationFromAngles === 'function' &&
typeof view.setSlidersFromAngles === 'function') {
view.angles.bank = 0;
view.matrix3DRot = view.getRotationFromAngles();
view.setSlidersFromAngles();
bankFix = true;
root.requestAnimationFrame(function () { bankFix = false; board.update(); });
}
var c = view.centre();
if (!lastCentre || Math.abs(c[0] - lastCentre[0]) > 1e-4 ||
Math.abs(c[1] - lastCentre[1]) > 1e-4) {
lastCentre = c;
view.triggerEventHandlers(['viewchange'], [{ lon: c[0], lat: c[1] }]);
}
};
board.on('update', onBoardUpdate);
undoListeners.push(function () {
if (board.off) { board.off('update', onBoardUpdate); }
});
var container = board.containerObj;
if (container && container.addEventListener) {
container.addEventListener('pointerdown', function () {
if (attr.lod.moving === null) { return; }
moving = true; applyLod();
});
root.addEventListener('pointerup', function () {
if (!moving) { return; }
moving = false; applyLod(); board.update();
});
}
var hovered = null;
function geoAt(e) {
var c = board.getUsrCoordsOfMouse(e);
return view.at(c[0], c[1]);
}
/**
* Tap detection.
*
* The board's own 'up' event is fed by touchend, which carries no touch
* points, so its coordinates are NaN. Pointer events on the container do
* carry clientX/clientY on release, so they are used where available and
* the board events serve as the fallback (and keep the tests working).
*/
var tapInfo = { down: 0, move: 0, up: 0, mode: '?', lastMoved: null,
lastCoords: null, rejected: 0, fired: 0 };
/**
* What the tap detector has seen: counts of down, move and up, whether it
* is reading pointer events or board events, and how far the last gesture
* travelled.
*
* For diagnosis. On touch devices the board's own 'up' carries no
* coordinates at all, which is why there are two paths to begin with.
*
* @returns {Object} A live view of the counters.
* @alias Globe3D#tapState
*/
view.tapState = function () { return tapInfo; };
function tapTracker(onTap) {
var downAt = null, lastAt = null, travelled = 0;
var el = board.containerObj, useDom = !!(el && el.addEventListener &&
typeof root.PointerEvent === 'function');
tapInfo.mode = useDom ? 'dom' : 'board';
function coords(e) {
var c = board.getUsrCoordsOfMouse(e);
return (isFinite(c[0]) && isFinite(c[1])) ? { x: c[0], y: c[1] } : null;
}
function down(e) {
var c = coords(e);
tapInfo.down++;
tapInfo.lastCoords = c ? [+c.x.toFixed(3), +c.y.toFixed(3)] : 'NaN';
if (!c) { return; }
downAt = c; lastAt = c; travelled = 0;
}
function move(e) {
if (!downAt) { return; }
tapInfo.move++;
var c = coords(e);
if (!c) { return; }
travelled = Math.max(travelled,
Math.hypot(c.x - downAt.x, c.y - downAt.y) * (board.unitX || 1));
lastAt = c;
}
function up(e) {
tapInfo.up++;
if (!downAt) { tapInfo.lastMoved = 'no down'; return; }
var c = coords(e) || lastAt, moved;
moved = Math.max(travelled,
Math.hypot(c.x - downAt.x, c.y - downAt.y) * (board.unitX || 1));
downAt = null; lastAt = null;
tapInfo.lastMoved = +moved.toFixed(1);
if (moved > attr.clickTolerance) { tapInfo.rejected++; return; } // a drag
tapInfo.fired++;
onTap(c, e);
}
if (useDom) {
var cancel = function () { downAt = null; };
el.addEventListener('pointerdown', down);
el.addEventListener('pointermove', move);
el.addEventListener('pointerup', up);
el.addEventListener('pointercancel', cancel);
undoListeners.push(function () {
el.removeEventListener('pointerdown', down);
el.removeEventListener('pointermove', move);
el.removeEventListener('pointerup', up);
el.removeEventListener('pointercancel', cancel);
});
} else {
board.on('down', down);
board.on('move', move);
board.on('up', up);
undoListeners.push(function () {
if (!board.off) { return; }
board.off('down', down); board.off('move', move); board.off('up', up);
});
}
}
tapTracker(function (pt, e) {
if (destroyed) { return; }
var g, id;
g = view.at(pt.x, pt.y);
if (!g) { return; }
view.triggerEventHandlers(['geoclick'], [{ lon: g[0], lat: g[1], originalEvent: e }]);
if (!ds || !attr.picking) { return; }
id = G.countryAt(ds, g[0], g[1]);
if (id) {
view.triggerEventHandlers(['countryclick'],
[{ id: id, name: byId[id] && byId[id].name, lon: g[0], lat: g[1], originalEvent: e }]);
// countryclick reports the click; selectionchange reports the result,
// so a listener never has to guess whether the toggle already ran.
// Through the public methods, not beside them. The two used to hold
// their own copies of what a mode means and drifted apart: a tap
// replaced the selection in 'single' while select() accumulated.
if (attr.selectMode === 'single') {
view.select(id);
} else if (attr.selectMode === 'multiple') {
if (picked[id]) { view.deselect(id); } else { view.select(id); }
}
}
});
// Attached unconditionally so hovering can be switched on later; the flag
// is checked inside instead of deciding at construction time.
if (ds) {
var onHover = function (e) {
if (destroyed) { return; }
if (!attr.hoverCountries || !attr.picking) { return; }
var g = geoAt(e), id = g ? G.countryAt(ds, g[0], g[1]) : null;
if (id === hovered) { return; }
if (hovered) { view.triggerEventHandlers(['countryout'], [{ id: hovered }]); }
hovered = id;
if (id) {
view.triggerEventHandlers(['countryover'],
[{ id: id, name: byId[id] && byId[id].name, lon: g[0], lat: g[1] }]);
}
};
board.on('move', onHover);
undoListeners.push(function () {
if (board.off) { board.off('move', onHover); }
});
}
/**
* Take the globe off the board.
*
* Listeners outlive the element that installed them: four globes left
* twenty of them behind, each answering every pointer move for something
* nobody is looking at. A board that cannot take a listener back is why
* the guard above exists as well.
*
* @returns {Object} The globe, for chaining.
* @alias Globe3D#destroy
*/
view.destroy = function () {
if (destroyed) { return view; }
destroyed = true;
undoListeners.forEach(function (undo) { undo(); });
undoListeners.length = 0;
view.stop();
view.stopScale();
return view;
};
/**
* elType must stay 'view3d'. While the view is being dragged JSXGraph sets
* board._change3DView, and prepareUpdate() then marks an element for update
* only if it carries visProp.element3d or has exactly that elType:
*
* pEl.needsUpdate = pEl.visProp.element3d ||
* pEl.elType === 'view3d' || ...
*
* Renaming it left the view unmarked, so matrix3DRot was never recomputed
* and the globe could not be turned by hand — while lookAt() still worked,
* because setView() updates outside that branch.
*/
view.geoElType = 'globe3d';
// The elements that need a board reach it through the host, and the map
// has always carried one. The globe did not, so geoSketch and geoHandle
// answered null on a globe and their examples simply did nothing.
view.board = board;
view.dataset = ds;
view.subs = { body: body };
Object.keys(layers).forEach(function (n) {
view.subs[n] = layers[n];
layers[n].dump = false; // internal, not part of a construction
});
body.dump = false;
if (typeof view.setParents === 'function') { view.setParents([]); }
view.lookAt(0, 20);
return view;
};
JXG.registerElement('globe3d', JXG.createGlobe3D);
if (typeof module === 'object' && module.exports) { module.exports = JXG.createGlobe3D; }
}(typeof globalThis !== 'undefined' ? globalThis : this));