plotly.js
Version:
The open source javascript graphing library that powers plotly
141 lines (114 loc) • 5.33 kB
JavaScript
;
const Lib = require('../../lib');
const subtypes = require('../scatter/subtypes');
const { BADNUM } = require('../../constants/numerical');
// Longitude span of one copy of the world
const WORLD_LON_SPAN = 360;
// Two screen distances count as equal below this many pixels
const OFFSET_TOLERANCE = 1e-3;
// Project a longitude and latitude to a screen point. The map subplot's axes
// carry the map's own projection, so a longitude outside [-180, 180] lands on
// the matching repeated copy of the world instead of wrapping.
const projectPoint = (xa, ya, lon, lat) => [xa.c2p([lon, lat]), ya.c2p([lon, lat])];
/**
* Measure the screen offset from one world copy of a point to the next.
*
* A bearing turns the offset into a diagonal. A pitch shrinks the offset with
* distance from the camera, which makes the offset depend on the latitude and,
* in combination with a bearing, differ between the two sides of a point.
*
* @param xa - X axis of the map subplot
* @param ya - Y axis of the map subplot
* @param lon - Longitude of the primary copy
* @param lat - Latitude to measure at
* @param base - Screen point of the primary copy
* @returns Offset `{ dx, dy, uniform }` in pixels, where `uniform` reports
* whether the same vector reaches the copy on either side
*/
function measureWorldCopyOffset(xa, ya, lon, lat, base) {
const right = projectPoint(xa, ya, lon + WORLD_LON_SPAN, lat);
const left = projectPoint(xa, ya, lon - WORLD_LON_SPAN, lat);
const dx = right[0] - base[0];
const dy = right[1] - base[1];
const uniform =
Math.abs(dx - (base[0] - left[0])) < OFFSET_TOLERANCE && Math.abs(dy - (base[1] - left[1])) < OFFSET_TOLERANCE;
return { dx, dy, uniform };
}
/**
* Find which world copies can put a point inside the tester's bounding box.
*
* An offset component below `OFFSET_TOLERANCE` constrains nothing, so it is
* skipped. When that leaves both axes unconstrained, every copy lands on the
* same pixel and the primary one stands for all of them.
*
* @param copyOffset - Offset from `measureWorldCopyOffset`
* @param base - Screen point of the primary copy
* @param tester - Selection tester, read for its bounding box
* @returns `[kMin, kMax]` copy indices, or `null` when the offset constrains
* neither axis
*/
function getWorldCopyRange(copyOffset, base, tester) {
let kMin = -Infinity;
let kMax = Infinity;
const constrain = (delta, lo, hi) => {
if (Math.abs(delta) < OFFSET_TOLERANCE) return;
const a = lo / delta;
const b = hi / delta;
kMin = Math.max(kMin, Math.min(a, b));
kMax = Math.min(kMax, Math.max(a, b));
};
constrain(copyOffset.dx, tester.xmin - base[0], tester.xmax - base[0]);
constrain(copyOffset.dy, tester.ymin - base[1], tester.ymax - base[1]);
if (!isFinite(kMin) || !isFinite(kMax) || kMin > kMax) return null;
// Pad by one copy, because an uneven offset makes the straight-line bounds
// slightly narrow.
return [Math.floor(kMin) - 1, Math.ceil(kMax) + 1];
}
module.exports = function selectPoints(searchInfo, selectionTester) {
const { cd, xaxis: xa, yaxis: ya } = searchInfo;
const { trace } = cd[0];
if (!subtypes.hasMarkers(trace)) return [];
if (selectionTester === false) {
for (const di of cd) {
di.selected = 0;
}
return [];
}
// MapLibre renders repeated copies of the world when renderWorldCopies is
// enabled, so a point can appear in the selection at any integer world
// offset from its primary projection. Hit-test every visible copy. Skip
// for degenerate testers (e.g. point-selection) where extent is zero.
const map = xa._subplot?.map;
const testWorldCopies = !!map?.getRenderWorldCopies() && selectionTester.xmax > selectionTester.xmin;
const selection = [];
for (let i = 0; i < cd.length; i++) {
const di = cd[i];
const [lon, lat] = di.lonlat;
if (lon === BADNUM) continue;
// Normalize lon to [-180, 180] so its projection lands on the primary world copy
const normalizedLon = Lib.modHalf(lon, WORLD_LON_SPAN);
const base = projectPoint(xa, ya, normalizedLon, lat);
let matched = false;
if (testWorldCopies) {
const copyOffset = measureWorldCopyOffset(xa, ya, normalizedLon, lat, base);
const range = getWorldCopyRange(copyOffset, base, selectionTester);
for (let k = range ? range[0] : 0; range && k <= range[1] && !matched; k++) {
// An even offset reaches every copy by arithmetic. An uneven one
// comes from a pitch, so project the shifted longitude instead.
const pt = copyOffset.uniform
? [base[0] + k * copyOffset.dx, base[1] + k * copyOffset.dy]
: projectPoint(xa, ya, normalizedLon + k * WORLD_LON_SPAN, lat);
matched = selectionTester.contains(pt, null, i, searchInfo);
}
} else {
matched = selectionTester.contains(base, null, i, searchInfo);
}
if (matched) {
selection.push({ pointNumber: i, lon, lat });
di.selected = 1;
} else {
di.selected = 0;
}
}
return selection;
};