您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Hectometerpaaltjes in WME
// ==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(); })();