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:
@@ -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>
|
||||
Reference in New Issue
Block a user