Tataaicoltd commited on
Commit
f8fa21c
·
verified ·
1 Parent(s): 09009c9

feat: keyboard shortcuts, all-street path mode, global duplicate check

Browse files
admin/css/maps.css CHANGED
@@ -25,7 +25,7 @@ h1 {
25
 
26
  .gallery {
27
  display: grid;
28
- grid-template-columns: repeat(23, 400px);
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, 0.1);
 
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
- throw new Error(data.detail || `HTTP ${response.status}`);
 
 
 
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
- <input type="text" id="f_type" placeholder="VD: Đường, Phố, Đại lộ...">
134
  </label>
135
  <div class="modal-error" id="modalError"></div>
136
  <div class="modal-actions">
137
- <button type="button" class="btn-cancel" onclick="closeModal()">Huỷ</button>
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 type = document.getElementById('f_type').value.trim();
 
 
 
 
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
- document.getElementById('modalError').textContent = err.message;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, '&amp;')
9
+ .replace(/</g, '&lt;')
10
+ .replace(/>/g, '&gt;')
11
+ .replace(/"/g, '&quot;')
12
+ .replace(/'/g, '&#039;');
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(0, 1)[0];
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 dx = rem[i].initialClientX - cur.initialClientX;
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
- function refreshStreetPath() {
186
- const svg = document.getElementById('street-path-svg');
187
- if (!svg) return;
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', '#2196F3');
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 = allStreetsData || [];
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, '&amp;')
742
- .replace(/</g, '&lt;')
743
- .replace(/>/g, '&gt;')
744
- .replace(/"/g, '&quot;')
745
- .replace(/'/g, '&#039;');
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="400" min="1">
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
- logger.error(f"Error creating street: {str(e)}")
374
- raise HTTPException(status_code=500, detail=str(e))
 
 
 
 
 
 
 
 
 
 
 
 
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 candidate existing streets in same district or city
182
- query = supabase.table("Streets").select("id, name, district_id, city_id").order("name")
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
- # exact or containment
193
- if en_norm == name_norm or (name_norm and en_norm and (name_norm in en_norm or en_norm in name_norm)):
194
- raise Exception(f"Similar street exists: {existing_name} (id={item.get('id')})")
195
- # small edit distance -> likely duplicate; threshold scales with length
 
 
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"Similar street exists: {existing_name} (id={item.get('id')})")
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")