Files
gods-eye-view/src/ui/imagerySplit.js
T
Bilawal Sidhuandmanjunath22466 082074a006 feat(layers): Recent Imagery — open satellite imagery for a selected box (#716)
Select a box on the world and browse the last 30 days of open satellite imagery over it, keyless: NASA GIBS Harmonized Landsat Sentinel-2 (30 m) and the VIIRS daily overview (250 m), with a day strip, IMAGE / VS BASEMAP / A / B modes with a swipe divider, PNG export and share links. The before/after swipe from the Nepal scene moves into shared modules so both use it.

Co-authored-by: manjunath22466 <[email protected]>
2026-09-22 20:34:11 -05:00

177 lines
5.5 KiB
JavaScript

const HANDLE_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'Home', 'End']);
function clampUnit(value) {
return Math.max(0, Math.min(1, Number(value) || 0));
}
function defaultViewportWidth() {
return (
Number(document.documentElement?.clientWidth) ||
Number(globalThis.window?.innerWidth) ||
0
);
}
function defaultValueText(beforePercent, afterPercent) {
return `A ${beforePercent} percent, B ${afterPercent} percent`;
}
/**
* Split divider for before/after imagery comparison.
*
* Owns the DOM line + handle, pointer capture, keyboard, ARIA, a CSS custom
* property and scene.splitPosition. `setValue` is the owner's own write and
* never echoes through `onChange`; `onChange` reports pointer and keyboard
* changes so the owner can mirror them, and `requestRender` fires whenever
* the clamped value actually changes. `destroy` is idempotent: it removes the
* listeners and line, clears the CSS property and restores the scene split
* captured at creation once.
*/
export function createImagerySplit({
scene,
parent = document.body,
initialValue = 0.5,
id,
handleClass,
cssTarget = document.documentElement,
cssProperty,
beforeLabel = 'A',
afterLabel = 'B',
beforeTitle,
afterTitle,
ariaLabel,
formatValueText = defaultValueText,
getViewportWidth = defaultViewportWidth,
onChange,
requestRender,
} = {}) {
const restoreValue = scene ? (scene.splitPosition ?? null) : null;
const line = document.createElement('div');
if (id) line.id = id;
const before = document.createElement('span');
const after = document.createElement('span');
const handle = document.createElement('button');
before.className = 'before';
after.className = 'after';
before.textContent = beforeLabel;
if (beforeTitle != null) before.title = beforeTitle;
after.textContent = afterLabel;
if (afterTitle != null) after.title = afterTitle;
handle.type = 'button';
if (handleClass) handle.className = handleClass;
handle.setAttribute('role', 'slider');
if (ariaLabel != null) handle.setAttribute('aria-label', ariaLabel);
handle.setAttribute('aria-valuemin', '0');
handle.setAttribute('aria-valuemax', '100');
handle.setAttribute('aria-orientation', 'horizontal');
handle.textContent = '↔';
let value = null;
let cssValue = null;
let dragging = false;
let pointerId = null;
let destroyed = false;
function apply(next, { notify = false, render = true } = {}) {
const clamped = clampUnit(next);
const changed = clamped !== value;
value = clamped;
if (scene && scene.splitPosition !== value) scene.splitPosition = value;
const css = `${value * 100}%`;
if (cssProperty && cssValue !== css) {
cssTarget.style.setProperty(cssProperty, css);
cssValue = css;
}
const beforePercent = Math.round(value * 100);
handle.setAttribute('aria-valuenow', String(beforePercent));
handle.setAttribute(
'aria-valuetext',
formatValueText(beforePercent, 100 - beforePercent),
);
if (changed && render) requestRender?.();
if (changed && notify) onChange?.(value);
return changed;
}
function setFromClientX(clientX) {
const viewportWidth = Number(getViewportWidth()) || 0;
if (viewportWidth <= 0 || !Number.isFinite(Number(clientX))) return;
apply(Number(clientX) / viewportWidth, { notify: true });
}
function handlePointerDown(event) {
if (event.button != null && event.button !== 0) return;
event.preventDefault?.();
dragging = true;
pointerId = event.pointerId ?? null;
handle.setPointerCapture?.(event.pointerId);
setFromClientX(event.clientX);
}
function handlePointerMove(event) {
if (!dragging) return;
if (pointerId != null && event.pointerId !== pointerId) return;
event.preventDefault?.();
setFromClientX(event.clientX);
}
function finishPointerDrag(event) {
if (!dragging) return;
if (pointerId != null && event.pointerId !== pointerId) return;
handle.releasePointerCapture?.(event.pointerId);
dragging = false;
pointerId = null;
}
function handleKeyDown(event) {
if (!HANDLE_KEYS.has(event.key)) return;
event.preventDefault?.();
const step = event.shiftKey ? 0.05 : 0.01;
if (event.key === 'Home') apply(0, { notify: true });
else if (event.key === 'End') apply(1, { notify: true });
else
apply(value + (event.key === 'ArrowRight' ? step : -step), {
notify: true,
});
}
const listeners = [
['pointerdown', handlePointerDown],
['pointermove', handlePointerMove],
['pointerup', finishPointerDrag],
['pointercancel', finishPointerDrag],
['keydown', handleKeyDown],
];
for (const [type, listener] of listeners)
handle.addEventListener(type, listener);
line.append(before, handle, after);
parent.appendChild(line);
apply(initialValue, { render: false });
return {
getValue() {
return value;
},
setValue(next) {
if (destroyed) return false;
return apply(next);
},
setVisible(visible) {
line.hidden = !visible;
},
destroy() {
if (destroyed) return;
destroyed = true;
if (dragging && pointerId != null)
handle.releasePointerCapture?.(pointerId);
dragging = false;
pointerId = null;
for (const [type, listener] of listeners)
handle.removeEventListener?.(type, listener);
line.remove();
if (cssProperty) cssTarget.style.removeProperty(cssProperty);
if (scene && restoreValue != null) scene.splitPosition = restoreValue;
},
};
}