From cb85ba915c229fa2f415fa03aa1ab98387789671 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 22 Sep 2026 12:10:20 +0200 Subject: [PATCH] fix(roadtrip): one ride-gap rule for both corridor searches, and two seam edges The corridor search in the browser cut its tiles around a ride, the MCP tool did not: it tiled and measured along the straight line between two terminals, so an assistant searching a flight day got boxes and hits under the flight path. The gap logic moves into shared/roadtrip/corridor.ts and both sides use it. A departure less than the check-in allowance after midnight no longer pins on the previous evening, and a hire car whose pick-up desk has no position is not seamed at its return desk. --- shared/src/i18n/ar/admin.ts | 1 + shared/src/i18n/ar/collab.ts | 2 + shared/src/i18n/br/admin.ts | 1 + shared/src/i18n/br/collab.ts | 2 + shared/src/i18n/ca/admin.ts | 1 + shared/src/i18n/ca/collab.ts | 2 + shared/src/i18n/cs/admin.ts | 1 + shared/src/i18n/cs/collab.ts | 2 + shared/src/i18n/de/admin.ts | 1 + shared/src/i18n/de/collab.ts | 2 + shared/src/i18n/en/admin.ts | 1 + shared/src/i18n/en/collab.ts | 2 + shared/src/i18n/es/admin.ts | 1 + shared/src/i18n/es/collab.ts | 2 + shared/src/i18n/fr/admin.ts | 1 + shared/src/i18n/fr/collab.ts | 2 + shared/src/i18n/gr/admin.ts | 1 + shared/src/i18n/gr/collab.ts | 2 + shared/src/i18n/hu/admin.ts | 1 + shared/src/i18n/hu/collab.ts | 2 + shared/src/i18n/id/admin.ts | 1 + shared/src/i18n/id/collab.ts | 2 + shared/src/i18n/it/admin.ts | 1 + shared/src/i18n/it/collab.ts | 2 + shared/src/i18n/ja/admin.ts | 1 + shared/src/i18n/ja/collab.ts | 2 + shared/src/i18n/ko/admin.ts | 1 + shared/src/i18n/ko/collab.ts | 2 + shared/src/i18n/nl/admin.ts | 1 + shared/src/i18n/nl/collab.ts | 2 + shared/src/i18n/pl/admin.ts | 1 + shared/src/i18n/pl/collab.ts | 2 + shared/src/i18n/ru/admin.ts | 1 + shared/src/i18n/ru/collab.ts | 2 + shared/src/i18n/sv/admin.ts | 1 + shared/src/i18n/sv/collab.ts | 2 + shared/src/i18n/tr/admin.ts | 1 + shared/src/i18n/tr/collab.ts | 2 + shared/src/i18n/uk/admin.ts | 1 + shared/src/i18n/uk/collab.ts | 2 + shared/src/i18n/vi/admin.ts | 1 + shared/src/i18n/vi/collab.ts | 2 + shared/src/i18n/zh-TW/admin.ts | 1 + shared/src/i18n/zh-TW/collab.ts | 2 + shared/src/i18n/zh/admin.ts | 1 + shared/src/i18n/zh/collab.ts | 2 + shared/src/maps/maps.schema.ts | 4 +- shared/src/roadtrip/carriers.spec.ts | 117 +++++++++++++++++++++++---- shared/src/roadtrip/carriers.ts | 38 ++++----- shared/src/roadtrip/corridor.spec.ts | 110 +++++++++++++++++++++++++ shared/src/roadtrip/corridor.ts | 82 +++++++++++++++++++ 51 files changed, 381 insertions(+), 39 deletions(-) diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index c6e2384a8..dbf9d5afb 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -198,6 +198,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'البحث عبر Google فقط', 'admin.placesGoogleOnly.subtitle': 'كل بحث وكل اقتراح يذهب إلى Google Places. عند الإيقاف يجيب فهرس TREK وOpenStreetMap أولًا، ولا يُسأل Google إلا إذا لم يجدا شيئًا.', 'admin.placesGoogleOnly.missingKey': 'يتطلب مفتاح Google Maps API. من دونه يعمل البحث عبر فهرس TREK وOpenStreetMap مهما كان وضع هذا المفتاح.', + 'admin.placesGoogleOnly.otherProvider': 'يتطلب Google كمزود للأماكن. عند اختيار Amap أو OpenStreetMap لا يذهب أي بحث إلى Google مهما كان وضع هذا المفتاح.', 'admin.transitProvider.title': 'مزود النقل العام', 'admin.transitProvider.subtitle': 'الخدمة التي تجيب على بحث النقل العام.', 'admin.transitProvider.transitous': 'Transitous (مجاني)', diff --git a/shared/src/i18n/ar/collab.ts b/shared/src/i18n/ar/collab.ts index 4c41584a1..4f3a1abaa 100644 --- a/shared/src/i18n/ar/collab.ts +++ b/shared/src/i18n/ar/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'حذف الرابط', 'collab.links.edit': 'تعديل الرابط', 'collab.links.open': 'فتح الرابط', + 'collab.links.confirmDeleteTitle': 'حذف الرابط؟', + 'collab.links.confirmDeleteBody': 'سيتم حذف هذا الرابط نهائيًا.', 'collab.whatsNext.title': 'ما التالي', 'collab.whatsNext.today': 'اليوم', 'collab.whatsNext.tomorrow': 'غدًا', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 926cd1ef1..d3bec71f2 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -176,6 +176,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Pesquisar apenas com o Google', 'admin.placesGoogleOnly.subtitle': 'Toda pesquisa e toda sugestão vão para o Google Places. Desligado, o índice do TREK e o OpenStreetMap respondem primeiro, e o Google só é consultado se eles não encontrarem nada.', 'admin.placesGoogleOnly.missingKey': 'Precisa de uma chave de API do Google Maps. Sem ela, a pesquisa usa o índice do TREK e o OpenStreetMap, independentemente desta chave.', + 'admin.placesGoogleOnly.otherProvider': 'Precisa do Google como provedor de lugares. Com Amap ou OpenStreetMap selecionado, nenhuma busca vai ao Google, seja qual for a posição deste botão.', 'admin.transitProvider.title': 'Provedor de transporte público', 'admin.transitProvider.subtitle': 'Qual serviço responde à busca de transporte público.', 'admin.transitProvider.transitous': 'Transitous (grátis)', diff --git a/shared/src/i18n/br/collab.ts b/shared/src/i18n/br/collab.ts index 630e72674..020225c14 100644 --- a/shared/src/i18n/br/collab.ts +++ b/shared/src/i18n/br/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Excluir link', 'collab.links.edit': 'Editar link', 'collab.links.open': 'Abrir link', + 'collab.links.confirmDeleteTitle': 'Excluir link?', + 'collab.links.confirmDeleteBody': 'Este link será excluído permanentemente.', 'collab.whatsNext.title': 'Próximos passos', 'collab.whatsNext.today': 'Hoje', 'collab.whatsNext.tomorrow': 'Amanhã', diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index 91cf3e46b..99126322e 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -169,6 +169,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Cercar només amb Google', 'admin.placesGoogleOnly.subtitle': 'Cada cerca i cada suggeriment van a Google Places. Desactivat, responen primer l\'índex de TREK i OpenStreetMap, i Google només es consulta si no troben res.', 'admin.placesGoogleOnly.missingKey': 'Cal una clau d\'API de Google Maps. Sense clau, la cerca fa servir l\'índex de TREK i OpenStreetMap, sigui com sigui aquest interruptor.', + 'admin.placesGoogleOnly.otherProvider': 'Necessita Google com a proveïdor de llocs. Amb Amap o OpenStreetMap seleccionats, cap cerca va a Google, digui el que digui aquest interruptor.', 'admin.transitProvider.title': 'Proveïdor de transport públic', 'admin.transitProvider.subtitle': 'Quin servei respon la cerca de transport públic.', 'admin.transitProvider.transitous': 'Transitous (gratuït)', diff --git a/shared/src/i18n/ca/collab.ts b/shared/src/i18n/ca/collab.ts index acddf0dd3..296f33d05 100644 --- a/shared/src/i18n/ca/collab.ts +++ b/shared/src/i18n/ca/collab.ts @@ -17,6 +17,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Suprimeix l\'enllaç', 'collab.links.edit': 'Edita l\'enllaç', 'collab.links.open': 'Obre l\'enllaç', + 'collab.links.confirmDeleteTitle': 'Suprimir l\'enllaç?', + 'collab.links.confirmDeleteBody': 'Aquest enllaç se suprimirà permanentment.', 'collab.whatsNext.title': 'Què ve ara', 'collab.whatsNext.today': 'Avui', 'collab.whatsNext.tomorrow': 'Demà', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index 23000b113..85a635ede 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -178,6 +178,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Hledat pouze přes Google', 'admin.placesGoogleOnly.subtitle': 'Každé hledání a každý návrh jde do Google Places. Vypnuto: nejprve odpovídá index TREK a OpenStreetMap, Google se ptáme jen tehdy, když nic nenajdou.', 'admin.placesGoogleOnly.missingKey': 'Vyžaduje klíč Google Maps API. Bez něj hledání běží přes index TREK a OpenStreetMap bez ohledu na tento přepínač.', + 'admin.placesGoogleOnly.otherProvider': 'Vyžaduje Google jako poskytovatele míst. Při zvoleném Amapu nebo OpenStreetMap nejde na Google žádné hledání, ať je tento přepínač nastaven jakkoli.', 'admin.transitProvider.title': 'Poskytovatel veřejné dopravy', 'admin.transitProvider.subtitle': 'Která služba odpovídá na vyhledávání veřejné dopravy.', 'admin.transitProvider.transitous': 'Transitous (zdarma)', diff --git a/shared/src/i18n/cs/collab.ts b/shared/src/i18n/cs/collab.ts index 55f71fa69..9fa356d84 100644 --- a/shared/src/i18n/cs/collab.ts +++ b/shared/src/i18n/cs/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Smazat odkaz', 'collab.links.edit': 'Upravit odkaz', 'collab.links.open': 'Otevřít odkaz', + 'collab.links.confirmDeleteTitle': 'Smazat odkaz?', + 'collab.links.confirmDeleteBody': 'Tento odkaz bude trvale smazán.', 'collab.whatsNext.title': 'Co následuje', 'collab.whatsNext.today': 'Dnes', 'collab.whatsNext.tomorrow': 'Zítra', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index 2849a63ed..917993e62 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -179,6 +179,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Nur mit Google suchen', 'admin.placesGoogleOnly.subtitle': 'Jede Suche und jeder Vorschlag geht an Google Places. Aus, antworten zuerst der TREK-Index und OpenStreetMap, Google wird nur gefragt, wenn beide nichts finden.', 'admin.placesGoogleOnly.missingKey': 'Braucht einen Google-Maps-API-Schlüssel. Ohne ihn läuft die Suche über den TREK-Index und OpenStreetMap, egal wie dieser Schalter steht.', + 'admin.placesGoogleOnly.otherProvider': 'Braucht Google als Orts-Anbieter. Mit Amap oder OpenStreetMap als Auswahl geht keine Suche an Google, egal wie dieser Schalter steht.', 'admin.transitProvider.title': 'Verkehrsanbieter', 'admin.transitProvider.subtitle': 'Welcher Dienst die ÖPNV-Suche beantwortet.', 'admin.transitProvider.transitous': 'Transitous (kostenlos)', diff --git a/shared/src/i18n/de/collab.ts b/shared/src/i18n/de/collab.ts index 453b2396c..d7aca4aa6 100644 --- a/shared/src/i18n/de/collab.ts +++ b/shared/src/i18n/de/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Link löschen', 'collab.links.edit': 'Link bearbeiten', 'collab.links.open': 'Link öffnen', + 'collab.links.confirmDeleteTitle': 'Link löschen?', + 'collab.links.confirmDeleteBody': 'Dieser Link wird dauerhaft gelöscht.', 'collab.whatsNext.title': 'Nächste', 'collab.whatsNext.today': 'Heute', 'collab.whatsNext.tomorrow': 'Morgen', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index f9d9d7322..639453714 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -218,6 +218,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Search with Google only', 'admin.placesGoogleOnly.subtitle': 'Every search and every suggestion goes to Google Places. Off, TREK\'s own index and OpenStreetMap answer first and Google is only asked when they find nothing.', 'admin.placesGoogleOnly.missingKey': 'Needs a Google Maps API key. Without one, search runs on TREK\'s own index and OpenStreetMap whatever this switch says.', + 'admin.placesGoogleOnly.otherProvider': 'Needs Google as the places provider. With Amap or OpenStreetMap picked, search never goes to Google whatever this switch says.', 'admin.transitProvider.title': 'Transit Provider', 'admin.transitProvider.subtitle': 'Which service answers public transit search.', 'admin.transitProvider.transitous': 'Transitous (free)', diff --git a/shared/src/i18n/en/collab.ts b/shared/src/i18n/en/collab.ts index b7a9fc9f9..06d5f3bab 100644 --- a/shared/src/i18n/en/collab.ts +++ b/shared/src/i18n/en/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Delete link', 'collab.links.edit': 'Edit link', 'collab.links.open': 'Open link', + 'collab.links.confirmDeleteTitle': 'Delete link?', + 'collab.links.confirmDeleteBody': 'This link will be permanently deleted.', 'collab.whatsNext.title': "What's Next", 'collab.whatsNext.today': 'Today', 'collab.whatsNext.tomorrow': 'Tomorrow', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index e4861e457..a4f62c39f 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -178,6 +178,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Buscar solo con Google', 'admin.placesGoogleOnly.subtitle': 'Cada búsqueda y cada sugerencia van a Google Places. Desactivado, responden primero el índice de TREK y OpenStreetMap, y Google solo se consulta si no encuentran nada.', 'admin.placesGoogleOnly.missingKey': 'Necesita una clave de API de Google Maps. Sin ella, la búsqueda usa el índice de TREK y OpenStreetMap, esté como esté este interruptor.', + 'admin.placesGoogleOnly.otherProvider': 'Necesita Google como proveedor de lugares. Con Amap u OpenStreetMap seleccionados, ninguna búsqueda va a Google, diga lo que diga este interruptor.', 'admin.transitProvider.title': 'Proveedor de transporte público', 'admin.transitProvider.subtitle': 'Qué servicio responde a la búsqueda de transporte público.', 'admin.transitProvider.transitous': 'Transitous (gratis)', diff --git a/shared/src/i18n/es/collab.ts b/shared/src/i18n/es/collab.ts index c2d2ce1b5..452fd0f57 100644 --- a/shared/src/i18n/es/collab.ts +++ b/shared/src/i18n/es/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Eliminar enlace', 'collab.links.edit': 'Editar enlace', 'collab.links.open': 'Abrir enlace', + 'collab.links.confirmDeleteTitle': '¿Eliminar el enlace?', + 'collab.links.confirmDeleteBody': 'Este enlace se eliminará permanentemente.', 'collab.whatsNext.title': 'Qué viene ahora', 'collab.whatsNext.today': 'Hoy', 'collab.whatsNext.tomorrow': 'Mañana', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 0a7ed9601..a9bff7e0a 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -180,6 +180,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Rechercher uniquement avec Google', 'admin.placesGoogleOnly.subtitle': 'Chaque recherche et chaque suggestion passent par Google Places. Désactivé, l\'index de TREK et OpenStreetMap répondent d\'abord, Google n\'est interrogé que s\'ils ne trouvent rien.', 'admin.placesGoogleOnly.missingKey': 'Nécessite une clé API Google Maps. Sans elle, la recherche passe par l\'index de TREK et OpenStreetMap, quelle que soit la position de cet interrupteur.', + 'admin.placesGoogleOnly.otherProvider': 'Nécessite Google comme fournisseur de lieux. Avec Amap ou OpenStreetMap sélectionné, aucune recherche ne part vers Google, quelle que soit la position de cet interrupteur.', 'admin.transitProvider.title': 'Fournisseur de transports en commun', 'admin.transitProvider.subtitle': 'Quel service répond à la recherche de transports en commun.', 'admin.transitProvider.transitous': 'Transitous (gratuit)', diff --git a/shared/src/i18n/fr/collab.ts b/shared/src/i18n/fr/collab.ts index c37f1063e..bdb7011e3 100644 --- a/shared/src/i18n/fr/collab.ts +++ b/shared/src/i18n/fr/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Supprimer le lien', 'collab.links.edit': 'Modifier le lien', 'collab.links.open': 'Ouvrir le lien', + 'collab.links.confirmDeleteTitle': 'Supprimer le lien ?', + 'collab.links.confirmDeleteBody': 'Ce lien sera définitivement supprimé.', 'collab.whatsNext.title': 'À venir', 'collab.whatsNext.today': "Aujourd'hui", 'collab.whatsNext.tomorrow': 'Demain', diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index a7064be6a..a7dbf2bcf 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -226,6 +226,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Αναζήτηση μόνο με Google', 'admin.placesGoogleOnly.subtitle': 'Κάθε αναζήτηση και κάθε πρόταση πηγαίνει στο Google Places. Απενεργοποιημένο, απαντούν πρώτα το ευρετήριο του TREK και το OpenStreetMap, και το Google ρωτιέται μόνο αν δεν βρουν τίποτα.', 'admin.placesGoogleOnly.missingKey': 'Χρειάζεται κλειδί Google Maps API. Χωρίς αυτό η αναζήτηση τρέχει στο ευρετήριο του TREK και στο OpenStreetMap, όπως κι αν είναι ο διακόπτης.', + 'admin.placesGoogleOnly.otherProvider': 'Χρειάζεται το Google ως πάροχο τοποθεσιών. Με επιλεγμένο Amap ή OpenStreetMap καμία αναζήτηση δεν πηγαίνει στο Google, ό,τι κι αν λέει αυτός ο διακόπτης.', 'admin.transitProvider.title': 'Πάροχος μέσων μεταφοράς', 'admin.transitProvider.subtitle': 'Ποια υπηρεσία απαντά στην αναζήτηση μέσων μεταφοράς.', 'admin.transitProvider.transitous': 'Transitous (δωρεάν)', diff --git a/shared/src/i18n/gr/collab.ts b/shared/src/i18n/gr/collab.ts index 7d5688fd8..76e9ea8b5 100644 --- a/shared/src/i18n/gr/collab.ts +++ b/shared/src/i18n/gr/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Διαγραφή συνδέσμου', 'collab.links.edit': 'Επεξεργασία συνδέσμου', 'collab.links.open': 'Άνοιγμα συνδέσμου', + 'collab.links.confirmDeleteTitle': 'Διαγραφή συνδέσμου;', + 'collab.links.confirmDeleteBody': 'Αυτός ο σύνδεσμος θα διαγραφεί οριστικά.', 'collab.whatsNext.title': 'Τι Επόμενο', 'collab.whatsNext.today': 'Σήμερα', 'collab.whatsNext.tomorrow': 'Αύριο', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 64e0637e9..262daf12e 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -180,6 +180,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Keresés csak a Google-lel', 'admin.placesGoogleOnly.subtitle': 'Minden keresés és minden javaslat a Google Places-hez megy. Kikapcsolva előbb a TREK saját indexe és az OpenStreetMap válaszol, a Google-t csak akkor kérdezzük, ha ők nem találnak semmit.', 'admin.placesGoogleOnly.missingKey': 'Google Maps API-kulcs kell hozzá. Nélküle a keresés a TREK indexén és az OpenStreetMapen fut, bárhogy áll is ez a kapcsoló.', + 'admin.placesGoogleOnly.otherProvider': 'A Google-t igényli helyszolgáltatóként. Amap vagy OpenStreetMap kiválasztásával egyetlen keresés sem megy a Google-höz, bárhogy áll is ez a kapcsoló.', 'admin.transitProvider.title': 'Tömegközlekedési szolgáltató', 'admin.transitProvider.subtitle': 'Melyik szolgáltatás válaszol a tömegközlekedési keresésre.', 'admin.transitProvider.transitous': 'Transitous (ingyenes)', diff --git a/shared/src/i18n/hu/collab.ts b/shared/src/i18n/hu/collab.ts index 13b86ea13..6596bb926 100644 --- a/shared/src/i18n/hu/collab.ts +++ b/shared/src/i18n/hu/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Link törlése', 'collab.links.edit': 'Link szerkesztése', 'collab.links.open': 'Link megnyitása', + 'collab.links.confirmDeleteTitle': 'Törlöd a linket?', + 'collab.links.confirmDeleteBody': 'Ez a link véglegesen törlődik.', 'collab.whatsNext.title': 'Mi következik', 'collab.whatsNext.today': 'Ma', 'collab.whatsNext.tomorrow': 'Holnap', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index addb67821..dafd65550 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -222,6 +222,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Cari hanya dengan Google', 'admin.placesGoogleOnly.subtitle': 'Setiap pencarian dan setiap saran dikirim ke Google Places. Nonaktif, indeks TREK dan OpenStreetMap menjawab lebih dulu, dan Google hanya ditanya jika keduanya tidak menemukan apa pun.', 'admin.placesGoogleOnly.missingKey': 'Memerlukan kunci API Google Maps. Tanpa kunci, pencarian memakai indeks TREK dan OpenStreetMap, apa pun posisi sakelar ini.', + 'admin.placesGoogleOnly.otherProvider': 'Membutuhkan Google sebagai penyedia tempat. Dengan Amap atau OpenStreetMap yang dipilih, tidak ada pencarian yang dikirim ke Google, apa pun posisi sakelar ini.', 'admin.transitProvider.title': 'Penyedia transportasi umum', 'admin.transitProvider.subtitle': 'Layanan mana yang menjawab pencarian transportasi umum.', 'admin.transitProvider.transitous': 'Transitous (gratis)', diff --git a/shared/src/i18n/id/collab.ts b/shared/src/i18n/id/collab.ts index 4c2b426ef..28cd555f3 100644 --- a/shared/src/i18n/id/collab.ts +++ b/shared/src/i18n/id/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Hapus tautan', 'collab.links.edit': 'Edit tautan', 'collab.links.open': 'Buka tautan', + 'collab.links.confirmDeleteTitle': 'Hapus tautan?', + 'collab.links.confirmDeleteBody': 'Tautan ini akan dihapus secara permanen.', 'collab.whatsNext.title': 'Berikutnya', 'collab.whatsNext.today': 'Hari ini', 'collab.whatsNext.tomorrow': 'Besok', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index 5cd3b6852..538a33b5b 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -178,6 +178,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Cerca solo con Google', 'admin.placesGoogleOnly.subtitle': 'Ogni ricerca e ogni suggerimento vanno a Google Places. Spento, rispondono prima l\'indice di TREK e OpenStreetMap, e Google viene interrogato solo se non trovano nulla.', 'admin.placesGoogleOnly.missingKey': 'Richiede una chiave API di Google Maps. Senza, la ricerca usa l\'indice di TREK e OpenStreetMap, comunque sia impostato questo interruttore.', + 'admin.placesGoogleOnly.otherProvider': 'Richiede Google come provider dei luoghi. Con Amap o OpenStreetMap selezionati, nessuna ricerca va a Google, qualunque sia la posizione di questo interruttore.', 'admin.transitProvider.title': 'Provider del trasporto pubblico', 'admin.transitProvider.subtitle': 'Quale servizio risponde alla ricerca di trasporto pubblico.', 'admin.transitProvider.transitous': 'Transitous (gratuito)', diff --git a/shared/src/i18n/it/collab.ts b/shared/src/i18n/it/collab.ts index a3f588b82..6e307e01a 100644 --- a/shared/src/i18n/it/collab.ts +++ b/shared/src/i18n/it/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Elimina link', 'collab.links.edit': 'Modifica link', 'collab.links.open': 'Apri link', + 'collab.links.confirmDeleteTitle': 'Eliminare il link?', + 'collab.links.confirmDeleteBody': 'Questo link verrà eliminato definitivamente.', 'collab.whatsNext.title': "Cosa c'è dopo", 'collab.whatsNext.today': 'Oggi', 'collab.whatsNext.tomorrow': 'Domani', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index c3fe2f90b..462147f4e 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -211,6 +211,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Google だけで検索', 'admin.placesGoogleOnly.subtitle': 'すべての検索と候補が Google Places に送られます。オフの場合は TREK のインデックスと OpenStreetMap が先に答え、何も見つからないときだけ Google に問い合わせます。', 'admin.placesGoogleOnly.missingKey': 'Google Maps API キーが必要です。キーがない場合、このスイッチに関係なく検索は TREK のインデックスと OpenStreetMap で行われます。', + 'admin.placesGoogleOnly.otherProvider': '場所プロバイダーとして Google が必要です。Amap または OpenStreetMap を選択している間は、このスイッチの状態にかかわらず検索は Google に送られません。', 'admin.transitProvider.title': '公共交通のプロバイダー', 'admin.transitProvider.subtitle': '公共交通の検索に応答するサービス。', 'admin.transitProvider.transitous': 'Transitous(無料)', diff --git a/shared/src/i18n/ja/collab.ts b/shared/src/i18n/ja/collab.ts index bc54cb6f4..57159b6b4 100644 --- a/shared/src/i18n/ja/collab.ts +++ b/shared/src/i18n/ja/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'リンクを削除', 'collab.links.edit': 'リンクを編集', 'collab.links.open': 'リンクを開く', + 'collab.links.confirmDeleteTitle': 'リンクを削除しますか?', + 'collab.links.confirmDeleteBody': 'このリンクは完全に削除されます。', 'collab.whatsNext.title': '次にすること', 'collab.whatsNext.today': '今日', 'collab.whatsNext.tomorrow': '明日', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index 343a9916b..e9ae58f5d 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -217,6 +217,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Google로만 검색', 'admin.placesGoogleOnly.subtitle': '모든 검색과 제안이 Google Places로 갑니다. 끄면 TREK 자체 색인과 OpenStreetMap이 먼저 답하고, 둘 다 찾지 못할 때만 Google에 묻습니다.', 'admin.placesGoogleOnly.missingKey': 'Google Maps API 키가 필요합니다. 키가 없으면 이 스위치와 상관없이 TREK 색인과 OpenStreetMap으로 검색합니다.', + 'admin.placesGoogleOnly.otherProvider': '장소 제공자로 Google이 필요합니다. Amap 또는 OpenStreetMap을 선택한 동안에는 이 스위치와 상관없이 검색이 Google로 가지 않습니다.', 'admin.transitProvider.title': '대중교통 제공자', 'admin.transitProvider.subtitle': '대중교통 검색에 응답하는 서비스입니다.', 'admin.transitProvider.transitous': 'Transitous (무료)', diff --git a/shared/src/i18n/ko/collab.ts b/shared/src/i18n/ko/collab.ts index bdc367907..729d06631 100644 --- a/shared/src/i18n/ko/collab.ts +++ b/shared/src/i18n/ko/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': '링크 삭제', 'collab.links.edit': '링크 편집', 'collab.links.open': '링크 열기', + 'collab.links.confirmDeleteTitle': '링크를 삭제할까요?', + 'collab.links.confirmDeleteBody': '이 링크는 영구적으로 삭제됩니다.', 'collab.whatsNext.title': '다음 할 일', 'collab.whatsNext.today': '오늘', 'collab.whatsNext.tomorrow': '내일', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index 230842753..d0c37c898 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -179,6 +179,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Alleen met Google zoeken', 'admin.placesGoogleOnly.subtitle': 'Elke zoekopdracht en elke suggestie gaat naar Google Places. Uit, antwoorden eerst de TREK-index en OpenStreetMap; Google wordt alleen gevraagd als die niets vinden.', 'admin.placesGoogleOnly.missingKey': 'Vereist een Google Maps API-sleutel. Zonder sleutel zoekt TREK via de eigen index en OpenStreetMap, wat deze schakelaar ook zegt.', + 'admin.placesGoogleOnly.otherProvider': 'Vereist Google als plaatsenprovider. Met Amap of OpenStreetMap gekozen gaat geen enkele zoekopdracht naar Google, wat deze schakelaar ook zegt.', 'admin.transitProvider.title': 'Ov-provider', 'admin.transitProvider.subtitle': 'Welke dienst het ov-zoeken beantwoordt.', 'admin.transitProvider.transitous': 'Transitous (gratis)', diff --git a/shared/src/i18n/nl/collab.ts b/shared/src/i18n/nl/collab.ts index 1abb00e60..91b63b5b6 100644 --- a/shared/src/i18n/nl/collab.ts +++ b/shared/src/i18n/nl/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Link verwijderen', 'collab.links.edit': 'Link bewerken', 'collab.links.open': 'Link openen', + 'collab.links.confirmDeleteTitle': 'Link verwijderen?', + 'collab.links.confirmDeleteBody': 'Deze link wordt definitief verwijderd.', 'collab.whatsNext.title': 'Wat komt er', 'collab.whatsNext.today': 'Vandaag', 'collab.whatsNext.tomorrow': 'Morgen', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index 868435bf3..3eda1722b 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -173,6 +173,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Szukaj tylko przez Google', 'admin.placesGoogleOnly.subtitle': 'Każde wyszukiwanie i każda podpowiedź trafia do Google Places. Wyłączone: najpierw odpowiadają indeks TREK i OpenStreetMap, a Google jest pytany tylko wtedy, gdy nic nie znajdą.', 'admin.placesGoogleOnly.missingKey': 'Wymaga klucza Google Maps API. Bez niego wyszukiwanie działa przez indeks TREK i OpenStreetMap, niezależnie od tego przełącznika.', + 'admin.placesGoogleOnly.otherProvider': 'Wymaga Google jako dostawcy miejsc. Przy wybranym Amap lub OpenStreetMap żadne wyszukiwanie nie trafia do Google, niezależnie od tego przełącznika.', 'admin.transitProvider.title': 'Dostawca transportu publicznego', 'admin.transitProvider.subtitle': 'Która usługa odpowiada na wyszukiwanie transportu publicznego.', 'admin.transitProvider.transitous': 'Transitous (bezpłatnie)', diff --git a/shared/src/i18n/pl/collab.ts b/shared/src/i18n/pl/collab.ts index de4fb3598..3b3174913 100644 --- a/shared/src/i18n/pl/collab.ts +++ b/shared/src/i18n/pl/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Usuń link', 'collab.links.edit': 'Edytuj link', 'collab.links.open': 'Otwórz link', + 'collab.links.confirmDeleteTitle': 'Usunąć link?', + 'collab.links.confirmDeleteBody': 'Ten link zostanie trwale usunięty.', 'collab.whatsNext.title': 'Co dalej', 'collab.whatsNext.today': 'Dzisiaj', 'collab.whatsNext.tomorrow': 'Jutro', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index abc78adae..432093484 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -180,6 +180,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Искать только через Google', 'admin.placesGoogleOnly.subtitle': 'Каждый поиск и каждая подсказка идут в Google Places. Выключено: сначала отвечают индекс TREK и OpenStreetMap, Google спрашивается, только если они ничего не нашли.', 'admin.placesGoogleOnly.missingKey': 'Нужен ключ Google Maps API. Без него поиск идёт через индекс TREK и OpenStreetMap независимо от этого переключателя.', + 'admin.placesGoogleOnly.otherProvider': 'Требуется Google как провайдер мест. Если выбран Amap или OpenStreetMap, ни один поиск не уходит в Google, как бы ни стоял этот переключатель.', 'admin.transitProvider.title': 'Поставщик общественного транспорта', 'admin.transitProvider.subtitle': 'Какая служба отвечает на поиск общественного транспорта.', 'admin.transitProvider.transitous': 'Transitous (бесплатно)', diff --git a/shared/src/i18n/ru/collab.ts b/shared/src/i18n/ru/collab.ts index f7c7c2881..4eb5564e4 100644 --- a/shared/src/i18n/ru/collab.ts +++ b/shared/src/i18n/ru/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Удалить ссылку', 'collab.links.edit': 'Изменить ссылку', 'collab.links.open': 'Открыть ссылку', + 'collab.links.confirmDeleteTitle': 'Удалить ссылку?', + 'collab.links.confirmDeleteBody': 'Эта ссылка будет удалена без возможности восстановления.', 'collab.whatsNext.title': 'Что дальше', 'collab.whatsNext.today': 'Сегодня', 'collab.whatsNext.tomorrow': 'Завтра', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index fe86e6961..14028b584 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -224,6 +224,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Sök bara med Google', 'admin.placesGoogleOnly.subtitle': 'Varje sökning och varje förslag går till Google Places. Av svarar TREK:s eget index och OpenStreetMap först, och Google tillfrågas bara när de inte hittar något.', 'admin.placesGoogleOnly.missingKey': 'Kräver en Google Maps API-nyckel. Utan nyckel söker TREK via sitt eget index och OpenStreetMap, oavsett hur den här brytaren står.', + 'admin.placesGoogleOnly.otherProvider': 'Kräver Google som platsleverantör. Med Amap eller OpenStreetMap valt går ingen sökning till Google, oavsett hur den här väljaren står.', 'admin.transitProvider.title': 'Kollektivtrafikleverantör', 'admin.transitProvider.subtitle': 'Vilken tjänst som besvarar sökningar i kollektivtrafiken.', 'admin.transitProvider.transitous': 'Transitous (gratis)', diff --git a/shared/src/i18n/sv/collab.ts b/shared/src/i18n/sv/collab.ts index bf5badd2c..73a5b6885 100644 --- a/shared/src/i18n/sv/collab.ts +++ b/shared/src/i18n/sv/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Ta bort länk', 'collab.links.edit': 'Redigera länk', 'collab.links.open': 'Öppna länk', + 'collab.links.confirmDeleteTitle': 'Ta bort länken?', + 'collab.links.confirmDeleteBody': 'Den här länken tas bort permanent.', 'collab.whatsNext.title': 'Vad händer härnäst?', 'collab.whatsNext.today': 'Idag', 'collab.whatsNext.tomorrow': 'I morgon', diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index 596def412..b587fbcdf 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -224,6 +224,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Yalnızca Google ile ara', 'admin.placesGoogleOnly.subtitle': 'Her arama ve her öneri Google Places\'e gider. Kapalıyken önce TREK dizini ve OpenStreetMap yanıtlar, Google yalnızca hiçbir şey bulamadıklarında sorulur.', 'admin.placesGoogleOnly.missingKey': 'Google Maps API anahtarı gerektirir. Anahtar yoksa arama, bu anahtar ne olursa olsun TREK dizini ve OpenStreetMap üzerinden çalışır.', + 'admin.placesGoogleOnly.otherProvider': 'Yer sağlayıcısı olarak Google gerekir. Amap veya OpenStreetMap seçiliyken bu anahtar ne derse desin hiçbir arama Google\'a gitmez.', 'admin.transitProvider.title': 'Toplu taşıma sağlayıcısı', 'admin.transitProvider.subtitle': 'Toplu taşıma aramasını hangi hizmetin yanıtlayacağı.', 'admin.transitProvider.transitous': 'Transitous (ücretsiz)', diff --git a/shared/src/i18n/tr/collab.ts b/shared/src/i18n/tr/collab.ts index 95ad138a2..3cb69011e 100644 --- a/shared/src/i18n/tr/collab.ts +++ b/shared/src/i18n/tr/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Bağlantıyı sil', 'collab.links.edit': 'Bağlantıyı düzenle', 'collab.links.open': 'Bağlantıyı aç', + 'collab.links.confirmDeleteTitle': 'Bağlantı silinsin mi?', + 'collab.links.confirmDeleteBody': 'Bu bağlantı kalıcı olarak silinecek.', 'collab.whatsNext.title': "What's Next", 'collab.whatsNext.today': 'Bugün', 'collab.whatsNext.tomorrow': 'Yarın', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index fd6cbed43..c1dc33de6 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -180,6 +180,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Шукати лише через Google', 'admin.placesGoogleOnly.subtitle': 'Кожен пошук і кожна підказка йдуть до Google Places. Вимкнено: спершу відповідають індекс TREK та OpenStreetMap, Google запитується лише тоді, коли вони нічого не знайшли.', 'admin.placesGoogleOnly.missingKey': 'Потрібен ключ Google Maps API. Без нього пошук іде через індекс TREK та OpenStreetMap незалежно від цього перемикача.', + 'admin.placesGoogleOnly.otherProvider': 'Потрібен Google як постачальник місць. Якщо вибрано Amap або OpenStreetMap, жоден пошук не йде до Google, хай як стоїть цей перемикач.', 'admin.transitProvider.title': 'Постачальник громадського транспорту', 'admin.transitProvider.subtitle': 'Яка служба відповідає на пошук громадського транспорту.', 'admin.transitProvider.transitous': 'Transitous (безкоштовно)', diff --git a/shared/src/i18n/uk/collab.ts b/shared/src/i18n/uk/collab.ts index df9ed1bb0..bb208cee7 100644 --- a/shared/src/i18n/uk/collab.ts +++ b/shared/src/i18n/uk/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Видалити посилання', 'collab.links.edit': 'Редагувати посилання', 'collab.links.open': 'Відкрити посилання', + 'collab.links.confirmDeleteTitle': 'Видалити посилання?', + 'collab.links.confirmDeleteBody': 'Це посилання буде видалено назавжди.', 'collab.whatsNext.title': 'Що далі', 'collab.whatsNext.today': 'Сьогодні', 'collab.whatsNext.tomorrow': 'Завтра', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index fe123c25a..306be5bb7 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -223,6 +223,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Chỉ tìm bằng Google', 'admin.placesGoogleOnly.subtitle': 'Mọi tìm kiếm và mọi gợi ý đều đi tới Google Places. Khi tắt, chỉ mục của TREK và OpenStreetMap trả lời trước, Google chỉ được hỏi khi cả hai không tìm thấy gì.', 'admin.placesGoogleOnly.missingKey': 'Cần khóa API Google Maps. Không có khóa, tìm kiếm chạy trên chỉ mục của TREK và OpenStreetMap, bất kể công tắc này.', + 'admin.placesGoogleOnly.otherProvider': 'Cần Google làm nhà cung cấp địa điểm. Khi chọn Amap hoặc OpenStreetMap, không tìm kiếm nào được gửi đến Google dù công tắc này đặt thế nào.', 'admin.transitProvider.title': 'Nhà cung cấp giao thông công cộng', 'admin.transitProvider.subtitle': 'Dịch vụ nào trả lời tìm kiếm giao thông công cộng.', 'admin.transitProvider.transitous': 'Transitous (miễn phí)', diff --git a/shared/src/i18n/vi/collab.ts b/shared/src/i18n/vi/collab.ts index c1a26a10e..06b47e4dd 100644 --- a/shared/src/i18n/vi/collab.ts +++ b/shared/src/i18n/vi/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Xóa liên kết', 'collab.links.edit': 'Chỉnh sửa liên kết', 'collab.links.open': 'Mở liên kết', + 'collab.links.confirmDeleteTitle': 'Xóa liên kết?', + 'collab.links.confirmDeleteBody': 'Liên kết này sẽ bị xóa vĩnh viễn.', 'collab.whatsNext.title': 'Tiếp theo là gì', 'collab.whatsNext.today': 'Hôm nay', 'collab.whatsNext.tomorrow': 'Ngày mai', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index f94e48953..e655a39c4 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -214,6 +214,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': '僅使用 Google 搜尋', 'admin.placesGoogleOnly.subtitle': '所有搜尋和建議都會送到 Google Places。關閉時,TREK 自有索引和 OpenStreetMap 先回答,只有兩者都沒有結果時才詢問 Google。', 'admin.placesGoogleOnly.missingKey': '需要 Google Maps API 金鑰。沒有金鑰時,無論此開關如何,搜尋都透過 TREK 索引和 OpenStreetMap 進行。', + 'admin.placesGoogleOnly.otherProvider': '需要將 Google 設為地點提供者。選擇 Amap 或 OpenStreetMap 時,無論此開關如何設定,搜尋都不會送往 Google。', 'admin.transitProvider.title': '公共運輸資料來源', 'admin.transitProvider.subtitle': '由哪個服務回應大眾運輸搜尋。', 'admin.transitProvider.transitous': 'Transitous(免費)', diff --git a/shared/src/i18n/zh-TW/collab.ts b/shared/src/i18n/zh-TW/collab.ts index e146932ff..9a8a80a7e 100644 --- a/shared/src/i18n/zh-TW/collab.ts +++ b/shared/src/i18n/zh-TW/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': '刪除連結', 'collab.links.edit': '編輯連結', 'collab.links.open': '開啟連結', + 'collab.links.confirmDeleteTitle': '刪除連結?', + 'collab.links.confirmDeleteBody': '此連結將被永久刪除。', 'collab.whatsNext.title': '接下來', 'collab.whatsNext.today': '今天', 'collab.whatsNext.tomorrow': '明天', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index 9a9fb0074..6fba359d6 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -175,6 +175,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': '仅使用 Google 搜索', 'admin.placesGoogleOnly.subtitle': '所有搜索和建议都发送到 Google Places。关闭时,TREK 自有索引和 OpenStreetMap 先回答,只有二者都没有结果时才询问 Google。', 'admin.placesGoogleOnly.missingKey': '需要 Google Maps API 密钥。没有密钥时,无论此开关如何,搜索都通过 TREK 索引和 OpenStreetMap 进行。', + 'admin.placesGoogleOnly.otherProvider': '需要将 Google 设为地点提供方。选择 Amap 或 OpenStreetMap 时,无论此开关如何设置,搜索都不会发送到 Google。', 'admin.transitProvider.title': '公共交通数据源', 'admin.transitProvider.subtitle': '由哪个服务响应公共交通搜索。', 'admin.transitProvider.transitous': 'Transitous(免费)', diff --git a/shared/src/i18n/zh/collab.ts b/shared/src/i18n/zh/collab.ts index adbf3de50..69e1985c9 100644 --- a/shared/src/i18n/zh/collab.ts +++ b/shared/src/i18n/zh/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': '删除链接', 'collab.links.edit': '编辑链接', 'collab.links.open': '打开链接', + 'collab.links.confirmDeleteTitle': '删除链接?', + 'collab.links.confirmDeleteBody': '此链接将被永久删除。', 'collab.whatsNext.title': '接下来', 'collab.whatsNext.today': '今天', 'collab.whatsNext.tomorrow': '明天', diff --git a/shared/src/maps/maps.schema.ts b/shared/src/maps/maps.schema.ts index af821f8ad..15cb626c4 100644 --- a/shared/src/maps/maps.schema.ts +++ b/shared/src/maps/maps.schema.ts @@ -30,7 +30,9 @@ export const mapsSearchRequestSchema = z.object({ * Ask one provider alone for this search. The index and OpenStreetMap answer * first by default and Google is only asked when they find nothing; a caller * whose results were not the place they meant can send the same query to - * Google instead. Ignored on an instance where Google holds no key. + * Google instead. Ignored unless Google holds the keyed slot: without a + * Google key, or with Amap or OpenStreetMap picked as the places provider, + * the index and OpenStreetMap answer as usual. */ provider: z.enum(['google']).optional(), }); diff --git a/shared/src/roadtrip/carriers.spec.ts b/shared/src/roadtrip/carriers.spec.ts index d42ad05ef..5c3718263 100644 --- a/shared/src/roadtrip/carriers.spec.ts +++ b/shared/src/roadtrip/carriers.spec.ts @@ -1,5 +1,5 @@ /** - * ROADTRIP-CARRIERS-001..019: a booking the traveller rides becomes a seam in the drive, + * ROADTRIP-CARRIERS-001..021: a booking the traveller rides becomes a seam in the drive, * and a hire car puts its desks on it. * * The road ends at the terminal the ride leaves from and starts again at the one it @@ -15,11 +15,9 @@ import { carrierLeg, carrierLegsFor, carrierReservationIds, - carrierRideMinutes, carrierSeam, carriesTheCar, isCarrierMode, - isCarrierStop, isPickupStop, seatCarrierStops, terminalAssignmentId, @@ -196,7 +194,6 @@ describe('a hire car', () => { )!; expect(noDesk.arrival).toBeNull(); expect(carrierSeam(rental({ endpoints: [] }))).toBeNull(); - expect(carrierRideMinutes(noDay, 0)).toBeNull(); const seated = seatCarrierStops(1, [stop({ ownerIndex: 0, time: '10:00' })], [0], [noDay]); expect(seated.map((s) => s.carrier?.role ?? s.name)).toEqual(['pickup', 'Stop 0']); expect(seatCarrierStops(3, [stop({ ownerIndex: 0 })], [0], [noDay]).map((s) => s.carrier?.role ?? s.name)).toEqual([ @@ -222,7 +219,6 @@ describe('a hire car', () => { incomingLegMode: null, }); expect(pickup.assignmentId).toBe(terminalAssignmentId(9, 'pickup')); - expect(isCarrierStop(pickup)).toBe(true); expect(isPickupStop(pickup)).toBe(true); // No ride leaves a pick-up desk: the leg out of it is a road like any other. expect(carrierLegsFor([pickup, first[1]!], 'driving', () => 0, roadtripLegKey)).toBeNull(); @@ -283,19 +279,52 @@ describe('a hire car', () => { expect(day.driveWarnings.some((w) => w.code === 'range')).toBe(false); expect(routes.totalStops).toBe(3); }); + + it('ROADTRIP-CARRIERS-021: a hire car whose pick-up desk is not located stands nowhere, the return desk alone is no pick-up', () => { + // The form and the booking import both drop an endpoint that did not geocode, so a + // one-way hire can arrive with its return desk only. Reading that desk as the pick-up + // started the drive in the city the car is handed back in. + const returnOnly = rental({ + endpoints: [{ role: 'to', sequence: 1, name: 'Sixt Airport', code: 'HAM', lat: 53.63, lng: 9.99 }], + }); + expect(carrierSeam(returnOnly)).toBeNull(); + const plannedStopOnly = rental({ + endpoints: [ + { role: 'stop', sequence: 1, name: 'Luebeck', code: null, lat: 53.87, lng: 10.69 }, + { role: 'to', sequence: 2, name: 'Sixt Airport', code: 'HAM', lat: 53.63, lng: 9.99 }, + ], + }); + expect(carrierSeam(plannedStopOnly)).toBeNull(); + // A ride still takes the first located stop for its departure when none is marked. + const unmarked = flight({ + endpoints: [ + { role: 'stop', sequence: 0, name: 'Munich Airport', code: 'MUC', lat: 48.35, lng: 11.78 }, + { role: 'to', sequence: 1, name: 'Hamburg Airport', code: 'HAM', lat: 53.63, lng: 9.99 }, + ], + }); + expect(carrierSeam(unmarked)!.departure.code).toBe('MUC'); + }); }); -describe('carrierRideMinutes', () => { +describe('the minutes of a ride', () => { + const rideOf = (booking: CarrierBooking, dayDelta: number): RoutedLeg => { + const seam = carrierSeam(booking)!; + const [dep] = seatCarrierStops(seam.departure.dayId, [], [], [seam]) as [RoadtripStop]; + const arr = seatCarrierStops(seam.arrival!.dayId, [], [], [seam]).find((s) => s.carrier?.role === 'arrival')!; + return carrierLegsFor([dep, arr], 'flight', () => dayDelta, roadtripLegKey)![roadtripLegKey(dep, arr)]!; + }; + it('ROADTRIP-CARRIERS-007: the ride is what the clocks say, across the days the booking spans, and never negative', () => { - const same = carrierSeam(flight())!; - expect(carrierRideMinutes(same, 0)).toBe(70); - const overnight = carrierSeam(flight({ reservation_time: '22:00', reservation_end_time: '07:00', end_day_id: 2 }))!; - expect(carrierRideMinutes(overnight, 1)).toBe(540); + expect(rideOf(flight(), 0).seg.duration).toBe(70 * 60); + const overnight = flight({ reservation_time: '22:00', reservation_end_time: '07:00', end_day_id: 2 }); + expect(rideOf(overnight, 1).seg.duration).toBe(540 * 60); // Westward across the date line the clock lands before it left; the chain cannot // run backwards, so the ride counts as none rather than as a day. - const dateLine = carrierSeam(flight({ reservation_time: '10:00', reservation_end_time: '06:00' }))!; - expect(carrierRideMinutes(dateLine, 0)).toBe(0); - expect(carrierRideMinutes(carrierSeam(flight({ reservation_end_time: null }))!, 0)).toBeNull(); + expect(rideOf(flight({ reservation_time: '10:00', reservation_end_time: '06:00' }), 0).seg.duration).toBe(0); + // Without an arrival clock there are no minutes to give, and the leg says so. + const unclocked = rideOf(flight({ reservation_end_time: null }), 0); + expect(unclocked.seg.duration).toBe(0); + expect(unclocked.seg.durationText).toBe(''); }); }); @@ -339,8 +368,6 @@ describe('seatCarrierStops', () => { expect(dep.placeId).toBeLessThan(0); expect(dep.ownerIndex).toBe(2); expect(arr.ownerIndex).toBe(2); - expect(isCarrierStop(dep)).toBe(true); - expect(isCarrierStop(day[0])).toBe(false); }); it('ROADTRIP-CARRIERS-009: a dragged position decides among untimed stops, and a ride before every timed stop opens the day', () => { @@ -530,4 +557,64 @@ describe('the ride in the drive', () => { expect(chainTwo.schedule.entries[0]).toMatchObject({ arrival: '07:00' }); expect(chainTwo.stops.some((s) => s.automaticNight)).toBe(false); }); + + it('ROADTRIP-CARRIERS-020: a red-eye inside its check-in allowance is pinned at midnight on its own day, and the day stays on one card', () => { + // 00:30 less an hour is 23:30, which the clock wraps onto the evening before: the + // chain then read the flight as leaving the next day and filed the arrival and every + // stop after it on the next card, with a spill mark on a day nothing spilled from. + const redEye = carrierSeam(flight({ reservation_time: '00:30', reservation_end_time: '02:30' }))!; + const seated = seatCarrierStops( + 1, + [ + stop({ ownerIndex: 0, time: '09:00', dwellMinutes: 0 }), + stop({ ownerIndex: 1, time: '14:00', dwellMinutes: 0 }), + ], + [0, 1], + [redEye], + ); + expect(seated.map((s) => s.carrier?.role ?? s.name)).toEqual(['departure', 'arrival', 'Stop 0', 'Stop 1']); + const [dep, arr, first, second] = seated as [RoadtripStop, RoadtripStop, RoadtripStop, RoadtripStop]; + expect(dep).toMatchObject({ time: '00:00', leaveAt: '00:30', dwellMinutes: 30 }); + expect(arr.time).toBe('02:30'); + + const nextDay = [ + stop({ ownerIndex: 0, ownerDayId: 2, time: '10:00', dwellMinutes: 0, lat: 53.7 }), + stop({ ownerIndex: 1, ownerDayId: 2, time: '12:00', dwellMinutes: 0, lat: 53.9 }), + ]; + const legs: Record = { + [roadtripLegKey(arr, first)]: road(30), + [roadtripLegKey(first, second)]: road(30), + [roadtripLegKey(nextDay[0]!, nextDay[1]!)]: road(30), + ...carrierLegsFor([dep, arr], 'flight', () => 0, roadtripLegKey)!, + }; + const routes = assembleRoadtrip({ + plan: [ + { dayId: 1, dayNumber: 1, date: '2026-06-01', title: null, stops: seated }, + { dayId: 2, dayNumber: 2, date: '2026-06-02', title: null, stops: nextDay }, + ], + quietDays: [], + window: null, + distanceUnit: 'metric', + allLegs: legs, + snapByDay: {}, + missedByDay: {}, + loading: false, + limits: { legMinutes: null, dayMinutes: null, rangeKm: null }, + vehicleKind: 'combustion', + connectDays: false, + boundaries: [], + labels: { start: 'go', end: 'stop' }, + }); + expect(routes.days.map((d) => d.stops.map((s) => s.carrier?.role ?? s.name))).toEqual([ + ['departure', 'arrival', 'Stop 0', 'Stop 1'], + ['Stop 0', 'Stop 1'], + ]); + expect(routes.days[0]!.schedule.entries.map((e) => [e.arrival, e.departure])).toEqual([ + ['00:00', '00:30'], + ['02:30', '02:30'], + ['09:00', '09:00'], + ['14:00', '14:00'], + ]); + expect(routes.days.some((d) => d.spills?.length)).toBe(false); + }); }); diff --git a/shared/src/roadtrip/carriers.ts b/shared/src/roadtrip/carriers.ts index b7f703078..348446a4a 100644 --- a/shared/src/roadtrip/carriers.ts +++ b/shared/src/roadtrip/carriers.ts @@ -169,11 +169,13 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | null { (e) => typeof e.lat === 'number' && typeof e.lng === 'number' && Number.isFinite(e.lat) && Number.isFinite(e.lng), ) .sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)); - const from = located.find((e) => e.role === 'from') ?? located[0]; // A ride needs both ends to say where the drive stops and where it resumes, and takes - // the last located stop for its arrival when no endpoint is marked as one. A hire car's - // return is only ever the desk marked as such: guessing one would put a stop on the - // road nobody booked. The same desk twice is fine for a car, and no seam for a ride. + // the first and the last located stop for them when no endpoint is marked. A hire car's + // desks are only ever the ones marked as such: guessing one would put a stop on the + // road nobody booked, and a return desk read as the pick-up starts the drive where it + // ends. The same desk twice is fine for a car, and no seam for a ride. + const from = + kind === 'ride' ? (located.find((e) => e.role === 'from') ?? located[0]) : located.find((e) => e.role === 'from'); const to = kind === 'ride' ? ([...located].reverse().find((e) => e.role === 'to') ?? located[located.length - 1]) @@ -225,16 +227,14 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | null { } /** - * Minutes of the ride as the clocks tell them, across the days the booking spans. + * Minutes of a ride as the clocks tell them, across the days it spans. * * Wall clocks on purpose, not elapsed time: the chain runs on the local clock, and after * landing the day goes on in the destination's time. A ride that lands earlier on the * clock than it left (westward across the date line) cannot be told from a ride of no * length this way and counts as one. Null when either clock is missing. */ -export function carrierRideMinutes(seam: CarrierSeam, dayDelta: number): number | null { - const dep = parseClock(seam.departure.clock); - const arr = parseClock(seam.arrival?.clock); +function rideMinutes(dep: number | null, arr: number | null, dayDelta: number): number | null { if (dep === null || arr === null) return null; return Math.max(0, arr + Math.max(0, dayDelta) * 1440 - dep); } @@ -251,11 +251,6 @@ function rolesOf(seam: CarrierSeam): { start: CarrierTerminal['role']; end: Carr return seam.kind === 'rental' ? { start: 'pickup', end: 'return' } : { start: 'departure', end: 'arrival' }; } -/** Whether a stop is a carrier terminal, which is what every writer has to refuse. */ -export function isCarrierStop(stop: Pick | null | undefined): boolean { - return !!stop?.carrier; -} - function terminalStop( seam: CarrierSeam, role: CarrierTerminal['role'], @@ -269,8 +264,10 @@ function terminalStop( // The departure terminal is pinned a check-in ahead of the timetable and left at the // timetable's minute; the arrival one is pinned at the timetable's minute and left at // once. Neither has a stay of its own. A hire car's desks are pinned at the booking's - // own clock, and the drive goes on from them without a stay. - const time = role === 'departure' && depart !== null ? formatClock(depart - checkIn) : end.clock; + // own clock, and the drive goes on from them without a stay. The pin never goes past + // midnight: a red-eye inside its allowance would otherwise read as the evening before, + // and the chain would file every stop after it on the next day's card. + const pinnedAt = role === 'departure' && depart !== null ? Math.max(0, depart - checkIn) : null; return { carrier: { reservationId: seam.reservationId, @@ -287,9 +284,9 @@ function terminalStop( name: end.name, lat: end.lat, lng: end.lng, - time, + time: pinnedAt === null ? end.clock : formatClock(pinnedAt), leaveAt: role === 'departure' ? end.clock : null, - dwellMinutes: role === 'departure' && depart !== null ? checkIn : 0, + dwellMinutes: depart !== null && pinnedAt !== null ? depart - pinnedAt : 0, checkInTime: null, night: false, endDay: false, @@ -458,12 +455,7 @@ export function carrierLegsFor( for (let i = 0; i < run.length - 1; i++) { const from = run[i]!; const to = run[i + 1]!; - const dep = parseClock(from.carrier?.at); - const arr = parseClock(to.carrier?.at); - const minutes = - from.carrier && to.carrier && dep !== null && arr !== null - ? Math.max(0, arr + Math.max(0, dayDelta(from, to)) * 1440 - dep) - : null; + const minutes = rideMinutes(parseClock(from.carrier?.at), parseClock(to.carrier?.at), dayDelta(from, to)); legs[key(from, to)] = carrierLeg(from, to, minutes); } return legs; diff --git a/shared/src/roadtrip/corridor.spec.ts b/shared/src/roadtrip/corridor.spec.ts index 31adf93a6..c40b0f307 100644 --- a/shared/src/roadtrip/corridor.spec.ts +++ b/shared/src/roadtrip/corridor.spec.ts @@ -2,13 +2,18 @@ import { boxAround, corridorTiles, distanceToSegmentKm, + drivenPieces, haversineKm, + inRiddenRange, pointAtMeters, projectOntoRoute, + rideGaps, + riddenRanges, sliceAtMeters, simplifyLine, type LatLng, } from './corridor'; +import type { RoadtripStop } from './planning-types'; import { describe, it, expect } from 'vitest'; @@ -283,3 +288,108 @@ describe('sliceAtMeters', () => { expect(out.every((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng))).toBe(true); }); }); + +describe('a ride in the corridor (#2428)', () => { + // A drive north along one meridian, a flight on to the next terminal, and a short drive + // after it: a degree of latitude is about 111 km, so every figure reads off the line. + const ORIGIN: LatLng = { lat: 52, lng: 13 }; + const DEPARTURE: LatLng = { lat: 53, lng: 13 }; + const ARRIVAL: LatLng = { lat: 55, lng: 13 }; + const HOTEL: LatLng = { lat: 55.5, lng: 13 }; + const spine = [ORIGIN, DEPARTURE, ARRIVAL, HOTEL]; + const underTheFlight: LatLng = { lat: 54, lng: 13 }; + + const terminal = ( + role: 'departure' | 'arrival' | 'pickup' | 'return', + at: LatLng, + reservationId = 7, + ): Pick => ({ + carrier: { + reservationId, + type: role === 'pickup' || role === 'return' ? 'car' : 'flight', + role, + title: '', + code: null, + at: null, + }, + ...at, + }); + + it('a departure followed by the arrival of its own booking is a gap, at the departure index', () => { + const stops = [ORIGIN, terminal('departure', DEPARTURE), terminal('arrival', ARRIVAL), HOTEL]; + expect(rideGaps(stops)).toEqual([{ index: 1, from: DEPARTURE, to: ARRIVAL }]); + }); + + it('a lone departure, another booking’s arrival and a hire car’s desks make no gap', () => { + // Landing tomorrow, the arrival is on another day; the drive between a return desk + // and a pick-up desk IS driven, whatever booking the two belong to. + expect(rideGaps([ORIGIN, terminal('departure', DEPARTURE)])).toEqual([]); + expect(rideGaps([terminal('departure', DEPARTURE, 7), terminal('arrival', ARRIVAL, 8)])).toEqual([]); + expect(rideGaps([terminal('pickup', DEPARTURE), terminal('return', ARRIVAL)])).toEqual([]); + expect(rideGaps([])).toEqual([]); + }); + + it('projects both terminals onto the spine and keeps the stretches in order', () => { + const [ride] = riddenRanges(spine, [{ from: DEPARTURE, to: ARRIVAL }]); + expect(ride!.fromKm).toBeCloseTo(111, -1); + expect(ride!.toKm).toBeCloseTo(333, -1); + // Handed in backwards, or twice over, the stretches still read from the start. + const two = riddenRanges(spine, [ + { from: ARRIVAL, to: HOTEL }, + { from: ARRIVAL, to: DEPARTURE }, + ]); + expect(two.map((r) => Math.round(r.fromKm))).toEqual([111, 334]); + // A ride the spine cannot place, or whose ends land on the same point, is no stretch. + expect(riddenRanges([ORIGIN], [{ from: DEPARTURE, to: ARRIVAL }])).toEqual([]); + expect(riddenRanges(spine, [{ from: DEPARTURE, to: DEPARTURE }])).toEqual([]); + }); + + it('a hit under the flight is inside the ride, the terminals themselves are not', () => { + const ridden = riddenRanges(spine, [{ from: DEPARTURE, to: ARRIVAL }]); + const along = (p: LatLng): number => projectOntoRoute(p, spine)!.alongKm; + expect(inRiddenRange(ridden, along(underTheFlight))).toBe(true); + expect(inRiddenRange(ridden, along(DEPARTURE))).toBe(false); + expect(inRiddenRange(ridden, along(ARRIVAL))).toBe(false); + expect(inRiddenRange(ridden, along(ORIGIN))).toBe(false); + expect(inRiddenRange([], along(underTheFlight))).toBe(false); + }); + + it('cuts the line into the driven pieces, and no box is asked for under the flight', () => { + const ridden = riddenRanges(spine, [{ from: DEPARTURE, to: ARRIVAL }]); + const pieces = drivenPieces(spine, ridden); + expect(pieces).toHaveLength(2); + expect(pieces[0]![0]).toEqual(ORIGIN); + expect(pieces[0]![pieces[0]!.length - 1]!.lat).toBeCloseTo(DEPARTURE.lat, 3); + expect(pieces[1]![0]!.lat).toBeCloseTo(ARRIVAL.lat, 3); + expect(pieces[1]![pieces[1]!.length - 1]).toEqual(HOTEL); + + const covers = (tiles: ReturnType, p: LatLng): boolean => + tiles.some((t) => t.south <= p.lat && t.north >= p.lat && t.west <= p.lng && t.east >= p.lng); + const tiles = pieces.flatMap((piece) => corridorTiles(piece, 5)); + expect(covers(tiles, underTheFlight)).toBe(false); + expect(covers(tiles, { lat: 52.5, lng: 13 })).toBe(true); + // The same line straight through is what the search asked for before. + expect(covers(corridorTiles(spine, 5), underTheFlight)).toBe(true); + }); + + it('without a ride the whole line is the one piece, and a window is a slice of it', () => { + expect(drivenPieces(spine, [])).toEqual([spine]); + expect(drivenPieces(spine, [])[0]).toBe(spine); + const [windowed] = drivenPieces(spine, [], { fromKm: 0, toKm: 50 }); + expect(windowed![windowed!.length - 1]!.lat).toBeCloseTo(52.45, 1); + expect(drivenPieces([ORIGIN], [])).toEqual([]); + }); + + it('a window and a ride together leave the driven part of the window only', () => { + const ridden = riddenRanges(spine, [{ from: DEPARTURE, to: ARRIVAL }]); + const pieces = drivenPieces(spine, ridden, { fromKm: 100, toKm: 400 }); + expect(pieces).toHaveLength(2); + expect(pieces[0]![0]!.lat).toBeCloseTo(52.9, 1); + expect(pieces[0]![pieces[0]!.length - 1]!.lat).toBeCloseTo(53, 2); + expect(pieces[1]![0]!.lat).toBeCloseTo(55, 2); + expect(pieces[1]![pieces[1]!.length - 1]).toEqual(HOTEL); + // A window that ends before the ride never reaches it; one lying inside it is empty. + expect(drivenPieces(spine, ridden, { fromKm: 0, toKm: 50 })).toHaveLength(1); + expect(drivenPieces(spine, ridden, { fromKm: 150, toKm: 300 })).toEqual([]); + }); +}); diff --git a/shared/src/roadtrip/corridor.ts b/shared/src/roadtrip/corridor.ts index eb922c163..f44bc2616 100644 --- a/shared/src/roadtrip/corridor.ts +++ b/shared/src/roadtrip/corridor.ts @@ -1,3 +1,5 @@ +import type { RoadtripStop } from './planning-types'; + export interface LatLng { lat: number; lng: number; @@ -224,3 +226,83 @@ export function boxAround(point: LatLng, radiusKm: number): Bbox { east: point.lng + dLng, }; } + +/** A stretch of the spine, in kilometres along it. */ +export interface KmRange { + fromKm: number; + toKm: number; +} + +/** A ride among a day's stops: the departure terminal, at `index`, and the arrival it jumps to. */ +export interface RideGap { + index: number; + from: LatLng; + to: LatLng; +} + +/** + * The rides on a day (#2428): each departure terminal followed by the arrival of the same + * booking. The line runs straight from the one to the other there, and the car is not on + * it, so a corridor search leaves the stretch out and drops what it finds under it. + */ +export function rideGaps(stops: readonly Pick[]): RideGap[] { + const out: RideGap[] = []; + stops.forEach((stop, i) => { + const next = stops[i + 1]; + if (stop.carrier?.role === 'departure' && next?.carrier?.reservationId === stop.carrier.reservationId) { + out.push({ index: i, from: { lat: stop.lat, lng: stop.lng }, to: { lat: next.lat, lng: next.lng } }); + } + }); + return out; +} + +/** + * The ridden stretches as kilometres along the spine, in order. A ride whose two ends + * land on the same point of the spine, or that has no spine to be measured against, is + * no stretch. A terminal well off the line still projects onto its nearest point, so a + * ride is never dropped for standing away from the road. + */ +export function riddenRanges(spine: LatLng[], gaps: readonly { from: LatLng; to: LatLng }[]): KmRange[] { + const out: KmRange[] = []; + for (const gap of gaps) { + const a = projectOntoRoute(gap.from, spine)?.alongKm; + const b = projectOntoRoute(gap.to, spine)?.alongKm; + if (a === undefined || b === undefined) continue; + const range = { fromKm: Math.min(a, b), toKm: Math.max(a, b) }; + if (range.toKm > range.fromKm) out.push(range); + } + return out.sort((a, b) => a.fromKm - b.fromKm); +} + +// The terminals themselves stand at the ends of a ride and stay on the road. +const TERMINAL_SLACK_KM = 0.05; + +export function inRiddenRange(ridden: readonly KmRange[], alongKm: number): boolean { + return ridden.some((r) => alongKm > r.fromKm + TERMINAL_SLACK_KM && alongKm < r.toKm - TERMINAL_SLACK_KM); +} + +/** + * The spine cut into the pieces the car drives: the window, or the whole line, with every + * ridden stretch left out. Only the pieces to tile are cut, never the spine itself: every + * alongKm is a distance along the spine, and cutting that would renumber every hit and + * every stop the moment a window moved. + */ +export function drivenPieces(spine: LatLng[], ridden: readonly KmRange[], window?: KmRange | null): LatLng[][] { + const start = window?.fromKm ?? 0; + const end = window?.toKm ?? Number.POSITIVE_INFINITY; + const slice = (fromKm: number, toKm: number): LatLng[] => + sliceAtMeters(spine, fromKm * 1000, Number.isFinite(toKm) ? toKm * 1000 : Number.MAX_SAFE_INTEGER); + const pieces: LatLng[][] = []; + if (!ridden.length) { + pieces.push(window ? slice(start, end) : spine); + } else { + let cursor = start; + for (const range of ridden) { + if (range.fromKm >= end) break; + if (range.fromKm > cursor) pieces.push(slice(cursor, range.fromKm)); + cursor = Math.max(cursor, range.toKm); + } + if (cursor < end) pieces.push(slice(cursor, end)); + } + return pieces.filter((piece) => piece.length > 1); +}