Compare commits

...

14 Commits

Author SHA1 Message Date
Maurice 067ab89430 fix(calendar): give transport events a LOCATION and GEO from their endpoints
A car or other transport booking carried its route only in the description, so
a calendar app had no place to navigate to. Without a location of its own, a
transport event now takes the endpoint the traveller has to get to: the
destination of a drive of their own (car, bicycle) and the departure of a ride
they board, the side the per-leg events already use. A car's pickup and
drop-off events are located at their own endpoints. A location the booking
states keeps winning.

Closes #2909
2026-10-30 21:54:19 +01:00
Maurice 019c668744 fix(llm-parse): ask Anthropic models for the extraction tool instead of forcing it
Claude Sonnet 5.5, Opus 5.5 and Fable 5.1 reject tool_choice type "tool" and
"any" with a 400, so every booking import through the Anthropic provider failed
on them. The request now sends tool_choice auto with parallel tool use off,
which every Anthropic model and Messages-compatible gateway accepts, and the
system prompt tells the model to answer through the emit tool exactly once. A
model that still answers in text is reported as before.

Closes #2902
2026-10-30 21:54:19 +01:00
Maurice b953ece4b3 fix(journey): stop the entry views from shifting while photos and providers load
Opening an entry on the share page showed the hero at no height until the
original arrived, then pushed the story down, and the lightbox stayed black
until the full photo had loaded. The hero now reserves the photo's proportions
(4:3 when none were recorded) with its thumbnail behind it, and the lightbox
shows the thumbnail and a spinner until the original is in.

The phone entry sheet probed the photo providers again on every open and added
the External photos button once the answer came, squeezing the icons of the
other two buttons. The journey screen already knows the connected providers, so
it hands them to the sheet, and the icons keep their size.

The picked neutral mood was a grey tint on a grey sheet; a picked mood is now
framed in its full colour and announced as pressed.

Closes #2907
Closes #2908
Closes #2905
2026-10-30 21:15:38 +01:00
Maurice 94a9cbf070 fix(journey): draw the live Dawarich trail in the colour of its day
The live trail arrived in Dawarich's own day palette, counted from the first
recorded day, while a journey colours its days by the entries. A day's line and
its pins therefore wore different colours. The trail of a day that has entries
now takes that day's colour, the way the kept recordings already did.

Closes #2906
2026-10-30 21:15:37 +01:00
Maurice 8eebcea9f5 fix(journey): keep GL photo markers absolutely positioned so thumbnails sit on their coordinates
The journey map's photo thumbnails set position:relative inline on the marker
element. That overrides the engine's .mapboxgl-marker/.maplibregl-marker
position:absolute, so every thumbnail entered the normal flow below the ones
drawn before it and the engine's translate was added on top: the further down
the list, the further the photo drifted from its spot. The location dot had the
same inline position. Both now leave position to the engine's class.

Closes #2910
2026-10-30 21:15:37 +01:00
Maurice 5e00282309 maps: drop indexHitsOnly, which the search no longer needs, and keep isHit inside its module 2026-10-30 17:26:56 +01:00
Maurice 3b675eed60 airports: publish the airport of a code as a contract the place search reads
The search turned an airport code into its airport by reaching into the
airports domain's data module. airports.contract.ts publishes that lookup
instead, as the import boundaries ask.
2026-10-30 17:14:55 +01:00
Maurice 51bbd337c9 i18n: format the admin strings of the TREK API card 2026-10-30 16:57:52 +01:00
Maurice 4203dcd91f maps: keep the merge and Photon's geocoding in modules of their own
maps.helpers.ts and osm.client.ts had grown past the 1000-line limit. The
merge behind the search and suggestion lists, with its lead rules, moves to
maps-merge.helpers.ts; the search records and Photon's half of the
OpenStreetMap geocoding (search, suggestions, airport codes, identity,
reverse, element details) move to osm-geocoding.ts. OsmClient keeps the
same methods and hands them on, so no caller changes.
2026-10-30 16:52:12 +01:00
Maurice 1e5314d847 maps, routing, i18n: settle what the checks found
- The lead is Photon's own row first; a row of its name inside the lead
  only stands in where Photon's row went as the same place. Rows without a
  line below the name are never folded as reading the same.
- A miss of Photon where it is the geocoder no longer asks it a second time
  on the way to an empty answer.
- The transport words of the identity match use a Unicode word boundary
  (straße, estación) and match 駅 anywhere.
- Opening an OSM place reads the coordinate it was placed at explicitly.
- The ferry spans of a Valhalla leg move to valhallaFerries.ts, and a day
  cut into pieces is asked with one options object.
- "Limit" on the allowance tiles is "Daily limit", which no locale left
  as an English copy.
- Tests follow the new behaviour: no Nominatim behind Photon, zoom 13,
  and the expectations of the merge and edit-distance cases.
2026-10-30 16:36:22 +01:00
Maurice b98e530ffc wiki: describe the address search, the merged result list and the routing of the TREK services 2026-10-30 16:16:10 +01:00
Maurice deb125bedc admin: show the TREK API as its three services
The card names place search, address search and routing with what each
answers and what is left of today's allowance. Each service is a tile with
what is left as its figure, the day's use as a bar and used and limit as
badges; the state, the kind of installation, a key from the environment
and the time the count starts again sit as badges in the header. The
sources add the terrain tiles the routes take their heights from.
2026-10-30 16:16:09 +01:00
Maurice c508bb9758 routing: a slow route is not a down relay, and the relay takes whole days
A request to the TREK Valhalla that runs out its deadline (a long bike
route) is answered 504 and no longer rests the relay for every user or a
tab for ten minutes; that one day goes to OSRM. Only an unreachable host
or a server error rests it.

A day goes to the relay with up to twenty stops per request and no pause
between pieces; a request that ends up at the public FOSSGIS Valhalla is
cut to ten and paced, as before. The 1.1 s spacing of the server applies
to the public hosts only, not to an admin's own OSRM or Valhalla. The
relay keeps its connections open and asks for maneuvers only, which the
relay contract now accepts.
2026-10-30 16:16:09 +01:00
Maurice a55d4e786f maps: lead with the place a name stands for, and leave Nominatim to installs without the services
The suggestions and the full search now share one merge. Photon's hits are
ranked with places that have a Wikidata link before their unlinked
namesakes, and its context bias is narrower (zoom 13). The list opens on a
lead where Photon's answer has one: a country or region always; a town,
island or sight when it is called what was typed (or a near typo of it),
lies within 1000 km of where the traveller is looking and no place of that
name ranks above it. Rows of the lead's name inside it, the same element
under two ids and rows that read the same are shown once. A three-letter
code in capitals is asked for as the airport of the bundled list.

Where Photon is the geocoder it answers alone: an empty answer and a miss
are the answer for search and imports, the reverse lookup asks again
within 50 km, a Google place is matched inside a box through Photon, and
an opened OpenStreetMap place is placed by Overpass and named by Photon.
Nominatim stays the search of installations without the TREK services or
with an operator's own Nominatim.

Measured on 5000 places from Wikidata: the right place first while typing
48 % before, 65 % now; full search 53 % to 68 %; no repeated rows.
2026-10-30 16:15:59 +01:00
95 changed files with 2923 additions and 630 deletions
+2 -2
View File
@@ -4,12 +4,12 @@
"@typescript-eslint/no-misused-promises": 847,
"no-console": 88,
"complexity": 218,
"@typescript-eslint/no-explicit-any": 146,
"@typescript-eslint/no-explicit-any": 144,
"trek/no-swallowed-catch": 129,
"max-params": 32,
"trek/store-without-selector": 67,
"react-hooks/refs": 555,
"react-hooks/set-state-in-effect": 194,
"react-hooks/set-state-in-effect": 193,
"@typescript-eslint/no-floating-promises": 164,
"react-hooks/exhaustive-deps": 116,
"@typescript-eslint/no-unused-vars": 124,
@@ -39,9 +39,13 @@ describe('ServiceIdentityPanel', () => {
const bar = screen.getByRole('progressbar', { name: 'admin.trekApi.quota.service.places' });
expect(bar).toHaveAttribute('aria-valuenow', '2500');
expect(bar).toHaveAttribute('aria-valuemax', '10000');
expect(screen.getByText('2,500 / 10,000')).toBeInTheDocument();
// What is left is the figure of the tile; used and the limit sit beside it as badges.
expect(screen.getByText('7,500')).toBeInTheDocument();
expect(screen.getByText('admin.trekApi.quota.resets')).toBeInTheDocument();
expect(screen.getByText('2,500')).toBeInTheDocument();
expect(screen.getByText('10,000')).toBeInTheDocument();
expect(screen.getByText('admin.trekApi.quota.limit')).toBeInTheDocument();
// The day starts again at the same time for every service, so it is said once, in the header.
expect(screen.getAllByText('admin.trekApi.quota.resets')).toHaveLength(1);
expect(screen.queryByText(/admin\.trekApi\.quota\.hint\./)).toBeNull();
});
@@ -63,7 +67,9 @@ describe('ServiceIdentityPanel', () => {
it('FE-ADMIN-SVCID-004: a key from the environment is named, and only changed there', async () => {
serve({ ...CONNECTED, keySource: 'env', tier: 'own' });
render(<ServiceIdentityPanel />);
expect(await screen.findByText('admin.trekApi.quota.envKey')).toBeInTheDocument();
// A badge naming the variable; the sentence explaining it is its tooltip.
expect(await screen.findByText('TREK_SERVICES_KEY')).toBeInTheDocument();
expect(screen.getByText('admin.trekApi.quota.key')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'admin.trekApi.quota.regenerate' })).toBeNull();
});
@@ -1,5 +1,5 @@
import type { ServiceAllowance, ServiceIdentityState, ServiceIdentityStatus, ServiceQuotaView } from '@trek/shared';
import { RefreshCw } from 'lucide-react';
import { MapPin, RefreshCw, Route, Search, type LucideIcon } from 'lucide-react';
import { useRef, useState } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
@@ -8,6 +8,7 @@ import { getApiErrorMessage } from '../../utils/apiError';
import { formatClockTime } from '../../utils/formatters';
import ConfirmDialog from '../shared/ConfirmDialog';
import { useToast } from '../shared/Toast';
import { FigureBadge } from '../Tours/paddle/FigureBadge';
import { useServiceIdentity } from './useServiceIdentity';
const STATE_DOT: Record<ServiceIdentityState, string> = {
@@ -18,78 +19,88 @@ const STATE_DOT: Record<ServiceIdentityState, string> = {
revoked: 'bg-danger',
};
const SERVICE_ICON: Record<string, LucideIcon> = { places: MapPin, photon: Search, valhalla: Route };
/** Below a tenth of the day left the bar turns amber, at nothing left red. */
function barTone(quota: ServiceQuotaView): string {
if (quota.remaining === 0) return 'bg-danger';
return quota.remaining < quota.limit / 10 ? 'bg-warning' : 'bg-accent';
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0">
<dt className="text-caption text-content-muted">{label}</dt>
<dd className="m-0 truncate text-body font-medium tabular-nums text-content">{value}</dd>
</div>
);
function figureTone(quota: ServiceQuotaView): string {
if (quota.remaining === 0) return 'text-danger';
return quota.remaining < quota.limit / 10 ? 'text-warning' : 'text-content';
}
function QuotaMeter({ quota, label }: { quota: ServiceQuotaView; label: string }) {
const { t, locale } = useTranslation();
/** The clock time the counts start again, in the user's format; one for all services, which share the day. */
function useResetTime(status: ServiceIdentityStatus | null): string | null {
const is12h = useSettingsStore((s) => s.settings.time_format === '12h');
const number = new Intl.NumberFormat(locale);
const share = quota.limit > 0 ? Math.min(100, (quota.used / quota.limit) * 100) : 100;
const reset = new Date(quota.resetAt);
const resetAt = formatClockTime(
const at = status?.services.find((s) => s.quota)?.quota?.resetAt;
if (!at) return null;
const reset = new Date(at);
return formatClockTime(
`${String(reset.getHours()).padStart(2, '0')}:${String(reset.getMinutes()).padStart(2, '0')}`,
is12h
);
return (
<div className="flex flex-col gap-2.5">
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={quota.limit}
aria-valuenow={quota.used}
className="h-1.5 overflow-hidden rounded-full bg-surface-tertiary"
>
<div
className={`h-full rounded-full transition-[width] duration-500 ${barTone(quota)}`}
style={{ width: `${share}%` }}
/>
</div>
<dl className="m-0 grid grid-cols-3 gap-3">
<Stat
label={t('admin.trekApi.quota.used')}
value={`${number.format(quota.used)} / ${number.format(quota.limit)}`}
/>
<Stat label={t('admin.trekApi.quota.remaining')} value={number.format(quota.remaining)} />
<Stat label={t('admin.trekApi.quota.resets')} value={resetAt} />
</dl>
</div>
);
}
function rowNote(t: TranslationFn, allowance: ServiceAllowance): string | null {
function tileNote(t: TranslationFn, allowance: ServiceAllowance): string | null {
if (!allowance.inUse) return t('admin.trekApi.quota.notInUse');
return allowance.quota ? null : t('admin.trekApi.quota.unavailable');
}
/**
* One service under the key: its name, today's allowance where its host told
* it, and whether this instance asks it at all. A service not in use is
* dimmed rather than hidden, so the list reads the same on every install.
* One service under the key, as a tile: what is left today as the figure that
* matters, the day's use as a bar, used and limit as badges. A service not in
* use is dimmed rather than hidden, so the row reads the same on every install.
*/
function ServiceRow({ allowance }: { allowance: ServiceAllowance }) {
const { t } = useTranslation();
function ServiceTile({ allowance }: { allowance: ServiceAllowance }) {
const { t, locale } = useTranslation();
const number = new Intl.NumberFormat(locale);
const name = t(`admin.trekApi.quota.service.${allowance.service}`);
const note = rowNote(t, allowance);
const note = tileNote(t, allowance);
const quota = allowance.quota;
const Icon = SERVICE_ICON[allowance.service] ?? MapPin;
const share = quota && quota.limit > 0 ? Math.min(100, (quota.used / quota.limit) * 100) : 100;
return (
<li className={`flex flex-col gap-2 py-2.5 first:pt-0 last:pb-0 ${allowance.inUse ? '' : 'opacity-60'}`}>
<p className="m-0 text-caption font-medium text-content-secondary">{name}</p>
{allowance.quota && <QuotaMeter quota={allowance.quota} label={name} />}
<li
className={`flex min-w-0 flex-col gap-2.5 rounded-xl border border-edge-faint bg-surface-card p-3 shadow-sm ${allowance.inUse ? '' : 'opacity-60'}`}
>
<div className="flex min-w-0 items-center gap-2">
<span className="grid h-7 w-7 flex-none place-items-center rounded-lg bg-surface-tertiary text-content-muted">
<Icon size={14} strokeWidth={2} aria-hidden="true" />
</span>
<p className="m-0 truncate text-caption font-medium text-content-secondary">{name}</p>
</div>
{quota && (
<>
<div className="min-w-0">
<p className={`m-0 truncate text-title font-semibold tabular-nums leading-tight ${figureTone(quota)}`}>
{number.format(quota.remaining)}
</p>
<p className="m-0 text-caption text-content-muted">{t('admin.trekApi.quota.remaining')}</p>
</div>
<div
role="progressbar"
aria-label={name}
aria-valuemin={0}
aria-valuemax={quota.limit}
aria-valuenow={quota.used}
className="h-1.5 overflow-hidden rounded-full bg-surface-tertiary"
>
<div
className={`h-full rounded-full transition-[width] duration-500 ${barTone(quota)}`}
style={{ width: `${share}%` }}
/>
</div>
<div className="flex flex-wrap gap-1.5">
<FigureBadge label={t('admin.trekApi.quota.used')} value={number.format(quota.used)} />
<FigureBadge label={t('admin.trekApi.quota.limit')} value={number.format(quota.limit)} />
</div>
</>
)}
{note && <p className="m-0 text-caption leading-snug text-content-muted">{note}</p>}
</li>
);
@@ -113,6 +124,7 @@ export default function ServiceIdentityPanel({ managed = false }: { managed?: bo
const panel = useRef<HTMLElement>(null);
const { status, failed, regenerating, regenerate } = useServiceIdentity(panel);
const [confirming, setConfirming] = useState(false);
const resetAt = useResetTime(status);
const confirmRegenerate = async () => {
setConfirming(false);
@@ -131,32 +143,38 @@ export default function ServiceIdentityPanel({ managed = false }: { managed?: bo
<section
ref={panel}
aria-label={t('admin.trekApi.quota.title')}
className="flex flex-col gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-3.5 py-3"
className="flex flex-col gap-3 rounded-xl border border-edge-faint bg-surface-secondary p-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="m-0 flex items-center gap-2 text-body font-medium text-content">
<p className="m-0 inline-flex h-[26px] items-center gap-2 rounded-full bg-surface-card px-2.5 text-caption font-semibold text-content shadow-sm">
<span aria-hidden="true" className={`h-2 w-2 flex-none rounded-full ${STATE_DOT[status?.state ?? 'off']}`} />
{status ? t(`admin.trekApi.quota.state.${status.state}`) : t('admin.trekApi.quota.title')}
</p>
{status?.tier && (
<span className="rounded-full border border-edge-faint bg-surface-card px-2 py-0.5 text-caption font-medium text-content-secondary">
{t(`admin.trekApi.quota.tier.${status.tier}`)}
</span>
)}
<div className="flex flex-wrap items-center gap-1.5">
{status?.tier && <FigureBadge value={t(`admin.trekApi.quota.tier.${status.tier}`)} />}
{status?.keySource === 'env' && (
<FigureBadge
label={t('admin.trekApi.quota.key')}
value="TREK_SERVICES_KEY"
info={t('admin.trekApi.quota.envKey')}
/>
)}
{resetAt && <FigureBadge label={t('admin.trekApi.quota.resets')} value={resetAt} />}
</div>
</div>
{status && status.services.length > 0 && (
<ul aria-label={t('admin.trekApi.quota.title')} className="m-0 list-none divide-y divide-edge-faint p-0">
<ul
aria-label={t('admin.trekApi.quota.title')}
className="m-0 grid list-none grid-cols-1 gap-2 p-0 sm:grid-cols-3"
>
{status.services.map((allowance) => (
<ServiceRow key={allowance.service} allowance={allowance} />
<ServiceTile key={allowance.service} allowance={allowance} />
))}
</ul>
)}
{hint && <p className="m-0 text-caption leading-snug text-content-muted">{hint}</p>}
{status?.keySource === 'env' && (
<p className="m-0 text-caption text-content-faint">{t('admin.trekApi.quota.envKey')}</p>
)}
{canRegenerate && (
<button
type="button"
@@ -1,8 +1,14 @@
import { describe, expect, it } from 'vitest';
import { TREK_API_SOURCES, trekApiFacts, trekApiFields } from './trekApiModel';
import {
TREK_API_SOURCES,
trekApiAddressFeatures,
trekApiFacts,
trekApiFields,
trekApiRoutingFeatures,
} from './trekApiModel';
// FE-TREKAPIMODEL-001 to FE-TREKAPIMODEL-003
// FE-TREKAPIMODEL-001 to FE-TREKAPIMODEL-004
const t = (key: string) => `<${key}>`;
@@ -22,10 +28,12 @@ describe('trekApiModel', () => {
]);
});
it('FE-TREKAPIMODEL-002: leads with four facts, each with a glyph', () => {
it('FE-TREKAPIMODEL-002: leads with the three services, then the terms, each with a glyph', () => {
const facts = trekApiFacts(t);
expect(facts.map((f) => f.text)).toEqual([
'<admin.trekApi.factPlaces>',
'<admin.trekApi.factAddresses>',
'<admin.trekApi.factRouting>',
'<admin.trekApi.factNoKey>',
'<admin.trekApi.factOffline>',
'<admin.trekApi.factPrivacy>',
@@ -34,6 +42,28 @@ describe('trekApiModel', () => {
});
it('FE-TREKAPIMODEL-003: names the data sources untranslated', () => {
expect(TREK_API_SOURCES).toEqual(['Overture Maps Foundation', 'OpenStreetMap', 'Wikivoyage', 'Wikimedia']);
expect(TREK_API_SOURCES).toEqual([
'Overture Maps Foundation',
'OpenStreetMap',
'Wikivoyage',
'Wikimedia',
'Mapzen Terrain Tiles',
]);
});
it('FE-TREKAPIMODEL-004: lists what the address search and the routing answer', () => {
expect(trekApiAddressFeatures(t)).toEqual([
'<admin.trekApi.addrStreets>',
'<admin.trekApi.addrTowns>',
'<admin.trekApi.addrReverse>',
'<admin.trekApi.addrLanguages>',
]);
expect(trekApiRoutingFeatures(t)).toEqual([
'<admin.trekApi.routeCar>',
'<admin.trekApi.routeBike>',
'<admin.trekApi.routeFoot>',
'<admin.trekApi.routeAvoid>',
'<admin.trekApi.routeElevation>',
]);
});
});
+33 -4
View File
@@ -1,13 +1,19 @@
import { Globe2, KeyRound, ShieldOff, WifiOff } from 'lucide-react';
import { Globe2, KeyRound, Route, Search, ShieldOff, WifiOff } from 'lucide-react';
import type { TranslationFn } from '../../types';
/**
* Proper nouns, so they are not translated. Naming them is also a licence obligation,
* not decoration: ODbL and CC BY-SA both require attribution wherever their content is
* shown.
* shown, and the terrain tiles the routes take their heights from ask to be named too.
*/
export const TREK_API_SOURCES = ['Overture Maps Foundation', 'OpenStreetMap', 'Wikivoyage', 'Wikimedia'];
export const TREK_API_SOURCES = [
'Overture Maps Foundation',
'OpenStreetMap',
'Wikivoyage',
'Wikimedia',
'Mapzen Terrain Tiles',
];
/**
* The fields a TREK Places API result carries, for the desktop card and the phone block
@@ -29,10 +35,33 @@ export function trekApiFields(t: TranslationFn): string[] {
];
}
/** The four facts the TREK Places API block leads with, each with its glyph. */
/** What the address search answers: an address typed, a town, the address of a point. */
export function trekApiAddressFeatures(t: TranslationFn): string[] {
return [
t('admin.trekApi.addrStreets'),
t('admin.trekApi.addrTowns'),
t('admin.trekApi.addrReverse'),
t('admin.trekApi.addrLanguages'),
];
}
/** What the routing does: the three ways of getting there, what a drive can avoid, the heights. */
export function trekApiRoutingFeatures(t: TranslationFn): string[] {
return [
t('admin.trekApi.routeCar'),
t('admin.trekApi.routeBike'),
t('admin.trekApi.routeFoot'),
t('admin.trekApi.routeAvoid'),
t('admin.trekApi.routeElevation'),
];
}
/** The facts the TREK API block leads with, each with its glyph: the three services first, then the terms. */
export function trekApiFacts(t: TranslationFn) {
return [
{ Icon: Globe2, text: t('admin.trekApi.factPlaces') },
{ Icon: Search, text: t('admin.trekApi.factAddresses') },
{ Icon: Route, text: t('admin.trekApi.factRouting') },
{ Icon: KeyRound, text: t('admin.trekApi.factNoKey') },
{ Icon: WifiOff, text: t('admin.trekApi.factOffline') },
{ Icon: ShieldOff, text: t('admin.trekApi.factPrivacy') },
@@ -1,4 +1,4 @@
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-048, FE-COMP-JMAPGL-2453
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-048, FE-COMP-JMAPGL-2453, FE-COMP-JMAPGL-2910
type Spy = ReturnType<typeof vi.fn>
@@ -644,6 +644,15 @@ describe('JourneyMapGL', () => {
expect(gl.map.on).toHaveBeenCalledWith('moveend', expect.any(Function))
})
it('FE-COMP-JMAPGL-2910: no marker element sets its own position, so the engine keeps it absolute', () => {
withToken()
const photos = [{ id: 'a', lat: 45, lng: 5, thumbUrl: '/api/photos/1/thumbnail' }]
const marks = [{ kind: 'stay', key: 's1', lat: 46, lng: 6, color: '#14b8a6', stay: {} }]
render(<JourneyMapGL checkins={[]} entries={entries} photos={photos} marks={marks} />)
expect(gl.markers.some(m => m.element.innerHTML.includes('/api/photos/'))).toBe(true)
for (const marker of gl.markers) expect(marker.element.style.position).toBe('')
})
it('FE-COMP-JMAPGL-2889: panning keeps the photo and mark groups, only a zoom regroups them', () => {
withToken()
const photos = [
@@ -557,7 +557,10 @@ function JourneyMapGL(
if (!photos?.length) return
for (const cluster of clusterPhotos(photos, glWorldPx(map))) {
const el = document.createElement('div')
el.style.cssText = 'position:relative;width:48px;height:48px;cursor:pointer'
// No inline `position` (#2910): the engine's marker class positions the element
// absolutely, and `position:relative` would drop it into the normal flow, where
// every thumbnail sits below the ones drawn before it.
el.style.cssText = 'width:48px;height:48px;cursor:pointer'
el.innerHTML = photoMarkerHtml(cluster.members[0].thumbUrl, cluster.members.length)
el.addEventListener('click', (ev) => {
ev.stopPropagation()
@@ -1,4 +1,4 @@
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-020
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-022
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
@@ -136,6 +136,18 @@ describe('MobileEntryView', () => {
expect(container.querySelector('svg.lucide-camera')).toBeNull()
})
it('FE-COMP-JENTRYVIEW-021: the hero holds the proportions of the photo and shows its thumbnail before the original arrives (#2907)', () => {
const { container } = renderView(buildEntry({ photos: [{ ...buildPhoto(4), width: 3000, height: 2000 }] }))
const hero = container.querySelector('img') as HTMLImageElement
expect(hero.style.aspectRatio).toBe('3000 / 2000')
expect(hero.style.backgroundImage).toContain('/api/photos/4/thumbnail')
})
it('FE-COMP-JENTRYVIEW-022: a photo without recorded dimensions still reserves a 4:3 hero', () => {
const { container } = renderView(buildEntry({ photos: [buildPhoto(4)] }))
expect((container.querySelector('img') as HTMLImageElement).style.aspectRatio).toBe('4 / 3')
})
it('FE-COMP-JENTRYVIEW-008: several photos add a counter and a thumbnail strip', () => {
const { container } = renderView(buildEntry({ photos: [buildPhoto(4), buildPhoto(5), buildPhoto(6)] }))
expect(screen.getByText('3 photos')).toBeInTheDocument()
@@ -4,6 +4,7 @@ import {
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
ThumbsUp, ThumbsDown,
} from 'lucide-react'
import type { CSSProperties } from 'react'
import JournalBody from './JournalBody'
import { useTranslation } from '../../i18n'
import { formatLocationName } from '../../utils/formatters'
@@ -39,6 +40,22 @@ function photoUrl(
return `/api/photos/${p.photo_id}/${size}`
}
/**
* The hero's box before its picture arrives (#2907): the photo's own proportions
* when the upload recorded them, else 4:3, so the story below does not jump down
* once the original has loaded. The thumbnail, usually cached by the strip or the
* feed already, fills that box meanwhile.
*/
function heroPlaceholderStyle(photo: JourneyPhoto, thumbUrl: string): CSSProperties {
const ratio = photo.width && photo.height ? `${photo.width} / ${photo.height}` : '4 / 3'
return {
aspectRatio: ratio,
backgroundImage: `url("${encodeURI(thumbUrl)}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}
}
interface Props {
entry: JourneyEntry
readOnly?: boolean
@@ -114,8 +131,10 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
<img
src={photoUrl(photos[0], photos[0].media_type === 'video' ? 'thumbnail' : 'original', publicPhotoUrl)}
alt=""
className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
decoding="async"
className={`block w-full max-h-[50vh] bg-surface-secondary ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
style={heroPlaceholderStyle(photos[0], photoUrl(photos[0], 'thumbnail', publicPhotoUrl))}
/>
)}
@@ -143,7 +162,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
key={p.id || i}
type="button"
aria-label={p.caption || t('journey.photos')}
className={`relative h-16 w-16 flex-shrink-0 cursor-pointer overflow-hidden rounded-lg border-0 p-0 hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all ${p.media_type === 'video' ? 'bg-black' : 'bg-transparent'
className={`relative h-16 w-16 flex-shrink-0 cursor-pointer overflow-hidden rounded-lg border-0 p-0 hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all ${p.media_type === 'video' ? 'bg-black' : 'bg-surface-secondary'
}`}
onClick={() => onPhotoClick(photos, i)}
>
@@ -1,4 +1,4 @@
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-027
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-029
// Plyr needs a real media pipeline, so the player is stubbed here.
vi.mock('./VideoPlayer', () => ({
@@ -326,4 +326,28 @@ describe('PhotoLightbox', () => {
expect(screen.getByText('Google', { selector: 'p' })).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('FE-COMP-LIGHTBOX-028: the thumbnail and a spinner stand in until the original has loaded (#2907)', () => {
const { container } = render(
<PhotoLightbox photos={[{ id: 'a', src: '/a/original', thumbSrc: '/a/thumbnail' }]} onClose={vi.fn()} />,
);
const placeholder = screen.getByTestId('lightbox-loading');
expect(placeholder.querySelector('img')).toHaveAttribute('src', '/a/thumbnail');
expect(placeholder.querySelector('svg.animate-spin')).toBeInTheDocument();
fireEvent.load(container.ownerDocument.querySelector('img[src="/a/original"]') as HTMLImageElement);
expect(screen.queryByTestId('lightbox-loading')).not.toBeInTheDocument();
});
it('FE-COMP-LIGHTBOX-029: the next photo waits for its own original, and a failed one drops the spinner too', () => {
render(
<PhotoLightbox photos={[{ id: 'a', src: '/a.jpg' }, { id: 'b', src: '/b.jpg' }]} onClose={vi.fn()} />,
);
fireEvent.load(document.querySelector('img[src="/a.jpg"]') as HTMLImageElement);
expect(screen.queryByTestId('lightbox-loading')).not.toBeInTheDocument();
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(screen.getByTestId('lightbox-loading')).toBeInTheDocument();
fireEvent.error(document.querySelector('img[src="/b.jpg"]') as HTMLImageElement);
expect(screen.queryByTestId('lightbox-loading')).not.toBeInTheDocument();
});
});
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { ChevronLeft, ChevronRight, X, ZoomIn, ZoomOut } from 'lucide-react'
import { ChevronLeft, ChevronRight, Loader2, X, ZoomIn, ZoomOut } from 'lucide-react'
import VideoPlayer from './VideoPlayerLazy'
import { usePhotoZoom } from './usePhotoZoom'
import { Tooltip } from '../shared/Tooltip'
@@ -8,6 +8,12 @@ import { isRtlLanguage, useTranslation } from '../../i18n'
interface LightboxPhoto {
id: string
src: string
/**
* A small rendition of the same photo, usually cached already by the grid that
* opened the lightbox. It stands in while the original loads, which on a slow
* server or connection can take seconds (#2907).
*/
thumbSrc?: string
caption?: string | null
/** Where the caption's credit points to (a photo's source page), opened in a new tab. */
captionHref?: string | null
@@ -52,6 +58,9 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
// Zoom starts over on every photo (#1484).
const zoom = usePhotoZoom(photo?.id)
const isImage = !!photo && photo.mediaType !== 'video'
// The id whose original has finished (or failed) loading; any other photo is still on its way.
const [loadedId, setLoadedId] = useState<string | null>(null)
const loading = isImage && loadedId !== photo.id
const hasPrev = idx > 0
const hasNext = idx < photos.length - 1
@@ -187,6 +196,8 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
src={photo.src}
alt={photo.caption || ''}
draggable={false}
onLoad={() => setLoadedId(photo.id)}
onError={() => setLoadedId(photo.id)}
{...zoom.mouse}
style={{
maxWidth: '92vw', maxHeight: '92vh',
@@ -198,6 +209,17 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
/>
)}
{loading && (
<div data-testid="lightbox-loading" aria-hidden="true" style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
{photo.thumbSrc && (
<img src={photo.thumbSrc} alt="" draggable={false} style={{ width: '92vw', height: '92vh', objectFit: 'contain', borderRadius: 4 }} />
)}
<span style={{ ...ROUND_BTN, position: 'absolute', cursor: 'default' }}>
<Loader2 size={18} className="animate-spin" />
</span>
</div>
)}
{/* Next button */}
{hasNext && (
<button type="button" onClick={next} aria-label={t('journey.lightbox.next')} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 focus-visible:opacity-100 transition-opacity" style={{
@@ -97,6 +97,8 @@ describe('attachLocationMarker', () => {
expect(root.children).toHaveLength(3)
// The cone starts hidden — there is no heading until the first fix.
expect((root.children[1] as HTMLElement).style.display).toBe('none')
// The GL marker class owns `position`; an inline one would drop it into the flow.
expect(root.style.position).toBe('')
})
it('FE-COMP-LOCMARKERGL-003: adds the accuracy source and fill layer when the map is already loaded', () => {
@@ -13,7 +13,9 @@ type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string
// and mapbox doesn't get confused about which element to position.
function buildLocationEl(): { root: HTMLDivElement; cone: HTMLDivElement } {
const root = document.createElement('div')
root.style.cssText = 'width:28px;height:28px;position:relative;pointer-events:none;'
// No inline `position`: the GL marker class keeps the root absolute, which the
// children's insets resolve against (see the note on markers in MapViewGL).
root.style.cssText = 'width:28px;height:28px;pointer-events:none;'
// Accuracy pulse behind the dot
const pulse = document.createElement('div')
pulse.style.cssText = `
@@ -192,6 +192,23 @@ describe('a day where Valhalla routes the days', () => {
expect([traffic.fossgis.length, traffic.osrm.length]).toEqual([0, 1]);
});
it('VDAY-004c: a day the relay ran out of time on goes to OSRM alone, and the next day asks the relay again', async () => {
let slow = true;
const traffic = listen((body) =>
slow
? HttpResponse.json({ error: 'The TREK routing service took too long for this route' }, { status: 504 })
: answered(body)
);
const fallen = await calculateRouteWithLegs(freshDay(3), { profile: 'cycling' });
expect([fallen.distance, fallen.engine]).toEqual([2000, 'osrm']);
expect([traffic.relay.length, traffic.fossgis.length, traffic.osrm.length]).toEqual([1, 0, 1]);
slow = false;
const next = await calculateRouteWithLegs(freshDay(3), { profile: 'cycling' });
expect([next.distance, next.engine]).toEqual([4000, 'valhalla']);
expect([traffic.relay.length, traffic.osrm.length]).toEqual([2, 1]);
});
it('VDAY-004b: an own Valhalla that does not answer in time hands the day to OSRM without a second wait', async () => {
choose({ valhalla_base_url: OWN_VALHALLA });
const timeout = AbortSignal.timeout.bind(AbortSignal);
@@ -0,0 +1,45 @@
import type { FerrySpan } from './valhallaRoute';
/** One step of a Valhalla leg, with the fields the ferry spans are read from. */
export interface ValhallaManeuver {
type?: number;
ferry?: boolean;
begin_shape_index?: number;
end_shape_index?: number;
time?: number;
length?: number;
}
/** Valhalla's maneuver type for boarding a ferry; the maneuver also carries `ferry: true` for its crossing. */
const FERRY_ENTER = 28;
/**
* CW-12: the stretches of a leg the router takes by boat, read off its
* maneuvers: a crossing is the maneuver that boards the ferry, from the point
* it starts at to the one it ends at, with its own length and time. Measured
* on Venice, the San Tomà to Ca' Rezzonico line came back as one maneuver of
* type 28, ferry true, 1.236 km and 745 s over shape points 24 to 46, then the
* walk off it. Crossings that touch are one. A leg the summary says has a
* ferry but whose maneuvers do not say where is taken whole, without a time:
* the crossing is somewhere on it, and none of it is counted as walked.
*/
export function ferrySpansOf(maneuvers: ValhallaManeuver[] | undefined, points: number): FerrySpan[] {
const spans: FerrySpan[] = [];
for (const maneuver of maneuvers ?? []) {
if (maneuver.ferry !== true && maneuver.type !== FERRY_ENTER) continue;
const from = Number(maneuver.begin_shape_index);
const to = Number(maneuver.end_shape_index);
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to <= from || to >= points) continue;
const metres = Number.isFinite(maneuver.length) ? Number(maneuver.length) * 1000 : 0;
const seconds = Number.isFinite(maneuver.time) ? Number(maneuver.time) : null;
const previous = spans[spans.length - 1];
if (previous && previous.to === from) {
previous.to = to;
previous.metres += metres;
previous.seconds = previous.seconds === null || seconds === null ? null : previous.seconds + seconds;
} else {
spans.push({ from, to, metres, seconds });
}
}
return spans.length || points < 2 ? spans : [{ from: 0, to: points - 1, metres: 0, seconds: null }];
}
@@ -364,6 +364,7 @@ describe('valhallaRun over a whole day', () => {
costing: 'pedestrian',
costing_options: { pedestrian: { max_hiking_difficulty: difficulty, use_ferry: 0 } },
directions_options: { units: 'kilometers' },
directions_type: 'maneuvers',
})
})
@@ -469,6 +470,53 @@ describe('valhallaRun over a whole day', () => {
expect(calls).toBe(3)
})
it('VALHALLA-RUN-009: asks the relay twenty locations at a time with no pause between the pieces', async () => {
const sizes: number[] = []
let fossgis = 0
server.use(
http.post('/api/routing/valhalla/:action', async ({ request }) => {
const body = await request.json() as { locations: unknown[] }
sizes.push(body.locations.length)
return HttpResponse.json(chain(body.locations.length - 1))
}),
http.post(FOSSGIS_VALHALLA, () => { fossgis++; return HttpResponse.json(chain(1)) }),
)
const started = Date.now()
const avoiding = await valhallaRun(stops(14), 'driving', ['toll'])
const long = await valhallaRun(stops(25), 'driving', ['toll'])
expect(avoiding?.legs).toHaveLength(13)
expect(long?.legs).toHaveLength(24)
expect(sizes).toEqual([14, 20, 6])
// The pause between pieces was 1.1 s; the relay and an own Valhalla are not paced.
expect(Date.now() - started).toBeLessThan(1000)
expect(fossgis).toBe(0)
})
it('VALHALLA-RUN-010: a day cut for the relay that the relay cannot answer is cut again in tens for FOSSGIS', async () => {
const relayed: number[] = []
const sizes: number[] = []
server.use(
http.post('/api/routing/valhalla/:action', async ({ request }) => {
relayed.push(((await request.json()) as { locations: unknown[] }).locations.length)
return HttpResponse.json({ error: 'down' }, { status: 502 })
}),
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
const body = await request.json() as { locations: unknown[] }
sizes.push(body.locations.length)
return HttpResponse.json(chain(body.locations.length - 1))
}),
)
const run = await valhallaRun(stops(14), 'driving', ['toll'])
expect(run?.legs).toHaveLength(13)
expect(relayed).toEqual([14])
// FOSSGIS never saw the piece of fourteen it would have refused with error 150.
expect(sizes).toEqual([10, 5])
})
it('VALHALLA-RUN-005: fewer than two points is nothing to route', async () => {
let calls = 0
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return HttpResponse.json(chain(1)) }))
@@ -735,10 +783,34 @@ describe('through the TREK services (routing relay)', () => {
expect(leg?.distance).toBeCloseTo(295700, 0)
expect(relayed).toHaveLength(1)
expect(relayed[0].action).toBe('route')
expect(relayed[0].body).toMatchObject({ costing: 'auto', costing_options: { auto: { use_tolls: 0 } } })
expect(relayed[0].body).toMatchObject({
costing: 'auto',
costing_options: { auto: { use_tolls: 0 } },
directions_type: 'maneuvers',
})
expect(fossgis).toBe(0)
})
it('VALHALLA-RELAY-009: a question the relay ran out of time on has no answer, without resting the relay or asking FOSSGIS', async () => {
let relayCalls = 0
let fossgis = 0
server.use(
http.post(RELAY, () => {
relayCalls++
return HttpResponse.json({ error: 'The TREK routing service took too long for this route' }, { status: 504 })
}),
http.post(FOSSGIS_VALHALLA, () => { fossgis++; return HttpResponse.json(answer()) }),
)
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'cycling', 'ferry')).resolves.toBeNull()
// Asked once: the relay would only run out of time on it again.
expect([relayCalls, fossgis]).toEqual([1, 0])
expect(valhallaBase()).toBe(TREK_VALHALLA)
server.use(http.post(RELAY, () => { relayCalls++; return HttpResponse.json(answer()) }))
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.not.toBeNull()
expect([relayCalls, fossgis]).toEqual([2, 0])
})
it('VALHALLA-RELAY-002: turns to FOSSGIS without a word where the relay cannot answer, and leaves it alone a while', async () => {
for (const status of [401, 429, 503]) {
resetValhallaTurns()
+62 -62
View File
@@ -9,6 +9,7 @@ import {
import { valhallaRelayRepo } from '../../repo/valhallaRelayRepo'
import { useSettingsStore } from '../../store/settingsStore'
import { decodePolyline } from './transitGeometry'
import { ferrySpansOf, type ValhallaManeuver } from './valhallaFerries'
import { haversineKm } from '../../utils/geo'
import type { Waypoint, SnappedWaypoint, RouteAvoidClass } from '../../types'
@@ -70,7 +71,7 @@ const RELAY_MAX_WAIT_MS = 60 * 60_000
/**
* How long a route or a height question may take through the relay or at an own Valhalla.
* The server gives the TREK Valhalla ten seconds; this leaves room for its 502 to arrive
* The server gives the TREK Valhalla ten seconds; this leaves room for its 504 to arrive
* first. Where the relay itself hangs, FOSSGIS answers soon after; where an own Valhalla
* does, OSRM routes the day, rather than the day waiting as long as the browser would.
*/
@@ -115,11 +116,11 @@ export type AvoidClass = RouteAvoidClass
const MAX_LOCATIONS = 10
/**
* Pause between the pieces of a split day, in milliseconds.
* The pace of the public instance, in milliseconds between two questions.
*
* The public instance allows about one request a second per client, and a day long
* enough to be split is exactly the case that would fire two back to back. Matches the
* 1100 ms the road trip rail already leaves between its own days.
* It allows about one request a second per client, and a day long enough to be split is
* exactly the case that would fire two back to back; `valhallaTurn` holds every question
* to it there. Matches the 1100 ms the road trip rail already leaves between its own days.
*/
const CHUNK_PAUSE_MS = 1100
@@ -307,9 +308,9 @@ export async function valhallaRouteAvoiding(
* came back within two metres and under a second of the same day asked whole, because
* the shared point is routed to and from identically.
*
* The pieces go out one after another with a pause, since a day long enough to need
* splitting is exactly the one that would otherwise fire two requests inside a second
* at a host that allows one.
* The pieces go out one after another. Those that reach the public instance wait their
* turn there (`valhallaTurn`), since a day long enough to need splitting is exactly the
* one that would otherwise fire two requests inside a second at a host that allows one.
*/
export async function valhallaRun(
waypoints: Waypoint[],
@@ -372,12 +373,12 @@ interface RunAsk {
}
/**
* Locations one request carries: ten wherever it may end up at the public instance,
* twenty at the TREK Valhalla asked alone and at an own one.
* Locations one request carries: ten at the public instance, twenty at the TREK
* Valhalla and at an own one. A day cut for the relay that the relay then cannot
* answer is cut again for FOSSGIS (`chainRun`).
*/
function locationsPerRequest(base: string, publicFallback: boolean): number {
const mayReachPublic = base === FOSSGIS_VALHALLA || (base === TREK_VALHALLA && publicFallback)
return mayReachPublic ? MAX_LOCATIONS : VALHALLA_RELAY_MAX_LOCATIONS
function locationsPerRequest(base: string): number {
return base === FOSSGIS_VALHALLA ? MAX_LOCATIONS : VALHALLA_RELAY_MAX_LOCATIONS
}
async function chainRun(
@@ -389,18 +390,43 @@ async function chainRun(
): Promise<ValhallaRun | ValhallaNoPath | null> {
const base = valhallaBase()
if (!mayAsk(base, ask.publicFallback) || waypoints.length < 2) return null
const size = locationsPerRequest(base)
const run = await chainRunSized(base, waypoints, { size, profile, avoid, signal, ask })
// The relay fell over during a day cut for it. It rests now, so the base reads as FOSSGIS
// and the day is asked again there in pieces of ten.
const recut = run === null && base === TREK_VALHALLA && ask.publicFallback && waypoints.length > MAX_LOCATIONS
if (recut && Date.now() < relayRestingUntil && !signal?.aborted) {
return chainRun(waypoints, profile, avoid, signal, ask)
}
return run
}
/** How one day is asked: in pieces of `size`, for this profile and these avoidances. */
interface SizedRun {
size: number
profile: 'driving' | 'walking' | 'cycling'
avoid: readonly AvoidClass[]
signal: AbortSignal | undefined
ask: RunAsk
}
async function chainRunSized(
base: string,
waypoints: Waypoint[],
{ size, profile, avoid, signal, ask }: SizedRun,
): Promise<ValhallaRun | ValhallaNoPath | null> {
const legs: ValhallaLeg[] = []
const chunks = chunkWaypoints(waypoints, locationsPerRequest(base, ask.publicFallback))
for (const [index, chunk] of chunks.entries()) {
// No pause between the pieces: a question that reaches FOSSGIS waits its turn in
// valhallaTurn, and the relay and an own Valhalla are not paced.
for (const chunk of chunkWaypoints(waypoints, size)) {
if (signal?.aborted) return null
if (index > 0) await pause(CHUNK_PAUSE_MS, signal)
// Retried per piece rather than per day. The caller retries the whole task, so a
// rate limit on the last piece of a split day would otherwise throw away the pieces
// that already answered and ask for all of them again — three times over, against a
// host that allows one request a second.
const request = () => requestRun(base, chunk, profile, avoid, signal, ask)
const run = await askWithRetry(base, request, signal, ask.publicFallback)
// FOSSGIS stands in only for a piece of at most ten; a longer one is cut again instead.
const run = await askWithRetry(base, request, signal, ask.publicFallback && chunk.length <= MAX_LOCATIONS)
// A day is one answer or none. Half a day of legs would be worse than no answer:
// the schedule chains leg times blindly and cannot tell that it is missing some, so
// it would print a complete, plausible, wrong timetable rather than a gap.
@@ -488,6 +514,9 @@ export function resetValhallaTurns(): void {
* relay refusing the body.
*/
async function relayCannotAnswer(response: Response): Promise<boolean> {
// 504: this one question took too long. The relay still answers the next one, and
// FOSSGIS would take as long over this one.
if (response.status === 504) return false
if (response.status === 400) {
// A body that is not JSON cannot be Valhalla's own refusal, so it reads as the relay's.
const data: unknown = await response.clone().json().catch(() => ({}))
@@ -543,7 +572,7 @@ export async function valhallaPost(
const relayed = await askRelay(action, body, signal)
if (relayed) return relayed
}
if (base === TREK_VALHALLA && !publicFallback) return new Response(null, { status: 503 })
if (base === TREK_VALHALLA && (!publicFallback || overPublicLimit(action, body))) return new Response(null, { status: 503 })
const host = base === TREK_VALHALLA ? FOSSGIS_VALHALLA : base
await valhallaTurn(host, signal)
return fetch(`${host}/${action}`, {
@@ -557,6 +586,12 @@ export async function valhallaPost(
})
}
/** Whether a route carries more locations than the public instance takes, so it cannot stand in for the relay. */
function overPublicLimit(action: ValhallaRelayAction, body: object): boolean {
const locations = (body as { locations?: unknown }).locations
return action === 'route' && Array.isArray(locations) && locations.length > MAX_LOCATIONS
}
/**
* Waits out the host's one request a second before the next question is put.
*
@@ -575,7 +610,8 @@ export function valhallaSpacing(signal?: AbortSignal): Promise<void> {
* on a second ask, and asking anyway is the request the memo exists to save. Nor, where
* nothing may stand in for this engine, when the relay now rests or the host used up its
* whole deadline: the second ask would not leave this tab or would most likely wait as
* long again, and OSRM is waiting for the day.
* long again, and OSRM is waiting for the day. Nor when the relay ran out of time on
* it: the relay does not rest for that, so the second ask would wait at the same host.
*/
async function askWithRetry<T>(
base: string,
@@ -587,6 +623,8 @@ async function askWithRetry<T>(
for (let attempt = 0; (!answer || tooSlow(answer)) && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) {
if (notValhalla.has(base)) break
if (!publicFallback && (tooSlow(answer) || (base === TREK_VALHALLA && Date.now() < relayRestingUntil))) break
// A question the relay ran out of time on would go to the same host again.
if (tooSlow(answer) && base === TREK_VALHALLA) break
await pause(CHUNK_PAUSE_MS, signal)
answer = await ask()
}
@@ -618,6 +656,8 @@ function routeBody(
costing,
costing_options: { [costing]: options },
directions_options: { units: 'kilometers' },
// The maneuvers, where the ferry spans come from, without the narrative nothing reads.
directions_type: 'maneuvers',
}
}
@@ -640,6 +680,8 @@ async function postRoute(
// is the public instance's one-per-second.
if (response.status === 404 || response.status === 405) notValhalla.add(base)
if (response.status === 400 && (await refusesTheWay(response))) return NO_PATH
// The relay's word that this question ran out its deadline, read as one that timed out here.
if (response.status === 504) return TOO_SLOW
return null
}
return await response.json()
@@ -727,7 +769,7 @@ export async function valhallaTraceAttributes(
}
}
/** One request, at most ten locations. */
/** One request, at most ten locations at the public instance and twenty elsewhere. */
async function requestRun(
base: string,
waypoints: Waypoint[],
@@ -890,14 +932,6 @@ export function runFrom(data: unknown): Omit<ValhallaRun, 'snapped'> | null {
interface ValhallaSummary { [key: string]: number | boolean | undefined }
interface ValhallaManeuver {
type?: number
ferry?: boolean
begin_shape_index?: number
end_shape_index?: number
time?: number
length?: number
}
function tripOf(data: unknown) {
return (data as {
@@ -908,40 +942,6 @@ function tripOf(data: unknown) {
})?.trip
}
/** Valhalla's maneuver type for boarding a ferry; the maneuver also carries `ferry: true` for its crossing. */
const FERRY_ENTER = 28
/**
* CW-12: the stretches of a leg the router takes by boat, read off its
* maneuvers: a crossing is the maneuver that boards the ferry, from the point
* it starts at to the one it ends at, with its own length and time. Measured
* on Venice, the San Tomà to Ca' Rezzonico line came back as one maneuver of
* type 28, ferry true, 1.236 km and 745 s over shape points 24 to 46, then the
* walk off it. Crossings that touch are one. A leg the summary says has a
* ferry but whose maneuvers do not say where is taken whole, without a time:
* the crossing is somewhere on it, and none of it is counted as walked.
*/
function ferrySpansOf(maneuvers: ValhallaManeuver[] | undefined, points: number): FerrySpan[] {
const spans: FerrySpan[] = []
for (const maneuver of maneuvers ?? []) {
if (maneuver.ferry !== true && maneuver.type !== FERRY_ENTER) continue
const from = Number(maneuver.begin_shape_index)
const to = Number(maneuver.end_shape_index)
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to <= from || to >= points) continue
const metres = Number.isFinite(maneuver.length) ? Number(maneuver.length) * 1000 : 0
const seconds = Number.isFinite(maneuver.time) ? Number(maneuver.time) : null
const previous = spans[spans.length - 1]
if (previous && previous.to === from) {
previous.to = to
previous.metres += metres
previous.seconds = previous.seconds === null || seconds === null ? null : previous.seconds + seconds
} else {
spans.push({ from, to, metres, seconds })
}
}
return spans.length || points < 2 ? spans : [{ from: 0, to: points - 1, metres: 0, seconds: null }]
}
/** A summary and a shape as a leg, or null when the summary carries no numbers. */
function measured(summary: ValhallaSummary, coordinates: [number, number][]): ValhallaLeg | null {
if (!coordinates.length) return null
@@ -1,7 +1,13 @@
import { useState } from 'react'
import { Check, ChevronRight, Library, X } from 'lucide-react'
import { Check, ChevronRight, Library, Route, Search, X } from 'lucide-react'
import TrekMark from '../../../components/shared/TrekMark'
import { TREK_API_SOURCES as SOURCES, trekApiFacts, trekApiFields } from '../../../components/Admin/trekApiModel'
import {
TREK_API_SOURCES as SOURCES,
trekApiAddressFeatures,
trekApiFacts,
trekApiFields,
trekApiRoutingFeatures,
} from '../../../components/Admin/trekApiModel'
import { useTranslation } from '../../../i18n'
/**
@@ -118,6 +124,18 @@ export function MTrekApiBlock() {
note={t('admin.trekApi.notIncludedNote')}
dashed
/>
<ChipGroup
icon={<Search size={13} strokeWidth={2.2} className="text-m-faint" aria-hidden />}
label={t('admin.trekApi.quota.service.photon')}
items={trekApiAddressFeatures(t)}
note={t('admin.trekApi.addressNote')}
/>
<ChipGroup
icon={<Route size={13} strokeWidth={2.2} className="text-m-faint" aria-hidden />}
label={t('admin.trekApi.quota.service.valhalla')}
items={trekApiRoutingFeatures(t)}
note={t('admin.trekApi.routingNote')}
/>
<ChipGroup
icon={<Library size={13} strokeWidth={2.2} className="text-m-faint" aria-hidden />}
label={t('admin.trekApi.sourcesLabel')}
@@ -258,6 +258,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
photos: photos.map(p => ({
id: p.id,
src: photoUrl(p, 'original'),
thumbSrc: photoUrl(p, 'thumbnail'),
caption: p.caption ?? null,
provider: p.provider,
asset_id: p.asset_id,
@@ -590,6 +591,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
showWeather={current.show_weather !== 0}
userId={useAuthStore.getState().user?.id || 0}
trips={current.trips}
photoProviders={availableProviders}
onMoveEarlier={
canEditEntries && editingEntry.id !== 0 && editingEntry.type !== 'skeleton' && dayNeighbours(editingEntry).canMoveUp
? () => { void moveWithinDay(editingEntry, -1) }
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useRef, useState, type CSSProperties } from 'react'
import { Briefcase, Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle, ChevronUp, ChevronDown, EyeOff, Play } from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
@@ -9,13 +9,23 @@ import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicke
import { isVideoFile } from '../../../utils/videoPoster'
import type { ResilientResult, UploadProgress } from '../../../utils/uploadQueue'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../../store/journeyStore'
import { useAddonStore } from '../../../store/addonStore'
import { photoUrl, posterlessVideo } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import JournalBody from '../../../components/Journey/JournalBody'
import { ProviderPicker } from '../../../components/Journey/JourneyDetailPageProviderPicker'
import { MOBILE_MOODS, MOBILE_WEATHERS } from './mobileJourneyMeta'
import { useJourneyEntryForm, type PendingProviderGroup } from '../../../components/Journey/useJourneyEntryForm'
const NO_PROVIDERS: { id: string; name: string }[] = []
/**
* A picked mood chip: its colour as the frame and the label, a light wash of it
* behind. The frame is the full colour because a tint alone left the grey neutral
* mood looking like the chips at rest on a dark sheet (#2905).
*/
function moodChipActiveStyle(color: string): CSSProperties {
return { background: `${color}33`, color, borderColor: color }
}
const PRO_COLOR = '#2FA37A'
const CON_COLOR = '#D6273B'
@@ -38,6 +48,12 @@ interface MJourneyEntrySheetProps {
readOnly?: boolean
userId?: number
trips?: JourneyTrip[]
/**
* The photo providers (Immich, Synology Photos) connected for this user. The
* journey screen asks once when it opens, so the sheet has the answer the
* moment it appears instead of probing again and growing a button later (#2908).
*/
photoProviders?: { id: string; name: string }[]
/** The optional fields this journey still keeps (discussion #2299). Values are never cleared. */
showVerdict?: boolean
showMood?: boolean
@@ -68,7 +84,7 @@ interface MJourneyEntrySheetProps {
* search, mood (4), weather (6) and tags. Read-only for viewer contributors.
*/
export default function MJourneyEntrySheet({
entry, galleryPhotos, quickCapture = false, readOnly = false, userId = 0, trips = [],
entry, galleryPhotos, quickCapture = false, readOnly = false, userId = 0, trips = [], photoProviders: providers = NO_PROVIDERS,
showVerdict = true, showMood = true, showWeather = true, onMoveEarlier, onMoveLater, onDismiss,
onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDelete, onDone,
}: MJourneyEntrySheetProps) {
@@ -88,41 +104,10 @@ export default function MJourneyEntrySheet({
})
const [showExternal, setShowExternal] = useState(false)
const [externalProvider, setExternalProvider] = useState<string | null>(null)
const [providers, setProviders] = useState<{ id: string; name: string }[]>([])
const [captureOnly, setCaptureOnly] = useState(quickCapture)
const fileRef = useRef<HTMLInputElement>(null)
const cameraRef = useRef<HTMLInputElement>(null)
// The addon list is already in the store — this only probes which of the
// photo providers is actually connected for this user.
const addons = useAddonStore(s => s.addons)
const addonsLoaded = useAddonStore(s => s.loaded)
const photoProviders = useMemo(
() => addons.filter(a => a.type === 'photo_provider' && a.enabled).map(a => ({ id: a.id, name: a.name })),
[addons],
)
useEffect(() => {
if (readOnly) return
// App.tsx loads the addon list on boot, so this only reacts to it. Kicking off
// a second load from here would race the other consumers, and loadAddons
// overwrites the list unconditionally when it lands.
if (!addonsLoaded) return
if (photoProviders.length === 0) { setProviders([]); return }
let active = true
;void (async () => {
const connected: { id: string; name: string }[] = []
for (const provider of photoProviders) {
try {
const res = await fetch(`/api/integrations/memories/${provider.id}/status`, { credentials: 'include' })
if (res.ok && (await res.json()).connected) connected.push(provider)
} catch { /* provider stays hidden */ }
}
if (active) setProviders(connected)
})()
return () => { active = false }
}, [readOnly, addonsLoaded, photoProviders])
const activeProvider = externalProvider || providers[0]?.id || null
const providerExistingAssetIds = providerAssetIds(activeProvider)
@@ -191,13 +176,13 @@ export default function MJourneyEntrySheet({
>
{uploadProgress ? (
<>
<span className="h-[14px] w-[14px] animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-m-muted" />
{t('journey.editor.uploadingProgress', { done: String(uploadProgress.done), total: String(uploadProgress.total) })}
<span className="h-[14px] w-[14px] shrink-0 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-m-muted" />
<span className="truncate">{t('journey.editor.uploadingProgress', { done: String(uploadProgress.done), total: String(uploadProgress.total) })}</span>
</>
) : (
<>
{captureOnly ? <Camera size={14} strokeWidth={2.2} /> : <Plus size={14} strokeWidth={2.2} />}
{captureOnly ? t('journey.photo.add') : t('journey.editor.uploadPhotos')}
{captureOnly ? <Camera size={14} strokeWidth={2.2} className="shrink-0" /> : <Plus size={14} strokeWidth={2.2} className="shrink-0" />}
<span className="truncate">{captureOnly ? t('journey.photo.add') : t('journey.editor.uploadPhotos')}</span>
</>
)}
</button>
@@ -215,12 +200,14 @@ export default function MJourneyEntrySheet({
: 'border-dashed border-[color:var(--m-rowbr)] text-m-muted'
}`}
>
<Image size={14} strokeWidth={2} />
{captureOnly ? t('journey.share.gallery') : t('journey.editor.fromGallery')}
<Image size={14} strokeWidth={2} className="shrink-0" />
<span className="truncate">{captureOnly ? t('journey.share.gallery') : t('journey.editor.fromGallery')}</span>
</button>
{/* Immich/Synology, the same source the desktop editor offers (#1808).
Only shown once a provider is actually connected — on a phone a
button that can only say "nothing connected" is not worth its width. */}
Only shown once a provider is actually connected: on a phone a
button that can only say "nothing connected" is not worth its width.
The icons keep their size and the labels truncate, so a third
button never squeezes the other two (#2908). */}
{!captureOnly && providers.length > 0 && (
<button
type="button"
@@ -232,8 +219,8 @@ export default function MJourneyEntrySheet({
: 'border-dashed border-[color:var(--m-rowbr)] text-m-muted'
}`}
>
<Images size={14} strokeWidth={2} />
{t('journey.editor.externalPhotos')}
<Images size={14} strokeWidth={2} className="shrink-0" />
<span className="truncate">{t('journey.editor.externalPhotos')}</span>
</button>
)}
</div>
@@ -627,11 +614,12 @@ export default function MJourneyEntrySheet({
key={m.id}
type="button"
disabled={readOnly}
aria-pressed={active}
onClick={() => setMood(active ? '' : m.id)}
className={`flex items-center gap-[5px] rounded-full border px-3 py-[7px] text-[0.71875rem] font-semibold ${
active ? '' : 'border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted'
}`}
style={active ? { background: `${m.color}24`, color: m.color, borderColor: `${m.color}4D` } : undefined}
style={active ? moodChipActiveStyle(m.color) : undefined}
>
<m.icon size={13} strokeWidth={2.2} />
{t(m.labelKey)}
+4 -4
View File
@@ -213,7 +213,7 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe
onClose={() => setViewingEntry(null)}
onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }}
onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
/>
)}
@@ -602,7 +602,7 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe
readOnly={!canEditEntries}
onEdit={() => setEditingEntry(entry)}
onDelete={() => setDeleteTarget(entry)}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
/>
)}
</div>
@@ -656,7 +656,7 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe
userId={useAuthStore.getState().user?.id || 0}
trips={current.trips}
canEdit={canEditEntries}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
onRefresh={() => loadJourney(Number(id))}
onAddProviderPhotos={addPickedProviderPhotos}
/>
@@ -735,7 +735,7 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe
const entry = current.entries.find(e => String(e.id) === entryId)
if (!entry?.photos?.length) return
setLightbox({
photos: entry.photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })),
photos: entry.photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })),
index,
})
}}
+18 -24
View File
@@ -72,6 +72,20 @@ function photoUrl(p: { photo_id: number }, shareToken: string, kind: 'thumbnail'
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`;
}
type SharedPhoto = { id: number | string; photo_id: number; caption?: string | null; media_type?: string | null };
// The lightbox opens on the original and shows the thumbnail the page already
// loaded until it arrives, instead of a black screen (#2907).
function lightboxPhoto(p: SharedPhoto, shareToken: string) {
return {
id: String(p.id),
src: photoUrl(p, shareToken, 'original'),
thumbSrc: photoUrl(p, shareToken, 'thumbnail'),
caption: p.caption ?? null,
mediaType: p.media_type,
};
}
// The share route answers 404 for the thumbnail of a clip that has no poster, and
// an <img> pointed at it draws the broken-image glyph (#2341). The black ground
// the layouts give a video stands in for the picture.
@@ -172,12 +186,7 @@ export default function JourneyPublicPage() {
const picked = allPhotos.filter((p) => wanted.has(String(p.id)));
if (!token || picked.length === 0) return;
setLightbox({
photos: picked.map((p) => ({
id: String(p.id),
src: photoUrl(p, token, 'original'),
caption: p.caption,
mediaType: p.media_type,
})),
photos: picked.map((p) => lightboxPhoto(p, token)),
index: 0,
});
};
@@ -243,12 +252,7 @@ export default function JourneyPublicPage() {
const prosArr = entry.pros_cons?.pros ?? [];
const consArr = entry.pros_cons?.cons ?? [];
const hasProscons = prosArr.length > 0 || consArr.length > 0;
const lightboxPhotos = photos.map((p) => ({
id: String(p.id),
src: photoUrl(p, token!, 'original'),
caption: p.caption,
mediaType: p.media_type,
}));
const lightboxPhotos = photos.map((p) => lightboxPhoto(p, token!));
const isActive = activeEntryId === String(entry.id);
return (
@@ -559,12 +563,7 @@ export default function JourneyPublicPage() {
className="relative block aspect-square w-full cursor-pointer overflow-hidden rounded-lg"
onClick={() =>
setLightbox({
photos: allPhotos.map((p) => ({
id: String(p.id),
src: photoUrl(p, token!, 'original'),
caption: p.caption,
mediaType: p.media_type,
})),
photos: allPhotos.map((p) => lightboxPhoto(p, token!)),
index: idx,
})
}
@@ -760,12 +759,7 @@ export default function JourneyPublicPage() {
onDelete={() => { }}
onPhotoClick={(photos, idx) =>
setLightbox({
photos: photos.map((p) => ({
id: String(p.id),
src: photoUrl(p as any, token!, 'original'),
caption: (p as any).caption ?? null,
mediaType: p.media_type,
})),
photos: photos.map((p) => lightboxPhoto(p, token!)),
index: idx,
})
}
+36 -8
View File
@@ -1,12 +1,18 @@
import React from 'react'
import { Check, Library, X } from 'lucide-react'
import { Library, MapPin, Route, Search, X } from 'lucide-react'
import TrekMark from '../../components/shared/TrekMark'
import { fs } from '../../components/shared/DialogShell'
import { StatusPill } from '../../components/Settings/settingsKit'
import FoldSummary from './FoldSummary'
import ServiceIdentityPanel from '../../components/Admin/ServiceIdentityPanel'
import type { TranslationFn } from '../../types'
import { TREK_API_SOURCES as SOURCES, trekApiFacts, trekApiFields } from '../../components/Admin/trekApiModel'
import {
TREK_API_SOURCES as SOURCES,
trekApiAddressFeatures,
trekApiFacts,
trekApiFields,
trekApiRoutingFeatures,
} from '../../components/Admin/trekApiModel'
interface TrekApiCardProps {
t: TranslationFn
@@ -14,7 +20,9 @@ interface TrekApiCardProps {
}
/**
* The TREK Places API, above the Google key because it is the alternative to it.
* The TREK API, above the Google key because it is the alternative to it: the
* place index, the address search and the routing an installation gets from
* the TREK services, each listed with what it answers.
*
* Given real weight on the page on purpose: it is what makes a key optional
* instead of expected. The weight comes from the mark, one line of type and an
@@ -28,7 +36,7 @@ interface TrekApiCardProps {
*/
const CHIP = 'rounded-full border px-2.5 py-[3px] font-medium'
/** One of the three lists under "more": an icon and a name, chips, a note. */
/** One of the lists under "more": an icon and a name, chips, a note. */
function FactGroup({ icon, title, note, children }: { icon: React.ReactNode; title: string; note: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-2 px-3.5 py-3">
@@ -55,7 +63,7 @@ export default function TrekApiCard({ t, managed = false }: TrekApiCardProps): R
A pill beside the mark rather than a banner, so it does not shout
over the settings around it. */}
<div className="flex flex-wrap items-center justify-between gap-2">
<TrekMark className="h-7 w-auto text-content" aria-label="TREK Places API" />
<TrekMark className="h-7 w-auto text-content" aria-label="TREK API" />
<StatusPill tone="accent">{t('admin.trekApi.badgeDefault')}</StatusPill>
</div>
@@ -85,10 +93,10 @@ export default function TrekApiCard({ t, managed = false }: TrekApiCardProps): R
{/* The fields as chips rather than a paragraph. A list of what you
get is something you scan, not something you read, and a sentence
forces the reader to parse commas to answer "is the phone number
in there". */}
in there". One list per service, under the service's own name. */}
<FactGroup
icon={<Check size={14} strokeWidth={2.4} className="text-success" aria-hidden="true" />}
title={t('admin.trekApi.included')}
icon={<MapPin size={14} strokeWidth={2.2} className="text-content-muted" aria-hidden="true" />}
title={t('admin.trekApi.quota.service.places')}
note={t('admin.trekApi.includedNote')}
>
{fields.map(field => (
@@ -109,6 +117,26 @@ export default function TrekApiCard({ t, managed = false }: TrekApiCardProps): R
))}
</FactGroup>
<FactGroup
icon={<Search size={14} strokeWidth={2.2} className="text-content-muted" aria-hidden="true" />}
title={t('admin.trekApi.quota.service.photon')}
note={t('admin.trekApi.addressNote')}
>
{trekApiAddressFeatures(t).map(item => (
<li key={item} className={`${CHIP} border-edge-faint bg-surface-card text-content-secondary`}>{item}</li>
))}
</FactGroup>
<FactGroup
icon={<Route size={14} strokeWidth={2.2} className="text-content-muted" aria-hidden="true" />}
title={t('admin.trekApi.quota.service.valhalla')}
note={t('admin.trekApi.routingNote')}
>
{trekApiRoutingFeatures(t).map(item => (
<li key={item} className={`${CHIP} border-edge-faint bg-surface-card text-content-secondary`}>{item}</li>
))}
</FactGroup>
{/* Attribution, and not in the small print: the licences require the
sources to be named, and naming them is also the answer to "where
does this actually come from". */}
@@ -470,7 +470,7 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO
const photos = (current?.gallery ?? []).filter(p => wanted.has(String(p.id)))
if (!photos.length) return
setLightbox({
photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })),
photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })),
index: 0,
})
}, [current?.gallery])
@@ -1,4 +1,4 @@
// FE-JRN-DETAIL-RECORDINGS-001 to FE-JRN-DETAIL-RECORDINGS-003
// FE-JRN-DETAIL-RECORDINGS-001 to FE-JRN-DETAIL-RECORDINGS-004
//
// JRN-01: the journey page's map lines, with the days the journey keeps beside
// the trips' GPX tracks and the live Dawarich trail.
@@ -81,4 +81,15 @@ describe('useJourneyDetailRecordings', () => {
});
expect(setup({ dawarichStatus: 'disconnected' }).result.current.canTakeOverRecording).toBe(false);
});
it('FE-JRN-DETAIL-RECORDINGS-004: the live trail of a day with entries wears the colour of that day, a day without keeps its own (#2906)', async () => {
repo.list.mockResolvedValue([]);
const live = [
line({ place_id: -1, trip_id: 0, kind: 'dawarich_live', date: '2026-05-14', color: '#2563EB' }),
line({ place_id: -2, trip_id: 0, kind: 'dawarich_live', date: '2026-05-15', color: '#7C3AED' }),
];
const { result } = setup({ liveTracks: live });
await waitFor(() => expect(repo.list).toHaveBeenCalledWith(7));
expect(result.current.mapTracks.map((track) => track.color)).toEqual(['#aa0000', '#ff0055', '#7C3AED']);
});
});
@@ -6,7 +6,7 @@ import type { DawarichSuggestionsStatus } from '../../hooks/useDawarichSuggestio
/**
* The recorded days a journey keeps (JRN-01) as the journey page wires them:
* their lines in the colour of the pins of their day, beside the trips' GPX
* tracks and the live Dawarich trail. A kept day is the journey's own, so it
* tracks and the live Dawarich trail, which takes those colours too. A kept day is the journey's own, so it
* draws whatever the GPX switch says, and wins over the live trail of its date.
*/
export function useJourneyDetailRecordings({
@@ -42,9 +42,16 @@ export function useJourneyDetailRecordings({
const mapTracks = useMemo(() => {
if (recordings.tracks.length === 0 && liveTracks.length === 0) return tripTracks;
const live = liveTracks.filter((track) => !track.date || !recordings.byDate.has(track.date));
// The live trail comes in Dawarich's own day palette; on a journey a day wears
// the colour of its entries, so the line of a day with entries takes theirs (#2906).
const live = liveTracks
.filter((track) => !track.date || !recordings.byDate.has(track.date))
.map((track) => {
const color = track.date ? colorOfDay(track.date) : null;
return color && color !== track.color ? { ...track, color } : track;
});
return [...tripTracks, ...recordings.tracks, ...live];
}, [tripTracks, recordings.tracks, recordings.byDate, liveTracks]);
}, [tripTracks, recordings.tracks, recordings.byDate, liveTracks, colorOfDay]);
return {
recordings,
@@ -26,7 +26,7 @@ export function useJourneyPublic() {
const [error, setError] = useState(false)
const isMobile = useIsMobile()
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null; mediaType?: string | null }[]; index: number } | null>(null)
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; thumbSrc?: string; caption?: string | null; mediaType?: string | null }[]; index: number } | null>(null)
const [showLangPicker, setShowLangPicker] = useState(false)
const locale = useSettingsStore(s => s.settings.language) || 'en'
const mapRef = useRef<JourneyMapHandle>(null)
@@ -4,7 +4,6 @@ import { http, HttpResponse } from 'msw';
import { localIsoDate } from '../../../../src/utils/localDate';
import userEvent from '@testing-library/user-event';
import { mapsApi, weatherApi } from '../../../../src/api/client';
import { useAddonStore } from '../../../../src/store/addonStore';
import MJourneyEntrySheet from '../../../../src/mobile/screens/journey/MJourneyEntrySheet';
import type { GalleryPhoto, JourneyEntry, JourneyPhoto } from '../../../../src/store/journeyStore';
import type { ResilientResult, UploadProgress } from '../../../../src/utils/uploadQueue';
@@ -252,6 +251,7 @@ interface MountOptions {
withProviderHook?: boolean;
upload?: UploadFn;
strict?: boolean;
photoProviders?: { id: string; name: string }[];
}
const defaultUpload: UploadFn = async (_entryId, files, cbs) => {
@@ -274,6 +274,7 @@ function mountSheet(sheetEntry: JourneyEntry, opts: MountOptions = {}) {
readOnly={opts.readOnly ?? false}
userId={42}
trips={[]}
photoProviders={opts.photoProviders}
onClose={onClose}
onSave={onSave}
onUploadPhotos={onUploadPhotos}
@@ -745,6 +746,18 @@ describe('MJourneyEntrySheet full editor', () => {
expect(onSave.mock.calls[0][0]).toMatchObject({ mood: null, weather: null });
});
it('FE-MOB-JENTRY-2905: the picked neutral mood is framed in its own colour and pressed, unlike the chips at rest', async () => {
const user = userEvent.setup();
mountSheet(buildEntry({ id: 5 }));
await user.click(screen.getByRole('button', { name: 'Neutral' }));
const neutral = screen.getByRole('button', { name: 'Neutral' });
expect(neutral).toHaveAttribute('aria-pressed', 'true');
expect(neutral.className).not.toContain('text-m-muted');
expect(neutral.style.borderColor).toBe('rgb(154, 154, 161)');
expect(screen.getByRole('button', { name: 'Good' })).toHaveAttribute('aria-pressed', 'false');
});
it('FE-MOB-JENTRY-022: picks a searched location and stores its coordinates', async () => {
server.use(http.post('/api/maps/search', () => HttpResponse.json({
places: [
@@ -1161,15 +1174,10 @@ describe('MJourneyEntrySheet quick capture edge cases', () => {
// FE-MOB-JENTRY-041 to FE-MOB-JENTRY-047 — external photo providers in the phone
// editor (#1808), mirroring the desktop EntryEditor mechanics.
const immichAddon = { id: 'immich', name: 'Immich', type: 'photo_provider', icon: 'camera', enabled: true };
function seedAddons(addons: Array<Record<string, unknown>>) {
useAddonStore.setState({ addons: addons as never, loaded: true });
}
const immich = [{ id: 'immich', name: 'Immich' }];
function useConnectedImmich() {
server.use(
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({
assets: [{ id: 'asset-1', takenAt: '2026-03-15T09:00:00.000Z', mediaType: 'image' }],
hasMore: false,
@@ -1185,27 +1193,32 @@ describe('MJourneyEntrySheet external photos', () => {
afterEach(() => {
delete window.__addToast;
useAddonStore.setState({ addons: [], loaded: false });
});
it('FE-MOB-JENTRY-041: hides the button while no provider is connected', async () => {
let probes = 0;
seedAddons([immichAddon]);
server.use(http.get('/api/integrations/memories/immich/status', () => {
probes += 1;
return HttpResponse.json({ connected: false });
}));
it('FE-MOB-JENTRY-041: hides the button while no provider is connected', () => {
mountSheet(buildEntry({ id: 5 }));
await waitFor(() => expect(probes).toBe(1));
expect(screen.queryByRole('button', { name: 'External photos' })).not.toBeInTheDocument();
});
it('FE-MOB-JENTRY-2908: a connected provider is there on the first render, without a probe of its own', () => {
let probes = 0;
server.use(http.get('/api/integrations/memories/immich/status', () => {
probes += 1;
return HttpResponse.json({ connected: true });
}));
mountSheet(buildEntry({ id: 5 }), { photoProviders: immich });
const external = screen.getByRole('button', { name: 'External photos' });
// The icons keep their width when a third button shares the row.
expect(external.querySelector('svg')).toHaveClass('shrink-0');
expect(screen.getByRole('button', { name: 'From Gallery' }).querySelector('svg')).toHaveClass('shrink-0');
expect(probes).toBe(0);
});
it('FE-MOB-JENTRY-042: opens the embedded picker for a connected provider', async () => {
seedAddons([immichAddon, { id: 'budget', name: 'Budget', type: 'feature', icon: 'wallet', enabled: true }]);
useConnectedImmich();
const user = userEvent.setup();
mountSheet(buildEntry({ id: 5, location_lat: 41.9, location_lng: 12.5, location_name: 'Rome' }));
mountSheet(buildEntry({ id: 5, location_lat: 41.9, location_lng: 12.5, location_name: 'Rome' }), { photoProviders: immich });
await user.click(await screen.findByRole('button', { name: 'External photos' }));
@@ -1216,20 +1229,18 @@ describe('MJourneyEntrySheet external photos', () => {
});
it('FE-MOB-JENTRY-043: stays out of the quick capture sheet', async () => {
seedAddons([immichAddon]);
useConnectedImmich();
Object.defineProperty(navigator, 'geolocation', { configurable: true, value: undefined });
mountSheet(buildEntry(), { quickCapture: true });
mountSheet(buildEntry(), { quickCapture: true, photoProviders: immich });
expect(await screen.findByRole('button', { name: 'Gallery' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'External photos' })).not.toBeInTheDocument();
});
it('FE-MOB-JENTRY-044: queues a picked photo and sends it after the save', async () => {
seedAddons([immichAddon]);
useConnectedImmich();
const user = userEvent.setup();
const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry({ id: 5 }));
const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry({ id: 5 }), { photoProviders: immich });
await user.click(await screen.findByRole('button', { name: 'External photos' }));
await user.click(await screen.findByAltText(''));
@@ -1249,10 +1260,9 @@ describe('MJourneyEntrySheet external photos', () => {
});
it('FE-MOB-JENTRY-045: drops the queue again from the panel header', async () => {
seedAddons([immichAddon]);
useConnectedImmich();
const user = userEvent.setup();
const { onAddProviderPhotos } = mountSheet(buildEntry({ id: 5 }));
const { onAddProviderPhotos } = mountSheet(buildEntry({ id: 5 }), { photoProviders: immich });
await user.click(await screen.findByRole('button', { name: 'External photos' }));
await user.click(await screen.findByAltText(''));
@@ -1265,11 +1275,10 @@ describe('MJourneyEntrySheet external photos', () => {
});
it('FE-MOB-JENTRY-046: a failed provider add keeps the queue and does not create a second entry', async () => {
seedAddons([immichAddon]);
useConnectedImmich();
const user = userEvent.setup();
// A brand new entry: the first save creates it, the provider write then fails.
const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry());
const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry(), { photoProviders: immich });
onAddProviderPhotos.mockRejectedValueOnce(new Error('immich down'));
await user.click(await screen.findByRole('button', { name: 'External photos' }));
@@ -1293,10 +1302,9 @@ describe('MJourneyEntrySheet external photos', () => {
});
it('FE-MOB-JENTRY-047: a skeleton with only provider photos is promoted to an entry', async () => {
seedAddons([immichAddon]);
useConnectedImmich();
const user = userEvent.setup();
const { onSave } = mountSheet(buildEntry({ id: 9, type: 'skeleton', title: 'Venice' }));
const { onSave } = mountSheet(buildEntry({ id: 9, type: 'skeleton', title: 'Venice' }), { photoProviders: immich });
await user.click(await screen.findByRole('button', { name: 'External photos' }));
await user.click(await screen.findByAltText(''));
@@ -0,0 +1,20 @@
/**
* What the airports domain offers other domains without its service: the
* bundled airport of an IATA code. The place search reads it to turn "JFK"
* into the airport it names (maps/providers/osm-geocoding.ts), a lookup in
* memory that needs neither the database nor the module.
*/
import { findByIata } from './airports.data';
/** An airport by name and coordinate, the part of it a lookup by code hands on. */
export interface AirportByCode {
iata: string;
name: string;
lat: number;
lng: number;
}
export function airportByCode(code: string): AirportByCode | null {
const airport = findByIata(code);
return airport ? { iata: airport.iata, name: airport.name, lat: airport.lat, lng: airport.lng } : null;
}
+33 -9
View File
@@ -14,6 +14,7 @@ import { addDays } from '../days/days.service';
import { ReservationsService } from '../reservations/reservations.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import { isTransportReservationType } from '@trek/shared';
/** The VCALENDAR preamble every TREK calendar starts with, single-trip or merged. */
export const CALENDAR_HEADER =
@@ -572,14 +573,18 @@ export class CalendarService {
// as the pickup. Positional first/last is only a fallback when no role is
// present at all, and only with more than one endpoint, so a lone endpoint is
// never both sides (#1721).
const windowSidesOf = (r: any): { start: WindowSide | null; end: WindowSide | null } => {
const eps = endpointsMap.get(r.id);
const endpointOf = (reservationId: number, side: 'from' | 'to') => {
const eps = endpointsMap.get(reservationId);
const ordered = eps && eps.length > 0 ? [...eps].sort((a, b) => a.sequence - b.sequence) : [];
const roleFrom = ordered.find((e) => e.role === 'from');
const roleTo = ordered.find((e) => e.role === 'to');
const noRoles = !roleFrom && !roleTo;
const startEp = roleFrom ?? (noRoles && ordered.length > 1 ? ordered[0] : undefined);
const endEp = roleTo ?? (noRoles && ordered.length > 1 ? ordered[ordered.length - 1] : undefined);
const byRole = ordered.find((e) => e.role === side);
if (byRole) return byRole;
const noRoles = !ordered.some((e) => e.role === 'from' || e.role === 'to');
if (!noRoles || ordered.length < 2) return undefined;
return side === 'from' ? ordered[0] : ordered[ordered.length - 1];
};
const windowSidesOf = (r: any): { start: WindowSide | null; end: WindowSide | null } => {
const startEp = endpointOf(r.id, 'from');
const endEp = endpointOf(r.id, 'to');
const fromEp = (ep: typeof startEp): WindowSide | null =>
ep && isDate(ep.local_date) && isTime(ep.local_time)
@@ -665,6 +670,24 @@ export class CalendarService {
return out;
};
// Where a transport takes the traveller, as a LOCATION (and GEO) a calendar can
// navigate to (#2909): the route in the description is no address. The side is
// the one the traveller has to get to, the destination of a drive of their own
// and the departure of a ride they board, as the per-leg events do. A
// location the booking states itself still wins.
const SELF_DRIVEN_TYPES = new Set(['car', 'bicycle']);
const locationLines = (
r: { id: number; type?: string | null; location?: string | null },
side: 'from' | 'to' = SELF_DRIVEN_TYPES.has(r.type ?? '') ? 'to' : 'from',
): string => {
if (r.location) return `LOCATION:${esc(r.location)}\r\n`;
const ep = isTransportReservationType(r.type) ? endpointOf(r.id, side) : undefined;
const name = ep?.name || ep?.code;
if (!ep || !name) return '';
const geo = Number.isFinite(ep.lat) && Number.isFinite(ep.lng) ? `GEO:${ep.lat};${ep.lng}\r\n` : '';
return `LOCATION:${esc(name)}\r\n${geo}`;
};
// Reservations as events
for (const r of reservations) {
// The two hand-over events below stand in for this booking entirely.
@@ -701,7 +724,7 @@ export class CalendarService {
const desc = describeReservation(r);
if (desc) ev += `DESCRIPTION:${esc(desc)}\r\n`;
if (r.location) ev += `LOCATION:${esc(r.location)}\r\n`;
ev += locationLines(r);
ev += `END:VEVENT\r\n`;
events.push(ev);
}
@@ -859,7 +882,8 @@ export class CalendarService {
}
ev += `SUMMARY:${esc(summary)}\r\n`;
if (desc) ev += `DESCRIPTION:${esc(desc)}\r\n`;
if (r.location) ev += `LOCATION:${esc(r.location)}\r\n`;
// A hand-over happens where the car is collected or returned.
ev += locationLines(r, kind === 'pickup' ? 'from' : 'to');
ev += `END:VEVENT\r\n`;
events.push(ev);
};
@@ -10,10 +10,13 @@ const DEFAULT_MAX_TOKENS = 8192;
const ANTHROPIC_VERSION = '2023-06-01';
/**
* Anthropic Messages API client. Structured output via forced tool-use: a single
* Anthropic Messages API client. Structured output via tool use: a single
* `emit_<rootKey>` tool (`emit_reservations` by default) whose `input_schema` is
* the caller's schema, with `tool_choice` forcing it — the documented, reliable
* way to get structured JSON.
* the caller's schema, which the system prompt asks the model to answer with.
* The tool is not forced through `tool_choice`: the current models (Claude
* Sonnet 5.5, Opus 5.5, Fable 5.1) refuse `{ type: 'tool' }` and `{ type: 'any' }`
* with a 400, while `auto` is accepted by every model and by Messages-compatible
* gateways (#2902). A model that answers in text instead is reported as such below.
* PDFs go as native base64 `document` blocks (Anthropic reads scanned PDFs), photos
* as `image` blocks.
* Raw fetch (no SDK) to match the codebase's HTTP style.
@@ -48,7 +51,7 @@ export class AnthropicClient implements LlmExtractionClient {
const body = {
model: input.model,
max_tokens: maxTokens,
system: input.prompt,
system: `${input.prompt}\n\n${toolInstruction(toolName)}`,
tools: [
{
name: toolName,
@@ -56,7 +59,8 @@ export class AnthropicClient implements LlmExtractionClient {
input_schema: input.jsonSchema,
},
],
tool_choice: { type: 'tool', name: toolName },
// At most one call: the whole list goes into it.
tool_choice: { type: 'auto', disable_parallel_tool_use: true },
messages: [{ role: 'user', content }],
};
@@ -97,7 +101,7 @@ export class AnthropicClient implements LlmExtractionClient {
throw new Error('Anthropic declined to process this document');
}
// A run that hits the cap stops mid-tool-call, so what arrives is a fragment
// of the list or nothing at all — and a forced tool that was not called left
// of the list or nothing at all — and a tool that was not called left
// no list either. Neither is "this document holds no booking", but both came
// back as [] and reached the person as an empty preview with nothing in the
// log: the #2375 symptom, on the provider the dropdown offers first.
@@ -114,3 +118,8 @@ export class AnthropicClient implements LlmExtractionClient {
}
const USER_TEXT = 'Extract every travel reservation from the following document as schema.org JSON-LD.';
/** What stands in for the forced tool call: the answer goes through the tool, once, and nowhere else. */
function toolInstruction(toolName: string): string {
return `Answer only by calling the ${toolName} tool, exactly once, with everything you extracted. Call it with an empty list when the document holds nothing to extract. Do not answer in text.`;
}
+47 -10
View File
@@ -5,8 +5,10 @@
* the decision to make them is MapsService's.
*/
import { getCountryFromCoords } from '../../utils/country-borders';
import { buildOsmDetails, interleave, parsePoiCategories, samePlace, toApiLang } from './maps.helpers';
import { mergeRanked, type RowView } from './maps-merge.helpers';
import { buildOsmDetails, parsePoiCategories, samePlace, toApiLang } from './maps.helpers';
import { POI_RESULT_CAP, type PoiSearchResult } from './providers/osm.client';
import type { PhotonLead } from './providers/photon.mapping';
import {
INDEX_MAX_CATEGORY_CHARS,
INDEX_MAX_LIMIT,
@@ -15,6 +17,7 @@ import {
isOsmHit,
osmPlaceId,
toPlaceRecord,
type TrekOsmPlace,
type TrekPlace,
type TrekSearchHit,
} from './trek-places.client';
@@ -339,16 +342,50 @@ type Suggestion = MapsAutocompleteResult['suggestions'][number];
/**
* The index's suggestions with Photon's interleaved, the way the search list
* merges the index with Nominatim (#2877). A Photon row the list already holds
* is dropped: the same OpenStreetMap element under the layer's copy of its id,
* or the same place by the rule the search list dedupes with.
* merges the index with Photon (#2877), through the same merge
* (`mergeRanked`): Photon's lead first, a row the list already holds once.
* A town leads only within `LEAD_TOWN_REACH_M` of where the traveller looks,
* so "Hamburg" asked in a Japan trip still opens on what the index found in
* Japan.
*/
export function mergeSuggestions(fromIndex: Suggestion[], fromPhoton: Suggestion[], limit = 8): Suggestion[] {
const asPlace = (s: Suggestion) => ({ lat: s.lat, lng: s.lng, name: s.mainText });
const extra = fromPhoton.filter(
(o) => !fromIndex.some((i) => i.placeId === o.placeId || samePlace(asPlace(i), asPlace(o))),
);
return interleave(fromIndex, extra, limit);
export function mergeSuggestions(
fromIndex: Suggestion[],
fromPhoton: Suggestion[],
limit = 8,
opts: { lead?: PhotonLead | null; bias?: { lat: number; lng: number } } = {},
): Suggestion[] {
return mergeRanked(fromIndex, fromPhoton, limit, suggestionView, opts);
}
const suggestionView = (s: Suggestion): RowView => ({
id: s.placeId,
name: s.mainText,
line: s.secondaryText,
lat: s.lat,
lng: s.lng,
});
/**
* A row of the index's OpenStreetMap layer in the shape a search answers
* with. The layer carries no address, so the line is the town it names and
* the country of its coordinate, the way its suggestion row reads.
*/
export function osmLayerRecord(p: TrekOsmPlace, lang?: string): Record<string, unknown> {
return {
google_place_id: null,
google_ftid: null,
osm_id: osmPlaceId(p),
name: p.name,
address: [p.locality, countryName(getCountryFromCoords(p.lat, p.lng), lang)].filter(Boolean).join(', '),
lat: Number.isFinite(p.lat) ? p.lat : null,
lng: Number.isFinite(p.lng) ? p.lng : null,
rating: null,
website: null,
phone: null,
// The layer's categories are TREK's own segments, not OpenStreetMap values.
category: null,
source: 'openstreetmap',
};
}
/** How long Photon's suggestions may still take once the index has answered. */
+142
View File
@@ -0,0 +1,142 @@
/**
* The one merge behind the search list and the suggestion list (`mergeRanked`),
* with the rules for the lead Photon's answer opens the list on.
*
* Kept apart from maps.helpers.ts, which it reads from: the place rows and the
* distance come from there, the ranking of a merged list lives here.
*/
import { haversineMetres, interleave, samePlace } from './maps.helpers';
import type { PhotonLead } from './providers/photon.mapping';
/**
* Put the index's answer and OpenStreetMap's into one list.
*
* Overture Places is a dataset of businesses. It is very good at those and
* largely does not carry temples, bridges, riverside walks, viewpoints or
* observation decks, which is a fair share of what somebody planning a trip
* searches for. Measured against 128 places out of a real trip to Japan, saved
* through the old search: the index answered 51.6 percent inside its top five,
* and for most of the rest it returned the shops AROUND the landmark rather
* than nothing, which is worse than nothing because it looks like an answer.
*
* Three orderings were tried against that corpus, and the two clever ones lost.
*
* A score threshold cannot tell the cases apart: at 0.75 it catches 19 of 62
* misses and throws away 6 of 66 hits. Promoting results whose name matches
* what was typed is worse than it sounds, because "Hase Station" shares a word
* with "Hase-dera" while OpenStreetMap answers in the local language and
* returns the correct temple under a name that shares nothing at all: the rule
* pushed seven weak index matches above the right answer, which landed at
* position eight.
*
* So this does not rank. It alternates, index first, and lets each source's own
* ordering stand. An exact business match still opens the list, because the
* index put it first; a landmark the index does not carry is second rather than
* eleventh. Neither source has to be judged by the other's yardstick.
*
* A place both sources know is kept once, as the index's copy, because that is
* the one carrying a stable id, contact details and hours.
*/
export function mergeSearchResults(
fromIndex: Record<string, unknown>[],
fromOsm: Record<string, unknown>[],
limit = 10,
opts: { lead?: PhotonLead | null; bias?: { lat: number; lng: number } } = {},
): Record<string, unknown>[] {
return mergeRanked(fromIndex, fromOsm, limit, recordView, opts);
}
/** What the merge reads of a row: its id, its name, the line below it and where it is. */
export interface RowView {
id: string | null;
name: string;
line: string;
lat?: number;
lng?: number;
}
const recordView = (r: Record<string, unknown>): RowView => ({
id: typeof r.osm_id === 'string' ? r.osm_id : null,
name: String(r.name ?? ''),
line: String(r.address ?? ''),
lat: typeof r.lat === 'number' ? r.lat : undefined,
lng: typeof r.lng === 'number' ? r.lng : undefined,
});
/**
* The one merge behind the search list and the suggestion list, so the two
* agree on what comes first. The second list's rows the first already holds
* go (the same id, or the same place by `samePlace`), the rest alternate with
* the first's, the first leading.
*
* Then Photon's lead (`pickLead`) goes first, and rows of its name inside it
* go: "Berlin" no longer opens on a business filed as Berlin above the city.
* Rows that read the same twice, two branches of a chain with the same town
* below them, are one row: nobody can tell them apart in the list. A row
* without a line below its name is never folded that way.
*/
export function mergeRanked<T>(
first: T[],
second: T[],
limit: number,
view: (row: T) => RowView,
{ lead = null, bias }: { lead?: PhotonLead | null; bias?: { lat: number; lng: number } } = {},
): T[] {
const asPlace = (row: T) => {
const v = view(row);
return { lat: v.lat, lng: v.lng, name: v.name };
};
const extra = second.filter((o) => {
const id = view(o).id;
return !first.some((i) => (id !== null && view(i).id === id) || samePlace(asPlace(i), asPlace(o)));
});
const merged = interleave(first, extra, Number.POSITIVE_INFINITY);
// Photon's own row first; a row of its name inside it only where that row went as the same place.
const top =
lead && leadsHere(lead, bias)
? (merged.find((row) => view(row).id === lead.placeId) ?? merged.find((row) => standsFor(view(row), lead)))
: undefined;
const ordered = top && lead ? [top, ...merged.filter((row) => row !== top && !standsFor(view(row), lead))] : merged;
const shown = new Set<string>();
return ordered
.filter((row) => {
const v = view(row);
// Without a line below the name two rows of one name are told apart by nothing but where they are.
if (!v.line.trim()) return true;
const reads = `${foldName(v.name)}\n${foldName(v.line)}`;
if (shown.has(reads)) return false;
shown.add(reads);
return true;
})
.slice(0, limit);
}
/** How far from where the traveller looks a town may lie and still be what its name means there. */
export const LEAD_TOWN_REACH_M = 1_000_000;
/** What may lead with nothing to say where the traveller is looking: a town, an island or a sight, not a municipality or a county. */
const UNBIASED_LEADS = new Set(['city', 'town', 'landmark', 'island', 'archipelago', 'airport']);
/** Whether a lead leads from where the traveller is looking. */
export function leadsHere(lead: PhotonLead, bias: { lat: number; lng: number } | undefined): boolean {
if (lead.reach === 'everywhere') return true;
if (!bias) return UNBIASED_LEADS.has(lead.kind);
return haversineMetres(bias.lat, bias.lng, lead.lat, lead.lng) <= LEAD_TOWN_REACH_M;
}
/** Whether a row is the lead itself, under its id or as a row of its name inside it. */
function standsFor(row: RowView, lead: PhotonLead): boolean {
if (row.id === lead.placeId) return true;
if (row.lat === undefined || row.lng === undefined || foldName(row.name) !== foldName(lead.name)) return false;
if (!lead.extent) return haversineMetres(row.lat, row.lng, lead.lat, lead.lng) < LEAD_WITHOUT_EXTENT_M;
const [west, north, east, south] = lead.extent;
return row.lng >= west && row.lng <= east && row.lat >= south && row.lat <= north;
}
/** Around a lead Photon gave no extent for, how near a row of its name has to be to be it. */
const LEAD_WITHOUT_EXTENT_M = 25_000;
/** A name as the merge compares it: without accents, case or extra spaces. */
export function foldName(value: string): string {
return value.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/\s+/gu, ' ').trim();
}
-38
View File
@@ -80,44 +80,6 @@ export { haversineMetres };
* list for all of them is its own maintenance problem. Anything the pair rule
* lets through has already survived the distance check.
*/
/**
* Put the index's answer and OpenStreetMap's into one list.
*
* Overture Places is a dataset of businesses. It is very good at those and
* largely does not carry temples, bridges, riverside walks, viewpoints or
* observation decks, which is a fair share of what somebody planning a trip
* searches for. Measured against 128 places out of a real trip to Japan, saved
* through the old search: the index answered 51.6 percent inside its top five,
* and for most of the rest it returned the shops AROUND the landmark rather
* than nothing, which is worse than nothing because it looks like an answer.
*
* Three orderings were tried against that corpus, and the two clever ones lost.
*
* A score threshold cannot tell the cases apart: at 0.75 it catches 19 of 62
* misses and throws away 6 of 66 hits. Promoting results whose name matches
* what was typed is worse than it sounds, because "Hase Station" shares a word
* with "Hase-dera" while OpenStreetMap answers in the local language and
* returns the correct temple under a name that shares nothing at all: the rule
* pushed seven weak index matches above the right answer, which landed at
* position eight.
*
* So this does not rank. It alternates, index first, and lets each source's own
* ordering stand. An exact business match still opens the list, because the
* index put it first; a landmark the index does not carry is second rather than
* eleventh. Neither source has to be judged by the other's yardstick.
*
* A place both sources know is kept once, as the index's copy, because that is
* the one carrying a stable id, contact details and hours.
*/
export function mergeSearchResults(
fromIndex: Record<string, unknown>[],
fromOsm: Record<string, unknown>[],
limit = 10,
): Record<string, unknown>[] {
const extra = fromOsm.filter((o) => !fromIndex.some((i) => samePlace(i, o)));
return interleave(fromIndex, extra, limit);
}
/** One row of each list in turn, the first list leading, up to `limit` rows. */
export function interleave<T>(first: T[], second: T[], limit: number): T[] {
const out: T[] = [];
+38 -26
View File
@@ -13,11 +13,13 @@ import {
indexSuggestions,
mergeIndexPages,
mergeSuggestions,
osmLayerRecord,
PHOTON_GRACE_MS,
} from './maps-index.helpers';
import { mergeSearchResults } from './maps-merge.helpers';
import { NEARBY_DEFAULT_LIMIT, NEARBY_DEFAULT_RADIUS_M, nearbyCacheKey, nearestFirst } from './maps-nearby.helpers';
import { MapsUrlResolver, type ResolvedMapsUrl } from './maps-url.resolver';
import { toApiLang, mergeSearchResults } from './maps.helpers';
import { toApiLang } from './maps.helpers';
import { PlaceDetailsResolver } from './place-details.resolver';
import { PlacePhotoResolver } from './place-photo.resolver';
import { PlacesProviderSelector, type KeyedProvider } from './places-provider.selector';
@@ -29,12 +31,11 @@ import type { PlacesProviderChoice } from './providers/places-provider';
import { WikimediaClient, type BrandLogo } from './providers/wikimedia.client';
import {
trekPlacesSearch,
indexHitsOnly,
isOsmHit,
trekPlacesArea,
trekPlacesNearby,
toPlaceRecord,
type TrekHighlight,
type TrekPlace,
} from './trek-places.client';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
@@ -304,7 +305,7 @@ export class MapsService {
* a Nominatim call on the background lane, waiting out its throttle. The index
* answers most of them without leaving our own infrastructure and without a
* throttle at all; a street address it does not know still falls through to
* Nominatim, which is the source that resolves addresses.
* Photon, which resolves addresses (Nominatim without the TREK services).
*
* Never throws: an import that cannot place a hotel still imports the hotel.
*/
@@ -380,38 +381,46 @@ export class MapsService {
!(await this.selector.googleOnly(keyed, opts.googleOnly))
) {
// Both at once. The index is a dataset of businesses and is very good
// at those; OpenStreetMap is where the temples, bridges, riverside
// walks and viewpoints are, and a travel search asks for those
// constantly. Concurrently, so the pair costs the slower one rather
// than the sum. This is the explicit search, not the keystroke path
// Nominatim's policy rules out.
// at those; its OpenStreetMap layer and Photon are where the temples,
// bridges, riverside walks, viewpoints and towns are, and a travel search
// asks for those constantly. Concurrently, so the pair costs the slower
// one rather than the sum. The list is merged the way the suggestions
// are (`mergeRanked`), Photon's lead first, so the search and the
// suggestions agree on what a name means.
//
// Each side catches its own failure. A rejection reaching Promise.all
// would throw away the answer the other side had already produced — and
// the index refusing a query is ordinary traffic, not an outage: a common
// single word without coordinates is turned down upstream as too
// expensive. That used to discard ten good OpenStreetMap results and ask
// Nominatim the same question a second time, behind its own 1.1 s
// process-wide throttle.
// expensive.
const [found, osm] = await Promise.all([
trekPlacesSearch(query, {
lat: locationBias?.lat,
lng: locationBias?.lng,
limit: 10,
sources: 'index,osm',
lang,
})
.then(indexHitsOnly)
.catch((err: unknown) => {
console.warn('TREK Places search failed, falling back:', (err as Error).message);
return [] as TrekPlace[];
}),
this.osm.searchNominatim(query, lang, 'interactive', locationBias).catch((err: unknown) => {
console.warn('OpenStreetMap search failed, index only:', (err as Error).message);
return [] as Record<string, unknown>[];
}).catch((err: unknown) => {
console.warn('TREK Places search failed, falling back:', (err as Error).message);
return [];
}),
this.osm
.searchPhoton(query, lang, locationBias)
// Nominatim only for an installation without Photon; where Photon is
// the geocoder its miss is an empty half, not a question for another.
.then(async (photon) => {
if (photon) return photon;
if (this.osm.photonIsGeocoder()) return { records: [] as Record<string, unknown>[], lead: null };
return { records: await this.osm.searchNominatim(query, lang, 'interactive', locationBias), lead: null };
})
.catch((err: unknown) => {
console.warn('OpenStreetMap search failed, index only:', (err as Error).message);
return { records: [] as Record<string, unknown>[], lead: null };
}),
]);
osmAnswer = osm;
const places = mergeSearchResults(found.map(toPlaceRecord), osm);
osmAnswer = osm.records;
const fromIndex = found.map((hit) => (isOsmHit(hit) ? osmLayerRecord(hit, lang) : toPlaceRecord(hit)));
const places = mergeSearchResults(fromIndex, osm.records, 10, { lead: osm.lead, bias: locationBias });
if (places.length > 0) {
// Names the sources that actually contributed, not the ones that were
// asked. Either side can come back empty — the index turns down a common
@@ -420,7 +429,7 @@ export class MapsService {
// scored against, so a list that is entirely OpenStreetMap must not be
// recorded as though the index had a hand in it.
const source =
found.length > 0 ? (osm.length > 0 ? 'trek-places+openstreetmap' : 'trek-places') : 'openstreetmap';
found.length > 0 ? (osm.records.length > 0 ? 'trek-places+openstreetmap' : 'trek-places') : 'openstreetmap';
return { places, source };
}
}
@@ -565,7 +574,7 @@ export class MapsService {
// answered it gets a moment more and is then dropped: a slow Photon must
// not hold back what the index already has.
const typing = new AbortController();
const asked = this.osm.autocompletePhoton(input, lang, boxCentre, typing.signal);
const asked = this.osm.suggestPhoton(input, lang, boxCentre, typing.signal);
const found = await trekPlacesSearch(input, {
lat: boxCentre?.lat,
lng: boxCentre?.lng,
@@ -578,7 +587,10 @@ export class MapsService {
});
const photon = found.length > 0 ? await answerWithin(asked, PHOTON_GRACE_MS) : await asked;
typing.abort();
const suggestions = mergeSuggestions(indexSuggestions(found, lang), photon ?? []);
const suggestions = mergeSuggestions(indexSuggestions(found, lang), photon?.rows ?? [], 8, {
lead: photon?.lead,
bias: boxCentre,
});
if (suggestions.length > 0) {
return { suggestions, source: found.length > 0 ? 'trek-places' : 'openstreetmap' };
}
@@ -0,0 +1,242 @@
/**
* The search records OpenStreetMap answers are read into, and Photon's half of
* the OpenStreetMap geocoding: search and suggestions with the lead they open
* on, airport codes, the identity of a place by name and coordinate, the
* reverse lookup and the details of an element Overpass placed.
*
* Free functions over the PhotonClient OsmClient holds, so OsmClient keeps one
* face for its callers while Photon's rules live in one place. Where Photon is
* the installation's geocoder these answer alone; Nominatim is OsmClient's
* business, for installations without the TREK services.
*/
import { airportByCode, type AirportByCode } from '../../airports/airports.contract';
import { buildOsmDetails, haversineMetres, namesOverlap, toApiLang } from '../maps.helpers';
import type { NominatimResult, NominatimReverse, OverpassElement } from './osm.client';
import type { PhotonClient } from './photon.client';
import {
asNominatimHit,
asNominatimReverse,
asSuggestion,
photonDisplayName,
pickLead,
rankFeatures,
type PhotonFeature,
type PhotonLead,
} from './photon.mapping';
import { readWikiIdentity } from './wiki-identity';
type Bias = { lat: number; lng: number };
function nominatimCategory(item: { class?: string; type?: string }): string | null {
if (!item.class || !item.type || item.type === 'yes') return null;
return item.class === 'shop' ? `shop_${item.type}` : item.type;
}
/** A search row as TREK's place record. */
export function searchRecord(item: NominatimResult) {
// Number.isFinite, not `|| null`: a place on the equator or prime
// meridian has a legitimate 0 coordinate.
const lat = Number.parseFloat(item.lat);
const lng = Number.parseFloat(item.lon);
return {
google_place_id: null,
google_ftid: null,
osm_id: `${item.osm_type}:${item.osm_id}`,
name: item.name || item.display_name?.split(',')[0] || '',
address: item.display_name || '',
lat: Number.isFinite(lat) ? lat : null,
lng: Number.isFinite(lng) ? lng : null,
rating: null,
website: null,
phone: null,
// What the place is, so the add form can preselect a category (#2282). A shop's
// value is the ware ("bakery"), so the key goes first and says it is a shop.
category: nominatimCategory(item),
source: 'openstreetmap',
...readWikiIdentity(item.extratags),
};
}
function isHit(hit: NominatimResult | null): hit is NominatimResult {
return hit !== null;
}
/**
* Photon's search, its hits ranked (`rankFeatures`) and read as search
* records, with the lead the list opens on; or null where Photon is not in
* use or did not answer, and the caller is left to what it had before.
*/
export async function photonSearch(photon: PhotonClient, query: string, lang?: string, bias?: Bias) {
const airport = airportOfCode(query);
const found = await photon.search(airport?.name ?? query, { lang, bias: airport ?? bias });
if (!found) return null;
const ranked = rankFeatures(found);
return {
records: ranked.map(asNominatimHit).filter(isHit).map(searchRecord),
lead: airport ? airportLead(ranked, airport) : pickLead(ranked, query, bias),
};
}
/** Photon's suggestions with the lead the list opens on, or null when it goes without them. */
export async function photonSuggest(
photon: PhotonClient,
input: string,
{ lang, bias, signal }: { lang?: string; bias?: Bias; signal?: AbortSignal },
) {
const airport = airportOfCode(input);
const found = await photon.suggest(airport?.name ?? input, { lang, bias: airport ?? bias, signal });
if (!found) return null;
const ranked = rankFeatures(found);
return {
rows: ranked.map(asSuggestion).filter((row) => row !== null),
lead: airport ? airportLead(ranked, airport) : pickLead(ranked, input, bias),
};
}
/**
* The airport a code in capitals names ("JFK", "LHR"), from the bundled
* dataset. Photon does not search codes: "JFK" answered with an expressway,
* "FRA" with France. So the code is asked for as the airport's name at its
* coordinate, where the aerodrome comes first. Lower case is left alone, so
* "Ber" typed on the way to "Berlin" stays a search for Berlin.
*/
function airportOfCode(query: string): AirportByCode | null {
const code = query.trim();
return /^[A-Z]{3}$/.test(code) ? airportByCode(code) : null;
}
/**
* The aerodrome of a code's answer, within a few kilometres of the airport, as
* a lead from everywhere; failing that a place there that calls itself an
* airport, which is how OpenStreetMap files Haneda (an island named "Tokyo
* International Airport").
*/
function airportLead(ranked: PhotonFeature[], airport: AirportByCode): PhotonLead | null {
const near = ranked
.map((feature) => ({ feature, row: asSuggestion(feature) }))
.filter(
(
c,
): c is {
feature: PhotonFeature;
row: NonNullable<ReturnType<typeof asSuggestion>> & { lat: number; lng: number };
} =>
c.row?.lat !== undefined &&
c.row.lng !== undefined &&
haversineMetres(airport.lat, airport.lng, c.row.lat, c.row.lng) <= AIRPORT_MATCH_M,
);
const best =
near.find((c) => c.feature.properties.osm_value === 'aerodrome') ??
near.find((c) => c.feature.properties.osm_key === 'place' && AIRPORT_WORDS.test(c.row.mainText));
if (!best) return null;
const { placeId, mainText, lat, lng } = best.row;
return { placeId, name: mainText, reach: 'everywhere', kind: 'airport', lat, lng, extent: null };
}
const AIRPORT_WORDS = /airport|flughafen|aéroport|aeropuerto|aeroporto|luchthaven|lotnisko|空港|机场|공항/iu;
/** How far an aerodrome may lie from the dataset's coordinate of its airport and still be it. */
const AIRPORT_MATCH_M = 8000;
// A station or a road named after a place is not the place, unless the name
// asked for one ("Hauptbahnhof", "Tor station").
const TRANSPORT_KEYS = new Set(['railway', 'public_transport', 'highway']);
const TRANSPORT_WORDS =
/(?:^|[^\p{L}])(?:station|bahnhof|hbf|bhf|halt|haltestelle|stop|street|straße|strasse|road|weg|gare|estación|stazione)(?:$|[^\p{L}])|駅/iu;
export interface IdentityMatch {
tags: Record<string, string>;
osmUrl: string | null;
matchedName: string;
}
/**
* The identity of a place from Photon, inside a box of about 2 km around the
* point and through the two gates OsmClient's identity match keeps: within
* `maxDistanceM`, and a name that shares a substantial word. Photon has no
* `importance`, so a Wikidata link stands in for it, and a station or a road
* named after the place steps back unless the name asked for one: the
* Brandenburg Gate before the station beside it. Measured on eight
* landmarks: seven right, against five in Photon's order.
*/
export async function photonIdentity(
photon: PhotonClient,
{
query,
lat,
lng,
maxDistanceM,
lang,
}: { query: string; lat: number; lng: number; maxDistanceM: number; lang?: string },
): Promise<IdentityMatch | null> {
const d = 0.02;
const found = await photon.search(query, { lang, bbox: [lng - d, lat - d, lng + d, lat + d], limit: 5 });
const transport = !TRANSPORT_WORDS.test(query);
const best = (found ?? [])
.map((feature) => ({ feature, hit: asNominatimHit(feature) }))
.filter((c): c is { feature: PhotonFeature; hit: NominatimResult } => c.hit !== null)
.map((c) => ({ ...c, metres: haversineMetres(lat, lng, Number(c.hit.lat), Number(c.hit.lon)) }))
.filter((c) => c.metres <= maxDistanceM && namesOverlap(query, c.hit.name || ''))
.map((c) => ({
...c,
score:
(c.hit.extratags?.wikidata ? -2 : 0) +
(transport && TRANSPORT_KEYS.has(c.feature.properties.osm_key ?? '') ? 3 : 0),
}))
.sort((a, b) => a.score - b.score || a.metres - b.metres)[0];
if (!best) return null;
return {
tags: best.hit.extratags ?? {},
osmUrl: `https://www.openstreetmap.org/${best.hit.osm_type}/${best.hit.osm_id}`,
matchedName: best.hit.name || query,
};
}
// How far a reverse lookup that found nothing next to the point looks again.
const WIDE_REVERSE_KM = 50;
/**
* Photon's nearest feature, or null where it has none. Photon looks within
* a small radius by default and finds nothing in open country or at sea,
* where Nominatim named the nearest object at any distance, so an empty
* answer is asked once more with a radius of 50 km.
*/
export async function photonReverse(
photon: PhotonClient,
lat: number,
lng: number,
{ lang, timeoutMs }: { lang?: string; timeoutMs?: number } = {},
): Promise<NominatimReverse | null> {
const near = await photon.reverse(lat, lng, lang, timeoutMs);
const found =
near?.length === 0 ? await photon.reverse(lat, lng, lang, timeoutMs, { radius: WIDE_REVERSE_KM }) : near;
return found?.[0] ? asNominatimReverse(found[0]) : null;
}
/**
* The details of an element without Nominatim: Overpass gave the tags and
* where the element is, Photon's reverse lookup at that point names it and
* writes its address, the element itself where Photon has it. Null when
* Overpass did not say where it is, so the caller can still try Nominatim.
*/
export async function photonElementDetails(
photon: PhotonClient,
element: OverpassElement,
{ osmType, osmId, placeId, lang }: { osmType: string; osmId: string; placeId: string; lang?: string },
): Promise<Record<string, unknown> | null> {
const lat = element.center?.lat ?? element.lat;
const lng = element.center?.lon ?? element.lon;
if (lat === undefined || lng === undefined) return null;
const near = await photon.reverse(lat, lng, lang, undefined, { limit: 10, radius: 0.3 });
const self = near?.find((f) => asNominatimHit(f)?.osm_type === osmType && String(f.properties.osm_id) === osmId);
const base = lang ? toApiLang(lang).split('-')[0] : '';
const extra = self ? (asNominatimHit(self)?.extratags ?? {}) : {};
return {
...buildOsmDetails({ ...extra, ...(element.tags ?? {}) }, osmType, osmId),
name: (base && element.tags?.[`name:${base}`]) || self?.properties.name || element.tags?.name || '',
address: self ? photonDisplayName(self.properties) : near?.[0] ? photonDisplayName(near[0].properties) : '',
lat,
lng,
osm_id: placeId,
};
}
+70 -77
View File
@@ -17,9 +17,9 @@
*
* Search and reverse ask Photon first (PhotonClient) where the TREK services
* hold a key and the operator runs no Nominatim of their own; its answers are
* read into Nominatim's shape, so both come out of the same mapping. Lookups
* by id and the identity match stay on Nominatim: they need its id lookup and
* its extratags.
* read into Nominatim's shape, so both come out of the same mapping. Photon's
* half (osm-geocoding.ts) also matches identities and names an element
* Overpass placed; Nominatim answers installations without the services.
*
* Never imports MapsService: providers sit below the orchestrator
* (lint:boundaries holds that).
@@ -47,18 +47,20 @@ import {
} from '../maps.helpers';
import { overpassSelector, tagsMatchFilter } from '../osm-filter.helpers';
import { OverpassBusy, hedged, lastAnsweredFirst, retryAfterMs, soonestRetryMs } from '../overpass-hedge.helpers';
import {
photonElementDetails,
photonIdentity,
photonReverse,
photonSearch,
photonSuggest,
searchRecord,
type IdentityMatch,
} from './osm-geocoding';
import { PhotonClient } from './photon.client';
import { asNominatimHit, asNominatimReverse, asSuggestion } from './photon.mapping';
import { readWikiIdentity } from './wiki-identity';
import { Injectable } from '@nestjs/common';
import { normalizePlaceWebsite } from '@trek/shared';
import type { LatLng } from '@trek/shared/roadtrip';
function nominatimCategory(item: { class?: string; type?: string }): string | null {
if (!item.class || !item.type || item.type === 'yes') return null;
return item.class === 'shop' ? `shop_${item.type}` : item.type;
}
export interface NominatimResult {
osm_type: string;
osm_id: string;
@@ -74,6 +76,10 @@ export interface NominatimResult {
export interface OverpassElement {
tags?: Record<string, string>;
/** Asked for with `located`: a node's coordinate, or the centre of a way or relation. */
lat?: number;
lon?: number;
center?: { lat: number; lon: number };
}
interface OverpassPoiElement {
@@ -107,35 +113,6 @@ export interface NominatimReverse {
address?: Record<string, string>;
}
/** A search row as TREK's place record. */
function searchRecord(item: NominatimResult) {
// Number.isFinite, not `|| null`: a place on the equator or prime
// meridian has a legitimate 0 coordinate.
const lat = Number.parseFloat(item.lat);
const lng = Number.parseFloat(item.lon);
return {
google_place_id: null,
google_ftid: null,
osm_id: `${item.osm_type}:${item.osm_id}`,
name: item.name || item.display_name?.split(',')[0] || '',
address: item.display_name || '',
lat: Number.isFinite(lat) ? lat : null,
lng: Number.isFinite(lng) ? lng : null,
rating: null,
website: null,
phone: null,
// What the place is, so the add form can preselect a category (#2282). A shop's
// value is the ware ("bakery"), so the key goes first and says it is a shop.
category: nominatimCategory(item),
source: 'openstreetmap',
...readWikiIdentity(item.extratags),
};
}
function isHit(hit: NominatimResult | null): hit is NominatimResult {
return hit !== null;
}
// Tighter than the wiki calls, because this one sits at the FRONT of a chain:
// identity, then sitelinks, then the extract. Nominatim answers a bounded
// search in 0.2-0.7s in practice, so anything past a couple of seconds is a bad
@@ -443,20 +420,32 @@ export class OsmClient {
// ── Nominatim search ───────────────────────────────────────────────────────
/** Photon's search with its lead (`photonSearch`), or null where Photon is not in use or did not answer. */
searchPhoton(query: string, lang?: string, bias?: { lat: number; lng: number }) {
return photonSearch(this.photon, query, lang, bias);
}
/** Whether Photon is this installation's geocoder, so a miss of it is not handed to Nominatim. */
photonIsGeocoder(): boolean {
return this.photon.inUse();
}
/**
* `lane` defaults to interactive because most callers are a keystroke.
* Photon's answer where the installation has it, Nominatim's otherwise.
*
* Photon answers whenever it is in use, an empty answer and a miss
* included: Nominatim is the search of an installation without the TREK
* services (or with an operator's own Nominatim), not a second opinion or a
* stand-in behind Photon. Asked
* beside it, every miss of Photon took a 1.1 s slot of the process-wide
* throttle, and a few searches at once queued for seconds.
*
* `lane` defaults to interactive because most callers are a keystroke.
* Bulk callers must pass 'background': booking-import geocodes every venue and
* every uncoordinated endpoint of an import in one request loop, which is up
* to thirty sequential calls. On the interactive lane those thirty take the
* next slot each time, so somebody typing in the place search waits behind the
* whole import. Yielding does not make the import faster, it stops it from
* being the only thing the process will do for half a minute. That loop
* asks Nominatim alone, so an import does not spend the Photon allowance
* the people typing need.
*
* Photon matches differently and can find nothing where Nominatim finds the
* place, so an answer without a usable hit is asked again there.
* next Nominatim slot each time, so somebody typing in the place search waits
* behind the whole import.
*/
async searchNominatim(
query: string,
@@ -464,9 +453,11 @@ export class OsmClient {
lane: GeoLane = 'interactive',
bias?: { lat: number; lng: number },
) {
const photon = lane === 'background' ? null : await this.photon.search(query, { lang, bias });
const hits = photon?.map(asNominatimHit).filter(isHit);
if (hits?.length) return hits.map(searchRecord);
const photon = await this.searchPhoton(query, lang, bias);
if (photon) return photon.records;
// Photon is this installation's geocoder and missed this one (an error, a
// timeout, a spent allowance): the answer is empty, not Nominatim's.
if (this.photon.inUse()) return [];
const params = new URLSearchParams({
q: query,
format: 'json',
@@ -533,8 +524,12 @@ export class OsmClient {
* waits for any more.
*/
async autocompletePhoton(input: string, lang?: string, bias?: { lat: number; lng: number }, signal?: AbortSignal) {
const found = await this.photon.suggest(input, { lang, bias, signal });
return found?.map(asSuggestion).filter((row) => row !== null) ?? null;
return (await this.suggestPhoton(input, lang, bias, signal))?.rows ?? null;
}
/** The same suggestions with Photon's first hit read as a lead, for the list that merges them with the index. */
suggestPhoton(input: string, lang?: string, bias?: { lat: number; lng: number }, signal?: AbortSignal) {
return photonSuggest(this.photon, input, { lang, bias, signal });
}
/**
@@ -560,10 +555,11 @@ export class OsmClient {
lat: number,
lng: number,
opts: { lang?: string; maxDistanceM?: number; signal?: AbortSignal } = {},
): Promise<{ tags: Record<string, string>; osmUrl: string | null; matchedName: string } | null> {
): Promise<IdentityMatch | null> {
const query = (name || '').trim();
if (!query || !Number.isFinite(lat) || !Number.isFinite(lng)) return null;
const maxDistanceM = opts.maxDistanceM ?? 2000;
if (this.photon.inUse()) return photonIdentity(this.photon, { query, lat, lng, maxDistanceM, lang: opts.lang });
// ~2km box around the point, so Nominatim ranks locally instead of handing
// back the most famous place on earth with this name.
@@ -673,8 +669,12 @@ export class OsmClient {
lang?: string,
opts?: { lane?: GeoLane; timeoutMs?: number; locality?: boolean },
): Promise<{ name: string | null; address: string | null }> {
const photon = opts?.lane === 'background' ? null : await this.reversePhoton(lat, lng, lang, opts?.timeoutMs);
const data = photon ?? (await this.reverseNominatim(lat, lng, lang, opts));
const data = this.photon.inUse()
? await photonReverse(this.photon, Number.parseFloat(lat), Number.parseFloat(lng), {
lang,
timeoutMs: opts?.timeoutMs,
})
: await this.reverseNominatim(lat, lng, lang, opts);
if (!data) return { name: null, address: null };
const addr = data.address || {};
const name = opts?.locality
@@ -683,21 +683,6 @@ export class OsmClient {
return { name, address: data.display_name || null };
}
/**
* Photon's nearest feature, or null for Nominatim to answer. Photon looks
* within a radius and can find nothing at all, where Nominatim names the
* nearest object at any distance, so an empty answer is asked again there.
*/
private async reversePhoton(
lat: string,
lng: string,
lang?: string,
timeoutMs?: number,
): Promise<NominatimReverse | null> {
const found = await this.photon.reverse(Number.parseFloat(lat), Number.parseFloat(lng), lang, timeoutMs);
return found?.[0] ? asNominatimReverse(found[0]) : null;
}
private async reverseNominatim(
lat: string,
lng: string,
@@ -723,8 +708,7 @@ export class OsmClient {
* coordinates are already known, so the caller still has something to return.
*/
async reverseRaw(lat: number, lng: number): Promise<NominatimReverse> {
const found = await this.photon.reverse(lat, lng);
if (found?.[0]) return asNominatimReverse(found[0]);
if (this.photon.inUse()) return (await photonReverse(this.photon, lat, lng)) ?? {};
const res = await nominatimFetch(
'reverse',
new URLSearchParams({ lat: String(lat), lon: String(lng), format: 'json', addressdetails: '1' }),
@@ -746,10 +730,17 @@ export class OsmClient {
// sent as a query of its own.
if (!OSM_PLACE_ID.test(placeId)) return null;
const [osmType, osmId] = placeId.split(':');
// Where Photon is the geocoder, Overpass places the element and Photon
// names it; Nominatim is asked only when Overpass could not place it.
const located = this.photon.inUse() ? await this.fetchOverpassDetails(osmType, osmId, true) : undefined;
if (located) {
const answer = await photonElementDetails(this.photon, located, { osmType, osmId, placeId, lang });
if (answer) return answer;
}
// buildOsmDetails never yields name/address/coordinates — Nominatim is
// always the source for those (Overpass contributes the tag-derived rest).
// the source for those (Overpass contributes the tag-derived rest).
const [element, nominatim] = await Promise.all([
this.fetchOverpassDetails(osmType, osmId),
located === undefined ? this.fetchOverpassDetails(osmType, osmId) : located,
this.lookupNominatim(osmType, osmId, lang),
]);
// Overpass has the fuller tag set and wins where both answer, but it is
@@ -771,7 +762,8 @@ export class OsmClient {
// ── Overpass API (OSM details) ─────────────────────────────────────────────
async fetchOverpassDetails(osmType: string, osmId: string): Promise<OverpassElement | null> {
/** `located` asks for the element's coordinate as well: a node's own, the centre of a way or relation. */
async fetchOverpassDetails(osmType: string, osmId: string, located = false): Promise<OverpassElement | null> {
const typeMap: Record<string, string> = { node: 'node', way: 'way', relation: 'rel' };
const oType = typeMap[osmType];
if (!oType) return null;
@@ -779,7 +771,8 @@ export class OsmClient {
// language. An OSM element id is a number and nothing else; anything with
// more in it is a statement of its own, sent under TREK's shared user agent.
if (!/^\d+$/.test(osmId)) return null;
const query = `[out:json][timeout:5];${oType}(${osmId});out tags;`;
const out = !located ? 'out tags;' : oType === 'node' ? 'out;' : 'out tags center;';
const query = `[out:json][timeout:5];${oType}(${osmId});${out}`;
try {
const res = await fetch('https://overpass-api.de/api/interpreter', {
method: 'POST',
@@ -45,10 +45,13 @@ const SUGGEST_TIMEOUT_MS = 1500;
const MAX_BYTES = 512_000;
const SEARCH_LIMIT = 10;
/**
* How far the bias reaches. Nominatim is handed a box of a degree around the
* point; zoom 10 is Photon's spelling of about that.
* How narrow the bias is. Asked from a city view ("Hauptbahnhof" from
* Hamburg, "Starbucks" from Tokyo), zoom 10 put the right place first 59 % of
* the time, 12 82 %, 13 91 % and 14 86 %. A wide decay lifts a namesake 50 km
* off above the real place; a narrow one leaves far places in Photon's own
* order of importance.
*/
const BIAS_ZOOM = 10;
const BIAS_ZOOM = 13;
const MISSES_BEFORE_PAUSE = 4;
const PAUSE_MS = 60_000;
/** The longest Retry-After followed; the first question after it finds out whether the allowance is back. */
@@ -57,6 +60,9 @@ const MAX_PAUSE_MS = 60 * 60_000;
interface PhotonSearchOptions {
lang?: string;
bias?: { lat: number; lng: number };
/** Only hits inside [west, south, east, north]. */
bbox?: [number, number, number, number];
limit?: number;
}
/** The misses in a row and the pause of one kind of question. */
@@ -90,15 +96,40 @@ export class PhotonClient {
/**
* What lies at a point, nearest first, or null when Nominatim has to answer.
* A caller's own deadline leaves Nominatim at least half of it.
* A caller's own deadline leaves Nominatim at least half of it. `radius` is
* Photon's search radius in km; its default finds nothing in open country,
* where Nominatim names the nearest object at any distance.
*/
reverse(lat: number, lng: number, lang?: string, timeoutMs?: number): Promise<PhotonFeature[] | null> {
reverse(
lat: number,
lng: number,
lang?: string,
timeoutMs?: number,
opts: { limit?: number; radius?: number } = {},
): Promise<PhotonFeature[] | null> {
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return Promise.resolve(null);
const params = new URLSearchParams({ lat: String(lat), lon: String(lng), limit: '1', lang: photonLang(lang) });
const params = new URLSearchParams({
lat: String(lat),
lon: String(lng),
limit: String(opts.limit ?? 1),
lang: photonLang(lang),
});
if (opts.radius) params.set('radius', String(opts.radius));
const deadline = timeoutMs ? Math.min(TIMEOUT_MS, timeoutMs / 2) : TIMEOUT_MS;
return this.ask('reverse', params, this.lookups, deadline);
}
/**
* Whether Photon is this installation's geocoder: the services are on, no
* operator's own Nominatim stands beside it, and there is a key to send. A
* question it then misses is a miss, not a reason to ask Nominatim.
*/
inUse(): boolean {
if (this.config.mode === 'off' || readEnv().integrations.nominatimOwn) return false;
const url = new URL(this.config.photonUrl);
return !!serviceIdentitySlot.get().headersFor(url).Authorization;
}
private async ask(
path: 'api' | 'reverse',
params: URLSearchParams,
@@ -167,7 +198,12 @@ export class PhotonClient {
}
function searchParams(query: string, opts: PhotonSearchOptions): URLSearchParams {
const params = new URLSearchParams({ q: query, limit: String(SEARCH_LIMIT), lang: photonLang(opts.lang) });
const params = new URLSearchParams({
q: query,
limit: String(opts.limit ?? SEARCH_LIMIT),
lang: photonLang(opts.lang),
});
if (opts.bbox) params.set('bbox', opts.bbox.join(','));
if (opts.bias && Number.isFinite(opts.bias.lat) && Number.isFinite(opts.bias.lng)) {
params.set('lat', String(opts.bias.lat));
params.set('lon', String(opts.bias.lng));
@@ -8,6 +8,7 @@
* the address as one comma-joined line. The OsmClient keeps one mapping from
* Nominatim's shape to TREK's place record, and this file feeds it.
*/
import { foldName, leadsHere } from '../maps-merge.helpers';
import { toApiLang } from '../maps.helpers';
import type { NominatimResult, NominatimReverse } from './osm.client';
import type { MapsAutocompleteResult } from '@trek/shared';
@@ -68,6 +69,8 @@ const photonFeatureSchema = z.object({
country: text,
countrycode: text,
extra: z.record(z.string(), z.unknown()).optional(),
// [west, north, east, south], on areas only.
extent: z.array(z.number()).length(4).optional(),
}),
});
@@ -144,24 +147,211 @@ export function asSuggestion(feature: PhotonFeature): Suggestion | null {
const hit = asNominatimHit(feature);
const [lng, lat] = feature.geometry.coordinates;
if (!hit || !Number.isFinite(lat) || !Number.isFinite(lng)) return null;
// The address line starts with the name, so the rest of it reads as where the
// place is. Cut by the name rather than at its first comma, which a name can
// hold; a house without a name is titled by its number, as Nominatim does.
// A house without a name is titled by its number, as Nominatim does, and
// the rest of the line says where it is.
const name = feature.properties.name?.trim();
const line = hit.display_name ?? '';
const [head = '', ...rest] = line.split(', ');
const [head = '', ...rest] = suggestionLine(feature.properties);
const mainText = name || head;
if (!mainText) return null;
return {
placeId: `${hit.osm_type}:${hit.osm_id}`,
mainText,
secondaryText: name ? line.slice(name.length).replace(/^, /, '') : rest.join(', '),
secondaryText: (name ? [head, ...rest] : rest).filter(Boolean).join(', '),
source: 'openstreetmap',
lat,
lng,
};
}
/**
* Where a suggestion is, below its name: house number and street, quarter,
* town and country, and the region only where there is no town. Shorter than
* the address line on purpose. The rows of the index beside it read "Mitte,
* Germany", and a postcode or a county ("Троицкий муниципальный округ", which
* has no English name) next to them only made the list harder to read.
*/
function suggestionLine(p: PhotonProperties): string[] {
const name = p.name?.trim();
const parts = [p.housenumber, p.street, p.district ?? p.locality, p.city, p.city ? undefined : p.state, p.country];
const seen = new Set<string>(name ? [name] : []);
const line: string[] = [];
for (const part of parts) {
const value = part?.trim();
if (value && !seen.has(value)) {
seen.add(value);
line.push(value);
}
}
return line;
}
/**
* Photon's hits with notable places before their namesakes. Photon ranks an
* artwork called "Neuschwanstein" above the castle and a street called
* "Colosseum" above the monument; a Wikidata link marks the place people
* mean, and streets, unlinked shops and unlinked artworks step back. Photon's
* own order stays the base and breaks ties. Measured on 74 queries without a
* context, the first hit went from 68 % to 76 % right, and from 61 % to 73 %
* asked from a map somewhere else.
*/
export function rankFeatures(features: PhotonFeature[]): PhotonFeature[] {
return features
.map((feature, index) => ({ feature, index, score: notability(feature.properties) - index }))
.sort((a, b) => b.score - a.score || a.index - b.index)
.map(({ feature }) => feature);
}
function notability(p: PhotonProperties): number {
const linked = typeof p.extra?.wikidata === 'string';
let score = linked ? 2.5 : 0;
if (p.osm_key === 'highway') score -= 2.5;
if (!linked && (p.osm_key === 'shop' || p.osm_key === 'office' || p.osm_key === 'craft')) score -= 1.5;
if (!linked && p.osm_key === 'tourism' && p.osm_value === 'artwork') score -= 2;
return score;
}
/**
* The place a name stands for, which the result lists put first
* (`mergeRanked`): a country or a region, which is what its name means
* wherever the traveller is looking; a town, or a sight with a Wikidata link,
* which is what its name means from not too far away. The index beside Photon
* is a dataset of businesses and answers a town's name with shops that carry
* it, so without a lead "Tokyo" opened on a restaurant chain.
*/
export interface PhotonLead {
placeId: string;
name: string;
reach: 'everywhere' | 'nearby';
/** What it is: Photon's value for the place (`city`, `town`, `municipality`), or `landmark` for a sight. */
kind: string;
lat: number;
lng: number;
/** [west, north, east, south], or null where Photon gave none. */
extent: [number, number, number, number] | null;
}
const REGIONS = new Set(['country', 'state', 'province', 'region', 'territory']);
/** An island is what its name means too: Spitzbergen, Gran Canaria. */
const ISLANDS = new Set(['island', 'archipelago']);
/** The keys a sight is filed under; `landuse` is left out, it named a field in Italy "Eiffel". */
const SIGHT_KEYS = new Set([
'tourism',
'historic',
'aeroway',
'man_made',
'building',
'railway',
'natural',
'amenity',
'leisure',
'bridge',
'waterway',
]);
/** A three-letter code in capitals is an airport to a traveller, never the village or the country it spells. */
const AIRPORT_CODE = /^[A-Z]{3}$/;
export function asLead(feature: PhotonFeature): PhotonLead | null {
const p = feature.properties;
if (p.osm_key !== 'place' && p.osm_key !== 'boundary') return null;
const reach =
REGIONS.has(p.osm_value ?? '') || p.type === 'country' || p.type === 'state'
? 'everywhere'
: p.type === 'city' || p.type === 'county' || ISLANDS.has(p.osm_value ?? '')
? 'nearby'
: null;
const row = reach ? asSuggestion(feature) : null;
if (!row || !reach || row.lat === undefined || row.lng === undefined) return null;
return {
placeId: row.placeId,
name: row.mainText,
reach,
// A town filed as an administrative boundary (Mérida) is a town.
kind: (p.osm_key === 'boundary' ? p.type : p.osm_value) ?? p.type ?? 'place',
lat: row.lat,
lng: row.lng,
// The schema holds it to four numbers.
extent: (p.extent as PhotonLead['extent']) ?? null,
};
}
/** A sight as a lead: one with a Wikidata link, under a key sights are filed under. */
function asSightLead(feature: PhotonFeature): PhotonLead | null {
const p = feature.properties;
if (typeof p.extra?.wikidata !== 'string' || !SIGHT_KEYS.has(p.osm_key ?? '')) return null;
const row = asSuggestion(feature);
if (!row || row.lat === undefined || row.lng === undefined) return null;
return {
placeId: row.placeId,
name: row.mainText,
reach: 'nearby',
kind: 'landmark',
lat: row.lat,
lng: row.lng,
extent: null,
};
}
/**
* The lead of a Photon answer (ranked by `rankFeatures`), or null. A country
* or a region in first place leads under any name, which keeps an exonym
* ("Mauretanien" answers Mauritania); anything else among the first three
* leads only when it is called what was typed, or a typo of it, when it may
* lead from where the traveller is looking (`leadsHere`), and when no
* namesake ranks above it. Without the name check "Paramraibo" opened on a
* district called Centrum and "Sagrada Familia" on a municipality in Brazil.
*/
export function pickLead(
features: PhotonFeature[],
query: string,
bias?: { lat: number; lng: number },
): PhotonLead | null {
if (AIRPORT_CODE.test(query.trim())) return null;
const first = features[0] ? asLead(features[0]) : null;
if (first?.reach === 'everywhere') return first;
for (const feature of features.slice(0, 3)) {
const lead = asLead(feature) ?? asSightLead(feature);
const named = nameLike(query, lead?.name ?? feature.properties.name ?? '');
if (lead && named && leadsHere(lead, bias)) return lead;
// A place of that name Photon ranks higher and that may not lead (the
// borough of Greenwich in London, a quarter, a municipality with nothing
// to say where the traveller is) outranks the ones behind it: leading with
// the next Greenwich put the town in Connecticut above London. A street or
// a shop of the name does not.
if (named && (feature.properties.osm_key === 'place' || feature.properties.osm_key === 'boundary')) return null;
}
return null;
}
/** Whether a name is what was typed: the same, the start of it, a word of it, or a typo away. */
export function nameLike(query: string, name: string): boolean {
const typed = foldName(query);
const called = foldName(name);
if (typed.length < 3 || !called) return false;
if (called.startsWith(typed) || typed.startsWith(called) || called.includes(` ${typed}`)) return true;
return editDistance(typed, called) <= (typed.length >= 8 ? 2 : 1);
}
/**
* Edits between two strings, a swap of neighbours counting as one: the
* optimal string alignment distance. "Paramraibo" is one from "Paramaribo".
*/
export function editDistance(a: string, b: string): number {
if (Math.abs(a.length - b.length) > 2) return 3;
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array<number>(b.length).fill(0)]);
for (let j = 1; j <= b.length; j++) d[0][j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
}
}
return d[a.length][b.length];
}
/**
* The point a reverse lookup found, in the fields Nominatim's reverse answer
* has. A town itself is its own `city`, and a named place is filed under its
@@ -396,11 +396,6 @@ export async function trekPlacesSearch(
return Array.isArray(body.results) ? body.results.map(withLocality) : [];
}
/** The index-only rows of a mixed result, for callers that need Overture fields. */
export function indexHitsOnly(hits: TrekSearchHit[]): TrekPlace[] {
return hits.filter((h): h is TrekPlace => !isOsmHit(h));
}
/**
* The planner's category pills, mapped onto Overture categories.
*
@@ -173,17 +173,43 @@ async function refusalOf(response: Response): Promise<'no_way' | 'too_long' | nu
/** The public Valhalla FOSSGIS runs, which TREK ships with. */
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de';
/** The public OSRM hosts FOSSGIS runs, one per profile, which TREK ships with. */
const FOSSGIS_OSRM = 'https://routing.openstreetmap.de';
/** Locations one request to FOSSGIS may carry; the eleventh is refused with error 150. */
const FOSSGIS_MAX_LOCATIONS = 10;
/** Whether a question goes to one of the public hosts, which allow about one request a second. */
function isPublicHost(url: string): boolean {
return url.startsWith(`${FOSSGIS_VALHALLA}/`) || url.startsWith(`${FOSSGIS_OSRM}/`);
}
/**
* A piece of a day cut for the relay, twenty locations, that has to go to
* FOSSGIS instead, which takes ten: the day is cut again for it (`valhallaLegs`).
*/
class TooManyForFossgis extends Error {
constructor() {
super('The route carries more locations than FOSSGIS takes');
this.name = 'TooManyForFossgis';
}
}
/** The locations a route body carries, none for a question of another kind. */
function locationCount(body: object): number {
const locations = (body as { locations?: unknown }).locations;
return Array.isArray(locations) ? locations.length : 0;
}
/**
* What the relay says when it cannot answer for the TREK services right now:
* no key, an allowance spent, a body it does not take, a path the host does
* not know, a host that is down or resting. The question then goes to
* FOSSGIS, as the planner in the browser sends it.
* FOSSGIS, as the planner in the browser sends it. A 504 is not among them:
* that one question took too long, FOSSGIS would take as long over it, and
* the relay answers the next one as before.
*/
function relayCannotAnswer(status: number): boolean {
return [401, 403, 404, 405, 413, 429].includes(status) || status >= 500;
return [401, 403, 404, 405, 413, 429].includes(status) || (status >= 500 && status !== 504);
}
/** The relay's answer read as the queue reads a router's: the payload, a refusal, or a failure. */
@@ -342,7 +368,13 @@ export class RoadtripRouterService {
return ['driving', 'walking', 'cycling', ...plugin];
}
/**
* One question to a router. The public hosts are asked one after another
* through a queue that leaves 1.1 s between them, as they ask; an admin's
* own OSRM or Valhalla sets its own limits and is asked at once.
*/
private async request(url: string, body?: unknown, timeoutMs = 20000): Promise<unknown> {
if (!isPublicHost(url)) return this.fetchPayload(url, body, timeoutMs);
const before = this.queue;
let release!: () => void;
this.queue = new Promise<void>((resolve) => {
@@ -353,34 +385,39 @@ export class RoadtripRouterService {
const delay = this.nextRequest - Date.now();
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
this.nextRequest = Date.now() + 1100;
const response = await safeFetchAdminConfigured(url, {
signal: AbortSignal.timeout(timeoutMs),
headers: { 'User-Agent': 'TREK Roadtrip', 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
...(body ? { method: 'POST', body: JSON.stringify(body) } : {}),
});
if (!response.ok) {
// A 400 from Valhalla says why: a way that does not exist is no failure of the host.
if (response.status !== 400) discardBody(response);
else {
const why = await refusalOf(response);
if (why) throw new RouteRefusal(why, 'Routing provider returned 400');
}
throw new Error(`Routing provider returned ${response.status}`);
}
const payload = await readCappedJson<unknown>(response, 8 * 1024 * 1024);
if (payload === undefined) throw new Error('Routing response is invalid or too large');
return payload;
return await this.fetchPayload(url, body, timeoutMs);
} finally {
release();
}
}
private async fetchPayload(url: string, body: unknown, timeoutMs: number): Promise<unknown> {
const response = await safeFetchAdminConfigured(url, {
signal: AbortSignal.timeout(timeoutMs),
headers: { 'User-Agent': 'TREK Roadtrip', 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
...(body ? { method: 'POST', body: JSON.stringify(body) } : {}),
});
if (!response.ok) {
// A 400 from Valhalla says why: a way that does not exist is no failure of the host.
if (response.status !== 400) discardBody(response);
else {
const why = await refusalOf(response);
if (why) throw new RouteRefusal(why, 'Routing provider returned 400');
}
throw new Error(`Routing provider returned ${response.status}`);
}
const payload = await readCappedJson<unknown>(response, 8 * 1024 * 1024);
if (payload === undefined) throw new Error('Routing response is invalid or too large');
return payload;
}
/**
* One question to a Valhalla. The TREK services' one is asked through the
* relay, under the user's allowance there and with the installation key; a
* relay that cannot answer hands the question to FOSSGIS through the paced
* queue, unless `publicFallback` is off. Every other Valhalla is asked
* through the queue directly.
* queue, unless `publicFallback` is off. A question the relay ran out of
* time on fails as it is, without FOSSGIS. Every other Valhalla is asked
* through `request` directly.
*/
private async askValhalla(
userId: number,
@@ -391,22 +428,24 @@ export class RoadtripRouterService {
): Promise<unknown> {
if (!this.relay.serves(valhalla)) return this.request(`${valhalla}/${action}`, body, timeoutMs);
const answer = await this.relay.ask(userId, action, body, timeoutMs).catch((err: unknown) => {
if (err instanceof DomainError && err.getStatus() === 504) throw new Error(err.message);
if (err instanceof DomainError) return null;
throw err;
});
if (answer && !relayCannotAnswer(answer.status)) return payloadOf(answer);
if (!publicFallback) throw new Error('The TREK routing service cannot answer');
if (locationCount(body) > FOSSGIS_MAX_LOCATIONS) throw new TooManyForFossgis();
return this.request(`${FOSSGIS_VALHALLA}/${action}`, body, timeoutMs);
}
/**
* Locations one route request carries: ten wherever it may end up at
* FOSSGIS, twenty at the TREK Valhalla asked alone and at an own one, as the
* planner splits a day (`locationsPerRequest`).
* Locations one route request carries: ten at FOSSGIS, twenty at the TREK
* Valhalla and at an own one, as the planner splits a day
* (`locationsPerRequest`). A piece cut for the relay that ends up at FOSSGIS
* has the day cut again (`valhallaLegs`).
*/
private locationsPerRequest(valhalla: string, publicFallback: boolean): number {
const mayReachPublic = valhalla === FOSSGIS_VALHALLA || (this.relay.serves(valhalla) && publicFallback);
return mayReachPublic ? FOSSGIS_MAX_LOCATIONS : VALHALLA_RELAY_MAX_LOCATIONS;
private locationsPerRequest(valhalla: string): number {
return valhalla === FOSSGIS_VALHALLA ? FOSSGIS_MAX_LOCATIONS : VALHALLA_RELAY_MAX_LOCATIONS;
}
async route(
@@ -612,9 +651,11 @@ export class RoadtripRouterService {
/**
* Valhalla's legs through the points, as many locations a request as the
* host takes (`locationsPerRequest`), one after another through the paced
* queue, each piece starting on the point the one before ended at. CW-12: a
* walk on foot binds no point to a ferry's edge (`offBoatLines`).
* host takes (`locationsPerRequest`), one after another, each piece starting
* on the point the one before ended at. Where the relay cannot answer a
* piece of twenty and FOSSGIS has to, the whole day is asked again in
* pieces of ten. CW-12: a walk on foot binds no point to a ferry's edge
* (`offBoatLines`).
*/
private async valhallaLegs(
userId: number,
@@ -622,10 +663,27 @@ export class RoadtripRouterService {
points: Point[],
costing: ValhallaCosting,
options: Record<string, number | string>,
{ offBoatLines = false, publicFallback = true }: { offBoatLines?: boolean; publicFallback?: boolean } = {},
ask: { offBoatLines?: boolean; publicFallback?: boolean } = {},
): Promise<ValhallaLegs> {
const size = this.locationsPerRequest(valhalla);
try {
return await this.legsInPieces(userId, valhalla, points, costing, options, ask, size);
} catch (err) {
if (!(err instanceof TooManyForFossgis)) throw err;
return this.legsInPieces(userId, valhalla, points, costing, options, ask, FOSSGIS_MAX_LOCATIONS);
}
}
private async legsInPieces(
userId: number,
valhalla: string,
points: Point[],
costing: ValhallaCosting,
options: Record<string, number | string>,
{ offBoatLines = false, publicFallback = true }: { offBoatLines?: boolean; publicFallback?: boolean },
size: number,
): Promise<ValhallaLegs> {
const legs: ValhallaLegs = [];
const size = this.locationsPerRequest(valhalla, publicFallback);
for (let start = 0; start < points.length - 1; start += size - 1) {
const body = {
locations: points.slice(start, start + size).map((p) => ({
@@ -637,6 +695,8 @@ export class RoadtripRouterService {
costing,
costing_options: { [costing]: options },
directions_options: { units: 'kilometers' },
// The maneuvers, where a walk boards a ferry, without the narrative TREK never reads.
directions_type: 'maneuvers',
};
const payload = await this.askValhalla(userId, valhalla, 'route', body, { publicFallback });
legs.push(...valhallaSchema.parse(payload).trip.legs);
@@ -767,6 +827,7 @@ export class RoadtripRouterService {
costing: 'pedestrian',
costing_options: { pedestrian: { ...costingOptions } },
directions_options: { units: 'kilometers' },
directions_type: 'maneuvers',
...(count > 0 ? { alternates: count } : {}),
});
const parsed = alternatesSchema.parse(payload);
@@ -23,11 +23,16 @@ const QUESTIONS_PER_MINUTE = 60;
*/
const INSTALLATION_QUESTIONS_PER_MINUTE = 100;
const RATE_WINDOW_MS = 60_000;
/** Short, because FOSSGIS still has to answer behind it while the planner waits. */
/**
* Short, because FOSSGIS still has to answer behind it while the planner waits. A
* question that runs past it is answered 504 and leaves the host asked: a bike route
* of 300 km takes the TREK Valhalla about twelve seconds, and that says nothing about
* the next question.
*/
const TIMEOUT_MS = 10_000;
/** Matching a long line takes Valhalla a while, on FOSSGIS as much as here. */
const TRACE_TIMEOUT_MS = 30_000;
/** How long every question goes to FOSSGIS after the TREK Valhalla failed one. */
/** How long every question goes to FOSSGIS after the TREK Valhalla could not be reached or failed one. */
const REST_MS = 60_000;
/** The longest wait a Retry-After is followed for; the first question after it finds out whether the allowance is back. */
const MAX_REST_MS = 60 * 60_000;
@@ -53,6 +58,8 @@ export interface ValhallaRelayAnswer {
* A host that failed a question, or refused it for a spent allowance, is left
* alone for a while: the relay then answers 503 at once and every caller asks
* FOSSGIS, instead of each leg of a long day waiting out the deadline again.
* A question that only took too long is no such failure: it is answered 504,
* and the next one is asked as before.
*/
@Injectable()
export class RoutingRelayService {
@@ -130,11 +137,14 @@ export class RoutingRelayService {
0,
undefined,
MAX_ANSWER_BYTES,
// One host asked many times in a row: its connection is kept rather than opened per question.
true,
);
this.identity.observe(url, response);
const retryAfter = response.headers.get('retry-after');
if (response.status === 429) this.rest(waitOf(retryAfter) ?? REST_MS);
else if (response.status >= 500) this.rest(REST_MS);
// A 504 of the host's own proxy is a slow question as much as the relay's deadline is.
else if (response.status >= 500 && response.status !== 504) this.rest(REST_MS);
const { bytes, truncated } = await readCapped(response, MAX_ANSWER_BYTES);
if (truncated) throw new Error('the answer is larger than the relay passes on');
return {
@@ -144,9 +154,14 @@ export class RoutingRelayService {
...(retryAfter ? { retryAfter } : {}),
};
} catch (err) {
this.rest(REST_MS);
// A question that ran out its deadline is a slow question, not a host that is down:
// only a host that could not be reached, or sent more than is passed on, rests.
const timedOut = err instanceof DOMException && err.name === 'TimeoutError';
if (!timedOut) this.rest(REST_MS);
logWarn(`[Routing relay] ${url.pathname}: ${err instanceof Error ? err.message : String(err)}`);
throw new DomainError(502, 'The TREK routing service did not answer');
throw timedOut
? new DomainError(504, 'The TREK routing service took too long for this route')
: new DomainError(502, 'The TREK routing service did not answer');
}
}
+66 -7
View File
@@ -261,6 +261,10 @@ function isLinkLocal(ip: string): boolean {
* legitimately wait longer (see safeFetchLlm). Left unset, the caller's own
* signal governs, and a call without one waits DEFAULT_RESPONSE_TIMEOUT_MS for
* the response headers. `maxBytes` caps the body as in SafeFetchOptions.
*
* `reuseConnections` keeps the dispatcher for the next call to the same vetted
* addresses (see keptDispatcher), so a caller that asks one host many times in
* a row, the routing relay, does not pay DNS, TCP and TLS on every question.
*/
export async function safeFetchAdminConfigured(
url: string,
@@ -268,6 +272,7 @@ export async function safeFetchAdminConfigured(
maxRedirects = 5,
responseTimeoutMs?: number,
maxBytes: number | null = DEFAULT_MAX_RESPONSE_BYTES,
reuseConnections = false,
): Promise<Response> {
let currentUrl = url;
let hopInit = init;
@@ -298,7 +303,14 @@ export async function safeFetchAdminConfigured(
throw new SsrfBlockedError('Requests to link-local / cloud-metadata addresses are not allowed');
}
const dispatcher = createOutboundDispatcher(currentUrl, usableIps, true, responseTimeoutMs, headersTimeoutMs);
const dispatcher = createOutboundDispatcher(
currentUrl,
usableIps,
true,
responseTimeoutMs,
headersTimeoutMs,
reuseConnections,
);
const response = await fetch(currentUrl, { ...hopInit, redirect: 'manual', dispatcher } as any);
// Only a 3xx WITH a Location header is a redirect we follow; anything else
@@ -683,14 +695,57 @@ export function createOutboundDispatcher(
rejectUnauthorized = true,
responseTimeoutMs?: number,
headersTimeoutMs?: number,
reuseConnections = false,
): Dispatcher {
const proxy = proxyFor(url);
if (!proxy) return createPinnedDispatcher(resolved, rejectUnauthorized, responseTimeoutMs, headersTimeoutMs);
return new ProxyAgent({
uri: proxy,
requestTls: { rejectUnauthorized },
...dispatcherTimeouts(responseTimeoutMs, headersTimeoutMs),
});
const keepAliveMs = reuseConnections ? KEPT_ALIVE_MS : undefined;
const create = (): Dispatcher =>
proxy
? new ProxyAgent({
uri: proxy,
requestTls: { rejectUnauthorized },
...dispatcherTimeouts(responseTimeoutMs, headersTimeoutMs),
...(keepAliveMs ? { keepAliveTimeout: keepAliveMs } : {}),
})
: createPinnedDispatcher(resolved, rejectUnauthorized, responseTimeoutMs, headersTimeoutMs, keepAliveMs);
if (!reuseConnections) return create();
// Through a proxy the proxy connects, so the addresses play no part in what the dispatcher does.
const addresses = proxy ? [] : typeof resolved === 'string' ? [resolved] : [...resolved];
const key = JSON.stringify([
proxy,
addresses,
rejectUnauthorized,
responseTimeoutMs ?? null,
headersTimeoutMs ?? null,
]);
return keptDispatcher(key, create);
}
/**
* Dispatchers kept for the callers that reuse their connections, by everything
* that makes one: the proxy, the vetted addresses, the TLS check and the
* timeouts. The addresses are part of the key, so the pin is as strict as a
* new agent's: a name that now resolves elsewhere gets a dispatcher of its own
* and never a connection to the old address. At most MAX_KEPT_DISPATCHERS are
* kept; the oldest is closed, after its requests finish, when one more is needed.
*/
const keptDispatchers = new Map<string, Dispatcher>();
const MAX_KEPT_DISPATCHERS = 32;
/** How long a kept connection may sit idle, where undici's own default is four seconds. */
const KEPT_ALIVE_MS = 10_000;
function keptDispatcher(key: string, create: () => Dispatcher): Dispatcher {
const kept = keptDispatchers.get(key);
if (kept) return kept;
const dispatcher = create();
keptDispatchers.set(key, dispatcher);
const [oldestKey] = keptDispatchers.keys();
if (keptDispatchers.size > MAX_KEPT_DISPATCHERS && oldestKey !== undefined) {
const oldest = keptDispatchers.get(oldestKey);
keptDispatchers.delete(oldestKey);
void oldest?.close().catch(() => {});
}
return dispatcher;
}
/**
@@ -717,12 +772,15 @@ function dispatcherTimeouts(
* `all: true` and walks the list itself, moving on after its attempt timeout
* when one family does not answer.
* A caller that still names one address gets exactly the old behaviour.
* `keepAliveMs` holds an idle connection open longer than undici's default,
* for a dispatcher that is kept (keptDispatcher).
*/
export function createPinnedDispatcher(
resolved: string | readonly string[],
rejectUnauthorized = true,
responseTimeoutMs?: number,
headersTimeoutMs?: number,
keepAliveMs?: number,
): Agent {
const addresses = (typeof resolved === 'string' ? [resolved] : [...resolved]).map((address) => ({
address,
@@ -733,6 +791,7 @@ export function createPinnedDispatcher(
// that cap is invisible from the call site: an AbortController set to
// fifteen still dies at five.
...dispatcherTimeouts(responseTimeoutMs, headersTimeoutMs),
...(keepAliveMs ? { keepAliveTimeout: keepAliveMs } : {}),
connect: {
rejectUnauthorized,
lookup: (_hostname: string, opts: Record<string, unknown>, callback: Function) => {
+8 -7
View File
@@ -1,5 +1,6 @@
/**
* /api/maps search and reverse e2e with Photon in front of Nominatim: the real
* /api/maps search and reverse e2e with Photon as the geocoder of an
* installation with a key, Nominatim of one without: the real
* JwtAuthGuard, Zod pipe and response contracts over MapsModule on a migrated
* temp database, the TREK Places index switched off so the OpenStreetMap half
* answers alone.
@@ -127,12 +128,12 @@ describe('POST /api/maps/search', () => {
expect(identity.observed).toEqual([{ host: 'photon.liketrek.com', status: 200 }]);
});
it('MAPS-PHOTON-E2E-002: falls back to Nominatim when Photon refuses, and the answer reads the same', async () => {
it('MAPS-PHOTON-E2E-002: a Photon that refuses is an empty answer, Nominatim is not asked in its place', async () => {
photonReply = () => photonAnswer([], 429);
const res = await request(app.getHttpServer()).post('/api/maps/search').set(as(USER)).send({ query: 'Café' });
expect(res.status).toBe(200);
expect(res.body.places[0]).toMatchObject({ osm_id: 'node:42', name: 'Café Nominatim', category: 'cafe' });
expect(hostsAsked()).toEqual(['photon.liketrek.com', 'nominatim.openstreetmap.org']);
expect(res.body.places).toEqual([]);
expect(hostsAsked()).toEqual(['photon.liketrek.com']);
});
it('MAPS-PHOTON-E2E-003: never asks Photon without a key', async () => {
@@ -164,14 +165,14 @@ describe('GET /api/maps/reverse', () => {
expect(asked[0].pathname).toBe('/reverse');
});
it('MAPS-PHOTON-E2E-006: asks Nominatim when Photon is down', async () => {
it('MAPS-PHOTON-E2E-006: a Photon that is down names nothing, Nominatim is not asked in its place', async () => {
photonReply = () => photonAnswer([], 503);
const res = await request(app.getHttpServer())
.get('/api/maps/reverse')
.set(as(USER))
.query({ lat: '52.5163', lng: '13.3777' });
expect(res.body).toEqual({ name: 'Café Nominatim', address: NOMINATIM_HIT.display_name });
expect(hostsAsked()).toEqual(['photon.liketrek.com', 'nominatim.openstreetmap.org']);
expect(res.body).toEqual({ name: null, address: null });
expect(hostsAsked()).toEqual(['photon.liketrek.com']);
});
it('MAPS-PHOTON-E2E-007: answers 401 without a session and asks nobody', async () => {
@@ -1513,6 +1513,50 @@ describe('car rentals', () => {
expect(ics).not.toContain('Pickup: Dayless');
expect(ics).not.toContain('Drop-off: Dayless');
});
// ── Where a transport goes: LOCATION and GEO from its endpoints (#2909) ─────
const eventOf = (ics: string, summary: string) =>
ics.replaceAll('\r\n ', '').split('BEGIN:VEVENT').find((ev) => ev.includes(`SUMMARY:${summary}\r\n`)) ?? '';
it('CAL-2909a: a drive of its own is located at its destination, each hand-over where it happens', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Road Trip' });
const drive = createReservation(testDb, trip.id, { title: 'Drive', type: 'car' });
await updateRows(orm, Reservations, { id: drive.id }, { reservation_time: null, reservation_end_time: null, location: null });
await insertEndpoint(drive.id, 'from', 0, '1 Origin Road, Startville', 48.1, 11.5, 'Europe/Berlin', '09:00', '2026-07-07');
await insertEndpoint(drive.id, 'to', 1, '123 Example Street, Sampletown', 47.5, 12.25, 'Europe/Berlin', '13:00', '2026-07-07');
const { ics } = await svc.exportICS(trip.id);
const event = eventOf(ics, 'Drive');
expect(event).toContain('LOCATION:123 Example Street\\, Sampletown\r\n');
expect(event).toContain('GEO:47.5;12.25\r\n');
// The route stays in the description.
expect(event).toContain('Route: 1 Origin Road\\, Startville → 123 Example Street\\, Sampletown');
expect(eventOf(ics, 'Pickup: Drive')).toContain('LOCATION:1 Origin Road\\, Startville\r\n');
expect(eventOf(ics, 'Drop-off: Drive')).toContain('LOCATION:123 Example Street\\, Sampletown\r\n');
});
it('CAL-2909b: a ride the traveller boards is located where it departs, and a stated location still wins', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Rail Trip' });
const train = createReservation(testDb, trip.id, { title: 'ICE 571', type: 'train' });
const ferry = createReservation(testDb, trip.id, { title: 'Ferry', type: 'ferry' });
await updateRows(orm, Reservations, { id: train.id }, { reservation_time: null, reservation_end_time: null, location: null });
await updateRows(orm, Reservations, { id: ferry.id }, { reservation_time: null, reservation_end_time: null, location: 'Pier 3' });
await insertEndpoint(train.id, 'from', 0, 'Mannheim Hbf', 49.4794, 8.4692, 'Europe/Berlin', '08:00', '2026-07-08');
await insertEndpoint(train.id, 'to', 1, 'Berlin Hbf', 52.525, 13.369, 'Europe/Berlin', '12:30', '2026-07-08');
await insertEndpoint(ferry.id, 'from', 0, 'Mannheim Hbf', 49.4794, 8.4692, 'Europe/Berlin', '08:00', '2026-07-08');
await insertEndpoint(ferry.id, 'to', 1, 'Berlin Hbf', 52.525, 13.369, 'Europe/Berlin', '12:30', '2026-07-08');
const { ics } = await svc.exportICS(trip.id);
expect(eventOf(ics, 'ICE 571')).toContain('LOCATION:Mannheim Hbf\r\nGEO:49.4794;8.4692\r\n');
const ferryEvent = eventOf(ics, 'Ferry');
expect(ferryEvent).toContain('LOCATION:Pier 3\r\n');
expect(ferryEvent).not.toContain('GEO:');
});
});
describe('staged bookings', () => {
@@ -576,7 +576,7 @@ describe('AnthropicClient', () => {
expect(fetchFn.mock.calls[0][0]).toBe('http://gateway.lan:4000/v1/messages');
expect(fetchFn.mock.calls[1][0]).toBe('https://api.anthropic.com/v1/messages');
const body = JSON.parse((fetchFn.mock.calls[0][1] as RequestInit).body as string);
expect(body.tool_choice).toMatchObject({ type: 'tool', name: 'emit_reservations' });
expect(body.tool_choice).toEqual({ type: 'auto', disable_parallel_tool_use: true });
});
it('tells a prompt too long for a gateway apart from other refusals', async () => {
@@ -632,7 +632,7 @@ describe('AnthropicClient', () => {
expect(blockOf(1).type).toBe('document');
});
it('forces the emit_reservations tool and reads its input', async () => {
it('asks for the emit_reservations tool without forcing it and reads its input (#2902)', async () => {
const fetchFn = mockFetch(() =>
jsonResponse({
stop_reason: 'tool_use',
@@ -644,8 +644,12 @@ describe('AnthropicClient', () => {
const out = await new AnthropicClient().extract(baseInput);
expect(out).toEqual([{ '@type': 'LodgingReservation' }]);
const body = JSON.parse((fetchFn.mock.calls[0][1] as RequestInit).body as string);
expect(body.tool_choice).toEqual({ type: 'tool', name: 'emit_reservations' });
// Claude Sonnet 5.5, Opus 5.5 and Fable 5.1 refuse a forced tool_choice with a 400;
// auto with one call at most is accepted by every model, the prompt names the tool.
expect(body.tool_choice).toEqual({ type: 'auto', disable_parallel_tool_use: true });
expect(body.tools[0].name).toBe('emit_reservations');
expect(body.system.startsWith(baseInput.prompt)).toBe(true);
expect(body.system).toContain('Answer only by calling the emit_reservations tool, exactly once');
});
/*
@@ -702,7 +706,7 @@ describe('AnthropicClient', () => {
});
/*
* A forced tool that was never called, and a run that stopped at the cap with a
* A tool that was never called, and a run that stopped at the cap with a
* fragment of one, both came back as [] — so a long voucher reached the person
* as "no reservations found", with an empty warnings array and nothing in the
* log. That is the #2375 symptom on the provider the dropdown offers first, so
@@ -734,7 +738,7 @@ describe('AnthropicClient', () => {
}
});
it('throws when the forced tool was never called (#2375)', async () => {
it('throws when the tool was never called (#2375)', async () => {
mockFetch(() =>
jsonResponse({
stop_reason: 'end_turn',
@@ -116,7 +116,7 @@ function settings(rows: Record<string, string>): AppSettingsRepository {
function withPhoton(rows: Suggestion[] | null, settingRows: Record<string, string> = {}) {
trekPlaces.on = true;
const parts = buildMapsParts({} as PlacePhotoCacheService, settings(settingRows), noUsers, {} as never, {} as never);
const photon = vi.spyOn(parts.osm, 'autocompletePhoton').mockResolvedValue(rows);
const photon = vi.spyOn(parts.osm, 'suggestPhoton').mockResolvedValue(rows && { rows, lead: null });
return { svc: parts.svc, photon };
}
@@ -238,7 +238,7 @@ describe('MapsService.autocompletePlaces', () => {
});
it('MAPS-AUTO-009: index and layer hits keep their own id form in one list', async () => {
mockSearch.mockResolvedValue([hit(), osmHit(), hit({ gers: 'def-456' })]);
mockSearch.mockResolvedValue([hit(), osmHit(), hit({ gers: 'def-456', name: 'Kirby Cafe Tenjin' })]);
const { suggestions } = await make().autocompletePlaces(1, INPUT);
@@ -540,7 +540,8 @@ describe('MapsService.autocompletePlaces', () => {
mockSearch.mockResolvedValue([]);
const { svc, photon } = withPhoton(null);
photon.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve([photonRow()]), PHOTON_GRACE_MS + 100)),
() =>
new Promise((resolve) => setTimeout(() => resolve({ rows: [photonRow()], lead: null }), PHOTON_GRACE_MS + 100)),
);
const result = await svc.autocompletePlaces(1, INPUT);
@@ -1,5 +1,5 @@
/**
* MAPS-MERGE-001..008 — showing the index's answer and OpenStreetMap's together.
* MAPS-MERGE-001..014 — showing the index's answer and OpenStreetMap's together.
*
* Why this exists, in numbers. 128 places out of a real trip to Japan, all
* saved through the old search, replayed against the index: 51.6 percent came
@@ -8,7 +8,8 @@
* ten plausible wrong answers look like an answer and the user never learns the
* temple was one query away in OpenStreetMap.
*/
import { mergeSearchResults } from '../../../src/nest/maps/maps.helpers';
import { mergeSearchResults } from '../../../src/nest/maps/maps-merge.helpers';
import type { PhotonLead } from '../../../src/nest/maps/providers/photon.mapping';
import { describe, it, expect } from 'vitest';
@@ -120,4 +121,37 @@ describe('mergeSearchResults', () => {
const osm = [p('長谷寺', 35.3125, 139.5335, 'openstreetmap')];
expect(mergeSearchResults(index, osm)).toHaveLength(2);
});
it('MAPS-MERGE-013: opens on the lead, as the suggestions do, and drops the rows of its name inside it', () => {
const tokyo: PhotonLead = {
placeId: 'relation:1543125',
name: 'Tokyo',
reach: 'everywhere',
kind: 'province',
lat: 35.68,
lng: 139.76,
extent: [135.85, 35.9, 154.2, 20.21],
};
const index = [
{ ...p('Tokyo Tokyo', 14.5, 121, 'trek-places'), address: 'Philippines' },
{ ...p('Tokyo', 35.69, 139.7, 'trek-places'), address: 'Shinjuku' },
];
const osm = [{ ...p('Tokyo', 35.68, 139.76, 'openstreetmap'), osm_id: 'relation:1543125' }];
expect(mergeSearchResults(index, osm, 10, { lead: tokyo }).map((r) => r.osm_id)).toEqual([
'relation:1543125',
'trek-places:Tokyo Tokyo',
]);
});
it('MAPS-MERGE-014: one row for the same element under both ids, and one for rows that read the same', () => {
const index = [
{ ...p('Kröpeliner Tor', 54.0893, 12.1325, 'openstreetmap'), osm_id: 'way:1' },
{ ...p('Lidl', 54.1, 12.1, 'trek-places'), address: 'Rostock' },
{ ...p('Lidl', 54.2, 12.2, 'trek-places'), address: 'Rostock', osm_id: 'gers:2' },
];
const osm = [{ ...p('Kröpeliner-Tor', 54.0894, 12.1326, 'openstreetmap'), osm_id: 'way:1' }];
expect(mergeSearchResults(index, osm).map((r) => r.osm_id)).toEqual(['way:1', 'trek-places:Lidl']);
});
});
@@ -319,7 +319,13 @@ describe('MapsService.searchPlaces — the merged path', () => {
await make().searchPlaces(1, "L'Osteria", 'de', { lat: 54.0879, lng: 12.1408, radius: 5000 });
expect(mockSearch).toHaveBeenCalledWith("L'Osteria", { lat: 54.0879, lng: 12.1408, limit: 10, lang: 'de' });
expect(mockSearch).toHaveBeenCalledWith("L'Osteria", {
lat: 54.0879,
lng: 12.1408,
limit: 10,
sources: 'index,osm',
lang: 'de',
});
// Without the viewbox, "Hase-dera" answers with the temple in Nara rather
// than the one the user is standing beside in Kamakura.
const params = mockNominatim.mock.calls[0][1] as URLSearchParams;
@@ -1,5 +1,5 @@
/**
* MAPS-SUGMERGE-001..004: the index's suggestions and Photon's in one list (#2877).
* MAPS-SUGMERGE-001..010: the index's suggestions and Photon's in one list (#2877).
*
* The keystroke path asks the index with its OpenStreetMap layer and Photon at
* once. Both carry OpenStreetMap places, so the same element can come back from
@@ -7,6 +7,8 @@
* Photon found.
*/
import { mergeSuggestions } from '../../../src/nest/maps/maps-index.helpers';
import { LEAD_TOWN_REACH_M } from '../../../src/nest/maps/maps-merge.helpers';
import type { PhotonLead } from '../../../src/nest/maps/providers/photon.mapping';
import { describe, it, expect } from 'vitest';
@@ -58,4 +60,87 @@ describe('mergeSuggestions', () => {
expect(mergeSuggestions([], photon)).toHaveLength(8);
expect(mergeSuggestions([], photon, 3).map((s) => s.placeId)).toEqual(['node:0', 'node:1', 'node:2']);
});
// Measured on "Berlin": the index's first row is a business filed under the
// city's name, Photon's first row is the city.
const berlin: PhotonLead = {
placeId: 'relation:62422',
name: 'Berlin',
reach: 'nearby',
kind: 'city',
lat: 52.5173885,
lng: 13.3951309,
extent: [13.088345, 52.6755087, 13.7611609, 52.3382448],
};
const berlinIndex = [
row('gers:office', 'Berlin', 52.512, 13.32, 'trek-places'),
row('node:484323', 'Berlin', 52.415, 13.197, 'openstreetmap'),
row('node:21302157', 'Berlin-Tiergarten', 52.514, 13.336, 'openstreetmap'),
];
const berlinPhoton = [
row('relation:62422', 'Berlin', 52.5173885, 13.3951309, 'openstreetmap'),
row('relation:170184', 'Berlin', 44.4697, -71.1811, 'openstreetmap'),
];
it('MAPS-SUGMERGE-005: opens on the lead and drops the rows of its name inside it', () => {
const list = mergeSuggestions(berlinIndex, berlinPhoton, 8, { lead: berlin });
// The "Berlin" in Kleinmachnow lies inside the city's box, so it goes too.
expect(list.map((s) => s.placeId)).toEqual(['relation:62422', 'relation:170184', 'node:21302157']);
});
it('MAPS-SUGMERGE-006: a town leads only near where the traveller looks', () => {
const tokyo = { lat: 35.68, lng: 139.76 };
expect(mergeSuggestions(berlinIndex, berlinPhoton, 8, { lead: berlin, bias: tokyo })[0].placeId).toBe(
'gers:office',
);
const potsdam = { lat: 52.39, lng: 13.06 };
expect(mergeSuggestions(berlinIndex, berlinPhoton, 8, { lead: berlin, bias: potsdam })[0].placeId).toBe(
'relation:62422',
);
expect(LEAD_TOWN_REACH_M).toBe(1_000_000);
});
it('MAPS-SUGMERGE-007: a country leads from anywhere', () => {
const mauritania: PhotonLead = {
placeId: 'relation:192763',
name: 'Mauritania',
reach: 'everywhere',
kind: 'country',
lat: 20.25,
lng: -10.33,
extent: [-17.2380959, 27.314942, -4.8333344, 14.7209909],
};
const index = [row('gers:embassy', 'Islamische Republik Mauretanien', 52.51, 13.4, 'trek-places')];
const photon = [row('relation:192763', 'Mauritania', 20.25, -10.33, 'openstreetmap')];
const list = mergeSuggestions(index, photon, 8, { lead: mauritania, bias: { lat: 52.5, lng: 13.4 } });
expect(list.map((s) => s.placeId)).toEqual(['relation:192763', 'gers:embassy']);
});
it("MAPS-SUGMERGE-008: leads with the layer's copy where Photon's was dropped as the same element", () => {
const index = [row('gers:shop', 'Berlin Döner', 52.52, 13.4, 'trek-places'), berlinPhoton[0]];
const list = mergeSuggestions(index, berlinPhoton, 8, { lead: berlin });
expect(list.map((s) => s.placeId)).toEqual(['relation:62422', 'gers:shop', 'relation:170184']);
});
it('MAPS-SUGMERGE-009: shows rows that read the same once, and fills the list up behind them', () => {
const branch = (id: string, lat: number) => ({
...row(id, 'Tokyo Tokyo', lat, 121, 'trek-places'),
secondaryText: 'Philippines',
});
const index = [branch('gers:1', 14.5), branch('gers:2', 14.6), branch('gers:3', 14.7)];
const photon = [row('relation:1543125', 'Tokyo', 35.68, 139.76, 'openstreetmap')];
expect(mergeSuggestions(index, photon, 2).map((s) => s.placeId)).toEqual(['gers:1', 'relation:1543125']);
});
it('MAPS-SUGMERGE-010: with nothing to say where the traveller is, a municipality does not lead', () => {
const municipality: PhotonLead = { ...berlin, kind: 'municipality' };
expect(mergeSuggestions(berlinIndex, berlinPhoton, 8, { lead: municipality })[0].placeId).toBe('gers:office');
expect(
mergeSuggestions(berlinIndex, berlinPhoton, 8, { lead: municipality, bias: { lat: 52.4, lng: 13.1 } })[0].placeId,
).toBe('relation:62422');
});
});
+160 -41
View File
@@ -1,9 +1,9 @@
/**
* OSM-PHOTON-001..014: the OpenStreetMap search and reverse lookup ask Photon
* first where the installation holds a key, answer in the shape Nominatim's
* answers always had, and fall back to Nominatim whenever Photon gives none.
* Lookups by id stay on Nominatim. The network is a stubbed global fetch
* routed by host.
* OSM-PHOTON-001..020: the OpenStreetMap search and reverse lookup are Photon's
* where the installation holds a key, answer in the shape Nominatim's answers
* always had, and leave Nominatim to installations without the TREK services.
* A lookup by id is Overpass and Photon, Nominatim only where Overpass cannot
* place the element. The network is a stubbed global fetch routed by host.
*/
import { deriveServices } from '../../../src/app-config/boot-derive';
import { OsmClient } from '../../../src/nest/maps/providers/osm.client';
@@ -33,6 +33,7 @@ const NOMINATIM_REVERSE = {
let identity: FakeServiceIdentity;
let photonReply: () => Response;
let overpassReply: () => Response;
let osm: OsmClient;
const hosts: string[] = [];
@@ -40,6 +41,20 @@ beforeEach(() => {
identity = new FakeServiceIdentity();
serviceIdentitySlot.install(identity);
photonReply = () => photonAnswer([gateFeature()]);
overpassReply = () =>
new Response(
JSON.stringify({
elements: [
{
type: 'way',
id: 518071791,
center: { lat: 52.5163, lon: 13.3777 },
tags: { name: 'Brandenburger Tor', 'name:en': 'Brandenburg Gate', website: 'https://example.org' },
},
],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
hosts.length = 0;
vi.stubEnv('NOMINATIM_URL', '');
vi.stubGlobal(
@@ -48,6 +63,7 @@ beforeEach(() => {
const url = new URL(String(input));
hosts.push(url.hostname);
if (url.hostname === 'photon.liketrek.com') return photonReply();
if (url.hostname === 'overpass-api.de') return overpassReply();
const body = url.pathname.endsWith('/reverse') ? NOMINATIM_REVERSE : [NOMINATIM_GATE];
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
}),
@@ -82,10 +98,15 @@ describe('search', () => {
});
});
it('OSM-PHOTON-002: asks Nominatim when Photon fails, with the bias it would have had', async () => {
it('OSM-PHOTON-002: a Photon that fails is an empty answer where Photon is the geocoder; Nominatim only without the services', async () => {
photonReply = () => photonAnswer([], 503);
expect(await osm.searchNominatim('Brandenburger Tor', 'de', 'interactive', { lat: 52.5, lng: 13.4 })).toEqual([]);
expect(hosts).toEqual(['photon.liketrek.com']);
hosts.length = 0;
identity.bearer = null;
const places = await osm.searchNominatim('Brandenburger Tor', 'de', 'interactive', { lat: 52.5, lng: 13.4 });
expect(hosts).toEqual(['photon.liketrek.com', 'nominatim.openstreetmap.org']);
expect(hosts).toEqual(['nominatim.openstreetmap.org']);
expect(places[0]).toMatchObject({ osm_id: 'way:518071791' });
});
@@ -95,26 +116,20 @@ describe('search', () => {
expect(places.map((p) => p.osm_id)).toEqual(['node:7']);
});
it('OSM-PHOTON-010: asks Nominatim when Photon finds nothing usable, without holding it against Photon', async () => {
it('OSM-PHOTON-010: an empty answer from Photon is the answer; Nominatim is not asked beside it', async () => {
photonReply = () => photonAnswer([]);
expect((await osm.searchNominatim('Brandenburger Tor', 'de'))[0]).toMatchObject({ osm_id: 'way:518071791' });
expect(await osm.searchNominatim('Brandenburger Tor', 'de')).toEqual([]);
photonReply = () => photonAnswer([gateFeature({ osm_type: undefined })]);
expect((await osm.searchNominatim('Brandenburger Tor', 'de'))[0]).toMatchObject({ wikidata: 'Q82425' });
photonReply = () => photonAnswer([gateFeature()]);
await osm.searchNominatim('Brandenburger Tor', 'de');
expect(hosts).toEqual([
'photon.liketrek.com',
'nominatim.openstreetmap.org',
'photon.liketrek.com',
'nominatim.openstreetmap.org',
'photon.liketrek.com',
]);
expect(await osm.searchNominatim('Brandenburger Tor', 'de')).toEqual([]);
expect(hosts).toEqual(['photon.liketrek.com', 'photon.liketrek.com']);
});
it('OSM-PHOTON-011: a bulk import on the background lane asks Nominatim alone', async () => {
await osm.searchNominatim('Brandenburger Tor', 'de', 'background');
it('OSM-PHOTON-011: a bulk import on the background lane asks Photon too, for its search and its reverse lookup', async () => {
expect((await osm.searchNominatim('Brandenburger Tor', 'de', 'background'))[0]).toMatchObject({
osm_id: 'way:518071791',
});
await osm.reverse('52.5', '13.4', 'de', { lane: 'background' });
expect(hosts).toEqual(['nominatim.openstreetmap.org', 'nominatim.openstreetmap.org']);
expect(hosts).toEqual(['photon.liketrek.com', 'photon.liketrek.com']);
});
it('OSM-PHOTON-012: carries the wiki tags of a Photon hit into the record', async () => {
@@ -145,7 +160,7 @@ describe('search', () => {
{
placeId: 'way:518071791',
mainText: 'Brandenburger Tor',
secondaryText: 'Pariser Platz, Mitte, Berlin, 10117, Deutschland',
secondaryText: 'Pariser Platz, Mitte, Berlin, Deutschland',
source: 'openstreetmap',
lat: 52.5163,
lng: 13.3777,
@@ -153,6 +168,67 @@ describe('search', () => {
]);
});
it("OSM-PHOTON-015: hands the suggestion list Photon's first hit as its lead where it names a town", async () => {
const town = gateFeature({
osm_type: 'R',
osm_id: 62422,
osm_key: 'place',
osm_value: 'city',
type: 'city',
name: 'Berlin',
extent: [13.088345, 52.6755087, 13.7611609, 52.3382448],
});
photonReply = () => photonAnswer([town, gateFeature()]);
const answer = await osm.suggestPhoton('Berl', 'de');
expect(answer?.rows.map((r) => r.placeId)).toEqual(['relation:62422', 'way:518071791']);
expect(answer?.lead).toMatchObject({ placeId: 'relation:62422', reach: 'nearby' });
// A sight with a Wikidata link leads too, when it is called what was typed.
photonReply = () => photonAnswer([gateFeature(), town]);
expect((await osm.suggestPhoton('Bran', 'de'))?.lead).toMatchObject({ placeId: 'way:518071791', kind: 'landmark' });
expect((await osm.suggestPhoton('Pariser', 'de'))?.lead).toBeNull();
});
it('OSM-PHOTON-016: the search hands its records ranked and its lead, and null where Photon does not answer', async () => {
const street = gateFeature({ osm_id: 1, osm_key: 'highway', osm_value: 'pedestrian', extra: undefined });
photonReply = () => photonAnswer([street, gateFeature()]);
const answer = await osm.searchPhoton('Brandenburger Tor', 'de');
// The street of the same name steps back behind the sight with a Wikidata link.
expect(answer?.records.map((r) => r.osm_id)).toEqual(['way:518071791', 'way:1']);
expect(answer?.lead).toMatchObject({ placeId: 'way:518071791' });
identity.bearer = null;
expect(await osm.searchPhoton('Brandenburger Tor', 'de')).toBeNull();
});
it('OSM-PHOTON-020: an airport code in capitals is asked as the airport at its place, and leads with the aerodrome', async () => {
const asked: URLSearchParams[] = [];
const aerodrome = gateFeature({
osm_type: 'W',
osm_id: 12345,
osm_key: 'aeroway',
osm_value: 'aerodrome',
name: 'John F. Kennedy International Airport',
});
(aerodrome.geometry as { coordinates: number[] }).coordinates = [-73.7781, 40.6413];
photonReply = () => photonAnswer([aerodrome]);
const fetchStub = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
const inner = fetchStub.getMockImplementation() as (input: string | URL) => Promise<Response>;
fetchStub.mockImplementation(async (input: string | URL) => {
asked.push(new URL(String(input)).searchParams);
return inner(input);
});
const answer = await osm.suggestPhoton('JFK', 'en');
expect(asked[0].get('q')).toBe('John F. Kennedy International Airport');
expect(asked[0].get('lat')).toBe('40.639447');
expect(answer?.lead).toMatchObject({ placeId: 'way:12345', reach: 'everywhere', kind: 'airport' });
// Lower case is a word being typed, not a code.
await osm.suggestPhoton('jfk', 'en');
expect(asked[1].get('q')).toBe('jfk');
});
it('OSM-PHOTON-014: answers null without Photon rather than asking Nominatim, whose policy rules out autocomplete', async () => {
identity.bearer = null;
expect(await osm.autocompletePhoton('Brandenb', 'de')).toBeNull();
@@ -176,30 +252,73 @@ describe('reverse', () => {
expect((await osm.reverse('52.5163', '13.3777', 'de', { locality: true })).name).toBe('Berlin');
});
it('OSM-PHOTON-007: asks Nominatim when Photon fails or finds nothing there', async () => {
photonReply = () => photonAnswer([], 429);
expect(await osm.reverse('52.5', '13.4', 'de')).toEqual({
name: 'Unter den Linden',
address: 'Unter den Linden 77, Mitte, Berlin, Deutschland',
});
it('OSM-PHOTON-007: asks again within 50 km when nothing is next to the point, and never Nominatim beside Photon', async () => {
const asked: string[] = [];
photonReply = () => photonAnswer([]);
expect((await osm.reverse('52.5', '13.4', 'de')).name).toBe('Unter den Linden');
expect(hosts).toEqual([
'photon.liketrek.com',
'nominatim.openstreetmap.org',
'photon.liketrek.com',
'nominatim.openstreetmap.org',
]);
const fetchStub = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
const inner = fetchStub.getMockImplementation() as (input: string | URL) => Promise<Response>;
fetchStub.mockImplementation(async (input: string | URL) => {
asked.push(new URL(String(input)).searchParams.get('radius') ?? '');
return inner(input);
});
expect(await osm.reverse('46.0', '104.0', 'de')).toEqual({ name: null, address: null });
expect(asked).toEqual(['', '50']);
photonReply = () => photonAnswer([], 429);
expect(await osm.reverse('52.5', '13.4', 'de')).toEqual({ name: null, address: null });
expect(hosts).not.toContain('nominatim.openstreetmap.org');
});
it('OSM-PHOTON-008: the raw answer for a pasted link comes from Photon, else from Nominatim', async () => {
it('OSM-PHOTON-008: the raw answer for a pasted link comes from Photon; a miss is an empty answer, not Nominatim', async () => {
expect((await osm.reverseRaw(52.5163, 13.3777)).address?.tourism).toBe('Brandenburger Tor');
photonReply = () => photonAnswer([], 500);
expect(await osm.reverseRaw(52.5, 13.4)).toEqual(NOMINATIM_REVERSE);
expect(await osm.reverseRaw(52.5, 13.4)).toEqual({});
expect(hosts).not.toContain('nominatim.openstreetmap.org');
});
it("OSM-PHOTON-017: without the TREK services the reverse lookup is Nominatim's, as it always was", async () => {
identity.bearer = null;
expect((await osm.reverse('52.5', '13.4', 'de')).name).toBe('Unter den Linden');
expect(hosts).toEqual(['nominatim.openstreetmap.org']);
});
});
it('OSM-PHOTON-009: a lookup by id stays on Nominatim', async () => {
await osm.lookupNominatim('way', '518071791', 'de');
expect(hosts).toEqual(['nominatim.openstreetmap.org']);
it('OSM-PHOTON-009: a lookup by id is Overpass for the tags and the place, Photon for the name and address', async () => {
const details = await osm.elementDetails('way:518071791', 'en');
expect(details).toMatchObject({
name: 'Brandenburg Gate',
address: 'Brandenburger Tor, Pariser Platz, Mitte, Berlin, 10117, Deutschland',
lat: 52.5163,
lng: 13.3777,
osm_id: 'way:518071791',
website: 'https://example.org',
});
expect(hosts).toEqual(['overpass-api.de', 'photon.liketrek.com']);
});
it('OSM-PHOTON-018: Nominatim names the element only when Overpass cannot place it, or without the TREK services', async () => {
overpassReply = () => new Response('busy', { status: 504 });
await osm.elementDetails('way:518071791', 'de');
expect(hosts.filter((h) => h === 'nominatim.openstreetmap.org')).toHaveLength(1);
hosts.length = 0;
identity.bearer = null;
await osm.elementDetails('way:518071791', 'de');
expect(hosts).toContain('nominatim.openstreetmap.org');
expect(hosts).not.toContain('photon.liketrek.com');
});
it('OSM-PHOTON-019: the identity of a place comes from Photon inside the box, the sight before the station of its name', async () => {
const station = gateFeature({
osm_type: 'N',
osm_id: 3862767512,
osm_key: 'railway',
osm_value: 'station',
extra: { wikidata: 'Q477185' },
});
photonReply = () => photonAnswer([station, gateFeature()]);
const identityAnswer = await osm.resolveOsmIdentity('Brandenburger Tor', 52.5163, 13.3777, { lang: 'de' });
expect(identityAnswer).toMatchObject({
osmUrl: 'https://www.openstreetmap.org/way/518071791',
tags: { wikidata: 'Q82425' },
});
expect(hosts).toEqual(['photon.liketrek.com']);
});
+1 -1
View File
@@ -84,7 +84,7 @@ describe('what Photon is asked', () => {
lang: 'pt',
lat: '35.31',
lon: '139.53',
zoom: '10',
zoom: '13',
});
});
+201 -4
View File
@@ -1,8 +1,14 @@
/**
* PHOTON-MAP-001..016: Photon's answers read into the shape Nominatim gives,
* the rows of the suggestion list, and the language Photon is asked in.
* PHOTON-MAP-001..029: Photon's answers read into the shape Nominatim gives,
* the rows of the suggestion list, the lead the list opens on, and the
* language Photon is asked in.
*/
import {
asLead,
editDistance,
nameLike,
pickLead,
rankFeatures,
asNominatimHit,
asNominatimReverse,
asSuggestion,
@@ -128,7 +134,7 @@ describe('asSuggestion', () => {
expect(asSuggestion(feature())).toEqual({
placeId: 'way:518071791',
mainText: 'Brandenburger Tor',
secondaryText: 'Pariser Platz, Mitte, Berlin, 10117, Deutschland',
secondaryText: 'Pariser Platz, Mitte, Berlin, Deutschland',
source: 'openstreetmap',
lat: 52.5163,
lng: 13.3777,
@@ -138,7 +144,7 @@ describe('asSuggestion', () => {
it('PHOTON-MAP-015: keeps a name that holds a comma whole, and titles a house without one by its number', () => {
const bar = asSuggestion(feature({ name: 'Bar, Café Einstein' }));
expect(bar?.mainText).toBe('Bar, Café Einstein');
expect(bar?.secondaryText).toBe('Pariser Platz, Mitte, Berlin, 10117, Deutschland');
expect(bar?.secondaryText).toBe('Pariser Platz, Mitte, Berlin, Deutschland');
const house = asSuggestion(
feature({ name: undefined, housenumber: '77', street: 'Unter den Linden', postcode: undefined }),
@@ -155,3 +161,194 @@ describe('asSuggestion', () => {
expect(asSuggestion(bare.features[0])).toBeNull();
});
});
describe('asSuggestion, the line below the name', () => {
it('PHOTON-MAP-017: leaves out postcode and county, and names the region only where there is no town', () => {
const village = asSuggestion(
feature({
osm_key: 'place',
osm_value: 'village',
name: 'Berlin',
street: undefined,
postcode: '457130',
district: undefined,
city: undefined,
county: 'Троицкий муниципальный округ',
state: 'Chelyabinsk Oblast',
country: 'Russia',
}),
);
expect(village?.secondaryText).toBe('Chelyabinsk Oblast, Russia');
expect(asSuggestion(feature({ county: 'Berlin', postcode: '10117' }))?.secondaryText).toBe(
'Pariser Platz, Mitte, Berlin, Deutschland',
);
});
});
describe('asLead', () => {
const city = (properties: Record<string, unknown> = {}) =>
feature({
osm_type: 'R',
osm_id: 62422,
osm_key: 'place',
osm_value: 'city',
type: 'city',
name: 'Berlin',
street: undefined,
postcode: undefined,
district: undefined,
city: undefined,
extent: [13.088345, 52.6755087, 13.7611609, 52.3382448],
...properties,
});
it('PHOTON-MAP-018: reads a town as a lead from nearby, with its extent', () => {
expect(asLead(city())).toEqual({
placeId: 'relation:62422',
name: 'Berlin',
reach: 'nearby',
kind: 'city',
lat: 52.5163,
lng: 13.3777,
extent: [13.088345, 52.6755087, 13.7611609, 52.3382448],
});
});
it('PHOTON-MAP-019: reads a country or a region as a lead from everywhere, extent or none', () => {
expect(asLead(city({ osm_value: 'country', type: 'country', name: 'Mauritania' }))?.reach).toBe('everywhere');
// Tokyo is a province Photon files on its town layer.
expect(asLead(city({ osm_value: 'province', name: 'Tokyo' }))?.reach).toBe('everywhere');
expect(asLead(city({ extent: undefined }))?.extent).toBeNull();
});
it('PHOTON-MAP-020: has no lead for a sight, a street or a quarter', () => {
expect(asLead(feature())).toBeNull();
expect(asLead(city({ osm_key: 'highway', osm_value: 'primary', type: 'street' }))).toBeNull();
expect(asLead(city({ osm_value: 'suburb', type: 'district' }))).toBeNull();
});
});
describe('rankFeatures', () => {
it('PHOTON-MAP-021: puts a place with a Wikidata link before its unlinked namesakes, Photon order otherwise', () => {
const art = feature({
osm_id: 1,
osm_key: 'tourism',
osm_value: 'artwork',
extra: undefined,
name: 'Neuschwanstein',
});
const castle = feature({ osm_id: 2, osm_key: 'tourism', osm_value: 'attraction', name: 'Schloss Neuschwanstein' });
const street = feature({
osm_id: 3,
osm_key: 'highway',
osm_value: 'residential',
extra: undefined,
name: 'Neuschwansteinstraße',
});
const shop = feature({
osm_id: 4,
osm_key: 'shop',
osm_value: 'gift',
extra: undefined,
name: 'Neuschwanstein Souvenirs',
});
expect(rankFeatures([art, street, castle, shop]).map((f) => f.properties.osm_id)).toEqual([2, 1, 3, 4]);
expect(rankFeatures([castle, art]).map((f) => f.properties.osm_id)).toEqual([2, 1]);
});
});
describe('pickLead', () => {
const town = (properties: Record<string, unknown>) =>
feature({
osm_key: 'place',
osm_value: 'city',
type: 'city',
street: undefined,
district: undefined,
city: undefined,
...properties,
});
it('PHOTON-MAP-022: a country or region first leads under any name, so an exonym keeps it', () => {
const lead = pickLead(
[town({ osm_type: 'R', osm_id: 192763, osm_value: 'country', type: 'country', name: 'Mauritania' })],
'Mauretanien',
);
expect(lead).toMatchObject({ placeId: 'relation:192763', reach: 'everywhere' });
});
it('PHOTON-MAP-023: anything else leads only when called what was typed, or a typo of it', () => {
const centrum = town({
osm_type: 'R',
osm_id: 1,
osm_key: 'boundary',
osm_value: 'administrative',
type: 'county',
name: 'Centrum',
});
const paramaribo = town({ osm_type: 'N', osm_id: 2, name: 'Paramaribo' });
expect(pickLead([centrum, paramaribo], 'Paramraibo')).toMatchObject({ placeId: 'node:2' });
expect(pickLead([centrum], 'Paramraibo')).toBeNull();
});
it('PHOTON-MAP-024: no lead for an airport code, nor for a municipality with nothing to say where the traveller is', () => {
expect(pickLead([town({ name: 'FRA' })], 'FRA')).toBeNull();
const municipality = town({ osm_value: 'municipality', name: 'Sagrada Família' });
expect(pickLead([municipality], 'Sagrada Familia')).toBeNull();
expect(pickLead([municipality], 'Sagrada Familia', { lat: 52.5, lng: 13.4 })).not.toBeNull();
});
});
describe('pickLead, namesakes', () => {
const place = (properties: Record<string, unknown>) =>
feature({ osm_key: 'place', street: undefined, district: undefined, city: undefined, ...properties });
it('PHOTON-MAP-027: a namesake ranked higher that may not lead leaves the list without a lead', () => {
const london = place({ osm_type: 'R', osm_id: 1, osm_value: 'town', type: 'district', name: 'Greenwich' });
const connecticut = place({ osm_type: 'R', osm_id: 2, osm_value: 'town', type: 'city', name: 'Greenwich' });
expect(pickLead([london, connecticut], 'Greenwich')).toBeNull();
expect(pickLead([connecticut, london], 'Greenwich')).toMatchObject({ placeId: 'relation:2' });
});
it('PHOTON-MAP-029: a street of the name ranked higher does not block, and an island leads', () => {
const street = place({
osm_type: 'W',
osm_id: 4,
osm_key: 'highway',
osm_value: 'primary',
type: 'street',
name: 'Spitzbergen',
});
const island = place({ osm_type: 'R', osm_id: 5, osm_value: 'island', type: 'other', name: 'Spitzbergen' });
expect(pickLead([street, island], 'Spitzbergen')).toMatchObject({ placeId: 'relation:5', kind: 'island' });
});
it('PHOTON-MAP-028: a town filed as an administrative boundary leads as a town', () => {
const merida = place({
osm_type: 'R',
osm_id: 3,
osm_key: 'boundary',
osm_value: 'administrative',
type: 'city',
name: 'Mérida',
});
expect(pickLead([merida], 'Mérida')).toMatchObject({ placeId: 'relation:3', kind: 'city' });
});
});
describe('nameLike and editDistance', () => {
it('PHOTON-MAP-025: counts a swap of neighbours as one edit', () => {
expect(editDistance('paramraibo', 'paramaribo')).toBe(1);
expect(editDistance('tokio', 'tokyo')).toBe(1);
expect(editDistance('ab', 'ba')).toBe(1);
expect(editDistance('berlin', 'berlin')).toBe(0);
});
it('PHOTON-MAP-026: takes the same name, its start, a word of it or a near typo, and nothing shorter than three letters', () => {
expect(nameLike('Berl', 'Berlin')).toBe(true);
expect(nameLike('Tor', 'Brandenburger Tor')).toBe(true);
expect(nameLike('Tokio', 'Tokyo')).toBe(true);
expect(nameLike('Mailand', 'Milan')).toBe(false);
expect(nameLike('Be', 'Berlin')).toBe(false);
});
});
+64 -2
View File
@@ -747,6 +747,23 @@ describe('server Roadtrip router through the TREK services (routing relay)', ()
});
});
it('ROUTER-RELAY-007: a question the relay ran out of time on is not handed to FOSSGIS, and OSRM routes the day', async () => {
const slow = [
() => Promise.reject(new DomainError(504, 'The TREK routing service took too long for this route')),
() => Promise.resolve(answer(504, { error: 'gateway timeout' })),
];
await oneByOne([...slow.entries()], async ([userId, timedOut]) => {
vi.mocked(safeFetchAdminConfigured).mockClear();
relay.ask.mockReset().mockImplementationOnce(timedOut);
// All three points: the stubbed OSRM day has a leg for each pair of them.
const route = await settled(setup().router.route(userId, 1, 1, points, 'driving', ['toll']));
expect(route.avoidMissed).toEqual(['toll']);
const asked = vi.mocked(safeFetchAdminConfigured).mock.calls.map(([url]) => String(url));
expect(asked).toHaveLength(1);
expect(asked[0]).toContain('routing.openstreetmap.de/routed-car');
});
});
it('ROUTER-RELAY-003: reads the TREK Valhalla saying no way joins the points as a refusal, and fails on any other error', async () => {
const costingOptions = { max_hiking_difficulty: 3 };
relay.ask.mockResolvedValueOnce(answer(400, { error_code: 442, error: 'No path could be found for input' }));
@@ -908,13 +925,22 @@ describe('server Roadtrip router: every day through Valhalla where it routes the
);
});
it('ROUTER-DAY-005: a day that avoids something keeps FOSSGIS behind the relay and its ten locations', async () => {
it('ROUTER-DAY-011: a day that avoids something asks the relay twenty locations at a time, with only the maneuvers', async () => {
const route = await settled(setup().router.route(4, 1, 1, day(12), 'driving', ['toll']));
expect(relayBodies().map((body) => body.locations.length)).toEqual([12]);
expect(relay.ask.mock.calls[0][2]).toMatchObject({ directions_type: 'maneuvers' });
expect(route.parts).toHaveLength(11);
expect(safeFetchAdminConfigured).not.toHaveBeenCalled();
});
it('ROUTER-DAY-005: a day that avoids something keeps FOSSGIS behind the relay, cut again in its ten locations', async () => {
relay.ask.mockReset().mockResolvedValue(answer(503, { error: 'The TREK routing service is resting' }));
vi.mocked(safeFetchAdminConfigured).mockImplementation(
async (_url, init) => new Response(JSON.stringify(legsFor(bodyOf(init)))),
);
const route = await settled(setup().router.route(4, 1, 1, day(12), 'driving', ['toll']));
expect(relayBodies().map((body) => body.locations.length)).toEqual([10, 3]);
// Twelve for the relay, then the day cut again: FOSSGIS refuses an eleventh location.
expect(relayBodies().map((body) => body.locations.length)).toEqual([12, 10, 3]);
expect(vi.mocked(safeFetchAdminConfigured).mock.calls.map(([url]) => url)).toEqual(
Array(2).fill('https://valhalla1.openstreetmap.de/route'),
);
@@ -986,6 +1012,42 @@ describe('server Roadtrip router: every day through Valhalla where it routes the
expect(relay.ask).not.toHaveBeenCalled();
});
it('ROUTER-DAY-012: paces the public hosts a request every 1.1 s, an own Valhalla and an own OSRM not at all', async () => {
const own = (url: unknown) => String(url).startsWith('https://valhalla.internal');
vi.mocked(safeFetchAdminConfigured).mockImplementation(async (url, init) =>
own(url) ? new Response(JSON.stringify(legsFor(bodyOf(init)))) : osrm(),
);
const ownValhalla = setup({ valhalla_base_url: 'https://valhalla.internal' }).router.route(
1,
1,
1,
day(21),
'cycling',
[],
);
const ownOsrm = setup({ routing_base_url: 'http://router.internal' }).router;
const twoDays = Promise.all([
ownOsrm.route(2, 1, 1, points, 'driving', []),
ownOsrm.route(2, 1, 2, points, 'driving', []),
]);
await vi.advanceTimersByTimeAsync(1000);
expect(await ownValhalla).toMatchObject({ parts: expect.any(Array) });
expect(await twoDays).toHaveLength(2);
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(4);
vi.mocked(safeFetchAdminConfigured).mockClear();
const { router } = setup();
const publicDays = Promise.all([
router.route(3, 1, 1, points, 'driving', []),
router.route(3, 1, 2, points, 'driving', []),
]);
await vi.advanceTimersByTimeAsync(1000);
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(200);
await publicDays;
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(2);
});
it('ROUTER-DAY-007: with FOSSGIS chosen, a day that avoids nothing stays with OSRM as before', async () => {
relay.trekValhalla.mockReturnValue(TREK_VALHALLA);
const { router } = setup({ routing_valhalla_provider: 'fossgis' });
@@ -81,9 +81,10 @@ describe('RoutingRelayService.ask', () => {
contentType: 'application/json',
body: Buffer.from('{"trip":{"legs":[]}}'),
});
const [url, init, redirects] = vi.mocked(safeFetchAdminConfigured).mock.calls[0];
const [url, init, redirects, , , reuseConnections] = vi.mocked(safeFetchAdminConfigured).mock.calls[0];
expect(url).toBe('https://valhalla.liketrek.com/route');
expect(redirects).toBe(0);
expect(reuseConnections).toBe(true);
expect(init?.signal).toBeInstanceOf(AbortSignal);
expect((init?.headers as Record<string, string>).Authorization).toBe('Bearer tk1.k.p.s');
expect(JSON.parse(String(init?.body))).toEqual(ROUTE);
@@ -158,12 +159,10 @@ describe('RoutingRelayService.ask', () => {
await expect(relay.ask(1, 'route', ROUTE)).rejects.toMatchObject({ status: 502 });
});
it('RELAY-009: after a host that hung, the next question is answered 503 at once, and asked again after the rest', async () => {
it('RELAY-009: after a host that could not be reached, the next question is answered 503 at once, and asked again after the rest', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-10-29T10:00:00Z'));
vi.mocked(safeFetchAdminConfigured).mockRejectedValueOnce(
new DOMException('The operation was aborted due to timeout', 'TimeoutError'),
);
vi.mocked(safeFetchAdminConfigured).mockRejectedValueOnce(new TypeError('fetch failed'));
await expect(relay.ask(1, 'height', { shape: [{ lat: 47, lon: 11 }] })).rejects.toMatchObject({ status: 502 });
const resting = await relay.ask(1, 'height', { shape: [{ lat: 47.1, lon: 11 }] });
@@ -177,12 +176,27 @@ describe('RoutingRelayService.ask', () => {
});
it('RELAY-010: passes an error of the host on and rests after it', async () => {
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(upstream(504, '{"error":"gateway"}'));
expect((await relay.ask(1, 'route', ROUTE)).status).toBe(504);
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(upstream(502, '{"error":"gateway"}'));
expect((await relay.ask(1, 'route', ROUTE)).status).toBe(502);
expect((await relay.ask(1, 'route', ROUTE)).status).toBe(503);
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(1);
});
it('RELAY-016: answers 504 for a question that ran out its deadline, and asks the host the next one', async () => {
vi.mocked(safeFetchAdminConfigured).mockRejectedValueOnce(
new DOMException('The operation was aborted due to timeout', 'TimeoutError'),
);
await expect(relay.ask(1, 'route', ROUTE)).rejects.toMatchObject({
status: 504,
message: 'The TREK routing service took too long for this route',
});
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(upstream(504, '{"error":"gateway timeout"}'));
expect((await relay.ask(1, 'route', ROUTE)).status).toBe(504);
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(upstream(200, '{}'));
expect((await relay.ask(1, 'route', ROUTE)).status).toBe(200);
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(3);
});
it("RELAY-011: follows a spent allowance's Retry-After, at most an hour of it, and passes it on", async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-10-29T10:00:00Z'));
+89 -2
View File
@@ -5,13 +5,21 @@
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
const built = vi.hoisted(() => ({ agents: [] as unknown[], proxies: [] as Array<Record<string, unknown>> }));
const built = vi.hoisted(() => ({
agents: [] as unknown[],
proxies: [] as Array<Record<string, unknown>>,
closed: [] as unknown[],
}));
vi.mock('undici', () => ({
Agent: class {
constructor(opts: unknown) {
constructor(readonly opts: unknown) {
built.agents.push(opts);
}
close() {
built.closed.push(this.opts);
return Promise.resolve();
}
},
ProxyAgent: class {
constructor(opts: Record<string, unknown>) {
@@ -33,6 +41,7 @@ afterEach(() => {
for (const key of PROXY_KEYS) delete process.env[key];
built.agents.length = 0;
built.proxies.length = 0;
built.closed.length = 0;
});
describe('proxyFor', () => {
@@ -87,3 +96,81 @@ describe('createOutboundDispatcher', () => {
});
});
});
describe('createOutboundDispatcher, connections kept (the routing relay)', () => {
it('SSRF-KEEP-001: hands the same dispatcher back for the same vetted addresses, held open ten seconds', async () => {
const { createOutboundDispatcher } = await loadGuard({});
const first = createOutboundDispatcher(
'https://valhalla.liketrek.com/route',
['203.0.113.7'],
true,
undefined,
undefined,
true,
);
const again = createOutboundDispatcher(
'https://valhalla.liketrek.com/height',
['203.0.113.7'],
true,
undefined,
undefined,
true,
);
expect(again).toBe(first);
expect(built.agents).toHaveLength(1);
expect(built.agents[0]).toMatchObject({ keepAliveTimeout: 10_000 });
});
it('SSRF-KEEP-002: a name that resolves elsewhere, or other timeouts, get a dispatcher of their own', async () => {
const { createOutboundDispatcher } = await loadGuard({});
const first = createOutboundDispatcher(
'https://valhalla.liketrek.com/',
['203.0.113.7'],
true,
undefined,
undefined,
true,
);
const moved = createOutboundDispatcher(
'https://valhalla.liketrek.com/',
['203.0.113.8'],
true,
undefined,
undefined,
true,
);
const slower = createOutboundDispatcher(
'https://valhalla.liketrek.com/',
['203.0.113.7'],
true,
60_000,
undefined,
true,
);
expect(new Set([first, moved, slower]).size).toBe(3);
// The new address is the one the socket gets, never the old one.
const lookup = (
built.agents[1] as { connect: { lookup: (h: string, o: object, cb: (...a: unknown[]) => void) => void } }
).connect.lookup;
const seen: unknown[] = [];
lookup('valhalla.liketrek.com', {}, (...args: unknown[]) => seen.push(...args));
expect(seen).toContain('203.0.113.8');
expect(seen).not.toContain('203.0.113.7');
});
it('SSRF-KEEP-003: keeps at most 32 and closes the oldest; a caller that does not ask keeps none', async () => {
const { createOutboundDispatcher } = await loadGuard({});
const at = (n: number) =>
createOutboundDispatcher('https://h.example/', [`203.0.113.${n}`], true, undefined, undefined, true);
const oldest = at(0);
for (let n = 1; n <= 32; n++) at(n);
expect(built.closed).toEqual([built.agents[0]]);
expect(at(0)).not.toBe(oldest);
built.agents.length = 0;
createOutboundDispatcher('https://h.example/', ['203.0.113.1']);
createOutboundDispatcher('https://h.example/', ['203.0.113.1']);
expect(built.agents).toHaveLength(2);
expect(built.agents[0]).not.toHaveProperty('keepAliveTimeout');
});
});
+17 -1
View File
@@ -142,11 +142,25 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'TREK مفتوح المصدر ولسنا محايدين هنا. بهذا الحجم لا توجد التقييمات وصور المحال العادية إلا لدى Google، وهذا هو الاحتكار. الحقل موجود لانعدام البديل، لا لأننا نوصي به. عندئذ يذهب كل استعلام إلى Google.',
'admin.trekApi.tagline':
'فهرس الأماكن الخاص بـ TREK. بحث بلا مفتاح من Google ودون إدخال أي مفتاح: يحصل كل تثبيت على حصة يومية عادلة، ولا يُحسب إلا عدد الطلبات.',
'خدمات TREK الخاصة: البحث عن الأماكن والعناوين وتحديد المسارات، بلا مفتاح من Google ودون إدخال أي مفتاح. يحصل كل تثبيت على حصة يومية عادلة، ولا يُحتسب سوى عدد الطلبات.',
'admin.trekApi.factPlaces': '73.6 مليون مكان حول العالم',
'admin.trekApi.factNoKey': 'لا مفتاح لإدخاله، حصة يومية عادلة',
'admin.trekApi.factOffline': 'حِزَم الدول تعمل دون اتصال',
'admin.trekApi.factPrivacy': 'لا تُسجَّل عمليات البحث أبدًا',
'admin.trekApi.factAddresses': 'عناوين ومدن حول العالم بـ 26 لغة',
'admin.trekApi.factRouting': 'مسارات بالسيارة والدراجة وسيرًا على الأقدام مع الارتفاع',
'admin.trekApi.addrStreets': 'الشوارع وأرقام المنازل',
'admin.trekApi.addrTowns': 'المدن والمناطق',
'admin.trekApi.addrReverse': 'عنوان نقطة على الخريطة',
'admin.trekApi.addrLanguages': '26 لغة',
'admin.trekApi.addressNote': 'كل عنوان في OpenStreetMap، أثناء الكتابة ومن نقطة على الخريطة.',
'admin.trekApi.routeCar': 'بالسيارة',
'admin.trekApi.routeBike': 'بالدراجة',
'admin.trekApi.routeFoot': 'سيرًا على الأقدام',
'admin.trekApi.routeAvoid': 'تجنّب الرسوم والطرق السريعة والعبّارات',
'admin.trekApi.routeElevation': 'مخطط الارتفاع',
'admin.trekApi.routingNote':
'مسارات عبر الكوكب بأكمله، يُعاد بناؤها كل شهر من OpenStreetMap، مع الارتفاعات من بلاطات التضاريس.',
'admin.trekApi.more': 'ماذا يحتوي',
'admin.trekApi.fieldPhone': 'هاتف',
'admin.trekApi.fieldStableId': 'معرّف ثابت',
@@ -163,6 +177,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'المستخدَم',
'admin.trekApi.quota.remaining': 'المتبقي',
'admin.trekApi.quota.resets': 'إعادة الضبط عند',
'admin.trekApi.quota.limit': 'الحد',
'admin.trekApi.quota.key': 'المفتاح',
'admin.trekApi.quota.state.connected': 'متصل',
'admin.trekApi.quota.state.registering': 'جارٍ الإعداد',
'admin.trekApi.quota.state.legacy': 'الحصة المشتركة',
+17 -1
View File
@@ -155,11 +155,25 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'TREK açıq mənbəlidir və biz burada bitərəf deyilik. Adi müəssisələrin reytinqləri və fotoları bu miqyasda yalnız Google-da mövcuddur və inhisar məhz budur. Bu sahə onu tövsiyə etdiyimiz üçün deyil, alternativ olmadığı üçün buradadır. Bundan sonra hər sorğu Google-a göndərilir.',
'admin.trekApi.tagline':
'TREK-in öz məkan indeksi. Google açarı olmadan və heç bir açar daxil etmədən axtarın: hər quraşdırma ədalətli gündəlik limit alır və yalnız sorğuların sayı hesablanır.',
'TREK-in öz xidmətləri: məkan axtarışı, ünvan axtarışı və marşrut, Google açarı olmadan və heç bir açar daxil etmədən. Hər quraşdırma ədalətli gündəlik limit alır və yalnız sorğuların sayı hesablanır.',
'admin.trekApi.factPlaces': 'Dünya üzrə 73,6 milyon məkan',
'admin.trekApi.factNoKey': 'Açar daxil etmək lazım deyil, ədalətli gündəlik limit',
'admin.trekApi.factOffline': 'Ölkə paketləri oflayn işləyir',
'admin.trekApi.factPrivacy': 'Axtarışlar heç vaxt qeydə alınmır',
'admin.trekApi.factAddresses': 'Dünya üzrə ünvanlar və şəhərlər, 26 dildə',
'admin.trekApi.factRouting': 'Avtomobil, velosiped və piyada marşrut, yüksəklik ilə',
'admin.trekApi.addrStreets': 'Küçələr və ev nömrələri',
'admin.trekApi.addrTowns': 'Şəhərlər və regionlar',
'admin.trekApi.addrReverse': 'Xəritədəki nöqtənin ünvanı',
'admin.trekApi.addrLanguages': '26 dil',
'admin.trekApi.addressNote': 'OpenStreetMap-dəki hər ünvan, yazdıqca və xəritədəki nöqtədən.',
'admin.trekApi.routeCar': 'Avtomobillə',
'admin.trekApi.routeBike': 'Velosipedlə',
'admin.trekApi.routeFoot': 'Piyada',
'admin.trekApi.routeAvoid': 'Ödənişli yollardan, magistrallardan və bərələrdən çəkinin',
'admin.trekApi.routeElevation': 'Yüksəklik profili',
'admin.trekApi.routingNote':
'Bütün planet üzrə marşrutlar, hər ay OpenStreetMap-dən yenidən qurulur, hündürlüklər relyef plitələrindən götürülür.',
'admin.trekApi.more': 'Nələr daxildir',
'admin.trekApi.fieldPhone': 'Telefon',
'admin.trekApi.fieldStableId': 'Sabit ID',
@@ -177,6 +191,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'İstifadə edilib',
'admin.trekApi.quota.remaining': 'Qalıb',
'admin.trekApi.quota.resets': 'Sıfırlanma vaxtı',
'admin.trekApi.quota.limit': 'Gündəlik limit',
'admin.trekApi.quota.key': 'Açar',
'admin.trekApi.quota.state.connected': 'Qoşulub',
'admin.trekApi.quota.state.registering': 'Qurulur',
'admin.trekApi.quota.state.legacy': 'Ümumi hovuz',
+18 -1
View File
@@ -98,11 +98,26 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'O TREK é código aberto e aqui não somos neutros. Nesta escala, avaliações e fotos de estabelecimentos comuns só existem no Google, e isso é um monopólio. O campo está aqui por falta de alternativa, não porque o recomendamos. Aí cada consulta vai para o Google.',
'admin.trekApi.tagline':
'O índice de locais do próprio TREK. Pesquise sem chave do Google e sem digitar chave nenhuma: cada instalação recebe uma cota diária justa, e só o número de solicitações é contado.',
'Os serviços próprios do TREK: busca de locais, busca de endereços e rotas, sem chave do Google e sem digitar chave nenhuma. Cada instalação recebe uma cota diária justa, e só o número de solicitações é contado.',
'admin.trekApi.factPlaces': '73,6 milhões de locais no mundo',
'admin.trekApi.factNoKey': 'Nenhuma chave para digitar, cota diária justa',
'admin.trekApi.factOffline': 'Os pacotes por país funcionam offline',
'admin.trekApi.factPrivacy': 'As buscas nunca são registadas',
'admin.trekApi.factAddresses': 'Endereços e cidades no mundo todo, em 26 idiomas',
'admin.trekApi.factRouting': 'Rotas de carro, bicicleta e a pé, com altitude',
'admin.trekApi.addrStreets': 'Ruas e números',
'admin.trekApi.addrTowns': 'Cidades e regiões',
'admin.trekApi.addrReverse': 'Endereço de um ponto no mapa',
'admin.trekApi.addrLanguages': '26 idiomas',
'admin.trekApi.addressNote':
'Todos os endereços do OpenStreetMap, enquanto você digita e a partir de um ponto no mapa.',
'admin.trekApi.routeCar': 'De carro',
'admin.trekApi.routeBike': 'De bicicleta',
'admin.trekApi.routeFoot': 'A pé',
'admin.trekApi.routeAvoid': 'Evitar pedágios, rodovias e balsas',
'admin.trekApi.routeElevation': 'Perfil de elevação',
'admin.trekApi.routingNote':
'Rotas por todo o planeta, refeitas todo mês a partir do OpenStreetMap, com altitudes de blocos de terreno.',
'admin.trekApi.more': 'O que vem dentro',
'admin.trekApi.fieldPhone': 'Telefone',
'admin.trekApi.fieldStableId': 'Identificador estável',
@@ -120,6 +135,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Usado',
'admin.trekApi.quota.remaining': 'Restante',
'admin.trekApi.quota.resets': 'Reinicia às',
'admin.trekApi.quota.limit': 'Limite',
'admin.trekApi.quota.key': 'Chave',
'admin.trekApi.quota.state.connected': 'Conectado',
'admin.trekApi.quota.state.registering': 'Configurando',
'admin.trekApi.quota.state.legacy': 'Reserva compartilhada',
+17 -1
View File
@@ -105,11 +105,25 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
"TREK és programari lliure i aquí no som neutrals. En aquesta escala, les valoracions i les fotos de negocis corrents només existeixen a Google, i això és un monopoli. El camp hi és per manca d'alternativa, no perquè el recomanem. Llavors cada consulta va a Google.",
'admin.trekApi.tagline':
"L'índex de llocs propi de TREK. Cerca sense clau de Google i sense haver d'introduir cap clau: cada instal·lació rep una quota diària justa, i només es compta el nombre de sol·licituds.",
"Els serveis propis de TREK: cerca de llocs, cerca d'adreces i rutes, sense clau de Google i sense haver d'introduir cap clau. Cada instal·lació rep una quota diària justa, i només es compta el nombre de sol·licituds.",
'admin.trekApi.factPlaces': '73,6 milions de llocs al món',
'admin.trekApi.factNoKey': 'Cap clau per introduir, quota diària justa',
'admin.trekApi.factOffline': 'Els paquets per país funcionen sense connexió',
'admin.trekApi.factPrivacy': 'Les cerques no es registren mai',
'admin.trekApi.factAddresses': 'Adreces i poblacions de tot el món, en 26 idiomes',
'admin.trekApi.factRouting': 'Rutes en cotxe, en bici i a peu, amb altitud',
'admin.trekApi.addrStreets': 'Carrers i números',
'admin.trekApi.addrTowns': 'Poblacions i regions',
'admin.trekApi.addrReverse': "Adreça d'un punt del mapa",
'admin.trekApi.addrLanguages': '26 idiomes',
'admin.trekApi.addressNote': "Totes les adreces d'OpenStreetMap, mentre escrius i des d'un punt del mapa.",
'admin.trekApi.routeCar': 'En cotxe',
'admin.trekApi.routeBike': 'En bici',
'admin.trekApi.routeFoot': 'A peu',
'admin.trekApi.routeAvoid': 'Evita peatges, autopistes i transbordadors',
'admin.trekApi.routeElevation': "Perfil d'elevació",
'admin.trekApi.routingNote':
"Rutes arreu del planeta, reconstruïdes cada mes a partir d'OpenStreetMap, amb altituds de rajoles de terreny.",
'admin.trekApi.more': 'Què hi ha a dins',
'admin.trekApi.fieldPhone': 'Telèfon',
'admin.trekApi.fieldStableId': 'Identificador estable',
@@ -127,6 +141,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Utilitzat',
'admin.trekApi.quota.remaining': 'Restant',
'admin.trekApi.quota.resets': 'Es reinicia a les',
'admin.trekApi.quota.limit': 'Límit',
'admin.trekApi.quota.key': 'Clau',
'admin.trekApi.quota.state.connected': 'Connectat',
'admin.trekApi.quota.state.registering': "S'està configurant",
'admin.trekApi.quota.state.legacy': 'Reserva compartida',
+17 -1
View File
@@ -100,11 +100,25 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'TREK je otevřený software a nejsme v tom neutrální. V tomto měřítku existují hodnocení a fotografie běžných podniků jen u Googlu, a to je monopol. Pole tu je z nedostatku alternativy, ne proto, že bychom je doporučovali. Každý dotaz pak jde do Googlu.',
'admin.trekApi.tagline':
'Vlastní index míst TREK. Hledejte bez klíče Google a bez zadávání jakéhokoli klíče: každá instalace dostane férový denní limit a počítá se jen počet požadavků.',
'Vlastní služby TREK: hledání míst, hledání adres a trasy, bez klíče Google a bez zadávání jakéhokoli klíče. Každá instalace dostane férový denní limit a počítá se jen počet požadavků.',
'admin.trekApi.factPlaces': '73,6 milionu míst po celém světě',
'admin.trekApi.factNoKey': 'Žádný klíč k zadání, férový denní limit',
'admin.trekApi.factOffline': 'Balíčky zemí fungují offline',
'admin.trekApi.factPrivacy': 'Dotazy se nikdy nezaznamenávají',
'admin.trekApi.factAddresses': 'Adresy a města po celém světě, v 26 jazycích',
'admin.trekApi.factRouting': 'Trasy autem, na kole a pěšky, s nadmořskou výškou',
'admin.trekApi.addrStreets': 'Ulice a čísla domů',
'admin.trekApi.addrTowns': 'Města a regiony',
'admin.trekApi.addrReverse': 'Adresa bodu na mapě',
'admin.trekApi.addrLanguages': '26 jazyků',
'admin.trekApi.addressNote': 'Každá adresa z OpenStreetMap, již při psaní i z bodu na mapě.',
'admin.trekApi.routeCar': 'Autem',
'admin.trekApi.routeBike': 'Na kole',
'admin.trekApi.routeFoot': 'Pěšky',
'admin.trekApi.routeAvoid': 'Vyhnout se mýtným úsekům, dálnicím a trajektům',
'admin.trekApi.routeElevation': 'Výškový profil',
'admin.trekApi.routingNote':
'Trasy po celé planetě, každý měsíc znovu sestavené z OpenStreetMap, s výškami z terénních dlaždic.',
'admin.trekApi.more': 'Co v tom je',
'admin.trekApi.fieldPhone': 'Telefon',
'admin.trekApi.fieldStableId': 'Stálý identifikátor',
@@ -122,6 +136,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Využito',
'admin.trekApi.quota.remaining': 'Zbývá',
'admin.trekApi.quota.resets': 'Obnoví se v',
'admin.trekApi.quota.limit': 'Denní limit',
'admin.trekApi.quota.key': 'Klíč',
'admin.trekApi.quota.state.connected': 'Připojeno',
'admin.trekApi.quota.state.registering': 'Nastavuje se',
'admin.trekApi.quota.state.legacy': 'Sdílený fond',
+18 -1
View File
@@ -100,11 +100,26 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'TREK ist quelloffen und wir sind hier nicht neutral. Bewertungen und Fotos gewöhnlicher Läden gibt es in dieser Größenordnung nur bei Google, und genau das ist ein Monopol. Das Feld steht hier, weil es keine Alternative gibt, nicht weil wir es empfehlen. Jede Abfrage geht dann an Google.',
'admin.trekApi.tagline':
'TREKs eigener Ortsindex. Suchen ohne Google-Schlüssel und ohne einen Schlüssel einzutragen: Jede Installation bekommt ein faires Tageskontingent, gezählt wird nur die Anzahl der Anfragen.',
'TREKs eigene Dienste: Ortssuche, Adresssuche und Routing, ohne Google-Schlüssel und ohne einen Schlüssel einzutragen. Jede Installation bekommt ein faires Tageskontingent, gezählt wird nur die Anzahl der Anfragen.',
'admin.trekApi.factPlaces': '73,6 Millionen Orte weltweit',
'admin.trekApi.factNoKey': 'Kein Schlüssel nötig, faires Tageskontingent',
'admin.trekApi.factOffline': 'Länderpakete auch offline',
'admin.trekApi.factPrivacy': 'Suchanfragen werden nie protokolliert',
'admin.trekApi.factAddresses': 'Adressen und Orte weltweit, in 26 Sprachen',
'admin.trekApi.factRouting': 'Routing mit Auto, Fahrrad und zu Fuß, mit Höhenprofil',
'admin.trekApi.addrStreets': 'Straßen und Hausnummern',
'admin.trekApi.addrTowns': 'Orte und Regionen',
'admin.trekApi.addrReverse': 'Adresse eines Punkts auf der Karte',
'admin.trekApi.addrLanguages': '26 Sprachen',
'admin.trekApi.addressNote':
'Jede Adresse aus OpenStreetMap, schon beim Tippen und von einem Punkt auf der Karte aus.',
'admin.trekApi.routeCar': 'Mit dem Auto',
'admin.trekApi.routeBike': 'Mit dem Fahrrad',
'admin.trekApi.routeFoot': 'Zu Fuß',
'admin.trekApi.routeAvoid': 'Mautstraßen, Autobahnen und Fähren meiden',
'admin.trekApi.routeElevation': 'Höhenprofil',
'admin.trekApi.routingNote':
'Routen über den ganzen Planeten, jeden Monat neu aus OpenStreetMap berechnet, mit Höhen aus Geländekacheln.',
'admin.trekApi.more': 'Was drin ist',
'admin.trekApi.fieldPhone': 'Telefon',
'admin.trekApi.fieldStableId': 'Stabile Kennung',
@@ -122,6 +137,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Verbraucht',
'admin.trekApi.quota.remaining': 'Verbleibend',
'admin.trekApi.quota.resets': 'Zurückgesetzt um',
'admin.trekApi.quota.limit': 'Tageslimit',
'admin.trekApi.quota.key': 'Schlüssel',
'admin.trekApi.quota.state.connected': 'Verbunden',
'admin.trekApi.quota.state.registering': 'Wird eingerichtet',
'admin.trekApi.quota.state.legacy': 'Gemeinsamer Topf',
+17 -1
View File
@@ -150,11 +150,25 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'TREK is open source and we are not neutral here. Ratings and photos of ordinary businesses exist at this scale only at Google, and that is what a monopoly is. The field is here because there is no alternative, not because we recommend it. Every lookup then goes to Google.',
'admin.trekApi.tagline':
"TREK's own place index. Search without a Google key and without a key to enter: every installation gets a fair daily allowance, and only the number of requests is counted.",
"TREK's own services: place search, address search and routing, without a Google key and without a key to enter. Every installation gets a fair daily allowance, and only the number of requests is counted.",
'admin.trekApi.factPlaces': '73.6 million places worldwide',
'admin.trekApi.factNoKey': 'No key to enter, fair daily allowance',
'admin.trekApi.factOffline': 'Country packages work offline',
'admin.trekApi.factPrivacy': 'Searches are never logged',
'admin.trekApi.factAddresses': 'Addresses and towns worldwide, in 26 languages',
'admin.trekApi.factRouting': 'Routing by car, bike and on foot, with elevation',
'admin.trekApi.addrStreets': 'Streets and house numbers',
'admin.trekApi.addrTowns': 'Towns and regions',
'admin.trekApi.addrReverse': 'Address of a point on the map',
'admin.trekApi.addrLanguages': '26 languages',
'admin.trekApi.addressNote': 'Every address in OpenStreetMap, as you type and from a point on the map.',
'admin.trekApi.routeCar': 'By car',
'admin.trekApi.routeBike': 'By bike',
'admin.trekApi.routeFoot': 'On foot',
'admin.trekApi.routeAvoid': 'Avoid tolls, motorways and ferries',
'admin.trekApi.routeElevation': 'Elevation profile',
'admin.trekApi.routingNote':
'Routes across the whole planet, rebuilt every month from OpenStreetMap, with heights from terrain tiles.',
'admin.trekApi.more': 'What is in it',
'admin.trekApi.fieldPhone': 'Phone',
'admin.trekApi.fieldStableId': 'Stable id',
@@ -172,6 +186,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Used',
'admin.trekApi.quota.remaining': 'Remaining',
'admin.trekApi.quota.resets': 'Resets at',
'admin.trekApi.quota.limit': 'Daily limit',
'admin.trekApi.quota.key': 'Key',
'admin.trekApi.quota.state.connected': 'Connected',
'admin.trekApi.quota.state.registering': 'Setting up',
'admin.trekApi.quota.state.legacy': 'Shared pool',
+17 -1
View File
@@ -100,11 +100,25 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'TREK es software libre y aquí no somos neutrales. A esta escala, las valoraciones y las fotos de negocios corrientes solo existen en Google, y eso es un monopolio. El campo está aquí por falta de alternativa, no porque lo recomendemos. Cada consulta va entonces a Google.',
'admin.trekApi.tagline':
'El índice de lugares propio de TREK. Busca sin clave de Google y sin introducir ninguna clave: cada instalación recibe un cupo diario justo y solo se cuenta el número de solicitudes.',
'Los servicios propios de TREK: búsqueda de lugares, búsqueda de direcciones y rutas, sin clave de Google y sin introducir ninguna clave. Cada instalación recibe un cupo diario justo y solo se cuenta el número de solicitudes.',
'admin.trekApi.factPlaces': '73,6 millones de lugares en el mundo',
'admin.trekApi.factNoKey': 'Sin clave que introducir, cupo diario justo',
'admin.trekApi.factOffline': 'Los paquetes por país funcionan sin conexión',
'admin.trekApi.factPrivacy': 'Las búsquedas nunca se registran',
'admin.trekApi.factAddresses': 'Direcciones y localidades de todo el mundo, en 26 idiomas',
'admin.trekApi.factRouting': 'Rutas en coche, en bici y a pie, con altitud',
'admin.trekApi.addrStreets': 'Calles y números',
'admin.trekApi.addrTowns': 'Localidades y regiones',
'admin.trekApi.addrReverse': 'Dirección de un punto del mapa',
'admin.trekApi.addrLanguages': '26 idiomas',
'admin.trekApi.addressNote': 'Todas las direcciones de OpenStreetMap, mientras escribes y desde un punto del mapa.',
'admin.trekApi.routeCar': 'En coche',
'admin.trekApi.routeBike': 'En bici',
'admin.trekApi.routeFoot': 'A pie',
'admin.trekApi.routeAvoid': 'Evitar peajes, autopistas y ferris',
'admin.trekApi.routeElevation': 'Perfil de elevación',
'admin.trekApi.routingNote':
'Rutas por todo el planeta, reconstruidas cada mes a partir de OpenStreetMap, con alturas de teselas de terreno.',
'admin.trekApi.more': 'Qué contiene',
'admin.trekApi.fieldPhone': 'Teléfono',
'admin.trekApi.fieldStableId': 'Identificador estable',
@@ -122,6 +136,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Usado',
'admin.trekApi.quota.remaining': 'Restante',
'admin.trekApi.quota.resets': 'Se reinicia a las',
'admin.trekApi.quota.limit': 'Límite',
'admin.trekApi.quota.key': 'Clave',
'admin.trekApi.quota.state.connected': 'Conectado',
'admin.trekApi.quota.state.registering': 'Configurando',
'admin.trekApi.quota.state.legacy': 'Fondo compartido',
+17 -1
View File
@@ -152,11 +152,25 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
"TREK on avatud lähtekoodiga ja me ei ole selles küsimuses neutraalsed. Tavaliste ettevõtete hinnanguid ja fotosid leidub sellises mahus ainult Google'il ning see ongi monopol. See väli on siin alternatiivi puudumise tõttu, mitte meie soovitusel. Sel juhul läheb iga päring Google'ile.",
'admin.trekApi.tagline':
"TREKi oma kohtade indeks. Otsi ilma Google'i võtmeta ja ilma ühtegi võtit sisestamata: iga paigaldus saab õiglase päevakvoodi ning loetakse ainult päringute arvu.",
"TREKi enda teenused: kohtade otsing, aadresside otsing ja marsruutimine, ilma Google'i võtmeta ja ilma ühtegi võtit sisestamata. Iga paigaldus saab õiglase päevakvoodi ning loetakse ainult päringute arvu.",
'admin.trekApi.factPlaces': '73,6 miljonit kohta üle maailma',
'admin.trekApi.factNoKey': 'Võtit pole vaja sisestada, õiglane päevakvoot',
'admin.trekApi.factOffline': 'Riigipaketid töötavad võrguühenduseta',
'admin.trekApi.factPrivacy': 'Otsinguid ei logita kunagi',
'admin.trekApi.factAddresses': 'Aadressid ja asulad üle maailma, 26 keeles',
'admin.trekApi.factRouting': 'Marsruudid autoga, rattaga ja jalgsi, koos kõrgusega',
'admin.trekApi.addrStreets': 'Tänavad ja majanumbrid',
'admin.trekApi.addrTowns': 'Asulad ja piirkonnad',
'admin.trekApi.addrReverse': 'Kaardil oleva punkti aadress',
'admin.trekApi.addrLanguages': '26 keelt',
'admin.trekApi.addressNote': 'Iga OpenStreetMapi aadress, juba tippimise ajal ja kaardil oleva punkti põhjal.',
'admin.trekApi.routeCar': 'Autoga',
'admin.trekApi.routeBike': 'Rattaga',
'admin.trekApi.routeFoot': 'Jalgsi',
'admin.trekApi.routeAvoid': 'Väldi teemakse, kiirteid ja parvlaevu',
'admin.trekApi.routeElevation': 'Kõrgusprofiil',
'admin.trekApi.routingNote':
'Marsruudid üle kogu planeedi, igal kuul OpenStreetMapi põhjal uuesti ehitatud, kõrgused maastikuplaatidest.',
'admin.trekApi.more': 'Mida see sisaldab',
'admin.trekApi.fieldPhone': 'Telefon',
'admin.trekApi.fieldStableId': 'Püsiv ID',
@@ -174,6 +188,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Kasutatud',
'admin.trekApi.quota.remaining': 'Järel',
'admin.trekApi.quota.resets': 'Lähtestub kell',
'admin.trekApi.quota.limit': 'Limiit',
'admin.trekApi.quota.key': 'Võti',
'admin.trekApi.quota.state.connected': 'Ühendatud',
'admin.trekApi.quota.state.registering': 'Seadistamisel',
'admin.trekApi.quota.state.legacy': 'Ühine kogum',
+18 -1
View File
@@ -100,11 +100,26 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
"TREK est un logiciel libre et nous ne sommes pas neutres ici. À cette échelle, les avis et les photos de commerces ordinaires n'existent que chez Google, et c'est cela, un monopole. Le champ est là faute d'alternative, pas parce que nous le recommandons. Chaque requête part alors chez Google.",
'admin.trekApi.tagline':
"L'index de lieux propre à TREK. Chercher sans clé Google et sans clé à saisir : chaque installation dispose d'un quota journalier équitable, et seul le nombre de requêtes est compté.",
"Les services propres à TREK : recherche de lieux, recherche d'adresses et calcul d'itinéraires, sans clé Google et sans clé à saisir. Chaque installation dispose d'un quota journalier équitable, et seul le nombre de requêtes est compté.",
'admin.trekApi.factPlaces': '73,6 millions de lieux dans le monde',
'admin.trekApi.factNoKey': 'Aucune clé à saisir, quota journalier équitable',
'admin.trekApi.factOffline': 'Paquets par pays utilisables hors ligne',
'admin.trekApi.factPrivacy': 'Les recherches ne sont jamais journalisées',
'admin.trekApi.factAddresses': 'Adresses et villes dans le monde entier, en 26 langues',
'admin.trekApi.factRouting': 'Itinéraires en voiture, à vélo et à pied, avec dénivelé',
'admin.trekApi.addrStreets': 'Rues et numéros',
'admin.trekApi.addrTowns': 'Villes et régions',
'admin.trekApi.addrReverse': "Adresse d'un point sur la carte",
'admin.trekApi.addrLanguages': '26 langues',
'admin.trekApi.addressNote':
"Toutes les adresses d'OpenStreetMap, pendant la saisie et depuis un point sur la carte.",
'admin.trekApi.routeCar': 'En voiture',
'admin.trekApi.routeBike': 'À vélo',
'admin.trekApi.routeFoot': 'À pied',
'admin.trekApi.routeAvoid': 'Éviter les péages, les autoroutes et les ferries',
'admin.trekApi.routeElevation': "Profil d'élévation",
'admin.trekApi.routingNote':
"Des itinéraires sur toute la planète, reconstruits chaque mois à partir d'OpenStreetMap, avec les altitudes issues de tuiles de relief.",
'admin.trekApi.more': "Ce qu'il contient",
'admin.trekApi.fieldPhone': 'Téléphone',
'admin.trekApi.fieldStableId': 'Identifiant stable',
@@ -122,6 +137,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Utilisé',
'admin.trekApi.quota.remaining': 'Restant',
'admin.trekApi.quota.resets': 'Remise à zéro à',
'admin.trekApi.quota.limit': 'Limite',
'admin.trekApi.quota.key': 'Clé',
'admin.trekApi.quota.state.connected': 'Connecté',
'admin.trekApi.quota.state.registering': 'Configuration en cours',
'admin.trekApi.quota.state.legacy': 'Réserve partagée',
+17 -1
View File
@@ -155,11 +155,25 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'Το TREK είναι ανοιχτού κώδικα και εδώ δεν είμαστε ουδέτεροι. Σε αυτή την κλίμακα, βαθμολογίες και φωτογραφίες συνηθισμένων καταστημάτων υπάρχουν μόνο στη Google, και αυτό είναι μονοπώλιο. Το πεδίο υπάρχει ελλείψει εναλλακτικής, όχι επειδή το συνιστούμε. Τότε κάθε αναζήτηση πηγαίνει στη Google.',
'admin.trekApi.tagline':
'Ο δικός κατάλογος τοποθεσιών του TREK. Αναζήτηση χωρίς κλειδί Google και χωρίς να εισαγάγετε κανένα κλειδί: κάθε εγκατάσταση παίρνει ένα δίκαιο ημερήσιο όριο, και μετριέται μόνο ο αριθμός των αιτημάτων.',
'Οι δικές υπηρεσίες του TREK: αναζήτηση τοποθεσιών, αναζήτηση διευθύνσεων και διαδρομές, χωρίς κλειδί Google και χωρίς να εισαγάγετε κανένα κλειδί. Κάθε εγκατάσταση παίρνει δίκαιο ημερήσιο όριο και μετράται μόνο ο αριθμός των αιτημάτων.',
'admin.trekApi.factPlaces': '73,6 εκατομμύρια τόποι παγκοσμίως',
'admin.trekApi.factNoKey': 'Κανένα κλειδί προς εισαγωγή, δίκαιο ημερήσιο όριο',
'admin.trekApi.factOffline': 'Τα πακέτα χωρών δουλεύουν και εκτός σύνδεσης',
'admin.trekApi.factPrivacy': 'Οι αναζητήσεις δεν καταγράφονται ποτέ',
'admin.trekApi.factAddresses': 'Διευθύνσεις και πόλεις παγκοσμίως, σε 26 γλώσσες',
'admin.trekApi.factRouting': 'Διαδρομές με αυτοκίνητο, ποδήλατο και πεζή, με υψόμετρο',
'admin.trekApi.addrStreets': 'Οδοί και αριθμοί',
'admin.trekApi.addrTowns': 'Πόλεις και περιοχές',
'admin.trekApi.addrReverse': 'Διεύθυνση ενός σημείου στον χάρτη',
'admin.trekApi.addrLanguages': '26 γλώσσες',
'admin.trekApi.addressNote': 'Κάθε διεύθυνση του OpenStreetMap, καθώς πληκτρολογείτε και από ένα σημείο στον χάρτη.',
'admin.trekApi.routeCar': 'Με αυτοκίνητο',
'admin.trekApi.routeBike': 'Με ποδήλατο',
'admin.trekApi.routeFoot': 'Πεζή',
'admin.trekApi.routeAvoid': 'Αποφυγή διοδίων, αυτοκινητοδρόμων και πορθμείων',
'admin.trekApi.routeElevation': 'Προφίλ υψομέτρου',
'admin.trekApi.routingNote':
'Διαδρομές σε ολόκληρο τον πλανήτη, που ξαναχτίζονται κάθε μήνα από το OpenStreetMap, με υψόμετρα από πλακίδια εδάφους.',
'admin.trekApi.more': 'Τι περιλαμβάνει',
'admin.trekApi.fieldPhone': 'Τηλέφωνο',
'admin.trekApi.fieldStableId': 'Σταθερό αναγνωριστικό',
@@ -177,6 +191,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Χρησιμοποιήθηκαν',
'admin.trekApi.quota.remaining': 'Απομένουν',
'admin.trekApi.quota.resets': 'Μηδενίζεται στις',
'admin.trekApi.quota.limit': 'Όριο',
'admin.trekApi.quota.key': 'Κλειδί',
'admin.trekApi.quota.state.connected': 'Συνδεδεμένο',
'admin.trekApi.quota.state.registering': 'Ρύθμιση σε εξέλιξη',
'admin.trekApi.quota.state.legacy': 'Κοινή δεξαμενή',
+17 -1
View File
@@ -99,11 +99,25 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'A TREK nyílt forráskódú, és ebben nem vagyunk semlegesek. Ebben a méretben az értékelések és a hétköznapi üzletek fotói csak a Google-nél léteznek, és ez a monopólium. A mező alternatíva híján van itt, nem mert ajánljuk. Minden lekérdezés ilyenkor a Google-höz megy.',
'admin.trekApi.tagline':
'A TREK saját helyindexe. Keress Google-kulcs nélkül és kulcs megadása nélkül: minden telepítés méltányos napi keretet kap, és csak a kérések száma számít.',
'A TREK saját szolgáltatásai: helykeresés, címkeresés és útvonaltervezés, Google-kulcs nélkül és kulcs megadása nélkül. Minden telepítés méltányos napi keretet kap, és csak a kérések száma számít.',
'admin.trekApi.factPlaces': '73,6 millió hely világszerte',
'admin.trekApi.factNoKey': 'Nem kell kulcsot megadni, méltányos napi keret',
'admin.trekApi.factOffline': 'Az országcsomagok offline is működnek',
'admin.trekApi.factPrivacy': 'A kereséseket soha nem naplózzuk',
'admin.trekApi.factAddresses': 'Címek és települések világszerte, 26 nyelven',
'admin.trekApi.factRouting': 'Útvonaltervezés autóval, kerékpárral és gyalog, szintadatokkal',
'admin.trekApi.addrStreets': 'Utcák és házszámok',
'admin.trekApi.addrTowns': 'Települések és régiók',
'admin.trekApi.addrReverse': 'A térkép egy pontjának címe',
'admin.trekApi.addrLanguages': '26 nyelv',
'admin.trekApi.addressNote': 'Minden OpenStreetMap-cím, gépelés közben és a térkép egy pontjáról.',
'admin.trekApi.routeCar': 'Autóval',
'admin.trekApi.routeBike': 'Kerékpárral',
'admin.trekApi.routeFoot': 'Gyalog',
'admin.trekApi.routeAvoid': 'Kerüld a fizetős utakat, autópályákat és kompokat',
'admin.trekApi.routeElevation': 'Magassági profil',
'admin.trekApi.routingNote':
'Útvonalak az egész bolygón, havonta újraépítve az OpenStreetMapből, a magasságok domborzati csempékből származnak.',
'admin.trekApi.more': 'Mi van benne',
'admin.trekApi.fieldPhone': 'Telefon',
'admin.trekApi.fieldStableId': 'Állandó azonosító',
@@ -121,6 +135,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Felhasznált',
'admin.trekApi.quota.remaining': 'Hátralévő',
'admin.trekApi.quota.resets': 'Visszaáll ekkor',
'admin.trekApi.quota.limit': 'Korlát',
'admin.trekApi.quota.key': 'Kulcs',
'admin.trekApi.quota.state.connected': 'Csatlakoztatva',
'admin.trekApi.quota.state.registering': 'Beállítás folyamatban',
'admin.trekApi.quota.state.legacy': 'Közös keret',
+17 -1
View File
@@ -147,11 +147,25 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'TREK adalah perangkat lunak sumber terbuka dan di sini kami tidak netral. Pada skala ini ulasan dan foto usaha biasa hanya ada di Google, dan itulah monopoli. Kolom ini ada karena tidak ada penggantinya, bukan karena kami menyarankan. Lalu setiap pencarian pergi ke Google.',
'admin.trekApi.tagline':
'Indeks tempat milik TREK sendiri. Cari tanpa kunci Google dan tanpa perlu memasukkan kunci apa pun: setiap instalasi mendapat kuota harian yang adil, dan hanya jumlah permintaan yang dihitung.',
'Layanan milik TREK sendiri: pencarian tempat, pencarian alamat, dan rute, tanpa kunci Google dan tanpa perlu memasukkan kunci apa pun. Setiap instalasi mendapat kuota harian yang adil, dan hanya jumlah permintaan yang dihitung.',
'admin.trekApi.factPlaces': '73,6 juta tempat di seluruh dunia',
'admin.trekApi.factNoKey': 'Tanpa kunci yang perlu dimasukkan, kuota harian yang adil',
'admin.trekApi.factOffline': 'Paket per negara jalan offline',
'admin.trekApi.factPrivacy': 'Pencarian tidak pernah dicatat',
'admin.trekApi.factAddresses': 'Alamat dan kota di seluruh dunia, dalam 26 bahasa',
'admin.trekApi.factRouting': 'Rute dengan mobil, sepeda, dan jalan kaki, lengkap dengan elevasi',
'admin.trekApi.addrStreets': 'Jalan dan nomor rumah',
'admin.trekApi.addrTowns': 'Kota dan wilayah',
'admin.trekApi.addrReverse': 'Alamat dari titik di peta',
'admin.trekApi.addrLanguages': '26 bahasa',
'admin.trekApi.addressNote': 'Setiap alamat di OpenStreetMap, saat Anda mengetik dan dari titik di peta.',
'admin.trekApi.routeCar': 'Dengan mobil',
'admin.trekApi.routeBike': 'Dengan sepeda',
'admin.trekApi.routeFoot': 'Jalan kaki',
'admin.trekApi.routeAvoid': 'Hindari jalan tol, jalan raya, dan feri',
'admin.trekApi.routeElevation': 'Profil elevasi',
'admin.trekApi.routingNote':
'Rute di seluruh planet, dibangun ulang setiap bulan dari OpenStreetMap, dengan ketinggian dari ubin medan.',
'admin.trekApi.more': 'Apa isinya',
'admin.trekApi.fieldPhone': 'Telepon',
'admin.trekApi.fieldStableId': 'Id tetap',
@@ -169,6 +183,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Terpakai',
'admin.trekApi.quota.remaining': 'Tersisa',
'admin.trekApi.quota.resets': 'Direset pukul',
'admin.trekApi.quota.limit': 'Batas',
'admin.trekApi.quota.key': 'Kunci',
'admin.trekApi.quota.state.connected': 'Terhubung',
'admin.trekApi.quota.state.registering': 'Sedang disiapkan',
'admin.trekApi.quota.state.legacy': 'Kumpulan bersama',
+17 -1
View File
@@ -99,11 +99,25 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
"TREK è software libero e su questo non siamo neutrali. A questa scala recensioni e foto degli esercizi comuni esistono solo da Google, ed è questo un monopolio. Il campo c'è per mancanza di alternative, non perché lo consigliamo. Ogni interrogazione va allora a Google.",
'admin.trekApi.tagline':
"L'indice dei luoghi di TREK. Cerca senza chiave Google e senza chiavi da inserire: ogni installazione riceve una quota giornaliera equa e viene contato solo il numero di richieste.",
'I servizi propri di TREK: ricerca di luoghi, ricerca di indirizzi e calcolo dei percorsi, senza chiave Google e senza chiavi da inserire. Ogni installazione riceve una quota giornaliera equa e viene contato solo il numero di richieste.',
'admin.trekApi.factPlaces': '73,6 milioni di luoghi nel mondo',
'admin.trekApi.factNoKey': 'Nessuna chiave da inserire, quota giornaliera equa',
'admin.trekApi.factOffline': 'I pacchetti per Paese funzionano offline',
'admin.trekApi.factPrivacy': 'Le ricerche non vengono mai registrate',
'admin.trekApi.factAddresses': 'Indirizzi e località in tutto il mondo, in 26 lingue',
'admin.trekApi.factRouting': 'Percorsi in auto, in bici e a piedi, con altitudine',
'admin.trekApi.addrStreets': 'Strade e numeri civici',
'admin.trekApi.addrTowns': 'Località e regioni',
'admin.trekApi.addrReverse': 'Indirizzo di un punto sulla mappa',
'admin.trekApi.addrLanguages': '26 lingue',
'admin.trekApi.addressNote': 'Ogni indirizzo di OpenStreetMap, mentre scrivi e da un punto sulla mappa.',
'admin.trekApi.routeCar': 'In auto',
'admin.trekApi.routeBike': 'In bici',
'admin.trekApi.routeFoot': 'A piedi',
'admin.trekApi.routeAvoid': 'Evita pedaggi, autostrade e traghetti',
'admin.trekApi.routeElevation': 'Profilo altimetrico',
'admin.trekApi.routingNote':
'Percorsi su tutto il pianeta, ricostruiti ogni mese da OpenStreetMap, con le altitudini da tessere del terreno.',
'admin.trekApi.more': 'Che cosa contiene',
'admin.trekApi.fieldPhone': 'Telefono',
'admin.trekApi.fieldStableId': 'Identificativo stabile',
@@ -121,6 +135,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Usata',
'admin.trekApi.quota.remaining': 'Rimanente',
'admin.trekApi.quota.resets': 'Si azzera alle',
'admin.trekApi.quota.limit': 'Limite',
'admin.trekApi.quota.key': 'Chiave',
'admin.trekApi.quota.state.connected': 'Connesso',
'admin.trekApi.quota.state.registering': 'Configurazione in corso',
'admin.trekApi.quota.state.legacy': 'Riserva condivisa',
+16 -1
View File
@@ -138,11 +138,24 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'TREK はオープンソースで、この点で中立ではありません。この規模でレビューとふつうの店舗の写真があるのは Google だけで、それが独占です。この欄は代わりがないから置いてあるのであって、勧めているからではありません。使えば問い合わせはすべて Google に届きます。',
'admin.trekApi.tagline':
'TREK 自前の場所インデックス。Google のキーも、入力するキーも不要です。各インストールに公平な1日の上限があり、数えるのはリクエストの件数だけです。',
'TREK 独自のサービス: 場所の検索、住所の検索、ルート案内を、Google キーなし、入力するキーなしで利用できます。設置ごとに公平な1日の上限があり、数えるのはリクエスト数だけです。',
'admin.trekApi.factPlaces': '世界で 7,360 万件の場所',
'admin.trekApi.factNoKey': '入力するキーは不要、公平な1日の上限',
'admin.trekApi.factOffline': '国別パッケージはオフラインでも使えます',
'admin.trekApi.factPrivacy': '検索内容は一切記録しません',
'admin.trekApi.factAddresses': '世界中の住所と町を26言語で',
'admin.trekApi.factRouting': '車、自転車、徒歩のルート案内(標高付き)',
'admin.trekApi.addrStreets': '番地までの通り',
'admin.trekApi.addrTowns': '町と地域',
'admin.trekApi.addrReverse': '地図上の地点の住所',
'admin.trekApi.addrLanguages': '26言語',
'admin.trekApi.addressNote': 'OpenStreetMap のすべての住所を、入力中でも地図上の地点からでも検索できます。',
'admin.trekApi.routeCar': '車',
'admin.trekApi.routeBike': '自転車',
'admin.trekApi.routeFoot': '徒歩',
'admin.trekApi.routeAvoid': '有料道路、高速道路、フェリーを避ける',
'admin.trekApi.routeElevation': '標高プロファイル',
'admin.trekApi.routingNote': '地球全体のルートを、OpenStreetMap から毎月作り直し、高さは地形タイルから取得します。',
'admin.trekApi.more': '何が入っているか',
'admin.trekApi.fieldPhone': '電話',
'admin.trekApi.fieldStableId': '不変の識別子',
@@ -160,6 +173,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': '使用済み',
'admin.trekApi.quota.remaining': '残り',
'admin.trekApi.quota.resets': 'リセット時刻',
'admin.trekApi.quota.limit': '上限',
'admin.trekApi.quota.key': 'キー',
'admin.trekApi.quota.state.connected': '接続済み',
'admin.trekApi.quota.state.registering': '設定中',
'admin.trekApi.quota.state.legacy': '共有枠',
+16 -1
View File
@@ -143,11 +143,24 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'TREK은 오픈 소스이며 이 점에서 중립이 아닙니다. 이 규모에서 평점과 일반 상점 사진은 Google에만 있고, 그것이 독점입니다. 이 입력란은 대안이 없어서 있는 것이지 권해서가 아닙니다. 쓰면 모든 조회가 Google로 갑니다.',
'admin.trekApi.tagline':
'TREK의 자체 장소 색인입니다. Google 키도, 입력할 키도 필요 없습니다. 설치마다 공정한 일일 할당량이 주어지며, 요청 수만 집계합니다.',
'TREK의 자체 서비스입니다. 장소 검색, 주소 검색, 경로 안내를 Google 키도, 입력할 키도 없이 쓸 수 있습니다. 설치마다 공정한 일일 할당량이 주어지며, 요청 수만 집계합니다.',
'admin.trekApi.factPlaces': '전 세계 7,363만 개 장소',
'admin.trekApi.factNoKey': '입력할 키 없음, 공정한 일일 할당량',
'admin.trekApi.factOffline': '국가별 패키지는 오프라인에서도 동작',
'admin.trekApi.factPrivacy': '검색어는 결코 기록하지 않음',
'admin.trekApi.factAddresses': '전 세계 주소와 도시, 26개 언어',
'admin.trekApi.factRouting': '자동차, 자전거, 도보 경로와 고도',
'admin.trekApi.addrStreets': '도로명과 번지',
'admin.trekApi.addrTowns': '도시와 지역',
'admin.trekApi.addrReverse': '지도 위 지점의 주소',
'admin.trekApi.addrLanguages': '26개 언어',
'admin.trekApi.addressNote': 'OpenStreetMap의 모든 주소를 입력하는 동안, 그리고 지도 위 지점에서 찾을 수 있습니다.',
'admin.trekApi.routeCar': '자동차',
'admin.trekApi.routeBike': '자전거',
'admin.trekApi.routeFoot': '도보',
'admin.trekApi.routeAvoid': '유료 도로, 고속도로, 페리 피하기',
'admin.trekApi.routeElevation': '고도 프로필',
'admin.trekApi.routingNote': '전 지구의 경로를 OpenStreetMap에서 매달 새로 만들고, 고도는 지형 타일에서 가져옵니다.',
'admin.trekApi.more': '무엇이 들어 있나',
'admin.trekApi.fieldPhone': '전화',
'admin.trekApi.fieldStableId': '고정 식별자',
@@ -165,6 +178,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': '사용함',
'admin.trekApi.quota.remaining': '남음',
'admin.trekApi.quota.resets': '초기화 시각',
'admin.trekApi.quota.limit': '한도',
'admin.trekApi.quota.key': '키',
'admin.trekApi.quota.state.connected': '연결됨',
'admin.trekApi.quota.state.registering': '설정 중',
'admin.trekApi.quota.state.legacy': '공용 풀',
+17 -1
View File
@@ -100,11 +100,25 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
"TREK is open source en wij zijn hier niet neutraal. Op deze schaal bestaan beoordelingen en foto's van gewone zaken alleen bij Google, en dat is een monopolie. Het veld staat er bij gebrek aan alternatief, niet omdat wij het aanraden. Elke opvraging gaat dan naar Google.",
'admin.trekApi.tagline':
'De eigen plaatsindex van TREK. Zoeken zonder Google-sleutel en zonder een sleutel in te voeren: elke installatie krijgt een eerlijk dagtegoed, en alleen het aantal verzoeken wordt geteld.',
'De eigen diensten van TREK: plaatsen zoeken, adressen zoeken en routeplanning, zonder Google-sleutel en zonder een sleutel in te voeren. Elke installatie krijgt een eerlijk dagtegoed, en alleen het aantal verzoeken wordt geteld.',
'admin.trekApi.factPlaces': '73,6 miljoen plaatsen wereldwijd',
'admin.trekApi.factNoKey': 'Geen sleutel nodig, eerlijk dagtegoed',
'admin.trekApi.factOffline': 'Landpakketten werken offline',
'admin.trekApi.factPrivacy': 'Zoekopdrachten worden nooit vastgelegd',
'admin.trekApi.factAddresses': 'Adressen en plaatsen wereldwijd, in 26 talen',
'admin.trekApi.factRouting': 'Routes met de auto, de fiets en te voet, met hoogte',
'admin.trekApi.addrStreets': 'Straten en huisnummers',
'admin.trekApi.addrTowns': "Plaatsen en regio's",
'admin.trekApi.addrReverse': 'Adres van een punt op de kaart',
'admin.trekApi.addrLanguages': '26 talen',
'admin.trekApi.addressNote': 'Elk adres uit OpenStreetMap, tijdens het typen en vanaf een punt op de kaart.',
'admin.trekApi.routeCar': 'Met de auto',
'admin.trekApi.routeBike': 'Met de fiets',
'admin.trekApi.routeFoot': 'Te voet',
'admin.trekApi.routeAvoid': 'Tolwegen, snelwegen en veerboten vermijden',
'admin.trekApi.routeElevation': 'Hoogteprofiel',
'admin.trekApi.routingNote':
'Routes over de hele planeet, elke maand opnieuw opgebouwd uit OpenStreetMap, met hoogtes uit terreintegels.',
'admin.trekApi.more': 'Wat erin zit',
'admin.trekApi.fieldPhone': 'Telefoon',
'admin.trekApi.fieldStableId': 'Stabiele id',
@@ -122,6 +136,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Gebruikt',
'admin.trekApi.quota.remaining': 'Resterend',
'admin.trekApi.quota.resets': 'Wordt gereset om',
'admin.trekApi.quota.limit': 'Limiet',
'admin.trekApi.quota.key': 'Sleutel',
'admin.trekApi.quota.state.connected': 'Verbonden',
'admin.trekApi.quota.state.registering': 'Wordt ingericht',
'admin.trekApi.quota.state.legacy': 'Gedeelde pot',
+17 -1
View File
@@ -98,11 +98,25 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'TREK jest otwartym oprogramowaniem i nie jesteśmy tu neutralni. W tej skali oceny i zdjęcia zwykłych lokali istnieją tylko u Google, i to jest właśnie monopol. Pole jest tu z braku alternatywy, a nie dlatego, że je zalecamy. Każde zapytanie idzie wtedy do Google.',
'admin.trekApi.tagline':
'Własny indeks miejsc TREK. Szukaj bez klucza Google i bez wpisywania żadnego klucza: każda instalacja dostaje uczciwy dzienny limit, a liczona jest tylko liczba zapytań.',
'Własne usługi TREK: wyszukiwanie miejsc, wyszukiwanie adresów i wyznaczanie tras, bez klucza Google i bez wpisywania żadnego klucza. Każda instalacja dostaje uczciwy dzienny limit, a liczona jest tylko liczba zapytań.',
'admin.trekApi.factPlaces': '73,6 miliona miejsc na świecie',
'admin.trekApi.factNoKey': 'Bez klucza do wpisania, uczciwy dzienny limit',
'admin.trekApi.factOffline': 'Pakiety krajowe działają offline',
'admin.trekApi.factPrivacy': 'Zapytania nigdy nie są zapisywane',
'admin.trekApi.factAddresses': 'Adresy i miejscowości na całym świecie, w 26 językach',
'admin.trekApi.factRouting': 'Trasy samochodem, rowerem i pieszo, z wysokością',
'admin.trekApi.addrStreets': 'Ulice i numery domów',
'admin.trekApi.addrTowns': 'Miejscowości i regiony',
'admin.trekApi.addrReverse': 'Adres punktu na mapie',
'admin.trekApi.addrLanguages': '26 języków',
'admin.trekApi.addressNote': 'Każdy adres z OpenStreetMap, już podczas pisania i z punktu na mapie.',
'admin.trekApi.routeCar': 'Samochodem',
'admin.trekApi.routeBike': 'Rowerem',
'admin.trekApi.routeFoot': 'Pieszo',
'admin.trekApi.routeAvoid': 'Unikaj płatnych dróg, autostrad i promów',
'admin.trekApi.routeElevation': 'Profil wysokościowy',
'admin.trekApi.routingNote':
'Trasy na całej planecie, co miesiąc budowane od nowa z OpenStreetMap, z wysokościami z kafli terenu.',
'admin.trekApi.more': 'Co w tym jest',
'admin.trekApi.fieldPhone': 'Telefon',
'admin.trekApi.fieldStableId': 'Stały identyfikator',
@@ -120,6 +134,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Wykorzystano',
'admin.trekApi.quota.remaining': 'Pozostało',
'admin.trekApi.quota.resets': 'Reset o',
'admin.trekApi.quota.limit': 'Limit dzienny',
'admin.trekApi.quota.key': 'Klucz',
'admin.trekApi.quota.state.connected': 'Połączono',
'admin.trekApi.quota.state.registering': 'Konfigurowanie',
'admin.trekApi.quota.state.legacy': 'Wspólna pula',
+17 -1
View File
@@ -108,11 +108,25 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'TREK с открытым исходным кодом, и мы здесь не нейтральны. В таком масштабе отзывы и фотографии обычных заведений есть только у Google, и это и есть монополия. Поле здесь из-за отсутствия альтернативы, а не потому, что мы его советуем. Тогда каждый запрос уходит в Google.',
'admin.trekApi.tagline':
'Собственный индекс мест TREK. Поиск без ключа Google и без ввода какого-либо ключа: каждая установка получает справедливый дневной лимит, и считается только число запросов.',
'Собственные сервисы TREK: поиск мест, поиск адресов и построение маршрутов, без ключа Google и без ввода какого-либо ключа. Каждая установка получает справедливый дневной лимит, считается только количество запросов.',
'admin.trekApi.factPlaces': '73,6 миллиона мест по всему миру',
'admin.trekApi.factNoKey': 'Ключ вводить не нужно, справедливый дневной лимит',
'admin.trekApi.factOffline': 'Страновые пакеты работают офлайн',
'admin.trekApi.factPrivacy': 'Запросы никогда не записываются',
'admin.trekApi.factAddresses': 'Адреса и города по всему миру, на 26 языках',
'admin.trekApi.factRouting': 'Маршруты на машине, велосипеде и пешком, с высотой',
'admin.trekApi.addrStreets': 'Улицы и номера домов',
'admin.trekApi.addrTowns': 'Города и регионы',
'admin.trekApi.addrReverse': 'Адрес точки на карте',
'admin.trekApi.addrLanguages': '26 языков',
'admin.trekApi.addressNote': 'Любой адрес из OpenStreetMap, прямо при вводе и по точке на карте.',
'admin.trekApi.routeCar': 'На машине',
'admin.trekApi.routeBike': 'На велосипеде',
'admin.trekApi.routeFoot': 'Пешком',
'admin.trekApi.routeAvoid': 'Избегать платных дорог, автомагистралей и паромов',
'admin.trekApi.routeElevation': 'Профиль высот',
'admin.trekApi.routingNote':
'Маршруты по всей планете, каждый месяц заново собираются из OpenStreetMap, высоты берутся из тайлов рельефа.',
'admin.trekApi.more': 'Что внутри',
'admin.trekApi.fieldPhone': 'Телефон',
'admin.trekApi.fieldStableId': 'Постоянный идентификатор',
@@ -130,6 +144,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Использовано',
'admin.trekApi.quota.remaining': 'Осталось',
'admin.trekApi.quota.resets': 'Сброс в',
'admin.trekApi.quota.limit': 'Лимит',
'admin.trekApi.quota.key': 'Ключ',
'admin.trekApi.quota.state.connected': 'Подключено',
'admin.trekApi.quota.state.registering': 'Настройка',
'admin.trekApi.quota.state.legacy': 'Общий пул',
+17 -1
View File
@@ -153,11 +153,25 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'TREK je otvorený softvér a nie sme v tomto neutrálni. V takomto rozsahu existujú hodnotenia a fotografie bežných podnikov len u Googlu, a to je monopol. Toto pole tu je preto, že nie je alternatíva, nie preto, že by sme ho odporúčali. Každý dotaz potom ide do Googlu.',
'admin.trekApi.tagline':
'Vlastný index miest TREK. Hľadajte bez kľúča Google a bez zadávania akéhokoľvek kľúča: každá inštalácia dostane férový denný limit a počíta sa len počet požiadaviek.',
'Vlastné služby TREK: hľadanie miest, hľadanie adries a trasy, bez kľúča Google a bez zadávania akéhokoľvek kľúča. Každá inštalácia dostane férový denný limit a počíta sa len počet požiadaviek.',
'admin.trekApi.factPlaces': '73,6 milióna miest na celom svete',
'admin.trekApi.factNoKey': 'Žiadny kľúč na zadanie, férový denný limit',
'admin.trekApi.factOffline': 'Balíčky krajín fungujú offline',
'admin.trekApi.factPrivacy': 'Vyhľadávania sa nikdy nezaznamenávajú',
'admin.trekApi.factAddresses': 'Adresy a mestá po celom svete, v 26 jazykoch',
'admin.trekApi.factRouting': 'Trasy autom, na bicykli a pešo, s nadmorskou výškou',
'admin.trekApi.addrStreets': 'Ulice a čísla domov',
'admin.trekApi.addrTowns': 'Mestá a regióny',
'admin.trekApi.addrReverse': 'Adresa bodu na mape',
'admin.trekApi.addrLanguages': '26 jazykov',
'admin.trekApi.addressNote': 'Každá adresa z OpenStreetMap, už pri písaní aj z bodu na mape.',
'admin.trekApi.routeCar': 'Autom',
'admin.trekApi.routeBike': 'Na bicykli',
'admin.trekApi.routeFoot': 'Pešo',
'admin.trekApi.routeAvoid': 'Vyhnúť sa spoplatneným cestám, diaľniciam a trajektom',
'admin.trekApi.routeElevation': 'Výškový profil',
'admin.trekApi.routingNote':
'Trasy po celej planéte, každý mesiac znova zostavené z OpenStreetMap, s výškami z terénnych dlaždíc.',
'admin.trekApi.more': 'Čo v tom je',
'admin.trekApi.fieldPhone': 'Telefón',
'admin.trekApi.fieldStableId': 'Stály identifikátor',
@@ -175,6 +189,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Využité',
'admin.trekApi.quota.remaining': 'Zostáva',
'admin.trekApi.quota.resets': 'Obnoví sa o',
'admin.trekApi.quota.limit': 'Denný limit',
'admin.trekApi.quota.key': 'Kľúč',
'admin.trekApi.quota.state.connected': 'Pripojené',
'admin.trekApi.quota.state.registering': 'Nastavuje sa',
'admin.trekApi.quota.state.legacy': 'Spoločný fond',
+17 -1
View File
@@ -152,11 +152,25 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'TREK är öppen källkod och vi är inte neutrala här. I den här skalan finns omdömen och foton på vanliga verksamheter bara hos Google, och det är ett monopol. Fältet finns i brist på alternativ, inte för att vi rekommenderar det. Varje uppslagning går då till Google.',
'admin.trekApi.tagline':
'TREK:s eget platsindex. Sök utan Google-nyckel och utan att ange någon nyckel: varje installation får en rimlig dagskvot, och bara antalet förfrågningar räknas.',
'TREK:s egna tjänster: platssökning, adresssökning och ruttplanering, utan Google-nyckel och utan att ange någon nyckel. Varje installation får en rimlig dagskvot, och bara antalet förfrågningar räknas.',
'admin.trekApi.factPlaces': '73,6 miljoner platser i världen',
'admin.trekApi.factNoKey': 'Ingen nyckel att ange, rimlig dagskvot',
'admin.trekApi.factOffline': 'Landpaket fungerar offline',
'admin.trekApi.factPrivacy': 'Sökningar loggas aldrig',
'admin.trekApi.factAddresses': 'Adresser och orter över hela världen, på 26 språk',
'admin.trekApi.factRouting': 'Rutter med bil, cykel och till fots, med höjd',
'admin.trekApi.addrStreets': 'Gator och husnummer',
'admin.trekApi.addrTowns': 'Orter och regioner',
'admin.trekApi.addrReverse': 'Adressen till en punkt på kartan',
'admin.trekApi.addrLanguages': '26 språk',
'admin.trekApi.addressNote': 'Varje adress i OpenStreetMap, medan du skriver och från en punkt på kartan.',
'admin.trekApi.routeCar': 'Med bil',
'admin.trekApi.routeBike': 'Med cykel',
'admin.trekApi.routeFoot': 'Till fots',
'admin.trekApi.routeAvoid': 'Undvik vägtullar, motorvägar och färjor',
'admin.trekApi.routeElevation': 'Höjdprofil',
'admin.trekApi.routingNote':
'Rutter över hela planeten, byggs om varje månad från OpenStreetMap, med höjder från terrängrutor.',
'admin.trekApi.more': 'Vad som ingår',
'admin.trekApi.fieldPhone': 'Telefon',
'admin.trekApi.fieldStableId': 'Stabilt id',
@@ -174,6 +188,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Använt',
'admin.trekApi.quota.remaining': 'Kvar',
'admin.trekApi.quota.resets': 'Nollställs kl.',
'admin.trekApi.quota.limit': 'Gräns',
'admin.trekApi.quota.key': 'Nyckel',
'admin.trekApi.quota.state.connected': 'Ansluten',
'admin.trekApi.quota.state.registering': 'Konfigureras',
'admin.trekApi.quota.state.legacy': 'Gemensam pool',
+16 -1
View File
@@ -782,11 +782,24 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'TREK เป็นโอเพนซอร์ส และเราไม่ได้วางตัวเป็นกลางในเรื่องนี้ คะแนนและรูปภาพของธุรกิจทั่วไปในระดับนี้มีอยู่ที่ Google เท่านั้น ซึ่งก็คือการผูกขาด ช่องนี้มีไว้เพราะไม่มีทางเลือก ไม่ใช่เพราะเราแนะนำ ทุกการค้นหาจะส่งไปยัง Google',
'admin.trekApi.tagline':
'ดัชนีสถานที่ของ TREK เอง ค้นหาได้โดยไม่ต้องใช้คีย์ Google และไม่ต้องกรอกคีย์ใด ๆ ทุกการติดตั้งได้รับโควตารายวันอย่างเป็นธรรม และนับเฉพาะจำนวนคำขอเท่านั้น',
'บริการของ TREK เอง ทั้งค้นหาสถานที่ ค้นหาที่อยู่ และวางเส้นทาง โดยไม่ต้องใช้คีย์ Google และไม่ต้องกรอกคีย์ใด ๆ ทุกการติดตั้งจะได้โควตารายวันที่เป็นธรรม และนับเฉพาะจำนวนคำขอเท่านั้น',
'admin.trekApi.factPlaces': '73.6 ล้านสถานที่ทั่วโลก',
'admin.trekApi.factNoKey': 'ไม่ต้องกรอกคีย์ โควตารายวันที่เป็นธรรม',
'admin.trekApi.factOffline': 'แพ็กเกจประเทศใช้งานแบบออฟไลน์ได้',
'admin.trekApi.factPrivacy': 'จะไม่บันทึกการค้นหา',
'admin.trekApi.factAddresses': 'ที่อยู่และเมืองทั่วโลก ใน 26 ภาษา',
'admin.trekApi.factRouting': 'เส้นทางทั้งขับรถ ปั่นจักรยาน และเดินเท้า พร้อมความสูง',
'admin.trekApi.addrStreets': 'ถนนและเลขที่บ้าน',
'admin.trekApi.addrTowns': 'เมืองและภูมิภาค',
'admin.trekApi.addrReverse': 'ที่อยู่ของจุดบนแผนที่',
'admin.trekApi.addrLanguages': '26 ภาษา',
'admin.trekApi.addressNote': 'ทุกที่อยู่ใน OpenStreetMap ทั้งขณะพิมพ์และจากจุดบนแผนที่',
'admin.trekApi.routeCar': 'ขับรถ',
'admin.trekApi.routeBike': 'ปั่นจักรยาน',
'admin.trekApi.routeFoot': 'เดินเท้า',
'admin.trekApi.routeAvoid': 'หลีกเลี่ยงทางด่วน ทางพิเศษ และเรือเฟอร์รี',
'admin.trekApi.routeElevation': 'โปรไฟล์ความสูง',
'admin.trekApi.routingNote': 'เส้นทางทั่วทั้งโลก สร้างใหม่ทุกเดือนจาก OpenStreetMap พร้อมความสูงจากไทล์ภูมิประเทศ',
'admin.trekApi.more': 'มีอะไรบ้าง',
'admin.trekApi.fieldPhone': 'โทรศัพท์',
'admin.trekApi.fieldStableId': 'รหัสประจำที่คงที่',
@@ -804,6 +817,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'ใช้ไปแล้ว',
'admin.trekApi.quota.remaining': 'คงเหลือ',
'admin.trekApi.quota.resets': 'รีเซ็ตเวลา',
'admin.trekApi.quota.limit': 'ขีดจำกัด',
'admin.trekApi.quota.key': 'คีย์',
'admin.trekApi.quota.state.connected': 'เชื่อมต่อแล้ว',
'admin.trekApi.quota.state.registering': 'กำลังตั้งค่า',
'admin.trekApi.quota.state.legacy': 'โควตาส่วนกลาง',
+17 -1
View File
@@ -152,11 +152,25 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
"TREK açık kaynaklıdır ve burada tarafsız değiliz. Bu ölçekte değerlendirmeler ve sıradan işletme fotoğrafları yalnızca Google'da vardır; tekel budur. Alan, başka seçenek olmadığı için burada; önerdiğimiz için değil. O zaman her sorgu Google'a gider.",
'admin.trekApi.tagline':
"TREK'in kendi yer dizini. Google anahtarı olmadan ve hiçbir anahtar girmeden arayın: her kurulum adil bir günlük kota alır ve yalnızca istek sayısı sayılır.",
"TREK'in kendi hizmetleri: yer arama, adres arama ve rota oluşturma; Google anahtarı olmadan ve hiçbir anahtar girmeden. Her kurulum adil bir günlük kota alır ve yalnızca istek sayısı sayılır.",
'admin.trekApi.factPlaces': 'Dünya genelinde 73,6 milyon yer',
'admin.trekApi.factNoKey': 'Girilecek anahtar yok, adil günlük kota',
'admin.trekApi.factOffline': 'Ülke paketleri çevrimdışı da çalışır',
'admin.trekApi.factPrivacy': 'Aramalar hiçbir zaman kaydedilmez',
'admin.trekApi.factAddresses': 'Dünya genelinde adresler ve şehirler, 26 dilde',
'admin.trekApi.factRouting': 'Araba, bisiklet ve yürüyüş rotaları, yükseklik bilgisiyle',
'admin.trekApi.addrStreets': 'Sokaklar ve kapı numaraları',
'admin.trekApi.addrTowns': 'Şehirler ve bölgeler',
'admin.trekApi.addrReverse': 'Haritadaki bir noktanın adresi',
'admin.trekApi.addrLanguages': '26 dil',
'admin.trekApi.addressNote': "OpenStreetMap'teki her adres, yazarken ve haritadaki bir noktadan.",
'admin.trekApi.routeCar': 'Arabayla',
'admin.trekApi.routeBike': 'Bisikletle',
'admin.trekApi.routeFoot': 'Yürüyerek',
'admin.trekApi.routeAvoid': 'Ücretli yollardan, otoyollardan ve feribotlardan kaçın',
'admin.trekApi.routeElevation': 'Yükseklik profili',
'admin.trekApi.routingNote':
"Tüm gezegen için rotalar, her ay OpenStreetMap'ten yeniden oluşturulur, yükseklikler arazi karolarından gelir.",
'admin.trekApi.more': 'İçinde ne var',
'admin.trekApi.fieldPhone': 'Telefon',
'admin.trekApi.fieldStableId': 'Kalıcı kimlik',
@@ -174,6 +188,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Kullanılan',
'admin.trekApi.quota.remaining': 'Kalan',
'admin.trekApi.quota.resets': 'Sıfırlanma saati',
'admin.trekApi.quota.limit': 'Sınır',
'admin.trekApi.quota.key': 'Anahtar',
'admin.trekApi.quota.state.connected': 'Bağlı',
'admin.trekApi.quota.state.registering': 'Kuruluyor',
'admin.trekApi.quota.state.legacy': 'Ortak havuz',
+17 -1
View File
@@ -108,11 +108,25 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'TREK має відкритий код, і ми тут не нейтральні. У такому масштабі відгуки та світлини звичайних закладів є лише в Google, і це і є монополія. Поле тут через брак альтернативи, а не тому, що ми його радимо. Тоді кожен запит іде до Google.',
'admin.trekApi.tagline':
'Власний індекс місць TREK. Пошук без ключа Google і без введення будь-якого ключа: кожна інсталяція отримує справедливий денний ліміт, і рахується лише кількість запитів.',
'Власні сервіси TREK: пошук місць, пошук адрес і побудова маршрутів, без ключа Google і без введення будь-якого ключа. Кожна інсталяція отримує справедливий денний ліміт, рахується лише кількість запитів.',
'admin.trekApi.factPlaces': '73,6 мільйона місць у світі',
'admin.trekApi.factNoKey': 'Ключ вводити не потрібно, справедливий денний ліміт',
'admin.trekApi.factOffline': 'Пакети країн працюють офлайн',
'admin.trekApi.factPrivacy': 'Запити ніколи не записуються',
'admin.trekApi.factAddresses': 'Адреси та міста по всьому світу, 26 мовами',
'admin.trekApi.factRouting': 'Маршрути авто, велосипедом і пішки, з висотою',
'admin.trekApi.addrStreets': 'Вулиці та номери будинків',
'admin.trekApi.addrTowns': 'Міста та регіони',
'admin.trekApi.addrReverse': 'Адреса точки на карті',
'admin.trekApi.addrLanguages': '26 мов',
'admin.trekApi.addressNote': 'Будь-яка адреса з OpenStreetMap, просто під час введення і за точкою на карті.',
'admin.trekApi.routeCar': 'Авто',
'admin.trekApi.routeBike': 'Велосипедом',
'admin.trekApi.routeFoot': 'Пішки',
'admin.trekApi.routeAvoid': 'Уникати платних доріг, автомагістралей і поромів',
'admin.trekApi.routeElevation': 'Профіль висот',
'admin.trekApi.routingNote':
'Маршрути по всій планеті, щомісяця заново збираються з OpenStreetMap, висоти беруться з тайлів рельєфу.',
'admin.trekApi.more': 'Що всередині',
'admin.trekApi.fieldPhone': 'Телефон',
'admin.trekApi.fieldStableId': 'Стала позначка',
@@ -130,6 +144,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Використано',
'admin.trekApi.quota.remaining': 'Залишилось',
'admin.trekApi.quota.resets': 'Скидання о',
'admin.trekApi.quota.limit': 'Ліміт',
'admin.trekApi.quota.key': 'Ключ',
'admin.trekApi.quota.state.connected': 'Підключено',
'admin.trekApi.quota.state.registering': 'Налаштування',
'admin.trekApi.quota.state.legacy': 'Спільний пул',
+17 -1
View File
@@ -147,11 +147,25 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'TREK là phần mềm nguồn mở và ở đây chúng tôi không trung lập. Ở quy mô này, đánh giá và ảnh của các cửa hàng thông thường chỉ có ở Google, và đó là độc quyền. Ô này có mặt vì không có lựa chọn khác, không phải vì chúng tôi khuyến nghị. Khi đó mọi truy vấn đều đi tới Google.',
'admin.trekApi.tagline':
'Chỉ mục địa điểm riêng của TREK. Tìm kiếm không cần khóa Google và không phải nhập khóa nào: mỗi bản cài đặt nhận một hạn mức hằng ngày công bằng, và chỉ số lượng yêu cầu được đếm.',
'Các dịch vụ riêng của TREK: tìm địa điểm, tìm địa chỉ và chỉ đường, không cần khóa Google và không phải nhập khóa nào. Mỗi bản cài đặt nhận một hạn mức hằng ngày công bằng, và chỉ số lượng yêu cầu được tính.',
'admin.trekApi.factPlaces': '73,6 triệu địa điểm trên toàn thế giới',
'admin.trekApi.factNoKey': 'Không cần nhập khóa, hạn mức hằng ngày công bằng',
'admin.trekApi.factOffline': 'Gói theo quốc gia dùng được ngoại tuyến',
'admin.trekApi.factPrivacy': 'Không bao giờ ghi lại nội dung tìm kiếm',
'admin.trekApi.factAddresses': 'Địa chỉ và thị trấn trên toàn thế giới, bằng 26 ngôn ngữ',
'admin.trekApi.factRouting': 'Chỉ đường bằng ô tô, xe đạp và đi bộ, kèm độ cao',
'admin.trekApi.addrStreets': 'Đường phố và số nhà',
'admin.trekApi.addrTowns': 'Thị trấn và khu vực',
'admin.trekApi.addrReverse': 'Địa chỉ của một điểm trên bản đồ',
'admin.trekApi.addrLanguages': '26 ngôn ngữ',
'admin.trekApi.addressNote': 'Mọi địa chỉ trong OpenStreetMap, ngay khi bạn gõ và từ một điểm trên bản đồ.',
'admin.trekApi.routeCar': 'Bằng ô tô',
'admin.trekApi.routeBike': 'Bằng xe đạp',
'admin.trekApi.routeFoot': 'Đi bộ',
'admin.trekApi.routeAvoid': 'Tránh đường thu phí, đường cao tốc và phà',
'admin.trekApi.routeElevation': 'Biểu đồ độ cao',
'admin.trekApi.routingNote':
'Tuyến đường trên toàn hành tinh, dựng lại hằng tháng từ OpenStreetMap, với độ cao từ các ô địa hình.',
'admin.trekApi.more': 'Bên trong có gì',
'admin.trekApi.fieldPhone': 'Điện thoại',
'admin.trekApi.fieldStableId': 'Mã định danh ổn định',
@@ -169,6 +183,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': 'Đã dùng',
'admin.trekApi.quota.remaining': 'Còn lại',
'admin.trekApi.quota.resets': 'Đặt lại lúc',
'admin.trekApi.quota.limit': 'Giới hạn',
'admin.trekApi.quota.key': 'Khóa',
'admin.trekApi.quota.state.connected': 'Đã kết nối',
'admin.trekApi.quota.state.registering': 'Đang thiết lập',
'admin.trekApi.quota.state.legacy': 'Nhóm dùng chung',
+16 -1
View File
@@ -141,11 +141,24 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'TREK 是開源軟體,在這件事上我們並不中立。在這個規模上,評分和一般店家的照片只有 Google 有,這就是壟斷。這個欄位存在是因為沒有替代品,而不是因為我們推薦。填了它,每次查詢都會送往 Google。',
'admin.trekApi.tagline':
'TREK 自有的地點索引。不需要 Google 金鑰,也不需要輸入任何金鑰:每個安裝都有公平的每日額度,只計算請求次數。',
'TREK 自有的服務:地點搜尋、地址搜尋和路線規劃。不需要 Google 金鑰,也不需要輸入任何金鑰:每個安裝都有公平的每日額度,只計算請求次數。',
'admin.trekApi.factPlaces': '全球 7363 萬個地點',
'admin.trekApi.factNoKey': '不需輸入金鑰,公平的每日額度',
'admin.trekApi.factOffline': '國家資料包可離線使用',
'admin.trekApi.factPrivacy': '搜尋內容從不記錄',
'admin.trekApi.factAddresses': '全球地址和城鎮,支援 26 種語言',
'admin.trekApi.factRouting': '開車、騎車和步行路線,附帶海拔',
'admin.trekApi.addrStreets': '街道和門牌號碼',
'admin.trekApi.addrTowns': '城鎮和地區',
'admin.trekApi.addrReverse': '地圖上某個點的地址',
'admin.trekApi.addrLanguages': '26 種語言',
'admin.trekApi.addressNote': 'OpenStreetMap 中的每個地址,輸入時即可搜尋,也可從地圖上的點取得。',
'admin.trekApi.routeCar': '開車',
'admin.trekApi.routeBike': '騎車',
'admin.trekApi.routeFoot': '步行',
'admin.trekApi.routeAvoid': '避開收費路段、高速公路和渡輪',
'admin.trekApi.routeElevation': '海拔剖面',
'admin.trekApi.routingNote': '涵蓋全球的路線,每月依據 OpenStreetMap 重新建構,海拔來自地形圖磚。',
'admin.trekApi.more': '裡面有什麼',
'admin.trekApi.fieldPhone': '電話',
'admin.trekApi.fieldStableId': '穩定識別碼',
@@ -161,6 +174,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': '已用',
'admin.trekApi.quota.remaining': '剩餘',
'admin.trekApi.quota.resets': '重設時間',
'admin.trekApi.quota.limit': '上限',
'admin.trekApi.quota.key': '金鑰',
'admin.trekApi.quota.state.connected': '已連線',
'admin.trekApi.quota.state.registering': '設定中',
'admin.trekApi.quota.state.legacy': '共用池',
+16 -1
View File
@@ -93,11 +93,24 @@ const admin: TranslationStrings = {
'admin.googleCaveat.body':
'TREK 是开源软件,在这件事上我们并不中立。在这个规模上,评分和普通店铺的照片只有 Google 有,这就是垄断。这个输入框存在是因为没有替代品,而不是因为我们推荐。填了它,每次查询都会发往 Google。',
'admin.trekApi.tagline':
'TREK 自有的地点索引。无需 Google 密钥,也无需填写任何密钥:每个安装都有公平的每日额度,只统计请求次数。',
'TREK 自有的服务:地点搜索、地址搜索和路线规划。无需 Google 密钥,也无需填写任何密钥:每个安装都有公平的每日额度,只统计请求次数。',
'admin.trekApi.factPlaces': '全球 7363 万个地点',
'admin.trekApi.factNoKey': '无需填写密钥,公平的每日额度',
'admin.trekApi.factOffline': '国家数据包可离线使用',
'admin.trekApi.factPrivacy': '搜索内容从不记录',
'admin.trekApi.factAddresses': '全球地址和城镇,支持 26 种语言',
'admin.trekApi.factRouting': '驾车、骑行和步行路线,附带海拔',
'admin.trekApi.addrStreets': '街道和门牌号',
'admin.trekApi.addrTowns': '城镇和地区',
'admin.trekApi.addrReverse': '地图上某个点的地址',
'admin.trekApi.addrLanguages': '26 种语言',
'admin.trekApi.addressNote': 'OpenStreetMap 中的每个地址,输入时即可搜索,也可从地图上的点获取。',
'admin.trekApi.routeCar': '驾车',
'admin.trekApi.routeBike': '骑行',
'admin.trekApi.routeFoot': '步行',
'admin.trekApi.routeAvoid': '避开收费路段、高速公路和渡轮',
'admin.trekApi.routeElevation': '海拔剖面',
'admin.trekApi.routingNote': '覆盖全球的路线,每月基于 OpenStreetMap 重新构建,海拔来自地形瓦片。',
'admin.trekApi.more': '里面有什么',
'admin.trekApi.fieldPhone': '电话',
'admin.trekApi.fieldStableId': '稳定标识',
@@ -113,6 +126,8 @@ const admin: TranslationStrings = {
'admin.trekApi.quota.used': '已用',
'admin.trekApi.quota.remaining': '剩余',
'admin.trekApi.quota.resets': '重置时间',
'admin.trekApi.quota.limit': '上限',
'admin.trekApi.quota.key': '密钥',
'admin.trekApi.quota.state.connected': '已连接',
'admin.trekApi.quota.state.registering': '正在设置',
'admin.trekApi.quota.state.legacy': '共享池',
@@ -30,6 +30,9 @@ describe('valhallaRouteRequestSchema', () => {
it('takes the route the planner asks for, its costing options included', () => {
expect(valhallaRouteRequestSchema.parse(route)).toEqual(route);
expect(valhallaRouteRequestSchema.parse({ ...route, alternates: 3 }).alternates).toBe(3);
expect(valhallaRouteRequestSchema.parse({ ...route, directions_type: 'maneuvers' }).directions_type).toBe(
'maneuvers',
);
const bike = {
...route,
costing: 'bicycle',
@@ -45,6 +48,8 @@ describe('valhallaRouteRequestSchema', () => {
const many = Array.from({ length: VALHALLA_RELAY_MAX_LOCATIONS + 1 }, () => route.locations[0]);
expect(valhallaRouteRequestSchema.safeParse({ ...route, locations: many }).success).toBe(false);
expect(valhallaRouteRequestSchema.safeParse({ ...route, costing: 'truck' }).success).toBe(false);
// Only the maneuvers: `none` would drop the ferry boarding the planner reads off them.
expect(valhallaRouteRequestSchema.safeParse({ ...route, directions_type: 'none' }).success).toBe(false);
const off = [{ lat: 91, lon: 0 }, route.locations[1]];
expect(valhallaRouteRequestSchema.safeParse({ ...route, locations: off }).success).toBe(false);
});
@@ -66,6 +66,8 @@ export const valhallaRouteRequestSchema = z
costing: costingSchema,
costing_options: costingOptionsSchema.optional(),
directions_options: z.object({ units: z.enum(['kilometers', 'miles']).optional() }).optional(),
// The maneuvers without their narrative: TREK reads where a route boards a ferry, never the instructions.
directions_type: z.enum(['maneuvers']).optional(),
alternates: z.number().int().min(0).max(VALHALLA_RELAY_MAX_ALTERNATES).optional(),
})
.refine(withinRelayBytes.check, withinRelayBytes.message);
+1 -1
View File
@@ -84,7 +84,7 @@ The **AI Parsing** tab starts with **Reads bookings with**, which shows the conn
The form for an **Own connection** has these fields:
- **Provider**: **Local (Ollama)**, **OpenAI** or **Anthropic**.
- **Base URL**: empty means the provider's own address: `http://localhost:11434/v1` for a local Ollama server, `https://api.openai.com/v1` for OpenAI and `https://api.anthropic.com` for Anthropic. Any OpenAI-compatible endpoint goes here. For Anthropic it can name a gateway that speaks the Anthropic Messages API; give it without `/v1`, since TREK adds `/v1/messages`, and the gateway has to accept PDF document blocks and forced tool calls. Picking another provider empties the field, so an address meant for one never receives another provider's key.
- **Base URL**: empty means the provider's own address: `http://localhost:11434/v1` for a local Ollama server, `https://api.openai.com/v1` for OpenAI and `https://api.anthropic.com` for Anthropic. Any OpenAI-compatible endpoint goes here. For Anthropic it can name a gateway that speaks the Anthropic Messages API; give it without `/v1`, since TREK adds `/v1/messages`, and the gateway has to accept PDF document blocks and tool calls. Picking another provider empties the field, so an address meant for one never receives another provider's key.
- **API key**: optional for a local server (the field says *Often not needed*), required for the cloud providers. It is stored **encrypted** and shown masked (`••••••••`) once saved; leaving it unchanged keeps the stored key.
- **Model**: the model id, for example `qwen3.5:4b`, `gpt-4o` or `claude-opus-4-8`.
+2 -2
View File
@@ -33,7 +33,7 @@ The trip addons are: **Lists**, **Costs**, **Documents**, **Collab** and **Tours
Each sub-toggle can be disabled independently while the parent addon remains enabled.
**Road trip** plans a trip as one continuous drive: search along the route, driving limits, daily travel times, via points and avoidance of toll roads, motorways and ferries. **Disabled by default.** It needs no key: routing runs on the public OSRM and Valhalla servers until you set your own under **User Defaults** (**Own routing engine**, **Own Valhalla instance**, restart required). See [Road-Trip](Road-Trip).
**Road trip** plans a trip as one continuous drive: search along the route, driving limits, daily travel times, via points and avoidance of toll roads, motorways and ferries. **Disabled by default.** It needs no key: routing runs on the Valhalla of the TREK services, with the public OSRM servers for a day it cannot answer (the public OSRM and Valhalla servers on an installation without the TREK services), until you set your own under **User Defaults** (**Own routing engine**, **Own Valhalla instance**, restart required). See [Road-Trip](Road-Trip).
**Tours** plans hikes, bike rides, ski tours, paddle tours (kayak, canoe, SUP) and other tours as tours of their own and adds them to the days of a trip. **Enabled by default.** Underneath its tile are four switches that decide which outside services Tours may use:
@@ -120,7 +120,7 @@ Some addons require credentials or environment variables before they are functio
- **Journey**: works without any external integration. To embed photos from Immich or Synology Photos, enable the corresponding photo-provider toggle listed under Journey, then configure credentials per-user in **Settings → Integrations**. See [Photo-Providers](Photo-Providers).
- **MCP**: requires `APP_URL` to be set so OAuth redirect URIs resolve correctly.
- **AI Assistant** and **AI Parsing**: need a connection, either one shared for everyone under **Set up AI** or one per person in **Settings → Integrations**.
- **Road trip**: works out of the box on the public routing servers. To use your own OSRM or Valhalla, set **Own routing engine** or **Own Valhalla instance** under **Admin → User Defaults**, then restart the server and reload the page. See [Road-Trip](Road-Trip#routing-engines).
- **Road trip**: works out of the box on the TREK services' Valhalla and the public routing servers. To use your own OSRM or Valhalla, set **Own routing engine** or **Own Valhalla instance** under **Admin → User Defaults**, then restart the server and reload the page. See [Road-Trip](Road-Trip#routing-engines).
- **Tours**: hikes need nothing more. For the obstacles on paddle tours, add the regions you paddle in under **Paddle water data** (see above), or let TREK load them.
## On a phone
+2 -2
View File
@@ -51,11 +51,11 @@ The address of an OSRM instance of your own, for example `https://osrm.example.o
Which Valhalla TREK asks, and with it how much of the routing Valhalla does.
- **Automatic** (nothing chosen): the TREK services, or **Own Valhalla instance** when one is set. If only **Own routing engine** is set, no Valhalla is asked.
- **TREK services**: the Valhalla at `valhalla.liketrek.com` (or `TREK_VALHALLA_URL`), a planet with elevation data. It calculates every route: the days of the planner and the road trip on foot, by bike and by car, driving around toll roads, motorways and ferries, and the routes, heights and ways of tours. This server asks it with the installation key, so the browser never talks to it and no restart is needed. Where it cannot answer (no key yet, the day's allowance spent, the service down or slow to answer, a shared page without a sign-in, or no way for one of the day's legs), OSRM calculates that day as it did before, without a message; a day is always calculated by one engine, never one leg by each, because the two estimate travel times differently. What only Valhalla can answer (avoiding road classes, tours) goes to the public FOSSGIS Valhalla instead. After such an answer TREK leaves the service alone for a while: a minute after an error or a question that took longer than ten seconds, as long as the service says when the allowance is spent (an hour at most). Each user may ask 60 routing questions a minute through this server, and the whole installation 100.
- **TREK services**: the Valhalla at `valhalla.liketrek.com` (or `TREK_VALHALLA_URL`), a planet with elevation data. It calculates every route: the days of the planner and the road trip on foot, by bike and by car, driving around toll roads, motorways and ferries, and the routes, heights and ways of tours. This server asks it with the installation key, so the browser never talks to it and no restart is needed. Where it cannot answer (no key yet, the day's allowance spent, the service unreachable or failing, a question that runs out its ten-second deadline, a shared page without a sign-in, or no way for one of the day's legs), OSRM calculates that day as it did before, without a message; a day is always calculated by one engine, never one leg by each, because the two estimate travel times differently. What only Valhalla can answer (avoiding road classes, tours) goes to the public FOSSGIS Valhalla instead, except a question that only ran out its deadline, which FOSSGIS would take as long over. Such a slow question, a very long bike route for example, is answered 504 and changes nothing for anyone else: the next question goes to the TREK services again. Only a host that cannot be reached or answers with a server error is left alone, for a minute on the server and ten minutes in a browser tab, and a spent allowance for as long as the service says (an hour at most). Each user may ask 60 routing questions a minute through this server, and the whole installation 100.
- **Public Valhalla (FOSSGIS)**: `valhalla1.openstreetmap.de`, as before: OSRM calculates the routes, and Valhalla only answers what OSRM cannot (driving around toll roads, motorways and ferries, and tours). This is also what an installation without the TREK services gets.
- **Own Valhalla instance**: the address below. Like the TREK services it calculates every route, with OSRM taking over a day it cannot answer.
A day longer than one request may carry is asked in pieces that share a stop: twenty stops a piece on the TREK services and an own Valhalla, ten on FOSSGIS.
A day longer than one request may carry is asked in pieces that share a stop: twenty stops a piece on the TREK services and an own Valhalla, one after another without a pause, and ten on FOSSGIS, paced to its one request a second. A day cut for the TREK services that ends up at FOSSGIS is cut again into pieces of ten.
Routes the browser has kept on the device are kept per engine, so switching the routing service never shows a day calculated by the other engine.
+1 -1
View File
@@ -66,7 +66,7 @@ See the [[Development Environment|Development-environment]] page for the full se
| Real-time | WebSocket (ws) |
| Database | SQLite with WAL mode |
| Auth | JWT (HS256), bcrypt, TOTP MFA, passkeys (WebAuthn), OIDC |
| Maps | Leaflet + react-leaflet (default, OpenFreeMap vector basemap via maplibre-gl-leaflet), MapLibre GL 5, Mapbox GL 3, OSRM, Nominatim |
| Maps | Leaflet + react-leaflet (default, OpenFreeMap vector basemap via maplibre-gl-leaflet), MapLibre GL 5, Mapbox GL 3, Valhalla, OSRM, Photon, Nominatim |
| i18n | 27 languages, EN canonical (locale directories live in shared/src/i18n/) |
Documentation changes go to `wiki/`. The same pages ship inside the app: the in-app help reads them (served under `/api/help`) and links into them, and the MCP help tools search them, so a wiki edit reaches users with the next release as well as the GitHub wiki.
+3 -3
View File
@@ -394,7 +394,7 @@ TREK's own place index, the [TREK Places API](TREK-Places-API), answers the sugg
| `TREK_SERVICES` | `auto` lets the installation register itself with the TREK services on first use and keep its key in the database; `off` sends no key, and searches use the shared pool. See [Installation key and daily allowance](TREK-Places-API#installation-key-and-daily-allowance). | `auto` |
| `TREK_SERVICES_KEY` | A key issued to you for this installation. Wins over the stored key and turns self-registration off. A value that is not a key aborts startup. | none |
| `TREK_SERVICES_HOSTS` | Comma-separated hosts that receive the installation key over https. | `places.liketrek.com,photon.liketrek.com,valhalla.liketrek.com` |
| `TREK_PHOTON_URL` | The address search of the TREK services, a [Photon](https://github.com/komoot/photon) instance. Once the installation holds a key, the OpenStreetMap search and the reverse lookups ask it before Nominatim and fall back to Nominatim whenever it does not answer. It is never asked with `TREK_SERVICES=off`, without a key, or when `NOMINATIM_URL` is set. See [Address search](TREK-Places-API#address-search). | `https://photon.liketrek.com` |
| `TREK_PHOTON_URL` | The address search of the TREK services, a [Photon](https://github.com/komoot/photon) instance. Once the installation holds a key it is the geocoder in place of Nominatim: the OpenStreetMap half of search and suggestions, import geocoding, the right-click on the map and pasted map links, matching a Google place to its OpenStreetMap record and naming an opened OpenStreetMap place. An empty answer is the answer, and so is a miss (an error, a timeout, a refused key or a spent allowance): Nominatim is not asked in its place. It is never asked with `TREK_SERVICES=off`, without a key, or when `NOMINATIM_URL` is set. See [Address search](TREK-Places-API#address-search). | `https://photon.liketrek.com` |
| `TREK_VALHALLA_URL` | The Valhalla of the TREK services. Routes, elevation and the description of a line go there unless the admin picks another routing service under Admin, Default user settings; this server relays them with the installation key, so the browser never talks to it. A trailing slash is stripped, and a value that is not a full URL aborts startup. | `https://valhalla.liketrek.com` |
On Helm these go under `env:` in `values.yaml`, except `TREK_SERVICES_KEY`, which goes under `secretEnv:`. The chart passes `TREK_PLACES_ENABLED` through whenever it is set at all, so an unquoted `false` or `--set env.TREK_PLACES_ENABLED=false` reaches the container as well.
@@ -508,14 +508,14 @@ next key rotation or admin reset would quietly switch the file back to WAL.
| Variable | Description | Default |
|---------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------|
| `IDEMPOTENCY_TTL_SECONDS` | How long (in seconds) stored idempotency keys are kept before garbage collection. The offline client replays queued mutations with their `X-Idempotency-Key` on reconnect, so this must exceed the longest expected offline window or a replay could create a duplicate. Invalid values abort startup. | `2592000` (30 days) |
| `NOMINATIM_URL` | Custom HTTP(S) Nominatim base URL for every geocoding call the server makes: place search, reverse lookup, place details and the Atlas region fill. When set it **replaces** the public OpenStreetMap service for all of them, with no fallback, so point it at an instance whose import covers the places your users plan and that speaks the Nominatim API (`/search`, `/reverse`, `/lookup`). Photon, Geoapify and LocationIQ are not drop-in replacements. TREK keeps its client-side spacing of roughly one request a second whichever instance answers, so this changes where the requests go, not how fast they are sent. Unset or blank uses the default. With the TREK Places API on, suggestions while typing come from the TREK API's own OpenStreetMap layer and reach Nominatim only when the TREK API has nothing. Set, it also keeps search and reverse lookups off the TREK services' Photon (see `TREK_PHOTON_URL`). | `https://nominatim.openstreetmap.org` |
| `NOMINATIM_URL` | Custom HTTP(S) Nominatim base URL for every geocoding call the server makes: place search, reverse lookup, place details and the Atlas region fill. When set it **replaces** the public OpenStreetMap service for all of them, with no fallback, so point it at an instance whose import covers the places your users plan and that speaks the Nominatim API (`/search`, `/reverse`, `/lookup`). Photon, Geoapify and LocationIQ are not drop-in replacements. TREK keeps its client-side spacing of roughly one request a second whichever instance answers, so this changes where the requests go, not how fast they are sent. Unset or blank uses the default, which is then asked only by an installation without the TREK services (no key yet, or `TREK_SERVICES=off`) and for a search the TREK services' address search does not answer at all: with a key, that address search geocodes instead (see `TREK_PHOTON_URL`). Set, it keeps every geocoding call off the address search and on your Nominatim, as before. With the TREK Places API on, suggestions while typing come from the TREK API, beside the address search where the installation has a key; without a key they reach Nominatim only when the TREK API has nothing. | `https://nominatim.openstreetmap.org` |
| `OVERPASS_URL` | Custom [Overpass API](https://wiki.openstreetmap.org/wiki/Overpass_API) endpoint(s), comma-separated, for the map's category buttons, the Road trip search along the drive and the `search_pois` MCP tool whenever the TREK Places API has no answer or is switched off. When set it **replaces** the bundled public mirrors: point it at an internal or self-hosted Overpass instance when the public mirrors are unreachable from your network (e.g. firewalled/locked-down egress in a Kubernetes cluster). Entries that aren't valid `http(s)` URLs are ignored. If you don't run your own Overpass but the public mirrors throttle TREK, first make sure `APP_URL` (or `ALLOWED_ORIGINS`) is set: that alone gives outbound Overpass/Nominatim requests a unique User-Agent, which the public mirrors rate-limit far less. One request is not covered: the tag lookup behind an OpenStreetMap place's details (website, phone, Wikidata) still goes to `overpass-api.de` whatever this is set to, and where that host is unreachable those details fall back to the tags Nominatim returns. | bundled public mirrors |
| `OVERPASS_TIMEOUT_MS` | Per-endpoint timeout (in milliseconds) for Overpass POI requests. Endpoints race in parallel and one that hasn't answered within this window is abandoned so a faster mirror can win. The query itself gives Overpass 20 seconds of work, so a lower value hangs up on answers that were still coming; that is why the default rose from `12000` in 4.3.0. Drop any lower value you set by hand, and raise it only for a slow self-hosted Overpass instance. The Tours search along a tour does not read it: it gives Overpass 10 seconds of work and waits 12, because someone is waiting for the list. Invalid values abort startup. | `25000` |
| `LLM_TIMEOUT_MS` | How long (in milliseconds) one AI-parsing call may take before it is abandoned. One ceiling for every provider, applied to the abort signal and to the underlying HTTP client alike. The default is generous so heavier parsing work fits without a code change; lower it if you use a cloud provider and would rather fail fast. Invalid values abort startup. | `900000` (15 min) |
| `LLM_MAX_OUTPUT_TOKENS` | The longest answer (in tokens) one AI-parsing call may ask the model for, sent to every provider. A document with many bookings needs a long answer; when the model hits the cap the import says so and names this variable. Raise it for such documents, but keep prompt plus cap inside the context your model has, or the server refuses the request. Unset keeps each provider's own cap. A whole number from 256 to 1000000, anything else aborts startup. | `4096` (OpenAI-compatible), `8192` (Anthropic) |
| `GOOGLE_LIST_DATA_BASE` | HTTP(S) base URL of a list-data service. A pasted URL starting with this base is fetched and parsed directly; the URL's origin must also match the base so a lookalike host cannot bypass the URL guard. A failed direct import reports an error without falling back to Google. Unset or a nonmatching URL uses the original Google flow. | Unset |
> **Routing is not set here.** The routing engines behind the planner and the [Road-Trip](Road-Trip) addon (OSRM for routes, Valhalla for avoiding toll roads, motorways and ferries) have no environment variable. An admin sets them under **Admin → User Defaults** as **Own routing engine** and **Own Valhalla instance**, and they take effect after a restart. `OVERPASS_URL` and `OVERPASS_TIMEOUT_MS` above also apply to the road trip's search along the route.
> **Routing is mostly not set here.** With the TREK services, their Valhalla calculates every route TREK draws and OSRM takes over a day it cannot answer; `TREK_VALHALLA_URL` only points at another copy of it. Engines of your own are set by an admin under **Admin → User Defaults** as **Routing service**, **Own routing engine** and **Own Valhalla instance**, and they take effect after a restart. `OVERPASS_URL` and `OVERPASS_TIMEOUT_MS` above also apply to the road trip's search along the route.
---
+3 -3
View File
@@ -61,7 +61,7 @@ Markers that [plugins](Places-and-Search#categories-from-plugins) put on the map
A day's route is drawn as a solid blue line, a bright core over a darker casing in the look Apple Maps uses, through that day's stops in the order you arranged them. It is not on automatically: switch it on with **Route** in the route bar of the selected day (see [Day Plans and Notes](Day-Plans-and-Notes#the-route-bar)), or in the day sheet on a phone. The choice is remembered per trip in your browser, and the phone's map turns it on by default the first time you open a trip you have not decided on.
A straight line is drawn immediately, then upgraded to real road geometry from a public OSRM router (or from a plugin route profile), each leg routed in the travel mode that leg carries: driving, walking or cycling. If routing fails, that leg stays a straight line between the two stops.
A straight line is drawn immediately, then upgraded to real road geometry from the router, or from a plugin route profile, each leg routed in the travel mode that leg carries: driving, walking or cycling. With the TREK services the router is their [Valhalla](TREK-Places-API#routing), and OSRM draws only a day it cannot answer; without them it is a public OSRM router. See [Road-Trip](Road-Trip#routing-engines). If routing fails, that leg stays a straight line between the two stops.
A leg you walk is drawn as a dotted line in the same colour, without the casing, so the drive stands apart from the stretches on foot.
@@ -105,14 +105,14 @@ Travel times are not drawn on the map; they sit in the day plan. Switch a day's
Each leg carries its own mode, so a day routed by car can still have one leg you walk. If you may edit the day, clicking a connector (*Change travel mode*) opens the mode menu for that leg alone, with **Public transit** on a trip with dates. See [Route Optimization](Route-Optimization#route-calculation).
Car, foot and bicycle times come from a public OSRM router, so they follow real roads, footpaths and cycle routes instead of straight-line estimates. A plugin route profile is answered by the plugin's own route provider instead, which is what lets it fold in things like charging stops; those legs can add a short note next to the distance. When routing is unavailable the leg falls back to a straight line and shows no time.
Car, foot and bicycle times come from the same router as the route line (the Valhalla of the TREK services, OSRM for a day it cannot answer or without them), so they follow real roads, footpaths and cycle routes instead of straight-line estimates. A plugin route profile is answered by the plugin's own route provider instead, which is what lets it fold in things like charging stops; those legs can add a short note next to the distance. When routing is unavailable the leg falls back to a straight line and shows no time.
## Reservation and transport overlay
Flights, trains, cars, cruises and other bookings with a start and an end point can be drawn as routes between those points. Booking routes are **off by default**. Switch one on with the route button of its row in the days column (*Show booking routes*), or on its badge under the place it is pinned to, or use one of the bulk options below. **On map** in the booking's detail does the same and opens the plan on the booking's day; pressed again, it switches the route off. The selection is remembered per trip in your browser.
- **Flights, cruises and ferries:** geodesic great-circle arcs.
- **Cars, buses, taxis and bicycles:** real routed lines that follow actual roads, fetched on demand from a public OSRM router (driving for car, bus and taxi, cycling for bicycle). A straight line is shown while the route loads, and kept if routing fails or the trip is very long (about 2,000 km or more).
- **Cars, buses, taxis and bicycles:** real routed lines that follow actual roads, fetched on demand from the same router as the day's route (driving for car, bus and taxi, cycling for bicycle). A straight line is shown while the route loads, and kept if routing fails or the trip is very long (about 2,000 km or more).
- **Trains:** a straight line between the endpoints; a multi-leg train draws its whole station chain (from, stop, to).
- **Cable cars and gondolas**, and bookings of the type **Other**: a straight line between the endpoints.
- **Automated public transit:** a journey added from the transit search draws its real rail and bus alignment instead of a straight line: each ride leg in its line's own colour over a white casing, walking transfers as a dotted grey line. A journey whose provider sent no shape falls back to a straight line. Unlike every other type it has no route button of its own in the days column. It is drawn when the day's own **Route** is on and the journey runs on that day, and independently of that by the bulk button, the account-wide default, or **On map** in its detail. Because those are two separate gates, **Hide all booking routes** does not clear a transit journey while that day's **Route** is still on.
+21 -4
View File
@@ -104,15 +104,15 @@ Deleting the place deletes its linked expenses too, the same way deleting a book
## Searching for a place
Place search needs no API key. Two sources answer it together: the **TREK API**, TREK's own index of 73.6 million places, and **OpenStreetMap**. The index is strongest on businesses such as restaurants, shops and hotels; OpenStreetMap is where the temples, bridges, viewpoints and stations are. What the index is, where its data comes from and what TREK sends to it is on its own page: [TREK Places API](TREK-Places-API).
Place search needs no API key. Two sources answer it together: the **TREK API**, TREK's own index of 73.6 million places, and **OpenStreetMap**, asked through the address search of the TREK services where the installation has a key and through Nominatim where it has none (see [Address search](TREK-Places-API#address-search)). The index is strongest on businesses such as restaurants, shops and hotels; OpenStreetMap is where the temples, bridges, viewpoints and stations are. What the index is, where its data comes from and what TREK sends to it is on its own page: [TREK Places API](TREK-Places-API).
### Suggestions while you type
After two or more characters and a 300 ms pause, suggestions appear under the search field of the place form.
- Use **↑ / ↓** to move through them, **Enter** to pick one, **Esc** to close the list.
- Suggestions come from the TREK API, from its index and from an OpenStreetMap layer it keeps. A suggestion from that layer shows the name that matched what you typed, with the name used on the spot underneath when the two differ.
- Only when the TREK API has nothing does TREK ask the keyed provider (Google or Amap), or, without one, OpenStreetMap's own search service.
- Suggestions come from the TREK API, from its index and from an OpenStreetMap layer it keeps, and from the address search of the TREK services asked at the same time. A suggestion from the layer shows the name that matched what you typed, with the name used on the spot underneath when the two differ. How the rows are ordered is described in [How the list is put together](#how-the-list-is-put-together).
- Only when both have nothing does TREK ask the keyed provider (Google or Amap), or, without one, OpenStreetMap's search on its own.
- An installed [search plugin](Plugin-Cookbook#suggestions-while-the-user-types) whose index can keep up with typing adds up to three of its places under the suggestions, marked with the plugin's name. Picking one fills in what the plugin knows about the place, with no second lookup.
### Language of place names
@@ -121,12 +121,29 @@ By default, search, suggestions and addresses answer in the language TREK is set
### The full search
Press **Enter** without picking a suggestion, or click the search button beside the field, to run a full search. It asks the TREK API and OpenStreetMap at the same time and interleaves their answers, TREK first, up to ten results. A place both of them know (within 60 metres, with a matching name) is listed once.
Press **Enter** without picking a suggestion, or click the search button beside the field, to run a full search. It asks the TREK API and OpenStreetMap at the same time and merges their answers the same way as the suggestions, up to ten results; see [How the list is put together](#how-the-list-is-put-together).
A Google or Amap key does not change this. The keyed provider is asked only when the TREK API and OpenStreetMap both come back empty; see [Google and Amap](#google-and-amap).
Installed [search plugins](Plugin-Cookbook#answer-place-searches-from-your-own-index) add their results below the core list. Only the ones built for it also answer while you type; the others appear here, once the search is run.
### How the list is put together
The suggestions and the full search build their list the same way, so the two agree on what a name means.
1. **Both are asked at once:** the index with its OpenStreetMap layer, and the address search of the TREK services. Its hits are ranked with places that have a Wikidata link before their unlinked namesakes, and streets, unlinked shops and unlinked artworks step back: "Neuschwanstein" opens on the castle, not on an artwork of that name. It leans towards the area you are planning (see [The open day steers the search](#the-open-day-steers-the-search)), more narrowly than before, so a namesake 50 km away no longer passes the real place. On an installation without a key Nominatim answers in its place, and the list has no lead.
2. **The list opens on a lead** when the address search's answer has one:
- a country or a region, always;
- a town, an island or a sight with a Wikidata link, when it is called what you typed (or a typo of it: one letter wrong, two in a name of eight letters or more), lies within 1000 km of where you are looking, and no place of that name ranks above it. With no hint at all, a town, an island or a sight leads; a municipality or a county does not.
3. **The rest alternate**, index and OpenStreetMap, the index first.
4. **Doubles go:** a place both sources know (within 60 metres, with a matching name) is listed once, as the index's copy; rows of the lead's own name inside its area go, so "Berlin" no longer opens on a business filed as "Berlin" in Berlin; the same element under two ids is shown once; and rows that read the same, with the same name and the same line below it, are shown once.
A three-letter code in capitals, such as `JFK` or `LHR`, is looked up in TREK's bundled airport list and asked for as that airport at its place, which then leads. Typed in lower case it stays an ordinary search, so `ber` on the way to "Berlin" still finds Berlin.
The line below a row from the address search shows the street, the quarter, the town and the country, and the region only where there is no town. It never shows the postcode or the county.
Measured on 5000 places from Wikidata, the right place came first while typing 65 percent of the time, against 48 before; in the full search 68 percent against 53; for cities 88 against 65; for countries 99 against 44. Rows that read the same twice are gone.
### Where each result came from
Every row, in the suggestions and in the search results, on the desktop and on the phone, carries a small mark naming its source: **TREK**, **OpenStreetMap**, **Google** or **Amap** (高德地图, or 高德地圖 in traditional Chinese), and for a row from a search plugin the name the plugin was installed under. A list that mixes several sources stays readable that way. Results from the offline cache carry no mark; see [Searching offline](#searching-offline).
+5 -5
View File
@@ -233,9 +233,9 @@ Routing uses two engines, and both have public defaults, so the addon works with
| **Valhalla** | with the TREK services or an own Valhalla: every road route TREK draws (the road trip, the day plan, the routed booking lines) on foot, by bike and by car, and the other ways of a leg. With the public FOSSGIS Valhalla only driving around toll roads, motorways and ferries, and the **No motorway**, **No tolls** and **No ferry** offers | the TREK services' Valhalla through this server (**Routing service**), the FOSSGIS Valhalla at `valhalla1.openstreetmap.de` where they are off | **Own Valhalla instance** |
| **OSRM** | every day Valhalla does not calculate: all of them with the public FOSSGIS Valhalla or without a Valhalla, and a day the TREK services or an own Valhalla cannot answer | the FOSSGIS servers at `routing.openstreetmap.de` | **Own routing engine** |
A day is always calculated by one engine, never one leg by each: the two estimate travel times differently, and a day's arrival times are added up leg by leg. When Valhalla cannot answer a day (the service down or its allowance spent, or no way for one of its legs), OSRM calculates the whole day instead, without a message, and TREK asks Valhalla again for the next day it routes once the service has rested (see [Admin-User-Defaults](Admin-User-Defaults#routing-service)). A day that avoids road classes still goes to the public FOSSGIS Valhalla in that case, since OSRM cannot avoid them.
A day is always calculated by one engine, never one leg by each: the two estimate travel times differently, and a day's arrival times are added up leg by leg. When Valhalla cannot answer a day (the service down or its allowance spent, a question that ran out its deadline, or no way for one of its legs), OSRM calculates the whole day instead, without a message. A question that only took too long, a very long bike route for example, costs that one day: the TREK services are asked again for the next. A host that cannot be reached or fails is left to rest first (see [Admin-User-Defaults](Admin-User-Defaults#routing-service)). A day that avoids road classes still goes to the public FOSSGIS Valhalla when the TREK services cannot answer, since OSRM cannot avoid them.
The public OSRM servers allow roughly one request a second. That is enough for a day plan and tight for a road trip, which routes every leg of every day: TREK spaces its requests and retries a refused one, but a long trip takes a while to fill in. An instance with many road trippers is better off with its own.
The public OSRM servers allow roughly one request a second, and so does the FOSSGIS Valhalla. That is enough for a day plan and tight for a road trip, which routes every leg of every day: TREK spaces its requests to those hosts 1.1 seconds apart and retries a refused one, but a long trip takes a while to fill in. An instance with many road trippers is better off with the TREK services or engines of its own. The relay keeps its connection to the TREK services open between questions, and the server, routing for the [MCP tools](#mcp-tools), asks the relay, an own OSRM and an own Valhalla without that spacing.
> **Admin:** Set your own engines under **Admin → User Defaults**, in the **Map** section, on the desktop or the phone: **Own routing engine** for OSRM, **Own Valhalla instance** for Valhalla. A value saves when you leave the field, and **reset** restores the public default. Then **restart the server and reload the page.** Only an admin can set them; they apply to everyone.
@@ -256,7 +256,7 @@ Choosing **Public Valhalla (FOSSGIS)** as **Routing service** keeps OSRM for eve
Requirements for your own instances:
- **OSRM** must serve the standard layout, `<address>/route/v1/<profile>/…`, which `osrm-routed` does out of the box. It answers every route in TREK that Valhalla does not, not only road trips. The public servers refuse to exclude road classes; an OSRM of your own built through the MLD pipeline, with a profile that has those classes, can answer **No motorway** and **No tolls** in **Other ways** by itself.
- **Valhalla** of your own answers every route, so it needs the `auto`, `pedestrian` and `bicycle` costings and should take twenty locations a request (`max_locations`, Valhalla's own default for `auto`); a longer day is asked in pieces of twenty that share a stop.
- **Valhalla** of your own answers every route, so it needs the `auto`, `pedestrian` and `bicycle` costings and should take twenty locations a request (`max_locations`, Valhalla's own default for `auto`); a longer day is asked in pieces of twenty that share a stop, one after another without a pause. Only a piece that goes to the public FOSSGIS Valhalla is cut to ten stops and paced.
- **Reachable from the browser.** The browser calls the engine directly, so the address has to work from the devices your travellers plan on, not only from the TREK server. `http://localhost:5000` means the traveller's own computer to a browser. When TREK is served over HTTPS, the engine has to be HTTPS too, or the browser blocks it as mixed content.
- **Reachable from the TREK server** for the [MCP tools](#mcp-tools), which calculate on the server. An address on your local network is accepted there without `ALLOW_INTERNAL_NETWORK`, because only an admin can set it.
@@ -399,10 +399,10 @@ What TREK contacts, and from where:
| Service | Asked by | When | To change it |
|---|---|---|---|
| TREK services' Valhalla (`valhalla.liketrek.com` by default) | the TREK server, for the browser and for MCP | every route | **Routing service**, or `TREK_VALHALLA_URL` |
| TREK services' Valhalla (`valhalla.liketrek.com` by default) | the TREK server, for the browser and for MCP | every route; it receives the stops' coordinates in order, the travel mode and the road classes to avoid, with the installation key, and no user or trip | **Routing service**, or `TREK_VALHALLA_URL` |
| OSRM (`routing.openstreetmap.de` by default) | the browser, and the TREK server for MCP | every route Valhalla does not calculate | **Own routing engine** |
| Valhalla (`valhalla1.openstreetmap.de`) | the browser, and the TREK server for MCP | with **Public Valhalla (FOSSGIS)**, or where the TREK services cannot answer: only when avoiding road classes or offering **No motorway**, **No tolls**, **No ferry** | **Own Valhalla instance**, or not at all once only **Own routing engine** is set |
| TREK's place index and OpenStreetMap | the TREK server | the search along the route (OpenStreetMap through Overpass), and the place lookups of **Add manually** and the Google import (the usual place search) | see [TREK-Places-API](TREK-Places-API) and [Places-and-Search](Places-and-Search), and `OVERPASS_URL` and `NOMINATIM_URL` in [Environment-Variables](Environment-Variables) |
| TREK's place index and OpenStreetMap | the TREK server | the search along the route (OpenStreetMap through Overpass), and the place lookups of **Add manually** and the Google import (the usual place search: the index, then the address search of the TREK services, or Nominatim without them) | see [TREK-Places-API](TREK-Places-API) and [Places-and-Search](Places-and-Search), and `OVERPASS_URL`, `NOMINATIM_URL` and `TREK_PHOTON_URL` in [Environment-Variables](Environment-Variables) |
| MobiData BW OCPDB | the TREK server | when a charging stop's data is shown | |
| DWD and GDACS | the TREK server | only with **Show hazard areas** on | switch it off |
| Google short links (`maps.app.goo.gl`) | the TREK server | only to unfold a short link during the Google import | |
+35 -13
View File
@@ -1,10 +1,10 @@
# TREK Places API
The TREK Places API is TREK's own place index. It answers place search, the suggestions under the search box, the details of a place picked from it, the category buttons on the map and the geocoding behind imports, with no API key to enter and no account. Every installation gets a fair daily allowance, counted by the number of requests only. In the app it is called the **TREK API**, and a result that came from it carries a **TREK** mark.
The TREK Places API is TREK's own place index. It answers place search, the suggestions under the search box, the details of a place picked from it, the category buttons on the map and the geocoding behind imports, with no API key to enter and no account. Two more services sit beside it under the same [installation key](#installation-key-and-daily-allowance): an [address search](#address-search) of OpenStreetMap (Photon, at `photon.liketrek.com`) and [routing](#routing) (Valhalla, at `valhalla.liketrek.com`). Every installation gets a fair daily allowance on each, counted by the number of requests only. In the app the three together are called the **TREK API**, and a result that came from the index carries a **TREK** mark.
It does not replace OpenStreetMap. A search asks both and shows their results together, because each is good at what the other lacks: the index is strongest on businesses such as restaurants, shops and hotels, and OpenStreetMap is where the temples, bridges, viewpoints and stations are. How the two meet in the search box is described in [Places and Search](Places-and-Search).
> **Admin:** there is nothing to set up. The index is on by default on every install and has no switch in the admin panel: whether searches leave the instance is a property of the deployment, so it is decided with environment variables (see [Switching it off](#switching-it-off) and [Running your own copy](#running-your-own-copy)), where a second admin cannot flip it from a browser. **Admin → Settings → API Keys** opens with its card, marked **Recommended default**, and **What is in it** on that card lists the fields and sources described below.
> **Admin:** there is nothing to set up. The index is on by default on every install and has no switch in the admin panel: whether searches leave the instance is a property of the deployment, so it is decided with environment variables (see [Switching it off](#switching-it-off) and [Running your own copy](#running-your-own-copy)), where a second admin cannot flip it from a browser. **Admin → Settings → API Keys** opens with its card, marked **Recommended default**. It names the three services with a few facts about each and shows the allowance of each as tiles: what is left, what was used and the limit. **What is in it** lists one block per service: the fields of a place described below; for the address search streets and house numbers, towns and regions, the address of a point and 26 languages; for routing by car, by bike, on foot, avoiding tolls, motorways and ferries, and an elevation profile. The sources under it are Overture Maps Foundation, OpenStreetMap, Wikivoyage, Wikimedia and Mapzen Terrain Tiles, which the heights of routes come from.
## What it is
@@ -30,22 +30,22 @@ Names are the ones the index has, which are the names used on the spot. The inde
| Where | What the TREK API does | When it has no answer or cannot be reached |
|---|---|---|
| Suggestions while typing | Answers on its own, from the index and its OpenStreetMap layer | Google or Amap when one holds the keyed slot, otherwise OpenStreetMap's search service (Nominatim) |
| Full search (search button or **Enter**) | Asked together with OpenStreetMap; the two lists are interleaved, TREK first | OpenStreetMap's answer on its own; the keyed provider only when both are empty |
| Suggestions while typing | Asked together with the [address search](#address-search), from the index and its OpenStreetMap layer; see [How the list is put together](Places-and-Search#how-the-list-is-put-together) | The address search's answer on its own; Google or Amap when both are empty and one holds the keyed slot, otherwise OpenStreetMap (the address search, or Nominatim on an installation without a key) |
| Full search (search button or **Enter**) | Asked together with OpenStreetMap (the address search, or Nominatim without a key), merged the same way as the suggestions | OpenStreetMap's answer on its own; the keyed provider only when both are empty |
| Opening a place picked from it | Address, contact details, opening hours and description | The place keeps what was saved with it |
| Category buttons on the trip map | Places of that category around the map centre | The public Overpass mirrors of OpenStreetMap |
| The [Road trip](Road-Trip#search-along-the-route) addon's search along the drive | Fuel, charging, rest areas, campsites and the other kinds it looks for | The public Overpass mirrors |
| Tours: the trail grades, surfaces and kinds of way along a planned tour | The OpenStreetMap tags of the ways the tour runs on, from its ways layer | The public Overpass mirrors |
| Tours: the signposted horse routes around the map view | The horse route relations in the view with their ways, from its routes layer | The public Overpass mirrors |
| Tours: the ski piste router (**Ski & winter** tours) | The winter layer: which build it serves, the index of ski areas, and the graph of runs, lifts and trails of one area, which your server keeps and checks once a day | A ski area already kept on your server still plans; a leg that cannot be planned gets a red row, *The piste data cannot be reached right now.*, with **Draw straight** |
| Booking imports, and stops given only by name in an imported Google Maps route | The coordinate of a venue, found by its name | OpenStreetMap's search service, which also resolves street addresses |
| Booking imports, and stops given only by name in an imported Google Maps route | The coordinate of a venue, found by its name | The [address search](#address-search), which also resolves street addresses; Nominatim on an installation without a key |
| Preparing a trip for offline use | Up to 3000 places around the trip, in one request | Nothing is cached, and offline search has nothing to answer from |
| MCP: `search_place` and `search_pois` | The same as the full search and the category buttons | The same fallbacks |
| Places near a point (**Places near this pin** in the place form, `POST /api/maps/nearby`, `search_nearby_places`) | Named places of any kind around the point, nearest first | Google Places when the index has nothing and a key exists, OpenStreetMap otherwise |
Where Google holds the keyed slot (a Google key, with neither Amap nor OpenStreetMap picked as the places provider), two things skip the index and OpenStreetMap for a search: the **Search Google instead** line under a result list (`provider: 'google'` on `search_place`) sends that one search to Google Places alone, and the admin switch **Search with Google only** sends every search and every suggestion there, `search_place` and **Places near this pin** included; the category buttons and `search_pois` keep asking the index. See [Google and Amap](Places-and-Search#google-and-amap).
Right-click reverse geocoding on the map does not use it: that is OpenStreetMap's (or Amap's inside mainland China, when Amap holds the keyed slot), through the [address search](#address-search) where the installation has a key.
Right-click reverse geocoding on the map and the name of a pasted map link do not use the index: they ask the [address search](#address-search) where the installation has a key, Nominatim where it has none. Inside mainland China the right-click asks Amap when Amap holds the keyed slot.
### Why two sources
@@ -69,7 +69,7 @@ On its first search an installation registers itself with the service in the bac
The key carries a random installation id and nothing else: no address, no host name, no user. It lets the service give each installation its own daily allowance instead of one pool per network, and it is sent only to the hosts in `TREK_SERVICES_HOSTS` (`places.liketrek.com`, `photon.liketrek.com` and `valhalla.liketrek.com` by default), never to a copy you run yourself or anywhere else.
**Admin → Settings → API Keys** shows on the card how this installation stands: connected, setting up its key, on the shared pool, switched off or revoked, with today's use, what is left and when the count starts again. **New identity** drops the key and asks for a new one; use it after cloning an installation onto a second machine, or after the key was revoked. Requests are counted per installation and, independently, per network; the counters are deleted after two days. Search terms and coordinates are never logged.
**Admin → Settings → API Keys** shows on the card how this installation stands: connected, setting up its key, on the shared pool, switched off or revoked, with today's use, what is left and the limit of each service, and when the count starts again. **New identity** drops the key and asks for a new one; use it after cloning an installation onto a second machine, or after the key was revoked. Requests are counted per installation and, independently, per network; the counters are deleted after two days. Search terms and coordinates are never logged.
| Variable | What it does |
|---|---|
@@ -81,11 +81,29 @@ An installation pointed at its own copy with `TREK_PLACES_URL`, or with `TREK_PL
## Address search
Where the installation holds a key, the OpenStreetMap half of the search, the suggestions of an install without the index or a key, the right-click on the map and the name of a pasted map link ask the address search of the TREK services first: a [Photon](https://github.com/komoot/photon) instance at `photon.liketrek.com` with a planet-wide import of OpenStreetMap. Nominatim stays behind it. A refused key, a spent allowance, an error, no answer within 2.5 seconds, or nothing found all go to Nominatim, so the result reads the same either way. After four misses in a row TREK asks Nominatim alone for a minute; a spent allowance that says when it comes back is waited out instead, for an hour at most. Imports that look up many points in a row (the venues of a booking, the stops of a route link, the addresses of imported places) go to Nominatim alone and leave the allowance to the people searching.
Where the installation holds a key, OpenStreetMap is asked through the address search of the TREK services: a [Photon](https://github.com/komoot/photon) instance at `photon.liketrek.com` with a planet-wide import of OpenStreetMap. It is then the installation's geocoder, and Nominatim is not asked beside it, nor when it finds nothing: an empty answer is the answer. It answers:
It is asked in the language TREK is set to, without its region (`pt-BR` asks in Portuguese, `zh-TW` in Chinese), where the import holds names in that language: Arabic, Azerbaijani, Catalan, Chinese, Czech, Dutch, English, Estonian, French, German, Greek, Hungarian, Indonesian, Italian, Japanese, Korean, Polish, Portuguese, Russian, Slovak, Spanish, Swedish, Thai, Turkish, Ukrainian and Vietnamese. Any other language gets the names used on the spot. Opening a place and matching a Google place to its OpenStreetMap record stay on Nominatim.
- **Search and suggestions:** the OpenStreetMap half of the full search and of the suggestions while typing. How its hits are ranked and merged with the index is described in [How the list is put together](Places-and-Search#how-the-list-is-put-together).
- **Imports:** the venues of a booking, the stops of a route link and the addresses of imported places, asked after the index.
- **A point on the map:** the right-click on the map and the name of a pasted map link. When nothing lies next to the point, it is asked once more within 50 km; nothing there either leaves the name and address empty.
- **Matching a Google place to its OpenStreetMap record**, for the record's Wikidata, Wikipedia and Commons tags: inside a box of about 2 km around the place, a place with a Wikidata link before an unlinked one, and a station or road of the same name after the place itself unless the name asked for one (*Hauptbahnhof*, *Tor station*).
- **Opening an OpenStreetMap place:** Overpass gives the element's tags and where it lies, and the address search names it and writes its address. Nominatim is asked only when Overpass cannot place the element.
It sends the words searched, the coordinate a search is ranked around, the point of a reverse lookup and that language, with the installation key and the `User-Agent` and nothing else. There is no shared pool: without a key, with `TREK_SERVICES=off`, or with `NOMINATIM_URL` set to a Nominatim of your own, TREK never asks it and searches the way it did before. `TREK_PHOTON_URL` points at another copy.
Nominatim is not asked as a stand-in either. A search the address search does not answer at all (a refused key, a spent allowance, an error, or no answer within 2.5 seconds) has no OpenStreetMap half, and the index answers on its own. After four of those in a row TREK stops asking the address search for a minute; a spent allowance that says when it comes back is waited out instead, for an hour at most.
It is asked in the language TREK is set to, without its region (`pt-BR` asks in Portuguese, `zh-TW` in Chinese), where the import holds names in that language: Arabic, Azerbaijani, Catalan, Chinese, Czech, Dutch, English, Estonian, French, German, Greek, Hungarian, Indonesian, Italian, Japanese, Korean, Polish, Portuguese, Russian, Slovak, Spanish, Swedish, Thai, Turkish, Ukrainian and Vietnamese. Any other language gets the names used on the spot.
It sends the words searched, the coordinate a search is ranked around, the point of a reverse lookup, the box around a place being matched and that language, with the installation key and the `User-Agent` and nothing else. There is no shared pool. Nominatim stays the geocoder of an installation without the TREK services (no key yet, or `TREK_SERVICES=off`) and of one with `NOMINATIM_URL` set to a Nominatim of its own: TREK then never asks the address search and geocodes the way it did before. `TREK_PHOTON_URL` points at another copy.
## Routing
The third service is a [Valhalla](https://github.com/valhalla/valhalla) at `valhalla.liketrek.com`, routing across the whole planet with elevation data. With **Routing service** on **Automatic** or **TREK services** it calculates the routes of the planner, the road trip and tours, by car, by bike and on foot, around toll roads, motorways and ferries where asked, and the heights of a line. The browser never asks it: a relay on your own server forwards each question with the installation key.
A day goes to the relay with up to twenty stops a request, and the pieces of a longer day follow one another without a pause. A question that runs out its deadline, a very long bike route for example, is answered 504: that one day is drawn by OSRM, and the next question goes to the TREK Valhalla as before. Only a host that cannot be reached or answers with a server error makes TREK leave it alone, for 60 seconds on the server and 10 minutes in a browser tab. Isochrones are not offered.
A routing request sends the coordinates of the stops in order, the travel mode and the road classes to avoid, with the installation key. Routes are asked for with their maneuvers only, without the written turn instructions. No user or trip goes along.
Which engine answers what, what happens when the TREK Valhalla cannot answer, and how to use engines of your own is on [Admin-User-Defaults](Admin-User-Defaults#routing-service) and [Road-Trip](Road-Trip#routing-engines).
## What leaves your instance
@@ -99,6 +117,8 @@ Every request goes out from your TREK server, never from a browser, so the servi
| The trip's area as a box | Preparing a trip for offline use |
| The index id of one place | Opening a place picked from the index |
| A venue name | Booking imports and route imports |
| The words searched, the point of a reverse lookup, the box around a place being matched, and the language TREK is set to | The [address search](#address-search), once the installation has a key |
| The coordinates of a day's stops in order, the travel mode and the road classes to avoid | [Routing](#routing) through the relay on your server |
| The OpenStreetMap way ids a planned tour runs on | Tours trail grades, surfaces and kinds of way |
| The map view as a box, grown to a 0.05 degree grid | Tours horse routes |
| The id of a ski area | Tours ski piste router. No coordinate goes along: the service learns which area a planner loaded, never where anyone clicked. |
@@ -108,7 +128,7 @@ Every request goes out from your TREK server, never from a browser, so the servi
The header carries an opaque token derived from your instance's own address, so the service can rate-limit each instance on its own instead of everyone behind one IP. Set `APP_URL` (or `ALLOWED_ORIGINS`): without either, the token is derived from the port alone, and two unconfigured installs on the same port share one rate-limit bucket.
Who is searching never goes along. No user, account, session, cookie or trip id is sent, and neither is the language TREK is set to or the one picked under **Place names** (see [Language of place names](Places-and-Search#language-of-place-names)). The service's own front page states that queries are not logged, that requests are counted per rate-limit bucket and nothing else, and that the instance token is not stored.
Who is searching never goes along. No user, account, session, cookie or trip id is sent, to the index, the address search or routing. The service's own front page states that queries are not logged, that requests are counted per rate-limit bucket and nothing else, and that the instance token is not stored.
The requests use Node's default HTTP client, so an outbound proxy set with `HTTPS_PROXY` applies to them, and `NO_PROXY` can exempt the service's host. The official image already turns Node's environment proxy on; a source install needs `NODE_USE_ENV_PROXY=1` as well. See [Environment Variables](Environment-Variables#outbound-https-proxy).
@@ -152,8 +172,9 @@ The address is read from your own configuration and is not a URL a user can type
| `TREK_PLACES_ENABLED` | Whether the index is asked at all |
| `TREK_PLACES_URL` | Which copy of the index is asked |
| `TREK_SERVICES`, `TREK_SERVICES_KEY`, `TREK_SERVICES_HOSTS` | The installation key, see [Installation key and daily allowance](#installation-key-and-daily-allowance) |
| `NOMINATIM_URL` | The OpenStreetMap half of the full search, right-click reverse geocoding, OpenStreetMap place details and import geocoding. Set, it also keeps them off the [address search](#address-search) |
| `TREK_PHOTON_URL` | Which copy of the [address search](#address-search) is asked |
| `NOMINATIM_URL` | A Nominatim of your own. Set, it answers the OpenStreetMap half of the search, right-click reverse geocoding, OpenStreetMap place details and import geocoding, and the [address search](#address-search) is not asked at all. Unset, the public Nominatim answers them only on an installation without the TREK services, or a search the address search does not answer at all |
| `TREK_PHOTON_URL` | Which copy of the [address search](#address-search) is asked; once the installation holds a key it is the geocoder for everything listed there |
| `TREK_VALHALLA_URL` | Which copy of the [routing](#routing) service the relay asks |
| `OVERPASS_URL`, `OVERPASS_TIMEOUT_MS` | The Overpass mirrors behind the category buttons, the Road trip search and the ways and horse routes of Tours when the index has no answer |
Defaults and details for each are on [Environment Variables](Environment-Variables).
@@ -174,6 +195,7 @@ Nothing breaks. Every call has a short deadline, 3.5 seconds for a search, 10 se
## See also
- [Places and Search](Places-and-Search): how the index, OpenStreetMap, Google and Amap meet in the search box
- [Road-Trip](Road-Trip#routing-engines): the routing engines and how they share the work
- [Offline Mode and PWA](Offline-Mode-and-PWA): keeping a trip on the device
- [Environment Variables](Environment-Variables): every variable named on this page
- [Plugin Cookbook](Plugin-Cookbook#answer-place-searches-from-your-own-index): adding a third index of your own to the search
+1 -1
View File
@@ -137,7 +137,7 @@ From the plan, **Edit** on a transport needs the right to edit days; on the Tran
A transport with both ends set can be drawn as a line on the trip map:
- **Flights**, **cruises** and **ferries** follow a great-circle curve, the way they actually travel across the globe.
- **Cars**, **buses**, **taxis** and **bicycles** follow real roads, routed on demand through a public OSRM router (driving for car, bus and taxi, cycling for the bicycle). A straight line shows while the route loads, and stays when routing fails or the distance is over about 2000 km.
- **Cars**, **buses**, **taxis** and **bicycles** follow real roads, routed on demand by the same router as a day's route (driving for car, bus and taxi, cycling for the bicycle): the Valhalla of the TREK services, or OSRM for a day it cannot answer and on an installation without them; see [Road-Trip](Road-Trip#routing-engines). A straight line shows while the route loads, and stays when routing fails or the distance is over about 2000 km.
- **Trains** are drawn as a straight line through all their stations.
- **Cable cars** are drawn as a straight line from the valley to the mountain station, the way the rope runs.
- **Public transit** journeys follow their real rail and bus lines, each ride in its line's own colour and the walks between them as a dotted grey line. A journey the provider sent no shape for falls back to a straight line. A journey is also drawn whenever its day's **Route** is switched on, so it has no route button of its own in the day plan.