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.
This commit is contained in:
Maurice
2026-09-22 12:10:20 +02:00
parent 81292d4d2b
commit cb85ba915c
51 changed files with 381 additions and 39 deletions
+1
View File
@@ -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 (مجاني)',
+2
View File
@@ -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': 'غدًا',
+1
View File
@@ -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)',
+2
View File
@@ -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ã',
+1
View File
@@ -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)',
+2
View File
@@ -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à',
+1
View File
@@ -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)',
+2
View File
@@ -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',
+1
View File
@@ -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)',
+2
View File
@@ -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',
+1
View File
@@ -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)',
+2
View File
@@ -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',
+1
View File
@@ -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)',
+2
View File
@@ -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',
+1
View File
@@ -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)',
+2
View File
@@ -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',
+1
View File
@@ -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 (δωρεάν)',
+2
View File
@@ -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': 'Αύριο',
+1
View File
@@ -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)',
+2
View File
@@ -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',
+1
View File
@@ -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)',
+2
View File
@@ -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',
+1
View File
@@ -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)',
+2
View File
@@ -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',
+1
View File
@@ -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(無料)',
+2
View File
@@ -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': '明日',
+1
View File
@@ -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 (무료)',
+2
View File
@@ -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': '내일',
+1
View File
@@ -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)',
+2
View File
@@ -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',
+1
View File
@@ -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)',
+2
View File
@@ -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',
+1
View File
@@ -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 (бесплатно)',
+2
View File
@@ -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': 'Завтра',
+1
View File
@@ -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)',
+2
View File
@@ -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',
+1
View File
@@ -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)',
+2
View File
@@ -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',
+1
View File
@@ -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 (безкоштовно)',
+2
View File
@@ -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': 'Завтра',
+1
View File
@@ -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í)',
+2
View File
@@ -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',
+1
View File
@@ -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(免費)',
+2
View File
@@ -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': '明天',
+1
View File
@@ -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(免费)',
+2
View File
@@ -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': '明天',
+3 -1
View File
@@ -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(),
});
+102 -15
View File
@@ -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<string, RoutedLeg> = {
[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);
});
});
+15 -23
View File
@@ -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<RoadtripStop, 'carrier'> | 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;
+110
View File
@@ -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<RoadtripStop, 'carrier' | 'lat' | 'lng'> => ({
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<typeof corridorTiles>, 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([]);
});
});
+82
View File
@@ -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<RoadtripStop, 'carrier' | 'lat' | 'lng'>[]): 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);
}