您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Schneller Hausnummern-Import für AT: Mit R drücken und Klick die nächste Hausnummer übernehmen. Zeigt (optional) Hilfskreise, nutzt Tile-Cache + FIFO-Queue, ignoriert synthetische Klicks.
// ==UserScript== // @name WME Quick HN Importer AT (Reloaded) // @description Schneller Hausnummern-Import für AT: Mit R drücken und Klick die nächste Hausnummer übernehmen. Zeigt (optional) Hilfskreise, nutzt Tile-Cache + FIFO-Queue, ignoriert synthetische Klicks. // @version 2025.10.01.3 // @author Ari (Reloaded); Gerhard (modified for AT and providing API); Tom 'Glodenox' Puttemans (Original concept for BE) // @namespace http://www.kbox.at/ // @match https://beta.waze.com/*editor* // @match https://www.waze.com/*editor* // @exclude https://www.waze.com/*user/*editor/* // @exclude https://www.waze.com/discuss/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @connect wms.kbox.at // @run-at document-idle // @license GPL-2.0 // ==/UserScript== (function () { 'use strict'; // --- Config/keys --- const TAG = 'AT QHN:'; const SCRIPT_ID = 'at-qhn'; const KEYS = { ENABLE: 'ATQHN_ENABLED', CIRCLES: 'ATQHN_CIRCLES', SNAP_PX: 'ATQHN_SNAP_PX', DEV: 'ATQHN_DEV' }; const DEFAULTS = { ENABLE: true, CIRCLES: true, SNAP_PX: 120, DEV: false }; // Tile cache configuration const TILE = { SIZE_M: 750, TTL_DAYS: 7, MAX: 300, NS: 'ATQHN_TILE_', META: 'ATQHN_META' }; // --- Logging --- /* eslint-disable no-console */ const LOG = (...a) => console.log(`%c${TAG}`, 'color:#d97e00;font-weight:bold;', ...a); const WARN = (...a) => console.warn(TAG, ...a); const ERR = (...a) => console.error(`%c${TAG}`, 'color:#ff0033;font-weight:bold;', ...a); /* eslint-enable no-console */ // Persisted storage helpers (TMs/GM) const hasGM = typeof GM_getValue === 'function' && typeof GM_setValue === 'function'; const GM_Get = (k, d) => { try { return GM_getValue(k, d); } catch { return d; } }; const GM_Set = (k, v) => { try { GM_setValue(k, v); } catch {} }; // WME handles let WME = null; let OL = null; let wmeSDK = null; // UI/state let hintsLayer = null; let loadingBanner = null; let editButtonsRoot = null; let tabLabelEl = null; let isEnabled = hasGM ? GM_Get(KEYS.ENABLE, DEFAULTS.ENABLE) !== false : DEFAULTS.ENABLE; let circlesEnabled = hasGM ? GM_Get(KEYS.CIRCLES, DEFAULTS.CIRCLES) !== false : DEFAULTS.CIRCLES; let SNAP_PX = Number(GM_Get(KEYS.SNAP_PX, DEFAULTS.SNAP_PX)) || DEFAULTS.SNAP_PX; let devMode = hasGM ? !!GM_Get(KEYS.DEV, DEFAULTS.DEV) : DEFAULTS.DEV; // Debug logger (active only in Dev mode) /* eslint-disable no-console */ const DBG = (...a) => { if (devMode) console.debug('%cAT QHN:[dev]', 'color:#7a7a7a;font-weight:bold;', ...a); }; /* eslint-enable no-console */ // Two-tap flow state let armedForNext = false; let pendingRefPx = null; let pendingCommit = null; // Fetch throttling timestamp let suppressFetchUntil = 0; // Serialized fill queue let isFilling = false; const fillQueue = []; // Address points + street matching let atPoints = []; // { lon, lat, number, streetId, processed } let currentStreetId = null; let streetNames = {}; let streetsByName = {}; // Selection tracking fallback let selPollTimer = null; let lastSelKey = ''; // In-memory tile cache mirror const memTiles = new Map(); // --- Styles (scoped to this script) --- if (typeof GM_addStyle === 'function') { GM_addStyle(` .at-qhn-hints, .at-qhn-hints * { pointer-events: none !important; } .atqhn-pane { padding: 12px; font-size: 13px; line-height: 1.4; } .atqhn-row { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; } .atqhn-row code { user-select: text; } .atqhn-status { font-weight: 600; } .atqhn-btn { border: 1px solid #ccc; border-radius: 6px; padding: 6px 10px; cursor: pointer; background: #f5f5f5; } .atqhn-btn:hover { filter: brightness(0.98); } .atqhn-toggle { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; } .atqhn-muted { opacity: .7; } .atqhn-snap { display:flex; align-items:center; gap:8px; } .atqhn-snap input[type="number"] { width: 90px; padding:4px 6px; } .atqhn-hint { font-size: 12px; color: #666; } `); } // --- Bootstrap WME and start once available --- (async function waitForWME() { LOG('Bootstrapping…'); const ok = await poll( () => unsafeWindow?.W && unsafeWindow?.OpenLayers && unsafeWindow?.W?.map && unsafeWindow?.W?.model, 900, 150 ); if (!ok) return ERR('WME not ready, aborting'); WME = unsafeWindow.W; OL = unsafeWindow.OpenLayers; try { if (unsafeWindow.SDK_INITIALIZED) { await unsafeWindow.SDK_INITIALIZED; wmeSDK = unsafeWindow.getWmeSdk && unsafeWindow.getWmeSdk({ scriptId: SCRIPT_ID, scriptName: 'AT Quick HN' }); } } catch {} init(); })(); // --- Main init: layers, events, UI wiring --- function init() { LOG(`Init (SNAP_PX=${SNAP_PX}, circles default ${DEFAULTS.CIRCLES ? 'AN' : 'AUS'})`); const search = document.getElementById('search-autocomplete'); editButtonsRoot = (search && search.parentNode) || document.body; // Vector layer for hint circles hintsLayer = new OL.Layer.Vector('Quick HN Importer (AT)', { uniqueName: 'quick-hn-importer-at', className: 'at-qhn-hints', styleMap: new OL.StyleMap({ default: new OL.Style( { fillColor: '${fillColor}', fillOpacity: '${opacity}', fontColor: '#111', fontWeight: 'bold', strokeColor: '#fff', strokeOpacity: '${opacity}', strokeWidth: 2, pointRadius: '${radius}', label: '${number}', title: '${title}' }, { context: { fillColor: f => (f.attributes?.street === currentStreetId ? '#99ee99' : '#cccccc'), radius: f => Math.max(String(f.attributes?.number || '').length * 6, 10), opacity: f => (f.attributes?.street === currentStreetId && f.attributes?.processed ? 0.3 : 1), title: f => (f.attributes?.number && f.attributes?.street) ? `${streetNames[f.attributes.street] || ''} ${f.attributes.number}` : '' } } ) }) }); try { WME.map.addLayer(hintsLayer); } catch (e) { return ERR('Failed to add layer', e); } liftHints(); // Bottom loading banner while fetching address points loadingBanner = document.createElement('div'); loadingBanner.style.cssText = 'position:absolute;bottom:35px;width:100%;pointer-events:none;display:none;'; loadingBanner.innerHTML = '<div style="margin:0 auto;max-width:360px;text-align:center;background:rgba(0,0,0,.5);color:#fff;border-radius:5px;padding:6px 14px"><i class="fa fa-pulse fa-spinner"></i> Lade AT-Adresspunkte</div>'; (document.getElementById('map') || document.body).appendChild(loadingBanner); // Subscribe to selection/house-number events (SDK if available, else fallback poller) if (wmeSDK) { wmeSDK.Events.on({ eventName: 'wme-selection-changed', eventHandler: onSelectionChanged }); wmeSDK.Events.on({ eventName: 'wme-house-number-added', eventHandler: refreshProcessedFromModel }); wmeSDK.Events.on({ eventName: 'wme-house-number-moved', eventHandler: refreshProcessedFromModel }); LOG('SDK events wired'); } else { selPollTimer = setInterval(() => { if (!isEnabled) return; if (Date.now() < suppressFetchUntil) return; const segs = safeSegs(); const key = segs ? segs.map(s => s.attributes.id).join(',') : ''; if (key !== lastSelKey) { lastSelKey = key; onSelectionChanged(); } }, 800); LOG('Fallback selection poller active'); } // Capture "R" in capture phase to avoid default reverse-direction action const keyHandler = (e) => { if (!isEnabled || !e?.key) return; if (e.key.toLowerCase() === 'r' && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) { const segs = safeSegs(); if (segs?.length && !isTyping()) { e.stopImmediatePropagation(); e.stopPropagation(); e.preventDefault(); onPressR(); } } }; document.addEventListener('keydown', keyHandler, true); window.addEventListener('keydown', keyHandler, true); onSelectionChanged(); WME.map?.events?.register('moveend', this, liftHints); WME.map?.events?.register('zoomend', this, liftHints); WME.map?.events?.register('changelayer', this, liftHints); setupSidebarTab(); applyEnabledStateToUi(); } // --- Sidebar UI --- async function setupSidebarTab() { try { if (!WME?.userscripts?.registerSidebarTab) throw new Error('Sidebar API not available'); const { tabLabel, tabPane } = WME.userscripts.registerSidebarTab(SCRIPT_ID); tabLabelEl = tabLabel; updateTabLabel(); tabLabel.title = 'AT Quick HN Importer'; tabPane.innerHTML = ` <div class="atqhn-pane"> <div class="atqhn-row"> <label class="atqhn-toggle"> <input type="checkbox" id="atqhn-enabled"> <span>Skript aktivieren (drücke <code>R</code> zum Ausfüllen)</span> </label> </div> <div class="atqhn-row"> <label class="atqhn-toggle"> <input type="checkbox" id="atqhn-circles"> <span>Kreise anzeigen</span> </label> </div> <div class="atqhn-row atqhn-snap"> <label for="atqhn-snap">SNAP-Abstand (px):</label> <input type="number" id="atqhn-snap" min="40" max="400" step="10" placeholder="120"> </div> <div class="atqhn-row atqhn-hint"> Ab einer Grösse über 120 px steigt das Risiko, dass zur falschen Nummer gesnappt wird. Bitte im eigenen Gebiet testen. </div> <div class="atqhn-row atqhn-muted"> Status: <span id="atqhn-status" class="atqhn-status"></span> </div> <div class="atqhn-row"> <button type="button" id="atqhn-clear" class="atqhn-btn">HN-Cache leeren</button> <button type="button" id="atqhn-reload" class="atqhn-btn">HN aktualisieren</button> </div> <div class="atqhn-row atqhn-muted"> Ablauf: R+Klick füllt HN und deaktiviert die Eingabe. </div> <div class="atqhn-row atqhn-muted"> Dieses Tool ermittelt die nächstgelegene Hausnummer, prüft aber nicht die Strassenzuordnung.<br /> Grüner Kreis = Nummer auf der gewählten Strasse<br /> Grauer Kreis = Nummer auf einer anderen Strasse oder Name weicht ab (z. B. „Sankt“ statt „St.“). Bitte mit Bedacht anwenden. </div> <!-- Dev mode at the very bottom: small + grey --> <div class="atqhn-row atqhn-muted" style="font-size:11px;"> <label class="atqhn-toggle"> <input type="checkbox" id="atqhn-dev"> <span>Dev-Modus (zusätzliche Logs)</span> </label> </div> </div>`; await WME.userscripts.waitForElementConnected(tabPane); const cbEnabled = tabPane.querySelector('#atqhn-enabled'); const cbCircles = tabPane.querySelector('#atqhn-circles'); const cbDev = tabPane.querySelector('#atqhn-dev'); const snapInput = tabPane.querySelector('#atqhn-snap'); const st = tabPane.querySelector('#atqhn-status'); const clr = tabPane.querySelector('#atqhn-clear'); const rld = tabPane.querySelector('#atqhn-reload'); cbEnabled.checked = !!isEnabled; cbCircles.checked = !!circlesEnabled; cbDev.checked = !!devMode; snapInput.value = String(SNAP_PX); st.textContent = isEnabled ? 'AN' : 'AUS'; cbEnabled.addEventListener('change', () => setEnabled(cbEnabled.checked)); cbCircles.addEventListener('change', () => setCirclesEnabled(cbCircles.checked)); cbDev.addEventListener('change', () => setDevMode(cbDev.checked)); snapInput.addEventListener('change', () => { const v = Number(snapInput.value); setSnapPx(v); snapInput.value = String(SNAP_PX); }); clr.addEventListener('click', () => { clearCache(); }); rld.addEventListener('click', () => { if (isEnabled) onSelectionChanged(); }); tabPane.addEventListener('element-disconnected', () => {}); } catch (e) { WARN('Sidebar setup unavailable:', e?.message || e); } } // Update the tab label (shows ON/OFF and DEV tag) function updateTabLabel() { if (!tabLabelEl) return; const onOff = isEnabled ? '• AN 🟢' : ''; const devTag = devMode ? ' • DEV 🛠' : ''; tabLabelEl.textContent = `AT-QHN ${onOff}${devTag}`; } // --- State setters (toggle handlers) --- function setDevMode(v) { devMode = !!v; GM_Set(KEYS.DEV, devMode); updateTabLabel(); LOG('Dev mode', devMode ? 'AN' : 'AUS'); } function setSnapPx(v) { let nv = Math.round(Number(v)); if (!Number.isFinite(nv)) nv = DEFAULTS.SNAP_PX; nv = Math.max(40, Math.min(400, nv)); SNAP_PX = nv; GM_Set(KEYS.SNAP_PX, SNAP_PX); LOG('SNAP_PX =', SNAP_PX, '(>120 can mis-snap; test locally)'); } function setEnabled(v) { isEnabled = !!v; GM_Set(KEYS.ENABLE, isEnabled); applyEnabledStateToUi(); if (isEnabled) { onSelectionChanged(); LOG('Script ON'); } else { // Reset ephemeral state when disabling armedForNext = false; pendingRefPx = null; pendingCommit = null; fillQueue.length = 0; atPoints = []; currentStreetId = null; streetNames = {}; streetsByName = {}; hintsLayer?.removeAllFeatures(); hintsLayer?.setVisibility(false); showLoading(false); LOG('Script OFF'); } } function setCirclesEnabled(v) { circlesEnabled = !!v; GM_Set(KEYS.CIRCLES, circlesEnabled); try { if (!circlesEnabled) { hintsLayer?.removeAllFeatures(); hintsLayer?.setVisibility(false); } else { if (isEnabled && atPoints?.length) onSelectionChanged(); hintsLayer?.setVisibility(isEnabled && circlesEnabled); liftHints(); } } catch {} applyEnabledStateToUi(); LOG(`Circles ${circlesEnabled ? 'ON' : 'OFF'}`); } // Apply current state to UI controls and layer visibility function applyEnabledStateToUi() { try { const cbE = document.querySelector('#atqhn-enabled'); const cbC = document.querySelector('#atqhn-circles'); const cbD = document.querySelector('#atqhn-dev'); const st = document.querySelector('#atqhn-status'); const snapInput = document.querySelector('#atqhn-snap'); if (cbE) cbE.checked = !!isEnabled; if (cbC) cbC.checked = !!circlesEnabled; if (cbD) cbD.checked = !!devMode; if (st) st.textContent = isEnabled ? 'AN' : 'AUS'; if (snapInput) snapInput.value = String(SNAP_PX); updateTabLabel(); } catch {} try { if (!isEnabled) { hintsLayer?.setVisibility(false); showLoading(false); } else { hintsLayer?.setVisibility(!!circlesEnabled); if (circlesEnabled) liftHints(); } } catch {} } // Bring hint layer above WME overlays and keep it non-interactive function liftHints() { try { if (!isEnabled || !circlesEnabled) return; hintsLayer?.setVisibility(true); hintsLayer?.setOpacity(1); hintsLayer?.setZIndex?.(9000000); if (hintsLayer?.div) hintsLayer.div.style.pointerEvents = 'none'; } catch {} } // --- Selection helpers --- function safeSegs() { try { const sel = WME.selectionManager.getSegmentSelection(); return sel && sel.segments && sel.segments.length ? sel.segments : null; } catch { return null; } } function onSelectionChanged() { pendingCommit = null; if (!isEnabled) { hintsLayer?.removeAllFeatures(); atPoints = []; currentStreetId = null; streetNames = {}; streetsByName = {}; return; } if (Date.now() < suppressFetchUntil) return; const segs = safeSegs(); if (segs?.length) { DBG('onSelectionChanged: segs=%d', segs.length); fetchATForSelection(); } else { hintsLayer?.removeAllFeatures(); atPoints = []; currentStreetId = null; streetNames = {}; streetsByName = {}; } } // --- R-key flow: arm, capture click, fill input --- async function onPressR() { if (!isEnabled) return; // If a previous number is ready to commit, try to commit it first if (pendingCommit && !getSelectionHNs().includes(String(pendingCommit.number))) { await commitPending(); } LOG('R pressed - waiting for next click'); armedForNext = true; pendingRefPx = null; if (!hintsLayer.features.length) fetchATForSelection(); if (circlesEnabled) { hintsLayer.setVisibility(true); liftHints(); } // Open HN tool if possible const btn = findHNButton(); if (btn) btn.click(); // Capture the next click within the map viewport let cancelTimer = null; const cleanup = () => { clearTimeout(cancelTimer); document.removeEventListener('mousedown', cap, true); document.removeEventListener('click', cap, true); }; const cap = (ev) => { try { if (!isEnabled) { cleanup(); return; } if (ev && ev.isTrusted === false) return; // ignore synthetic const vp = WME.map.viewPortDiv || document.querySelector('.olMapViewport'); if (!vp) return; const rect = vp.getBoundingClientRect(); const inside = ev.clientX >= rect.left && ev.clientX <= rect.right && ev.clientY >= rect.top && ev.clientY <= rect.bottom; if (!inside) return; const px = new OL.Pixel(ev.clientX - rect.left, ev.clientY - rect.top); pendingRefPx = px; LOG('Captured click pixel', { x: px.x, y: px.y }); enqueueFillJob(px); cleanup(); } catch { cleanup(); } }; document.addEventListener('mousedown', cap, true); document.addEventListener('click', cap, true); // Safety timeout to disarm cancelTimer = setTimeout(() => { cleanup(); if (armedForNext) { armedForNext = false; LOG('Timeout - no click captured'); } }, 4000); } // Try to find the "add house number" button heuristically function findHNButton() { const sels = ['[data-testid="add-house-number"]', '.add-house-number', 'wz-button:has(.w-icon-home)']; for (const s of sels) { const el = document.querySelector(s) || editButtonsRoot.querySelector?.(s); if (el) return el; } return null; } // Avoid intercepting R when typing in inputs function isTyping() { const el = document.activeElement; return !!(el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)); } // External trigger (kept for completeness) async function pollForHNInputAndFill() { if (!isEnabled || !armedForNext || !pendingRefPx) return; enqueueFillJob(pendingRefPx); } // --- Queue + fill execution --- function enqueueFillJob(px) { if (!isEnabled) return; const snapshot = atPoints.slice(); // take a snapshot to avoid race with async updates DBG('enqueueFillJob px=%o queueLen(before)=%d', px, fillQueue.length); fillQueue.push({ px, atSnapshot: snapshot }); armedForNext = false; if (!isFilling) drainFillQueue(); } async function drainFillQueue() { if (isFilling) return; isFilling = true; try { while (fillQueue.length) { const job = fillQueue.shift(); if (!isEnabled) break; await runOneFillJob(job); await sleep(120); } } finally { isFilling = false; } } async function runOneFillJob(job) { if (!isEnabled) return; const deadline = Date.now() + 3000; let inputEl = null; while (Date.now() < deadline) { inputEl = findHNInputInTree(document); if (inputEl) break; await sleep(60); } if (!inputEl) { WARN('HN input not found (selectors may have changed)'); return; } DBG('runOneFillJob: input found'); await tryFillFromAT(inputEl, job.px, job.atSnapshot); } // Locate the HN input robustly (set of fallbacks) function findHNInputInTree(root) { const sels = [ 'div.house-number.is-active input.number:not(.number-preview)', 'div.house-number.is-active input[type="text"]:not(.number-preview)', '[data-testid="house-number-input"] input', 'input[name="number"]', 'input[aria-label="House number"]', 'input[placeholder="House number"]', 'input.number', 'input[type="text"]' ]; for (const s of sels) { const el = root.querySelector(s); if (el && el.tagName === 'INPUT' && !el.disabled) return el; } return null; } // --- Tile helpers --- const tileKeyForXY = (x, y) => `${Math.floor(x / TILE.SIZE_M)}_${Math.floor(y / TILE.SIZE_M)}`; function tilesForBounds(b) { const x1 = Math.floor(b.left / TILE.SIZE_M); const y1 = Math.floor(b.bottom / TILE.SIZE_M); const x2 = Math.floor(b.right / TILE.SIZE_M); const y2 = Math.floor(b.top / TILE.SIZE_M); const keys = []; for (let ty = y1; ty <= y2; ty += 1) for (let tx = x1; tx <= x2; tx += 1) keys.push(`${tx}_${ty}`); return keys; } function bboxFromTiles(keys) { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const k of keys) { const [txS, tyS] = k.split('_'); const tx = +txS; const ty = +tyS; const left = tx * TILE.SIZE_M, bottom = ty * TILE.SIZE_M; const right = left + TILE.SIZE_M, top = bottom + TILE.SIZE_M; minX = Math.min(minX, left); minY = Math.min(minY, bottom); maxX = Math.max(maxX, right); maxY = Math.max(maxY, top); } return { x1: Math.floor(minX), y1: Math.floor(minY), x2: Math.ceil(maxX), y2: Math.ceil(maxY) }; } const nowDays = () => Math.floor(Date.now() / 86400000); function getTileFromStore(key) { const m = memTiles.get(key); if (m) return m; if (!hasGM) return null; try { const raw = GM_getValue(TILE.NS + key, null); if (!raw) return null; const obj = JSON.parse(raw); memTiles.set(key, obj); return obj; } catch { return null; } } function putTileToStore(key, obj) { memTiles.set(key, obj); if (!hasGM) return; try { GM_Set(TILE.NS + key, JSON.stringify(obj)); // via wrapper const meta = loadMeta(); touchLRU(meta, key); enforceLRU(meta); saveMeta(meta); } catch {} } function loadMeta() { if (!hasGM) return { order: [] }; try { const m = GM_getValue(TILE.META, null); return m ? JSON.parse(m) : { order: [] }; } catch { return { order: [] }; } } function saveMeta(meta) { if (!hasGM) return; try { GM_setValue(TILE.META, JSON.stringify(meta)); } catch {} } function touchLRU(meta, key) { meta.order = (meta.order || []).filter(k => k !== key); meta.order.push(key); } function enforceLRU(meta) { while ((meta.order || []).length > TILE.MAX) { const victim = meta.order.shift(); try { GM_deleteValue(TILE.NS + victim); } catch {} memTiles.delete(victim); } } function clearCache() { try { if (hasGM) { GM_listValues().forEach(k => { if (String(k).startsWith(TILE.NS) || k === TILE.META) GM_deleteValue(k); }); } memTiles.clear(); LOG('Cache cleared'); } catch (e) { ERR('clearCache error', e); } } const isFresh = (tileObj) => !!(tileObj && typeof tileObj.ts === 'number' && nowDays() - tileObj.ts <= TILE.TTL_DAYS); // --- Fetch + draw (with cache) --- function fetchATForSelection() { if (!isEnabled) return; const segs = safeSegs(); if (!segs?.length) return; // Build padded bbox around selection (projected meters) let bounds = null; for (const seg of segs) { const b = seg.attributes.geometry.getBounds(); bounds = bounds ? (bounds.extend(b), bounds) : b; } const padded = { left: Math.floor(bounds.left - 200), right: Math.floor(bounds.right + 200), top: Math.floor(bounds.top + 200), bottom: Math.floor(bounds.bottom - 200) }; const neededKeys = tilesForBounds(padded); // Try cache first let allFresh = true; let assembled = []; for (const key of neededKeys) { const tile = getTileFromStore(key); if (!isFresh(tile)) { allFresh = false; break; } if (tile?.items?.length) assembled = assembled.concat(tile.items); } if (allFresh) { LOG(`Cache hit (${neededKeys.length} tile(s)) - skipping network`); processATResultArray(assembled, segs); return; } // Network fetch showLoading(true); const body = bboxFromTiles(neededKeys); DBG('POST /adr body', body); GM_xmlhttpRequest({ method: 'POST', url: 'https://wms.kbox.at/adr', data: JSON.stringify(body), responseType: 'json', headers: { Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json' }, onload: (resp) => { let result = resp.response; if (!result) { try { result = JSON.parse(resp.responseText || '[]'); } catch (e) { ERR('AT JSON parse fail', e, resp.responseText); showLoading(false); return; } } const buckets = new Map(); for (const r of result) { const x = r.lon, y = r.lat, key = tileKeyForXY(x, y); if (!buckets.has(key)) buckets.set(key, []); buckets.get(key).push({ lon: x, lat: y, number: String(r.hausnummerzahl1), streetId: String(r.strassennr), streetName: r.strassenname }); } const today = nowDays(); for (const k of neededKeys) { const items = buckets.get(k) || []; putTileToStore(k, { ts: today, items }); } let assembledAfter = []; for (const k of neededKeys) { const tile = getTileFromStore(k); if (tile?.items?.length) assembledAfter = assembledAfter.concat(tile.items); } processATResultArray(assembledAfter, segs); showLoading(false); }, onerror: (e) => { ERR('AT request error', e); showLoading(false); } }); } // Build features, color by street match, and draw circles function processATResultArray(items, segs) { const existing = getSelectionHNs(); atPoints = []; const features = []; streetNames = {}; streetsByName = {}; for (const r of items) { const { lon: x, lat: y, number, streetId, streetName } = r; atPoints.push({ lon: x, lat: y, number, streetId, processed: existing.includes(number) }); if (circlesEnabled) { features.push(new OL.Feature.Vector(new OL.Geometry.Point(x, y), { number, street: streetId, processed: existing.includes(number) })); } if (streetName) streetsByName[streetName] = streetId; if (streetId) streetNames[streetId] = streetName || streetNames[streetId] || ''; } // Determine currentStreetId from selected segment's streets const seg0 = segs[0]; const segStIds = (seg0?.attributes?.streetIDs || []).slice(); if (seg0?.attributes?.primaryStreetID != null) segStIds.push(seg0.attributes.primaryStreetID); const selectedStreetNames = WME.model.streets.getByIds(segStIds).map(s => s?.attributes?.name).filter(Boolean); const matchingName = selectedStreetNames.find(nm => streetsByName[nm] != null); currentStreetId = streetsByName[matchingName]; hintsLayer.removeAllFeatures(); if (circlesEnabled && features.length) hintsLayer.addFeatures(features); hintsLayer.setVisibility(!!(isEnabled && circlesEnabled)); if (circlesEnabled) liftHints(); LOG(`Loaded circles: ${features.length} (items: ${items.length}); street match: ${matchingName || '(none)'}; cache(mem)=${memTiles.size}`); } // React-safe input setter (ensures React picks up value changes) function setReactInputValue(input, value) { const win = input.ownerDocument.defaultView || window; const desc = Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, 'value'); const nativeSetter = desc && desc.set; if (!nativeSetter) { input.value = value; return; } nativeSetter.call(input, value); input.dispatchEvent(new win.Event('input', { bubbles: true })); input.dispatchEvent(new win.Event('change', { bubbles: true })); } // --- Commit helpers --- function isVisible(el) { if (!el || !el.getBoundingClientRect) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden'; } function clickLikeHuman(el) { try { const w = el.ownerDocument.defaultView || window; const r = el.getBoundingClientRect(); const cx = r.left + Math.min(Math.max(4, r.width/2), r.width-4); const cy = r.top + Math.min(Math.max(4, r.height/2), r.height-4); const opts = { bubbles:true, cancelable:true, clientX:cx, clientY:cy }; el.focus?.(); el.dispatchEvent(new w.MouseEvent('mousedown', opts)); el.dispatchEvent(new w.MouseEvent('mouseup', opts)); el.dispatchEvent(new w.MouseEvent('click', opts)); } catch {} } function findHouseNumberPanelRoot() { return document.querySelector('div.house-number.is-active') || document.querySelector('#edit-panel') || document.querySelector('[data-testid="edit-panel"]'); } // Heuristics to find the "commit/apply" button in the HN panel function collectCommitButtonsStrict() { const root = findHouseNumberPanelRoot(); if (!root) return []; const btns = Array.from(root.querySelectorAll('button,[role="button"],wz-button,wz-icon-button')).filter(isVisible); return btns.filter((el) => { const cls = (el.className || '').toString().toLowerCase(); if (cls.includes('edit-restrictions')) return false; const txt = (el.textContent || '').toLowerCase(); const title = (el.getAttribute?.('title') || '').toLowerCase(); const aria = (el.getAttribute?.('aria-label') || '').toLowerCase(); const hay = `${txt} ${title} ${aria}`; if (/beschr(a|ä)nkung|restriction/.test(hay)) return false; const hasCheckIcon = !!(el.querySelector?.('.w-icon-check,.fa-check')); const isActionWord = /\b(save|apply|commit|confirm|ok|add|speichern|bestätigen|hinzufügen)\b/.test(hay); return hasCheckIcon || isActionWord; }); } async function commitPending(){ if (!pendingCommit) return false; const ok = await commitHouseNumberViaUI(String(pendingCommit.number)); if (ok) { pendingCommit = null; refreshProcessedFromModel(); return true; } return false; } function getSelectionHNs() { const segs = safeSegs(); if (!segs) return []; const ids = segs.map(s=>s.attributes.id); return WME.model.segmentHouseNumbers.getObjectArray() .filter(h => ids.includes(h.attributes.segID)) .map(h => String(h.attributes.number)); } async function commitHouseNumberViaUI(numberStr) { // Allow UI to render the button await sleep(400); const before = getSelectionHNs(); const buttons = collectCommitButtonsStrict(); if (!buttons.length) { LOG('commit: no HN commit buttons found'); return false; } const preferred = buttons.filter(b => { const hay = `${b.textContent || ''} ${b.getAttribute?.('title') || ''} ${b.getAttribute?.('aria-label') || ''}`.toLowerCase(); return /✓|✔|save|apply|commit|confirm|ok|speichern|bestätigen/.test(hay) || b.querySelector?.('.w-icon-check,.fa-check'); }); const candidates = preferred.length ? preferred : buttons; LOG(`commit: trying button(s) (preferred=${preferred.length}, total=${buttons.length})`); for (let i=0;i<candidates.length;i+=1){ const b = candidates[i]; clickLikeHuman(b); await sleep(250); const after = getSelectionHNs(); if (after.length > before.length || after.includes(String(numberStr))) { LOG(`commit: success (index ${i}, ${preferred.length ? 'preferred' : 'fallback'})`); return true; } } WARN('commit: no change detected after clicks'); return false; } // --- Fill flow: choose nearest dot and write it into the input --- async function tryFillFromAT(inputEl, refPx, atSnapshotOpt) { try { if (!isEnabled) return; const snap = Array.isArray(atSnapshotOpt) && atSnapshotOpt.length ? atSnapshotOpt : atPoints; DBG('tryFillFromAT refPx=%o snapshotLen=%d', refPx, Array.isArray(atSnapshotOpt) ? atSnapshotOpt.length : atPoints.length); if (!refPx) return; const found = nearestATByPixel(refPx, snap); if (!found) { WARN('fill: no candidate'); return; } const { point, distPx } = found; LOG('fill: candidate', { number: point.number, distPx: Math.round(distPx), SNAP_PX }); if (distPx > SNAP_PX) { WARN(`fill: skipped - nearest dot too far (SNAP_PX=${SNAP_PX})`); return; } inputEl.focus(); setReactInputValue(inputEl, ''); setReactInputValue(inputEl, String(point.number)); try { inputEl.blur(); } catch {} pendingCommit = { number: String(point.number) }; LOG('fill: entered', point.number, '- press R to commit'); } catch (e) { ERR('fill: exception', e?.message || e, e?.stack || ''); } } // --- Pixel-nearest helper and projection conversion --- function nearestATByPixel(clickPx, pointsList = atPoints) { if (!pointsList.length) return null; let best = null, bestD = Infinity; for (const p of pointsList) { const geo = toGeoLonLatFromProjected(p.lon, p.lat); const pDot = WME.map.getPixelFromLonLat(geo); if (!pDot) continue; const d = Math.hypot(clickPx.x - pDot.x, clickPx.y - pDot.y); if (d < bestD) { bestD = d; best = p; } } return best ? { point: best, distPx: bestD } : null; } let _epsg4326 = null; function toGeoLonLatFromProjected(x, y) { try { _epsg4326 = _epsg4326 || new OL.Projection('EPSG:4326'); const proj = WME.map.getProjectionObject?.() || WME.map.projection || new OL.Projection('EPSG:900913'); const ll = new OL.LonLat(x, y); return ll.transform(proj, _epsg4326); } catch { // Spherical mercator fallback const R = 6378137; const lon = (x / R) * (180 / Math.PI); const lat = (2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * (180 / Math.PI); return new OL.LonLat(lon, lat); } } // --- Sync processed state back into features after model changes --- function refreshProcessedFromModel() { const current = getSelectionHNs(); for (const p of atPoints) p.processed = current.includes(p.number); if (circlesEnabled && hintsLayer?.features?.length) { for (const f of hintsLayer.features) if (f?.attributes) f.attributes.processed = current.includes(f.attributes.number); hintsLayer.redraw(); } } // --- Misc helpers --- function showLoading(show) { if (!loadingBanner) return; if (!isEnabled) { loadingBanner.style.display = 'none'; return; } loadingBanner.style.display = show ? '' : 'none'; } const sleep = (ms) => new Promise(r => setTimeout(r, ms)); function poll(test, maxTries = 300, delay = 100) { return new Promise(res => { let i = 0; const t = setInterval(() => { try { if (test()) { clearInterval(t); res(true); } else if ((i += 1) >= maxTries) { clearInterval(t); res(false); } } catch {} }, delay); }); } })();