331 lines
11 KiB
HTML
331 lines
11 KiB
HTML
<!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>
|