tools / admin /gmaps.html
Tataaicoltd's picture
feat: add /flow/tickets fake API
36e4bd8 verified
Raw History Blame Contribute Delete
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">&times;</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">&times;</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">&times;</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">&times;</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">&times;</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>
<!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 {