From f5b378a8bca27c32062eec70053d190d16a389e8 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 00:37:30 +0100 Subject: [PATCH] fix(service-map): tiles stay put while you place them - The camera frames the tiles only, so lifting or putting down a tile no longer refits it and slides the board under the pointer. A bigger board after a deal glides into frame instead of jumping. - Lakes and open country are worked out with a lifted tile still at home: they only move once it lands. - The cell a held tile goes to is the one drawn under the pointer (tile, plate, lake or country), not a ground-plane hit. - The tile in hand is ringed and starts over a cell at once, so it never reads as already placed and Enter works without moving the mouse. - The outer ring of country hides while a tile is held instead of floating as islands, and the footer stays one line so the map never resizes. --- apps/web/src/page/service-map/tiles/scene.ts | 179 +++++++++++++++--- apps/web/src/page/service-map/tiles/view.ts | 32 ++-- .../src/page/service-map/tiles/world.test.ts | 43 +++++ apps/web/src/page/service-map/tiles/world.ts | 17 +- 4 files changed, 218 insertions(+), 53 deletions(-) diff --git a/apps/web/src/page/service-map/tiles/scene.ts b/apps/web/src/page/service-map/tiles/scene.ts index 5a6b286bec..ac6588f211 100644 --- a/apps/web/src/page/service-map/tiles/scene.ts +++ b/apps/web/src/page/service-map/tiles/scene.ts @@ -56,6 +56,8 @@ const DRAG_TOLERANCE = 5 const AMBIENT_FRAME_MS = 24 /** How high a held tile floats over the board. */ const HELD_LIFT = 1.7 +/** How long the camera takes to glide to a new framing. */ +const FIT_GLIDE_MS = 650 const INRADIUS = NEIGHBOUR_DISTANCE / 2 interface Size { @@ -264,6 +266,7 @@ export function startScene(canvas: HTMLCanvasElement, initial: SceneInput, event readonly lines: THREE.LineSegments readonly glass: THREE.Mesh | null readonly cell: string + readonly ring: 1 | 2 } >() const wildFeatures = new WeakMap() @@ -298,6 +301,17 @@ export function startScene(canvas: HTMLCanvasElement, initial: SceneInput, event cameraMoved: false, userMoved: false, fitKey: "", + /** A glide to a new framing under way. */ + flight: null as { + readonly fromTarget: THREE.Vector3 + readonly fromPosition: THREE.Vector3 + readonly to: { + readonly distance: number + readonly target: THREE.Vector3 + readonly position: THREE.Vector3 + } + readonly start: number + } | null, lastCommand: initial.command.serial, pressed: { x: 0, y: 0 }, } @@ -318,6 +332,11 @@ export function startScene(canvas: HTMLCanvasElement, initial: SceneInput, event const gesture = { active: false } orbit.addEventListener("start", () => { gesture.active = true + // Taking the view stops a glide where it is. + if (state.flight !== null) { + reach(state.flight.to.distance) + state.flight = null + } }) orbit.addEventListener("end", () => { gesture.active = false @@ -464,7 +483,8 @@ export function startScene(canvas: HTMLCanvasElement, initial: SceneInput, event const { input, nodes, palette } = state.derived const node = nodes.get(id) const level = node === undefined ? "healthy" : health(node.errorRate) - const selected = input.selectedId === id + // The tile in hand is ringed like a selection, so it never reads as already put down. + const selected = input.selectedId === id || input.heldId === id const color = selected ? palette.selected : level === "healthy" ? null : HEALTH_COLOR[level] const key = `${color}:${selected}` if (key === view.ring.key) return @@ -575,7 +595,8 @@ export function startScene(canvas: HTMLCanvasElement, initial: SceneInput, event * kept by key, so putting a tile down rebuilds only the cells whose country changed. */ const syncWilds = () => { const { wilds, palette, cells, nodes, biomes, input } = state.derived - const down = Arr.filter([...cells], ([id]) => id !== input.heldId) + // A lifted tile still counts as home, so picking it up does not repaint the country. + const down = [...cells] const specs = Arr.map(wilds, ({ cell, ring }) => { const nearest = Arr.head( Arr.sort( @@ -594,18 +615,25 @@ export function startScene(canvas: HTMLCanvasElement, initial: SceneInput, event ) const biome = biomeOf(biomes, namespace) const feature = featureOf(cellKey(cell), ring, biome) - return { cell, biome, feature, key: `${cellKey(cell)}:${biome.id}:${feature}:${palette.night}` } + return { + cell, + ring, + biome, + feature, + key: `${cellKey(cell)}:${biome.id}:${feature}:${palette.night}`, + } }) const key = Arr.join( Arr.map(specs, (spec) => spec.key), "|", ) // While a tile is in hand, the country on the cells it may go to steps aside for the - // pale plates that mark them. + // pale plates that mark them, and the outer ring goes too: it would float as islands. const offered = new Set(Arr.map(state.derived.targets?.free ?? [], cellKey)) + const holding = input.heldId !== null const showOffers = () => wildTiles.forEach((view) => { - view.mesh.visible = !offered.has(view.cell) + view.mesh.visible = !offered.has(view.cell) && !(holding && view.ring === 2) view.lines.visible = view.mesh.visible if (view.glass !== null) view.glass.visible = view.mesh.visible }) @@ -638,7 +666,7 @@ export function startScene(canvas: HTMLCanvasElement, initial: SceneInput, event wildFeatures.set(mesh, spec.feature) const glass = tile.glass === null ? null : new THREE.Mesh(tile.glass, materials.glass) glass?.position.copy(mesh.position) - wildTiles.set(spec.key, { mesh, lines, glass, cell: cellKey(spec.cell) }) + wildTiles.set(spec.key, { mesh, lines, glass, cell: cellKey(spec.cell), ring: spec.ring }) layers.wilds.add(mesh, lines, ...(glass === null ? [] : [glass])) }) showOffers() @@ -842,7 +870,8 @@ export function startScene(canvas: HTMLCanvasElement, initial: SceneInput, event invalidate() } - const fit = () => { + /** Where the camera frames the whole board from, and how far it may zoom from there. */ + const framing = () => { const { radius, centre } = state.derived const vertical = THREE.MathUtils.degToRad(camera.fov) / 2 const horizontal = Math.atan(Math.tan(vertical) * camera.aspect) @@ -853,18 +882,68 @@ export function startScene(canvas: HTMLCanvasElement, initial: SceneInput, event ) const polar = 0.9 const azimuth = 0.55 - orbit.target.set(centre[0], -0.3, centre[1]) - camera.position.set( - centre[0] + distance * Math.sin(polar) * Math.sin(azimuth), - distance * Math.cos(polar) - 0.3, - centre[1] + distance * Math.sin(polar) * Math.cos(azimuth), - ) + // The near edge of a tilted board looks bigger than the far one: aiming a little short of + // the middle centres it on the screen. + const nearer = radius * 0.08 + return { + distance, + target: new THREE.Vector3( + centre[0] + nearer * Math.sin(azimuth), + -0.3, + centre[1] + nearer * Math.cos(azimuth), + ), + position: new THREE.Vector3( + centre[0] + nearer * Math.sin(azimuth) + distance * Math.sin(polar) * Math.sin(azimuth), + distance * Math.cos(polar) - 0.3, + centre[1] + nearer * Math.cos(azimuth) + distance * Math.sin(polar) * Math.cos(azimuth), + ), + } + } + const reach = (distance: number) => { orbit.minDistance = distance * 0.12 orbit.maxDistance = distance * 1.8 camera.near = distance * 0.02 camera.far = distance * 6 camera.updateProjectionMatrix() - orbit.update() + } + /** Frames the board: at once on the first frame, else a short glide, so the board never + * jumps under the pointer as it grows. */ + const fit = (glide: boolean) => { + const goal = framing() + if (!glide) { + state.flight = null + orbit.target.copy(goal.target) + camera.position.copy(goal.position) + reach(goal.distance) + orbit.update() + return + } + // The zoom limits take in both ends of the glide until it lands. + const from = camera.position.distanceTo(orbit.target) + orbit.minDistance = Math.min(orbit.minDistance, goal.distance * 0.12) + orbit.maxDistance = Math.max(orbit.maxDistance, from, goal.distance * 1.8) + camera.far = Math.max(camera.far, goal.distance * 6) + camera.updateProjectionMatrix() + state.flight = { + fromTarget: orbit.target.clone(), + fromPosition: camera.position.clone(), + to: goal, + start: performance.now(), + } + } + /** One step of a glide; `true` while it is still under way. */ + const glide = (now: number): boolean => { + const flight = state.flight + if (flight === null) return false + const t = Math.min(1, (now - flight.start) / FIT_GLIDE_MS) + // Ease out: it sets off at once and settles gently. + const eased = 1 - (1 - t) ** 3 + orbit.target.lerpVectors(flight.fromTarget, flight.to.target, eased) + camera.position.lerpVectors(flight.fromPosition, flight.to.position, eased) + if (t < 1) return true + state.flight = null + reach(flight.to.distance) + return false } const resize = () => { @@ -878,22 +957,26 @@ export function startScene(canvas: HTMLCanvasElement, initial: SceneInput, event } /** Fits on the first frame, on Reset, and on a bigger board or a new canvas size until the user takes the view. */ - const rig = () => { + const rig = (now: number) => { const { input, radius, centre } = state.derived const key = `${centre[0].toFixed(2)}:${centre[1].toFixed(2)}:${radius}:${state.size.width}:${state.size.height}` const command = input.command const fresh = command.serial !== state.lastCommand state.lastCommand = command.serial if (fresh && command.action === "reset") state.userMoved = false - if ( - (state.fitKey !== key && !state.userMoved) || - state.fitKey === "" || - (fresh && command.action === "reset") - ) - fit() + if (state.fitKey === "") fit(false) + else if ((state.fitKey !== key && !state.userMoved) || (fresh && command.action === "reset")) + fit(true) state.fitKey = key + if (glide(now)) invalidate() if (fresh && command.action !== "reset") { state.userMoved = true + if (state.flight !== null) { + orbit.target.copy(state.flight.to.target) + camera.position.copy(state.flight.to.position) + reach(state.flight.to.distance) + state.flight = null + } const offset = camera.position.clone().sub(orbit.target) const length = THREE.MathUtils.clamp( offset.length() * (command.action === "in" ? 0.8 : 1.25), @@ -985,7 +1068,7 @@ export function startScene(canvas: HTMLCanvasElement, initial: SceneInput, event const delta = Math.min(0.05, (now - state.last) / 1000) state.last = now state.pending = Math.max(0, state.pending - 1) - rig() + rig(now) if (state.cameraMoved) { state.cameraMoved = false retarget() @@ -1088,10 +1171,36 @@ export function startScene(canvas: HTMLCanvasElement, initial: SceneInput, event const point = raycaster.ray.intersectPlane(ground, new THREE.Vector3()) return point === null ? null : [point.x, point.z] } - const targetAt = (point: Point | null): Cell | null => { + const centreOf = new THREE.Vector3() + /** The cell drawn under the pointer: the tile, plate, lake or country it is over (the tile in + * hand is see-through to this), else where the pointer meets the ground. Tall houses on a + * tile in front win over the cell behind them, as they look. */ + const cellUnder = (event: Aim): Cell | null => { + const { input } = state.derived + aim(event) + const solid = [ + ...Arr.filter( + Arr.map( + Arr.filter([...tiles], ([id]) => id !== input.heldId), + ([, view]) => view.mesh, + ), + (mesh) => mesh.visible, + ), + ...Arr.filter(layers.slots.children, (child) => child instanceof THREE.Mesh), + ...Arr.filter(layers.lakes.children, (child) => child instanceof THREE.Mesh), + ...Arr.filter(layers.wilds.children, (child) => child instanceof THREE.Mesh && child.visible), + ] + const hit = Arr.head(raycaster.intersectObjects(solid, false)) + if (Option.isSome(hit)) { + hit.value.object.getWorldPosition(centreOf) + return pointToCell([centreOf.x, centreOf.z]) + } + const point = raycaster.ray.intersectPlane(ground, new THREE.Vector3()) + return point === null ? null : pointToCell([point.x, point.z]) + } + const targetAt = (cell: Cell | null): Cell | null => { const { targets, cells, input } = state.derived - if (point === null || targets === null || input.heldId === null) return null - const cell = pointToCell(point) + if (cell === null || targets === null || input.heldId === null) return null const home = cells.get(input.heldId) const valid = (home !== undefined && sameCell(home, cell)) || @@ -1136,9 +1245,8 @@ export function startScene(canvas: HTMLCanvasElement, initial: SceneInput, event return } outline.visible = false - const point = pickGround(event) - state.heldPoint = point - setHover(targetAt(point)) + state.heldPoint = pickGround(event) + setHover(targetAt(cellUnder(event))) canvas.style.cursor = state.hoverCell === null ? "grabbing" : "copy" invalidate() } @@ -1155,7 +1263,7 @@ export function startScene(canvas: HTMLCanvasElement, initial: SceneInput, event return const { input } = state.derived if (input.heldId !== null) { - const cell = targetAt(pickGround(event)) + const cell = targetAt(cellUnder(event)) if (cell !== null) events.onPlace(input.heldId, cell) return } @@ -1286,12 +1394,23 @@ export function startScene(canvas: HTMLCanvasElement, initial: SceneInput, event birds.flush([x, 0.5, z]) } if (input.heldId !== null) outline.visible = false - if (previous.input.heldId !== input.heldId || previous.input.cells !== input.cells) { + const handChanged = previous.input.heldId !== input.heldId || previous.input.cells !== input.cells + if (handChanged) { state.hoverCell = null state.heldPoint = null + state.isKeyed = false hover.visible = false } sync() + // A tile coming into hand is over somewhere at once: under the pointer, or (with no + // pointer on the map) the place nearest the middle, ready for the arrow keys and Enter. + if (handChanged && input.heldId !== null) { + if (state.pointerAt !== null) follow(state.pointerAt) + else { + state.isKeyed = true + setHover(keyedTarget(state.derived, null, [0, 0])) + } + } }, stop: () => { cancelAnimationFrame(state.raf) diff --git a/apps/web/src/page/service-map/tiles/view.ts b/apps/web/src/page/service-map/tiles/view.ts index 8f4a885cc2..59f5e1dd73 100644 --- a/apps/web/src/page/service-map/tiles/view.ts +++ b/apps/web/src/page/service-map/tiles/view.ts @@ -486,31 +486,31 @@ const viewport = (h: H, model: Model, board: Board, inputs: ViewInputs, flowing: ) } +// One line whatever the hint says: a footer that wraps when a tile is lifted would shrink the +// map under the pointer. const footer = (h: H, model: Model): Html => hstack( h, { - gap: "2", + gap: "3", align: "center", justify: "between", - wrap: true, className: "shrink-0 border-t px-4 py-2 text-3xs text-muted-foreground", }, [ - h.span( - [], - [ - heldIdOf(model) !== null - ? model.pickedId === null - ? "Click a pale cell to put the tile down · or arrow keys, then Enter" - : "Click a pale cell or another tile · or arrow keys, then Enter · Esc puts it back" - : model.isBuilding - ? `Click a tile to lift it, or press Enter on its name · drag to pan · right-drag to turn` - : `Click anything · drag to pan · right-drag to turn`, - ], - ), - hstack(h, { gap: "3", wrap: true, as: "span" }, [ - h.span([], ["Touching tiles share a road · the rest go by rail"]), + typography(h, { truncate: true, className: "min-w-0" }, [ + heldIdOf(model) !== null + ? model.pickedId === null + ? "Click a pale cell to put the tile down · or arrow keys, then Enter" + : "Click a pale cell or another tile · or arrow keys, then Enter · Esc puts it back" + : model.isBuilding + ? `Click a tile to lift it, or press Enter on its name · drag to pan · right-drag to turn` + : `Click anything · drag to pan · right-drag to turn`, + ]), + hstack(h, { gap: "3", as: "span", className: "shrink-0 whitespace-nowrap" }, [ + typography(h, { className: "hidden @5xl/page:inline" }, [ + "Touching tiles share a road · the rest go by rail", + ]), ...Arr.map(["healthy", "elevated", "degraded"] as const, (status) => hstack(h, { gap: "1", as: "span" }, [ statusDot(h, { diff --git a/apps/web/src/page/service-map/tiles/world.test.ts b/apps/web/src/page/service-map/tiles/world.test.ts index 38d7f0a83b..e1ce9bb4e2 100644 --- a/apps/web/src/page/service-map/tiles/world.test.ts +++ b/apps/web/src/page/service-map/tiles/world.test.ts @@ -1,7 +1,9 @@ import { Array as Arr } from "effect" import { describe, expect, it } from "vitest" +import { SERVICE_MAP_3D_TOPOLOGY } from "../../../lab/service-map-3d/fixture" import type { Node3D } from "../three/types" import { type Cell, cellKey, hexDistance } from "./hex" +import { init, sync } from "./update" import { derive, keyedTarget, type SceneInput } from "./world" const node = (id: string): Node3D => ({ @@ -52,7 +54,48 @@ describe("moving the held tile with the keyboard", () => { expect(beyond === null ? 0 : hexDistance(beyond, HOME)).toBe(2) }) + it("starts a lifted tile over its own cell when no way is pressed", () => { + expect(keyedTarget(holding("a"), null, [0, 0])).toEqual(HOME) + }) + it("has nowhere to go with no tile in hand", () => { expect(keyedTarget(holding(null), null, [1, 0])).toBeNull() }) }) + +describe("lifting a tile off the full map", () => { + const topology = SERVICE_MAP_3D_TOPOLOGY + const cells = sync(init(), topology).cells + const lifted = topology.nodes[0]?.id ?? "" + const scene = (heldId: string | null, previous: ReturnType | null) => + derive( + { + topology, + cells, + selectedId: null, + heldId, + building: true, + flowing: false, + dark: false, + clock: { hour: 12, day: 80, isSouthern: false }, + muted: true, + command: { action: "reset", serial: 0 }, + }, + previous, + ) + + it("leaves the lakes, the country and the camera's framing where they were", () => { + const down = scene(null, null) + const held = scene(lifted, down) + expect(held.lakes).toEqual(down.lakes) + expect(held.wilds).toEqual(down.wilds) + expect(held.centre).toEqual(down.centre) + expect(held.radius).toBe(down.radius) + }) + + it("offers the tile its own cell back", () => { + const home = cells[lifted] + expect(home).toBeDefined() + expect(Arr.map(scene(lifted, null).targets?.free ?? [], cellKey)).toContain(cellKey(home ?? HOME)) + }) +}) diff --git a/apps/web/src/page/service-map/tiles/world.ts b/apps/web/src/page/service-map/tiles/world.ts index 7ea31be0e4..45047ca982 100644 --- a/apps/web/src/page/service-map/tiles/world.ts +++ b/apps/web/src/page/service-map/tiles/world.ts @@ -122,8 +122,8 @@ export const keyedTarget = (derived: Derived, current: Cell | null, direction: P ) } -/** The smallest circle round the tiles (and the cells a held tile may go to), so the view grows - * as tiles go down. */ +/** The smallest circle round the tiles and a ring of country, so the view grows as tiles go + * down. Only the tiles count: lifting one, or the country shifting, never moves the camera. */ const extentOf = (cells: ReadonlyArray): { readonly centre: Point; readonly radius: number } => { const points = Arr.map(cells, cellToPoint) const first = points[0] @@ -144,7 +144,7 @@ const extentOf = (cells: ReadonlyArray): { readonly centre: Point; readonl const reach = Math.hypot(furthest[0] - centre[0], furthest[1] - centre[1]) // Quarter-tile steps, so the camera refits when the board grows, not on every tile. const step = NEIGHBOUR_DISTANCE / 4 - return { centre, radius: Math.ceil((reach + HEX_SIZE * 1.1) / step) * step } + return { centre, radius: Math.ceil((reach + HEX_SIZE * 1.1 + NEIGHBOUR_DISTANCE * 1.6) / step) * step } } export const derive = (input: SceneInput, previous: Derived | null): Derived => { @@ -169,9 +169,12 @@ export const derive = (input: SceneInput, previous: Derived | null): Derived => ? previous.cells : new Map(Arr.filter(Rec.toEntries(input.cells), ([id]) => nodes.has(id))) const targets = input.heldId === null ? null : targetsFor(cells, input.heldId) - const sameBoard = sameTopology && sameCells && previous.input.heldId === input.heldId - const lakes = sameBoard ? previous.lakes : lakesOf(cells, input.heldId ?? undefined) - const wilds = sameBoard ? previous.wilds : wildsOf(cells, lakes, input.heldId ?? undefined) + // The country round the board is worked out with a lifted tile still at home: picking a tile + // up leaves a gap to put it back in, and the lakes and woods only move once it lands. + const sameCountry = sameTopology && sameCells + const lakes = sameCountry ? previous.lakes : lakesOf(cells) + const wilds = sameCountry ? previous.wilds : wildsOf(cells, lakes) + const sameBoard = sameCountry && previous.input.heldId === input.heldId const country = [...lakes, ...Arr.map(wilds, (wild) => wild.cell)] return { input, @@ -188,6 +191,6 @@ export const derive = (input: SceneInput, previous: Derived | null): Derived => lakes, wilds, network: sameBoard ? previous.network : networkOf(links, cells, input.heldId, country), - ...extentOf([...cells.values(), ...country]), + ...extentOf([...cells.values()]), } }