diff --git a/client/src/mobile/screens/atlas/MAtlasCountryPopup.test.tsx b/client/src/mobile/screens/atlas/MAtlasCountryPopup.test.tsx new file mode 100644 index 000000000..06a4cb3ff --- /dev/null +++ b/client/src/mobile/screens/atlas/MAtlasCountryPopup.test.tsx @@ -0,0 +1,76 @@ +// FE-COMP-MATLASPOPUP-001 to FE-COMP-MATLASPOPUP-004 +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent, waitFor } from '../../../../tests/helpers/render' +import MAtlasCountryPopup from './MAtlasCountryPopup' +import type { AtlasController } from './atlasController' + +const bucketItem = (id: number, name: string, countryCode: string | null) => ({ + id, name, country_code: countryCode, lat: null, lng: null, notes: null, target_date: null, +}) + +// Only the slice of the atlas hook this sheet reads. `t` echoes the key back so +// the assertions name the contract, not a locale string. +function makeAtlas(overrides: Record = {}): AtlasController { + return { + t: (key: string) => key, + confirmAction: { code: 'FR', name: 'France', type: 'choose' }, + setConfirmAction: vi.fn(), + executeConfirmAction: vi.fn(), + setData: vi.fn(), + setVisitedRegions: vi.fn(), + setBucketList: vi.fn(), + visitedRegions: {}, + bucketList: [], + handleDeleteBucketItem: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as AtlasController +} + +describe('MAtlasCountryPopup', () => { + it('FE-COMP-MATLASPOPUP-001: offers the wishlist removal for a country on the bucket list', () => { + const atlas = makeAtlas({ bucketList: [bucketItem(42, 'Paris', 'FR')] }) + render() + + expect(screen.getByText('atlas.removeFromBucket')).toBeInTheDocument() + expect(screen.getByText('atlas.removeFromBucketHint')).toBeInTheDocument() + }) + + it('FE-COMP-MATLASPOPUP-002: hides the removal for a country that is not on the bucket list', () => { + const atlas = makeAtlas({ bucketList: [bucketItem(42, 'Tokyo', 'JP')] }) + render() + + expect(screen.getByText('atlas.addToBucket')).toBeInTheDocument() + expect(screen.queryByText('atlas.removeFromBucket')).not.toBeInTheDocument() + }) + + it('FE-COMP-MATLASPOPUP-003: removing drops every entry for that country and closes the sheet', async () => { + const handleDeleteBucketItem = vi.fn().mockResolvedValue(undefined) + const setConfirmAction = vi.fn() + const atlas = makeAtlas({ + // Two places in France plus one elsewhere, only the French ones go. + bucketList: [bucketItem(1, 'Paris', 'FR'), bucketItem(2, 'Nice', 'FR'), bucketItem(3, 'Tokyo', 'JP')], + handleDeleteBucketItem, + setConfirmAction, + }) + render() + + fireEvent.click(screen.getByText('atlas.removeFromBucket')) + + await waitFor(() => expect(setConfirmAction).toHaveBeenCalledWith(null)) + expect(handleDeleteBucketItem).toHaveBeenCalledTimes(2) + expect(handleDeleteBucketItem).toHaveBeenCalledWith(1) + expect(handleDeleteBucketItem).toHaveBeenCalledWith(2) + }) + + it('FE-COMP-MATLASPOPUP-004: keeps the removal out of the region variant', () => { + const atlas = makeAtlas({ + confirmAction: { code: 'FR', name: 'Corsica', type: 'choose-region', regionCode: 'FR-20R', countryName: 'France' }, + bucketList: [bucketItem(42, 'Paris', 'FR')], + }) + render() + + expect(screen.getByText('atlas.markVisited')).toBeInTheDocument() + expect(screen.queryByText('atlas.removeFromBucket')).not.toBeInTheDocument() + }) +}) diff --git a/client/src/mobile/screens/atlas/MAtlasCountryPopup.tsx b/client/src/mobile/screens/atlas/MAtlasCountryPopup.tsx index 5d9afe0d6..c4ad38a23 100644 --- a/client/src/mobile/screens/atlas/MAtlasCountryPopup.tsx +++ b/client/src/mobile/screens/atlas/MAtlasCountryPopup.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { ChevronRight, MapPin, Star } from 'lucide-react' +import { ChevronRight, MapPin, Star, Trash2 } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import apiClient from '../../../api/client' import { continentForCountry } from '@trek/shared' @@ -11,6 +11,7 @@ import type { AtlasController } from './atlasController' const markTint = 'bg-[rgba(47,163,122,.16)] text-[color:var(--m-st-confirmed)]' // theme-lint-disable — fixed tint from the design's option rows const bucketTint = 'bg-[rgba(232,161,58,.16)] text-[color:var(--m-st-pending)]' // theme-lint-disable — fixed tint from the design's option rows +const removeTint = 'bg-[color:color-mix(in_srgb,var(--m-st-danger)_16%,transparent)] text-[color:var(--m-st-danger)]' const btnBase = 'flex-1 rounded-full py-[11px] text-[0.8125rem] font-bold' const cancelBtn = `${btnBase} bg-[color:var(--m-ic)] text-m-ink` @@ -66,6 +67,8 @@ export default function MAtlasCountryPopup({ atlas }: MAtlasCountryPopupProps) { setVisitedRegions, setBucketList, visitedRegions, + bucketList, + handleDeleteBucketItem, } = atlas const toast = useToast() const [bucketDate, setBucketDate] = useState('') @@ -155,7 +158,17 @@ export default function MAtlasCountryPopup({ atlas }: MAtlasCountryPopupProps) { setConfirmAction(null) } + // A country can sit on the bucket list more than once (one entry per place), + // so taking it off the wishlist means dropping every entry for that code. + const removeBucket = async (): Promise => { + if (!confirmAction) return + const wishlistItems = bucketList.filter((b) => b.country_code === confirmAction.code) + await Promise.all(wishlistItems.map((item) => handleDeleteBucketItem(item.id))) + setConfirmAction(null) + } + const a = confirmAction + const onWishlist = !!a && bucketList.some((b) => b.country_code === a.code) return ( setConfirmAction(null)} variant="card" ariaLabel={a?.name}> @@ -190,6 +203,15 @@ export default function MAtlasCountryPopup({ atlas }: MAtlasCountryPopupProps) { hint={t('atlas.addToBucketHint')} onClick={() => setConfirmAction({ ...a, type: 'bucket' })} /> + {onWishlist && ( + + )} )} diff --git a/client/src/pages/AtlasPage.test.tsx b/client/src/pages/AtlasPage.test.tsx index 15e4a22af..5ff3a1762 100644 --- a/client/src/pages/AtlasPage.test.tsx +++ b/client/src/pages/AtlasPage.test.tsx @@ -10,6 +10,11 @@ import { useAuthStore } from '../store/authStore'; import { useSettingsStore } from '../store/settingsStore'; import AtlasPage from './AtlasPage'; +// ── Captured style() results, for tests asserting fill/border on a specific +// feature (e.g. the wishlist hatch pattern) without duplicating the mock's +// internal geoJSON wiring ──────────────────────────────────────────────────── +const { capturedStyles } = vi.hoisted(() => ({ capturedStyles: [] as { feature: any; result: any }[] })); + // ── Leaflet mock ────────────────────────────────────────────────────────────── vi.mock('leaflet', () => { // Mock layer returned by onEachFeature — supports event registration @@ -73,7 +78,7 @@ vi.mock('leaflet', () => { for (const feature of data.features) { const layer = makeMockLayer(); try { - if (options.style) options.style(feature); + if (options.style) capturedStyles.push({ feature, result: options.style(feature) }); options.onEachFeature(feature, layer); } catch { // ignore errors from callbacks in mock @@ -187,6 +192,7 @@ function useDefaultAtlasHandlers() { beforeEach(() => { resetAllStores(); vi.clearAllMocks(); + capturedStyles.length = 0; seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() }); seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }) }); @@ -961,6 +967,45 @@ describe('AtlasPage', () => { }); }); + describe('FE-PAGE-ATLAS-049: bucket-list country renders hatched wishlist fill', () => { + it('styles an unvisited bucket-list country with the wishlist hatch, not the flat unvisited gray', async () => { + const geoJsonFRandJP = { + type: 'FeatureCollection', + features: [ + { type: 'Feature', properties: { ISO_A2: 'FR', ADM0_A3: 'FRA', ISO_A3: 'FRA', NAME: 'France', ADMIN: 'France' }, geometry: null }, + { type: 'Feature', properties: { ISO_A2: 'JP', ADM0_A3: 'JPN', ISO_A3: 'JPN', NAME: 'Japan', ADMIN: 'Japan' }, geometry: null }, + ], + }; + server.use( + http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonFRandJP)), + http.get('/api/addons/atlas/bucket-list', () => + HttpResponse.json({ + items: [{ id: 1, name: 'Kyoto', country_code: 'JP', lat: null, lng: null, notes: null, target_date: null }], + }), + ), + ); + + render(); + + await waitFor(() => { + expect(capturedStyles.some((s) => s.feature.properties.ADM0_A3 === 'JPN')).toBe(true); + }); + + // FR is visited → keeps the normal solid fill, in its own hash-derived color + // from the palette (stable regardless of visit order or list contents). + const frStyle = capturedStyles.find((s) => s.feature.properties.ADM0_A3 === 'FRA')!.result; + expect(frStyle.fillColor).toBe('#dc2626'); + expect(frStyle.dashArray).toBeUndefined(); + + // JP is on the bucket list and not visited → hatch, in JP's own hash-derived + // color — distinct from FR's, and unaffected by FR being visited. + const jpStyle = capturedStyles.find((s) => s.feature.properties.ADM0_A3 === 'JPN')!.result; + expect(jpStyle.fillColor).toBe('#0ea5e9'); + expect(jpStyle.dashArray).toBe('3 2'); + expect(jpStyle.fillColor).not.toBe(frStyle.fillColor); + }); + }); + describe('FE-PAGE-ATLAS-034: dropdown button click + mouse events', () => { it('clicking France dropdown button covers onClick and mouse event handlers', async () => { server.use( @@ -1088,6 +1133,65 @@ describe('AtlasPage', () => { }); }); + describe('FE-PAGE-ATLAS-050: choose popup offers Remove from wishlist for a bucket-list country', () => { + it('shows Remove from wishlist when the searched country is already on the bucket list, and removing it clears the item', async () => { + server.use( + http.get('/api/addons/atlas/stats', () => HttpResponse.json(emptyAtlasResponse)), + http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)), + http.get('/api/addons/atlas/bucket-list', () => + HttpResponse.json({ + items: [{ id: 42, name: 'Paris', country_code: 'FR', lat: null, lng: null, notes: null, target_date: null }], + }), + ), + http.delete('/api/addons/atlas/bucket-list/:id', () => HttpResponse.json({ success: true })), + ); + + const user = userEvent.setup(); + render(); + + await waitFor(() => screen.getByPlaceholderText(/search a country/i)); + const searchInput = screen.getByPlaceholderText(/search a country/i); + await user.type(searchInput, 'fr'); + fireEvent.keyDown(searchInput, { key: 'Enter' }); + + await waitFor(() => { + expect(screen.getByText(/remove from wishlist/i)).toBeInTheDocument(); + }); + + await user.click(screen.getByText(/remove from wishlist/i)); + + await waitFor(() => { + expect(screen.queryByText(/remove from wishlist/i)).not.toBeInTheDocument(); + }); + + // Bucket List tab no longer shows the removed item + await user.click(screen.getAllByText('Bucket List')[0]); + await waitFor(() => { + expect(screen.queryByText('Paris')).not.toBeInTheDocument(); + }); + }); + + it('does not show Remove from wishlist for a country with no bucket-list entry', async () => { + server.use( + http.get('/api/addons/atlas/stats', () => HttpResponse.json(emptyAtlasResponse)), + http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)), + ); + + const user = userEvent.setup(); + render(); + + await waitFor(() => screen.getByPlaceholderText(/search a country/i)); + const searchInput = screen.getByPlaceholderText(/search a country/i); + await user.type(searchInput, 'fr'); + fireEvent.keyDown(searchInput, { key: 'Enter' }); + + await waitFor(() => { + expect(screen.getByText(/mark as visited/i)).toBeInTheDocument(); + }); + expect(screen.queryByText(/remove from wishlist/i)).not.toBeInTheDocument(); + }); + }); + describe('FE-PAGE-ATLAS-036: bucket popup submit action', () => { it('submits a bucket list item from the confirm popup', async () => { server.use( diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index 716707354..3a2a8bb77 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -207,6 +207,26 @@ function AtlasPageDesktop(): React.ReactElement {
{t('atlas.addToBucketHint')}
+ {(() => { + const wishlistItems = bucketList.filter(b => b.country_code === confirmAction.code) + if (wishlistItems.length === 0) return null + return ( + + ) + })()} )} diff --git a/client/src/pages/atlas/atlasModel.test.ts b/client/src/pages/atlas/atlasModel.test.ts index 64d86881f..7d3fe9b66 100644 --- a/client/src/pages/atlas/atlasModel.test.ts +++ b/client/src/pages/atlas/atlasModel.test.ts @@ -5,7 +5,11 @@ import { isCountryVisible, normalizeRegionName, withCountryMarkedVisited, + wishlistA3Codes, + countryColor, + COUNTRY_COLORS, type AtlasData, + type BucketItem, } from './atlasModel'; describe('normalizeRegionName', () => { @@ -155,3 +159,54 @@ describe('withCountryMarkedVisited', () => { expect(next.continentsPlanned).toEqual({ Asia: 0 }); }); }); + +function bucketItem(overrides: Partial): BucketItem { + return { id: 1, name: 'Somewhere', lat: null, lng: null, country_code: null, notes: null, target_date: null, ...overrides }; +} + +describe('countryColor', () => { + it('always returns a color from the shared palette', () => { + expect(COUNTRY_COLORS).toContain(countryColor('FRA')); + }); + + it('is stable for the same code across repeated calls', () => { + expect(countryColor('JPN')).toBe(countryColor('JPN')); + }); + + it('is independent of how many other codes have been resolved before it (#reshuffle-bug)', () => { + // Regression guard: an earlier order-index-based scheme reassigned every + // country's color whenever one more country was visited/wishlisted. + const before = countryColor('JPN'); + countryColor('FRA'); countryColor('DEU'); countryColor('ESP'); countryColor('ITA'); + expect(countryColor('JPN')).toBe(before); + }); + + it('gives different codes different colors in the common case', () => { + expect(countryColor('FRA')).not.toBe(countryColor('JPN')); + }); +}); + +describe('wishlistA3Codes', () => { + it('resolves a bucket-list country to its A3 code', () => { + const result = wishlistA3Codes([bucketItem({ country_code: 'JP' })], new Set()); + expect(result).toEqual(new Set(['JPN'])); + }); + + it('excludes a bucket-list country that is already visited', () => { + const result = wishlistA3Codes([bucketItem({ country_code: 'JP' })], new Set(['JPN'])); + expect(result.size).toBe(0); + }); + + it('ignores bucket-list items with no country_code (POI-only entries)', () => { + const result = wishlistA3Codes([bucketItem({ country_code: null, lat: 35.6, lng: 139.7 })], new Set()); + expect(result.size).toBe(0); + }); + + it('dedupes multiple bucket-list items in the same country', () => { + const result = wishlistA3Codes( + [bucketItem({ id: 1, country_code: 'JP' }), bucketItem({ id: 2, country_code: 'JP' })], + new Set(), + ); + expect(result).toEqual(new Set(['JPN'])); + }); +}); diff --git a/client/src/pages/atlas/atlasModel.ts b/client/src/pages/atlas/atlasModel.ts index aeacaff69..40ecaadd0 100644 --- a/client/src/pages/atlas/atlasModel.ts +++ b/client/src/pages/atlas/atlasModel.ts @@ -113,6 +113,31 @@ export function normalizeRegionName(name: string): string { .trim() } +// Alpha-3 codes for bucket-list countries that aren't visited yet — the Atlas +// map renders these with a hatched fill so they read as "on the wishlist" +// instead of blending into the flat unvisited gray. +export function wishlistA3Codes(bucketList: BucketItem[], visitedA3: Set): Set { + const result = new Set() + for (const item of bucketList) { + const a3 = item.country_code ? A2_TO_A3[item.country_code] : undefined + if (a3 && !visitedA3.has(a3)) result.add(a3) + } + return result +} + +// Palette for the Atlas map's country fills (visited: solid, wishlist: hatched). +export const COUNTRY_COLORS = ['#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#ef4444', '#3b82f6', '#22c55e', '#06b6d4', '#f43f5e', '#a855f7', '#10b981', '#0ea5e9', '#e11d48', '#0d9488', '#7c3aed', '#2563eb', '#dc2626', '#059669', '#d946ef'] + +// Deterministic color for a country code, hashed from the code itself so a country +// keeps the same color forever — regardless of visit order or how many other +// countries are visited/wishlisted. An order/index-based scheme would reshuffle +// every other country's color each time one more was marked or added. +export function countryColor(a3: string): string { + let hash = 0 + for (let i = 0; i < a3.length; i++) hash = (hash * 31 + a3.charCodeAt(i)) >>> 0 + return COUNTRY_COLORS[hash % COUNTRY_COLORS.length] +} + // Convert country code to flag emoji export function countryCodeToFlag(code: string): string { if (!code || code.length !== 2) return '' diff --git a/client/src/pages/atlas/useAtlas.test.tsx b/client/src/pages/atlas/useAtlas.test.tsx index 8fb0f016c..55f1d9489 100644 --- a/client/src/pages/atlas/useAtlas.test.tsx +++ b/client/src/pages/atlas/useAtlas.test.tsx @@ -843,24 +843,29 @@ describe('useAtlas', () => { await waitFor(() => expect(countryLayers().length).toBeGreaterThan(0)); const before = countryLayers().length; - act(() => atlas.togglePlanned()); - await waitFor(() => expect(countryLayers().length).toBeGreaterThan(before)); - - const layers = countryLayers(); - const layer = layers[layers.length - 1]; - const styleFor = (a2: string) => { + const styleOn = (layer: MockGeoJson, a2: string) => { const i = (layer.data.features ?? []).findIndex( (f) => (f.properties as Record).ISO_A2 === a2, ); return layer.styles[i] as { fillOpacity: number; dashArray?: string; fillColor: string }; }; + const newestLayer = () => { + const layers = countryLayers(); + return layers[layers.length - 1]; + }; + const frBeforeToggle = styleOn(newestLayer(), 'FR').fillColor; + + act(() => atlas.togglePlanned()); + await waitFor(() => expect(countryLayers().length).toBeGreaterThan(before)); + + const styleFor = (a2: string) => styleOn(newestLayer(), a2); expect(styleFor('FR').dashArray).toBeUndefined(); expect(styleFor('DE').dashArray).toBe('6 4'); - // The palette is built from the visited list only, so France keeps the first colour - // whether or not the planned layer is on. - expect(styleFor('FR').fillColor).toBe('#6366f1'); - expect(styleFor('DE').fillColor).not.toBe('#6366f1'); + // Colours are hashed from the country code, so France keeps the same colour + // whether or not the planned layer is on, and a planned country never takes it. + expect(styleFor('FR').fillColor).toBe(frBeforeToggle); + expect(styleFor('DE').fillColor).not.toBe(styleFor('FR').fillColor); }); }); }); diff --git a/client/src/pages/atlas/useAtlas.ts b/client/src/pages/atlas/useAtlas.ts index 00340c860..b8f244478 100644 --- a/client/src/pages/atlas/useAtlas.ts +++ b/client/src/pages/atlas/useAtlas.ts @@ -5,11 +5,51 @@ import { useSettingsStore } from '../../store/settingsStore' import apiClient, { mapsApi, pluginsApi, type PluginAtlasLayer } from '../../api/client' import L from 'leaflet' import type { GeoJsonFeatureCollection } from '../../types' -import { A2_TO_A3, countryStatus, isCountryVisible, normalizeRegionName, withCountryMarkedVisited, type AtlasData, type CountryDetail, type BucketItem } from './atlasModel' +import { A2_TO_A3, countryStatus, isCountryVisible, normalizeRegionName, withCountryMarkedVisited, wishlistA3Codes, countryColor, type AtlasData, type CountryDetail, type BucketItem } from './atlasModel' import { continentForCountry, type VisitStatus } from '@trek/shared' const PLANNED_KEY = 'trek_atlas_show_planned' +function hexToRgba(hex: string, alpha: number): string { + const clean = hex.replace('#', '') + const r = parseInt(clean.substring(0, 2), 16) + const g = parseInt(clean.substring(2, 4), 16) + const b = parseInt(clean.substring(4, 6), 16) + return `rgba(${r},${g},${b},${alpha})` +} + +// Diagonal-stripe CanvasPattern for the wishlist country fill, in that +// country's own "if visited" color — built once per render pass so it +// survives Leaflet's Canvas renderer (ctx.fillStyle accepts a CanvasPattern +// object same as a color string). Returns null in environments without a +// real 2D canvas context (e.g. jsdom in tests), so callers must fall back to +// a plain color. +function createWishlistPattern(color: string, dark: boolean): CanvasPattern | null { + try { + const size = 8 + const patternCanvas = document.createElement('canvas') + patternCanvas.width = size + patternCanvas.height = size + const ctx = patternCanvas.getContext('2d') + if (!ctx) return null + ctx.fillStyle = hexToRgba(color, dark ? 0.14 : 0.2) + ctx.fillRect(0, 0, size, size) + ctx.strokeStyle = color + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(-1, size + 1) + ctx.lineTo(size + 1, -1) + ctx.moveTo(-1, 1) + ctx.lineTo(1, -1) + ctx.moveTo(size - 1, size + 1) + ctx.lineTo(size + 1, size - 1) + ctx.stroke() + return ctx.createPattern(patternCanvas, 'repeat') + } catch { + return null + } +} + function useCountryNames(language: string): (code: string) => string { const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code) useEffect(() => { @@ -312,6 +352,7 @@ export function useAtlas() { const plannedA3 = new Set(visibleCountries.filter(c => countryStatus(c) !== 'visited').map(c => A2_TO_A3[c.code]).filter(Boolean)) const countryMap = {} visibleCountries.forEach(c => { if (A2_TO_A3[c.code]) countryMap[A2_TO_A3[c.code]] = c }) + const wishlistA3 = wishlistA3Codes(bucketList, visitedA3) // Preserve current map view const currentCenter = mapInstance.current.getCenter() @@ -321,15 +362,11 @@ export function useAtlas() { mapInstance.current.removeLayer(geoLayerRef.current) } - // Generate deterministic color per country code - const VISITED_COLORS = ['#6366f1','#ec4899','#14b8a6','#f97316','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#e11d48','#0d9488','#7c3aed','#2563eb','#dc2626','#059669','#d946ef'] - // Assign colors in order of visit (by index in countries array) so no two neighbors share a color easily. - // Built from the visited countries only — if planned ones took part, flipping the toggle - // would shift every index and reshuffle the colours of the whole map. - const visitedA3List = [...new Set(visitedCountries.map(c => A2_TO_A3[c.code]).filter(Boolean))] - const colorMap = {} - visitedA3List.forEach((a3, i) => { colorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] }) - const colorForCode = (a3) => colorMap[a3] || VISITED_COLORS[0] + // Color per country code, hashed from the code itself (countryColor in atlasModel) — + // stable forever, regardless of visit order or how many countries are visited/planned/ + // wishlisted. Also used by the region layer below, so both stay in sync. + const colorForCode = countryColor + const wishlistPatternCache = new Map() const canvasRenderer = L.canvas({ padding: 0.5, tolerance: 5 }) @@ -351,6 +388,20 @@ export function useAtlas() { } } const visited = visitedA3.has(a3) + if (!visited && wishlistA3.has(a3)) { + const wishlistColor = colorForCode(a3) + if (!wishlistPatternCache.has(wishlistColor)) { + wishlistPatternCache.set(wishlistColor, createWishlistPattern(wishlistColor, dark)) + } + const pattern = wishlistPatternCache.get(wishlistColor) + return { + fillColor: (pattern || wishlistColor) as unknown as string, + fillOpacity: pattern ? 1 : 0.4, + color: wishlistColor, + weight: 1, + dashArray: '3 2', + } + } return { fillColor: visited ? colorForCode(a3) : (dark ? '#1e1e2e' : '#e2e8f0'), fillOpacity: visited ? 0.7 : 0.3, @@ -435,7 +486,7 @@ export function useAtlas() { // Restore map view after re-render mapInstance.current.setView(currentCenter, currentZoom, { animate: false }) - }, [geoData, data, dark, visibleCountries, visitedCountries]) + }, [geoData, data, dark, visibleCountries, visitedCountries, bucketList]) // Render plugin tint layers (atlasLayerProvider hook) — a dashed wash over the // countries a plugin flagged, in its own non-interactive pane above the country @@ -546,16 +597,10 @@ export function useAtlas() { } 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'] - // Same source as the country layer's palette (visited only), or the two layers would - // hand the same country different colours. - const countryA3Set = [...new Set(visitedCountries.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 + // Same palette as the country layer — countryColor is a pure hash of the code, so + // both layers always agree on a country's color without needing to share state. const a2ColorMap: Record = {} - visitedCountries.forEach(c => { if (A2_TO_A3[c.code] && countryColorMap[A2_TO_A3[c.code]]) a2ColorMap[c.code] = countryColorMap[A2_TO_A3[c.code]] }) + visitedCountries.forEach(c => { if (A2_TO_A3[c.code]) a2ColorMap[c.code] = countryColor(A2_TO_A3[c.code]) }) const mergedGeo = { type: 'FeatureCollection', features: allFeatures } diff --git a/shared/src/i18n/ar/atlas.ts b/shared/src/i18n/ar/atlas.ts index f802c6f09..276dd9272 100644 --- a/shared/src/i18n/ar/atlas.ts +++ b/shared/src/i18n/ar/atlas.ts @@ -19,6 +19,8 @@ const atlas: TranslationStrings = { 'atlas.month': 'الشهر', 'atlas.year': 'السنة', 'atlas.addToBucketHint': 'حفظ كمكان تريد زيارته', + 'atlas.removeFromBucket': 'إزالة من قائمة الأمنيات', + 'atlas.removeFromBucketHint': 'إزالة هذا البلد من قائمة أمنياتك', 'atlas.bucketWhen': 'متى تخطط للزيارة؟', 'atlas.statsTab': 'الإحصائيات', 'atlas.bucketTab': 'قائمة الأمنيات', diff --git a/shared/src/i18n/br/atlas.ts b/shared/src/i18n/br/atlas.ts index 7656cb7a9..6638e71df 100644 --- a/shared/src/i18n/br/atlas.ts +++ b/shared/src/i18n/br/atlas.ts @@ -19,6 +19,8 @@ const atlas: TranslationStrings = { 'atlas.month': 'Mês', 'atlas.year': 'Ano', 'atlas.addToBucketHint': 'Salvar como lugar que você quer visitar', + 'atlas.removeFromBucket': 'Remover da lista de desejos', + 'atlas.removeFromBucketHint': 'Tirar este país da sua lista de desejos', 'atlas.bucketWhen': 'Quando pretende visitar?', 'atlas.statsTab': 'Estatísticas', 'atlas.bucketTab': 'Lista de desejos', diff --git a/shared/src/i18n/ca/atlas.ts b/shared/src/i18n/ca/atlas.ts index e72cf02ec..aba23bb89 100644 --- a/shared/src/i18n/ca/atlas.ts +++ b/shared/src/i18n/ca/atlas.ts @@ -53,6 +53,8 @@ const atlas: TranslationStrings = { 'atlas.searchCountry': 'Cerca un país...', 'atlas.month': 'Mes', 'atlas.addToBucketHint': 'Desa-ho com a lloc que vols visitar', + 'atlas.removeFromBucket': 'Elimina de la llista de desitjos', + 'atlas.removeFromBucketHint': 'Treu aquest país de la teva llista de desitjos', 'atlas.bucketWhen': 'Quan planeges visitar-lo?', 'atlas.planned': 'Planificat', 'atlas.showPlanned': 'Mostra els països planificats', diff --git a/shared/src/i18n/cs/atlas.ts b/shared/src/i18n/cs/atlas.ts index b54bf436c..6c3f89aab 100644 --- a/shared/src/i18n/cs/atlas.ts +++ b/shared/src/i18n/cs/atlas.ts @@ -17,6 +17,8 @@ const atlas: TranslationStrings = { 'atlas.bucketNamePlaceholder': 'Název (země, město, místo...)', 'atlas.month': 'Měsíc', 'atlas.addToBucketHint': 'Uložit jako místo, které chcete navštívit', + 'atlas.removeFromBucket': 'Odebrat ze seznamu přání', + 'atlas.removeFromBucketHint': 'Odebrat tuto zemi z vašeho bucket listu', 'atlas.bucketWhen': 'Kdy plánujete návštěvu?', 'atlas.statsTab': 'Statistiky', 'atlas.bucketTab': 'Bucket List', diff --git a/shared/src/i18n/de/atlas.ts b/shared/src/i18n/de/atlas.ts index 50c65852e..146f023d0 100644 --- a/shared/src/i18n/de/atlas.ts +++ b/shared/src/i18n/de/atlas.ts @@ -19,6 +19,8 @@ const atlas: TranslationStrings = { 'atlas.month': 'Monat', 'atlas.year': 'Jahr', 'atlas.addToBucketHint': 'Als Wunschziel speichern', + 'atlas.removeFromBucket': 'Von der Wunschliste entfernen', + 'atlas.removeFromBucketHint': 'Dieses Land von deiner Bucket List nehmen', 'atlas.bucketWhen': 'Wann möchtest du dorthin reisen?', 'atlas.statsTab': 'Statistik', 'atlas.bucketTab': 'Wunschliste', diff --git a/shared/src/i18n/en/atlas.ts b/shared/src/i18n/en/atlas.ts index 7c3c48fea..05ca20b00 100644 --- a/shared/src/i18n/en/atlas.ts +++ b/shared/src/i18n/en/atlas.ts @@ -19,6 +19,8 @@ const atlas: TranslationStrings = { 'atlas.month': 'Month', 'atlas.year': 'Year', 'atlas.addToBucketHint': 'Save as a place you want to visit', + 'atlas.removeFromBucket': 'Remove from wishlist', + 'atlas.removeFromBucketHint': 'Take this country off your bucket list', 'atlas.bucketWhen': 'When do you plan to visit?', 'atlas.statsTab': 'Stats', 'atlas.bucketTab': 'Bucket List', diff --git a/shared/src/i18n/es/atlas.ts b/shared/src/i18n/es/atlas.ts index 4db239b61..506161606 100644 --- a/shared/src/i18n/es/atlas.ts +++ b/shared/src/i18n/es/atlas.ts @@ -53,6 +53,8 @@ const atlas: TranslationStrings = { 'atlas.searchCountry': 'Buscar un país...', 'atlas.month': 'Mes', 'atlas.addToBucketHint': 'Guardar como lugar que quieres visitar', + 'atlas.removeFromBucket': 'Quitar de la lista de deseos', + 'atlas.removeFromBucketHint': 'Saca este país de tu lista de deseos', 'atlas.bucketWhen': '¿Cuándo planeas visitarlo?', 'atlas.planned': 'Planeado', 'atlas.showPlanned': 'Mostrar países planeados', diff --git a/shared/src/i18n/fr/atlas.ts b/shared/src/i18n/fr/atlas.ts index 55ece22d6..bb7a487ff 100644 --- a/shared/src/i18n/fr/atlas.ts +++ b/shared/src/i18n/fr/atlas.ts @@ -53,6 +53,8 @@ const atlas: TranslationStrings = { 'atlas.searchCountry': 'Rechercher un pays…', 'atlas.month': 'Mois', 'atlas.addToBucketHint': 'Sauvegarder comme lieu à visiter', + 'atlas.removeFromBucket': 'Retirer de la bucket list', + 'atlas.removeFromBucketHint': 'Enlever ce pays de votre bucket list', 'atlas.bucketWhen': "Quand prévoyez-vous d'y aller ?", 'atlas.planned': 'Prévu', 'atlas.showPlanned': 'Afficher les pays prévus', diff --git a/shared/src/i18n/gr/atlas.ts b/shared/src/i18n/gr/atlas.ts index 376cdc9db..8be8de15c 100644 --- a/shared/src/i18n/gr/atlas.ts +++ b/shared/src/i18n/gr/atlas.ts @@ -19,6 +19,8 @@ const atlas: TranslationStrings = { 'atlas.month': 'Μήνας', 'atlas.year': 'Έτος', 'atlas.addToBucketHint': 'Αποθηκεύστε ως μέρος που θέλετε να επισκεφθείτε', + 'atlas.removeFromBucket': 'Αφαίρεση από τη λίστα επιθυμιών', + 'atlas.removeFromBucketHint': 'Αφαιρέστε αυτή τη χώρα από τη λίστα επιθυμιών σας', 'atlas.bucketWhen': 'Πότε σκοπεύετε να το επισκεφθείτε;', 'atlas.statsTab': 'Στατιστικά', 'atlas.bucketTab': 'Λίστα Επιθυμιών', diff --git a/shared/src/i18n/hu/atlas.ts b/shared/src/i18n/hu/atlas.ts index 0bbdb7730..9c982a846 100644 --- a/shared/src/i18n/hu/atlas.ts +++ b/shared/src/i18n/hu/atlas.ts @@ -18,6 +18,8 @@ const atlas: TranslationStrings = { 'atlas.bucketNamePlaceholder': 'Név (ország, város, hely...)', 'atlas.month': 'Hónap', 'atlas.addToBucketHint': 'Mentés meglátogatni kívánt helyként', + 'atlas.removeFromBucket': 'Eltávolítás a bakancslistáról', + 'atlas.removeFromBucketHint': 'Vedd le ezt az országot a bakancslistádról', 'atlas.bucketWhen': 'Mikor tervezed meglátogatni?', 'atlas.statsTab': 'Statisztikák', 'atlas.bucketTab': 'Bakancslista', diff --git a/shared/src/i18n/id/atlas.ts b/shared/src/i18n/id/atlas.ts index 1547693e1..beb5232fc 100644 --- a/shared/src/i18n/id/atlas.ts +++ b/shared/src/i18n/id/atlas.ts @@ -19,6 +19,8 @@ const atlas: TranslationStrings = { 'atlas.month': 'Bulan', 'atlas.year': 'Tahun', 'atlas.addToBucketHint': 'Simpan sebagai tempat yang ingin dikunjungi', + 'atlas.removeFromBucket': 'Hapus dari bucket list', + 'atlas.removeFromBucketHint': 'Keluarkan negara ini dari daftar impianmu', 'atlas.bucketWhen': 'Kapan kamu berencana mengunjunginya?', 'atlas.statsTab': 'Statistik', 'atlas.bucketTab': 'Daftar Impian', diff --git a/shared/src/i18n/it/atlas.ts b/shared/src/i18n/it/atlas.ts index 9ebccfe3a..aa86a977d 100644 --- a/shared/src/i18n/it/atlas.ts +++ b/shared/src/i18n/it/atlas.ts @@ -17,6 +17,8 @@ const atlas: TranslationStrings = { 'atlas.bucketNamePlaceholder': 'Nome (paese, città, luogo...)', 'atlas.month': 'Mese', 'atlas.addToBucketHint': 'Salvalo come luogo che vuoi visitare', + 'atlas.removeFromBucket': 'Rimuovi dalla lista desideri', + 'atlas.removeFromBucketHint': 'Togli questo paese dalla tua lista desideri', 'atlas.bucketWhen': 'Quando pensi di visitarlo?', 'atlas.statsTab': 'Statistiche', 'atlas.bucketTab': 'Lista desideri', diff --git a/shared/src/i18n/ja/atlas.ts b/shared/src/i18n/ja/atlas.ts index d6e5b9bb2..9efc4f638 100644 --- a/shared/src/i18n/ja/atlas.ts +++ b/shared/src/i18n/ja/atlas.ts @@ -19,6 +19,8 @@ const atlas: TranslationStrings = { 'atlas.month': '月', 'atlas.year': '年', 'atlas.addToBucketHint': '行きたい場所として保存', + 'atlas.removeFromBucket': '行きたいリストから削除', + 'atlas.removeFromBucketHint': 'この国を行きたいリストから外します', 'atlas.bucketWhen': '訪問予定はいつですか?', 'atlas.statsTab': '統計', 'atlas.bucketTab': '行きたいリスト', diff --git a/shared/src/i18n/ko/atlas.ts b/shared/src/i18n/ko/atlas.ts index ca49b0191..47c5f29c4 100644 --- a/shared/src/i18n/ko/atlas.ts +++ b/shared/src/i18n/ko/atlas.ts @@ -19,6 +19,8 @@ const atlas: TranslationStrings = { 'atlas.month': '월', 'atlas.year': '연도', 'atlas.addToBucketHint': '방문하고 싶은 장소로 저장', + 'atlas.removeFromBucket': '버킷 리스트에서 제거', + 'atlas.removeFromBucketHint': '이 나라를 버킷 리스트에서 뺍니다', 'atlas.bucketWhen': '방문 예정 시기는 언제인가요?', 'atlas.statsTab': '통계', 'atlas.bucketTab': '버킷 리스트', diff --git a/shared/src/i18n/nl/atlas.ts b/shared/src/i18n/nl/atlas.ts index ab04f4337..c143e69d1 100644 --- a/shared/src/i18n/nl/atlas.ts +++ b/shared/src/i18n/nl/atlas.ts @@ -53,6 +53,8 @@ const atlas: TranslationStrings = { 'atlas.searchCountry': 'Zoek een land...', 'atlas.month': 'Maand', 'atlas.addToBucketHint': 'Opslaan als plek die je wilt bezoeken', + 'atlas.removeFromBucket': 'Uit bucket list verwijderen', + 'atlas.removeFromBucketHint': 'Haal dit land van je bucketlist af', 'atlas.bucketWhen': 'Wanneer ben je van plan te gaan?', 'atlas.planned': 'Gepland', 'atlas.showPlanned': 'Geplande landen tonen', diff --git a/shared/src/i18n/pl/atlas.ts b/shared/src/i18n/pl/atlas.ts index 101607ce2..7fa984b27 100644 --- a/shared/src/i18n/pl/atlas.ts +++ b/shared/src/i18n/pl/atlas.ts @@ -18,6 +18,8 @@ const atlas: TranslationStrings = { 'atlas.month': 'Miesiąc', 'atlas.year': 'Rok', 'atlas.addToBucketHint': 'Zapisz jako miejsce, które chcesz odwiedzić', + 'atlas.removeFromBucket': 'Usuń z listy marzeń', + 'atlas.removeFromBucketHint': 'Usuń ten kraj ze swojej listy marzeń', 'atlas.bucketWhen': 'Kiedy planujesz je odwiedzić?', 'atlas.statsTab': 'Statystyki', 'atlas.bucketTab': 'Lista marzeń', diff --git a/shared/src/i18n/ru/atlas.ts b/shared/src/i18n/ru/atlas.ts index 84fe0a849..6978580d5 100644 --- a/shared/src/i18n/ru/atlas.ts +++ b/shared/src/i18n/ru/atlas.ts @@ -53,6 +53,8 @@ const atlas: TranslationStrings = { 'atlas.searchCountry': 'Поиск страны...', 'atlas.month': 'Месяц', 'atlas.addToBucketHint': 'Сохранить как место для посещения', + 'atlas.removeFromBucket': 'Убрать из списка желаний', + 'atlas.removeFromBucketHint': 'Удалить эту страну из списка желаний', 'atlas.bucketWhen': 'Когда вы планируете поехать?', 'atlas.planned': 'Запланировано', 'atlas.showPlanned': 'Показать запланированные страны', diff --git a/shared/src/i18n/sv/atlas.ts b/shared/src/i18n/sv/atlas.ts index b4c985ec3..0f210ed19 100644 --- a/shared/src/i18n/sv/atlas.ts +++ b/shared/src/i18n/sv/atlas.ts @@ -19,6 +19,8 @@ const atlas: TranslationStrings = { 'atlas.month': 'Månad', 'atlas.year': 'År', 'atlas.addToBucketHint': 'Spara som en plats du vill besöka', + 'atlas.removeFromBucket': 'Ta bort från bucketlistan', + 'atlas.removeFromBucketHint': 'Ta bort det här landet från din bucketlista', 'atlas.bucketWhen': 'När har du tänkt att komma på besök?', 'atlas.statsTab': 'Statistik', 'atlas.bucketTab': 'Bucketlista', diff --git a/shared/src/i18n/tr/atlas.ts b/shared/src/i18n/tr/atlas.ts index 272d60e00..f6897bd94 100644 --- a/shared/src/i18n/tr/atlas.ts +++ b/shared/src/i18n/tr/atlas.ts @@ -19,6 +19,8 @@ const atlas: TranslationStrings = { 'atlas.month': 'Ay', 'atlas.year': 'Yıl', 'atlas.addToBucketHint': 'Ziyaret etmek istediğiniz yer olarak kaydedin', + 'atlas.removeFromBucket': 'Yapılacaklar listesinden kaldır', + 'atlas.removeFromBucketHint': 'Bu ülkeyi yapılacaklar listenizden çıkarın', 'atlas.bucketWhen': 'Ne zaman ziyaret etmeyi planlıyorsunuz?', 'atlas.statsTab': 'İstatistikler', 'atlas.bucketTab': 'Yapılacaklar Listesi', diff --git a/shared/src/i18n/uk/atlas.ts b/shared/src/i18n/uk/atlas.ts index 18b0da5b2..edf93a507 100644 --- a/shared/src/i18n/uk/atlas.ts +++ b/shared/src/i18n/uk/atlas.ts @@ -53,6 +53,8 @@ const atlas: TranslationStrings = { 'atlas.searchCountry': 'Пошук країни...', 'atlas.month': 'Місяць', 'atlas.addToBucketHint': 'Зберегти як місце для відвідування', + 'atlas.removeFromBucket': 'Видалити зі списку бажань', + 'atlas.removeFromBucketHint': 'Прибрати цю країну зі списку бажань', 'atlas.bucketWhen': 'Коли ви плануєте поїхати?', 'atlas.planned': 'Заплановано', 'atlas.showPlanned': 'Показати заплановані країни', diff --git a/shared/src/i18n/vi/atlas.ts b/shared/src/i18n/vi/atlas.ts index b5a7ababf..ce6e4e4cf 100644 --- a/shared/src/i18n/vi/atlas.ts +++ b/shared/src/i18n/vi/atlas.ts @@ -19,6 +19,8 @@ const atlas: TranslationStrings = { 'atlas.month': 'Tháng', 'atlas.year': 'Năm', 'atlas.addToBucketHint': 'Lưu làm địa điểm bạn muốn ghé thăm', + 'atlas.removeFromBucket': 'Xóa khỏi danh sách nhóm', + 'atlas.removeFromBucketHint': 'Bỏ quốc gia này khỏi danh sách nhóm của bạn', 'atlas.bucketWhen': 'Khi nào bạn có kế hoạch đến thăm?', 'atlas.statsTab': 'Thống kê', 'atlas.bucketTab': 'Danh sách nhóm', diff --git a/shared/src/i18n/zh-TW/atlas.ts b/shared/src/i18n/zh-TW/atlas.ts index ffd3f0277..9f34ae421 100644 --- a/shared/src/i18n/zh-TW/atlas.ts +++ b/shared/src/i18n/zh-TW/atlas.ts @@ -53,6 +53,8 @@ const atlas: TranslationStrings = { 'atlas.searchCountry': '搜尋國家...', 'atlas.month': '月份', 'atlas.addToBucketHint': '儲存為想去的地方', + 'atlas.removeFromBucket': '從心願單移除', + 'atlas.removeFromBucketHint': '將此國家從心願單中移除', 'atlas.bucketWhen': '你計劃什麼時候去?', 'atlas.planned': '計劃中', 'atlas.showPlanned': '顯示計劃中的國家', diff --git a/shared/src/i18n/zh/atlas.ts b/shared/src/i18n/zh/atlas.ts index d609c466a..f42cba55f 100644 --- a/shared/src/i18n/zh/atlas.ts +++ b/shared/src/i18n/zh/atlas.ts @@ -53,6 +53,8 @@ const atlas: TranslationStrings = { 'atlas.searchCountry': '搜索国家...', 'atlas.month': '月份', 'atlas.addToBucketHint': '保存为想去的地方', + 'atlas.removeFromBucket': '从心愿单移除', + 'atlas.removeFromBucketHint': '将此国家从心愿单中移除', 'atlas.bucketWhen': '你计划什么时候去?', 'atlas.planned': '计划中', 'atlas.showPlanned': '显示计划中的国家', diff --git a/wiki/Atlas.md b/wiki/Atlas.md index e10a8ffe3..eed4ca294 100644 --- a/wiki/Atlas.md +++ b/wiki/Atlas.md @@ -22,12 +22,16 @@ Planned countries are hidden from the map by default. Use the **Show planned cou Countries you mark by hand always count as visited, whatever the dates of any trip going there. +Countries on your bucket list that you have not been to yet are drawn with a diagonal hatch, in the colour that country would carry once you get there. That keeps a wishlist entry easy to tell apart from a visited country and from the plain grey of everywhere else. Every country keeps its colour permanently: marking one more country as visited never reshuffles the rest of the map. + ## Marking countries as visited Click any country on the map to open an action popup where you can mark it as visited or add it to your bucket list. Use the search bar at the top of the map to find and fly to a country — pressing Enter or selecting a result from the dropdown opens the same action popup. To remove a manually-marked country (one with no trips or places recorded in it), click it on the map and confirm removal in the popup. +If the country is already on your bucket list, the same popup offers **Remove from wishlist**, which takes every entry for that country off the list without the detour through the Bucket List tab. + Visits detected automatically from your trips are shown in addition to any countries you mark manually. ### Sub-national regions