Files
LisCastle/mapview.html
2026-04-16 15:26:59 +03:00

331 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Карта зоны</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f172a;
color: #f1f5f9;
}
#bar {
height: 44px;
display: flex;
align-items: center;
padding: 0 1rem;
background: #1e293b;
border-bottom: 1px solid #334155;
gap: 1rem;
}
#title {
font-weight: 600;
}
#status {
font-size: 0.9rem;
color: #94a3b8;
}
#canvas {
display: block;
width: 100%;
height: calc(100% - 44px);
}
</style>
</head>
<body>
<div id="bar">
<span id="title">Карта зоны</span>
<span id="status">Ожидание данных...</span>
<span id="debug" style="margin-left:auto;font-size:0.75rem;color:#64748b;">rx: 0</span>
</div>
<canvas id="canvas"></canvas>
<script>
const statusEl = document.getElementById('status');
const debugEl = document.getElementById('debug');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const PADDING = 30;
let state = null;
let rxCount = 0;
let mapImage = null;
let mapImageKey = '';
let mapImageLoading = false;
function fitCanvas() {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function computeViewport() {
if (!state) return null;
const rect = canvas.getBoundingClientRect();
const w = Math.max(1, rect.width - PADDING * 2);
const h = Math.max(1, rect.height - PADDING * 2);
const { bounds } = state;
const lonRange = Math.max(1e-9, bounds.maxLon - bounds.minLon);
const latRange = Math.max(1e-9, bounds.maxLat - bounds.minLat);
const midLat = (bounds.maxLat + bounds.minLat) / 2;
const aspectGeo = (lonRange * Math.cos((midLat * Math.PI) / 180)) / latRange;
const aspectCanvas = w / h;
let sw;
let sh;
if (aspectGeo > aspectCanvas) {
sw = w;
sh = w / aspectGeo;
} else {
sh = h;
sw = h * aspectGeo;
}
const ox = PADDING + (w - sw) / 2;
const oy = PADDING + (h - sh) / 2;
return { ox, oy, sw, sh, lonRange, latRange, bounds };
}
function project(lat, lon, vp) {
const x = vp.ox + ((lon - vp.bounds.minLon) / vp.lonRange) * vp.sw;
const y = vp.oy + ((vp.bounds.maxLat - lat) / vp.latRange) * vp.sh;
return { x, y };
}
function buildMapImageUrl(vp) {
const w = Math.max(128, Math.floor(vp.sw));
const h = Math.max(128, Math.floor(vp.sh));
const bbox = [
vp.bounds.minLon,
vp.bounds.minLat,
vp.bounds.maxLon,
vp.bounds.maxLat
].join(',');
return `https://staticmap.openstreetmap.de/staticmap.php?bbox=${bbox}&size=${w}x${h}&maptype=mapnik`;
}
function ensureMapImage(vp) {
const key = [
vp.bounds.minLon.toFixed(6),
vp.bounds.minLat.toFixed(6),
vp.bounds.maxLon.toFixed(6),
vp.bounds.maxLat.toFixed(6),
Math.floor(vp.sw),
Math.floor(vp.sh)
].join('|');
if (key === mapImageKey || mapImageLoading) {
return;
}
mapImageLoading = true;
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
mapImage = img;
mapImageKey = key;
mapImageLoading = false;
draw();
};
img.onerror = () => {
mapImageLoading = false;
};
img.src = buildMapImageUrl(vp);
}
function drawGrid(vp) {
ctx.strokeStyle = '#1e293b';
ctx.lineWidth = 1;
const steps = 8;
ctx.beginPath();
for (let i = 0; i <= steps; i++) {
const x = vp.ox + (vp.sw * i) / steps;
ctx.moveTo(x, vp.oy);
ctx.lineTo(x, vp.oy + vp.sh);
}
for (let j = 0; j <= steps; j++) {
const y = vp.oy + (vp.sh * j) / steps;
ctx.moveTo(vp.ox, y);
ctx.lineTo(vp.ox + vp.sw, y);
}
ctx.stroke();
}
function draw() {
fitCanvas();
const rect = canvas.getBoundingClientRect();
ctx.fillStyle = '#0f172a';
ctx.fillRect(0, 0, rect.width, rect.height);
if (!state) return;
const vp = computeViewport();
if (!vp) return;
ensureMapImage(vp);
if (mapImage) {
ctx.drawImage(mapImage, vp.ox, vp.oy, vp.sw, vp.sh);
} else {
ctx.fillStyle = 'rgba(239, 68, 68, 0.08)';
ctx.fillRect(vp.ox, vp.oy, vp.sw, vp.sh);
}
drawGrid(vp);
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 2;
ctx.strokeRect(vp.ox, vp.oy, vp.sw, vp.sh);
ctx.fillStyle = '#38bdf8';
state.tiles.forEach((tile) => {
const p = project(tile.lat, tile.lon, vp);
ctx.beginPath();
ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2);
ctx.fill();
});
ctx.fillStyle = '#22c55e';
state.visited.forEach((v) => {
const p = project(v.lat, v.lon, vp);
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx.fill();
});
if (state.active) {
const p = project(state.active.lat, state.active.lon, vp);
ctx.fillStyle = state.done ? '#10b981' : '#fbbf24';
ctx.strokeStyle = state.done ? '#064e3b' : '#92400e';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(p.x, p.y, 9, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
}
function setInit(bounds, tiles) {
state = {
bounds,
tiles: Array.isArray(tiles) ? tiles : [],
visited: [],
active: null,
done: false,
total: Array.isArray(tiles) ? tiles.length : 0
};
mapImage = null;
mapImageKey = '';
}
let lastInitTs = 0;
let lastPosTs = 0;
let lastDoneTs = 0;
function handleMessage(msg, source) {
if (!msg || !msg.type) return;
rxCount += 1;
debugEl.textContent = `rx: ${rxCount} (${msg.type}/${source})`;
if (msg.type === 'init') {
if (msg.ts && msg.ts <= lastInitTs) return;
lastInitTs = msg.ts || Date.now();
setInit(msg.bounds, msg.tiles);
statusEl.textContent = `Готов. Точек: ${state.total}`;
draw();
} else if (msg.type === 'position') {
if (msg.ts && msg.ts <= lastPosTs) return;
lastPosTs = msg.ts || Date.now();
if (!state && msg.bounds) {
setInit(msg.bounds, msg.tiles);
state.total = msg.total || state.total;
}
if (!state) return;
if (state.active) state.visited.push(state.active);
state.active = { lat: msg.lat, lon: msg.lon };
statusEl.textContent = `Съёмка: ${msg.index} / ${msg.total} (${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)})`;
draw();
} else if (msg.type === 'done') {
if (msg.ts && msg.ts <= lastDoneTs) return;
lastDoneTs = msg.ts || Date.now();
if (!state) return;
state.done = true;
statusEl.textContent = `Готово. Кадров: ${msg.total ?? state.total}`;
draw();
}
}
function pollLocalStorage() {
try {
const initRaw = localStorage.getItem('sandcastle-map-init');
if (initRaw) {
const msg = JSON.parse(initRaw);
if (msg.ts && msg.ts > lastInitTs) handleMessage(msg, 'ls');
}
const posRaw = localStorage.getItem('sandcastle-map-pos');
if (posRaw) {
const msg = JSON.parse(posRaw);
if (msg.ts && msg.ts > lastPosTs) handleMessage(msg, 'ls');
}
const doneRaw = localStorage.getItem('sandcastle-map-done');
if (doneRaw) {
const msg = JSON.parse(doneRaw);
if (msg.ts && msg.ts > lastDoneTs) handleMessage(msg, 'ls');
}
} catch (e) {}
setTimeout(pollLocalStorage, 500);
}
window.addEventListener('storage', (ev) => {
if (!ev.key || !ev.newValue) return;
if (!ev.key.startsWith('sandcastle-map-')) return;
try {
handleMessage(JSON.parse(ev.newValue), 'evt');
} catch (e) {}
});
let channel = null;
try {
channel = new BroadcastChannel('sandcastle-map');
} catch (e) {
debugEl.textContent = 'BroadcastChannel не поддерживается';
}
if (channel) {
channel.onmessage = (ev) => handleMessage(ev.data, 'bc');
}
window.addEventListener('resize', draw);
fitCanvas();
draw();
pollLocalStorage();
function requestInit() {
if (state) return;
if (channel) {
try { channel.postMessage({ type: 'request-init' }); } catch (e) {}
}
setTimeout(requestInit, 1000);
}
requestInit();
setTimeout(() => {
if (state) return;
const hasBC = !!channel;
const hasLS = (() => { try { localStorage.setItem('__t', '1'); localStorage.removeItem('__t'); return true; } catch (e) { return false; } })();
statusEl.textContent = `Нет данных ${rxCount}с. BC=${hasBC} LS=${hasLS}. Захват запущен?`;
}, 3000);
</script>
</body>
</html>