// ==UserScript==
// @name Google Maps Enhanced Photos
// @namespace https://github.com/gncnpk/google-maps-enhanced-photos
// @version 0.0.2
// @description Filter photos and videos in Google Maps contributions
// @author Gavin Canon-Phratsachack (https://github.com/gncnpk)
// @match https://www.google.com/maps/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=google.com/maps
// @grant none
// @run-at document-start
// @license MIT
// ==/UserScript==
(function() {
'use strict';
let isInit = false;
let oldHref = document.location.href;
// Store observers for cleanup
let containerObserver = null;
// UI elements
let popup, filterContainer, placeFilterContainer;
let autoLoadEnabled = false;
let currentFilter = 'all'; // 'all', 'photos', 'videos'
let currentPlaceFilter = 'all'; // 'all' or specific place name
// Container references
let contentContainer = null;
let scrollContainer = null;
// Most viewed element tracking
let mostViewedElement = null;
let mostViewedCount = 0;
// Place tracking
let availablePlaces = new Set();
// Multi-language photo and video keywords
const PHOTO_KEYWORDS = [
// English
'photo', 'picture', 'image', 'pic',
// German
'foto', 'bild',
// French
'photo', 'image',
// Spanish
'foto', 'imagen',
// Italian
'foto', 'immagine',
// Portuguese
'foto', 'imagem',
// Dutch
'foto', 'afbeelding',
// Russian
'фото',
// Japanese
'写真', 'しゃしん',
// Chinese (Simplified & Traditional)
'照片', '相片',
// Korean
'사진',
// Arabic
'صورة',
// Polish
'zdjęcie', 'foto',
// Czech
'fotografie', 'obrázek',
// Swedish
'foto', 'bild',
// Norwegian
'foto', 'bilde',
// Danish
'foto', 'billede',
// Finnish
'kuva', 'valokuva',
// Hungarian
'fotó', 'kép',
// Turkish
'fotoğraf', 'resim',
// Hebrew
'תמונה'
];
const VIDEO_KEYWORDS = [
// English
'video', 'movie', 'clip',
// German
'video', 'film',
// French
'vidéo', 'film',
// Spanish
'vídeo', 'video', 'película',
// Italian
'video', 'filmato',
// Portuguese
'vídeo', 'filme',
// Dutch
'video', 'film',
// Russian
'видео',
// Japanese
'ビデオ', '動画', 'びでお', 'どうが',
// Chinese (Simplified & Traditional)
'视频', '影片', '錄像',
// Korean
'비디오', '영상',
// Arabic
'فيديو',
// Polish
'wideo', 'film',
// Czech
'video', 'film',
// Swedish
'video', 'film',
// Norwegian
'video', 'film',
// Danish
'video', 'film',
// Finnish
'video', 'elokuva',
// Hungarian
'videó', 'film',
// Turkish
'video', 'film',
// Hebrew
'וידאו', 'סרטון'
];
// Function to check if an aria-label contains photo keywords
function hasPhotoKeyword(ariaLabel) {
if (!ariaLabel) return false;
const lowerLabel = ariaLabel.toLowerCase();
return PHOTO_KEYWORDS.some(keyword =>
lowerLabel.includes(keyword.toLowerCase())
);
}
// Function to check if an aria-label contains video keywords
function hasVideoKeyword(ariaLabel) {
if (!ariaLabel) return false;
const lowerLabel = ariaLabel.toLowerCase();
return VIDEO_KEYWORDS.some(keyword =>
lowerLabel.includes(keyword.toLowerCase())
);
}
// Inject CSS
const style = document.createElement('style');
style.textContent = `
.photo-video-popup {
position: fixed;
top: 10px;
right: 10px;
background: #fff;
border: 1px solid #ccc;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
padding: 15px;
z-index: 9999;
width: 250px;
font-family: Arial, sans-serif;
max-height: 80vh;
overflow-y: auto;
}
.filter-btn {
background: #f0f0f0;
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px 12px;
margin: 4px 2px;
cursor: pointer;
font-size: 12px;
width: calc(50% - 4px);
display: inline-block;
text-align: center;
}
.filter-btn.active {
background: #4285f4;
color: white;
border-color: #4285f4;
}
.place-filter-select {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 12px;
margin-top: 5px;
background: white;
}
.auto-load-btn, .scroll-to-most-viewed-btn {
background: #34a853;
border: none;
border-radius: 4px;
color: white;
padding: 8px 12px;
font-size: 12px;
cursor: pointer;
width: 100%;
margin-top: 8px;
}
.auto-load-btn.enabled {
background: #ea4335;
}
.scroll-to-most-viewed-btn {
background: #ff9800;
}
.scroll-to-most-viewed-btn:hover {
background: #f57c00;
}
.scroll-to-most-viewed-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.drag-handle {
cursor: move;
font-weight: bold;
margin-bottom: 10px;
padding: 5px;
border-bottom: 1px solid #eee;
}
.stats {
line-height: 1.25em;
}
.most-viewed-info {
font-size: 11px;
color: #666;
margin-top: 5px;
padding: 5px;
background: #f9f9f9;
border-radius: 3px;
}
.filter-section {
margin-bottom: 15px;
}
.filter-header {
font-size: 14px;
font-weight: bold;
margin-bottom: 8px;
}
`;
document.head.appendChild(style);
// Find content container (photos/videos grid)
function findContentContainer() {
// Look for the container that holds photos/videos
const containers = document.querySelectorAll('.m6QErb.XiKgde');
for (let container of containers) {
// Check if container has photo/video items using our keyword functions
const mediaElements = container.querySelectorAll('.xUc6Hf[aria-label]');
const hasMedia = Array.from(mediaElements).some(el =>
hasPhotoKeyword(el.getAttribute('aria-label')) ||
hasVideoKeyword(el.getAttribute('aria-label'))
);
if (hasMedia && container.children.length > 5) { // Reasonable threshold
return container;
}
}
return null;
}
// Find scroll container
function findScrollContainer() {
if (contentContainer) {
let container = contentContainer;
while (container && container !== document.body) {
const style = getComputedStyle(container);
if ((style.overflowY === 'auto' || style.overflowY === 'scroll') &&
container.scrollHeight > container.clientHeight) {
return container;
}
container = container.parentElement;
}
}
// Fallback: find any scrollable container with media
const scrollableElements = Array.from(document.querySelectorAll('*')).filter(el => {
const style = getComputedStyle(el);
const hasScrollableY = (style.overflowY === 'auto' || style.overflowY === 'scroll') &&
el.scrollHeight > el.clientHeight;
if (!hasScrollableY) return false;
// Check for media using our keyword functions
const mediaElements = el.querySelectorAll('.xUc6Hf[aria-label]');
return Array.from(mediaElements).some(element =>
hasPhotoKeyword(element.getAttribute('aria-label')) ||
hasVideoKeyword(element.getAttribute('aria-label'))
);
});
return scrollableElements.length > 0 ? scrollableElements[0] : null;
}
// Extract place name from a post element
function getPlaceFromPost(postElement) {
const placeElement = postElement.querySelector(".UwKPnd .fgD3Vc.fontTitleSmall");
return placeElement ? placeElement.innerText.trim() : null;
}
// Scan all posts and collect unique places
function updateAvailablePlaces() {
if (!contentContainer) return;
const newPlaces = new Set();
Array.from(contentContainer.children).forEach(item => {
const place = getPlaceFromPost(item);
if (place) {
newPlaces.add(place);
}
});
// Check if places have changed
const placesChanged = newPlaces.size !== availablePlaces.size ||
[...newPlaces].some(place => !availablePlaces.has(place));
availablePlaces = newPlaces;
// Update place filter dropdown if places changed
if (placesChanged) {
updatePlaceFilterDropdown();
}
}
// Update the place filter dropdown
function updatePlaceFilterDropdown() {
const placeSelect = popup?.querySelector('.place-filter-select');
if (!placeSelect) return;
const currentValue = placeSelect.value;
placeSelect.innerHTML = '<option value="all">All Places</option>';
const sortedPlaces = [...availablePlaces].sort();
sortedPlaces.forEach(place => {
const option = document.createElement('option');
option.value = place;
option.textContent = place;
placeSelect.appendChild(option);
});
// Restore previous selection if it still exists
if (currentValue && availablePlaces.has(currentValue)) {
placeSelect.value = currentValue;
currentPlaceFilter = currentValue;
} else if (currentValue !== 'all') {
// Reset to 'all' if previous selection no longer exists
currentPlaceFilter = 'all';
placeSelect.value = 'all';
}
}
// Find element with most views (respecting current filters)
function findMostViewedElement() {
if (!contentContainer) {
mostViewedElement = null;
mostViewedCount = 0;
return;
}
const viewElements = document.querySelectorAll("div.WqkvRc.fontBodySmall.BfMscf > div.HtPsUd");
if (viewElements.length === 0) {
mostViewedElement = null;
mostViewedCount = 0;
return;
}
let maxViews = 0;
let maxElement = null;
viewElements.forEach(element => {
// Find the parent post container
let postContainer = element;
while (postContainer && !postContainer.parentElement?.classList.contains('m6QErb')) {
postContainer = postContainer.parentElement;
}
// Skip if we can't find the post container or if it's hidden by the filter
if (!postContainer || getComputedStyle(postContainer).display === 'none') {
return;
}
const viewText = element.innerText.replace(/,/g, '');
const views = parseInt(viewText);
if (!isNaN(views) && views > maxViews) {
maxViews = views;
maxElement = element;
}
});
mostViewedElement = maxElement;
mostViewedCount = maxViews;
}
// Scroll to most viewed element
function scrollToMostViewed() {
if (!mostViewedElement || !scrollContainer) {
return;
}
// Find the parent post container
let postContainer = mostViewedElement;
while (postContainer && !postContainer.matches('.m6QErb.XiKgde > *')) {
postContainer = postContainer.parentElement;
}
if (postContainer) {
// Scroll the element into view
postContainer.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
// Add temporary highlight effect
const originalTransition = postContainer.style.transition;
const originalBackground = postContainer.style.backgroundColor;
postContainer.style.transition = 'background-color 0.3s ease';
postContainer.style.backgroundColor = '#fff3cd';
setTimeout(() => {
postContainer.style.backgroundColor = originalBackground;
setTimeout(() => {
postContainer.style.transition = originalTransition;
}, 300);
}, 1500);
}
}
// Filter content based on selection
function filterContent() {
if (!contentContainer) return;
// Scroll to top when filtering
if (scrollContainer) {
scrollContainer.scrollTop = 0;
}
Array.from(contentContainer.children).forEach(item => {
// Use our keyword functions to detect photos and videos
const mediaElements = item.querySelectorAll('.xUc6Hf[aria-label]');
const hasPhoto = Array.from(mediaElements).some(el =>
hasPhotoKeyword(el.getAttribute('aria-label'))
);
const hasVideo = Array.from(mediaElements).some(el =>
hasVideoKeyword(el.getAttribute('aria-label'))
);
const itemPlace = getPlaceFromPost(item);
let visible = true;
// Apply media type filter
switch (currentFilter) {
case 'photos':
visible = hasPhoto;
break;
case 'videos':
visible = hasVideo;
break;
case 'all':
default:
visible = true;
break;
}
// Apply place filter
if (visible && currentPlaceFilter !== 'all') {
visible = itemPlace === currentPlaceFilter;
}
item.style.display = visible ? '' : 'none';
});
updateStats();
}
// Update statistics
function updateStats() {
if (!contentContainer) return;
const allItems = Array.from(contentContainer.children);
const visibleItems = allItems.filter(item => getComputedStyle(item).display !== 'none');
let photoCount = 0;
let videoCount = 0;
visibleItems.forEach(item => {
const mediaElements = item.querySelectorAll('.xUc6Hf[aria-label]');
Array.from(mediaElements).forEach(element => {
const ariaLabel = element.getAttribute('aria-label');
if (hasPhotoKeyword(ariaLabel)) {
photoCount++;
} else if (hasVideoKeyword(ariaLabel)) {
videoCount++;
}
});
});
// Update available places
updateAvailablePlaces();
// Find most viewed element
findMostViewedElement();
const statsDiv = popup.querySelector('.stats');
if (statsDiv) {
let statsText = `${visibleItems.length} posts<br>${photoCount} photos<br>${videoCount} videos`;
if (currentPlaceFilter !== 'all') {
statsText += `<br>Place: ${currentPlaceFilter}`;
}
statsDiv.innerHTML = statsText;
}
// Update most viewed info
const mostViewedInfo = popup.querySelector('.most-viewed-info');
const scrollBtn = popup.querySelector('.scroll-to-most-viewed-btn');
if (mostViewedInfo && scrollBtn) {
if (mostViewedElement && mostViewedCount > 0) {
mostViewedInfo.innerHTML = `Most viewed: ${mostViewedCount.toLocaleString()} views`;
scrollBtn.disabled = false;
scrollBtn.textContent = 'Go to Most Viewed';
} else {
mostViewedInfo.innerHTML = 'No view data found';
scrollBtn.disabled = true;
scrollBtn.textContent = 'No Views Found';
}
}
// Auto-scroll if enabled
if (autoLoadEnabled && scrollContainer) {
setTimeout(() => {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}, 100);
}
}
// Toggle auto load
function toggleAutoLoad() {
autoLoadEnabled = !autoLoadEnabled;
const autoLoadBtn = popup.querySelector('.auto-load-btn');
if (autoLoadBtn) {
autoLoadBtn.textContent = autoLoadEnabled ? 'Disable Auto Load' : 'Enable Auto Load';
autoLoadBtn.classList.toggle('enabled', autoLoadEnabled);
}
if (autoLoadEnabled && scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
}
// Make element draggable
function makeDraggable(el, handleSelector) {
const handle = el.querySelector(handleSelector);
if (!handle) return;
handle.style.cursor = 'move';
let offsetX = 0, offsetY = 0;
handle.addEventListener('pointerdown', e => {
const r = el.getBoundingClientRect();
el.style.left = `${r.left}px`;
el.style.top = `${r.top}px`;
el.style.right = 'auto';
el.style.transform = 'none';
offsetX = e.clientX - r.left;
offsetY = e.clientY - r.top;
el.setPointerCapture(e.pointerId);
e.preventDefault();
});
el.addEventListener('pointermove', e => {
if (!el.hasPointerCapture(e.pointerId)) return;
el.style.left = `${e.clientX - offsetX}px`;
el.style.top = `${e.clientY - offsetY}px`;
});
['pointerup', 'pointercancel'].forEach(evt => {
el.addEventListener(evt, e => {
if (el.hasPointerCapture(e.pointerId)) {
el.releasePointerCapture(e.pointerId);
}
});
});
}
// Create popup UI
function createPopup() {
popup = document.createElement('div');
popup.className = 'photo-video-popup';
// Drag handle / stats
const statsDiv = document.createElement('div');
statsDiv.className = 'stats drag-handle';
statsDiv.textContent = 'Photo/Video Filter';
popup.appendChild(statsDiv);
// Media type filter section
const mediaFilterSection = document.createElement('div');
mediaFilterSection.className = 'filter-section';
const mediaFilterHeader = document.createElement('div');
mediaFilterHeader.className = 'filter-header';
mediaFilterHeader.textContent = 'Filter by Media Type';
mediaFilterSection.appendChild(mediaFilterHeader);
filterContainer = document.createElement('div');
const filters = [
{ id: 'all', label: 'All' },
{ id: 'photos', label: 'Photos' },
{ id: 'videos', label: 'Videos' }
];
filters.forEach(filter => {
const btn = document.createElement('button');
btn.className = 'filter-btn';
btn.textContent = filter.label;
if (filter.id === currentFilter) {
btn.classList.add('active');
}
btn.addEventListener('click', () => {
// Remove active class from all buttons
filterContainer.querySelectorAll('.filter-btn').forEach(b =>
b.classList.remove('active'));
// Add active class to clicked button
btn.classList.add('active');
currentFilter = filter.id;
filterContent();
});
filterContainer.appendChild(btn);
});
mediaFilterSection.appendChild(filterContainer);
popup.appendChild(mediaFilterSection);
// Place filter section
const placeFilterSection = document.createElement('div');
placeFilterSection.className = 'filter-section';
const placeFilterHeader = document.createElement('div');
placeFilterHeader.className = 'filter-header';
placeFilterHeader.textContent = 'Filter by Place';
placeFilterSection.appendChild(placeFilterHeader);
const placeSelect = document.createElement('select');
placeSelect.className = 'place-filter-select';
placeSelect.innerHTML = '<option value="all">All Places</option>';
placeSelect.addEventListener('change', (e) => {
currentPlaceFilter = e.target.value;
filterContent();
});
placeFilterSection.appendChild(placeSelect);
popup.appendChild(placeFilterSection);
// Most viewed info
const mostViewedInfo = document.createElement('div');
mostViewedInfo.className = 'most-viewed-info';
mostViewedInfo.textContent = 'Searching for views...';
popup.appendChild(mostViewedInfo);
// Scroll to most viewed button
const scrollToMostViewedBtn = document.createElement('button');
scrollToMostViewedBtn.className = 'scroll-to-most-viewed-btn';
scrollToMostViewedBtn.textContent = 'Go to Most Viewed';
scrollToMostViewedBtn.addEventListener('click', scrollToMostViewed);
popup.appendChild(scrollToMostViewedBtn);
// Auto load button
const autoLoadBtn = document.createElement('button');
autoLoadBtn.className = 'auto-load-btn';
autoLoadBtn.textContent = 'Enable Auto Load';
autoLoadBtn.addEventListener('click', toggleAutoLoad);
popup.appendChild(autoLoadBtn);
document.body.appendChild(popup);
makeDraggable(popup, '.drag-handle');
}
// Watch for container changes
function setupContainerWatcher() {
let retryCount = 0;
const maxRetries = 30;
function trySetup() {
console.log(`Looking for content container (attempt ${retryCount + 1}/${maxRetries})`);
const container = findContentContainer();
if (!container) {
retryCount++;
if (retryCount < maxRetries) {
setTimeout(trySetup, 1000);
} else {
console.warn('Could not find content container');
}
return;
}
console.log('Found content container:', container);
contentContainer = container;
scrollContainer = findScrollContainer();
if (scrollContainer) {
console.log('Found scroll container:', scrollContainer);
}
// Initial update
updateStats();
filterContent();
// Watch for changes
containerObserver = new MutationObserver(() => {
updateStats();
if (currentFilter !== 'all' || currentPlaceFilter !== 'all') {
filterContent();
}
});
containerObserver.observe(contentContainer, {
childList: true,
subtree: true
});
}
trySetup();
}
// Cleanup function
function cleanup() {
console.log('Cleaning up Photo/Video Filter');
if (popup && popup.parentNode) {
popup.parentNode.removeChild(popup);
popup = null;
}
if (containerObserver) {
containerObserver.disconnect();
containerObserver = null;
}
contentContainer = null;
scrollContainer = null;
filterContainer = null;
placeFilterContainer = null;
autoLoadEnabled = false;
currentFilter = 'all';
currentPlaceFilter = 'all';
mostViewedElement = null;
mostViewedCount = 0;
availablePlaces.clear();
isInit = false;
}
// Check if we should initialize
function checkInitState() {
const shouldInit = window.location.href.includes('/contrib/') &&
window.location.href.includes('/photos');
if (shouldInit && !isInit) {
console.log('Initializing Photo/Video Filter');
isInit = true;
createPopup();
setupContainerWatcher();
} else if (!shouldInit && isInit) {
cleanup();
}
}
// Initialize on DOM ready and watch for URL changes
document.addEventListener("DOMContentLoaded", function() {
const bodyList = document.querySelector('body');
const observer = new MutationObserver(function(mutations) {
if (oldHref !== document.location.href) {
oldHref = document.location.href;
checkInitState();
}
});
observer.observe(bodyList, {
childList: true,
subtree: true
});
checkInitState();
});
})();