WME Editor Heatmap

High-performance heatmap for WME, optimized for massive datasets using Geohashing, Spatial Indexing, and Stream Processing.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

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

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

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.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

// ==UserScript==
// @name         WME Editor Heatmap
// @version      v2.0.0
// @description  High-performance heatmap for WME, optimized for massive datasets using Geohashing, Spatial Indexing, and Stream Processing.
// @author       MinhtanZ1
// @include      https://www.waze.com/editor*
// @include      https://www.waze.com/*/editor*
// @include      https://beta.waze.com/editor*
// @include      https://beta.waze.com/*/editor*
// @exclude      https://www.waze.com/user*
// @exclude      https://www.waze.com/*/user*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/build/heatmap.min.js
// @require      https://update.greasyfork.org/scripts/24851/WazeWrap.js
// @require      https://cdn.jsdelivr.net/npm/dexie@4/dist/dexie.min.js
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        unsafeWindow
// @namespace    https://greasyfork.org/users/1440408
// ==/UserScript==

/* global W, WazeWrap, h337, Dexie, OpenLayers, getWmeSdk */
(function() {
    'use strict';
    const SCRIPT_NAME = 'WME Editor Heatmap';
    const SCRIPT_VERSION = 'v2.0.0';
    const SCRIPT_ID = 'wme-editor-heatmap-optimized';

    const CHUNK_SIZE_DEGREES = 0.05;
    const MIN_DISTANCE_METERS = 10;
    const SAVE_DEBOUNCE_MS = 10000;
    const db = new Dexie('WMEHeatmapDB');
    db.version(1).stores({
        coordinates: '++id, [cx+cy], ts'
    });
    let heatmapInstance = null;
    let isHeatmapVisible = false;
    let wmeSDK = null;
    let lastSavedPointCache = null;

    function debounce(fn, delay) {
        let timeoutId;
        return (...args) => {
            clearTimeout(timeoutId);
            timeoutId = setTimeout(() => fn.apply(this, args), delay);
        };
    }
    function haversineDistance(p1, p2) {
        const R = 6371e3;
        const φ1 = p1.la * Math.PI / 180;
        const φ2 = p2.la * Math.PI / 180;
        const Δφ = (p2.la - p1.la) * Math.PI / 180;
        const Δλ = (p2.lo - p1.lo) * Math.PI / 180;
        const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
              Math.cos(φ1) * Math.cos(φ2) *
              Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
        const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
        return R * c;
    }
    function handleKeyPress(e) {
        if (e.key.toLowerCase() !== 'y') return;
        const target = e.target.tagName.toLowerCase();
        if (target === 'input' || target === 'textarea') return;
        if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return;
        e.preventDefault();
        toggleHeatmap();
    }

    function getCutoffDate(filter) {
        if (filter === 'all') return null;
        const now = new Date();
        const cutoff = new Date();
        switch (filter) {
            case 'week':
                cutoff.setDate(now.getDate() - 7);
                break;
            case 'month':
                cutoff.setMonth(now.getMonth() - 1);
                break;
            case 'year':
                cutoff.setFullYear(now.getFullYear() - 1);
                break;
            default:
                return null;
        }
        return cutoff;
    }

    if (typeof unsafeWindow.SDK_INITIALIZED !== 'undefined') {
        unsafeWindow.SDK_INITIALIZED.then(initSDK);
    }
    async function cleanupOldData() {
        const cutoff = new Date();
        cutoff.setFullYear(cutoff.getFullYear() - 1);
        try {
            const count = await db.coordinates.where('ts')
            .below(cutoff.toISOString())
            .delete();
            if (count > 0) {
                console.log(`${SCRIPT_NAME}: Cleaned up ${count} records older than 1 year.`);
            }
        } catch (err) {
            console.error(`${SCRIPT_NAME}: Error during data cleanup:`, err);
        }
    }
    function initSDK() {
        if (!unsafeWindow.getWmeSdk || !WazeWrap.Ready || !W.map || !W.userscripts) {
            setTimeout(initSDK, 500);
            return;
        }
        wmeSDK = unsafeWindow.getWmeSdk({ scriptId: SCRIPT_ID, scriptName: SCRIPT_NAME });
        initialize();
    }
    async function initialize() {
        console.log(`${SCRIPT_NAME} ${SCRIPT_VERSION} - SDK Initialized`);
        cleanupOldData();
        window.addEventListener('keydown', handleKeyPress);

        lastSavedPointCache = await db.coordinates.orderBy('id').last();
        WazeWrap.Interface.Tab('Heatmap', `
            <div id="heatmap-controls" style="padding: 10px;">
                <h4>Editor Heatmap</h4>
                <p>Hiển thị mật độ các vị trí bạn đã chỉnh sửa (lưu trữ local).</p>
                <label for="timeFilter">Lọc theo thời gian:</label>
                <select id="timeFilter" style="margin: 5px 0 10px; width: 100%;">
                    <option value="all">Tất cả</option>
                    <option value="week" selected>Tuần qua</option>
                    <option value="month">Tháng qua</option>
                    <option value="year">Năm qua</option>
                </select>
                <div id="heatmap-loader" style="display: none; margin-top: 10px; color: #888; text-align: center;"></div>
                <button id="toggleHeatmapBtn" class="wz-button wz-blue-button wz-button-primary" style="width: 100%;">Hiển thị Heatmap</button>
                <button id="exportDataBtn" class="wz-button" style="width: 100%; margin-top: 5px;">Xuất Dữ Liệu (JSON)</button>
                <label for="importFileInput" class="wz-button" style="width: 100%; margin-top: 5px; display: block; cursor: pointer;">Nhập & Tổng Hợp Dữ Liệu
                    <input type="file" id="importFileInput" accept=".json" style="display: none;">
                </label>
                <button id="refreshHeatmapBtn" class="wz-button" style="width: 100%; margin-top: 5px;">Làm mới Heatmap</button>
                <button id="clearDataBtn" class="wz-button wz-red-button" style="width: 100%; margin-top: 10px;">Xóa Toàn Bộ Dữ Liệu</button>
            </div>
        `, () => {
            document.getElementById('toggleHeatmapBtn')
                .addEventListener('click', toggleHeatmap);
            document.getElementById('exportDataBtn')
                .addEventListener('click', exportData);
            document.getElementById('importFileInput')
                .addEventListener('change', (e) => importAndMerge(e.target.files[0]));
            document.getElementById('refreshHeatmapBtn')
                .addEventListener('click', redrawHeatmap);
            document.getElementById('clearDataBtn')
                .addEventListener('click', clearAllData);
            document.getElementById('timeFilter')
                .addEventListener('change', () => {
                if (isHeatmapVisible) {
                    redrawHeatmap();
                }
            });
        });
        wmeSDK.Events.on({ eventName: "wme-map-move-end", eventHandler: saveCoordinates });
        const debouncedRedraw = debounce(redrawHeatmap, 500);
        wmeSDK.Events.on({ eventName: "wme-map-move", eventHandler: debouncedRedraw });
        wmeSDK.Events.on({ eventName: "wme-map-zoom-changed", eventHandler: debouncedRedraw });
    }
    /**
     * [TỐI ƯU HÓA] Hàm lưu tọa độ được thiết kế lại hoàn toàn.
     */
    async function saveCoordinates() {
        if (W.map.getZoom() < 15) return;
        const center = getWGS84Center();
        if (!center) return;
        const newPoint = { la: center.lat, lo: center.lon };
        if (lastSavedPointCache) {
            if (haversineDistance(newPoint, lastSavedPointCache) < 5) return;
            if (new Date() - new Date(lastSavedPointCache.ts) < SAVE_DEBOUNCE_MS) return;
        }
        try {
            const cx = Math.floor(newPoint.lo / CHUNK_SIZE_DEGREES);
            const cy = Math.floor(newPoint.la / CHUNK_SIZE_DEGREES);

            const chunkQueries = [];
            for (let dx = -1; dx <= 1; dx++) {
                for (let dy = -1; dy <= 1; dy++) {
                    chunkQueries.push([cx + dx, cy + dy]);
                }
            }
            const nearbyPoints = await db.coordinates.where('[cx+cy]').anyOf(chunkQueries).toArray();
            for (const existingPoint of nearbyPoints) {
                if (haversineDistance(newPoint, existingPoint) < MIN_DISTANCE_METERS) {
                    return;
                }
            }
            const pointToSave = { ...newPoint, ts: new Date().toISOString(), cx, cy };
            await db.coordinates.add(pointToSave);
            lastSavedPointCache = pointToSave;
        } catch (err) {
            console.error(`${SCRIPT_NAME}: Error saving to IndexedDB:`, err);
        }
    }
    async function redrawHeatmap() {
        if (!isHeatmapVisible) {
            if (heatmapInstance) heatmapInstance.setData({ max: 0, data: [] });
            return;
        }
        const ZOOM_THRESHOLD = 10;
        const currentZoom = W.map.getZoom();
        if (currentZoom >= ZOOM_THRESHOLD) {

            await renderViewportHeatmap();
        } else {

            await renderGlobalHeatmap();
        }
    }
    async function renderGlobalHeatmap() {
        showLoader('Đang lấy mẫu dữ liệu tổng quan...');
        try {
            const GLOBAL_SAMPLE_SIZE = 50000;
            const totalCount = await db.coordinates.count();
            if (totalCount === 0) {
                if (heatmapInstance) heatmapInstance.setData({ max: 0, data: [] });
                hideLoader();
                return;
            }
            let points = [];

            if (totalCount <= GLOBAL_SAMPLE_SIZE) {
                points = await db.coordinates.toArray();
            } else {

                const step = Math.floor(totalCount / GLOBAL_SAMPLE_SIZE);
                const sampledPoints = [];
                let i = 0;

                await db.coordinates.each(point => {
                    if (i % step === 0) {
                        sampledPoints.push(point);
                    }
                    i++;
                });
                points = sampledPoints;
            }
            showLoader(`Đang vẽ ${points.length} điểm mẫu...`);

            if (!heatmapInstance) {
                const mapViewport = document.querySelector('.olMapViewport');
                if (!mapViewport) throw new Error('Không tìm thấy container của bản đồ (olMapViewport).');
                heatmapInstance = h337.create({
                    container: mapViewport,
                    radius: 15, maxOpacity: 0.6, minOpacity: 0.1, blur: 0.8,
                    gradient: { 0.1: 'navy', 0.2: 'blue', 0.3: 'deepskyblue', 0.4: 'cyan', 0.5: 'limegreen', 0.6: 'lime', 0.7: 'greenyellow', 0.8: 'yellow', 0.9: 'orange', 0.95: 'orangered', 0.99: 'red' }
                });
                const heatmapCanvas = mapViewport.querySelector('canvas');
                if (heatmapCanvas) {
                    heatmapCanvas.style.pointerEvents = 'none';
                    heatmapCanvas.style.zIndex = 800;
                    heatmapCanvas.style.position = 'absolute';
                    heatmapCanvas.style.top = '0px';
                    heatmapCanvas.style.left = '0px';
                }
            }
            const heatmapData = [];
            for (const point of points) {
                const lonLat = new OpenLayers.LonLat(point.lo, point.la);
                try {
                    const pixel = W.map.getPixelFromLonLat(lonLat);
                    if (pixel) {
                        heatmapData.push({ x: Math.round(pixel.x), y: Math.round(pixel.y), value: 1 });
                    }
                } catch (e) { continue; }
            }
            const maxDensity = Math.max(10, Math.ceil(heatmapData.length / 100));
            heatmapInstance.setData({ max: maxDensity, data: heatmapData });
        } catch (err) {
            WazeWrap.Alerts.error(SCRIPT_NAME, `Lỗi khi vẽ heatmap tổng quan: ${err.message}`);
            console.error(err);
        } finally {
            hideLoader();
        }
    }
    /**
 * [HELPER] Render heatmap chi tiết trong viewport sử dụng Geohash.
 * Cực nhanh và hiệu quả.
 */
    async function renderViewportHeatmap() {
        showLoader('Đang truy vấn dữ liệu viewport...');
        try {
            if (!W || !W.map) {
                throw new Error('Đối tượng W.map không tồn tại. Script có thể đã khởi động quá sớm.');
            }
            const extent = W.map.getExtent();
            if (!extent) {
                console.warn(`${SCRIPT_NAME}: Không thể lấy được vùng bản đồ (extent). Đang thử lại...`);
                hideLoader();
                return;
            }
            const proj900913 = W.map.getProjectionObject();
            const proj4326 = new OpenLayers.Projection("EPSG:4326");
            const bounds = extent.transform(proj900913, proj4326);



            const minCX = Math.floor(bounds.left / CHUNK_SIZE_DEGREES) - 1;
            const maxCX = Math.floor(bounds.right / CHUNK_SIZE_DEGREES) + 1;
            const minCY = Math.floor(bounds.bottom / CHUNK_SIZE_DEGREES) - 1;
            const maxCY = Math.floor(bounds.top / CHUNK_SIZE_DEGREES) + 1;

            const queries = [];
            for (let cx = minCX; cx <= maxCX; cx++) {
                queries.push(
                    db.coordinates.where('[cx+cy]').between([cx, minCY], [cx, maxCY]).toArray()
                );
            }
            const chunkResults = await Promise.all(queries);
            let points = chunkResults.flat();
            const filterValue = document.getElementById('timeFilter').value;
            const cutoffDate = getCutoffDate(filterValue);
            if (cutoffDate) {
                points = points.filter(p => new Date(p.ts) >= cutoffDate);
            }
            showLoader(`Đang vẽ ${points.length} điểm...`);
            if (points.length === 0) {
                if (heatmapInstance) heatmapInstance.setData({ max: 0, data: [] });
                hideLoader();
                return;
            }
            if (!heatmapInstance) {

                const mapViewport = document.querySelector('.olMapViewport');
                if (!mapViewport) throw new Error('Không tìm thấy container của bản đồ (.olMapViewport). Giao diện WME có thể đã thay đổi.');
                heatmapInstance = h337.create({
                    container: mapViewport,
                    radius: 25, maxOpacity: 0.8, minOpacity: 0.1, blur: 0.6,
                    gradient: { 0.2: 'blue', 0.4: 'cyan', 0.6: 'lime', 0.8: 'yellow', 1.0: 'red' }
                });
                const heatmapCanvas = mapViewport.querySelector('canvas');
                if (heatmapCanvas) {
                    heatmapCanvas.style.pointerEvents = 'none';
                    heatmapCanvas.style.zIndex = 400;
                    heatmapCanvas.style.position = 'absolute';
                    heatmapCanvas.style.top = '0px';
                    heatmapCanvas.style.left = '0px';
                }
            }
            const heatmapData = [];
            for (const point of points) {
                const lonLat = new OpenLayers.LonLat(point.lo, point.la);
                try {
                    const pixel = W.map.getPixelFromLonLat(lonLat);
                    if (pixel) {
                        heatmapData.push({ x: Math.round(pixel.x), y: Math.round(pixel.y), value: 1 });
                    }
                } catch (e) {
                    continue;
                }
            }
            const maxDensity = Math.max(5, Math.ceil(heatmapData.length / 100));
            heatmapInstance.setData({ max: maxDensity, data: heatmapData });
        } catch (err) {
            WazeWrap.Alerts.error(SCRIPT_NAME, `Lỗi khi vẽ heatmap chi tiết: ${err.message}`);
            console.error(`${SCRIPT_NAME}: Lỗi chi tiết trong renderViewportHeatmap:`, err);
        } finally {
            hideLoader();
        }
    }
    /**
     * [TỐI ƯU HÓA] Hàm nhập file sử dụng stream để xử lý file cực lớn.
     */
    function importAndMerge(file) {
        if (!file) return;
        showLoader('Đang nhập dữ liệu (stream)...');
        let pointsBuffer = [];
        let countAdded = 0;
        const BATCH_SIZE = 50000;
        const processBatch = async () => {
            if (pointsBuffer.length === 0) return;
            try {


                await db.coordinates.bulkPut(pointsBuffer);
                countAdded += pointsBuffer.length;
                pointsBuffer = [];
                showLoader(`Đã nhập ${countAdded} điểm...`);
            } catch (err) {
                console.error("Lỗi khi ghi lô dữ liệu:", err);
            }
        };
        const reader = new FileReader();
        reader.onload = async (e) => {
            try {
                const text = e.target.result;

                const imported = JSON.parse(text);
                if (!imported.data || !Array.isArray(imported.data)) throw new Error('Định dạng file không hợp lệ.');
                for (const pointArray of imported.data) {
                    const la = pointArray[0];
                    const lo = pointArray[1];
                    const point = {
                        la: la,
                        lo: lo,
                        ts: pointArray[2],
                        cx: Math.floor(lo / CHUNK_SIZE_DEGREES),
                        cy: Math.floor(la / CHUNK_SIZE_DEGREES)
                    };
                    pointsBuffer.push(point);
                    if (pointsBuffer.length >= BATCH_SIZE) {
                        await processBatch();
                    }
                }

                await processBatch();
                WazeWrap.Alerts.success(SCRIPT_NAME, `Nhập và tổng hợp thành công ${countAdded} điểm.`);
                redrawHeatmap();
            } catch (err) {
                WazeWrap.Alerts.error(SCRIPT_NAME, `Lỗi khi nhập: ${err.message}`);
                console.error(err);
            } finally {
                hideLoader();
            }
        };
        reader.readAsText(file);
    }
    /**
     * [TỐI ƯU HÓA] Hàm xuất dữ liệu ra định dạng nén.
     */
    async function exportData() {
        showLoader('Đang xuất dữ liệu...');
        try {
            const dataArray = [];

            await db.coordinates.each(point => {
                dataArray.push([point.la, point.lo, point.ts]);
            });
            const exportObject = {
                schema: ["lat", "lon", "timestamp"],
                data: dataArray
            };
            const blob = new Blob([JSON.stringify(exportObject)], { type: 'application/json' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = 'wme_heatmap_data_optimized.json';
            a.click();
            URL.revokeObjectURL(url);
        } catch (err) {
            WazeWrap.Alerts.error(SCRIPT_NAME, `Lỗi khi xuất dữ liệu: ${err.message}`);
        } finally {
            hideLoader();
        }
    }
    function getWGS84Center() {
        const mapCenter = W.map.getCenter();
        try {
            const proj900913 = W.map.getProjectionObject() || new OpenLayers.Projection("EPSG:900913");
            const proj4326 = new OpenLayers.Projection("EPSG:4326");
            const lonLat = new OpenLayers.LonLat(mapCenter.lon, mapCenter.lat);
            const transformedLonLat = lonLat.transform(proj900913, proj4326);
            return {
                lat: transformedLonLat.lat,
                lon: transformedLonLat.lon
            };
        } catch (e) {
            console.error(`${SCRIPT_NAME}: Error transforming coordinates for saving:`, e);
            return null;
        }
    }
    function toggleHeatmap() {
        isHeatmapVisible = !isHeatmapVisible;
        const btn = document.getElementById('toggleHeatmapBtn');
        btn.textContent = isHeatmapVisible ? 'Ẩn Heatmap' : 'Hiển thị Heatmap';
        if (isHeatmapVisible) {
            redrawHeatmap();
        } else {
            if (heatmapInstance) {
                heatmapInstance.setData({
                    max: 0,
                    data: []
                });
            }
        }
    }
    function showLoader(message) {
        const loader = document.getElementById('heatmap-loader');
        if (loader) {
            loader.textContent = message;
            loader.style.display = 'block';
        }
    }
    function hideLoader() {
        const loader = document.getElementById('heatmap-loader');
        if (loader) {
            loader.style.display = 'none';
        }
    }
    async function clearAllData() {

        const confirmation = window.confirm("Bạn có chắc chắn muốn xóa TOÀN BỘ dữ liệu tọa độ đã lưu không?\n\nHành động này không thể hoàn tác.");

        if (!confirmation) {
            return;
        }
        showLoader('Đang xóa dữ liệu...');
        try {
            await db.coordinates.clear();
            console.log(`${SCRIPT_NAME}: All coordinate data has been cleared.`);
            WazeWrap.Alerts.success(SCRIPT_NAME, 'Đã xóa toàn bộ dữ liệu thành công.');

            if (isHeatmapVisible) {
                redrawHeatmap();
            }
        } catch (err) {
            console.error(`${SCRIPT_NAME}: Error clearing data:`, err);
            WazeWrap.Alerts.error(SCRIPT_NAME, `Lỗi khi xóa dữ liệu: ${err.message}`);
        } finally {
            hideLoader();
        }
    }



})();