Spaces:
Running
Running
feat: keyboard shortcuts, all-street path mode, global duplicate check
Browse files- admin/css/maps.css +110 -2
- admin/js/api.js +4 -1
- admin/js/maps.js +1 -0
- admin/js/maps/navigation.js +225 -5
- admin/js/maps/pointr.js +174 -25
- admin/js/maps/settings.js +4 -0
- admin/maps.html +18 -2
- backend/main.py +15 -3
- backend/services/maps_service.py +9 -37
admin/css/maps.css
CHANGED
|
@@ -25,7 +25,7 @@ h1 {
|
|
| 25 |
|
| 26 |
.gallery {
|
| 27 |
display: grid;
|
| 28 |
-
grid-template-columns: repeat(23,
|
| 29 |
gap: 0;
|
| 30 |
width: 100%;
|
| 31 |
opacity: 0.5;
|
|
@@ -56,7 +56,8 @@ h1 {
|
|
| 56 |
height: auto;
|
| 57 |
display: block;
|
| 58 |
object-fit: cover;
|
| 59 |
-
border: 0.1px solid rgba(0, 0, 0,
|
|
|
|
| 60 |
}
|
| 61 |
|
| 62 |
.image-info {
|
|
@@ -273,6 +274,113 @@ h1 {
|
|
| 273 |
margin: 6px 0;
|
| 274 |
}
|
| 275 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
/* ==================== SEARCH ==================== */
|
| 277 |
|
| 278 |
.search-btn {
|
|
|
|
| 25 |
|
| 26 |
.gallery {
|
| 27 |
display: grid;
|
| 28 |
+
grid-template-columns: repeat(23, 800px);
|
| 29 |
gap: 0;
|
| 30 |
width: 100%;
|
| 31 |
opacity: 0.5;
|
|
|
|
| 56 |
height: auto;
|
| 57 |
display: block;
|
| 58 |
object-fit: cover;
|
| 59 |
+
border-right: 0.1px solid rgba(0, 0, 0, 1);
|
| 60 |
+
border-bottom: 0.1px solid rgba(0, 0, 0, 1);
|
| 61 |
}
|
| 62 |
|
| 63 |
.image-info {
|
|
|
|
| 274 |
margin: 6px 0;
|
| 275 |
}
|
| 276 |
|
| 277 |
+
/* ==================== STREET PANEL ==================== */
|
| 278 |
+
|
| 279 |
+
.street-panel-toggle {
|
| 280 |
+
position: fixed;
|
| 281 |
+
top: 50%;
|
| 282 |
+
left: 0;
|
| 283 |
+
transform: translateY(-50%);
|
| 284 |
+
z-index: 200;
|
| 285 |
+
width: 22px;
|
| 286 |
+
height: 48px;
|
| 287 |
+
border: 1px solid #bbb;
|
| 288 |
+
border-left: none;
|
| 289 |
+
border-radius: 0 6px 6px 0;
|
| 290 |
+
background: rgba(255,255,255,0.92);
|
| 291 |
+
font-size: 13px;
|
| 292 |
+
cursor: pointer;
|
| 293 |
+
display: flex;
|
| 294 |
+
align-items: center;
|
| 295 |
+
justify-content: center;
|
| 296 |
+
box-shadow: 2px 0 6px rgba(0,0,0,0.12);
|
| 297 |
+
padding: 0;
|
| 298 |
+
transition: background 0.15s;
|
| 299 |
+
}
|
| 300 |
+
.street-panel-toggle:hover { background: #e8f0fe; }
|
| 301 |
+
.street-panel-toggle.open { background: #d0e4ff; border-color: #2b7cff; }
|
| 302 |
+
|
| 303 |
+
.street-panel {
|
| 304 |
+
position: fixed;
|
| 305 |
+
top: 50%;
|
| 306 |
+
left: -240px;
|
| 307 |
+
transform: translateY(-50%);
|
| 308 |
+
z-index: 199;
|
| 309 |
+
width: 240px;
|
| 310 |
+
max-height: 70vh;
|
| 311 |
+
background: rgba(255,255,255,0.97);
|
| 312 |
+
border: 1px solid #ccc;
|
| 313 |
+
border-radius: 0 8px 8px 0;
|
| 314 |
+
box-shadow: 4px 0 16px rgba(0,0,0,0.13);
|
| 315 |
+
display: flex;
|
| 316 |
+
flex-direction: column;
|
| 317 |
+
transition: left 0.22s ease;
|
| 318 |
+
overflow: hidden;
|
| 319 |
+
}
|
| 320 |
+
.street-panel.open { left: 22px; }
|
| 321 |
+
|
| 322 |
+
.street-panel-header {
|
| 323 |
+
display: flex;
|
| 324 |
+
justify-content: space-between;
|
| 325 |
+
align-items: center;
|
| 326 |
+
padding: 8px 10px 6px;
|
| 327 |
+
border-bottom: 1px solid #eee;
|
| 328 |
+
flex-shrink: 0;
|
| 329 |
+
}
|
| 330 |
+
.street-panel-title {
|
| 331 |
+
font-size: 12px;
|
| 332 |
+
font-weight: 700;
|
| 333 |
+
color: #222;
|
| 334 |
+
}
|
| 335 |
+
.street-panel-radius {
|
| 336 |
+
font-size: 10px;
|
| 337 |
+
color: #888;
|
| 338 |
+
background: #f0f4ff;
|
| 339 |
+
border-radius: 10px;
|
| 340 |
+
padding: 1px 7px;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.street-panel-list {
|
| 344 |
+
overflow-y: auto;
|
| 345 |
+
flex: 1;
|
| 346 |
+
padding: 4px 0;
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
.street-panel-empty {
|
| 350 |
+
font-size: 11px;
|
| 351 |
+
color: #aaa;
|
| 352 |
+
text-align: center;
|
| 353 |
+
padding: 18px 10px;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
.street-panel-item {
|
| 357 |
+
display: flex;
|
| 358 |
+
flex-direction: column;
|
| 359 |
+
padding: 6px 12px;
|
| 360 |
+
cursor: pointer;
|
| 361 |
+
border-bottom: 1px solid #f5f5f5;
|
| 362 |
+
transition: background 0.1s;
|
| 363 |
+
}
|
| 364 |
+
.street-panel-item:hover { background: #e8f0fe; }
|
| 365 |
+
.street-panel-item.active { background: #d0e4ff; }
|
| 366 |
+
|
| 367 |
+
.street-panel-item-name {
|
| 368 |
+
font-size: 12px;
|
| 369 |
+
font-weight: 600;
|
| 370 |
+
color: #1a3a6c;
|
| 371 |
+
white-space: nowrap;
|
| 372 |
+
overflow: hidden;
|
| 373 |
+
text-overflow: ellipsis;
|
| 374 |
+
}
|
| 375 |
+
.street-panel-item-meta {
|
| 376 |
+
font-size: 10px;
|
| 377 |
+
color: #888;
|
| 378 |
+
margin-top: 1px;
|
| 379 |
+
white-space: nowrap;
|
| 380 |
+
overflow: hidden;
|
| 381 |
+
text-overflow: ellipsis;
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
/* ==================== SEARCH ==================== */
|
| 385 |
|
| 386 |
.search-btn {
|
admin/js/api.js
CHANGED
|
@@ -31,7 +31,10 @@ class ApiClient {
|
|
| 31 |
const data = await response.json();
|
| 32 |
|
| 33 |
if (!response.ok) {
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
| 35 |
}
|
| 36 |
|
| 37 |
return data;
|
|
|
|
| 31 |
const data = await response.json();
|
| 32 |
|
| 33 |
if (!response.ok) {
|
| 34 |
+
const err = new Error(data.detail || data.error || `HTTP ${response.status}`);
|
| 35 |
+
err.status = response.status;
|
| 36 |
+
err.data = data; // full response body available as err.data
|
| 37 |
+
throw err;
|
| 38 |
}
|
| 39 |
|
| 40 |
return data;
|
admin/js/maps.js
CHANGED
|
@@ -19,6 +19,7 @@ window.addEventListener('load', async () => {
|
|
| 19 |
initEditorListener();
|
| 20 |
initSettings();
|
| 21 |
initSearch();
|
|
|
|
| 22 |
|
| 23 |
// Test pointr
|
| 24 |
const allPointrs = [
|
|
|
|
| 19 |
initEditorListener();
|
| 20 |
initSettings();
|
| 21 |
initSearch();
|
| 22 |
+
initStreetPanel();
|
| 23 |
|
| 24 |
// Test pointr
|
| 25 |
const allPointrs = [
|
admin/js/maps/navigation.js
CHANGED
|
@@ -3,6 +3,15 @@
|
|
| 3 |
// Global variable to store all streets from database
|
| 4 |
let allStreetsData = [];
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
// Helper: populate a select, preserving negative-value options (Tạo Mới)
|
| 7 |
function populateSelect(selectEl, items, labelKey, valueKey) {
|
| 8 |
const negativeOpts = Array.from(selectEl.options).filter(o => parseInt(o.value) < 0);
|
|
@@ -106,7 +115,7 @@ function showCreateDistrictModal() {
|
|
| 106 |
});
|
| 107 |
}
|
| 108 |
|
| 109 |
-
function showCreateStreetModal(onCreated) {
|
| 110 |
const cityId = document.getElementById('Cities').value;
|
| 111 |
const districtId = document.getElementById('Districts').value;
|
| 112 |
if (!cityId || cityId === 'all' || parseInt(cityId) < 0) {
|
|
@@ -121,6 +130,19 @@ function showCreateStreetModal(onCreated) {
|
|
| 121 |
}
|
| 122 |
const cityName = document.getElementById('Cities').selectedOptions[0].text;
|
| 123 |
const districtName = document.getElementById('Districts').selectedOptions[0].text;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
openModal(`
|
| 125 |
<div class="modal-content">
|
| 126 |
<h2>Tạo Street mới</h2>
|
|
@@ -130,23 +152,48 @@ function showCreateStreetModal(onCreated) {
|
|
| 130 |
<input type="text" id="f_name" placeholder="VD: Nguyễn Huệ" required>
|
| 131 |
</label>
|
| 132 |
<label>Loại đường
|
| 133 |
-
|
| 134 |
</label>
|
| 135 |
<div class="modal-error" id="modalError"></div>
|
| 136 |
<div class="modal-actions">
|
| 137 |
-
<button type="button" class="btn-cancel"
|
| 138 |
<button type="submit" class="btn-submit">Tạo</button>
|
| 139 |
</div>
|
| 140 |
</form>
|
| 141 |
</div>
|
| 142 |
`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
document.getElementById('createForm').addEventListener('submit', async (e) => {
|
| 144 |
e.preventDefault();
|
| 145 |
const name = document.getElementById('f_name').value.trim();
|
| 146 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
if (!name) return;
|
| 148 |
try {
|
| 149 |
const street = await apiClient.createStreet(name, districtId, cityId, type || null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
closeModal();
|
| 151 |
await loadStreets(districtId, cityId);
|
| 152 |
document.getElementById('Streets').value = street.id;
|
|
@@ -154,7 +201,48 @@ function showCreateStreetModal(onCreated) {
|
|
| 154 |
try { onCreated(street.id); } catch (_) {}
|
| 155 |
}
|
| 156 |
} catch (err) {
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
}
|
| 159 |
});
|
| 160 |
}
|
|
@@ -307,3 +395,135 @@ async function initFilterListeners() {
|
|
| 307 |
if (e.key === 'Escape') closeModal();
|
| 308 |
});
|
| 309 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
// Global variable to store all streets from database
|
| 4 |
let allStreetsData = [];
|
| 5 |
|
| 6 |
+
function escapeHtml(text) {
|
| 7 |
+
return String(text)
|
| 8 |
+
.replace(/&/g, '&')
|
| 9 |
+
.replace(/</g, '<')
|
| 10 |
+
.replace(/>/g, '>')
|
| 11 |
+
.replace(/"/g, '"')
|
| 12 |
+
.replace(/'/g, ''');
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
// Helper: populate a select, preserving negative-value options (Tạo Mới)
|
| 16 |
function populateSelect(selectEl, items, labelKey, valueKey) {
|
| 17 |
const negativeOpts = Array.from(selectEl.options).filter(o => parseInt(o.value) < 0);
|
|
|
|
| 115 |
});
|
| 116 |
}
|
| 117 |
|
| 118 |
+
function showCreateStreetModal(onCreated, onCancel) {
|
| 119 |
const cityId = document.getElementById('Cities').value;
|
| 120 |
const districtId = document.getElementById('Districts').value;
|
| 121 |
if (!cityId || cityId === 'all' || parseInt(cityId) < 0) {
|
|
|
|
| 130 |
}
|
| 131 |
const cityName = document.getElementById('Cities').selectedOptions[0].text;
|
| 132 |
const districtName = document.getElementById('Districts').selectedOptions[0].text;
|
| 133 |
+
|
| 134 |
+
// Build type dropdown from allStreetsData distinct types
|
| 135 |
+
const knownTypes = [...new Set((allStreetsData || []).map(s => s.type).filter(Boolean))].sort();
|
| 136 |
+
const typeOptions = knownTypes.map(t => `<option value="${t}">${t}</option>`).join('');
|
| 137 |
+
const typeField = knownTypes.length > 0
|
| 138 |
+
? `<select id="f_type">
|
| 139 |
+
<option value="">-- Không có --</option>
|
| 140 |
+
${typeOptions}
|
| 141 |
+
<option value="__custom__">-- Nhập mới... --</option>
|
| 142 |
+
</select>
|
| 143 |
+
<input type="text" id="f_type_custom" placeholder="Nhập loại đường..." style="display:none;margin-top:4px;">`
|
| 144 |
+
: `<input type="text" id="f_type" placeholder="VD: Đường, Phố, Đại lộ...">`;
|
| 145 |
+
|
| 146 |
openModal(`
|
| 147 |
<div class="modal-content">
|
| 148 |
<h2>Tạo Street mới</h2>
|
|
|
|
| 152 |
<input type="text" id="f_name" placeholder="VD: Nguyễn Huệ" required>
|
| 153 |
</label>
|
| 154 |
<label>Loại đường
|
| 155 |
+
${typeField}
|
| 156 |
</label>
|
| 157 |
<div class="modal-error" id="modalError"></div>
|
| 158 |
<div class="modal-actions">
|
| 159 |
+
<button type="button" class="btn-cancel" id="createStreetCancel">Huỷ</button>
|
| 160 |
<button type="submit" class="btn-submit">Tạo</button>
|
| 161 |
</div>
|
| 162 |
</form>
|
| 163 |
</div>
|
| 164 |
`);
|
| 165 |
+
|
| 166 |
+
// Show/hide custom type input
|
| 167 |
+
const typeSelect = document.getElementById('f_type');
|
| 168 |
+
const typeCustom = document.getElementById('f_type_custom');
|
| 169 |
+
if (typeSelect && typeSelect.tagName === 'SELECT') {
|
| 170 |
+
typeSelect.addEventListener('change', () => {
|
| 171 |
+
if (typeCustom) typeCustom.style.display = typeSelect.value === '__custom__' ? 'block' : 'none';
|
| 172 |
+
});
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
document.getElementById('createStreetCancel').addEventListener('click', () => {
|
| 176 |
+
closeModal();
|
| 177 |
+
if (typeof onCancel === 'function') try { onCancel(); } catch (_) {}
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
document.getElementById('createForm').addEventListener('submit', async (e) => {
|
| 181 |
e.preventDefault();
|
| 182 |
const name = document.getElementById('f_name').value.trim();
|
| 183 |
+
const typeEl = document.getElementById('f_type');
|
| 184 |
+
let type = typeEl ? typeEl.value.trim() : '';
|
| 185 |
+
if (type === '__custom__') {
|
| 186 |
+
type = (document.getElementById('f_type_custom')?.value || '').trim();
|
| 187 |
+
}
|
| 188 |
if (!name) return;
|
| 189 |
try {
|
| 190 |
const street = await apiClient.createStreet(name, districtId, cityId, type || null);
|
| 191 |
+
// Inject into allStreetsData immediately so showSaveAllModal can find it
|
| 192 |
+
if (typeof allStreetsData !== 'undefined' && Array.isArray(allStreetsData)) {
|
| 193 |
+
if (!allStreetsData.find(s => s.id === street.id)) {
|
| 194 |
+
allStreetsData.push(street);
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
closeModal();
|
| 198 |
await loadStreets(districtId, cityId);
|
| 199 |
document.getElementById('Streets').value = street.id;
|
|
|
|
| 201 |
try { onCreated(street.id); } catch (_) {}
|
| 202 |
}
|
| 203 |
} catch (err) {
|
| 204 |
+
// 409 duplicate: err.data = { error: 'duplicate', street: { id, name } }
|
| 205 |
+
const dup = err.status === 409 && err.data?.error === 'duplicate' ? err.data.street : null;
|
| 206 |
+
if (dup) {
|
| 207 |
+
const errEl = document.getElementById('modalError');
|
| 208 |
+
errEl.innerHTML = `Street tương tự đã tồn tại: <strong>${escapeHtml(dup.name)}</strong> (id=${dup.id})`;
|
| 209 |
+
|
| 210 |
+
// Show/replace apply button
|
| 211 |
+
let applyBtn = document.getElementById('applyFoundStreet');
|
| 212 |
+
if (!applyBtn) {
|
| 213 |
+
applyBtn = document.createElement('button');
|
| 214 |
+
applyBtn.id = 'applyFoundStreet';
|
| 215 |
+
applyBtn.type = 'button';
|
| 216 |
+
applyBtn.className = 'btn-submit';
|
| 217 |
+
applyBtn.style.marginTop = '8px';
|
| 218 |
+
errEl.after(applyBtn);
|
| 219 |
+
}
|
| 220 |
+
applyBtn.textContent = `Dùng "${dup.name}" (#${dup.id})`;
|
| 221 |
+
applyBtn.onclick = async () => {
|
| 222 |
+
let foundStreet = (allStreetsData || []).find(s => s.id === dup.id);
|
| 223 |
+
if (!foundStreet) {
|
| 224 |
+
try {
|
| 225 |
+
foundStreet = await apiClient.getStreetById(dup.id);
|
| 226 |
+
if (foundStreet && typeof allStreetsData !== 'undefined' && Array.isArray(allStreetsData)) {
|
| 227 |
+
allStreetsData.push(foundStreet);
|
| 228 |
+
}
|
| 229 |
+
} catch (_) {
|
| 230 |
+
foundStreet = { id: dup.id, name: dup.name };
|
| 231 |
+
if (typeof allStreetsData !== 'undefined' && Array.isArray(allStreetsData)) {
|
| 232 |
+
allStreetsData.push(foundStreet);
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
closeModal();
|
| 237 |
+
await loadStreets(districtId, cityId);
|
| 238 |
+
document.getElementById('Streets').value = dup.id;
|
| 239 |
+
if (typeof onCreated === 'function') {
|
| 240 |
+
try { onCreated(dup.id); } catch (_) {}
|
| 241 |
+
}
|
| 242 |
+
};
|
| 243 |
+
} else {
|
| 244 |
+
document.getElementById('modalError').textContent = err.message;
|
| 245 |
+
}
|
| 246 |
}
|
| 247 |
});
|
| 248 |
}
|
|
|
|
| 395 |
if (e.key === 'Escape') closeModal();
|
| 396 |
});
|
| 397 |
}
|
| 398 |
+
|
| 399 |
+
// ==================== STREET PANEL ====================
|
| 400 |
+
|
| 401 |
+
let _streetPanelOpen = false;
|
| 402 |
+
let _streetPanelDebounce = null;
|
| 403 |
+
|
| 404 |
+
function initStreetPanel() {
|
| 405 |
+
const toggle = document.getElementById('streetPanelToggle');
|
| 406 |
+
const panel = document.getElementById('streetPanel');
|
| 407 |
+
if (!toggle || !panel) return;
|
| 408 |
+
|
| 409 |
+
toggle.addEventListener('click', () => {
|
| 410 |
+
_streetPanelOpen = !_streetPanelOpen;
|
| 411 |
+
panel.classList.toggle('open', _streetPanelOpen);
|
| 412 |
+
toggle.classList.toggle('open', _streetPanelOpen);
|
| 413 |
+
if (_streetPanelOpen) refreshStreetPanel();
|
| 414 |
+
});
|
| 415 |
+
|
| 416 |
+
// Refresh on scroll (debounced 400ms)
|
| 417 |
+
document.querySelector('.container').addEventListener('scroll', () => {
|
| 418 |
+
if (!_streetPanelOpen) return;
|
| 419 |
+
clearTimeout(_streetPanelDebounce);
|
| 420 |
+
_streetPanelDebounce = setTimeout(refreshStreetPanel, 400);
|
| 421 |
+
});
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
function getScreenCenterLatLon() {
|
| 425 |
+
const container = document.querySelector('.container');
|
| 426 |
+
const cx = container.scrollLeft + container.clientWidth / 2;
|
| 427 |
+
const cy = container.scrollTop + container.clientHeight / 2;
|
| 428 |
+
const sizePer = parseInt(document.getElementById('sSize').value) / 2400;
|
| 429 |
+
return pixelToLatLon(cx, cy, sizePer);
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
function haverDistKm(lat1, lon1, lat2, lon2) {
|
| 433 |
+
const R = 6371;
|
| 434 |
+
const dLat = (lat2 - lat1) * Math.PI / 180;
|
| 435 |
+
const dLon = (lon2 - lon1) * Math.PI / 180;
|
| 436 |
+
const a = Math.sin(dLat / 2) ** 2 +
|
| 437 |
+
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
| 438 |
+
Math.sin(dLon / 2) ** 2;
|
| 439 |
+
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
function refreshStreetPanel() {
|
| 443 |
+
const listEl = document.getElementById('streetPanelList');
|
| 444 |
+
if (!listEl) return;
|
| 445 |
+
|
| 446 |
+
const { lat: cLat, lon: cLon } = getScreenCenterLatLon();
|
| 447 |
+
|
| 448 |
+
// Collect (streetId → minDist) using loaded pointrs
|
| 449 |
+
const sidDist = new Map();
|
| 450 |
+
const checkPtrs = (d) => {
|
| 451 |
+
if (d.lat == null || d.lon == null) return;
|
| 452 |
+
const dist = haverDistKm(cLat, cLon, d.lat, d.lon);
|
| 453 |
+
if (dist > 0.8) return;
|
| 454 |
+
(d.streets || []).forEach(sid => {
|
| 455 |
+
if (!sidDist.has(sid) || dist < sidDist.get(sid)) sidDist.set(sid, dist);
|
| 456 |
+
});
|
| 457 |
+
};
|
| 458 |
+
if (typeof pointrReload !== 'undefined') pointrReload.forEach(checkPtrs);
|
| 459 |
+
if (typeof pointrData !== 'undefined') pointrData.forEach(checkPtrs);
|
| 460 |
+
|
| 461 |
+
if (sidDist.size === 0) {
|
| 462 |
+
listEl.innerHTML = '<div class="street-panel-empty">Không có street nào trong 2km</div>';
|
| 463 |
+
return;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
// Sort by distance, then name
|
| 467 |
+
const entries = [...sidDist.entries()]
|
| 468 |
+
.map(([sid, dist]) => ({ sid, dist, street: (allStreetsData || []).find(s => s.id === sid) }))
|
| 469 |
+
.filter(e => e.street)
|
| 470 |
+
.sort((a, b) => a.dist - b.dist || (a.street.name || '').localeCompare(b.street.name || ''));
|
| 471 |
+
|
| 472 |
+
const currentStreetId = parseInt(document.getElementById('Streets').value) || null;
|
| 473 |
+
|
| 474 |
+
listEl.innerHTML = '';
|
| 475 |
+
entries.forEach(({ sid, dist, street }) => {
|
| 476 |
+
const item = document.createElement('div');
|
| 477 |
+
item.className = 'street-panel-item' + (sid === currentStreetId ? ' active' : '');
|
| 478 |
+
item.dataset.streetId = sid;
|
| 479 |
+
|
| 480 |
+
const distStr = dist < 1 ? `${Math.round(dist * 1000)}m` : `${dist.toFixed(1)}km`;
|
| 481 |
+
const typeStr = street.type ? ` · ${street.type}` : '';
|
| 482 |
+
|
| 483 |
+
item.innerHTML = `
|
| 484 |
+
<span class="street-panel-item-name">${escapeHtml(street.name || '')}</span>
|
| 485 |
+
<span class="street-panel-item-meta">${distStr}${typeStr}</span>
|
| 486 |
+
`;
|
| 487 |
+
|
| 488 |
+
item.addEventListener('click', () => selectStreetFromPanel(sid));
|
| 489 |
+
listEl.appendChild(item);
|
| 490 |
+
});
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
async function selectStreetFromPanel(streetId) {
|
| 494 |
+
const street = (allStreetsData || []).find(s => s.id === streetId);
|
| 495 |
+
if (!street) return;
|
| 496 |
+
|
| 497 |
+
const cityId = street.city_id;
|
| 498 |
+
const districtId = street.district_id;
|
| 499 |
+
|
| 500 |
+
// 1. Set City
|
| 501 |
+
const citySelect = document.getElementById('Cities');
|
| 502 |
+
if (cityId && citySelect.value != cityId) {
|
| 503 |
+
citySelect.value = cityId;
|
| 504 |
+
localStorage.setItem('maps_city', cityId);
|
| 505 |
+
await loadDistricts(cityId);
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
// 2. Set District
|
| 509 |
+
const districtSelect = document.getElementById('Districts');
|
| 510 |
+
if (districtId && districtSelect.value != districtId) {
|
| 511 |
+
districtSelect.value = districtId;
|
| 512 |
+
localStorage.setItem('maps_district', districtId);
|
| 513 |
+
await loadStreets(districtId, cityId);
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
// 3. Set Street
|
| 517 |
+
const streetSelect = document.getElementById('Streets');
|
| 518 |
+
streetSelect.value = streetId;
|
| 519 |
+
localStorage.setItem('maps_street', String(streetId));
|
| 520 |
+
|
| 521 |
+
// Highlight active item in panel
|
| 522 |
+
document.querySelectorAll('.street-panel-item').forEach(el => {
|
| 523 |
+
el.classList.toggle('active', parseInt(el.dataset.streetId) === streetId);
|
| 524 |
+
});
|
| 525 |
+
|
| 526 |
+
// Draw path on map
|
| 527 |
+
if (typeof scrollToStreet === 'function') scrollToStreet(streetId);
|
| 528 |
+
}
|
| 529 |
+
|
admin/js/maps/pointr.js
CHANGED
|
@@ -151,18 +151,36 @@ function collectStreetPointrs(streetId) {
|
|
| 151 |
return pts;
|
| 152 |
}
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
function nearestNeighborSort(pts) {
|
| 155 |
if (pts.length <= 1) return [...pts];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
const rem = [...pts];
|
| 157 |
const result = [];
|
| 158 |
-
let cur = rem.splice(
|
| 159 |
result.push(cur);
|
| 160 |
while (rem.length > 0) {
|
| 161 |
let minD = Infinity, minI = 0;
|
| 162 |
for (let i = 0; i < rem.length; i++) {
|
| 163 |
-
const
|
| 164 |
-
const dy = rem[i].initialClientY - cur.initialClientY;
|
| 165 |
-
const d = dx * dx + dy * dy;
|
| 166 |
if (d < minD) { minD = d; minI = i; }
|
| 167 |
}
|
| 168 |
cur = rem.splice(minI, 1)[0];
|
|
@@ -182,13 +200,9 @@ function clearStreetPath() {
|
|
| 182 |
if (svg) svg.innerHTML = '';
|
| 183 |
}
|
| 184 |
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
svg.innerHTML = '';
|
| 189 |
-
if (!activeStreetPathId) return;
|
| 190 |
-
const pts = collectStreetPointrs(activeStreetPathId);
|
| 191 |
-
if (pts.length < 2) return;
|
| 192 |
const sorted = nearestNeighborSort(pts);
|
| 193 |
const container = document.querySelector('.container');
|
| 194 |
const sl = container.scrollLeft;
|
|
@@ -196,13 +210,51 @@ function refreshStreetPath() {
|
|
| 196 |
const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
|
| 197 |
polyline.setAttribute('points', sorted.map(d => `${d.initialClientX - sl},${d.initialClientY - st}`).join(' '));
|
| 198 |
polyline.setAttribute('fill', 'none');
|
| 199 |
-
polyline.setAttribute('stroke',
|
| 200 |
polyline.setAttribute('stroke-width', '2');
|
| 201 |
polyline.setAttribute('stroke-dasharray', '6,3');
|
| 202 |
polyline.setAttribute('opacity', '0.8');
|
| 203 |
svg.appendChild(polyline);
|
| 204 |
}
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
// ==================== POINTR CREATION ====================
|
| 207 |
|
| 208 |
function createPointr(clientX, clientY, title = '', lat = null, lon = null) {
|
|
@@ -372,12 +424,35 @@ function deletePointr(pointrId) {
|
|
| 372 |
|
| 373 |
// ==================== POINTR MODALS ====================
|
| 374 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
function showSetNodeModal(pointrId) {
|
| 376 |
const data = pointrData.get(pointrId);
|
| 377 |
if (!data) return;
|
| 378 |
|
| 379 |
const currentStreets = data.streets || [];
|
| 380 |
-
const availableStreets =
|
| 381 |
|
| 382 |
let tableHTML = `<table class="node-streets-table">
|
| 383 |
<thead>
|
|
@@ -736,15 +811,6 @@ function showSaveAllModal() {
|
|
| 736 |
});
|
| 737 |
}
|
| 738 |
|
| 739 |
-
function escapeHtml(text) {
|
| 740 |
-
return String(text)
|
| 741 |
-
.replace(/&/g, '&')
|
| 742 |
-
.replace(/</g, '<')
|
| 743 |
-
.replace(/>/g, '>')
|
| 744 |
-
.replace(/"/g, '"')
|
| 745 |
-
.replace(/'/g, ''');
|
| 746 |
-
}
|
| 747 |
-
|
| 748 |
// ==================== POINTR MOVEMENT ====================
|
| 749 |
|
| 750 |
function movePointr(direction) {
|
|
@@ -857,16 +923,16 @@ function handleGalleryClick(e) {
|
|
| 857 |
const data = pointrData.get(pointrId);
|
| 858 |
if (data) {
|
| 859 |
data.streets = [parseInt(newStreetId)];
|
| 860 |
-
// update tooltip
|
| 861 |
const tooltip = data.element ? data.element.querySelector('.pointr-tooltip') : null;
|
| 862 |
if (tooltip) tooltip.textContent = streetName;
|
| 863 |
}
|
| 864 |
-
// update streetPointsList entry
|
| 865 |
const sp = streetPointsList.find(p => p.id === pointrId);
|
| 866 |
if (sp) sp.streetId = parseInt(newStreetId);
|
| 867 |
-
// remove temp marker class
|
| 868 |
pointr.classList.remove('pointr-temp');
|
| 869 |
refreshStreetPath();
|
|
|
|
|
|
|
|
|
|
| 870 |
});
|
| 871 |
return;
|
| 872 |
}
|
|
@@ -896,6 +962,80 @@ function initContainerScrollListener() {
|
|
| 896 |
|
| 897 |
function initKeyboardListener() {
|
| 898 |
document.addEventListener('keydown', (e) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 899 |
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
| 900 |
const editor = document.querySelector('#editor');
|
| 901 |
if (editor.value === 'move' && activePointrId) {
|
|
@@ -934,11 +1074,20 @@ function initPointrDrag() {
|
|
| 934 |
});
|
| 935 |
}
|
| 936 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 937 |
function initEditorListener() {
|
| 938 |
const editor = document.getElementById('editor');
|
| 939 |
editor.addEventListener('change', (e) => {
|
| 940 |
document.body.className = `mode-${e.target.value}`;
|
|
|
|
| 941 |
activePointrId = null;
|
| 942 |
updateActivePointrVisual();
|
| 943 |
});
|
|
|
|
|
|
|
| 944 |
}
|
|
|
|
| 151 |
return pts;
|
| 152 |
}
|
| 153 |
|
| 154 |
+
// Geographic distance squared (in degrees²) — good enough for relative comparison within a city
|
| 155 |
+
function _geoDist2(a, b) {
|
| 156 |
+
const dLat = (a.lat ?? 0) - (b.lat ?? 0);
|
| 157 |
+
const dLon = (a.lon ?? 0) - (b.lon ?? 0);
|
| 158 |
+
// scale lon by cos(lat) to approximate equal-distance in both axes
|
| 159 |
+
const cosLat = Math.cos(((a.lat ?? 0) * Math.PI) / 180);
|
| 160 |
+
return dLat * dLat + (dLon * dLon * cosLat * cosLat);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
function nearestNeighborSort(pts) {
|
| 164 |
if (pts.length <= 1) return [...pts];
|
| 165 |
+
|
| 166 |
+
// Find the pair of points with the maximum distance (diameter of the set).
|
| 167 |
+
// One of those endpoints is the natural start of the street path.
|
| 168 |
+
let maxD = -1, startIdx = 0;
|
| 169 |
+
for (let i = 0; i < pts.length; i++) {
|
| 170 |
+
for (let j = i + 1; j < pts.length; j++) {
|
| 171 |
+
const d = _geoDist2(pts[i], pts[j]);
|
| 172 |
+
if (d > maxD) { maxD = d; startIdx = i; }
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
const rem = [...pts];
|
| 177 |
const result = [];
|
| 178 |
+
let cur = rem.splice(startIdx, 1)[0];
|
| 179 |
result.push(cur);
|
| 180 |
while (rem.length > 0) {
|
| 181 |
let minD = Infinity, minI = 0;
|
| 182 |
for (let i = 0; i < rem.length; i++) {
|
| 183 |
+
const d = _geoDist2(rem[i], cur);
|
|
|
|
|
|
|
| 184 |
if (d < minD) { minD = d; minI = i; }
|
| 185 |
}
|
| 186 |
cur = rem.splice(minI, 1)[0];
|
|
|
|
| 200 |
if (svg) svg.innerHTML = '';
|
| 201 |
}
|
| 202 |
|
| 203 |
+
const _PATH_COLORS = ['#2196F3','#E91E63','#4CAF50','#FF9800','#9C27B0','#00BCD4','#FF5722','#8BC34A','#F44336','#3F51B5'];
|
| 204 |
+
|
| 205 |
+
function _appendPolyline(svg, pts, color) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
const sorted = nearestNeighborSort(pts);
|
| 207 |
const container = document.querySelector('.container');
|
| 208 |
const sl = container.scrollLeft;
|
|
|
|
| 210 |
const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
|
| 211 |
polyline.setAttribute('points', sorted.map(d => `${d.initialClientX - sl},${d.initialClientY - st}`).join(' '));
|
| 212 |
polyline.setAttribute('fill', 'none');
|
| 213 |
+
polyline.setAttribute('stroke', color);
|
| 214 |
polyline.setAttribute('stroke-width', '2');
|
| 215 |
polyline.setAttribute('stroke-dasharray', '6,3');
|
| 216 |
polyline.setAttribute('opacity', '0.8');
|
| 217 |
svg.appendChild(polyline);
|
| 218 |
}
|
| 219 |
|
| 220 |
+
function refreshStreetPath() {
|
| 221 |
+
const svg = document.getElementById('street-path-svg');
|
| 222 |
+
if (!svg) return;
|
| 223 |
+
svg.innerHTML = '';
|
| 224 |
+
|
| 225 |
+
const showAll = document.getElementById('sIsStreetPath')?.checked;
|
| 226 |
+
|
| 227 |
+
if (showAll) {
|
| 228 |
+
// Draw paths for all streets that have pointrs (Maps → iterate values)
|
| 229 |
+
const streetIds = new Set();
|
| 230 |
+
pointrReload.forEach(d => { if (Array.isArray(d.streets)) d.streets.forEach(sid => streetIds.add(sid)); });
|
| 231 |
+
pointrData.forEach(d => { if (Array.isArray(d.streets)) d.streets.forEach(sid => streetIds.add(sid)); });
|
| 232 |
+
let colorIdx = 0;
|
| 233 |
+
streetIds.forEach(sid => {
|
| 234 |
+
const pts = collectStreetPointrs(sid);
|
| 235 |
+
if (pts.length >= 2) {
|
| 236 |
+
const color = sid === activeStreetPathId
|
| 237 |
+
? '#2196F3'
|
| 238 |
+
: _PATH_COLORS[(colorIdx + 1) % _PATH_COLORS.length];
|
| 239 |
+
colorIdx++;
|
| 240 |
+
_appendPolyline(svg, pts, color);
|
| 241 |
+
}
|
| 242 |
+
});
|
| 243 |
+
// Also draw active street on top if not already covered
|
| 244 |
+
if (activeStreetPathId && !streetIds.has(activeStreetPathId)) {
|
| 245 |
+
const pts = collectStreetPointrs(activeStreetPathId);
|
| 246 |
+
if (pts.length >= 2) _appendPolyline(svg, pts, '#2196F3');
|
| 247 |
+
}
|
| 248 |
+
return;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
// Default: draw only the active street
|
| 252 |
+
if (!activeStreetPathId) return;
|
| 253 |
+
const pts = collectStreetPointrs(activeStreetPathId);
|
| 254 |
+
if (pts.length < 2) return;
|
| 255 |
+
_appendPolyline(svg, pts, '#2196F3');
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
// ==================== POINTR CREATION ====================
|
| 259 |
|
| 260 |
function createPointr(clientX, clientY, title = '', lat = null, lon = null) {
|
|
|
|
| 424 |
|
| 425 |
// ==================== POINTR MODALS ====================
|
| 426 |
|
| 427 |
+
// Return streets from allStreetsData that have at least one pointr within radiusKm of (lat, lon)
|
| 428 |
+
function getStreetsNearPoint(lat, lon, radiusKm) {
|
| 429 |
+
if (lat == null || lon == null) return allStreetsData || [];
|
| 430 |
+
const R = 6371;
|
| 431 |
+
function haverDist(lat1, lon1, lat2, lon2) {
|
| 432 |
+
const dLat = (lat2 - lat1) * Math.PI / 180;
|
| 433 |
+
const dLon = (lon2 - lon1) * Math.PI / 180;
|
| 434 |
+
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
|
| 435 |
+
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
| 436 |
+
}
|
| 437 |
+
const nearSids = new Set();
|
| 438 |
+
const check = (d) => {
|
| 439 |
+
if (d.lat == null || d.lon == null) return;
|
| 440 |
+
if (haverDist(lat, lon, d.lat, d.lon) <= radiusKm) {
|
| 441 |
+
(d.streets || []).forEach(sid => nearSids.add(sid));
|
| 442 |
+
}
|
| 443 |
+
};
|
| 444 |
+
pointrReload.forEach(check);
|
| 445 |
+
pointrData.forEach(check);
|
| 446 |
+
const nearby = (allStreetsData || []).filter(s => nearSids.has(s.id));
|
| 447 |
+
return nearby.length > 0 ? nearby : (allStreetsData || []);
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
function showSetNodeModal(pointrId) {
|
| 451 |
const data = pointrData.get(pointrId);
|
| 452 |
if (!data) return;
|
| 453 |
|
| 454 |
const currentStreets = data.streets || [];
|
| 455 |
+
const availableStreets = getStreetsNearPoint(data.lat, data.lon, 0.8);
|
| 456 |
|
| 457 |
let tableHTML = `<table class="node-streets-table">
|
| 458 |
<thead>
|
|
|
|
| 811 |
});
|
| 812 |
}
|
| 813 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 814 |
// ==================== POINTR MOVEMENT ====================
|
| 815 |
|
| 816 |
function movePointr(direction) {
|
|
|
|
| 923 |
const data = pointrData.get(pointrId);
|
| 924 |
if (data) {
|
| 925 |
data.streets = [parseInt(newStreetId)];
|
|
|
|
| 926 |
const tooltip = data.element ? data.element.querySelector('.pointr-tooltip') : null;
|
| 927 |
if (tooltip) tooltip.textContent = streetName;
|
| 928 |
}
|
|
|
|
| 929 |
const sp = streetPointsList.find(p => p.id === pointrId);
|
| 930 |
if (sp) sp.streetId = parseInt(newStreetId);
|
|
|
|
| 931 |
pointr.classList.remove('pointr-temp');
|
| 932 |
refreshStreetPath();
|
| 933 |
+
}, () => {
|
| 934 |
+
// User cancelled → remove the temp pointr
|
| 935 |
+
deletePointr(pointrId);
|
| 936 |
});
|
| 937 |
return;
|
| 938 |
}
|
|
|
|
| 962 |
|
| 963 |
function initKeyboardListener() {
|
| 964 |
document.addEventListener('keydown', (e) => {
|
| 965 |
+
// Skip if focus is inside an input/textarea/select
|
| 966 |
+
const tag = document.activeElement?.tagName;
|
| 967 |
+
const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
| 968 |
+
|
| 969 |
+
if (!inInput) {
|
| 970 |
+
// N -> Create Point
|
| 971 |
+
if (e.key === 'n' || e.key === 'N') {
|
| 972 |
+
const ed = document.getElementById('editor');
|
| 973 |
+
ed.value = 'create'; ed.dispatchEvent(new Event('change'));
|
| 974 |
+
e.preventDefault();
|
| 975 |
+
return;
|
| 976 |
+
}
|
| 977 |
+
// M -> Move Point
|
| 978 |
+
if (e.key === 'm' || e.key === 'M') {
|
| 979 |
+
const ed = document.getElementById('editor');
|
| 980 |
+
ed.value = 'move'; ed.dispatchEvent(new Event('change'));
|
| 981 |
+
e.preventDefault();
|
| 982 |
+
return;
|
| 983 |
+
}
|
| 984 |
+
// V -> toggle View All Street Paths (sIsStreetPath)
|
| 985 |
+
if (e.key === 'v' || e.key === 'V') {
|
| 986 |
+
const cb = document.getElementById('sIsStreetPath');
|
| 987 |
+
if (cb) { cb.checked = !cb.checked; refreshStreetPath(); }
|
| 988 |
+
e.preventDefault();
|
| 989 |
+
return;
|
| 990 |
+
}
|
| 991 |
+
// B -> toggle Street Panel
|
| 992 |
+
if (e.key === 'b' || e.key === 'B') {
|
| 993 |
+
document.getElementById('streetPanelToggle')?.click();
|
| 994 |
+
e.preventDefault();
|
| 995 |
+
return;
|
| 996 |
+
}
|
| 997 |
+
// T -> reset Streets dropdown to "all"
|
| 998 |
+
if (e.key === 't' || e.key === 'T') {
|
| 999 |
+
const sel = document.getElementById('Streets');
|
| 1000 |
+
if (sel) { sel.value = 'all'; sel.dispatchEvent(new Event('change')); }
|
| 1001 |
+
e.preventDefault();
|
| 1002 |
+
return;
|
| 1003 |
+
}
|
| 1004 |
+
// + / - -> increase / decrease tile size by 50px
|
| 1005 |
+
if (e.key === '+' || e.key === '=') {
|
| 1006 |
+
const sl = document.getElementById('sSize');
|
| 1007 |
+
if (sl) { sl.value = parseInt(sl.value) + 50; sl.dispatchEvent(new Event('change')); }
|
| 1008 |
+
e.preventDefault();
|
| 1009 |
+
return;
|
| 1010 |
+
}
|
| 1011 |
+
if (e.key === '-' || e.key === '_') {
|
| 1012 |
+
const sl = document.getElementById('sSize');
|
| 1013 |
+
if (sl) { sl.value = Math.max(50, parseInt(sl.value) - 50); sl.dispatchEvent(new Event('change')); }
|
| 1014 |
+
e.preventDefault();
|
| 1015 |
+
return;
|
| 1016 |
+
}
|
| 1017 |
+
// < / > -> decrease / increase opacity by 5
|
| 1018 |
+
if (e.key === '<' || e.key === ',') {
|
| 1019 |
+
const sl = document.getElementById('sOpacity');
|
| 1020 |
+
if (sl) { sl.value = Math.max(0, parseInt(sl.value) - 5); sl.dispatchEvent(new Event('input')); }
|
| 1021 |
+
e.preventDefault();
|
| 1022 |
+
return;
|
| 1023 |
+
}
|
| 1024 |
+
if (e.key === '>' || e.key === '.') {
|
| 1025 |
+
const sl = document.getElementById('sOpacity');
|
| 1026 |
+
if (sl) { sl.value = Math.min(100, parseInt(sl.value) + 5); sl.dispatchEvent(new Event('input')); }
|
| 1027 |
+
e.preventDefault();
|
| 1028 |
+
return;
|
| 1029 |
+
}
|
| 1030 |
+
}
|
| 1031 |
+
|
| 1032 |
+
// Ctrl+S -> Save All modal
|
| 1033 |
+
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
| 1034 |
+
e.preventDefault();
|
| 1035 |
+
showSaveAllModal();
|
| 1036 |
+
return;
|
| 1037 |
+
}
|
| 1038 |
+
|
| 1039 |
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
| 1040 |
const editor = document.querySelector('#editor');
|
| 1041 |
if (editor.value === 'move' && activePointrId) {
|
|
|
|
| 1074 |
});
|
| 1075 |
}
|
| 1076 |
|
| 1077 |
+
const _EDITOR_CURSORS = { create: 'crosshair', move: 'all-scroll', view: 'default' };
|
| 1078 |
+
|
| 1079 |
+
function _applyEditorCursor(mode) {
|
| 1080 |
+
document.querySelector('.container').style.cursor = _EDITOR_CURSORS[mode] || 'default';
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
function initEditorListener() {
|
| 1084 |
const editor = document.getElementById('editor');
|
| 1085 |
editor.addEventListener('change', (e) => {
|
| 1086 |
document.body.className = `mode-${e.target.value}`;
|
| 1087 |
+
_applyEditorCursor(e.target.value);
|
| 1088 |
activePointrId = null;
|
| 1089 |
updateActivePointrVisual();
|
| 1090 |
});
|
| 1091 |
+
// Apply initial cursor on load
|
| 1092 |
+
_applyEditorCursor(editor.value);
|
| 1093 |
}
|
admin/js/maps/settings.js
CHANGED
|
@@ -72,6 +72,10 @@ function initSettings() {
|
|
| 72 |
if (window._gallerySettings) window._gallerySettings.size = newSize;
|
| 73 |
});
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
document.getElementById('settingsSave').addEventListener('click', () => {
|
| 76 |
const items = parseInt(document.getElementById('sItems').value) || 23;
|
| 77 |
const newSize = parseInt(document.getElementById('sSize').value) || 400;
|
|
|
|
| 72 |
if (window._gallerySettings) window._gallerySettings.size = newSize;
|
| 73 |
});
|
| 74 |
|
| 75 |
+
document.getElementById('sIsStreetPath').addEventListener('change', () => {
|
| 76 |
+
refreshStreetPath();
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
document.getElementById('settingsSave').addEventListener('click', () => {
|
| 80 |
const items = parseInt(document.getElementById('sItems').value) || 23;
|
| 81 |
const newSize = parseInt(document.getElementById('sSize').value) || 400;
|
admin/maps.html
CHANGED
|
@@ -38,6 +38,18 @@
|
|
| 38 |
<div id="modal" class="modal"></div>
|
| 39 |
</div>
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
<!-- Search -->
|
| 42 |
<button id="searchBtn" class="search-btn" title="Search">🔍</button>
|
| 43 |
<div id="searchModal" class="search-modal" style="display:none;">
|
|
@@ -66,11 +78,15 @@
|
|
| 66 |
</label>
|
| 67 |
<label class="settings-label">
|
| 68 |
Size (px)
|
| 69 |
-
<input type="number" id="sSize" value="
|
| 70 |
</label>
|
| 71 |
<label class="settings-label settings-label--row">
|
| 72 |
<input type="checkbox" id="sIsBorder" checked>
|
| 73 |
-
Border
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
</label>
|
| 75 |
<label class="settings-label">
|
| 76 |
Opacity
|
|
|
|
| 38 |
<div id="modal" class="modal"></div>
|
| 39 |
</div>
|
| 40 |
|
| 41 |
+
<!-- Street Panel -->
|
| 42 |
+
<button id="streetPanelToggle" class="street-panel-toggle" title="Nearby Streets">☰</button>
|
| 43 |
+
<div id="streetPanel" class="street-panel">
|
| 44 |
+
<div class="street-panel-header">
|
| 45 |
+
<span class="street-panel-title">Nearby Streets</span>
|
| 46 |
+
<span id="streetPanelRadius" class="street-panel-radius">r=2km</span>
|
| 47 |
+
</div>
|
| 48 |
+
<div id="streetPanelList" class="street-panel-list">
|
| 49 |
+
<div class="street-panel-empty">Scroll map to load...</div>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
<!-- Search -->
|
| 54 |
<button id="searchBtn" class="search-btn" title="Search">🔍</button>
|
| 55 |
<div id="searchModal" class="search-modal" style="display:none;">
|
|
|
|
| 78 |
</label>
|
| 79 |
<label class="settings-label">
|
| 80 |
Size (px)
|
| 81 |
+
<input type="number" id="sSize" value="800" min="1">
|
| 82 |
</label>
|
| 83 |
<label class="settings-label settings-label--row">
|
| 84 |
<input type="checkbox" id="sIsBorder" checked>
|
| 85 |
+
Tile Border
|
| 86 |
+
</label>
|
| 87 |
+
<label class="settings-label settings-label--row">
|
| 88 |
+
<input type="checkbox" id="sIsStreetPath">
|
| 89 |
+
Street Path
|
| 90 |
</label>
|
| 91 |
<label class="settings-label">
|
| 92 |
Opacity
|
backend/main.py
CHANGED
|
@@ -366,12 +366,24 @@ async def search_streets(payload: dict = Body(...)):
|
|
| 366 |
|
| 367 |
@app.post("/api/streets", response_model=Street)
|
| 368 |
async def create_street(street: StreetCreate):
|
| 369 |
-
"""Create a new street"""
|
| 370 |
try:
|
| 371 |
return await MapsService.create_street(street)
|
| 372 |
except Exception as e:
|
| 373 |
-
|
| 374 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
|
| 376 |
|
| 377 |
@app.get("/api/streets/{street_id}", response_model=Street)
|
|
|
|
| 366 |
|
| 367 |
@app.post("/api/streets", response_model=Street)
|
| 368 |
async def create_street(street: StreetCreate):
|
| 369 |
+
"""Create a new street. Returns 409 with {error:'duplicate', street:{id,name}} if similar exists."""
|
| 370 |
try:
|
| 371 |
return await MapsService.create_street(street)
|
| 372 |
except Exception as e:
|
| 373 |
+
msg = str(e)
|
| 374 |
+
# Structured duplicate signal: "Failed to create street: DUPLICATE_STREET:<id>:<name>"
|
| 375 |
+
inner = msg.replace("Failed to create street: ", "")
|
| 376 |
+
if inner.startswith("DUPLICATE_STREET:"):
|
| 377 |
+
parts = inner.split(":", 2) # ["DUPLICATE_STREET", "<id>", "<name>"]
|
| 378 |
+
dup_id = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
| 379 |
+
dup_name = parts[2] if len(parts) > 2 else ""
|
| 380 |
+
from fastapi.responses import JSONResponse
|
| 381 |
+
return JSONResponse(
|
| 382 |
+
status_code=409,
|
| 383 |
+
content={"error": "duplicate", "street": {"id": dup_id, "name": dup_name}}
|
| 384 |
+
)
|
| 385 |
+
logger.error(f"Error creating street: {msg}")
|
| 386 |
+
raise HTTPException(status_code=500, detail=msg)
|
| 387 |
|
| 388 |
|
| 389 |
@app.get("/api/streets/{street_id}", response_model=Street)
|
backend/services/maps_service.py
CHANGED
|
@@ -175,51 +175,23 @@ class MapsService:
|
|
| 175 |
return prev[-1]
|
| 176 |
|
| 177 |
name_norm = _normalize(data.name)
|
| 178 |
-
city_id = data.city_id
|
| 179 |
-
district_id = data.district_id
|
| 180 |
|
| 181 |
-
# Fetch
|
| 182 |
-
|
| 183 |
-
if district_id is not None:
|
| 184 |
-
query = query.eq("district_id", district_id)
|
| 185 |
-
elif city_id is not None:
|
| 186 |
-
query = query.eq("city_id", city_id)
|
| 187 |
-
existing_res = query.execute()
|
| 188 |
existing_list = existing_res.data or []
|
| 189 |
for item in existing_list:
|
| 190 |
existing_name = item.get('name') or ''
|
| 191 |
en_norm = _normalize(existing_name)
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
| 196 |
max_dist = max(2, int(max(len(en_norm), len(name_norm)) * 0.2))
|
| 197 |
dist = _levenshtein(en_norm, name_norm)
|
| 198 |
-
# debug log
|
| 199 |
-
try:
|
| 200 |
-
import logging
|
| 201 |
-
logging.getLogger(__name__).info(f"create_street: compare '{name_norm}' vs '{en_norm}' -> dist={dist} threshold={max_dist}")
|
| 202 |
-
except Exception:
|
| 203 |
-
pass
|
| 204 |
if dist <= max_dist:
|
| 205 |
-
raise Exception(f"
|
| 206 |
-
|
| 207 |
-
# Also perform a global fuzzy search to catch similar names in other districts/cities
|
| 208 |
-
try:
|
| 209 |
-
global_results = await MapsService.search_streets_by_text(data.name, district_id=None, city_id=None, limit=10)
|
| 210 |
-
# Accept if any match has a low score (<= 0.15)
|
| 211 |
-
for r in (global_results or []):
|
| 212 |
-
if float(r.get('score', 1.0)) <= 0.15:
|
| 213 |
-
raise Exception(f"Similar street exists elsewhere: {r.get('name')} (id={r.get('id')})")
|
| 214 |
-
except Exception as e:
|
| 215 |
-
# If search_streets_by_text raised an exception because of DB, re-raise as generic
|
| 216 |
-
if str(e).startswith('Failed to create street'):
|
| 217 |
-
raise
|
| 218 |
-
# If we raised above due to similarity, pass it up
|
| 219 |
-
if 'Similar street exists' in str(e) or 'Similar street exists elsewhere' in str(e):
|
| 220 |
-
raise
|
| 221 |
-
# otherwise ignore search errors and proceed with insert
|
| 222 |
-
pass
|
| 223 |
|
| 224 |
# No similar found -> proceed to insert
|
| 225 |
next_id = _next_id("Streets")
|
|
|
|
| 175 |
return prev[-1]
|
| 176 |
|
| 177 |
name_norm = _normalize(data.name)
|
|
|
|
|
|
|
| 178 |
|
| 179 |
+
# Fetch ALL streets globally to check for duplicates across all districts/cities
|
| 180 |
+
existing_res = supabase.table("Streets").select("id, name, district_id, city_id").order("name").execute()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
existing_list = existing_res.data or []
|
| 182 |
for item in existing_list:
|
| 183 |
existing_name = item.get('name') or ''
|
| 184 |
en_norm = _normalize(existing_name)
|
| 185 |
+
if not en_norm:
|
| 186 |
+
continue
|
| 187 |
+
# exact or containment match
|
| 188 |
+
if en_norm == name_norm or (name_norm and (name_norm in en_norm or en_norm in name_norm)):
|
| 189 |
+
raise Exception(f"DUPLICATE_STREET:{item.get('id')}:{existing_name}")
|
| 190 |
+
# edit distance threshold scales with length
|
| 191 |
max_dist = max(2, int(max(len(en_norm), len(name_norm)) * 0.2))
|
| 192 |
dist = _levenshtein(en_norm, name_norm)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
if dist <= max_dist:
|
| 194 |
+
raise Exception(f"DUPLICATE_STREET:{item.get('id')}:{existing_name}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
|
| 196 |
# No similar found -> proceed to insert
|
| 197 |
next_id = _next_id("Streets")
|