From 39f71fdbcc7048ff528006c3392dd0554169d117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Thu, 10 Sep 2026 08:44:51 +0200 Subject: [PATCH 1/2] feat(web-standalone): finishing on the map applies what you picked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching which node represents a prefix in "Resolve on map" only ticked a checkbox in the Step 2 list. On most windows that panel is off-screen from the map, so "Done resolving" ended the mode with the pick stranded in a list the operator could not see, and the map redrew from applied state — back on the old pick, reading as if the click had not registered. Leaving the mode is the operator saying the picks are final, so it now applies them. applyClusterEditor() is pulled out of the button handler and used by both "Apply Inside Locked Cluster" and "Done resolving", keeping the one apply path #33 asked for: weights carry over, nothing reimplements the read. Only that button applies. switchToRegion() and syncControlStates() drop manual mode too, but they are abandoning the locked cluster, and applying on the way out would write picks into a region the operator just left. Closes #129 Co-Authored-By: Claude Opus 5 --- web-standalone/changelog.json | 6 +++ web-standalone/index.html | 85 ++++++++++++++++++++++------------- 2 files changed, 61 insertions(+), 30 deletions(-) diff --git a/web-standalone/changelog.json b/web-standalone/changelog.json index 1304e30..8aa48aa 100644 --- a/web-standalone/changelog.json +++ b/web-standalone/changelog.json @@ -1,4 +1,10 @@ [ + { + "id": "2026-09-10-done-resolving-applies", + "date": "2026-09-10", + "title": "Finishing on the map applies what you picked", + "body": "Swapping which node stands for a prefix on the map only ticked a box in the Step 2 list; until you scrolled back and pressed Apply Inside Locked Cluster the map kept showing the old pick. “Done resolving” now applies the picks, along with any weight you changed, and says so. Leaving the cluster instead, with Choose Another Cluster or by switching to a rival region, still applies nothing." + }, { "id": "2026-09-07-antenna-height-from-the-roof", "date": "2026-09-07", diff --git a/web-standalone/index.html b/web-standalone/index.html index 2084fe8..795352c 100644 --- a/web-standalone/index.html +++ b/web-standalone/index.html @@ -1391,7 +1391,7 @@

Duplicates

-

One prefix can match several real nodes. The one with proven links wins, else the one nearest the cluster. That fallback is a guess, not evidence, so it is called out in the cluster status line. The rest are listed unchecked as duplicate prefix so you can swap the pick.

+

One prefix can match several real nodes. The one with proven links wins, else the one nearest the cluster. That fallback is a guess, not evidence, so it is called out in the cluster status line. The rest are listed unchecked as duplicate prefix so you can swap the pick. Swapping on the map takes effect when you click Done resolving; in the list, with Apply Inside Locked Cluster.

Missing a repeater

@@ -2762,11 +2762,15 @@

Unknown Node Estimate

// prefix. // // It writes through the editor's own checkboxes rather than mutating - // currentMatchedNodes directly. That keeps ONE apply path: the existing - // "Apply to estimate" button reads the DOM, and the per-prefix weight - // carryover in syncClusterEditorFromLockedRegion() keeps a weight the - // operator set when the pick changes. A second write path would have to - // reimplement both and would drift. + // currentMatchedNodes directly. That keeps ONE apply path: applyClusterEditor() + // reads the DOM for both "Apply Inside Locked Cluster" and "Done resolving", + // and the per-prefix weight carryover in syncClusterEditorFromLockedRegion() + // keeps a weight the operator set when the pick changes. A second write path + // would have to reimplement both and would drift. + // + // Leaving the mode applies: the panel holding the pick is off-screen from + // the map on most windows, so "Done resolving" used to leave the map back on + // the old pick with the change stranded in a list the operator could not see. // // What this mode can and cannot say: it shows prefix AMBIGUITY, never // correctness. Which candidate is the real observer is not knowable from @@ -2837,7 +2841,7 @@

Unknown Node Estimate

groups.forEach((group) => selectDuplicateCandidate(group, autoPickFor(group))); renderManualLayer(); if (groups.length) { - clusterEditorStatus.textContent = `${groups.length} prefix${groups.length === 1 ? "" : "es"} back on the automatic pick. Apply to re-run the estimate.`; + clusterEditorStatus.textContent = `${groups.length} prefix${groups.length === 1 ? "" : "es"} back on the automatic pick. Applied when you finish resolving.`; } } @@ -3038,7 +3042,7 @@

Unknown Node Estimate

} updateApplyButtonState(); clusterEditorStatus.textContent = - `${group.prefix} now represented by ${node.adv_name || node.name || "the selected node"}. Apply to re-estimate.`; + `${group.prefix} now represented by ${node.adv_name || node.name || "the selected node"}. Applied when you finish resolving.`; renderManualLayer(); return true; } @@ -3060,7 +3064,7 @@

