WME Hectometering

Hectometerpaaltjes in WME

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        WME Hectometering
// @author      DeKoerier
// @namespace   https://greasyfork.org/users/1499279
// @description Hectometerpaaltjes in WME
// @include     /^https:\/\/(www|beta)\.waze\.com\/(?!user\/)(.{2,6}\/)?editor.*$/
// @version     1.0.5
// @grant       GM_xmlhttpRequest
// @connect     matrixnl.nl
// @connect     *.matrixnl.nl
// @license MIT
// ==/UserScript==

/* global W, $, OpenLayers, I18n */

(function () {
  /** ---------- ROBUUSTE BOOTSTRAP ---------- */
  function log(...a){ try{ console.log('[WME Hecto]', ...a); }catch{} }

  function startWhenReady() {
    try {
      if (!window.W) {
        log('W niet aanwezig → luister op wme-initialized');
        document.addEventListener('wme-initialized', onWmeInitialized, { once: true });
        return;
      }
      onWmeInitialized();
    } catch (e) { log('bootstrap error', e); }
  }

  function onWmeInitialized() {
    try {
      if (W?.userscripts?.state?.isReady) {
        log('W.userscripts isReady → init overlay');
        initOverlay();
      } else {
        log('W.userscripts nog niet ready → luister op wme-ready');
        document.addEventListener('wme-ready', initOverlay, { once: true });
      }
    } catch (e) { log('onWmeInitialized error', e); }
  }

  /** ---------- HOOFDLOGICA ---------- */
  function initOverlay() {
    try {
      if (!W || !W.map || !W.userscripts?.registerSidebarTab) {
        log('Essentiële WME objecten ontbreken; abort.');
        return;
      }
      log('Init overlay start');

      // --- CONFIG ---
      const HMP_ENDPOINT      = 'https://matrixnl.nl/api/hectometering.php';
      const HECTOIMG_ENDPOINT = 'https://matrixnl.nl/api/hectoimg.php';
      const MIN_ZOOM = 15;
      const STRINGS = {
        tab_title: 'Hectometering',
        toggle_hecto: 'Toon hectometerpunten',
        zoom_in: 'Zoom verder in om deze data te bekijken.',
        loading: 'laden…',
        load_error: 'Kon bordje niet laden.',
        gps: 'GPS',
        open_in_maps: 'Open in Maps',
        copy: 'Kopieer',
        copied: 'Gekopieerd!'
      };

      let showHecto = false;
      let hectoCount = 0;
      let lastReqId = 0;
      let loading = false;

      const P_MAP = W.map.getProjectionObject();
      const P_WGS = new OpenLayers.Projection('EPSG:4326');

      // Sidebar tab registreren
      const { tabLabel, tabPane } = W.userscripts.registerSidebarTab('hecto-overlay');
      tabLabel.innerHTML = '<span class="fa fa-road" aria-hidden="true"></span><span style="margin-left:6px">HM</span>';
      tabLabel.title = STRINGS.tab_title;
      tabPane.id = 'sidepanel-hecto';

      const readyP = (W.userscripts.waitForElementConnected
        ? W.userscripts.waitForElementConnected(tabPane)
        : Promise.resolve());

      readyP.then(buildUI).catch(()=> buildUI());

      // Laag direct toevoegen (onzichtbaar), zodat SelectFeature kan binden
      const hectoLayer = new OpenLayers.Layer.Vector('Hectometering', {
        renderers: OpenLayers.Layer.Vector.prototype.renderers,
        visibility: false,
        styleMap: new OpenLayers.StyleMap(new OpenLayers.Style({
          externalGraphic: '${svg}',
          graphicWidth: '${w}',
          graphicHeight: '${h}',
          graphicXOffset: '${xOff}',
          graphicYOffset: '${yOff}'
        }))
      });
      if (W.map.getLayerIndex(hectoLayer) === -1) {
        W.map.addLayer(hectoLayer);
      }

      // Zorg dat de laag net boven “roads” komt wanneer we ‘m tonen
      function placeLayer() {
        const roads = W.map.getLayerByUniqueName('roads');
        if (roads) {
          const idx = W.map.getLayerIndex(roads);
          if (idx >= 0) {
            W.map.getOLMap().setLayerIndex(hectoLayer, idx + 1);
          }
        }
      }

      // SelectFeature (na toevoegen laag)
      const selectCtrl = new OpenLayers.Control.SelectFeature(hectoLayer, {
        onSelect: onFeatureSelect,
        onUnselect: onFeatureUnselect,
        hover: false
      });
      W.map.addControl(selectCtrl);
      selectCtrl.activate();

      // Debounce helper
      function debounce(fn, ms){ let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a), ms); }; }

      /** ---------- UI: checkbox + hint ---------- */
      function buildUI(){
        log('buildUI → tabPane connected:', !!tabPane.isConnected);
        tabPane.style.padding = '8px';

        const row = document.createElement('div');
        row.style.display = 'flex';
        row.style.alignItems = 'center';
        row.style.gap = '6px';
        row.style.fontSize = '14px';

        const cb = document.createElement('input');
        cb.type = 'checkbox';
        cb.id = 'hectoToggle';
        cb.addEventListener('change', (e)=>{
          showHecto = e.target.checked;
          toggleLayer();
        });

        const label = document.createElement('label');
        label.htmlFor = cb.id;
        label.id = 'hectoLabel';
        label.textContent = STRINGS.toggle_hecto;

        row.appendChild(cb);
        row.appendChild(label);

        const hint = document.createElement('div');
        hint.id = 'hectoHint';
        hint.style.cssText = 'margin-top:6px;padding:6px 8px;border:1px solid #ddd;border-radius:8px;background:#fff;display:none;';
        hint.textContent = STRINGS.zoom_in;

        tabPane.appendChild(row);
        tabPane.appendChild(hint);

        // Events voor zoom/move
        W.map.events.register('zoomend', null, onViewChanged);
        W.map.events.register('moveend', null, debouncedLoad);

        onViewChanged(); // init
      }

      function updateLabel(){
        const label = document.getElementById('hectoLabel');
        if (!label) return;
        const zoomTooLow = W.map.getZoom() < MIN_ZOOM;
        label.textContent = zoomTooLow
          ? STRINGS.zoom_in
          : `${STRINGS.toggle_hecto} (${hectoCount})`;
        const cb = document.getElementById('hectoToggle');
        if (cb) {
          cb.disabled = zoomTooLow;
          cb.checked = showHecto && !zoomTooLow;
        }
      }

      function onViewChanged(){
        const hint = document.getElementById('hectoHint');
        const tooLow = W.map.getZoom() < MIN_ZOOM;
        if (hint) hint.style.display = tooLow ? 'block' : 'none';
        updateLabel();
        if (showHecto && !tooLow) debouncedLoad();
        if (tooLow) clearLayer();
      }

      function toggleLayer(){
        const tooLow = W.map.getZoom() < MIN_ZOOM;
        if (showHecto && !tooLow) {
          placeLayer();
          hectoLayer.setVisibility(true);
          debouncedLoad();
        } else {
          hectoLayer.setVisibility(false);
        }
        updateLabel();
      }

      const debouncedLoad = debounce(loadHecto, 250);

      /** ---------- Geo helpers ---------- */
      function getBboxWgs84(){
        const b = W.map.getExtent().clone().transform(P_MAP, P_WGS);
        return [b.left, b.bottom, b.right, b.top].map(n=>+n.toFixed(6)).join(',');
      }

      function formatHecto(val){
        if (val===null || val===undefined) return '';
        const s = String(val);
        if (/^\d+$/.test(s) && s.length>1) return s.slice(0,-1)+'.'+s.slice(-1);
        return s;
      }
      function formatRoadLabel(p){
        let raw = p.WVK_WEGNR_HMP || p.WVK_WEGNUMMER || '';
        const wegb = (p.WVK_WEGBEHSRT || '').toUpperCase();
        if (/^[Aa]\d+$/.test(raw)) return { label: raw.toUpperCase(), cls:'a' };
        if (/^[Nn]\d+$/.test(raw)) return { label: raw.toUpperCase(), cls:'n' };
        if (/^\d+$/.test(raw)) {
          const num = raw.replace(/^0+/, '') || '0';
          return { label: (wegb==='R' ? 'A' : 'N') + num, cls: wegb==='R' ? 'a' : 'n' };
        }
        const up = String(raw).toUpperCase();
        return { label: up, cls: up.startsWith('A') ? 'a':'n' };
      }
      function hasLetter(s){ return typeof s==='string' && /[A-Za-z]/.test(s); }

      function buildBadgeSVG(props={}){
        const Wd = 40, Hd = 25;
        const panelW = 34, panelH = 22;
        const roadW = 16, roadH = 9;

        const { label, cls } = formatRoadLabel(props);
        const hm = formatHecto(props.hectomtrng);
        const side = props.WVK_RPE_CODE || '';
        const suffix = props.WVK_HECTO_LTTR || '';

        const roadFill = (cls==='a') ? '#A00005' : '#FDAF2B';
        const roadText = (cls==='a') ? '#FFF'    : '#000';
        const suffixShow = hasLetter(suffix);

        return `
<svg xmlns="http://www.w3.org/2000/svg" width="${Wd}" height="${Hd}">
  <defs><style>.rg{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,'Noto Sans',sans-serif;}</style></defs>
  <rect x="0" y="0" width="${Wd}" height="${Hd}" rx="3" fill="#10756E"/>
  <rect x="${(Wd-panelW)/2}" y="${(Hd-panelH)/2}" width="${panelW}" height="${panelH}" rx="3" fill="none" stroke="#FFF" stroke-width="1"/>
  <rect x="${(Wd-panelW)/2+1}" y="${(Hd-panelH)/2+1}" width="${roadW}" height="${roadH}" rx="2" fill="${roadFill}" stroke="${cls==='a'?'#FFF':'none'}" stroke-width="${cls==='a'?1:0}"/>
  <text x="${(Wd-panelW)/2+1+roadW/2}" y="${(Hd-panelH)/2+1+roadH/2+2}" text-anchor="middle" font-size="7" fill="${roadText}" class="rg">${label || ''}</text>
  ${side ? `<text x="${(Wd+panelW)/2-3}" y="${(Hd-panelH)/2+7}" text-anchor="end" font-size="7" fill="#FFF" class="rg">${side}</text>` : ''}
  ${hm ? `<text x="${(Wd-panelW)/2+2}" y="${(Hd+panelH)/2-3}" font-size="9" fill="#FFF" class="rg">${hm}</text>` : ''}
  ${suffixShow ? `
    <rect x="${(Wd+panelW)/2-1-6}" y="${(Hd+panelH)/2-1-8}" width="6" height="8" rx="2" fill="#FDAF2B"/>
    <text x="${(Wd+panelW)/2-1-3}" y="${(Hd+panelH)/2-1-2}" text-anchor="middle" font-size="7" fill="#000" class="rg">${suffix}</text>
  ` : ''}
</svg>`.trim();
      }

      /** ---------- GM helpers (CSP-proof) ---------- */
      function gmFetch(url, { method = 'GET', headers = {}, responseType = 'text' } = {}) {
        return new Promise((resolve, reject) => {
          GM_xmlhttpRequest({
            method, url, headers, responseType,
            onload: (res) => {
              const ok = res.status >= 200 && res.status < 300;
              if (!ok) return reject(new Error(`HTTP ${res.status}: ${String(res.response).slice(0,180)}`));
              resolve(res.response);
            },
            onerror: () => reject(new Error('GM_xmlhttpRequest error')),
            ontimeout: () => reject(new Error('GM_xmlhttpRequest timeout')),
          });
        });
      }
      function gmFetchBinary(url, { method = 'GET', headers = {} } = {}) {
        return new Promise((resolve, reject) => {
          GM_xmlhttpRequest({
            method, url, headers, responseType: 'arraybuffer',
            onload: (res) => {
              const ok = res.status >= 200 && res.status < 300;
              if (!ok) return reject(new Error(`HTTP ${res.status}`));
              try {
                const contentType = res.responseHeaders
                  ?.split(/\r?\n/).find(h => /^content-type:/i.test(h))
                  ?.split(':')[1]?.trim() || 'application/octet-stream';
                const blob = new Blob([res.response], { type: contentType });
                resolve(blob);
              } catch (e) { reject(e); }
            },
            onerror: () => reject(new Error('GM_xmlhttpRequest binary error')),
            ontimeout: () => reject(new Error('GM_xmlhttpRequest timeout')),
          });
        });
      }
      async function inlineExternalImages(html, baseHref = location.origin) {
        const wrapper = document.createElement('div');
        wrapper.innerHTML = html;
        const imgs = Array.from(wrapper.querySelectorAll('img[src]'));
        for (const img of imgs) {
          const src = img.getAttribute('src') || '';
          if (/^(data:|blob:)/i.test(src)) continue;
          const abs = new URL(src, baseHref).href;
          try {
            const blob = await gmFetchBinary(abs);
            const url = URL.createObjectURL(blob);
            img.setAttribute('src', url);
          } catch (e) {
            log('IMG inline fail:', abs, e);
          }
        }
        return wrapper.innerHTML;
      }

      /** ---------- Data laden ---------- */
      async function loadHecto(){
        if (loading) return;
        if (!showHecto || W.map.getZoom() < MIN_ZOOM) return;
        loading = true;
        const reqId = ++lastReqId;

        try {
          const bbox = getBboxWgs84();
          const url = `${HMP_ENDPOINT}?bbox=${encodeURIComponent(bbox)}`;
          const txt = await gmFetch(url);
          let fc; try { fc = JSON.parse(txt); }
          catch(e){ log('hectometering JSON parse error. snippet:', txt.slice(0,180)); throw e; }

          if (reqId !== lastReqId) return;

          clearLayer();

          if (!fc || fc.type!=='FeatureCollection' || !Array.isArray(fc.features)) {
            hectoCount = 0; updateLabel(); return;
          }

          hectoCount = fc.features.length;
          updateLabel();

          const reader = new OpenLayers.Format.GeoJSON({
            ignoreExtraDims: true,
            internalProjection: P_MAP,
            externalProjection: P_WGS
          });

          const feats = [];
          for (const f of fc.features) {
            if (!f || !f.geometry) continue;
            const props = f.properties || {};
            const svg = buildBadgeSVG(props);
            const svgUri = 'data:image/svg+xml;utf8,' + encodeURIComponent(svg);

            const olFeats = reader.read(f);
            const arr = Array.isArray(olFeats) ? olFeats : [olFeats];
            for (const ofeat of arr) {
              const w=40, h=25;
              ofeat.attributes = Object.assign({}, props, { svg: svgUri, w, h, xOff: -w/2, yOff: -h/2 });
              feats.push(ofeat);
            }
          }

          if (feats.length) {
            hectoLayer.addFeatures(feats);
            placeLayer();
            hectoLayer.setVisibility(true);
          }
        } catch (e) {
          log('Hectometering BBOX load error:', e);
        } finally {
          if (reqId === lastReqId) loading = false;
        }
      }

      function clearLayer(){
        if (hectoLayer && hectoLayer.features?.length) hectoLayer.removeAllFeatures();
      }

      /** ---------- Popup (zonder FramedCloud) ---------- */
      let activePopup = null;
      function onFeatureSelect(f){
        const lonlat = f.geometry.getBounds().getCenterLonLat().clone().transform(P_MAP, P_WGS);
        const lat = lonlat.lat.toFixed(6);
        const lon = lonlat.lon.toFixed(6);

        const p = f.attributes || {};
        const params = {
          wegnummer: p.WVK_WEGNR_HMP || p.WVK_WEGNUMMER || '',
          wegbhsrt:  p.WVK_WEGBEHSRT || '',
          wegdeel:   p.WVK_RPE_CODE || '',
          suffix:    p.WVK_HECTO_LTTR || '',
          h:         p.hectomtrng,
          lat, lon
        };
        const qs = Object.entries(params)
          .filter(([,v]) => v !== undefined && v !== null)
          .map(([k,v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
        const popupUrl = `${HECTOIMG_ENDPOINT}?${qs}`;

        // Gebruik basis OpenLayers.Popup (FramedCloud ontbreekt in jouw WME build)
        const lonlatMapProj = f.geometry.getBounds().getCenterLonLat(); // in map projectie
        const html = `<div id="hectoContent" style="padding:4px 6px;">${STRINGS.loading}</div>`;

        activePopup = new OpenLayers.Popup(
          'hectoPopup',
          lonlatMapProj,
          null,                 // auto size
          html,
          null,                 // no anchor
          true,                 // close box
          function(){ selectCtrl.unselect(f); }
        );
        W.map.addPopup(activePopup);

        gmFetch(popupUrl).then(async (remoteHtml) => {
          const inlined = await inlineExternalImages(remoteHtml, popupUrl);

          const ua = navigator.userAgent.toLowerCase();
          let mapsHref;
          if (/iphone|ipad|ipod/.test(ua)) mapsHref = `http://maps.apple.com/?ll=${lat},${lon}`;
          else if (/android/.test(ua))     mapsHref = `geo:${lat},${lon}?q=${lat},${lon}`;
          else                             mapsHref = `https://www.google.com/maps/search/?api=1&query=${lat},${lon}`;

          const extra = `
            <hr style="margin:6px 0;border:0;border-top:1px solid #eee;">
            <div style="font:12px/1.3 system-ui,sans-serif;">
              <b>${STRINGS.gps}</b>: ${lat}, ${lon}
              <div style="margin-top:4px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
                <a href="${mapsHref}" target="_blank" rel="noopener noreferrer">${STRINGS.open_in_maps}</a>
                <button type="button" id="copy-coords"
                  style="padding:2px 6px;border:1px solid #ccc;border-radius:4px;background:#fff;cursor:pointer;">
                  ${STRINGS.copy}
                </button>
              </div>
            </div>
          `;

          // Update popup-inhoud
          if (activePopup && activePopup.contentDiv) {
            activePopup.contentDiv.innerHTML = (inlined || '—') + extra;

            // eventlistener voor kopieerknop
            setTimeout(() => {
              const btn = activePopup.contentDiv.querySelector('#copy-coords');
              if (btn) {
                btn.addEventListener('click', () => {
                  navigator.clipboard.writeText(`${lat}, ${lon}`).then(()=>{
                    btn.textContent = STRINGS.copied;
                    setTimeout(()=>btn.textContent = STRINGS.copy, 1200);
                  }).catch(()=> alert('Kopiëren mislukt'));
                });
              }
            }, 0);

            activePopup.updateSize();
          }
        }).catch(()=>{
          if (activePopup && activePopup.contentDiv) {
            activePopup.contentDiv.textContent = STRINGS.load_error;
            activePopup.updateSize();
          }
        });
      }
      function onFeatureUnselect(_f){
        if (activePopup) {
          W.map.removePopup(activePopup);
          activePopup.destroy();
          activePopup = null;
        }
      }

      // Stylesheet (tab UI)
      (function injectStyles(){
        const css = `
#sidepanel-hecto label{cursor:pointer}
#sidepanel-hecto input[disabled]+label{cursor:not-allowed;opacity:.6}
`;
        const el = document.createElement('style');
        el.textContent = css;
        document.head.appendChild(el);
      })();

      log('Init overlay klaar');
    } catch (e) {
      log('initOverlay error', e);
    }
  }

  // Start!
  startWhenReady();
})();