Stable WME Suite: Tools + Route Tester + Advanced Selection (Resizable & Optimized)
// ==UserScript==
// @name Abdullah Abbas WME Tools
// @namespace https://greasyfork.org/users/abdullah-abbas
// @description Stable WME Suite: Tools + Route Tester + Advanced Selection (Resizable & Optimized)
// @include https://www.waze.com/*/editor*
// @include https://www.waze.com/editor*
// @include https://beta.waze.com/*
// @exclude https://www.waze.com/user/editor*
// @version 2026.01.12.40
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @connect waze.com
// @connect routing-livemap-row.waze.com
// @connect routing-livemap-na.waze.com
// @connect routing-livemap-il.waze.com
// @author Abdullah Abbas
// @require https://greasyfork.org/scripts/24851-wazewrap/code/WazeWrap.js
// ==/UserScript==
/*
* Abdullah Abbas WME Tools (V2026.01.12.40)
* Updates:
* - Fix (Map Validator): Excluded (Railroad, Walking Trail, Boardwalk, Stairway) from "Disconnected" check.
* - Fix (Map Validator): Excluded (Railroad, Walking Trail, Boardwalk, Stairway) from "No Node" cross check.
* - Fix: Optimized ResizeObserver to prevent UI freezing (Added Debounce).
* - Code: Un-minified Roundabout Editor for better stability and performance.
* - General: Smoother window dragging and resizing.
*/
(function() {
'use strict';
// ===========================================================================
// GLOBAL SETUP
// ===========================================================================
var W, OpenLayers, WazeWrap;
if (typeof unsafeWindow !== 'undefined') {
W = unsafeWindow.W;
OpenLayers = unsafeWindow.OpenLayers;
WazeWrap = unsafeWindow.WazeWrap;
} else {
W = window.W;
OpenLayers = window.OpenLayers;
WazeWrap = window.WazeWrap;
}
const SCRIPT_NAME = "Abdullah Abbas WME Tools";
const SCRIPT_VERSION = "2026.01.12.40";
const DEFAULT_W = "340px";
const DEFAULT_H = "480px";
// ===========================================================================
// LOCALIZATION
// ===========================================================================
const STRINGS = {
'ar-IQ': {
main_title: 'أدوات عبدالله عباس',
btn_city: 'مستكشف المدن', btn_places: 'مستكشف الأماكن',
btn_editors: 'مستكشف المحررين', btn_ra: 'تعديل الدوار', btn_lock: 'مؤشر القفل',
btn_qa: 'مدقق الخريطة', btn_adv: 'تحديد متقدم', btn_speed: 'مؤشر السرعة', btn_route: 'اختبار المسار 🚗',
win_city: 'مستكشف المدن', win_places: 'مستكشف الأماكن',
win_editors: 'مستكشف المحررين', win_ra: 'تعديل الدوار', win_lock: 'مؤشر القفل',
win_speed: 'مؤشر السرعة', win_route: 'اختبار المسار', win_adv: 'تحديد متقدم',
common_scan: 'بحث', common_clear: 'مسح', common_close: 'إغلاق', common_ready: 'جاهز للتعديل',
ph_city: 'اسم المدينة...', ph_place: 'اسم المكان...', ph_user: 'اسم المحرر...',
lbl_days: 'عدد الأيام (0 = الكل)', lbl_enable: 'تفعيل',
ra_in: 'تصغير (-)', ra_out: 'تكبير (+)', ra_err: 'حدد دوار لتفعيله', unit_m: 'م',
city_no_name: 'بدون مدينة', no_results: 'لا توجد نتائج',
qa_title: 'مدقق الخريطة',
qa_btn_scan: '🔍 فحص المنطقة', qa_btn_clear: 'مسح النتائج',
qa_btn_gmaps: 'فتح في خرائط جوجل 🌏',
qa_lbl_short: 'قطاع قصير', qa_lbl_angle: 'زوايا حادة', qa_lbl_cross: 'بلا عقدة',
qa_lbl_lock: 'أقفال', qa_lbl_ghost: 'مدن فارغة', qa_lbl_speed: 'سرعة',
qa_lbl_discon: 'غير متصل', qa_lbl_jagged: 'تشوهات',
qa_opt_exclude_rab: 'تجاهل الدوارات',
qa_lbl_discon_mode: 'نوع عدم الاتصال:',
qa_opt_discon_1w: 'جهة واحدة', qa_opt_discon_2w: 'جهتين (عائم)',
qa_lbl_limit_dist: 'حد المسافة', qa_lbl_limit_angle: 'حد الزاوية',
qa_unit_m: 'متر', qa_unit_i: 'ميل',
qa_msg_scanning: 'جاري الفحص...', qa_msg_no_segments: '⚠️ المنطقة واسعة! يرجى التقريب.',
qa_msg_clean: '✅ سليم (لم يتم العثور على أخطاء)', qa_msg_found: 'تم كشف', qa_msg_ready: 'جاهز',
adv_lbl_crit: 'معيار التحديد:', adv_lbl_val: 'القيمة:',
adv_opt_nocity: 'بدون مدينة (Ghost)', adv_opt_nospeed: 'بدون سرعة (Driveable)',
adv_opt_lock: 'مستوى القفل', adv_opt_type: 'نوع الطريق',
adv_btn_sel: 'تحديد العناصر', adv_btn_desel: 'إلغاء التحديد',
adv_msg_found: 'تم تحديد', adv_msg_none: 'لم يتم العثور على عناصر مطابقة',
// Road Types AR
adv_type_st: 'شارع (St)', adv_type_ps: 'شارع رئيسي (PS)', adv_type_mh: 'سريع ثانوي (mH)',
adv_type_maj: 'سريع رئيسي (MH)', adv_type_fw: 'طريق حرة (Fw)', adv_type_rmp: 'منحدر (Rmp)',
adv_type_plr: 'موقف (PLR)', adv_type_pw: 'طريق ضيق (Pw)', adv_type_pr: 'طريق خاص (PR)',
adv_type_or: 'طريق ترابي (OR)',
rt_btn_a: '📍 تعيين البداية (A)', rt_btn_b: '🏁 تعيين النهاية (B)',
rt_btn_calc: 'رسم المسار', rt_btn_clear: 'مسح',
rt_msg_ready: 'جاهز للاستخدام', rt_msg_wait: '⏳ جاري الحساب...',
rt_msg_err: '❌ خطأ', rt_msg_no_route: '❌ لا يوجد مسار',
rt_msg_select: '⚠️ حدد عنصراً في الخريطة أولاً!',
rt_lbl_a: 'A: غير محدد', rt_lbl_b: 'B: غير محدد',
rt_btn_km: 'المسافة (KM)', rt_btn_spd: 'السرعة', rt_btn_time: 'الوقت'
},
'ckb-IQ': {
main_title: 'Abdullah Abbas WME Tools',
btn_city: 'Pşkinerî Şar', btn_places: 'Pşkinerî Şwênekan',
btn_editors: 'Pşkinerî Destkarikeran', btn_ra: 'Destkarî Flke', btn_lock: 'Nîşanderî Qufł',
btn_qa: 'Pşkinerî Nexşe', btn_adv: 'Diyarîkrdinî Pêşkewtû', btn_speed: 'Nîşanderî Xêrayî', btn_route: 'Taqîkrdinewey Rêga 🚗',
win_city: 'Pşkinerî Şar', win_places: 'Pşkinerî Şwênekan',
win_editors: 'Pşkinerî Destkarikeran', win_ra: 'Destkarî Flke', win_lock: 'Nîşanderî Qufł',
win_speed: 'Nîşanderî Xêrayî', win_route: 'Taqîkrdinewey Rêga', win_adv: 'Diyarîkrdinî Pêşkewtû',
common_scan: 'Geřan', common_clear: 'Pakkrdinewe', common_close: 'Daxistin', common_ready: 'Amadeye',
ph_city: 'Nawî Şar...', ph_place: 'Nawî Şwên...', ph_user: 'Nawî Bekarhêner...',
lbl_days: 'Roj (0 = Hemû)', lbl_enable: 'Çalak',
ra_in: 'Bçûk (-)', ra_out: 'Gewre (+)', ra_err: 'Flkeyek diyarî bike', unit_m: 'm',
city_no_name: 'Bê Şar', no_results: 'Hîç nedozrayewe',
qa_title: 'Pşkinerî Nexşe',
qa_btn_scan: '🔍 Pşkinîn', qa_btn_clear: 'Pakkrdinewe', qa_btn_gmaps: 'Google Maps 🌏',
qa_lbl_short: 'Kort', qa_lbl_angle: 'Goşe', qa_lbl_cross: 'Yektrbřîn',
qa_lbl_lock: 'Qufł', qa_lbl_ghost: 'Bê Şar', qa_lbl_speed: 'Xêrayî',
qa_lbl_discon: 'Pçřaw', qa_lbl_jagged: 'Şêwaw',
qa_opt_exclude_rab: 'Bê Flke',
qa_lbl_discon_mode: 'Pçřaw:',
qa_opt_discon_1w: 'Yek la', qa_opt_discon_2w: 'Dû la',
qa_lbl_limit_dist: 'Snûrî Dûrî', qa_lbl_limit_angle: 'Snûrî Goşe',
qa_unit_m: 'Metr', qa_unit_i: 'Mîl',
qa_msg_scanning: 'Pşkinîn...', qa_msg_no_segments: '⚠️ Zoom In bike.',
qa_msg_clean: '✅ Pak e', qa_msg_found: 'Dozrayewe', qa_msg_ready: 'Amade',
adv_lbl_crit: 'Pêwer:', adv_lbl_val: 'Nirx:',
adv_opt_nocity: 'Bê Şar', adv_opt_nospeed: 'Bê Xêrayî',
adv_opt_lock: 'Asti Qufł', adv_opt_type: 'Corî Rêga',
adv_btn_sel: 'Diyarîkrdin', adv_btn_desel: 'Ladanî Diyarîkrdin',
adv_msg_found: 'Diyarîkra', adv_msg_none: 'Hîç nedozrayewe',
// Road Types CKB
adv_type_st: 'Şeqam (St)', adv_type_ps: 'Şeqamî Sereke (PS)', adv_type_mh: 'Xêrayî Lawekî (mH)',
adv_type_maj: 'Xêrayî Sereke (MH)', adv_type_fw: 'Rêgay Xêra (Fw)', adv_type_rmp: 'Ramp (Rmp)',
adv_type_plr: 'Parking (PLR)', adv_type_pw: 'Kolłan (Pw)', adv_type_pr: 'Taybet (PR)',
adv_type_or: 'Rêgay Xoľ (OR)',
rt_btn_a: '📍 Destpêk (A)', rt_btn_b: '🏁 Kotayî (B)',
rt_btn_calc: 'Kêşanî Rêga', rt_btn_clear: 'Pakkrdinewe',
rt_msg_ready: 'Amadeye', rt_msg_wait: '⏳ Dejmrêt...',
rt_msg_err: '❌ Hele', rt_msg_no_route: '❌ Rêga nîye',
rt_msg_select: '⚠️ Şwênêk diyarî bike!',
rt_lbl_a: 'A: ...', rt_lbl_b: 'B: ...',
rt_btn_km: 'Dûrî (KM)', rt_btn_spd: 'Xêrayî', rt_btn_time: 'Kat'
},
'en-US': {
main_title: 'Abdullah Abbas WME Tools',
btn_city: 'City Explorer', btn_places: 'Places Explorer',
btn_editors: 'Editor Explorer', btn_ra: 'Roundabout Editor', btn_lock: 'Lock Indicator',
btn_qa: 'Map Validator', btn_adv: 'Advanced Selection', btn_speed: 'Speed Indicator', btn_route: 'Route Tester 🚗',
win_city: 'City Explorer', win_places: 'Places Explorer',
win_editors: 'Editor Explorer', win_ra: 'Roundabout Editor', win_lock: 'Lock Indicator',
win_speed: 'Speed Indicator', win_route: 'Route Tester', win_adv: 'Advanced Selection',
common_scan: 'Scan', common_clear: 'Clear', common_close: 'Close', common_ready: 'Ready',
ph_city: 'City Name...', ph_place: 'Place Name...', ph_user: 'Username...',
lbl_days: 'Days (0 = All)', lbl_enable: 'Enable',
ra_in: 'Shrink', ra_out: 'Expand', ra_err: 'Select RA', unit_m: 'm',
city_no_name: 'No City', no_results: 'No results',
qa_title: 'Map Validator',
qa_btn_scan: '🔍 Scan Area', qa_btn_clear: 'Clear', qa_btn_gmaps: 'Open Google Maps 🌏',
qa_lbl_short: 'Short Seg', qa_lbl_angle: 'Sharp Angle', qa_lbl_cross: 'No Node',
qa_lbl_lock: 'Locks', qa_lbl_ghost: 'Ghost City', qa_lbl_speed: 'Speed',
qa_lbl_discon: 'Disconnected', qa_lbl_jagged: 'Jagged',
qa_opt_exclude_rab: 'Exclude RA',
qa_lbl_discon_mode: 'Discon Type:',
qa_opt_discon_1w: '1-Side', qa_opt_discon_2w: '2-Sides',
qa_lbl_limit_dist: 'Dist Limit', qa_lbl_limit_angle: 'Angle Limit',
qa_unit_m: 'Meter', qa_unit_i: 'Mile',
qa_msg_scanning: 'Scanning...', qa_msg_no_segments: '⚠️ Zoom In please.',
qa_msg_clean: '✅ Clean', qa_msg_found: 'Found', qa_msg_ready: 'Ready',
adv_lbl_crit: 'Criteria:', adv_lbl_val: 'Value:',
adv_opt_nocity: 'No City', adv_opt_nospeed: 'No Speed',
adv_opt_lock: 'Lock Level', adv_opt_type: 'Road Type',
adv_btn_sel: 'Select', adv_btn_desel: 'Deselect',
adv_msg_found: 'Selected', adv_msg_none: 'No matches found',
// Road Types EN
adv_type_st: 'Street (St)', adv_type_ps: 'Primary Street (PS)', adv_type_mh: 'Minor Highway (mH)',
adv_type_maj: 'Major Highway (MH)', adv_type_fw: 'Freeway (Fw)', adv_type_rmp: 'Ramp (Rmp)',
adv_type_plr: 'Parking Lot (PLR)', adv_type_pw: 'Private Way (Pw)', adv_type_pr: 'Private (PR)',
adv_type_or: 'Off-Road (OR)',
rt_btn_a: '📍 Set Start (A)', rt_btn_b: '🏁 Set End (B)',
rt_btn_calc: 'Calculate', rt_btn_clear: 'Clear',
rt_msg_ready: 'Ready', rt_msg_wait: '⏳ Calculating...',
rt_msg_err: '❌ Error', rt_msg_no_route: '❌ No Route',
rt_msg_select: '⚠️ Select an element first!',
rt_lbl_a: 'A: ...', rt_lbl_b: 'B: ...',
rt_btn_km: 'Dist (KM)', rt_btn_spd: 'Speed', rt_btn_time: 'Time'
}
};
let currentLang = 'ar-IQ';
const _t = (key) => (STRINGS[currentLang] || STRINGS['en-US'])[key] || key;
const _dir = () => (currentLang === 'en-US' ? 'ltr' : 'rtl');
// ===========================================================================
// CORE UTILITIES
// ===========================================================================
function getAllObjects(modelName) {
if(!W || !W.model || !W.model[modelName]) return [];
var repo = W.model[modelName];
if (typeof repo.getObjectArray === 'function') return repo.getObjectArray();
if (repo.objects) return Object.values(repo.objects);
return [];
}
function fastClone(obj) { return JSON.parse(JSON.stringify(obj)); }
class UIBuilder {
static getSavedState(id) {
try { return JSON.parse(localStorage.getItem(`AA_Win_${id}`)) || null; } catch (e) { return null; }
}
static saveState(id, element) {
const state = {
top: element.style.top,
left: element.style.left,
width: element.style.width,
height: element.style.height,
display: element.style.display
};
localStorage.setItem(`AA_Win_${id}`, JSON.stringify(state));
}
static createFloatingWindow(id, titleKey, colorClass, contentHtml, fixedSize = null) {
let win = document.getElementById(id);
if (win) {
win.style.display = (win.style.display === 'none' ? 'block' : 'none');
if(win.style.display === 'block') UIBuilder.saveState(id, win);
return win;
}
const state = UIBuilder.getSavedState(id) || {
top: '100px',
left: '100px',
width: fixedSize ? fixedSize.w : DEFAULT_W,
height: fixedSize ? fixedSize.h : DEFAULT_H
};
win = document.createElement('div');
win.id = id;
win.className = `aa-window ${_dir()}`;
win.style.top = state.top;
win.style.left = state.left;
win.style.width = fixedSize ? fixedSize.w : state.width;
win.style.height = fixedSize ? fixedSize.h : state.height;
win.style.display = 'block';
const header = document.createElement('div');
header.className = `aa-header ${colorClass}`;
header.innerHTML = `<span>${_t(titleKey)}</span><span class="aa-close">✖</span>`;
const content = document.createElement('div');
content.className = 'aa-content';
content.innerHTML = contentHtml;
win.appendChild(header);
win.appendChild(content);
document.body.appendChild(win);
win.querySelector('.aa-close').onclick = () => { win.style.display = 'none'; UIBuilder.saveState(id, win); };
let isDragging = false, startX, startY, initialLeft, initialTop;
header.onmousedown = (e) => {
if(e.target.className === 'aa-close') return;
isDragging = true;
startX = e.clientX;
startY = e.clientY;
initialLeft = win.offsetLeft;
initialTop = win.offsetTop;
document.onmousemove = (e) => {
if (!isDragging) return;
e.preventDefault();
win.style.left = (initialLeft + e.clientX - startX) + 'px';
win.style.top = (initialTop + e.clientY - startY) + 'px';
};
document.onmouseup = () => { isDragging = false; document.onmousemove = null; document.onmouseup = null; UIBuilder.saveState(id, win); };
};
// OPTIMIZED RESIZE OBSERVER (With Debounce)
if(!fixedSize) {
let resizeTimeout;
new ResizeObserver(() => {
if(win.style.display === 'none') return;
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
UIBuilder.saveState(id, win);
}, 500); // Wait 500ms before saving to avoid freeze
}).observe(win);
} else {
win.style.resize = 'none';
}
return win;
}
}
// ===========================================================================
// MODULE: ROUTE TESTER
// ===========================================================================
const RouteTester = {
layer: null,
startPoint: null,
endPoint: null,
lastRouteData: null,
LAYER_NAME: 'AA_Route_Layer_Final_V15',
settings: { showMarkers: true, showSpeed: true, showTime: true },
init: () => {
const html = `
<div id="aa-rt-body">
<button id="rt_btn_a" class="aa-btn rt-btn-a">${_t('rt_btn_a')}</button>
<div id="rt_lbl_a" class="rt-lbl">${_t('rt_lbl_a')}</div>
<button id="rt_btn_b" class="aa-btn rt-btn-b">${_t('rt_btn_b')}</button>
<div id="rt_lbl_b" class="rt-lbl">${_t('rt_lbl_b')}</div>
<div style="margin: 15px 0; display:flex; gap:5px; justify-content: space-between;">
<button id="rt_toggle_km" class="aa-btn aa-setting-btn aa-bg-gold active">
<span class="aa-chk-box">✔</span> ${_t('rt_btn_km')}
</button>
<button id="rt_toggle_spd" class="aa-btn aa-setting-btn aa-bg-red active">
<span class="aa-chk-box">✔</span> ${_t('rt_btn_spd')}
</button>
<button id="rt_toggle_time" class="aa-btn aa-setting-btn aa-bg-blue active">
<span class="aa-chk-box">✔</span> ${_t('rt_btn_time')}
</button>
</div>
<div id="rt_msg">${_t('rt_msg_ready')}</div>
<div style="display:flex; gap:10px; margin-top:15px;">
<button id="rt_btn_clear" class="aa-btn rt-btn-clr" style="flex:1;">${_t('rt_btn_clear')}</button>
<button id="rt_btn_calc" class="aa-btn rt-btn-go" style="flex:2;">${_t('rt_btn_calc')}</button>
</div>
</div>
`;
const win = UIBuilder.createFloatingWindow('AA_RouteWin', 'win_route', 'aa-bg-darkblue', html, {w: '340px', h: '420px'});
document.getElementById('rt_btn_a').onclick = (e) => RouteTester.handleSetPoint(e, 'A');
document.getElementById('rt_btn_b').onclick = (e) => RouteTester.handleSetPoint(e, 'B');
document.getElementById('rt_btn_calc').onclick = RouteTester.handleCalc;
document.getElementById('rt_btn_clear').onclick = RouteTester.handleClear;
const setupToggle = (id, settingKey) => {
const btn = document.getElementById(id);
const chk = btn.querySelector('.aa-chk-box');
btn.onclick = () => {
RouteTester.settings[settingKey] = !RouteTester.settings[settingKey];
if (RouteTester.settings[settingKey]) {
btn.classList.add('active');
chk.innerHTML = '✔';
} else {
btn.classList.remove('active');
chk.innerHTML = '';
}
if (RouteTester.lastRouteData) RouteTester.drawRoute(RouteTester.lastRouteData);
};
};
setupToggle('rt_toggle_km', 'showMarkers');
setupToggle('rt_toggle_spd', 'showSpeed');
setupToggle('rt_toggle_time', 'showTime');
RouteTester.initLayer();
},
initLayer: () => {
try {
const oldLayers = W.map.getLayersBy('uniqueName', RouteTester.LAYER_NAME);
oldLayers.forEach(l => W.map.removeLayer(l));
} catch(e) {}
RouteTester.layer = new OpenLayers.Layer.Vector(RouteTester.LAYER_NAME, {
displayInLayerSwitcher: false,
uniqueName: RouteTester.LAYER_NAME,
styleMap: new OpenLayers.StyleMap({
default: {
strokeColor: "#00b0ff", strokeWidth: 6, strokeOpacity: 0.7,
pointRadius: 6, fillColor: "#00b0ff", fillOpacity: 1,
fontFamily: "Arial", fontWeight: "bold", labelOutlineColor: "white", labelOutlineWidth: 4
}
})
});
W.map.addLayer(RouteTester.layer);
const layerDiv = document.getElementById(RouteTester.layer.id);
if (layerDiv) {
layerDiv.style.pointerEvents = "none";
layerDiv.style.zIndex = "900";
}
},
getSelectionCenter: () => {
const selObjects = W.selectionManager.getSelectedDataModelObjects();
if (selObjects.length > 0 && selObjects[0].geometry) {
const geom = selObjects[0].geometry;
const center = geom.getCentroid();
return { x: center.x, y: center.y };
}
return null;
},
handleSetPoint: (e, type) => {
e.preventDefault(); e.stopPropagation();
const coords = RouteTester.getSelectionCenter();
if (!coords) {
RouteTester.msg(_t('rt_msg_select'), 'error');
return;
}
const proj = new OpenLayers.LonLat(coords.x, coords.y).transform(
W.map.getProjectionObject(), new OpenLayers.Projection("EPSG:4326")
);
if (type === 'A') {
RouteTester.startPoint = { api: proj, map: coords };
document.getElementById('rt_lbl_a').innerText = `A: ${proj.lat.toFixed(5)}, ${proj.lon.toFixed(5)}`;
RouteTester.drawMarker(coords, '#1565c0', 'A');
} else {
RouteTester.endPoint = { api: proj, map: coords };
document.getElementById('rt_lbl_b').innerText = `B: ${proj.lat.toFixed(5)}, ${proj.lon.toFixed(5)}`;
RouteTester.drawMarker(coords, '#c2185b', 'B');
}
RouteTester.msg(_t('rt_msg_ready'), 'neutral');
},
drawMarker: (coords, color, label) => {
if(RouteTester.layer.features) {
const existing = RouteTester.layer.features.filter(f => f.attributes.type === label);
RouteTester.layer.removeFeatures(existing);
}
const pt = new OpenLayers.Geometry.Point(coords.x, coords.y);
const feat = new OpenLayers.Feature.Vector(pt, { type: label }, {
pointRadius: 8, fillColor: color, strokeColor: '#fff', strokeWidth: 2
});
RouteTester.layer.addFeatures([feat]);
},
handleCalc: (e) => {
if(e) { e.preventDefault(); e.stopPropagation(); }
if (!RouteTester.startPoint || !RouteTester.endPoint) {
RouteTester.msg(_t('rt_msg_select'), 'error');
return;
}
RouteTester.msg(_t('rt_msg_wait'), 'warn');
let region = 'row';
if (W.model.topCountry && W.model.topCountry.env) region = W.model.topCountry.env.toLowerCase();
let url = 'https://routing-livemap-row.waze.com/RoutingManager/routingRequest';
if (region === 'usa' || region === 'na') url = 'https://routing-livemap-am.waze.com/RoutingManager/routingRequest';
if (region === 'il') url = 'https://routing-livemap-il.waze.com/RoutingManager/routingRequest';
const data = [
`from=x%3A${RouteTester.startPoint.api.lon}%20y%3A${RouteTester.startPoint.api.lat}`,
`to=x%3A${RouteTester.endPoint.api.lon}%20y%3A${RouteTester.endPoint.api.lat}`,
`at=0`, `returnJSON=true`, `returnGeometries=true`, `returnInstructions=true`,
`timeout=60000`, `nPaths=1`, `clientVersion=4.0.0`, `options=AVOID_TRAILS%3At%2CALLOW_UTURNS%3At`
].join('&');
GM_xmlhttpRequest({
method: "GET", url: url + "?" + data, headers: { "Content-Type": "application/json" },
onload: function(response) {
if (response.status !== 200) { RouteTester.msg(`HTTP ${response.status}`, 'error'); return; }
let json = null; try { json = JSON.parse(response.responseText); } catch(e) {}
if (!json || (!json.coords && !json.alternatives)) { RouteTester.msg(_t('rt_msg_no_route'), 'error'); return; }
const route = json.coords ? json : (json.alternatives ? json.alternatives[0].response : null);
if (route) {
RouteTester.lastRouteData = route;
RouteTester.drawRoute(route);
} else {
RouteTester.msg(_t('rt_msg_no_route'), 'error');
}
},
onerror: function() { RouteTester.msg(_t('rt_msg_err'), 'error'); }
});
},
drawRoute: (routeData) => {
const markers = RouteTester.layer.features.filter(f => f.geometry.CLASS_NAME.includes('Point') && f.attributes.type && (f.attributes.type === 'A' || f.attributes.type === 'B'));
RouteTester.layer.removeAllFeatures();
RouteTester.layer.addFeatures(markers);
let points = [];
if (routeData.coords) {
points = routeData.coords.map(c => new OpenLayers.Geometry.Point(c.x, c.y).transform(new OpenLayers.Projection("EPSG:4326"), W.map.getProjectionObject()));
} else if (routeData.results) {
routeData.results.forEach(res => {
points.push(new OpenLayers.Geometry.Point(res.path.x, res.path.y).transform(new OpenLayers.Projection("EPSG:4326"), W.map.getProjectionObject()));
});
}
if(points.length > 0) {
const line = new OpenLayers.Geometry.LineString(points);
const feat = new OpenLayers.Feature.Vector(line, {}, { strokeColor: "#9c27b0", strokeWidth: 8, strokeOpacity: 0.7 });
RouteTester.layer.addFeatures([feat]);
let totalMeters = 0;
let projection = W.map.getProjectionObject();
totalMeters = line.getGeodesicLength(projection);
let totalSeconds = 0;
if(routeData.summary) {
totalSeconds = routeData.summary.totalTime;
} else if (routeData.results) {
routeData.results.forEach(r => totalSeconds += (r.crossTime || 0));
}
if (totalSeconds === 0 && totalMeters > 0) totalSeconds = (totalMeters / 11.1);
const km = (totalMeters / 1000).toFixed(1);
const min = Math.round(totalSeconds / 60);
document.getElementById('rt_msg').innerHTML = `<b style="color:green; font-size:14px;">${km} km | ${min} min</b>`;
if (RouteTester.settings.showMarkers || RouteTester.settings.showSpeed || RouteTester.settings.showTime) {
let runningDist = 0;
let nextMarkerDist = 1000;
for (let i = 0; i < points.length - 1; i++) {
let segDist = points[i].distanceTo(points[i+1]);
runningDist += segDist;
if (runningDist >= nextMarkerDist) {
let ratio = 1 - ((runningDist - nextMarkerDist) / segDist);
let mx = points[i].x + (points[i+1].x - points[i].x) * ratio;
let my = points[i].y + (points[i+1].y - points[i].y) * ratio;
let pt = new OpenLayers.Geometry.Point(mx, my);
let currentKm = Math.round(nextMarkerDist / 1000);
let currentTimeRatio = nextMarkerDist / totalMeters;
let currentTimeMin = Math.round((totalSeconds * currentTimeRatio) / 60);
let currentSpeed = 0;
if (routeData.results) {
let resIdx = Math.floor((i / points.length) * routeData.results.length);
if (routeData.results[resIdx] && routeData.results[resIdx].crossTime > 0) {
currentSpeed = Math.round((routeData.results[resIdx].length / routeData.results[resIdx].crossTime) * 3.6);
}
}
if (currentSpeed === 0) currentSpeed = Math.round((totalMeters/totalSeconds) * 3.6);
if (RouteTester.settings.showMarkers) {
let kmStyle = { label: `${currentKm} km`, fontColor: "#FFD700", fontSize: "15px", fontWeight: "bold", pointRadius: 5, fillColor: "#FFD700", labelYOffset: 7, strokeColor: "#000", labelOutlineColor: "black", labelOutlineWidth: 5 };
RouteTester.layer.addFeatures([new OpenLayers.Feature.Vector(pt.clone(), {}, kmStyle)]);
}
if (RouteTester.settings.showSpeed) {
let spdStyle = { label: `${currentSpeed} km/h`, fontColor: "#FF0000", fontSize: "14px", fontWeight: "bold", pointRadius: 0, labelYOffset: -12, labelOutlineColor: "white", labelOutlineWidth: 4 };
RouteTester.layer.addFeatures([new OpenLayers.Feature.Vector(pt.clone(), {}, spdStyle)]);
}
if (RouteTester.settings.showTime) {
let timeStyle = { label: `${currentTimeMin} min`, fontColor: "#0000FF", fontSize: "14px", fontWeight: "bold", pointRadius: 0, labelYOffset: -28, labelOutlineColor: "white", labelOutlineWidth: 4 };
RouteTester.layer.addFeatures([new OpenLayers.Feature.Vector(pt.clone(), {}, timeStyle)]);
}
nextMarkerDist += 1000;
}
}
}
}
},
handleClear: (e) => {
if(e) { e.preventDefault(); e.stopPropagation(); }
if(RouteTester.layer) RouteTester.layer.removeAllFeatures();
RouteTester.startPoint = null; RouteTester.endPoint = null;
RouteTester.lastRouteData = null;
document.getElementById('rt_lbl_a').innerText = _t('rt_lbl_a');
document.getElementById('rt_lbl_b').innerText = _t('rt_lbl_b');
RouteTester.msg(_t('rt_msg_ready'), 'neutral');
},
msg: (txt, type) => {
const el = document.getElementById('rt_msg');
el.innerText = txt;
if(type === 'error') { el.style.background = '#ffebee'; el.style.color = '#c62828'; }
else if(type === 'warn') { el.style.background = '#fff3e0'; el.style.color = '#ef6c00'; }
else { el.style.background = '#f5f5f5'; el.style.color = '#333'; }
}
};
// ===========================================================================
// SPEED INDICATOR & VALIDATOR
// ===========================================================================
const SpeedIndicator = {
layer: null,
init: () => {
const html = `
<div style="padding:5px;">
<label style="font-weight:bold; display:block; margin-bottom:15px; cursor:pointer;"><input type="checkbox" id="speed_master_enable" checked> ${_t('lbl_enable')}</label>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px;">
<label class="aa-lock-opt"><input type="checkbox" class="aa-speed-cb" value="0" checked> 0-40 <span style="margin-left:auto; display:inline-block;width:20px;height:12px;background:#00FF00;border-radius:2px;border:1px solid #ddd;"></span></label>
<label class="aa-lock-opt"><input type="checkbox" class="aa-speed-cb" value="1" checked> 41-60 <span style="margin-left:auto; display:inline-block;width:20px;height:12px;background:#00FFFF;border-radius:2px;border:1px solid #ddd;"></span></label>
<label class="aa-lock-opt"><input type="checkbox" class="aa-speed-cb" value="2" checked> 61-80 <span style="margin-left:auto; display:inline-block;width:20px;height:12px;background:#0000FF;border-radius:2px;border:1px solid #ddd;"></span></label>
<label class="aa-lock-opt"><input type="checkbox" class="aa-speed-cb" value="3" checked> 81-100 <span style="margin-left:auto; display:inline-block;width:20px;height:12px;background:#4B0082;border-radius:2px;border:1px solid #ddd;"></span></label>
<label class="aa-lock-opt"><input type="checkbox" class="aa-speed-cb" value="4" checked> 101-120 <span style="margin-left:auto; display:inline-block;width:20px;height:12px;background:#800080;border-radius:2px;border:1px solid #ddd;"></span></label>
<label class="aa-lock-opt"><input type="checkbox" class="aa-speed-cb" value="5" checked> 121-140 <span style="margin-left:auto; display:inline-block;width:20px;height:12px;background:#FF8000;border-radius:2px;border:1px solid #ddd;"></span></label>
<label class="aa-lock-opt"><input type="checkbox" class="aa-speed-cb" value="6" checked> 141+ <span style="margin-left:auto; display:inline-block;width:20px;height:12px;background:#FF0000;border-radius:2px;border:1px solid #ddd;"></span></label>
</div>
<div style="margin-top:20px; display:flex; gap:10px;">
<button id="speed_scan" class="aa-btn aa-red" style="flex:2;">${_t('common_scan')}</button>
<button id="speed_clear" class="aa-btn aa-gray" style="flex:1;">${_t('common_clear')}</button>
</div>
</div>`;
UIBuilder.createFloatingWindow('AA_SpeedWin', 'win_speed', 'aa-bg-red', html);
document.getElementById('speed_scan').onclick = SpeedIndicator.scan;
document.getElementById('speed_clear').onclick = () => {
if (SpeedIndicator.layer) SpeedIndicator.layer.removeAllFeatures();
};
},
scan: () => {
if (!SpeedIndicator.layer) {
SpeedIndicator.layer = new OpenLayers.Layer.Vector("AA_Speed_Labels", { displayInLayerSwitcher: true });
W.map.addLayer(SpeedIndicator.layer);
SpeedIndicator.layer.setZIndex(9999);
}
SpeedIndicator.layer.removeAllFeatures();
SpeedIndicator.layer.setVisibility(true);
W.map.setLayerIndex(SpeedIndicator.layer, 9999);
if (!document.getElementById('speed_master_enable').checked) return;
let enabledRanges = [];
document.querySelectorAll('.aa-speed-cb').forEach(cb => { if (cb.checked) enabledRanges.push(parseInt(cb.value)) });
const extent = W.map.getExtent();
let features = [];
getAllObjects('segments').forEach(seg => {
if (!seg.geometry || !extent.intersectsBounds(seg.geometry.getBounds())) return;
let speed = Math.max(seg.attributes.fwdMaxSpeed || 0, seg.attributes.revMaxSpeed || 0);
if (speed === 0) return;
let rangeIdx = -1, color = "";
if (speed <= 40) { rangeIdx = 0; color = "#00FF00"; }
else if (speed <= 60) { rangeIdx = 1; color = "#00FFFF"; }
else if (speed <= 80) { rangeIdx = 2; color = "#0000FF"; }
else if (speed <= 100) { rangeIdx = 3; color = "#4B0082"; }
else if (speed <= 120) { rangeIdx = 4; color = "#800080"; }
else if (speed <= 140) { rangeIdx = 5; color = "#FF8000"; }
else { rangeIdx = 6; color = "#FF0000"; }
if (enabledRanges.includes(rangeIdx)) {
let centerPt = seg.geometry.getCentroid();
let style = {
pointRadius: 12, fillColor: color, fillOpacity: 0.9, strokeColor: "#ffffff", strokeWidth: 2,
label: speed.toString(), fontColor: (color === '#00FF00' || color === '#00FFFF') ? "black" : "white",
fontSize: "11px", fontWeight: "bold", graphicName: "circle"
};
features.push(new OpenLayers.Feature.Vector(centerPt, {}, style));
}
});
SpeedIndicator.layer.addFeatures(features);
}
};
const ValidatorCleanUI = {
qaLayer: null, visualLayer: null, isInitialized: false,
settings: { checkShort: false, checkAngle: false, checkCross: false, checkLock: false, checkGhost: false, checkSpeed: false, checkDiscon: false, checkJagged: false, limitShort: 6, limitAngle: 30, excludeRAB: true, unitSystem: 'metric', disconMode: '2w', winTop: '100px', winLeft: '100px', winWidth: DEFAULT_W, winHeight: DEFAULT_H },
SETTINGS_STORE: 'AA_WME_VALIDATOR_V18',
init: () => { if (ValidatorCleanUI.isInitialized) { ValidatorCleanUI.toggle(); return; } ValidatorCleanUI.loadSettings(); ValidatorCleanUI.createWindow(); ValidatorCleanUI.isInitialized = true; ValidatorCleanUI.toggle(); },
toggle: () => { const win = document.getElementById('aa-qa-pro-window'); if (win) { win.style.display = (win.style.display === 'none' ? 'block' : 'none'); if (win.style.display === 'block') ValidatorCleanUI.saveSettings(); } },
loadSettings: () => { const s = localStorage.getItem(ValidatorCleanUI.SETTINGS_STORE); if (s) ValidatorCleanUI.settings = {...ValidatorCleanUI.settings, ...JSON.parse(s)}; if (!ValidatorCleanUI.settings.limitShort) ValidatorCleanUI.settings.limitShort = 6; if (!ValidatorCleanUI.settings.limitAngle) ValidatorCleanUI.settings.limitAngle = 30; if (!ValidatorCleanUI.settings.winWidth) ValidatorCleanUI.settings.winWidth = DEFAULT_W; if (!ValidatorCleanUI.settings.winHeight) ValidatorCleanUI.settings.winHeight = DEFAULT_H; if (!ValidatorCleanUI.settings.disconMode || ValidatorCleanUI.settings.disconMode === 'all') ValidatorCleanUI.settings.disconMode = '2w'; },
saveSettings: () => { localStorage.setItem(ValidatorCleanUI.SETTINGS_STORE, JSON.stringify(ValidatorCleanUI.settings)); },
openGMaps: () => { if (!W || !W.map) return; const center = W.map.getCenter(); const lonlat = new OpenLayers.LonLat(center.lon, center.lat).transform(W.map.getProjectionObject(), new OpenLayers.Projection("EPSG:4326")); const url = `https://www.google.com/maps?q=${lonlat.lat},${lonlat.lon}`; window.open(url, '_blank'); },
scanMap: () => {
if (typeof W === 'undefined' || !W.map || !W.model) return;
const statusEl = document.getElementById('aa_qa_status'); statusEl.innerText = _t('qa_msg_scanning'); statusEl.style.color = '#2196F3';
if (!ValidatorCleanUI.qaLayer) { ValidatorCleanUI.qaLayer = new OpenLayers.Layer.Vector("AA_QA_Results", {displayInLayerSwitcher:true}); W.map.addLayer(ValidatorCleanUI.qaLayer); }
ValidatorCleanUI.qaLayer.removeAllFeatures(); ValidatorCleanUI.qaLayer.setVisibility(true); ValidatorCleanUI.qaLayer.setZIndex(1001); W.selectionManager.unselectAll();
const extent = W.map.getExtent();
const segments = W.model.segments.getObjectArray().filter(s => s.geometry && extent.intersectsBounds(s.geometry.getBounds()));
const nodes = W.model.nodes.getObjectArray().filter(n => n.geometry && extent.intersectsBounds(n.geometry.getBounds()));
if (segments.length === 0) { statusEl.innerText = _t('qa_msg_no_segments'); statusEl.style.color = '#F44336'; return; }
const features = []; const modelsToSelect = []; const isMetric = ValidatorCleanUI.settings.unitSystem === 'metric'; const isRAB = (s) => s.isInRoundabout(); const s = ValidatorCleanUI.settings;
if (s.checkShort) {
let limit = parseFloat(s.limitShort) || 6; if (!isMetric) limit = limit * 0.3048;
segments.forEach(seg => {
if(!seg.geometry) return; if (s.excludeRAB && isRAB(seg)) return;
const len = seg.geometry.getGeodesicLength(W.map.getProjectionObject());
if (len < limit) {
const txt = isMetric ? Math.round(len)+'m' : Math.round(len*3.28)+'ft';
features.push(ValidatorCleanUI.createFeature(seg.geometry, '#E91E63', txt));
modelsToSelect.push(seg);
}
});
}
if (s.checkDiscon) {
const ignoredTypes = [5, 10, 16, 18]; // FIX: Excluded types from Disconnected check
segments.forEach(seg => {
if(!seg.geometry) return; if (s.excludeRAB && isRAB(seg)) return;
if (ignoredTypes.includes(seg.attributes.roadType)) return;
const nodeA = W.model.nodes.objects[seg.attributes.fromNodeID]; const nodeB = W.model.nodes.objects[seg.attributes.toNodeID];
if(!nodeA || !nodeB || !nodeA.geometry || !nodeB.geometry) return;
const conA = nodeA.attributes.segIDs.length; const conB = nodeB.attributes.segIDs.length;
const visibleA = extent.intersectsBounds(nodeA.geometry.getBounds()); const visibleB = extent.intersectsBounds(nodeB.geometry.getBounds());
let isDisc = false;
if (s.disconMode === '2w') { if (conA === 1 && conB === 1 && visibleA && visibleB) isDisc = true; }
else if (s.disconMode === '1w') { const deadA = (conA === 1 && visibleA); const deadB = (conB === 1 && visibleB); if ((deadA && conB > 1) || (deadB && conA > 1)) isDisc = true; }
if (isDisc) { features.push(ValidatorCleanUI.createFeature(seg.geometry, '#FF5722', 'Disc')); modelsToSelect.push(seg); }
});
}
if (s.checkJagged) {
segments.forEach(seg => {
if(!seg.geometry) return; if (s.excludeRAB && isRAB(seg)) return;
const verts = seg.geometry.getVertices(); const len = seg.geometry.getGeodesicLength(W.map.getProjectionObject());
if (verts.length > 3 && (len / verts.length) < 3) {
features.push(ValidatorCleanUI.createFeature(seg.geometry, '#795548', 'Jagged'));
modelsToSelect.push(seg);
}
});
}
if (s.checkCross) {
const items = segments.map(seg => ({ s: seg, b: seg.geometry.getBounds() }));
const ignoredTypes = [5, 10, 16, 18]; // 5=Walking Trail, 10=Boardwalk, 16=Stairway, 18=Railroad
for (let i = 0; i < items.length; i++) {
let item1 = items[i];
for (let j = i + 1; j < items.length; j++) {
let item2 = items[j];
if (!item1.b.intersectsBounds(item2.b)) continue;
let s1 = item1.s; let s2 = item2.s;
// FIX: Exclude specified types from Cross check
if (ignoredTypes.includes(s1.attributes.roadType) || ignoredTypes.includes(s2.attributes.roadType)) continue;
if (s1.attributes.level === s2.attributes.level &&
s1.attributes.fromNodeID !== s2.attributes.fromNodeID &&
s1.attributes.fromNodeID !== s2.attributes.toNodeID &&
s1.attributes.toNodeID !== s2.attributes.fromNodeID &&
s1.attributes.toNodeID !== s2.attributes.toNodeID) {
if (s1.geometry.intersects(s2.geometry)) {
features.push(ValidatorCleanUI.createFeature(s1.geometry, '#D50000', 'X'));
if(!modelsToSelect.includes(s1)) modelsToSelect.push(s1);
if(!modelsToSelect.includes(s2)) modelsToSelect.push(s2);
}
}
}
}
}
if (s.checkLock) segments.forEach(seg => { if(!seg.geometry) return; const rt = seg.attributes.roadType; const lock = (seg.attributes.lockRank || 0) + 1; let req = 1; if (rt === 3) req = 4; else if (rt === 6) req = 3; else if (rt === 7) req = 2; else if (rt === 4 && lock < 2) req = 2; if (lock < req) { features.push(ValidatorCleanUI.createFeature(seg.geometry, '#F44336', `L${lock}`)); modelsToSelect.push(seg); } });
if (s.checkGhost) segments.forEach(seg => { if(!seg.geometry) return; const sid = seg.attributes.primaryStreetID; if (sid) { const st = W.model.streets.objects[sid]; if (st && st.attributes.name && st.attributes.name.trim() !== "") { let ce = !st.attributes.cityID; if (!ce) { const c = W.model.cities.objects[st.attributes.cityID]; if (!c || !c.attributes.name || c.attributes.name.trim() === "") ce = true; } if (ce) { features.push(ValidatorCleanUI.createFeature(seg.geometry, '#FF9800', 'NoCity')); modelsToSelect.push(seg); } } } });
if (s.checkSpeed) segments.forEach(seg => { if(!seg.geometry) return; if (s.excludeRAB && isRAB(seg)) return; const sp = seg.attributes.fwdMaxSpeed; if(!sp) return; const tn = W.model.nodes.objects[seg.attributes.toNodeID]; if(tn && tn.attributes.segIDs.length === 2) { const oid = tn.attributes.segIDs.find(id => id !== seg.attributes.id); const os = W.model.segments.objects[oid]; if(os) { let osp = (os.attributes.fromNodeID === tn.attributes.id) ? os.attributes.fwdMaxSpeed : os.attributes.revMaxSpeed; if(osp > 0 && Math.abs(sp - osp) >= 30) { features.push(ValidatorCleanUI.createFeature(tn.geometry, '#2196F3', 'Jump', true)); modelsToSelect.push(tn); } } } });
if (s.checkAngle) nodes.forEach(n => { if(!n.geometry) return; if(n.attributes.segIDs.length < 2) return; const sg = n.attributes.segIDs.map(id => W.model.segments.objects[id]); if (s.excludeRAB && sg.some(seg => seg && isRAB(seg))) return; for(let i=0; i<sg.length; i++) for(let j=i+1; j<sg.length; j++) { if(!sg[i] || !sg[j] || !sg[i].geometry || !sg[j].geometry) continue; const angle = ValidatorCleanUI.calculateAngleAtNode(n, sg[i], sg[j]); if(angle < (parseFloat(s.limitAngle)||30)) { features.push(ValidatorCleanUI.createFeature(n.geometry, '#9C27B0', Math.round(angle)+'°', true)); if(!modelsToSelect.includes(n)) modelsToSelect.push(n); } } });
ValidatorCleanUI.qaLayer.addFeatures(features);
if (modelsToSelect.length > 0) { statusEl.innerText = `${_t('qa_msg_found')}: ${modelsToSelect.length}`; statusEl.style.color = '#D50000'; W.selectionManager.setSelectedModels(modelsToSelect); let b = null; modelsToSelect.forEach(o => { if(o.geometry) { if(!b) b = o.geometry.getBounds().clone(); else b.extend(o.geometry.getBounds()); } }); if(b) W.map.setCenter(b.getCenterLonLat()); } else { statusEl.innerText = _t('qa_msg_clean'); statusEl.style.color = '#4CAF50'; }
},
createFeature: (geometry, color, label, isPoint = false) => { if(!geometry) return null; return new OpenLayers.Feature.Vector(geometry.clone(), {}, { strokeColor: color, strokeWidth: isPoint?0:6, strokeOpacity: 0.6, pointRadius: isPoint?7:0, fillColor: color, fillOpacity: 0.8, label: label, labelOutlineColor: "white", labelOutlineWidth: 2, fontSize: "10px", fontColor: color, labelYOffset: 16, fontWeight: "bold" }); },
calculateAngleAtNode: (node, s1, s2) => { const pNode = node.geometry; const getP = (s) => { const v = s.geometry.getVertices(); return (s.attributes.fromNodeID === node.attributes.id) ? v[1] : v[v.length - 2]; }; const p1 = getP(s1); const p2 = getP(s2); const a = Math.sqrt(Math.pow(p1.x-pNode.x,2)+Math.pow(p1.y-pNode.y,2)); const b = Math.sqrt(Math.pow(p1.x-p2.x,2)+Math.pow(p1.y-p2.y,2)); const c = Math.sqrt(Math.pow(p2.x-pNode.x,2)+Math.pow(p2.y-pNode.y,2)); const cosC = (a*a+c*c-b*b)/(2*a*c); return Math.acos(Math.max(-1, Math.min(1, cosC)))*180/Math.PI; },
createWindow: () => { if (document.getElementById('aa-qa-pro-window')) return; const s = ValidatorCleanUI.settings; const win = document.createElement('div'); win.id = 'aa-qa-pro-window'; win.className = `aa-window ${_dir()}`; win.style.cssText = ` position: fixed; top: ${s.winTop}; left: ${s.winLeft}; width: ${s.winWidth}; height: ${s.winHeight}; background: #fff; border-radius: 8px; z-index: 9999; box-shadow: 0 5px 15px rgba(0,0,0,0.3); display: none; font-family: 'Cairo', sans-serif, Arial; overflow: hidden; resize: none; direction: ${_dir()}; `; const resizeHandle = document.createElement('div'); resizeHandle.id = 'aa-qa-resize-handle'; win.appendChild(resizeHandle); const head = document.createElement('div'); head.className = 'aa-header aa-bg-orange'; head.innerHTML = `<span>${_t('qa_title')}</span><span id="aa-qa-close" class="aa-close">✖</span>`; win.appendChild(head); const body = document.createElement('div'); body.className = 'aa-content'; const createChk = (key, label) => `<label class="aa-qa-chk-card"><input type="checkbox" id="aa_qa_${key}" ${s[key]?'checked':''} data-key="${key}"><span>${label}</span></label>`; let html = `<div class="aa-qa-grid"> ${createChk('checkShort', _t('qa_lbl_short'))} ${createChk('checkAngle', _t('qa_lbl_angle'))} ${createChk('checkCross', _t('qa_lbl_cross'))} ${createChk('checkLock', _t('qa_lbl_lock'))} ${createChk('checkGhost', _t('qa_lbl_ghost'))} ${createChk('checkSpeed', _t('qa_lbl_speed'))} ${createChk('checkDiscon', _t('qa_lbl_discon'))} ${createChk('checkJagged', _t('qa_lbl_jagged'))} <button id="aa_qa_gmaps_grid" class="aa-qa-grid-btn">${_t('qa_btn_gmaps')}</button> </div>`; html += `<div class="aa-qa-settings-box"> <div class="aa-qa-setting-row"><span>${_t('qa_opt_exclude_rab')}</span><input type="checkbox" id="aa_qa_excludeRAB" ${s.excludeRAB?'checked':''}></div> <div class="aa-qa-setting-row"><span>${_t('qa_lbl_discon_mode')}</span><div class="aa-qa-pill"><div id="aa_qa_disc_1w" class="aa-qa-pill-opt ${s.disconMode==='1w'?'active':''}">${_t('qa_opt_discon_1w')}</div><div id="aa_qa_disc_2w" class="aa-qa-pill-opt ${s.disconMode==='2w'?'active':''}">${_t('qa_opt_discon_2w')}</div></div></div> <div class="aa-qa-setting-row"><span>${_t('qa_unit_m')} / ${_t('qa_unit_i')}</span><div class="aa-qa-pill"><div id="aa_qa_unit_m" class="aa-qa-pill-opt ${s.unitSystem==='metric'?'active':''}">${_t('qa_unit_m')}</div><div id="aa_qa_unit_i" class="aa-qa-pill-opt ${s.unitSystem==='imperial'?'active':''}">${_t('qa_unit_i')}</div></div></div> <div class="aa-qa-setting-row"><span>${_t('qa_lbl_limit_dist')}</span><div><input type="number" id="aa_qa_limitShort" class="aa-qa-input" value="${s.limitShort}"> <span id="aa_qa_lbl_short_unit" style="color:#888;">${s.unitSystem==='metric'?'m':'ft'}</span></div></div> <div class="aa-qa-setting-row"><span>${_t('qa_lbl_limit_angle')}</span><div><input type="number" id="aa_qa_limitAngle" class="aa-qa-input" value="${s.limitAngle}"> <span>°</span></div></div> </div>`; html += `<div class="aa-qa-action-row"> <button id="aa_qa_scan" class="aa-qa-btn aa-btn-scan">${_t('qa_btn_scan')}</button> <button id="aa_qa_clear" class="aa-qa-btn aa-btn-clear">${_t('qa_btn_clear')}</button> </div>`; html += `<div id="aa_qa_status" style="text-align:center; margin-top:8px; font-weight:bold; font-size:11px; color:#777;">${_t('qa_msg_ready')}</div>`; body.innerHTML = html; win.appendChild(body); document.body.appendChild(win); document.getElementById('aa-qa-close').onclick = () => { win.style.display = 'none'; ValidatorCleanUI.saveSettings(); }; document.getElementById('aa_qa_scan').onclick = ValidatorCleanUI.scanMap; document.getElementById('aa_qa_gmaps_grid').onclick = ValidatorCleanUI.openGMaps; document.getElementById('aa_qa_clear').onclick = () => { W.selectionManager.unselectAll(); if(ValidatorCleanUI.qaLayer) ValidatorCleanUI.qaLayer.removeAllFeatures(); if(ValidatorCleanUI.visualLayer) ValidatorCleanUI.visualLayer.removeAllFeatures(); document.getElementById('aa_qa_status').innerText = _t('qa_msg_ready'); }; win.querySelectorAll('input[type="checkbox"][data-key]').forEach(c => { c.onchange = function() { ValidatorCleanUI.settings[this.getAttribute('data-key')] = this.checked; ValidatorCleanUI.saveSettings(); }; }); document.getElementById('aa_qa_limitShort').onchange = (e) => { ValidatorCleanUI.settings.limitShort = e.target.value; ValidatorCleanUI.saveSettings(); }; document.getElementById('aa_qa_limitAngle').onchange = (e) => { ValidatorCleanUI.settings.limitAngle = e.target.value; ValidatorCleanUI.saveSettings(); }; document.getElementById('aa_qa_excludeRAB').onchange = (e) => { ValidatorCleanUI.settings.excludeRAB = e.target.checked; ValidatorCleanUI.saveSettings(); }; const setupPill = (ids, settingKey, values) => { ids.forEach((id, idx) => { document.getElementById(id).onclick = () => { ValidatorCleanUI.settings[settingKey] = values[idx]; ValidatorCleanUI.saveSettings(); ids.forEach((oid, oidx) => { const el = document.getElementById(oid); if(idx === oidx) el.classList.add('active'); else el.classList.remove('active'); }); if(settingKey === 'unitSystem') document.getElementById('aa_qa_lbl_short_unit').innerText = values[idx] === 'metric' ? 'm' : 'ft'; }; }); }; setupPill(['aa_qa_unit_m', 'aa_qa_unit_i'], 'unitSystem', ['metric', 'imperial']); setupPill(['aa_qa_disc_1w', 'aa_qa_disc_2w'], 'disconMode', ['1w', '2w']); let isDrag = false, startX, startY, initialLeft, initialTop; head.onmousedown = (e) => { if(e.target.className.includes('aa-close')) return; isDrag = true; startX = e.clientX; startY = e.clientY; initialLeft = win.offsetLeft; initialTop = win.offsetTop; document.onmousemove = (e) => { if(!isDrag) return; e.preventDefault(); win.style.left = (initialLeft + e.clientX - startX) + 'px'; win.style.top = (initialTop + e.clientY - startY) + 'px'; }; document.onmouseup = () => { isDrag = false; document.onmousemove = null; document.onmouseup = null; ValidatorCleanUI.settings.winTop = win.style.top; ValidatorCleanUI.settings.winLeft = win.style.left; ValidatorCleanUI.saveSettings(); }; }; const handle = document.getElementById('aa-qa-resize-handle'); let isResizing = false, rStartX, rStartY, rStartW, rStartH; handle.onmousedown = (e) => { isResizing = true; rStartX = e.clientX; rStartY = e.clientY; rStartW = win.offsetWidth; rStartH = win.offsetHeight; e.stopPropagation(); e.preventDefault(); }; document.addEventListener('mousemove', (e) => { if (!isResizing) return; const newW = rStartW + (rStartX - e.clientX); const newH = rStartH + (e.clientY - rStartY); if (newW > 280) { win.style.width = newW + 'px'; win.style.left = (e.clientX) + 'px'; } if (newH > 300) win.style.height = newH + 'px'; }); document.addEventListener('mouseup', () => { if(isResizing) { isResizing = false; ValidatorCleanUI.settings.winWidth = win.style.width; ValidatorCleanUI.settings.winHeight = win.style.height; ValidatorCleanUI.settings.winLeft = win.style.left; ValidatorCleanUI.saveSettings(); } }); }
};
// ===========================================================================
// ADVANCED SELECTION
// ===========================================================================
const AdvancedSelection = {
init: () => {
const html = `
<div style="padding:5px;">
<label style="font-weight:bold; font-size:12px; display:block; margin-bottom:5px;">${_t('adv_lbl_crit')}</label>
<select id="adv_crit_sel" class="aa-input">
<option value="no_city">${_t('adv_opt_nocity')}</option>
<option value="no_speed">${_t('adv_opt_nospeed')}</option>
<option value="lock">${_t('adv_opt_lock')}</option>
<option value="type">${_t('adv_opt_type')}</option>
</select>
<div id="adv_val_container" style="display:none; margin-top:10px;">
<label style="font-weight:bold; font-size:12px; display:block; margin-bottom:5px;">${_t('adv_lbl_val')}</label>
<select id="adv_val_lock" class="aa-input">
<option value="1">Level 1</option>
<option value="2">Level 2</option>
<option value="3">Level 3</option>
<option value="4">Level 4</option>
<option value="5">Level 5</option>
<option value="6">Level 6</option>
</select>
<select id="adv_val_type" class="aa-input" style="display:none;"></select>
</div>
<div style="margin-top:20px; display:flex; gap:10px;">
<button id="adv_btn_scan" class="aa-btn aa-indigo" style="flex:2;">${_t('adv_btn_sel')}</button>
<button id="adv_btn_clear" class="aa-btn aa-gray" style="flex:1;">${_t('common_clear')}</button>
</div>
<div id="adv_msg" style="text-align:center; margin-top:10px; font-weight:bold; font-size:11px; color:#555;"></div>
</div>
`;
// Resizable window (pass null for size)
const win = UIBuilder.createFloatingWindow('AA_AdvWin', 'win_adv', 'aa-bg-indigo', html, null);
// Set default size (320x440) only if no state is saved
if (!localStorage.getItem('AA_Win_AA_AdvWin')) {
win.style.width = '320px';
win.style.height = '440px';
UIBuilder.saveState('AA_AdvWin', win);
}
const critSel = document.getElementById('adv_crit_sel');
const valContainer = document.getElementById('adv_val_container');
const valLock = document.getElementById('adv_val_lock');
const valType = document.getElementById('adv_val_type');
const roadTypes = [
{val: 1, key: 'adv_type_st'}, {val: 2, key: 'adv_type_ps'},
{val: 7, key: 'adv_type_mh'}, {val: 6, key: 'adv_type_maj'},
{val: 3, key: 'adv_type_fw'}, {val: 4, key: 'adv_type_rmp'},
{val: 20, key: 'adv_type_plr'}, {val: 22, key: 'adv_type_pw'},
{val: 17, key: 'adv_type_pr'}, {val: 8, key: 'adv_type_or'}
];
valType.innerHTML = '';
roadTypes.forEach(rt => {
let opt = document.createElement('option');
opt.value = rt.val;
opt.text = _t(rt.key);
valType.appendChild(opt);
});
critSel.onchange = () => {
const val = critSel.value;
if(val === 'lock') {
valContainer.style.display = 'block';
valLock.style.display = 'block';
valType.style.display = 'none';
} else if (val === 'type') {
valContainer.style.display = 'block';
valLock.style.display = 'none';
valType.style.display = 'block';
} else {
valContainer.style.display = 'none';
}
};
document.getElementById('adv_btn_scan').onclick = AdvancedSelection.run;
document.getElementById('adv_btn_clear').onclick = () => { W.selectionManager.unselectAll(); document.getElementById('adv_msg').innerText = ''; };
},
run: () => {
const criteria = document.getElementById('adv_crit_sel').value;
const extent = W.map.getExtent();
let objectsToSelect = [];
let segments = getAllObjects('segments');
segments.forEach(seg => {
if (!seg.geometry || !extent.intersectsBounds(seg.geometry.getBounds())) return;
const attr = seg.attributes;
let match = false;
if (criteria === 'no_city') {
const streetId = attr.primaryStreetID;
if (streetId) {
const street = W.model.streets.objects[streetId];
if (street) {
if (!street.attributes.cityID) match = true;
else {
const city = W.model.cities.objects[street.attributes.cityID];
if (!city || !city.attributes.name || city.attributes.name.trim() === '') match = true;
}
}
} else {
match = true;
}
}
else if (criteria === 'no_speed') {
// Driveable roads only
const driveable = [1, 2, 3, 4, 6, 7, 8, 17, 20, 22];
if (driveable.includes(attr.roadType)) {
const fwd = attr.fwdMaxSpeed;
const rev = attr.revMaxSpeed;
if ((fwd === null || fwd === 0) && (rev === null || rev === 0)) match = true;
}
}
else if (criteria === 'lock') {
const reqRank = parseInt(document.getElementById('adv_val_lock').value) - 1;
if ((attr.lockRank || 0) === reqRank) match = true;
}
else if (criteria === 'type') {
if (attr.roadType === parseInt(document.getElementById('adv_val_type').value)) match = true;
}
if (match) objectsToSelect.push(seg);
});
const msgEl = document.getElementById('adv_msg');
if (objectsToSelect.length > 0) {
W.selectionManager.setSelectedModels(objectsToSelect);
msgEl.innerText = `${_t('adv_msg_found')}: ${objectsToSelect.length}`;
msgEl.style.color = 'green';
} else {
msgEl.innerText = _t('adv_msg_none');
msgEl.style.color = 'red';
}
}
};
const CityExplorer={init:()=>{const html=`<div class="aa-section-box"><input type="text" id="aa_city_input" class="aa-input" placeholder="${_t('ph_city')}"><div class="aa-btn-group"><button id="aa_city_scan" class="aa-btn aa-gold">${_t('common_scan')}</button><button id="aa_city_clear" class="aa-btn aa-gray">${_t('common_clear')}</button></div></div><div id="aa_city_res" class="aa-results"></div>`;UIBuilder.createFloatingWindow('AA_CityWin','win_city','aa-bg-gold',html);document.getElementById('aa_city_scan').onclick=CityExplorer.scan;document.getElementById('aa_city_clear').onclick=()=>{document.getElementById('aa_city_res').innerHTML='';document.getElementById('aa_city_input').value='';W.selectionManager.unselectAll()}},scan:()=>{const query=document.getElementById('aa_city_input').value.toLowerCase().trim();const resDiv=document.getElementById('aa_city_res');resDiv.innerHTML='<div style="text-align:center; padding:10px;">...</div>';setTimeout(()=>{let cityGroups={};const extent=W.map.getExtent();const segments=getAllObjects('segments');let foundAny=false;segments.forEach(seg=>{if(!seg.geometry||!extent.intersectsBounds(seg.geometry.getBounds()))return;let cityName=_t('city_no_name');if(seg.attributes.primaryStreetID){let street=W.model.streets.objects[seg.attributes.primaryStreetID];if(street&&street.attributes.cityID){let city=W.model.cities.objects[street.attributes.cityID];if(city&&city.attributes.name&&city.attributes.name.trim().length>0)cityName=city.attributes.name}}if(query!==""&&!cityName.toLowerCase().includes(query))return;if(!cityGroups[cityName])cityGroups[cityName]=[];cityGroups[cityName].push(seg);foundAny=true});resDiv.innerHTML='';const sortedCities=Object.keys(cityGroups).sort();if(!foundAny){resDiv.innerHTML=`<div style="text-align:center; padding:10px; color:#999;">${_t('no_results')}</div>`;return}sortedCities.forEach(city=>{let count=cityGroups[city].length;let row=document.createElement('div');row.className='aa-item-row';row.innerHTML=`<span style="font-weight:700; color:#2c3e50; font-size:14px;">${city}</span><span class="aa-badge aa-bg-gold">${count}</span>`;row.onclick=()=>{resDiv.querySelectorAll('.aa-item-row').forEach(r=>r.style.background='transparent');row.style.background='#fff3cd';W.selectionManager.setSelectedModels(cityGroups[city]);let totalBounds=null;cityGroups[city].forEach(seg=>{if(seg.geometry){if(!totalBounds)totalBounds=seg.geometry.getBounds().clone();else totalBounds.extend(seg.geometry.getBounds())}});if(totalBounds)W.map.setCenter(totalBounds.getCenterLonLat())};resDiv.appendChild(row)})},100)}};
const PlacesExplorer={init:()=>{const html=`<div class="aa-section-box"><input type="text" id="aa_place_input" class="aa-input" placeholder="${_t('ph_place')}"><div class="aa-btn-group"><button id="aa_place_scan" class="aa-btn aa-blue">${_t('common_scan')}</button><button id="aa_place_clear" class="aa-btn aa-gray">${_t('common_clear')}</button></div></div><div id="aa_place_res" class="aa-results"></div>`;UIBuilder.createFloatingWindow('AA_PlaceWin','win_places','aa-bg-blue',html);document.getElementById('aa_place_scan').onclick=PlacesExplorer.scan;document.getElementById('aa_place_clear').onclick=()=>{document.getElementById('aa_place_res').innerHTML='';document.getElementById('aa_place_input').value='';W.selectionManager.unselectAll()}},scan:()=>{const query=document.getElementById('aa_place_input').value.toLowerCase().trim();const resDiv=document.getElementById('aa_place_res');resDiv.innerHTML='<div style="text-align:center; padding:10px;">...</div>';setTimeout(()=>{const extent=W.map.getExtent();const venues=getAllObjects('venues');let foundAny=false;resDiv.innerHTML='';if(query===""){let cityGroups={};venues.forEach(v=>{if(!v.geometry||!extent.intersectsBounds(v.geometry.getBounds()))return;let cityName=_t('city_no_name');if(v.attributes.streetID){let street=W.model.streets.objects[v.attributes.streetID];if(street&&street.attributes.cityID){let city=W.model.cities.objects[street.attributes.cityID];if(city&&city.attributes.name)cityName=city.attributes.name}}if(!cityGroups[cityName])cityGroups[cityName]=[];cityGroups[cityName].push(v);foundAny=true});if(!foundAny){resDiv.innerHTML=`<div style="text-align:center;color:#999;">${_t('no_results')}</div>`;return}Object.keys(cityGroups).sort().forEach(city=>{let count=cityGroups[city].length;let row=document.createElement('div');row.className='aa-item-row';row.innerHTML=`<span style="font-weight:700; color:#2c3e50; font-size:14px;">${city}</span><span class="aa-badge aa-bg-blue">${count}</span>`;row.onclick=()=>{resDiv.querySelectorAll('.aa-item-row').forEach(r=>r.style.background='transparent');row.style.background='#d6eaf8';W.selectionManager.setSelectedModels(cityGroups[city]);let totalBounds=null;cityGroups[city].forEach(v=>{if(v.geometry){if(!totalBounds)totalBounds=v.geometry.getBounds().clone();else totalBounds.extend(v.geometry.getBounds())}});if(totalBounds)W.map.setCenter(totalBounds.getCenterLonLat())};resDiv.appendChild(row)})}else{let results=[];venues.forEach(v=>{if(!v.geometry||!extent.intersectsBounds(v.geometry.getBounds()))return;let name=v.attributes.name||"Unnamed";if(name.toLowerCase().includes(query))results.push(v)});if(results.length===0){resDiv.innerHTML=`<div style="text-align:center;color:#999;">${_t('no_results')}</div>`;return}results.sort((a,b)=>(a.attributes.name||"").localeCompare(b.attributes.name||"")).forEach(v=>{let row=document.createElement('div');row.className='aa-item-row';row.innerHTML=`<span style="font-weight:700; color:#2c3e50; font-size:14px;">${v.attributes.name||"Unnamed"}</span>`;row.onclick=()=>{resDiv.querySelectorAll('.aa-item-row').forEach(r=>r.style.background='transparent');row.style.background='#d6eaf8';W.selectionManager.setSelectedModels([v]);W.map.setCenter(v.geometry.getBounds().getCenterLonLat())};resDiv.appendChild(row)})}},100)}};
const EditorExplorer={init:()=>{const html=`<div class="aa-section-box"><input type="text" id="aa_user_input" class="aa-input" placeholder="${_t('ph_user')}"><label style="font-size:11px; font-weight:bold; display:block; margin-bottom:3px;">${_t('lbl_days')}</label><input type="number" id="aa_days_input" class="aa-input" value="0" min="0"><div class="aa-btn-group"><button id="aa_user_scan" class="aa-btn aa-purple">${_t('common_scan')}</button><button id="aa_user_clear" class="aa-btn aa-gray">${_t('common_clear')}</button></div></div><div id="aa_user_res" class="aa-results"></div>`;UIBuilder.createFloatingWindow('AA_UserWin','win_editors','aa-bg-purple',html);document.getElementById('aa_user_scan').onclick=EditorExplorer.scan;document.getElementById('aa_user_clear').onclick=()=>{document.getElementById('aa_user_res').innerHTML='';W.selectionManager.unselectAll()}},scan:()=>{const resDiv=document.getElementById('aa_user_res');const query=document.getElementById('aa_user_input').value.toLowerCase().trim();const daysVal=parseInt(document.getElementById('aa_days_input').value);const days=isNaN(daysVal)?0:daysVal;const cutoff=days>0?new Date(Date.now()-(days*86400000)):null;resDiv.innerHTML='<div style="text-align:center; padding:10px;">...</div>';setTimeout(()=>{let users={};const extent=W.map.getExtent();const processObj=(obj,type)=>{if(!obj.geometry)return;if(!extent.intersectsBounds(obj.geometry.getBounds()))return;let uID=obj.attributes.updatedBy||obj.attributes.createdBy;let uTime=obj.attributes.updatedOn||obj.attributes.createdOn;if(cutoff&&new Date(uTime)<cutoff)return;if(uID){let uName="Unknown";if(W.model.users.objects[uID])uName=W.model.users.objects[uID].attributes.userName;else uName="ID:"+uID;if(query!==""&&!uName.toLowerCase().includes(query))return;if(!users[uName])users[uName]={segCount:0,venCount:0,objs:[]};if(type==='segment')users[uName].segCount++;if(type==='venue')users[uName].venCount++;users[uName].objs.push(obj)}};getAllObjects('segments').forEach(o=>processObj(o,'segment'));getAllObjects('venues').forEach(o=>processObj(o,'venue'));resDiv.innerHTML='';const sortedUsers=Object.keys(users).sort((a,b)=>(users[b].segCount+users[b].venCount)-(users[a].segCount+users[a].venCount));if(sortedUsers.length===0){resDiv.innerHTML=`<div style="text-align:center; padding:10px; color:#999;">${_t('no_results')}</div>`;return}sortedUsers.forEach(u=>{let data=users[u];let r=document.createElement('div');r.className='aa-item-row';r.innerHTML=`<span style="font-weight:700; color:#2c3e50; font-size:13px; max-width:140px; overflow:hidden; text-overflow:ellipsis;">${u}</span><div style="display:flex; gap:5px;"><span class="aa-badge aa-bg-gold" title="Segments"><i class="fa fa-road"></i> ${data.segCount}</span><span class="aa-badge aa-bg-blue" title="Venues"><i class="fa fa-map-marker"></i> ${data.venCount}</span></div>`;r.onclick=()=>{resDiv.querySelectorAll('.aa-item-row').forEach(x=>x.style.background='transparent');r.style.background='#e8daef';W.selectionManager.setSelectedModels(data.objs);let totalBounds=null;data.objs.forEach(o=>{if(o.geometry){if(!totalBounds)totalBounds=o.geometry.getBounds().clone();else totalBounds.extend(o.geometry.getBounds())}});if(totalBounds)W.map.setCenter(totalBounds.getCenterLonLat())};resDiv.appendChild(r)})},100)}};
const LockIndicator={layer:null,init:()=>{const html=`<div style="padding:5px;"><label style="font-weight:bold; display:block; margin-bottom:15px; cursor:pointer;"><input type="checkbox" id="lock_master_enable" checked> ${_t('lbl_enable')}</label><div style="display:grid; grid-template-columns:1fr 1fr; gap:8px;"><label class="aa-lock-opt"><input type="checkbox" class="aa-lock-cb" value="0" checked> L1 <span style="display:inline-block;width:12px;height:12px;background:#B0B0B0;border-radius:50%;"></span></label><label class="aa-lock-opt"><input type="checkbox" class="aa-lock-cb" value="1" checked> L2 <span style="display:inline-block;width:12px;height:12px;background:#FFC800;border-radius:50%;"></span></label><label class="aa-lock-opt"><input type="checkbox" class="aa-lock-cb" value="2" checked> L3 <span style="display:inline-block;width:12px;height:12px;background:#00FF00;border-radius:50%;"></span></label><label class="aa-lock-opt"><input type="checkbox" class="aa-lock-cb" value="3" checked> L4 <span style="display:inline-block;width:12px;height:12px;background:#00BFFF;border-radius:50%;"></span></label><label class="aa-lock-opt"><input type="checkbox" class="aa-lock-cb" value="4" checked> L5 <span style="display:inline-block;width:12px;height:12px;background:#BF00FF;border-radius:50%;"></span></label><label class="aa-lock-opt"><input type="checkbox" class="aa-lock-cb" value="5" checked> L6 <span style="display:inline-block;width:12px;height:12px;background:#FF0000;border-radius:50%;"></span></label></div><button id="lock_scan" class="aa-btn aa-cyan" style="margin-top:20px;">${_t('common_scan')}</button><button id="lock_clear" class="aa-btn aa-gray">${_t('common_clear')}</button></div>`;UIBuilder.createFloatingWindow('AA_LockWin','win_lock','aa-bg-cyan',html);document.getElementById('lock_scan').onclick=LockIndicator.scan;document.getElementById('lock_clear').onclick=()=>{if(LockIndicator.layer)LockIndicator.layer.removeAllFeatures()}},scan:()=>{if(!LockIndicator.layer){LockIndicator.layer=new OpenLayers.Layer.Vector("AA_Locks",{displayInLayerSwitcher:true});W.map.addLayer(LockIndicator.layer);LockIndicator.layer.setZIndex(9999)}LockIndicator.layer.removeAllFeatures();LockIndicator.layer.setVisibility(true);W.map.setLayerIndex(LockIndicator.layer,9999);if(!document.getElementById('lock_master_enable').checked)return;let enabledLevels=[];document.querySelectorAll('.aa-lock-cb').forEach(cb=>{if(cb.checked)enabledLevels.push(parseInt(cb.value))});const LOCK_COLORS={0:'#B0B0B0',1:'#FFC800',2:'#00FF00',3:'#00BFFF',4:'#BF00FF',5:'#FF0000'};const extent=W.map.getExtent();let features=[];const process=(obj,isVenue)=>{if(!obj.geometry||!extent.intersectsBounds(obj.geometry.getBounds()))return;let rank=(obj.attributes.lockRank!==undefined&&obj.attributes.lockRank!==null)?obj.attributes.lockRank:0;if(enabledLevels.includes(rank)){let centerPt=obj.geometry.getCentroid();let style={pointRadius:10,fontSize:"10px",fontWeight:"bold",label:"L"+(rank+1),fontColor:"black",fillColor:LOCK_COLORS[rank],fillOpacity:0.85,strokeColor:"#333",strokeWidth:1,graphicName:isVenue?"square":"circle"};features.push(new OpenLayers.Feature.Vector(centerPt,{},style))}};getAllObjects('segments').forEach(o=>process(o,false));getAllObjects('venues').forEach(o=>process(o,true));LockIndicator.layer.addFeatures(features)}};
// ===========================================================================
// ROUNDABOUT EDITOR (Un-minified & Safe)
// ===========================================================================
const RoundaboutEditor = {
isInitialized: false,
timeout: null,
init: () => {
const html = `<div style="text-align:center;padding:10px;"><div style="margin-bottom:15px;background:#fff;border:2px solid #333;padding:10px;border-radius:8px;"><span style="font-size:16px;font-weight:bold;color:#000;">${_t('unit_m')}: </span><input type="number" id="ra-val" class="aa-input" value="1" style="width:80px;display:inline-block;font-size:18px;font-weight:bold;text-align:center;border:1px solid #000;"></div><div style="font-size:14px;font-weight:bold;margin-bottom:5px;color:#000;">تحريك (Move)</div><div class="aa-ra-controls"><div></div><button id="ra_up" class="aa-btn aa-green aa-big-icon">▲</button><div></div><button id="ra_left" class="aa-btn aa-green aa-big-icon">◄</button><button id="ra_down" class="aa-btn aa-green aa-big-icon">▼</button><button id="ra_right" class="aa-btn aa-green aa-big-icon">►</button></div><div style="margin-top:20px;"><div style="font-size:14px;font-weight:bold;margin-bottom:5px;color:#000;">تدوير (Rotate)</div><div class="aa-btn-group"><button id="ra_rot_l" class="aa-btn aa-bg-red aa-huge-icon">↺</button><button id="ra_rot_r" class="aa-btn aa-bg-blue aa-huge-icon">↻</button></div></div><div style="margin-top:15px;"><div style="font-size:14px;font-weight:bold;margin-bottom:5px;color:#000;">حجم (Size)</div><div class="aa-btn-group"><button id="ra_shrink" class="aa-btn aa-teal" style="font-size:16px;">${_t('ra_in')}</button><button id="ra_expand" class="aa-btn aa-teal" style="font-size:16px;">${_t('ra_out')}</button></div></div></div><div id="ra_status" style="margin-top:15px;text-align:center;font-weight:bold;font-size:16px;color:red;border-top:2px solid #000;padding-top:10px;">${_t('ra_err')}</div>`;
UIBuilder.createFloatingWindow('AA_RAWin', 'win_ra', 'aa-bg-green', html);
if (!RoundaboutEditor.isInitialized) {
W.selectionManager.events.register("selectionchanged", null, RoundaboutEditor.onSelectionChanged);
RoundaboutEditor.isInitialized = true;
}
RoundaboutEditor.checkSelection();
document.getElementById('ra_up').onclick = () => RoundaboutEditor.run('ShiftLat', 1);
document.getElementById('ra_down').onclick = () => RoundaboutEditor.run('ShiftLat', -1);
document.getElementById('ra_left').onclick = () => RoundaboutEditor.run('ShiftLong', -1);
document.getElementById('ra_right').onclick = () => RoundaboutEditor.run('ShiftLong', 1);
document.getElementById('ra_rot_l').onclick = () => RoundaboutEditor.run('Rotate', -1);
document.getElementById('ra_rot_r').onclick = () => RoundaboutEditor.run('Rotate', 1);
document.getElementById('ra_shrink').onclick = () => RoundaboutEditor.run('Diameter', -1);
document.getElementById('ra_expand').onclick = () => RoundaboutEditor.run('Diameter', 1);
},
onSelectionChanged: () => {
if (RoundaboutEditor.timeout) clearTimeout(RoundaboutEditor.timeout);
RoundaboutEditor.timeout = setTimeout(() => { RoundaboutEditor.checkSelection() }, 200);
},
checkSelection: () => {
try {
const win = document.getElementById('AA_RAWin');
if (!win || win.style.display === 'none') return;
const el = document.getElementById('ra_status');
if (!el) return;
const sel = W.selectionManager.getSelectedFeatures();
let isRA = false;
if (sel.length > 0 && sel[0].model.type === 'segment') {
if (WazeWrap.Model.isRoundaboutSegmentID(sel[0].model.attributes.id)) isRA = true;
}
el.innerText = isRA ? _t('common_ready') : _t('ra_err');
el.style.color = isRA ? '#00c853' : '#d50000';
} catch (e) {}
},
run: (action, multiplier) => {
try {
var WazeActionUpdateSegmentGeometry = require('Waze/Action/UpdateSegmentGeometry');
var WazeActionMoveNode = require('Waze/Action/MoveNode');
var WazeActionMultiAction = require('Waze/Action/MultiAction');
} catch (e) { return; }
var val = parseFloat(document.getElementById('ra-val').value) * multiplier;
var segs = WazeWrap.getSelectedFeatures();
if (!segs || segs.length === 0) return;
var segObj = segs[0];
const getRASegs = (s) => WazeWrap.Model.getAllRoundaboutSegmentsFromObj(s);
try {
if (action === 'ShiftLong' || action === 'ShiftLat') {
var RASegs = getRASegs(segObj);
var multiaction = new WazeActionMultiAction();
for (let i = 0; i < RASegs.length; i++) {
let s = W.model.segments.getObjectById(RASegs[i]);
let newGeo = fastClone(s.attributes.geoJSONGeometry);
let isLat = (action === 'ShiftLat');
let c_idx = isLat ? 1 : 0;
let offset = 0;
let c = WazeWrap.Geometry.ConvertTo4326(s.attributes.geoJSONGeometry.coordinates[0][0], s.attributes.geoJSONGeometry.coordinates[0][1]);
if (isLat) offset = WazeWrap.Geometry.CalculateLatOffsetGPS(val, c.lon, c.lat);
else offset = WazeWrap.Geometry.CalculateLongOffsetGPS(val, c.lon, c.lat);
for (let j = 1; j < newGeo.coordinates.length - 1; j++) newGeo.coordinates[j][c_idx] += offset;
multiaction.doSubAction(W.model, new WazeActionUpdateSegmentGeometry(s, s.attributes.geoJSONGeometry, newGeo));
let node = W.model.nodes.objects[s.attributes.toNodeID];
if (s.attributes.revDirection) node = W.model.nodes.objects[s.attributes.fromNodeID];
let newNodeGeo = fastClone(node.attributes.geoJSONGeometry);
newNodeGeo.coordinates[c_idx] += offset;
let connected = {};
for (let k = 0; k < node.attributes.segIDs.length; k++) connected[node.attributes.segIDs[k]] = fastClone(W.model.segments.getObjectById(node.attributes.segIDs[k]).attributes.geoJSONGeometry);
multiaction.doSubAction(W.model, new WazeActionMoveNode(node, node.attributes.geoJSONGeometry, newNodeGeo, connected, {}));
}
W.model.actionManager.add(multiaction);
} else if (action === 'Rotate') {
let RASegs = getRASegs(segObj);
let raCenter = W.model.junctions.objects[segObj.WW.getAttributes().junctionID].attributes.geoJSONGeometry.coordinates;
let { lon: centerX, lat: centerY } = WazeWrap.Geometry.ConvertTo900913(raCenter[0], raCenter[1]);
let angleDeg = 5 * multiplier;
let angleRad = angleDeg * (Math.PI / 180);
let cosTheta = Math.cos(angleRad);
let sinTheta = Math.sin(angleRad);
let multiaction = new WazeActionMultiAction();
for (let i = 0; i < RASegs.length; i++) {
let s = W.model.segments.getObjectById(RASegs[i]);
let newGeo = fastClone(s.attributes.geoJSONGeometry);
for (let j = 1; j < newGeo.coordinates.length - 1; j++) {
let pt = s.attributes.geoJSONGeometry.coordinates[j];
let { lon: pX, lat: pY } = WazeWrap.Geometry.ConvertTo900913(pt[0], pt[1]);
let nX900913 = cosTheta * (pX - centerX) - sinTheta * (pY - centerY) + centerX;
let nY900913 = sinTheta * (pX - centerX) + cosTheta * (pY - centerY) + centerY;
let { lon: nX, lat: nY } = WazeWrap.Geometry.ConvertTo4326(nX900913, nY900913);
newGeo.coordinates[j] = [nX, nY];
}
multiaction.doSubAction(W.model, new WazeActionUpdateSegmentGeometry(s, s.attributes.geoJSONGeometry, newGeo));
let node = W.model.nodes.objects[s.attributes.toNodeID];
if (s.attributes.revDirection) node = W.model.nodes.objects[s.attributes.fromNodeID];
let newNodeGeo = fastClone(node.attributes.geoJSONGeometry);
let { lon: npX, lat: npY } = WazeWrap.Geometry.ConvertTo900913(newNodeGeo.coordinates[0], newNodeGeo.coordinates[1]);
let nodeX900913 = cosTheta * (npX - centerX) - sinTheta * (npY - centerY) + centerX;
let nodeY900913 = sinTheta * (npX - centerX) + cosTheta * (npY - centerY) + centerY;
let { lon: nnX, lat: nnY } = WazeWrap.Geometry.ConvertTo4326(nodeX900913, nodeY900913);
newNodeGeo.coordinates = [nnX, nnY];
let connected = {};
for (let k = 0; k < node.attributes.segIDs.length; k++) connected[node.attributes.segIDs[k]] = fastClone(W.model.segments.getObjectById(node.attributes.segIDs[k]).attributes.geoJSONGeometry);
multiaction.doSubAction(W.model, new WazeActionMoveNode(node, node.attributes.geoJSONGeometry, newNodeGeo, connected, {}));
}
W.model.actionManager.add(multiaction);
} else if (action === 'Diameter') {
let RASegs = getRASegs(segObj);
let raCenter = W.model.junctions.objects[segObj.WW.getAttributes().junctionID].attributes.geoJSONGeometry.coordinates;
let { lon: centerX, lat: centerY } = WazeWrap.Geometry.ConvertTo900913(raCenter[0], raCenter[1]);
let multiaction = new WazeActionMultiAction();
for (let i = 0; i < RASegs.length; i++) {
let s = W.model.segments.getObjectById(RASegs[i]);
let newGeo = fastClone(s.attributes.geoJSONGeometry);
for (let j = 1; j < newGeo.coordinates.length - 1; j++) {
let pt = s.attributes.geoJSONGeometry.coordinates[j];
let { lon: pX, lat: pY } = WazeWrap.Geometry.ConvertTo900913(pt[0], pt[1]);
let h = Math.sqrt(Math.abs(Math.pow(pX - centerX, 2) + Math.pow(pY - centerY, 2)));
let ratio = (h + val) / h;
let x = centerX + (pX - centerX) * ratio;
let y = centerY + (pY - centerY) * ratio;
let { lon: nX, lat: nY } = WazeWrap.Geometry.ConvertTo4326(x, y);
newGeo.coordinates[j] = [nX, nY];
}
multiaction.doSubAction(W.model, new WazeActionUpdateSegmentGeometry(s, s.attributes.geoJSONGeometry, newGeo));
let node = W.model.nodes.objects[s.attributes.toNodeID];
if (s.attributes.revDirection) node = W.model.nodes.objects[s.attributes.fromNodeID];
let newNodeGeo = fastClone(node.attributes.geoJSONGeometry);
let { lon: npX, lat: npY } = WazeWrap.Geometry.ConvertTo900913(newNodeGeo.coordinates[0], newNodeGeo.coordinates[1]);
let h = Math.sqrt(Math.abs(Math.pow(npX - centerX, 2) + Math.pow(npY - centerY, 2)));
let ratio = (h + val) / h;
let x = centerX + (npX - centerX) * ratio;
let y = centerY + (npY - centerY) * ratio;
let { lon: nX, lat: nY } = WazeWrap.Geometry.ConvertTo4326(x, y);
newNodeGeo.coordinates = [nX, nY];
let connected = {};
for (let k = 0; k < node.attributes.segIDs.length; k++) connected[node.attributes.segIDs[k]] = fastClone(W.model.segments.getObjectById(node.attributes.segIDs[k]).attributes.geoJSONGeometry);
multiaction.doSubAction(W.model, new WazeActionMoveNode(node, node.attributes.geoJSONGeometry, newNodeGeo, connected, {}));
}
W.model.actionManager.add(multiaction);
}
} catch (e) {}
}
};
// ===========================================================================
// MAIN INIT & STYLES
// ===========================================================================
function injectCSS() {
const css = `
.aa-window {
position:fixed; background:#fff; border-radius:8px; box-shadow:0 5px 15px rgba(0,0,0,0.3); z-index:9999;
font-family:'Cairo', sans-serif; overflow: hidden; resize: both; min-width: 200px; min-height: 200px;
}
.aa-header { padding:10px; color:#fff; cursor:move; display:flex; justify-content:space-between; align-items:center; font-weight:bold; font-size:14px; height: 35px; }
.aa-content { padding:10px; background:#f9f9f9; height: calc(100% - 35px); overflow-y:auto; box-sizing:border-box; }
.aa-close { cursor:pointer; font-weight:bold; font-size:18px; margin-left:10px; }
.aa-btn { width:100%; padding:8px; margin-top:5px; border:none; border-radius:4px; color:#fff; cursor:pointer; font-weight:800; font-size:14px; display:flex; align-items:center; justify-content:center; gap:5px; box-shadow: 0 2px 5px rgba(0,0,0,0.2); text-shadow: 1px 1px 2px rgba(0,0,0,0.5); }
.aa-btn:hover { filter: brightness(1.1); }
.aa-btn:active { transform: translateY(1px); box-shadow: none; }
.aa-input { width:100%; padding:6px; margin-bottom:5px; border:1px solid #ccc; border-radius:4px; box-sizing:border-box; font-family:'Cairo'; font-weight:bold; }
.aa-results { min-height:100px; border-top:1px solid #ddd; margin-top:5px; padding-top:5px; font-size:12px; }
.aa-item-row { padding:12px; border-bottom:1px solid #ddd; display:flex; justify-content:space-between; align-items:center; cursor:pointer; transition: background 0.2s; }
.aa-item-row:hover { background:#eee; }
.aa-ra-controls { display:grid; grid-template-columns:1fr 1fr 1fr; gap:5px; width:140px; margin:0 auto; }
/* Route Tester Specifics */
.rt-lbl { font-size: 11px; color: #555; margin-bottom: 5px; direction: ltr; text-align: left; padding: 6px; background: #fff; border: 1px solid #ccc; border-radius: 4px; overflow:hidden; white-space:nowrap; }
.rt-btn-a { background: #e3f2fd; color: #1565c0; border: 1px solid #bbdefb; text-shadow: none; box-shadow:none; }
.rt-btn-b { background: #fce4ec; color: #c2185b; border: 1px solid #f8bbd0; text-shadow: none; box-shadow:none; }
.rt-btn-go { background: #4caf50; } .rt-btn-clr { background: #f44336; }
#rt_msg { margin-top: 15px; font-size: 13px; color: #333; background: #fff3e0; padding: 10px; border: 1px solid #ffe0b2; border-radius: 4px; min-height: 20px; text-align: center; font-weight: bold; }
/* Setting Buttons with Checkbox */
.aa-setting-btn { opacity: 0.5; transition: all 0.2s; justify-content: flex-start; padding-left: 10px; position:relative; }
.aa-setting-btn.active { opacity: 1; box-shadow: inset 0 0 5px rgba(0,0,0,0.2); }
.aa-chk-box {
display: inline-block; width: 16px; height: 16px; background: rgba(255,255,255,0.3); border-radius: 3px;
margin-right: 5px; margin-left: 5px; text-align: center; line-height: 16px; font-size: 12px; color: #fff;
}
/* Validator Styles */
.aa-lock-opt { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; margin-bottom: 0; background: #f5f5f5; border: 1px solid #ddd; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold; color: #333; transition: all 0.2s ease; box-shadow: 0 1px 2px rgba(0,0,0,0.05); }
.aa-lock-opt:hover { background: #e0e0e0; transform: translateY(-1px); } .aa-lock-opt input { margin-left: 8px; }
#aa-qa-resize-handle { position: absolute; bottom: 0; left: 0; width: 15px; height: 15px; cursor: sw-resize; background: linear-gradient(45deg, transparent 50%, #2196F3 50%); z-index: 10; opacity: 0.7; }
.aa-qa-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-bottom: 8px; }
.aa-qa-chk-card { background: #fdfdfd; border: 1px solid #ddd; padding: 6px; border-radius: 4px; cursor: pointer; font-size: 11px; font-weight: bold; color: #555; display: flex; align-items: center; gap: 8px; height: 36px; transition: border 0.2s; }
.aa-qa-chk-card:hover { border-color: #999; } .aa-qa-chk-card input[type="checkbox"] { cursor: pointer; margin: 0; width: 14px; height: 14px; accent-color: #2196F3; } .aa-qa-chk-card:has(input:checked) { border-color: #2196F3; color: #333; background: #fff; box-shadow: 0 1px 3px rgba(33, 150, 243, 0.15); }
.aa-qa-grid-btn { grid-column: span 1; background: #4285F4; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 11px; display: flex; align-items: center; justify-content: center; box-shadow: 0 1px 2px rgba(0,0,0,0.1); height: 36px; }
.aa-qa-settings-box { background: #f9f9f9; border: 1px solid #eee; border-radius: 4px; padding: 8px; margin-top: 10px; font-size: 11px; color: #333; }
.aa-qa-setting-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.aa-qa-input { width: 40px; text-align: center; border: 1px solid #ccc; border-radius: 3px; padding: 2px; font-size: 11px; font-weight: bold; }
.aa-qa-pill { display: flex; background: #e0e0e0; border-radius: 3px; overflow: hidden; cursor: pointer; }
.aa-qa-pill-opt { padding: 3px 8px; font-size: 10px; font-weight: bold; color: #666; transition: 0.2s; }
.aa-qa-pill-opt.active { background: #2196F3; color: white; }
.aa-qa-action-row { display: flex; gap: 8px; margin-top: 12px; width: 100%; }
.aa-qa-btn { flex: 1; border: none; padding: 10px; border-radius: 4px; font-weight: bold; cursor: pointer; font-size: 13px; color: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2); }
.aa-btn-scan { background: #4CAF50; } .aa-btn-clear { background: #757575; }
/* Colors */
.aa-bg-gold { background: #FFD700; color: #000; } .aa-gold { background: #FFC107; color:#000; } .aa-bg-blue { background: #00B0FF; } .aa-blue { background: #0091EA; } .aa-bg-teal { background: #00E5FF; color:#000; } .aa-teal { background: #00B8D4; } .aa-bg-purple { background: #D500F9; } .aa-purple { background: #AA00FF; } .aa-bg-green { background: #00E676; color:#000; } .aa-green { background: #00C853; } .aa-bg-cyan { background: #18FFFF; color:#000; } .aa-cyan { background: #00B8D4; } .aa-bg-red { background: #FF1744; } .aa-red { background: #D50000; } .aa-bg-orange { background: #FF9800; color:#000; } .aa-bg-darkblue { background: #1565C0; } .aa-bg-white { background: #ffffff; color: #333; text-shadow: none; } .aa-txt-dark { color: #333; } .aa-gray { background: #78909C; } .aa-bg-indigo { background: #3F51B5; } .aa-indigo { background: #303F9F; } .rtl { direction: rtl; } .ltr { direction: ltr; } .aa-big-icon { font-size: 24px; padding: 5px 0; font-weight: 900; } .aa-huge-icon { font-size: 32px; padding: 5px 0; font-weight: 900; }
`;
const style = document.createElement('style');
style.innerHTML = css;
document.head.appendChild(style);
}
function buildSidebar() {
const userTabs = document.getElementById('user-info');
if (!userTabs) return;
const existingTab = document.getElementById('aa-suite-tab-content');
if(existingTab) existingTab.remove();
const existingLink = document.querySelector('ul.nav-tabs li a[href="#aa-suite-tab-content"]');
if(existingLink) existingLink.parentElement.remove();
const navTabs = userTabs.querySelector('.nav-tabs');
const tabContent = userTabs.querySelector('.tab-content');
if(!navTabs || !tabContent) return;
const addon = document.createElement('div');
addon.id = "aa-suite-tab-content";
addon.className = "tab-pane";
addon.style.padding = "10px";
const langOptions = [
{code: 'ar-IQ', name: 'العربية (العراق)'},
{code: 'ckb-IQ', name: 'Kurdî (Soranî)'},
{code: 'en-US', name: 'English (US)'}
].map(l => `<option value="${l.code}" ${l.code === currentLang ? 'selected' : ''}>${l.name}</option>`).join('');
addon.innerHTML = `
<div style="text-align:center; font-family:'Cairo', sans-serif;">
<div style="font-weight:bold; color:#000; margin-bottom:10px; padding-bottom:5px; border-bottom:3px solid #FFD700; font-size:16px;">${_t('main_title')}</div>
<select id="aa_lang_sel" class="aa-input" style="margin-bottom:15px; text-align:center;">${langOptions}</select>
<button id="btn_open_city" class="aa-btn aa-bg-gold"><i class="fa fa-building"></i> ${_t('btn_city')}</button>
<button id="btn_open_places" class="aa-btn aa-bg-blue"><i class="fa fa-map-marker"></i> ${_t('btn_places')}</button>
<button id="btn_open_editors" class="aa-btn aa-bg-purple"><i class="fa fa-users"></i> ${_t('btn_editors')}</button>
<button id="btn_open_lock" class="aa-btn aa-bg-cyan"><i class="fa fa-lock"></i> ${_t('btn_lock')}</button>
<div style="height:2px; background:#ccc; margin:10px 0;"></div>
<button id="btn_open_speed" class="aa-btn aa-bg-red"><i class="fa fa-tachometer"></i> ${_t('btn_speed')}</button>
<button id="btn_open_qa" class="aa-btn aa-bg-orange"><i class="fa fa-bug"></i> ${_t('btn_qa')}</button>
<button id="btn_open_adv" class="aa-btn aa-bg-indigo"><i class="fa fa-filter"></i> ${_t('btn_adv')}</button>
<button id="btn_open_ra" class="aa-btn aa-bg-green"><i class="fa fa-refresh"></i> ${_t('btn_ra')}</button>
<button id="btn_open_route" class="aa-btn aa-bg-darkblue"><i class="fa fa-road"></i> ${_t('btn_route')}</button>
<div style="margin-top:15px; font-size:10px; color:#555; font-weight:bold;">v${SCRIPT_VERSION}</div>
</div>
`;
const newtab = document.createElement('li');
newtab.innerHTML = '<a href="#aa-suite-tab-content" data-toggle="tab" title="Abdullah Abbas WME Tools">Abdullah Abbas WME Tools</a>';
navTabs.appendChild(newtab);
tabContent.appendChild(addon);
document.getElementById('aa_lang_sel').onchange = (e) => {
currentLang = e.target.value;
localStorage.setItem('AA_Lang', currentLang);
buildSidebar();
document.querySelectorAll('.aa-window').forEach(w => w.remove());
};
document.getElementById('btn_open_city').onclick = CityExplorer.init;
document.getElementById('btn_open_places').onclick = PlacesExplorer.init;
document.getElementById('btn_open_editors').onclick = EditorExplorer.init;
document.getElementById('btn_open_ra').onclick = RoundaboutEditor.init;
document.getElementById('btn_open_lock').onclick = LockIndicator.init;
document.getElementById('btn_open_qa').onclick = ValidatorCleanUI.init;
document.getElementById('btn_open_adv').onclick = AdvancedSelection.init;
document.getElementById('btn_open_speed').onclick = SpeedIndicator.init;
document.getElementById('btn_open_route').onclick = RouteTester.init;
}
function bootstrap(tries=1) {
if (typeof W !== 'undefined' && W.map && W.model && document.getElementById('user-info')) {
const savedLang = localStorage.getItem('AA_Lang');
if(savedLang && STRINGS[savedLang]) currentLang = savedLang;
injectCSS();
buildSidebar();
console.log(`${SCRIPT_NAME} v${SCRIPT_VERSION} Loaded.`);
} else if (tries < 50) {
setTimeout(() => bootstrap(tries+1), 200);
}
}
bootstrap();
})();