PURPOSE

All canvas drawing for the hub-and-spoke world layer: hub circles, spoke corridors, lanterns at hub centers, ambient particles inside hubs, flow particles along spokes, lantern activation burst VFX, and a multiply-blend danger tint over dark hubs. Every color derives from the 4-slot palette on LevelConfig (base, line, accent, illuminated) plus the separate illuminationColor. No hardcoded colors except the danger tint hex and a white-hot beacon core. Illuminated hubs/spokes swap their line color references to illuminated at reduced opacity.

OWNS

  • All Canvas2D drawing for hubs, spokes, lanterns, lantern bursts, flow particles, ambient particles, and danger tint.
  • Three style switches mapped to draw branches: SpokeStyleId (highway | energy | ghost), HubStyleId (station | nexus | crater), HubParticleType (none | dust | embers | data | spores).
  • Three LanternPresetId burst animations: solar_flare, dawn, shatter, with hard-coded durations in BURST_DURATIONS (1.0s, 1.5s, 0.5s).
  • Local helpers: hexToRgb, rgbaStr, toScreen, hashId, lerp.
  • Per-hub deterministic seeding for ambient particles via hashId(hub.id) combined with prime multipliers.
  • Crater-style deterministic rim wobble keyed off hub world position.

READS FROM

  • LevelData.config (LevelConfig): palette, spokeStyle, spokeWidth, spokeWMin, spokeWMax, spokeFlow.enabled, spokeFlow.speed, hubStyle, hubParticles, illuminationColor, lanternPreset, dangerTint.
  • LevelData.generation.hubs and LevelData.generation.spokes for geometry.
  • LevelData.illuminationMap: Map<string, boolean> for per-hub lit state (spoke is lit if either endpoint hub is lit).
  • visibleHubIndices: Set<number> from the caller to cull both hubs and spokes (spoke kept if either endpoint is visible).
  • lanterns: Map<string, LanternState> from engine/world/illumination for activated flag and activatedAt timestamp.
  • time: number (seconds) for rim rotation, lantern pulse, particle motion, burst progress, and flow scroll.
  • CameraXform { x, y, zoom, halfW, halfH } matching Camera.toS() semantics.

PUSHES TO

  • Mutates the passed CanvasRenderingContext2D only. Every public draw function brackets its work in ctx.save() / ctx.restore().
  • drawLanterns uses a nested save/restore around globalCompositeOperation = 'lighter' for additive beacon glow.
  • drawDangerTintOverlay switches globalCompositeOperation to 'multiply' then restores to 'source-over' before restore().
  • No state writes outside the canvas context. No allocations into LevelData or lanterns.

DOES NOT

  • Does not compute illumination state, lantern activation, or chunk visibility — consumes them.
  • Does not own the render order; the call sequence is enforced by bridge.ts (see header comment).
  • Does not generate hubs, spokes, or lantern entries.
  • Does not draw the atmospheric fog layers (above/below); the wilds fog mask is a no-op stub kept only to preserve call sites.
  • Does not branch on hub kind/biome — only on hubStyle / spokeStyle / hubParticles / lanternPreset from LevelConfig.
  • Crater hub style has no inner structure (drawHubsInnerStructure early-returns).
  • Center line and flow particles are restricted to a single style each (highway median; energy flow), and flow also requires spokeFlow.enabled.

Signals

  • lit boolean per hub from illuminationMap.get(hub.id) === true; per spoke is aLit || bLit.
  • LanternState.activated plus activatedAt → burst is drawn while time - activatedAt is in [0, BURST_DURATIONS[lanternPreset]).
  • levelData.config.spokeFlow.enabled gates flow particles (also requires spokeStyle === 'energy').
  • hubParticles === 'none' short-circuits ambient particle pass.
  • dangerTint <= 0 short-circuits the multiply overlay; otherwise tintAlpha = dangerTint * 0.3 on each dark (unlit) hub.
  • Unlit lantern pulse uses 0.1 + 0.1 * sin(time * 3.14) (~0.5 Hz).
  • Nexus rim rotates at 2π/30 rad/s (2 RPM, 5 arc segments, 15% gap fraction).
  • Embers wrap upward over sr * 2 with phase (time * 20 + pSeed) % (sr * 2) and discard points that land outside the hub circle.

Entry points

  • drawSpokesFill(ctx, levelData, cam, visibleHubIndices)
  • drawSpokesEdges(ctx, levelData, cam, visibleHubIndices)
  • drawSpokesCenterLine(ctx, levelData, cam, visibleHubIndices)
  • drawSpokeFlowParticles(ctx, levelData, cam, visibleHubIndices, time)
  • drawHubsFill(ctx, levelData, cam, visibleHubIndices)
  • drawHubsRim(ctx, levelData, cam, visibleHubIndices, time)
  • drawHubsInnerStructure(ctx, levelData, cam, visibleHubIndices)
  • drawLanterns(ctx, levelData, cam, visibleHubIndices, time)
  • drawLanternBurst(ctx, levelData, cam, visibleHubIndices, time, lanterns)
  • drawHubAmbientParticles(ctx, levelData, cam, visibleHubIndices, time)
  • drawDangerTintOverlay(ctx, levelData, cam, visibleHubIndices)
  • drawWildsFogMask(_ctx, _levelData, _cam, _viewW, _viewH) — intentional no-op stub.

Render order (from header, enforced by bridge.ts): spokes fill → spokes edges → spokes center line → spoke flow particles → hubs fill → hubs rim → hubs inner structure → lanterns → lantern burst → hub ambient particles → danger tint overlay.

Pattern notes

  • Pure draw module: receives ctx, geometry, illumination, time; returns void. No game state, no allocations beyond per-frame temporaries.
  • Style branching is switch on the config style id, with style-specific opacity tables inlined (e.g., spoke fill alpha is 0.2 for ghost, 0.4 for energy, 0.7 otherwise).
  • Spoke width derives from lerp(spokeWMin, spokeWMax, spokeWidth) and is doubled then scaled by zoom for stroke width; edge offsets use a perpendicular unit normal computed from dx, dy, len = hypot(dx, dy) || 1.
  • Illumination changes color (line → illuminated) and reduces alpha rather than adding extra strokes (except station rim and nexus rim, which add a bloom pass at 0.06 alpha and 4 * cam.zoom width when lit).
  • Crater wobble is 1 + 0.06 * sin(a * 7 + hub.x * 0.1) + 0.04 * cos(a * 11 + hub.y * 0.1) over 32 segments — deterministic per hub from world position.
  • Ambient particle determinism: seed = hashId(hub.id), per-particle pSeed = seed + i * 7919, then two prime-multiplier hashes (2654435761, 1597334677) mod 2^32 normalized to [0,1) for base angle and base radius. Count is min(30, floor(hub.r / 3)).
  • Spoke flow uses DOT_COUNT = 7 evenly spaced dots scrolling along the spoke with offset (time * speed * 200 * cam.zoom) % len; color is palette.accent (or illuminated when lit) at alpha 0.3.
  • Lantern lit state uses globalCompositeOperation = 'lighter' for three additive layers (wide bloom, core glow, white-hot center) plus a vertical pillar gradient stroke above the hub.
  • Danger tint uses globalCompositeOperation = 'multiply' with DANGER_COLOR = '#2a0a0a'; only unlit hubs receive it (spokes are not tinted by this function despite the function name’s plural).
  • drawWildsFogMask is preserved as an empty stub: the previous implementation produced a checkerboard grid artifact and was deleted; the export remains so callers don’t break.