From 280f9eaf1bd10562bc00f3b411fb68e7ebb2814e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 01:15:05 +0000 Subject: [PATCH] SCOPENET Map viewer: shared canvas engine, launcher window and claim terrain, admin map card; BlueMap/Vantage UI removed --- Dockerfile | 2 + launcher/src-tauri/src/commands.rs | 103 +-- launcher/src-tauri/src/lib.rs | 4 +- launcher/src-tauri/src/state.rs | 4 - launcher/src/components/BlueMapModal.svelte | 80 -- launcher/src/components/ChunkMap.svelte | 21 +- ...lueMapTiles.svelte => ClaimTerrain.svelte} | 124 ++-- launcher/src/components/MapModal.svelte | 68 ++ launcher/src/lib/mock.ts | 10 +- launcher/src/pages/Home.svelte | 14 +- launcher/tsconfig.json | 6 +- launcher/vite.config.ts | 5 +- panel/web/src/components/LiveMapPanel.svelte | 52 -- panel/web/src/components/MapPanel.svelte | 120 +++ panel/web/src/components/ServerForm.svelte | 7 +- panel/web/src/lib/types.ts | 14 +- panel/web/src/pages/ServerDetail.svelte | 11 +- panel/web/src/pages/Servers.svelte | 4 +- panel/web/tsconfig.json | 6 +- panel/web/vite.config.ts | 4 + shared/map/MapViewer.svelte | 238 ++++++ shared/map/engine.ts | 687 ++++++++++++++++++ shared/map/feed.ts | 51 ++ shared/map/index.ts | 6 + shared/map/types.ts | 89 +++ 25 files changed, 1387 insertions(+), 343 deletions(-) delete mode 100644 launcher/src/components/BlueMapModal.svelte rename launcher/src/components/{BlueMapTiles.svelte => ClaimTerrain.svelte} (59%) create mode 100644 launcher/src/components/MapModal.svelte delete mode 100644 panel/web/src/components/LiveMapPanel.svelte create mode 100644 panel/web/src/components/MapPanel.svelte create mode 100644 shared/map/MapViewer.svelte create mode 100644 shared/map/engine.ts create mode 100644 shared/map/feed.ts create mode 100644 shared/map/index.ts create mode 100644 shared/map/types.ts diff --git a/Dockerfile b/Dockerfile index 38f8b7c..acdf99b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,8 @@ FROM --platform=$BUILDPLATFORM node:22-alpine AS web WORKDIR /src/panel/web COPY panel/web/package.json panel/web/package-lock.json ./ RUN npm ci --no-audit --no-fund +# The map viewer is shared with the launcher (imported as @scopenet/map). +COPY shared/ /src/shared/ COPY panel/web/ ./ RUN npm run build diff --git a/launcher/src-tauri/src/commands.rs b/launcher/src-tauri/src/commands.rs index 0409624..d6d75c1 100644 --- a/launcher/src-tauri/src/commands.rs +++ b/launcher/src-tauri/src/commands.rs @@ -635,90 +635,37 @@ pub async fn get_public_servers(state: State<'_, AppState>) -> Res, - etag: Option, -} - -/// Native transport for BlueMap settings and 2D tiles. Only paths under the -/// BlueMap address supplied by the panel can be fetched. -#[tauri::command] -pub async fn fetch_bluemap_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option) -> Res { - let configured = state.map_urls.lock().unwrap().get(&server_id).cloned().ok_or("map not configured for this server")?; - let base = reqwest::Url::parse(&format!("{}/", configured.trim_end_matches('/'))).map_err(err)?; - let target = reqwest::Url::parse(&url).map_err(err)?; - let root = base.clone(); - if !matches!(target.scheme(), "http" | "https") - || target.origin() != base.origin() - || !target.path().starts_with(root.path()) - || target.username() != "" - || target.password().is_some() - || target.fragment().is_some() - { - return Err("map asset is outside the configured BlueMap site".into()); - } - fetch_map_asset(target, etag, None).await -} - -/// What the launcher needs to offer a Live Map button for one game server. -#[tauri::command] -pub async fn get_livemap_info(state: State<'_, AppState>, server_id: i64) -> Res { - let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/livemap")).await?; - let resp = req.send().await.map_err(err)?; - if !resp.status().is_success() { - let body: serde_json::Value = resp.json().await.unwrap_or_default(); - return Err(body["error"].as_str().unwrap_or("unable to load the live map").to_string()); - } resp.json().await.map_err(err) } -async fn fetch_map_asset(target: reqwest::Url, etag: Option, bearer: Option) -> Res { - use base64::Engine; - if target.as_str().len() > 2048 { - return Err("map asset URL is too long".into()); +/// What the SCOPENET Map needs for one game server: which dimensions have tiles, the tile key, and where tiles live (made absolute, +/// because the map loads them straight from the panel as images). +#[tauri::command] +pub async fn get_map_info(state: State<'_, AppState>, server_id: i64) -> Res { + let panel = state.panel_url().ok_or("no panel configured")?; + let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/map")).await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(body["error"].as_str().unwrap_or("unable to load the map").to_string()); } - let client = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .timeout(std::time::Duration::from_secs(60)) - .build() - .map_err(err)?; - let mut request = client.get(target); - if let Some(token) = bearer { - request = request.bearer_auth(token); + let mut info: serde_json::Value = resp.json().await.map_err(err)?; + if let Some(base) = info["tile_base"].as_str().map(str::to_owned) { + info["tile_base"] = serde_json::json!(format!("{}{}", panel.trim_end_matches('/'), base)); } - if let Some(etag) = etag.filter(|e| e.len() < 256 && !e.contains('\n') && !e.contains('\r')) { - request = request.header(reqwest::header::IF_NONE_MATCH, etag); + Ok(info) +} + +/// Players now, plus the claims and pins the game server last sent. +#[tauri::command] +pub async fn get_map_overlay(state: State<'_, AppState>, server_id: i64) -> Res { + let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/map/overlay")).await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(body["error"].as_str().unwrap_or("unable to load the map").to_string()); } - let response = request.send().await.map_err(err)?; - let status = response.status().as_u16(); - let etag = response.headers().get(reqwest::header::ETAG).and_then(|v| v.to_str().ok()).map(str::to_owned); - if status == 304 { - return Ok(MapResource { status, data: None, etag }); - } - if !response.status().is_success() { - return Ok(MapResource { status, data: None, etag }); - } - if response.content_length().is_some_and(|n| n > 64 * 1024 * 1024) { - return Err("BlueMap asset exceeds 64 MiB".into()); - } - let bytes = response.bytes().await.map_err(err)?; - if bytes.len() > 64 * 1024 * 1024 { - return Err("BlueMap asset exceeds 64 MiB".into()); - } - Ok(MapResource { status, data: Some(base64::engine::general_purpose::STANDARD.encode(bytes)), etag }) + resp.json().await.map_err(err) } // --------------------------------------------------------------------------- diff --git a/launcher/src-tauri/src/lib.rs b/launcher/src-tauri/src/lib.rs index 91d2a7e..b22856f 100644 --- a/launcher/src-tauri/src/lib.rs +++ b/launcher/src-tauri/src/lib.rs @@ -97,8 +97,8 @@ pub fn run() { commands::get_player_stats, commands::get_leaderboard, commands::get_public_servers, - commands::fetch_bluemap_resource, - commands::get_livemap_info, + commands::get_map_info, + commands::get_map_overlay, commands::get_my_level, commands::get_my_quests, commands::get_reward_summaries, diff --git a/launcher/src-tauri/src/state.rs b/launcher/src-tauri/src/state.rs index a4a1f3e..6800827 100644 --- a/launcher/src-tauri/src/state.rs +++ b/launcher/src-tauri/src/state.rs @@ -4,7 +4,6 @@ use crate::settings::Settings; use scopenet_core::Layout; use scopenet_shared::LauncherManifest; use std::path::PathBuf; -use std::collections::HashMap; use std::sync::{Mutex, RwLock}; /// Values baked in at build time by CI (see `.github/workflows/release.yml`). @@ -41,8 +40,6 @@ pub struct AppState { pub accounts: RwLock, pub secrets: Secrets, pub manifest: RwLock>, - /// BlueMap addresses received from the panel, keyed by linked game server. - pub map_urls: Mutex>, /// Install/launch pipeline in progress (abortable). pub task: Mutex>, pub game: Mutex>, @@ -61,7 +58,6 @@ impl AppState { settings: RwLock::new(settings), accounts: RwLock::new(accounts), manifest: RwLock::new(manifest), - map_urls: Mutex::new(HashMap::new()), task: Mutex::new(None), game: Mutex::new(Vec::new()), data_dir, diff --git a/launcher/src/components/BlueMapModal.svelte b/launcher/src/components/BlueMapModal.svelte deleted file mode 100644 index 091a1bc..0000000 --- a/launcher/src/components/BlueMapModal.svelte +++ /dev/null @@ -1,80 +0,0 @@ - - - e.key === 'Escape' && (selected ? (selected = null) : onclose())} /> - - - - diff --git a/launcher/src/components/ChunkMap.svelte b/launcher/src/components/ChunkMap.svelte index 9864184..dca3e67 100644 --- a/launcher/src/components/ChunkMap.svelte +++ b/launcher/src/components/ChunkMap.svelte @@ -2,15 +2,15 @@ import { onMount } from 'svelte'; import { Compass, Globe, LocateFixed, RefreshCw, Server as ServerIcon, Shield } from '@lucide/svelte'; import { invoke } from '../lib/tauri'; - import { toast } from '../lib/store.svelte'; + import { activeAccount, toast } from '../lib/store.svelte'; import type { GuildClaim } from '../lib/types'; - import BlueMapTiles from './BlueMapTiles.svelte'; + import ClaimTerrain from './ClaimTerrain.svelte'; let { instanceId, myGuildId = null, myGuildTag = '', onclaim, onunclaim }: { instanceId: string; myGuildId?: string | null; myGuildTag?: string; onclaim?: (claim: GuildClaim) => void; onunclaim?: (claimId: number) => void; } = $props(); - type GameServer = { id: number; name: string; instance_id: string; map_address?: string; live_map?: boolean }; + type GameServer = { id: number; name: string; instance_id: string; map?: boolean }; type Player = { x: number; z: number; dimension: string }; let servers = $state([]); let serverId = $state(null); @@ -32,9 +32,10 @@ async function refreshPosition(id: number) { try { - const info = await invoke<{ me?: Player | null }>('get_livemap_info', { serverId: id }); + const info = await invoke<{ players: Array }>('get_map_overlay', { serverId: id }); if (serverId !== id) return; - player = info.me ?? null; + const mine = activeAccount()?.uuid?.toLowerCase(); + player = info.players.find((p) => p.uuid.toLowerCase() === mine) ?? null; if (player && centeredServer !== id) { centeredServer = id; dimension = (dimensions.find((d) => d.id === player?.dimension)?.id ?? 'minecraft:overworld'); @@ -134,20 +135,20 @@ - {#if selectedServer?.map_address} - {#key `${serverId}:${selectedServer.map_address}`} - { centerX = x; centerZ = z; }} /> {/key} {:else} -
Configure this server's BlueMap address in the panel to show its 2D terrain and claim land.
+
The map is switched off for this server. An admin can turn it on under Servers in the panel.
{/if} diff --git a/launcher/src/components/BlueMapTiles.svelte b/launcher/src/components/ClaimTerrain.svelte similarity index 59% rename from launcher/src/components/BlueMapTiles.svelte rename to launcher/src/components/ClaimTerrain.svelte index 1f46adc..0d18f15 100644 --- a/launcher/src/components/BlueMapTiles.svelte +++ b/launcher/src/components/ClaimTerrain.svelte @@ -1,113 +1,74 @@ -
{ if (e.key === 'Enter') onpaint([{ x: Math.floor(worldX / 16), z: Math.floor(worldZ / 16) }], 'claim'); }} onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up} onwheel={wheel} oncontextmenu={(e) => e.preventDefault()}> {#each visibleTiles as tile (tile.key)} - {#if images[tile.key]}{/if} + (broken[tile.url] = true)} style:left="{tile.left}px" style:top="{tile.top}px" style:width="{tile.size}px" style:height="{tile.size}px" /> {/each}
@@ -191,10 +152,10 @@ {/each} {#if playerHere}
{/if} - {#if loading}
Loading BlueMap tiles…
{/if} - {#if error}
{error}
{/if} + {#if loading}
Loading the map…
{/if} + {#if error && !loading}
{error}
{/if}
Left click or drag: claim · Right click or drag: unclaim · Alt drag: pan · Wheel: zoom
-
{mapId} · {Math.floor(worldX)}, {Math.floor(worldZ)}
+
{Math.floor(worldX)}, {Math.floor(worldZ)}
diff --git a/launcher/src/components/MapModal.svelte b/launcher/src/components/MapModal.svelte new file mode 100644 index 0000000..2405158 --- /dev/null +++ b/launcher/src/components/MapModal.svelte @@ -0,0 +1,68 @@ + + + e.key === 'Escape' && onclose()} /> + + + + diff --git a/launcher/src/lib/mock.ts b/launcher/src/lib/mock.ts index 9205531..6807625 100644 --- a/launcher/src/lib/mock.ts +++ b/launcher/src/lib/mock.ts @@ -137,10 +137,12 @@ export async function mockInvoke(cmd: string, args: Record): Promis await sleep(40); switch (cmd) { case 'get_public_servers': - return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map_address: '', live_map: true, online: true, players_online: 42, players_max: 100 }] }; - case 'get_livemap_info': - return { enabled: true, ready: true, message: '', me: null, players: 0, - dimensions: [{ id: 'minecraft:overworld', slug: 'overworld', label: 'Overworld', available: true }, { id: 'minecraft:the_nether', slug: 'the_nether', label: 'The Nether', available: false }, { id: 'minecraft:the_end', slug: 'the_end', label: 'The End', available: false }] }; + return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map: true, online: true, players_online: 42, players_max: 100 }] }; + case 'get_map_info': + return { enabled: true, ready: false, message: 'Waiting for the game server to draw its world.', tile_size: 256, max_zoom: 6, tile_base: 'https://panel.example/api/map/1', token: null, token_ttl_secs: 3600, players: 0, + dimensions: [] }; + case 'get_map_overlay': + return { players: [], claims: [], pins: [], updated: null }; case 'get_player_stats': return { username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', total: null, servers: [], events: [] }; case 'get_leaderboard': diff --git a/launcher/src/pages/Home.svelte b/launcher/src/pages/Home.svelte index 1547577..e93f8a2 100644 --- a/launcher/src/pages/Home.svelte +++ b/launcher/src/pages/Home.svelte @@ -4,7 +4,7 @@ import ServerStatus from '../components/ServerStatus.svelte'; import NewsFeed from '../components/NewsFeed.svelte'; import Console from '../components/Console.svelte'; - import BlueMapModal from '../components/BlueMapModal.svelte'; + import MapModal from '../components/MapModal.svelte'; import { abs, activeAccount, app, instances, selectedInstance } from '../lib/store.svelte'; import { loaderName, shortLoaderVersion } from '../lib/format'; import { invoke, openUrl } from '../lib/tauri'; @@ -13,8 +13,8 @@ const b = $derived(app.manifest!.branding); // Lucide ships no brand logos, so each platform gets a recognisable generic icon. const linkIcon: Record = { discord: MessageCircle, website: Globe, store: ShoppingBag, youtube: CirclePlay, twitch: Tv, github: Code, twitter: AtSign }; - // A game server linked to this instance with BlueMap configured. - let mapServer = $state<{ id: number; name: string; map_address?: string } | null>(null); + // A game server linked to this instance that draws the SCOPENET Map. + let mapServer = $state<{ id: number; name: string } | null>(null); let mapOpen = $state(false); $effect(() => { const id = inst?.id; @@ -22,8 +22,8 @@ mapOpen = false; if (!id) return; let alive = true; - invoke<{ servers: Array<{ id: number; name: string; instance_id: string; map_address?: string }> }>('get_public_servers') - .then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && s.map_address) ?? null; }) + invoke<{ servers: Array<{ id: number; name: string; instance_id: string; map?: boolean }> }>('get_public_servers') + .then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && s.map) ?? null; }) .catch(() => {}); return () => { alive = false; }; }); @@ -57,7 +57,7 @@ - {#if mapServer}{/if} + {#if mapServer}{/if} {#if inst.server && b.features.server_status}{/if} @@ -82,7 +82,7 @@ {#if app.consoleOpen}{/if} - {#if mapOpen && mapServer?.map_address} (mapOpen = false)} />{/if} + {#if mapOpen && mapServer} (mapOpen = false)} />{/if} diff --git a/panel/web/src/components/MapPanel.svelte b/panel/web/src/components/MapPanel.svelte new file mode 100644 index 0000000..08af75a --- /dev/null +++ b/panel/web/src/components/MapPanel.svelte @@ -0,0 +1,120 @@ + + +
+
+

SCOPENET Map

+ {enabled ? 'On' : 'Off'} + + +
+ + {#if !enabled} +

The map is off for this server. Switch it on in the server's settings and the plugin or mod starts drawing the world.

+ {:else} +
+
Tiles{totalTiles.toLocaleString()}
+
Size{formatBytes(totalBytes)}
+
Online{status?.players ?? 0}
+
Last tiles{status?.stats.last_upload_at ? timeAgo(status.stats.last_upload_at) : '—'}
+
Last players{status?.stats.last_players_at ? timeAgo(status.stats.last_players_at) : '—'}
+
+ {#if status?.dimensions.length} +

{#each status.dimensions as d, i}{i ? ' · ' : ''}{d.label}: {d.tiles.toLocaleString()} tiles ({formatBytes(d.bytes)}){/each}

+ {/if} +
+ {#key nonce} + + {#snippet player(p: LivePlayer, close: () => void)} +
+ +
{p.dimension.replace('minecraft:', '').replace('the_', '')}
+

X {Math.round(p.x)} · Y {Math.round(p.y)} · Z {Math.round(p.z)}

+ /tp {p.name} +
+ {/snippet} +
+ {/key} +
+ {/if} +
+ + +

This deletes every map tile stored on the panel. The game server notices and draws the world again from its region files, starting near the players. The map stays empty for a while on big worlds.

+
+ + +
+
+ + diff --git a/panel/web/src/components/ServerForm.svelte b/panel/web/src/components/ServerForm.svelte index 73a45b1..f97a599 100644 --- a/panel/web/src/components/ServerForm.svelte +++ b/panel/web/src/components/ServerForm.svelte @@ -18,11 +18,10 @@ -
diff --git a/panel/web/src/lib/types.ts b/panel/web/src/lib/types.ts index de9e32a..3d7a082 100644 --- a/panel/web/src/lib/types.ts +++ b/panel/web/src/lib/types.ts @@ -93,8 +93,7 @@ export interface AuthServerInfo { public_url: string; yggdrasil_url: string; pub export interface GameServer { instance_id: string; - live_map_enabled: boolean; - map_address: string; + map_enabled: boolean; economy_group: string; id: number; name: string; @@ -144,18 +143,19 @@ export interface ServerDetail { export interface UserActivity { servers: PlayerStats[]; events: ServerEvent[]; online_on: { id: number; name: string }[] } -export interface LiveMapStatus { +/** What the admin map card shows: tiles per dimension plus when the game server last sent things. */ +export interface MapStatus { enabled: boolean; ready: boolean; message: string; - dimensions: { id: string; slug: string; label: string; available: boolean }[]; + dimensions: { id: string; slug: string; label: string; tiles: number; bytes: number }[]; players: number; - world: { level_dat: boolean; dimensions: { id: string; slug: string; regions: number; bytes: number }[] }; - stats: { chunks_received: number; last_chunk_at: string | null; last_players_at: string | null }; + epoch: number; + stats: { tiles_received: number; bytes_received: number; last_upload_at: string | null; last_players_at: string | null; last_overlay_at: string | null }; roster: { uuid: string; name: string; dimension: string; x: number; y: number; z: number }[]; } -export type ServerDraft = { instance_id?: string; name: string; live_map_enabled: boolean; economy_group?: string; map_address?: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean }; +export type ServerDraft = { instance_id?: string; name: string; map_enabled: boolean; economy_group?: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean }; export interface HostedDownload { platform: 'windows' | 'mac' | 'linux'; diff --git a/panel/web/src/pages/ServerDetail.svelte b/panel/web/src/pages/ServerDetail.svelte index 9123c9b..b2924b0 100644 --- a/panel/web/src/pages/ServerDetail.svelte +++ b/panel/web/src/pages/ServerDetail.svelte @@ -1,12 +1,12 @@ + +
+ + +
+
+ {#if dims.length > 1} +
+ {#each dims as d} + + {/each} +
+ {:else if dims.length === 1} + {dims[0].label} + {/if} +
+ + + +
+ {#if toolbar}{@render toolbar()}{/if} +
+ +
+ + {#if listOpen && everyone.length} +
    + {#each everyone as p (p.uuid)} +
  • + +
  • + {/each} +
+ {/if} +
+
+ +
+ {#if hint}{hint}{/if} + {#if cursor}X {fmt(cursor.x)} · Z {fmt(cursor.z)}{:else} {/if}{zoomText} +
+ +
+ {#if me}{/if} + +
+ + +
+
+ + {#if selection} + + {/if} + + {#if waiting} +
+
+ {info?.enabled === false ? 'The map is off for this server' : 'Drawing the world…'} + {error || info?.message || 'Connecting to the map.'} +
+ {:else if error} +
{error}
+ {/if} +
+ + diff --git a/shared/map/engine.ts b/shared/map/engine.ts new file mode 100644 index 0000000..f28601f --- /dev/null +++ b/shared/map/engine.ts @@ -0,0 +1,687 @@ +import type { LivePlayer, MapBounds, MapClaim, MapDimension, MapInfo, MapLayers, MapOverlay, MapPin, MapSelection } from './types'; +import { dimSlug } from './types'; + +/** A framework-free map renderer: pans and zooms the tile pyramid on a canvas and draws claims, pins and players on top. */ + +export interface Theme { + background: string; + grid: string; + label: string; + labelBg: string; + accent: string; + self: string; +} + +const DARK: Theme = { background: '#0d1220', grid: 'rgba(255,255,255,0.045)', label: '#f4f6ff', labelBg: 'rgba(10,14,26,0.78)', accent: '#8b6cff', self: '#34d399' }; + +const PIN_COLORS: Record = { + spawn: '#fbbf24', warp: '#38bdf8', home: '#a78bfa', guild_home: '#fb7185', market: '#34d399', shop: '#f59e0b' +}; + +export interface MapViewOptions { + tileUrl: (slug: string, zoom: number, x: number, y: number) => string; + avatarUrl?: (uuid: string) => string; + theme?: Partial; + selfUuid?: string | null; + onselect?: (selection: MapSelection | null) => void; + onhover?: (hover: { cursor: { x: number; z: number } | null; label: string | null }) => void; + onview?: (view: { x: number; z: number; scale: number }) => void; +} + +interface Tile { img: HTMLImageElement; state: 'loading' | 'ok' | 'missing'; at: number; used: number } + +const MIN_SCALE = 1 / 128; +const MAX_SCALE = 20; +const TILE = 256; +const MAX_LOADS = 10; + +export class MapView { + private ctx: CanvasRenderingContext2D; + private theme: Theme; + private info: MapInfo | null = null; + private dimension: MapDimension | null = null; + private overlay: MapOverlay = { players: [], claims: [], pins: [] }; + private layers: MapLayers = { claims: true, pins: true, players: true }; + private tiles = new Map(); + private queue: string[] = []; + private loading = 0; + private icons = new Map(); + private paths = new Map(); + private width = 300; + private height = 200; + private dpr = 1; + private cx = 0; + private cz = 0; + private scale = 1; + private hoverId: string | null = null; + private selected: MapSelection | null = null; + private following: string | null = null; + private raf = 0; + private fly: { fromX: number; fromZ: number; fromS: number; toX: number; toZ: number; toS: number; start: number; ms: number } | null = null; + private pointers = new Map(); + private drag: { x: number; y: number; moved: number } | null = null; + private pinchStart: { dist: number; scale: number } | null = null; + private disposed = false; + private resizeObserver: ResizeObserver; + + constructor(private canvas: HTMLCanvasElement, private opts: MapViewOptions) { + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('canvas is not available'); + this.ctx = ctx; + this.theme = { ...DARK, ...(opts.theme ?? {}) }; + canvas.style.touchAction = 'none'; + canvas.addEventListener('pointerdown', this.down); + canvas.addEventListener('pointermove', this.move); + canvas.addEventListener('pointerup', this.up); + canvas.addEventListener('pointercancel', this.up); + canvas.addEventListener('pointerleave', this.leave); + canvas.addEventListener('wheel', this.wheel, { passive: false }); + canvas.addEventListener('dblclick', this.dbl); + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.resizeObserver.observe(canvas); + this.resize(); + } + + // ---- public API -------------------------------------------------------------------------------------- + + setInfo(info: MapInfo) { + const tokenChanged = this.info?.token !== info.token || this.info?.tile_base !== info.tile_base; + this.info = info; + if (tokenChanged) { + // New tile key: tiles that failed earlier may work now; loaded ones stay. + for (const [k, t] of this.tiles) if (t.state !== 'ok') this.tiles.delete(k); + } + const keep = this.dimension ? info.dimensions.find((d) => d.slug === this.dimension!.slug) : undefined; + this.dimension = keep ?? info.dimensions[0] ?? null; + if (!keep && this.dimension) this.frameDimension(); + this.invalidate(); + } + + setOverlay(overlay: MapOverlay) { + this.overlay = overlay; + this.paths.clear(); + if (this.following) { + const p = overlay.players.find((q) => q.uuid === this.following); + if (p) { + if (dimSlug(p.dimension) !== this.dimension?.slug) this.setDimension(p.dimension); + this.fly = { fromX: this.cx, fromZ: this.cz, fromS: this.scale, toX: p.x, toZ: p.z, toS: this.scale, start: performance.now(), ms: 700 }; + } + } + if (this.selected?.type === 'player') { + const fresh = overlay.players.find((p) => p.uuid === (this.selected as { player: LivePlayer }).player.uuid); + if (fresh) this.selected = { type: 'player', player: fresh }; + } + this.invalidate(); + } + + setLayers(layers: MapLayers) { this.layers = { ...layers }; this.invalidate(); } + + setSelf(uuid: string | null | undefined) { this.opts.selfUuid = uuid ?? null; this.invalidate(); } + + /** Switch dimension by id (`minecraft:the_nether`) or slug. */ + setDimension(idOrSlug: string) { + const slug = dimSlug(idOrSlug); + const next = this.info?.dimensions.find((d) => d.slug === slug); + if (!next || next.slug === this.dimension?.slug) return; + this.dimension = next; + this.select(null); + this.frameDimension(); + this.invalidate(); + } + + get currentDimension(): MapDimension | null { return this.dimension; } + + follow(uuid: string | null) { this.following = uuid; if (uuid) this.invalidate(); } + + flyTo(x: number, z: number, scale?: number) { + this.following = null; + this.fly = { fromX: this.cx, fromZ: this.cz, fromS: this.scale, toX: x, toZ: z, toS: clamp(scale ?? this.scale, MIN_SCALE, MAX_SCALE), start: performance.now(), ms: 520 }; + this.invalidate(); + } + + zoomBy(factor: number) { + this.fly = null; + this.zoomAround(this.width / 2, this.height / 2, factor, true); + } + + select(selection: MapSelection | null) { + this.selected = selection; + this.opts.onselect?.(selection); + this.invalidate(); + } + + frameDimension() { + const b = this.dimension?.bounds; + if (!b) return; + this.fly = null; + const [minX, maxX, minZ, maxZ] = worldBounds(b); + this.cx = (minX + maxX) / 2; + this.cz = (minZ + maxZ) / 2; + const fit = Math.min(this.width / Math.max(1, maxX - minX), this.height / Math.max(1, maxZ - minZ)) * 0.9; + this.scale = clamp(fit, 0.05, 2); + this.emitView(); + } + + pinsOfKind(kind: string): MapPin[] { return this.overlay.pins.filter((p) => p.kind === kind && dimSlug(p.dimension) === this.dimension?.slug); } + + getView() { return { x: this.cx, z: this.cz, scale: this.scale }; } + + destroy() { + this.disposed = true; + cancelAnimationFrame(this.raf); + this.resizeObserver.disconnect(); + const c = this.canvas; + c.removeEventListener('pointerdown', this.down); + c.removeEventListener('pointermove', this.move); + c.removeEventListener('pointerup', this.up); + c.removeEventListener('pointercancel', this.up); + c.removeEventListener('pointerleave', this.leave); + c.removeEventListener('wheel', this.wheel); + c.removeEventListener('dblclick', this.dbl); + this.tiles.clear(); + } + + // ---- geometry --------------------------------------------------------------------------------------- + + private resize() { + const rect = this.canvas.getBoundingClientRect(); + this.dpr = Math.min(window.devicePixelRatio || 1, 2.5); + this.width = Math.max(50, rect.width); + this.height = Math.max(50, rect.height); + this.canvas.width = Math.round(this.width * this.dpr); + this.canvas.height = Math.round(this.height * this.dpr); + this.invalidate(); + } + + private toScreen(x: number, z: number) { return { x: (x - this.cx) * this.scale + this.width / 2, y: (z - this.cz) * this.scale + this.height / 2 }; } + private toWorld(sx: number, sy: number) { return { x: (sx - this.width / 2) / this.scale + this.cx, z: (sy - this.height / 2) / this.scale + this.cz }; } + + private zoomAround(sx: number, sy: number, factor: number, animate = false) { + const before = this.toWorld(sx, sy); + const target = clamp(this.scale * factor, MIN_SCALE, MAX_SCALE); + if (animate) { + // Keep the point under (sx, sy) fixed while easing the scale. + const toX = before.x - (sx - this.width / 2) / target; + const toZ = before.z - (sy - this.height / 2) / target; + this.fly = { fromX: this.cx, fromZ: this.cz, fromS: this.scale, toX, toZ, toS: target, start: performance.now(), ms: 220 }; + } else { + this.scale = target; + this.cx = before.x - (sx - this.width / 2) / this.scale; + this.cz = before.z - (sy - this.height / 2) / this.scale; + } + this.following = null; + this.invalidate(); + } + + // ---- input -------------------------------------------------------------------------------------------- + + private localPoint(e: PointerEvent | WheelEvent | MouseEvent) { + const r = this.canvas.getBoundingClientRect(); + return { x: e.clientX - r.left, y: e.clientY - r.top }; + } + + private down = (e: PointerEvent) => { + this.canvas.setPointerCapture(e.pointerId); + const p = this.localPoint(e); + this.pointers.set(e.pointerId, p); + this.fly = null; + if (this.pointers.size === 1) this.drag = { x: p.x, y: p.y, moved: 0 }; + if (this.pointers.size === 2) { + const [a, b] = [...this.pointers.values()]; + this.pinchStart = { dist: Math.hypot(a.x - b.x, a.y - b.y), scale: this.scale }; + this.drag = null; + } + }; + + private move = (e: PointerEvent) => { + const p = this.localPoint(e); + if (this.pointers.has(e.pointerId)) this.pointers.set(e.pointerId, p); + if (this.pinchStart && this.pointers.size === 2) { + const [a, b] = [...this.pointers.values()]; + const dist = Math.hypot(a.x - b.x, a.y - b.y); + this.zoomAround((a.x + b.x) / 2, (a.y + b.y) / 2, (this.pinchStart.scale * dist / this.pinchStart.dist) / this.scale); + return; + } + if (this.drag && this.pointers.has(e.pointerId)) { + const dx = p.x - this.drag.x, dy = p.y - this.drag.y; + this.drag.moved += Math.abs(dx) + Math.abs(dy); + this.drag.x = p.x; this.drag.y = p.y; + if (this.drag.moved > 3) { + this.cx -= dx / this.scale; + this.cz -= dy / this.scale; + this.following = null; + this.canvas.style.cursor = 'grabbing'; + this.invalidate(); + this.emitView(); + } + return; + } + const hit = this.hitTest(p.x, p.y); + const id = hit ? hitId(hit) : null; + if (id !== this.hoverId) { this.hoverId = id; this.invalidate(); } + this.canvas.style.cursor = hit ? 'pointer' : 'grab'; + const w = this.toWorld(p.x, p.y); + this.opts.onhover?.({ cursor: { x: w.x, z: w.z }, label: hit ? hitLabel(hit) : null }); + }; + + private up = (e: PointerEvent) => { + const wasClick = this.drag && this.drag.moved <= 3 && this.pointers.size === 1; + this.pointers.delete(e.pointerId); + if (this.pointers.size < 2) this.pinchStart = null; + if (wasClick) { + const p = this.localPoint(e); + const hit = this.hitTest(p.x, p.y); + this.select(hit); + } + if (this.pointers.size === 0) { this.drag = null; this.canvas.style.cursor = 'grab'; } + }; + + private leave = () => { + if (this.hoverId) { this.hoverId = null; this.invalidate(); } + this.opts.onhover?.({ cursor: null, label: null }); + }; + + private wheel = (e: WheelEvent) => { + e.preventDefault(); + const p = this.localPoint(e); + const delta = e.deltaMode === 1 ? e.deltaY * 18 : e.deltaY; + this.fly = null; + this.zoomAround(p.x, p.y, Math.exp(-delta * 0.0016)); + this.emitView(); + }; + + private dbl = (e: MouseEvent) => { + const p = this.localPoint(e); + if (this.hitTest(p.x, p.y)) return; + this.zoomAround(p.x, p.y, 2, true); + }; + + private emitView() { this.opts.onview?.({ x: this.cx, z: this.cz, scale: this.scale }); } + + // ---- hit testing -------------------------------------------------------------------------------------- + + private visibleSlug() { return this.dimension?.slug ?? ''; } + + private hitTest(sx: number, sy: number): MapSelection | null { + const slug = this.visibleSlug(); + if (this.layers.players) { + let best: LivePlayer | null = null, bestD = 15 * 15; + for (const p of this.overlay.players) { + if (dimSlug(p.dimension) !== slug) continue; + const s = this.toScreen(p.x, p.z); + const d = (s.x - sx) ** 2 + (s.y - sy) ** 2; + if (d < bestD) { best = p; bestD = d; } + } + if (best) return { type: 'player', player: best }; + } + if (this.layers.pins) { + let best: MapPin | null = null, bestD = 15 * 15; + for (const p of this.overlay.pins) { + if (dimSlug(p.dimension) !== slug) continue; + const s = this.toScreen(p.x, p.z); + const d = (s.x - sx) ** 2 + (s.y - sy) ** 2; + if (d < bestD) { best = p; bestD = d; } + } + if (best) return { type: 'pin', pin: best }; + } + if (this.layers.claims) { + const w = this.toWorld(sx, sy); + for (const c of this.overlay.claims) { + if (dimSlug(c.dimension) !== slug) continue; + if (inside(c.outer, w.x, w.z) && !c.holes.some((h) => inside(h, w.x, w.z))) return { type: 'claim', claim: c }; + } + } + return null; + } + + // ---- tiles -------------------------------------------------------------------------------------------- + + private tileKey(slug: string, z: number, x: number, y: number) { return `${slug}/${z}/${x}/${y}`; } + + private tileAt(slug: string, z: number, x: number, y: number, load: boolean): Tile | null { + const key = this.tileKey(slug, z, x, y); + const t = this.tiles.get(key); + if (t) { + t.used = performance.now(); + if (t.state === 'missing' && load && performance.now() - t.at > 20000) { this.tiles.delete(key); return this.tileAt(slug, z, x, y, load); } + return t; + } + if (!load || !this.info?.token) return null; + const img = new Image(); + img.decoding = 'async'; + const tile: Tile = { img, state: 'loading', at: performance.now(), used: performance.now() }; + this.tiles.set(key, tile); + this.queue.push(key); + this.pump(); + this.evict(); + return tile; + } + + private pump() { + while (this.loading < MAX_LOADS && this.queue.length) { + const key = this.queue.pop()!; // newest first: what is on screen now matters most + const tile = this.tiles.get(key); + if (!tile || tile.state !== 'loading') continue; + const [slug, z, x, y] = key.split('/'); + this.loading++; + tile.img.onload = () => { tile.state = 'ok'; this.loading--; this.pump(); this.invalidate(); }; + tile.img.onerror = () => { tile.state = 'missing'; tile.at = performance.now(); this.loading--; this.pump(); }; + tile.img.src = this.opts.tileUrl(slug, Number(z), Number(x), Number(y)); + } + } + + private evict() { + if (this.tiles.size <= 900) return; + const old = [...this.tiles.entries()].sort((a, b) => a[1].used - b[1].used).slice(0, 250); + for (const [k] of old) this.tiles.delete(k); + } + + private inBounds(z: number, x: number, y: number): boolean { + const b = this.dimension?.bounds; + if (!b) return true; + const span = 2 ** z; + return x * span <= b.max_x && (x + 1) * span - 1 >= b.min_x && y * span <= b.max_y && (y + 1) * span - 1 >= b.min_y; + } + + // ---- drawing ------------------------------------------------------------------------------------------ + + invalidate() { + if (this.disposed || this.raf) return; + this.raf = requestAnimationFrame((t) => { this.raf = 0; this.frame(t); }); + } + + private frame(now: number) { + if (this.fly) { + const f = this.fly; + const k = Math.min(1, (now - f.start) / f.ms); + const e = 1 - (1 - k) ** 3; + this.scale = f.fromS * (f.toS / f.fromS) ** e; + this.cx = f.fromX + (f.toX - f.fromX) * e; + this.cz = f.fromZ + (f.toZ - f.fromZ) * e; + if (k >= 1) this.fly = null; + this.emitView(); + this.invalidate(); + } + this.draw(); + } + + private draw() { + const ctx = this.ctx; + ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + ctx.fillStyle = this.theme.background; + ctx.fillRect(0, 0, this.width, this.height); + if (!this.dimension || !this.info) return; + this.drawTiles(ctx); + ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + if (this.layers.claims) this.drawClaims(ctx); + if (this.layers.pins) this.drawPins(ctx); + if (this.layers.players) this.drawPlayers(ctx); + } + + private drawTiles(ctx: CanvasRenderingContext2D) { + const slug = this.dimension!.slug; + const maxZoom = this.info!.max_zoom; + const zoom = clamp(Math.floor(Math.log2(1 / this.scale)), 0, maxZoom); + const span = TILE * 2 ** zoom; // world blocks per tile + const px = span * this.scale; // screen pixels per tile + const tl = this.toWorld(0, 0), br = this.toWorld(this.width, this.height); + const x0 = Math.floor(tl.x / span), x1 = Math.floor(br.x / span), y0 = Math.floor(tl.z / span), y1 = Math.floor(br.z / span); + ctx.imageSmoothingEnabled = this.scale < 1; + ctx.imageSmoothingQuality = 'high'; + // A faint chunk grid when close enough to matter. + if (this.scale >= 3) this.drawGrid(ctx); + for (let ty = y0; ty <= y1; ty++) { + for (let tx = x0; tx <= x1; tx++) { + if (!this.inBounds(zoom, tx, ty)) continue; + const dx = Math.floor((tx * span - this.cx) * this.scale + this.width / 2); + const dy = Math.floor((ty * span - this.cz) * this.scale + this.height / 2); + const dsize = Math.ceil(px) + 1; // overlap by a pixel so seams never show + const tile = this.tileAt(slug, zoom, tx, ty, true); + if (tile?.state === 'ok') { ctx.drawImage(tile.img, dx, dy, dsize, dsize); continue; } + // Not here yet: show the blurry parent underneath, then the real one replaces it. + for (let up = 1; zoom + up <= maxZoom; up++) { + const parent = this.tileAt(slug, zoom + up, tx >> up, ty >> up, tile?.state === 'loading' && up === 1); + if (parent?.state === 'ok') { + const part = TILE / 2 ** up; + const sx = (tx & (2 ** up - 1)) * part, sy = (ty & (2 ** up - 1)) * part; + ctx.drawImage(parent.img, sx, sy, part, part, dx, dy, dsize, dsize); + break; + } + } + } + } + } + + private drawGrid(ctx: CanvasRenderingContext2D) { + const step = 16 * this.scale; + const a = this.toWorld(0, 0); + ctx.strokeStyle = this.theme.grid; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let x = Math.floor(a.x / 16) * 16; (x - this.cx) * this.scale + this.width / 2 < this.width + step; x += 16) { + const sx = Math.round((x - this.cx) * this.scale + this.width / 2) + 0.5; + ctx.moveTo(sx, 0); ctx.lineTo(sx, this.height); + } + for (let z = Math.floor(a.z / 16) * 16; (z - this.cz) * this.scale + this.height / 2 < this.height + step; z += 16) { + const sy = Math.round((z - this.cz) * this.scale + this.height / 2) + 0.5; + ctx.moveTo(0, sy); ctx.lineTo(this.width, sy); + } + ctx.stroke(); + } + + private claimPath(c: MapClaim): Path2D { + let p = this.paths.get(c.id); + if (!p) { + p = new Path2D(); + for (const ring of [c.outer, ...c.holes]) { + ring.forEach(([x, z], i) => (i ? p!.lineTo(x, z) : p!.moveTo(x, z))); + p.closePath(); + } + this.paths.set(c.id, p); + } + return p; + } + + private drawClaims(ctx: CanvasRenderingContext2D) { + const slug = this.visibleSlug(); + const tl = this.toWorld(0, 0), br = this.toWorld(this.width, this.height); + const picked = this.selected?.type === 'claim' ? this.selected.claim.id : null; + for (const c of this.overlay.claims) { + if (dimSlug(c.dimension) !== slug || !c.outer.length) continue; + const xs = c.outer.map((p) => p[0]), zs = c.outer.map((p) => p[1]); + if (Math.max(...xs) < tl.x || Math.min(...xs) > br.x || Math.max(...zs) < tl.z || Math.min(...zs) > br.z) continue; + const hot = c.id === this.hoverId || c.id === picked; + ctx.save(); + ctx.setTransform(this.dpr * this.scale, 0, 0, this.dpr * this.scale, this.dpr * (this.width / 2 - this.cx * this.scale), this.dpr * (this.height / 2 - this.cz * this.scale)); + const path = this.claimPath(c); + ctx.fillStyle = withAlpha(c.color, hot ? 0.46 : 0.28); + ctx.fill(path, 'evenodd'); + ctx.lineJoin = 'round'; + ctx.lineWidth = (hot ? 3 : 2) / this.scale; + ctx.strokeStyle = withAlpha(c.color, 0.95); + ctx.stroke(path); + ctx.restore(); + } + // Labels on top of all shapes, once they are big enough to read. + for (const c of this.overlay.claims) { + if (dimSlug(c.dimension) !== slug || !c.outer.length) continue; + const xs = c.outer.map((p) => p[0]), zs = c.outer.map((p) => p[1]); + const w = (Math.max(...xs) - Math.min(...xs)) * this.scale, h = (Math.max(...zs) - Math.min(...zs)) * this.scale; + if (w < 46 || h < 30) continue; + const s = this.toScreen(c.label_x, c.label_z); + if (s.x < -80 || s.y < -40 || s.x > this.width + 80 || s.y > this.height + 40) continue; + const showName = w > 150 && h > 50; + this.chip(ctx, s.x, s.y, showName ? `${c.tag ? `[${c.tag}] ` : ''}${c.name}` : c.tag || c.name, c.color, c.icon); + } + } + + private drawPins(ctx: CanvasRenderingContext2D) { + const slug = this.visibleSlug(); + const picked = this.selected?.type === 'pin' ? this.selected.pin.id : null; + for (const p of this.overlay.pins) { + if (dimSlug(p.dimension) !== slug) continue; + const s = this.toScreen(p.x, p.z); + if (s.x < -30 || s.y < -30 || s.x > this.width + 30 || s.y > this.height + 30) continue; + const hot = p.id === this.hoverId || p.id === picked; + const color = PIN_COLORS[p.kind] ?? '#94a3b8'; + const r = hot ? 14 : 12; + ctx.save(); + ctx.shadowColor = 'rgba(0,0,0,0.5)'; ctx.shadowBlur = 8; ctx.shadowOffsetY = 2; + ctx.beginPath(); ctx.arc(s.x, s.y, r, 0, Math.PI * 2); + ctx.fillStyle = color; ctx.fill(); + ctx.restore(); + ctx.lineWidth = 2; ctx.strokeStyle = '#fff'; ctx.stroke(); + drawGlyph(ctx, p.kind, s.x, s.y, r * 0.58); + if (hot || this.scale >= 0.9) this.tag(ctx, s.x, s.y + r + 9, p.label); + } + } + + private drawPlayers(ctx: CanvasRenderingContext2D) { + const slug = this.visibleSlug(); + const picked = this.selected?.type === 'player' ? this.selected.player.uuid : null; + for (const p of this.overlay.players) { + if (dimSlug(p.dimension) !== slug) continue; + const s = this.toScreen(p.x, p.z); + if (s.x < -30 || s.y < -30 || s.x > this.width + 30 || s.y > this.height + 30) continue; + const me = !!this.opts.selfUuid && p.uuid.toLowerCase() === this.opts.selfUuid.toLowerCase(); + const hot = p.uuid === this.hoverId || p.uuid === picked; + const ring = me ? this.theme.self : this.theme.accent; + const r = hot ? 14 : 12; + // facing direction: Minecraft yaw 0 = south (+z), 90 = west (-x) + const a = (p.yaw * Math.PI) / 180; + const fx = -Math.sin(a), fz = Math.cos(a); + ctx.save(); + ctx.translate(s.x, s.y); + ctx.rotate(Math.atan2(fz, fx)); + ctx.beginPath(); ctx.moveTo(r + 9, 0); ctx.lineTo(r + 1, -5); ctx.lineTo(r + 1, 5); ctx.closePath(); + ctx.fillStyle = ring; ctx.fill(); + ctx.restore(); + ctx.save(); + ctx.shadowColor = 'rgba(0,0,0,0.55)'; ctx.shadowBlur = 8; ctx.shadowOffsetY = 2; + ctx.beginPath(); ctx.arc(s.x, s.y, r + 2, 0, Math.PI * 2); + ctx.fillStyle = ring; ctx.fill(); + ctx.restore(); + const img = this.opts.avatarUrl ? this.icon(this.opts.avatarUrl(p.uuid)) : null; + ctx.save(); + ctx.beginPath(); ctx.arc(s.x, s.y, r, 0, Math.PI * 2); ctx.clip(); + if (img) { ctx.imageSmoothingEnabled = false; ctx.drawImage(img, s.x - r, s.y - r, r * 2, r * 2); } + else { ctx.fillStyle = '#1e293b'; ctx.fillRect(s.x - r, s.y - r, r * 2, r * 2); ctx.fillStyle = '#fff'; ctx.font = '700 12px system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText((p.name[0] ?? '?').toUpperCase(), s.x, s.y + 1); } + ctx.restore(); + this.tag(ctx, s.x, s.y - r - 12, p.name, me ? this.theme.self : undefined); + } + } + + private icon(url: string): HTMLImageElement | null { + if (!url) return null; + const got = this.icons.get(url); + if (got === 'failed') return null; + if (got) return got.complete && got.naturalWidth ? got : null; + const img = new Image(); + img.onload = () => this.invalidate(); + img.onerror = () => this.icons.set(url, 'failed'); + img.src = url; + this.icons.set(url, img); + return null; + } + + /** A small rounded label with an optional round icon, centred on (x, y). */ + private chip(ctx: CanvasRenderingContext2D, x: number, y: number, text: string, color: string, iconUrl: string) { + ctx.font = '700 12px system-ui, -apple-system, Segoe UI, sans-serif'; + const img = iconUrl ? this.icon(iconUrl) : null; + const iconW = img ? 20 : 0; + const w = ctx.measureText(text).width + 18 + iconW, h = 24; + ctx.save(); + ctx.shadowColor = 'rgba(0,0,0,0.45)'; ctx.shadowBlur = 8; ctx.shadowOffsetY = 2; + roundRect(ctx, x - w / 2, y - h / 2, w, h, 12); + ctx.fillStyle = this.theme.labelBg; ctx.fill(); + ctx.restore(); + ctx.lineWidth = 1.5; ctx.strokeStyle = withAlpha(color, 0.9); ctx.stroke(); + let left = x - w / 2 + 9; + if (img) { + ctx.save(); ctx.beginPath(); ctx.arc(left + 8, y, 8, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(img, left, y - 8, 16, 16); ctx.restore(); + left += 20; + } + ctx.fillStyle = this.theme.label; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText(text, left, y + 0.5); + } + + private tag(ctx: CanvasRenderingContext2D, x: number, y: number, text: string, color?: string) { + ctx.font = '600 11px system-ui, -apple-system, Segoe UI, sans-serif'; + const w = ctx.measureText(text).width + 12; + roundRect(ctx, x - w / 2, y - 9, w, 18, 9); + ctx.fillStyle = this.theme.labelBg; ctx.fill(); + if (color) { ctx.lineWidth = 1; ctx.strokeStyle = color; ctx.stroke(); } + ctx.fillStyle = this.theme.label; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(text, x, y + 0.5); + } +} + +// ---- helpers -------------------------------------------------------------------------------------------- + +function clamp(v: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, v)); } + +/** Zoom-0 tile bounds -> world block bounds [minX, maxX, minZ, maxZ]. */ +function worldBounds(b: MapBounds): [number, number, number, number] { + return [b.min_x * TILE, (b.max_x + 1) * TILE, b.min_y * TILE, (b.max_y + 1) * TILE]; +} + +function hitId(s: MapSelection): string { return s.type === 'player' ? s.player.uuid : s.type === 'pin' ? s.pin.id : s.claim.id; } +function hitLabel(s: MapSelection): string { return s.type === 'player' ? s.player.name : s.type === 'pin' ? s.pin.label : `${s.claim.tag ? `[${s.claim.tag}] ` : ''}${s.claim.name}`; } + +function inside(ring: [number, number][], x: number, z: number): boolean { + let in_ = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const [ax, az] = ring[i], [bx, bz] = ring[j]; + if ((az > z) !== (bz > z) && x < ((bx - ax) * (z - az)) / (bz - az) + ax) in_ = !in_; + } + return in_; +} + +export function withAlpha(color: string, alpha: number): string { + const m = /^#?([0-9a-f]{6})$/i.exec(color.trim()); + if (!m) return color; + const n = parseInt(m[1], 16); + return `rgba(${n >> 16}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`; +} + +function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +/** A tiny white symbol for each kind of pin, drawn as shapes so no icon font is needed. */ +function drawGlyph(ctx: CanvasRenderingContext2D, kind: string, x: number, y: number, r: number) { + ctx.save(); + ctx.translate(x, y); + ctx.fillStyle = '#fff'; + ctx.strokeStyle = '#fff'; + ctx.beginPath(); + switch (kind) { + case 'spawn': // star + for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + (i * Math.PI) / 5, rr = i % 2 ? r * 0.45 : r * 1.15; ctx.lineTo(Math.cos(a) * rr, Math.sin(a) * rr); } + ctx.closePath(); ctx.fill(); + break; + case 'warp': // diamond with a hole + ctx.moveTo(0, -r * 1.2); ctx.lineTo(r * 0.95, 0); ctx.lineTo(0, r * 1.2); ctx.lineTo(-r * 0.95, 0); ctx.closePath(); ctx.fill(); + break; + case 'home': // house + ctx.moveTo(-r * 1.1, 0); ctx.lineTo(0, -r * 1.1); ctx.lineTo(r * 1.1, 0); ctx.lineTo(r * 0.7, 0); ctx.lineTo(r * 0.7, r); ctx.lineTo(-r * 0.7, r); ctx.lineTo(-r * 0.7, 0); ctx.closePath(); ctx.fill(); + break; + case 'guild_home': // flag + ctx.fillRect(-r * 0.8, -r * 1.1, r * 0.28, r * 2.2); + ctx.moveTo(-r * 0.52, -r * 1.1); ctx.lineTo(r * 1.0, -r * 0.5); ctx.lineTo(-r * 0.52, 0.1 * r); ctx.closePath(); ctx.fill(); + break; + case 'market': // $ + ctx.font = `800 ${Math.round(r * 2.3)}px system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('$', 0, r * 0.1); + break; + default: // shop: a bag + ctx.moveTo(-r * 0.9, -r * 0.3); ctx.lineTo(r * 0.9, -r * 0.3); ctx.lineTo(r * 0.7, r * 1.0); ctx.lineTo(-r * 0.7, r * 1.0); ctx.closePath(); ctx.fill(); + ctx.lineWidth = Math.max(1.2, r * 0.22); ctx.beginPath(); ctx.arc(0, -r * 0.3, r * 0.5, Math.PI, 0); ctx.stroke(); + } + ctx.restore(); +} diff --git a/shared/map/feed.ts b/shared/map/feed.ts new file mode 100644 index 0000000..b91d192 --- /dev/null +++ b/shared/map/feed.ts @@ -0,0 +1,51 @@ +import type { MapInfo, MapOverlay } from './types'; + +export interface FeedOptions { + loadInfo: () => Promise; + loadOverlay: () => Promise; + onInfo: (info: MapInfo) => void; + onOverlay: (overlay: MapOverlay) => void; + onError?: (message: string) => void; + /** How often players move on the map. */ + overlayMs?: number; +} + +/** + * Keeps a map fresh: the overlay (players, claims, pins) every couple of seconds while the window is visible, and the info + * (which carries the tile key) when it is about to expire. Returns a function that stops everything. + */ +export function startFeed(o: FeedOptions): () => void { + let stopped = false; + let infoTimer: ReturnType | undefined; + let overlayTimer: ReturnType | undefined; + let ready = false; + + const fail = (e: unknown) => o.onError?.(typeof e === 'string' ? e : (e as { message?: string })?.message ?? 'Could not reach the map'); + + async function info() { + try { + const next = await o.loadInfo(); + if (stopped) return; + ready = next.ready; + o.onInfo(next); + // The tile key is good for an hour: renew it with plenty of time to spare. While waiting for the first tiles, check often. + const wait = next.ready ? Math.max(30, Math.min(next.token_ttl_secs * 0.6, 1200)) * 1000 : 8000; + infoTimer = setTimeout(info, wait); + } catch (e) { + fail(e); + if (!stopped) infoTimer = setTimeout(info, 10000); + } + } + + async function overlay() { + if (stopped) return; + if (ready && !document.hidden) { + try { o.onOverlay(await o.loadOverlay()); } catch (e) { fail(e); } + } + if (!stopped) overlayTimer = setTimeout(overlay, o.overlayMs ?? 2500); + } + + info(); + overlayTimer = setTimeout(overlay, 600); + return () => { stopped = true; clearTimeout(infoTimer); clearTimeout(overlayTimer); }; +} diff --git a/shared/map/index.ts b/shared/map/index.ts new file mode 100644 index 0000000..f9f1dfd --- /dev/null +++ b/shared/map/index.ts @@ -0,0 +1,6 @@ +export { MapView, withAlpha } from './engine'; +export type { MapViewOptions, Theme } from './engine'; +export { startFeed } from './feed'; +export type { FeedOptions } from './feed'; +export { dimSlug } from './types'; +export type * from './types'; diff --git a/shared/map/types.ts b/shared/map/types.ts new file mode 100644 index 0000000..e202313 --- /dev/null +++ b/shared/map/types.ts @@ -0,0 +1,89 @@ +// What the panel's map endpoints return (see panel/server/src/routes/worldmap.rs), shared by the launcher and the admin panel. + +export interface MapBounds { min_x: number; max_x: number; min_y: number; max_y: number } + +export interface MapDimension { + id: string; + slug: string; + label: string; + available: boolean; + tiles: number; + bytes: number; + bounds: MapBounds | null; +} + +export interface MapInfo { + enabled: boolean; + ready: boolean; + message: string; + tile_size: number; + max_zoom: number; + /** Where tiles live, e.g. `https://panel.example.com/api/map/3`. */ + tile_base: string; + /** Short-lived key that goes on each tile address. */ + token: string | null; + token_ttl_secs: number; + dimensions: MapDimension[]; + players: number; +} + +export interface LivePlayer { + uuid: string; + name: string; + dimension: string; + x: number; + y: number; + z: number; + yaw: number; +} + +export interface MapClaim { + id: string; + guild_id: string; + name: string; + tag: string; + icon: string; + color: string; + dimension: string; + chunks: number; + label_x: number; + label_z: number; + outer: [number, number][]; + holes: [number, number][][]; + lines: string[]; +} + +export type PinKind = 'spawn' | 'warp' | 'home' | 'guild_home' | 'market' | 'shop'; + +export interface MapPin { + id: string; + kind: PinKind | string; + label: string; + dimension: string; + x: number; + y: number; + z: number; + lines: string[]; +} + +export interface MapOverlay { + players: LivePlayer[]; + claims: MapClaim[]; + pins: MapPin[]; + updated?: string | null; +} + +export type MapSelection = + | { type: 'player'; player: LivePlayer } + | { type: 'pin'; pin: MapPin } + | { type: 'claim'; claim: MapClaim }; + +export interface MapLayers { claims: boolean; pins: boolean; players: boolean } + +/** Same naming as the panel: `minecraft:the_nether` -> `the_nether`, `mod:dim` -> `mod__dim`. */ +export function dimSlug(id: string): string { + const trimmed = id.startsWith('minecraft:') ? id.slice(10) : id; + if (trimmed === 'nether') return 'the_nether'; + if (trimmed === 'end') return 'the_end'; + return trimmed.replace(':', '__'); +}