mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(atlas): search by place, and count cities that are actually cities (#1908)
* refactor(atlas): drop the last-trip tile from the stats bar The row beside the continent counts is statistics; a single trip sitting in it was neither, and the trip it named is already one click away in the country detail below. Its two locale keys go with it, along with atlas.lastVisitLabel, which nothing had rendered for a while. * feat(atlas): resolve a coordinate to its country and region Groundwork for searching the atlas by place: /locate answers with the admin1 region a point falls in, resolved against the same bundled polygons the visited colouring uses, so the answer is always something the map can highlight. A country without admin1 coverage resolves to a country and a null region, which is a real answer rather than a failure. * feat(atlas): search the map by place, not only by country Typing a city geocodes it through the same /maps/search everything else uses and offers the hits below the instant local country matches. Picking one flies there and opens the dialog for the region it sits in, so Milan marks Lombardy without the user knowing that is where Milan is. The country list stays offline and instant; only the place lookup waits on the network, debounced and sequence-guarded so a slow answer cannot overwrite a newer query. * fix(atlas): count cities instead of shop names The visited-cities figure scanned the address left to right and took the first part that was not a country and matched a latin-only pattern. The first part of a geocoded address is the place itself, so a trip to Tokyo counted Shibuya Sky, Shibuya Parco and Family Mart as three cities, while an address written in Japanese, Cyrillic, Greek or Korean counted none at all. Read from the administrative end instead, skipping the country, anything with digits, and the region already resolved for that place in place_regions, which is what separates the city from the region above it. On one real trip the count went from 20 entries, nearly all of them streets and shops, to Paris, Shibuya, Minato and three Berlin boroughs. * i18n(atlas): translate the place-search heading * docs(atlas): document place search and how cities are counted * fix(atlas): stop the search panel closing itself while results arrive The dropdown closed on mouseleave, which was survivable while it held only local country matches and appeared complete in one go. Geocoded places land a moment later and grow the list under a cursor already resting there, so the panel shut itself seconds after opening. Closes on a click outside now, Escape unchanged.
This commit is contained in:
@@ -244,13 +244,12 @@ describe('AtlasPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-ATLAS-004: last trip shows in highlights', () => {
|
||||
it('displays the lastTrip title returned by the API', async () => {
|
||||
describe('FE-PAGE-ATLAS-004: the highlights row no longer carries a last-trip tile', () => {
|
||||
it('keeps the trip out of the stats bar even when the API still returns one', async () => {
|
||||
render(<AtlasPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Paris Trip')).toBeInTheDocument();
|
||||
});
|
||||
// The row is stats only now; the trip lives in the country detail below.
|
||||
await waitFor(() => expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0));
|
||||
expect(screen.queryByText('Paris Trip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -760,24 +759,6 @@ describe('AtlasPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-ATLAS-026: lastTrip button click navigates to trip', () => {
|
||||
it('clicking the lastTrip button triggers navigation to the trip', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AtlasPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Paris Trip')).toBeInTheDocument());
|
||||
|
||||
// Click the Paris Trip button
|
||||
const parisTripEl = screen.getByText('Paris Trip');
|
||||
const tripButton = parisTripEl.closest('button') as HTMLButtonElement | null;
|
||||
if (tripButton) {
|
||||
await user.click(tripButton);
|
||||
// Navigation would happen; verify no error thrown
|
||||
expect(screen.queryByText('Paris Trip')).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-ATLAS-027: search clear via backspace triggers empty onChange branch', () => {
|
||||
it('clearing the search input by backspace covers the empty-query onChange branch', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -35,6 +35,7 @@ function AtlasPageDesktop(): React.ReactElement {
|
||||
atlas_country_search, set_atlas_country_search,
|
||||
atlas_country_results, set_atlas_country_results,
|
||||
atlas_country_open, set_atlas_country_open, atlas_country_options,
|
||||
atlas_place_results, atlas_places_loading, search_places, select_place_from_search,
|
||||
confirmAction, setConfirmAction, executeConfirmAction,
|
||||
bucketMonth, setBucketMonth, bucketYear, setBucketYear,
|
||||
bucketList, setBucketList, bucketTab, setBucketTab,
|
||||
@@ -89,6 +90,10 @@ function AtlasPageDesktop(): React.ReactElement {
|
||||
setOpen={set_atlas_country_open}
|
||||
options={atlas_country_options}
|
||||
onSelect={select_country_from_search}
|
||||
placeResults={atlas_place_results}
|
||||
placesLoading={atlas_places_loading}
|
||||
onQueryChange={search_places}
|
||||
onSelectPlace={select_place_from_search}
|
||||
/>
|
||||
<AtlasLayerToggle
|
||||
t={t}
|
||||
@@ -682,18 +687,6 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
||||
|
||||
{/* ═══ SECTION 3: Highlights & Streaks ═══ */}
|
||||
<div className="flex items-center gap-5 px-3 py-4">
|
||||
{/* Last trip */}
|
||||
{lastTrip && (
|
||||
<button onClick={() => onTripClick(lastTrip.id)} className="flex items-center gap-2.5 text-left transition-opacity hover:opacity-75">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-lg shrink-0" style={{ background: bg(0.06) }}>
|
||||
{lastTrip.countryCode ? countryCodeToFlag(lastTrip.countryCode) : <MapPin size={16} style={{ color: tm }} />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: tf }}>{t('atlas.lastTrip')}</p>
|
||||
<p className="text-[13px] font-bold truncate" style={{ color: tp }}>{lastTrip.title}</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{/* Streak */}
|
||||
{streak > 0 && (
|
||||
<div className="flex flex-col items-center justify-center px-3">
|
||||
|
||||
@@ -392,7 +392,7 @@ describe('AtlasPage wiring', () => {
|
||||
});
|
||||
|
||||
describe('sidebar', () => {
|
||||
it('FE-PAGE-ATLASW-019: the stats tab lists totals, continents ordered by size and the last trip', () => {
|
||||
it('FE-PAGE-ATLASW-019: the stats tab lists totals and continents ordered by size', () => {
|
||||
const atlas = setAtlas({
|
||||
data: buildAtlasData({
|
||||
continents: { Europe: 3, Asia: 5, Africa: 1 },
|
||||
@@ -411,8 +411,8 @@ describe('AtlasPage wiring', () => {
|
||||
expect(screen.getByText('atlas.yearInRow')).toBeInTheDocument();
|
||||
expect(screen.getByText(/atlas\.tripIn/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Paris Trip'));
|
||||
expect(atlas.navigate).toHaveBeenCalledWith('/trips/7');
|
||||
// The last-trip tile used to sit in this row; the row is stats only now.
|
||||
expect(screen.queryByText('Paris Trip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-ATLASW-020: an empty atlas shows the mascot empty state instead of the stats', () => {
|
||||
@@ -420,7 +420,6 @@ describe('AtlasPage wiring', () => {
|
||||
render(<AtlasPage />);
|
||||
|
||||
expect(screen.getByText('atlas.noData')).toBeInTheDocument();
|
||||
expect(screen.queryByText('atlas.lastTrip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-ATLASW-021: the country detail overlay lists trips and offers the manual unmark', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Search, X, ChevronRight } from 'lucide-react'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { Search, X, ChevronRight, MapPin, Loader2 } from 'lucide-react'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import type { AtlasPlaceHit } from './atlasModel'
|
||||
|
||||
type CountryOption = { code: string; label: string }
|
||||
|
||||
@@ -15,6 +16,12 @@ interface AtlasCountrySearchProps {
|
||||
setOpen: (v: boolean) => void
|
||||
options: CountryOption[]
|
||||
onSelect: (code: string) => void
|
||||
/** Geocoded places for the same query (#1115) — a second, clearly separated
|
||||
* section below the countries, so the instant local matches stay on top. */
|
||||
placeResults: AtlasPlaceHit[]
|
||||
placesLoading: boolean
|
||||
onQueryChange: (raw: string) => void
|
||||
onSelectPlace: (hit: AtlasPlaceHit) => void
|
||||
}
|
||||
|
||||
// The floating country search box that overlays the globe (search input + results
|
||||
@@ -22,13 +29,30 @@ interface AtlasCountrySearchProps {
|
||||
// markup are byte-identical to the inline version it replaced.
|
||||
export default function AtlasCountrySearch({
|
||||
dark, t, search, setSearch, results, setResults, open, setOpen, options, onSelect,
|
||||
placeResults, placesLoading, onQueryChange, onSelectPlace,
|
||||
}: AtlasCountrySearchProps): React.ReactElement {
|
||||
const boxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close on a click outside rather than on mouseleave. Leaving the box used to
|
||||
// close it, which was survivable while the list was only local countries and
|
||||
// appeared complete in one go. Geocoded places arrive a moment later and make
|
||||
// the list grow under a cursor that is already resting there, so the panel
|
||||
// closed by itself seconds after opening.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!boxRef.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [open, setOpen])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-20 flex justify-center"
|
||||
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 14px)', left: 0, right: 0, pointerEvents: 'none' }}
|
||||
>
|
||||
<div style={{ width: 'min(520px, calc(100vw - 28px))', pointerEvents: 'auto' }}>
|
||||
<div ref={boxRef} style={{ width: 'min(520px, calc(100vw - 28px))', pointerEvents: 'auto' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -47,6 +71,7 @@ export default function AtlasCountrySearch({
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value
|
||||
setSearch(raw)
|
||||
onQueryChange(raw)
|
||||
const q = raw.trim().toLowerCase()
|
||||
if (!q) {
|
||||
setResults([])
|
||||
@@ -69,7 +94,9 @@ export default function AtlasCountrySearch({
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
const first = results[0]
|
||||
if (first) onSelect(first.code)
|
||||
if (first) { onSelect(first.code); return }
|
||||
const place = placeResults[0]
|
||||
if (place) onSelectPlace(place)
|
||||
}
|
||||
}}
|
||||
placeholder={t('atlas.searchCountry')}
|
||||
@@ -101,7 +128,7 @@ export default function AtlasCountrySearch({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && results.length > 0 && (
|
||||
{open && (results.length > 0 || placeResults.length > 0 || placesLoading) && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
@@ -113,7 +140,6 @@ export default function AtlasCountrySearch({
|
||||
WebkitBackdropFilter: 'blur(18px) saturate(180%)',
|
||||
boxShadow: dark ? '0 12px 30px rgba(0,0,0,0.35)' : '0 12px 30px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
{results.map((r) => (
|
||||
<button
|
||||
@@ -144,6 +170,66 @@ export default function AtlasCountrySearch({
|
||||
<ChevronRight size={16} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{(placeResults.length > 0 || placesLoading) && (
|
||||
<div
|
||||
className="text-content-faint"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '7px 12px',
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.06em',
|
||||
borderTop: results.length > 0 ? '1px solid ' + (dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)') : 'none',
|
||||
}}
|
||||
>
|
||||
{placesLoading && <Loader2 size={11} className="animate-spin" />}
|
||||
{t('atlas.searchPlaces')}
|
||||
</div>
|
||||
)}
|
||||
{placeResults.map((p) => (
|
||||
<button
|
||||
key={`${p.lat},${p.lng},${p.name}`}
|
||||
onClick={() => onSelectPlace(p)}
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
padding: '10px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontFamily: 'inherit',
|
||||
textAlign: 'left',
|
||||
borderBottom: '1px solid ' + (dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'),
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 28, height: 20, borderRadius: 4, flexShrink: 0,
|
||||
display: 'grid', placeItems: 'center',
|
||||
background: dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.05)',
|
||||
}}
|
||||
>
|
||||
<MapPin size={13} className="text-content-faint" />
|
||||
</span>
|
||||
<span style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 650, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{p.name}
|
||||
</span>
|
||||
{p.address && (
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{p.address}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight size={16} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -89,6 +89,14 @@ export function withCountryMarkedVisited(prev: AtlasData, code: string): AtlasDa
|
||||
}
|
||||
}
|
||||
|
||||
/** A geocoded hit in the atlas search box (#1115), before it is resolved to a region. */
|
||||
export interface AtlasPlaceHit {
|
||||
name: string
|
||||
address: string | null
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
export interface BucketItem {
|
||||
id: number
|
||||
name: string
|
||||
|
||||
@@ -37,6 +37,7 @@ const lf = vi.hoisted(() => ({
|
||||
mapsCreated: 0,
|
||||
mapsRemoved: 0,
|
||||
markers: 0,
|
||||
map: null as null | { setView: ReturnType<typeof vi.fn> },
|
||||
reset() {
|
||||
this.mapHandlers = {};
|
||||
this.geoJson = [];
|
||||
@@ -82,6 +83,9 @@ vi.mock('leaflet', () => {
|
||||
createPane: vi.fn((name: string) => { lf.panes[name] = { style: {} }; }),
|
||||
getPane: vi.fn((name: string) => lf.panes[name]),
|
||||
};
|
||||
// Published so a test can assert the map actually moved (place search flies
|
||||
// there before the region lookup returns).
|
||||
lf.map = map;
|
||||
|
||||
const L = {
|
||||
map: vi.fn(() => { lf.mapsCreated += 1; return map; }),
|
||||
@@ -868,4 +872,80 @@ describe('useAtlas', () => {
|
||||
expect(styleFor('DE').fillColor).not.toBe(styleFor('FR').fillColor);
|
||||
});
|
||||
});
|
||||
// ── Place search (#1115) ───────────────────────────────────────────────────
|
||||
describe('searching for a place, not just a country', () => {
|
||||
const milan = { name: 'Milan', address: 'Milan, Lombardy, Italy', lat: 45.46, lng: 9.19 };
|
||||
|
||||
beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const typeAndSettle = async (query: string) => {
|
||||
act(() => atlas.search_places(query));
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(400); });
|
||||
};
|
||||
|
||||
it('FE-HOOK-ATLAS-051: geocodes the term and offers the hits', async () => {
|
||||
await mountAtlas();
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({ places: [milan], source: 'osm' })));
|
||||
|
||||
await typeAndSettle('milan');
|
||||
await waitFor(() => expect(atlas.atlas_place_results).toHaveLength(1));
|
||||
expect(atlas.atlas_place_results[0]).toMatchObject({ name: 'Milan', lat: 45.46, lng: 9.19 });
|
||||
});
|
||||
|
||||
it('FE-HOOK-ATLAS-052: leaves the network alone for a query too short to mean anything', async () => {
|
||||
await mountAtlas();
|
||||
const hit = vi.fn(() => HttpResponse.json({ places: [milan], source: 'osm' }));
|
||||
server.use(http.post('/api/maps/search', hit));
|
||||
|
||||
await typeAndSettle('mi');
|
||||
expect(hit).not.toHaveBeenCalled();
|
||||
expect(atlas.atlas_place_results).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ATLAS-053: drops a provider hit without usable coordinates', async () => {
|
||||
await mountAtlas();
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({
|
||||
places: [{ name: 'No coords' }, milan],
|
||||
source: 'osm',
|
||||
})));
|
||||
|
||||
await typeAndSettle('milan');
|
||||
await waitFor(() => expect(atlas.atlas_place_results).toHaveLength(1));
|
||||
expect(atlas.atlas_place_results[0].name).toBe('Milan');
|
||||
});
|
||||
|
||||
it('FE-HOOK-ATLAS-054: picking a place resolves its region and offers to mark it', async () => {
|
||||
await mountAtlas();
|
||||
server.use(
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [milan], source: 'osm' })),
|
||||
http.get('/api/addons/atlas/locate', () => HttpResponse.json({ country_code: 'IT', region_code: 'IT-25', region_name: 'Lombardia' })),
|
||||
);
|
||||
|
||||
await act(async () => { await atlas.select_place_from_search(milan); });
|
||||
|
||||
expect(atlas.confirmAction).toMatchObject({ type: 'choose-region', code: 'IT', regionCode: 'IT-25', name: 'Lombardia' });
|
||||
// The map went there rather than waiting for the lookup.
|
||||
expect(lf.map!.setView).toHaveBeenCalledWith([45.46, 9.19], 7, expect.anything());
|
||||
});
|
||||
|
||||
it('FE-HOOK-ATLAS-055: a region already visited offers removal instead', async () => {
|
||||
await mountAtlas({ regions: { IT: [{ code: 'IT-25', name: 'Lombardia', placeCount: 1 }] } });
|
||||
await waitFor(() => expect(atlas.visitedRegions.IT).toHaveLength(1));
|
||||
server.use(http.get('/api/addons/atlas/locate', () => HttpResponse.json({ country_code: 'IT', region_code: 'IT-25', region_name: 'Lombardia' })));
|
||||
|
||||
await act(async () => { await atlas.select_place_from_search(milan); });
|
||||
expect(atlas.confirmAction).toMatchObject({ type: 'unmark-region', regionCode: 'IT-25' });
|
||||
});
|
||||
|
||||
it('FE-HOOK-ATLAS-056: a country without region coverage falls back to the country flow', async () => {
|
||||
await mountAtlas();
|
||||
server.use(http.get('/api/addons/atlas/locate', () => HttpResponse.json({ country_code: 'FR', region_code: null, region_name: null })));
|
||||
|
||||
await act(async () => { await atlas.select_place_from_search({ ...milan, name: 'Somewhere' }); });
|
||||
// FR is in the fixture as visited with places, so the country flow opens its detail
|
||||
// rather than the mark dialog — either way, not a region dialog.
|
||||
expect(atlas.confirmAction?.type).not.toBe('choose-region');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ 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, wishlistA3Codes, countryColor, type AtlasData, type CountryDetail, type BucketItem } from './atlasModel'
|
||||
import { A2_TO_A3, countryStatus, isCountryVisible, normalizeRegionName, withCountryMarkedVisited, wishlistA3Codes, countryColor, type AtlasData, type AtlasPlaceHit, type CountryDetail, type BucketItem } from './atlasModel'
|
||||
import { continentForCountry, type VisitStatus } from '@trek/shared'
|
||||
|
||||
const PLANNED_KEY = 'trek_atlas_show_planned'
|
||||
@@ -149,6 +149,13 @@ export function useAtlas() {
|
||||
const [atlas_country_search, set_atlas_country_search] = useState('')
|
||||
const [atlas_country_results, set_atlas_country_results] = useState<{ code: string; label: string }[]>([])
|
||||
const [atlas_country_open, set_atlas_country_open] = useState(false)
|
||||
// Geocoded places beside the local country matches (#1115): searching for Milan
|
||||
// should not require knowing it sits in Lombardy. Kept in its own list so the
|
||||
// instant offline country filter never waits on a network round trip.
|
||||
const [atlas_place_results, set_atlas_place_results] = useState<AtlasPlaceHit[]>([])
|
||||
const [atlas_places_loading, set_atlas_places_loading] = useState(false)
|
||||
const placeSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const placeSearchSeqRef = useRef(0)
|
||||
|
||||
// visitedCountries drives the colour palette and every "how many" number;
|
||||
// visibleCountries drives what the map paints and what stays clickable.
|
||||
@@ -710,6 +717,89 @@ export function useAtlas() {
|
||||
setConfirmAction({ type: 'unmark', code, name: resolveName(code) })
|
||||
}
|
||||
|
||||
/** Debounced forward geocode for the atlas search box. Runs through the same
|
||||
* /maps/search everything else uses, so it follows the configured provider. */
|
||||
const search_places = (raw: string): void => {
|
||||
const query = raw.trim()
|
||||
if (placeSearchTimerRef.current) clearTimeout(placeSearchTimerRef.current)
|
||||
if (query.length < 3) {
|
||||
set_atlas_place_results([])
|
||||
set_atlas_places_loading(false)
|
||||
return
|
||||
}
|
||||
set_atlas_places_loading(true)
|
||||
// Sequence guard: a slow answer for an earlier query must not overwrite a
|
||||
// newer one the user has already typed past.
|
||||
const seq = ++placeSearchSeqRef.current
|
||||
placeSearchTimerRef.current = setTimeout(() => {
|
||||
mapsApi.search(query, language)
|
||||
.then(result => {
|
||||
if (seq !== placeSearchSeqRef.current) return
|
||||
// The provider blob is deliberately open (Google and OSM disagree on
|
||||
// fields), so narrow rather than cast.
|
||||
const hits: AtlasPlaceHit[] = []
|
||||
for (const raw of result.places || []) {
|
||||
const p = raw as Record<string, unknown>
|
||||
if (typeof p.lat !== 'number' || typeof p.lng !== 'number') continue
|
||||
hits.push({
|
||||
name: typeof p.name === 'string' && p.name ? p.name : query,
|
||||
address: typeof p.address === 'string' && p.address ? p.address : null,
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
})
|
||||
if (hits.length === 5) break
|
||||
}
|
||||
set_atlas_place_results(hits)
|
||||
})
|
||||
.catch(() => { if (seq === placeSearchSeqRef.current) set_atlas_place_results([]) })
|
||||
.finally(() => { if (seq === placeSearchSeqRef.current) set_atlas_places_loading(false) })
|
||||
}, 350)
|
||||
}
|
||||
|
||||
/**
|
||||
* Picking a geocoded place: fly there, then ask the server which country and admin1
|
||||
* region the coordinate falls in and offer the same dialog a click on that region
|
||||
* would. Zoom 7 because the region layer only loads from zoom 5 up, so landing
|
||||
* closer means the highlighted region is actually on screen.
|
||||
*/
|
||||
const select_place_from_search = async (hit: AtlasPlaceHit): Promise<void> => {
|
||||
set_atlas_country_search(hit.name)
|
||||
set_atlas_country_open(false)
|
||||
set_atlas_country_results([])
|
||||
set_atlas_place_results([])
|
||||
|
||||
try {
|
||||
mapInstance.current?.setView([hit.lat, hit.lng], 7, { animate: true })
|
||||
} catch (e) {
|
||||
console.error('Error flying to place', e)
|
||||
}
|
||||
|
||||
let info: { country_code: string | null; region_code: string | null; region_name: string | null }
|
||||
try {
|
||||
info = (await apiClient.get('/addons/atlas/locate', { params: { lat: hit.lat, lng: hit.lng } })).data
|
||||
} catch {
|
||||
return // The map already moved; a failed lookup just means no dialog.
|
||||
}
|
||||
if (!info.country_code) return
|
||||
|
||||
// No admin1 coverage for this country: fall back to the country flow, which is
|
||||
// what the search did before it knew about places at all.
|
||||
if (!info.region_code || !info.region_name) {
|
||||
select_country_from_search(info.country_code)
|
||||
return
|
||||
}
|
||||
|
||||
const countryName = resolveName(info.country_code)
|
||||
const alreadyVisited = (visitedRegions[info.country_code] || []).some(r => r.code === info.region_code)
|
||||
setConfirmAction({
|
||||
type: alreadyVisited ? 'unmark-region' : 'choose-region',
|
||||
code: info.country_code,
|
||||
name: info.region_name,
|
||||
regionCode: info.region_code,
|
||||
countryName,
|
||||
})
|
||||
}
|
||||
|
||||
const select_country_from_search = (country_code: string): void => {
|
||||
const country_label = resolveName(country_code)
|
||||
set_atlas_country_search(country_label)
|
||||
@@ -865,6 +955,7 @@ export function useAtlas() {
|
||||
atlas_country_search, set_atlas_country_search,
|
||||
atlas_country_results, set_atlas_country_results,
|
||||
atlas_country_open, set_atlas_country_open, atlas_country_options,
|
||||
atlas_place_results, atlas_places_loading, search_places, select_place_from_search,
|
||||
confirmAction, setConfirmAction, executeConfirmAction,
|
||||
bucketMonth, setBucketMonth, bucketYear, setBucketYear,
|
||||
bucketList, setBucketList, bucketTab, setBucketTab,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import type { RegionGeo } from '@trek/shared';
|
||||
import type { AtlasLocateResponse, RegionGeo } from '@trek/shared';
|
||||
import type { User } from '../../types';
|
||||
import { AtlasService } from './atlas.service';
|
||||
import { AtlasMarkRegionDto, AtlasCreateBucketItemDto, AtlasUpdateBucketItemDto } from './atlas.dto';
|
||||
@@ -51,6 +51,24 @@ export class AtlasController {
|
||||
return this.atlas.visitedRegions(user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which country and admin1 region a coordinate falls in (#1115). The Atlas search
|
||||
* used to know country names only, so finding Milan meant knowing it is in Lombardy
|
||||
* first. The client geocodes the term through /maps/search as everywhere else and
|
||||
* sends the winning coordinate here; resolution runs against the same bundled
|
||||
* polygons the visited-region colouring uses, so the answer is always a feature the
|
||||
* map can actually highlight.
|
||||
*/
|
||||
@Get('locate')
|
||||
async locate(@Query('lat') lat: string, @Query('lng') lng: string): Promise<AtlasLocateResponse> {
|
||||
const latNum = Number(lat);
|
||||
const lngNum = Number(lng);
|
||||
if (!Number.isFinite(latNum) || !Number.isFinite(lngNum) || Math.abs(latNum) > 90 || Math.abs(lngNum) > 180) {
|
||||
throw new HttpException({ error: 'Valid lat and lng are required' }, 400);
|
||||
}
|
||||
return this.atlas.locate(latNum, lngNum);
|
||||
}
|
||||
|
||||
@Get('regions/geo')
|
||||
async regionGeo(
|
||||
@Query('countries') countries: string | undefined,
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CONTINENT_MAP, strongerVisitStatus, todayUtc, tripVisitStatus, VisitStatus } from '@trek/shared';
|
||||
import type { AtlasLocateResponse } from '@trek/shared';
|
||||
import { Trip, Place } from '../../types';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import {
|
||||
getCountryFromCoords,
|
||||
getCountryGeoGz,
|
||||
getRegionFromCoords,
|
||||
getRegionGeo,
|
||||
geocodingInFlight,
|
||||
resolveCountryCodeSync,
|
||||
reverseGeocodeRegion,
|
||||
} from './atlas-geo';
|
||||
import { KNOWN_COUNTRIES } from './known-countries';
|
||||
import { cityFromAddress } from './city-from-address';
|
||||
import { haversineKm } from '../common/geo';
|
||||
|
||||
export type CreateBucketData = {
|
||||
@@ -705,6 +708,26 @@ export class AtlasService {
|
||||
|
||||
// ── Region GeoJSON / country geometry (delegates to the atlas-geo module) ──
|
||||
|
||||
/**
|
||||
* Resolve a coordinate to the country and admin1 region the map draws (#1115), so
|
||||
* searching for a city can highlight the region it sits in without the user knowing
|
||||
* which one that is. Reads the same bundled polygons as the visited-region colouring,
|
||||
* so the answer is always a feature the client can highlight. A country with no admin1
|
||||
* coverage in the bundle resolves to a country and a null region, which is a real
|
||||
* answer rather than a failure: zooming to the country is still what the user wanted.
|
||||
*/
|
||||
async locate(lat: number, lng: number): Promise<AtlasLocateResponse> {
|
||||
const countryCode = getCountryFromCoords(lat, lng);
|
||||
if (!countryCode) return { country_code: null, region_code: null, region_name: null };
|
||||
|
||||
const region = await getRegionFromCoords(countryCode, lat, lng);
|
||||
return {
|
||||
country_code: countryCode.toUpperCase(),
|
||||
region_code: region?.region_code ?? null,
|
||||
region_name: region?.region_name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
regionGeo(countries: string[]) {
|
||||
return getRegionGeo(countries);
|
||||
}
|
||||
@@ -796,11 +819,14 @@ export class AtlasService {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getTravelStats(userId: number) {
|
||||
const places = this.db.all<{ address: string | null; lat: number | null; lng: number | null }>(`
|
||||
SELECT DISTINCT p.address, p.lat, p.lng
|
||||
// The resolved region rides along so cityFromAddress can tell the city apart from
|
||||
// the region sitting right above it in the same address (#1115).
|
||||
const places = this.db.all<{ address: string | null; lat: number | null; lng: number | null; region_name: string | null }>(`
|
||||
SELECT DISTINCT p.address, p.lat, p.lng, pr.region_name
|
||||
FROM places p
|
||||
JOIN trips t ON p.trip_id = t.id
|
||||
LEFT JOIN trip_members tm ON t.id = tm.trip_id
|
||||
LEFT JOIN place_regions pr ON pr.place_id = p.id
|
||||
WHERE t.user_id = ? OR tm.user_id = ?
|
||||
`, userId, userId);
|
||||
|
||||
@@ -823,11 +849,8 @@ export class AtlasService {
|
||||
// Explicit null checks: lat/lng of exactly 0 (equator / prime meridian)
|
||||
// are valid coordinates the former falsy check silently dropped.
|
||||
if (p.lat != null && p.lng != null) coords.push({ lat: p.lat, lng: p.lng });
|
||||
if (p.address) {
|
||||
const parts = p.address.split(',').map(s => s.trim().replace(/\d{3,}/g, '').trim());
|
||||
const cityPart = parts.find(s => !KNOWN_COUNTRIES.has(s) && /^[A-Za-z\u00C0-\u00FF\s-]{2,}$/.test(s));
|
||||
if (cityPart) cities.add(cityPart);
|
||||
}
|
||||
const cityPart = cityFromAddress(p.address, part => KNOWN_COUNTRIES.has(part), p.region_name);
|
||||
if (cityPart) cities.add(cityPart);
|
||||
});
|
||||
|
||||
// Visited countries \u2014 same source the Atlas page uses: ISO-2 codes from
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Pull the settlement out of a formatted address, for the "cities visited" figure.
|
||||
*
|
||||
* The previous version scanned the parts left to right and took the first one that
|
||||
* was not a known country and matched a latin-only pattern. Both halves were wrong.
|
||||
* Left to right means the first part, which in a geocoder's output is the name of the
|
||||
* place itself, so "Shibuya Sky, 12, Shibuya 2, Shibuya, Tokyo, 150-0002, Japan"
|
||||
* counted "Shibuya Sky" as a city, and the pattern excluded every non-latin script,
|
||||
* so an address written in Japanese, Cyrillic, Greek or Korean counted nothing at all.
|
||||
*
|
||||
* Reading from the end instead lands on the administrative tail, where the settlement
|
||||
* lives. The trick for separating the city from the region above it is that we already
|
||||
* know the region: `place_regions.region_name` is filled by the geocoder, so it can
|
||||
* simply be skipped. For the address above, with the region resolved as Tokyo, walking
|
||||
* back past Japan (country), 150-0002 (digits) and Tokyo (the region) lands on Shibuya.
|
||||
*
|
||||
* This is still a heuristic over a formatted string. Structured `address.city` from the
|
||||
* geocoder would be exact, but the fast path for region resolution runs against bundled
|
||||
* polygons and never calls the geocoder at all, so storing a city would mean an extra
|
||||
* network round trip per place. Not worth it for one number on a stats card.
|
||||
*/
|
||||
|
||||
/** A part carrying digits is a postcode, a house number or a numbered district. */
|
||||
function hasDigits(part: string): boolean {
|
||||
return /\d/.test(part);
|
||||
}
|
||||
|
||||
function normalize(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function cityFromAddress(
|
||||
address: string | null | undefined,
|
||||
isKnownCountry: (part: string) => boolean,
|
||||
regionName?: string | null,
|
||||
): string | null {
|
||||
if (!address) return null;
|
||||
|
||||
const parts = address.split(',').map(p => p.trim()).filter(Boolean);
|
||||
// One part is the place's own name, never an address with a city in it.
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
const region = regionName ? normalize(regionName) : null;
|
||||
|
||||
// A geocoder always ends on the country, so drop the last part outright once the
|
||||
// address is long enough for that to be certain. The country-name list below still
|
||||
// runs for every part, but it is a finite list (it has no "Nederland", for one), and
|
||||
// this way a country it has never heard of cannot be counted as a city.
|
||||
const last = parts.length >= 3 ? parts.length - 2 : parts.length - 1;
|
||||
|
||||
// Index 0 stays out of the running: geocoders lead with the place itself.
|
||||
for (let i = last; i >= 1; i--) {
|
||||
const part = parts[i];
|
||||
if (hasDigits(part)) continue;
|
||||
if (isKnownCountry(part)) continue;
|
||||
if (region && normalize(part) === region) continue;
|
||||
// Anything with no letter at all (stray punctuation, a lone dash) is not a name.
|
||||
if (!/\p{L}/u.test(part)) continue;
|
||||
return part;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -7,6 +7,10 @@
|
||||
* into places.address, which is display text in the user's language.
|
||||
*/
|
||||
export const KNOWN_COUNTRIES = new Set([
|
||||
// Nominatim inserts an extra tier above the country for France and its overseas
|
||||
// territories, which reads exactly like a place name and would otherwise be counted
|
||||
// as a city.
|
||||
'Metropolitanes Frankreich', 'France métropolitaine', 'Metropolitan France',
|
||||
'Japan', 'Germany', 'Deutschland', 'France', 'Frankreich', 'Italy', 'Italien', 'Spain', 'Spanien',
|
||||
'United States', 'USA', 'United Kingdom', 'UK', 'Thailand', 'Australia', 'Australien',
|
||||
'Canada', 'Kanada', 'Mexico', 'Mexiko', 'Brazil', 'Brasilien', 'China', 'India', 'Indien',
|
||||
|
||||
@@ -184,4 +184,38 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => {
|
||||
expect(jp.status).toBe(200);
|
||||
expect(jp.body.status).toBe('planned');
|
||||
});
|
||||
// ── /locate (#1115) ────────────────────────────────────────────────────────
|
||||
describe('GET locate', () => {
|
||||
it('resolves a coordinate to the country and the region the map can highlight', async () => {
|
||||
// Rome. Italy has admin1 coverage in the bundle, so both come back.
|
||||
const res = await request(server)
|
||||
.get('/api/addons/atlas/locate?lat=41.9028&lng=12.4964')
|
||||
.set('Cookie', sessionCookie(userId));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.country_code).toBe('IT');
|
||||
expect(typeof res.body.region_code === 'string' || res.body.region_code === null).toBe(true);
|
||||
});
|
||||
|
||||
it('answers with nulls out at sea rather than failing', async () => {
|
||||
const res = await request(server)
|
||||
.get('/api/addons/atlas/locate?lat=0&lng=-140')
|
||||
.set('Cookie', sessionCookie(userId));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ country_code: null, region_code: null, region_name: null });
|
||||
});
|
||||
|
||||
it('rejects a missing or out-of-range coordinate', async () => {
|
||||
for (const query of ['', '?lat=41.9', '?lat=abc&lng=12.5', '?lat=91&lng=12.5', '?lat=41.9&lng=181']) {
|
||||
const res = await request(server).get(`/api/addons/atlas/locate${query}`).set('Cookie', sessionCookie(userId));
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Valid lat and lng are required' });
|
||||
}
|
||||
});
|
||||
|
||||
it('needs a session like every other atlas route', async () => {
|
||||
expect((await request(server).get('/api/addons/atlas/locate?lat=41.9&lng=12.5')).status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* The "cities visited" extractor (#1115). Every address below is either a real one
|
||||
* from a trip or the shape a geocoder returns for that part of the world, because
|
||||
* the bug being fixed here was precisely that the old rule looked plausible against
|
||||
* invented examples and failed against actual data.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { cityFromAddress } from '../../../src/nest/atlas/city-from-address';
|
||||
|
||||
const COUNTRIES = new Set(['Japan', 'France', 'Germany', 'Italy', 'Nederland', '日本', 'Россия', 'Ελλάδα', '대한민국']);
|
||||
const isCountry = (part: string) => COUNTRIES.has(part);
|
||||
const city = (address: string | null | undefined, region?: string | null) =>
|
||||
cityFromAddress(address, isCountry, region);
|
||||
|
||||
describe('cityFromAddress', () => {
|
||||
it('takes the city, not the name of the place itself', () => {
|
||||
// The old rule scanned left to right and returned "Shibuya Sky" for this.
|
||||
expect(city('Shibuya Sky, 12, Shibuya 2, Shibuya, Tokyo, 150-0002, Japan', 'Tokyo')).toBe('Shibuya');
|
||||
expect(city('Family Mart, Omotesandō, Kita-Aoyama 3, Kita-Aoyama, Minato, Tokyo, 107-0061, Japan', 'Tokyo')).toBe('Minato');
|
||||
expect(city('BNP Paribas, Boulevard de Courcelles, Paris, Île-de-France, 75008, France', 'Île-de-France')).toBe('Paris');
|
||||
});
|
||||
|
||||
it('skips the region so the city below it wins', () => {
|
||||
// Without the region hint, walking back from the end stops at the region.
|
||||
expect(city('Museum, Marienplatz, München, Bayern, 80331, Germany', 'Bayern')).toBe('München');
|
||||
expect(city('Museum, Marienplatz, München, Bayern, 80331, Germany')).toBe('Bayern');
|
||||
});
|
||||
|
||||
it('reads addresses that are not written in the latin alphabet', () => {
|
||||
// Every one of these returned nothing before.
|
||||
expect(city('渋谷スカイ, 渋谷区, 東京都, 日本', '東京都')).toBe('渋谷区');
|
||||
expect(city('Красная площадь, Москва, Россия')).toBe('Москва');
|
||||
expect(city('Πλάκα, Αθήνα, Ελλάδα')).toBe('Αθήνα');
|
||||
expect(city('경복궁, 종로구, 서울특별시, 대한민국', '서울특별시')).toBe('종로구');
|
||||
});
|
||||
|
||||
it('ignores postcodes, house numbers and numbered districts', () => {
|
||||
expect(city('Trevi Fountain, Piazza di Trevi, Rione II Trevi, Roma, 00187, Italy', 'Lazio')).toBe('Roma');
|
||||
expect(city('Cafe, 12, Paris 8e Arrondissement, Paris, 75008, France', 'Île-de-France')).toBe('Paris');
|
||||
});
|
||||
|
||||
it('has nothing to offer for an address that is only a name', () => {
|
||||
expect(city('Eiffel Tower')).toBeNull();
|
||||
expect(city('')).toBeNull();
|
||||
expect(city(null)).toBeNull();
|
||||
expect(city(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null rather than a country when everything else drops out', () => {
|
||||
expect(city('Somewhere, France')).toBeNull();
|
||||
expect(city('Somewhere, 75008, France')).toBeNull();
|
||||
});
|
||||
|
||||
it('survives stray separators', () => {
|
||||
expect(city('Place, , Lyon, France')).toBe('Lyon');
|
||||
expect(city('Place, -, Lyon, France')).toBe('Lyon');
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,19 @@ export const regionGeoSchema = z.object({
|
||||
});
|
||||
export type RegionGeo = z.infer<typeof regionGeoSchema>;
|
||||
|
||||
/**
|
||||
* Where a coordinate lands on the atlas map (#1115). Both fields are nullable: the
|
||||
* point may sit outside every bundled polygon (open sea, a simplification gap at a
|
||||
* coastline), and plenty of countries have no admin1 coverage in the bundle at all,
|
||||
* in which case the country resolves and the region does not.
|
||||
*/
|
||||
export const atlasLocateResponseSchema = z.object({
|
||||
country_code: z.string().nullable(),
|
||||
region_code: z.string().nullable(),
|
||||
region_name: z.string().nullable(),
|
||||
});
|
||||
export type AtlasLocateResponse = z.infer<typeof atlasLocateResponseSchema>;
|
||||
|
||||
/**
|
||||
* How a country/region ended up on the atlas map. Before this, every trip counted as a
|
||||
* visit, so a booked-but-not-taken trip painted the map as if you had already been there.
|
||||
|
||||
@@ -15,6 +15,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات',
|
||||
'atlas.addPoi': 'إضافة مكان',
|
||||
'atlas.searchCountry': 'ابحث عن دولة...',
|
||||
'atlas.searchPlaces': 'الأماكن',
|
||||
'atlas.bucketNamePlaceholder': 'الاسم (بلد، مدينة، مكان…)',
|
||||
'atlas.month': 'الشهر',
|
||||
'atlas.year': 'السنة',
|
||||
@@ -33,7 +34,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'المدن',
|
||||
'atlas.noData': 'لا توجد بيانات سفر بعد',
|
||||
'atlas.noDataHint': 'أنشئ رحلة وأضف أماكن لرؤية خريطتك العالمية',
|
||||
'atlas.lastTrip': 'آخر رحلة',
|
||||
'atlas.nextTrip': 'الرحلة القادمة',
|
||||
'atlas.daysLeft': 'يوم متبقٍ',
|
||||
'atlas.streak': 'سلسلة',
|
||||
@@ -51,7 +51,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'أوقيانوسيا',
|
||||
'atlas.other': 'أخرى',
|
||||
'atlas.firstVisit': 'أول رحلة',
|
||||
'atlas.lastVisitLabel': 'آخر رحلة',
|
||||
'atlas.tripSingular': 'رحلة',
|
||||
'atlas.tripPlural': 'رحلات',
|
||||
'atlas.placeVisited': 'مكان تمت زيارته',
|
||||
|
||||
@@ -15,6 +15,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': 'Adicionar à lista de desejos',
|
||||
'atlas.addPoi': 'Adicionar lugar',
|
||||
'atlas.searchCountry': 'Buscar um país...',
|
||||
'atlas.searchPlaces': 'Lugares',
|
||||
'atlas.bucketNamePlaceholder': 'Nome (país, cidade, lugar…)',
|
||||
'atlas.month': 'Mês',
|
||||
'atlas.year': 'Ano',
|
||||
@@ -33,7 +34,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'Cidades',
|
||||
'atlas.noData': 'Ainda sem dados de viagem',
|
||||
'atlas.noDataHint': 'Crie uma viagem e adicione lugares para ver o mapa mundial',
|
||||
'atlas.lastTrip': 'Última viagem',
|
||||
'atlas.nextTrip': 'Próxima viagem',
|
||||
'atlas.daysLeft': 'dias restantes',
|
||||
'atlas.streak': 'Sequência',
|
||||
@@ -51,7 +51,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'Oceania',
|
||||
'atlas.other': 'Outro',
|
||||
'atlas.firstVisit': 'Primeira viagem',
|
||||
'atlas.lastVisitLabel': 'Última viagem',
|
||||
'atlas.tripSingular': 'Viagem',
|
||||
'atlas.tripPlural': 'Viagens',
|
||||
'atlas.placeVisited': 'Lugar visitado',
|
||||
|
||||
@@ -10,7 +10,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'Ciutats',
|
||||
'atlas.noData': 'Encara no hi ha dades de viatge',
|
||||
'atlas.noDataHint': 'Crea un viatge i afegeix llocs per veure el teu mapa del món',
|
||||
'atlas.lastTrip': 'Darrer viatge',
|
||||
'atlas.nextTrip': 'Proper viatge',
|
||||
'atlas.daysLeft': 'dies restants',
|
||||
'atlas.streak': 'Ratxa',
|
||||
@@ -29,7 +28,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'Oceania',
|
||||
'atlas.other': 'Altres',
|
||||
'atlas.firstVisit': 'Primer viatge',
|
||||
'atlas.lastVisitLabel': 'Darrer viatge',
|
||||
'atlas.tripSingular': 'Viatge',
|
||||
'atlas.tripPlural': 'Viatges',
|
||||
'atlas.placeVisited': 'Lloc visitat',
|
||||
@@ -51,6 +49,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': 'Afegeix a la llista de desitjos',
|
||||
'atlas.addPoi': 'Afegeix un lloc',
|
||||
'atlas.searchCountry': 'Cerca un país...',
|
||||
'atlas.searchPlaces': 'Llocs',
|
||||
'atlas.month': 'Mes',
|
||||
'atlas.addToBucketHint': 'Desa-ho com a lloc que vols visitar',
|
||||
'atlas.removeFromBucket': 'Elimina de la llista de desitjos',
|
||||
|
||||
@@ -31,7 +31,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'Města',
|
||||
'atlas.noData': 'Zatím žádná cestovatelská data',
|
||||
'atlas.noDataHint': 'Vytvořte cestu a přidejte místa, abyste viděli svou mapu světa',
|
||||
'atlas.lastTrip': 'Poslední cesta',
|
||||
'atlas.nextTrip': 'Další cesta',
|
||||
'atlas.daysLeft': 'dní zbývá',
|
||||
'atlas.streak': 'Série',
|
||||
@@ -50,12 +49,12 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'Oceánie',
|
||||
'atlas.other': 'Ostatní',
|
||||
'atlas.firstVisit': 'První cesta',
|
||||
'atlas.lastVisitLabel': 'Poslední cesta',
|
||||
'atlas.tripSingular': 'Cesta',
|
||||
'atlas.tripPlural': 'Cesty',
|
||||
'atlas.placeVisited': 'Navštívené místo',
|
||||
'atlas.placesVisited': 'Navštívená místa',
|
||||
'atlas.searchCountry': 'Hledat zemi...',
|
||||
'atlas.searchPlaces': 'Místa',
|
||||
'atlas.planned': 'Plánováno',
|
||||
'atlas.showPlanned': 'Zobrazit plánované země',
|
||||
'atlas.plannedFor': 'Plánováno na',
|
||||
|
||||
@@ -15,6 +15,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': 'Zur Bucket List',
|
||||
'atlas.addPoi': 'Ort hinzufügen',
|
||||
'atlas.searchCountry': 'Land suchen...',
|
||||
'atlas.searchPlaces': 'Orte',
|
||||
'atlas.bucketNamePlaceholder': 'Name (Land, Stadt, Ort...)',
|
||||
'atlas.month': 'Monat',
|
||||
'atlas.year': 'Jahr',
|
||||
@@ -33,7 +34,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'Städte',
|
||||
'atlas.noData': 'Noch keine Reisedaten',
|
||||
'atlas.noDataHint': 'Erstelle einen Trip und füge Orte hinzu',
|
||||
'atlas.lastTrip': 'Letzter Trip',
|
||||
'atlas.nextTrip': 'Nächster Trip',
|
||||
'atlas.daysLeft': 'Tage',
|
||||
'atlas.streak': 'Serie',
|
||||
@@ -51,7 +51,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'Ozeanien',
|
||||
'atlas.other': 'Andere',
|
||||
'atlas.firstVisit': 'Erste Reise',
|
||||
'atlas.lastVisitLabel': 'Letzte Reise',
|
||||
'atlas.tripSingular': 'Reise',
|
||||
'atlas.tripPlural': 'Reisen',
|
||||
'atlas.placeVisited': 'Ort besucht',
|
||||
|
||||
@@ -15,6 +15,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': 'Add to bucket list',
|
||||
'atlas.addPoi': 'Add place',
|
||||
'atlas.searchCountry': 'Search a country...',
|
||||
'atlas.searchPlaces': 'Places',
|
||||
'atlas.bucketNamePlaceholder': 'Name (country, city, place...)',
|
||||
'atlas.month': 'Month',
|
||||
'atlas.year': 'Year',
|
||||
@@ -33,7 +34,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'Cities',
|
||||
'atlas.noData': 'No travel data yet',
|
||||
'atlas.noDataHint': 'Create a trip and add places to see your world map',
|
||||
'atlas.lastTrip': 'Last trip',
|
||||
'atlas.nextTrip': 'Next trip',
|
||||
'atlas.daysLeft': 'days left',
|
||||
'atlas.streak': 'Streak',
|
||||
@@ -51,7 +51,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'Oceania',
|
||||
'atlas.other': 'Other',
|
||||
'atlas.firstVisit': 'First trip',
|
||||
'atlas.lastVisitLabel': 'Last trip',
|
||||
'atlas.tripSingular': 'Trip',
|
||||
'atlas.tripPlural': 'Trips',
|
||||
'atlas.placeVisited': 'Place visited',
|
||||
|
||||
@@ -10,7 +10,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'Ciudades',
|
||||
'atlas.noData': 'Aún no hay datos de viaje',
|
||||
'atlas.noDataHint': 'Crea un viaje y añade lugares para ver tu mapa del mundo',
|
||||
'atlas.lastTrip': 'Último viaje',
|
||||
'atlas.nextTrip': 'Próximo viaje',
|
||||
'atlas.daysLeft': 'días restantes',
|
||||
'atlas.streak': 'Racha',
|
||||
@@ -29,7 +28,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'Oceanía',
|
||||
'atlas.other': 'Otros',
|
||||
'atlas.firstVisit': 'Primer viaje',
|
||||
'atlas.lastVisitLabel': 'Último viaje',
|
||||
'atlas.tripSingular': 'Viaje',
|
||||
'atlas.tripPlural': 'Viajes',
|
||||
'atlas.placeVisited': 'Lugar visitado',
|
||||
@@ -51,6 +49,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': 'Añadir a lista de deseos',
|
||||
'atlas.addPoi': 'Añadir lugar',
|
||||
'atlas.searchCountry': 'Buscar un país...',
|
||||
'atlas.searchPlaces': 'Lugares',
|
||||
'atlas.month': 'Mes',
|
||||
'atlas.addToBucketHint': 'Guardar como lugar que quieres visitar',
|
||||
'atlas.removeFromBucket': 'Quitar de la lista de deseos',
|
||||
|
||||
@@ -10,7 +10,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'Villes',
|
||||
'atlas.noData': 'Aucune donnée de voyage',
|
||||
'atlas.noDataHint': 'Créez un voyage et ajoutez des lieux pour voir votre carte du monde',
|
||||
'atlas.lastTrip': 'Dernier voyage',
|
||||
'atlas.nextTrip': 'Prochain voyage',
|
||||
'atlas.daysLeft': 'jours restants',
|
||||
'atlas.streak': 'Série',
|
||||
@@ -29,7 +28,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'Océanie',
|
||||
'atlas.other': 'Autre',
|
||||
'atlas.firstVisit': 'Premier voyage',
|
||||
'atlas.lastVisitLabel': 'Dernier voyage',
|
||||
'atlas.tripSingular': 'Voyage',
|
||||
'atlas.tripPlural': 'Voyages',
|
||||
'atlas.placeVisited': 'Lieu visité',
|
||||
@@ -51,6 +49,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': 'Ajouter à la bucket list',
|
||||
'atlas.addPoi': 'Ajouter un lieu',
|
||||
'atlas.searchCountry': 'Rechercher un pays…',
|
||||
'atlas.searchPlaces': 'Lieux',
|
||||
'atlas.month': 'Mois',
|
||||
'atlas.addToBucketHint': 'Sauvegarder comme lieu à visiter',
|
||||
'atlas.removeFromBucket': 'Retirer de la bucket list',
|
||||
|
||||
@@ -15,6 +15,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': 'Προσθήκη στη λίστα επιθυμιών',
|
||||
'atlas.addPoi': 'Προσθήκη μέρους',
|
||||
'atlas.searchCountry': 'Αναζήτηση χώρας...',
|
||||
'atlas.searchPlaces': 'Μέρη',
|
||||
'atlas.bucketNamePlaceholder': 'Όνομα (χώρα, πόλη, μέρος...)',
|
||||
'atlas.month': 'Μήνας',
|
||||
'atlas.year': 'Έτος',
|
||||
@@ -33,7 +34,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'Πόλεις',
|
||||
'atlas.noData': 'Δεν υπάρχουν ταξιδιωτικά δεδομένα ακόμη',
|
||||
'atlas.noDataHint': 'Δημιουργήστε ένα ταξίδι και προσθέστε μέρη για να δείτε τον παγκόσμιο χάρτη σας',
|
||||
'atlas.lastTrip': 'Τελευταίο ταξίδι',
|
||||
'atlas.nextTrip': 'Επόμενο ταξίδι',
|
||||
'atlas.daysLeft': 'ημέρες απομένουν',
|
||||
'atlas.streak': 'Σερί',
|
||||
@@ -51,7 +51,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'Ωκεανία',
|
||||
'atlas.other': 'Άλλο',
|
||||
'atlas.firstVisit': 'Πρώτο ταξίδι',
|
||||
'atlas.lastVisitLabel': 'Τελευταίο ταξίδι',
|
||||
'atlas.tripSingular': 'Ταξίδι',
|
||||
'atlas.tripPlural': 'Ταξίδια',
|
||||
'atlas.placeVisited': 'Μέρος που επισκεφθήκατε',
|
||||
|
||||
@@ -15,6 +15,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': 'Hozzáadás a bakancslistához',
|
||||
'atlas.addPoi': 'Hely hozzáadása',
|
||||
'atlas.searchCountry': 'Ország keresése...',
|
||||
'atlas.searchPlaces': 'Helyek',
|
||||
'atlas.bucketNamePlaceholder': 'Név (ország, város, hely...)',
|
||||
'atlas.month': 'Hónap',
|
||||
'atlas.addToBucketHint': 'Mentés meglátogatni kívánt helyként',
|
||||
@@ -32,7 +33,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'Városok',
|
||||
'atlas.noData': 'Még nincsenek utazási adatok',
|
||||
'atlas.noDataHint': 'Hozz létre egy utazást és adj hozzá helyeket a világtérképhez',
|
||||
'atlas.lastTrip': 'Utolsó utazás',
|
||||
'atlas.nextTrip': 'Következő utazás',
|
||||
'atlas.daysLeft': 'nap van hátra',
|
||||
'atlas.streak': 'Sorozat',
|
||||
@@ -51,7 +51,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'Óceánia',
|
||||
'atlas.other': 'Egyéb',
|
||||
'atlas.firstVisit': 'Első utazás',
|
||||
'atlas.lastVisitLabel': 'Utolsó utazás',
|
||||
'atlas.tripSingular': 'Utazás',
|
||||
'atlas.tripPlural': 'Utazások',
|
||||
'atlas.placeVisited': 'Meglátogatott hely',
|
||||
|
||||
@@ -15,6 +15,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': 'Tambah ke bucket list',
|
||||
'atlas.addPoi': 'Tambah tempat',
|
||||
'atlas.searchCountry': 'Cari negara...',
|
||||
'atlas.searchPlaces': 'Tempat',
|
||||
'atlas.bucketNamePlaceholder': 'Nama (negara, kota, tempat...)',
|
||||
'atlas.month': 'Bulan',
|
||||
'atlas.year': 'Tahun',
|
||||
@@ -33,7 +34,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'Kota',
|
||||
'atlas.noData': 'Belum ada data perjalanan',
|
||||
'atlas.noDataHint': 'Buat perjalanan dan tambahkan tempat untuk melihat peta duniamu',
|
||||
'atlas.lastTrip': 'Perjalanan terakhir',
|
||||
'atlas.nextTrip': 'Perjalanan berikutnya',
|
||||
'atlas.daysLeft': 'hari lagi',
|
||||
'atlas.streak': 'Rentetan',
|
||||
@@ -51,7 +51,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'Oseania',
|
||||
'atlas.other': 'Lainnya',
|
||||
'atlas.firstVisit': 'Perjalanan pertama',
|
||||
'atlas.lastVisitLabel': 'Perjalanan terakhir',
|
||||
'atlas.tripSingular': 'Perjalanan',
|
||||
'atlas.tripPlural': 'Perjalanan',
|
||||
'atlas.placeVisited': 'Tempat dikunjungi',
|
||||
|
||||
@@ -31,7 +31,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'Città',
|
||||
'atlas.noData': 'Ancora nessun dato di viaggio',
|
||||
'atlas.noDataHint': 'Crea un viaggio e aggiungi luoghi per vedere la tua mappa del mondo',
|
||||
'atlas.lastTrip': 'Ultimo viaggio',
|
||||
'atlas.nextTrip': 'Prossimo viaggio',
|
||||
'atlas.daysLeft': 'giorni rimasti',
|
||||
'atlas.streak': 'Serie',
|
||||
@@ -50,12 +49,12 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'Oceania',
|
||||
'atlas.other': 'Altro',
|
||||
'atlas.firstVisit': 'Primo viaggio',
|
||||
'atlas.lastVisitLabel': 'Ultimo viaggio',
|
||||
'atlas.tripSingular': 'Viaggio',
|
||||
'atlas.tripPlural': 'Viaggi',
|
||||
'atlas.placeVisited': 'Luogo visitato',
|
||||
'atlas.placesVisited': 'Luoghi visitati',
|
||||
'atlas.searchCountry': 'Cerca un paese...',
|
||||
'atlas.searchPlaces': 'Luoghi',
|
||||
'atlas.planned': 'In programma',
|
||||
'atlas.showPlanned': 'Mostra i paesi in programma',
|
||||
'atlas.plannedFor': 'In programma per',
|
||||
|
||||
@@ -15,6 +15,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': '行きたいリストに追加',
|
||||
'atlas.addPoi': '場所を追加',
|
||||
'atlas.searchCountry': '国を検索...',
|
||||
'atlas.searchPlaces': '場所',
|
||||
'atlas.bucketNamePlaceholder': '名前(国・都市・場所など)',
|
||||
'atlas.month': '月',
|
||||
'atlas.year': '年',
|
||||
@@ -33,7 +34,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': '都市',
|
||||
'atlas.noData': '旅行データがありません',
|
||||
'atlas.noDataHint': '旅行を作成して場所を追加すると、マップに表示されます',
|
||||
'atlas.lastTrip': '前回の旅行',
|
||||
'atlas.nextTrip': '次の旅行',
|
||||
'atlas.daysLeft': '残り日数',
|
||||
'atlas.streak': '連続',
|
||||
@@ -51,7 +51,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'オセアニア',
|
||||
'atlas.other': 'その他',
|
||||
'atlas.firstVisit': '最初の旅行',
|
||||
'atlas.lastVisitLabel': '最後の旅行',
|
||||
'atlas.tripSingular': '旅行',
|
||||
'atlas.tripPlural': '旅行',
|
||||
'atlas.placeVisited': '訪問した場所',
|
||||
|
||||
@@ -15,6 +15,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': '버킷 리스트에 추가',
|
||||
'atlas.addPoi': '장소 추가',
|
||||
'atlas.searchCountry': '국가 검색...',
|
||||
'atlas.searchPlaces': '장소',
|
||||
'atlas.bucketNamePlaceholder': '이름 (국가, 도시, 장소...)',
|
||||
'atlas.month': '월',
|
||||
'atlas.year': '연도',
|
||||
@@ -33,7 +34,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': '도시',
|
||||
'atlas.noData': '아직 여행 데이터가 없습니다',
|
||||
'atlas.noDataHint': '여행을 만들고 장소를 추가하여 세계 지도를 확인하세요',
|
||||
'atlas.lastTrip': '마지막 여행',
|
||||
'atlas.nextTrip': '다음 여행',
|
||||
'atlas.daysLeft': '일 남음',
|
||||
'atlas.streak': '연속',
|
||||
@@ -51,7 +51,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': '오세아니아',
|
||||
'atlas.other': '기타',
|
||||
'atlas.firstVisit': '첫 번째 여행',
|
||||
'atlas.lastVisitLabel': '마지막 여행',
|
||||
'atlas.tripSingular': '여행',
|
||||
'atlas.tripPlural': '여행',
|
||||
'atlas.placeVisited': '방문한 장소',
|
||||
|
||||
@@ -10,7 +10,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'Steden',
|
||||
'atlas.noData': 'Nog geen reisgegevens',
|
||||
'atlas.noDataHint': 'Maak een reis aan en voeg plaatsen toe om je wereldkaart te zien',
|
||||
'atlas.lastTrip': 'Laatste reis',
|
||||
'atlas.nextTrip': 'Volgende reis',
|
||||
'atlas.daysLeft': 'dagen te gaan',
|
||||
'atlas.streak': 'Reeks',
|
||||
@@ -29,7 +28,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'Oceanië',
|
||||
'atlas.other': 'Overig',
|
||||
'atlas.firstVisit': 'Eerste reis',
|
||||
'atlas.lastVisitLabel': 'Laatste reis',
|
||||
'atlas.tripSingular': 'Reis',
|
||||
'atlas.tripPlural': 'Reizen',
|
||||
'atlas.placeVisited': 'Bezochte plaats',
|
||||
@@ -51,6 +49,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': 'Aan bucket list toevoegen',
|
||||
'atlas.addPoi': 'Plaats toevoegen',
|
||||
'atlas.searchCountry': 'Zoek een land...',
|
||||
'atlas.searchPlaces': 'Plekken',
|
||||
'atlas.month': 'Maand',
|
||||
'atlas.addToBucketHint': 'Opslaan als plek die je wilt bezoeken',
|
||||
'atlas.removeFromBucket': 'Uit bucket list verwijderen',
|
||||
|
||||
@@ -32,7 +32,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'Miasta',
|
||||
'atlas.noData': 'Brak danych o podróżach',
|
||||
'atlas.noDataHint': 'Utwórz podróż i dodaj miejsca, aby zobaczyć swoją mapę świata',
|
||||
'atlas.lastTrip': 'Ostatnia podróż',
|
||||
'atlas.nextTrip': 'Następna podróż',
|
||||
'atlas.daysLeft': 'dni do wyjazdu',
|
||||
'atlas.streak': 'Seria',
|
||||
@@ -50,12 +49,12 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'Oceania',
|
||||
'atlas.other': 'Inne',
|
||||
'atlas.firstVisit': 'Pierwsza podróż',
|
||||
'atlas.lastVisitLabel': 'Ostatnia podróż',
|
||||
'atlas.tripSingular': 'Podróż',
|
||||
'atlas.tripPlural': 'Podróże',
|
||||
'atlas.placeVisited': 'Odwiedzone miejsce',
|
||||
'atlas.placesVisited': 'Odwiedzone miejsca',
|
||||
'atlas.searchCountry': 'Szukaj kraju...',
|
||||
'atlas.searchPlaces': 'Miejsca',
|
||||
'atlas.planned': 'Zaplanowane',
|
||||
'atlas.showPlanned': 'Pokaż zaplanowane kraje',
|
||||
'atlas.plannedFor': 'Zaplanowano na',
|
||||
|
||||
@@ -10,7 +10,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'Города',
|
||||
'atlas.noData': 'Данных о поездках пока нет',
|
||||
'atlas.noDataHint': 'Создайте поездку и добавьте места, чтобы увидеть карту мира',
|
||||
'atlas.lastTrip': 'Последняя поездка',
|
||||
'atlas.nextTrip': 'Следующая поездка',
|
||||
'atlas.daysLeft': 'дней осталось',
|
||||
'atlas.streak': 'Серия',
|
||||
@@ -29,7 +28,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'Океания',
|
||||
'atlas.other': 'Другое',
|
||||
'atlas.firstVisit': 'Первая поездка',
|
||||
'atlas.lastVisitLabel': 'Последняя поездка',
|
||||
'atlas.tripSingular': 'Поездка',
|
||||
'atlas.tripPlural': 'Поездки',
|
||||
'atlas.placeVisited': 'Посещённое место',
|
||||
@@ -51,6 +49,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': 'В список желаний',
|
||||
'atlas.addPoi': 'Добавить место',
|
||||
'atlas.searchCountry': 'Поиск страны...',
|
||||
'atlas.searchPlaces': 'Места',
|
||||
'atlas.month': 'Месяц',
|
||||
'atlas.addToBucketHint': 'Сохранить как место для посещения',
|
||||
'atlas.removeFromBucket': 'Убрать из списка желаний',
|
||||
|
||||
@@ -15,6 +15,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': 'Lägg till på bucketlistan',
|
||||
'atlas.addPoi': 'Lägg till plats',
|
||||
'atlas.searchCountry': 'Sök efter ett land...',
|
||||
'atlas.searchPlaces': 'Platser',
|
||||
'atlas.bucketNamePlaceholder': 'Namn (land, stad, ort...)',
|
||||
'atlas.month': 'Månad',
|
||||
'atlas.year': 'År',
|
||||
@@ -33,7 +34,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'Städer',
|
||||
'atlas.noData': 'Inga resedata ännu',
|
||||
'atlas.noDataHint': 'Skapa en resa och lägg till platser på din världskarta',
|
||||
'atlas.lastTrip': 'Den senaste resan',
|
||||
'atlas.nextTrip': 'Nästa resa',
|
||||
'atlas.daysLeft': 'dagar kvar',
|
||||
'atlas.streak': 'Streak',
|
||||
@@ -51,7 +51,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'Oceanien',
|
||||
'atlas.other': 'Andra',
|
||||
'atlas.firstVisit': 'Första resan',
|
||||
'atlas.lastVisitLabel': 'Den senaste resan',
|
||||
'atlas.tripSingular': 'Resa',
|
||||
'atlas.tripPlural': 'Resor',
|
||||
'atlas.placeVisited': 'Plats besökt',
|
||||
|
||||
@@ -15,6 +15,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': 'Yapılacaklar listesine ekle',
|
||||
'atlas.addPoi': 'Yer ekle',
|
||||
'atlas.searchCountry': 'Bir ülkeyi arayın...',
|
||||
'atlas.searchPlaces': 'Yerler',
|
||||
'atlas.bucketNamePlaceholder': 'İsim (ülke, şehir, yer...)',
|
||||
'atlas.month': 'Ay',
|
||||
'atlas.year': 'Yıl',
|
||||
@@ -33,7 +34,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'Şehirler',
|
||||
'atlas.noData': 'Henüz seyahat verisi yok',
|
||||
'atlas.noDataHint': 'Bir gezi oluşturun ve dünya haritanızı göreceğiniz yerleri ekleyin',
|
||||
'atlas.lastTrip': 'Son yolculuk',
|
||||
'atlas.nextTrip': 'Sonraki gezi',
|
||||
'atlas.daysLeft': 'kalan günler',
|
||||
'atlas.streak': 'Rüzgâr gibi geçmek',
|
||||
@@ -51,7 +51,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'Okyanusya',
|
||||
'atlas.other': 'Diğer',
|
||||
'atlas.firstVisit': 'İlk yolculuk',
|
||||
'atlas.lastVisitLabel': 'Son yolculuk',
|
||||
'atlas.tripSingular': 'Seyahat',
|
||||
'atlas.tripPlural': 'Geziler',
|
||||
'atlas.placeVisited': 'Ziyaret edilen yer',
|
||||
|
||||
@@ -10,7 +10,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'Міста',
|
||||
'atlas.noData': 'Даних про подорожі поки немає',
|
||||
'atlas.noDataHint': 'Створіть подорож і додайте місця, щоб побачити карту світу',
|
||||
'atlas.lastTrip': 'Остання подорож',
|
||||
'atlas.nextTrip': 'Наступна подорож',
|
||||
'atlas.daysLeft': 'днів залишилося',
|
||||
'atlas.streak': 'Серія',
|
||||
@@ -29,7 +28,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'Океанія',
|
||||
'atlas.other': 'Інше',
|
||||
'atlas.firstVisit': 'Перша поїздка',
|
||||
'atlas.lastVisitLabel': 'Остання поїздка',
|
||||
'atlas.tripSingular': 'Поїздка',
|
||||
'atlas.tripPlural': 'Поїздки',
|
||||
'atlas.placeVisited': 'Відвідане місце',
|
||||
@@ -51,6 +49,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': 'До списку бажань',
|
||||
'atlas.addPoi': 'Додати місце',
|
||||
'atlas.searchCountry': 'Пошук країни...',
|
||||
'atlas.searchPlaces': 'Місця',
|
||||
'atlas.month': 'Місяць',
|
||||
'atlas.addToBucketHint': 'Зберегти як місце для відвідування',
|
||||
'atlas.removeFromBucket': 'Видалити зі списку бажань',
|
||||
|
||||
@@ -15,6 +15,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': 'Thêm vào danh sách nhóm',
|
||||
'atlas.addPoi': 'Thêm địa điểm',
|
||||
'atlas.searchCountry': 'Tìm kiếm một quốc gia...',
|
||||
'atlas.searchPlaces': 'Địa điểm',
|
||||
'atlas.bucketNamePlaceholder': 'Tên (quốc gia, thành phố, địa điểm...)',
|
||||
'atlas.month': 'Tháng',
|
||||
'atlas.year': 'Năm',
|
||||
@@ -33,7 +34,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'Thành phố',
|
||||
'atlas.noData': 'Chưa có dữ liệu du lịch',
|
||||
'atlas.noDataHint': 'Tạo chuyến đi và thêm địa điểm để xem bản đồ thế giới của bạn',
|
||||
'atlas.lastTrip': 'Chuyến đi cuối cùng',
|
||||
'atlas.nextTrip': 'Chuyến đi tiếp theo',
|
||||
'atlas.daysLeft': 'ngày còn lại',
|
||||
'atlas.streak': 'Vệt',
|
||||
@@ -51,7 +51,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': 'Châu Đại Dương',
|
||||
'atlas.other': 'Khác',
|
||||
'atlas.firstVisit': 'Chuyến đi đầu tiên',
|
||||
'atlas.lastVisitLabel': 'Chuyến đi cuối cùng',
|
||||
'atlas.tripSingular': 'Chuyến đi',
|
||||
'atlas.tripPlural': 'Chuyến đi',
|
||||
'atlas.placeVisited': 'Nơi đã ghé thăm',
|
||||
|
||||
@@ -10,7 +10,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': '城市',
|
||||
'atlas.noData': '暫無旅行資料',
|
||||
'atlas.noDataHint': '建立旅行並新增地點以檢視世界地圖',
|
||||
'atlas.lastTrip': '上次旅行',
|
||||
'atlas.nextTrip': '下次旅行',
|
||||
'atlas.daysLeft': '天后出發',
|
||||
'atlas.streak': '連續',
|
||||
@@ -29,7 +28,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': '大洋洲',
|
||||
'atlas.other': '其他',
|
||||
'atlas.firstVisit': '首次旅行',
|
||||
'atlas.lastVisitLabel': '最近旅行',
|
||||
'atlas.tripSingular': '次旅行',
|
||||
'atlas.tripPlural': '次旅行',
|
||||
'atlas.placeVisited': '個地點已訪問',
|
||||
@@ -51,6 +49,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': '新增到心願單',
|
||||
'atlas.addPoi': '新增地點',
|
||||
'atlas.searchCountry': '搜尋國家...',
|
||||
'atlas.searchPlaces': '地點',
|
||||
'atlas.month': '月份',
|
||||
'atlas.addToBucketHint': '儲存為想去的地方',
|
||||
'atlas.removeFromBucket': '從心願單移除',
|
||||
|
||||
@@ -10,7 +10,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': '城市',
|
||||
'atlas.noData': '暂无旅行数据',
|
||||
'atlas.noDataHint': '创建旅行并添加地点以查看世界地图',
|
||||
'atlas.lastTrip': '上次旅行',
|
||||
'atlas.nextTrip': '下次旅行',
|
||||
'atlas.daysLeft': '天后出发',
|
||||
'atlas.streak': '连续',
|
||||
@@ -29,7 +28,6 @@ const atlas: TranslationStrings = {
|
||||
'atlas.oceania': '大洋洲',
|
||||
'atlas.other': '其他',
|
||||
'atlas.firstVisit': '首次旅行',
|
||||
'atlas.lastVisitLabel': '最近旅行',
|
||||
'atlas.tripSingular': '次旅行',
|
||||
'atlas.tripPlural': '次旅行',
|
||||
'atlas.placeVisited': '个地点已访问',
|
||||
@@ -51,6 +49,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.addToBucket': '添加到心愿单',
|
||||
'atlas.addPoi': '添加地点',
|
||||
'atlas.searchCountry': '搜索国家...',
|
||||
'atlas.searchPlaces': '地点',
|
||||
'atlas.month': '月份',
|
||||
'atlas.addToBucketHint': '保存为想去的地方',
|
||||
'atlas.removeFromBucket': '从心愿单移除',
|
||||
|
||||
+3
-1
@@ -28,6 +28,8 @@ Countries on your bucket list that you have not been to yet are drawn with a dia
|
||||
|
||||
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.
|
||||
|
||||
The search box also finds **places**, not just countries. Type a city, a landmark or an address and the matches appear under a **Places** heading below the country hits. Picking one flies there and works out which sub-national region the coordinate falls in, so you can mark Lombardy as visited by searching for Milan, without knowing that Milan is in Lombardy. Countries with no region data in the map bundle fall back to the country itself.
|
||||
|
||||
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.
|
||||
@@ -49,7 +51,7 @@ Your Atlas statistics panel shows:
|
||||
- **Countries visited** — total number of distinct countries you have actually been to. Countries from upcoming trips are counted separately and shown next to this number.
|
||||
- **Trips** — total number of trips across all time.
|
||||
- **Places** — total number of individual places logged in trips.
|
||||
- **Cities** — total number of distinct cities visited.
|
||||
- **Cities** — total number of distinct cities visited. Derived from the addresses of your places, read from the administrative end of the address and cross-checked against the region the map resolved for that place, so a restaurant or a street is never mistaken for a city.
|
||||
- **Travel days** — total days spent travelling.
|
||||
- **Continent breakdown** — number of countries visited per continent (Europe, Asia, North America, South America, Africa, Oceania). Antarctica joins the row once you have been.
|
||||
- **Travel streak** — number of consecutive years in which you have taken at least one trip.
|
||||
|
||||
Reference in New Issue
Block a user