"use client"; import { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Loader2, Plus, Minus, RotateCcw, Search, Layers, } from "lucide-react"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { useMaps } from "@/hooks/use-game-data"; import { useCharacters } from "@/hooks/use-characters"; import type { MapTile, Character } from "@/lib/types"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const CONTENT_COLORS: Record = { monster: "#ef4444", monsters: "#ef4444", resource: "#22c55e", resources: "#22c55e", bank: "#3b82f6", grand_exchange: "#f59e0b", workshop: "#a855f7", npc: "#06b6d4", tasks_master: "#ec4899", }; const EMPTY_COLOR = "#1f2937"; const CHARACTER_COLOR = "#facc15"; const BASE_CELL_SIZE = 40; const IMAGE_BASE = "https://artifactsmmo.com/images"; const LEGEND_ITEMS = [ { label: "Monsters", color: "#ef4444", key: "monster" }, { label: "Resources", color: "#22c55e", key: "resource" }, { label: "Bank", color: "#3b82f6", key: "bank" }, { label: "Grand Exchange", color: "#f59e0b", key: "grand_exchange" }, { label: "Workshop", color: "#a855f7", key: "workshop" }, { label: "NPC", color: "#06b6d4", key: "npc" }, { label: "Tasks Master", color: "#ec4899", key: "tasks_master" }, { label: "Empty", color: "#1f2937", key: "empty" }, { label: "Character", color: "#facc15", key: "character" }, ]; const LAYER_OPTIONS = [ { key: "overworld", label: "Overworld" }, { key: "underground", label: "Underground" }, { key: "interior", label: "Interior" }, ] as const; // Content types that have images on the CDN const IMAGE_CONTENT_TYPES = new Set([ "monster", "monsters", "resource", "resources", "npc", ]); // --------------------------------------------------------------------------- // Module-level image cache (persists across re-renders) // --------------------------------------------------------------------------- const imageCache = new Map(); function loadImage(url: string): Promise { const cached = imageCache.get(url); if (cached) return Promise.resolve(cached); return new Promise((resolve, reject) => { const img = new Image(); // Note: do NOT set crossOrigin – the CDN doesn't send CORS headers, // and we only need to draw the images on canvas (not read pixel data). img.onload = () => { imageCache.set(url, img); resolve(img); }; img.onerror = reject; img.src = url; }); } function skinUrl(skin: string): string { return `${IMAGE_BASE}/maps/${skin}.png`; } function contentIconUrl(type: string, code: string): string { const normalizedType = type === "monsters" ? "monster" : type === "resources" ? "resource" : type; // CDN path uses plural folder names const folder = normalizedType === "monster" ? "monsters" : normalizedType === "resource" ? "resources" : "npcs"; return `${IMAGE_BASE}/${folder}/${code}.png`; } function getTileColor(tile: MapTile): string { if (!tile.content?.type) return EMPTY_COLOR; return CONTENT_COLORS[tile.content.type] ?? EMPTY_COLOR; } function normalizeContentType(type: string): string { if (type === "monsters") return "monster"; if (type === "resources") return "resource"; return type; } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- interface SelectedTile { tile: MapTile; characters: Character[]; } interface SearchResult { tile: MapTile; label: string; } export default function MapPage() { const { data: tiles, isLoading, error } = useMaps(); const { data: characters } = useCharacters(); const canvasRef = useRef(null); const containerRef = useRef(null); const minimapCanvasRef = useRef(null); const tooltipRef = useRef(null); const searchInputRef = useRef(null); const hoveredKeyRef = useRef(""); const rafRef = useRef(0); const dragDistRef = useRef(0); const [zoom, setZoom] = useState(0.5); const [offset, setOffset] = useState({ x: 0, y: 0 }); const [dragging, setDragging] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [selectedTile, setSelectedTile] = useState(null); const [imagesLoaded, setImagesLoaded] = useState(false); const [imageLoadProgress, setImageLoadProgress] = useState({ loaded: 0, total: 0 }); const [activeLayer, setActiveLayer] = useState("overworld"); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); const [searchOpen, setSearchOpen] = useState(false); const [filters, setFilters] = useState>({ monster: true, resource: true, bank: true, grand_exchange: true, workshop: true, npc: true, tasks_master: true, empty: true, character: true, }); const cellSize = BASE_CELL_SIZE * zoom; // ---- Computed data ---- // Filter tiles by active layer const layerTiles = useMemo(() => { if (!tiles) return []; return tiles.filter((t) => t.layer === activeLayer); }, [tiles, activeLayer]); const bounds = useMemo(() => { if (layerTiles.length === 0) return { minX: 0, maxX: 0, minY: 0, maxY: 0 }; let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (const tile of layerTiles) { if (tile.x < minX) minX = tile.x; if (tile.x > maxX) maxX = tile.x; if (tile.y < minY) minY = tile.y; if (tile.y > maxY) maxY = tile.y; } return { minX, maxX, minY, maxY }; }, [layerTiles]); const tileMap = useMemo(() => { const map = new Map(); for (const tile of layerTiles) map.set(`${tile.x},${tile.y}`, tile); return map; }, [layerTiles]); const charPositions = useMemo(() => { if (!characters) return new Map(); const map = new Map(); for (const char of characters) { const key = `${char.x},${char.y}`; if (!map.has(key)) map.set(key, []); map.get(key)!.push(char); } return map; }, [characters]); // ---- Image preloading ---- useEffect(() => { if (!tiles || tiles.length === 0) return; const urls = new Set(); for (const tile of tiles) { if (tile.skin) urls.add(skinUrl(tile.skin)); if (tile.content && IMAGE_CONTENT_TYPES.has(tile.content.type)) { urls.add(contentIconUrl(tile.content.type, tile.content.code)); } } if (urls.size === 0) { setImagesLoaded(true); return; } const total = urls.size; let loaded = 0; setImageLoadProgress({ loaded: 0, total }); const promises = [...urls].map((url) => loadImage(url) .then(() => { loaded++; setImageLoadProgress({ loaded, total }); }) .catch(() => { loaded++; setImageLoadProgress({ loaded, total }); }) ); Promise.allSettled(promises).then(() => setImagesLoaded(true)); }, [tiles]); // ---- Canvas drawing ---- const drawMap = useCallback(() => { const canvas = canvasRef.current; const container = containerRef.current; if (!canvas || !container || layerTiles.length === 0) return; const dpr = window.devicePixelRatio || 1; const width = container.clientWidth; const height = container.clientHeight; canvas.width = width * dpr; canvas.height = height * dpr; canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.scale(dpr, dpr); ctx.fillStyle = "#0f172a"; ctx.fillRect(0, 0, width, height); const gridWidth = bounds.maxX - bounds.minX + 1; const gridHeight = bounds.maxY - bounds.minY + 1; const centerOffsetX = (width - gridWidth * cellSize) / 2 + offset.x; const centerOffsetY = (height - gridHeight * cellSize) / 2 + offset.y; // Draw tiles for (const tile of layerTiles) { const contentType = tile.content?.type ?? "empty"; const normalizedType = normalizeContentType(contentType); if (!filters[normalizedType] && normalizedType !== "empty") continue; if (normalizedType === "empty" && !filters.empty) continue; const px = (tile.x - bounds.minX) * cellSize + centerOffsetX; const py = (tile.y - bounds.minY) * cellSize + centerOffsetY; // Cull off-screen tiles if (px + cellSize < 0 || py + cellSize < 0 || px > width || py > height) continue; // Draw skin image – seamless tiles, no gaps const skinImg = tile.skin ? imageCache.get(skinUrl(tile.skin)) : null; if (skinImg) { ctx.drawImage(skinImg, px, py, cellSize, cellSize); } else { // Fallback: dark ground color for tiles without skin images ctx.fillStyle = EMPTY_COLOR; ctx.fillRect(px, py, cellSize, cellSize); } // Content icon overlay if (tile.content) { if (IMAGE_CONTENT_TYPES.has(tile.content.type)) { const iconImg = imageCache.get( contentIconUrl(tile.content.type, tile.content.code) ); if (iconImg) { const iconSize = cellSize * 0.6; const iconX = px + (cellSize - iconSize) / 2; const iconY = py + (cellSize - iconSize) / 2; // Drop shadow for icon readability ctx.shadowColor = "rgba(0,0,0,0.5)"; ctx.shadowBlur = 3; ctx.drawImage(iconImg, iconX, iconY, iconSize, iconSize); ctx.shadowColor = "transparent"; ctx.shadowBlur = 0; } } else { // For bank/workshop/etc – small colored badge in bottom-right const badgeSize = Math.max(6, cellSize * 0.25); const badgeX = px + cellSize - badgeSize - 2; const badgeY = py + cellSize - badgeSize - 2; ctx.fillStyle = "rgba(0,0,0,0.5)"; ctx.beginPath(); ctx.arc(badgeX + badgeSize / 2, badgeY + badgeSize / 2, badgeSize / 2 + 1, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = CONTENT_COLORS[tile.content.type] ?? "#fff"; ctx.beginPath(); ctx.arc(badgeX + badgeSize / 2, badgeY + badgeSize / 2, badgeSize / 2, 0, Math.PI * 2); ctx.fill(); } } // Selected tile highlight – bright outline if ( selectedTile && tile.x === selectedTile.tile.x && tile.y === selectedTile.tile.y ) { ctx.strokeStyle = "#3b82f6"; ctx.lineWidth = 2.5; ctx.strokeRect(px + 1, py + 1, cellSize - 2, cellSize - 2); } // Coordinate text at high zoom – text shadow for readability on tile images if (cellSize >= 60) { const fontSize = Math.max(8, cellSize * 0.18); ctx.font = `${fontSize}px sans-serif`; ctx.textAlign = "left"; ctx.textBaseline = "top"; ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fillText(`${tile.x},${tile.y}`, px + 3, py + 3); ctx.fillStyle = "rgba(255,255,255,0.75)"; ctx.fillText(`${tile.x},${tile.y}`, px + 2, py + 2); } // Tile labels at very high zoom if (cellSize >= 80 && (tile.name || tile.content?.code)) { const nameSize = Math.max(9, cellSize * 0.16); ctx.font = `bold ${nameSize}px sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "bottom"; if (tile.name) { // Text with shadow ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.fillText(tile.name, px + cellSize / 2 + 1, py + cellSize - 1); ctx.fillStyle = "rgba(255,255,255,0.9)"; ctx.fillText(tile.name, px + cellSize / 2, py + cellSize - 2); } if (tile.content?.code) { const codeSize = Math.max(8, cellSize * 0.13); ctx.font = `${codeSize}px sans-serif`; ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fillText( tile.content.code, px + cellSize / 2 + 1, py + cellSize - 1 - Math.max(10, cellSize * 0.18) ); ctx.fillStyle = "rgba(255,255,255,0.75)"; ctx.fillText( tile.content.code, px + cellSize / 2, py + cellSize - 2 - Math.max(10, cellSize * 0.18) ); } } } // Draw character markers if (filters.character && characters) { for (const char of characters) { const px = (char.x - bounds.minX) * cellSize + centerOffsetX + cellSize / 2; const py = (char.y - bounds.minY) * cellSize + centerOffsetY + cellSize / 2; if (px < -20 || py < -20 || px > width + 20 || py > height + 20) continue; const radius = Math.max(4, cellSize / 3); // Outer glow ctx.beginPath(); ctx.arc(px, py, radius + 2, 0, Math.PI * 2); ctx.fillStyle = "rgba(250, 204, 21, 0.3)"; ctx.fill(); // Inner dot ctx.beginPath(); ctx.arc(px, py, radius, 0, Math.PI * 2); ctx.fillStyle = CHARACTER_COLOR; ctx.fill(); ctx.strokeStyle = "#000"; ctx.lineWidth = 1; ctx.stroke(); // Label with shadow for readability on tile images if (cellSize >= 14) { const labelFont = `bold ${Math.max(9, cellSize * 0.4)}px sans-serif`; ctx.font = labelFont; ctx.textAlign = "center"; ctx.textBaseline = "bottom"; ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.fillText(char.name, px + 1, py - radius - 2); ctx.fillStyle = "#fff"; ctx.fillText(char.name, px, py - radius - 3); } } } }, [layerTiles, characters, bounds, cellSize, offset, filters, selectedTile, imagesLoaded]); // ---- Minimap ---- const drawMinimap = useCallback(() => { const minimapCanvas = minimapCanvasRef.current; const container = containerRef.current; if (!minimapCanvas || !container || layerTiles.length === 0) return; const size = 150; const dpr = window.devicePixelRatio || 1; minimapCanvas.width = size * dpr; minimapCanvas.height = size * dpr; minimapCanvas.style.width = `${size}px`; minimapCanvas.style.height = `${size}px`; const ctx = minimapCanvas.getContext("2d"); if (!ctx) return; ctx.scale(dpr, dpr); ctx.fillStyle = "#0f172a"; ctx.fillRect(0, 0, size, size); const gridWidth = bounds.maxX - bounds.minX + 1; const gridHeight = bounds.maxY - bounds.minY + 1; const miniCellW = size / gridWidth; const miniCellH = size / gridHeight; for (const tile of layerTiles) { const mx = (tile.x - bounds.minX) * miniCellW; const my = (tile.y - bounds.minY) * miniCellH; // Use skin image color or fallback ctx.fillStyle = getTileColor(tile); if (tile.skin && imageCache.get(skinUrl(tile.skin))) { // Approximate dominant color for minimap – just draw the image tiny ctx.drawImage( imageCache.get(skinUrl(tile.skin))!, mx, my, Math.max(1, miniCellW), Math.max(1, miniCellH) ); } else { ctx.fillRect(mx, my, Math.max(1, miniCellW), Math.max(1, miniCellH)); } } // Character dots if (characters) { ctx.fillStyle = CHARACTER_COLOR; for (const char of characters) { const cx = (char.x - bounds.minX) * miniCellW + miniCellW / 2; const cy = (char.y - bounds.minY) * miniCellH + miniCellH / 2; ctx.beginPath(); ctx.arc(cx, cy, 2, 0, Math.PI * 2); ctx.fill(); } } // Viewport rectangle const containerWidth = container.clientWidth; const containerHeight = container.clientHeight; const mainCenterX = (containerWidth - gridWidth * cellSize) / 2 + offset.x; const mainCenterY = (containerHeight - gridHeight * cellSize) / 2 + offset.y; // Convert main canvas viewport to grid coordinates const viewMinX = -mainCenterX / cellSize; const viewMinY = -mainCenterY / cellSize; const viewW = containerWidth / cellSize; const viewH = containerHeight / cellSize; ctx.strokeStyle = "#3b82f6"; ctx.lineWidth = 1.5; ctx.strokeRect( viewMinX * miniCellW, viewMinY * miniCellH, viewW * miniCellW, viewH * miniCellH ); }, [layerTiles, characters, bounds, cellSize, offset]); // ---- RAF draw scheduling ---- const scheduleDrawRef = useRef<() => void>(null); scheduleDrawRef.current = () => { cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(() => { drawMap(); drawMinimap(); }); }; const scheduleDraw = useCallback(() => { scheduleDrawRef.current?.(); }, []); useEffect(() => { scheduleDraw(); }, [drawMap, drawMinimap, scheduleDraw]); useEffect(() => { const container = containerRef.current; if (!container) return; const observer = new ResizeObserver(() => scheduleDraw()); observer.observe(container); return () => observer.disconnect(); }, [scheduleDraw]); // ---- Search ---- useEffect(() => { if (!searchQuery.trim() || layerTiles.length === 0) { setSearchResults([]); return; } const q = searchQuery.trim().toLowerCase(); // Try coordinate parse: "3, -5" or "3 -5" const coordMatch = q.match(/^(-?\d+)[,\s]+(-?\d+)$/); if (coordMatch) { const cx = parseInt(coordMatch[1]); const cy = parseInt(coordMatch[2]); const tile = tileMap.get(`${cx},${cy}`); if (tile) { setSearchResults([ { tile, label: `(${cx}, ${cy}) ${tile.name || "Unknown"}` }, ]); } else { setSearchResults([]); } return; } // Text search on name and content code const results: SearchResult[] = []; for (const tile of layerTiles) { if (results.length >= 20) break; const nameMatch = tile.name?.toLowerCase().includes(q); const codeMatch = tile.content?.code?.toLowerCase().includes(q); if (nameMatch || codeMatch) { results.push({ tile, label: `(${tile.x}, ${tile.y}) ${tile.name || tile.content?.code || "Unknown"}`, }); } } setSearchResults(results); }, [searchQuery, layerTiles, tileMap]); function jumpToTile(tile: MapTile) { const container = containerRef.current; if (!container) return; const gridWidth = bounds.maxX - bounds.minX + 1; const gridHeight = bounds.maxY - bounds.minY + 1; const containerWidth = container.clientWidth; const containerHeight = container.clientHeight; // Center the tile in view const tilePxX = (tile.x - bounds.minX) * cellSize + cellSize / 2; const tilePxY = (tile.y - bounds.minY) * cellSize + cellSize / 2; const gridOriginX = (containerWidth - gridWidth * cellSize) / 2; const gridOriginY = (containerHeight - gridHeight * cellSize) / 2; setOffset({ x: containerWidth / 2 - gridOriginX - tilePxX, y: containerHeight / 2 - gridOriginY - tilePxY, }); const charsOnTile = charPositions.get(`${tile.x},${tile.y}`) ?? []; setSelectedTile({ tile, characters: charsOnTile }); setSearchOpen(false); setSearchQuery(""); } // ---- Mouse events ---- function handleCanvasClick(e: React.MouseEvent) { // Ignore clicks after dragging if (dragDistRef.current > 5) return; const canvas = canvasRef.current; const container = containerRef.current; if (!canvas || !container || layerTiles.length === 0) return; const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const gridWidth = bounds.maxX - bounds.minX + 1; const gridHeight = bounds.maxY - bounds.minY + 1; const centerOffsetX = (container.clientWidth - gridWidth * cellSize) / 2 + offset.x; const centerOffsetY = (container.clientHeight - gridHeight * cellSize) / 2 + offset.y; const tileX = Math.floor((mx - centerOffsetX) / cellSize) + bounds.minX; const tileY = Math.floor((my - centerOffsetY) / cellSize) + bounds.minY; const key = `${tileX},${tileY}`; const tile = tileMap.get(key); if (tile) { const charsOnTile = charPositions.get(key) ?? []; setSelectedTile({ tile, characters: charsOnTile }); } else { setSelectedTile(null); } } function handleMouseDown(e: React.MouseEvent) { if (e.button !== 0) return; setDragging(true); setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y }); dragDistRef.current = 0; } function handleMouseMove(e: React.MouseEvent) { // Tooltip handling updateTooltip(e); if (!dragging) return; const dx = e.clientX - dragStart.x - offset.x; const dy = e.clientY - dragStart.y - offset.y; dragDistRef.current += Math.abs(dx) + Math.abs(dy); setOffset({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y, }); } function handleMouseUp() { setDragging(false); } function handleMouseLeave() { setDragging(false); if (tooltipRef.current) { tooltipRef.current.style.display = "none"; } hoveredKeyRef.current = ""; } function updateTooltip(e: React.MouseEvent) { const canvas = canvasRef.current; const container = containerRef.current; const tooltip = tooltipRef.current; if (!canvas || !container || !tooltip || layerTiles.length === 0) return; const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const gridWidth = bounds.maxX - bounds.minX + 1; const gridHeight = bounds.maxY - bounds.minY + 1; const centerOffsetX = (container.clientWidth - gridWidth * cellSize) / 2 + offset.x; const centerOffsetY = (container.clientHeight - gridHeight * cellSize) / 2 + offset.y; const tileX = Math.floor((mx - centerOffsetX) / cellSize) + bounds.minX; const tileY = Math.floor((my - centerOffsetY) / cellSize) + bounds.minY; const key = `${tileX},${tileY}`; // Skip re-render if same tile if (key === hoveredKeyRef.current) { tooltip.style.left = `${e.clientX - rect.left + 12}px`; tooltip.style.top = `${e.clientY - rect.top + 12}px`; return; } hoveredKeyRef.current = key; const tile = tileMap.get(key); if (!tile) { tooltip.style.display = "none"; return; } const charsOnTile = charPositions.get(key); let html = `
${tile.name || "Unknown"}
`; html += `
(${tile.x}, ${tile.y})
`; if (tile.content) { html += `
${tile.content.type}: ${tile.content.code}
`; } if (charsOnTile && charsOnTile.length > 0) { html += `
${charsOnTile.map((c) => c.name).join(", ")}
`; } tooltip.innerHTML = html; tooltip.style.display = "block"; tooltip.style.left = `${e.clientX - rect.left + 12}px`; tooltip.style.top = `${e.clientY - rect.top + 12}px`; } // ---- Zoom toward cursor ---- function handleWheel(e: React.WheelEvent) { e.preventDefault(); const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const factor = e.deltaY > 0 ? 0.9 : 1.1; const newZoom = Math.max(0.1, Math.min(5, zoom * factor)); const scale = newZoom / zoom; // Adjust offset so the point under cursor stays fixed setOffset((prev) => ({ x: mouseX - scale * (mouseX - prev.x), y: mouseY - scale * (mouseY - prev.y), })); setZoom(newZoom); } function resetView() { setZoom(0.5); setOffset({ x: 0, y: 0 }); } function toggleFilter(key: string) { setFilters((prev) => ({ ...prev, [key]: !prev[key] })); } // ---- Keyboard shortcuts ---- useEffect(() => { function handleKeyDown(e: KeyboardEvent) { // Don't intercept if typing in an input if ( e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement ) { if (e.key === "Escape") { setSearchOpen(false); setSearchQuery(""); (e.target as HTMLElement).blur(); } return; } switch (e.key) { case "+": case "=": e.preventDefault(); setZoom((z) => Math.min(5, z * 1.15)); break; case "-": e.preventDefault(); setZoom((z) => Math.max(0.1, z * 0.87)); break; case "0": e.preventDefault(); resetView(); break; case "Escape": setSelectedTile(null); setSearchOpen(false); setSearchQuery(""); break; case "/": e.preventDefault(); setSearchOpen(true); setTimeout(() => searchInputRef.current?.focus(), 0); break; } } window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, []); // ---- Render ---- return (

World Map

Interactive map of the game world. Click tiles for details, drag to pan, scroll to zoom. Press{" "} / {" "} to search.

{/* Search */}
setSearchOpen(true)} onChange={(e) => { setSearchQuery(e.target.value); setSearchOpen(true); }} />
{searchOpen && searchResults.length > 0 && (
{searchResults.map((r) => ( ))}
)}
{error && (

Failed to load map data. Make sure the backend is running.

)} {/* Layer selector + Filters */}
{LAYER_OPTIONS.map((layer) => ( ))}
{LEGEND_ITEMS.map((item) => ( ))}
{/* Map container */}
{isLoading && (
)} {/* Image loading progress */} {!imagesLoaded && imageLoadProgress.total > 0 && (

Loading map images... {imageLoadProgress.loaded}/ {imageLoadProgress.total}

)} {/* Hover tooltip */}
{/* Minimap */} {/* Zoom controls */}
{/* Coordinate display + zoom level */}
{Math.round(zoom * 100)}%
{/* Side panel */} {selectedTile && (

{selectedTile.tile.name}

{/* Tile skin image */} {selectedTile.tile.skin && (
{/* eslint-disable-next-line @next/next/no-img-element */} {selectedTile.tile.name}
)}
Position

({selectedTile.tile.x}, {selectedTile.tile.y})

{selectedTile.tile.content && (
Content
{selectedTile.tile.content.type}

{selectedTile.tile.content.code}

{/* Content icon image */} {IMAGE_CONTENT_TYPES.has(selectedTile.tile.content.type) && (
{/* eslint-disable-next-line @next/next/no-img-element */} {selectedTile.tile.content.code}
)}
)} {!selectedTile.tile.content && (
Content

Empty tile

)} {selectedTile.characters.length > 0 && (
Characters
{selectedTile.characters.map((char) => ( {char.name} (Lv. {char.level}) ))}
)}
)}
); }