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>
7.4 KiB
World rendering package
Deprecated. This package belongs to the deprecated
galaxy/clientFyne client. New code must not import it. The active map renderer lives inui/frontend/src/map/(TypeScript
- PixiJS), with its specification in
ui/docs/renderer.md. The sources here remain for historical context only and are not the reference algorithm for the new renderer.
Purpose
world is the client-side map model and renderer for a 2D world that normally
behaves like a torus. It owns:
- primitive storage (
Point,Line,Circle) - world-space indexing for render and hit-test queries
- theme and style resolution
- full-frame and incremental rendering onto an expanded canvas
- no-wrap helpers used by the UI when torus scrolling is disabled
The package does not own UI widgets, event loops, or camera policy beyond the helpers exposed for zoom/clamp calculations.
Symbol Map
- World creation and mutation:
NewWorld,AddPoint,AddLine,AddCircle,Remove,Reindex - Viewport/index lifecycle:
IndexOnViewportChange,SetCircleRadiusScaleFp - Rendering:
Render,RenderParams,RenderOptions,PrimitiveDrawer,GGDrawer - No-wrap camera helpers:
CorrectCameraZoom,ClampCameraNoWrapViewport,ClampRenderParamsNoWrap,PivotZoomCameraNoWrap - Hit testing:
HitTest,Hit,PrimitiveKind - Styling and themes:
Style,StyleOverride,StyleTable,StyleTheme,DefaultTheme,ThemeLight,ThemeDark
Coordinate Model
- World geometry is stored in fixed-point integers.
SCALE == 1000, so1.0world units are represented as1000.- Primitive coordinates, radii, world dimensions, and camera positions use
world-fixedunits. - Viewport and canvas sizes use integer
canvas px. - Rectangles in world space and canvas space are treated as half-open intervals:
[minX, maxX) x [minY, maxY). RenderParamsdescribes the visible viewport, but rendering happens on the expanded canvas:canvasWidthPx = viewportWidthPx + 2*marginXPxcanvasHeightPx = viewportHeightPx + 2*marginYPx
- The camera always points to the center of the visible viewport, not the center of the expanded canvas.
Data Model
Worldstores torus dimensionsWandHin fixed-point units.MapItemis implemented byPoint,Line, andCircle.PrimitiveIDis allocated byWorldand may be reused after removal.- Each primitive carries:
- geometry in fixed-point world coordinates
Priorityfor deterministic draw order inside a tile- resolved
StyleID - theme binding metadata (
Base,Override,Class) - optional per-primitive hit slop in pixels
- Themes resolve base styles per primitive kind, then optional class overrides,
then optional user
StyleOverride. - Explicit
StyleIDbypasses theme-relative recomputation across theme changes.
Spatial Index Lifecycle
- Rendering and hit testing depend on the grid index stored in
World.grid. IndexOnViewportChangemust be called after viewport size or zoom changes.- The grid cell size is derived from the current visible world span:
- start from roughly
visibleMin / 8 - clamp into
[16*SCALE, 512*SCALE]
- start from roughly
AddPoint,AddLine,AddCircle,Remove,SetCircleRadiusScaleFp, andReindexmark the index dirty and rebuild it automatically when the last viewport/zoom state is known.- Circle indexing uses the effective radius after
circleRadiusScaleFpis applied. - Line indexing uses the torus-shortest representation and indexes its wrapped bounding boxes rather than exact rasterized coverage.
Render Pipeline
Render follows this sequence:
- Validate
RenderParamsand resolve background color/theme state. - Convert zoom to fixed-point and compute the expanded unwrapped world rect.
- Split that rect into
WorldTilesegments:- torus mode uses wrapped tiling
- no-wrap mode intersects against the bounded world once
- Query the spatial grid per tile and deduplicate candidates per tile by
PrimitiveID. - Build a
RenderPlancontaining:- tile-to-canvas clip rectangles
- per-tile candidate lists
- Draw background before primitives.
- Draw primitives tile-by-tile in deterministic order:
Priorityascending- primitive kind as stable tie-breaker
PrimitiveIDascending
- For wrapped rendering:
- points and circles emit only the torus copies that intersect the current tile
- lines are split into torus-shortest canonical segments before projection
Incremental Pan Rendering
Renderfirst tries incremental pan reuse throughComputePanShiftPxandPlanIncrementalPan.- If only camera pan changed and the shift stays inside the configured margins:
- existing pixels are moved with
PrimitiveDrawer.CopyShift - newly exposed strips become dirty rects
- dirty rects are cleared, background-redrawn, and clipped primitive redraw is applied
- existing pixels are moved with
- If geometry changed in a way that breaks reuse, rendering falls back to full redraw.
- Theme changes, circle radius scale changes, and explicit
ForceFullRedrawNextreset incremental state.
No-Wrap Behavior
When RenderOptions.DisableWrapScroll == true, the world is treated as a bounded
plane instead of a torus.
CorrectCameraZoomprevents the visible viewport from becoming larger than the world.ClampCameraNoWrapViewportclamps the camera so the viewport remains inside the world.ClampRenderParamsNoWrapapplies the same rule directly toRenderParams.PivotZoomCameraNoWrapkeeps the world point under the cursor stable while zoom changes.
Margins are ignored by viewport clamp on purpose so panning remains usable even when the expanded canvas extends beyond the world bounds.
Hit Testing
HitTestexpects the grid to be built already.- Cursor coordinates are passed in viewport pixels relative to the viewport top-left.
- The query path is:
- convert cursor position into world-fixed coordinates
- clamp or wrap based on no-wrap mode
- query a conservative grid search box using default hit slop
- run exact per-primitive hit checks
- Point hits use disc distance.
- Circle hits distinguish between filled circles and stroke-only rings.
- Line hits use the same torus-shortest segment decomposition as rendering.
- Final ranking is:
Prioritydescending- squared distance ascending
- primitive kind ascending
PrimitiveIDascending
UI Integration Checklist
Typical UI flow:
- Create the world with
NewWorld. - Add primitives and optional styles/themes.
- Before each render, compute the current viewport size in pixels.
- Call
CorrectCameraZoomwhen UI zoom changes. - Call
IndexOnViewportChangewhen viewport size or zoom changes. - If no-wrap mode is enabled, call
ClampRenderParamsNoWrap. - Render into a
PrimitiveDrawerwithRender. - Reuse the same
RenderParamssnapshot forHitTest.
The client package in this repository follows exactly that pattern.
Important Invariants and Limits
RenderandHitTestrequire the grid to be initialized; otherwise they returnerrGridNotBuilt.- The package assumes single-goroutine access to hot render scratch buffers stored in
World. RenderScheduleris only a coalescing example. It is not a license to callRenderon arbitrary background goroutines in real UI code.PrimitiveDrawerreceives final canvas coordinates only; all torus math stays insideworld.- Background anchoring can be viewport-relative or world-relative, but dirty redraws always use the same anchoring logic as full redraws.