From 16cadeb09e9a4bf2c42a2aa4d157f836e54160b6 Mon Sep 17 00:00:00 2001 From: mauriceboe Date: Sun, 5 Apr 2026 01:31:19 +0200 Subject: [PATCH 1/2] 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) { From 71c1683bb355c5ddd370ce46dedaafbb1d687bb1 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 5 Apr 2026 03:17:33 +0200 Subject: [PATCH 2/2] feat(atlas): mark sub-national regions as visited with cascade behavior - Add visited_regions table migration - Mark/unmark region endpoints with auto-mark parent country - Unmark country cascades to its regions; unmark last region cascades to country - Region modal with mark/unmark flow and bucket list shortcut - Viewport-based lazy loading of region GeoJSON at zoom >= 6 - i18n: add atlas.markRegionVisitedHint and atlas.confirmUnmarkRegion across all 13 locales --- client/src/i18n/translations/ar.ts | 2 + client/src/i18n/translations/br.ts | 2 + client/src/i18n/translations/cs.ts | 2 + client/src/i18n/translations/de.ts | 2 + client/src/i18n/translations/en.ts | 2 + client/src/i18n/translations/es.ts | 2 + client/src/i18n/translations/fr.ts | 2 + client/src/i18n/translations/hu.ts | 2 + client/src/i18n/translations/it.ts | 2 + client/src/i18n/translations/nl.ts | 2 + client/src/i18n/translations/pl.ts | 2 + client/src/i18n/translations/ru.ts | 2 + client/src/i18n/translations/zh.ts | 2 + client/src/pages/AtlasPage.tsx | 253 +++++++++++++++++++++++----- server/src/db/migrations.ts | 14 ++ server/src/routes/atlas.ts | 16 ++ server/src/services/atlasService.ts | 37 +++- 17 files changed, 301 insertions(+), 45 deletions(-) diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index f0b6596..3a357a9 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -690,8 +690,10 @@ const ar: Record = { 'atlas.unmark': 'إزالة', 'atlas.confirmMark': 'تعيين هذا البلد كمُزار؟', 'atlas.confirmUnmark': 'إزالة هذا البلد من قائمة المُزارة؟', + 'atlas.confirmUnmarkRegion': 'إزالة هذه المنطقة من قائمة المُزارة؟', 'atlas.markVisited': 'تعيين كمُزار', 'atlas.markVisitedHint': 'إضافة هذا البلد إلى قائمة المُزارة', + 'atlas.markRegionVisitedHint': 'إضافة هذه المنطقة إلى قائمة المُزارة', 'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات', 'atlas.addPoi': 'إضافة مكان', 'atlas.searchCountry': 'ابحث عن دولة...', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index aad3bc0..76ed4d5 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -672,8 +672,10 @@ const br: Record = { 'atlas.unmark': 'Remover', 'atlas.confirmMark': 'Marcar este país como visitado?', 'atlas.confirmUnmark': 'Remover este país da lista de visitados?', + 'atlas.confirmUnmarkRegion': 'Remover esta região da lista de visitados?', 'atlas.markVisited': 'Marcar como visitado', 'atlas.markVisitedHint': 'Adicionar este país à lista de visitados', + 'atlas.markRegionVisitedHint': 'Adicionar esta região à lista de visitados', 'atlas.addToBucket': 'Adicionar à lista de desejos', 'atlas.addPoi': 'Adicionar lugar', 'atlas.searchCountry': 'Buscar um país...', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 97c2014..970ebb5 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -689,8 +689,10 @@ const cs: Record = { 'atlas.unmark': 'Odebrat', 'atlas.confirmMark': 'Označit tuto zemi jako navštívenou?', 'atlas.confirmUnmark': 'Odebrat tuto zemi ze seznamu navštívených?', + 'atlas.confirmUnmarkRegion': 'Odebrat tento region ze seznamu navštívených?', 'atlas.markVisited': 'Označit jako navštívené', 'atlas.markVisitedHint': 'Přidat tuto zemi do seznamu navštívených', + 'atlas.markRegionVisitedHint': 'Přidat tento region do seznamu navštívených', 'atlas.addToBucket': 'Přidat do seznamu přání (Bucket list)', 'atlas.addPoi': 'Přidat místo', 'atlas.bucketNamePlaceholder': 'Název (země, město, místo...)', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 1185128..23f17b6 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -688,8 +688,10 @@ const de: Record = { 'atlas.unmark': 'Entfernen', 'atlas.confirmMark': 'Dieses Land als besucht markieren?', 'atlas.confirmUnmark': 'Dieses Land von der Liste entfernen?', + 'atlas.confirmUnmarkRegion': 'Diese Region von der Liste entfernen?', 'atlas.markVisited': 'Als besucht markieren', 'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen', + 'atlas.markRegionVisitedHint': 'Diese Region zur besuchten Liste hinzufügen', 'atlas.addToBucket': 'Zur Bucket List', 'atlas.addPoi': 'Ort hinzufügen', 'atlas.searchCountry': 'Land suchen...', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 91077b0..9b19cbc 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -685,8 +685,10 @@ const en: Record = { 'atlas.unmark': 'Remove', 'atlas.confirmMark': 'Mark this country as visited?', 'atlas.confirmUnmark': 'Remove this country from your visited list?', + 'atlas.confirmUnmarkRegion': 'Remove this region from your visited list?', 'atlas.markVisited': 'Mark as visited', 'atlas.markVisitedHint': 'Add this country to your visited list', + 'atlas.markRegionVisitedHint': 'Add this region to your visited list', 'atlas.addToBucket': 'Add to bucket list', 'atlas.addPoi': 'Add place', 'atlas.searchCountry': 'Search a country...', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 3c5ee41..aed723a 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -700,8 +700,10 @@ const es: Record = { 'atlas.unmark': 'Eliminar', 'atlas.confirmMark': '¿Marcar este país como visitado?', 'atlas.confirmUnmark': '¿Eliminar este país de tu lista de visitados?', + 'atlas.confirmUnmarkRegion': '¿Eliminar esta región de tu lista de visitados?', 'atlas.markVisited': 'Marcar como visitado', 'atlas.markVisitedHint': 'Añadir este país a tu lista de visitados', + 'atlas.markRegionVisitedHint': 'Añadir esta región a tu lista de visitados', 'atlas.addToBucket': 'Añadir a lista de deseos', 'atlas.addPoi': 'Añadir lugar', 'atlas.searchCountry': 'Buscar un país...', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 5334882..02781b4 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -723,8 +723,10 @@ const fr: Record = { 'atlas.unmark': 'Retirer', 'atlas.confirmMark': 'Marquer ce pays comme visité ?', 'atlas.confirmUnmark': 'Retirer ce pays de votre liste ?', + 'atlas.confirmUnmarkRegion': 'Retirer cette région de votre liste ?', 'atlas.markVisited': 'Marquer comme visité', 'atlas.markVisitedHint': 'Ajouter ce pays à votre liste de visités', + 'atlas.markRegionVisitedHint': 'Ajouter cette région à votre liste de visités', 'atlas.addToBucket': 'Ajouter à la bucket list', 'atlas.addPoi': 'Ajouter un lieu', 'atlas.searchCountry': 'Rechercher un pays…', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 7017d67..1d3d845 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -688,8 +688,10 @@ const hu: Record = { 'atlas.unmark': 'Eltávolítás', 'atlas.confirmMark': 'Megjelölöd ezt az országot meglátogatottként?', 'atlas.confirmUnmark': 'Eltávolítod ezt az országot a meglátogatottak listájáról?', + 'atlas.confirmUnmarkRegion': 'Eltávolítod ezt a régiót a meglátogatottak listájáról?', 'atlas.markVisited': 'Megjelölés meglátogatottként', 'atlas.markVisitedHint': 'Ország hozzáadása a meglátogatottak listájához', + 'atlas.markRegionVisitedHint': 'Régió hozzáadása a meglátogatottak listájához', 'atlas.addToBucket': 'Hozzáadás a bakancslistához', 'atlas.addPoi': 'Hely hozzáadása', 'atlas.searchCountry': 'Ország keresése...', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index f74456b..b53f5cf 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -688,8 +688,10 @@ const it: Record = { 'atlas.unmark': 'Rimuovi', 'atlas.confirmMark': 'Segnare questo paese come visitato?', 'atlas.confirmUnmark': 'Rimuovere questo paese dalla tua lista dei visitati?', + 'atlas.confirmUnmarkRegion': 'Rimuovere questa regione dalla tua lista dei visitati?', 'atlas.markVisited': 'Segna come visitato', 'atlas.markVisitedHint': 'Aggiungi questo paese alla tua lista dei visitati', + 'atlas.markRegionVisitedHint': 'Aggiungi questa regione alla tua lista dei visitati', 'atlas.addToBucket': 'Aggiungi alla lista desideri', 'atlas.addPoi': 'Aggiungi luogo', 'atlas.bucketNamePlaceholder': 'Nome (paese, città, luogo...)', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 3bcd4e7..7b9333f 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -723,8 +723,10 @@ const nl: Record = { 'atlas.unmark': 'Verwijderen', 'atlas.confirmMark': 'Dit land als bezocht markeren?', 'atlas.confirmUnmark': 'Dit land van je bezochte lijst verwijderen?', + 'atlas.confirmUnmarkRegion': 'Deze regio van je bezochte lijst verwijderen?', 'atlas.markVisited': 'Markeren als bezocht', 'atlas.markVisitedHint': 'Dit land toevoegen aan je bezochte lijst', + 'atlas.markRegionVisitedHint': 'Deze regio toevoegen aan je bezochte lijst', 'atlas.addToBucket': 'Aan bucket list toevoegen', 'atlas.addPoi': 'Plaats toevoegen', 'atlas.searchCountry': 'Zoek een land...', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 031dbc9..6bbddbd 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -651,8 +651,10 @@ const pl: Record = { 'atlas.unmark': 'Usuń', 'atlas.confirmMark': 'Oznaczyć ten kraj jako odwiedzony?', 'atlas.confirmUnmark': 'Usunąć ten kraj z listy odwiedzonych?', + 'atlas.confirmUnmarkRegion': 'Usunąć ten region z listy odwiedzonych?', 'atlas.markVisited': 'Oznacz jako odwiedzony', 'atlas.markVisitedHint': 'Dodaj ten kraj do listy odwiedzonych', + 'atlas.markRegionVisitedHint': 'Dodaj ten region do listy odwiedzonych', 'atlas.addToBucket': 'Dodaj do listy marzeń', 'atlas.addPoi': 'Dodaj miejsce', 'atlas.bucketNamePlaceholder': 'Nazwa (kraj, miasto, miejsce...)', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index f961d08..6397439 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -723,8 +723,10 @@ const ru: Record = { 'atlas.unmark': 'Удалить', 'atlas.confirmMark': 'Отметить эту страну как посещённую?', 'atlas.confirmUnmark': 'Удалить эту страну из списка посещённых?', + 'atlas.confirmUnmarkRegion': 'Удалить этот регион из списка посещённых?', 'atlas.markVisited': 'Отметить как посещённую', 'atlas.markVisitedHint': 'Добавить эту страну в список посещённых', + 'atlas.markRegionVisitedHint': 'Добавить этот регион в список посещённых', 'atlas.addToBucket': 'В список желаний', 'atlas.addPoi': 'Добавить место', 'atlas.searchCountry': 'Поиск страны...', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 58026c6..ac038d6 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -723,8 +723,10 @@ const zh: Record = { 'atlas.unmark': '移除', 'atlas.confirmMark': '将此国家标记为已访问?', 'atlas.confirmUnmark': '从已访问列表中移除此国家?', + 'atlas.confirmUnmarkRegion': '从已访问列表中移除此地区?', 'atlas.markVisited': '标记为已访问', 'atlas.markVisitedHint': '将此国家添加到已访问列表', + 'atlas.markRegionVisitedHint': '将此地区添加到已访问列表', 'atlas.addToBucket': '添加到心愿单', 'atlas.addPoi': '添加地点', 'atlas.searchCountry': '搜索国家...', diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index 051797b..ddff4ce 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -154,13 +154,16 @@ 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 [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 loadCountryDetailRef = useRef<(code: string) => void>(() => {}) + const handleMarkCountryRef = useRef<(code: string, name: string) => void>(() => {}) + const setConfirmActionRef = useRef(() => {}) + const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket' | 'choose-region' | 'unmark-region'; code: string; name: string; regionCode?: string; countryName?: string } | null>(null) const [bucketMonth, setBucketMonth] = useState(0) const [bucketYear, setBucketYear] = useState(0) @@ -227,33 +230,41 @@ export default function AtlasPage(): React.ReactElement { .catch(() => {}) }, []) - // Load visited regions from server + per-country GeoJSON (once on mount) - const regionsLoadedRef = useRef(false) + // Load visited regions (geocoded from places/trips) — once on mount 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(() => {}) - }) + .then(r => setVisitedRegions(r.data?.regions || {})) .catch(() => {}) }, []) + // Load admin-1 GeoJSON for countries visible in the current viewport + const loadRegionsForViewportRef = useRef<() => void>(() => {}) + const loadRegionsForViewport = (): void => { + if (!mapInstance.current) return + const bounds = mapInstance.current.getBounds() + const toLoad: string[] = [] + for (const [code, layer] of Object.entries(country_layer_by_a2_ref.current)) { + if (regionGeoCache.current[code]) continue + try { + if (bounds.intersects((layer as any).getBounds())) toLoad.push(code) + } catch {} + } + if (!toLoad.length) return + apiClient.get(`/addons/atlas/regions/geo?countries=${toLoad.join(',')}`) + .then(geoRes => { + const geo = geoRes.data + if (!geo?.features) return + let added = false + for (const c of toLoad) { + const features = geo.features.filter((f: any) => f.properties?.iso_a2?.toUpperCase() === c) + if (features.length > 0) { regionGeoCache.current[c] = { type: 'FeatureCollection', features }; added = true } + } + if (added) setRegionGeoLoaded(v => v + 1) + }) + .catch(() => {}) + } + loadRegionsForViewportRef.current = loadRegionsForViewport + // Initialize map — runs after loading is done and mapRef is available useEffect(() => { if (loading || !mapRef.current) return @@ -263,7 +274,7 @@ export default function AtlasPage(): React.ReactElement { center: [25, 0], zoom: 3, minZoom: 3, - maxZoom: 7, + maxZoom: 10, zoomControl: false, attributionControl: false, maxBounds: [[-90, -220], [90, 220]], @@ -279,7 +290,7 @@ export default function AtlasPage(): React.ReactElement { : 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png' L.tileLayer(tileUrl, { - maxZoom: 8, + maxZoom: 10, keepBuffer: 25, updateWhenZooming: true, updateWhenIdle: false, @@ -290,31 +301,47 @@ export default function AtlasPage(): React.ReactElement { // Preload adjacent zoom level tiles L.tileLayer(tileUrl, { - maxZoom: 8, + maxZoom: 10, keepBuffer: 10, opacity: 0, tileSize: 256, crossOrigin: true, }).addTo(map) - // Custom pane for region layer — below overlay (z-index 400) but above tiles + // Custom pane for region layer — above overlay (z-index 400) map.createPane('regionPane') - map.getPane('regionPane')!.style.zIndex = '399' + map.getPane('regionPane')!.style.zIndex = '401' mapInstance.current = map - // Zoom-based region switching — dim country layer directly + // Zoom-based region switching 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' + if (overlayPane) { + overlayPane.style.opacity = shouldShow ? '0.35' : '1' + overlayPane.style.pointerEvents = shouldShow ? 'none' : 'auto' } + if (shouldShow) { + // Re-add region layer if it was removed while zoomed out + if (regionLayerRef.current && !map.hasLayer(regionLayerRef.current)) { + regionLayerRef.current.addTo(map) + } + loadRegionsForViewportRef.current() + } else { + // Physically remove region layer so its SVG paths can't intercept events + if (regionTooltipRef.current) regionTooltipRef.current.style.display = 'none' + if (regionLayerRef.current && map.hasLayer(regionLayerRef.current)) { + regionLayerRef.current.resetStyle() + regionLayerRef.current.removeFrom(map) + } + } + }) + + map.on('moveend', () => { + if (map.getZoom() >= 6) loadRegionsForViewportRef.current() }) return () => { map.remove(); mapInstance.current = null } @@ -465,13 +492,11 @@ export default function AtlasPage(): React.ReactElement { return false } - // Only include visited region features (non-visited pass through to country layer) + // Include ALL region features — visited ones get colored fill, unvisited get outline only const allFeatures: any[] = [] for (const geo of Object.values(regionGeoCache.current)) { for (const f of geo.features) { - if (isVisitedFeature(f)) { - allFeatures.push(f) - } + allFeatures.push(f) } } if (allFeatures.length === 0) return @@ -495,26 +520,64 @@ export default function AtlasPage(): React.ReactElement { pane: 'regionPane', style: (feature) => { const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase() - return { + const visited = isVisitedFeature(feature) + return visited ? { fillColor: a2ColorMap[countryA2] || '#6366f1', fillOpacity: 0.85, color: dark ? '#888' : '#64748b', weight: 1.2, + } : { + fillColor: dark ? '#ffffff' : '#000000', + fillOpacity: 0.03, + color: dark ? '#555' : '#94a3b8', + weight: 1, } }, onEachFeature: (feature, layer) => { const regionName = feature?.properties?.name || '' const countryName = feature?.properties?.admin || '' const regionCode = feature?.properties?.iso_3166_2 || '' + const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase() + const visited = isVisitedFeature(feature) const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || 0 + layer.on('click', () => { + if (!countryA2) return + if (visited) { + const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode) + if (regionEntry?.manuallyMarked) { + setConfirmActionRef.current({ + type: 'unmark-region', + code: countryA2, + name: regionName, + regionCode, + countryName, + }) + } else { + loadCountryDetailRef.current(countryA2) + } + } else { + setConfirmActionRef.current({ + type: 'choose-region', + code: countryA2, // country A2 code — used for flag display + name: regionName, // region name — shown as heading + regionCode, + countryName, + }) + } + }) layer.on('mouseover', (e: any) => { - e.target.setStyle({ fillOpacity: 0.95, weight: 2, color: dark ? '#818cf8' : '#4f46e5' }) + e.target.setStyle(visited + ? { fillOpacity: 0.95, weight: 2, color: dark ? '#818cf8' : '#4f46e5' } + : { fillOpacity: 0.15, fillColor: dark ? '#818cf8' : '#4f46e5', weight: 1.5, 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'}
` + tt.innerHTML = visited + ? `
${regionName}
${countryName}
${count} ${count === 1 ? 'place' : 'places'}
` + : `
${regionName}
${countryName}
` } }) layer.on('mousemove', (e: any) => { @@ -527,12 +590,18 @@ export default function AtlasPage(): React.ReactElement { if (tt) tt.style.display = 'none' }) }, - }).addTo(mapInstance.current) + }) + // Only add to map if currently in region mode — otherwise hold it ready for when user zooms in + if (mapInstance.current.getZoom() >= 6) { + regionLayerRef.current.addTo(mapInstance.current) + } }, [regionGeoLoaded, visitedRegions, dark, t]) const handleMarkCountry = (code: string, name: string): void => { setConfirmAction({ type: 'choose', code, name }) } + handleMarkCountryRef.current = handleMarkCountry + setConfirmActionRef.current = setConfirmAction const handleUnmarkCountry = (code: string): void => { const country = data?.countries.find(c => c.code === code) @@ -586,6 +655,12 @@ export default function AtlasPage(): React.ReactElement { stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) }, } }) + setVisitedRegions(prev => { + if (!prev[code]) return prev + const next = { ...prev } + delete next[code] + return next + }) } } @@ -663,6 +738,7 @@ export default function AtlasPage(): React.ReactElement { setCountryDetail(r.data) } catch { /* */ } } + loadCountryDetailRef.current = loadCountryDetail const stats = data?.stats || { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 } const countries = data?.countries || [] @@ -932,6 +1008,50 @@ export default function AtlasPage(): React.ReactElement {
)} + {confirmAction.type === 'choose-region' && ( +
+ {confirmAction.countryName && ( +

{confirmAction.countryName}

+ )} + + +
+ )} + {confirmAction.type === 'unmark' && ( <>

{t('atlas.confirmUnmark')}

@@ -948,6 +1068,51 @@ export default function AtlasPage(): React.ReactElement { )} + {confirmAction.type === 'unmark-region' && ( + <> + {confirmAction.countryName && ( +

{confirmAction.countryName}

+ )} +

{t('atlas.confirmUnmarkRegion')}

+
+ + +
+ + )} + {confirmAction.type === 'bucket' && ( <>

{t('atlas.bucketWhen')}

@@ -978,7 +1143,7 @@ export default function AtlasPage(): React.ReactElement {
- diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index f734b7d..f6ce2b0 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -563,6 +563,20 @@ function runMigrations(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_place_regions_region ON place_regions(region_code); `); }, + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS visited_regions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + region_code TEXT NOT NULL, + region_name TEXT NOT NULL, + country_code TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, region_code) + ); + CREATE INDEX IF NOT EXISTS idx_visited_regions_country ON visited_regions(country_code); + `); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/atlas.ts b/server/src/routes/atlas.ts index 0b1dc48..d5af8d8 100644 --- a/server/src/routes/atlas.ts +++ b/server/src/routes/atlas.ts @@ -6,6 +6,8 @@ import { getCountryPlaces, markCountryVisited, unmarkCountryVisited, + markRegionVisited, + unmarkRegionVisited, getVisitedRegions, getRegionGeo, listBucketList, @@ -56,6 +58,20 @@ router.delete('/country/:code/mark', (req: Request, res: Response) => { res.json({ success: true }); }); +router.post('/region/:code/mark', (req: Request, res: Response) => { + const userId = (req as AuthRequest).user.id; + const { name, country_code } = req.body; + if (!name || !country_code) return res.status(400).json({ error: 'name and country_code are required' }); + markRegionVisited(userId, req.params.code.toUpperCase(), name, country_code.toUpperCase()); + res.json({ success: true }); +}); + +router.delete('/region/:code/mark', (req: Request, res: Response) => { + const userId = (req as AuthRequest).user.id; + unmarkRegionVisited(userId, req.params.code.toUpperCase()); + res.json({ success: true }); +}); + // ── Bucket List ───────────────────────────────────────────────────────────── router.get('/bucket-list', (req: Request, res: Response) => { diff --git a/server/src/services/atlasService.ts b/server/src/services/atlasService.ts index c423ca9..93779dd 100644 --- a/server/src/services/atlasService.ts +++ b/server/src/services/atlasService.ts @@ -371,6 +371,32 @@ export function markCountryVisited(userId: number, code: string): void { export function unmarkCountryVisited(userId: number, code: string): void { db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, code); + db.prepare('DELETE FROM visited_regions WHERE user_id = ? AND country_code = ?').run(userId, code); +} + +// ── Mark / unmark region ──────────────────────────────────────────────────── + +export function listManuallyVisitedRegions(userId: number): { region_code: string; region_name: string; country_code: string }[] { + return db.prepare( + 'SELECT region_code, region_name, country_code FROM visited_regions WHERE user_id = ? ORDER BY created_at DESC' + ).all(userId) as { region_code: string; region_name: string; country_code: string }[]; +} + +export function markRegionVisited(userId: number, regionCode: string, regionName: string, countryCode: string): void { + db.prepare('INSERT OR IGNORE INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)').run(userId, regionCode, regionName, countryCode); + // Auto-mark parent country if not already visited + db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, countryCode); +} + +export function unmarkRegionVisited(userId: number, regionCode: string): void { + const region = db.prepare('SELECT country_code FROM visited_regions WHERE user_id = ? AND region_code = ?').get(userId, regionCode) as { country_code: string } | undefined; + db.prepare('DELETE FROM visited_regions WHERE user_id = ? AND region_code = ?').run(userId, regionCode); + if (region) { + const remaining = db.prepare('SELECT COUNT(*) as count FROM visited_regions WHERE user_id = ? AND country_code = ?').get(userId, region.country_code) as { count: number }; + if (remaining.count === 0) { + db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, region.country_code); + } + } } // ── Sub-national region resolution ──────────────────────────────────────── @@ -450,11 +476,20 @@ export async function getVisitedRegions(userId: number): Promise<{ regions: Reco } } - const result: Record = {}; + const result: Record = {}; for (const [country, regions] of Object.entries(regionMap)) { result[country] = [...regions.values()]; } + // Merge manually marked regions + const manualRegions = listManuallyVisitedRegions(userId); + for (const r of manualRegions) { + if (!result[r.country_code]) result[r.country_code] = []; + if (!result[r.country_code].find(x => x.code === r.region_code)) { + result[r.country_code].push({ code: r.region_code, name: r.region_name, placeCount: 0, manuallyMarked: true }); + } + } + return { regions: result }; }