File size: 11,394 Bytes
8be1be4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2dedb1c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
<!DOCTYPE html>
<html lang="vi">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
  <title>Bản đồ tra cứu hành chính TP.HCM - Long Ngo, 2025</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
  <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
  <script src="https://unpkg.com/topojson-client@3"></script>
  <style>
    html, body { height: 100%; margin: 0; padding: 0; font-family: 'Helvetica Neue', Arial, sans-serif; background: #15161c;}
    #map { height: 100vh; width: 100vw; background: #15161c; }
    .non-serif { font-family: 'Helvetica Neue', Arial, sans-serif !important; }
    .shadow-sm { box-shadow: 0 2px 8px #0001; }
    .popup-header { background: #24272f; color: #f9c846; padding: 1rem 1.3rem 0.7rem 1.3rem; border-bottom: 1px solid #393a44; font-size: 1.13rem; font-weight: 600; }
    .popup-body { padding: 0.9rem 1.3rem; font-size: 1rem; color: #f2f2f5;}
    .popup-metrics { display: flex; gap: 1.3rem; margin-bottom: 0.7rem; }
    .popup-metric { background: #23243a; border-radius: 8px; padding: 0.5rem 1rem; text-align: center; min-width: 90px; color:#f9c846;}
    .popup-label { font-size: 0.85rem; color: #bbb; }
    .popup-value { font-size: 1.15rem; font-weight: 700; color: #fff; }
    .popup-footer { background: #23243a; color: #bbb; padding: 0.8rem 1.3rem; border-top: 1px solid #393a44; }
    .search-bar { position: absolute; top: 1.2rem; left: 50%; transform: translateX(-50%); width: 98vw; max-width: 450px; z-index: 999; }
    .search-input { width: 100%; border-radius: 0.85rem; border: 1px solid #2d3141; padding: 0.75rem 1.1rem; font-size: 1.1rem; outline: none; box-shadow: 0 4px 16px #0002; background: #23243a; color:#f4e6b7;}
    .search-results { background: #23243a; border-radius: 0.85rem; box-shadow: 0 6px 32px #0006; margin-top: 0.3rem; border: 1px solid #393a44; max-height: 44vh; overflow-y: auto;}
    .search-item { width: 100%; text-align: left; border: none; background: none; padding: 0.8rem 1.1rem; font-size: 1.03rem; color: #ffecc3; cursor: pointer;}
    .search-item:hover,.search-item:focus { background: #393a44; color: #fff;}
    @media (max-width: 600px) {
      .search-bar { top: 0.6rem; max-width: 98vw; }
      .search-input { font-size: 0.99rem; padding: 0.6rem 0.8rem; }
      .popup-header, .popup-footer, .popup-body { padding: 0.7rem 0.7rem;}
      .popup-metrics { gap: 0.5rem;}
      .popup-metric { min-width: 65px; }
    }
  </style>
</head>
<body class="non-serif">

  <div class="search-bar">
    <input id="searchInput" class="search-input" type="text" autocomplete="off" placeholder="Tìm xã/phường, huyện/quận...">
    <div id="searchResults" class="search-results" style="display:none"></div>
  </div>
  <div id="map"></div>
  <footer style="position:fixed;right:8px;bottom:8px;z-index:10001;font-size:13px;color:#b1b3bb;">Nguồn: Long Ngo, 2025</footer>

  <script>
    // CHỈ hiện ranh giới các xã/phường TP.HCM, nổi bật so với nền xung quanh!
    const WARDS_TOPOJSON_URL = 'https://raw.githubusercontent.com/lqtue/phuongnao/main/Data/Wards.json';
    const WARD_DATA_TSV_URL = 'https://raw.githubusercontent.com/lqtue/phuongnao/main/Data/Data.tsv';

    let map, wardsLayer, highlightLayer;
    let features = [];
    let searchArr = [];
    let searchResults = [];
    let currentFocus = -1;

    function normalize(str) {
      return (str||'').toLowerCase()
        .normalize("NFD").replace(/[\u0300-\u036f]/g,"")
        .replace(/đ/g,"d").replace(/[^a-z0-9 ]/g,"").replace(/\s+/g," ").trim();
    }

    function getHCMBounds(features) {
      // Tìm ranh giới bao quanh tất cả các xã/phường TP.HCM (cho fitBounds)
      const coords = [];
      features.forEach(f=>{
        let geom = f.geometry.coordinates;
        if (f.geometry.type==='Polygon') coords.push(...geom[0]);
        if (f.geometry.type==='MultiPolygon') geom.forEach(g=>coords.push(...g[0]));
      });
      const latlngs = coords.map(([lng,lat])=>[lat,lng]);
      return L.latLngBounds(latlngs);
    }

    function wardColor(name) {
      // Cho mỗi xã/phường 1 màu khác biệt
      let hash = 0; for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
      let h = Math.abs(hash % 360); return `hsl(${h},68%,48%)`;
    }
    function metric(n, unit='') {
      return !n||n==='NaN'?'-':parseFloat(n).toLocaleString('vi-VN')+(unit?(' '+unit):'');
    }
    function createPopup(props){
      return `
      <div class="popup-header">${props['Loại']||''} ${props['Tên']||''}</div>
      <div class="popup-body">
        <div class="popup-metrics">
          <div class="popup-metric"><div class="popup-label">Dân số</div><div class="popup-value">${metric(props['Dân số (người)'])}</div></div>
          <div class="popup-metric"><div class="popup-label">Diện tích</div><div class="popup-value">${metric(props['Diện tích (km2)'],'km²')}</div></div>
          <div class="popup-metric"><div class="popup-label">Mật độ</div><div class="popup-value">${metric(props['Mật độ (người/km2)'])}</div></div>
        </div>
        ${props['Sáp nhập toàn bộ từ']||props['Sáp nhập một phần từ']?
          `<div class="popup-label" style="margin-top:6px;">Sáp nhập: 
          ${props['Sáp nhập toàn bộ từ']?'Toàn bộ '+props['Sáp nhập toàn bộ từ']:''}
          ${props['Sáp nhập toàn bộ từ']&&props['Sáp nhập một phần từ']?'; ':''}
          ${props['Sáp nhập một phần từ']?'Một phần '+props['Sáp nhập một phần từ']:''}
          </div>`:''
        }
      </div>
      <div class="popup-footer">${props['Full Address 1']||'&nbsp;'}</div>
      `;
    }

    async function main(){
      // Load dữ liệu
      const [topo, tsv] = await Promise.all([
        fetch(WARDS_TOPOJSON_URL).then(r=>r.json()),
        fetch(WARD_DATA_TSV_URL).then(r=>r.text())
      ]);
      // Chỉ lấy các xã/phường của TP.HCM (hầu hết file này đã tách riêng TP.HCM)
      const geojson = topojson.feature(topo, Object.keys(topo.objects)[0]);
      features = geojson.features;
      // Parse TSV
      const [header,...rows] = tsv.trim().split(/\r?\n/);
      const keys = header.split('\t');
      const arr = rows.map(line=>{
        const cells = line.split('\t');
        let obj={};
        keys.forEach((k,i)=>obj[k]=cells[i]);
        return obj;
      });
      // Map data by STT
      let mapData = {};
      arr.forEach(row=>{ mapData[row.STT]=row; });
      features.forEach(f=>{
        Object.assign(f.properties, mapData[f.properties.STT]);
      });
      // Chuẩn bị cho tìm kiếm
      searchArr = features.map(f=>{
        return {
          name: (f.properties['Tên']||'').trim(),
          id: f.properties['STT'],
          type: f.properties['Loại']||'',
          norm: normalize(f.properties['Tên']),
          pop: f.properties['Dân số (người)']||'',
          area: f.properties['Diện tích (km2)']||'',
          density: f.properties['Mật độ (người/km2)']||'',
          f
        };
      });

      // Hiện nền tối, làm mờ vùng ngoài TP.HCM, nổi bật vùng TP.HCM:
      map = L.map('map', {
        center: [10.8,106.7], zoom: 10.3, attributionControl: false,
        zoomControl: true, minZoom: 9, maxZoom: 17
      });

      // Nền tối CartoDB (dễ phân biệt vùng)
      let tiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
        attribution: '&copy; CartoDB',
        subdomains: 'abcd', maxZoom: 20
      }).addTo(map);

      // Lớp ranh giới các xã/phường, mỗi xã/phường 1 màu khác
      wardsLayer = L.geoJSON(geojson, {
        style: f => ({
          color: '#fff', weight: 1.2, fillOpacity: 0.71,
          fillColor: wardColor(f.properties['Tên'])
        }),
        onEachFeature: (feature, layer) => {
          layer.on({
            mouseover: function(e) { e.target.setStyle({fillOpacity:0.95, weight:2.2}); },
            mouseout: function(e) { wardsLayer.resetStyle(e.target); },
            click: function(e){
              map.closePopup();
              if (highlightLayer) { map.removeLayer(highlightLayer);}
              highlightLayer = L.geoJSON(feature, {
                style:{ color:'#f9c846', weight:3, fillColor:'#f8d364', fillOpacity:0.44 }
              }).addTo(map);
              map.openPopup(createPopup(feature.properties), e.latlng, { className: 'shadow-sm non-serif', maxWidth:390 });
            }
          });
          layer.bindTooltip((feature.properties['Loại']||'')+' '+feature.properties['Tên'],{sticky:true, direction:'top'});
        }
      }).addTo(map);

      // Fit bản đồ vừa vùng TP.HCM (tự động, không bị lệch vùng)
      map.fitBounds(getHCMBounds(features),{padding:[16,16]});
    }

    // Search & UI
    document.addEventListener('DOMContentLoaded', ()=>{
      main();
      const searchInput = document.getElementById('searchInput');
      const resultsDiv = document.getElementById('searchResults');
      searchInput.addEventListener('input', ()=>{
        const q = normalize(searchInput.value);
        if (q.length<2) { resultsDiv.style.display='none'; return;}
        searchResults = searchArr.filter(e=>e.norm.includes(q)).slice(0,12);
        resultsDiv.innerHTML = searchResults.map((e,i)=>
          `<button class="search-item" tabindex="0" data-idx="${i}">${e.type} ${e.name}</button>`
        ).join('');
        resultsDiv.style.display = searchResults.length ? 'block':'none';
        currentFocus = -1;
      });
      resultsDiv.addEventListener('click', function(e){
        let btn = e.target.closest('.search-item');
        if (!btn) return;
        let idx = +btn.getAttribute('data-idx');
        let feature = searchResults[idx].f;
        map.closePopup();
        if (highlightLayer) { map.removeLayer(highlightLayer);}
        highlightLayer = L.geoJSON(feature, {
          style:{ color:'#f9c846', weight:3, fillColor:'#f8d364', fillOpacity:0.44 }
        }).addTo(map);
        map.fitBounds(highlightLayer.getBounds(),{maxZoom:15});
        map.openPopup(createPopup(feature.properties), highlightLayer.getBounds().getCenter(), { className: 'shadow-sm non-serif', maxWidth:390 });
        resultsDiv.style.display='none';
        searchInput.value = searchResults[idx].name;
      });
      document.addEventListener('click', function(e){
        if (!e.target.closest('.search-bar')) resultsDiv.style.display='none';
      });
      searchInput.addEventListener('keydown', function(e){
        let items = resultsDiv.querySelectorAll('.search-item');
        if (['ArrowDown','ArrowUp','Enter'].includes(e.key)){
          if (e.key==='ArrowDown') { currentFocus = (currentFocus+1)%items.length; }
          if (e.key==='ArrowUp') { currentFocus = (currentFocus-1+items.length)%items.length; }
          if (e.key==='Enter' && items[currentFocus]) { items[currentFocus].click(); return;}
          items.forEach((el,i)=>el.classList.toggle('bg-[#393a44]',i===currentFocus));
          if (items[currentFocus]) items[currentFocus].focus();
        }
      });
    });
  </script>
</body>
</html>