Waze ⇄ Google Maps Jump

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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