feat(hikes): SBB-style public-transport journey planner for hike prose
Add JourneyPlanner.svelte: a "Von / Nach" connections widget to drop into a hike's prose (e.g. with a fixed trailhead destination) so readers can plan the trip there by public transport. Backed by the free Swiss transport API (transport.opendata.ch — the same one sbb-tui uses). - From/To fields: prefillable + per-field lockable (fromFixed / toFixed), swap when both editable, and "Aktueller Standort" via geolocation (coarse accuracy → fast on laptops; resolves to the nearest stop). - Station typeahead on both fields via /locations?type=station, so routes start/end at canonical stops instead of fuzzy-geocoded points (which was yielding roundabout connections); plus resolve-on-search fallback. The dropdown grows flush from the field as a route-line spur (matching dots), typed text highlighted. - Separate date + time controls: date defaults to the next weekend day (Sat on weekdays, Sun on a Saturday); time + departure/arrival target are author-settable props (time="08:00" target="arrival") and reader-editable. - Results: per-leg transport-type icons (bus/train/tram/ship/cable) and an expandable full itinerary (stations, times, platforms, line + direction, coloured rails, walk connectors); link to the full search.ch timetable. Add TimePicker.svelte: a shared pill time picker (HH:MM string, chevron nudges, hour/minute dropdown) in the same language as DatePicker / DateTimePicker.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.83.0",
|
||||
"version": "1.84.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
<script lang="ts">
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import Clock from '@lucide/svelte/icons/clock';
|
||||
import { m } from '$lib/js/commonI18n';
|
||||
import type { CommonLang } from '$lib/js/commonI18n';
|
||||
|
||||
/**
|
||||
* Pill-styled time picker, sibling to `DatePicker.svelte` (date) and
|
||||
* `DateTimePicker.svelte` (combined). Operates on a plain `"HH:MM"` string so
|
||||
* it drops straight into 24-hour API params and `<input type="time">`-shaped
|
||||
* stores.
|
||||
*
|
||||
* - Chevron arrows nudge by `step` minutes (wrapping across the hour).
|
||||
* - The display opens a two-column hour / minute dropdown.
|
||||
* - Optional `min` / `max` (also `"HH:MM"`) disable out-of-range cells.
|
||||
*/
|
||||
interface Props {
|
||||
value?: string;
|
||||
/** Minute granularity for the dropdown + chevron nudges. */
|
||||
step?: number;
|
||||
min?: string;
|
||||
max?: string;
|
||||
lang?: CommonLang;
|
||||
/** Optional extra CSS class on the outer wrapper. */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
step = 5,
|
||||
min = '',
|
||||
max = '',
|
||||
lang = 'de',
|
||||
class: extraClass = ''
|
||||
}: Props = $props();
|
||||
|
||||
const t = $derived(m[lang]);
|
||||
|
||||
let open = $state(false);
|
||||
let pickerRef = $state<HTMLDivElement | null>(null);
|
||||
let hourCol = $state<HTMLDivElement | null>(null);
|
||||
let minCol = $state<HTMLDivElement | null>(null);
|
||||
|
||||
function pad(n: number): string {
|
||||
return n.toString().padStart(2, '0');
|
||||
}
|
||||
function parse(v: string): { h: number; m: number } | null {
|
||||
const mt = /^(\d{1,2}):(\d{2})$/.exec(v ?? '');
|
||||
if (!mt) return null;
|
||||
const h = Number(mt[1]);
|
||||
const mm = Number(mt[2]);
|
||||
if (h < 0 || h > 23 || mm < 0 || mm > 59) return null;
|
||||
return { h, m: mm };
|
||||
}
|
||||
|
||||
const current = $derived(parse(value));
|
||||
const label = $derived(current ? `${pad(current.h)}:${pad(current.m)}` : t.select_time);
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
const minutes = $derived(
|
||||
Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step).filter((mm) => mm < 60)
|
||||
);
|
||||
|
||||
function outOfRange(time: string): boolean {
|
||||
if (min && time < min) return true;
|
||||
if (max && time > max) return true;
|
||||
return false;
|
||||
}
|
||||
function hourDisabled(h: number): boolean {
|
||||
for (let mm = 0; mm < 60; mm += step) {
|
||||
if (!outOfRange(`${pad(h)}:${pad(mm)}`)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function minuteDisabled(mm: number): boolean {
|
||||
const h = current?.h ?? -1;
|
||||
if (h < 0) return false;
|
||||
return outOfRange(`${pad(h)}:${pad(mm)}`);
|
||||
}
|
||||
|
||||
function commit(h: number, mm: number) {
|
||||
const next = `${pad(h)}:${pad(mm)}`;
|
||||
if (outOfRange(next)) return;
|
||||
value = next;
|
||||
}
|
||||
function selectHour(h: number) {
|
||||
commit(h, current?.m ?? 0);
|
||||
}
|
||||
function selectMinute(mm: number) {
|
||||
commit(current?.h ?? new Date().getHours(), mm);
|
||||
}
|
||||
function nudge(delta: number) {
|
||||
const base = current ?? { h: new Date().getHours(), m: 0 };
|
||||
let total = (base.h * 60 + base.m + delta) % (24 * 60);
|
||||
if (total < 0) total += 24 * 60;
|
||||
commit(Math.floor(total / 60), total % 60);
|
||||
}
|
||||
function setNow() {
|
||||
const d = new Date();
|
||||
let mm = Math.round(d.getMinutes() / step) * step;
|
||||
let h = d.getHours();
|
||||
if (mm >= 60) {
|
||||
mm = 0;
|
||||
h = (h + 1) % 24;
|
||||
}
|
||||
commit(h, mm);
|
||||
open = false;
|
||||
}
|
||||
|
||||
// Centre the selected cells when the dropdown opens.
|
||||
function centreCol(col: HTMLDivElement | null) {
|
||||
if (!col) return;
|
||||
const sel = col.querySelector<HTMLElement>('.tp-cell.selected');
|
||||
if (sel) col.scrollTop = sel.offsetTop - col.clientHeight / 2 + sel.clientHeight / 2;
|
||||
}
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
centreCol(hourCol);
|
||||
centreCol(minCol);
|
||||
}
|
||||
});
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (pickerRef && e.target instanceof Node && !pickerRef.contains(e.target)) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
document.addEventListener('pointerdown', handleClickOutside);
|
||||
return () => document.removeEventListener('pointerdown', handleClickOutside);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="tp {extraClass}" bind:this={pickerRef}>
|
||||
<div class="tp-pill" class:empty={current == null}>
|
||||
<button type="button" class="tp-arrow" onclick={() => nudge(-step)} aria-label="−{step} min">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button type="button" class="tp-display" onclick={() => (open = !open)} aria-label={t.select_time}>
|
||||
<Clock size={14} aria-hidden="true" />
|
||||
<span class="tp-label">{label}</span>
|
||||
</button>
|
||||
<button type="button" class="tp-arrow" onclick={() => nudge(step)} aria-label="+{step} min">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div class="tp-dropdown" role="dialog" aria-label={t.select_time}>
|
||||
<div class="tp-cols">
|
||||
<div class="tp-col" bind:this={hourCol} role="listbox" aria-label="Stunde">
|
||||
{#each hours as h (h)}
|
||||
<button
|
||||
type="button"
|
||||
class="tp-cell"
|
||||
class:selected={current?.h === h}
|
||||
disabled={hourDisabled(h)}
|
||||
onclick={() => selectHour(h)}
|
||||
>
|
||||
{pad(h)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tp-col" bind:this={minCol} role="listbox" aria-label="Minute">
|
||||
{#each minutes as mm (mm)}
|
||||
<button
|
||||
type="button"
|
||||
class="tp-cell"
|
||||
class:selected={current?.m === mm}
|
||||
disabled={minuteDisabled(mm)}
|
||||
onclick={() => selectMinute(mm)}
|
||||
>
|
||||
{pad(mm)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="tp-now" onclick={setNow}>{t.now}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tp {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tp-pill {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pill);
|
||||
overflow: hidden;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tp-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.35rem 0.4rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-normal), background var(--transition-normal);
|
||||
}
|
||||
.tp-arrow:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.tp-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 1px solid var(--color-border);
|
||||
border-right: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color var(--transition-normal), background var(--transition-normal);
|
||||
}
|
||||
.tp-display:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.tp-label {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tp-pill.empty .tp-label {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Dropdown — mirrors DatePicker / DateTimePicker chrome. */
|
||||
.tp-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.4rem);
|
||||
left: 0;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 0.5rem;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.tp-cols {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.tp-col {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 11rem;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.15rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.tp-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.6rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.82rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-normal), color var(--transition-normal);
|
||||
}
|
||||
.tp-cell:hover:not(:disabled) {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
.tp-cell:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.tp-cell.selected {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
.tp-cell.selected:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.tp-now {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.3rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-normal);
|
||||
}
|
||||
.tp-now:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user