feat(atlas): sub-national region view when zooming in

- Zoom >= 5 shows visited regions (states/provinces/departments) colored on the map
- Server resolves places to regions via Nominatim reverse geocoding (zoom=8)
- Supports all ISO levels: lvl4 (states), lvl5 (provinces), lvl6 (departments)
- Handles city-states (Berlin, Vienna, Hamburg) via city/county fallback
- Fuzzy name matching between Nominatim and GeoJSON for cross-format compatibility
- 10m admin_1 GeoJSON loaded server-side (cached), filtered per country
- Region colors match their parent country color
- Custom DOM tooltip (ref-based, no re-renders on hover)
- Country layer dims to 35% opacity when regions visible
- place_regions DB table caches resolved regions permanently
- Rate-limited Nominatim calls (1 req/sec) with progressive resolution
This commit is contained in:
mauriceboe
2026-04-05 01:31:19 +02:00
parent b8058a2755
commit 16cadeb09e
4 changed files with 313 additions and 3 deletions

View File

@@ -154,6 +154,12 @@ export default function AtlasPage(): React.ReactElement {
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
const [visitedRegions, setVisitedRegions] = useState<Record<string, { code: string; name: string; placeCount: number }[]>>({})
const regionLayerRef = useRef<L.GeoJSON | null>(null)
const regionGeoCache = useRef<Record<string, GeoJsonFeatureCollection>>({})
const [showRegions, setShowRegions] = useState(false)
const [regionGeoLoaded, setRegionGeoLoaded] = useState(0)
const regionTooltipRef = useRef<HTMLDivElement>(null)
const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket'; code: string; name: string } | null>(null)
const [bucketMonth, setBucketMonth] = useState(0)
const [bucketYear, setBucketYear] = useState(0)
@@ -221,6 +227,33 @@ export default function AtlasPage(): React.ReactElement {
.catch(() => {})
}, [])
// Load visited regions from server + per-country GeoJSON (once on mount)
const regionsLoadedRef = useRef(false)
useEffect(() => {
if (regionsLoadedRef.current) return
regionsLoadedRef.current = true
apiClient.get(`/addons/atlas/regions?_t=${Date.now()}`)
.then(r => {
const regions = r.data?.regions || {}
setVisitedRegions(regions)
const countries = Object.keys(regions)
if (countries.length === 0) return
apiClient.get(`/addons/atlas/regions/geo?countries=${countries.join(',')}`)
.then(geoRes => {
const geo = geoRes.data
if (geo?.features) {
for (const c of countries) {
const features = geo.features.filter((f: any) => f.properties?.iso_a2?.toUpperCase() === c)
regionGeoCache.current[c] = { type: 'FeatureCollection', features }
}
setRegionGeoLoaded(v => v + 1)
}
})
.catch(() => {})
})
.catch(() => {})
}, [])
// Initialize map — runs after loading is done and mapRef is available
useEffect(() => {
if (loading || !mapRef.current) return
@@ -264,7 +297,26 @@ export default function AtlasPage(): React.ReactElement {
crossOrigin: true,
}).addTo(map)
// Custom pane for region layer — below overlay (z-index 400) but above tiles
map.createPane('regionPane')
map.getPane('regionPane')!.style.zIndex = '399'
mapInstance.current = map
// Zoom-based region switching — dim country layer directly
map.on('zoomend', () => {
const z = map.getZoom()
const shouldShow = z >= 5
setShowRegions(shouldShow)
const overlayPane = map.getPane('overlayPane')
if (overlayPane) overlayPane.style.opacity = shouldShow ? '0.35' : '1'
const regionPane = map.getPane('regionPane')
if (regionPane) {
regionPane.style.opacity = shouldShow ? '1' : '0'
regionPane.style.pointerEvents = shouldShow ? 'auto' : 'none'
}
})
return () => { map.remove(); mapInstance.current = null }
}, [dark, loading])
@@ -339,10 +391,7 @@ export default function AtlasPage(): React.ReactElement {
})
layer.on('click', () => {
if (c.placeCount === 0 && c.tripCount === 0) {
// Manually marked only — show unmark popup
handleUnmarkCountry(c.code)
} else {
loadCountryDetail(c.code)
}
})
layer.on('mouseover', (e) => {
@@ -379,6 +428,108 @@ export default function AtlasPage(): React.ReactElement {
mapInstance.current.setView(currentCenter, currentZoom, { animate: false })
}, [geoData, data, dark])
// Render sub-national region layer (zoom >= 5)
useEffect(() => {
if (!mapInstance.current) return
// Remove existing region layer
if (regionLayerRef.current) {
mapInstance.current.removeLayer(regionLayerRef.current)
regionLayerRef.current = null
}
if (Object.keys(regionGeoCache.current).length === 0) return
// Build set of visited region codes first
const visitedRegionCodes = new Set<string>()
const visitedRegionNames = new Set<string>()
const regionPlaceCounts: Record<string, number> = {}
for (const [, regions] of Object.entries(visitedRegions)) {
for (const r of regions) {
visitedRegionCodes.add(r.code)
visitedRegionNames.add(r.name.toLowerCase())
regionPlaceCounts[r.code] = r.placeCount
regionPlaceCounts[r.name.toLowerCase()] = r.placeCount
}
}
// Match feature by ISO code OR region name
const isVisitedFeature = (f: any) => {
if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true
const name = (f.properties?.name || '').toLowerCase()
if (visitedRegionNames.has(name)) return true
// Fuzzy: check if any visited name is contained in feature name or vice versa
for (const vn of visitedRegionNames) {
if (name.includes(vn) || vn.includes(name)) return true
}
return false
}
// Only include visited region features (non-visited pass through to country layer)
const allFeatures: any[] = []
for (const geo of Object.values(regionGeoCache.current)) {
for (const f of geo.features) {
if (isVisitedFeature(f)) {
allFeatures.push(f)
}
}
}
if (allFeatures.length === 0) return
// Use same colors as country layer
const VISITED_COLORS = ['#6366f1','#ec4899','#14b8a6','#f97316','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#e11d48','#0d9488','#7c3aed','#2563eb','#dc2626','#059669','#d946ef']
const countryA3Set = data ? data.countries.map(c => A2_TO_A3[c.code]).filter(Boolean) : []
const countryColorMap: Record<string, string> = {}
countryA3Set.forEach((a3, i) => { countryColorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] })
// Map country A2 code to country color
const a2ColorMap: Record<string, string> = {}
if (data) data.countries.forEach(c => { if (A2_TO_A3[c.code] && countryColorMap[A2_TO_A3[c.code]]) a2ColorMap[c.code] = countryColorMap[A2_TO_A3[c.code]] })
const mergedGeo = { type: 'FeatureCollection', features: allFeatures }
const svgRenderer = L.svg({ pane: 'regionPane' })
regionLayerRef.current = L.geoJSON(mergedGeo as any, {
renderer: svgRenderer,
interactive: true,
pane: 'regionPane',
style: (feature) => {
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
return {
fillColor: a2ColorMap[countryA2] || '#6366f1',
fillOpacity: 0.85,
color: dark ? '#888' : '#64748b',
weight: 1.2,
}
},
onEachFeature: (feature, layer) => {
const regionName = feature?.properties?.name || ''
const countryName = feature?.properties?.admin || ''
const regionCode = feature?.properties?.iso_3166_2 || ''
const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || 0
layer.on('mouseover', (e: any) => {
e.target.setStyle({ fillOpacity: 0.95, weight: 2, color: dark ? '#818cf8' : '#4f46e5' })
const tt = regionTooltipRef.current
if (tt) {
tt.style.display = 'block'
tt.style.left = e.originalEvent.clientX + 12 + 'px'
tt.style.top = e.originalEvent.clientY - 10 + 'px'
tt.innerHTML = `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div><div style="margin-top:5px;font-size:11px"><b>${count}</b> ${count === 1 ? 'place' : 'places'}</div>`
}
})
layer.on('mousemove', (e: any) => {
const tt = regionTooltipRef.current
if (tt) { tt.style.left = e.originalEvent.clientX + 12 + 'px'; tt.style.top = e.originalEvent.clientY - 10 + 'px' }
})
layer.on('mouseout', (e: any) => {
regionLayerRef.current?.resetStyle(e.target)
const tt = regionTooltipRef.current
if (tt) tt.style.display = 'none'
})
},
}).addTo(mapInstance.current)
}, [regionGeoLoaded, visitedRegions, dark, t])
const handleMarkCountry = (code: string, name: string): void => {
setConfirmAction({ type: 'choose', code, name })
}
@@ -533,6 +684,18 @@ export default function AtlasPage(): React.ReactElement {
<div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0 }}>
{/* Map */}
<div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} />
{/* Region tooltip (custom, always on top, ref-controlled to avoid re-renders) */}
<div ref={regionTooltipRef} style={{
position: 'fixed', display: 'none',
zIndex: 9999, pointerEvents: 'none',
background: dark ? 'rgba(15,15,20,0.92)' : 'rgba(255,255,255,0.96)',
color: dark ? '#fff' : '#111',
borderRadius: 10, padding: '10px 14px',
boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'}`,
fontSize: 12, minWidth: 120,
}} />
<div
className="absolute z-20 flex justify-center"
style={{ top: 14, left: 0, right: 0, pointerEvents: 'none' }}