Unknown Node Estimate

manualBannerText.textContent = (groups.length ? `${groups.length} ambiguous prefix${groups.length === 1 ? "" : "es"}, ${others} other candidate${others === 1 ? "" : "s"} shown. Click one to use it.` : "No prefix in this cluster has more than one candidate.") - + (changed ? ` ${changed} changed from the automatic pick.` : "") + + (changed ? ` ${changed} changed from the automatic pick; “Done resolving” applies them.` : "") + rivalNote + " Drag an observer to move it." + (moved ? ` ${moved} moved.` : ""); @@ -4075,6 +4079,36 @@

Unknown Node Estimate

applyClusterWeightsButton.disabled = editorSignature() === editorBaselineSignature; } + // Whether the editor holds picks/weights that are not in the estimate yet. + function hasPendingClusterEdits() { + return !observerEditor.hidden && editorSignature() !== editorBaselineSignature; + } + + // The one apply path (#33): read the editor's rows and push them at the + // locked cluster. "Apply Inside Locked Cluster" and "Done resolving" both + // land here, so a map pick and a tick in the list reach the estimate the + // same way. + function applyClusterEditor() { + const selectedNodes = []; + observerEditor.querySelectorAll(".observer-row").forEach((row) => { + const checkbox = row.querySelector('input[type="checkbox"]'); + const weightInput = row.querySelector(".observer-weight"); + if (!checkbox || !weightInput || !checkbox.checked) { + return; + } + const weight = Number.parseFloat(weightInput.value || "1"); + selectedNodes.push({ + id: checkbox.dataset.nodeid, + weight: Number.isFinite(weight) && weight > 0 ? weight : 1 + }); + }); + if (!selectedNodes.length) { + clusterEditorStatus.textContent = "Select at least one resolved observer inside the locked cluster."; + return false; + } + return reweightLockedCluster(selectedNodes); + } + function reweightLockedCluster(selectedNodes, missingPrefixes = []) { if (!currentResolvedContext) { return false; @@ -4607,26 +4641,7 @@

Unknown Node Estimate

