High-performance heatmap for WME, optimized for massive datasets using Geohashing, Spatial Indexing, and Stream Processing.
// ==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(); } } })();