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