Waze ⇄ Google Maps Jump

Waze右下の緯度経度表示を高精度に検出してGoogleマップへ。Googleマップ側からはWazeライブマップへ同座標で戻す

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Waze ⇄ Google Maps Jump
// @namespace    aoi-tools
// @version      5.0
// @description  Waze右下の緯度経度表示を高精度に検出してGoogleマップへ。Googleマップ側からはWazeライブマップへ同座標で戻す
// @author       Rakuten AI
// @match        https://www.waze.com/ja/live-map/*
// @match        https://www.waze.com/live-map/*
// @match        https://www.google.co.jp/maps/*
// @match        https://www.google.com/maps/*
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  let currentLat = null;
  let currentLon = null;

  /******************************
   * ボタン作成 (位置記憶・ドラッグ可能)
   ******************************/
  function createBtn(id, label, color, onClick) {
    const existing = document.getElementById(id);
    if (existing) return existing;

    const btn = document.createElement('button');
    btn.id = id;
    btn.textContent = label;

    const saved = JSON.parse(localStorage.getItem(id) || '{"x":100,"y":100}');
    Object.assign(btn.style, {
      position: 'fixed',
      left: saved.x + 'px',
      top: saved.y + 'px',
      zIndex: 999999,
      padding: '10px 15px',
      background: color,
      color: 'white',
      border: '2px solid white',
      borderRadius: '20px',
      cursor: 'move',
      fontSize: '13px',
      fontWeight: 'bold',
      boxShadow: '0 4px 8px rgba(0,0,0,0.3)',
      opacity: '0.70',
      userSelect: 'none'
    });

    // ドラッグ誤判定対策:一定距離以上動いたらドラッグ扱い
    let startClientX = 0, startClientY = 0;
    let startLeft = 0, startTop = 0;
    let dragging = false;
    const DRAG_THRESHOLD_PX = 6;

    const onMouseMove = (ev) => {
      const dx = ev.clientX - startClientX;
      const dy = ev.clientY - startClientY;

      if (!dragging) {
        if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
        dragging = true;
      }

      btn.style.left = (startLeft + dx) + 'px';
      btn.style.top = (startTop + dy) + 'px';
    };

    const onMouseUp = () => {
      document.removeEventListener('mousemove', onMouseMove);
      document.removeEventListener('mouseup', onMouseUp);

      localStorage.setItem(id, JSON.stringify({
        x: parseInt(btn.style.left, 10),
        y: parseInt(btn.style.top, 10)
      }));

      // mouseup直後のclickで誤発火しないように少し遅延で解除
      setTimeout(() => { dragging = false; }, 0);
    };

    btn.addEventListener('mousedown', (e) => {
      startClientX = e.clientX;
      startClientY = e.clientY;
      startLeft = btn.offsetLeft;
      startTop = btn.offsetTop;
      dragging = false;

      document.addEventListener('mousemove', onMouseMove);
      document.addEventListener('mouseup', onMouseUp);
    });

    btn.addEventListener('click', () => {
      if (!dragging) onClick();
    });

    document.body.appendChild(btn);
    return btn;
  }

  /******************************
   * 座標パース("lat | lon" / "lat, lon" / "lat lon" など対応)
   ******************************/
  function parseLatLonFromText(text) {
    if (!text) return null;
    const t = String(text).replace(/\u00A0/g, ' ').trim(); // NBSP対策

    // lat: -90..90, lon: -180..180 の小数
    const re = /(-?\d{1,2}\.\d+)\s*(?:\||,|\s)\s*(-?\d{1,3}\.\d+)/;
    const m = t.match(re);
    if (!m) return null;

    const lat = parseFloat(m[1]);
    const lon = parseFloat(m[2]);

    if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
    if (lat < -90 || lat > 90) return null;
    if (lon < -180 || lon > 180) return null;

    return { lat, lon };
  }

  /******************************
   * Waze: 右下表示を「画面から」検出(誤検出・負荷対策)
   * - 右下エリア中心に限定
   * - 可視性/サイズ/テキスト長で絞り込み
   ******************************/
  function findCoordsOnScreenPreferBottomRight() {
    const vw = window.innerWidth;
    const vh = window.innerHeight;

    // 右下エリアに限定(負荷&誤検出対策)
    const regionLeft = vw * 0.55;
    const regionTop = vh * 0.55;

    const candidates = [];
    const nodes = document.querySelectorAll('div, span, p, small, button, a, li');

    for (const el of nodes) {
      const rect = el.getBoundingClientRect();

      // 右下エリア外はスキップ
      if (rect.right < regionLeft || rect.bottom < regionTop) continue;

      // 画面外/小さすぎる要素はスキップ
      if (rect.width < 30 || rect.height < 10) continue;
      if (rect.bottom < 0 || rect.right < 0 || rect.top > vh || rect.left > vw) continue;

      const style = window.getComputedStyle(el);
      if (style.display === 'none' || style.visibility === 'hidden') continue;
      if (parseFloat(style.opacity || '1') === 0) continue;

      const text = (el.innerText || el.textContent || '').trim();
      if (!text) continue;
      if (text.length > 60) continue; // 長文は誤検出しやすい

      const parsed = parseLatLonFromText(text);
      if (!parsed) continue;

      // 右下に近いほど優先
      const dist = Math.hypot(vw - rect.right, vh - rect.bottom);

      // 追加のスコア:座標表示っぽい短さを少し優遇
      const lenPenalty = Math.max(0, text.length - 25) * 2;

      candidates.push({
        lat: parsed.lat,
        lon: parsed.lon,
        dist: dist + lenPenalty,
        raw: text
      });
    }

    if (candidates.length === 0) return null;
    candidates.sort((a, b) => a.dist - b.dist);
    return candidates[0];
  }

  /******************************
   * Google Maps: URLから座標抽出(複数形式対応)
   ******************************/
  function getLatLonFromGoogleUrl(url) {
    // 1) /@lat,lon,zoom
    let m = url.match(/@(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/);
    if (m) return { lat: parseFloat(m[1]), lon: parseFloat(m[2]) };

    // 2) ?q=lat,lon
    m = url.match(/[?&]q=(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/);
    if (m) return { lat: parseFloat(m[1]), lon: parseFloat(m[2]) };

    // 3) ?query=lat,lon (search api)
    m = url.match(/[?&]query=(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/);
    if (m) return { lat: parseFloat(m[1]), lon: parseFloat(m[2]) };

    // 4) Place/ルート系: !3dlat!4dlon
    m = url.match(/!3d(-?\d+(?:\.\d+)?)!4d(-?\d+(?:\.\d+)?)/);
    if (m) return { lat: parseFloat(m[1]), lon: parseFloat(m[2]) };

    return null;
  }

  /******************************
   * Waze側
   ******************************/
  if (location.host.includes('waze.com')) {
    const jumpBtn = createBtn('jump-to-gmaps', '→ Google Maps', '#4285F4', () => {
      if (currentLat != null && currentLon != null) {
        // zoom安定:@lat,lon,17z
        const url = `https://www.google.com/maps/@${currentLat},${currentLon},17z`;
        window.location.href = url;
      } else {
        alert('右下の座標が読み取れていません。地図を少し動かして座標表示が出た状態で押してください。');
      }
    });

    let lastKey = '';
    let scheduled = false;

    const update = () => {
      scheduled = false;
      const found = findCoordsOnScreenPreferBottomRight();
      if (!found) return;

      const key = `${found.lat},${found.lon}`;
      if (key === lastKey) return;
      lastKey = key;

      currentLat = found.lat;
      currentLon = found.lon;

      jumpBtn.style.opacity = '1';
      jumpBtn.textContent = `→ Google Maps (${currentLat.toFixed(5)}, ${currentLon.toFixed(5)})`;
    };

    // MutationObserverはデバウンスして負荷を抑える
    const requestUpdate = () => {
      if (scheduled) return;
      scheduled = true;
      setTimeout(update, 120);
    };

    const obs = new MutationObserver(() => requestUpdate());
    obs.observe(document.documentElement, { childList: true, subtree: true, characterData: true });

    // 保険の定期更新(低頻度)
    setInterval(() => requestUpdate(), 2500);

    requestUpdate();
  }

  /******************************
   * Google Maps側
   ******************************/
  if (location.host.includes('google.')) {
    createBtn('jump-to-waze', '→ Waze Live Map', '#33CCFF', () => {
      const ll = getLatLonFromGoogleUrl(window.location.href);
      if (!ll) {
        alert('URLに座標が含まれていません。地図を少し動かしてURLが更新されてから押してください。');
        return;
      }

      // ライブマップで同座標を表示(アプリが開く場合あり)
      const url = `https://www.waze.com/ul?ll=${ll.lat},${ll.lon}&navigate=no`;
      window.location.href = url;
    });
  }
})();