Google Maps Enhanced Photos

Filter photos and videos in Google Maps contributions

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Google Maps Enhanced Photos
// @namespace    https://github.com/gncnpk/google-maps-enhanced-photos
// @version      0.0.3
// @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.replace(/"|\,|\./g, ''));

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