WME Quick HN Importer CH

Quick housnumber importer CH: fetches address points per tile via GeoAdmin Identify, colors green when street names match; Do NOT use together with other QHN scripts.

// ==UserScript==
// @name         WME Quick HN Importer CH
// @description  Quick housnumber importer CH: fetches address points per tile via GeoAdmin Identify, colors green when street names match; Do NOT use together with other QHN scripts.
// @version      2025.10.03.12
// @author       Ari (Reloaded); Gerhard; Based on Tom 'Glodenox' Puttemans original concept for BE
// @namespace    ch-qhn/wme
// @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      api3.geo.admin.ch
// @run-at       document-idle
// @license      GPL-2.0
// ==/UserScript==

(function () {
    'use strict';
  
    // --------------------------
    // Config (readable constants)
    // --------------------------
    const TAG = 'CH QHN:';
    const SCRIPT_ID = 'ch-qhn';
  
    const KEYS = {
      ENABLE : 'CHQHN_ENABLED',
      CIRCLES: 'CHQHN_CIRCLES',
      SNAP_PX: 'CHQHN_SNAP',
      DEV    : 'CHQHN_DEV'
    };
  
    const DEFAULTS = {
      ENABLE : true,
      CIRCLES: true,
      SNAP_PX: 120,
      DEV    : false
    };
  
    // Small tiles avoid Identify's per-request cap; per-tile cache with simple LRU
    const TILE = {
      SIZE_M : 500,
      TTL_DAYS: 7,
      MAX    : 500,
      NS     : 'CHQHN_TILE_',
      META   : 'CHQHN_META',
      SCHEMA : 1
    };
  
    // Modest concurrency to be nice to the API
    const REQ = {
      CONC : 5,
      RETRY: 1
    };
  
    // --------------------------
    // Logging (dev-friendly)
    // --------------------------
    /* eslint-disable no-console */
    const LOG =( ...a)=>console.log(`%c${TAG}`,'color:#0a7e00;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 */
  
    // --------------------------
    // Storage helpers
    // --------------------------
    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 & UI state
    // --------------------------
    let WME=null, OL=null, wmeSDK=null;
  
    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;
    const DBG=(...a)=>{ if (devMode) console.debug('%cCH QHN:[dev]','color:#7a7a7a;font-weight:bold;',...a); };
  
    // Data used for filling/painting
    let chPoints=[]; // {lon,lat,number,streetName,processed,sameStreet}
    let selectedStreetNames=[];
    let selectedNormSet=new Set();
    let lastRenderedItems=[];
  
    // R-key flow
    let armedForNext=false, pendingRefPx=null, pendingCommit=null, isFilling=false;
    const fillQueue=[];
  
    // Debounced selection
    let selDebounceTimer=null;
  
    // In-memory tile mirror
    const memTiles=new Map();
  
    // Projections
    let _epsg4326=null, _mapProj=null, _epsg4326_back=null;
  
    // --- Styles ---
    GM_addStyle?.(`
      .ch-qhn-hints, .ch-qhn-hints * { pointer-events:none !important; }
      .pane { padding:12px; font-size:13px; line-height:1.4; }
      .row { display:flex; align-items:center; gap:8px; margin:0 0 10px; }
      .row.muted { display: block; } 
      .status { font-weight:600; }
      .btn { border:1px solid #ccc; border-radius:6px; padding:6px 10px; cursor:pointer; background:#f5f5f5; }
      .btn:hover { filter:brightness(0.98); }
      .toggle { display:inline-flex; align-items:center; gap:8px; cursor:pointer; }
      .muted { opacity:.7; }
      .snap input[type="number"] { width:90px; padding:4px 6px; }
    `);
  
    // --- Bootstrap WME and start once available ---
    (async function boot(){
      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: 'CH Quick HN' });
        }
      }catch{}
      init();
    })();
  
    // --- Main init: layers, events, UI wiring ---
    function init(){
      LOG(`Init (SNAP_PX=${SNAP_PX}, Circles=${circlesEnabled?'ON':'OFF'})`);
      const search=document.getElementById('search-autocomplete');
      editButtonsRoot=(search && search.parentNode) || document.body;
  
      // Vector hints layer: green = same street (normalized), grey = other street
      hintsLayer=new OL.Layer.Vector('Quick HN Importer (CH)',{
        uniqueName:'quick-hn-importer-ch-tiled',
        className:'ch-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?.__sameStreet ? '#99ee99' : '#cccccc'),
                radius  : f => Math.max(String(f.attributes?.number||'').length*6, 10),
                opacity : f => (f.attributes?.__sameStreet && f.attributes?.processed ? 0.3 : 1),
                title   : f => (f.attributes?.streetName && f.attributes?.number)
                               ? `${f.attributes.streetName} ${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> Loading address points…</div>';
      (document.getElementById('map')||document.body).appendChild(loadingBanner);
  
      // WME events
      if (wmeSDK){
        wmeSDK.Events.on({ eventName:'wme-selection-changed', eventHandler: ()=>{
          if (selDebounceTimer) clearTimeout(selDebounceTimer);
          selDebounceTimer=setTimeout(onSelectionChanged, 200);
        }});
        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 {
        setInterval(onSelectionChanged, 1200);
        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);
  
      setupSidebar();
      applyEnabledStateToUi();
      onSelectionChanged();
    }
  
    // --- Sidebar UI ---
    async function setupSidebar(){
      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='CH Quick HN Importer';
  
        tabPane.innerHTML = `
          <div class="pane">
            <div class="row">
              <label class="toggle">
                <input type="checkbox" id="enabled">
                <span>Enable script (press <code>R</code> to fill)</span>
              </label>
            </div>
  
            <div class="row">
              <label class="toggle">
                <input type="checkbox" id="circles">
                <span>Show circles</span>
              </label>
            </div>
  
            <div class="row snap">
              <label for="snap">SNAP distance (px):</label>
              <input type="number" id="snap" min="40" max="400" step="10" placeholder="120">
            </div>
  
            <div class="row muted">
              Above ~120 px the risk of snapping to the wrong number increases. Please test in your area.
            </div>
  
            <div class="row muted">
              Status: <span id="status" class="status"></span>
            </div>
  
            <div class="row">
              <button type="button" id="clear" class="btn">Clear HN cache</button>
              <button type="button" id="reload" class="btn">Reload HN</button>
            </div>
  
            <div class="row muted">
              Flow: R + click fills the HN and disables the entry.
            </div>
  
            <div class="row muted">
              The tool picks the nearest house number; it does not validate street assignment.<br/>
              Green circle = number on the selected street<br/>
              Grey circle = number on another street or name mismatch (e.g., “Saint” vs “St.”). Use carefully.
            </div>
  
            <div class="row muted"><p style="margin:0">
            Note: Switzerland only. Do <strong>not</strong> run together with other QHN scripts.
            </p></div>
  
            <div class="row muted" style="font-size:11px;">
              <label class="toggle">
                <input type="checkbox" id="dev">
                <span>Dev mode (extra logs)</span>
              </label>
            </div>
          </div>`;
  
        await WME.userscripts.waitForElementConnected(tabPane);
  
        tabPane.querySelector('#enabled').checked = !!isEnabled;
        tabPane.querySelector('#circles').checked = !!circlesEnabled;
        tabPane.querySelector('#dev').checked     = !!devMode;
        tabPane.querySelector('#snap').value      = String(SNAP_PX);
        tabPane.querySelector('#status').textContent = isEnabled ? 'ON' : 'OFF';
  
        tabPane.querySelector('#enabled').addEventListener('change',e=>setEnabled(e.target.checked));
        tabPane.querySelector('#circles').addEventListener('change',e=>setCirclesEnabled(e.target.checked));
        tabPane.querySelector('#dev').addEventListener('change',e=>setDevMode(e.target.checked));
        tabPane.querySelector('#snap').addEventListener('change',e=>{ setSnapPx(Number(e.target.value)); e.target.value=String(SNAP_PX); });
        tabPane.querySelector('#clear').addEventListener('click',clearCache);
        tabPane.querySelector('#reload').addEventListener('click',()=>{ if (isEnabled) onSelectionChanged(true); });
      }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 ? '• ON 🟢' : '';
        const devTag = devMode ? ' • DEV 🛠' : '';
        tabLabelEl.textContent = `CH-QHN ${onOff}${devTag}`;
      }
    // --- Toggles ---
    function setDevMode(v){ devMode=!!v; GM_Set(KEYS.DEV, devMode); updateTabLabel(); }
    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); }
    function setEnabled(v){
      isEnabled=!!v; GM_Set(KEYS.ENABLE,isEnabled); applyEnabledStateToUi();
      if (isEnabled){ onSelectionChanged(); }
      else{
        armedForNext=false; pendingRefPx=null; pendingCommit=null; fillQueue.length=0;
        chPoints=[]; selectedStreetNames=[]; selectedNormSet=new Set(); lastRenderedItems=[];
        hintsLayer?.removeAllFeatures(); hintsLayer?.setVisibility(false); showLoading(false);
      }
    }
    function setCirclesEnabled(v){
      circlesEnabled=!!v; GM_Set(KEYS.CIRCLES,circlesEnabled);
      try{
        if (!circlesEnabled){ hintsLayer?.removeAllFeatures(); hintsLayer?.setVisibility(false); }
        else { hintsLayer?.setVisibility(isEnabled && circlesEnabled); liftHints(); if (lastRenderedItems.length) redrawFromItems(lastRenderedItems); }
      }catch{}
      applyEnabledStateToUi();
    }
    function applyEnabledStateToUi(){
      try{
        const st=document.querySelector('#status'); if (st) st.textContent=isEnabled?'ON':'OFF';
        const cbE=document.querySelector('#enabled'); if (cbE) cbE.checked=!!isEnabled;
        const cbC=document.querySelector('#circles'); if (cbC) cbC.checked=!!circlesEnabled;
        const cbD=document.querySelector('#dev'); if (cbD) cbD.checked=!!devMode;
        const snap=document.querySelector('#snap'); if (snap) snap.value=String(SNAP_PX);
        updateTabLabel();
      }catch{}
      try{ if (!isEnabled){ hintsLayer?.setVisibility(false); showLoading(false); } else { hintsLayer?.setVisibility(!!circlesEnabled); if (circlesEnabled) liftHints(); } }catch{}
    }
  
    // --- Selection → Fetch (tiled) ---
    function onSelectionChanged(forceNetwork=false){
      if (!isEnabled) return;
  
      const segs = safeSegs();
      const hadSel = !!(selectedStreetNames && selectedStreetNames.length);
  
      selectedStreetNames=[]; selectedNormSet=new Set();
      if (segs?.length){
        try{
          const ids=new Set();
          for (const s of segs){
            (s?.attributes?.streetIDs||[]).forEach(id=>ids.add(id));
            if (s?.attributes?.primaryStreetID!=null) ids.add(s.attributes.primaryStreetID);
          }
          selectedStreetNames=WME.model.streets.getByIds(Array.from(ids)).map(s=>s?.attributes?.name).filter(Boolean);
          selectedNormSet=new Set(selectedStreetNames.map(normStreet));
        }catch{}
      }
  
      // Sticky: when selection is temporarily cleared (opening HN panel), don't wipe circles immediately
      if (!segs?.length && hadSel) return;
  
      const vp = getViewportBoundsOrNull();
      const base = vp || (segs && segs.length ? selectionBounds(segs) : null);
      if (!base) return;
  
      // Fetch visible tiles (+1 ring)
      const bbox = expandBounds(base, TILE.SIZE_M);
      const keys = tilesForBounds(bbox);
      if (!keys.length) return;
  
      fetchTiles(keys, forceNetwork).then(()=>{
        const items = collectItemsFromKeys(keys);
        if (items.length){ processCHResultArray(items); lastRenderedItems = items.slice(); }
      });
    }
  
    function fetchTiles(keys, forceNetwork=false){
      return new Promise((resolve)=>{
        const stale=[];
        for (const k of keys){
          const t=getTileFromStore(k);
          if (forceNetwork || !isFresh(t)) stale.push(k);
        }
        if (!stale.length){ LOG(`CH cache hit (${keys.length} tile(s))`); resolve(); return; }
  
        LOG(`Fetching ${stale.length}/${keys.length} tile(s)…`);
        showLoading(true);
  
        let inFlight=0, i=0, done=0;
        const next=()=>{
          while (inFlight<REQ.CONC && i<stale.length){
            const key=stale[i++]; inFlight++;
            fetchOneTile(key, REQ.RETRY).then(()=>{ inFlight--; done++; if (done===stale.length){ showLoading(false); resolve(); } else next(); });
          }
          if (!inFlight && i>=stale.length){ showLoading(false); resolve(); }
        };
        next();
      });
    }
  
    function fetchOneTile(key, retriesLeft){
      return new Promise((resolve)=>{
        const b   = tileBoundsFromKey(key);
        const env = `${b.left},${b.bottom},${b.right},${b.top}`;
        const url = 'https://api3.geo.admin.ch/rest/services/api/MapServer/identify?' + new URLSearchParams({
          geometryType   : 'esriGeometryEnvelope',
          geometry       : env,
          imageDisplay   : '256,256,96',
          mapExtent      : env,
          tolerance      : '0',
          layers         : 'all:ch.bfs.gebaeude_wohnungs_register',
          geometryFormat : 'geojson',
          sr             : '3857',
          lang           : 'en',
          returnGeometry : 'true'
        }).toString();
  
        GM_xmlhttpRequest({
          method:'GET', url, responseType:'json',
          headers:{ Accept:'application/json, text/plain, */*' },
          onload:(resp)=>{
            try{
              let result=resp.response || JSON.parse(resp.responseText||'{}');
              const arr=Array.isArray(result?.results)?result.results:(Array.isArray(result?.features)?result.features:[]);
              const out=[];
              for (const it of arr){
                const feat=it.feature||it, geom=feat.geometry||{};
                if (!geom || geom.type!=='Point' || !Array.isArray(geom.coordinates)) continue;
                const [cx,cy]=geom.coordinates; if (!Number.isFinite(cx) || !Number.isFinite(cy)) continue;
                const a=feat.attributes||feat.properties||{};
                const sn=extractStreetAndNumber(a); if (!sn) continue;
                const { street, number } = sn;
                const m=toMapXY(cx,cy);
                out.push({ lon:m.x, lat:m.y, number, streetName:street });
              }
              putTileToStore(key, { ts:nowDays(), items:out });
              DBG(`tile ${key} -> ${out.length} item(s)`);
              resolve();
            }catch(e){
              if (retriesLeft>0){ DBG(`tile ${key} parse error; retry…`); fetchOneTile(key, retriesLeft-1).then(resolve); }
              else { WARN('tile error (giving up)', key, e?.message||e); resolve(); }
            }
          },
          onerror:()=>{ if (retriesLeft>0){ DBG(`tile ${key} network error; retry…`); fetchOneTile(key, retriesLeft-1).then(resolve); } else { WARN('tile network error (giving up)', key); resolve(); } }
        });
      });
    }
  
    // --- Rendering ---
    function processCHResultArray(items){
      redrawFromItems(items);
      LOG(`Loaded circles: ${hintsLayer?.features?.length||0} (raw: ${items.length}) • selected street(s): ${selectedStreetNames.join(' | ')||'(none)'}`);
    }
  
    function redrawFromItems(items){
      const existing = getSelectionHNs();
      const features=[]; chPoints=[];
      for (const r of items){
        const sameStreet = selectedNormSet.has(normStreet(r.streetName));
        const processed  = existing.includes(r.number);
        chPoints.push({ ...r, processed, sameStreet });
        features.push(new OL.Feature.Vector(
          new OL.Geometry.Point(r.lon, r.lat),
          { number:r.number, streetName:r.streetName, processed, __sameStreet: !!sameStreet }
        ));
      }
      hintsLayer.removeAllFeatures();
      if (circlesEnabled && features.length) hintsLayer.addFeatures(features);
      hintsLayer.setVisibility(!!(isEnabled && circlesEnabled));
      if (circlesEnabled){ liftHints(); hintsLayer.redraw(true); }
    }
  
    // --- 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) onSelectionChanged();
      if (circlesEnabled){ hintsLayer.setVisibility(true); liftHints(); }
      const btn=findHNButton(); if (btn) btn.click();
  
      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;
          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);
      cancelTimer=setTimeout(()=>{ cleanup(); if (armedForNext){ armedForNext=false; LOG('Timeout - no click captured'); } },4000);
    }
  
    function enqueueFillJob(px){ if (!isEnabled) return; const snapshot=chPoints.slice(); fillQueue.push({ px, chSnapshot: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){
      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'); return; }
      await tryFillFromCH(inputEl, job.px, job.chSnapshot);
    }
    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;
    }
    async function tryFillFromCH(inputEl, refPx, chSnapshotOpt){
      try{
        const snap=Array.isArray(chSnapshotOpt)&&chSnapshotOpt.length?chSnapshotOpt:chPoints;
        if (!refPx || !snap.length) return;
        const found=nearestCHByPixel(refPx, snap); if (!found) return;
        const { point, distPx }=found;
        if (distPx>SNAP_PX) return;
        inputEl.focus(); setReactInputValue(inputEl,''); setReactInputValue(inputEl,String(point.number)); try{ inputEl.blur(); }catch{}
        pendingCommit={ number:String(point.number) };
      }catch(e){ ERR('fill error', e?.message||e); }
    }
    function nearestCHByPixel(clickPx, pointsList=chPoints){
      if (!pointsList.length) return null;
      let best=null,bestD=Infinity;
      for (const p of pointsList){
        const geo=toGeoLonLatFromProjected(p.lon,p.lat);
        const pd=WME.map.getPixelFromLonLat(geo); if (!pd) continue;
        const d=Math.hypot(clickPx.x-pd.x, clickPx.y-pd.y);
        if (d<bestD){ bestD=d; best=p; }
      }
      return best?{ point:best, distPx:bestD }:null;
    }
  
    // --- Commit & model sync ---
    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){
      await sleep(400);
      const before=getSelectionHNs();
      const buttons=collectCommitButtonsStrict();
      if (!buttons.length) 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|add/.test(hay) || b.querySelector?.('.w-icon-check,.fa-check');
      });
      const candidates=preferred.length?preferred:buttons;
      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))) return true;
      }
      return false;
    }
    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 hay=`${(el.textContent||'')} ${(el.getAttribute?.('title')||'')} ${(el.getAttribute?.('aria-label')||'')}`.toLowerCase();
        const hasCheckIcon=!!(el.querySelector?.('.w-icon-check,.fa-check'));
        const isAction=/\b(save|apply|commit|confirm|ok|add)\b/.test(hay);
        return hasCheckIcon || isAction;
      });
    }
    function findHouseNumberPanelRoot(){ return document.querySelector('div.house-number.is-active')||document.querySelector('#edit-panel')||document.querySelector('[data-testid="edit-panel"]'); }
    function refreshProcessedFromModel(){
      const current=getSelectionHNs();
      for (const p of chPoints) 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(true);
      }
    }
  
    // --- Utilities ---
    function liftHints(){ try{ if (!isEnabled || !circlesEnabled) return; hintsLayer?.setVisibility(true); hintsLayer?.setOpacity(1); hintsLayer?.setZIndex?.(9e6); if (hintsLayer?.div) hintsLayer.div.style.pointerEvents='none'; }catch{} }
    function isTyping(){ const el=document.activeElement; return !!(el && (el.tagName==='INPUT'||el.tagName==='TEXTAREA'||el.isContentEditable)); }
    function safeSegs(){ try{ const sel=WME.selectionManager.getSegmentSelection(); return sel && sel.segments && sel.segments.length ? sel.segments : null; }catch{ return null; } }
    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; }
    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, 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{}
    }
  
    // Bounds/tiling
    function getViewportBoundsOrNull(){
      try{
        const ext=WME.map.getExtent(); if (!ext) return null;
        const L=ext.left, R=ext.right, T=ext.top, B=ext.bottom;
        if (![L,R,T,B].every(Number.isFinite)) return null;
        if (R<=L || T<=B) return null;
        return { left:L, right:R, top:T, bottom:B };
      }catch{ return null; }
    }
    function selectionBounds(segs){
      let bounds=null;
      for (const seg of (segs||[])){ const b=seg.attributes.geometry.getBounds(); bounds = bounds ? (bounds.extend(b), bounds) : b; }
      if (!bounds) return null;
      return { left:Math.floor(bounds.left), right:Math.ceil(bounds.right), top:Math.ceil(bounds.top), bottom:Math.floor(bounds.bottom) };
    }
    function expandBounds(b,px){ return { left:Math.floor(b.left-px), right:Math.ceil(b.right+px), top:Math.ceil(b.top+px), bottom:Math.floor(b.bottom-px) }; }
    const tileKeyForXY=(x,y)=>`${Math.floor(x/TILE.SIZE_M)}_${Math.floor(y/TILE.SIZE_M)}`;
    function tileBoundsFromKey(key){ const [txS,tyS]=key.split('_'); const tx=+txS, ty=+tyS; const left=tx*TILE.SIZE_M, bottom=ty*TILE.SIZE_M; return { left, bottom, right:left+TILE.SIZE_M, top:bottom+TILE.SIZE_M }; }
    function tilesForBounds(b){ const x1=Math.floor(b.left/TILE.SIZE_M), y1=Math.floor(b.bottom/TILE.SIZE_M); const x2=Math.floor(b.right/TILE.SIZE_M), y2=Math.floor(b.top/TILE.SIZE_M); if (![x1,y1,x2,y2].every(Number.isFinite)) return []; if (x2<x1||y2<y1) return []; const keys=[]; for (let ty=y1; ty<=y2; ty+=1) for (let tx=x1; tx<=x2; tx+=1) keys.push(`${tx}_${ty}`); return keys; }
  
    // Cache
    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);
        if (!obj || obj.v!==TILE.SCHEMA){ try{ GM_deleteValue(TILE.NS+key); }catch{} return null; }
        memTiles.set(key,obj); return obj;
      }catch{ return null; }
    }
    function putTileToStore(key,obj){
      const withSchema={ ...obj, v:TILE.SCHEMA };
      memTiles.set(key,withSchema);
      if (!hasGM) return;
      try{
        GM_Set(TILE.NS+key, JSON.stringify(withSchema));
        const meta=loadMeta(); touchLRU(meta,key); enforceLRU(meta); saveMeta(meta);
      }catch{}
    }
    function loadMeta(){ if (!hasGM) return {order:[]}; try{ const m=GM_Get(TILE.META,null); return m?JSON.parse(m):{order:[]}; }catch{ return {order:[]}; } }
    function saveMeta(meta){ if (!hasGM) return; try{ GM_Set(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); } }
    function collectItemsFromKeys(keys){ let arr=[]; for (const k of keys){ const t=getTileFromStore(k); if (t?.items) arr=arr.concat(t.items); } return arr; }
    const isFresh=(t)=>!!(t && t.v===TILE.SCHEMA && typeof t.ts==='number' && nowDays()-t.ts<=TILE.TTL_DAYS);
  
    // Projection helpers
    function toMapXY(likeX,likeY){
      try{
        _epsg4326=_epsg4326||new OL.Projection('EPSG:4326');
        _mapProj=_mapProj||(WME.map.getProjectionObject?.()||WME.map.projection||new OL.Projection('EPSG:900913'));
        const looks4326=Math.abs(likeX)<=180 && Math.abs(likeY)<=90;
        if (!looks4326) return { x:likeX, y:likeY, crs:'3857' };
        const p=new OL.Geometry.Point(likeX,likeY); p.transform(_epsg4326,_mapProj);
        return { x:p.x, y:p.y, crs:'4326→map' };
      }catch{ return { x:likeX, y:likeY, crs:'unknown' }; }
    }
    function toGeoLonLatFromProjected(x,y){
      try{
        _epsg4326_back=_epsg4326_back||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_back);
      }catch{
        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);
      }
    }
  
    // Parsing / normalization (CH-friendly; unify “Saint/Sankt/San/St.” → “saint”)
    function firstStr(x){
      if (Array.isArray(x) && x.length) return String(x[0]||'').trim();
      if (typeof x==='string') return x.trim();
      return '';
    }
  
    // Filter out Swiss-style decimal house numbers (including letter+decimal like 15a.1)
    function isSwissDecimalLike(numStr){
      const s=String(numStr||'').trim();
      if (!s.includes('.')) return false;
      const stripped=s.replace(/[A-Za-z]/g,''); // "15a.1" -> "15.1"
      return /^\d+\.\d+$/.test(stripped);
    }
  
    function extractStreetAndNumber(props){
      let street=firstStr(props.strname)||'';
      let number=String(props.deinr ?? '').trim();
  
      // Fallback from combined field, if available
      if ((!street || !number) && props.strname_deinr){
        const s=String(props.strname_deinr);
        const m=s.match(/^(.*?)[\s,]+(\d+[A-Za-z]?(?:[-/]\d+[A-Za-z]?)?)\s*$/);
        if (m){ if (!street) street=m[1].trim(); if (!number) number=m[2].trim(); }
        else if (!street){ street=s.trim(); }
      }
  
      if (!street || !number) return null;
      if (isSwissDecimalLike(number)) return null;
      return { street, number };
    }
  
    function normStreet(s){
      // remove diacritics, lowercase, normalize punctuation & whitespace,
      // and unify saint-like tokens across languages
      const base = String(s||'')
        .normalize('NFD').replace(/\p{Diacritic}/gu,'')
        .toLowerCase()
        .replace(/\./g,' ')
        .replace(/\b(st|st\.|sankt|saint|sainte|san|santo|santa)\b/g, 'saint')
        .replace(/\s+/g,' ')
        .trim();
      return base;
    }
  
    // --- DOM helpers ---
    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}));
    }
    function showLoading(show){ if (!loadingBanner) return; if (!isEnabled){ loadingBanner.style.display='none'; return; } loadingBanner.style.display=show?'':'none'; }
  
    // --- Small helpers ---
    const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
    function poll(test,max=300,delay=100){ return new Promise(res=>{ let i=0; const t=setInterval(()=>{ try{ if (test()){ clearInterval(t); res(true); } else if ((i+=1)>=max){ clearInterval(t); res(false); } }catch{} }, delay); }); }
  
  })();