// metro-maps.jsx — 日本地鐵圖(Leaflet 互動地圖)
// ── MetroMapViewer ────────────────────────────────────────────
// Uses refs + direct DOM manipulation for 60fps smooth zoom/pan.
// Image is rendered at natural pixel dimensions; initial scale fits screen.
// Zooming in approaches 1:1 = full resolution (no blurriness).
function MetroMapViewer({ map, onClose }) {
const imgRef = React.useRef(null);
const containerRef = React.useRef(null);
const st = React.useRef({
scale: 1, tx: 0, ty: 0,
lastDist: null, startScale: 1,
startTouchX: 0, startTouchY: 0, startTx: 0, startTy: 0,
pinchCx: 0, pinchCy: 0,
naturalW: 0, naturalH: 0,
});
// transformOrigin: '0 0' — image top-left is the anchor.
// State (tx, ty) = image top-left position in container space.
// Point at image pixel (px, py) → container pos: (tx + px*scale, ty + py*scale)
// Pinch-to-point math:
// After scale change s1→s2, container point (cx,cy) stays fixed:
// tx_new = cx - (cx - tx) * (s2/s1)
// ty_new = cy - (cy - ty) * (s2/s1)
function applyTransform() {
if (!imgRef.current) return;
const { scale, tx, ty } = st.current;
imgRef.current.style.transform = `translate(${tx}px,${ty}px) scale(${scale})`;
}
function fitScale() {
const c = containerRef.current;
const s = st.current;
if (!c || !s.naturalW) return 1;
return Math.min(c.clientWidth / s.naturalW, c.clientHeight / s.naturalH);
}
function resetToFit() {
const c = containerRef.current;
const s = st.current;
if (!c || !s.naturalW) return;
const fs = fitScale();
s.scale = fs;
// Center the image in the container
s.tx = (c.clientWidth - s.naturalW * fs) / 2;
s.ty = (c.clientHeight - s.naturalH * fs) / 2;
applyTransform();
}
function onImgLoad(e) {
const s = st.current;
s.naturalW = e.target.naturalWidth;
s.naturalH = e.target.naturalHeight;
e.target.style.width = s.naturalW + 'px';
e.target.style.height = s.naturalH + 'px';
resetToFit();
}
function getTouchDist(e) {
if (e.touches.length < 2) return null;
return Math.hypot(e.touches[0].clientX-e.touches[1].clientX, e.touches[0].clientY-e.touches[1].clientY);
}
function onTS(e) {
const s = st.current;
if (e.touches.length >= 2) {
s.lastDist = getTouchDist(e);
} else {
s.lastDist = null;
s.startTouchX = e.touches[0].clientX;
s.startTouchY = e.touches[0].clientY;
s.startTx = s.tx; s.startTy = s.ty;
}
}
function onTM(e) {
e.preventDefault();
const s = st.current;
if (e.touches.length === 2 && s.lastDist) {
const newDist = getTouchDist(e);
const scaleRatio = newDist / s.lastDist;
const fs = fitScale();
const newScale = Math.min(4.0, Math.max(fs * 0.9, s.scale * scaleRatio));
const actualRatio = newScale / s.scale;
// Current pinch midpoint in container space (recalculated each frame)
const rect = containerRef.current.getBoundingClientRect();
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
// Zoom toward current midpoint (incremental)
s.tx = midX - (midX - s.tx) * actualRatio;
s.ty = midY - (midY - s.ty) * actualRatio;
s.scale = newScale;
s.lastDist = newDist; // ← update each frame (incremental, not absolute)
applyTransform();
} else if (e.touches.length === 1 && !s.lastDist) {
s.tx = s.startTx + (e.touches[0].clientX - s.startTouchX);
s.ty = s.startTy + (e.touches[0].clientY - s.startTouchY);
applyTransform();
}
}
function onTE(e) {
if (e.touches.length < 2) {
st.current.lastDist = null;
// Update pan start pos if still one finger on screen
if (e.touches.length === 1) {
const s = st.current;
s.startTouchX = e.touches[0].clientX;
s.startTouchY = e.touches[0].clientY;
s.startTx = s.tx; s.startTy = s.ty;
}
}
}
// Attach passive:false listener so preventDefault works on mobile
React.useEffect(() => {
const el = containerRef.current;
if (!el) return;
const opts = { passive: false };
el.addEventListener('touchmove', onTM, opts);
return () => el.removeEventListener('touchmove', onTM, opts);
}, []);
return ReactDOM.createPortal(
{/* Header */}
{map.emoji}
{map.city_name}
{/* Map viewer */}
雙指縮放・單指拖動・點「重設」回復
,
document.body
);
}
// ── MetroMapsScreen ───────────────────────────────────────────
function MetroMapsScreen({ go }) {
const [maps, setMaps] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [viewing, setViewing] = React.useState(null);
const [search, setSearch] = React.useState('');
const [leafletReady, setLeafletReady] = React.useState(false);
const mapDivRef = React.useRef(null);
const leafletMap = React.useRef(null);
const markers = React.useRef([]);
// Load maps data
React.useEffect(() => {
fetch('/api/metro-maps', { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
.then(r => r.ok ? r.json() : [])
.then(d => { setMaps(Array.isArray(d) ? d : []); setLoading(false); })
.catch(() => setLoading(false));
}, []);
// Load Leaflet (hosted locally)
React.useEffect(() => {
if (window.L) { setLeafletReady(true); return; }
const link = document.createElement('link');
link.rel = 'stylesheet'; link.href = '/vendor/leaflet.css';
document.head.appendChild(link);
const script = document.createElement('script');
script.src = '/vendor/leaflet.min.js';
script.onload = () => setLeafletReady(true);
document.body.appendChild(script);
}, []);
// Init / update map when ready + maps loaded
React.useEffect(() => {
if (!leafletReady || !mapDivRef.current || maps.length === 0) return;
const L = window.L;
if (!leafletMap.current) {
const m = L.map(mapDivRef.current, {
center: [37.5, 137.5], zoom: 5,
zoomControl: false, // ← remove +/- from top-left
attributionControl: false,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
}).addTo(m);
// No zoom control on mobile — pinch to zoom is natural
leafletMap.current = m;
}
// Clear old markers
markers.current.forEach(mk => mk.remove());
markers.current = [];
// Add city markers — small clean pin style
const filtered = search ? maps.filter(m => m.city_name.includes(search) || m.city_key.includes(search.toLowerCase())) : maps;
filtered.forEach(city => {
if (!city.lat || !city.lng) return;
const icon = L.divIcon({
className: '',
html: `${city.emoji}
`,
iconSize: [36, 36],
iconAnchor: [18, 18],
tooltipAnchor: [18, -20],
});
const mk = L.marker([city.lat, city.lng], { icon })
.addTo(leafletMap.current)
.bindTooltip(city.city_name, {
permanent: false, direction: 'top', offset: [0, -4],
className: 'metro-tooltip',
})
.on('click', () => setViewing(city));
markers.current.push(mk);
});
// If search match, fly to first result
if (search && filtered.length > 0 && filtered[0].lat) {
leafletMap.current.flyTo([filtered[0].lat, filtered[0].lng], 11, { duration: 0.8 });
}
}, [leafletReady, maps, search]);
// Cleanup on unmount
React.useEffect(() => () => {
if (leafletMap.current) {
leafletMap.current.remove();
leafletMap.current = null;
}
}, []);
return (
{/* ① 頁頭:返回 + 標題 */}
{/* ② 搜尋欄 */}
🔍
setSearch(e.target.value)}
placeholder="搜尋城市,例:東京、大阪"
style={{
width:'100%', padding:'9px 12px 9px 34px', borderRadius:10, border:`1px solid ${TABI_COLORS.line}`,
background:TABI_COLORS.bg, fontSize:16, color:TABI_COLORS.ink,
outline:'none', fontFamily:'inherit', boxSizing:'border-box',
}} />
{search && (
)}
{/* ③ 城市快捷列 */}
{!loading && maps.length > 0 && (
{maps.map(m => (
))}
)}
{/* Leaflet tooltip CSS */}
{/* ④ 地圖(fills remaining height) */}
{loading ? (
載入中…
) : maps.length === 0 ? (
) : (
)}
{viewing &&
setViewing(null)} />}
);
}