if (el) el.addEventListener("input", () => { resetShareUi(); syncAddressBar(); }); }); terrainRescoreButton.addEventListener("click", runTerrainRescore); - applyClusterWeightsButton.addEventListener("click", () => { - const selectedNodes = []; - observerEditor.querySelectorAll(".observer-row").forEach((row) => { - const checkbox = row.querySelector('input[type="checkbox"]'); - const weightInput = row.querySelector(".observer-weight"); - if (!checkbox || !weightInput || !checkbox.checked) { - return; - } - const weight = Number.parseFloat(weightInput.value || "1"); - selectedNodes.push({ - id: checkbox.dataset.nodeid, - weight: Number.isFinite(weight) && weight > 0 ? weight : 1 - }); - }); - if (!selectedNodes.length) { - clusterEditorStatus.textContent = "Select at least one resolved observer inside the locked cluster."; - return; - } - reweightLockedCluster(selectedNodes); - }); + applyClusterWeightsButton.addEventListener("click", applyClusterEditor); addClusterPrefixesButton.addEventListener("click", () => { const prefixes = parseWeightedPrefixes(clusterAddPrefixInput.value).map((item) => item.prefix); if (!prefixes.length) { @@ -4664,7 +4679,17 @@

Unknown Node Estimate

if (manualResetButton) manualResetButton.addEventListener("click", resetDuplicatePicks); if (manualPositionsButton) manualPositionsButton.addEventListener("click", restoreAllPositions); if (manualModeButton) { - manualModeButton.addEventListener("click", () => setManualMode(!manualMode)); + // Leaving the mode is the operator saying the picks are final, so it + // applies them instead of stranding them in a panel that is off-screen + // from the map. Only this button does it: switchToRegion() and + // syncControlStates() also drop manual mode, but they are abandoning the + // locked cluster, and applying on the way out would write picks into a + // region the operator just left. + manualModeButton.addEventListener("click", () => { + const leaving = manualMode; + if (leaving && hasPendingClusterEdits()) applyClusterEditor(); + setManualMode(!leaving); + }); } changeClusterButton.addEventListener("click", () => { if (manualMode) setManualMode(false); From 5f2fec2ef7334939058ffd4d2c4690516fa59575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Thu, 10 Sep 2026 08:45:04 +0200 Subject: [PATCH 2/2] fix(web-standalone): adding a prefix adds one observer, not every node carrying it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Step 2 list holds every candidate a prefix resolved to, so matching on the prefix and ticking each row activated both nodes of an ambiguous prefix at once. A single prefix then contributed twice, from positions that can be tens of km apart, and pulled the estimate to somewhere between them — silently. It also ticked proven-link support nodes, which are range-only and never represent a prefix. Add now picks one node per prefix: the one dedupeByPrefix() kept, a support node only as a last resort, and it says when there was more than one candidate rather than presenting the choice as settled (#85). Swapping the pick stays in "Resolve on map", where the geometry is visible. A prefix the operator already has active is left alone. Closes #130 Co-Authored-By: Claude Opus 5 --- web-standalone/changelog.json | 6 ++++ web-standalone/index.html | 53 ++++++++++++++++++++++++++--------- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/web-standalone/changelog.json b/web-standalone/changelog.json index 8aa48aa..2762e96 100644 --- a/web-standalone/changelog.json +++ b/web-standalone/changelog.json @@ -1,4 +1,10 @@ [ + { + "id": "2026-09-10-add-prefix-adds-one-observer", + "date": "2026-09-10", + "title": "Adding a prefix adds one observer, not every node that carries it", + "body": "Typing a prefix into Add Prefixes In Locked Cluster ticked every node with that prefix. When a prefix resolved to more than one node, both went into the estimate, from positions that can be tens of km apart, and pulled the answer between them. It now adds the same node the automatic de-duplication kept, says when there was more than one to choose from, and leaves the swap to Resolve on map. A prefix you already picked yourself is left alone." + }, { "id": "2026-09-10-done-resolving-applies", "date": "2026-09-10", diff --git a/web-standalone/index.html b/web-standalone/index.html index 795352c..47676a6 100644 --- a/web-standalone/index.html +++ b/web-standalone/index.html @@ -4648,25 +4648,52 @@

Unknown Node Estimate

clusterEditorStatus.textContent = "Enter one or more repeater prefixes that exist inside the locked cluster."; return; } - const found = new Set(); + // One prefix stands for ONE observer. The editor lists every candidate a + // prefix resolved to, so ticking each matching row let a single prefix + // contribute twice, from two positions that can be tens of km apart, and + // pull the estimate between them. Add the same node dedupeByPrefix() kept + // and leave the rest to "Resolve on map", which is where a swap belongs. + const rowsByPrefix = new Map(); observerEditor.querySelectorAll(".observer-row").forEach((row) => { + const checkbox = row.querySelector('input[type="checkbox"]'); + if (!checkbox || !row.querySelector(".observer-weight")) return; + const prefix = checkbox.dataset.prefix; + if (!rowsByPrefix.has(prefix)) rowsByPrefix.set(prefix, []); + rowsByPrefix.get(prefix).push(row); + }); + const rowNodeId = (row) => row.querySelector('input[type="checkbox"]').dataset.nodeid; + const found = new Set(); + const ambiguous = []; + prefixes.forEach((prefix) => { + const rows = rowsByPrefix.get(prefix); + if (!rows || !rows.length) return; + found.add(prefix); + // Already represented — a pick in force is the operator's, not ours. + if (rows.some((row) => row.querySelector('input[type="checkbox"]').checked)) return; + // A proven-link support node is range-only and never represents a + // prefix, so it is the last resort rather than the first row. + const real = rows.filter((row) => observerExclusionReasons.get(rowNodeId(row)) !== "proven-link (range only)"); + const pool = real.length ? real : rows; + const row = pool.find((candidate) => !observerExclusionReasons.has(rowNodeId(candidate))) || pool[0]; const checkbox = row.querySelector('input[type="checkbox"]'); const weightInput = row.querySelector(".observer-weight"); - if (!checkbox || !weightInput) { - return; - } - if (prefixes.includes(checkbox.dataset.prefix)) { - checkbox.checked = true; - if (!Number.parseFloat(weightInput.value)) { - weightInput.value = "1.00"; - } - found.add(checkbox.dataset.prefix); + checkbox.checked = true; + if (!Number.parseFloat(weightInput.value)) { + weightInput.value = "1.00"; } + if (pool.length > 1) ambiguous.push(`${prefix} (${pool.length} candidates)`); }); const missing = prefixes.filter((prefix) => !found.has(prefix)); - clusterEditorStatus.textContent = missing.length - ? `Added prefixes inside locked cluster: ${Array.from(found).join(", ")}. Not found here: ${missing.join(", ")}.` - : `Added prefixes inside locked cluster: ${Array.from(found).join(", ")}.`; + // Which candidate is the real observer is not knowable here (#85), so the + // choice is named rather than presented as settled. + const ambiguousNote = ambiguous.length + ? ` More than one node carries ${ambiguous.join(", ")} — the automatic pick was added; swap it with “Resolve on map”.` + : ""; + clusterEditorStatus.textContent = (found.size + ? `Added prefixes inside locked cluster: ${Array.from(found).join(", ")}.` + : "None of those prefixes are inside the locked cluster.") + + (missing.length ? ` Not found here: ${missing.join(", ")}.` : "") + + ambiguousNote; updateApplyButtonState(); }); // Enable Apply only when the editor differs from the last applied state.