12 KiB
Earth Interactable Usage
Interactable is the shared rendering entry point for icon-like interactive elements on the Earth surface. It extracts the pattern proven by the vessel layer into reusable behavior: normal state uses batched THREE.Points, hover and locked states use small overlays, picking uses screen-space hit testing, icon assets are normalized into canvas textures, and the shared layer handles glow, state, size, ground rendering, and same-coordinate avoidance.
Currently integrated layers:
| Layer | Business File | Icon Source | Extra Animation |
|---|---|---|---|
| AIS vessels | frontend/public/earth/js/vessels.js |
canvas draw, moving triangle / anchored dot | Vessel tracks are still maintained by the business layer |
| Compute centers | frontend/public/earth/js/compute-centers.js |
assets/icons/compute-*.svg |
Estimated-location ? badge is added through icon.afterDraw() |
| BGP events | frontend/public/earth/js/bgp.js |
canvas draw, symbol by event type | Expanding rings are still maintained by the BGP business layer |
| BGP observers | frontend/public/earth/js/bgp.js |
assets/icons/bgp-broadcast-pin.svg |
Halo, activity core, coverage wedge, and radar sweep remain in the BGP business layer |
Landing sites were previously attempted on Interactable, but pin-style SVGs were fragmented by THREE.Points depth testing near the Earth edge. They now use a dedicated THREE.Sprite path with a yellow flat-sphere texture generated by canvas. The old SVG assets remain in assets/icons/, but landing sites no longer depend on SVG at runtime.
Why Interactable Exists
Before this layer, each surface icon layer could easily reimplement its own version of:
- icon texture generation
- hover / locked state
- glow styling
- picking radius
- zoom-dependent size strategy
- overlap avoidance for identical coordinates
When this logic is scattered across business files, visual behavior drifts and later tuning becomes layer-by-layer repair. The boundary of Interactable is: the shared layer owns how icons remain stable on Earth and how they are selected; the business layer owns where data comes from, what the icon means, what detail cards show, and whether extra animation exists.
Entry Point
import { createInteractableLayer } from "./interactable.js";
Core call shape:
const layer = createInteractableLayer({
id: "example",
objectType: "example_object",
renderOrder: 4.4,
altitudeOffset: 0.2,
pointSize: 34,
icon: {
draw(context, options) {
// draw canvas icon
},
},
getPosition: (item) => ({
latitude: item.latitude,
longitude: item.longitude,
}),
getKind: (item) => item.kind || "default",
});
Business modules usually expose only a thin wrapper:
export function getExampleMarkers() {
return layer.getMarkers();
}
export function getExamplePointerIntersections(options) {
return layer.getPointerIntersections(options);
}
export function setExampleMarkerState(marker, state = "normal") {
layer.setMarkerState(marker, state);
}
export function updateExampleVisualState(lockedObjectType, lockedObject, camera) {
layer.updateVisualState(lockedObjectType, lockedObject, camera);
}
Configuration
| Option | Default | Description |
|---|---|---|
id |
required | Unique layer id used for group name, avoidance registration, and debug. |
objectType |
id |
Business type written to marker.userData.type; the main interaction layer uses it to identify locked objects. |
renderOrder |
4 |
Base render order for normal points and hover / locked overlays. |
altitudeOffset |
0.2 |
Business altitude, used as CONFIG.earthRadius + altitudeOffset for the original surface position. |
pointSize |
32 |
Base screen pixel size used by both normal points and overlays. |
sizeMode |
"fixed" |
Fixed screen size by default; non-"fixed" modes scale by camera distance. |
sizeScale |
{ referenceFov: 75, min: 0.12, max: 3 } |
Scaling bounds when sizeMode !== "fixed". |
atlasCellSize |
128 |
Canvas texture cell size for icons. |
colors |
{} |
Supports normal, flattened kind keys, and byKind. |
opacity |
{ normal: 0.88, dimmed: 0.26, hover: 0.98, locked: 1 } |
Opacity per state. |
stateScale |
{ hover: 1, locked: 1, dimmed: 1 } |
Size multiplier per state. |
pulse |
{} |
Optional locked-state breathing scale, with enabled, speed, and amplitude. |
avoidance |
{ enabled: true, precision: 4, radius: 1.1, step: 0.35 } |
Same-coordinate avoidance across Interactable layers. |
icon |
required | Icon source, supporting canvas draw, SVG / image asset, state asset, anchor, and post-processing. |
getPosition(item) |
required | Returns { latitude, longitude } or THREE.Vector3. |
getKind(item) |
`item.type | |
getRotationBin(marker) |
0 |
Returns a rotation bucket, such as 32 heading buckets for vessels. |
getBucketKey(marker) |
String(getRotationBin(marker)) |
Returns a texture / geometry bucket key. |
getPointSizeMultiplier(marker) |
1 |
Per-marker size multiplier. BGP events use severity; observers use activity. |
getUserData(item) |
item |
Business fields written onto the marker. |
Icon Configuration
icon.anchor is optional and defaults to { x: 0.5, y: 0.5 }, meaning the texture center aligns with the marker coordinate. It is only suitable for small visual anchor offsets. If the icon body is large and must remain fully visible at the Earth edge, such as the old landing-site pin, it should not be forced through THREE.Points + depthTest; the body will be clipped by Earth depth.
Canvas Icons
Canvas icons fit vessels and BGP events where symbols need to be drawn dynamically by state or rotation:
const vesselIconLayer = createInteractableLayer({
id: "vessels",
objectType: "vessel",
pointSize: 34,
icon: {
draw(context, { marker, rotationBin = 0, glow = false, color = "#ffffff" }) {
if (!marker.userData.anchored) {
context.rotate((rotationBin / 32) * Math.PI * 2);
}
context.fillStyle = color;
context.shadowColor = color;
context.shadowBlur = glow ? 14 : 0;
context.beginPath();
context.moveTo(0, -37);
context.lineTo(28, 32);
context.lineTo(0, 17);
context.lineTo(-28, 32);
context.closePath();
context.fill();
},
},
getRotationBin: getCourseBin,
getBucketKey: (marker) => `${marker.userData.anchored ? "anchored" : "moving"}:${getCourseBin(marker)}`,
});
When icon.coordinates !== "canvas", Interactable translates the context to the atlas center first. Vessel-style icons that already draw around center coordinates do not need to declare coordinates.
SVG / Image Asset Icons
Asset icons fit facilities such as compute centers and BGP observers:
const computeCenterIconLayer = createInteractableLayer({
id: "computeCenters",
objectType: "compute_center",
pointSize: 36,
atlasCellSize: 128,
icon: {
coordinates: "canvas",
colorable: false,
fitSize: 60,
glowBlur: 16,
getSource({ marker, item }) {
const siteType = marker?.userData?.site_type || item?.site_type || "gpu_cluster";
return COMPUTE_CENTER_ICON_SOURCES[siteType];
},
afterDraw(context, { marker, item }) {
if (marker?.userData?.is_estimated ?? item?.is_estimated) {
drawComputeCenterEstimatedBadge(context, true);
}
},
},
});
Asset conventions:
- SVG / image files live in
frontend/public/earth/assets/icons/and are referenced as/earth/assets/icons/name.svg. - Original SVGs should keep a standard
viewBoxand paths; avoid hard-coding transform only for display size. - Display size is controlled by
icon.fitSize; it can be a number,{ width, height }, or a function. - If
icon.colorable !== falseand state colors are provided, the shared layer first draws the asset to a temporary canvas and then tints it withsource-in. - Multicolor images or SVGs that should not be tinted must set
colorable: false.
Lifecycle
Typical load flow:
export async function loadExampleLayer(_scene, earth) {
clearExampleData(earth);
const markerData = await fetchExampleData();
await layer.preloadAssets(markerData);
layer.setData(markerData);
layer.attach(earth);
layer.setVisible(showExampleLayer);
return { totalCount: layer.getCount() };
}
Method responsibilities:
| Method | Description |
|---|---|
preloadAssets(items) |
Collects asset sources that may be used by normal / hover / locked states and preloads them with browser Image. Canvas-drawn icons can skip this. |
setData(items) |
Clears old points, creates markers, registers avoidance, and rebuilds THREE.Points by bucket. |
attach(parent) |
Mounts the layer group onto the Earth root. |
setVisible(next) |
Controls visibility for the group, points, and overlays. |
setMarkerState(marker, state) |
Sets normal / hover and other states, then invalidates visual state. |
updateVisualState(focusType, focusObject, camera) |
Updates normal opacity / size and refreshes hover / locked overlays. |
getPointerIntersections(options) |
Runs screen-space picking and returns hits sorted by pixel distance. |
clearData(parent) |
Unregisters avoidance, disposes geometry / material, clears markers, and removes the group from the parent. |
Picking Integration
Interactable does not depend on the default Three.js raycast for Points. The main interaction layer passes Earth, camera, pointer, and hit radius:
const intersects = getVesselPointerIntersections({
earth,
camera,
pointer,
radiusPx: 22,
width: window.innerWidth,
height: window.innerHeight,
});
The shared layer:
- Converts the camera position into Earth-local coordinates.
- Skips markers on the back side.
- Projects marker world position into screen coordinates.
- Uses
radiusPxfor pixel-distance hits. - Returns the nearest candidate objects.
Earth dragging, inertia, and hover throttling still belong to main.js because they depend on global input state.
Same-Coordinate Avoidance
Avoidance is enabled by default and applies to all layers created through createInteractableLayer(). The shared layer builds an icon_avoidance_key from latitude / longitude or THREE.Vector3, then arranges markers with the same key into a small circle along the surface tangent plane.
Key points:
icon_base_positionkeeps the original business position.- Avoidance only changes rendering and picking position. It does not change business latitude / longitude.
- When a single marker returns to its original position, it uses the business surface position computed from
altitudeOffset. - When multiple markers share coordinates, the first ring uses
avoidance.radius; later rings addavoidance.step.
If a business layer must stay exactly on the original point, disable avoidance explicitly:
createInteractableLayer({
id: "strict-layer",
avoidance: { enabled: false },
});
Business Animation Boundary
Interactable currently owns only the icon body and common hover / locked overlays. Complex animations remain in business modules, but should follow the Interactable marker position:
- BGP event expanding rings are independent ring sprites created by
bgp.js, updated every frame withposition.copy(marker.position). - BGP observer halo, status core, coverage halo, and coverage wedge are managed by
bgp.js; the icon body is managed by Interactable. - Vessel tracks remain in
vessels.jsbecause they depend on track data loaded after a click.
This boundary avoids pushing every animation type into the shared interface too early. If multiple layers reuse the same animation type later, it can move into an Interactable animations extension.
New Layer Checklist
- Prepare marker data in the business file and keep required business fields.
- Choose an icon type: canvas draw, SVG / image asset, or dynamic
getSource(). - Configure
pointSize,icon.fitSize,colors,opacity, andstateScale. - Provide
getPointSizeMultiplier()if business-specific size variation is needed. - Provide
getRotationBin()and a stablegetBucketKey()if rotation exists. - During load, call
preloadAssets()beforesetData(),attach(), andsetVisible(). - Wire
getPointerIntersections()inmain.jsand reuse the existing hover / locked state update flow. - Record altitude,
renderOrder,pointSize, and animation ordering in the layer style index and render order documents.