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.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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); }); }
  
  })();