// ==================== POINTR STATE ====================
let pointrIdCounter = 0;
const pointrData = new Map(); // new/edited pointrs (to be saved/updated)
const pointrReload = new Map(); // pointrs loaded from DB (read-only until edited)
const renderedDbIds = new Set(); // tracks DB position ids already rendered — prevents duplicate render
let activePointrId = null;
const selectedPointrIds = new Set(); // multi-select via shift+click
let isDragging = false;
let activeStreetPathId = null; // street currently being visualised with a path
// ==================== RELOAD HELPERS ====================
/**
* Move a reload pointr into pointrData so it gets included in the next save (update path).
* No-op if the pointr is already in pointrData or doesn't exist.
*/
function markPointrAsEdited(pointrId) {
if (!pointrReload.has(pointrId)) return;
const data = pointrReload.get(pointrId);
pointrData.set(pointrId, data);
pointrReload.delete(pointrId);
if (data.element) data.element.classList.remove('pointr-reload');
console.log(`[pointr] #${pointrId} moved to edit queue (dbId=${data.dbId})`);
}
/**
* Fetch ALL positions from DB and render them as reload pointrs.
* Street names are resolved from allStreetsData.
* Already-loaded positions (by dbId) are skipped to avoid duplicates.
*/
async function loadAllPointrsFromDB() {
// renderedDbIds is module-level; also sync from current maps in case of hot-reload edge cases
pointrReload.forEach(d => { if (d.dbId) renderedDbIds.add(d.dbId); });
pointrData.forEach(d => { if (d.dbId) renderedDbIds.add(d.dbId); });
try {
const _apiBase = location.hostname === 'localhost' ? 'http://localhost:4000/api' : '/api';
const resp = await fetch(`${_apiBase}/positions`, { headers: { 'X-API-Key': sessionStorage.getItem('admin_api_key') || '' } });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const positions = await resp.json();
if (!Array.isArray(positions) || positions.length === 0) return;
const sizePer = parseInt(document.getElementById('sSize').value) / 2400;
const coordinatesEl = document.querySelector('.coordinates');
positions.forEach(pos => {
if (!pos.id || renderedDbIds.has(pos.id)) return;
// pos.y = lat, pos.x = lon (from backend _db_pos_to_model)
if (pos.y == null || pos.x == null) return;
// Round coordinates to 6 decimal places before rendering
const lat = Math.round(pos.y * 1e6) / 1e6;
const lon = Math.round(pos.x * 1e6) / 1e6;
// Resolve street name(s) from allStreetsData
const streetIds = Array.isArray(pos.streets) ? pos.streets : [];
const streetName = streetIds
.map(sid => (typeof allStreetsData !== 'undefined' ? allStreetsData : []).find(s => s.id === sid)?.name)
.filter(Boolean)
.join(' - ');
const pixelPos = latLonToPixel(lat, lon, sizePer);
const { pointr, pointrId } = createPointr(pixelPos.left, pixelPos.top, streetName, lat, lon);
// createPointr adds to pointrData; move the entry to pointrReload
const data = pointrData.get(pointrId);
if (data) {
data.dbId = pos.id;
data.streets = streetIds;
pointrReload.set(pointrId, data);
pointrData.delete(pointrId);
}
// Sync streetPointsList entry with DB values
const sp = streetPointsList.find(p => p.id === pointrId);
if (sp) {
sp.streetId = streetIds[0] ?? null;
sp.ban = Array.isArray(pos.ban) ? pos.ban : [];
sp.speed = pos.speed ?? null;
sp.park = pos.park ?? null;
sp.lane = pos.lane ?? null;
sp.toll = pos.tool ?? null; // DB column is 'tool'
sp.flooding = pos.flooding ?? null;
}
pointr.classList.add('pointr-reload');
coordinatesEl.appendChild(pointr);
renderedDbIds.add(pos.id);
});
console.log(`[loadAllPointrsFromDB] loaded ${positions.length} position(s)`);
} catch (err) {
console.error('[loadAllPointrsFromDB] Error:', err);
}
}
// ==================== STREET POINTS LIST ====================
let streetPointsList = []; // [{ id, streetId, x, y, ban, speed, park, lane, toll, flooding }]
function getCurrentStreetId() {
const val = document.getElementById('Streets').value;
const parsed = parseInt(val);
return (!isNaN(parsed) && parsed > 0) ? parsed : null;
}
function logStreetPoints() {
console.log('[streetPointsList]', JSON.parse(JSON.stringify(streetPointsList)));
}
function addToStreetPoints(pointrId, x, y) {
streetPointsList.push({
id: pointrId,
streetId: getCurrentStreetId(),
x, y,
ban: [], speed: null, park: null, lane: null, toll: null, flooding: null
});
logStreetPoints();
}
function removeFromStreetPoints(pointrId) {
streetPointsList = streetPointsList.filter(p => p.id !== pointrId);
logStreetPoints();
}
function updateStreetPoint(pointrId, key, value) {
const point = streetPointsList.find(p => p.id === pointrId);
if (!point) return;
if (key === 'ban') { point.ban.push(value); } else { point[key] = value; }
logStreetPoints();
}
// ==================== POINTR POSITIONS ====================
function updatePointrPositions() {
const container = document.querySelector('.container');
const scrollLeft = container.scrollLeft;
const scrollTop = container.scrollTop;
const applyPos = (data) => {
data.element.style.left = (data.initialClientX - scrollLeft) + 'px';
data.element.style.top = (data.initialClientY - scrollTop) + 'px';
};
pointrData.forEach(applyPos);
pointrReload.forEach(applyPos);
refreshStreetPath();
if (typeof updatePoiMarkerPositions === 'function') updatePoiMarkerPositions();
}
// ==================== STREET PATH ====================
function collectStreetPointrs(streetId) {
const pts = [];
pointrReload.forEach(d => { if (Array.isArray(d.streets) && d.streets.includes(streetId)) pts.push(d); });
pointrData.forEach(d => { if (Array.isArray(d.streets) && d.streets.includes(streetId)) pts.push(d); });
return pts;
}
// Geographic distance squared (in degrees²) — good enough for relative comparison within a city
function _geoDist2(a, b) {
const dLat = (a.lat ?? 0) - (b.lat ?? 0);
const dLon = (a.lon ?? 0) - (b.lon ?? 0);
// scale lon by cos(lat) to approximate equal-distance in both axes
const cosLat = Math.cos(((a.lat ?? 0) * Math.PI) / 180);
return dLat * dLat + (dLon * dLon * cosLat * cosLat);
}
function nearestNeighborSort(pts) {
if (pts.length <= 1) return [...pts];
// Find the pair of points with the maximum distance (diameter of the set).
// One of those endpoints is the natural start of the street path.
let maxD = -1, startIdx = 0;
for (let i = 0; i < pts.length; i++) {
for (let j = i + 1; j < pts.length; j++) {
const d = _geoDist2(pts[i], pts[j]);
if (d > maxD) { maxD = d; startIdx = i; }
}
}
const rem = [...pts];
const result = [];
let cur = rem.splice(startIdx, 1)[0];
result.push(cur);
while (rem.length > 0) {
let minD = Infinity, minI = 0;
for (let i = 0; i < rem.length; i++) {
const d = _geoDist2(rem[i], cur);
if (d < minD) { minD = d; minI = i; }
}
cur = rem.splice(minI, 1)[0];
result.push(cur);
}
return result;
}
function drawStreetPath(streetId) {
activeStreetPathId = streetId;
refreshStreetPath();
}
function clearStreetPath() {
activeStreetPathId = null;
const svg = document.getElementById('street-path-svg');
if (svg) svg.innerHTML = '';
}
const _PATH_COLORS = ['#2196F3','#E91E63','#4CAF50','#FF9800','#9C27B0','#00BCD4','#FF5722','#8BC34A','#F44336','#3F51B5'];
function _appendPolyline(svg, pts, color) {
const sorted = nearestNeighborSort(pts);
const container = document.querySelector('.container');
const sl = container.scrollLeft;
const st = container.scrollTop;
const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
polyline.setAttribute('points', sorted.map(d => `${d.initialClientX - sl},${d.initialClientY - st}`).join(' '));
polyline.setAttribute('fill', 'none');
polyline.setAttribute('stroke', color);
polyline.setAttribute('stroke-width', '2');
polyline.setAttribute('stroke-dasharray', '6,3');
polyline.setAttribute('opacity', '0.8');
svg.appendChild(polyline);
}
function refreshStreetPath() {
const svg = document.getElementById('street-path-svg');
if (!svg) return;
svg.innerHTML = '';
const showAll = document.getElementById('sIsStreetPath')?.checked;
if (showAll) {
// Draw paths for all streets that have pointrs (Maps → iterate values)
const streetIds = new Set();
pointrReload.forEach(d => { if (Array.isArray(d.streets)) d.streets.forEach(sid => streetIds.add(sid)); });
pointrData.forEach(d => { if (Array.isArray(d.streets)) d.streets.forEach(sid => streetIds.add(sid)); });
let colorIdx = 0;
streetIds.forEach(sid => {
const pts = collectStreetPointrs(sid);
if (pts.length >= 2) {
const color = sid === activeStreetPathId
? '#2196F3'
: _PATH_COLORS[(colorIdx + 1) % _PATH_COLORS.length];
colorIdx++;
_appendPolyline(svg, pts, color);
}
});
// Also draw active street on top if not already covered
if (activeStreetPathId && !streetIds.has(activeStreetPathId)) {
const pts = collectStreetPointrs(activeStreetPathId);
if (pts.length >= 2) _appendPolyline(svg, pts, '#2196F3');
}
return;
}
// Default: draw only the active street
if (!activeStreetPathId) return;
const pts = collectStreetPointrs(activeStreetPathId);
if (pts.length < 2) return;
_appendPolyline(svg, pts, '#2196F3');
}
// ==================== POINTR CREATION ====================
function createPointr(clientX, clientY, title = '', lat = null, lon = null) {
const pointrId = ++pointrIdCounter;
const pointr = document.createElement('div');
pointr.className = 'pointr';
pointr.id = `pointr-${pointrId}`;
pointr.style.left = clientX + 'px';
pointr.style.top = clientY + 'px';
if (title) {
const tooltip = document.createElement('div');
tooltip.className = 'pointr-tooltip';
tooltip.textContent = title;
pointr.appendChild(tooltip);
}
const currentStreetId = getCurrentStreetId();
pointrData.set(pointrId, {
id: pointrId,
initialClientX: clientX,
initialClientY: clientY,
lat: lat,
lon: lon,
streets: currentStreetId ? [currentStreetId] : [],
element: pointr,
timestamp: new Date().toISOString()
});
addToStreetPoints(pointrId, clientX, clientY);
console.log(pointrId);
pointr.addEventListener('mousedown', (e) => {
const editor = document.querySelector('#editor');
if (editor.value !== 'move') return;
activePointrId = pointrId;
updateActivePointrVisual();
isDragging = true;
e.stopPropagation();
e.preventDefault();
});
pointr.addEventListener('dblclick', (e) => {
e.stopPropagation();
deletePointr(pointrId);
});
pointr.addEventListener('click', (e) => {
e.stopPropagation();
const editor = document.querySelector('#editor');
if (e.shiftKey) {
// Toggle multi-select; no submenu
if (selectedPointrIds.has(pointrId)) {
selectedPointrIds.delete(pointrId);
} else {
selectedPointrIds.add(pointrId);
}
closePointrSubmenu();
updateActivePointrVisual();
return;
}
// Regular click — clear multi-selection
selectedPointrIds.clear();
if (editor.value === 'move') {
activePointrId = pointrId;
updateActivePointrVisual();
}
showPointrSubmenu(pointrId, pointr);
});
return { pointr, pointrId };
}
function updateActivePointrVisual() {
document.querySelectorAll('.pointr').forEach(p => {
p.classList.remove('active');
p.classList.remove('selected');
});
selectedPointrIds.forEach(pid => {
const el = document.getElementById(`pointr-${pid}`);
if (el) el.classList.add('selected');
});
if (activePointrId) {
const el = document.getElementById(`pointr-${activePointrId}`);
if (el) el.classList.add('active');
}
}
// ==================== POINTR SUB-MENU ====================
let submenuEl = null;
function closePointrSubmenu() {
if (submenuEl) { submenuEl.remove(); submenuEl = null; }
}
function showPointrSubmenu(pointrId, anchorEl) {
closePointrSubmenu();
const rect = anchorEl.getBoundingClientRect();
const menu = document.createElement('div');
menu.className = 'pointr-submenu';
menu.style.left = (rect.right + 8) + 'px';
menu.style.top = (rect.top - 4) + 'px';
const actions = [
{ label: 'Set Node', action: 'set-node' },
{ label: 'Add Ban', action: 'add-ban' },
{ label: 'Set Speed', action: 'set-speed' },
{ label: 'Set Park', action: 'set-park' },
{ label: 'Set Lane', action: 'set-lane' },
{ label: 'Set Toll', action: 'set-toll' },
{ label: 'Set Flooding', action: 'set-flooding' },
{ label: 'Delete', action: 'delete', danger: true },
];
actions.forEach(({ label, action, danger }) => {
const btn = document.createElement('button');
btn.textContent = label;
if (danger) btn.classList.add('btn-danger');
btn.addEventListener('click', (e) => {
e.stopPropagation();
closePointrSubmenu();
handleSubmenuAction(action, pointrId);
});
menu.appendChild(btn);
});
submenuEl = menu;
document.querySelector('.coordinates').appendChild(menu);
}
function handleSubmenuAction(action, pointrId) {
// Any edit action on a reload pointr moves it into pointrData for update
if (action !== 'delete') markPointrAsEdited(pointrId);
switch (action) {
case 'set-node': showSetNodeModal(pointrId); break;
case 'add-ban': showAddBanModal(pointrId); break;
case 'set-speed': showSetIntModal(pointrId, 'speed', 'Set Speed', 'Tốc độ (km/h)'); break;
case 'set-park': showSetBoolModal(pointrId, 'park', 'Set Park'); break;
case 'set-lane': showSetIntModal(pointrId, 'lane', 'Set Lane', 'Số làn đường'); break;
case 'set-toll': showSetIntModal(pointrId, 'toll', 'Set Toll', 'Phí đường (VNĐ)'); break;
case 'set-flooding': showSetBoolModal(pointrId, 'flooding', 'Set Flooding'); break;
case 'delete': deletePointr(pointrId); break;
}
}
function deletePointr(pointrId) {
closePointrSubmenu();
const data = pointrData.get(pointrId) || pointrReload.get(pointrId);
if (data) {
// If this pointr exists in DB, delete it cascade (remove from Positions + Streets.positions)
if (data.dbId) {
const _apiBaseCascade = location.hostname === 'localhost' ? 'http://localhost:4000/api' : '/api';
fetch(`${_apiBaseCascade}/positions/${data.dbId}/cascade`, { method: 'DELETE', headers: { 'X-API-Key': sessionStorage.getItem('admin_api_key') || '' } })
.then(r => { if (!r.ok) r.text().then(t => console.error('[deletePointr] cascade failed:', t)); })
.catch(err => console.error('[deletePointr] cascade error:', err));
}
if (data.dbId) renderedDbIds.delete(data.dbId);
data.element.remove();
pointrData.delete(pointrId);
pointrReload.delete(pointrId);
}
removeFromStreetPoints(pointrId);
selectedPointrIds.delete(pointrId);
if (activePointrId === pointrId) { activePointrId = null; updateActivePointrVisual(); }
refreshStreetPath();
}
// ==================== POINTR MODALS ====================
// Return streets from allStreetsData that have at least one pointr within radiusKm of (lat, lon)
function getStreetsNearPoint(lat, lon, radiusKm) {
if (lat == null || lon == null) return allStreetsData || [];
const R = 6371;
function haverDist(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
const nearSids = new Set();
const check = (d) => {
if (d.lat == null || d.lon == null) return;
if (haverDist(lat, lon, d.lat, d.lon) <= radiusKm) {
(d.streets || []).forEach(sid => nearSids.add(sid));
}
};
pointrReload.forEach(check);
pointrData.forEach(check);
const nearby = (allStreetsData || []).filter(s => nearSids.has(s.id));
return nearby.length > 0 ? nearby : (allStreetsData || []);
}
function showSetNodeModal(pointrId) {
const data = pointrData.get(pointrId);
if (!data) return;
const currentStreets = data.streets || [];
const availableStreets = getStreetsNearPoint(data.lat, data.lon, 0.8);
let tableHTML = `
| Street ID |
Street Name |
Action |
`;
currentStreets.forEach((streetId, index) => {
const street = availableStreets.find(s => s.id === streetId);
const streetName = street ? street.name : 'Unknown';
tableHTML += `
| ${streetId} |
${streetName} |
|
`;
});
tableHTML += `
`;
const streetsOptions = availableStreets.map(s => ``).join('');
openModal(`
Set Node Streets
${tableHTML}
`);
// Handle delete buttons
document.querySelectorAll('.btn-remove-street').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.target.closest('tr').remove();
});
});
// Handle add button
document.querySelector('.btn-add-street').addEventListener('click', (e) => {
e.preventDefault();
const select = document.getElementById('newStreetSelect');
const streetId = parseInt(select.value);
if (!streetId) {
document.getElementById('modalError').textContent = 'Vui lòng chọn một street.';
return;
}
const tbody = document.getElementById('nodeStreetsBody');
const street = availableStreets.find(s => s.id === streetId);
const newIndex = currentStreets.length;
const row = document.createElement('tr');
row.setAttribute('data-street-id', streetId);
row.innerHTML = `
${streetId} |
${street.name} |
|
`;
tbody.appendChild(row);
currentStreets.push(streetId);
// Attach delete handler to new button
row.querySelector('.btn-remove-street').addEventListener('click', (e) => {
e.preventDefault();
row.remove();
});
select.value = '';
document.getElementById('modalError').textContent = '';
});
// Handle save
document.getElementById('nodeStreetsSave').addEventListener('click', (e) => {
e.preventDefault();
const rows = document.querySelectorAll('#nodeStreetsBody tr');
const newStreets = [];
rows.forEach(row => {
const streetId = parseInt(row.getAttribute('data-street-id'));
if (streetId) newStreets.push(streetId);
});
data.streets = newStreets;
// Update tooltip to reflect new street names
const tooltip = data.element ? data.element.querySelector('.pointr-tooltip') : null;
if (tooltip) {
const names = newStreets
.map(sid => (typeof allStreetsData !== 'undefined' ? allStreetsData : []).find(s => s.id === sid)?.name)
.filter(Boolean);
tooltip.textContent = names.join(' - ') || tooltip.textContent;
}
closeModal();
});
}
function showAddBanModal(pointrId) {
openModal(`
`);
document.getElementById('createForm').addEventListener('submit', (e) => {
e.preventDefault();
const type = document.getElementById('f_type').value.trim();
const hour = parseInt(document.getElementById('f_hour').value);
const weightRaw = document.getElementById('f_weight').value;
const weight = weightRaw !== '' ? parseInt(weightRaw) : null;
if (!type || isNaN(hour)) { document.getElementById('modalError').textContent = 'Vui lòng điền đầy đủ.'; return; }
updateStreetPoint(pointrId, 'ban', { type, hour, weight });
closeModal();
});
}
function showSetIntModal(pointrId, key, title, label) {
const current = streetPointsList.find(p => p.id === pointrId)?.[key];
openModal(`
`);
if (current !== null && current !== undefined) {
document.getElementById('f_value').value = current;
}
document.getElementById('createForm').addEventListener('submit', (e) => {
e.preventDefault();
const value = parseInt(document.getElementById('f_value').value);
if (isNaN(value)) { document.getElementById('modalError').textContent = 'Vui lòng nhập số nguyên.'; return; }
updateStreetPoint(pointrId, key, value);
closeModal();
});
}
function showSetBoolModal(pointrId, key, title) {
const current = streetPointsList.find(p => p.id === pointrId)?.[key];
openModal(`
`);
if (current !== null && current !== undefined) {
const radio = document.querySelector(`input[name="f_bool"][value="${current}"]`);
if (radio) radio.checked = true;
}
document.getElementById('createForm').addEventListener('submit', (e) => {
e.preventDefault();
const selected = document.querySelector('input[name="f_bool"]:checked');
if (!selected) { document.getElementById('modalError').textContent = 'Vui lòng chọn một giá trị.'; return; }
updateStreetPoint(pointrId, key, selected.value === 'true');
closeModal();
});
}
// ==================== SAVE ALL / SUMMARY MODAL ====================
function showSaveAllModal() {
// Collect unique pointrs — each appears once, carrying its full streets array
const pointrsList = [];
pointrData.forEach((data) => {
const streets = Array.isArray(data.streets)
? data.streets.filter(sid => {
if (typeof allStreetsData !== 'undefined' && Array.isArray(allStreetsData)) {
return !!allStreetsData.find(s => s.id === sid);
}
return true;
})
: [];
if (streets.length === 0) return; // skip pointrs not linked to any known street
const tooltip = data.element ? data.element.querySelector('.pointr-tooltip') : null;
const name = tooltip ? tooltip.textContent
: (data.lat != null ? `${data.lat.toFixed(6)}, ${data.lon.toFixed(6)}` : `Pointr ${data.id}`);
pointrsList.push({ id: data.id, name, lat: data.lat, lon: data.lon, streets, dbId: data.dbId || null });
});
// Edited POIs queued for bulk update (poi.js owns this list)
const poisList = (typeof getEditedPoisForSave === 'function') ? getEditedPoisForSave() : [];
if (pointrsList.length === 0 && poisList.length === 0) {
openModal(`
Save Pointrs
Không có Pointr/POI nào cần lưu.
`);
return;
}
let html = `Save Pointrs
`;
if (pointrsList.length > 0) {
html += `
Pointrs (${pointrsList.length}):
`;
pointrsList.forEach(pt => {
const streetNames = pt.streets
.map(sid => (typeof allStreetsData !== 'undefined' ? allStreetsData : []).find(s => s.id === sid)?.name)
.filter(Boolean).join(' - ');
html += `- ${escapeHtml(pt.name)} [${escapeHtml(streetNames)}] (#${pt.id}${pt.dbId ? ' ✏️ update' : ' 🆕 new'})
`;
});
html += `
`;
}
if (poisList.length > 0) {
html += `
POIs (${poisList.length}):
`;
poisList.forEach(p => {
html += `- ${escapeHtml(p.name || ('POI #' + p.poi_id))} [${p.lat.toFixed(6)}, ${p.long.toFixed(6)}] (#${p.poi_id} ✏️ update)
`;
});
html += `
`;
}
html += `
`;
html += `
`;
openModal(html);
// Attach Confirm handler
document.getElementById('confirmSaveAll').addEventListener('click', async (e) => {
e.preventDefault();
const confirmBtn = document.getElementById('confirmSaveAll');
confirmBtn.disabled = true;
confirmBtn.textContent = 'Saving...';
// payload: [{lat, lon, streets: [...], pos_id?}] — one entry per unique position
const payload = pointrsList
.filter(p => typeof p.lat === 'number' && !isNaN(p.lat) && typeof p.lon === 'number' && !isNaN(p.lon))
.map(p => {
const pt = { lat: p.lat, lon: p.lon, streets: p.streets };
if (p.dbId) pt.pos_id = p.dbId;
return pt;
});
console.log('positions bulk payload:', JSON.stringify(payload));
try {
const _apiBaseBulk = location.hostname === 'localhost' ? 'http://localhost:4000/api' : '/api';
// 1) Save pointrs (if any)
if (payload.length > 0) {
const resp = await fetch(`${_apiBaseBulk}/positions/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': sessionStorage.getItem('admin_api_key') || '' },
body: JSON.stringify(payload)
});
if (!resp.ok) {
const txt = await resp.text();
throw new Error(txt || 'Server error');
}
const result = await resp.json();
const created = result.created || result;
// Build lat/lon → server row lookup for dbId assignment
const latLonToServer = new Map();
if (Array.isArray(created)) {
created.forEach(pos => {
const lat = parseFloat(pos.lat ?? pos.y);
const lon = parseFloat(pos.long ?? pos.x);
if (!isNaN(lat) && !isNaN(lon)) {
latLonToServer.set(`${lat.toFixed(9)},${lon.toFixed(9)}`, pos);
}
});
}
pointrsList.forEach(pt => {
const data = pointrData.get(pt.id);
if (!data) return;
const key = `${Number(pt.lat).toFixed(9)},${Number(pt.lon).toFixed(9)}`;
const pos = latLonToServer.get(key);
if (pos) {
data.dbId = pos.id;
data.streets = Array.isArray(pos.streets) ? pos.streets : (data.streets || []);
const sp = streetPointsList.find(p => p.id === pt.id);
if (sp) {
sp.streetId = data.streets[0] ?? sp.streetId;
sp.ban = pos.ban ?? sp.ban;
sp.speed = pos.speed ?? sp.speed;
sp.park = pos.park ?? sp.park;
sp.lane = pos.lane ?? sp.lane;
sp.toll = pos.tool ?? sp.toll;
sp.flooding = pos.flooding ?? sp.flooding;
}
} else if (pt.dbId) {
data.dbId = pt.dbId;
}
if (data.dbId) renderedDbIds.add(data.dbId);
pointrReload.set(pt.id, data);
pointrData.delete(pt.id);
if (data.element) data.element.classList.add('pointr-reload');
});
}
// 2) Save POIs (if any)
let poiSavedCount = 0;
if (poisList.length > 0 && typeof savePoisBulk === 'function') {
const poiRes = await savePoisBulk();
poiSavedCount = poiRes.saved || 0;
}
closeModal();
const parts = [];
if (payload.length > 0) parts.push(`${payload.length} pointr(s)`);
if (poiSavedCount > 0) parts.push(`${poiSavedCount} POI(s)`);
alert('Saved ' + (parts.join(' + ') || 'nothing') + ' successfully.');
} catch (err) {
console.error(err);
const errEl = document.getElementById('modalError');
if (errEl) errEl.textContent = 'Lỗi khi lưu: ' + (err.message || err);
confirmBtn.disabled = false;
confirmBtn.textContent = 'Confirm';
}
});
}
// ==================== POINTR MOVEMENT ====================
function movePointr(direction) {
if (!activePointrId) return;
markPointrAsEdited(activePointrId);
const data = pointrData.get(activePointrId);
if (!data) return;
const step = 0.5;
switch (direction) {
case 'ArrowUp': data.initialClientY -= step; break;
case 'ArrowDown': data.initialClientY += step; break;
case 'ArrowLeft': data.initialClientX -= step; break;
case 'ArrowRight': data.initialClientX += step; break;
}
const sizePer = parseInt(document.getElementById('sSize').value) / 2400;
const { lat: newLat, lon: newLon } = pixelToLatLon(data.initialClientX, data.initialClientY, sizePer);
data.lat = newLat;
data.lon = newLon;
updatePointrPositions();
}
function rescalePointrs(oldSize, newSize) {
if (!oldSize || !newSize || oldSize === newSize) return;
const ratio = newSize / oldSize;
const sizePer = newSize / 2400;
const rescaleEntry = (data) => {
if (data.lat !== null && data.lon !== null) {
const pos = latLonToPixel(data.lat, data.lon, sizePer);
data.initialClientX = pos.left;
data.initialClientY = pos.top;
} else {
data.initialClientX *= ratio;
data.initialClientY *= ratio;
}
};
pointrData.forEach(rescaleEntry);
pointrReload.forEach(rescaleEntry);
updatePointrPositions();
}
// ==================== GALLERY INTERACTION ====================
function enableGalleryDragScroll() {
const gallery = document.querySelector('.container');
const toolbar = document.querySelector('.toolbar');
let isMouseDown = false;
let startX = 0, startY = 0, scrollLeft = 0, scrollTop = 0;
gallery.addEventListener('mousedown', (e) => {
if (toolbar && toolbar.contains(e.target)) return;
if (e.target.tagName === 'SELECT' || e.target.tagName === 'INPUT') return;
isMouseDown = true;
startX = e.pageX - gallery.offsetLeft;
startY = e.pageY - gallery.offsetTop;
scrollLeft = gallery.scrollLeft;
scrollTop = gallery.scrollTop;
e.preventDefault();
});
gallery.addEventListener('mousemove', (e) => {
if (!isMouseDown) return;
gallery.scrollLeft = scrollLeft - (e.pageX - gallery.offsetLeft - startX);
gallery.scrollTop = scrollTop - (e.pageY - gallery.offsetTop - startY);
});
document.addEventListener('mouseup', () => { isMouseDown = false; });
gallery.addEventListener('mouseleave', () => { isMouseDown = false; });
}
function handleGalleryClick(e) {
const toolbar = document.querySelector('.toolbar');
if (toolbar && toolbar.contains(e.target)) return;
if (e.target.tagName === 'SELECT' || e.target.tagName === 'INPUT') return;
if (e.target.classList.contains('pointr')) {
const editor = document.querySelector('#editor');
if (editor.value === 'move') {
activePointrId = parseInt(e.target.id.split('-')[1]);
updateActivePointrVisual();
}
return;
}
const editor = document.querySelector('#editor');
if (editor.value !== 'create') return;
//use pixelToLatLon to get lat/lon of click position before creating pointr
const container = document.querySelector('.container');
const clickX = e.clientX + container.scrollLeft;
const clickY = e.clientY + container.scrollTop;
const sizePer = parseInt(document.getElementById('sSize').value) / 2400;
const { lat, lon } = pixelToLatLon(clickX, clickY, sizePer);
const streetSelect = document.getElementById('Streets');
const streetValue = streetSelect.value;
// If Streets is 'all' but a City and District are selected, prompt to create a new street
const cityVal = document.getElementById('Cities').value;
const districtVal = document.getElementById('Districts').value;
if (streetValue === 'all' && cityVal && cityVal !== 'all' && parseInt(cityVal) > 0 && districtVal && districtVal !== 'all' && parseInt(districtVal) > 0) {
// Create a temporary pointr now so user sees the marker immediately.
const { pointr, pointrId } = createPointr(clickX, clickY, 'Tạm', lat, lon);
// mark as temporary so UI can indicate it's pending street assignment
pointr.classList.add('pointr-temp');
document.querySelector('.coordinates').appendChild(pointr);
refreshStreetPath();
// Open create-street modal; when new street is created, assign it to this pointr
showCreateStreetModal((newStreetId) => {
try { document.getElementById('Streets').value = newStreetId; } catch (e) {}
const streetName = document.getElementById('Streets').selectedOptions[0]?.text || 'New Street';
const data = pointrData.get(pointrId);
if (data) {
data.streets = [parseInt(newStreetId)];
const tooltip = data.element ? data.element.querySelector('.pointr-tooltip') : null;
if (tooltip) tooltip.textContent = streetName;
}
const sp = streetPointsList.find(p => p.id === pointrId);
if (sp) sp.streetId = parseInt(newStreetId);
pointr.classList.remove('pointr-temp');
refreshStreetPath();
}, () => {
// User cancelled → remove the temp pointr
deletePointr(pointrId);
});
return;
}
const streetName = streetSelect.options[streetSelect.selectedIndex]?.text || 'Unknown Street';
const { pointr } = createPointr(clickX, clickY, streetName , lat, lon);
document.querySelector('.coordinates').appendChild(pointr);
refreshStreetPath();
}
// ==================== INIT ====================
function initGalleryClickListener() {
document.querySelector('.gallery').addEventListener('click', handleGalleryClick);
}
function initPathSvg() {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.id = 'street-path-svg';
svg.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:49;overflow:visible;';
document.querySelector('.coordinates').appendChild(svg);
}
function initContainerScrollListener() {
document.querySelector('.container').addEventListener('scroll', updatePointrPositions);
}
function initKeyboardListener() {
document.addEventListener('keydown', (e) => {
// Skip if focus is inside an input/textarea/select
const tag = document.activeElement?.tagName;
const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
if (!inInput) {
// N -> Create Point
if (e.key === 'n' || e.key === 'N') {
const ed = document.getElementById('editor');
ed.value = 'create'; ed.dispatchEvent(new Event('change'));
e.preventDefault();
return;
}
// M -> Move Point
if (e.key === 'm' || e.key === 'M') {
const ed = document.getElementById('editor');
ed.value = 'move'; ed.dispatchEvent(new Event('change'));
e.preventDefault();
return;
}
// V -> toggle View All Street Paths (sIsStreetPath)
if (e.key === 'v' || e.key === 'V') {
const cb = document.getElementById('sIsStreetPath');
if (cb) { cb.checked = !cb.checked; refreshStreetPath(); }
e.preventDefault();
return;
}
// B -> toggle Street Panel
if (e.key === 'b' || e.key === 'B') {
document.getElementById('streetPanelToggle')?.click();
e.preventDefault();
return;
}
// T -> reset Streets dropdown to "all"
if (e.key === 't' || e.key === 'T') {
const sel = document.getElementById('Streets');
if (sel) { sel.value = 'all'; sel.dispatchEvent(new Event('change')); }
e.preventDefault();
return;
}
// + / - -> increase / decrease tile size by 50px
if (e.key === '+' || e.key === '=') {
const sl = document.getElementById('sSize');
if (sl) { sl.value = parseInt(sl.value) + 50; sl.dispatchEvent(new Event('change')); }
e.preventDefault();
return;
}
if (e.key === '-' || e.key === '_') {
const sl = document.getElementById('sSize');
if (sl) { sl.value = Math.max(50, parseInt(sl.value) - 50); sl.dispatchEvent(new Event('change')); }
e.preventDefault();
return;
}
// < / > -> decrease / increase opacity by 5
if (e.key === '<' || e.key === ',') {
const sl = document.getElementById('sOpacity');
if (sl) { sl.value = Math.max(0, parseInt(sl.value) - 5); sl.dispatchEvent(new Event('input')); }
e.preventDefault();
return;
}
if (e.key === '>' || e.key === '.') {
const sl = document.getElementById('sOpacity');
if (sl) { sl.value = Math.min(100, parseInt(sl.value) + 5); sl.dispatchEvent(new Event('input')); }
e.preventDefault();
return;
}
}
// Ctrl+S -> Save All modal
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
showSaveAllModal();
return;
}
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
const editor = document.querySelector('#editor');
if (editor.value === 'move' && activePointrId) {
movePointr(e.key);
e.preventDefault();
} else if (editor.value === 'move'
&& typeof activePoiId !== 'undefined' && activePoiId != null
&& typeof movePoi === 'function') {
movePoi(e.key);
e.preventDefault();
}
}
if (e.key === 'Delete' && selectedPointrIds.size > 0) {
const toDelete = [...selectedPointrIds];
selectedPointrIds.clear();
toDelete.forEach(pid => deletePointr(pid));
e.preventDefault();
}
}, true);
}
function initPointrDrag() {
document.addEventListener('mousemove', (e) => {
if (!isDragging || activePointrId === null) return;
markPointrAsEdited(activePointrId);
const data = pointrData.get(activePointrId);
if (!data) { isDragging = false; return; }
const container = document.querySelector('.container');
data.initialClientX = e.clientX + container.scrollLeft;
data.initialClientY = e.clientY + container.scrollTop;
const sizePer = parseInt(document.getElementById('sSize').value) / 2400;
const { lat: newLat, lon: newLon } = pixelToLatLon(data.initialClientX, data.initialClientY, sizePer);
data.lat = newLat;
data.lon = newLon;
data.element.style.left = e.clientX + 'px';
data.element.style.top = e.clientY + 'px';
});
document.addEventListener('mouseup', () => { isDragging = false; });
document.addEventListener('click', (e) => {
if (submenuEl && !submenuEl.contains(e.target)) closePointrSubmenu();
});
}
const _EDITOR_CURSORS = { create: 'crosshair', move: 'all-scroll', view: 'default' };
function _applyEditorCursor(mode) {
document.querySelector('.container').style.cursor = _EDITOR_CURSORS[mode] || 'default';
}
function initEditorListener() {
const editor = document.getElementById('editor');
editor.addEventListener('change', (e) => {
document.body.className = `mode-${e.target.value}`;
_applyEditorCursor(e.target.value);
activePointrId = null;
updateActivePointrVisual();
if (typeof activePoiId !== 'undefined') {
activePoiId = null;
if (typeof updateActivePoiVisual === 'function') updateActivePoiVisual();
}
});
// Apply initial cursor on load
_applyEditorCursor(editor.value);
// Also set body class so CSS mode-specific selectors apply on first render
document.body.className = `mode-${editor.value}`;
}