Files
planet/docs/technical/en/earth-interactable-usage.md
2026-04-30 09:41:08 +08:00

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 viewBox and 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 !== false and state colors are provided, the shared layer first draws the asset to a temporary canvas and then tints it with source-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:

  1. Converts the camera position into Earth-local coordinates.
  2. Skips markers on the back side.
  3. Projects marker world position into screen coordinates.
  4. Uses radiusPx for pixel-distance hits.
  5. 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_position keeps 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 add avoidance.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 with position.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.js because 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

  1. Prepare marker data in the business file and keep required business fields.
  2. Choose an icon type: canvas draw, SVG / image asset, or dynamic getSource().
  3. Configure pointSize, icon.fitSize, colors, opacity, and stateScale.
  4. Provide getPointSizeMultiplier() if business-specific size variation is needed.
  5. Provide getRotationBin() and a stable getBucketKey() if rotation exists.
  6. During load, call preloadAssets() before setData(), attach(), and setVisible().
  7. Wire getPointerIntersections() in main.js and reuse the existing hover / locked state update flow.
  8. Record altitude, renderOrder, pointSize, and animation ordering in the layer style index and render order documents.