View File

@@ -550,6 +550,19 @@ function runMigrations(db: Database.Database): void {
);
`);
},
// Migration 69: Place region cache for sub-national Atlas regions
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS place_regions (
place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE,
country_code TEXT NOT NULL,
region_code TEXT NOT NULL,
region_name TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_place_regions_country ON place_regions(country_code);
CREATE INDEX IF NOT EXISTS idx_place_regions_region ON place_regions(region_code);
`);
},
];
if (currentVersion < migrations.length) {

View File

@@ -6,6 +6,8 @@ import {
getCountryPlaces,
markCountryVisited,
unmarkCountryVisited,
getVisitedRegions,
getRegionGeo,
listBucketList,
createBucketItem,
updateBucketItem,
@@ -21,6 +23,21 @@ router.get('/stats', async (req: Request, res: Response) => {
res.json(data);
});
router.get('/regions', async (req: Request, res: Response) => {
const userId = (req as AuthRequest).user.id;
res.setHeader('Cache-Control', 'no-cache, no-store');
const data = await getVisitedRegions(userId);
res.json(data);
});
router.get('/regions/geo', async (req: Request, res: Response) => {
const countries = (req.query.countries as string || '').split(',').filter(Boolean);
if (countries.length === 0) return res.json({ type: 'FeatureCollection', features: [] });
const geo = await getRegionGeo(countries);
res.setHeader('Cache-Control', 'public, max-age=86400');
res.json(geo);
});
router.get('/country/:code', (req: Request, res: Response) => {
const userId = (req as AuthRequest).user.id;
const code = req.params.code.toUpperCase();

View File

@@ -2,6 +2,38 @@ import fetch from 'node-fetch';
import { db } from '../db/database';
import { Trip, Place } from '../types';
// ── Admin-1 GeoJSON cache (sub-national regions) ─────────────────────────
let admin1GeoCache: any = null;
let admin1GeoLoading: Promise<any> | null = null;
async function loadAdmin1Geo(): Promise<any> {
if (admin1GeoCache) return admin1GeoCache;
if (admin1GeoLoading) return admin1GeoLoading;
admin1GeoLoading = fetch(
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_1_states_provinces.geojson',
{ headers: { 'User-Agent': 'TREK Travel Planner' } }
).then(r => r.json()).then(geo => {
admin1GeoCache = geo;
admin1GeoLoading = null;
console.log(`[Atlas] Cached admin-1 GeoJSON: ${geo.features?.length || 0} features`);
return geo;
}).catch(err => {
admin1GeoLoading = null;
console.error('[Atlas] Failed to load admin-1 GeoJSON:', err);
return null;
});
return admin1GeoLoading;
}
export async function getRegionGeo(countryCodes: string[]): Promise<any> {
const geo = await loadAdmin1Geo();
if (!geo) return { type: 'FeatureCollection', features: [] };
const codes = new Set(countryCodes.map(c => c.toUpperCase()));
const features = geo.features.filter((f: any) => codes.has(f.properties?.iso_a2?.toUpperCase()));
return { type: 'FeatureCollection', features };
}
// ── Geocode cache ───────────────────────────────────────────────────────────
const geocodeCache = new Map<string, string | null>();
@@ -341,6 +373,91 @@ export function unmarkCountryVisited(userId: number, code: string): void {
db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, code);
}
// ── Sub-national region resolution ────────────────────────────────────────
interface RegionInfo { country_code: string; region_code: string; region_name: string }
const regionCache = new Map<string, RegionInfo | null>();
async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInfo | null> {
const key = roundKey(lat, lng);
if (regionCache.has(key)) return regionCache.get(key)!;
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=8&accept-language=en`,
{ headers: { 'User-Agent': 'TREK Travel Planner' } }
);
if (!res.ok) return null;
const data = await res.json() as { address?: Record<string, string> };
const countryCode = data.address?.country_code?.toUpperCase() || null;
// Try finest ISO level first (lvl6 = departments/provinces), then lvl5, then lvl4 (states/regions)
let regionCode = data.address?.['ISO3166-2-lvl6'] || data.address?.['ISO3166-2-lvl5'] || data.address?.['ISO3166-2-lvl4'] || null;
// Normalize: FR-75C → FR-75 (strip trailing letter suffixes for GeoJSON compatibility)
if (regionCode && /^[A-Z]{2}-\d+[A-Z]$/i.test(regionCode)) {
regionCode = regionCode.replace(/[A-Z]$/i, '');
}
const regionName = data.address?.county || data.address?.state || data.address?.province || data.address?.region || data.address?.city || null;
if (!countryCode || !regionName) { regionCache.set(key, null); return null; }
const info: RegionInfo = {
country_code: countryCode,
region_code: regionCode || `${countryCode}-${regionName.substring(0, 3).toUpperCase()}`,
region_name: regionName,
};
regionCache.set(key, info);
return info;
} catch {
return null;
}
}
export async function getVisitedRegions(userId: number): Promise<{ regions: Record<string, { code: string; name: string; placeCount: number }[]> }> {
const trips = getUserTrips(userId);
const tripIds = trips.map(t => t.id);
const places = getPlacesForTrips(tripIds);
// Check DB cache first
const placeIds = places.filter(p => p.lat && p.lng).map(p => p.id);
const cached = placeIds.length > 0
? db.prepare(`SELECT * FROM place_regions WHERE place_id IN (${placeIds.map(() => '?').join(',')})`).all(...placeIds) as { place_id: number; country_code: string; region_code: string; region_name: string }[]
: [];
const cachedMap = new Map(cached.map(c => [c.place_id, c]));
// Resolve uncached places (rate-limited to avoid hammering Nominatim)
const uncached = places.filter(p => p.lat && p.lng && !cachedMap.has(p.id));
const insertStmt = db.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)');
for (const place of uncached) {
const info = await reverseGeocodeRegion(place.lat!, place.lng!);
if (info) {
insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
cachedMap.set(place.id, { place_id: place.id, ...info });
}
// Nominatim rate limit: 1 req/sec
if (uncached.indexOf(place) < uncached.length - 1) {
await new Promise(r => setTimeout(r, 1100));
}
}
// Group by country → regions with place counts
const regionMap: Record<string, Map<string, { code: string; name: string; placeCount: number }>> = {};
for (const [, entry] of cachedMap) {
if (!regionMap[entry.country_code]) regionMap[entry.country_code] = new Map();
const existing = regionMap[entry.country_code].get(entry.region_code);
if (existing) {
existing.placeCount++;
} else {
regionMap[entry.country_code].set(entry.region_code, { code: entry.region_code, name: entry.region_name, placeCount: 1 });
}
}
const result: Record<string, { code: string; name: string; placeCount: number }[]> = {};
for (const [country, regions] of Object.entries(regionMap)) {
result[country] = [...regions.values()];
}
return { regions: result };
}
// ── Bucket list CRUD ────────────────────────────────────────────────────────
export function listBucketList(userId: number) {