Files
galaxy-game/ui/frontend/src/lib/active-view/map.svelte
T
Ilia Denisov 9ae7b88b89
Tests · UI / test (push) Successful in 2m14s
Tests · Go / test (push) Successful in 2m25s
feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
Fuse the standalone ship-class designer (Phases 17/18) into a sidebar calculator: live mass/speed/attack/defence/bombing results, a planet build-rate readout, single-target goal-seek, a modernization-cost mode, and auto reach circles on the map for the selected planet.

pkg/calc becomes the single source for the new math (no mirroring): extract BombingPower from the engine model and the per-turn ship-production loop from controller.ProduceShip into pkg/calc (engine now delegates), and add inverse goal-seek solvers in pkg/calc/solve.go. Thin-bridge the combat, planet-build, and solver functions through ui/core/calc + ui/wasm and rebuild core.wasm.

Remove the standalone designer view/route; the ship-classes table and the view/bottom menus open the calculator via a shared request store.

Docs: rewrite ui/PLAN.md Phase 30, adjust Phase 34 (realistic forecast + CAP/COL ownership), add ui/docs/calculator-ux.md, extend calc-bridge.md, fix navigation.md; remove ui/CALCULATOR.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:04:07 +02:00

757 lines
25 KiB
Svelte

<!--
Phase 11 map active view: integrates the Phase 9 renderer with the
per-game `GameStateStore` provided through context by
`routes/games/[id]/+layout.svelte`. The view mounts the renderer
once the store has produced a report and re-mounts when the
report's turn changes (a refresh that returns the same turn keeps
the existing renderer instance alive). Empty-planet reports render
the empty world without errors — the regression test in
`tests/e2e/game-shell-map.spec.ts` covers this.
Phase 9 owns the renderer's hit-test and pan/zoom semantics. Phase 13
plugs map clicks into the inspector by translating the renderer's
`clicked` event into a hit-test, looking the planet up by id in the
report, and calling `SelectionStore.selectPlanet`. The selection
store, set in the layout, drives both the desktop sidebar inspector
tab and the mobile bottom-sheet — the map view itself does not need
to know which surface is showing the result.
Phase 29 wires the wrap-mode toggle on top of the per-game `wrapMode`
preference the store already manages.
-->
<script lang="ts">
import { getContext, onDestroy, onMount, untrack } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { i18n } from "$lib/i18n/index.svelte";
import {
createRenderer,
minScaleNoWrap,
type RendererHandle,
} from "../../map/index";
import { buildCargoRouteLines } from "../../map/cargo-routes";
import { buildPendingSendLines } from "../../map/pending-send-routes";
import { computeReachCircles } from "../../map/reach-circles";
import { reachStore } from "$lib/calculator/reach.svelte";
import {
reportToWorld,
type HitTarget,
type MapCategory,
} from "../../map/state-binding";
import {
computeFogCircles,
computeHiddenIds,
computeHiddenPlanetNumbers,
fingerprintHiddenPlanets,
} from "../../map/visibility";
import type { PrimitiveID } from "../../map/world";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
import type { OrderCommand } from "../../sync/order-types";
import {
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
type MapToggles,
} from "$lib/game-state.svelte";
import {
SELECTION_CONTEXT_KEY,
type SelectionStore,
} from "$lib/selection.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
MAP_PICK_CONTEXT_KEY,
MapPickService,
} from "$lib/map-pick.svelte";
import {
installRendererDebugSurface,
registerMapCameraProvider,
registerMapFogProvider,
registerMapModeProvider,
registerMapPickStateProvider,
registerMapPrimitivesProvider,
registerMapRenderCountProvider,
type MapCameraSnapshot,
type MapFogSnapshot,
type MapPickStateSnapshot,
type MapPrimitiveSnapshot,
} from "$lib/debug-surface.svelte";
import MapTogglesControl from "./map-toggles.svelte";
const store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
const renderedReport = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
// `MapPickService` is owned by the in-game shell layout (set on
// the context tree the inspector subsections also descend from).
// Renderer changes attach / detach via `bindResolver` so a
// remount mid-pick does not orphan a pending promise. The map
// view is mounted only beneath the layout, so the service is
// always present in production; tests render the map in isolation
// and may omit it.
const pickService = getContext<MapPickService | undefined>(
MAP_PICK_CONTEXT_KEY,
);
// Pending Send commands turn into green dashed tracks on the
// map overlay; the draft is read alongside the report so the
// overlay stays in lock-step with the order tab.
const orderDraft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
let canvasEl: HTMLCanvasElement | null = $state(null);
let containerEl: HTMLDivElement | null = $state(null);
let mountError: string | null = $state(null);
let handle: RendererHandle | null = null;
let hitLookup = new Map<PrimitiveID, HitTarget>();
// currentCategories / currentPlanetDependents are populated by
// `reportToWorld` inside `mountRenderer` and consumed by the
// Phase 29 hide-set computation on every effect re-run (mount or
// toggle change). Both maps cover the base world; extras
// (cargo-routes, pending-Send) are gated upstream via
// `skipPlanets`, so they never need a categories entry.
let currentCategories: ReadonlyMap<PrimitiveID, MapCategory> = new Map();
let currentPlanetDependents: ReadonlyMap<
number,
ReadonlySet<PrimitiveID>
> = new Map();
// currentFogCircles mirrors the latest `setVisibilityFog` input so
// the debug surface can report it to Playwright. The renderer
// keeps the Graphics, not the data; recomputing on every read
// would duplicate work.
let currentFogCircles: ReadonlyArray<{
x: number;
y: number;
radius: number;
}> = [];
let mountedTurn: number | null = null;
let mountedGameId: string | null = null;
let onResize: (() => void) | null = null;
let detachClick: (() => void) | null = null;
let detachDebugProviders: (() => void) | null = null;
let detachDebugSurface: (() => void) | null = null;
// `mounted` must be `$state` so the renderer-mount effect re-runs
// once `onMount` flips it true. On the first map navigation the
// effect's initial pass returns early (gameState is still hydrating
// → `report` is null), and the subsequent server-driven `report`
// transition re-fires the effect after `onMount` has already
// completed. On a second navigation back to /map the report is
// already loaded — without reactivity here the effect's first
// pass would gate on `mounted === false`, and there would be no
// later state change to wake it up. The visible symptom is a
// black canvas (renderer never re-mounted on the new DOM).
let mounted = $state(false);
// Mount serialization. The `$effect` may re-fire while the
// async `mountRenderer` is mid-flight (e.g. report transitions
// from null → populated → overlay-mutated during boot). Without
// the in-progress gate, parallel `createRenderer` awaits would
// leave both old and new viewport listeners on the canvas,
// double-firing every click. The gate is intentionally a plain
// `let` (not `$state`) so reads from the effect do not register
// as a reactive dependency.
let mountInProgress = false;
let pendingMountSignal = $state(0);
// Track the latest cargo-route fingerprint we pushed to the
// renderer so a no-op push (e.g. report refresh that yields the
// same overlay) doesn't churn Pixi graphics needlessly.
let lastExtrasFingerprint: string | null = null;
$effect(() => {
// Read the overlay-applied report so the map labels reflect
// pending renames immediately. Falls back to raw report when
// the rendered source is missing (e.g. component used outside
// the in-game shell layout).
const report = renderedReport?.report ?? store?.report;
const status = store?.status ?? "idle";
// Track the wrap mode so the renderer remounts when Phase 29's
// toggle UI flips it; the read here also subscribes the effect.
const mode = store?.wrapMode ?? "torus";
// Track the Phase 29 visibility toggles so the effect re-runs
// when the gear popover flips any flag. The hide set + fog +
// extras filter all derive from this rune.
const toggles = store?.mapToggles;
const gameId = store?.gameId ?? "";
if (!mounted || canvasEl === null || containerEl === null) return;
if (status !== "ready" || !report || toggles === undefined) return;
// Explicit reads of every toggle key — Svelte 5's deep proxy
// tracks per-property access, and the actual consumers
// (computeHiddenIds, computeFogCircles, buildExtras) run
// inside `untrack` blocks or async continuations where the
// tracking would otherwise be lost. Touching every key here
// synchronously guarantees a flip triggers the effect.
void toggles.hyperspaceGroups;
void toggles.incomingGroups;
void toggles.unidentifiedGroups;
void toggles.foreignPlanets;
void toggles.uninhabitedPlanets;
void toggles.unidentifiedPlanets;
void toggles.unreachablePlanets;
void toggles.cargoRoutes;
void toggles.battleMarkers;
void toggles.bombingMarkers;
void toggles.visibleHyperspace;
// Subscribe to the calculator's published reach so the rings
// redraw as the design or the selected planet changes.
void reachStore.origin;
void reachStore.speedPerTurn;
// Phase 29 visibility derivation. Cargo routes and pending-
// Send overlay are extras (no Pixi remount on flip); the
// cascade-filtering happens here so the extras list shrinks
// when a destination planet hides. The hide set + fog are
// applied after mount / on every toggle change without a
// remount.
const hiddenPlanetNumbers = computeHiddenPlanetNumbers(report, toggles);
const hiddenPlanetFingerprint =
fingerprintHiddenPlanets(hiddenPlanetNumbers);
// Cargo-route arrows and pending-Send tracks are pushed onto
// the live renderer via `setExtraPrimitives` so the overlay
// can change inside a single turn without disposing the Pixi
// `Application` — Pixi 8 does not reliably re-init on the
// same canvas. The fingerprint guard avoids redundant Pixi
// rebuilds when the overlay computation re-runs but the
// routes / pending-Send content is unchanged (e.g. status
// transitions valid → submitting → applied for the same
// command). The Phase 29 cascade + cargoRoutes toggle are
// folded into the fingerprint so a toggle flip that changes
// the visible set reliably triggers a push.
const draftCommands = orderDraft?.commands ?? [];
const draftStatuses = orderDraft?.statuses ?? {};
const reachOrigin = reachStore.origin;
const reachFingerprint =
reachOrigin === null
? ""
: `${reachOrigin.x},${reachOrigin.y},${reachStore.speedPerTurn}`;
const extrasFingerprint =
`cr=${toggles.cargoRoutes ? "1" : "0"}|hp=${hiddenPlanetFingerprint}|` +
`reach=${reachFingerprint}|` +
computeRoutesFingerprint(report.routes) +
"|" +
computePendingSendFingerprint(draftCommands, draftStatuses);
const sameSnapshot =
mountedTurn === report.turn &&
mountedGameId === gameId &&
handle !== null;
if (sameSnapshot) {
// Apply wrap-mode flips in-place via the renderer's own
// `setMode` — a full re-mount is unnecessary (the world,
// primitives, and camera are unchanged) and Pixi 8 does
// not reliably re-init on the same canvas (the symptom is
// a crashed tab when the wrap-mode radio fires).
if (handle !== null && handle.getMode() !== mode) {
untrack(() => {
handle?.setMode(mode);
});
}
// Always re-apply hide set + fog on a same-snapshot pass:
// toggle flips bypass the extras fingerprint when they
// only change which baked-world primitives are hidden,
// and a no-op `setHiddenPrimitiveIds` is cheap.
untrack(() => {
applyVisibilityState(report, toggles, hiddenPlanetNumbers);
});
if (lastExtrasFingerprint !== extrasFingerprint) {
untrack(() => {
handle?.setExtraPrimitives(
buildExtras(
report,
draftCommands,
draftStatuses,
toggles,
hiddenPlanetNumbers,
mode,
),
);
});
lastExtrasFingerprint = extrasFingerprint;
}
return;
}
// Read the pending-mount signal so the effect re-runs after
// the in-flight mount completes (it bumps the signal in its
// finally block). Without this, a dep change observed while
// `mountInProgress` is true would be silently dropped.
void pendingMountSignal;
if (mountInProgress) return;
untrack(() => {
void runSerializedMount(
report,
mode,
toggles,
hiddenPlanetNumbers,
extrasFingerprint,
draftCommands,
draftStatuses,
);
});
});
function buildExtras(
report: NonNullable<GameStateStore["report"]>,
draftCommands: readonly OrderCommand[],
draftStatuses: Readonly<Record<string, string>>,
toggles: MapToggles,
hiddenPlanetNumbers: ReadonlySet<number>,
mode: "torus" | "no-wrap",
): import("../../map/world").Primitive[] {
const skip = hiddenPlanetNumbers.size > 0 ? hiddenPlanetNumbers : undefined;
const cargo = toggles.cargoRoutes
? buildCargoRouteLines(report, skip ? { skipPlanets: skip } : undefined)
: [];
const pending = buildPendingSendLines(
report,
draftCommands,
draftStatuses,
skip ? { skipPlanets: skip } : undefined,
);
// Reach circles published by the ship-class calculator. Empty
// when no own planet is selected or the design is invalid, so
// this is a no-op for the rest of the map.
const reachOrigin = reachStore.origin;
const reach =
reachOrigin !== null && reachStore.speedPerTurn > 0
? computeReachCircles(
reachOrigin,
reachStore.speedPerTurn,
report.mapWidth,
report.mapHeight,
mode,
)
: [];
return [...cargo, ...pending, ...reach];
}
function applyVisibilityState(
report: NonNullable<GameStateStore["report"]>,
toggles: MapToggles,
hiddenPlanetNumbers: ReadonlySet<number>,
): void {
if (handle === null) return;
const hiddenIds = computeHiddenIds(
currentCategories,
currentPlanetDependents,
hiddenPlanetNumbers,
toggles,
);
handle.setHiddenPrimitiveIds(hiddenIds);
const fogCircles = computeFogCircles(report, toggles);
currentFogCircles = fogCircles;
handle.setVisibilityFog(fogCircles);
}
async function runSerializedMount(
report: NonNullable<GameStateStore["report"]>,
mode: "torus" | "no-wrap",
toggles: MapToggles,
hiddenPlanetNumbers: ReadonlySet<number>,
extrasFingerprint: string,
draftCommands: readonly OrderCommand[],
draftStatuses: Readonly<Record<string, string>>,
): Promise<void> {
mountInProgress = true;
try {
await mountRenderer(report, mode);
if (handle === null) return;
applyVisibilityState(report, toggles, hiddenPlanetNumbers);
handle.setExtraPrimitives(
buildExtras(
report,
draftCommands,
draftStatuses,
toggles,
hiddenPlanetNumbers,
mode,
),
);
lastExtrasFingerprint = extrasFingerprint;
} finally {
mountInProgress = false;
// Bump the reactive signal so any dep change observed
// while the gate was up gets a fresh effect run with the
// current state.
pendingMountSignal += 1;
}
}
function computeRoutesFingerprint(
routes: NonNullable<GameStateStore["report"]>["routes"],
): string {
if (routes.length === 0) return "";
const parts = routes.map((route) => {
const entries = route.entries
.map((entry) => `${entry.loadType}->${entry.destinationPlanetNumber}`)
.join(",");
return `${route.sourcePlanetNumber}:${entries}`;
});
return parts.join(";");
}
function computePendingSendFingerprint(
commands: readonly OrderCommand[],
statuses: Readonly<Record<string, string>>,
): string {
const parts: string[] = [];
for (const cmd of commands) {
if (cmd.kind !== "sendShipGroup") continue;
const status = statuses[cmd.id];
if (status === "rejected" || status === "invalid") continue;
parts.push(`${cmd.groupId}->${cmd.destinationPlanetNumber}`);
}
return parts.join(";");
}
async function mountRenderer(
report: NonNullable<GameStateStore["report"]>,
mode: "torus" | "no-wrap",
): Promise<void> {
if (canvasEl === null || containerEl === null) return;
// Capture camera state before disposing so a remount inside
// the same game (e.g. cargo-route overlay change) keeps the
// user's pan/zoom. A new game / first mount has no prior
// camera, so `previousCamera` stays null and the default
// centring path runs.
const previousGameId = mountedGameId;
const targetGameId = store?.gameId ?? "";
const previousCamera =
handle !== null && previousGameId === targetGameId
? handle.getCamera()
: null;
if (detachClick !== null) {
detachClick();
detachClick = null;
}
// Detach the previous resolver before disposing — the
// renderer's `dispose` already calls `onPick(null)` on any
// open session, which `bindResolver(null)` would also do, so
// we route the cancel through one path only.
pickService?.bindResolver(null);
if (detachDebugProviders !== null) {
detachDebugProviders();
detachDebugProviders = null;
}
if (handle !== null) {
handle.dispose();
handle = null;
}
try {
const {
world,
hitLookup: nextHitLookup,
categories,
planetDependents,
} = reportToWorld(report);
hitLookup = nextHitLookup;
currentCategories = categories;
currentPlanetDependents = planetDependents;
handle = await createRenderer({
canvas: canvasEl,
world,
mode,
preference: ["webgpu", "webgl"],
});
const minScale = minScaleNoWrap(
{
widthPx: containerEl.clientWidth,
heightPx: containerEl.clientHeight,
},
world,
);
if (previousCamera !== null) {
// Same-game remount — preserve pan/zoom. Clamp zoom
// to `minScale` so a remount that re-derives the
// minimum (e.g. a viewport resize between renderers)
// does not strand the user below the current floor.
handle.viewport.moveCenter(
previousCamera.centerX,
previousCamera.centerY,
);
handle.viewport.setZoom(
Math.max(previousCamera.scale, minScale),
true,
);
} else {
handle.viewport.moveCenter(world.width / 2, world.height / 2);
handle.viewport.setZoom(minScale * 1.05, true);
}
if (mode === "no-wrap") handle.setMode("no-wrap");
detachClick = handle.onClick(handleMapClick);
pickService?.bindResolver(({ sourcePlanetNumber, reachableIds, onResolve }) => {
if (handle === null) {
onResolve(null);
return null;
}
const planet = report.planets.find(
(p) => p.number === sourcePlanetNumber,
);
if (planet === undefined) {
onResolve(null);
return null;
}
return handle.setPickMode({
sourcePrimitiveId: sourcePlanetNumber,
sourceX: planet.x,
sourceY: planet.y,
reachableIds,
onPick: onResolve,
});
});
const detachPrim = registerMapPrimitivesProvider(() => {
const h = handle;
if (h === null) return [];
return h.getPrimitives().map<MapPrimitiveSnapshot>((p) => ({
id: p.id,
kind: p.kind,
priority: p.priority,
alpha: h.getPrimitiveAlpha(p.id),
fillColor: p.style.fillColor ?? null,
strokeColor: p.style.strokeColor ?? null,
x: p.kind === "point" ? p.x : null,
y: p.kind === "point" ? p.y : null,
visible: !h.isPrimitiveHidden(p.id),
}));
});
const detachPick = registerMapPickStateProvider(() => {
const h = handle;
if (h === null) {
return {
active: false,
sourcePlanetNumber: null,
reachableIds: [],
hoveredId: null,
} satisfies MapPickStateSnapshot;
}
const state = h.getPickState();
return {
active: state.active,
sourcePlanetNumber:
state.sourcePrimitiveId === null
? null
: Number(state.sourcePrimitiveId),
reachableIds:
state.reachableIds === null
? []
: Array.from(state.reachableIds).map((id) => Number(id)),
hoveredId:
state.hoveredId === null ? null : Number(state.hoveredId),
} satisfies MapPickStateSnapshot;
});
const detachCamera = registerMapCameraProvider(() => {
const h = handle;
if (h === null) return null;
const camera = h.getCamera();
const viewport = h.getViewport();
const rect = canvasEl?.getBoundingClientRect();
return {
camera,
viewport,
canvasOrigin: {
x: rect?.left ?? 0,
y: rect?.top ?? 0,
},
} satisfies MapCameraSnapshot;
});
const detachFog = registerMapFogProvider(() => ({
circles: currentFogCircles.map((c) => ({ ...c })),
}) satisfies MapFogSnapshot);
const detachMode = registerMapModeProvider(() =>
handle === null ? null : handle.getMode(),
);
const detachRenderCount = registerMapRenderCountProvider(() =>
handle === null ? null : handle.getRenderCount(),
);
detachDebugProviders = (): void => {
detachPrim();
detachPick();
detachCamera();
detachFog();
detachMode();
detachRenderCount();
};
mountedTurn = report.turn;
mountedGameId = targetGameId;
// runSerializedMount immediately pushes the visibility
// state + extras after this resolves; clearing the
// fingerprint here is defensive in case the post-mount
// path is ever bypassed (e.g. mount-then-throw before the
// extras push). The hide set / fog are applied by the
// caller too, so we do not call them here.
lastExtrasFingerprint = null;
mountError = null;
} catch (err) {
mountError = err instanceof Error ? err.message : String(err);
}
}
// handleMapClick translates a renderer click into a selection
// update. A click that misses every primitive (empty space) is a
// deliberate no-op: the selection rule from Phase 13 is that only
// the explicit close button on the mobile sheet clears the
// current selection. The Phase 19 ship-group surface dispatches
// through the same `hit-test` plumbing — the hitLookup map keyed
// by primitive id resolves a hit back to either a planet or a
// ship-group selection variant.
function handleMapClick(cursorPx: { x: number; y: number }): void {
if (handle === null || store?.report === undefined || store.report === null) {
return;
}
if (selection === undefined) return;
const hit = handle.hitAt(cursorPx);
if (hit === null) return;
const target = hitLookup.get(hit.primitive.id);
if (target === undefined) return;
switch (target.kind) {
case "planet":
if (hit.primitive.kind !== "point") return;
selection.selectPlanet(target.number);
break;
case "shipGroup":
if (hit.primitive.kind !== "point") return;
selection.selectShipGroup(target.ref);
break;
case "battle": {
const gameId = page.params.id ?? "";
const turn = store?.report?.turn ?? 0;
void goto(
`/games/${gameId}/battle/${target.battleId}?turn=${turn}`,
);
break;
}
case "bombing": {
const gameId = page.params.id ?? "";
void goto(
`/games/${gameId}/report#report-bombings`,
).then(() => {
if (typeof document === "undefined") return;
const row = document.querySelector(
`[data-testid="report-bombing-row"][data-planet="${target.planet}"]`,
);
if (row && row.scrollIntoView) {
row.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
break;
}
}
}
onMount(() => {
mounted = true;
onResize = (): void => {
if (handle === null || containerEl === null) return;
handle.resize(containerEl.clientWidth, containerEl.clientHeight);
};
window.addEventListener("resize", onResize);
// In DEV the in-game shell mounts on a fresh document load
// (`page.goto`), which discards anything the
// `/__debug/store` route may have installed earlier in the
// session. The renderer-side accessors are still useful for
// e2e specs driving the map, so we install them here too.
if (import.meta.env.DEV) {
detachDebugSurface = installRendererDebugSurface();
}
});
onDestroy(() => {
mounted = false;
if (onResize !== null) {
window.removeEventListener("resize", onResize);
onResize = null;
}
if (detachClick !== null) {
detachClick();
detachClick = null;
}
pickService?.bindResolver(null);
if (detachDebugProviders !== null) {
detachDebugProviders();
detachDebugProviders = null;
}
if (detachDebugSurface !== null) {
detachDebugSurface();
detachDebugSurface = null;
}
if (handle !== null) {
handle.dispose();
handle = null;
}
});
</script>
<section class="active-view" data-testid="active-view-map" data-status={store?.status ?? "idle"}>
{#if store?.status === "error"}
<p class="overlay error" role="alert" data-testid="map-error">
{store.error ?? "request failed"}
</p>
{:else if mountError !== null}
<p class="overlay error" role="alert" data-testid="map-mount-error">
{mountError}
</p>
{:else if store?.status !== "ready"}
<p class="overlay" data-testid="map-loading">{i18n.t("common.loading")}</p>
{/if}
<div
class="canvas-wrap"
data-testid="map-canvas-wrap"
data-planet-count={store?.report?.planets.length ?? 0}
bind:this={containerEl}
>
<canvas bind:this={canvasEl}></canvas>
{#if store !== undefined && store.status === "ready"}
<MapTogglesControl {store} />
{/if}
</div>
</section>
<style>
.active-view {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.canvas-wrap {
flex: 1;
min-height: 0;
position: relative;
overflow: hidden;
background: #0a0e1a;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
.overlay {
position: absolute;
top: 0.75rem;
left: 50%;
transform: translateX(-50%);
padding: 0.4rem 0.9rem;
background: rgba(20, 24, 42, 0.85);
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 6px;
z-index: 10;
font-family: system-ui, sans-serif;
font-size: 0.9rem;
margin: 0;
}
.overlay.error {
background: #4a1820;
border-color: #6d2530;
color: #ffb4b4;
}
</style>