WME Curve Safety

Détecttion des virages dangereux

目前為 2025-10-02 提交的版本,檢視 最新版本

// ==UserScript==
// @name         WME Curve Safety
// @namespace    csa.ultralite.locked.uifold
// @version      2025.01.00
// @description  Détecttion des virages dangereux
// @author       CSA
// @match        https://www.waze.com/*/editor*
// @match        https://www.waze.com/editor*
// @match        https://beta.waze.com/*/editor*
// @match        https://beta.waze.com/editor*
// @grant        none
// @license      MIT
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  /* ===== Utils ===== */
  const g = 9.81;
  const fmt  = (x, n = 0) => (x == null || Number.isNaN(x) ? '—' : x.toFixed(n).replace('.', ','));
  const toNum = (s) => (typeof s === 'number' ? s : Number(String(s ?? '').replace(',', '.')));
  const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
  const lsGet = (k, d) => { try { return localStorage.getItem(k) ?? d; } catch { return d; } };
  const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch {} };
  const until = (test, every=200, tries=80) => new Promise((res, rej)=>{
    let n=0, t=setInterval(()=>{ try{ if(test()){ clearInterval(t); res(); } else if(++n>=tries){ clearInterval(t); rej(new Error('WME non prêt')); } }catch(e){ clearInterval(t); rej(e); } }, every);
  });

  /* ===== Tooltip (simple) ===== */
  const TOOLTIP_CSS = `
    .csa-tip{ position:fixed; max-width:320px; background:#111; color:#f5f5f5;
      padding:8px 10px; border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.35);
      z-index:999999; font:12px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
      opacity:0; transform:translateY(-4px); transition:opacity .12s ease, transform .12s ease; pointer-events:none; }
    .csa-tip.show{ opacity:1; transform:translateY(0); pointer-events:auto; }
    .csa-tip .csa-tip-title{ font-weight:700; margin-bottom:4px; color:#eaeaea; }
    .csa-tip .csa-tip-muted{ color:#cbd5e1; }
    .csa-row-label{ display:flex; align-items:center; gap:6px; min-width:150px; color:#111; cursor:help; }
  `;
  function injectCSS(css){ const s=document.createElement('style'); s.textContent=css; document.head.appendChild(s); }
  injectCSS(TOOLTIP_CSS);

  let tipEl=null; let tipHideTO=null;
  function ensureTipEl(){ if(tipEl) return tipEl; tipEl=document.createElement('div'); tipEl.className='csa-tip'; document.body.appendChild(tipEl); return tipEl; }
  function showTipNear(target, html){ const tip=ensureTipEl(); tip.innerHTML=html; const r=target.getBoundingClientRect(); const pad=8; const vw=innerWidth, vh=innerHeight; tip.style.left=Math.min(Math.max(8,r.left+6),vw-8-320)+'px'; tip.style.top=Math.min(Math.max(8,r.bottom+pad),vh-8-200)+'px'; tip.classList.add('show'); clearTimeout(tipHideTO); tipHideTO=setTimeout(()=>{},9e6); }
  function hideTipSoon(d=120){ clearTimeout(tipHideTO); tipHideTO=setTimeout(()=> tipEl && tipEl.classList.remove('show'), d); }
  function bindTip(node, html){ node.addEventListener('mouseenter', ()=>showTipNear(node, typeof html==='function'? html(): html)); node.addEventListener('mouseleave', ()=>hideTipSoon(150)); node.addEventListener('click', (e)=>{ e.stopPropagation(); showTipNear(node, typeof html==='function'? html(): html); }); }

  /* ===== Drag helper ===== */
  function enableDrag(panel, handle, storageKey='csa.panelPos'){
    let down=false, ox=0, oy=0;
    const readPos = () => { try { return JSON.parse(localStorage.getItem(storageKey)||'{}'); } catch { return {}; } };
    const savePos = (x,y) => { try { localStorage.setItem(storageKey, JSON.stringify({x, y})); } catch {} };
    (function restore(){ const p = readPos(); if (Number.isFinite(p.x) && Number.isFinite(p.y)){ panel.style.left=p.x+'px'; panel.style.top=p.y+'px'; panel.style.right='unset'; panel.style.bottom='unset'; } })();
    const start=(cx,cy)=>{ const r=panel.getBoundingClientRect(); ox=cx-r.left; oy=cy-r.top; down=true; document.body.style.userSelect='none'; };
    const move=(cx,cy)=>{ if(!down) return; const x=Math.max(4,cx-ox), y=Math.max(4,cy-oy); panel.style.left=x+'px'; panel.style.top=y+'px'; panel.style.right='unset'; panel.style.bottom='unset'; };
    const end=()=>{ if(!down) return; down=false; document.body.style.userSelect=''; const r=panel.getBoundingClientRect(); savePos(r.left,r.top); };
    handle.addEventListener('mousedown',e=>{ e.preventDefault(); start(e.clientX,e.clientY); });
    addEventListener('mousemove',e=>move(e.clientX,e.clientY)); addEventListener('mouseup',end);
    handle.addEventListener('touchstart',e=>{ const t=e.touches[0]; if(!t) return; start(t.clientX,t.clientY); },{passive:false});
    addEventListener('touchmove',e=>{ const t=e.touches[0]; if(!t) return; move(t.clientX,t.clientY); },{passive:false}); addEventListener('touchend',end);
  }

  /* ===== Profils ===== */
  const PROFILES = {
    urbain:   { label: 'Urbain (strict)',      f: 0.14, iDeg: 2, step: 1.5, halfWin: 20, marginKmh: 12, vTargetKmh: 50, percentile: 30 },
    campagne: { label: 'Campagne (équilibré)', f: 0.16, iDeg: 2, step: 2.0, halfWin: 30, marginKmh: 18, vTargetKmh: 50, percentile: 40 },
    montagne: { label: 'Montagne (épingles)',  f: 0.13, iDeg: 2, step: 1.5, halfWin: 18, marginKmh: 10, vTargetKmh: 40, percentile: 25 }
  };
  const defaultProfileKey = lsGet('csa.profile', 'campagne');

  /* ===== Etat UI ===== */
  let ui={}; let scheduled=null; let vectorLayer=null; let headerBadge=null; let collapseBtn=null;

  /* ===== Boot ===== */
  until(()=>window.W && W.map && W.model && W.selectionManager && window.OpenLayers).then(init).catch(()=>console.warn('[CSA] init timeout'));

  /* ===== Sélection ===== */
  function getSelSegs(){
    const sm=W?.selectionManager; const segById=id=>W?.model?.segments?.getObjectById?.(id);
    const resolve=(x)=>{ if(!x) return null; if(x.type==='segment'||x.CLASS_NAME==='Waze.Model.Segment') return x; const m=x.model||x.dataModel||x._dataModel||x.attributes?.model; if(m && (m.type==='segment'||m.CLASS_NAME==='Waze.Model.Segment')) return m; const id=x.id||x.attributes?.id||m?.attributes?.id; if(id!=null){ const s=segById(id); if(s) return s; } if(x.geometry && x.attributes?.id!=null){ const s=segById(x.attributes.id); if(s) return s; } return null; };
    try{ const raw=sm.getSelectedDataModelObjects?.()||[]; const list=Array.isArray(raw)?raw:[raw]; const segs=list.map(resolve).filter(Boolean); if(segs.length) return segs; }catch{}
    try{ const feats=sm.getSelectedWMEFeatures?.()||sm.getSelectedFeatures?.()||[]; const segs=feats.map(resolve).filter(Boolean); if(segs.length) return segs; }catch{}
    return [];
  }
  function hookSelection(){
    const sm=W?.selectionManager; try{ sm.events.register('selectionchanged',null,()=>{ if(ui.live?.checked) scheduleRun(40); else clearOverlay(); }); }catch{} try{ sm.events.register('selected',null,()=>{ if(ui.live?.checked) scheduleRun(40); else clearOverlay(); }); }catch{}
    let last=''; setInterval(()=>{ const sel=getSelSegs(); const now=sel.map(s=>s?.attributes?.id).join(','); if(now!==last){ last=now; if(ui.live?.checked) scheduleRun(60); } }, 300);
    document.addEventListener('keydown',(e)=>{ if(e.key==='Escape') clearOverlay(); });
  }

  /* ===== UI ===== */
  function el(tag, attrs={}){ const d=document.createElement(tag); for(const [k,v] of Object.entries(attrs)){ if(k==='text') d.textContent=v; else if(k==='style') d.setAttribute('style',v); else d[k]=v; } return d; }

  function setCollapsed(coll){
    lsSet('csa.collapsed', coll?'1':'0');
    if(!ui.panel) return;
    ui.panel.classList.toggle('csa-collapsed', !!coll);
    collapseBtn.textContent = coll ? '+' : '–';
    if (coll){ const t=document.querySelector('.csa-tip'); t && t.classList.remove('show'); }
  }

  function buildPanel(){
    const panel = el('div', { id:'csa-panel', style: `
      position:fixed; right:16px; top:16px; width:360px; max-width:42vw;
      background:#fff; border-radius:12px; box-shadow:0 8px 24px rgba(0,0,0,.18);
      padding:12px; font:13px/1.35 system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif; z-index:9999;
    `});

    // CSS pour repli total
    const extraCSS = `
      #csa-panel.csa-collapsed{ padding:8px 10px; width:auto; }
      #csa-panel.csa-collapsed .csa-content{ display:none; }
      #csa-panel .csa-head-btn{ border:0; background:#eef2f6; color:#333; width:28px; height:28px; border-radius:6px; cursor:pointer; font-weight:700; }
      #csa-panel .csa-group-right{ display:flex; align-items:center; gap:6px; }
    `;
    injectCSS(extraCSS);

    const head = el('div', {style:'display:flex; align-items:center; justify-content:space-between; margin-bottom:8px; cursor:move;'});
    const left = el('div', {style:'display:flex; align-items:center; gap:8px;'});
    const title = el('div', {text:'WME CSA', style:'font-weight:700;'});
    headerBadge = el('span', {text:'—', style:'font-weight:700; padding:2px 6px; border-radius:6px; background:#eee; color:#333;'});
    left.appendChild(title); left.appendChild(headerBadge);

    // Droite: ?  ⚙︎  ±
    const right = el('div', {className:'csa-group-right'});
    const helpBtn = el('button', {text:'?', title:'Aide', className:'csa-head-btn'});
    const gearBtn = el('button', {text:'⚙︎', title:'Afficher/Masquer les réglages', className:'csa-head-btn'});
    collapseBtn = el('button', {text:'–', title:'Replier/Déplier', className:'csa-head-btn'});
    right.appendChild(helpBtn); right.appendChild(gearBtn); right.appendChild(collapseBtn);

    head.appendChild(left); head.appendChild(right);
    panel.appendChild(head);

    const content = el('div', {className:'csa-content'});

    const row = (labelTxt, inputNode, tipHTML) => {
      const r = el('div', {style:'display:flex; align-items:center; margin:6px 0; gap:8px;'});
      const lblWrap = el('div', {className:'csa-row-label'});
      const l = el('div', {}); l.textContent = labelTxt; lblWrap.appendChild(l);
      if (tipHTML) bindTip(lblWrap, tipHTML);
      r.appendChild(lblWrap); r.appendChild(inputNode); content.appendChild(r); return r;
    };

    // Profil
    const selProfile = el('select', {id:'csa-profile', style:'width:200px;'});
    for (const [k,p] of Object.entries(PROFILES)) selProfile.appendChild(el('option', {value:k, text:p.label}));
    selProfile.value = defaultProfileKey;
    row('Profil', selProfile, () => `
      <div class="csa-tip-title">Profil</div>
      <div class="csa-tip-muted">Préréglages (f, i°, pas, fenêtre, marge). Changer de profil met à jour tous les paramètres.</div>
    `);

    // Réglages (repliés par défaut)
    const settingsWrap = el('div', {id:'csa-settings', style:'display:none; border-top:1px dashed #e2e8f0; margin-top:6px; padding-top:8px;'});
    const mkInput = (id,w=90,title='')=>el('input',{id, type:'text', title, style:`width:${w}px; box-sizing:border-box; padding:4px 6px; border:1px solid #ccc; border-radius:8px;`});
    const f=mkInput('csa-f',90,'f (adhérence)'), i=mkInput('csa-i',90,'i (dévers°)'), step=mkInput('csa-step',90,'Pas (m)'), hw=mkInput('csa-halfwin',90,'½ Fenêtre (m)');
    const margin=mkInput('csa-margin',90,'Marge (km/h)'), vt=mkInput('csa-vtarget',90,'V cible (km/h)'), perc=mkInput('csa-percentile',90,'Percentile (R) %');

    settingsWrap.appendChild(row('f (adhérence)', f, `<div class="csa-tip-title">f – Adhérence</div><div class="csa-tip-muted">~0.10–0.20. Plus f ↑, plus V sûre ↑.</div>`));
    settingsWrap.appendChild(row('i (dévers°)', i, `<div class="csa-tip-title">i – Dévers (°)</div><div class="csa-tip-muted">i>0 (vers l’intérieur) ↑ V sûre, i<0 ↓ V sûre.</div>`));
    settingsWrap.appendChild(row('Pas (m)', step, `<div class="csa-tip-title">Pas (m)</div><div class="csa-tip-muted">1–3 m conseillé. Plus petit = plus précis, plus coûteux.</div>`));
    settingsWrap.appendChild(row('½ Fenêtre (m)', hw, `<div class="csa-tip-title">½ Fenêtre</div><div class="csa-tip-muted">Plus grand = courbe lissée, apex élargi.</div>`));
    settingsWrap.appendChild(row('Marge (km/h)', margin, `<div class="csa-tip-title">Marge (km/h)</div><div class="csa-tip-muted">Tolérance ajoutée à V sûre pour la comparaison.</div>`));
    settingsWrap.appendChild(row('V cible (km/h)', vt, `<div class="csa-tip-title">V cible</div><div class="csa-tip-muted">Référence si limite absente.</div>`));
    settingsWrap.appendChild(row('Percentile (R) %', perc, `<div class="csa-tip-title">Percentile (R)</div><div class="csa-tip-muted">Rmin robuste = P% ; plus bas = plus sévère.</div>`));

    content.appendChild(settingsWrap);

    // Options
    const optsRow = el('div', {style:'display:flex; align-items:center; gap:16px; margin:8px 0 6px 0; flex-wrap:wrap;'});
    const live = el('input', {id:'csa-live', type:'checkbox'}); live.checked = true;
    const liveLbl = el('label', {text:'Live (sélection)'}); liveLbl.htmlFor='csa-live';
    const draw = el('input', {id:'csa-draw', type:'checkbox'}); draw.checked = true;
    const drawLbl = el('label', {text:'Surligner dangereux (vue)'}); drawLbl.htmlFor='csa-draw';
    bindTip(liveLbl, `<div class="csa-tip-title">Live</div><div class="csa-tip-muted">Relance à chaque changement de sélection.</div>`);
    bindTip(drawLbl, `<div class="csa-tip-title">Surligner dangereux</div><div class="csa-tip-muted">Overlay sur les tronçons dangereux.</div>`);
    optsRow.appendChild(live); optsRow.appendChild(liveLbl); optsRow.appendChild(draw); optsRow.appendChild(drawLbl);
    content.appendChild(optsRow);

    // Bouton + astuce + sortie
    const btn = el('button', {id:'csa-run', text:'Analyser la sélection', style:'padding:8px 10px; border:0; border-radius:8px; background:#2a77f4; color:#fff; cursor:pointer; font-weight:600;'});
    btn.addEventListener('click', ()=>scheduleRun(20));
    content.appendChild(btn);
    content.appendChild(el('div', {text:'Astuce: pas 1–3 m, ½ fenêtre 20–30 m.', style:'color:#666; margin:6px 0;'}));
    const out = el('div', {id:'csa-out', style:'margin-top:4px; white-space:pre-line; color:#111;'});
    content.appendChild(out);

    panel.appendChild(content);
    (document.body||document.documentElement).appendChild(panel);

    // Help + gear + collapse
    bindTip(helpBtn, () => `
      <div class="csa-tip-title">Méthode & critères</div>
      <div class="csa-tip-muted">Rmin robuste = percentile des rayons (fenêtre glissante). V sûre = 3.6×√((f+tan(i°))×g×R). Statut: Dangereux si V sûre + Marge < V ref.</div>
    `);
    gearBtn.addEventListener('click', ()=>toggleSettings());
    collapseBtn.addEventListener('click', ()=> setCollapsed(!ui.panel.classList.contains('csa-collapsed')));

    // Drag
    enableDrag(panel, head, 'csa.panelPos');

    ui = { panel, selProfile, settingsWrap, f, i, step, halfWin: hw, margin, vtarget: vt, percentile: perc, live, draw, out, btn };

    // Etats init
    applyProfileToUI(defaultProfileKey);
    setCollapsed(lsGet('csa.collapsed','0')==='1');
  }

  function toggleSettings(force){ const shown = force!==undefined ? !!force : (ui.settingsWrap.style.display==='none'); ui.settingsWrap.style.display = shown ? 'block' : 'none'; lsSet('csa.showSettings', shown?'1':'0'); }
  function applyProfileToUI(key){ const p = PROFILES[key]; if(!p) return; ui.f.value=String(p.f).replace('.',','); ui.i.value=p.iDeg; ui.step.value=p.step; ui.halfWin.value=p.halfWin; ui.margin.value=p.marginKmh; ui.vtarget.value=p.vTargetKmh; ui.percentile.value=p.percentile; lsSet('csa.profile', key); }
  function hookInputs(){ ui.selProfile.addEventListener('change', e=>applyProfileToUI(e.target.value)); [ui.f, ui.i, ui.step, ui.halfWin, ui.margin, ui.vtarget, ui.percentile].forEach(elm => elm.addEventListener('input', ()=>{ if (ui.live.checked) scheduleRun(80); })); }
  function scheduleRun(delay=120){ if (scheduled) clearTimeout(scheduled); scheduled=setTimeout(run, delay); }

  /* ===== Géométrie & calcul ===== */
  function segToPts(seg){ const geom=seg.getOLGeometry? seg.getOLGeometry(): seg.geometry; if(!geom||!geom.components) return []; return geom.components.map(c=>[c.x,c.y]); }
  function densifyPath(pts, stepM=2){ if(!pts||pts.length<2) return []; const out=[pts[0]]; let prev=pts[0]; for(let i=1;i<pts.length;i++){ const cur=pts[i], dx=cur[0]-prev[0], dy=cur[1]-prev[1]; const L=Math.hypot(dx,dy); if(L<=0) continue; const n=Math.max(1,Math.floor(L/stepM)); for(let k=1;k<=n;k++){ const t=k/n; out.push([prev[0]+dx*t, prev[1]+dy*t]); } prev=cur; } return out; }
  function circleRadius(a,b,c){ const ax=a[0],ay=a[1],bx=b[0],by=b[1],cx=c[0],cy=c[1]; const A=bx-ax,B=by-ay,C=cx-ax,D=cy-ay,E=A*(ax+bx)+B*(ay+by),F=C*(ax+cx)+D*(ay+cy),G=2*(A*(cy-by)-B*(cx-bx)); if(Math.abs(G)<1e-9) return Infinity; const ux=(D*E-B*F)/G, uy=(A*F-C*E)/G; return Math.hypot(ax-ux,ay-uy); }
  function slidingRadii(pts, halfWinMeters, stepM){ const hw=Math.max(2,Math.round(halfWinMeters/(stepM||2))); const out=[]; for(let i=hw;i<pts.length-hw;i++){ out.push(circleRadius(pts[i-hw],pts[i],pts[i+hw])); } return out; }
  function percentile(arr,p){ if(!arr.length) return Infinity; const k=clamp(Math.floor((p/100)*arr.length),0,arr.length-1); const s=arr.slice().sort((a,b)=>a-b); return s[k]; }
  function radiusToSpeed(R,f,iDeg){ if(!Number.isFinite(R)||R<=0) return 0; const i=Math.tan((iDeg||0)*Math.PI/180); const a=(f+i)*g; return Math.sqrt(a*R)*3.6; }
  function speedRef(segs, fallback){ let lim=null; for(const s of segs){ const a=s.attributes||{}; const v=a.fwdMaxSpeed ?? a.revMaxSpeed ?? a.speedLimit ?? a.speed ?? null; if(v && v>0){ lim=v; break; } } return (lim && lim>0)? lim: fallback; }
  function classify(vSure,vRef,margin){ if(!vRef) return '—'; return (vSure + margin >= vRef) ? 'OK' : 'Dangereux'; }

  /* ===== Overlay minimal (danger) ===== */
  function buildVectorLayer(){ if(!W||!W.map) return; const styleBad=new OpenLayers.Style({ strokeColor:'#d33', strokeWidth:3, strokeOpacity:0.95, strokeDashstyle:'dash' }); vectorLayer=new OpenLayers.Layer.Vector('CSA overlay',{ styleMap:new OpenLayers.StyleMap(styleBad) }); W.map.addLayer(vectorLayer); }
  function clearOverlay(){ vectorLayer && vectorLayer.removeAllFeatures(); }
  function drawDangerLine(pts, halfWinMeters, stepM){ if(!vectorLayer || !pts || !pts.length || !ui.draw.checked) return; clearOverlay(); const hw=Math.max(2,Math.round(halfWinMeters/(stepM||2))); let minR=Infinity, idx=-1; for(let i=hw;i<pts.length-hw;i++){ const r=circleRadius(pts[i-hw],pts[i],pts[i+hw]); if(r<minR){ minR=r; idx=i; } } if(idx<0) return; const A=new OpenLayers.Geometry.Point(pts[idx-hw][0],pts[idx-hw][1]); const C=new OpenLayers.Geometry.Point(pts[idx+hw][0],pts[idx+hw][1]); const line=new OpenLayers.Geometry.LineString([A,C]); vectorLayer.addFeatures([new OpenLayers.Feature.Vector(line)]); }

  /* ===== Run ===== */
  function run(){
    if(!ui||!ui.out) return;
    const segs=getSelSegs(); clearOverlay();
    if(!segs.length){
      ui.out.textContent='';
      headerBadge.textContent='—'; headerBadge.style.background='#eee'; headerBadge.style.color='#333';
      return;
    }
    const p={ f:toNum(ui.f.value), iDeg:toNum(ui.i.value), step:clamp(toNum(ui.step.value)||2,.5,10), halfWin:clamp(toNum(ui.halfWin.value)||20,5,80), marginKmh:clamp(toNum(ui.margin.value)||10,0,50), vTargetKmh:clamp(toNum(ui.vtarget.value)||50,10,130), percentile:clamp(toNum(ui.percentile.value)||40,1,99) };
    const all=[]; segs.forEach(s=>all.push(...segToPts(s)));
    if(all.length<3){ ui.out.textContent='Quasi-ligne droite (pas assez de points)'; headerBadge.textContent='—'; return; }
    const dens=densifyPath(all,p.step); const radii=slidingRadii(dens,p.halfWin,p.step); const finite=radii.filter(r=>Number.isFinite(r)&&r>0);
    const Rrob=percentile(finite,p.percentile); const vSure=radiusToSpeed(Rrob,p.f,p.iDeg); const vRef=speedRef(segs,p.vTargetKmh); const statut=classify(vSure,vRef,p.marginKmh);
    if(statut==='Dangereux') drawDangerLine(dens,p.halfWin,p.step);
    if(statut==='OK'){ headerBadge.textContent='OK'; headerBadge.style.background='#e6f7ea'; headerBadge.style.color='#0b7a2a'; } else if(statut==='Dangereux'){ headerBadge.textContent='Dangereux'; headerBadge.style.background='#fde8e8'; headerBadge.style.color='#c00'; } else { headerBadge.textContent='—'; headerBadge.style.background='#eee'; headerBadge.style.color='#333'; }
    ui.out.textContent = `Rmin robuste (P${fmt(toNum(ui.percentile.value),0)}): ${Number.isFinite(Rrob)? fmt(Rrob,0)+' m':'—'}\n`+
      `V sûre (f,i) : ${fmt(vSure,1)} km/h\n`+
      `V. de référence : ${vRef? fmt(vRef,0):'—'} km/h\n`+
      `Statut : ${statut}`;
  }

  function init(){
    buildPanel();
    buildVectorLayer();
    hookSelection();
    hookInputs();
    console.log('[CSA] locked-uifold prêt.');
  }

})();