Spaces:
Running
Running
Download admin/gmaps.html from Tataaicoltd/tools: direct link, hf CLI and curl.
- Browser
- Download file 114 kB
-
https://huggingface.co/spaces/Tataaicoltd/tools/resolve/main/admin/gmaps.html
- Command line
-
hf download hf://spaces/Tataaicoltd/tools/admin/gmaps.html
-
curl -L -o gmaps.html https://huggingface.co/spaces/Tataaicoltd/tools/resolve/main/admin/gmaps.html
114 kB
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GMaps Vector</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; font-family: 'Segoe UI', Arial, sans-serif; }
#map { position: fixed; inset: 0; z-index: 0; }
/* ── Toolbar ── */
#toolbar {
position: fixed;
top: 12px; left: 50%;
transform: translateX(-50%);
display: flex; gap: 8px; align-items: center;
background: #fff;
border-radius: 12px;
padding: 8px 12px;
box-shadow: 0 2px 12px rgba(0,0,0,.25);
z-index: 10;
flex-wrap: wrap;
max-width: calc(100vw - 24px);
}
#toolbar select, #toolbar input[type=text] {
border: 1px solid #d0d0d0;
border-radius: 8px;
padding: 6px 10px;
font-size: 13px;
background: #fafafa;
outline: none;
cursor: pointer;
min-width: 130px;
}
#toolbar select:focus, #toolbar input[type=text]:focus {
border-color: #4285f4;
background: #fff;
}
#searchInput {
min-width: 180px;
}
#toolbar button {
border: none;
border-radius: 8px;
padding: 6px 14px;
font-size: 13px;
cursor: pointer;
background: #4285f4;
color: #fff;
font-weight: 600;
transition: background .15s;
}
#toolbar button:hover { background: #2a6dd4; }
#toolbar button.secondary {
background: #f1f3f4; color: #3c4043;
}
#toolbar button.secondary:hover { background: #e2e4e6; }
#toolbar button.active {
background: #34a853; color: #fff;
}
#toolbar button.active:hover { background: #2d9248; }
/* ── Counter badge ── */
#counter {
position: fixed;
bottom: 24px; left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,.65);
color: #fff;
border-radius: 20px;
padding: 4px 16px;
font-size: 13px;
z-index: 10;
pointer-events: none;
white-space: nowrap;
}
/* ── Info panel (marker click) ── */
#infoPanel {
position: fixed;
bottom: 60px; right: 16px;
width: 280px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 16px rgba(0,0,0,.25);
padding: 16px;
z-index: 10;
display: none;
font-size: 13px;
line-height: 1.6;
max-height: calc(100vh - 90px);
overflow-y: auto;
}
#infoPanel h3 {
font-size: 14px;
margin-bottom: 8px;
color: #202124;
}
#infoPanel .row { display: flex; justify-content: space-between; }
#infoPanel .label { color: #777; }
#infoPanel .close-btn {
position: absolute; top: 10px; right: 12px;
cursor: pointer; font-size: 18px; color: #888; line-height: 1;
background: none; border: none;
}
#infoPanel .close-btn:hover { color: #333; }
/* ── Loading overlay ── */
#loading {
position: fixed; inset: 0;
background: rgba(255,255,255,.7);
display: flex; align-items: center; justify-content: center;
z-index: 20;
font-size: 16px; color: #555;
}
#loading.hidden { display: none; }
/* ── Toast ── */
#toast {
position: fixed;
bottom: 70px; left: 50%;
transform: translateX(-50%);
background: rgba(52,168,83,.95);
color: #fff;
border-radius: 8px;
padding: 8px 18px;
font-size: 13px;
z-index: 30;
opacity: 0;
pointer-events: none;
transition: opacity .25s;
white-space: nowrap;
max-width: calc(100vw - 32px);
text-align: center;
}
#toast.show { opacity: 1; }
/* ── Add-point modal (panel bên trái, không chặn map) ── */
#addModal, #setNodeModal {
position: fixed;
top: 70px; left: 16px;
width: 300px;
max-height: calc(100vh - 90px);
overflow-y: auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 16px rgba(0,0,0,.25);
padding: 16px;
z-index: 15;
display: none;
font-size: 13px;
}
#addModal h3, #setNodeModal h3 { font-size: 14px; margin-bottom: 12px; color: #202124; }
#addModal .close-btn, #setNodeModal .close-btn {
position: absolute; top: 10px; right: 12px;
cursor: pointer; font-size: 18px; color: #888; line-height: 1;
background: none; border: none;
}
#addModal .close-btn:hover, #setNodeModal .close-btn:hover { color: #333; }
.modal-field { margin-bottom: 12px; }
.modal-field > label {
display: block; color: #777; margin-bottom: 4px; font-size: 12px;
}
.latlng-label {
font-family: monospace; background: #f1f3f4;
border-radius: 6px; padding: 6px 8px;
}
.row-inline { display: flex; gap: 6px; align-items: center; }
.row-inline select { flex: 1; min-width: 0; }
#addModal select, #setNodeModal select {
border: 1px solid #d0d0d0; border-radius: 8px;
padding: 6px 8px; font-size: 13px; background: #fafafa; outline: none;
width: 100%; box-sizing: border-box;
}
#addModal .icon-btn, #setNodeModal .icon-btn {
border: none; border-radius: 8px; padding: 6px 11px;
font-size: 15px; line-height: 1; cursor: pointer;
background: #34a853; color: #fff; font-weight: 700; flex: 0 0 auto;
}
#addModal .icon-btn:hover, #setNodeModal .icon-btn:hover { background: #2d9248; }
.modal-hint { display: block; color: #999; font-size: 11px; margin-top: 4px; }
.modal-actions { display: flex; gap: 8px; margin-top: 6px; }
#addModal .modal-actions button, #setNodeModal .modal-actions button {
border: none; border-radius: 8px; padding: 8px 14px;
font-size: 13px; cursor: pointer; font-weight: 600; flex: 1;
}
#btnSubmitPoint, #btnSetNodeSave { background: #4285f4; color: #fff; }
#btnSubmitPoint:hover, #btnSetNodeSave:hover { background: #2a6dd4; }
#btnSubmitPoint:disabled, #btnSetNodeSave:disabled { background: #b9c6e0; cursor: not-allowed; }
#btnCancelPoint, #btnSetNodeCancel { background: #f1f3f4; color: #3c4043; }
#btnCancelPoint:hover, #btnSetNodeCancel:hover { background: #e2e4e6; }
#infoPanel .info-action {
margin-top: 12px; width: 100%;
border: none; border-radius: 8px; padding: 8px;
background: #34a853; color: #fff; font-weight: 600; font-size: 13px; cursor: pointer;
}
#infoPanel .info-action:hover { background: #2d9248; }
.nearby-box {
max-height: 140px; overflow-y: auto;
border: 1px solid #e0e0e0; border-radius: 8px;
padding: 6px 8px; background: #fafafa;
}
.nearby-box label {
display: flex; align-items: center; gap: 6px;
padding: 2px 0; font-size: 12px; color: #3c4043; cursor: pointer;
}
.nearby-box .empty { color: #999; font-size: 12px; padding: 2px 0; }
.node-street-row {
display: flex; align-items: center; justify-content: space-between;
padding: 3px 2px; font-size: 12px; color: #3c4043;
}
.node-street-row button {
border: none; background: none; color: #d33; cursor: pointer;
font-size: 15px; line-height: 1;
}
#nearbyCount { color: #34a853; font-weight: 600; }
/* ── Opacity slider (điều chỉnh độ mờ tile nền) ── */
.opacity-control {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 5px rgba(0,0,0,.4);
padding: 8px 6px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.opacity-control .opacity-icon { font-size: 14px; line-height: 1; }
.opacity-control input[type=range] {
-webkit-appearance: slider-vertical;
appearance: slider-vertical;
writing-mode: vertical-lr;
direction: rtl;
width: 8px;
height: 120px;
cursor: pointer;
accent-color: #4285f4;
}
.opacity-control #tileOpacityVal {
font-size: 11px; color: #555; font-variant-numeric: tabular-nums;
}
/* ── Search-point buttons (điểm đầu/đích) — dưới slider, dọc bên phải ── */
.searchpt-control {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 5px rgba(0,0,0,.4);
padding: 4px;
display: flex;
flex-direction: column;
gap: 4px;
}
.searchpt-btn {
width: 34px; height: 34px;
border: none; border-radius: 6px;
background: #f1f3f4; cursor: pointer;
font-size: 17px; line-height: 1;
display: flex; align-items: center; justify-content: center;
transition: background .15s;
}
.searchpt-btn:hover { background: #e2e4e6; }
#btnSetStart.active { background: #0b8043; }
#btnSetEnd.active { background: #d93025; }
/* ── Create Group modal ── */
#createGroupModal {
position: fixed;
top: 70px; left: 16px;
width: 320px;
max-height: calc(100vh - 90px);
overflow-y: auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 16px rgba(0,0,0,.25);
padding: 16px;
z-index: 16;
display: none;
font-size: 13px;
}
#createGroupModal h3 { font-size: 14px; margin-bottom: 12px; color: #202124; }
#createGroupModal .close-btn {
position: absolute; top: 10px; right: 12px;
cursor: pointer; font-size: 18px; color: #888; line-height: 1;
background: none; border: none;
}
#createGroupModal .close-btn:hover { color: #333; }
#createGroupModal select {
border: 1px solid #d0d0d0; border-radius: 8px;
padding: 6px 8px; font-size: 13px; background: #fafafa; outline: none;
}
#cgNeighborSel { width: 100%; }
#createGroupModal .icon-btn {
border: none; border-radius: 8px; padding: 6px 11px;
font-size: 15px; line-height: 1; cursor: pointer;
background: #34a853; color: #fff; font-weight: 700; flex: 0 0 auto;
}
#createGroupModal .icon-btn:hover { background: #2d9248; }
#createGroupModal .modal-actions button {
border: none; border-radius: 8px; padding: 8px 14px;
font-size: 13px; cursor: pointer; font-weight: 600; flex: 1;
}
#cgSave { background: #4285f4; color: #fff; }
#cgSave:hover { background: #2a6dd4; }
#cgSave:disabled { background: #b9c6e0; cursor: not-allowed; }
#cgCancel { background: #f1f3f4; color: #3c4043; }
#cgCancel:hover { background: #e2e4e6; }
/* ── Edit Street modal ── */
#editStreetModal {
position: fixed;
top: 70px; left: 16px;
width: 320px;
max-height: calc(100vh - 90px);
overflow-y: auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 16px rgba(0,0,0,.25);
padding: 16px;
z-index: 16;
display: none;
font-size: 13px;
}
#editStreetModal h3 { font-size: 14px; margin-bottom: 12px; color: #202124; }
#editStreetModal .close-btn {
position: absolute; top: 10px; right: 12px;
cursor: pointer; font-size: 18px; color: #888; line-height: 1;
background: none; border: none;
}
#editStreetModal .close-btn:hover { color: #333; }
#editStreetModal select, #editStreetModal input[type=text] {
width: 100%;
border: 1px solid #d0d0d0; border-radius: 8px;
padding: 6px 8px; font-size: 13px; background: #fafafa; outline: none;
}
#editStreetModal .row-inline select { width: auto; flex: 1; }
#editStreetModal .es-checks { gap: 18px; }
#editStreetModal .es-checks label {
display: flex; align-items: center; gap: 6px; color: #3c4043; font-size: 13px; margin: 0; cursor: pointer;
}
#editStreetModal .es-checks input { width: auto; }
#esAddEdge {
width: 100%; margin-top: 8px;
border: none; border-radius: 8px; padding: 7px 12px;
font-size: 13px; cursor: pointer; font-weight: 600;
background: #34a853; color: #fff;
}
#esAddEdge:hover { background: #2d9248; }
#editStreetModal .es-oneway-line {
display: flex; align-items: center; gap: 6px; margin-top: 8px;
color: #3c4043; font-size: 12px; cursor: pointer;
}
#editStreetModal .es-oneway-line input { width: auto; }
#esEdgesList { display: flex; flex-direction: column; gap: 8px; }
#esEdgesList .empty { color: #999; font-size: 12px; padding: 4px 2px; }
.es-edge-block {
border: 1px solid #e2e4e6; border-radius: 8px; padding: 8px; background: #fafafa;
}
.es-edge-block.oneway { border-color: #f6c6c1; background: #fff6f5; }
.es-edge-head {
display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
font-size: 12px; font-weight: 600; color: #3c4043;
}
.es-edge-head .es-edge-title { flex: 1; }
.es-edge-head label {
display: flex; align-items: center; gap: 4px; font-weight: 400; cursor: pointer; margin: 0;
}
.es-edge-head input { width: auto; }
.es-edge-head .es-edge-del {
border: none; background: none; color: #d33; cursor: pointer;
font-size: 16px; line-height: 1; flex: 0 0 auto;
}
.es-edge-block select { width: 100%; }
#esStartPopup {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,.35);
z-index: 30;
align-items: center; justify-content: center;
}
#esStartPopup.show { display: flex; }
#esStartPopup .es-start-card {
background: #fff; border-radius: 12px; padding: 18px;
width: 300px; box-shadow: 0 4px 24px rgba(0,0,0,.3);
}
#esStartPopup h4 { font-size: 15px; margin: 0 0 6px; color: #202124; }
#esStartPopup .es-start-hint { font-size: 12px; color: #5f6368; margin: 0 0 10px; }
#esStartPopup select {
width: 100%; border: 1px solid #d0d0d0; border-radius: 8px;
padding: 6px 8px; font-size: 13px; background: #fafafa; outline: none;
}
#esStartPopup .es-start-actions { display: flex; gap: 8px; margin-top: 12px; }
#esStartPopup .es-start-actions button {
flex: 1; border: none; border-radius: 8px; padding: 8px 14px;
font-size: 13px; cursor: pointer; font-weight: 600;
}
#esStartConfirm { background: #34a853; color: #fff; }
#esStartConfirm:hover { background: #2d9248; }
#esStartCancel { background: #f1f3f4; color: #3c4043; }
#esStartCancel:hover { background: #e2e4e6; }
#editStreetModal .modal-actions button {
border: none; border-radius: 8px; padding: 8px 14px;
font-size: 13px; cursor: pointer; font-weight: 600; flex: 1;
}
#esSave { background: #4285f4; color: #fff; }
#esSave:hover { background: #2a6dd4; }
#esSave:disabled { background: #b9c6e0; cursor: not-allowed; }
#esCancel { background: #f1f3f4; color: #3c4043; }
#esCancel:hover { background: #e2e4e6; }
/* ── Main create FAB (nút + góc phải dưới, dùng chung) ── */
#mainCreateBtn {
position: fixed;
bottom: 24px; right: 20px;
width: 52px; height: 52px;
border-radius: 50%;
border: none;
background: #9aa0a6; /* xám = disable */
color: #fff;
font-size: 30px; line-height: 1;
cursor: not-allowed;
box-shadow: 0 3px 12px rgba(0,0,0,.3);
z-index: 12;
display: flex; align-items: center; justify-content: center;
transition: background .15s, transform .15s;
}
#mainCreateBtn.active { background: #34a853; cursor: pointer; } /* xanh = active */
#mainCreateBtn.active:hover { background: #2d9248; transform: scale(1.06); }
#mainCreateBtn.editing { font-size: 24px; } /* pencil nhỏ hơn dấu + */
</style>
</head>
<body>
<!-- Map canvas -->
<div id="map"></div>
<!-- Loading overlay -->
<div id="loading">Đang tải bản đồ…</div>
<!-- Toolbar -->
<div id="toolbar">
<input type="text" id="searchInput" placeholder="🔍 Tìm địa điểm…" />
<button id="btnSearch" class="secondary">Tìm</button>
<select id="selCity"><option value="">-- Thành phố --</option></select>
<select id="selDistrict"><option value="">-- Quận/Huyện --</option></select>
<select id="selStreet"><option value="">-- Đường --</option></select>
<button id="btnLoad">Lọc điểm</button>
<button id="btnMove" class="secondary">Move điểm</button>
<button id="btnAdd" class="secondary">Add điểm</button>
<button id="btnGroup" class="secondary">Create Group</button>
<select id="selGroup" title="Chọn group để sửa"><option value="">-- Group --</option></select>
<button id="btnClear" class="secondary">Reset</button>
</div>
<!-- Marker count -->
<div id="counter" style="display:none"></div>
<!-- Toast -->
<div id="toast"></div>
<!-- Main create FAB (nút + dùng chung cho các tính năng create) -->
<button id="mainCreateBtn" disabled title="Chọn 1 điểm ở chế độ Create Group để bật">+</button>
<!-- Info panel -->
<div id="infoPanel">
<button class="close-btn" id="closeInfo">×</button>
<h3 id="infoTitle">Chi tiết điểm</h3>
<div id="infoBody"></div>
<button id="btnSetNode" class="info-action">Set Node (sửa đường)</button>
</div>
<!-- Set Node modal -->
<div id="setNodeModal">
<button class="close-btn" id="setNodeCloseX">×</button>
<h3>Set Node — đường của điểm</h3>
<div class="modal-field">
<label>Điểm</label>
<div id="setNodeInfo" class="latlng-label">—</div>
</div>
<div class="modal-field">
<label>Đường lân cận ≤ 1km <span id="setNodeNearbyCount"></span></label>
<div class="row-inline">
<select id="setNodeNearbySel" multiple size="4"></select>
<button id="btnSetNodeAdd" class="icon-btn" title="Thêm các đường đã chọn vào node">+</button>
</div>
</div>
<div class="modal-field">
<label>Đường của node</label>
<div id="setNodeStreetsList" class="nearby-box"></div>
</div>
<div class="modal-field">
<label>Cấm rẽ — point lân cận ≤ 500m <span id="setNodeBanTurnCount"></span></label>
<select id="setNodeBanTurn" multiple size="4"></select>
</div>
<div class="modal-field">
<label>Cấm phương tiện</label>
<select id="setNodeBanVehicle" multiple size="3"></select>
</div>
<div class="modal-field">
<label>Cấm giờ</label>
<select id="setNodeBanHours" multiple size="5"></select>
</div>
<div class="modal-field">
<label>Cấm tải trọng (tấn)</label>
<select id="setNodeBanWeight" multiple size="4"></select>
</div>
<div class="modal-actions">
<button id="btnSetNodeSave">Lưu</button>
<button id="btnSetNodeCancel">Huỷ</button>
</div>
</div>
<!-- Add-point modal -->
<div id="addModal">
<button class="close-btn" id="btnCancelPointX">×</button>
<h3>Thêm điểm mới</h3>
<div class="modal-field">
<label>Toạ độ (lat / lng)</label>
<div id="addLatLng" class="latlng-label">—</div>
</div>
<div class="modal-field">
<label>Phường (district)</label>
<div class="row-inline">
<select id="addDistrictSel"><option value="">-- Chọn phường --</option></select>
<button id="btnCreateDistrict" class="icon-btn" title="Tạo phường từ OSM">+</button>
</div>
<small id="addDistrictOsm" class="modal-hint"></small>
</div>
<div class="modal-field">
<label>Đường (street) *</label>
<div class="row-inline">
<select id="addStreetSel"><option value="">-- Chọn đường --</option></select>
<button id="btnCreateStreet" class="icon-btn" title="Tạo đường từ OSM">+</button>
</div>
<small id="addStreetOsm" class="modal-hint"></small>
</div>
<div class="modal-field">
<label>Đường lân cận ≤ 1km <span id="nearbyCount"></span></label>
<div class="row-inline">
<select id="nearbyStreetSel" multiple size="4"></select>
<button id="btnAddNearby" class="icon-btn" title="Thêm các đường đã chọn vào node">+</button>
</div>
<div id="nodeStreetsList" class="nearby-box"></div>
</div>
<div class="modal-actions">
<button id="btnSubmitPoint" disabled>Tạo điểm</button>
<button id="btnCancelPoint">Huỷ</button>
</div>
</div>
<!-- Create Group modal -->
<div id="createGroupModal">
<button class="close-btn" id="cgCloseX">×</button>
<h3 id="cgTitle">Tạo Group</h3>
<div class="modal-field">
<label>Tâm (lat / lng)</label>
<div id="cgCenter" class="latlng-label">—</div>
</div>
<div class="modal-field">
<label>Danh sách đường (main) <span id="cgMainCount"></span></label>
<div id="cgMainList" class="nearby-box"></div>
</div>
<div class="modal-field">
<label>Thêm đường ≤<span id="cgOuterKm"></span>km (chưa có trong main) <span id="cgOuterCount"></span></label>
<div class="row-inline">
<select id="cgOuterSel" multiple size="4"></select>
<button id="cgAddOuter" class="icon-btn" title="Thêm các đường đã chọn vào main list">+</button>
</div>
</div>
<div class="modal-field">
<label>Group lân cận (neighbor) <span id="cgNeighborCount"></span></label>
<select id="cgNeighborSel" multiple size="4"></select>
<small class="modal-hint">Giữ Ctrl/Cmd để chọn nhiều group.</small>
</div>
<div class="modal-actions">
<button id="cgSave">Lưu group</button>
<button id="cgCancel">Huỷ</button>
</div>
</div>
<!-- Edit Street modal -->
<div id="editStreetModal">
<button class="close-btn" id="esCloseX">×</button>
<h3 id="esTitle">Sửa đường</h3>
<div class="modal-field">
<label>Tên đường</label>
<input type="text" id="esName" placeholder="Tên đường" />
</div>
<div class="modal-field">
<label>Loại (type)</label>
<select id="esType">
<option value="Phố">Phố</option>
<option value="Quốc lộ">Quốc lộ</option>
<option value="Cao tốc">Cao tốc</option>
<option value="Xã">Xã</option>
<option value="Huyện">Huyện</option>
<option value="Tỉnh">Tỉnh</option>
</select>
</div>
<div class="modal-field">
<label>Phường/Quận (district)</label>
<select id="esDistrict"></select>
</div>
<div class="modal-field">
<label>Thành phố (city)</label>
<select id="esCity"></select>
</div>
<div class="modal-field row-inline es-checks">
<label><input type="checkbox" id="esToll" /> Thu phí</label>
<label><input type="checkbox" id="esFlooding" /> Ngập</label>
</div>
<div class="modal-field">
<label>Tất cả điểm của đường <span id="esAllPosCount"></span></label>
<select id="esAllPosSel" multiple size="6"></select>
<label class="es-oneway-line"><input type="checkbox" id="esEdgeOneway" /> Cạnh 1 chiều (theo thứ tự chọn)</label>
<button id="esAddEdge">+ Thêm cạnh từ điểm đã chọn</button>
<small class="modal-hint">Chọn nhiều điểm ở danh sách trên rồi bấm “Thêm cạnh”. Mỗi cạnh tạo 1 ô riêng bên dưới.</small>
</div>
<div class="modal-field">
<label>Các cạnh của đường <span id="esEdgesCount"></span></label>
<div id="esEdgesList"></div>
</div>
<div class="modal-actions">
<button id="esSave">Lưu đường</button>
<button id="esCancel">Huỷ</button>
</div>
</div>
<!-- Popup xác nhận điểm đầu của cạnh trước khi nội suy thứ tự -->
<div id="esStartPopup">
<div class="es-start-card">
<h4>Điểm đầu của cạnh</h4>
<p class="es-start-hint">Chọn điểm bắt đầu. Thứ tự các điểm còn lại sẽ được nội suy từ điểm này.</p>
<select id="esStartPopupSel" size="6"></select>
<div class="es-start-actions">
<button id="esStartConfirm">Tạo cạnh</button>
<button id="esStartCancel">Huỷ</button>
</div>
</div>
</div>
<!-- Leaflet JS -->
<script src="js/auth.js"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// ══════════════════════════════════════════════════
// CONFIG
// ══════════════════════════════════════════════════
const API = location.hostname === 'localhost' ? 'http://localhost:4000/api' : '/api';
const DEFAULT_LAT = 10.8231;
const DEFAULT_LNG = 106.6297;
const DEFAULT_ZOOM = 12;
const NEARBY_RADIUS_KM = 1; // bán kính gộp đường lân cận vào 1 node (Add điểm)
const BAN_TURN_RADIUS_KM = 0.5; // bán kính (km) point lân cận cho "cấm rẽ" (500m)
const BAN_VEHICLE_OPTIONS = ['car', 'truck', 'bus'];
const BAN_HOURS_OPTIONS = [
{ id: 1, label: '06:00 - 09:00' },
{ id: 2, label: '16:00 - 20:00' },
{ id: 3, label: '06:00 - 22:00' },
{ id: 4, label: '20:00 - 05:00' },
{ id: 5, label: '19:00 - 23:00' },
{ id: 6, label: '06:00 - 08:30' },
{ id: 7, label: '09:00 - 16:00' },
{ id: 8, label: '16:00 - 19:00' },
{ id: 9, label: '06:00 - 21:00' },
{ id: 10, label: '19:00 - 24:00' },
];
const BAN_WEIGHT_OPTIONS = [0.95, 2, 2.5, 3.5, 5, 10, 18, 30];
const GROUP_RADIUS_KM = 1; // bán kính (km) gom đường quanh điểm cho "Create Group"
const GROUP_OUTER_MULT = 2; // vùng gấp đôi bán kính để bổ sung đường thủ công vào main list
const DEFAULT_MARKER_COLOR = '#4285f4'; // màu marker mặc định (street chưa thuộc group nào)
const LAST_MARKER_KEY = 'gmaps_last_marker'; // localStorage: toạ độ marker chọn gần nhất
// ══════════════════════════════════════════════════
// STATE
// ══════════════════════════════════════════════════
let map;
let tileLayer = null; // lớp tile nền (điều chỉnh opacity)
let layerGroup = null; // L.LayerGroup chứa toàn bộ marker
let pathLayer = null; // L.LayerGroup chứa polyline path của street
let streetMap = {}; // id → street object
let districtMap = {}; // id → district object
let allStreets = [];
let moveMode = false; // bật/tắt chế độ kéo-thả marker
let activeInfoPos = null; // pos đang hiển thị ở info panel
let addMode = false; // bật/tắt chế độ thêm điểm (click map)
let pendingMarker = null; // marker tạm cho điểm đang thêm
let pendingGeo = null; // { lat, lng, osmStreet, osmWard }
let groupMode = false; // bật/tắt chế độ "Create Group"
let groupHiliteMarkers = []; // các marker đang tô đỏ theo nhóm
let groupRadiusCircle = null; // vòng tròn minh hoạ bán kính nhóm
let groupAnchorMarker = null; // điểm được chọn làm tâm nhóm
let allGroups = []; // GLOBAL store: toàn bộ group (nạp khi boot + sau khi tạo group)
let streetGroupMap = {}; // street id → group id (loại trừ street đã thuộc group khác)
let selectedGroupId = null; // group đang chọn trên toolbar (để sửa) — bật icon pencil ở FAB
let cgEditId = null; // modal đang sửa group id nào (null = tạo mới)
let selectedStreetId = null; // đường đang chọn trên toolbar (để sửa street)
let esEditId = null; // street id đang sửa trong modal
let esStreetPoints = []; // [{id,lat,lng}] các point của street đang sửa
let esEdges = []; // [{ids:[+id...], oneway:bool}] các cạnh của đường (positions 2-D)
let esPendingEdge = null; // {ids:[+id...], oneway} cạnh đang chờ chọn điểm đầu ở popup
let esHiliteMarkers = []; // marker đang tô sáng theo lựa chọn trong dropdown điểm
let pointPickMode = null; // 'start' | 'end' | null — chế độ chọn điểm đầu/đích
let roadProbeMode = false; // chế độ free-click kiểm tra đường OSM
let roadProbeMarker = null; // marker tạm của road-probe
let startPickMarker = null, endPickMarker = null; // marker cờ người dùng đặt
let startNearestId = null, endNearestId = null; // id position gần nhất đang tô màu
let startTempMarker = null, endTempMarker = null; // marker tạm khi nearest không hiển thị
const PT_START_COLOR = '#0b8043'; // xanh lá — nearest của điểm đầu
const PT_END_COLOR = '#d93025'; // đỏ — nearest của điểm đích
const ES_SELECT_COLOR = '#ff9800'; // cam — point đang chọn trong dropdown điểm
// ══════════════════════════════════════════════════
// HELPERS
// ══════════════════════════════════════════════════
async function apiFetch(path) {
const res = await fetch(API + path, { headers: { 'X-API-Key': sessionStorage.getItem('admin_api_key') || '' } });
if (!res.ok) throw new Error(`API ${path} → ${res.status}`);
return res.json();
}
async function apiSend(path, method, body) {
const res = await fetch(API + path, {
method,
headers: {
'Content-Type': 'application/json',
'X-API-Key': sessionStorage.getItem('admin_api_key') || '',
},
body: body != null ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`API ${path} → ${res.status}`);
return res.json();
}
let _toastTimer = null;
function showToast(msg) {
const el = document.getElementById('toast');
if (!el) return;
el.textContent = msg;
el.classList.add('show');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => el.classList.remove('show'), 2200);
}
// Lưu toạ độ marker vừa chọn để lần reload sau center về đó (zoom 15)
function saveLastMarker(pos) {
try { localStorage.setItem(LAST_MARKER_KEY, JSON.stringify({ lat: pos.y, lng: pos.x })); } catch (e) {}
}
function setLoading(on) {
document.getElementById('loading').classList.toggle('hidden', !on);
}
function setCounter(n) {
const el = document.getElementById('counter');
if (n === 0) { el.style.display = 'none'; return; }
el.style.display = 'block';
el.textContent = `${n} điểm`;
}
function hideInfo() {
document.getElementById('infoPanel').style.display = 'none';
activeInfoPos = null;
}
// ══════════════════════════════════════════════════
// LEAFLET MAP INIT
// ══════════════════════════════════════════════════
function initMap() {
map = L.map('map', { zoomControl: true }).setView([DEFAULT_LAT, DEFAULT_LNG], DEFAULT_ZOOM);
tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
layerGroup = L.layerGroup().addTo(map);
pathLayer = L.layerGroup().addTo(map);
addOpacityControl();
addSearchPointControl();
// Click map: ưu tiên chế độ chọn điểm đầu/đích, rồi tới "Add điểm"
map.on('click', e => {
if (roadProbeMode) { probeRoadAt(e.latlng.lat, e.latlng.lng); return; }
if (pointPickMode) {
const role = pointPickMode;
const lat = Math.round(e.latlng.lat * 1e6) / 1e6;
const lng = Math.round(e.latlng.lng * 1e6) / 1e6;
setPointPickMode(role); // toggle tắt (đang active role)
pickSearchPoint(role, lat, lng);
return;
}
if (!addMode) return;
startAddPoint(e.latlng.lat, e.latlng.lng);
});
}
// Slider dọc chỉnh opacity tile nền (giúp thấy path rõ hơn)
function addOpacityControl() {
const ctrl = L.control({ position: 'topright' });
ctrl.onAdd = function () {
const div = L.DomUtil.create('div', 'leaflet-control opacity-control');
div.innerHTML =
'<span class="opacity-icon" title="Độ mờ nền">🗺️</span>' +
'<input type="range" id="tileOpacity" min="0" max="100" value="100" orient="vertical" aria-label="Độ mờ nền" />' +
'<span id="tileOpacityVal">100%</span>';
L.DomEvent.disableClickPropagation(div);
L.DomEvent.disableScrollPropagation(div);
const slider = div.querySelector('#tileOpacity');
const val = div.querySelector('#tileOpacityVal');
slider.addEventListener('input', () => {
if (tileLayer) tileLayer.setOpacity(slider.value / 100);
val.textContent = slider.value + '%';
});
return div;
};
ctrl.addTo(map);
}
// Hai nút chọn điểm đầu/đích (đặt dưới slider opacity, dọc bên phải)
function addSearchPointControl() {
const ctrl = L.control({ position: 'topright' });
ctrl.onAdd = function () {
const div = L.DomUtil.create('div', 'leaflet-control searchpt-control');
div.innerHTML =
'<button id="btnSetStart" class="searchpt-btn" title="Đặt điểm đầu — click lên bản đồ">🚩</button>' +
'<button id="btnSetEnd" class="searchpt-btn" title="Đặt điểm đích — click lên bản đồ">🏁</button>' +
'<button id="btnRoadProbe" class="searchpt-btn" title="Kiểm tra đường OSM — click tự do lên bản đồ">🛣️</button>';
L.DomEvent.disableClickPropagation(div);
div.querySelector('#btnSetStart').addEventListener('click', () => setPointPickMode('start'));
div.querySelector('#btnSetEnd').addEventListener('click', () => setPointPickMode('end'));
div.querySelector('#btnRoadProbe').addEventListener('click', () => setRoadProbeMode(!roadProbeMode));
return div;
};
ctrl.addTo(map);
}
// Icon cờ cho điểm người dùng đặt
function flagIcon(emoji) {
return L.divIcon({
html: `<span style="font-size:22px;line-height:1;filter:drop-shadow(0 1px 1px rgba(0,0,0,.4))">${emoji}</span>`,
className: '', iconSize: [24, 24], iconAnchor: [6, 22],
});
}
// Bật/tắt chế độ chọn điểm (toggle). role: 'start' | 'end' | null
function setPointPickMode(role) {
pointPickMode = (pointPickMode === role) ? null : role;
if (pointPickMode) { setMoveMode(false); setAddMode(false); setGroupMode(false); }
const bs = document.getElementById('btnSetStart');
const be = document.getElementById('btnSetEnd');
if (bs) bs.classList.toggle('active', pointPickMode === 'start');
if (be) be.classList.toggle('active', pointPickMode === 'end');
document.getElementById('map').style.cursor = pointPickMode ? 'crosshair' : '';
if (pointPickMode === 'start') showToast('Chọn điểm ĐẦU trên bản đồ');
else if (pointPickMode === 'end') showToast('Chọn điểm ĐÍCH trên bản đồ');
}
// Bật/tắt chế độ free-click kiểm tra đường OSM
function setRoadProbeMode(on) {
roadProbeMode = on;
if (on) { setPointPickMode(null); setMoveMode(false); setAddMode(false); setGroupMode(false); }
const b = document.getElementById('btnRoadProbe');
if (b) b.classList.toggle('active', on);
document.getElementById('map').style.cursor = on ? 'crosshair' : '';
if (on) showToast('Click tự do lên bản đồ để xem thông tin đường OSM');
if (!on && roadProbeMarker) { map.removeLayer(roadProbeMarker); roadProbeMarker = null; }
}
// Free-click → marker tạm + popup thông tin đường OSM (loại, 1/2 chiều, tốc độ, hạn chế)
async function probeRoadAt(lat, lng) {
const rlat = Math.round(lat * 1e6) / 1e6;
const rlng = Math.round(lng * 1e6) / 1e6;
if (roadProbeMarker) map.removeLayer(roadProbeMarker);
roadProbeMarker = L.marker([rlat, rlng], { icon: circleIcon('#ff9800'), draggable: true }).addTo(map);
roadProbeMarker.bindPopup('Đang tra cứu OSM…').openPopup();
roadProbeMarker.on('dragend', () => {
const ll = roadProbeMarker.getLatLng();
probeRoadAt(ll.lat, ll.lng);
});
try {
const r = await roadInfoAt(rlat, rlng, 30);
if (!r) { roadProbeMarker.setPopupContent('Không tìm thấy đường OSM gần đây.'); return; }
const info = formatRoadInfo(r.tags);
const speed = r.tags.maxspeed ? `${r.tags.maxspeed} km/h` : '—';
const name = r.tags.name || '—';
roadProbeMarker.setPopupContent(
`<div style="font-size:12px;line-height:1.5">
<b>${name}</b><br>
Loại: ${info.loai}<br>
Chiều: ${info.oneway}<br>
Tốc độ: ${speed}<br>
Hạn chế: ${info.camTai}<br>
<small>${rlat}, ${rlng} · cách ~${Math.round(r.dist)}m</small>
</div>`);
} catch (e) {
console.error('probeRoadAt', e);
roadProbeMarker.setPopupContent('Tra cứu OSM thất bại.');
}
}
function findMarkerByPosId(id) {
let found = null;
if (layerGroup) layerGroup.eachLayer(l => { if (l._pos && l._pos.id === id) found = l; });
return found;
}
// Trả marker gần nhất (của role) về màu nền + xoá marker tạm
function revertNearest(role) {
const id = role === 'start' ? startNearestId : endNearestId;
if (id != null) {
const m = findMarkerByPosId(id);
if (m) m.setIcon(circleIcon(posBaseColor(m._pos)));
}
if (role === 'start') {
startNearestId = null;
if (startTempMarker) { map.removeLayer(startTempMarker); startTempMarker = null; }
} else {
endNearestId = null;
if (endTempMarker) { map.removeLayer(endTempMarker); endTempMarker = null; }
}
}
// Đặt điểm đầu/đích → gọi /search/point → tô màu marker gần nhất
async function pickSearchPoint(role, lat, lng) {
const color = role === 'start' ? PT_START_COLOR : PT_END_COLOR;
const emoji = role === 'start' ? '🚩' : '🏁';
// marker người dùng đặt (thay marker cũ cùng role)
if (role === 'start') {
if (startPickMarker) map.removeLayer(startPickMarker);
startPickMarker = L.marker([lat, lng], { icon: flagIcon(emoji), title: 'Điểm đầu', zIndexOffset: 1000 }).addTo(map);
} else {
if (endPickMarker) map.removeLayer(endPickMarker);
endPickMarker = L.marker([lat, lng], { icon: flagIcon(emoji), title: 'Điểm đích', zIndexOffset: 1000 }).addTo(map);
}
revertNearest(role); // bỏ tô màu nearest cũ của role này
try {
const res = await apiSend('/search/point', 'POST', { lat, lon: lng });
if (!res || !res.found || !res.position) { showToast('Không tìm thấy điểm gần nhất'); return; }
const p = res.position;
const m = findMarkerByPosId(p.id);
if (m) {
m.setIcon(circleIcon(color));
if (role === 'start') startNearestId = p.id; else endNearestId = p.id;
} else {
// nearest không nằm trong tập marker đang hiển thị → marker tạm
const tmp = L.circleMarker([p.lat, p.lon], { radius: 8, color: '#fff', weight: 2, fillColor: color, fillOpacity: 1 }).addTo(map);
if (role === 'start') startTempMarker = tmp; else endTempMarker = tmp;
}
const dtxt = res.distance_m != null ? `${res.distance_m}m` : '?';
const sname = (p.street_names && p.street_names[0]) || ('#' + p.id);
showToast(`${role === 'start' ? 'Điểm đầu' : 'Điểm đích'} → gần nhất #${p.id} (${sname}) · ${dtxt}`);
} catch (e) {
console.error('pickSearchPoint', e);
alert('Tìm điểm gần nhất thất bại.');
}
}
// Xoá toàn bộ điểm đầu/đích + tô màu (dùng khi Reset)
function clearSearchPoints() {
setPointPickMode(null);
revertNearest('start');
revertNearest('end');
if (startPickMarker) { map.removeLayer(startPickMarker); startPickMarker = null; }
if (endPickMarker) { map.removeLayer(endPickMarker); endPickMarker = null; }
}
// ══════════════════════════════════════════════════
// CUSTOM CIRCLE ICON
// ══════════════════════════════════════════════════
function circleIcon(color) {
color = color || '#4285f4';
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14">
<circle cx="7" cy="7" r="6" fill="${color}" stroke="#fff" stroke-width="1.5"/>
</svg>`;
return L.divIcon({
html: svg,
className: '',
iconSize: [14, 14],
iconAnchor: [7, 7],
});
}
// ══════════════════════════════════════════════════
// MARKERS
// ══════════════════════════════════════════════════
function clearMarkers() {
if (layerGroup) layerGroup.clearLayers();
clearPaths();
clearGroupHighlight();
setCounter(0);
hideInfo();
}
function createMarker(pos) {
// pos: { id, x:lon, y:lat, streets:[], speed, ban, ... }
const lat = pos.y, lng = pos.x;
if (lat == null || lng == null) return null;
const streetNames = (pos.streets || [])
.map(id => streetMap[id]?.name)
.filter(Boolean)
.join(', ') || '—';
const m = L.marker([lat, lng], { icon: circleIcon(posBaseColor(pos)), title: streetNames, draggable: moveMode });
m._pos = pos;
m._streetNames = streetNames;
m.on('click', () => {
if (groupMode) { selectGroupAnchor(m); return; }
showInfo(pos, streetNames, m);
});
m.on('dragend', () => onMarkerDragEnd(m));
return m;
}
// ── Drag-drop: cập nhật lat/lng mới khi thả marker ──
async function onMarkerDragEnd(marker) {
const pos = marker._pos;
const prevLat = pos.y, prevLng = pos.x;
const ll = marker.getLatLng();
// Làm tròn 6 chữ số thập phân
const newLat = Math.round(ll.lat * 1e6) / 1e6;
const newLng = Math.round(ll.lng * 1e6) / 1e6;
// Snap marker về vị trí đã làm tròn + cập nhật dữ liệu
marker.setLatLng([newLat, newLng]);
pos.y = newLat;
pos.x = newLng;
pos._geo = null; // toạ độ đổi → xoá cache geocode để lấy lại
pos._road = null;
if (activeInfoPos === pos) showInfo(pos, marker._streetNames, marker);
try {
await apiSend(`/positions/${pos.id}`, 'PUT', { x: newLng, y: newLat });
showToast(`Đã lưu #${pos.id}: ${newLat}, ${newLng}`);
} catch (e) {
console.error('onMarkerDragEnd', e);
// Revert khi lưu thất bại
pos.y = prevLat; pos.x = prevLng;
marker.setLatLng([prevLat, prevLng]);
if (activeInfoPos === pos) showInfo(pos, marker._streetNames, marker);
alert('Không thể lưu vị trí mới cho điểm #' + pos.id);
}
}
// ── Bật/tắt chế độ di chuyển điểm ──
function setMoveMode(on) {
moveMode = on;
if (on) { setAddMode(false); setGroupMode(false); setPointPickMode(null); closeAddModal(); closeSetNode(); } // không bật đồng thời với "Add điểm"/"Create Group"
const btn = document.getElementById('btnMove');
btn.classList.toggle('active', on);
btn.classList.toggle('secondary', !on);
if (layerGroup) {
layerGroup.eachLayer(layer => {
if (!layer.dragging) return;
if (on) layer.dragging.enable();
else layer.dragging.disable();
});
}
showToast(on ? 'Chế độ di chuyển: BẬT — kéo marker để đổi vị trí' : 'Chế độ di chuyển: TẮT');
}
// ══════════════════════════════════════════════════
// STREET PATH (vẽ path nối các điểm của street)
// ══════════════════════════════════════════════════
const PATH_COLORS = ['#2196F3','#E91E63','#4CAF50','#FF9800','#9C27B0','#00BCD4','#FF5722','#8BC34A','#F44336','#3F51B5'];
function clearPaths() {
if (pathLayer) pathLayer.clearLayers();
}
// Khoảng cách bình phương (xấp xỉ) để so sánh tương đối
function _geoDist2(a, b) {
const dLat = a.lat - b.lat;
const dLon = a.lng - b.lng;
const cosLat = Math.cos(a.lat * Math.PI / 180);
return dLat * dLat + dLon * dLon * cosLat * cosLat;
}
// Sắp xếp điểm dọc theo đường: bắt đầu từ 1 đầu (2 điểm xa nhau nhất) rồi nối nearest-neighbor
function nearestNeighborSort(pts) {
if (pts.length <= 1) return [...pts];
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], result = [];
let cur = rem.splice(startIdx, 1)[0];
result.push(cur);
while (rem.length) {
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;
}
// Lấy các điểm (đã load) thuộc 1 street
function collectStreetPoints(streetId) {
const pts = [];
if (layerGroup) layerGroup.eachLayer(l => {
const p = l._pos;
if (p && Array.isArray(p.streets) && p.streets.includes(streetId) && p.y != null && p.x != null) {
pts.push({ lat: p.y, lng: p.x });
}
});
return pts;
}
// Vẽ polyline cho 1 street; trả về true nếu vẽ được (≥2 điểm)
function drawStreetPath(streetId, color) {
const pts = collectStreetPoints(streetId);
if (pts.length < 2) return false;
const sorted = nearestNeighborSort(pts);
L.polyline(sorted.map(p => [p.lat, p.lng]), {
color: color || '#E91E63', weight: 3, opacity: 0.85, dashArray: '6,4',
}).addTo(pathLayer);
return true;
}
// Vẽ path cho tất cả street thuộc 1 phường (mỗi đường 1 màu)
function drawDistrictPaths(districtId) {
let colorIdx = 0;
Object.values(streetMap).forEach(s => {
if (s && Number(s.district_id) === districtId) {
if (drawStreetPath(s.id, PATH_COLORS[colorIdx % PATH_COLORS.length])) colorIdx++;
}
});
}
// Cập nhật path theo toolbar: chọn street → vẽ street đó; chỉ chọn phường → vẽ mọi street của phường
function updatePaths() {
clearPaths();
const streetId = parseInt(document.getElementById('selStreet').value);
const districtId = parseInt(document.getElementById('selDistrict').value);
if (streetId) drawStreetPath(streetId, '#E91E63');
else if (districtId) drawDistrictPaths(districtId);
}
// ══════════════════════════════════════════════════
// LOAD ALL POSITIONS FROM DB
// ══════════════════════════════════════════════════
async function loadPositions(endpoint) {
endpoint = endpoint || '/positions';
clearMarkers();
setLoading(true);
try {
const positions = await apiFetch(endpoint);
if (!Array.isArray(positions) || positions.length === 0) {
setCounter(0);
setLoading(false);
return;
}
const bounds = L.latLngBounds();
let valid = 0;
let firstLatLng = null;
positions.forEach(pos => {
const m = createMarker(pos);
if (!m) return;
m.addTo(layerGroup);
bounds.extend(m.getLatLng());
if (!firstLatLng) firstLatLng = m.getLatLng();
valid++;
});
setCounter(valid);
// Tâm theo marker đầu tiên, sau đó fit bounds nếu có nhiều điểm
if (firstLatLng) {
if (valid === 1) {
map.setView(firstLatLng, 15);
} else {
map.fitBounds(bounds, { padding: [40, 40] });
}
}
} catch (e) {
console.error('loadPositions', e);
alert('Không thể tải dữ liệu vị trí.\nKiểm tra server tại ' + API);
} finally {
setLoading(false);
updatePaths();
}
}
// ══════════════════════════════════════════════════
// INFO PANEL
// ══════════════════════════════════════════════════
function showInfo(pos, streetNames, marker) {
activeInfoPos = pos;
saveLastMarker(pos);
syncToolbarToPos(pos);
document.getElementById('infoTitle').textContent = streetNames || 'Điểm #' + pos.id;
// Đường*: tất cả street trong pos.streets (bảng Streets cục bộ)
const streetsLocal = (pos.streets || [])
.map(id => streetMap[id]?.name)
.filter(Boolean)
.join(', ') || '—';
// Phường*: district của street ĐẦU TIÊN (bảng Districts cục bộ)
const firstSid = (pos.streets || [])[0];
const wardLocal = firstSid != null
? (districtMap[streetMap[firstSid]?.district_id]?.name || '—')
: '—';
const rows = [
['ID', pos.id],
['Lat / Lng', `${Number(pos.y).toFixed(6)} / ${Number(pos.x).toFixed(6)}`],
['Đường*', streetsLocal],
['Đường (osm)', '<span id="infoOsmStreet">Đang tải…</span>'],
['Phường*', wardLocal],
['Phường (osm)', '<span id="infoOsmWard">Đang tải…</span>'],
['Loại đường (osm)', '<span id="infoRoadType">Đang tải…</span>'],
['Chiều (osm)', '<span id="infoRoadOneway">Đang tải…</span>'],
['Cấm tải (osm)', '<span id="infoRoadRestrict">Đang tải…</span>'],
['Tốc độ', pos.speed != null ? pos.speed + ' km/h' : '—'],
['Làn xe', pos.lane ?? '—'],
['Đỗ xe', pos.park != null ? (pos.park ? 'Có' : 'Không') : '—'],
['Lũ lụt', pos.flooding != null ? (pos.flooding ? 'Có' : 'Không') : '—'],
['Thu phí', pos.tool != null ? (pos.tool ? 'Có' : 'Không') : '—'],
];
document.getElementById('infoBody').innerHTML = rows
.map(([l, v]) => `<div class="row"><span class="label">${l}</span><span>${v}</span></div>`)
.join('');
document.getElementById('infoPanel').style.display = 'block';
map.panTo(marker.getLatLng());
// Lấy street + district từ Nominatim (reverse geocoding)
fillReverseGeocode(pos);
// Lấy thuộc tính đường (1 chiều, cấm tải, cao tốc…) từ Overpass
fillRoadInfo(pos);
}
// ── Reverse geocoding qua Nominatim: lat/lon → street, ward (phường) ──
async function reverseGeocode(lat, lon) {
const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}`
+ `&format=json&addressdetails=1&accept-language=vi`;
const res = await fetch(url, { headers: { 'Accept-Language': 'vi' } });
if (!res.ok) throw new Error(`Nominatim → ${res.status}`);
const data = await res.json();
const a = data.address || {};
const street = a.road || a.pedestrian || a.residential || a.footway || a.path || a.neighbourhood || '';
const ward = a.quarter || a.suburb || a.ward || a.neighbourhood || a.city_district || a.district || a.county || '';
return { street, ward };
}
// Điền street/phường (OSM) vào info panel (cache theo pos để tránh gọi lại Nominatim)
async function fillReverseGeocode(pos) {
const apply = (street, ward) => {
if (activeInfoPos !== pos) return; // marker khác đã được chọn
const sEl = document.getElementById('infoOsmStreet');
const wEl = document.getElementById('infoOsmWard');
if (sEl) sEl.textContent = street || '—';
if (wEl) wEl.textContent = ward || '—';
};
if (pos._geo) { apply(pos._geo.street, pos._geo.ward); return; }
try {
const geo = await reverseGeocode(pos.y, pos.x);
pos._geo = geo;
apply(geo.street, geo.ward);
} catch (e) {
console.error('reverseGeocode', e);
apply('', '');
}
}
// ══════════════════════════════════════════════════
// ROAD INFO (Overpass: 1 chiều, cấm tải, cao tốc…)
// ══════════════════════════════════════════════════
const OVERPASS_URL = 'https://overpass-api.de/api/interpreter';
// Khoảng cách điểm → đoạn thẳng (mét), xấp xỉ phẳng (đủ cho cự ly ngắn)
function distPointToSegM(plat, plon, alat, alon, blat, blon) {
const mLat = 111320, mLon = 111320 * Math.cos(plat * Math.PI / 180);
const px = plon * mLon, py = plat * mLat;
const ax = alon * mLon, ay = alat * mLat;
const bx = blon * mLon, by = blat * mLat;
const dx = bx - ax, dy = by - ay;
const len2 = dx * dx + dy * dy;
let t = len2 ? ((px - ax) * dx + (py - ay) * dy) / len2 : 0;
t = Math.max(0, Math.min(1, t));
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
}
// Tìm way [highway] gần (lat,lon) nhất trong bán kính radius (m) qua Overpass
async function roadInfoAt(lat, lon, radius = 30) {
const ql = `[out:json][timeout:25];way(around:${radius},${lat},${lon})[highway];out tags geom;`;
const res = await fetch(OVERPASS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'data=' + encodeURIComponent(ql),
});
if (!res.ok) throw new Error(`Overpass → ${res.status}`);
const data = await res.json();
let best = null, bestD = Infinity;
for (const w of (data.elements || [])) {
if (w.type !== 'way' || !Array.isArray(w.geometry)) continue;
let d = Infinity;
for (let i = 0; i + 1 < w.geometry.length; i++) {
const dd = distPointToSegM(lat, lon, w.geometry[i].lat, w.geometry[i].lon, w.geometry[i+1].lat, w.geometry[i+1].lon);
if (dd < d) d = dd;
}
if (d < bestD) { bestD = d; best = w; }
}
return best ? { tags: best.tags || {}, dist: bestD } : null;
}
// Diễn giải tag OSM → tiếng Việt
function formatRoadInfo(tags) {
const HW = {
motorway: 'Cao tốc', motorway_link: 'Nhánh cao tốc',
trunk: 'Trục chính/QL', trunk_link: 'Nhánh trục chính',
primary: 'Đường chính', secondary: 'Đường thứ cấp', tertiary: 'Đường nhánh',
residential: 'Khu dân cư', service: 'Đường nội bộ', living_street: 'Đường nội khu',
unclassified: 'Chưa phân loại', pedestrian: 'Đi bộ', footway: 'Lối đi bộ', cycleway: 'Đường xe đạp',
};
const hw = tags.highway || '';
const loai = HW[hw] || hw || '—';
let oneway;
const ow = tags.oneway;
if (['yes', 'true', '1'].includes(ow)) oneway = '1 chiều';
else if (['-1', 'reverse'].includes(ow)) oneway = '1 chiều (ngược hướng vẽ)';
else if (ow === 'no' || ow === 'false') oneway = '2 chiều';
else if (hw === 'motorway' || hw === 'motorway_link') oneway = '1 chiều (mặc định cao tốc)';
else oneway = ow ? ow : '2 chiều / chưa rõ';
const restr = [];
if (['no', 'destination'].includes(tags.hgv)) restr.push('hạn chế xe tải');
if (['no', 'private'].includes(tags.access)) restr.push('hạn chế lưu thông');
if (tags.motor_vehicle === 'no') restr.push('cấm xe cơ giới');
if (tags.maxweight) restr.push('tải ≤ ' + tags.maxweight + 't');
if (tags.maxheight) restr.push('cao ≤ ' + tags.maxheight + 'm');
const camTai = restr.length ? restr.join(', ') : '—';
return { loai, oneway, camTai };
}
// Điền thuộc tính đường vào info panel (cache theo pos._road)
async function fillRoadInfo(pos) {
const apply = (info) => {
if (activeInfoPos !== pos) return;
const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
set('infoRoadType', info ? info.loai : '—');
set('infoRoadOneway', info ? info.oneway : '—');
set('infoRoadRestrict', info ? info.camTai : '—');
};
if (pos._road) { apply(pos._road); return; }
try {
const r = await roadInfoAt(pos.y, pos.x, 30);
pos._road = r ? formatRoadInfo(r.tags) : { loai: '—', oneway: '—', camTai: '—' };
apply(pos._road);
} catch (e) {
console.error('roadInfoAt', e);
apply(null);
}
}
// ══════════════════════════════════════════════════
// ADD POINT (thêm điểm mới qua click map)
// ══════════════════════════════════════════════════
function setAddMode(on) {
addMode = on;
if (on) { if (moveMode) setMoveMode(false); setGroupMode(false); setPointPickMode(null); }
const btn = document.getElementById('btnAdd');
btn.classList.toggle('active', on);
btn.classList.toggle('secondary', !on);
document.getElementById('map').style.cursor = on ? 'crosshair' : '';
if (on) showToast('Chế độ thêm điểm: BẬT — click 1 điểm trên bản đồ');
}
// Quick-add: tạo điểm ngay theo street id từ toolbar (không hiện modal)
async function quickAddPoint(lat, lng, streetId) {
try {
const resp = await apiSend('/positions/bulk', 'POST', [{ lat, lon: lng, streets: [streetId] }]);
const row = (resp.created || [])[0];
if (!row) throw new Error('Không nhận được dữ liệu điểm');
const pos = { id: row.id, x: row.long != null ? row.long : lng, y: row.lat != null ? row.lat : lat, streets: row.streets || [streetId] };
const m = createMarker(pos);
if (m) m.addTo(layerGroup);
const sname = (streetMap[streetId] && streetMap[streetId].name) || ('#' + streetId);
showToast(`Đã tạo điểm #${pos.id} trên ${sname}`);
} catch (e) {
console.error('quickAddPoint', e);
alert('Tạo điểm thất bại.');
}
}
async function startAddPoint(lat, lng) {
const rlat = Math.round(lat * 1e6) / 1e6;
const rlng = Math.round(lng * 1e6) / 1e6;
// Quick-add: toolbar đã chọn street → tạo ngay theo id toolbar (không hiện modal), giữ chế độ để thêm tiếp
const toolbarStreet = parseInt(document.getElementById('selStreet').value);
if (toolbarStreet) { await quickAddPoint(rlat, rlng, toolbarStreet); return; }
setAddMode(false); // một click → một điểm (modal khi chưa chọn street)
closeAddModal(); // dọn điểm tạm/modal trước đó
closeSetNode();
pendingGeo = { lat: rlat, lng: rlng, osmStreet: '', osmWard: '' };
// Marker tạm (xanh lá, kéo-thả để tinh chỉnh vị trí)
pendingMarker = L.marker([rlat, rlng], { icon: circleIcon('#34a853'), draggable: true }).addTo(map);
pendingMarker.on('dragend', () => {
if (!pendingGeo) return;
const ll = pendingMarker.getLatLng();
pendingGeo.lat = Math.round(ll.lat * 1e6) / 1e6;
pendingGeo.lng = Math.round(ll.lng * 1e6) / 1e6;
document.getElementById('addLatLng').textContent = `${pendingGeo.lat} / ${pendingGeo.lng}`;
fillNearbyStreets(pendingGeo.lat, pendingGeo.lng);
});
// Mở modal + trạng thái loading
document.getElementById('addLatLng').textContent = `${rlat} / ${rlng}`;
document.getElementById('addDistrictOsm').textContent = 'OSM: đang tải…';
document.getElementById('addStreetOsm').textContent = 'OSM: đang tải…';
resetSelect('addDistrictSel', '-- Chọn phường --');
resetSelect('addStreetSel', '-- Chọn đường --');
clearNodeStreets();
updateSubmitState();
document.getElementById('addModal').style.display = 'block';
map.panTo([rlat, rlng]);
// Đổ đường lân cận ≤1km vào dropdown (tham khảo "Set Node" bên maps.html)
fillNearbyStreets(rlat, rlng);
// 1) Reverse geocode → tên đường + phường OSM
let geo = { street: '', ward: '' };
try { geo = await reverseGeocode(rlat, rlng); }
catch (e) { console.error('reverseGeocode(add)', e); }
if (!pendingGeo) return; // đã huỷ trong lúc chờ
pendingGeo.osmStreet = geo.street || '';
pendingGeo.osmWard = geo.ward || '';
document.getElementById('addStreetOsm').textContent = 'OSM: ' + (geo.street || '(không có)');
document.getElementById('addDistrictOsm').textContent = 'OSM: ' + (geo.ward || '(không có)');
// 2) Fuzzy search song song street + district (dữ liệu cục bộ, city_id=1)
const [streetRes, districtRes] = await Promise.all([
geo.street ? apiSend('/streets/search', 'POST', { text: geo.street, city_id: 1, limit: 10 }).catch(() => ({ results: [] })) : Promise.resolve({ results: [] }),
geo.ward ? apiSend('/districts/search', 'POST', { text: geo.ward, city_id: 1, limit: 10 }).catch(() => ({ results: [] })) : Promise.resolve({ results: [] }),
]);
if (!pendingGeo) return;
fillDistrictOptions(districtRes.results || []);
fillStreetOptions(streetRes.results || []);
updateSubmitState();
}
function resetSelect(id, placeholder) {
document.getElementById(id).innerHTML = `<option value="">${placeholder}</option>`;
}
function fillDistrictOptions(results) {
const sel = document.getElementById('addDistrictSel');
sel.innerHTML = '<option value="">-- Chọn phường --</option>';
results.forEach(d => {
const o = document.createElement('option');
o.value = d.id;
o.textContent = `${d.name} (${d.score})`;
sel.appendChild(o);
});
if (results[0] && results[0].score <= 0.2) sel.value = results[0].id;
}
function fillStreetOptions(results) {
const sel = document.getElementById('addStreetSel');
sel.innerHTML = '<option value="">-- Chọn đường --</option>';
results.forEach(s => {
const o = document.createElement('option');
o.value = s.id;
o.textContent = `${s.name} (${s.score})`;
sel.appendChild(o);
});
if (results[0] && results[0].score <= 0.2) sel.value = results[0].id;
}
// Haversine (km)
function haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371;
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));
}
// Đường có ít nhất 1 điểm (marker đã load) nằm trong bán kính radiusKm quanh (lat,lng)
function getNearbyStreets(lat, lng, radiusKm) {
const sids = new Set();
if (layerGroup) {
layerGroup.eachLayer(layer => {
const p = layer._pos;
if (!p || p.y == null || p.x == null) return;
if (haversineKm(lat, lng, p.y, p.x) <= radiusKm) {
(p.streets || []).forEach(sid => sids.add(sid));
}
});
}
return [...sids]
.map(sid => (streetMap[sid] ? { id: sid, name: streetMap[sid].name || ('#' + sid) } : null))
.filter(Boolean)
.sort((a, b) => String(a.name).localeCompare(String(b.name)));
}
// Các point (marker đã load) trong bán kính radiusKm quanh (lat,lng), trừ chính nó
function getNearbyPoints(lat, lng, radiusKm, excludeId) {
const out = [];
if (layerGroup) {
layerGroup.eachLayer(layer => {
const p = layer._pos;
if (!p || p.y == null || p.x == null || p.id === excludeId) return;
if (haversineKm(lat, lng, p.y, p.x) <= radiusKm) {
out.push({ id: p.id, lat: Number(p.y), lng: Number(p.x) });
}
});
}
return out.sort((a, b) => a.id - b.id);
}
// Đổ danh sách đường lân cận vào dropdown để chọn thêm vào node (giống Set Node bên maps)
function fillNearbyStreets(lat, lng) {
const sel = document.getElementById('nearbyStreetSel');
const list = getNearbyStreets(lat, lng, NEARBY_RADIUS_KM);
document.getElementById('nearbyCount').textContent = list.length ? `(${list.length})` : '';
sel.innerHTML = '';
list.forEach(s => {
const o = document.createElement('option');
o.value = s.id;
o.textContent = `${s.name} (#${s.id})`;
sel.appendChild(o);
});
}
// Xoá danh sách đường đã thêm vào node
function clearNodeStreets() {
document.getElementById('nodeStreetsList').innerHTML = '';
}
// Thêm 1 dòng đường vào danh sách node (dedup); onChange gọi sau khi thêm/xoá
function appendStreetRow(listEl, id, onChange) {
if (!id || listEl.querySelector(`[data-street-id="${id}"]`)) return false;
const name = (streetMap[id] && streetMap[id].name) || ('#' + id);
const row = document.createElement('div');
row.className = 'node-street-row';
row.setAttribute('data-street-id', id);
const span = document.createElement('span');
span.textContent = `${name} (#${id})`;
const btn = document.createElement('button');
btn.type = 'button'; btn.textContent = '✕'; btn.title = 'Bỏ';
btn.addEventListener('click', () => { row.remove(); if (onChange) onChange(); });
row.appendChild(span);
row.appendChild(btn);
listEl.appendChild(row);
if (onChange) onChange();
return true;
}
// Thêm các đường lân cận đang chọn (nhiều) vào danh sách node
function addNearbyStreet() {
const sel = document.getElementById('nearbyStreetSel');
const ids = [...sel.selectedOptions].map(o => parseInt(o.value)).filter(Boolean);
if (!ids.length) return;
const listEl = document.getElementById('nodeStreetsList');
ids.forEach(id => appendStreetRow(listEl, id));
sel.selectedIndex = -1; // bỏ chọn sau khi thêm
}
// Tạo phường mới từ OSM, gắn vào dropdown
async function onCreateDistrict() {
const ward = pendingGeo && pendingGeo.osmWard;
if (!ward) { alert('Không có tên phường từ OSM để tạo.'); return; }
try {
const d = await apiSend('/districts', 'POST', { name: ward, lname: ward, city: 1 });
districtMap[d.id] = d; // cập nhật resolver toàn cục
const sel = document.getElementById('addDistrictSel');
const o = document.createElement('option');
o.value = d.id; o.textContent = `${d.name} (mới)`;
sel.appendChild(o);
sel.value = d.id;
showToast(`Đã tạo phường #${d.id}: ${d.name}`);
} catch (e) {
console.error('createDistrict', e);
alert('Tạo phường thất bại.');
}
}
// Tạo đường mới từ OSM (cần district đã chọn), gắn vào dropdown
async function onCreateStreet() {
const districtId = parseInt(document.getElementById('addDistrictSel').value);
if (!districtId) { alert('Hãy chọn/tạo phường trước khi tạo đường.'); return; }
const name = pendingGeo && pendingGeo.osmStreet;
if (!name) { alert('Không có tên đường từ OSM để tạo.'); return; }
try {
const s = await createStreetAjax({ name, type: 'Phố', district_id: districtId, city_id: 1 });
streetMap[s.id] = { id: s.id, name: s.name, district_id: districtId, city_id: 1, type: 'Phố' };
const sel = document.getElementById('addStreetSel');
let opt = [...sel.options].find(o => String(o.value) === String(s.id));
if (!opt) {
opt = document.createElement('option');
opt.value = s.id;
opt.textContent = `${s.name}${s.duplicate ? ' (đã có)' : ' (mới)'}`;
sel.appendChild(opt);
}
sel.value = s.id;
updateSubmitState();
showToast(`${s.duplicate ? 'Đường đã tồn tại' : 'Đã tạo đường'} #${s.id}: ${s.name}`);
} catch (e) {
console.error('createStreet', e);
alert('Tạo đường thất bại.');
}
}
// POST /api/streets có thể trả 409 (trùng) → dùng lại street đang có
async function createStreetAjax(payload) {
const res = await fetch(API + '/streets', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': sessionStorage.getItem('admin_api_key') || '' },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (res.status === 409 && data.street) {
return { id: data.street.id, name: data.street.name, duplicate: true };
}
if (!res.ok) throw new Error(data.detail || `streets → ${res.status}`);
return data; // { id, name, ... }
}
// Submit: tạo Position mới với streets=[street_id]
async function onSubmitPoint() {
const streetId = parseInt(document.getElementById('addStreetSel').value);
if (!streetId) { alert('Vui lòng chọn đường (street) trước khi tạo điểm.'); return; }
if (!pendingGeo) return;
const lat = pendingGeo.lat, lng = pendingGeo.lng;
// Gộp đường chính + đường lân cận đã thêm vào chung 1 node
const nearbyIds = [...document.querySelectorAll('#nodeStreetsList [data-street-id]')].map(el => parseInt(el.getAttribute('data-street-id')));
const streets = [...new Set([streetId, ...nearbyIds].filter(Boolean))];
const btn = document.getElementById('btnSubmitPoint');
btn.disabled = true;
try {
const resp = await apiSend('/positions/bulk', 'POST', [{ lat, lon: lng, streets }]);
const row = (resp.created || [])[0];
if (!row) throw new Error('Không nhận được dữ liệu điểm');
const pos = { id: row.id, x: row.long != null ? row.long : lng, y: row.lat != null ? row.lat : lat, streets: row.streets || streets };
const m = createMarker(pos);
if (m) m.addTo(layerGroup);
closeAddModal();
showToast(`Đã tạo điểm #${pos.id}`);
} catch (e) {
console.error('createPosition', e);
alert('Tạo điểm thất bại.');
btn.disabled = false;
}
}
function updateSubmitState() {
const has = !!document.getElementById('addStreetSel').value;
document.getElementById('btnSubmitPoint').disabled = !has;
}
function closeAddModal() {
document.getElementById('addModal').style.display = 'none';
if (pendingMarker) { map.removeLayer(pendingMarker); pendingMarker = null; }
pendingGeo = null;
}
// ══════════════════════════════════════════════════
// SET NODE (sửa đường của 1 điểm có sẵn)
// ══════════════════════════════════════════════════
let setNodeTarget = null;
function findMarkerByPos(pos) {
let found = null;
if (layerGroup) layerGroup.eachLayer(l => { if (l._pos === pos) found = l; });
return found;
}
function openSetNode() {
const pos = activeInfoPos;
if (!pos) return;
setNodeTarget = pos;
closeAddModal();
document.getElementById('setNodeInfo').textContent =
`#${pos.id} — ${Number(pos.y).toFixed(6)} / ${Number(pos.x).toFixed(6)}`;
// Dropdown đường lân cận ≤1km
const sel = document.getElementById('setNodeNearbySel');
const list = getNearbyStreets(pos.y, pos.x, NEARBY_RADIUS_KM);
document.getElementById('setNodeNearbyCount').textContent = list.length ? `(${list.length})` : '';
sel.innerHTML = '';
list.forEach(s => {
const o = document.createElement('option');
o.value = s.id; o.textContent = `${s.name} (#${s.id})`;
sel.appendChild(o);
});
// Danh sách đường hiện tại của node
const listEl = document.getElementById('setNodeStreetsList');
listEl.innerHTML = '';
(pos.streets || []).forEach(id => appendStreetRow(listEl, id, updateSetNodeSaveState));
updateSetNodeSaveState();
// ── Cấm rẽ: point lân cận ≤500m ──
const btSel = document.getElementById('setNodeBanTurn');
const nearbyPts = getNearbyPoints(pos.y, pos.x, BAN_TURN_RADIUS_KM, pos.id);
document.getElementById('setNodeBanTurnCount').textContent = nearbyPts.length ? `(${nearbyPts.length})` : '';
const banTurn = Array.isArray(pos.ban_turn) ? pos.ban_turn.map(Number) : [];
btSel.innerHTML = '';
nearbyPts.forEach(p => {
const o = document.createElement('option');
o.value = String(p.id);
o.textContent = `#${p.id} (${p.lat.toFixed(4)}, ${p.lng.toFixed(4)})`;
if (banTurn.includes(p.id)) o.selected = true;
btSel.appendChild(o);
});
// ── Cấm phương tiện ──
const bvSel = document.getElementById('setNodeBanVehicle');
const banVeh = Array.isArray(pos.ban_vehicle) ? pos.ban_vehicle : [];
bvSel.innerHTML = '';
BAN_VEHICLE_OPTIONS.forEach(v => {
const o = document.createElement('option');
o.value = v; o.textContent = v;
if (banVeh.includes(v)) o.selected = true;
bvSel.appendChild(o);
});
// ── Cấm giờ ──
const bhSel = document.getElementById('setNodeBanHours');
const banHrs = Array.isArray(pos.ban_hours) ? pos.ban_hours.map(Number) : [];
bhSel.innerHTML = '';
BAN_HOURS_OPTIONS.forEach(h => {
const o = document.createElement('option');
o.value = String(h.id); o.textContent = `${h.id}. ${h.label}`;
if (banHrs.includes(h.id)) o.selected = true;
bhSel.appendChild(o);
});
// ── Cấm tải trọng ──
const bwSel = document.getElementById('setNodeBanWeight');
const banW = Array.isArray(pos.ban_weight) ? pos.ban_weight.map(Number) : [];
bwSel.innerHTML = '';
BAN_WEIGHT_OPTIONS.forEach(w => {
const o = document.createElement('option');
o.value = String(w); o.textContent = `${w} tấn`;
if (banW.includes(w)) o.selected = true;
bwSel.appendChild(o);
});
document.getElementById('setNodeModal').style.display = 'block';
}
function setNodeAdd() {
const sel = document.getElementById('setNodeNearbySel');
const ids = [...sel.selectedOptions].map(o => parseInt(o.value)).filter(Boolean);
if (!ids.length) return;
const listEl = document.getElementById('setNodeStreetsList');
ids.forEach(id => appendStreetRow(listEl, id, updateSetNodeSaveState));
sel.selectedIndex = -1;
}
function updateSetNodeSaveState() {
const n = document.querySelectorAll('#setNodeStreetsList [data-street-id]').length;
document.getElementById('btnSetNodeSave').disabled = n === 0;
}
function closeSetNode() {
document.getElementById('setNodeModal').style.display = 'none';
setNodeTarget = null;
}
async function saveSetNode() {
const pos = setNodeTarget;
if (!pos) return;
const streets = [...document.querySelectorAll('#setNodeStreetsList [data-street-id]')]
.map(el => parseInt(el.getAttribute('data-street-id'))).filter(Boolean);
if (!streets.length) { alert('Node cần ít nhất 1 đường.'); return; }
const ban_turn = [...document.getElementById('setNodeBanTurn').selectedOptions]
.map(o => parseInt(o.value)).filter(v => !Number.isNaN(v));
const ban_vehicle = [...document.getElementById('setNodeBanVehicle').selectedOptions]
.map(o => o.value);
const ban_hours = [...document.getElementById('setNodeBanHours').selectedOptions]
.map(o => parseInt(o.value)).filter(v => !Number.isNaN(v));
const ban_weight = [...document.getElementById('setNodeBanWeight').selectedOptions]
.map(o => parseFloat(o.value)).filter(v => !Number.isNaN(v));
const btn = document.getElementById('btnSetNodeSave');
btn.disabled = true;
try {
// pos_id có → backend UPDATE streets + đồng bộ Streets.positions
await apiSend('/positions/bulk', 'POST', [{
lat: pos.y, lon: pos.x, streets, pos_id: pos.id,
ban_turn, ban_vehicle, ban_hours, ban_weight,
}]);
pos.streets = streets;
pos.ban_turn = ban_turn;
pos.ban_vehicle = ban_vehicle;
pos.ban_hours = ban_hours;
pos.ban_weight = ban_weight;
const names = streets.map(id => streetMap[id] && streetMap[id].name).filter(Boolean).join(', ') || '—';
const marker = findMarkerByPos(pos);
if (marker) {
marker._streetNames = names;
const el = marker.getElement && marker.getElement();
if (el) el.setAttribute('title', names);
}
closeSetNode();
if (activeInfoPos === pos && marker) showInfo(pos, names, marker);
showToast(`Đã lưu node #${pos.id}: ${streets.length} đường`);
} catch (e) {
console.error('saveSetNode', e);
alert('Lưu node thất bại.');
btn.disabled = false;
}
}
// ══════════════════════════════════════════════════
// GROUPS STORE + MÀU MARKER THEO GROUP (global)
// ══════════════════════════════════════════════════
// Nạp toàn bộ group từ DB vào store toàn cục (gọi khi boot + sau khi tạo group)
async function loadAllGroups() {
try {
const groups = await apiFetch('/groups');
allGroups = Array.isArray(groups) ? groups : [];
} catch (e) {
console.error('loadAllGroups', e);
allGroups = [];
}
rebuildStreetGroupMap();
refreshGroupDropdown();
}
// Dựng lại map street id → group id (1 street chỉ thuộc group đầu tiên claim nó)
function rebuildStreetGroupMap() {
streetGroupMap = {};
(allGroups || []).forEach(g => {
(g.streets || []).forEach(sid => {
if (streetGroupMap[sid] == null) streetGroupMap[sid] = g.id;
});
});
}
// Thêm/cập nhật 1 group vào store sau khi tạo thành công trên DB
function addGroupToStore(g) {
if (!g || g.id == null) return;
const idx = allGroups.findIndex(x => x.id === g.id);
if (idx >= 0) allGroups[idx] = g; else allGroups.push(g);
rebuildStreetGroupMap();
refreshGroupDropdown();
}
// Street đã thuộc 1 group nào đó → loại trừ khi tạo group mới
function isStreetGrouped(sid) {
return streetGroupMap[sid] != null;
}
// Street thuộc group KHÁC (bỏ qua group đang sửa) → loại trừ khi thêm đường vào group
function isStreetGroupedByOther(sid) {
const gid = streetGroupMap[sid];
return gid != null && gid !== cgEditId;
}
// Màu riêng ổn định cho mỗi group theo id (góc vàng cho phân bố hue đều)
function groupColor(gid) {
const hue = (Number(gid) * 137.508) % 360;
return `hsl(${Math.round(hue)}, 65%, 45%)`;
}
// Group id mà 1 điểm thuộc về (theo street đầu tiên có group), hoặc null
function groupOfPos(pos) {
if (!pos || !Array.isArray(pos.streets)) return null;
for (const sid of pos.streets) {
if (streetGroupMap[sid] != null) return streetGroupMap[sid];
}
return null;
}
// Màu nền của marker: theo group nếu thuộc group, ngược lại xanh mặc định
function posBaseColor(pos) {
const gid = groupOfPos(pos);
return gid != null ? groupColor(gid) : DEFAULT_MARKER_COLOR;
}
// Tô lại màu toàn bộ marker theo group membership hiện tại
function refreshMarkerColors() {
if (!layerGroup) return;
layerGroup.eachLayer(m => { if (m._pos) m.setIcon(circleIcon(posBaseColor(m._pos))); });
}
// ══════════════════════════════════════════════════
// CREATE GROUP (chọn 1 điểm → tô đỏ điểm của các đường chưa thuộc group trong bán kính)
// ══════════════════════════════════════════════════
// Bật/tắt chế độ tạo nhóm (loại trừ với Move/Add điểm)
function setGroupMode(on) {
groupMode = on;
if (on) { setMoveMode(false); setAddMode(false); setPointPickMode(null); closeAddModal(); closeSetNode(); hideInfo(); }
else { clearGroupHighlight(); closeCreateGroupModal(); }
const btn = document.getElementById('btnGroup');
if (btn) {
btn.classList.toggle('active', on);
btn.classList.toggle('secondary', !on);
}
document.getElementById('map').style.cursor = on ? 'pointer' : '';
updateMainCreateState();
if (on) showToast(`Chế độ tạo nhóm: BẬT — chọn 1 điểm để gom đường ≤${GROUP_RADIUS_KM}km`);
}
// Trả các marker đã tô đỏ về màu nền (theo group) + xoá vòng tròn bán kính
function clearGroupHighlight() {
groupHiliteMarkers.forEach(m => { if (m) m.setIcon(circleIcon(posBaseColor(m._pos))); });
groupHiliteMarkers = [];
if (groupRadiusCircle && map) map.removeLayer(groupRadiusCircle);
groupRadiusCircle = null;
groupAnchorMarker = null;
updateMainCreateState();
}
// Chọn 1 điểm làm tâm → tô đỏ mọi điểm thuộc các đường có ≥1 điểm trong bán kính 2km
function selectGroupAnchor(marker) {
clearGroupHighlight();
closeCreateGroupModal();
const pos = marker && marker._pos;
if (!pos || pos.y == null || pos.x == null) return;
groupAnchorMarker = marker;
// Vòng tròn minh hoạ bán kính quanh điểm được chọn
groupRadiusCircle = L.circle([pos.y, pos.x], {
radius: GROUP_RADIUS_KM * 1000,
color: '#ea4335', weight: 1.5, opacity: 0.7,
fillColor: '#ea4335', fillOpacity: 0.06,
}).addTo(map);
// Mọi đường (chưa thuộc group khác) có ≥1 điểm trong bán kính quanh điểm được chọn
const streets = getNearbyStreets(pos.y, pos.x, GROUP_RADIUS_KM).filter(s => !isStreetGrouped(s.id));
const sidSet = new Set(streets.map(s => s.id));
// Tô đỏ mọi điểm thuộc các đường tìm thấy
let count = 0;
if (layerGroup) layerGroup.eachLayer(m => {
const p = m._pos;
if (!p || !Array.isArray(p.streets)) return;
if (p.streets.some(sid => sidSet.has(sid))) {
m.setIcon(circleIcon('#ea4335'));
groupHiliteMarkers.push(m);
count++;
}
});
showToast(`Nhóm quanh #${pos.id}: ${streets.length} đường · ${count} điểm ≤${GROUP_RADIUS_KM}km`);
updateMainCreateState();
}
// ══════════════════════════════════════════════════
// CREATE GROUP — MODAL & SAVE
// ══════════════════════════════════════════════════
// FAB main_create: pencil (sửa) khi có group chọn trên toolbar; + (tạo) khi Create Group + đã chọn điểm
function updateMainCreateState() {
const btn = document.getElementById('mainCreateBtn');
if (!btn) return;
const editGroup = selectedGroupId != null;
const editStreet = !editGroup && selectedStreetId != null;
const editing = editGroup || editStreet;
const creating = groupMode && !!groupAnchorMarker;
const enabled = editing || creating;
btn.classList.toggle('active', enabled);
btn.classList.toggle('editing', editing);
btn.disabled = !enabled;
btn.textContent = editing ? '✏️' : '+';
btn.title = editGroup
? `Sửa Group #${selectedGroupId}`
: (editStreet ? `Sửa đường #${selectedStreetId}`
: (creating ? 'Tạo Group từ điểm đã chọn' : 'Chọn 1 điểm ở Create Group, hoặc chọn Group/đường để sửa'));
}
// Click FAB → sửa group đang chọn trên toolbar (ưu tiên) hoặc tạo group mới
function onMainCreate() {
if (selectedGroupId != null) { openEditGroupModal(selectedGroupId); return; }
if (selectedStreetId != null) { openEditStreetModal(selectedStreetId); return; }
if (groupMode && groupAnchorMarker) { openCreateGroupModal(); return; }
}
// Đổ danh sách group vào dropdown toolbar (giữ lựa chọn hiện tại nếu còn)
function refreshGroupDropdown() {
const sel = document.getElementById('selGroup');
if (!sel) return;
const cur = sel.value;
sel.innerHTML = '<option value="">-- Group --</option>';
(allGroups || []).forEach(g => {
const o = document.createElement('option');
const nStreets = Array.isArray(g.streets) ? g.streets.length : 0;
o.value = g.id;
o.textContent = `Group #${g.id} · ${nStreets} đường`;
o.style.color = groupColor(g.id);
sel.appendChild(o);
});
if (cur && (allGroups || []).some(g => String(g.id) === String(cur))) sel.value = cur;
else { sel.value = ''; selectedGroupId = null; }
}
// Chọn group trên toolbar → FAB thành pencil (sửa) + pan tới tâm group
function onSelectGroupFromToolbar() {
const sel = document.getElementById('selGroup');
const v = sel ? sel.value : '';
selectedGroupId = v ? parseInt(v) : null;
if (selectedGroupId != null) clearGroupHighlight(); // bỏ preview tạo group khi chuyển sang sửa
updateMainCreateState();
const g = selectedGroupId != null ? (allGroups || []).find(x => x.id === selectedGroupId) : null;
if (g && g.lat_center != null && g.long_center != null && map) {
map.setView([g.lat_center, g.long_center], Math.max(map.getZoom(), 14));
}
}
// Mở modal TẠO group từ điểm tâm đang chọn
function openCreateGroupModal() {
const pos = groupAnchorMarker && groupAnchorMarker._pos;
if (!pos) return;
const streetIds = getNearbyStreets(pos.y, pos.x, GROUP_RADIUS_KM)
.filter(s => !isStreetGrouped(s.id)).map(s => s.id);
populateGroupModal({ editId: null, lat: pos.y, lng: pos.x, streetIds, neighborIds: [] });
}
// Mở modal SỬA group đã chọn trên toolbar
function openEditGroupModal(gid) {
const g = (allGroups || []).find(x => x.id === gid);
if (!g) { alert('Không tìm thấy group #' + gid); return; }
populateGroupModal({
editId: g.id,
lat: g.lat_center, lng: g.long_center,
streetIds: (g.streets || []),
neighborIds: (g.neighbor || []),
});
}
// Đổ dữ liệu vào modal (dùng chung cho TẠO & SỬA)
function populateGroupModal({ editId, lat, lng, streetIds, neighborIds }) {
cgEditId = editId != null ? editId : null;
closeAddModal(); closeSetNode();
document.getElementById('cgTitle').textContent = cgEditId != null ? `Sửa Group #${cgEditId}` : 'Tạo Group';
document.getElementById('cgCenter').textContent = `${Number(lat).toFixed(6)} / ${Number(lng).toFixed(6)}`;
document.getElementById('cgOuterKm').textContent = (GROUP_RADIUS_KM * GROUP_OUTER_MULT);
// Main list
const listEl = document.getElementById('cgMainList');
listEl.innerHTML = '';
(streetIds || []).forEach(sid => appendStreetRow(listEl, sid, cgUpdateCounts));
const mainSet = new Set((streetIds || []).map(Number));
// Dropdown second = đường quanh tâm (vùng gấp đôi bán kính), chưa có trong main & chưa thuộc group KHÁC
const outer = getNearbyStreets(lat, lng, GROUP_RADIUS_KM * GROUP_OUTER_MULT)
.filter(s => !mainSet.has(s.id) && !isStreetGroupedByOther(s.id));
const outerSel = document.getElementById('cgOuterSel');
outerSel.innerHTML = '';
outer.forEach(s => {
const o = document.createElement('option');
o.value = s.id; o.textContent = `${s.name} (#${s.id})`;
outerSel.appendChild(o);
});
document.getElementById('cgOuterCount').textContent = outer.length ? `(${outer.length})` : '';
// Neighbor dropdown (loại chính group đang sửa, pre-select neighbor hiện có)
loadGroupsIntoNeighbor(cgEditId, neighborIds || []);
document.getElementById('cgSave').textContent = cgEditId != null ? 'Cập nhật' : 'Lưu group';
cgUpdateCounts();
document.getElementById('createGroupModal').style.display = 'block';
}
// Đếm đường trong main list + bật/tắt nút Lưu
function cgUpdateCounts() {
const n = document.querySelectorAll('#cgMainList [data-street-id]').length;
document.getElementById('cgMainCount').textContent = n ? `(${n})` : '';
document.getElementById('cgSave').disabled = n === 0;
}
// Thêm các đường ngoài bán kính (đã chọn) vào main list
function cgAddOuterStreets() {
const sel = document.getElementById('cgOuterSel');
const chosen = [...sel.selectedOptions];
if (!chosen.length) return;
const listEl = document.getElementById('cgMainList');
chosen.forEach(o => {
const id = parseInt(o.value);
if (id) { appendStreetRow(listEl, id, cgUpdateCounts); o.remove(); }
});
const remain = sel.options.length;
document.getElementById('cgOuterCount').textContent = remain ? `(${remain})` : '';
cgUpdateCounts();
}
// Nạp neighbor dropdown từ store allGroups (loại group đang sửa, pre-select neighbor có sẵn)
function loadGroupsIntoNeighbor(excludeId, selectedIds) {
const sel = document.getElementById('cgNeighborSel');
const cnt = document.getElementById('cgNeighborCount');
const selSet = new Set((selectedIds || []).map(Number));
sel.innerHTML = '';
(allGroups || []).forEach(g => {
if (excludeId != null && g.id === excludeId) return; // group không tự làm neighbor của chính nó
const o = document.createElement('option');
const nStreets = Array.isArray(g.streets) ? g.streets.length : 0;
o.value = g.id;
o.textContent = `Group #${g.id} · ${nStreets} đường`;
o.style.color = groupColor(g.id);
if (selSet.has(g.id)) o.selected = true;
sel.appendChild(o);
});
cnt.textContent = `(${sel.options.length})`;
}
function closeCreateGroupModal() {
const el = document.getElementById('createGroupModal');
if (el) el.style.display = 'none';
cgEditId = null;
}
// Lưu group qua API: TẠO (POST) hoặc SỬA (PUT). streets = main list, neighbor = group đã chọn.
async function saveCreateGroup() {
const streets = [...document.querySelectorAll('#cgMainList [data-street-id]')]
.map(el => parseInt(el.getAttribute('data-street-id'))).filter(Boolean);
if (!streets.length) { alert('Group cần ít nhất 1 đường.'); return; }
const neighbor = [...document.getElementById('cgNeighborSel').selectedOptions]
.map(o => parseInt(o.value)).filter(Boolean);
const btn = document.getElementById('cgSave');
btn.disabled = true;
try {
let g;
if (cgEditId != null) {
// SỬA: giữ nguyên tâm, chỉ cập nhật streets + neighbor
g = await apiSend(`/groups/${cgEditId}`, 'PUT', { streets, neighbor });
addGroupToStore(g);
showToast(`Đã cập nhật Group #${g.id}: ${streets.length} đường · ${neighbor.length} neighbor`);
} else {
// TẠO: tâm = điểm đang chọn
const pos = groupAnchorMarker && groupAnchorMarker._pos;
if (!pos) { alert('Chưa có điểm tâm.'); btn.disabled = false; return; }
g = await apiSend('/groups', 'POST', { lat_center: pos.y, long_center: pos.x, streets, neighbor });
addGroupToStore(g);
clearGroupHighlight();
showToast(`Đã tạo Group #${g.id}: ${streets.length} đường · ${neighbor.length} neighbor`);
}
refreshMarkerColors(); // tô lại marker theo group membership mới
closeCreateGroupModal();
} catch (e) {
console.error('saveCreateGroup', e);
alert('Lưu group thất bại.');
btn.disabled = false;
}
}
// ══════════════════════════════════════════════════
// EDIT STREET (sửa đường + sắp xếp thứ tự point + oneway)
// ══════════════════════════════════════════════════
// Chọn đường trên toolbar → target sửa street (bỏ chọn group nếu đang chọn)
function onSelectStreetFromToolbar() {
const v = document.getElementById('selStreet').value;
selectedStreetId = v ? parseInt(v) : null;
if (selectedStreetId != null) {
const sg = document.getElementById('selGroup');
if (sg && sg.value) { sg.value = ''; selectedGroupId = null; }
}
updatePaths();
updateMainCreateState();
}
// Sắp xếp point theo nearest-neighbor bắt đầu từ startId (trả list {id,lat,lng})
function orderPointsFromStart(points, startId) {
const rem = points.slice();
const si = rem.findIndex(p => p.id === startId);
if (si < 0) return rem;
const result = [rem.splice(si, 1)[0]];
let cur = result[0];
while (rem.length) {
let minD = Infinity, minI = 0;
for (let i = 0; i < rem.length; i++) {
const d = _geoDist2({ lat: cur.lat, lng: cur.lng }, { lat: rem[i].lat, lng: rem[i].lng });
if (d < minD) { minD = d; minI = i; }
}
cur = rem.splice(minI, 1)[0];
result.push(cur);
}
return result;
}
// Đổ dropdown master: tất cả point của đường
function esRenderAllPos() {
const sel = document.getElementById('esAllPosSel');
sel.innerHTML = '';
esStreetPoints.forEach(p => {
const o = document.createElement('option');
o.value = String(p.id);
o.textContent = `#${p.id} (${p.lat.toFixed(4)}, ${p.lng.toFixed(4)})`;
sel.appendChild(o);
});
document.getElementById('esAllPosCount').textContent = esStreetPoints.length ? `(${esStreetPoints.length})` : '';
}
// Render các cạnh: mỗi cạnh là 1 block có dropdown multi-select riêng
function esRenderEdges() {
const listEl = document.getElementById('esEdgesList');
listEl.innerHTML = '';
if (!esEdges.length) {
listEl.innerHTML = '<div class="empty">Chưa có cạnh nào.</div>';
document.getElementById('esEdgesCount').textContent = '';
return;
}
esEdges.forEach((edge, idx) => {
const block = document.createElement('div');
block.className = 'es-edge-block' + (edge.oneway ? ' oneway' : '');
const head = document.createElement('div');
head.className = 'es-edge-head';
const title = document.createElement('span');
title.className = 'es-edge-title';
title.textContent = `Cạnh ${idx + 1} (${edge.ids.length} điểm)`;
const owLabel = document.createElement('label');
const owCb = document.createElement('input');
owCb.type = 'checkbox';
owCb.checked = !!edge.oneway;
owCb.addEventListener('change', () => {
edge.oneway = owCb.checked;
block.classList.toggle('oneway', owCb.checked);
});
owLabel.appendChild(owCb);
owLabel.appendChild(document.createTextNode(' 1 chiều'));
const del = document.createElement('button');
del.className = 'es-edge-del';
del.textContent = '×';
del.title = 'Xoá cạnh';
del.addEventListener('click', () => { esEdges.splice(idx, 1); esRenderEdges(); });
head.appendChild(title);
head.appendChild(owLabel);
head.appendChild(del);
const sel = document.createElement('select');
sel.multiple = true;
sel.size = Math.min(Math.max(edge.ids.length, 2), 6);
edge.ids.forEach((id, i) => {
const o = document.createElement('option');
o.value = String(id);
o.textContent = `${i + 1}. #${id}`;
sel.appendChild(o);
});
sel.addEventListener('change', () => esHighlightFromSelect(sel));
block.appendChild(head);
block.appendChild(sel);
listEl.appendChild(block);
});
document.getElementById('esEdgesCount').textContent = `(${esEdges.length})`;
}
// Thêm 1 cạnh từ các điểm đang chọn ở dropdown master → mở popup chọn điểm đầu
function esAddEdge() {
const sel = document.getElementById('esAllPosSel');
const ids = [...sel.selectedOptions].map(o => Math.abs(parseInt(o.value))).filter(v => !Number.isNaN(v));
if (ids.length < 2) { showToast('Chọn ít nhất 2 điểm ở danh sách trên để tạo cạnh'); return; }
const oneway = document.getElementById('esEdgeOneway').checked;
esPendingEdge = { ids, oneway };
// đổ popup: liệt kê các điểm đã chọn để user xác nhận điểm đầu
const popSel = document.getElementById('esStartPopupSel');
popSel.innerHTML = '';
ids.forEach(id => {
const p = esStreetPoints.find(pt => pt.id === id);
const o = document.createElement('option');
o.value = String(id);
o.textContent = p ? `#${id} (${p.lat.toFixed(4)}, ${p.lng.toFixed(4)})` : `#${id}`;
popSel.appendChild(o);
});
popSel.selectedIndex = 0;
document.getElementById('esStartPopup').classList.add('show');
}
// Xác nhận điểm đầu ở popup → nội suy thứ tự các điểm còn lại rồi tạo cạnh
function esConfirmStartEdge() {
if (!esPendingEdge) { esCloseStartPopup(); return; }
const startId = parseInt(document.getElementById('esStartPopupSel').value);
if (Number.isNaN(startId)) { showToast('Chọn điểm đầu'); return; }
const subset = esPendingEdge.ids
.map(id => esStreetPoints.find(p => p.id === id))
.filter(Boolean);
const ordered = orderPointsFromStart(subset, startId).map(p => p.id);
const finalIds = ordered.length ? ordered : esPendingEdge.ids;
esEdges.push({ ids: finalIds, oneway: esPendingEdge.oneway });
esRenderEdges();
esCloseStartPopup();
const sel = document.getElementById('esAllPosSel');
sel.selectedIndex = -1;
document.getElementById('esEdgeOneway').checked = false;
esClearHighlight();
}
// Đóng popup chọn điểm đầu
function esCloseStartPopup() {
document.getElementById('esStartPopup').classList.remove('show');
esPendingEdge = null;
}
// Tìm marker trên map theo id point
function esFindMarker(pointId) {
let found = null;
if (layerGroup) layerGroup.eachLayer(m => { if (m._pos && m._pos.id === pointId) found = m; });
return found;
}
// Trả các marker đang tô sáng về màu nền
function esClearHighlight() {
esHiliteMarkers.forEach(m => { if (m && m._pos) m.setIcon(circleIcon(posBaseColor(m._pos))); });
esHiliteMarkers = [];
}
// Tô sáng + pan tới marker của các point đang chọn trong 1 dropdown bất kỳ
function esHighlightFromSelect(selectEl) {
esClearHighlight();
const bounds = L.latLngBounds();
let n = 0;
[...selectEl.selectedOptions].forEach(o => {
const id = Math.abs(parseInt(o.value));
const m = esFindMarker(id);
if (!m) return;
m.setIcon(circleIcon(ES_SELECT_COLOR));
esHiliteMarkers.push(m);
bounds.extend(m.getLatLng());
n++;
});
if (n && map) {
if (n === 1) map.panTo(bounds.getCenter());
else map.fitBounds(bounds, { padding: [60, 60] });
}
}
// Đổ city/district vào 2 dropdown của modal (city clone từ toolbar, district = tất cả)
function esFillCityDistrict(cityId, districtId) {
const selCity = document.getElementById('esCity');
const selDist = document.getElementById('esDistrict');
selCity.innerHTML = '';
[...document.getElementById('selCity').options].forEach(o => {
if (!o.value) return;
const c = document.createElement('option');
c.value = o.value; c.textContent = o.textContent;
selCity.appendChild(c);
});
selDist.innerHTML = '';
Object.values(districtMap)
.sort((a, b) => String(a.name).localeCompare(String(b.name)))
.forEach(d => {
const o = document.createElement('option');
o.value = d.id; o.textContent = d.name;
selDist.appendChild(o);
});
if (cityId != null) selCity.value = String(cityId);
if (districtId != null) selDist.value = String(districtId);
}
// Mở modal sửa đường (từ street đang chọn trên toolbar)
async function openEditStreetModal(streetId) {
const base = streetMap[streetId];
if (!base) { alert('Không tìm thấy đường #' + streetId); return; }
closeAddModal(); closeSetNode(); closeCreateGroupModal();
esEditId = streetId;
let full = base;
try { full = await apiFetch('/streets/' + streetId); } catch (e) { console.error('get street', e); }
document.getElementById('esTitle').textContent = `Sửa đường #${streetId}`;
document.getElementById('esName').value = full.name || base.name || '';
document.getElementById('esType').value = full.type || base.type || 'Phố';
esFillCityDistrict(full.city_id ?? base.city_id, full.district_id ?? base.district_id);
document.getElementById('esToll').checked = !!full.toll;
document.getElementById('esFlooding').checked = !!full.flooding;
// các point của street: parse positions 2-D (mỗi phần tử = 1 cạnh); hỗ trợ mảng 1-D cũ
const stored = Array.isArray(full.positions) ? full.positions : [];
const edges2d = (stored.length && Array.isArray(stored[0]))
? stored
: (stored.length ? [stored] : []);
esEdges = edges2d
.map(edge => {
const arr = (edge || []).map(v => parseInt(v)).filter(v => !Number.isNaN(v));
return { ids: arr.map(v => Math.abs(v)), oneway: arr.length > 0 && arr.every(v => v < 0) };
})
.filter(e => e.ids.length);
const storedIds = [...new Set(esEdges.flatMap(e => e.ids))];
// Nạp toạ độ: lấy nhanh theo street_id, sau đó bổ sung các id còn thiếu từ các cạnh
const coordMap = new Map();
let pts = [];
try { pts = await apiFetch('/positions?street_id=' + streetId); } catch (e) { console.error('get points', e); }
(pts || []).forEach(p => {
if (p.x != null && p.y != null) coordMap.set(p.id, { id: p.id, lat: Number(p.y), lng: Number(p.x) });
});
const missing = storedIds.filter(id => !coordMap.has(id));
if (missing.length) {
const fetched = await Promise.all(missing.map(id =>
apiFetch('/positions/' + id).catch(e => { console.error('get point', id, e); return null; })
));
fetched.forEach(p => {
if (p && p.x != null && p.y != null) coordMap.set(p.id, { id: p.id, lat: Number(p.y), lng: Number(p.x) });
});
}
// Thứ tự: theo các cạnh trước, các point còn lại append phía sau
const orderedIds = [];
const seen = new Set();
storedIds.forEach(id => { if (coordMap.has(id) && !seen.has(id)) { orderedIds.push(id); seen.add(id); } });
coordMap.forEach((_, id) => { if (!seen.has(id)) { orderedIds.push(id); seen.add(id); } });
esStreetPoints = orderedIds.map(id => coordMap.get(id));
esRenderAllPos(); // đổ dropdown master: tất cả point của đường
esRenderEdges(); // hiển thị các cạnh hiện có (mỗi cạnh 1 dropdown)
document.getElementById('esSave').disabled = false;
document.getElementById('editStreetModal').style.display = 'block';
}
function closeEditStreetModal() {
const el = document.getElementById('editStreetModal');
if (el) el.style.display = 'none';
esClearHighlight();
esEditId = null;
}
// Lưu đường: PUT /streets/{id} với positions (đã sort, có thể âm) + name/type/district/city/toll/flooding
async function saveEditStreet() {
if (esEditId == null) return;
const name = document.getElementById('esName').value.trim();
if (!name) { alert('Tên đường không được trống.'); return; }
const type = document.getElementById('esType').value;
const districtId = parseInt(document.getElementById('esDistrict').value) || null;
const cityId = parseInt(document.getElementById('esCity').value) || null;
const toll = document.getElementById('esToll').checked;
const flooding = document.getElementById('esFlooding').checked;
// positions 2-D: mỗi cạnh = mảng id; cạnh 1 chiều → toàn bộ id âm
const positions = esEdges
.map(e => (e.oneway ? e.ids.map(id => -Math.abs(id)) : e.ids.map(id => Math.abs(id))))
.filter(edge => edge.length);
const btn = document.getElementById('esSave');
btn.disabled = true;
try {
await apiSend('/streets/' + esEditId, 'PUT', {
name, type, district_id: districtId, city_id: cityId, toll, flooding, positions,
});
if (streetMap[esEditId]) {
streetMap[esEditId].name = name;
streetMap[esEditId].type = type;
if (districtId != null) streetMap[esEditId].district_id = districtId;
if (cityId != null) streetMap[esEditId].city_id = cityId;
}
const opt = [...document.getElementById('selStreet').options].find(o => String(o.value) === String(esEditId));
if (opt) opt.textContent = name;
showToast(`Đã lưu đường #${esEditId}: ${positions.length} cạnh`);
closeEditStreetModal();
} catch (e) {
console.error('saveEditStreet', e);
alert('Lưu đường thất bại.');
btn.disabled = false;
}
}
// ══════════════════════════════════════════════════
// DROPDOWN LOADERS
// ══════════════════════════════════════════════════
async function loadCities() {
try {
const cities = await apiFetch('/cities');
const sel = document.getElementById('selCity');
cities.forEach(c => {
const o = document.createElement('option');
o.value = c.id; o.textContent = c.name;
sel.appendChild(o);
});
} catch (e) { console.error('loadCities', e); }
}
async function loadDistricts(cityId) {
const sel = document.getElementById('selDistrict');
sel.innerHTML = '<option value="">-- Quận/Huyện --</option>';
document.getElementById('selStreet').innerHTML = '<option value="">-- Đường --</option>';
if (!cityId) return;
try {
const list = await apiFetch(`/districts?city_id=${cityId}`);
list.forEach(d => {
const o = document.createElement('option');
o.value = d.id; o.textContent = d.name;
sel.appendChild(o);
});
} catch (e) { console.error('loadDistricts', e); }
}
async function loadStreets(districtId, cityId) {
const sel = document.getElementById('selStreet');
sel.innerHTML = '<option value="">-- Đường --</option>';
let q = '';
if (districtId) q = `?district_id=${districtId}`;
else if (cityId) q = `?city_id=${cityId}`;
if (!q) return;
try {
const list = await apiFetch(`/streets${q}`);
allStreets = list;
list.forEach(s => {
streetMap[s.id] = s;
const o = document.createElement('option');
o.value = s.id; o.textContent = s.name;
sel.appendChild(o);
});
} catch (e) { console.error('loadStreets', e); }
}
// Ánh xạ city/district/street của 1 điểm lên toolbar (selCity/selDistrict/selStreet)
async function syncToolbarToPos(pos) {
const sid = (pos.streets || [])[0];
const street = sid != null ? streetMap[sid] : null;
if (!street) return;
const districtId = street.district_id;
const cityId = street.city_id != null ? street.city_id : (districtMap[districtId] && districtMap[districtId].city);
const selCity = document.getElementById('selCity');
const selDistrict = document.getElementById('selDistrict');
const selStreet = document.getElementById('selStreet');
if (cityId != null) {
selCity.value = String(cityId);
await loadDistricts(cityId);
}
if (districtId != null) {
selDistrict.value = String(districtId);
await loadStreets(districtId, cityId);
}
if (sid != null) selStreet.value = String(sid);
selectedStreetId = sid != null ? sid : null;
updatePaths();
updateMainCreateState();
}
// ══════════════════════════════════════════════════
// SEARCH (Nominatim geocoding – miễn phí, OSM)
// ══════════════════════════════════════════════════
function setupSearch() {
const input = document.getElementById('searchInput');
const btn = document.getElementById('btnSearch');
async function doSearch() {
const q = input.value.trim();
if (!q) return;
try {
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=1&countrycodes=vn`;
const res = await fetch(url, { headers: { 'Accept-Language': 'vi' } });
const data = await res.json();
if (data.length === 0) { alert('Không tìm thấy địa điểm: ' + q); return; }
const { lat, lon } = data[0];
map.setView([parseFloat(lat), parseFloat(lon)], 15);
} catch (e) { console.error('search', e); }
}
btn.addEventListener('click', doSearch);
input.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
}
// ══════════════════════════════════════════════════
// UI LISTENERS
// ══════════════════════════════════════════════════
function setupUIListeners() {
document.getElementById('selCity').addEventListener('change', async e => {
await loadDistricts(e.target.value);
updatePaths();
});
document.getElementById('selDistrict').addEventListener('change', async e => {
const cityId = document.getElementById('selCity').value;
await loadStreets(e.target.value, cityId);
updatePaths();
});
document.getElementById('selStreet').addEventListener('change', onSelectStreetFromToolbar);
document.getElementById('btnLoad').addEventListener('click', () => {
const streetId = document.getElementById('selStreet').value;
const endpoint = streetId ? `/positions?street_id=${streetId}` : '/positions';
loadPositions(endpoint);
});
document.getElementById('btnMove').addEventListener('click', () => setMoveMode(!moveMode));
document.getElementById('btnAdd').addEventListener('click', () => setAddMode(!addMode));
document.getElementById('btnGroup').addEventListener('click', () => setGroupMode(!groupMode));
document.getElementById('mainCreateBtn').addEventListener('click', onMainCreate);
document.getElementById('selGroup').addEventListener('change', onSelectGroupFromToolbar);
document.getElementById('cgAddOuter').addEventListener('click', cgAddOuterStreets);
document.getElementById('cgSave').addEventListener('click', saveCreateGroup);
document.getElementById('cgCancel').addEventListener('click', closeCreateGroupModal);
document.getElementById('cgCloseX').addEventListener('click', closeCreateGroupModal);
document.getElementById('esSave').addEventListener('click', saveEditStreet);
document.getElementById('esCancel').addEventListener('click', closeEditStreetModal);
document.getElementById('esCloseX').addEventListener('click', closeEditStreetModal);
document.getElementById('esAllPosSel').addEventListener('change', function () { esHighlightFromSelect(this); });
document.getElementById('esAddEdge').addEventListener('click', esAddEdge);
document.getElementById('esStartPopupSel').addEventListener('change', function () { esHighlightFromSelect(this); });
document.getElementById('esStartConfirm').addEventListener('click', esConfirmStartEdge);
document.getElementById('esStartCancel').addEventListener('click', esCloseStartPopup);
document.getElementById('btnCreateDistrict').addEventListener('click', onCreateDistrict);
document.getElementById('btnCreateStreet').addEventListener('click', onCreateStreet);
document.getElementById('btnAddNearby').addEventListener('click', addNearbyStreet);
document.getElementById('btnSubmitPoint').addEventListener('click', onSubmitPoint);
document.getElementById('btnCancelPoint').addEventListener('click', closeAddModal);
document.getElementById('btnCancelPointX').addEventListener('click', closeAddModal);
document.getElementById('addStreetSel').addEventListener('change', updateSubmitState);
document.getElementById('btnSetNode').addEventListener('click', openSetNode);
document.getElementById('btnSetNodeAdd').addEventListener('click', setNodeAdd);
document.getElementById('btnSetNodeSave').addEventListener('click', saveSetNode);
document.getElementById('btnSetNodeCancel').addEventListener('click', closeSetNode);
document.getElementById('setNodeCloseX').addEventListener('click', closeSetNode);
document.getElementById('btnClear').addEventListener('click', () => {
setGroupMode(false);
clearSearchPoints();
clearMarkers();
document.getElementById('selCity').value = '';
document.getElementById('selDistrict').innerHTML = '<option value="">-- Quận/Huyện --</option>';
document.getElementById('selStreet').innerHTML = '<option value="">-- Đường --</option>';
document.getElementById('selGroup').value = '';
selectedGroupId = null;
selectedStreetId = null;
updateMainCreateState();
document.getElementById('searchInput').value = '';
map.setView([DEFAULT_LAT, DEFAULT_LNG], DEFAULT_ZOOM);
});
document.getElementById('closeInfo').addEventListener('click', hideInfo);
// Hotkeys: N = Add điểm, M = Move điểm (bỏ qua khi đang gõ trong input/select/textarea)
document.addEventListener('keydown', e => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
const tag = ((e.target && e.target.tagName) || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable)) return;
const k = (e.key || '').toLowerCase();
if (k === 'n') { e.preventDefault(); setAddMode(!addMode); }
else if (k === 'm') { e.preventDefault(); setMoveMode(!moveMode); }
else if (k === 'g') { e.preventDefault(); setGroupMode(!groupMode); }
});
}
// ══════════════════════════════════════════════════
// BOOT
// ══════════════════════════════════════════════════
// Nạp toàn bộ Streets + Districts để resolve tên đường/phường cho mọi marker
async function loadAllRefData() {
try {
const [streets, districts] = await Promise.all([
apiFetch('/streets'),
apiFetch('/districts'),
]);
(streets || []).forEach(s => { streetMap[s.id] = s; });
allStreets = streets || [];
(districts || []).forEach(d => { districtMap[d.id] = d; });
} catch (e) {
console.error('loadAllRefData', e);
}
}
(async function boot() {
initMap();
setupSearch();
setupUIListeners();
await loadCities();
await loadAllRefData(); // nạp Streets + Districts trước khi render marker
await loadAllGroups(); // nạp GLOBAL group store (để tô màu marker theo group)
// Load toàn bộ marker từ DB ngay khi trang mở
await loadPositions('/positions');
// Nếu lần trước có chọn marker → center về đó, zoom 15
try {
const last = JSON.parse(localStorage.getItem(LAST_MARKER_KEY) || 'null');
if (last && last.lat != null && last.lng != null) {
map.setView([last.lat, last.lng], 15);
}
} catch (e) {}
setLoading(false);
})();
</script>
</body>
</html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GMaps Vector</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; font-family: 'Segoe UI', Arial, sans-serif; }
#map { position: fixed; inset: 0; z-index: 0; }
/* ── Toolbar ── */
#toolbar {
position: fixed;
top: 12px; left: 50%;
transform: translateX(-50%);
display: flex; gap: 8px; align-items: center;
background: #fff;
border-radius: 12px;
padding: 8px 12px;
box-shadow: 0 2px 12px rgba(0,0,0,.25);
z-index: 10;
flex-wrap: wrap;
max-width: calc(100vw - 24px);
}
#toolbar select, #toolbar input[type=text] {
border: 1px solid #d0d0d0;
border-radius: 8px;
padding: 6px 10px;
font-size: 13px;
background: #fafafa;
outline: none;
cursor: pointer;
min-width: 130px;
}
#toolbar select:focus, #toolbar input[type=text]:focus {
border-color: #4285f4;
background: #fff;
}
#searchInput {
min-width: 180px;
}
#toolbar button {
border: none;
border-radius: 8px;
padding: 6px 14px;
font-size: 13px;
cursor: pointer;
background: #4285f4;
color: #fff;
font-weight: 600;
transition: background .15s;
}
#toolbar button:hover { background: #2a6dd4; }
#toolbar button.secondary {
background: #f1f3f4; color: #3c4043;
}
#toolbar button.secondary:hover { background: #e2e4e6; }
#toolbar button.active {
background: #34a853; color: #fff;
}
#toolbar button.active:hover { background: #2d9248; }
/* ── Counter badge ── */
#counter {
position: fixed;
bottom: 24px; left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,.65);
color: #fff;
border-radius: 20px;
padding: 4px 16px;
font-size: 13px;
z-index: 10;
pointer-events: none;
white-space: nowrap;
}
/* ── Info panel (marker click) ── */
#infoPanel {
position: fixed;
bottom: 60px; right: 16px;
width: 280px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 16px rgba(0,0,0,.25);
padding: 16px;
z-index: 10;
display: none;
font-size: 13px;
line-height: 1.6;
max-height: calc(100vh - 90px);
overflow-y: auto;
}
#infoPanel h3 {