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