|
<!DOCTYPE html> |
|
<html lang="en"> |
|
|
|
<head> |
|
<meta charset='utf-8'> |
|
<title>誰還沒簽?</title> |
|
|
|
<meta property="og:description" content="門牌分布地圖" /> |
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
|
|
|
<link rel='stylesheet' href='https://unpkg.com/[email protected]/dist/maplibre-gl.css' /> |
|
|
|
<script src='https://unpkg.com/[email protected]/dist/maplibre-gl.js'></script> |
|
<script src="https://unpkg.com/[email protected]/dist/pmtiles.js"></script> |
|
<style> |
|
body { |
|
margin: 0; |
|
padding: 0; |
|
display: flex; |
|
} |
|
|
|
html, |
|
body, |
|
#map { |
|
flex: 70%; |
|
height: 100%; |
|
} |
|
.maplibregl-popup-content { |
|
font-size: 1.5em; |
|
width: fit-content; |
|
min-width: 15em; |
|
max-height: 70vh; |
|
overflow: scroll; |
|
} |
|
pre { |
|
display: none; |
|
} |
|
</style> |
|
</head> |
|
|
|
<!-- |
|
--> |
|
<body> |
|
<div id="map"></div> |
|
<script> |
|
// PMTiles |
|
let protocol = new pmtiles.Protocol(); |
|
maplibregl.addProtocol("pmtiles", protocol.tile); |
|
const p = new pmtiles.PMTiles("https://4ba.tw/addr.pmtiles"); |
|
protocol.add(p); |
|
|
|
const map = new maplibregl.Map({ |
|
container: 'map', |
|
style: 'https://4ba.tw/style.json', |
|
center: [121, 24], |
|
zoom: 8, |
|
hash: true, |
|
attributionControl: false |
|
}) |
|
map.addControl(new maplibregl.AttributionControl(), 'top-left') |
|
map.addControl(new maplibregl.NavigationControl({ visualizePitch: true })); |
|
|
|
map.on('load', () => { |
|
const circleLayer = { |
|
type: "circle", |
|
layout: {}, |
|
paint: { |
|
"circle-radius": [ |
|
"interpolate", ["linear"], ["zoom"], |
|
10, 1, |
|
18, 5 |
|
], |
|
"circle-color": "gray", |
|
"circle-stroke-width": [ |
|
"interpolate", ["linear"], ["zoom"], |
|
8, 0, |
|
13, 1 |
|
], |
|
"circle-stroke-color": "white" |
|
}, |
|
} |
|
map.addSource('address', { |
|
type: 'vector', |
|
url: "pmtiles://https://4ba.tw/addr.pmtiles", |
|
}) |
|
const addressLayer = JSON.parse(JSON.stringify(circleLayer)) |
|
addressLayer.id = 'address' |
|
addressLayer.source = 'address' |
|
addressLayer['source-layer'] = 'address' |
|
map.addLayer(addressLayer, 'road_label') |
|
|
|
map.addSource('highlight', { |
|
type: 'geojson', |
|
data: { type: "FeatureCollection", features: [] }, |
|
}) |
|
const highlightLayer = Object.create(circleLayer) |
|
highlightLayer.id = 'highlight' |
|
highlightLayer.source = 'highlight' |
|
highlightLayer.paint['circle-color'] = ['match', ['get', 'selected'], 'true', 'red', 'blue'] |
|
highlightLayer.paint['circle-radius'] = ['match', ['get', 'selected'], 'true', 8, 5] |
|
highlightLayer.paint["circle-stroke-width"] = 2 |
|
highlightLayer.layout['circle-sort-key'] = ['match', ['get', 'selected'], 'true', 1, 0] |
|
map.addLayer(highlightLayer, 'road_label') |
|
}) |
|
|
|
// Popup for info |
|
const popup = new maplibregl.Popup({ |
|
closeButton: false, |
|
offset: { top: [0, 20], left: [20, 0], bottom: [0, -20], right: [-20, 0] }, |
|
}) |
|
map.on('click', e => { |
|
const features = map.queryRenderedFeatures([ |
|
[e.point.x - 5, e. point.y - 5], |
|
[e.point.x + 5, e. point.y + 5] |
|
]) |
|
console.log('Rendered features', features) |
|
const housenumber = features.find(f => f.source == "address") |
|
|
|
if (housenumber) { |
|
popup.remove() |
|
popup.addTo(map) |
|
popup.setLngLat(e.lngLat) |
|
|
|
const neighbors = map.querySourceFeatures('address', { |
|
sourceLayer: "address", |
|
filter: [ |
|
"all", |
|
[ '==', '縣市', housenumber.properties['縣市'] ], |
|
[ '==', '鄉鎮市區', housenumber.properties['鄉鎮市區'] ], |
|
[ '==', '村里', housenumber.properties['村里'] ], |
|
[ '==', '鄰', housenumber.properties['鄰'] ], |
|
] |
|
}) |
|
const selected = neighbors.find(f => |
|
f.properties['巷'] === housenumber.properties['巷'] && |
|
f.properties['弄'] === housenumber.properties['弄'] && |
|
f.properties['號'] === housenumber.properties['號'] |
|
) |
|
selected.properties.selected = 'true' |
|
popup.setHTML(getHTML(neighbors)) |
|
map.getSource('highlight').setData({type: 'FeatureCollection', features: neighbors}) |
|
console.log('query', neighbors) |
|
popup.on('close', () => { |
|
map.getSource('highlight').setData({type: 'FeatureCollection', features: []}) |
|
}); |
|
} |
|
}) |
|
|
|
// Show info dialog |
|
function getHTML(features) { |
|
const selected = features.find(f => f.properties.selected == 'true') |
|
const props = selected.properties |
|
var info = '' |
|
|
|
// Append field of Google Map Link |
|
let lat = selected.geometry.coordinates[1]; |
|
let lon = selected.geometry.coordinates[0]; |
|
let googleMapLink = `https://www.google.com/maps/@${lat},${lon},${map.getZoom() + 2}z` |
|
info += `` |
|
|
|
// Header |
|
info += `<h3 style="text-aligh: center;">${props['縣市']}${props['鄉鎮市區']}${props['村里']}${props['鄰']}鄰</h2>` |
|
info += `<div style="display: flex; flex-direction: row; margin-bottom: 1em;"> |
|
<h4 style="margin: 0;">${features.length}個門牌</h4> |
|
<div style="flex-grow: 1; text-align: end;"><a style="text-align: end" href="${googleMapLink}">Google 地圖</a></div> |
|
</div>` |
|
|
|
// Address in same LV.5 administrative |
|
info += `<fieldset style="padding: 0; border-color: transparent;">` |
|
features.forEach((f, index) => { |
|
const props = f.properties |
|
const emphasize = props.selected == 'true' ? 'color: red; font-weight: bold;' : '' |
|
const housenumber = `${props['巷']}${props['弄']}${props['號']}` |
|
const id = 'h_' + index |
|
info += `<div style="white-space: nowrap; ${emphasize}"><input id="${id}" type="checkbox"/><label for="${id}">${housenumber}</label></div>` |
|
}) |
|
info += `</fieldset>` |
|
|
|
return info |
|
} |
|
|
|
</script> |
|
</body> |
|
|
|
</html> |
TO ALL
這份腳本使用 Makefile,來正規化罷免選區所在的11個縣市政府的門牌資料
目前共有 10750個村里 698萬筆門牌
(尚有
南投縣
、連江縣
、花蓮縣
和基隆市
未取得資料集,已要求 puma 委員索取,FYI @audreyt )正規化後的資料可使用指令
make addr address.misc
窮舉出所有地址,製作多級目錄和用於描述當前目錄的index.json
將之作為伺服器的靜態資源即可完成門牌查詢API
目前成果由我的 VPS 提供API服務: https://4ba.tw/address/
亦可使用指令
wget --recursive https://4ba.tw/address
來鏡像所有資料我製作了一個示範頁面,用於展示如何將 API 用於快速登打連署人地址