ラウンド終了時に画面下部に正解地点およびGuess地点のストリートビューを表示します
// ==UserScript==
// @name GeoDiff
// @namespace http://tampermonkey.net/
// @version 0.93
// @description ラウンド終了時に画面下部に正解地点およびGuess地点のストリートビューを表示します
// @author Daig_O
// @copyright 2026 Daig_O
// @license MIT
// @match https://www.geoguessr.com/*
// @run-at document-start
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// ==/UserScript==
(function () {
'use strict';
// =============================================
// 言語設定
// =============================================
const i18n = {
ja: {
toggleAutoShow: '自動表示を切り替え',
toggleMinDist: '自動表示の最小距離を変更',
toggleLayout: 'レイアウト反転を切り替え',
toggleLanguage: 'Change Language',
autoShowToggled: (state) => `自動表示を ${state ? 'ON' : 'OFF'} にしました。`,
minDistPrompt: '何km以上離れていたら自動表示するか(0=常に表示)',
minDistUpdated: (n) => `最小距離を ${n} km に設定しました。`,
minDistInvalid: '正の数値を入力してください',
layoutToggled: (state) => `レイアウト反転を ${state ? 'ON' : 'OFF'} にしました。`,
languageChanged: (lang) => `言語を ${lang} に変更しました。`,
mapTitle: 'Google マップで開く',
correctLabel: '📍 正解地点',
guessLabel: '🗺 Guess周辺',
svNotFound: 'SV未検出',
guessNoSV: '100km圏内にSV未検出',
distanceSuffix: '先',
},
en: {
toggleAutoShow: 'Toggle Auto-show',
toggleMinDist: 'Change Min Distance',
toggleLayout: 'Toggle Layout Reverse',
toggleLanguage: 'Change Language',
autoShowToggled: (state) => `Auto-show turned ${state ? 'ON' : 'OFF'}.`,
minDistPrompt: 'Show auto-display if distance is greater than (km)? (0=always)',
minDistUpdated: (n) => `Min distance set to ${n} km.`,
minDistInvalid: 'Please enter a positive number',
layoutToggled: (state) => `Layout reverse turned ${state ? 'ON' : 'OFF'}.`,
languageChanged: (lang) => `Language changed to ${lang}.`,
mapTitle: 'Open in Google Maps',
correctLabel: '📍 Correct Location',
guessLabel: '🗺 Guess Vicinity',
svNotFound: 'No Street View',
guessNoSV: 'No Street View within 100km',
distanceSuffix: ' away',
},
};
// =============================================
// 設定
// =============================================
let CFG_AUTO_SHOW = GM_getValue('autoShow', false);
let CFG_MIN_DIST_KM = GM_getValue('minDistKm', 0);
let CFG_LAYOUT_REVERSED = GM_getValue('layoutReversed', false);
let CFG_LANGUAGE = GM_getValue('language', 'ja');
const t = (key, ...args) => {
const msg = i18n[CFG_LANGUAGE]?.[key] ?? i18n['ja'][key];
return typeof msg === 'function' ? msg(...args) : msg;
};
GM_registerMenuCommand(
t('toggleAutoShow'),
() => {
CFG_AUTO_SHOW = !CFG_AUTO_SHOW;
GM_setValue('autoShow', CFG_AUTO_SHOW);
alert(t('autoShowToggled', CFG_AUTO_SHOW));
}
);
GM_registerMenuCommand(
t('toggleMinDist'),
() => {
const val = prompt(t('minDistPrompt'), CFG_MIN_DIST_KM);
if (val === null) return;
const n = parseFloat(val);
if (isNaN(n) || n < 0) { alert(t('minDistInvalid')); return; }
CFG_MIN_DIST_KM = n;
GM_setValue('minDistKm', n);
alert(t('minDistUpdated', n));
}
);
GM_registerMenuCommand(
t('toggleLayout'),
() => {
CFG_LAYOUT_REVERSED = !CFG_LAYOUT_REVERSED;
GM_setValue('layoutReversed', CFG_LAYOUT_REVERSED);
alert(t('layoutToggled', CFG_LAYOUT_REVERSED));
}
);
GM_registerMenuCommand(
t('toggleLanguage'),
() => {
CFG_LANGUAGE = CFG_LANGUAGE === 'ja' ? 'en' : 'ja';
GM_setValue('language', CFG_LANGUAGE);
alert(t('languageChanged', CFG_LANGUAGE));
}
);
// =============================================
// 状態管理
// =============================================
let isProcessing = false;
let lastRoundIdx = -1;
let lastGameId = null;
let lastDistM = 0;
let lastGuess = null;
let lastRound = null;
let panelVisible = false;
let observerActive = false; //observer の二重起動防止フラグ
const getGameId = () => {
const m = location.pathname.match(/^\/(?:[a-z]{2}\/)?game\/([^/]+)/);
return m ? m[1] : null;
};
const isGamePage = () => /^\/(?:[a-z]{2}\/)?game\/[^/]+/.test(location.pathname);
const fetchGameData = async (gameId) => {
const res = await fetch(`/api/v3/games/${gameId}`, { credentials: 'include' });
if (!res.ok) throw new Error('API fetch failed');
return res.json();
};
const decodePanoId = (hexStr) => {
if (!hexStr) return null;
if (!/^[0-9A-Fa-f]+$/.test(hexStr) || hexStr.length % 2 !== 0) return hexStr;
try {
let decoded = '';
for (let i = 0; i < hexStr.length; i += 2)
decoded += String.fromCharCode(parseInt(hexStr.substr(i, 2), 16));
return decoded;
} catch (e) { return hexStr; }
};
const haversineM = (lat1, lng1, lat2, lng2) => {
const R = 6371000;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180)
* Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
const formatDist = (m) =>
m < 1000 ? `${Math.round(m)}m` : `${(m / 1000).toFixed(1)}km`;
// =============================================
// description から州・地域名を抽出
// =============================================
const extractState = (description) => {
if (!description) return null;
const m = description.match(/State of (.+)/i);
if (m) return m[1].trim();
const parts = description.split(',');
return parts[parts.length - 1].trim();
};
const buildLocText = (streakCode, description, distPrefix = null) => {
const code = streakCode ? streakCode.toUpperCase() : '';
const state = extractState(description);
const place = state || code;
const base = code ? `[${code}] ${place}` : place;
return distPrefix ? `${base} · ${distPrefix}` : base;
};
// =============================================
// StreetView API
// =============================================
let _svService = null;
const getSVService = () => {
if (_svService) return _svService;
const dummy = document.createElement('div');
dummy.setAttribute('style', 'display:none;width:1px;height:1px;position:absolute;');
document.body.appendChild(dummy);
new google.maps.Map(dummy, { center: { lat: 0, lng: 0 }, zoom: 8 });
_svService = new google.maps.StreetViewService();
return _svService;
};
const searchOnce = (lat, lng, radiusM) => new Promise((resolve) => {
getSVService().getPanorama({
location: { lat, lng },
radius: radiusM,
preference: google.maps.StreetViewPreference.NEAREST,
sources: [
google.maps.StreetViewSource.GOOGLE,
google.maps.StreetViewSource.OUTDOOR
]
}, (data, status) => {
if (status === 'OK' && data) {
resolve({
panoId: data.location.pano,
lat: data.location.latLng.lat(),
lng: data.location.latLng.lng(),
description: data.location.description || '',
});
} else {
resolve(null);
}
});
});
const findNearestPano = async (lat, lng) => {
const radii = [10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000, 100000];
let best = null;
for (const r of radii) {
const result = await searchOnce(lat, lng, r);
if (result) {
const distM = haversineM(lat, lng, result.lat, result.lng);
const candidate = { ...result, distM };
if (!best || distM < best.distM) best = candidate;
if (distM <= r) break;
}
}
return best;
};
const getPanoInfo = (panoId) => new Promise((resolve) => {
getSVService().getPanorama({ pano: panoId }, (data, status) => {
if (status === 'OK' && data) {
resolve({
panoId: data.location.pano,
lat: data.location.latLng.lat(),
lng: data.location.latLng.lng(),
description: data.location.description || '',
});
} else {
resolve(null);
}
});
});
const renderSV = (containerDiv, panoId, heading = 0, pitch = 0) => {
return new google.maps.StreetViewPanorama(containerDiv, {
pano: panoId,
pov: { heading, pitch },
zoom: 1,
addressControl: false,
fullscreenControl: false,
motionTracking: false,
motionTrackingControl: false,
showRoadLabels: false,
linksControl: true,
});
};
// =============================================
// Google Maps リンクボタン
// =============================================
const makeMapLinkBtn = (url) => {
const btn = document.createElement('a');
btn.href = url;
btn.target = '_blank';
btn.rel = 'noopener noreferrer';
btn.title = t('mapTitle');
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>`;
btn.setAttribute('style', [
'display:inline-flex', 'align-items:center', 'justify-content:center',
'width:20px', 'height:20px', 'border-radius:4px',
'background:rgba(255,255,255,0.18)', 'color:#fff',
'text-decoration:none', 'margin-left:5px', 'flex-shrink:0',
'cursor:pointer', 'line-height:1', 'vertical-align:middle',
].join(';'));
btn.addEventListener('mouseover', () => btn.style.background = 'rgba(255,255,255,0.38)');
btn.addEventListener('mouseout', () => btn.style.background = 'rgba(255,255,255,0.18)');
return btn;
};
// =============================================
// SVブロック生成
// =============================================
const makeSVBlock = (label) => {
const block = document.createElement('div');
block.setAttribute('style', 'flex:1;display:flex;flex-direction:column;overflow:hidden;background:#111;min-width:0;');
const header = document.createElement('div');
header.setAttribute('style', [
'padding:6px 10px',
'background:rgba(0,0,0,0.75)',
'color:#fff',
'font-family:sans-serif',
'flex-shrink:0',
'display:flex',
'align-items:center',
'justify-content:space-between',
'gap:8px',
'min-height:20px',
].join(';'));
const leftRow = document.createElement('div');
leftRow.setAttribute('style', 'display:flex;align-items:center;flex-shrink:0;gap:5px;');
const labelEl = document.createElement('span');
labelEl.textContent = label;
labelEl.setAttribute('style', 'font-weight:bold;font-size:12px;white-space:nowrap;');
const mapLinkSlot = document.createElement('span');
leftRow.appendChild(labelEl);
leftRow.appendChild(mapLinkSlot);
const rightRow = document.createElement('div');
rightRow.setAttribute('style', [
'font-size:11px', 'opacity:0.85', 'text-align:right',
'min-width:0', 'flex:1',
'overflow:hidden', 'text-overflow:ellipsis', 'white-space:nowrap',
].join(';'));
header.appendChild(leftRow);
header.appendChild(rightRow);
const svDiv = document.createElement('div');
svDiv.setAttribute('style', 'flex:1;min-height:0;');
block.appendChild(header);
block.appendChild(svDiv);
return { block, svDiv, rightRow, mapLinkSlot };
};
// =============================================
// ズームアウト
// =============================================
const zoomOutMap = (steps = 3) => {
const mapEl = document.querySelector('[class*="coordinate-result-map"]');
if (!mapEl) return;
const target = Array.from(mapEl.querySelectorAll('button'))
.find(b => (b.title + (b.getAttribute('aria-label') || '')).includes('Zoom out'));
if (target) for (let i = 0; i < steps; i++) setTimeout(() => target.click(), i * 80);
};
// =============================================
// トグルボタン
// =============================================
const injectToggleButton = () => {
if (document.getElementById('gg-sv-toggle')) return;
const nextBtn = document.querySelector('[data-qa="close-round-result"]');
if (!nextBtn) return;
const btn = document.createElement('button');
btn.id = 'gg-sv-toggle';
btn.textContent = '📺 SV';
btn.setAttribute('style', [
'margin-left:8px', 'padding:0 12px', 'height:40px',
'border:none', 'border-radius:6px', 'background:rgba(255,255,255,0.15)',
'color:#fff', 'font-size:13px', 'font-weight:bold',
'cursor:pointer', 'flex-shrink:0', 'vertical-align:middle',
].join(';'));
btn.addEventListener('mouseover', () => btn.style.background = panelVisible ? 'rgba(100,180,255,0.5)' : 'rgba(255,255,255,0.28)');
btn.addEventListener('mouseout', () => btn.style.background = panelVisible ? 'rgba(100,180,255,0.35)' : 'rgba(255,255,255,0.15)');
btn.addEventListener('click', async () => {
if (!panelVisible) {
panelVisible = true;
btn.style.background = 'rgba(100,180,255,0.35)';
if (lastGuess && lastRound) await buildPanel(lastGuess, lastRound);
} else {
panelVisible = false;
btn.style.background = 'rgba(255,255,255,0.15)';
hidePanel();
}
});
nextBtn.insertAdjacentElement('afterend', btn);
};
const hidePanel = () => {
document.getElementById('gg-sv-root')?.remove();
const resultTop = document.querySelector('[data-qa="result-view-top"]');
if (resultTop) resultTop.removeAttribute('style');
};
// =============================================
// メインパネル構築
// =============================================
const buildPanel = async (guess, round) => {
document.getElementById('gg-sv-root')?.remove();
const resultTop = document.querySelector('[data-qa="result-view-top"]');
const resultBottom = document.querySelector('[data-qa="result-view-bottom"]');
if (!resultTop || !resultBottom) return;
const totalH = window.innerHeight;
const nextBandH = resultBottom.getBoundingClientRect().height;
const mapH = Math.floor((totalH - nextBandH) * 0.5);
const svH = totalH - nextBandH - mapH;
resultTop.setAttribute('style', `height:${mapH}px;overflow:hidden;flex-shrink:0;`);
const root = document.createElement('div');
root.id = 'gg-sv-root';
root.setAttribute('style', [
`height:${svH}px`, 'display:flex', 'flex-direction:row',
'overflow:hidden', 'background:#1a1a2e',
'border-top:1px solid rgba(255,255,255,0.1)',
].join(';'));
const { block: correctBlock, svDiv: correctSVDiv, rightRow: correctInfo, mapLinkSlot: correctSlot } =
makeSVBlock(t('correctLabel'));
const { block: guessBlock, svDiv: guessSVDiv, rightRow: guessInfo, mapLinkSlot: guessSlot } =
makeSVBlock(t('guessLabel'));
const divider = document.createElement('div');
divider.setAttribute('style', 'width:2px;background:rgba(255,255,255,0.12);flex-shrink:0;');
if (CFG_LAYOUT_REVERSED) {
root.appendChild(guessBlock);
root.appendChild(divider);
root.appendChild(correctBlock);
} else {
root.appendChild(correctBlock);
root.appendChild(divider);
root.appendChild(guessBlock);
}
resultBottom.parentElement.insertBefore(root, resultBottom);
setTimeout(() => zoomOutMap(3), 300);
// ===== 正解地点 =====
const decodedPanoId = decodePanoId(round.panoId);
if (decodedPanoId) {
const panoInfo = await getPanoInfo(decodedPanoId);
renderSV(correctSVDiv, decodedPanoId, round.heading || 0, round.pitch || 0);
correctInfo.textContent = buildLocText(
round.streakLocationCode,
panoInfo?.description || ''
);
correctSlot.appendChild(makeMapLinkBtn(makeMapsUrl(round.lat, round.lng, decodedPanoId)));
} else {
const nearest = await findNearestPano(round.lat, round.lng);
if (nearest) {
renderSV(correctSVDiv, nearest.panoId, round.heading || 0, round.pitch || 0);
correctInfo.textContent = buildLocText(round.streakLocationCode, nearest.description);
correctSlot.appendChild(makeMapLinkBtn(makeMapsUrl(nearest.lat, nearest.lng, nearest.panoId)));
} else {
correctInfo.textContent = round.streakLocationCode
? `[${round.streakLocationCode.toUpperCase()}] ${t('svNotFound')}`
: t('svNotFound');
}
}
// ===== Guess周辺 =====
const guessNearest = await findNearestPano(guess.lat, guess.lng);
if (guessNearest) {
const distM = haversineM(guess.lat, guess.lng, guessNearest.lat, guessNearest.lng);
guessInfo.textContent = buildLocText(
guess.streakLocationCode || '',
guessNearest.description,
formatDist(distM) + t('distanceSuffix')
);
renderSV(guessSVDiv, guessNearest.panoId);
guessSlot.appendChild(makeMapLinkBtn(makeMapsUrl(guessNearest.lat, guessNearest.lng, guessNearest.panoId)));
} else {
guessInfo.textContent = t('guessNoSV');
}
};
// =============================================
// Google Maps URL生成
// =============================================
const makeMapsUrl = (lat, lng, panoId) => panoId
? `https://www.google.com/maps/@${lat},${lng},3a,90y,0h,90t/data=!3m4!1e1!3m2!1s${encodeURIComponent(panoId)}!2e0`
: `https://www.google.com/maps/@${lat},${lng},15z`;
// =============================================
// リセット・初期化
// =============================================
const resetState = () => {
isProcessing = false;
lastRoundIdx = -1;
panelVisible = false;
document.querySelector('[data-qa="result-view-top"]')?.removeAttribute('style');
document.getElementById('gg-sv-root')?.remove();
document.getElementById('gg-sv-toggle')?.remove();
console.log('[GeoDiff] reset:', getGameId());
};
// initGame: /game/ ページに来たときに observer を確実に起動する関数
const initGame = () => {
const newId = getGameId();
if (!isGamePage()) {
// /game/ を離れたら observer を停める
if (observerActive) {
observer.disconnect();
observerActive = false;
}
return;
}
if (newId !== lastGameId) {
lastGameId = newId;
resetState();
}
if (!observerActive) {
observer.observe(document.body, { childList: true, subtree: true });
observerActive = true;
console.log('[GeoDiff] observer started for:', newId);
}
};
// =============================================
// ラウンド結果処理
// =============================================
const onRoundResult = async () => {
if (isProcessing) return;
if (!document.querySelector('[data-qa="result-view-top"]')) return;
if (document.querySelector('[data-qa="final-result-score"]')) return;
isProcessing = true;
try {
const data = await fetchGameData(getGameId());
const guesses = data.player?.guesses ?? [];
const roundIdx = guesses.length - 1;
if (roundIdx < 0 || roundIdx === lastRoundIdx) return;
lastRoundIdx = roundIdx;
const guess = guesses[roundIdx];
const round = data.rounds?.[roundIdx];
if (!guess || !round) return;
lastDistM = haversineM(guess.lat, guess.lng, round.lat, round.lng);
lastGuess = guess;
lastRound = round;
const shouldAutoShow = CFG_AUTO_SHOW && (lastDistM / 1000 >= CFG_MIN_DIST_KM);
// bserver を止めてパネル操作、終わったら再開(元のコードと同じ方針を維持)
observer.disconnect();
observerActive = false;
if (shouldAutoShow) {
panelVisible = true;
await buildPanel(guess, round);
} else {
panelVisible = false;
await new Promise(r => setTimeout(r, 200));
injectToggleButton();
}
observer.observe(document.body, { childList: true, subtree: true });
observerActive = true;
} catch (e) {
console.error('[GeoDiff]', e);
} finally {
isProcessing = false;
}
};
// =============================================
// URL変化検知(SPA対応)
// =============================================
const _origPushState = history.pushState.bind(history);
const _origReplaceState = history.replaceState.bind(history);
history.pushState = function (...args) {
_origPushState(...args);
setTimeout(initGame, 0); // setTimeout(,0) でURL確定後に実行
};
history.replaceState = function (...args) {
_origReplaceState(...args);
setTimeout(initGame, 0);
};
window.addEventListener('popstate', () => setTimeout(initGame, 0));
// =============================================
// MutationObserver
// =============================================
const observer = new MutationObserver(() => {
if (!isGamePage()) return; // /game/ 以外では何もしない
if (document.querySelector('[data-qa="final-result-score"]')) {
if (document.getElementById('gg-sv-root') || document.getElementById('gg-sv-toggle')) resetState();
return;
}
if (isProcessing) return;
if (document.querySelector('[data-qa="result-view-top"]') &&
!document.getElementById('gg-sv-root') &&
!document.getElementById('gg-sv-toggle')) {
onRoundResult();
return;
}
if (!CFG_AUTO_SHOW &&
document.querySelector('[data-qa="result-view-top"]') &&
!document.getElementById('gg-sv-toggle') &&
document.querySelector('[data-qa="close-round-result"]')) {
injectToggleButton();
}
});
// =============================================
// 初回起動
// document-start なので DOM がまだない場合がある。
// DOMContentLoaded 後に initGame を呼ぶ
// =============================================
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initGame);
} else {
initGame(); // すでに DOM が存在する場合(リロード等)
}
console.log('[GeoDiff] v3.6 loaded');
})();