Add terrain minimap to dashboard

This commit is contained in:
Tanner
2026-11-08 23:46:52 +00:00
parent 20e53537b1
commit 2d5bf2df22
7 changed files with 418 additions and 8 deletions
+79
View File
@@ -8,6 +8,7 @@ import threading
import time import time
import traceback import traceback
import sys import sys
from math import floor
from pathlib import Path from pathlib import Path
import logging import logging
@@ -158,6 +159,84 @@ def api_global():
return response return response
def _minimap_surface_cell(chunks, x, z):
"""Return the uppermost solid/water block in a loaded world column."""
from mosfet.info import blocks
chunk_x, chunk_z = floor(x / 16), floor(z / 16)
chunk = chunks.chunks.get((chunk_x, chunk_z))
if chunk is None:
return None
local_x, local_z = x - chunk_x * 16, z - chunk_z * 16
min_y = getattr(chunks, 'min_y', -64)
for section_y in sorted(chunk.sections, reverse=True):
if section_y * 16 + 15 < min_y:
continue
section = chunk.sections[section_y]
if section.get('non_empty_blocks') == 0:
continue
for y in range(section_y * 16 + 15, max(min_y, section_y * 16) - 1, -1):
state_id = chunk.get_block_at(local_x, y, local_z)
if state_id is None or state_id == blocks.AIR:
continue
if (state_id in blocks.NON_SOLID_IDS and
state_id not in blocks.WATER_IDS):
continue
return {'block': blocks.BLOCKS.get(state_id, 'unknown'), 'y': y}
return None
@app.route('/api/minimap')
def api_minimap():
position = g.get('pos') or g.get('player_position')
chunks = g.get('chunks')
if (not g.get('connected') or position is None or position is False or
chunks is None or not hasattr(chunks, 'chunks')):
return jsonify(ready=False, cells=[])
try:
radius = int(request.args.get('radius', 12))
except (TypeError, ValueError):
radius = 12
radius = max(4, min(32, radius))
try:
radius_x = int(request.args.get('radius_x', radius))
except (TypeError, ValueError):
radius_x = radius
try:
radius_z = int(request.args.get('radius_z', radius))
except (TypeError, ValueError):
radius_z = radius
radius_x = max(4, min(48, radius_x))
radius_z = max(4, min(32, radius_z))
center_x, center_z = floor(position.x), floor(position.z)
center_y = floor(position.y)
lock = getattr(chunks, 'lock', None)
if lock is not None:
lock.acquire()
try:
cells = [
[_minimap_surface_cell(chunks, center_x + dx, center_z + dz)
for dx in range(-radius_x, radius_x + 1)]
for dz in range(-radius_z, radius_z + 1)
]
finally:
if lock is not None:
lock.release()
ready = any(cell is not None for row in cells for cell in row)
return jsonify(
ready=ready,
center={'x': center_x, 'y': center_y, 'z': center_z},
yaw=float(g.get('yaw', 0.0) or 0.0),
radius=radius,
radius_x=radius_x,
radius_z=radius_z,
cells=cells if ready else [],
)
@app.route('/api/items') @app.route('/api/items')
def api_items(): def api_items():
from mosfet.info.items import ITEM_NAMES from mosfet.info.items import ITEM_NAMES
+40 -1
View File
@@ -2,7 +2,9 @@ import importlib.util
from pathlib import Path from pathlib import Path
import threading import threading
import unittest import unittest
from munch import Munch
from mosfet.info import blocks
from mosfet.protocol.types import Slot from mosfet.protocol.types import Slot
@@ -32,7 +34,8 @@ class TestWebInventoryAPI(unittest.TestCase):
self.g = mosfet_web.g self.g = mosfet_web.g
self.original = { self.original = {
key: self.g.get(key) for key in key: self.g.get(key) for key in
('connected', 'game', 'inv', 'chest_viewers', 'time', 'time_base') ('connected', 'game', 'inv', 'chest_viewers', 'time', 'time_base',
'chunks', 'pos', 'yaw')
} }
self.game = FakeGame() self.game = FakeGame()
self.g.connected = True self.g.connected = True
@@ -54,6 +57,42 @@ class TestWebInventoryAPI(unittest.TestCase):
item = response.get_json()['inv']['9'] item = response.get_json()['inv']['9']
self.assertEqual(item['components']['custom_name']['text'], 'Test Paper') self.assertEqual(item['components']['custom_name']['text'], 'Test Paper')
def test_minimap_api_returns_surface_blocks_centered_on_bot(self):
grass = blocks.MCD_BLOCKS['grass_block']['minStateId']
class FakeChunk:
sections = {
0: {'non_empty_blocks': 1},
1: {'non_empty_blocks': 0},
}
@staticmethod
def get_block_at(x, y, z):
return grass if (x, y, z) == (1, 5, 1) else blocks.AIR
class FakeChunks:
min_y = 0
chunks = {(0, 0): FakeChunk()}
self.g.connected = True
self.g.chunks = FakeChunks()
self.g.pos = Munch(x=1.5, y=6.0, z=1.5)
self.g.yaw = 90
response = self.client.get(
'/api/minimap?radius=4&radius_x=6&radius_z=4')
self.assertEqual(response.status_code, 200)
data = response.get_json()
self.assertTrue(data['ready'])
self.assertEqual(data['center'], {'x': 1, 'y': 6, 'z': 1})
self.assertEqual(data['yaw'], 90)
self.assertEqual(data['radius_x'], 6)
self.assertEqual(data['radius_z'], 4)
self.assertEqual(len(data['cells']), 9)
self.assertEqual(len(data['cells'][0]), 13)
self.assertEqual(data['cells'][4][6], {'block': 'grass_block', 'y': 5})
def test_global_api_exposes_world_time_ticks(self): def test_global_api_exposes_world_time_ticks(self):
self.g.time = 12345.5 self.g.time = 12345.5
self.g.time_base = 12345.5 self.g.time_base = 12345.5
+16 -2
View File
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#10130f" /> <meta name="theme-color" content="#10130f" />
<meta name="description" content="Live Minecraft bot dashboard" /> <meta name="description" content="Live Minecraft bot dashboard" />
<title>Mosfet · Minecraft Bot</title> <title>Mosfet · Minecraft Bot</title>
<link rel="stylesheet" href="/static/css/dashboard.css?v=16" /> <link rel="stylesheet" href="/static/css/dashboard.css?v=19" />
<script defer src="/static/js/dashboard.js?v=14"></script> <script defer src="/static/js/dashboard.js?v=19"></script>
</head> </head>
<body> <body>
<div class="shell"> <div class="shell">
@@ -72,6 +72,20 @@
</section> </section>
<aside class="side-column"> <aside class="side-column">
<section id="minimap-panel" class="side-panel panel minimap-panel">
<div class="section-heading compact-heading">
<div><div class="eyebrow">WORLD · SURFACE</div><h2>Minimap</h2></div>
</div>
<canvas id="minimap-canvas" class="minimap-canvas" width="320" height="240" role="img" aria-label="Surface map centered on the bot"></canvas>
<div id="minimap-caption" class="minimap-caption">Waiting for world data</div>
<div class="minimap-controls" aria-label="Minimap controls">
<button id="minimap-zoom-out" type="button" aria-label="Zoom out" title="Zoom out">−</button>
<button id="minimap-zoom-in" type="button" aria-label="Zoom in" title="Zoom in">+</button>
<button id="minimap-north-lock" type="button" aria-label="Lock north to the top" aria-pressed="false" title="Lock north to the top">N ↑</button>
<button id="minimap-size" type="button" aria-label="Enlarge minimap" aria-pressed="false" title="Enlarge minimap">⤢</button>
</div>
</section>
<section class="side-panel panel"> <section class="side-panel panel">
<div class="section-heading compact-heading"> <div class="section-heading compact-heading">
<div><div class="eyebrow">MULTIPLAYER</div><h2>Players</h2></div> <div><div class="eyebrow">MULTIPLAYER</div><h2>Players</h2></div>
+16 -2
View File
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#10130f" /> <meta name="theme-color" content="#10130f" />
<meta name="description" content="Live Minecraft bot dashboard" /> <meta name="description" content="Live Minecraft bot dashboard" />
<title>Mosfet · Minecraft Bot</title> <title>Mosfet · Minecraft Bot</title>
<link rel="stylesheet" href="/static/css/dashboard.css?v=16" /> <link rel="stylesheet" href="/static/css/dashboard.css?v=19" />
<script defer src="/static/js/dashboard.js?v=14"></script> <script defer src="/static/js/dashboard.js?v=19"></script>
</head> </head>
<body> <body>
<div class="shell"> <div class="shell">
@@ -72,6 +72,20 @@
</section> </section>
<aside class="side-column"> <aside class="side-column">
<section id="minimap-panel" class="side-panel panel minimap-panel">
<div class="section-heading compact-heading">
<div><div class="eyebrow">WORLD · SURFACE</div><h2>Minimap</h2></div>
</div>
<canvas id="minimap-canvas" class="minimap-canvas" width="320" height="240" role="img" aria-label="Surface map centered on the bot"></canvas>
<div id="minimap-caption" class="minimap-caption">Waiting for world data</div>
<div class="minimap-controls" aria-label="Minimap controls">
<button id="minimap-zoom-out" type="button" aria-label="Zoom out" title="Zoom out">−</button>
<button id="minimap-zoom-in" type="button" aria-label="Zoom in" title="Zoom in">+</button>
<button id="minimap-north-lock" type="button" aria-label="Lock north to the top" aria-pressed="false" title="Lock north to the top">N ↑</button>
<button id="minimap-size" type="button" aria-label="Enlarge minimap" aria-pressed="false" title="Enlarge minimap">⤢</button>
</div>
</section>
<section class="side-panel panel"> <section class="side-panel panel">
<div class="section-heading compact-heading"> <div class="section-heading compact-heading">
<div><div class="eyebrow">MULTIPLAYER</div><h2>Players</h2></div> <div><div class="eyebrow">MULTIPLAYER</div><h2>Players</h2></div>
+10
View File
@@ -136,6 +136,16 @@ h2 { margin: 3px 0 0; font-size: 20px; letter-spacing: -.01em; font-weight: 600;
.side-column { display: grid; gap: 8px; } .side-column { display: grid; gap: 8px; }
.side-panel { padding: 14px; } .side-panel { padding: 14px; }
.minimap-panel { min-width: 0; }
.minimap-panel.is-large { grid-column: 1 / -1; width: 100%; }
.minimap-canvas { display: block; width: 100%; height: auto; aspect-ratio: 4 / 3; margin: 2px auto 0; border: 1px solid #4b5549; border-radius: 2px; background: #111411; image-rendering: pixelated; }
.minimap-panel.is-large .minimap-canvas { aspect-ratio: 2 / 1; }
.minimap-caption { min-height: 14px; margin-top: 6px; color: var(--muted); text-align: center; font: 10px var(--mono); }
.minimap-controls { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px; margin-top: 8px; }
.minimap-controls button { min-width: 0; min-height: 29px; padding: 3px 5px; border: 1px solid var(--border); border-radius: 3px; color: var(--text); background: #171a17; font: 11px var(--mono); cursor: pointer; }
.minimap-controls button:hover:not(:disabled), .minimap-controls button[aria-pressed="true"] { border-color: #68894e; color: var(--accent); background: #22291f; }
.minimap-controls button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.minimap-controls button:disabled { color: var(--subtle); cursor: not-allowed; }
.compact-heading { margin-bottom: 9px; } .compact-heading { margin-bottom: 9px; }
.compact-heading h2 { font-size: 18px; } .compact-heading h2 { font-size: 18px; }
.count-badge { min-width: 21px; height: 21px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 3px; color: var(--accent); font: 10px var(--mono); } .count-badge { min-width: 21px; height: 21px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 3px; color: var(--accent); font: 10px var(--mono); }
+241 -1
View File
@@ -2,6 +2,10 @@
'use strict'; 'use strict';
const POLL_MS = 1000; const POLL_MS = 1000;
const MINIMAP_POLL_MS = 2000;
const MINIMAP_MIN_RADIUS = 4;
const MINIMAP_MAX_RADIUS = 24;
const MINIMAP_ZOOM_STEP = 4;
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
let itemNames = {}; let itemNames = {};
let lastInventorySignature = ''; let lastInventorySignature = '';
@@ -10,6 +14,11 @@
let selectedInventoryItem = null; let selectedInventoryItem = null;
let menuAnchor = null; let menuAnchor = null;
let toastTimer = null; let toastTimer = null;
let minimapRadius = 12;
let minimapNorthLocked = false;
let minimapLarge = false;
let minimapRequestPending = false;
let lastMinimapData = null;
const escapeHTML = (value) => String(value ?? '').replace(/[&<>"']/g, (ch) => ({ const escapeHTML = (value) => String(value ?? '').replace(/[&<>"']/g, (ch) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
@@ -71,6 +80,125 @@
return `${String(hour12).padStart(2, '0')}:${String(minute).padStart(2, '0')} ${period}`; return `${String(hour12).padStart(2, '0')}:${String(minute).padStart(2, '0')} ${period}`;
} }
function minimapBlockColor(name) {
const block = String(name || '').replace(/^minecraft:/, '').toLowerCase();
if (/water|bubble_column/.test(block)) return '#3979a5';
if (/lava|magma|fire/.test(block)) return '#bd5d32';
if (/leaves|moss|grass_block|fern|azalea|flower|crop/.test(block)) return '#557c42';
if (/dirt|mud|podzol|mycelium|farmland|rooted_dirt/.test(block)) return '#806044';
if (/sand|snow|ice|clay|calcite|diorite|quartz/.test(block)) return '#c0b58b';
if (/log|wood|planks|stem|hyphae|bamboo/.test(block)) return '#896842';
if (/stone|deepslate|cobble|ore|tuff|andesite|granite|bedrock/.test(block)) return '#777d7d';
if (/netherrack|blackstone|basalt|nether/.test(block)) return '#76515a';
return '#7a806f';
}
function shadeMinimapColor(color, amount) {
const value = Number.parseInt(color.slice(1), 16);
const channels = [value >> 16, (value >> 8) & 0xff, value & 0xff]
.map((channel) => Math.max(0, Math.min(255, Math.round(channel * amount))));
return `rgb(${channels[0]} ${channels[1]} ${channels[2]})`;
}
function drawMinimap(data = lastMinimapData) {
const canvas = $('minimap-canvas');
if (!canvas) return;
const context = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const centerX = width / 2;
const centerY = height / 2;
context.clearRect(0, 0, width, height);
context.fillStyle = '#111411';
context.fillRect(0, 0, width, height);
if (!data?.ready || !Array.isArray(data.cells)) {
$('minimap-caption').textContent = 'Waiting for loaded terrain';
updateMinimapControls();
return;
}
const radiusZ = Number(data.radius_z || data.radius || minimapRadius);
const radiusX = Number(data.radius_x || data.radius || minimapRadius);
const rows = radiusZ * 2 + 1;
const columns = radiusX * 2 + 1;
const cellWidth = width / columns;
const cellHeight = height / rows;
const yaw = Number(data.yaw || 0);
const mapRotation = minimapNorthLocked ? 0 : (180 - yaw) * Math.PI / 180;
context.save();
context.beginPath();
context.rect(0, 0, width, height);
context.clip();
context.translate(centerX, centerY);
context.rotate(mapRotation);
for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
const row = data.cells[rowIndex] || [];
for (let columnIndex = 0; columnIndex < columns; columnIndex++) {
const surface = row[columnIndex];
const x = -centerX + columnIndex * cellWidth;
const y = -centerY + rowIndex * cellHeight;
if (!surface) {
context.fillStyle = '#171b17';
} else {
const heightDelta = Number(surface.y) - Number(data.center?.y || 0);
const shade = Math.max(0.72, Math.min(1.28, 1 + heightDelta * 0.018));
context.fillStyle = shadeMinimapColor(
minimapBlockColor(surface.block), shade);
}
context.fillRect(x, y, Math.ceil(cellWidth) + 0.2,
Math.ceil(cellHeight) + 0.2);
}
}
context.restore();
context.save();
context.translate(centerX, centerY);
if (minimapNorthLocked) context.rotate((yaw + 180) * Math.PI / 180);
context.beginPath();
context.moveTo(0, -8);
context.lineTo(-5, 6);
context.lineTo(0, 3);
context.lineTo(5, 6);
context.closePath();
context.fillStyle = '#f1f3ef';
context.strokeStyle = '#151815';
context.lineWidth = 2;
context.fill();
context.stroke();
context.restore();
context.strokeStyle = '#566252';
context.lineWidth = 2;
context.strokeRect(1, 1, width - 2, height - 2);
$('minimap-caption').textContent =
`X ${data.center.x} · Z ${data.center.z} · ${columns}×${rows}`;
updateMinimapControls();
}
function minimapRadii() {
const canvas = $('minimap-canvas');
return {
x: Math.max(4, Math.min(48,
Math.round(minimapRadius * canvas.width / canvas.height))),
z: minimapRadius,
};
}
function updateMinimapControls() {
$('minimap-zoom-out').disabled = minimapRadius >= MINIMAP_MAX_RADIUS;
$('minimap-zoom-in').disabled = minimapRadius <= MINIMAP_MIN_RADIUS;
const northButton = $('minimap-north-lock');
northButton.setAttribute('aria-pressed', String(minimapNorthLocked));
northButton.title = minimapNorthLocked ? 'Rotate with the bot' : 'Lock north to the top';
northButton.setAttribute('aria-label', northButton.title);
const sizeButton = $('minimap-size');
sizeButton.setAttribute('aria-pressed', String(minimapLarge));
sizeButton.title = minimapLarge ? 'Reduce minimap size' : 'Enlarge minimap';
sizeButton.setAttribute('aria-label', sizeButton.title);
sizeButton.textContent = minimapLarge ? '⤡' : '⤢';
}
function textureCandidates(name) { function textureCandidates(name) {
const id = String(name || '').replace(/^minecraft:/, '').toLowerCase(); const id = String(name || '').replace(/^minecraft:/, '').toLowerCase();
if (!/^[a-z0-9_]+$/.test(id) || id === 'air') return []; if (!/^[a-z0-9_]+$/.test(id) || id === 'air') return [];
@@ -140,7 +268,6 @@
} }
lastInventorySignature = signature; lastInventorySignature = signature;
hideItemTooltip(); hideItemTooltip();
closeInventoryMenu();
const grid = $('inventory-grid'); const grid = $('inventory-grid');
const items = []; const items = [];
@@ -209,6 +336,7 @@
<div id="hotbar-inventory" class="hotbar-inventory">${slotsHTML(36, 44)}</div> <div id="hotbar-inventory" class="hotbar-inventory">${slotsHTML(36, 44)}</div>
</div> </div>
</div>`; </div>`;
reanchorInventoryMenu(inv);
items.forEach(({slot, item}) => { items.forEach(({slot, item}) => {
if (!item || !item.present) return; if (!item || !item.present) return;
@@ -374,6 +502,33 @@
positionFloating(menu, rect.left, rect.bottom + 4); positionFloating(menu, rect.left, rect.bottom + 4);
} }
function reanchorInventoryMenu(inv) {
if (!selectedInventoryItem) return;
const {slot, itemId} = selectedInventoryItem;
const item = inv[String(slot)] || inv[slot];
if (!item?.present || item.item_id !== itemId) {
closeInventoryMenu();
return;
}
selectedInventoryItem.count = Number(item.item_count || 0);
const anchor = $('inventory-grid').querySelector(
`.inventory-slot[data-slot="${slot}"]`);
if (!anchor) {
closeInventoryMenu();
return;
}
menuAnchor = anchor;
anchor.setAttribute('aria-expanded', 'true');
const name = itemNames[String(item.item_id)] || `item_${item.item_id}`;
$('inventory-menu-title').textContent = itemDisplayName(item, name);
$('inventory-menu-meta').textContent = `Slot ${slot} · ${selectedInventoryItem.count} item${selectedInventoryItem.count === 1 ? '' : 's'}`;
const rect = anchor.getBoundingClientRect();
const menu = $('inventory-menu');
menu.hidden = false;
positionFloating(menu, rect.left, rect.bottom + 4);
}
function showToast(message, isError = false) { function showToast(message, isError = false) {
const toast = $('toast'); const toast = $('toast');
toast.textContent = message; toast.textContent = message;
@@ -470,6 +625,10 @@
$('world-time').textContent = Number.isFinite(worldTime) $('world-time').textContent = Number.isFinite(worldTime)
? `${Math.floor(worldTime).toLocaleString()} t · ${formatWorldClock(worldTime)}` ? `${Math.floor(worldTime).toLocaleString()} t · ${formatWorldClock(worldTime)}`
: '—'; : '—';
if (lastMinimapData?.ready) {
lastMinimapData.yaw = Number(state.yaw || 0);
drawMinimap(lastMinimapData);
}
$('job-state').textContent = prettyName(state.job_state || 'idle').toUpperCase(); $('job-state').textContent = prettyName(state.job_state || 'idle').toUpperCase();
$('path-length').textContent = String(state.path?.length || 0); $('path-length').textContent = String(state.path?.length || 0);
$('entity-count').textContent = `${Object.keys(state.mobs || {}).length} mobs · ${Object.keys(state.objects || {}).length} drops`; $('entity-count').textContent = `${Object.keys(state.mobs || {}).length} mobs · ${Object.keys(state.objects || {}).length} drops`;
@@ -495,6 +654,43 @@
} }
} }
async function pollMinimap() {
if (!lastState?.connected || lastState.connection_status !== 'in_game') {
lastMinimapData = null;
drawMinimap(null);
return;
}
if (minimapRequestPending) return;
minimapRequestPending = true;
const requestedRadius = minimapRadius;
const requestedRadii = minimapRadii();
const requestKey = `${requestedRadii.x}:${requestedRadii.z}`;
try {
const response = await fetch(
`/api/minimap?radius=${requestedRadius}&radius_x=${requestedRadii.x}&radius_z=${requestedRadii.z}`,
{cache: 'no-store'},
);
if (!response.ok) throw new Error(`API ${response.status}`);
const data = await response.json();
const currentRadii = minimapRadii();
if (requestedRadius !== minimapRadius ||
requestKey !== `${currentRadii.x}:${currentRadii.z}` ||
!lastState?.connected) return;
lastMinimapData = data;
drawMinimap(data);
} catch (_) {
const currentRadii = minimapRadii();
if (requestedRadius === minimapRadius &&
requestKey === `${currentRadii.x}:${currentRadii.z}`) {
lastMinimapData = null;
drawMinimap(null);
$('minimap-caption').textContent = 'Map unavailable';
}
} finally {
minimapRequestPending = false;
}
}
async function poll() { async function poll() {
try { try {
const response = await fetch('/api/global', {cache: 'no-store'}); const response = await fetch('/api/global', {cache: 'no-store'});
@@ -581,7 +777,51 @@
positionFloating($('inventory-menu'), rect.left, rect.bottom + 4); positionFloating($('inventory-menu'), rect.left, rect.bottom + 4);
}); });
$('minimap-zoom-out').addEventListener('click', () => {
minimapRadius = Math.min(MINIMAP_MAX_RADIUS,
minimapRadius + MINIMAP_ZOOM_STEP);
lastMinimapData = null;
drawMinimap();
pollMinimap();
});
$('minimap-zoom-in').addEventListener('click', () => {
minimapRadius = Math.max(MINIMAP_MIN_RADIUS,
minimapRadius - MINIMAP_ZOOM_STEP);
lastMinimapData = null;
drawMinimap();
pollMinimap();
});
$('minimap-north-lock').addEventListener('click', () => {
minimapNorthLocked = !minimapNorthLocked;
updateMinimapControls();
drawMinimap();
});
$('minimap-size').addEventListener('click', () => {
minimapLarge = !minimapLarge;
const panel = $('minimap-panel');
const canvas = $('minimap-canvas');
const contentGrid = document.querySelector('.content-grid');
const sideColumn = document.querySelector('.side-column');
if (minimapLarge) {
canvas.width = 640;
canvas.height = 320;
contentGrid.insertBefore(panel, contentGrid.firstElementChild);
} else {
canvas.width = 320;
canvas.height = 240;
sideColumn.insertBefore(panel, sideColumn.firstElementChild);
}
panel.classList.toggle('is-large', minimapLarge);
lastMinimapData = null;
updateMinimapControls();
drawMinimap();
pollMinimap();
});
loadItems(); loadItems();
drawMinimap(null);
poll(); poll();
pollMinimap();
window.setInterval(poll, POLL_MS); window.setInterval(poll, POLL_MS);
window.setInterval(pollMinimap, MINIMAP_POLL_MS);
})(); })();
+16 -2
View File
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#10130f" /> <meta name="theme-color" content="#10130f" />
<meta name="description" content="Live Minecraft bot dashboard" /> <meta name="description" content="Live Minecraft bot dashboard" />
<title>Mosfet · Minecraft Bot</title> <title>Mosfet · Minecraft Bot</title>
<link rel="stylesheet" href="/static/css/dashboard.css?v=16" /> <link rel="stylesheet" href="/static/css/dashboard.css?v=19" />
<script defer src="/static/js/dashboard.js?v=14"></script> <script defer src="/static/js/dashboard.js?v=19"></script>
</head> </head>
<body> <body>
<div class="shell"> <div class="shell">
@@ -72,6 +72,20 @@
</section> </section>
<aside class="side-column"> <aside class="side-column">
<section id="minimap-panel" class="side-panel panel minimap-panel">
<div class="section-heading compact-heading">
<div><div class="eyebrow">WORLD · SURFACE</div><h2>Minimap</h2></div>
</div>
<canvas id="minimap-canvas" class="minimap-canvas" width="320" height="240" role="img" aria-label="Surface map centered on the bot"></canvas>
<div id="minimap-caption" class="minimap-caption">Waiting for world data</div>
<div class="minimap-controls" aria-label="Minimap controls">
<button id="minimap-zoom-out" type="button" aria-label="Zoom out" title="Zoom out">−</button>
<button id="minimap-zoom-in" type="button" aria-label="Zoom in" title="Zoom in">+</button>
<button id="minimap-north-lock" type="button" aria-label="Lock north to the top" aria-pressed="false" title="Lock north to the top">N ↑</button>
<button id="minimap-size" type="button" aria-label="Enlarge minimap" aria-pressed="false" title="Enlarge minimap">⤢</button>
</div>
</section>
<section class="side-panel panel"> <section class="side-panel panel">
<div class="section-heading compact-heading"> <div class="section-heading compact-heading">
<div><div class="eyebrow">MULTIPLAYER</div><h2>Players</h2></div> <div><div class="eyebrow">MULTIPLAYER</div><h2>Players</h2></div>