From 16cadeb09e9a4bf2c42a2aa4d157f836e54160b6 Mon Sep 17 00:00:00 2001 From: mauriceboe Date: Sun, 5 Apr 2026 01:31:19 +0200 Subject: [PATCH] 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 --- client/src/pages/AtlasPage.tsx | 169 +++++++++++++++++++++++++++- server/src/db/migrations.ts | 13 +++ server/src/routes/atlas.ts | 17 +++ server/src/services/atlasService.ts | 117 +++++++++++++++++++ 4 files changed, 313 insertions(+), 3 deletions(-) diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index 8106238..051797b 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -154,6 +154,12 @@ export default function AtlasPage(): React.ReactElement { const [selectedCountry, setSelectedCountry] = useState(null) const [countryDetail, setCountryDetail] = useState(null) const [geoData, setGeoData] = useState(null) + const [visitedRegions, setVisitedRegions] = useState>({}) + const regionLayerRef = useRef(null) + const regionGeoCache = useRef>({}) + const [showRegions, setShowRegions] = useState(false) + const [regionGeoLoaded, setRegionGeoLoaded] = useState(0) + const regionTooltipRef = useRef(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() + const visitedRegionNames = new Set() + const regionPlaceCounts: Record = {} + 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 = {} + countryA3Set.forEach((a3, i) => { countryColorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] }) + // Map country A2 code to country color + const a2ColorMap: Record = {} + 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 = `
${regionName}
${countryName}
${count} ${count === 1 ? 'place' : 'places'}
` + } + }) + 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 {
{/* Map */}
+ + {/* Region tooltip (custom, always on top, ref-controlled to avoid re-renders) */} +
{ + 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) { diff --git a/server/src/routes/atlas.ts b/server/src/routes/atlas.ts index 9929c3d..0b1dc48 100644 --- a/server/src/routes/atlas.ts +++ b/server/src/routes/atlas.ts @@ -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(); diff --git a/server/src/services/atlasService.ts b/server/src/services/atlasService.ts index 11a317b..c423ca9 100644 --- a/server/src/services/atlasService.ts +++ b/server/src/services/atlasService.ts @@ -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 | null = null; + +async function loadAdmin1Geo(): Promise { + 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 { + 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(); @@ -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(); + +async function reverseGeocodeRegion(lat: number, lng: number): Promise { + 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 }; + 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 }> { + 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> = {}; + 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 = {}; + for (const [country, regions] of Object.entries(regionMap)) { + result[country] = [...regions.values()]; + } + + return { regions: result }; +} + // ── Bucket list CRUD ──────────────────────────────────────────────────────── export function listBucketList(userId: number) {