ui/phase-9: PixiJS map renderer with torus and no-wrap modes

Stand up the vector map renderer in ui/frontend/src/map/ on top of
PixiJS v8 + pixi-viewport@^6. Torus mode renders nine container
copies for seamless wrap; no-wrap mode pins the camera at world
bounds and centres on an axis when the viewport exceeds the world
along that axis. Hit-test is a brute-force pass with deterministic
[-priority, distSq, kindOrder, id] ordering and torus-shortest
distance, validated by hand-built unit cases.

The development playground at /__debug/map exposes a window
debug surface for the Playwright spec, which forces WebGPU on
chromium-desktop, WebGL on webkit-desktop, and accepts the
auto-picked backend on mobile projects.

Algorithm spec lives in ui/docs/renderer.md, which also pins the
new deprecation status of galaxy/client (the entire Fyne client
module, including client/world). client/world/README.md and the
Phase 9 stub in ui/PLAN.md gain matching deprecation banners.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-08 14:06:23 +02:00
parent 9d2504c42d
commit db415f8aa4
17 changed files with 2064 additions and 41 deletions
@@ -0,0 +1,195 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { page } from "$app/state";
import {
createRenderer,
sampleWorld,
type RendererHandle,
type RendererPreference,
type WrapMode,
} from "../../../map/index";
interface DebugMapSurface {
ready: true;
getMode(): WrapMode;
setMode(mode: WrapMode): void;
getCamera(): { centerX: number; centerY: number; scale: number };
getViewport(): { widthPx: number; heightPx: number };
getBackend(): string;
getWorldSize(): { width: number; height: number };
hitAt(x: number, y: number): number | null;
}
type DebugMapWindow = typeof globalThis & { __galaxyMap?: DebugMapSurface };
let canvasEl: HTMLCanvasElement | null = $state(null);
let containerEl: HTMLDivElement | null = $state(null);
let mode: WrapMode = $state("torus");
let backend = $state("");
let initError: string | null = $state(null);
let handle: RendererHandle | null = null;
let onResize: (() => void) | null = null;
function readPreference(): RendererPreference | RendererPreference[] {
const v = page.url.searchParams.get("renderer");
if (v === "webgl") return "webgl";
if (v === "webgpu") return "webgpu";
return ["webgpu", "webgl"];
}
function describe(err: unknown): string {
if (err instanceof Error) return `${err.name}: ${err.message}`;
return String(err);
}
onMount(() => {
(async () => {
if (canvasEl === null || containerEl === null) return;
const world = sampleWorld();
try {
handle = await createRenderer({
canvas: canvasEl,
world,
mode,
preference: readPreference(),
});
} catch (err) {
initError = describe(err);
return;
}
backend = handle.getBackend();
// Initial camera: place world centre.
handle.viewport.moveCenter(world.width / 2, world.height / 2);
// Initial zoom: fit-ish (slight zoom-in from minScale).
const minScale = Math.max(
containerEl.clientWidth / world.width,
containerEl.clientHeight / world.height,
);
handle.viewport.setZoom(minScale * 1.2, true);
if (mode === "no-wrap") handle.setMode("no-wrap"); // re-clamp post zoom
const surface: DebugMapSurface = {
ready: true,
getMode: () => handle?.getMode() ?? "torus",
setMode: (m) => {
if (handle === null) return;
handle.setMode(m);
mode = m;
},
getCamera: () => handle?.getCamera() ?? { centerX: 0, centerY: 0, scale: 1 },
getViewport: () =>
handle?.getViewport() ?? { widthPx: 0, heightPx: 0 },
getBackend: () => handle?.getBackend() ?? "",
getWorldSize: () => ({ width: world.width, height: world.height }),
hitAt: (x, y) => {
if (handle === null) return null;
const hit = handle.hitAt({ x, y });
return hit?.primitive.id ?? null;
},
};
(window as DebugMapWindow).__galaxyMap = surface;
onResize = (): void => {
if (handle === null || containerEl === null) return;
handle.resize(containerEl.clientWidth, containerEl.clientHeight);
};
window.addEventListener("resize", onResize);
})();
});
onDestroy(() => {
if (onResize !== null) {
window.removeEventListener("resize", onResize);
onResize = null;
}
if (handle !== null) {
handle.dispose();
handle = null;
}
const w = window as DebugMapWindow;
if (w.__galaxyMap !== undefined) delete w.__galaxyMap;
});
function toggleMode(): void {
if (handle === null) return;
const next: WrapMode = mode === "torus" ? "no-wrap" : "torus";
handle.setMode(next);
mode = next;
}
</script>
<main>
<header>
<h1>map debug</h1>
<div class="controls">
<button type="button" data-testid="mode-toggle" onclick={toggleMode}>
mode: {mode}
</button>
<span data-testid="backend" data-backend={backend}>backend: {backend || "…"}</span>
</div>
</header>
{#if initError !== null}
<p class="error" data-testid="init-error">{initError}</p>
{/if}
<div class="canvas-wrap" bind:this={containerEl}>
<canvas bind:this={canvasEl}></canvas>
</div>
</main>
<style>
main {
display: flex;
flex-direction: column;
height: 100vh;
margin: 0;
font-family: system-ui, sans-serif;
color: #e8eaf6;
background: #0a0e1a;
}
header {
padding: 0.5rem 1rem;
display: flex;
gap: 1rem;
align-items: center;
border-bottom: 1px solid #20253a;
}
h1 {
margin: 0;
font-size: 1rem;
font-weight: 500;
}
.controls {
display: flex;
gap: 0.75rem;
align-items: center;
}
button {
padding: 0.25rem 0.75rem;
background: #1c2238;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 4px;
cursor: pointer;
font: inherit;
}
button:hover {
background: #232b48;
}
.canvas-wrap {
flex: 1;
position: relative;
overflow: hidden;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
.error {
padding: 0.5rem 1rem;
background: #4a1820;
color: #ffb4b4;
}
</style>