| <!DOCTYPE html> |
| <html lang="vi"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width,initial-scale=1.0" /> |
| <title>Bản đồ Ranh giới Hành chính Việt Nam – Long Ngo, 2025</title> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.css"/> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"/> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/@turf/turf@6/turf.min.js"></script> |
| <style> |
| :root{ |
| --brand-bg: #eef6fa; |
| --brand-blue: #1790e0; |
| --brand-blue-dark: #0c3a69; |
| --brand-blue-light: #b3e5fc; |
| --brand-yellow: #ffeb3b; |
| --brand-white: #fff; |
| --brand-table-border: #90caf9; |
| } |
| body{font-family:"Segoe UI",Tahoma,Geneva,Verdana,sans-serif;background:var(--brand-bg);color:#174067;min-height:100vh;} |
| .header{ |
| background: linear-gradient(90deg, #e3f2fd 0%, #b3e5fc 100%); |
| padding: 1.1rem 2rem 1rem 2rem; |
| border-bottom: 4px solid var(--brand-blue); |
| box-shadow: 0 2px 16px rgba(23,144,224,.13); |
| display: flex; align-items: center; gap: 16px; |
| } |
| .header h1{font-size:2.1rem;font-weight:bold;color:var(--brand-blue-dark);margin:0;letter-spacing:0.5px;flex:1;} |
| .map-controls{ |
| position:absolute;top:18px;left:18px;z-index:1000;display:flex;flex-direction:column;gap:13px |
| } |
| .map-controls .box{ |
| background:var(--brand-white);padding:7px 14px 7px 13px;border-left:5px solid var(--brand-blue); |
| border-radius:10px;box-shadow:0 2px 7px rgba(23,144,224,.09) |
| } |
| .switch{display:inline-flex;align-items:center;gap:9px;font-weight:600;color:var(--brand-blue-dark)} |
| .switch .label{font-size:1.04rem} |
| .switch input{display:none} |
| .switch .slider{ |
| width:47px;height:23px;border-radius:12px;background:var(--brand-blue-light);position:relative;cursor:pointer;transition:.3s} |
| .switch .slider::before{ |
| content:"";position:absolute;left:2px;top:2px;width:18px;height:18px;border-radius:50%;background:#fff; |
| box-shadow:0 1px 5px rgba(23,144,224,.16);transition:.3s} |
| .switch input:checked + .slider{background:var(--brand-blue)} |
| .switch input:checked + .slider::before{transform:translateX(24px)} |
| .basemap-select{margin-left:7px;font-size:1.03rem;border-radius:6px;padding:4px 8px;border:1.2px solid #b3e5fc;background:#fff;color:#1976d2;} |
| .download-btn{ |
| display:inline-block;padding:9px 15px;border-radius:8px;background:var(--brand-blue-dark); |
| color:#fff;font-weight:600;text-decoration:none;box-shadow:0 2px 7px rgba(23,144,224,.12) |
| } |
| .download-btn:hover{background:var(--brand-blue)} |
| .leaflet-popup-content-wrapper{ |
| background:#fff;border:1.6px solid var(--brand-blue-dark);border-radius:10px; |
| box-shadow:0 5px 18px rgba(23,144,224,.14) |
| } |
| .leaflet-popup-close-button{font-size:20px !important;padding:4px 7px;color:#1976d2} |
| .leaflet-popup-content{padding:10px 15px !important;font-size:15px;line-height:1.47} |
| .popup-title{margin:0 0 6px;font-weight:700;color:var(--brand-blue-dark)} |
| .popup-row{display:flex;gap:8px;margin-bottom:5px} |
| .popup-row .icon{width:22px;text-align:center} |
| .province-label { |
| font-size: 13px; |
| font-weight: 700; |
| color: var(--brand-blue-dark); |
| background:rgba(255,255,255,0.88); |
| padding:1px 6px 1px 6px; |
| border-radius:8px; |
| border:1.1px solid #1976d2; |
| box-shadow: 0 0 3px #b3e5fc, 0 0 5px #fff; |
| pointer-events: none; |
| white-space: nowrap; |
| letter-spacing: 0.5px; |
| } |
| .leaflet-tooltip.province-label { |
| background: transparent; |
| border: none; |
| box-shadow: none; |
| } |
| .leaflet-tooltip.province-label::after { display: none; } |
| .compare-bottom{ |
| position:absolute;left:0;right:0;bottom:0;max-height:42%;overflow:auto; |
| background:#fff;border-top:4px solid var(--brand-blue-dark); |
| box-shadow:0 -7px 20px rgba(23,144,224,.13);padding:14px 15px 8px 15px; |
| border-radius:13px 13px 0 0;z-index:900 |
| } |
| #comparePanel table,#comparePanel th,#comparePanel td{border:1.5px solid var(--brand-table-border)} |
| #comparePanel th,#comparePanel td{padding:5px 9px;text-align:center} |
| #comparePanel th{background:var(--brand-blue-light);font-size:1.01rem;} |
| .compare-remove{cursor:pointer;color:#c00;font-weight:bold} |
| .compare-remove:hover{text-decoration:underline} |
| .leaflet-control-zoom{display:none !important} |
| .footer { |
| position: fixed; left:0; right:0; bottom:0; z-index: 99999; background:rgba(240,248,255,0.94); |
| color:#0d47a1;font-size:14px;padding:3px 16px;text-align:right;box-shadow:0 -1.5px 10px #b3e5fc; |
| letter-spacing:0.5px |
| } |
| </style> |
| </head> |
| <body> |
| <div class="header"> |
| <h1>Bản đồ Ranh giới Hành chính Việt Nam</h1> |
| <select id="basemapSelect" class="basemap-select" title="Chọn nền bản đồ"> |
| <option value="satellite">Vệ tinh</option> |
| <option value="roadmap">Bản đồ đường</option> |
| </select> |
| </div> |
| <div class="container"> |
| <div id="map" style="height: 85vh;"></div> |
| <div class="map-controls"> |
| <div class="box"> |
| <label class="switch"> |
| <span class="label">Cũ</span> |
| <input type="checkbox" id="switchBoundary"> |
| <span class="slider"></span> |
| <span class="label">Mới</span> |
| </label> |
| </div> |
| <button id="toggleCompare" class="download-btn" style="margin-top:7px;"> |
| Hiện So sánh |
| </button> |
| </div> |
| <div id="comparePanel" class="compare-bottom" style="display:none"> |
| <h3><i class="fas fa-balance-scale"></i> So sánh (0/5)</h3> |
| <div id="compareTable" style="font-size:13px"></div> |
| </div> |
| </div> |
| <div class="footer">Nguồn: Long Ngo, 2025.</div> |
| <script> |
| const CONFIG = { |
| old:"https://raw.githubusercontent.com/lqtue/LacaProvinceMap/main/old.geojson", |
| new:"https://raw.githubusercontent.com/lqtue/LacaProvinceMap/main/new.geojson" |
| }; |
| let map, oldLayer, newLayer; |
| let baseLayerSatellite, baseLayerRoad; |
| const dataStore = {old:null,new:null}; |
| let compareList=[]; |
| |
| const iconMap = { |
| "Tỉnh thành mới": "📍", "Tỉnh thành cũ": "📍", "TT hành chính": "📍", |
| "GRDP 2024 (tỷ VND)": "💰", "Thu ngân sách 2024 (tỷ VND)": "💰", |
| "Diện tích (km2)": "🗺️", "Dân số": "👥", "ĐVHC cấp xã": "🏛️" |
| }; |
| const labelOverrides = { |
| "Khánh Hoà": [12.248126980225129, 109.183807743233], |
| "TP HCM": [10.801867540653552, 106.68102175169227], |
| "TP. Hồ Chí Minh": [10.801867540653552, 106.68102175169227] |
| }; |
| const numFmt = v=>{ |
| const s = String(v).replace(/,/g, '.').replace(/ /g, ''); |
| const n = parseFloat(s); |
| return isNaN(n) ? v : n.toLocaleString('vi-VN'); |
| }; |
| const ATTRS = ["Diện tích (km2)","Dân số","GRDP 2024 (tỷ VND)","Thu ngân sách 2024 (tỷ VND)","ĐVHC cấp xã"]; |
| const numAttrs = ["Diện tích (km2)","Dân số","GRDP 2024 (tỷ VND)","Thu ngân sách 2024 (tỷ VND)"]; |
| const palette = [ |
| '#1565c0','#1976d2','#0277bd','#00838f','#0288d1','#00b8d4', |
| '#00897b','#00695c','#039be5','#00acc1','#0097a7','#01579b', |
| '#1e88e5','#29b6f6','#26c6da','#26a69a','#388e3c','#2e7d32', |
| '#4caf50','#0288d1','#2b32b2','#134e5e','#56ab2f','#0f2027', |
| '#43cea2','#185a9d','#283e51','#4776e6','#36d1c4','#159957' |
| ]; |
| function getProvinceColour(name){ |
| let hash = 0; |
| for(let i=0;i<name.length;i++) hash = name.charCodeAt(i)+(hash<<5)-hash; |
| const idx = Math.abs(hash) % palette.length; |
| return palette[idx]; |
| } |
| function init(){ |
| map = L.map('map',{zoomControl:false, attributionControl:false}).setView([16,108],6); |
| baseLayerSatellite = L.tileLayer('https://{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', |
| {subdomains:['mt0','mt1','mt2','mt3'], attribution:'© Google Satellite'}); |
| baseLayerRoad = L.tileLayer('https://{s}.google.com/vt/lyrs=m&x={x}&y={y}&z={z}', |
| {subdomains:['mt0','mt1','mt2','mt3'], attribution:'© Google Maps'}); |
| baseLayerSatellite.addTo(map); |
| |
| L.control.attribution({prefix: ''}).addTo(map); |
| map.attributionControl.setPrefix('© Google | Leaflet'); |
| oldLayer=L.layerGroup().addTo(map); |
| newLayer=L.layerGroup().addTo(map); |
| |
| fetch(CONFIG.old).then(r=>r.json()).then(gOld=>{ |
| dataStore.old=gOld; drawLayer(gOld,'old'); |
| }); |
| fetch(CONFIG.new).then(r=>r.json()).then(gNew=>{ |
| dataStore.new=gNew; drawLayer(gNew,'new'); |
| if (gNew && gNew.features && gNew.features.length > 0) { |
| map.fitBounds(L.geoJSON(gNew).getBounds()); |
| } |
| }); |
| |
| const sw=document.getElementById('switchBoundary'); |
| sw.checked=true; map.removeLayer(oldLayer); |
| sw.onchange=()=> { |
| if (sw.checked) { |
| map.removeLayer(oldLayer); map.addLayer(newLayer); |
| } else { |
| map.removeLayer(newLayer); map.addLayer(oldLayer); |
| } |
| updateLabels(); |
| }; |
| document.getElementById('basemapSelect').onchange = function() { |
| if (this.value==="satellite") { |
| map.removeLayer(baseLayerRoad); map.addLayer(baseLayerSatellite); |
| } else { |
| map.removeLayer(baseLayerSatellite); map.addLayer(baseLayerRoad); |
| } |
| }; |
| } |
| function drawLayer(gjson, type) { |
| const layer = type === 'old' ? oldLayer : newLayer; |
| layer.clearLayers(); |
| L.geoJSON(gjson, { |
| style: feature => { |
| const provName = feature.properties["Tỉnh thành mới"] || feature.properties["Tỉnh thành cũ"]; |
| return { |
| color: '#ffeb3b', |
| weight: 2.7, |
| opacity: 1, |
| fillColor: getProvinceColour(provName), |
| fillOpacity: 0.73 |
| }; |
| }, |
| onEachFeature: (f, ly) => { |
| const p = f.properties; |
| const title = (p["Tỉnh thành mới"] || p["Tỉnh thành cũ"]) || 'Thông tin'; |
| let html = `<h4 class="popup-title">${title}</h4>`; |
| [ |
| "Tỉnh thành mới", "Tỉnh thành cũ", "TT hành chính", ...ATTRS |
| ].forEach(k => { |
| if (p[k]) { |
| const val = numAttrs.includes(k) ? numFmt(p[k]) : p[k]; |
| html += `<div class="popup-row"><span class="icon">${iconMap[k] || ''}</span> |
| <strong>${k}:</strong> <span>${val}</span></div>`; |
| } |
| }); |
| const key = (type === 'old' ? 'old:' : 'new:') + title; |
| html += `<button class="addCompare" data-key="${key}" data-type="${type}" |
| style="margin-top:8px;padding:6px 10px;border:none;border-radius:6px; |
| background:var(--brand-blue);color:#fff;cursor:pointer"> |
| + Thêm so sánh</button>`; |
| ly.bindPopup(html); |
| const labelText = type === 'old' |
| ? (p["Tỉnh thành cũ"] || p["Tỉnh thành mới"]) |
| : (p["Tỉnh thành mới"] || p["Tỉnh thành cũ"]); |
| let labelPosition = null; |
| const featureGeometry = f.geometry; |
| if (labelText && labelOverrides[labelText]) { |
| labelPosition = labelOverrides[labelText]; |
| } |
| else if (labelText && featureGeometry) { |
| let labelPolygonFeature = null; |
| if (featureGeometry.type === 'Polygon') { |
| labelPolygonFeature = turf.polygon(featureGeometry.coordinates); |
| } else if (featureGeometry.type === 'MultiPolygon') { |
| let largestArea = 0; |
| featureGeometry.coordinates.forEach(polygonCoords => { |
| const poly = turf.polygon(polygonCoords); |
| const area = turf.area(poly); |
| if (area > largestArea) { |
| largestArea = area; |
| labelPolygonFeature = poly; |
| } |
| }); |
| } |
| if (labelPolygonFeature) { |
| try { |
| const pointOnSurface = turf.pointOnFeature(labelPolygonFeature); |
| if (pointOnSurface && pointOnSurface.geometry && pointOnSurface.geometry.coordinates) { |
| labelPosition = [pointOnSurface.geometry.coordinates[1], pointOnSurface.geometry.coordinates[0]]; |
| } |
| } catch (e) { |
| const bounds = ly.getBounds(); |
| if (bounds.isValid()) { |
| labelPosition = bounds.getCenter(); |
| } |
| } |
| } |
| } |
| const tooltipOptions = { |
| permanent: true, |
| direction: 'center', |
| className: 'province-label', |
| pane: 'tooltipPane' |
| }; |
| if (labelPosition) { |
| const customTooltip = L.tooltip(tooltipOptions) |
| .setLatLng(labelPosition) |
| .setContent(labelText); |
| ly.bindTooltip(customTooltip); |
| } else if (labelText) { |
| ly.bindTooltip(labelText, tooltipOptions); |
| } |
| ly.on('mouseover', e => e.target.setStyle({ fillOpacity: 0.88, weight: 3.3 })); |
| ly.on('mouseout', e => e.target.setStyle({ fillOpacity: 0.73, weight: 2.7 })); |
| } |
| }).addTo(layer); |
| } |
| function updateCompare(){ |
| const panel=document.getElementById('comparePanel'); |
| const tgt=document.getElementById('compareTable'); |
| if(!compareList.length){panel.style.display='none';return;} |
| panel.style.display='block'; |
| panel.querySelector('h3').innerHTML= |
| `<i class="fas fa-balance-scale"></i> So sánh (${compareList.length}/5)`; |
| let html='<table style="width:100%;border-collapse:collapse"><tr><th>Thuộc tính</th>'; |
| compareList.forEach(c=>{ |
| const nm=c.props["Tỉnh thành mới"]||c.props["Tỉnh thành cũ"]; |
| html+=`<th>${nm}<br><span style="font-size:11px;color:#666">(${c.layer==='old'?'Cũ':'Mới'})</span> |
| <span class="compare-remove" data-k="${c.key}">×</span></th>`; |
| }); |
| html+='</tr>'; |
| ATTRS.forEach(a=>{ |
| html+=`<tr><td><strong>${a}</strong></td>`; |
| compareList.forEach(c=>{ |
| let v=c.props[a]??'--'; |
| if(numAttrs.includes(a)) v=numFmt(v); |
| html+=`<td>${v}</td>`; |
| }); |
| html+='</tr>'; |
| }); |
| html+='</table>'; |
| tgt.innerHTML=html; |
| tgt.querySelectorAll('.compare-remove').forEach(x=>{ |
| x.onclick=()=>{compareList=compareList.filter(c=>c.key!==x.dataset.k);updateCompare();}; |
| }); |
| } |
| function attachCompare(){ |
| map.on('popupopen',e=>{ |
| const btn=e.popup._contentNode.querySelector('.addCompare'); |
| if(!btn) return; |
| btn.onclick=()=>{ |
| const props=e.popup._source.feature.properties; |
| const key=btn.dataset.key, layerType=btn.dataset.type; |
| if(compareList.find(c=>c.key===key)) return; |
| if(compareList.length>=5){alert('Tối đa 5 mục so sánh.');return;} |
| compareList.push({key,props,layer:layerType}); |
| updateCompare(); |
| }; |
| }); |
| } |
| function updateLabels() { |
| const zoom = map.getZoom(); |
| map.eachLayer(layer => { |
| if (layer.getTooltip && layer.getTooltip()) { |
| const tooltip = layer.getTooltip(); |
| if (tooltip.options.className && tooltip.options.className.includes('province-label')) { |
| const el = tooltip.getElement(); |
| if (el) { |
| if (zoom < 7) {el.style.display = 'none';} |
| else {el.style.display = 'block'; el.style.fontSize = Math.min(Math.max(zoom * 1.5, 12), 22) + 'px';} |
| } |
| } |
| } |
| }); |
| } |
| document.addEventListener('DOMContentLoaded',()=>{ |
| init(); attachCompare(); |
| map && map.whenReady(() => { updateLabels(); }); |
| map && map.on('zoomend', updateLabels); |
| map && map.on('layeradd layerremove', updateLabels); |
| const toggleBtn = document.getElementById('toggleCompare'); |
| const comparePanel = document.getElementById('comparePanel'); |
| toggleBtn.addEventListener('click', () => { |
| const isHidden = comparePanel.style.display === 'none' || comparePanel.style.display === ''; |
| if (isHidden) { |
| comparePanel.style.display = 'block'; |
| toggleBtn.innerHTML = '<i class="fas fa-eye-slash"></i> Ẩn So sánh'; |
| } else { |
| comparePanel.style.display = 'none'; |
| toggleBtn.innerHTML = '<i class="fas fa-eye"></i> Hiện So sánh'; |
| } |
| }); |
| }); |
| </script> |
| </body> |
| </html> |
|
|