// ==UserScript==
// @name WME Quick HN Importer - Slovenia
// @namespace https://github.com/zigapovhe/wme-sl-hn-import
// @version 2.4.1
// @description Quickly add Slovenian house numbers with clickable overlays
// @author ThatByte
// @downloadURL https://raw.githubusercontent.com/zigapovhe/wme-sl-hn-import/main/wme-sl-hn-import.user.js
// @updateURL https://raw.githubusercontent.com/zigapovhe/wme-sl-hn-import/main/wme-sl-hn-import.user.js
// @supportURL https://github.com/zigapovhe/wme-sl-hn-import/issues
// @icon https://raw.githubusercontent.com/zigapovhe/wme-sl-hn-import/main/icon48.png
// @icon64 https://raw.githubusercontent.com/zigapovhe/wme-sl-hn-import/main/icon64.png
// @match https://www.waze.com/editor*
// @match https://www.waze.com/*/editor*
// @match https://beta.waze.com/*
// @exclude https://www.waze.com/user/editor*
// @connect ipi.eprostor.gov.si
// @connect raw.githubusercontent.com
// @require https://cdnjs.cloudflare.com/ajax/libs/proj4js/2.9.0/proj4.js
// @grant GM_xmlhttpRequest
// @grant GM_setClipboard
// @license MIT
// @noframes
// ==/UserScript==
/*
* Click handling and nearest segment matching based on work by
* Tom 'Glodenox' Puttemans (https://github.com/Glodenox/wme-quick-hn-importer)
*/
/* global I18n, proj4, getWmeSdk, unsafeWindow */
(function () {
'use strict';
let wmeSDK;
const SDK_LAYER_NAME = 'qhnsl-sdk';
const SDK_STREETNAMES_LAYER_NAME = 'qhnsl-streetnames';
const SDK_AUDIT_LAYER_NAME = 'qhnsl-audit';
const SDK_NAVPOINTS_LAYER_NAME = 'qhnsl-navpoints';
const MAX_CLICK_DISTANCE_PX = 25;
// Below this zoom the overlays are hidden: the markers would overlap into noise.
const MIN_OVERLAY_ZOOM = 18;
// Auto-load debounce: click-dragging across several segments should cost one fetch.
const AUTO_LOAD_DEBOUNCE_MS = 400;
// How long after a map move/zoom a click is treated as part of that gesture rather
// than a deliberate click on a marker.
const CLICK_AFTER_MOVE_GRACE_MS = 250;
const MAX_HN_CONFLICT_DISTANCE = 10;
// A WME house number counts as matched only if eProstor has that number on the
// same street within this many metres. Above it, the number is reported as
// misplaced rather than missing.
const AUDIT_MAX_DISTANCE = 30;
// Waze road types house numbers should never attach to, using the ids from the
// SDK's ROAD_TYPE constant:
// 5 WALKING_TRAIL 10 PEDESTRIAN_BOARDWALK
// 9 WALKWAY 16 STAIRWAY
// 18 RAILROAD 19 RUNWAY_TAXIWAY
// WALKWAY (9) is the non-routable pedestrian type and the most common pedestrian
// geometry in Slovenian residential areas — without it a courtyard path that
// happens to be nearer than the real street could take the house number, and
// could then be renamed by the fix-street flow, which shares this matching.
const NON_ADDRESSABLE_ROAD_TYPES = new Set([5, 9, 10, 16, 18, 19]);
// A name-matched segment counts as "suspiciously far" when it is farther than
// FAR_STREET_MIN_DISTANCE meters AND more than FAR_STREET_RATIO times farther
// than the closest segment of any name. Both must hold: the floor keeps
// driveway-adjacent houses quiet, the ratio keeps remote farmhouses quiet.
const FAR_STREET_MIN_DISTANCE = 50;
const FAR_STREET_RATIO = 2;
// Radius in pixels for a map circle wide enough to hold its house-number label.
// Shared by every overlay that draws one, so a longer number cannot spill out of one
// of them: the sum used to be written out twice and the two copies had to be kept in
// step by hand.
function labelFitRadius(number) {
return number ? Math.max(String(number).length * 7, 12) : 12;
}
// Added on top for circles that must read as foreground rather than as one more point
// in the field: the audit markers, and the red circles a click will refuse. Without it
// they sat flush with their neighbours and read as background.
const MARKER_RADIUS_MARGIN = 6;
// EProstor API configuration
const EPROSTOR_API = 'https://ipi.eprostor.gov.si/wfs-si-gurs-rn/ogc/features/collections/SI.GURS.RN:REGISTER_NASLOVOV/items';
const EPROSTOR_LIMIT = 1000;
const EPROSTOR_MAX_PAGES = 30; // hard cap: 30 pages × 1000 addresses per load
// eProstor times out at random, on areas of any size — transport flakiness rather than a
// slow query, which is the class another attempt recovers.
//
// The timeout stays at 30 s deliberately. If failures are random then a shorter timeout
// plus a retry recovers faster than one long wait, but a 500 m buffer over a dense area
// plausibly has legitimate 20-28 s pages that a shorter timeout would start killing.
// Trading working loads for faster failure recovery needs measurement first.
//
// The per-load budget is what bounds the damage: without it, 30 pages at ~94 s of
// retrying each is a 47-minute worst case reachable by clicking Load.
const FETCH_TIMEOUT_MS = 30000;
const FETCH_PAGE_RETRIES = 2; // 3 attempts per page
const FETCH_RETRY_BACKOFF_MS = [1000, 3000];
const FETCH_RETRY_BUDGET = 4; // retries per load, across all pages
// Shown in the status box on startup and after Clear
const INSTRUCTIONS_HTML = `<b>Instructions</b><br/>
1) Select a segment • 2) Click "Load selected street" • 3) <b>Click house numbers on map to add them</b><br/>
Green = selected street • Orange = other streets • Red = conflict or wrong street • Faded = already in WME`;
// Common Slovenian street name abbreviations
const ABBREVIATIONS = {
'c.': 'cesta',
'ul.': 'ulica',
'nab.': 'nabrežje',
'trg.': 'trg'
};
const LS = {
getBuffer() { return Number(localStorage.getItem('qhnsl-buffer') ?? '500'); },
setBuffer(v) { localStorage.setItem('qhnsl-buffer', String(v)); },
getLayerVisible() { return localStorage.getItem('qhnsl-layer-visible') === '1'; },
setLayerVisible(v){ localStorage.setItem('qhnsl-layer-visible', v ? '1' : '0'); },
getSelectedOnly() { return localStorage.getItem('qhnsl-selected-only') === '1'; },
setSelectedOnly(v){ localStorage.setItem('qhnsl-selected-only', v ? '1' : '0'); },
getNavPoints() { return localStorage.getItem('qhnsl-navpoints') === '1'; },
setNavPoints(v) { localStorage.setItem('qhnsl-navpoints', v ? '1' : '0'); },
getStreetNames() { return localStorage.getItem('qhnsl-street-names') !== '0'; }, // default on
setStreetNames(v) { localStorage.setItem('qhnsl-street-names', v ? '1' : '0'); },
getAudit() { return localStorage.getItem('qhnsl-audit') === '1'; },
setAudit(v) { localStorage.setItem('qhnsl-audit', v ? '1' : '0'); },
getAutoLoad() { return localStorage.getItem('qhnsl-autoload') === '1'; },
setAutoLoad(v) { localStorage.setItem('qhnsl-autoload', v ? '1' : '0'); }
};
const TOAST_COLORS = {
info: { bg: '#e7f1ff', border: '#5b9bd5', text: '#12385e' },
success: { bg: '#d4edda', border: '#28a745', text: '#155724' },
warning: { bg: '#fff3cd', border: '#ffc107', text: '#7a5b00' },
error: { bg: '#f8d7da', border: '#dc3545', text: '#721c24' }
};
const TOAST_TIMEOUT_MS = 4000;
// Self-rolled because the SDK has no notifications API: its class list is
// BigJunctions … Sidebar, States, Streets, Venues, with nothing for toasts. The
// previous wmeSDK.Notifications.show call could never fire, so every message the
// script tried to show — including "result truncated, reduce the buffer" — went only
// to the console where nobody was looking. Built with plain DOM, like the
// fix-street dialog, so it depends on nothing else being installed.
let toastHost = null;
const toast = (msg, type = 'info') => {
console.info(`[SL-HN] ${msg}`); // keep the console trail for debugging
try {
if (!toastHost) {
toastHost = document.createElement('div');
toastHost.style.cssText = 'position:fixed;top:70px;right:16px;z-index:10001;'
+ 'display:flex;flex-direction:column;gap:6px;align-items:flex-end;'
+ 'pointer-events:none;max-width:340px;';
document.body.appendChild(toastHost);
}
const colors = TOAST_COLORS[type] || TOAST_COLORS.info;
const el = document.createElement('div');
el.textContent = msg; // textContent, not innerHTML: messages interpolate street names
el.style.cssText = `background:${colors.bg};border:1px solid ${colors.border};`
+ `color:${colors.text};border-radius:4px;padding:8px 12px;font-size:12px;`
+ 'box-shadow:0 2px 6px rgba(0,0,0,0.2);opacity:0;transition:opacity 150ms;';
toastHost.appendChild(el);
requestAnimationFrame(() => { el.style.opacity = '1'; });
setTimeout(() => {
el.style.opacity = '0';
setTimeout(() => el.remove(), 200);
}, TOAST_TIMEOUT_MS);
} catch (e) {
console.debug('[SL-HN] toast render failed:', e);
}
};
// True in Tampermonkey, false when this file is require()d by the test suite,
// which has no DOM, no @require'd proj4 and no WME SDK. Everything gated on it
// is browser-only setup; the pure logic below stays reachable either way.
const IN_USERSCRIPT_ENV = typeof unsafeWindow !== 'undefined' || typeof window !== 'undefined';
// EPSG:3794 definition (Slovenia D96/TM)
if (IN_USERSCRIPT_ENV && typeof proj4 !== 'undefined' && !proj4.defs['EPSG:3794']) {
proj4.defs(
'EPSG:3794',
'+proj=tmerc +lat_0=0 +lon_0=15 +k=0.9999 +x_0=500000 +y_0=-5000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs'
);
}
// The join key for street names on both sides of every comparison. Trimmed for the
// same reason normalizeHN is: a WME street typed with a trailing space would key as
// "celovska_cesta_", match nothing in the eProstor data, and silently drop the whole
// street out of the audit while its circles stayed clickable.
function normalizeStreetName(name) {
return String(name).trim().toLowerCase().replace(/\s+/g, '_');
}
// Escape HTML special characters for safe attribute insertion
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/</g, '<')
.replace(/>/g, '>');
}
// Expand abbreviations and normalize for comparison
function normalizeForComparison(name) {
let normalized = String(name).toLowerCase().trim();
for (const [abbrev, full] of Object.entries(ABBREVIATIONS)) {
const escapedAbbrev = abbrev.replace(/\./g, '\\.');
const regex = new RegExp('(^|\\s)' + escapedAbbrev + '(?=\\s|$)', 'gi');
normalized = normalized.replace(regex, '$1' + full);
}
// Remove extra whitespace
normalized = normalized.replace(/\s+/g, ' ');
return normalized;
}
function removeDiacritics(str) {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
// Calculate similarity between two strings (0-1)
function calculateSimilarity(str1, str2) {
const s1 = normalizeForComparison(str1);
const s2 = normalizeForComparison(str2);
// Exact match after normalization
if (s1 === s2) return 1.0;
// Match without diacritics
if (removeDiacritics(s1) === removeDiacritics(s2)) return 0.95;
// Levenshtein distance based similarity
const distance = levenshteinDistance(s1, s2);
const maxLen = Math.max(s1.length, s2.length);
const similarity = 1 - (distance / maxLen);
return similarity;
}
// Levenshtein distance implementation
function levenshteinDistance(str1, str2) {
const m = str1.length;
const n = str2.length;
const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost
);
}
}
return dp[m][n];
}
function getHNGeometry(hn) {
if (!hn?.geometry?.coordinates) return null;
return { x: hn.geometry.coordinates[0], y: hn.geometry.coordinates[1] };
}
function getSelectedSegments() {
const sel = wmeSDK.Editing.getSelection();
if (!sel || sel.objectType !== 'segment') return [];
return sel.ids
.map(id => wmeSDK.DataModel.Segments.getById({ segmentId: id }))
.filter(Boolean);
}
// The single normalization rule for comparing house numbers. Both sides of
// every comparison must go through this: eProstor writes "12a" where an editor
// may have typed "12 a", and a mismatch makes the audit report valid data as
// missing. Internal whitespace is collapsed; "/" is deliberately kept, since
// it can distinguish genuinely different addresses.
function normalizeHN(value) {
return String(value == null ? '' : value).trim().toLowerCase().replace(/\s+/g, '');
}
// Build house number string from components
function buildHouseNumber(stevilka, dodatek) {
let hn = String(stevilka || '').trim();
if (dodatek) {
hn += String(dodatek).trim();
}
return normalizeHN(hn);
}
// Check if a house number has a nearby conflict (different HN within threshold distance)
function hasConflict(hn, wx, wy, entry) {
if (!entry?.items?.length) return false;
for (const it of entry.items) {
if (!it || it.x == null || it.y == null) continue;
if (it.num !== hn) {
const dx = wx - it.x, dy = wy - it.y;
if (dx * dx + dy * dy <= MAX_HN_CONFLICT_DISTANCE * MAX_HN_CONFLICT_DISTANCE) {
return true;
}
}
}
return false;
}
// Reverse audit: WME house numbers with no eProstor counterpart.
// Pure by design — every input is a parameter, so the test suite can exercise it
// without a browser, an SDK or a network. Keep it that way: read no closure state.
// features: eProstor points, each { street, number, eX, eY }
// selectionHNMap: WME house numbers, street key -> { items: [...] }
// loadedBbox: EPSG:3794 area eProstor was fetched for, or null
function computeAuditFindings(features, selectionHNMap, loadedBbox) {
const findings = [];
if (!features || !features.length || !selectionHNMap) return findings;
// Without a known fetch area we cannot tell "absent from eProstor" from
// "outside what we asked eProstor about".
if (!loadedBbox) return findings;
// eProstor side: street -> number -> [projected points]
const official = new Map();
features.forEach(f => {
if (!f.street || !f.number) return;
let byNum = official.get(f.street);
if (!byNum) { byNum = new Map(); official.set(f.street, byNum); }
const num = normalizeHN(f.number);
let pts = byNum.get(num);
if (!pts) { pts = []; byNum.set(num, pts); }
pts.push({ eX: f.eX, eY: f.eY });
});
// WME side: one record per house number. The same HN is indexed under a
// segment's primary AND alternate names, so fold by id first — otherwise
// every dual-named segment becomes a false positive.
const wmeHns = new Map();
selectionHNMap.forEach((entry, streetKey) => {
entry.items.forEach(it => {
if (!it.hnId) return;
let rec = wmeHns.get(it.hnId);
if (!rec) {
rec = {
hnId: it.hnId, num: it.num, x: it.x, y: it.y,
lon: it.lon, lat: it.lat, segmentId: it.segmentId,
streetKeys: new Set()
};
wmeHns.set(it.hnId, rec);
}
rec.streetKeys.add(streetKey);
});
});
const maxSq = AUDIT_MAX_DISTANCE * AUDIT_MAX_DISTANCE;
wmeHns.forEach(rec => {
if (rec.lon == null || rec.lat == null) return;
// Compare like with like. selectionHNMap is scoped to the current viewport,
// which drifts as the user pans, while `official` is frozen to the fetched
// bbox. Auditing a house number outside that bbox would flag valid data as
// missing purely because we never asked eProstor about it.
if (rec.x == null || rec.y == null) return;
// Inset by AUDIT_MAX_DISTANCE, not flush to the fetched box. A pin just inside
// the edge can legitimately match an eProstor point just outside it — which the
// CQL filter never returned — so judging that band produces false "missing".
if (rec.x < loadedBbox.minE + AUDIT_MAX_DISTANCE || rec.x > loadedBbox.maxE - AUDIT_MAX_DISTANCE ||
rec.y < loadedBbox.minN + AUDIT_MAX_DISTANCE || rec.y > loadedBbox.maxN - AUDIT_MAX_DISTANCE) return;
let audited = false;
let numberExistsSomewhere = false;
let matched = false;
for (const key of rec.streetKeys) {
const byNum = official.get(key);
if (!byNum) continue; // street not in eProstor data: out of scope
audited = true;
const pts = byNum.get(rec.num);
if (!pts || !pts.length) continue; // number absent on this street
numberExistsSomewhere = true;
if (pts.some(p => {
const dx = p.eX - rec.x;
const dy = p.eY - rec.y;
return dx * dx + dy * dy <= maxSq;
})) { matched = true; break; }
}
if (!audited || matched) return;
findings.push({
hnId: rec.hnId,
number: rec.num,
segmentId: rec.segmentId,
lon: rec.lon,
lat: rec.lat,
// EPSG:3794, so the wrong-street pairing can compare metres against a feature's
// eX/eY without reprojecting. lon/lat above are for map centring only.
eX: rec.x,
eY: rec.y,
streetKeys: Array.from(rec.streetKeys),
type: numberExistsSomewhere ? 'misplaced' : 'missing'
});
});
return findings;
}
// The wrong-street case: an eProstor address nobody has added, sitting on top of a WME
// house number with the same number under a *different* street name. Both sides must be
// unaccounted for — an eProstor point already in WME is not waiting for anything, and a
// house number the audit could explain belongs to the street it is on. Without that
// second half, two legitimate same-numbered addresses at a corner would flag each other.
// Pure by design, like computeAuditFindings: read no closure state.
// features: eProstor points, each { number, street, eX, eY, processed }. Unlike
// computeAuditFindings, this does not re-normalize `number` — callers must have
// already run it through normalizeHN.
// findings: computeAuditFindings output
// Returned `distance` is metres; nothing in production reads it, only the tests do.
function findWrongStreetPairs(features, findings) {
const pairs = [];
if (!features || !features.length || !findings || !findings.length) return pairs;
const maxSq = MAX_HN_CONFLICT_DISTANCE * MAX_HN_CONFLICT_DISTANCE;
const candidates = [];
features.forEach(feature => {
if (!feature || feature.processed) return;
// Number.isFinite, not != null: a NaN coordinate passes every comparison below
// (distSq > maxSq is false for NaN), so it would pair with everything and then
// wreck the sort. proj4 rejects non-finite input today, but that is its invariant
// to keep, not ours.
if (!feature.number || !Number.isFinite(feature.eX) || !Number.isFinite(feature.eY)) return;
findings.forEach(finding => {
// 'misplaced' means the number *was* found on that street, just far away. That is
// a different diagnosis and keeps its own purple marker.
if (finding.type !== 'missing') return;
if (finding.number !== feature.number) return;
if (!Number.isFinite(finding.eX) || !Number.isFinite(finding.eY)) return;
// A shared name means the audit already judged this pairing and called it matched
// or misplaced. Only a genuine name mismatch is the wrong-street case.
// Unreachable when both arguments come from the same feature array, as both call
// sites pass them: a feature whose own street key carries this number indexed
// itself into `official`, so computeAuditFindings typed the finding 'misplaced'
// and the check above already returned. The distance constants have nothing to do
// with it — AUDIT_MAX_DISTANCE only ever chooses between no finding and
// 'misplaced'. Kept for callers that mix a finding list with a different feature
// list, and because it costs one array lookup.
if ((finding.streetKeys || []).includes(feature.street)) return;
const dx = finding.eX - feature.eX;
const dy = finding.eY - feature.eY;
const distSq = dx * dx + dy * dy;
if (distSq > maxSq) return;
candidates.push({ feature, finding, distSq });
});
});
// Nearest first, one-to-one. Without this, one house number could redden two circles
// on a street that carries the same number twice a few metres apart. sort is stable,
// so equidistant candidates keep input order.
candidates.sort((a, b) => a.distSq - b.distSq);
const usedFeatures = new Set();
const usedFindings = new Set();
candidates.forEach(c => {
if (usedFeatures.has(c.feature) || usedFindings.has(c.finding)) return;
usedFeatures.add(c.feature);
usedFindings.add(c.finding);
pairs.push({ feature: c.feature, finding: c.finding, distance: Math.sqrt(c.distSq) });
});
return pairs;
}
// Applies the wrong-street verdict: the circle records which WME house number it clashes
// with, and the finding is dropped so no purple marker claims the number is absent from
// eProstor when it plainly is not.
// `wrongStreet` is the only field written, deliberately. An earlier version also set
// `conflict` — one fact in two fields, with only one of them reset below, so a stale red
// could outlive its own explanation. The red is derived from `wrongStreet` at render time
// instead, which makes this reset the whole story.
// Clearing every feature first is not optional: a marking left from the previous run
// would survive the user fixing the street and keep refusing the add, the same trap the
// wme-after-edit handler recomputes to avoid.
function applyWrongStreetPairs(features, findings) {
(features || []).forEach(f => { if (f) f.wrongStreet = null; });
const pairs = findWrongStreetPairs(features, findings);
if (!pairs.length) return findings || [];
const paired = new Set();
pairs.forEach(({ feature, finding }) => {
feature.wrongStreet = {
segmentId: finding.segmentId,
// The street keys WME has this house number under. The click needs them to name
// the right one when an alternate name carried the match, and the "selected street
// only" filter needs them to keep this circle on screen while that street is the
// selected one — which is exactly the state the click itself creates.
streetKeys: finding.streetKeys || []
};
paired.add(finding);
});
return findings.filter(f => !paired.has(f));
}
// The EPSG:3794 box to ask eProstor about: the selected segments' extent, grown by
// `buffer` metres. Returns null when no segment has usable geometry.
// proj4 is passed in so this stays testable without the @require'd global.
function computeFetchBbox(segments, buffer, project = proj4) {
let minLon = Infinity, maxLon = -Infinity;
let minLat = Infinity, maxLat = -Infinity;
for (const seg of segments || []) {
const coords = seg?.geometry?.coordinates;
if (!Array.isArray(coords)) continue;
for (const [lon, lat] of coords) {
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
}
}
if (minLon === Infinity) return null;
const [blE, blN] = project('EPSG:4326', 'EPSG:3794', [minLon, minLat]);
const [trE, trN] = project('EPSG:4326', 'EPSG:3794', [maxLon, maxLat]);
return {
minE: Math.floor(blE - buffer),
minN: Math.floor(blN - buffer),
maxE: Math.ceil(trE + buffer),
maxN: Math.ceil(trN + buffer)
};
}
// True when every point of every selected segment lies inside `bbox` (EPSG:3794),
// i.e. we already hold reference data covering that selection.
//
// Answers false unless it actually verified at least one point. Claiming coverage
// after examining nothing — no geometry, or NaN coordinates where every comparison
// is false — made auto-load silently stop fetching.
// proj4 is injectable so this stays testable outside the browser.
function isSelectionInsideBbox(segments, bbox, project = proj4) {
if (!bbox) return false;
let checked = 0;
for (const seg of segments || []) {
const coords = seg?.geometry?.coordinates;
if (!Array.isArray(coords)) continue;
for (const pt of coords) {
const [e, n] = project('EPSG:4326', 'EPSG:3794', [pt[0], pt[1]]);
if (!Number.isFinite(e) || !Number.isFinite(n)) return false;
checked++;
if (e < bbox.minE || e > bbox.maxE || n < bbox.minN || n > bbox.maxN) return false;
}
}
return checked > 0;
}
// Auto-load's whole decision, kept pure so every guard is reachable from a test —
// the browser side only gathers state. Returns:
// 'load' fetch this selection
// 'defer' ask again once the self-selection window closes
// 'drop' forget this selection change
function decideAutoLoad({
enabled,
isLoading,
dialogOpen,
selectedIds = [],
selfSelectionIds = null,
now = 0,
suppressUntil = 0,
selectionCovered = false
}) {
if (!enabled || isLoading) return 'drop';
// The fix-street dialog renames whatever is selected NOW, and its own text asks the
// user to adjust that selection on the map. Such an adjustment is a genuine
// selection change, so no suppression window covers it — and loading would call
// clearFixStreetState and take the dialog away mid-decision. Drop rather than
// defer: a deferred run just returns while the dialog is still open.
if (dialogOpen) return 'drop';
if (!selectedIds.length) return 'drop';
// Our own setSelection raises wme-selection-changed like any other. The suppression
// window alone could not stop it, because the deferred run below outlives the
// window and then loads against the very selection the script made, wiping the
// audit finding or the dialog the user had just opened. Identity, not timing, is
// what makes a selection ours.
if (isSameSelection(selectedIds, selfSelectionIds)) return 'drop';
// A genuine selection arriving inside the window is honoured once it closes.
// Dropping it outright meant that, since every house-number add marks a self
// selection, click-add-move-on work suppressed auto-load indefinitely.
if (now < suppressUntil) return 'defer';
if (selectionCovered) return 'drop';
return 'load';
}
// Set comparison: the SDK hands back ids in no guaranteed order, and mixes numeric
// and string ids depending on the call.
function isSameSelection(a, b) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
const seen = new Set(b.map(String));
return a.every(id => seen.has(String(id)));
}
// Grid size for the location component of a feature key. Anything coarser than a
// town and finer than the distance between towns works.
const FEAT_KEY_GRID_M = 1000;
// Identifies one address for the "added this session but not yet saved" set.
//
// The location component is load-bearing: keyed on street name and number alone,
// "Glavna cesta 12" in one town collided with "Glavna cesta 12" in another. Adding
// the first without saving then made the second look already-added — its circle
// faded, "Show only missing" hid it, and clicking did nothing, so a genuinely
// absent house number could not be added at all until the editor was saved.
//
// Derived from the address's own EPSG:3794 coordinates, so it is stable for a given
// address regardless of where the grid boundaries fall.
function makeFeatKey(streetId, number, eX, eY) {
const cell = (Number.isFinite(eX) && Number.isFinite(eY))
? `${Math.floor(eX / FEAT_KEY_GRID_M)}:${Math.floor(eY / FEAT_KEY_GRID_M)}`
: 'nocoords';
return `${streetId}|${number}|${cell}`;
}
// Build CQL filter for coordinate bounds (excludes apartments)
function buildCqlFilter(minE, minN, maxE, maxN) {
return `E>=${minE} AND E<=${maxE} AND N>=${minN} AND N<=${maxN} AND ST_STANOVANJA IS NULL`;
}
// What went wrong with one page request, and is it worth another attempt?
// kind: 'timeout' | 'error' | 'load' — which GM_xmlhttpRequest handler fired
// status: HTTP status when there is one
// `reason` is user-facing and completes the sentence "eProstor ...", so the message can
// say what actually happened instead of blaming every failure on a timeout.
//
// The 4xx/5xx split is not decoration: a non-2xx response reaches onload and dies inside
// JSON.parse, which is why a rejected query used to surface as "Unexpected token <".
function classifyFetchFailure(kind, status) {
if (kind === 'timeout') return { retryable: true, reason: 'timed out' };
if (kind === 'error') return { retryable: true, reason: 'request failed' };
if (typeof status === 'number' && status >= 500) {
return { retryable: true, reason: 'returned a server error' };
}
if (typeof status === 'number' && status >= 400) {
// Our query is what it objects to. Repeating it verbatim cannot help, and would
// spend the retry budget a genuinely flaky page might need.
return { retryable: false, reason: `rejected the request (HTTP ${status})` };
}
return { retryable: false, reason: 'returned an unreadable response' };
}
// Given that verdict and the budget spent so far, what happens next.
// 'retry' — same page, same startIndex, after a backoff
// 'partial' — stop, keep the pages that did arrive (complete: false)
// 'fail' — stop with nothing to keep
// Pure, like decideAutoLoad, so the policy is testable without a browser or a network.
function decideFetchRetry({ retryable, attempt, retriesUsed, haveFeatures }) {
if (retryable && attempt <= FETCH_PAGE_RETRIES && retriesUsed < FETCH_RETRY_BUDGET) {
return 'retry';
}
return haveFeatures ? 'partial' : 'fail';
}
// Backoff before re-issuing `attempt`. Clamped, so the table can be shorter than the
// retry count without the last waits collapsing to undefined.
function fetchRetryDelay(attempt) {
const i = Math.max(0, Math.min(attempt - 1, FETCH_RETRY_BACKOFF_MS.length - 1));
return FETCH_RETRY_BACKOFF_MS[i];
}
// Fetch addresses from EProstor API with pagination. shouldAbort (optional)
// is checked between pages so a Clear / newer Load stops the request chain.
//
// Resolves { features, complete }. `complete` is false when the result is a partial
// answer for the requested box — the page cap was hit, or a page came back invalid
// after earlier pages succeeded. Callers must not treat a partial result as
// authoritative coverage: the reverse audit would report addresses in the
// never-fetched remainder as missing from eProstor.
function fetchAddresses(minE, minN, maxE, maxN, shouldAbort) {
return new Promise((resolve, reject) => {
const allFeatures = [];
let startIndex = 0;
let pageCount = 0;
let retriesUsed = 0;
const aborted = () => typeof shouldAbort === 'function' && shouldAbort();
// One page failed. Retry it, settle for what we have, or give up — and say which,
// because the alternative is a spinner that can sit there for a minute and a half
// with nothing to explain it.
function handleFailure(attempt, kind, status) {
const { retryable, reason } = classifyFetchFailure(kind, status);
const verdict = decideFetchRetry({
retryable, attempt, retriesUsed, haveFeatures: allFeatures.length > 0
});
if (verdict === 'retry') {
retriesUsed++;
toast(
`eProstor ${reason} — retrying (attempt ${attempt + 1} of ${FETCH_PAGE_RETRIES + 1})`,
'warning'
);
// Re-check after the wait, not just before it: a Clear or a newer Load during
// the backoff must stop the chain, or this resolves into a load the user has
// already thrown away.
setTimeout(() => {
if (aborted()) { resolve({ features: allFeatures, complete: false }); return; }
requestPage(attempt + 1);
}, fetchRetryDelay(attempt));
return;
}
if (verdict === 'partial') {
console.warn(`[SL-HN] EProstor ${reason}; result is partial`);
toast(
`eProstor ${reason} — showing ${allFeatures.length} addresses that loaded, `
+ 'audit off for this area',
'warning'
);
resolve({ features: allFeatures, complete: false });
return;
}
const err = new Error(`EProstor ${reason}`);
// Preferred over the caller's generic "Error fetching address data.", which reads
// as vague after two messages that named the actual problem.
err.userMessage = `eProstor ${reason} — no addresses loaded`;
reject(err);
}
function requestPage(attempt) {
if (aborted()) {
resolve({ features: allFeatures, complete: false }); // caller discards stale results anyway
return;
}
// Counted once per page, not once per attempt: a retry re-fetches the same
// startIndex, so charging it to the page cap would shorten the box we cover.
if (attempt === 1 && ++pageCount > EPROSTOR_MAX_PAGES) {
console.warn(`[SL-HN] EProstor result truncated at ${EPROSTOR_MAX_PAGES} pages — reduce the buffer`);
toast('Too many addresses in area — result truncated, reduce the buffer', 'warning');
resolve({ features: allFeatures, complete: false });
return;
}
const filter = buildCqlFilter(minE, minN, maxE, maxN);
const url = EPROSTOR_API +
'?f=application/json' +
'&limit=' + EPROSTOR_LIMIT +
'&startIndex=' + startIndex +
'&filter=' + encodeURIComponent(filter) +
'&filter-lang=cql-text';
GM_xmlhttpRequest({
method: 'GET',
url: url,
timeout: FETCH_TIMEOUT_MS,
onload: function (response) {
let data;
try {
data = JSON.parse(response.responseText);
} catch (err) {
handleFailure(attempt, 'load', response.status);
return;
}
if (!data.features || !Array.isArray(data.features)) {
handleFailure(attempt, 'load', response.status);
return;
}
allFeatures.push(...data.features);
// Check if there are more pages: trust numberMatched when the
// server provides it, otherwise assume a full page means more.
const returned = data.numberReturned || data.features.length;
const total = typeof data.numberMatched === 'number' ? data.numberMatched : null;
const hasMore = total != null
? startIndex + returned < total
: returned >= EPROSTOR_LIMIT;
if (hasMore && returned > 0) {
startIndex += returned;
requestPage(1);
} else {
resolve({ features: allFeatures, complete: true });
}
},
onerror: function () {
handleFailure(attempt, 'error');
},
ontimeout: function () {
handleFailure(attempt, 'timeout');
}
});
}
requestPage(1);
});
}
// Copy text to clipboard
function copyToClipboard(text) {
if (typeof GM_setClipboard === 'function') {
GM_setClipboard(text, 'text');
toast(`Copied "${text}" to clipboard`, 'success');
} else {
navigator.clipboard.writeText(text).then(() => {
toast(`Copied "${text}" to clipboard`, 'success');
}).catch(() => {
toast('Failed to copy to clipboard', 'error');
});
}
}
// ---- Fix-street floating dialog (shown when an official street name has no WME match) ----
let fixStreetDialogEl = null;
function closeFixStreetDialog() {
if (fixStreetDialogEl) {
fixStreetDialogEl.remove();
fixStreetDialogEl = null;
}
}
function showFixStreetDialog({ officialName, hnCount, segmentCount, nearestStreetName, farInfo, onRename, onAddAnyway, onCancel }) {
closeFixStreetDialog();
const escapedOfficial = escapeHtml(officialName);
const headerHtml = farInfo
? `⚠️ Official street <b>"${escapedOfficial}"</b> is about ${Math.round(farInfo.farDistance)} m away — the nearest segment (${Math.round(farInfo.nearestDistance)} m) has a different name`
: `⚠️ Official street <b>"${escapedOfficial}"</b> not found in WME`;
const addAnywayLabel = farInfo
? `Add to "${escapedOfficial}" ${Math.round(farInfo.farDistance)} m away anyway`
: (nearestStreetName
? `Add to "${escapeHtml(nearestStreetName)}" anyway`
: 'Add to unnamed segment anyway');
const primaryBtnStyle = 'font-size:12px;padding:4px 10px;cursor:pointer;border:1px solid #28a745;border-radius:3px;background:#d4edda;color:#155724;font-weight:bold;';
const plainBtnStyle = 'font-size:12px;padding:4px 10px;cursor:pointer;border:1px solid #ccc;border-radius:3px;background:#f8f8f8;color:#333;';
const div = document.createElement('div');
div.id = 'qhnsl-fix-street-dialog';
div.style.cssText = 'position:fixed;top:70px;left:50%;transform:translateX(-50%);z-index:10000;'
+ 'background:#fff;border:1px solid #ffc107;border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,0.35);'
+ 'padding:12px 16px;font-size:13px;max-width:440px;font-family:inherit;';
div.innerHTML = `
<div style="margin-bottom:6px;">${headerHtml}</div>
<div style="font-size:12px;color:#555;margin-bottom:10px;">
${hnCount} house number${hnCount === 1 ? '' : 's'} belong${hnCount === 1 ? 's' : ''} to it (highlighted blue) •
${segmentCount} segment${segmentCount === 1 ? '' : 's'} selected<br/>
Adjust the segment selection on the map if needed, then rename.
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button class="fix-rename-btn" style="${primaryBtnStyle}">✓ Rename selected segments</button>
<button class="fix-copy-btn" style="${plainBtnStyle}">📋 Copy name</button>
<button class="fix-add-anyway-btn" style="${plainBtnStyle}">${addAnywayLabel}</button>
<button class="fix-cancel-btn" style="${plainBtnStyle}">✕ Cancel</button>
</div>`;
div.querySelector('.fix-rename-btn').addEventListener('click', onRename);
div.querySelector('.fix-copy-btn').addEventListener('click', () => copyToClipboard(officialName));
div.querySelector('.fix-add-anyway-btn').addEventListener('click', onAddAnyway);
div.querySelector('.fix-cancel-btn').addEventListener('click', onCancel);
document.body.appendChild(div);
fixStreetDialogEl = div;
}
// City for a segment that has no street of its own (e.g. a freshly drawn road
// saved without an address): borrow a real city from another selected segment,
// else WME's top city, else the empty "no city" entry for the current country.
function resolveFallbackCityId(segments) {
// City id borrowed from the selection but not confirmed as a real city
let borrowedCityId = null;
for (const seg of segments) {
const street = seg.primaryStreetId
? wmeSDK.DataModel.Streets.getById({ streetId: seg.primaryStreetId })
: null;
if (!street?.cityId) continue;
const city = wmeSDK.DataModel.Cities.getById({ cityId: street.cityId });
if (city && !city.isEmpty) return street.cityId;
if (borrowedCityId === null) borrowedCityId = street.cityId;
}
const topCity = wmeSDK.DataModel.Cities.getTopCity();
if (topCity && !topCity.isEmpty) return topCity.id;
if (borrowedCityId !== null) return borrowedCityId;
if (topCity) return topCity.id; // empty top city beats creating one
const countryId = wmeSDK.DataModel.Countries.getTopCountry()?.id;
if (!countryId) return null;
const emptyCity = wmeSDK.DataModel.Cities.getCity({ cityName: '', countryId })
|| wmeSDK.DataModel.Cities.addCity({ cityName: '', countryId });
return emptyCity?.id ?? null;
}
// Rename all currently selected segments to the given street name via WME SDK.
// Returns the number of segments successfully renamed.
function updateSegmentStreetName(newStreetName, onSuccess) {
const selectedSegments = getSelectedSegments();
if (selectedSegments.length === 0) {
toast('No segment selected', 'warning');
return 0;
}
let renamed = 0;
let failed = 0;
let fallbackCityId; // resolved lazily when first needed
selectedSegments.forEach(segment => {
try {
// Resolve the city from the segment's current primary street
const currentStreet = segment.primaryStreetId
? wmeSDK.DataModel.Streets.getById({ streetId: segment.primaryStreetId })
: null;
let cityId = currentStreet?.cityId;
// Segments saved without any address have no street to take the city
// from — fall back to a city borrowed from the rest of the selection
if (!cityId) {
if (fallbackCityId === undefined) fallbackCityId = resolveFallbackCityId(selectedSegments);
cityId = fallbackCityId;
}
if (!cityId) {
console.warn('[SL-HN] Segment has no resolvable city, skipping:', segment.id);
failed++;
return;
}
// Get existing street with this name in this city, or create it
let street = wmeSDK.DataModel.Streets.getStreet({
cityId: cityId,
streetName: newStreetName
});
if (!street) {
console.debug('[SL-HN] Street not found, creating new street:', newStreetName);
street = wmeSDK.DataModel.Streets.addStreet({
streetName: newStreetName,
cityId: cityId
});
}
// SDK v2.359 moved the address fields into an `addressData` envelope. The old
// top-level shape still works but is deprecated, and SegmentAddressData makes
// ids and raw names mutually exclusive (`primaryStreetId?: never` in the raw
// variant), so the two can never be mixed by accident.
//
// Do not "simplify" this to the raw variant, i.e. addressData: { streetName }.
// It looks like it would let WME resolve or create the street and delete the
// city lookup above, but the API rejects it:
// ValidationError: cityName is required for raw address updates
// (use empty string for no city)
// So the city still has to be resolved — as a name rather than an id, saving
// nothing — and an empty or wrong cityName strips the city off the segment.
// Verified against a live segment that already had both street and city.
wmeSDK.DataModel.Segments.updateAddress({
segmentId: segment.id,
addressData: { primaryStreetId: street.id }
});
console.debug('[SL-HN] Updated segment', segment.id, 'to street ID:', street.id);
renamed++;
} catch (err) {
console.error('[SL-HN] Error renaming segment', segment.id, err);
failed++;
}
});
if (renamed === 0) {
toast('Could not rename any segment. See console.', 'error');
return 0;
}
if (failed > 0) {
toast(`Renamed ${renamed} of ${renamed + failed} segments to "${newStreetName}"`, 'warning');
} else {
toast(`Updated street to "${newStreetName}"`, 'success');
}
if (typeof onSuccess === 'function') {
onSuccess();
}
return renamed;
}
// Returns { segment, distance } with distance in approximate meters, or null.
function findNearestSegment(feature, streetName, matchName) {
const point = { x: feature.lon, y: feature.lat };
// WGS84 → meters scaling around the feature's latitude; plenty accurate
// for comparing segments within a loaded area a few km across.
const M_PER_DEG_LAT = 111320;
const mPerDegLon = M_PER_DEG_LAT * Math.cos(feature.lat * Math.PI / 180);
const allSegments = wmeSDK.DataModel.Segments.getAll()
.filter(segment => !NON_ADDRESSABLE_ROAD_TYPES.has(segment.roadType));
let candidateSegments = allSegments;
if (matchName) {
const matchingStreetIds = wmeSDK.DataModel.Streets.getAll()
.filter(street => street.name?.toLowerCase() === streetName.toLowerCase())
.map(street => street.id);
if (matchingStreetIds.length === 0) {
return null;
}
candidateSegments = allSegments.filter(segment => {
const primaryMatch = matchingStreetIds.includes(segment.primaryStreetId);
const altMatch = (segment.alternateStreetIds || []).some(id => matchingStreetIds.includes(id));
return primaryMatch || altMatch;
});
}
if (candidateSegments.length === 0) {
return null;
}
let nearestSegment = null;
let minDistance = Infinity;
candidateSegments.forEach(segment => {
const coords = segment.geometry?.coordinates;
if (!Array.isArray(coords) || coords.length < 2) return;
const distance = pointToLineDistance(point, coords, mPerDegLon, M_PER_DEG_LAT);
if (distance < minDistance) {
minDistance = distance;
nearestSegment = segment;
}
});
return nearestSegment ? { segment: nearestSegment, distance: minDistance } : null;
}
// sx/sy scale lon/lat into meters so the returned distance is in meters.
function pointToLineDistance(point, coords, sx = 1, sy = 1) {
const px = point.x * sx;
const py = point.y * sy;
let minDist = Infinity;
for (let i = 0; i < coords.length - 1; i++) {
const [x1, y1] = coords[i];
const [x2, y2] = coords[i + 1];
const dist = pointToSegmentDistance(px, py, x1 * sx, y1 * sy, x2 * sx, y2 * sy);
if (dist < minDist) minDist = dist;
}
return minDist;
}
function pointToSegmentDistance(px, py, x1, y1, x2, y2) {
const dx = x2 - x1;
const dy = y2 - y1;
const lengthSquared = dx * dx + dy * dy;
if (lengthSquared === 0) {
const dpx = px - x1;
const dpy = py - y1;
return Math.sqrt(dpx * dpx + dpy * dpy);
}
let t = ((px - x1) * dx + (py - y1) * dy) / lengthSquared;
t = Math.max(0, Math.min(1, t));
const closestX = x1 + t * dx;
const closestY = y1 + t * dy;
const dpx = px - closestX;
const dpy = py - closestY;
return Math.sqrt(dpx * dpx + dpy * dpy);
}
// The three map overlays, all created hidden. Style only — no state, no wiring —
// so this stays out of init(), which is about behaviour.
//
// Note the absence of setLayerZIndex on the HN and audit layers: an explicit
// index on the HN layer buried it under WME's own layers until a selection change
// made OpenLayers recompute and discard the value, so circles appeared only after
// deselecting. OL's own ordering works. Audit/HN overlap is resolved by picking
// the nearest marker in handleMapClick, not by stacking.
function createOverlayLayers() {
// House-number circles: green on the selected street, orange elsewhere, red for
// a conflict, blue while the fix-street flow highlights a street.
wmeSDK.Map.addLayer({
layerName: SDK_LAYER_NAME,
zIndexing: true,
styleContext: {
getFillColor: ({ feature }) => {
const p = feature.properties;
// Conflict before fixHighlight. Blue means "click me and I get added on rename",
// and the fix-street highlight covers a whole street — including circles the
// click refuses. A wrong-street circle showing blue promised the one thing it
// will never do.
if (p.conflict) return '#ff6666';
if (p.fixHighlight) return '#4da6ff';
return p.isSelectedStreet ? '#99ee99' : '#fb9c4f';
},
getOpacity: ({ feature }) => {
const p = feature.properties;
if (p.conflict || p.fixHighlight) return 1;
return (p.isSelectedStreet && p.processed) ? 0.3 : 1;
},
getRadius: ({ feature }) => {
const p = feature.properties;
// Red means "do not just click this" — a different number is already within
// 10 m, or this one is in WME under another street. Sized like the audit
// markers so it reads as foreground; the wrong-street case in particular no
// longer has a purple marker to carry that weight for it.
return labelFitRadius(p.number) + (p.conflict ? MARKER_RADIUS_MARGIN : 0);
},
getLabel: ({ feature }) => String(feature.properties.number ?? '')
},
styleRules: [{
style: {
graphicName: 'circle',
pointRadius: '${getRadius}',
fillColor: '${getFillColor}',
fillOpacity: '${getOpacity}',
strokeColor: '#ffffff',
strokeWidth: 2,
strokeOpacity: '${getOpacity}',
label: '${getLabel}',
fontColor: '#111111',
fontWeight: 'bold',
labelOutlineColor: '#ffffff',
labelOutlineWidth: 0
}
}]
});
// Label-only: one reference street name floated over each street's cluster of
// house-number points.
wmeSDK.Map.addLayer({
layerName: SDK_STREETNAMES_LAYER_NAME,
zIndexing: true, // required, or setLayerZIndex on this layer has no effect
styleContext: {
getLabel: ({ feature }) => String(feature.properties.name ?? '')
},
styleRules: [{
style: {
pointRadius: 0,
fillOpacity: 0,
strokeOpacity: 0,
label: '${getLabel}',
fontColor: '#1a3d7c',
fontSize: '13px',
fontWeight: 'bold',
labelOutlineColor: '#ffffff',
labelOutlineWidth: 3
}
}]
});
// Reverse-audit markers. Solid = number not on that street at all; hollow =
// number exists but the WME pin sits more than AUDIT_MAX_DISTANCE away.
wmeSDK.Map.addLayer({
layerName: SDK_AUDIT_LAYER_NAME,
zIndexing: true,
styleContext: {
getAuditFill: ({ feature }) => feature.properties.type === 'missing' ? '#b04ce6' : '#ffffff',
// The hollow variant used to sit at 0.15, which against WME's pale basemap left
// only the ring showing and read as nothing at all.
getAuditFillOpacity: ({ feature }) => feature.properties.type === 'missing' ? 1 : 0.6,
// The same label fit as the house-number circles, plus the margin that lifts a
// marker above the circle beside it — which is what was making these read as
// background and, since handleMapClick takes the nearest centre, made them
// fiddly to hit.
getAuditRadius: ({ feature }) => labelFitRadius(feature.properties.number) + MARKER_RADIUS_MARGIN,
getAuditLabel: ({ feature }) => String(feature.properties.number ?? '')
},
styleRules: [{
style: {
graphicName: 'circle',
pointRadius: '${getAuditRadius}',
fillColor: '${getAuditFill}',
fillOpacity: '${getAuditFillOpacity}',
strokeColor: '#b04ce6',
strokeWidth: 4,
strokeOpacity: 1,
label: '${getAuditLabel}',
// White on the solid purple fill: the old dark purple was barely legible
// against it. The outline carries the contrast on the hollow variant.
fontColor: '#ffffff',
fontWeight: 'bold',
labelOutlineColor: '#3d0a4d',
labelOutlineWidth: 2
}
}]
});
for (const layerName of [SDK_LAYER_NAME, SDK_STREETNAMES_LAYER_NAME, SDK_AUDIT_LAYER_NAME]) {
wmeSDK.Map.setLayerVisibility({ layerName, visibility: false });
}
}
// wz-checkbox exposes its state as an attribute, not a .checked property. Module
// scope because both the main panel and the NavPoints overlay need them.
const isChecked = (el) => el?.hasAttribute('checked');
const setChecked = (el, v) => v ? el.setAttribute('checked', '') : el.removeAttribute('checked');
// NavPoints overlay: dashed line from each Waze house-number pin to its anchor on
// the segment. Self-contained by construction — it takes the sidebar pane and
// otherwise touches only module-scope helpers, so it lives outside init() rather
// than adding 167 lines to that scope. Keep it dependency-free.
function setupNavPoints(tabPane) {
const chkNavPoints = tabPane.querySelector('#qhnsl-navpoints');
if (!chkNavPoints) return;
let lastNavIds = [];
let currentRenderId = 0;
let renderTimer = null;
wmeSDK.Map.addLayer({
layerName: SDK_NAVPOINTS_LAYER_NAME,
zIndexing: true,
styleContext: {
getColor: ({ feature }) => {
const p = feature.properties;
if (p.forced) return p.touched ? '#ff9933' : '#ff3333';
return p.touched ? '#ffffff' : '#ffdd00';
},
getLabel: ({ feature }) => String(feature.properties.number ?? '')
},
styleRules: [
{
predicate: (featureProperties) => featureProperties.kind === 'line',
style: {
strokeColor: '${getColor}',
strokeWidth: 2,
strokeOpacity: 0.9,
strokeDashstyle: 'dash',
fill: false
}
},
{
predicate: (featureProperties) => featureProperties.kind === 'label',
style: {
label: '${getLabel}',
fontColor: '#111111',
fontSize: '12px',
fontWeight: 'bold',
fontFamily: '"Open Sans", Arial, sans-serif',
labelOutlineColor: '${getColor}',
labelOutlineWidth: 3,
labelOutlineOpacity: 1,
pointRadius: 0,
stroke: false,
fill: false
}
}
]
});
function clearNavLayer() {
if (!lastNavIds.length) return;
try {
wmeSDK.Map.removeFeaturesFromLayer({ layerName: SDK_NAVPOINTS_LAYER_NAME, featureIds: lastNavIds });
} catch (e) {
console.debug('[SL-HN] NavPoints clearLayer:', e);
}
lastNavIds = [];
}
async function renderNavPoints() {
// Bump the generation BEFORE bailing out, or an in-flight render started at a
// higher zoom finishes its await, still passes its own generation check, and
// draws NavPoints onto a map that has since zoomed out past the threshold.
// The checkbox handler already does this; these early exits must match.
if (!LS.getNavPoints()) { currentRenderId++; clearNavLayer(); return; }
if (wmeSDK.Map.getZoomLevel() < MIN_OVERLAY_ZOOM) { currentRenderId++; clearNavLayer(); return; }
const myRenderId = ++currentRenderId;
const segIds = wmeSDK.DataModel.Segments.getAll()
.filter(s => s.hasHouseNumbers)
.map(s => s.id);
if (!segIds.length) { clearNavLayer(); return; }
let allHns;
try {
allHns = await wmeSDK.DataModel.HouseNumbers.fetchHouseNumbers({ segmentIds: segIds });
} catch (err) {
console.warn('[SL-HN] NavPoints fetch failed:', err);
return;
}
if (myRenderId !== currentRenderId) return;
const features = [];
for (const hn of allHns) {
const touched = hn.updatedBy != null;
const forced = hn.isForced === true;
if (hn.fractionPoint?.coordinates && hn.geometry?.coordinates) {
features.push({
type: 'Feature',
id: `navp-${hn.id}-line`,
geometry: {
type: 'LineString',
coordinates: [hn.fractionPoint.coordinates, hn.geometry.coordinates]
},
properties: { kind: 'line', touched, forced }
});
}
if (hn.geometry?.coordinates) {
features.push({
type: 'Feature',
id: `navp-${hn.id}-label`,
geometry: hn.geometry,
properties: { kind: 'label', number: hn.number, touched, forced }
});
}
}
if (lastNavIds.length) {
try {
wmeSDK.Map.removeFeaturesFromLayer({ layerName: SDK_NAVPOINTS_LAYER_NAME, featureIds: lastNavIds });
} catch (e) {
console.debug('[SL-HN] NavPoints swap-clear:', e);
}
}
if (features.length) {
try {
wmeSDK.Map.addFeaturesToLayer({ layerName: SDK_NAVPOINTS_LAYER_NAME, features });
} catch (e) {
console.warn('[SL-HN] NavPoints addFeaturesToLayer:', e);
lastNavIds = [];
return;
}
}
lastNavIds = features.map(f => f.id);
}
function scheduleRender() {
if (renderTimer) clearTimeout(renderTimer);
renderTimer = setTimeout(() => {
renderTimer = null;
renderNavPoints().catch(err => console.warn('[SL-HN] NavPoints render failed:', err));
}, 300);
}
chkNavPoints.addEventListener('click', () => {
const on = !isChecked(chkNavPoints);
setChecked(chkNavPoints, on);
LS.setNavPoints(on);
if (on) {
scheduleRender();
} else {
currentRenderId++; // invalidate any in-flight render so it can't re-add features
clearNavLayer();
}
});
if (LS.getNavPoints()) scheduleRender();
const NAVPOINTS_TRIGGER_EVENTS = [
'wme-map-zoom-changed',
'wme-map-move-end',
'wme-house-number-added',
'wme-house-number-deleted',
'wme-house-number-moved',
'wme-house-number-updated',
'wme-map-data-loaded'
];
NAVPOINTS_TRIGGER_EVENTS.forEach(eventName => {
wmeSDK.Events.on({
eventName,
eventHandler: () => {
if (LS.getNavPoints()) scheduleRender();
}
});
});
}
function init() {
let currentStreetId = null;
// EPSG:3794 bbox eProstor was last fetched for. Outside it we have no reference
// data, so the audit must stay silent rather than claim a number is missing.
// Auto-load also uses it to tell whether a selection is already covered.
let lastLoadedBbox = null;
// The last box we asked eProstor for, set whether or not the answer was usable.
// Auto-load's dedup key; see autoLoadState for why it is not lastLoadedBbox.
let lastAttemptedBbox = null;
let autoLoadTimer = null;
// When the map last panned or zoomed, so a click belonging to that gesture can be
// told apart from a deliberate one.
let lastMapMovedAt = 0;
// The script's own setSelection calls raise wme-selection-changed. Auto-load must
// not re-enter on them and reload underneath the user — closing the fix-street
// dialog mid-decision, or wiping the audit findings on the very click meant to
// inspect one.
//
// Two pieces of state, because the window alone was not enough: it expires, and the
// deferred re-check outlives it, so our own selection came back around ~2s later
// and loaded anyway. The ids identify which selection is ours regardless of timing;
// the window still exists so a genuine selection arriving mid-settle is deferred
// rather than lost.
let suppressAutoLoadUntil = 0;
let selfSelectionIds = null;
function markSelfSelection(ids) {
suppressAutoLoadUntil = Date.now() + 2000;
selfSelectionIds = Array.isArray(ids) ? ids.map(String) : null;
}
// Select a segment as *our* selection, so the resulting selection-changed event is not
// mistaken for the user's. Shared by the audit marker click and the wrong-street click.
function selectSegment(segmentId) {
let marked = false;
try {
// Already the entire selection: setSelection raises no selection-changed event, so
// marking it as ours would arm selfSelectionIds for an event that never arrives,
// and the user's next deliberate selection of this segment would be dropped as
// ours. Nothing rules this out — the segment a wrong-street circle points at can
// easily be the one already selected.
if (isSameSelection(getSelectedSegments().map(seg => seg.id), [segmentId])) return true;
markSelfSelection([segmentId]);
marked = true;
wmeSDK.Editing.setSelection({
selection: { ids: [segmentId], objectType: 'segment' }
});
return true;
} catch (e) {
// Disarm what we armed: with no selection event, nothing will consume the marker,
// and leaving it set silently swallows the next auto-load for this segment.
if (marked) selfSelectionIds = null;
console.warn('[SL-HN] could not select segment', segmentId, e);
return false;
}
}
// The street name WME shows for a segment. Resolved at click time rather than stored:
// the keys we keep are normalized ("ulica_b"), which is not a name to show anyone.
// preferKeys are the normalized keys the caller cares about: a house number is indexed
// under a segment's primary AND alternate names, so the name that produced a match is
// not necessarily the primary one, and naming the primary would send the user looking
// under a street that carries no such house number.
function wmeStreetNameOfSegment(segmentId, preferKeys) {
try {
const seg = wmeSDK.DataModel.Segments.getById({ segmentId });
if (!seg) return null;
const streetIds = [];
if (seg.primaryStreetId) streetIds.push(seg.primaryStreetId);
(seg.alternateStreetIds || []).forEach(id => { if (id) streetIds.push(id); });
const names = streetIds
.map(streetId => wmeSDK.DataModel.Streets.getById({ streetId })?.name)
.filter(Boolean);
if (!names.length) return null;
const wanted = new Set(preferKeys || []);
return names.find(name => wanted.has(normalizeStreetName(name))) || names[0];
} catch (e) {
console.debug('[SL-HN] could not resolve segment street name:', e);
return null;
}
}
let streetNames = {};
let streets = {};
let lastFeatures = [];
let fixStreetHighlightStreetId = null; // official street ID whose HNs are highlighted during fix-street flow
let lastAuditFindings = [];
let isLoading = false;
let currentLoadId = 0;
// One record per map overlay, replacing three parallel sets of variables.
// wanted — the user's toggle for this overlay
// shown — what we last told the SDK, so we only call it on a real change
// ids — the feature ids currently on the layer
// Per-overlay quirks (the zoom toast, lifting labels above other scripts) stay
// at their call sites rather than becoming fields here.
// Namespaced deliberately: `hn` alone is used ~78 times in this file to mean
// "house number", so a bare `hn` overlay would shadow and mislead.
const overlays = {
hn: { name: SDK_LAYER_NAME, wanted: false, shown: false, ids: [] },
labels: { name: SDK_STREETNAMES_LAYER_NAME, wanted: LS.getStreetNames(), shown: false, ids: [] },
audit: { name: SDK_AUDIT_LAYER_NAME, wanted: LS.getAudit(), shown: false, ids: [] }
};
// Replace a layer's features in one step.
//
// Failures are contained here rather than at each call site. Rendering is
// display-only, but applyFeatureFilter runs inside addHouseNumberToSegment's try
// block — so an escaping throw would report a house number that was genuinely
// added as a failure. Two of the three call sites wrapped this; the house-number
// one did not, which is exactly the kind of gap a central guarantee closes.
//
// Ids are recorded before the add: if it throws part-way, some features may
// already be on the layer, and later removing an id that was never added is
// harmless, while leaving an orphan behind is not.
function setOverlayFeatures(overlay, features) {
try {
if (overlay.ids.length) {
wmeSDK.Map.removeFeaturesFromLayer({ layerName: overlay.name, featureIds: overlay.ids });
overlay.ids = [];
}
if (!features.length) return;
overlay.ids = features.map(f => f.id);
wmeSDK.Map.addFeaturesToLayer({ layerName: overlay.name, features });
} catch (e) {
console.warn(`[SL-HN] rendering ${overlay.name} failed:`, e);
}
}
let streetNameSpan = null;
let currentStreetDiv = null;
let streetAnalysisDiv = null;
let auditSummaryDiv = null;
// Track unsaved house-number edits this session. fetchHouseNumbers reflects the
// SAVED state only: it keeps returning pending-deleted HNs and omits pending-added
// ones until the editor saves. We layer our own edits on top, keyed by the stable
// houseNumberId the SDK events provide.
const deletedHnIds = new Set(); // HNs deleted this session (still in saved model until save)
const sessionAddedKeys = new Set(); // feature keys we added this session (not yet in saved model)
// Keys added via "Add anyway", i.e. onto a segment named something other than the
// official street. WME indexes those under the segment's own name, so they never show
// up for this address — unlike sessionAddedKeys, this set is NOT cleared on save.
// The second set is write-once, and exists only so a redo can tell an ordinary
// session add from an "add anyway" one, which the id → key mapping does not record.
const addedAnywayKeys = new Set();
const everAddedAnywayKeys = new Set();
const hnIdToAddedKey = new Map(); // added houseNumberId -> feature key, to undo on later delete
let pendingAddKey = null; // set just before addHouseNumber, consumed by the added event
const featKey = makeFeatKey;
let chkMissing = null;
let chkSelectedOnly = null;
let applyFeatureFilter = () => {};
let analyzeStreetMatches = () => {};
// Assigned when the panel is built. Needed out here so updateLayerVisibility can
// re-render the audit when its layer becomes visible or hidden.
let renderAuditFindings = () => {};
try {
I18n.translations[I18n.currentLocale()].layers.name['quick-hn-sl-importer'] = 'Quick HN Importer';
} catch (_) {}
createOverlayLayers();
// Baseline z-index: above WME's own labels but below the segment-interaction
// layer, so segments stay clickable even if the click-through step below never
// succeeds.
try { wmeSDK.Map.setLayerZIndex({ layerName: SDK_STREETNAMES_LAYER_NAME, zIndex: 1000 }); } catch (_) {}
// The label layer is display-only (dot clicks go through wme-map-mouse-click,
// not the layer). Mark its DOM node pointer-events:none so it never swallows
// segment clicks, then — and only then — lift it above every other overlay
// (WME Toolbox speed limits, etc.). Raising the z-index before neutralizing
// pointer events would block segment selection across the whole map.
//
// OpenLayers recomputes every layer's z-index whenever a layer is added or
// removed, so a script loading after us silently drops our labels back down.
// Re-assert on each redraw/pan/zoom instead of setting this once.
let labelLayerDiv = null;
function liftLabelLayer() {
try {
// A cached node that has been detached (map re-init, another script
// re-adding layers) must be re-resolved: writing pointer-events to the dead
// node while lifting the live layer to z-index 10000 would put a
// full-viewport click-swallowing div over the whole editor.
if (labelLayerDiv && !labelLayerDiv.isConnected) labelLayerDiv = null;
if (!labelLayerDiv) {
// W.map is a WMEMap wrapper; the OpenLayers map (with .layers) is behind getOLMap().
const wmeMap = (unsafeWindow || window).W?.map;
const layers = wmeMap?.getOLMap?.()?.layers;
const layer = layers?.find(l => [l?.name, l?.uniqueName].some(
n => typeof n === 'string' && n.includes(SDK_STREETNAMES_LAYER_NAME)));
if (!layer?.div) return;
labelLayerDiv = layer.div;
}
labelLayerDiv.style.pointerEvents = 'none';
wmeSDK.Map.setLayerZIndex({ layerName: SDK_STREETNAMES_LAYER_NAME, zIndex: 10000 });
} catch (_) {}
}
liftLabelLayer();
function updateLayerVisibility() {
const auditWasShown = overlays.audit.shown;
const zoomOk = wmeSDK.Map.getZoomLevel() >= MIN_OVERLAY_ZOOM;
// hn is the base gate; the other two ride it plus their own toggle.
const hnVisible = overlays.hn.wanted && zoomOk;
const desired = new Map([
[overlays.hn, hnVisible],
[overlays.labels, hnVisible && overlays.labels.wanted],
[overlays.audit, hnVisible && overlays.audit.wanted]
]);
for (const [overlay, visible] of desired) {
if (visible === overlay.shown) continue;
// Record only after the SDK accepted it. Setting `shown` first meant one
// failed call left the flag lying forever — and since the loop skips on
// `visible === overlay.shown`, nothing would ever retry. handleMapClick trusts
// `shown`, so it would hit-test circles the user cannot see and a click on
// apparently empty map would add a house number.
try {
wmeSDK.Map.setLayerVisibility({ layerName: overlay.name, visibility: visible });
} catch (e) {
console.warn(`[SL-HN] could not change visibility of ${overlay.name}:`, e);
continue;
}
overlay.shown = visible;
// Only the base layer explains itself: hiding the other two is always a
// deliberate toggle, never a surprise.
if (overlay === overlays.hn && overlays.hn.wanted && !visible && lastFeatures.length > 0) {
toast(`Zoom in to level ${MIN_OVERLAY_ZOOM}+ to see house numbers`, 'info');
}
}
// Runs on every pan/zoom, not just on change: reclaims the top spot if another
// script's layer load reshuffled z-indexes since the last time we looked.
if (desired.get(overlays.labels)) liftLabelLayer();
// The audit renders only while its layer is shown, so a flip either way needs a
// re-render: to populate markers on becoming visible, and to drop the summary
// on becoming hidden.
if (overlays.audit.shown !== auditWasShown) renderAuditFindings();
}
const onMapMoved = () => {
lastMapMovedAt = Date.now();
updateLayerVisibility();
};
wmeSDK.Events.on({ eventName: 'wme-map-zoom-changed', eventHandler: onMapMoved });
wmeSDK.Events.on({ eventName: 'wme-map-move-end', eventHandler: onMapMoved });
wmeSDK.Events.on({ eventName: 'wme-selection-changed', eventHandler: onSelectionChanged });
// Get current WME street name from selection
function getWmeStreetName() {
const selectedSegments = getSelectedSegments();
if (selectedSegments.length === 0) return null;
const seg = selectedSegments[0];
const primaryStreetId = seg.primaryStreetId;
if (!primaryStreetId) return null;
const street = wmeSDK.DataModel.Streets.getById({ streetId: primaryStreetId });
return street?.name || null;
}
// Analyze street name matches and update UI
analyzeStreetMatches = function() {
if (!streetAnalysisDiv) return;
if (!lastFeatures.length) {
streetAnalysisDiv.style.display = 'none';
return;
}
const wmeStreetName = getWmeStreetName();
// Count addresses per official street name
const streetCounts = {};
lastFeatures.forEach(f => {
const name = streetNames[f.street];
if (!name) return;
streetCounts[name] = (streetCounts[name] || 0) + 1;
});
// Sort by count descending
// Always order by number of loaded HNs (desc), then alphabetically for stability
const sorted = Object.entries(streetCounts)
.sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]));
if (sorted.length === 0) {
streetAnalysisDiv.style.display = 'none';
return;
}
// Check how many match current WME street
const matchCount = wmeStreetName ? (streetCounts[wmeStreetName] || 0) : 0;
const hasMismatch = wmeStreetName && matchCount === 0 && sorted.length > 0;
// Find fuzzy match if there's a mismatch
let suggestedMatch = null;
let suggestionSimilarity = 0;
if (hasMismatch && wmeStreetName) {
for (const [name] of sorted) {
const similarity = calculateSimilarity(wmeStreetName, name);
if (similarity > 0.7 && similarity > suggestionSimilarity) {
suggestedMatch = name;
suggestionSimilarity = similarity;
}
}
}
// Build HTML
let html = '';
if (hasMismatch) {
html += `<div style="background:#fff3cd;border:1px solid #ffc107;border-radius:4px;padding:8px;margin-bottom:8px;">`;
html += `<b style="color:#856404;">⚠️ No matching addresses found!</b><br/>`;
html += `<span style="font-size:11px;color:#856404;">WME street name doesn't match any official names</span>`;
html += `</div>`;
if (suggestedMatch) {
const escapedSuggested = escapeHtml(suggestedMatch);
html += `<div style="background:#d4edda;border:1px solid #28a745;border-radius:4px;padding:8px;margin-bottom:8px;">`;
html += `<b style="color:#155724;">💡 Possible match found:</b><br/>`;
html += `<div style="margin:4px 0;font-size:12px;">`;
html += `<span style="color:#666;">WME:</span> <span style="color:#dc3545;text-decoration:line-through;">${escapeHtml(wmeStreetName)}</span><br/>`;
html += `<span style="color:#666;">Official:</span> <b style="color:#155724;">${escapedSuggested}</b>`;
html += `</div>`;
html += `<div style="display:flex;gap:6px;margin-top:6px;">`;
html += `<button class="wz-button update-street-btn" data-street="${escapedSuggested}" style="font-size:11px;padding:2px 8px;">✓ Use official name</button>`;
html += `<button class="copy-street-btn" data-street="${escapedSuggested}" style="font-size:11px;padding:2px 8px;background:#f8f8f8;border:1px solid #ccc;border-radius:3px;cursor:pointer;">📋 Copy</button>`;
html += `</div>`;
html += `</div>`;
}
}
html += `<div style="font-size:12px;margin-bottom:4px;"><b>Official streets in area:</b></div>`;
html += `<div style="max-height:150px;overflow-y:auto;border:1px solid #ddd;border-radius:4px;background:#fafafa;">`;
sorted.forEach(([name, _count], index) => {
const isMatch = name === wmeStreetName;
const isSuggestion = name === suggestedMatch;
const escapedName = escapeHtml(name);
let rowStyle = 'padding:4px 8px;font-size:11px;border-bottom:1px solid #eee;display:flex;justify-content:space-between;align-items:center;';
if (isMatch) rowStyle += 'background:#d4edda;';
else if (isSuggestion) rowStyle += 'background:#fff3cd;';
else if (index % 2 === 0) rowStyle += 'background:#f8f8f8;';
html += `<div style="${rowStyle}">`;
html += `<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapedName}">`;
if (isMatch) html += '✓ ';
if (isSuggestion) html += '→ ';
html += `${escapedName}</span>`;
html += `<span style="margin-left:8px;white-space:nowrap;display:flex;align-items:center;gap:4px;">`;
// Always show the update button - if already matched, show as disabled-looking but still clickable
const btnStyle = isMatch
? 'padding:1px 4px;font-size:10px;cursor:default;border:1px solid #ccc;border-radius:2px;background:#e9e9e9;color:#999;'
: 'padding:1px 4px;font-size:10px;cursor:pointer;border:1px solid #28a745;border-radius:2px;background:#d4edda;color:#155724;';
html += `<button class="update-street-btn" data-street="${escapedName}" style="${btnStyle}" title="${isMatch ? 'Already set' : 'Use this name'}">${isMatch ? '✓' : '→'}</button>`;
html += `<button class="copy-street-btn" data-street="${escapedName}" style="padding:1px 4px;font-size:10px;cursor:pointer;border:1px solid #ccc;border-radius:2px;background:#fff;" title="Copy to clipboard">📋</button>`;
html += `</span>`;
html += `</div>`;
});
html += `</div>`;
html += `<div style="font-size:10px;color:#888;margin-top:4px;">→ = apply name • 📋 = copy</div>`;
streetAnalysisDiv.innerHTML = html;
streetAnalysisDiv.style.display = 'block';
// Add click handlers for copy buttons
streetAnalysisDiv.querySelectorAll('.copy-street-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const streetName = btn.getAttribute('data-street');
copyToClipboard(streetName);
});
});
// Add click handlers for update buttons
streetAnalysisDiv.querySelectorAll('.update-street-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const streetName = btn.getAttribute('data-street');
// Skip only when EVERY selected segment already carries this name —
// the rename applies to the whole selection, so a mixed selection
// whose first segment happens to be correct must still proceed.
const selectedSegments = getSelectedSegments();
const allAlreadySet = selectedSegments.length > 0 && selectedSegments.every(seg => {
const street = seg.primaryStreetId
? wmeSDK.DataModel.Streets.getById({ streetId: seg.primaryStreetId })
: null;
return street?.name === streetName;
});
if (allAlreadySet) {
toast('Street name already set', 'info');
return;
}
updateSegmentStreetName(streetName, () => {
// After successful update, refresh the current street state
// Find the street ID for the new street name
const newStreetId = streets[streetName];
if (newStreetId) {
currentStreetId = newStreetId;
showCurrentStreet(streetName);
}
// Re-analyze and redraw with updated state
setTimeout(() => {
analyzeStreetMatches();
applyFeatureFilter();
}, 100);
});
});
});
};
// Show/hide the "Current street" badge — the only place its DOM is touched.
function showCurrentStreet(name) {
if (streetNameSpan && currentStreetDiv) {
streetNameSpan.textContent = name;
currentStreetDiv.style.display = 'block';
}
}
function hideCurrentStreet() {
if (streetNameSpan && currentStreetDiv) {
streetNameSpan.textContent = '—';
currentStreetDiv.style.display = 'none';
}
}
// Map a set of WME street IDs to the loaded register street with the most
// house numbers. Shared by selection changes and the initial load.
function findBestMatchingStreetId(wmeStreetIds, featureList) {
const names = Array.from(wmeStreetIds)
.map(id => wmeSDK.DataModel.Streets.getById({ streetId: id })?.name)
.filter(Boolean);
let bestId = null;
let bestCount = -1;
names.forEach(name => {
const sid = streets[name];
if (!sid) return;
const count = featureList.reduce((n, f) => n + (f.street === sid ? 1 : 0), 0);
if (count > bestCount) {
bestCount = count;
bestId = sid;
}
});
return bestId;
}
function onSelectionChanged() {
if (!lastFeatures.length) return;
const selectedSegments = getSelectedSegments();
if (selectedSegments.length === 0) {
return;
}
const selectedStreetIds = new Set();
selectedSegments.forEach(seg => {
const psid = seg.primaryStreetId;
if (psid && psid > 0) selectedStreetIds.add(psid);
(seg.alternateStreetIds || []).forEach(id => {
if (id && id > 0) selectedStreetIds.add(id);
});
});
const newStreetId = selectedStreetIds.size > 0
? findBestMatchingStreetId(selectedStreetIds, lastFeatures)
: null;
if (!newStreetId) {
currentStreetId = null;
hideCurrentStreet();
applyFeatureFilter();
analyzeStreetMatches();
return;
}
// Always update state and refresh UI, even if street is the same
// (because we might be on a different segment with the same street)
currentStreetId = newStreetId;
if (streetNames[currentStreetId]) {
showCurrentStreet(streetNames[currentStreetId]);
}
applyFeatureFilter();
analyzeStreetMatches();
}
// Single source of truth for which loaded HNs are currently drawn on the map.
// Used by both the layer redraw and the click hit-test so that circles hidden
// by the checkbox filters are never clickable.
function isFeatureVisible(feat) {
if (chkMissing?.hasAttribute('checked') && feat.processed) return false;
if (chkSelectedOnly?.hasAttribute('checked') && currentStreetId
&& feat.street !== currentStreetId && feat.street !== fixStreetHighlightStreetId
// A wrong-street circle is the only diagnostic left for its case — the purple
// marker was dropped as a duplicate of it — and the WME street it points at is
// by construction not its own. Filtering on `street` alone therefore hid it
// exactly when the selected street was the one that needs fixing, including
// after this circle's own click selected that segment.
&& !(feat.wrongStreet?.streetKeys || []).includes(currentStreetId)) return false;
return true;
}
// Mirrors isFeatureVisible for audit markers, so the checkbox filters hide them
// and — because the hit-test consults this too — a filtered-out marker can no
// longer hijack a click and drag the user onto a street they filtered away.
function isAuditFindingVisible(finding) {
if (chkSelectedOnly?.hasAttribute('checked') && currentStreetId) {
const keys = finding.streetKeys || [];
if (!keys.includes(currentStreetId)) return false;
}
return true;
}
function onAuditFindingClick(finding) {
// Close any open fix-street dialog first, exactly as onFeatureClick does. That
// dialog's Rename button acts on the *current* selection, and this function is
// about to change it — leaving the dialog open would rename the segment the user
// clicked to inspect, into a street name they never chose for it.
clearFixStreetState();
try {
wmeSDK.Map.setMapCenter({ lonLat: { lon: finding.lon, lat: finding.lat } });
} catch (e) {
console.debug('[SL-HN] setMapCenter failed:', e);
}
if (!selectSegment(finding.segmentId)) return;
const what = finding.type === 'missing'
? `"${finding.number}" is not in eProstor for this street`
: `"${finding.number}" is more than ${AUDIT_MAX_DISTANCE} m from its eProstor point`;
toast(`${what} — segment selected`, 'info');
}
function handleMapClick(evt) {
// overlays.hn.shown is false when the layer is hidden (e.g. zoom < 18):
// no visible circles means clicks must do nothing.
if (!overlays.hn.wanted || !overlays.hn.shown || !lastFeatures.length) return;
if (evt == null || evt.x == null || evt.y == null) return;
// A pan or zoom ends in a mouse-up that also arrives here as a click. Acting on
// it means adding a house number, or recentring on an audit marker, purely
// because the user moved the map. Nobody pans and then deliberately clicks
// inside this window, so ignoring it costs nothing.
if (Date.now() - lastMapMovedAt < CLICK_AFTER_MOVE_GRACE_MS) return;
const MAX_PIXELS_SQ = MAX_CLICK_DISTANCE_PX * MAX_CLICK_DISTANCE_PX;
// Nearest audit marker, if the audit is live. Resolved against the nearest
// eProstor circle below rather than short-circuiting here: a 'missing'
// finding is by definition NOT co-located with an eProstor point, so
// winning on mere presence would make the neighbouring address unclickable.
let bestAudit = null;
let bestAuditDistSq = Infinity;
if (overlays.audit.wanted && overlays.audit.shown && lastAuditFindings.length) {
for (const f of lastAuditFindings) {
if (!isAuditFindingVisible(f)) continue;
const px = wmeSDK.Map.getMapPixelFromLonLat({ lonLat: { lon: f.lon, lat: f.lat } });
if (!px) continue;
const dx = px.x - evt.x;
const dy = px.y - evt.y;
const d2 = dx * dx + dy * dy;
if (d2 <= MAX_PIXELS_SQ && d2 < bestAuditDistSq) {
bestAuditDistSq = d2;
bestAudit = f;
}
}
}
let bestFeature = null;
let bestDistSq = Infinity;
for (const f of lastFeatures) {
if (f.lon == null || f.lat == null) continue;
if (!isFeatureVisible(f)) continue;
const fPx = wmeSDK.Map.getMapPixelFromLonLat({ lonLat: { lon: f.lon, lat: f.lat } });
if (!fPx) continue;
const dx = fPx.x - evt.x;
const dy = fPx.y - evt.y;
const d2 = dx * dx + dy * dy;
if (d2 <= MAX_PIXELS_SQ && d2 < bestDistSq) {
bestDistSq = d2;
bestFeature = f;
}
}
// Closest wins. Ties go to the audit, since an audit marker overlapping an
// eProstor circle means the two concern the same address.
if (bestAudit && bestAuditDistSq <= bestDistSq) {
onAuditFindingClick(bestAudit);
return;
}
if (!bestFeature) return;
onFeatureClick(bestFeature);
}
wmeSDK.Events.on({ eventName: 'wme-map-mouse-click', eventHandler: handleMapClick });
// Refuse a wrong-street add and hand the user the segment that needs fixing. Shared by
// the circle click and by addHouseNumberToSegment, because the fix-street dialog is
// modeless: it survives pans, and a pan recomputes states on the very feature object
// the dialog closed over, so a feature can become a wrong-street case while its dialog
// is open. Guarding only at the click let "Add anyway" and the post-rename auto-add
// create the duplicate this marking exists to prevent.
function explainWrongStreet(feature) {
clearFixStreetState();
const officialName = streetNames[feature.street] || 'eProstor';
const wmeName = wmeStreetNameOfSegment(
feature.wrongStreet.segmentId, feature.wrongStreet.streetKeys
);
const where = wmeName ? `on ${wmeName}` : 'on another street';
// The segment id is cached from the last audit run, so it can be gone — split,
// deleted, or panned out of the model. Say so rather than telling the user to go
// fix a segment that was never selected: the panel's rename button acts on whatever
// IS selected, which would rename an innocent street.
const selected = selectSegment(feature.wrongStreet.segmentId);
toast(
`"${feature.number}" is ${where}, but eProstor has it on ${officialName} — `
+ (selected
? 'fix that house number instead of adding a duplicate'
: 'that segment is no longer loaded, so nothing was selected'),
'warning'
);
}
function onFeatureClick(feature) {
// Clear AFTER deciding we will act, not before. A click that does nothing —
// most often on an already-added, faded circle — used to close an open
// fix-street dialog anyway, which looked like the dialog dismissing itself
// whenever the map was nudged.
if (feature.processed) return;
// Wrong street: the number is in eProstor, just not on the street this WME house
// number hangs off. Adding here would duplicate it, so explain and hand over the
// segment that actually needs fixing. There is no "add anyway" on purpose.
if (feature.wrongStreet) {
explainWrongStreet(feature);
return;
}
// A click that will act supersedes any open fix-street dialog.
clearFixStreetState();
const streetName = streetNames[feature.street];
const named = findNearestSegment(feature, streetName, true);
const nearest = findNearestSegment(feature, streetName, false);
if (named) {
// The official street exists — but when it is much farther away than the
// closest differently-named segment, that closer segment is probably the
// real street carrying a wrong or missing name. Ask instead of silently
// adding the HN far from the house.
const suspiciouslyFar = nearest
&& nearest.segment.id !== named.segment.id
&& named.distance > FAR_STREET_MIN_DISTANCE
&& named.distance > FAR_STREET_RATIO * nearest.distance;
if (!suspiciouslyFar) {
addHouseNumberToSegment(feature, named.segment);
return;
}
startFixStreetFlow(feature, streetName, named.segment, {
farDistance: named.distance,
nearestDistance: nearest.distance
});
return;
}
if (!nearest) {
toast('No nearby segment found', 'warning');
return;
}
startFixStreetFlow(feature, streetName, nearest.segment);
}
// Attach a house number to a segment (shared by direct add, "add anyway" and post-rename auto-add).
// `anyway` marks the deliberate mismatch: the official street name exists nowhere in
// WME and the user chose this differently-named segment regardless.
function addHouseNumberToSegment(feature, segment, { anyway = false } = {}) {
// The single choke point for every add path, so a feature that turned into a
// wrong-street case after its dialog opened cannot slip through one of them.
if (feature.wrongStreet) {
explainWrongStreet(feature);
return;
}
markSelfSelection([segment.id]);
wmeSDK.Editing.setSelection({ selection: { ids: [segment.id], objectType: 'segment' } });
const key = featKey(feature.street, feature.number, feature.eX, feature.eY);
// Set before the call so a synchronous added-event can pair the new id with this feature.
pendingAddKey = key;
try {
wmeSDK.DataModel.HouseNumbers.addHouseNumber({
number: feature.number,
point: { type: 'Point', coordinates: [feature.lon, feature.lat] },
segmentId: segment.id
});
// Remember this add: fetchHouseNumbers won't report it until the editor saves.
sessionAddedKeys.add(key);
// An "add anyway" lands on a segment that is, by construction, named something
// else — so WME indexes the house number under that other name and this address
// never reads as added, no matter how many times it is recomputed. Without a
// record that survives the save, the next audit run pairs the two and reddens the
// circle the script itself filled in, accusing the user of the edit they asked
// for. Known gap: deleting such a house number after a save leaves the key behind
// (the id → key mapping the delete handlers use is cleared at save), so the circle
// stays faded until the next Load.
if (anyway) { addedAnywayKeys.add(key); everAddedAnywayKeys.add(key); }
feature.processed = true;
feature.conflict = false;
applyFeatureFilter();
console.log('[SL-HN] Added house number', feature.number);
toast(`Added house number ${feature.number}`, 'success');
} catch (err) {
pendingAddKey = null;
console.error('[SL-HN] Error adding house number:', err);
toast('Error adding house number. See console.', 'error');
}
}
// Close the fix-street dialog and remove the blue HN highlight
function clearFixStreetState() {
closeFixStreetDialog();
if (fixStreetHighlightStreetId !== null) {
fixStreetHighlightStreetId = null;
applyFeatureFilter();
}
}
// Official street name missing in WME (or only found suspiciously far away):
// preview affected segments and offer a one-click rename. addAnywaySegment is
// where "Add anyway" attaches the HN; farInfo = { farDistance, nearestDistance }
// marks the far-match variant.
function startFixStreetFlow(feature, officialName, addAnywaySegment, farInfo) {
const matchedHNs = lastFeatures.filter(f => f.street === feature.street);
// Candidate segments = nearest segment to each matched HN, minus ones already named correctly
const officialLower = officialName.toLowerCase();
const candidateIds = new Set();
matchedHNs.forEach(f => {
const found = findNearestSegment(f, null, false);
if (!found) return;
const seg = found.segment;
const street = seg.primaryStreetId
? wmeSDK.DataModel.Streets.getById({ streetId: seg.primaryStreetId })
: null;
if (street?.name && street.name.toLowerCase() === officialLower) return;
candidateIds.add(seg.id);
});
const segmentIds = [...candidateIds];
if (segmentIds.length > 0) {
markSelfSelection(segmentIds);
wmeSDK.Editing.setSelection({ selection: { ids: segmentIds, objectType: 'segment' } });
}
fixStreetHighlightStreetId = feature.street;
applyFeatureFilter();
const addAnywayStreet = addAnywaySegment.primaryStreetId
? wmeSDK.DataModel.Streets.getById({ streetId: addAnywaySegment.primaryStreetId })
: null;
showFixStreetDialog({
officialName,
hnCount: matchedHNs.length,
segmentCount: segmentIds.length,
nearestStreetName: addAnywayStreet?.name || null,
farInfo,
onRename: () => {
// Renames whatever is selected NOW (user may have adjusted the selection)
const renamed = updateSegmentStreetName(officialName, null);
if (renamed === 0) return; // nothing renamed (empty selection / all failed): keep dialog open
// Make the renamed street the current one so its circles turn green immediately
// (updateAddress fires no selection event, so onSelectionChanged won't do it for us)
const newStreetId = streets[officialName];
if (newStreetId) {
currentStreetId = newStreetId;
showCurrentStreet(officialName);
}
clearFixStreetState(); // repaints via applyFeatureFilter
analyzeStreetMatches();
// Renamed segments now match the official name; auto-add the clicked HN
const found = findNearestSegment(feature, officialName, true);
if (found) {
addHouseNumberToSegment(feature, found.segment);
} else {
toast('Street renamed — click the house number again to add it', 'info');
}
},
onAddAnyway: () => {
clearFixStreetState();
addHouseNumberToSegment(feature, addAnywaySegment, { anyway: true });
},
onCancel: () => {
clearFixStreetState();
}
});
}
const loading = document.createElement('div');
loading.style.position = 'absolute';
loading.style.bottom = '35px';
loading.style.width = '100%';
loading.style.pointerEvents = 'none';
loading.style.display = 'none';
loading.innerHTML =
'<div style="margin:0 auto; max-width:300px; text-align:center; background:rgba(0, 0, 0, 0.5); color:white; border-radius:3px; padding:5px 15px;"><i class="fa fa-pulse fa-spinner"></i> Loading address points</div>';
document.getElementById('map').appendChild(loading);
wmeSDK.Sidebar.registerScriptTab().then(({ tabLabel, tabPane }) => {
tabLabel.innerText = 'SL-HN';
tabLabel.title = 'Quick HN Importer (Slovenia)';
tabPane.innerHTML = `
<div id="qhnsl-pane" style="padding:10px;">
<h2 style="margin-top:0;">Quick HN Importer 🇸🇮</h2>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin:4px 0 8px 0;">
<button id="hn-load" class="wz-button"><span id="hn-load-label">Load selected street</span> <kbd style="margin-left:6px;font-size:10px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:rgba(0,0,0,0.08);border-radius:3px;padding:2px 5px;color:#555;">Alt+Shift+L</kbd></button>
<button id="hn-clear" class="wz-button wz-button--secondary">Clear <kbd style="margin-left:6px;font-size:10px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:rgba(0,0,0,0.08);border-radius:3px;padding:2px 5px;color:#555;">Alt+Shift+K</kbd></button>
</div>
<div id="hn-current-street" style="margin:8px 0;padding:8px;background:#f0f0f0;border-radius:4px;font-size:13px;display:none;">
<b>WME selected street:</b> <span id="hn-street-name" style="color:#2a7;font-weight:bold;">—</span>
</div>
<div id="hn-street-analysis" style="margin:8px 0;display:none;"></div>
<div id="hn-audit-summary" style="margin:8px 0;display:none;font-size:12px;"></div>
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">
<wz-checkbox id="hn-toggle">Show layer</wz-checkbox>
<wz-checkbox id="qhnsl-missing">Show only missing</wz-checkbox>
<wz-checkbox id="qhnsl-selected-only">Selected street only</wz-checkbox>
<wz-checkbox id="qhnsl-street-names">Show street names</wz-checkbox>
<wz-checkbox id="qhnsl-audit">Show WME HN audit</wz-checkbox>
<wz-checkbox id="qhnsl-autoload">Auto-load on street select</wz-checkbox>
<wz-checkbox id="qhnsl-navpoints">Show HN NavPoints</wz-checkbox>
<span style="font-size:12px;">Buffer (m): <input id="qhnsl-buffer" type="number" min="0" step="50" style="width:80px;margin-left:6px"></span>
</div>
<div id="hn-status" style="margin-top:10px;font-size:12px;color:#666;line-height:1.4;">${INSTRUCTIONS_HTML}</div>
</div>
`;
const btnLoad = tabPane.querySelector('#hn-load');
const btnLoadLabel = tabPane.querySelector('#hn-load-label');
const btnClear = tabPane.querySelector('#hn-clear');
const chkVis = tabPane.querySelector('#hn-toggle');
chkMissing = tabPane.querySelector('#qhnsl-missing');
chkSelectedOnly = tabPane.querySelector('#qhnsl-selected-only');
const chkStreetNames = tabPane.querySelector('#qhnsl-street-names');
const chkAudit = tabPane.querySelector('#qhnsl-audit');
const chkAutoLoad = tabPane.querySelector('#qhnsl-autoload');
const chkNavPoints = tabPane.querySelector('#qhnsl-navpoints');
const bufferEl = tabPane.querySelector('#qhnsl-buffer');
const statusDiv = tabPane.querySelector('#hn-status');
currentStreetDiv = tabPane.querySelector('#hn-current-street');
streetNameSpan = tabPane.querySelector('#hn-street-name');
streetAnalysisDiv = tabPane.querySelector('#hn-street-analysis');
auditSummaryDiv = tabPane.querySelector('#hn-audit-summary');
bufferEl.value = String(LS.getBuffer());
if (LS.getLayerVisible()) {
setChecked(chkVis, true);
overlays.hn.wanted = true;
updateLayerVisibility();
}
if (LS.getSelectedOnly()) {
setChecked(chkSelectedOnly, true);
}
setChecked(chkStreetNames, overlays.labels.wanted);
setChecked(chkAudit, overlays.audit.wanted);
setChecked(chkAutoLoad, LS.getAutoLoad());
setChecked(chkNavPoints, LS.getNavPoints());
bufferEl.addEventListener('change', () => {
const val = Number(bufferEl.value);
if (!Number.isFinite(val) || val < 0) {
bufferEl.value = String(LS.getBuffer());
return;
}
LS.setBuffer(val);
});
chkVis.addEventListener('click', () => {
const on = isChecked(chkVis);
setChecked(chkVis, !on);
overlays.hn.wanted = !on;
LS.setLayerVisible(!on);
updateLayerVisibility();
});
chkMissing.addEventListener('click', () => {
setChecked(chkMissing, !isChecked(chkMissing));
applyFeatureFilter();
});
chkStreetNames.addEventListener('click', () => {
const on = !isChecked(chkStreetNames);
setChecked(chkStreetNames, on);
overlays.labels.wanted = on;
LS.setStreetNames(on);
updateLayerVisibility();
// Label features are only built while the overlay is wanted, so turning it
// back on has to rebuild them — changing visibility alone would show an
// empty layer.
applyFeatureFilter();
});
chkAudit.addEventListener('click', () => {
const on = !isChecked(chkAudit);
setChecked(chkAudit, on);
overlays.audit.wanted = on;
LS.setAudit(on);
updateLayerVisibility();
renderAuditFindings();
});
chkAutoLoad.addEventListener('click', () => {
const on = !isChecked(chkAutoLoad);
setChecked(chkAutoLoad, on);
LS.setAutoLoad(on);
});
chkSelectedOnly.addEventListener('click', () => {
const newState = !isChecked(chkSelectedOnly);
setChecked(chkSelectedOnly, newState);
LS.setSelectedOnly(newState);
applyFeatureFilter();
});
// auto: true when triggered by selection change rather than by the user.
// Callers must not be wired directly to an event, or the event object lands
// in this options slot.
async function loadSelectedStreet({ auto = false } = {}) {
if (isLoading) return;
// Validate before wiping anything — an accidental Alt+Shift+L with no
// selection must not destroy the currently loaded street.
if (getSelectedSegments().length === 0) {
toast('Select a segment first.', 'warning');
return;
}
clearFixStreetState();
isLoading = true;
const myLoadId = ++currentLoadId;
// Everything below must run under try/finally. Without it a single throw —
// an SDK removeFeaturesFromLayer, an unprotected DOM write — leaves isLoading
// true forever, which rejects every later Load, silently kills auto-load, and
// strands the button on "Loading…" until the page is reloaded.
try {
btnLoad.disabled = true;
btnLoadLabel.textContent = 'Loading…';
setOverlayFeatures(overlays.hn, []);
setOverlayFeatures(overlays.labels, []);
setOverlayFeatures(overlays.audit, []);
lastAuditFindings = [];
streets = {};
streetNames = {};
currentStreetId = null;
lastFeatures = [];
// Reset coverage too: until this load succeeds we hold no authoritative data,
// and a stale box would let auto-load treat the area as already fetched.
// The attempt key goes with it — updateLayer re-sets it the moment it has a
// box, so this only matters on the paths that never get that far.
lastLoadedBbox = null;
lastAttemptedBbox = null;
streetAnalysisDiv.style.display = 'none';
if (auditSummaryDiv) auditSummaryDiv.style.display = 'none';
await updateLayer(statusDiv, myLoadId).catch(err => console.warn('[SL-HN] updateLayer:', err));
// Skip post-load side effects if user clicked Clear (or another Load) mid-fetch
if (myLoadId === currentLoadId) {
// Pressing Load is an explicit request to see the result, so it forces the
// layer on. An auto-load is not: silently re-showing an overlay the user
// hid, just because they selected a segment, would be surprising. The data
// is loaded either way and appears as soon as they tick Show layer.
if (!auto) {
overlays.hn.wanted = true;
setChecked(chkVis, true);
LS.setLayerVisible(true);
}
updateLayerVisibility();
}
} finally {
btnLoad.disabled = false;
btnLoadLabel.textContent = 'Load selected street';
isLoading = false;
}
}
// Wrapped, not passed directly: the click event would arrive as the options argument.
btnLoad.addEventListener('click', () => loadSelectedStreet());
// Dedup on the area eProstor was last asked about, not the area it successfully
// answered for. lastLoadedBbox is null after a failed or truncated fetch — right
// for the audit, which must never judge against data it does not hold, but as an
// auto-load key it means "nothing is covered", so every further selection change
// refires the same failing request, up to 30 paginated calls a time, at a service
// that has just failed. A manual Load ignores this key, so a retry stays one
// click away.
const autoLoadState = (selected) => ({
enabled: LS.getAutoLoad(),
isLoading,
dialogOpen: fixStreetDialogEl !== null,
selectedIds: selected.map(seg => seg.id),
selfSelectionIds,
now: Date.now(),
suppressUntil: suppressAutoLoadUntil,
selectionCovered: isSelectionInsideBbox(selected, lastAttemptedBbox)
});
// Auto-load fires on selection change, never on pan: updateLayer derives its
// bbox from the selected segment, and auto-fetching per pan would hammer the
// eProstor API far harder than the manual flow.
// Defined here (not next to onSelectionChanged) because loadSelectedStreet
// lives in this closure.
function maybeAutoLoad() {
const state = autoLoadState(getSelectedSegments());
// Consume the marker. Our setSelection raises exactly one selection-changed
// event, so once that event has been seen the ids must stop counting as ours —
// otherwise the user deliberately re-selecting one of those segments later, say
// the one an audit marker just centred on, would be dropped as ours forever.
// Read after the state snapshot, so this event is still judged with it.
if (isSameSelection(state.selectedIds, selfSelectionIds)) selfSelectionIds = null;
// Debounce: click-dragging across segments must fire one fetch, not many.
// Deliberately arms on 'defer' as well as 'load' — the deferred run re-checks.
if (decideAutoLoad(state) === 'drop') return;
if (autoLoadTimer) clearTimeout(autoLoadTimer);
autoLoadTimer = setTimeout(runAutoLoad, AUTO_LOAD_DEBOUNCE_MS);
}
function runAutoLoad() {
autoLoadTimer = null;
// Re-decide from scratch: during the debounce the user may have deselected,
// turned auto-load off, or opened the fix-street dialog.
const state = autoLoadState(getSelectedSegments());
const verdict = decideAutoLoad(state);
if (verdict === 'defer') {
autoLoadTimer = setTimeout(runAutoLoad, (state.suppressUntil - state.now) + 50);
return;
}
if (verdict !== 'load') return;
loadSelectedStreet({ auto: true }).catch(err => console.warn('[SL-HN] auto-load failed:', err));
}
wmeSDK.Events.on({ eventName: 'wme-selection-changed', eventHandler: maybeAutoLoad });
function clearLayer() {
clearFixStreetState();
currentLoadId++; // invalidate any in-flight load so its results are discarded
setOverlayFeatures(overlays.hn, []);
setOverlayFeatures(overlays.labels, []);
setOverlayFeatures(overlays.audit, []);
lastAuditFindings = [];
if (auditSummaryDiv) auditSummaryDiv.style.display = 'none';
// Cancel any armed auto-load, or it fires after this and silently undoes
// the Clear the user just asked for.
if (autoLoadTimer) { clearTimeout(autoLoadTimer); autoLoadTimer = null; }
lastLoadedBbox = null; // no reference data any more, so the audit must stay silent
// Clear is also how the user retries a failed area: dropping the attempt key
// lets auto-load fetch it again, instead of treating it as already tried.
lastAttemptedBbox = null;
overlays.hn.wanted = false;
updateLayerVisibility(); // keeps overlays.hn.shown in sync (a direct setLayerVisibility here left it stale)
setChecked(chkVis, false);
LS.setLayerVisible(false);
streets = {};
streetNames = {};
currentStreetId = null;
lastFeatures = [];
hideCurrentStreet();
streetAnalysisDiv.style.display = 'none';
statusDiv.innerHTML = INSTRUCTIONS_HTML;
}
btnClear.addEventListener('click', clearLayer);
renderAuditFindings = function () {
// Silent unless the markers are actually on screen. `wanted` alone was not
// enough: below MIN_OVERLAY_ZOOM, or when an auto-load left the base layer
// off, the summary still printed counts for markers that were not drawn and
// that handleMapClick refuses to hit-test.
if (!overlays.audit.wanted || !overlays.audit.shown) {
setOverlayFeatures(overlays.audit, []);
if (auditSummaryDiv) auditSummaryDiv.style.display = 'none';
return;
}
const visibleFindings = lastAuditFindings.filter(isAuditFindingVisible);
setOverlayFeatures(overlays.audit, visibleFindings.map(f => ({
type: 'Feature',
id: `qhnsl-audit-${f.hnId}`,
geometry: { type: 'Point', coordinates: [f.lon, f.lat] },
properties: { number: f.number, type: f.type, segmentId: f.segmentId }
})));
if (!auditSummaryDiv) return;
// Wrong-street cases are audit results too — their finding was dropped precisely
// because the red circle already says it. Counting them here is what keeps an area
// whose findings are *all* wrong-street pairs from reading exactly like an area
// where the audit found nothing.
const wrongStreet = lastFeatures.filter(f => f.wrongStreet && isFeatureVisible(f)).length;
if (!visibleFindings.length && !wrongStreet) {
auditSummaryDiv.style.display = 'none';
return;
}
const missing = visibleFindings.filter(f => f.type === 'missing').length;
const misplaced = visibleFindings.filter(f => f.type === 'misplaced').length;
auditSummaryDiv.innerHTML =
`<b style="color:#b04ce6;">Audit:</b> ${missing} not in eProstor · ${misplaced} misplaced`
+ (wrongStreet ? ` · <span style="color:#c0392b;">${wrongStreet} on the wrong street</span>` : '');
auditSummaryDiv.style.display = 'block';
};
applyFeatureFilter = function () {
// Drop unplottable points before handing them to the SDK: one bad coordinate
// makes it reject the whole batch, which would take out every circle. The
// label path already guards its centroids for the same reason.
const visible = lastFeatures
.filter(isFeatureVisible)
.filter(feat => Number.isFinite(feat.lon) && Number.isFinite(feat.lat));
setOverlayFeatures(overlays.hn, visible.map((feat, i) => ({
type: 'Feature',
id: `qhnsl-${i}`,
geometry: { type: 'Point', coordinates: [feat.lon, feat.lat] },
properties: {
number: feat.number,
street: feat.street,
processed: feat.processed,
// Derived, not stored twice. Both causes of a red circle — a different number
// already within 10 m, and this number sitting under another street name —
// are owned by different passes, and keeping a second `conflict` flag in sync
// with wrongStreet meant one pass could clear its own field and leave the
// other's red behind, with nothing to explain it.
conflict: feat.conflict || feat.wrongStreet != null,
isSelectedStreet: feat.street === currentStreetId,
fixHighlight: fixStreetHighlightStreetId != null && feat.street === fixStreetHighlightStreetId
}
})));
// One street-name label per street, anchored at the centroid of that street's
// visible house-number points. Skipped entirely when the overlay is off —
// building and uploading features nobody can see is pure waste.
const labelSdk = [];
if (overlays.labels.wanted) {
const byStreet = new Map();
visible.forEach(feat => {
let g = byStreet.get(feat.street);
if (!g) { g = { sumLon: 0, sumLat: 0, n: 0 }; byStreet.set(feat.street, g); }
g.sumLon += feat.lon; g.sumLat += feat.lat; g.n++;
});
byStreet.forEach((g, streetId) => {
const name = streetNames[streetId];
if (!name) return;
const lon = g.sumLon / g.n;
const lat = g.sumLat / g.n;
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return; // a NaN centroid is rejected by the SDK
labelSdk.push({
type: 'Feature',
id: `qhnsl-street-${streetId}`,
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: { name }
});
});
}
setOverlayFeatures(overlays.labels, labelSdk);
if (labelSdk.length) liftLabelLayer();
try {
renderAuditFindings();
} catch (e) {
console.warn('[SL-HN] audit render failed:', e);
}
};
// Single source of truth for a circle's processed/conflict state, shared
// by the initial load and every later recalculation. Processed = saved in
// WME (entry) OR added this session but not saved yet.
function computeFeatureState(streetId, hn, x, y, selectionHNMap) {
const entry = selectionHNMap.get(streetId);
const key = featKey(streetId, hn, x, y);
const processed = entry?.set.has(hn) === true
|| sessionAddedKeys.has(key)
|| addedAnywayKeys.has(key);
const conflict = !processed && hasConflict(hn, x, y, entry);
return { processed, conflict };
}
// The audit and the wrong-street reconciliation, in one place. Both the initial Load
// and every later recalculation run it, and they used to hold near-identical copies
// that were already drifting — one carried the comment explaining the recovery, the
// other silently did the same thing.
function runAudit(features, selectionHNMap) {
try {
return applyWrongStreetPairs(
features,
computeAuditFindings(features, selectionHNMap, lastLoadedBbox)
);
} catch (e) {
// A throw part-way through the apply would leave circles marked while the
// findings that explain them are gone. Clearing the marking is the whole
// recovery: the red is derived from wrongStreet, so nothing else is left over.
features.forEach(f => { if (f) f.wrongStreet = null; });
console.warn('[SL-HN] audit failed:', e);
return [];
}
}
// Generation counter for recalculateFeatureStates, which writes shared state after
// an await and is reachable from nine unserialized subscriptions.
let recalcId = 0;
async function recalculateFeatureStates() {
if (!lastFeatures.length) return;
// Pin both the generation and the array. A single user action fires several of
// these events, and a Load can replace lastFeatures wholesale while one of them is
// still fetching — a late run would otherwise stamp states derived from the old
// viewport onto the new features and repaint over the fresh render. updateLayer's
// currentLoadId cannot help here, since this path never participates in it.
const myRecalcId = ++recalcId;
const features = lastFeatures;
const selectionHNMap = await getVisibleHNsByStreet();
if (myRecalcId !== recalcId || features !== lastFeatures) return;
features.forEach(feat => {
const { number: hn, street: streetId, eX, eY } = feat;
if (!hn || !streetId) return;
const { processed, conflict } = computeFeatureState(streetId, hn, eX, eY, selectionHNMap);
feat.processed = processed;
feat.conflict = conflict;
});
lastAuditFindings = runAudit(features, selectionHNMap);
applyFeatureFilter();
}
function setupHouseNumberEventListeners() {
const refresh = () => {
if (lastFeatures.length > 0) {
recalculateFeatureStates().catch(err => console.warn('[SL-HN] recalculate failed:', err));
}
};
// An HN was added — if it was our pending add, remember its id so we can undo
// the session-added overlay if the same HN is later deleted.
wmeSDK.Events.on({
eventName: 'wme-house-number-added',
eventHandler: (payload) => {
const hnId = payload?.houseNumberId != null ? String(payload.houseNumberId) : null;
if (hnId != null) {
// This id exists again — it must no longer be treated as deleted.
deletedHnIds.delete(hnId);
if (pendingAddKey != null) {
hnIdToAddedKey.set(hnId, pendingAddKey);
sessionAddedKeys.add(pendingAddKey);
} else {
// Redo of a session add: restore its overlay.
const key = hnIdToAddedKey.get(hnId);
if (key != null) sessionAddedKeys.add(key);
}
}
pendingAddKey = null;
refresh();
}
});
// An HN was deleted — record its id (the model keeps returning it until save) and
// drop any matching session-added overlay so the circle un-fades immediately.
wmeSDK.Events.on({
eventName: 'wme-house-number-deleted',
eventHandler: (payload) => {
const hnId = payload?.houseNumberId != null ? String(payload.houseNumberId) : null;
if (hnId != null) {
// Known gap: undoing the delete of a SAVED HN fires no event at
// all, so this entry can go stale until save reconciles it.
deletedHnIds.add(hnId);
// Un-fade the circle, but KEEP the id → key mapping so a redo of
// this add (same id) can restore the overlay.
const key = hnIdToAddedKey.get(hnId);
if (key != null) {
sessionAddedKeys.delete(key);
// Drop the "added anyway" record too, or the circle stays faded for a
// house number that no longer exists. Only reachable before a save,
// which is also the only point where this mapping still exists.
addedAnywayKeys.delete(key);
}
}
refresh();
}
});
['wme-house-number-moved', 'wme-house-number-updated'].forEach(eventName => {
wmeSDK.Events.on({ eventName, eventHandler: refresh });
});
wmeSDK.Events.on({ eventName: 'wme-map-data-loaded', eventHandler: refresh });
// The high-level HN events don't fire on undo/redo of an unsaved add,
// but the data-model events do: undo removes the HN object from the
// model, redo re-adds it with the same id. Only ids we created (in
// hnIdToAddedKey) are touched, so unrelated model churn is ignored.
try {
wmeSDK.Events.trackDataModelEvents({ dataModelName: 'segmentHouseNumbers' });
wmeSDK.Events.on({
eventName: 'wme-data-model-objects-removed',
eventHandler: (payload) => {
if (payload?.dataModelName !== 'segmentHouseNumbers') return;
let changed = false;
(payload.objectIds || []).forEach(rawId => {
const key = hnIdToAddedKey.get(String(rawId));
if (key != null && sessionAddedKeys.has(key)) {
sessionAddedKeys.delete(key); // keep the mapping for a possible redo
addedAnywayKeys.delete(key);
changed = true;
}
});
if (changed) refresh();
}
});
wmeSDK.Events.on({
eventName: 'wme-data-model-objects-added',
eventHandler: (payload) => {
if (payload?.dataModelName !== 'segmentHouseNumbers') return;
let changed = false;
(payload.objectIds || []).forEach(rawId => {
const id = String(rawId);
// Unconditional: a house number reappearing is no longer deleted,
// whether we added it or not. Nesting this inside the hnIdToAddedKey
// guard below meant undoing the deletion of a SAVED house number left
// it flagged forever — it stayed hidden from the WME index, so its
// circle showed as missing and one click created a duplicate.
// Set.delete reports whether it removed anything, so the UI refreshes
// for this case too rather than waiting for an unrelated event.
if (deletedHnIds.delete(id)) changed = true;
const key = hnIdToAddedKey.get(id);
if (key != null && !sessionAddedKeys.has(key)) {
sessionAddedKeys.add(key);
if (everAddedAnywayKeys.has(key)) addedAnywayKeys.add(key);
changed = true;
}
});
if (changed) refresh();
}
});
} catch (err) {
console.warn('[SL-HN] could not track HN data-model events:', err);
}
// A successful save is the reconciliation point: fetchHouseNumbers now
// reflects reality, and saved HNs get new permanent ids, so the session
// overlays (keyed by pre-save ids) would only drift from here — drop them.
try {
wmeSDK.Events.on({
eventName: 'wme-save-finished',
eventHandler: (payload) => {
if (payload && payload.success === false) return;
sessionAddedKeys.clear();
deletedHnIds.clear();
hnIdToAddedKey.clear();
pendingAddKey = null;
refresh();
}
});
} catch (err) {
console.warn('[SL-HN] could not subscribe to wme-save-finished:', err);
}
// Listen for segment edits (like street name changes) to refresh UI
wmeSDK.Events.on({
eventName: 'wme-after-edit',
eventHandler: () => {
if (lastFeatures.length > 0) {
// Refresh the street analysis panel to reflect any street name changes
analyzeStreetMatches();
// Recompute, not just re-render: renaming a street is the main way a
// user fixes a 'missing' finding, and applyFeatureFilter alone would
// redraw the same purple marker over the problem they just solved.
recalculateFeatureStates().catch(err => console.warn('[SL-HN] recalculate after edit failed:', err));
}
}
});
}
setupHouseNumberEventListeners();
['qhnsl-load', 'qhnsl-clear'].forEach(id => {
try { wmeSDK.Shortcuts.deleteShortcut({ shortcutId: id }); } catch (_) {}
});
[
{ shortcutId: 'qhnsl-load', shortcutKeys: 'AS+l', description: 'SL-HN: Load selected street', callback: () => loadSelectedStreet() },
{ shortcutId: 'qhnsl-clear', shortcutKeys: 'AS+k', description: 'SL-HN: Clear', callback: clearLayer }
].forEach(spec => {
try { wmeSDK.Shortcuts.createShortcut(spec); }
catch (e) { console.warn('[SL-HN] failed to register shortcut', spec.shortcutId, e); }
});
function updateLayer(statusDiv, loadId) {
return new Promise((resolve) => {
const selectedSegments = getSelectedSegments();
if (selectedSegments.length === 0) {
toast('Select a segment first.', 'warning');
statusDiv.textContent = 'No segment selected.';
resolve();
return;
}
loading.style.display = null;
const bbox = computeFetchBbox(selectedSegments, LS.getBuffer());
if (!bbox) {
loading.style.display = 'none';
statusDiv.textContent = 'No geometry for selected segments.';
resolve();
return;
}
const { minE, minN, maxE, maxN } = bbox;
// Recorded before the request, not after: the point of this key is to stop
// auto-load re-asking for a box whose fetch failed, and a failed fetch never
// reaches the code below.
lastAttemptedBbox = { minE, minN, maxE, maxN };
Promise.all([
fetchAddresses(minE, minN, maxE, maxN, () => loadId !== currentLoadId),
getVisibleHNsByStreet()
])
.then(([fetchResult, selectionHNMap]) => {
// Bail out if user clicked Clear (or started a newer load) while the fetch was in flight
if (loadId !== currentLoadId) {
loading.style.display = 'none';
resolve();
return;
}
const apiFeatures = fetchResult.features;
const fetchComplete = fetchResult.complete;
const features = [];
for (const item of apiFeatures) {
const props = item.properties;
if (!props) continue;
// Skip addresses without coordinates
const e = props.E;
const n = props.N;
if (e == null || n == null) continue;
// Convert from EPSG:3794 to EPSG:4326
const [lon, lat] = proj4('EPSG:3794', 'EPSG:4326', [e, n]);
// Build house number from components
const hn = buildHouseNumber(props.HS_STEVILKA, props.HS_DODATEK);
if (!hn) continue;
// Get street name, or settlement name for villages without streets
const streetName = props.ULICA_NAZIV || props.NASELJE_NAZIV;
if (!streetName) continue;
const streetId = normalizeStreetName(streetName);
if (!streets[streetName]) {
streets[streetName] = streetId;
streetNames[streetId] = streetName;
}
const { processed, conflict } = computeFeatureState(streetId, hn, e, n, selectionHNMap);
features.push({
number: hn,
street: streetId,
processed,
conflict,
lon,
lat,
eX: e,
eY: n
});
}
lastFeatures = features; // computeAuditFindings reads lastFeatures
// Record the area eProstor actually answered for — only when the answer
// covered the whole box. A truncated result (page cap, bad page) would
// otherwise make the audit call addresses in the unfetched remainder
// "missing", and make auto-load consider that remainder already loaded.
// null means: no authoritative coverage, so the audit stays silent.
lastLoadedBbox = fetchComplete ? { minE, minN, maxE, maxN } : null;
if (!fetchComplete) {
console.warn('[SL-HN] partial address data: reverse audit disabled for this load');
}
lastAuditFindings = runAudit(lastFeatures, selectionHNMap);
const allStreetIds = new Set();
selectedSegments.forEach(seg => {
(seg.alternateStreetIds || []).forEach(id => allStreetIds.add(id));
if (seg.primaryStreetId) allStreetIds.add(seg.primaryStreetId);
});
currentStreetId = findBestMatchingStreetId(allStreetIds, features);
if (!features.length) {
loading.style.display = 'none';
statusDiv.textContent = 'No address points in view.';
resolve();
return;
}
lastFeatures = features;
if (currentStreetId && streetNames[currentStreetId]) {
showCurrentStreet(streetNames[currentStreetId]);
} else {
hideCurrentStreet();
}
setOverlayFeatures(overlays.hn, []);
applyFeatureFilter();
analyzeStreetMatches();
loading.style.display = 'none';
statusDiv.innerHTML = `Loaded ${lastFeatures.length} address points.<br/><b>Click numbers on map to add them!</b><br/>Green = selected • Orange = other • Red = conflict or wrong street`;
resolve();
})
.catch(err => {
console.error('[SL-HN] API error:', err);
loading.style.display = 'none';
if (loadId === currentLoadId) {
statusDiv.textContent = 'Error fetching address data. See console.';
// fetchAddresses names what actually went wrong; anything else falls back
// to the generic line.
toast(err?.userMessage || 'Error fetching address data.', 'error');
}
resolve();
});
});
}
// Visible HNs grouped by normalized street name (primary + alternate)
async function getVisibleHNsByStreet() {
const map = new Map();
const ext = wmeSDK.Map.getMapExtent();
const [lonMin, latMin, lonMax, latMax] = Array.isArray(ext)
? ext
: [ext.lonMin, ext.latMin, ext.lonMax, ext.latMax];
const segIds = wmeSDK.DataModel.Segments.getAll()
.filter(s => s.hasHouseNumbers)
.map(s => s.id);
const allHns = segIds.length
? await wmeSDK.DataModel.HouseNumbers.fetchHouseNumbers({ segmentIds: segIds })
: [];
allHns.forEach(hn => {
// Skip HNs deleted this session: the model still returns them until save.
if (deletedHnIds.has(String(hn.id))) return;
const seg = wmeSDK.DataModel.Segments.getById({ segmentId: hn.segmentId });
if (!seg) return;
const streetIdSet = new Set();
if (seg.primaryStreetId) {
streetIdSet.add(seg.primaryStreetId);
}
(seg.alternateStreetIds || []).forEach(id => {
if (id) streetIdSet.add(id);
});
if (!streetIdSet.size) return;
const g = getHNGeometry(hn);
let x, y;
if (g && typeof g.x === 'number' && typeof g.y === 'number') {
x = g.x;
y = g.y;
}
if (x == null || y == null || x < lonMin || x > lonMax || y < latMin || y > latMax) return;
const [eX, eY] = proj4('EPSG:4326', 'EPSG:3794', [x, y]);
const numRaw = normalizeHN(hn.number);
streetIdSet.forEach(streetId => {
const st = wmeSDK.DataModel.Streets.getById({ streetId });
const name = st?.name;
if (!name) return;
const sidNorm = normalizeStreetName(name);
let entry = map.get(sidNorm);
if (!entry) {
entry = { set: new Set(), items: [] };
map.set(sidNorm, entry);
}
entry.set.add(numRaw);
entry.items.push({
num: numRaw,
x: eX,
y: eY,
// Null, not String(hn.id): String(undefined) is the truthy string
// "undefined", which would collapse every id-less house number into
// one audit finding under a colliding feature id.
hnId: hn.id != null ? String(hn.id) : null,
segmentId: hn.segmentId,
lon: x,
lat: y
});
});
});
return map;
}
setupNavPoints(tabPane);
});
}
// Everything above is definitions only. This is the single entry point, and the
// only reason the file cannot simply be require()d — so it is the one thing the
// test suite skips. In Tampermonkey IN_USERSCRIPT_ENV is always true, so startup
// behaves exactly as before.
if (IN_USERSCRIPT_ENV) {
(unsafeWindow || window).SDK_INITIALIZED.then(() => {
wmeSDK = getWmeSdk({ scriptId: 'quick-hn-sl-importer', scriptName: 'Quick HN Importer (SI)' });
wmeSDK.Events.once({ eventName: 'wme-ready' }).then(() => {
// Last checked against SDK changelog entry v2.360, on 2026-07-26. The docs build
// then read v2.361-8-gc4a9d05e95; the two numbers routinely differ, so the
// changelog is the one to resume from — read the entries above v2.360:
// https://www.waze.com/editor/sdk/documents/CHANGELOG.html
// Per-class method lists are at /sdk/classes/index.SDK.Map.html and the same for
// Segments, Streets, HouseNumbers, Cities, Countries, Editing, Shortcuts and
// Sidebar. The Events page and MIGRATION.html need a signed-in browser.
//
// getWmeSdk is called without a `version`, so WME always hands this script the
// latest SDK. Nothing pins it, which is the reason these lists exist. All 31
// methods were present and undeprecated at v2.360, and the only deprecation in a
// namespace touched here is Editing.isEditingHouseNumbers, which is not called.
//
// Two entries mattered, both already handled:
// v2.359 updateAddress moved its fields into the addressData envelope; see
// the comment in updateSegmentStreetName
// v2.360 segment flag updates rerouted through ActionManager, and
// UserSession.isFirstLogin removed — neither is used here
//
// Keep the lists below in step with what the script actually calls:
// grep -oE 'wmeSDK\.[A-Za-z.]+\(' wme-sl-hn-import.user.js | sort -u
//
// Two lists, because the consequence differs. Anything in `required` aborts
// startup — put a method here only if the script is genuinely unusable without
// it. Adding a merely-nice-to-have here once made the whole script vanish when
// a single optional method was absent.
const required = [
'DataModel.Segments.getAll',
'DataModel.Segments.getById',
'DataModel.Streets.getAll',
'DataModel.Streets.getById',
'DataModel.Streets.getStreet',
'DataModel.HouseNumbers.fetchHouseNumbers',
'DataModel.HouseNumbers.addHouseNumber',
'DataModel.Segments.updateAddress',
'DataModel.Streets.addStreet',
'Editing.setSelection',
'Editing.getSelection',
'Events.on',
'Events.once',
'Sidebar.registerScriptTab',
'Map.addLayer',
'Map.addFeaturesToLayer',
'Map.removeFeaturesFromLayer',
'Map.setLayerVisibility',
'Map.getZoomLevel',
'Map.getMapExtent',
'Map.getMapPixelFromLonLat'
];
// Each of these is already guarded at its call site and degrades to a smaller
// feature set. They are checked only so a renamed SDK method shows up in the
// console instead of silently doing nothing.
const optional = [
'Map.setLayerZIndex', // street labels sink under other overlays
'Map.setMapCenter', // audit clicks select but do not recentre
'Shortcuts.createShortcut', // keyboard shortcuts unavailable
'Shortcuts.deleteShortcut',
'Events.trackDataModelEvents', // live un-fade on external HN edits
'DataModel.Cities.getById', // fallback city lookup for address-less segments
'DataModel.Cities.getCity',
'DataModel.Cities.getTopCity',
'DataModel.Cities.addCity',
'DataModel.Countries.getTopCountry'
];
const isMissing = (path) => {
let cur = wmeSDK;
for (const p of path.split('.')) { cur = cur?.[p]; if (cur == null) return true; }
return false;
};
const missing = required.filter(isMissing);
if (missing.length) {
console.error('[SL-HN] WME SDK missing required APIs:', missing);
toast(`SL-HN: WME SDK is missing ${missing.length} required APIs. See console.`, 'error');
return;
}
const missingOptional = optional.filter(isMissing);
if (missingOptional.length) {
console.warn('[SL-HN] WME SDK missing optional APIs (features degraded):', missingOptional);
}
init();
});
});
}
// Test-only surface. `module` does not exist in Tampermonkey, so this is a no-op
// there and the userscript is unaffected. Only pure functions are exposed —
// anything touching the SDK, the DOM or the network stays private.
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
normalizeHN,
buildHouseNumber,
normalizeStreetName,
buildCqlFilter,
hasConflict,
computeAuditFindings,
findWrongStreetPairs,
applyWrongStreetPairs,
computeFetchBbox,
isSelectionInsideBbox,
decideAutoLoad,
classifyFetchFailure,
decideFetchRetry,
fetchRetryDelay,
makeFeatKey,
NON_ADDRESSABLE_ROAD_TYPES,
AUDIT_MAX_DISTANCE,
MAX_HN_CONFLICT_DISTANCE
};
}
})();