1// The pins on the sphere: what they are drawn over, and what hides them.2//3// Two defects the reader found, and one number that must not move.4//5// AN ICON IS DRAWN ON TOP OF THE RASTER. A pin's sprite is screen-sized6// around a point half a unit off the skin, so at the limb most of that square7// stands over ground *nearer* the camera than its own anchor. Tested against8// the depth buffer, the planet swallowed the buried half: the reader's9// screenshot of Mars is a rim of icons sunk to the waist. So the depth test10// comes off, as it already had for the cards and the chips.11//12// AND THE FAR SIDE MUST NOT SHINE THROUGH. Depth was doing one honest job as13// well as the dishonest one, and `cull` takes it over: a pin past the limb is14// ground nobody can see.15//16// THE STANDING COUNT IS THE FILTERS' ANSWER. `visibleSprites` in six recorded17// tours is a count of `sprite.visible` — 2048 at every Mars step, which is the18// registry with nothing filtered out — and it means "what the legend left",19// never "what the camera is pointing at". So the horizon is spelled with20// layers: the camera draws layer 0 and nothing else, and a pin over the rim21// steps off it without touching a boolean somebody else owns.22//23// The last section is the other half of the reader's report: a world change24// under a standing sphere. What the pane does about it is `viewport.test.ts`'s25// — here it is only the sphere's own half, that another world is another skin26// and another set of pins rather than the last world's, redrawn.2728import test from "node:test";29import { strict as assert } from "node:assert";30import * as THREE from "three";31import {32 KEY_GEOMETRY_EQUIRECT_DEG,33 KEY_GEOMETRY_EQUIRECT_PX,34 KEY_GEOMETRY_PROJECTION,35 KEY_GEOMETRY_SURFACE,36} from "@atlas/analysis/semconv/keys";37import { setLevel } from "../log.ts";38import type { WorldContext } from "../context.ts";3940setLevel("error");4142// ---- the page the sphere is drawn in ---------------------------------4344/** A stub node: enough of one for `globe.gl` to load over. */45function stubNode(): Record<string, unknown> {46 return {47 width: 0, height: 0, style: {}, classList: { add: () => {} },48 getContext: () => ({49 font: "10px", textAlign: "", textBaseline: "", lineWidth: 0,50 fillStyle: "", strokeStyle: "", globalAlpha: 1,51 measureText: (text: string) => ({ width: text.length * 8 }),52 fillRect: () => {}, fillText: () => {}, strokeText: () => {},53 drawImage: () => {}, beginPath: () => {}, arc: () => {}, stroke: () => {},54 clearRect: () => {},55 }),56 appendChild: () => {}, insertBefore: () => {},57 setAttribute: () => {}, addEventListener: () => {},58 };59}6061const host = globalThis as unknown as Record<string, unknown>;62host.HTMLElement = class {};63host.window = globalThis;64host.requestAnimationFrame = (): number => 0;65host.cancelAnimationFrame = (): void => {};66host.Image = class { src = ""; crossOrigin = ""; };67host.document = {68 createElement: stubNode, createElementNS: stubNode, createTextNode: stubNode,69 getElementsByTagName: () => [stubNode()], querySelector: () => null,70 querySelectorAll: () => [], addEventListener: () => {},71 head: stubNode(), body: stubNode(),72};7374// Imported after the page exists: `globe.gl` reads the document as it loads.75const { AtlasGlobe } = await import("../globe/element.ts");7677// ---- a sphere with pins standing on it -------------------------------7879/**80 * The globe, as much of one as a pin needs.81 *82 * `getCoords` is globe.gl's own spelling of a latitude and a longitude at an83 * altitude given as a fraction of the radius, so longitude 0 stands at84 * +x — which is where the camera below is parked, and therefore what "the85 * near side" means in every assertion here.86 */87function stubGlobe(at = { x: 300, y: 0, z: 0 }) {88 const camera = { position: at };89 return {90 getCoords(lat: number, lng: number, altitude = 0) {91 const radius = 100 * (1 + altitude);92 const phi = (lat * Math.PI) / 180;93 const theta = (lng * Math.PI) / 180;94 return {95 x: radius * Math.cos(phi) * Math.cos(theta),96 y: radius * Math.sin(phi),97 z: radius * Math.cos(phi) * Math.sin(theta),98 };99 },100 camera: () => camera,101 pointOfView: () => ({ lat: 0, lng: 0, altitude: 2.5 }),102 scene: () => new THREE.Scene(),103 };104}105106/** The sphere's own fields, so a test can put a globe where a build would. */107interface Inside {108 globe: unknown;109 skin: unknown;110 lensKey: string;111}112113function inside(element: object): Inside {114 return element as unknown as Inside;115}116117/**118 * The declared window: the whole planet, 3600 by 1800 world pixels.119 *120 * Which makes a place easy to name — world x 1800 is longitude 0 and world y121 * -900 is the equator — and the second world below declares a different one,122 * because a window is a world's own declaration and that is the whole reason123 * a skin cannot be carried across.124 */125function attrs(width: number): Record<string, string> {126 return {127 [KEY_GEOMETRY_SURFACE]: "sphere",128 [KEY_GEOMETRY_PROJECTION]: "equirect",129 [KEY_GEOMETRY_EQUIRECT_PX]: `0,0,${width},${width / 2}`,130 [KEY_GEOMETRY_EQUIRECT_DEG]: "-180,90,180,-90",131 };132}133134/** A pin somewhere on the planet, in the world's own coordinates. */135function point(id: string, x: number, y: number) {136 return { id, title: id, coordinate: [x, y], collection: { id: 1 } };137}138139/** Longitude 0, facing the camera; longitude 180, behind the planet. */140const NEAR = point("elysium", 1800, -900);141const FAR = point("hellas", 3600, -900);142143interface Standing {144 hidden: ReadonlySet<string>;145}146147/**148 * A world as the sphere is handed one: the payload's declaration, the pins,149 * and a standing set the filters answer through.150 */151function world(slug: string, points: ReturnType<typeof point>[], standing: Standing, width = 3600) {152 return {153 base: "/data/mars",154 outset: "light",155 lens: null,156 cell: "",157 labelsHeld: false,158 subgridVisible: false,159 system: null,160 scene: { selected: "", gridSystem: "" },161 model: {162 slug,163 payload: { attrs: attrs(width), lenses: [], collections: [] },164 points,165 collections: [{ id: 1, title: "Impact Craters", kind: "point", visible: true }],166 },167 visibility: {168 at: (index: number) => ({ hidden: standing.hidden.has(points[index]?.id ?? "") }),169 shapesShown: [], highlightedShapes: [],170 },171 } as unknown as WorldContext;172}173174/** A skin that says what it was told, without compositing anything. */175function stubSkin() {176 const retargeted: unknown[] = [];177 return {178 retargeted,179 retarget: (window: unknown) => { retargeted.push(window); },180 base: () => Promise.resolve(),181 clearDetail: () => {},182 };183}184185/** A sphere standing over one world, with its pins built. */186function showing(context: WorldContext, globe = stubGlobe()) {187 const element = new AtlasGlobe();188 inside(element).globe = globe;189 inside(element).skin = stubSkin();190 element.show(context);191 return element;192}193194/** The camera's own layers: what a renderer will and will not draw. */195const CAMERA = new THREE.Layers();196197function drawn(sprite: THREE.Object3D): boolean {198 return sprite.layers.test(CAMERA);199}200201function pin(element: InstanceType<typeof AtlasGlobe>, id: string): THREE.Sprite {202 const sprite = element.seam.sprites.get(id);203 assert.ok(sprite, `${id} is standing`);204 return sprite as THREE.Sprite;205}206207const nothing: Standing = { hidden: new Set() };208209// ---- an icon is drawn on top of the raster ----------------------------210211test("a pin draws over the ground it stands on, and under the writing", () => {212 const element = showing(world("mars", [NEAR], nothing));213 const sprite = pin(element, "elysium");214 const material = sprite.material as THREE.SpriteMaterial;215 assert.equal(material.depthTest, false, "the planet no longer buries the limb pins");216 assert.equal(material.depthWrite, false);217 assert.equal(material.sizeAttenuation, false, "screen-sized, not world-sized");218 assert.equal(sprite.renderOrder, 3,219 "over the tiles (0), the fills (1) and the boundaries (2); under the chips (4) and cards (5)");220});221222// ---- and the far side does not shine through --------------------------223224test("a pin past the limb is taken off the layer the camera draws", () => {225 const element = showing(world("mars", [NEAR, FAR], nothing));226 assert.equal(drawn(pin(element, "elysium")), true, "the near side is drawn");227 assert.equal(drawn(pin(element, "hellas")), false, "the far side is not");228});229230test("the planet turning brings the far side back, and nothing else moves", () => {231 const element = showing(world("mars", [NEAR, FAR], nothing));232 const near = pin(element, "elysium");233 const far = pin(element, "hellas");234 const before = [near.visible, far.visible, near.scale.x, far.scale.x];235236 // The reader turns the planet: the camera goes round to face longitude 180.237 const turned = showing(world("mars", [NEAR, FAR], nothing),238 stubGlobe({ x: -300, y: 0, z: 0 }));239 assert.equal(drawn(pin(turned, "elysium")), false);240 assert.equal(drawn(pin(turned, "hellas")), true);241 assert.deepEqual(242 [pin(turned, "elysium").visible, pin(turned, "hellas").visible,243 pin(turned, "elysium").scale.x, pin(turned, "hellas").scale.x],244 before,245 "the horizon moved and neither the standing flag nor the size did");246});247248// ---- the standing count is the filters' answer ------------------------249250test("what is standing is what the legend left, wherever the camera points", () => {251 // What a diagnostics reading of the pane is: the size of the map, and how252 // many of the sprites in it are `visible`. On Mars both numbers are 2048253 // -- the whole registry, nothing filtered -- even with the camera pointed254 // so that half the planet is turned away.255 const both = showing(world("mars", [NEAR, FAR], nothing));256 const standing = (element: InstanceType<typeof AtlasGlobe>): number =>257 [...element.seam.sprites.values()].filter((sprite) => sprite.visible).length;258 assert.equal(both.seam.sprites.size, 2, "every pin the world has, built once");259 assert.equal(standing(both), 2,260 "and every one of them standing, though one is behind the planet");261262 // A filter, which is the one thing that may move that number.263 const filtered = showing(world("mars", [NEAR, FAR], { hidden: new Set(["elysium"]) }));264 assert.equal(filtered.seam.sprites.size, 2, "the map is the registry, not the standing set");265 assert.equal(standing(filtered), 1, "and a hidden collection is one fewer standing");266});267268test("a pin a filter lets back in is held to the horizon before it is drawn", () => {269 // The order that made this worth a test: `update` writes `visible` from the270 // filters, and a sprite born or restored on the camera's layer would shine271 // through the planet until the reader next moved it.272 const context = world("mars", [FAR], { hidden: new Set(["hellas"]) });273 const element = showing(context);274 assert.equal(pin(element, "hellas").visible, false, "filtered out to begin with");275276 const shown = context as unknown as { visibility: { at: () => { hidden: boolean } } };277 shown.visibility = {278 at: () => ({ hidden: false }), shapesShown: [], highlightedShapes: [],279 } as unknown as (typeof shown)["visibility"];280 element.update();281 assert.equal(pin(element, "hellas").visible, true, "the filter let it back in");282 assert.equal(drawn(pin(element, "hellas")), false, "and the planet is still in front of it");283});284285// ---- another world is another sphere ----------------------------------286287test("a world change rebuilds the pins and retargets the skin", () => {288 const element = showing(world("mars", [NEAR], nothing));289 const skin = inside(element).skin as ReturnType<typeof stubSkin>;290 inside(element).lensKey = "/data/mars/mars#viking-mdim";291292 element.show(world("phobos", [FAR], nothing, 1800));293 assert.deepEqual([...element.seam.sprites.keys()], ["hellas"],294 "the pins of the world being left are down, and the new world's are up");295 assert.deepEqual(skin.retargeted, [296 { x: 0, y: 0, width: 3600, height: 1800 },297 { x: 0, y: 0, width: 1800, height: 900 },298 ], "the skin was given each world's own window of world pixels, in turn");299 assert.equal(inside(element).lensKey, "",300 "and the lens memo went with it, so the skin is composited again");301});302303test("the same world shown again rebuilds nothing", () => {304 // Which is what makes a filter a filter: `show` is called on every scene305 // change, and two thousand sprites and a four-megapixel composite are not306 // what turning a collection off asked for.307 const element = showing(world("mars", [NEAR], nothing));308 const skin = inside(element).skin as ReturnType<typeof stubSkin>;309 const sprite = pin(element, "elysium");310 skin.retargeted.length = 0;311 element.show(world("mars", [NEAR], nothing));312 assert.equal(pin(element, "elysium"), sprite, "the same sprite, not a new one");313 assert.deepEqual(skin.retargeted, [], "and the skin was left alone");314});