mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-06 06:57:24 +00:00
## Problem `handleNodes` runs the geo-filter, `nodeBlacklist`, `hiddenNamePrefixes` and area passes **after** the SQL `LIMIT/OFFSET`, and rewrites `total` to the filtered length. A page that loses a row is therefore short **without being the last page**, and neither the page length nor `total` can tell a client whether to ask for another page. #1606 added the pagination loop and chose the page length as the canonical stop. That is correct only where nothing is ever filtered. Everywhere else the list truncates at the first filtered page boundary and strands every node behind it — the #1598 symptom reached by a different route: a node that is relaying right now simply stops being in the list. The comment at `app.js:240` rejects `total` for exactly the right reason, then picks the signal the same code path also breaks. ## Measured on a live 2346-node deployment Page sizes for the query the map issues: ``` offset=0 returned=500 ← full, loop continues offset=500 returned=499 ← one row filtered AFTER the LIMIT → loop STOPS offset=1000 returned=500 ← never requested offset=1500 returned=500 ← never requested offset=2000 returned=345 ← never requested ``` | stop rule | requests | nodes reached | |---|---:|---:| | short page (master) | 2 | **999** | | `has_more`, else empty page | 6 | **2344** | **1341 nodes, 57%, unreachable through the UI.** ### One hidden node truncates the whole list The deployment this came from has no `geoFilter` (`/api/config/geo-filter` returns `polygon: null`) and no `nodeBlacklist`. It has a single `hiddenNamePrefixes` entry — a deliberate operator choice — and exactly one node whose name starts with it: ``` public_key d4a46ea2…1054 (64 clean hex chars) name 🚫🔥☀️ role repeater last_seen 2026-09-22T10:09:38Z ``` `handleNodes` drops that row in the `IsNameHidden` pass, which runs after the SQL `LIMIT`. The row is counted by the `LIMIT` and by `COUNT(*)`, so the page it lands in comes back exactly one short — and stops every client that treats a short page as the end. Isolated against SQL on the same database, seconds apart: ``` SELECT lower(public_key) FROM nodes ORDER BY last_seen DESC LIMIT 500 OFFSET 500 -> 500 rows GET /api/nodes?limit=500&offset=500 -> 499 rows comm -23 sql.txt api.txt -> d4a46ea2e99cab132a3286ef3d9cce9099318790af7f25671fe83de453721054 ``` Deterministic — `offset=500` returned 499 on three consecutive requests. Not a CDN artifact either: `cf-cache-status: DYNAMIC`, origin `cache-control: no-store`, no `age` header, and four requests with deliberately unique cache keys all returned 499. So **one deliberately hidden node makes 1341 of 2344 nodes unreachable.** The hiding feature does exactly what it was asked to do for that one node, and takes 57% of the network with it, silently. A single `hiddenNamePrefixes` entry is enough; no geo-filter, blacklist or area filter is needed to reach this state. ### The cutoff moves, which is why this reads as intermittent The visible set is the sum of the pages up to and including the first short one, so the boundary sits wherever the unreturnable row currently sorts by `last_seen`, and jumps a whole page as ingest reorders the list. Same deployment, same code, same config, ~2h apart: | dropped row's rank | first short page | nodes visible | |---|---|---:| | inside 0–499 | page 1 | 499 | | inside 500–999 | page 2 | 999 | A node is visible or invisible purely by where it lands relative to that moving line, so affected nodes appear to vanish and return on their own. Two operators on this deployment reported exactly that, independently, while I was measuring. ### A named reproduction `HU-ZA-Lentihegy` (`5287a33f…`), reported missing from the map by an operator whose companion had logged its advert at 04:20 local the same morning. Ingest was fine. The row is in `nodes` with `last_seen` `2026-09-22T02:20:35Z` — the same advert, to the second — valid GPS, role `repeater`, 1033 adverts, and `/api/nodes/search?q=lentihegy` returns it. ``` rank by last_seen : 1081 cutoff at the time: 999 ``` It missed by 82 positions. Walking the same live endpoint, same moment: | stop rule | requests | nodes reached | Lentihegy | |---|---:|---:|---| | short page (master) | 2 | 999 | **not reached** | | `has_more`, else empty page | 6 | 2340 | reached | The practical shape of this on a busy mesh: 1081 nodes had been heard more recently than 9.4 hours, so on that deployment **anything last heard more than ~9 hours ago was invisible**, alive or not. `#/nodes` compounds it — its search box filters client-side over the truncated set, so the server-side `?search=` never runs and an operator cannot find the node by searching for it either, even though the endpoint would return it. ## Change **Server** — `NodeListResponse` gains `has_more`, computed from the raw SQL page against the real `COUNT(*)` before the filter passes run, so it survives them: ```go hasMore := offset+len(nodes) < total ``` Always emitted (no `omitempty`) so a client can tell `false` from an old server. No extra request in the fixed path: `has_more` ends the loop exactly, where the old rule needed a probe page. **Clients** — `app.js` `fetchAllNodes`, `nodes.js` `loadNodes` and `area-map.html`'s inline helper stop on `has_more`, falling back to a zero-length page against a server that predates it. An empty page always ends the loop, so a `has_more` against a concurrently-shrinking table cannot spin to `safetyCap`. Left alone: the three loops are still three copies. Collapsing them onto `fetchAllNodes` is a bigger change than this fix needs, and `nodes.js` has its own inter-page progress UI. Happy to do it separately if you want it. ## Testing - **Unit** (`tests/unit/test-fetch-all-nodes-pagination.js`): the fixture now models the real handler — a row counted by the LIMIT and by `COUNT(*)`, then removed from the page. Three new cases. Fails on the old rule at 499 of 1199. - **E2E** (`tests/e2e/test-map-nodes-pagination-e2e.js`, already wired into `deploy.yml`): the mock drops a page-1 row and emits `has_more`. Mutation-checked — restoring master's stop rule fails 3 of its steps. - **Go** (`cmd/server/nodes_pagination_has_more_test.go`): asserts `has_more` stays true on a page filtering shortened. Mutation-checked — recomputing it after the filter block fails the test. - Full server suite `go test -race`: ok, 41.4s. `gofmt` clean, `go vet` passes. - **Against a real binary**, not just mocks: fixture DB migrated with `corescope-migrate`, `hiddenNamePrefixes: ["SKCE"]`, `limit=3`. Page 1 returns 2 of 3 with `total` rewritten to 2 and `has_more=true`. Walking the real server with master's rule reaches 2 nodes; with `has_more`, all 199 visible of 200, the hidden one still hidden. The real frontend against that server loads 199 with no JS errors. Two existing expectations changed, both deliberate: 1. `surfaces ALL nodes past the 500 server cap` — 3 → 4 requests. That mock emits no `has_more`, so the 200-row final page can no longer end the loop (a short page is exactly what a filtered page looks like) and a zero-length probe follows. Against a current server `has_more` still ends it at 3. 2. `rows missing public_key are NOT collapsed into one` — its stub returned a constant body, which would now be paged to `safetyCap`. It serves one page then empties. Local `test-all.sh` exits 1 on two XSS-gate self-tests (`good-2-tested.js`, `good-4-tested.js`) via a `UnicodeEncodeError` printing an emoji under Windows cp1252. Identical on clean `origin/master` in a scratch worktree, so it is pre-existing and platform-local, not this branch. There is a second identical filter block further down `routes.go` on another list endpoint. Likely the same class; not touched here. If you would rather land your own version of this, say so and I will close mine. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
381 lines
16 KiB
HTML
381 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Area Map</title>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: system-ui, sans-serif; background: #f5f5f5; color: #222; height: 100vh; display: flex; flex-direction: column; }
|
|
header { padding: 10px 16px; background: #fff; border-bottom: 1px solid #ddd; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
|
header h1 { font-size: 1rem; font-weight: 600; color: #1a6abf; white-space: nowrap; }
|
|
#base-url-form { display: flex; gap: 8px; align-items: center; }
|
|
#base-url-form label { font-size: 0.8rem; color: #666; }
|
|
#base-url { background: #f5f5f5; border: 1px solid #ccc; border-radius: 6px; padding: 5px 10px; color: #222; font-size: 0.85rem; width: 260px; }
|
|
#btn-load { padding: 5px 14px; background: #1a6abf; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
|
|
#btn-load:hover { background: #155299; }
|
|
#status { font-size: 0.8rem; color: #888; margin-left: auto; }
|
|
#status.err { color: #cc2222; }
|
|
#main { flex: 1; display: flex; overflow: hidden; }
|
|
#sidebar { width: 250px; min-width: 250px; background: #fff; border-right: 1px solid #ddd; display: flex; flex-direction: column; overflow: hidden; }
|
|
|
|
/* --- existing areas --- */
|
|
.section-title { padding: 8px 14px; font-size: 0.72rem; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid #eee; background: #fafafa; }
|
|
#area-list { overflow-y: auto; padding: 4px 0; max-height: 220px; }
|
|
.area-item { display: flex; align-items: center; gap: 10px; padding: 7px 14px; user-select: none; }
|
|
.area-swatch { width: 13px; height: 13px; border-radius: 3px; flex-shrink: 0; }
|
|
.area-label { font-size: 0.84rem; flex: 1; }
|
|
.area-key { font-size: 0.71rem; color: #999; }
|
|
.area-check { width: 15px; height: 15px; cursor: pointer; accent-color: #1a6abf; }
|
|
#node-toggle-row { display: flex; align-items: center; gap: 8px; padding: 8px 14px; border-top: 1px solid #eee; }
|
|
#node-toggle-row label { font-size: 0.82rem; color: #444; cursor: pointer; }
|
|
#show-nodes { accent-color: #1a6abf; cursor: pointer; }
|
|
#node-count { font-size: 0.75rem; color: #999; margin-left: auto; }
|
|
|
|
/* --- builder --- */
|
|
#builder { flex: 1; display: flex; flex-direction: column; border-top: 2px solid #e0e8f4; overflow: hidden; }
|
|
#builder .section-title { background: #eef4fd; color: #1a6abf; border-bottom-color: #ccddf5; }
|
|
#builder-fields { padding: 10px 14px; display: flex; flex-direction: column; gap: 8px; }
|
|
.field-row { display: flex; flex-direction: column; gap: 3px; }
|
|
.field-row label { font-size: 0.75rem; color: #666; }
|
|
.field-row input { padding: 5px 8px; border: 1px solid #ccc; border-radius: 5px; font-size: 0.84rem; background: #f9f9f9; color: #222; }
|
|
.field-row input:focus { outline: none; border-color: #1a6abf; background: #fff; }
|
|
#builder-controls { padding: 0 14px 10px; display: flex; gap: 6px; flex-wrap: wrap; }
|
|
#btn-draw { padding: 5px 12px; background: #1a6abf; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 0.82rem; }
|
|
#btn-draw.active { background: #c04020; }
|
|
#btn-draw:hover { opacity: 0.88; }
|
|
#btn-undo { padding: 5px 10px; background: #e8e8e8; color: #444; border: none; border-radius: 6px; cursor: pointer; font-size: 0.82rem; }
|
|
#btn-undo:hover { background: #ddd; }
|
|
#btn-clear-draw { padding: 5px 10px; background: #fde8e8; color: #b02020; border: none; border-radius: 6px; cursor: pointer; font-size: 0.82rem; }
|
|
#btn-clear-draw:hover { background: #f8cccc; }
|
|
#draw-hint { padding: 0 14px 6px; font-size: 0.75rem; color: #888; }
|
|
#builder-output { flex: 1; margin: 0 14px 10px; background: #f0f4f8; border: 1px solid #ccd; border-radius: 6px; padding: 9px 11px; font-family: monospace; font-size: 0.76rem; color: #1a5080; white-space: pre; overflow: auto; min-height: 60px; cursor: text; }
|
|
#builder-output.empty { color: #aaa; font-style: italic; }
|
|
#btn-copy-area { margin: 0 14px 12px; padding: 5px 14px; background: #1a6abf; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 0.82rem; }
|
|
#btn-copy-area:hover { background: #155299; }
|
|
#btn-copy-area.copied { background: #1a7a3a; }
|
|
|
|
#map { flex: 1; }
|
|
.node-popup { font-size: 0.8rem; line-height: 1.5; }
|
|
.node-popup strong { color: #1a6abf; }
|
|
/* Phosphor icon helper (mirrors style.css .ph-icon — area-map.html is standalone) */
|
|
.ph-icon { width: 1em; height: 1em; fill: currentColor; vertical-align: -0.125em; display: inline-block; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<h1>Area Map</h1>
|
|
<div id="base-url-form">
|
|
<label for="base-url">Server:</label>
|
|
<input id="base-url" type="text" value="" placeholder="(same server)"/>
|
|
<button id="btn-load">Load</button>
|
|
</div>
|
|
<span id="status">Click Load to fetch areas and nodes</span>
|
|
</header>
|
|
|
|
<div id="main">
|
|
<div id="sidebar">
|
|
<div class="section-title">Existing Areas</div>
|
|
<div id="area-list"><div style="padding:10px 14px;color:#aaa;font-size:0.8rem;">Not loaded</div></div>
|
|
<div id="node-toggle-row">
|
|
<input type="checkbox" id="show-nodes"/>
|
|
<label for="show-nodes">All nodes (grey)</label>
|
|
<span id="node-count"></span>
|
|
</div>
|
|
|
|
<div id="builder">
|
|
<div class="section-title">Draw New Area</div>
|
|
<div id="builder-fields">
|
|
<div class="field-row">
|
|
<label for="area-key">Key (e.g. BAY)</label>
|
|
<input id="area-key" type="text" placeholder="BAY" maxlength="12"/>
|
|
</div>
|
|
<div class="field-row">
|
|
<label for="area-label">Label</label>
|
|
<input id="area-label" type="text" placeholder="Bay Area"/>
|
|
</div>
|
|
</div>
|
|
<div id="builder-controls">
|
|
<button id="btn-draw">Draw</button>
|
|
<button id="btn-undo"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-arrow-u-up-left"/></svg> Undo</button>
|
|
<button id="btn-clear-draw"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg> Clear</button>
|
|
</div>
|
|
<div id="draw-hint">0 points — need ≥ 3</div>
|
|
<div id="builder-output" class="empty">Fill key + label, then draw polygon points…</div>
|
|
<button id="btn-copy-area">Copy JSON</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="map"></div>
|
|
</div>
|
|
|
|
<script>
|
|
const COLORS = ['#1a6abf','#e03030','#1a9a40','#d07000','#8830cc','#d05010','#0a9080','#c03070','#2080d0','#608020'];
|
|
|
|
const map = L.map('map').setView([37.5, -122.0], 9);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors',
|
|
maxZoom: 19
|
|
}).addTo(map);
|
|
|
|
// ---- state ----
|
|
let areaLayers = {};
|
|
let allNodeLayer = L.layerGroup();
|
|
let areas = [];
|
|
let allNodes = [];
|
|
let baseUrl = '';
|
|
|
|
// ---- draw state ----
|
|
let drawing = false;
|
|
let drawPoints = [];
|
|
let drawMarkers = [];
|
|
let drawPolygon = null;
|
|
|
|
function setStatus(msg, isErr) {
|
|
const el = document.getElementById('status');
|
|
el.textContent = msg;
|
|
el.className = isErr ? 'err' : '';
|
|
}
|
|
|
|
// ---- load from server ----
|
|
async function load() {
|
|
baseUrl = document.getElementById('base-url').value.replace(/\/$/, '');
|
|
setStatus('Loading…');
|
|
|
|
Object.values(areaLayers).forEach(g => { map.removeLayer(g.poly); map.removeLayer(g.nodes); });
|
|
areaLayers = {};
|
|
allNodeLayer.clearLayers();
|
|
map.removeLayer(allNodeLayer);
|
|
document.getElementById('area-list').innerHTML = '';
|
|
document.getElementById('node-count').textContent = '';
|
|
document.getElementById('show-nodes').checked = false;
|
|
|
|
try {
|
|
const areasResp = await fetch(`${baseUrl}/api/config/areas/polygons`);
|
|
if (!areasResp.ok) throw new Error(`areas/polygons: ${areasResp.status}`);
|
|
|
|
areas = await areasResp.json();
|
|
allNodes = await fetchAllNodesPaged('');
|
|
|
|
buildSidebar();
|
|
buildNodeLayer();
|
|
|
|
const msg = areas.length
|
|
? `${areas.length} area(s), ${allNodes.length} node(s)`
|
|
: `No areas in config — ${allNodes.length} node(s) loaded`;
|
|
setStatus(msg);
|
|
|
|
if (areas.length) {
|
|
const allBounds = [];
|
|
areas.forEach(a => getLatLngs(a).forEach(p => allBounds.push(p)));
|
|
if (allBounds.length) map.fitBounds(L.latLngBounds(allBounds).pad(0.1));
|
|
}
|
|
} catch(e) {
|
|
setStatus('Error: ' + e.message, true);
|
|
}
|
|
}
|
|
|
|
function getLatLngs(area) {
|
|
if (area.polygon && area.polygon.length) return area.polygon.map(p => [p[0], p[1]]);
|
|
if (area.latMin != null) return [
|
|
[area.latMin, area.lonMin], [area.latMin, area.lonMax],
|
|
[area.latMax, area.lonMax], [area.latMax, area.lonMin],
|
|
];
|
|
return [];
|
|
}
|
|
|
|
function buildSidebar() {
|
|
const list = document.getElementById('area-list');
|
|
list.innerHTML = areas.length
|
|
? ''
|
|
: '<div style="padding:10px 14px;color:#aaa;font-size:0.8rem;">None defined</div>';
|
|
|
|
areas.forEach((area, i) => {
|
|
const color = COLORS[i % COLORS.length];
|
|
const item = document.createElement('div');
|
|
item.className = 'area-item';
|
|
item.innerHTML = `
|
|
<div class="area-swatch" style="background:${color}33;border:1px solid ${color}"></div>
|
|
<div style="flex:1;min-width:0">
|
|
<div class="area-label">${area.label || area.key}</div>
|
|
<div class="area-key">${area.key}</div>
|
|
</div>
|
|
<input type="checkbox" class="area-check" data-key="${area.key}" checked/>
|
|
`;
|
|
item.querySelector('.area-check').addEventListener('change', e => toggleArea(area.key, e.target.checked));
|
|
list.appendChild(item);
|
|
|
|
const lls = getLatLngs(area);
|
|
if (!lls.length) return;
|
|
const poly = L.polygon(lls, { color, fillColor: color, fillOpacity: 0.1, weight: 2 }).addTo(map);
|
|
poly.bindTooltip(area.label || area.key, { sticky: true });
|
|
|
|
const nodesGroup = L.layerGroup().addTo(map);
|
|
loadAreaNodes(area.key, color, nodesGroup);
|
|
areaLayers[area.key] = { poly, nodes: nodesGroup };
|
|
});
|
|
}
|
|
|
|
// Escape untrusted strings (e.g. mesh-advertised node names) before HTML interpolation. (#1536)
|
|
// 5-char OWASP set — including ' for single-quoted attribute safety.
|
|
function escapeHtml(s) {
|
|
if (s == null) return '';
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
// Paginate past the server's per-request node cap (listLimits.nodesMax) so the
|
|
// full node set is surfaced. This page is embeddable cross-origin via baseUrl,
|
|
// so it can't use app.js's fetchAllNodes; the loop is inlined. extra: query
|
|
// fragment '&'-prefixed (e.g. '&area=x'). Throws on a non-OK response so callers
|
|
// surface the error instead of silently rendering a truncated, complete-looking set.
|
|
async function fetchAllNodesPaged(extra) {
|
|
const PAGE = 500, CAP = 10000, out = [];
|
|
for (let off = 0; off < CAP; off += PAGE) {
|
|
const r = await fetch(`${baseUrl}/api/nodes?limit=${PAGE}&offset=${off}${extra}`);
|
|
if (!r.ok) throw new Error(`/api/nodes ${r.status} at offset ${off}`);
|
|
const d = await r.json();
|
|
const page = Array.isArray(d) ? d : (d.nodes || []);
|
|
out.push.apply(out, page);
|
|
// has_more, not page length: /api/nodes filters rows out AFTER the SQL
|
|
// LIMIT, so a short page can still have pages behind it. Zero-length is
|
|
// the fallback for a server that predates the flag.
|
|
if (page.length === 0) break;
|
|
if (typeof d.has_more === 'boolean' && !d.has_more) break;
|
|
}
|
|
// Dedup by public_key; rows missing one get a unique key so they aren't collapsed.
|
|
const seen = new Map();
|
|
out.forEach((n, i) => seen.set((n && n.public_key) || ('__nokey' + i), n));
|
|
return Array.from(seen.values());
|
|
}
|
|
|
|
async function loadAreaNodes(areaKey, color, group) {
|
|
try {
|
|
const list = await fetchAllNodesPaged(`&area=${encodeURIComponent(areaKey)}`);
|
|
list.forEach(n => {
|
|
if (!n.lat || !n.lon) return;
|
|
const m = L.circleMarker([n.lat, n.lon], { radius: 7, color, fillColor: color, fillOpacity: 0.85, weight: 2 });
|
|
m.bindPopup(`<div class="node-popup"><strong>${escapeHtml(n.name || n.public_key?.slice(0,8) || '?')}</strong><br>
|
|
${escapeHtml(n.public_key?.slice(0,16) || '')}…<br>GPS: ${n.lat.toFixed(5)}, ${n.lon.toFixed(5)}<br><em>${escapeHtml(areaKey)}</em></div>`);
|
|
group.addLayer(m);
|
|
});
|
|
} catch (e) { console.error('loadAreaNodes failed for', areaKey, e); }
|
|
}
|
|
|
|
function buildNodeLayer() {
|
|
allNodeLayer.clearLayers();
|
|
let count = 0;
|
|
allNodes.forEach(n => {
|
|
if (!n.lat || !n.lon || (Math.abs(n.lat) < 0.001 && Math.abs(n.lon) < 0.001)) return;
|
|
count++;
|
|
const m = L.circleMarker([n.lat, n.lon], { radius: 4, color: '#999', fillColor: '#bbb', fillOpacity: 0.6, weight: 1 });
|
|
m.bindPopup(`<div class="node-popup"><strong>${escapeHtml(n.name || n.public_key?.slice(0,8) || '?')}</strong><br>
|
|
${escapeHtml(n.public_key?.slice(0,16) || '')}…<br>GPS: ${n.lat.toFixed(5)}, ${n.lon.toFixed(5)}</div>`);
|
|
allNodeLayer.addLayer(m);
|
|
});
|
|
document.getElementById('node-count').textContent = count;
|
|
}
|
|
|
|
function toggleArea(key, visible) {
|
|
const g = areaLayers[key];
|
|
if (!g) return;
|
|
if (visible) { g.poly.addTo(map); g.nodes.addTo(map); }
|
|
else { map.removeLayer(g.poly); map.removeLayer(g.nodes); }
|
|
}
|
|
|
|
// ---- builder ----
|
|
function setDrawing(on) {
|
|
drawing = on;
|
|
const btn = document.getElementById('btn-draw');
|
|
btn.textContent = on ? 'Stop Drawing' : 'Draw';
|
|
btn.classList.toggle('active', on);
|
|
map.getContainer().style.cursor = on ? 'crosshair' : '';
|
|
}
|
|
|
|
function updateDrawOutput() {
|
|
const hint = document.getElementById('draw-hint');
|
|
const out = document.getElementById('builder-output');
|
|
hint.textContent = `${drawPoints.length} point${drawPoints.length !== 1 ? 's' : ''} — need ≥ 3`;
|
|
|
|
const key = document.getElementById('area-key').value.trim().toUpperCase();
|
|
const label = document.getElementById('area-label').value.trim();
|
|
|
|
if (drawPoints.length < 3 || !key) {
|
|
out.textContent = drawPoints.length < 3
|
|
? 'Draw at least 3 points…'
|
|
: 'Enter a key name above…';
|
|
out.classList.add('empty');
|
|
return;
|
|
}
|
|
out.classList.remove('empty');
|
|
const entry = { label: label || key, polygon: drawPoints };
|
|
out.textContent = `"${key}": ${JSON.stringify(entry, null, 2)}`;
|
|
}
|
|
|
|
function renderDrawPolygon() {
|
|
if (drawPolygon) { map.removeLayer(drawPolygon); drawPolygon = null; }
|
|
if (drawPoints.length >= 2) {
|
|
drawPolygon = L.polygon(drawPoints, {
|
|
color: '#e06020', fillColor: '#e06020', fillOpacity: 0.1, weight: 2, dashArray: drawPoints.length < 3 ? '6,4' : null
|
|
}).addTo(map);
|
|
}
|
|
updateDrawOutput();
|
|
}
|
|
|
|
map.on('click', function(e) {
|
|
if (!drawing) return;
|
|
const pt = [parseFloat(e.latlng.lat.toFixed(6)), parseFloat(e.latlng.lng.toFixed(6))];
|
|
drawPoints.push(pt);
|
|
const idx = drawPoints.length;
|
|
const m = L.circleMarker(e.latlng, {
|
|
radius: 6, color: '#e06020', weight: 2, fillColor: '#e06020', fillOpacity: 0.9
|
|
}).addTo(map).bindTooltip(String(idx), { permanent: true, direction: 'top', offset: [0,-8] });
|
|
drawMarkers.push(m);
|
|
renderDrawPolygon();
|
|
});
|
|
|
|
document.getElementById('btn-draw').addEventListener('click', () => setDrawing(!drawing));
|
|
|
|
document.getElementById('btn-undo').addEventListener('click', () => {
|
|
if (!drawPoints.length) return;
|
|
drawPoints.pop();
|
|
const m = drawMarkers.pop();
|
|
if (m) map.removeLayer(m);
|
|
renderDrawPolygon();
|
|
});
|
|
|
|
document.getElementById('btn-clear-draw').addEventListener('click', () => {
|
|
drawPoints = [];
|
|
drawMarkers.forEach(m => map.removeLayer(m));
|
|
drawMarkers = [];
|
|
if (drawPolygon) { map.removeLayer(drawPolygon); drawPolygon = null; }
|
|
setDrawing(false);
|
|
updateDrawOutput();
|
|
});
|
|
|
|
document.getElementById('area-key').addEventListener('input', updateDrawOutput);
|
|
document.getElementById('area-label').addEventListener('input', updateDrawOutput);
|
|
|
|
document.getElementById('btn-copy-area').addEventListener('click', () => {
|
|
const text = document.getElementById('builder-output').textContent;
|
|
if (!text || document.getElementById('builder-output').classList.contains('empty')) return;
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
const btn = document.getElementById('btn-copy-area');
|
|
btn.textContent = 'Copied!';
|
|
btn.classList.add('copied');
|
|
setTimeout(() => { btn.textContent = 'Copy JSON'; btn.classList.remove('copied'); }, 2000);
|
|
});
|
|
});
|
|
|
|
document.getElementById('btn-load').addEventListener('click', load);
|
|
document.getElementById('base-url').addEventListener('keydown', e => { if (e.key === 'Enter') load(); });
|
|
document.getElementById('show-nodes').addEventListener('change', e => {
|
|
if (e.target.checked) allNodeLayer.addTo(map);
|
|
else map.removeLayer(allNodeLayer);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|