feat: location selector in route UI
Some checks failed
TrafficCue CI / check (push) Failing after 47s
TrafficCue CI / build (push) Failing after 41s

This commit is contained in:
Cfp
2025-08-21 15:45:16 +02:00
parent 15e88979d5
commit 03b129f947
5 changed files with 175 additions and 74 deletions

View File

@ -130,5 +130,11 @@
"choose-lang": "Wählen Sie Ihre Sprache",
"first-vehicle": "Erstellen wir Ihr erstes Fahrzeug."
}
}
},
"location-selector": {
"current": "Mein Standort",
"searching": "Suchen...",
"no-results": "Keine Orte gefunden."
},
"unsave": "Löschen"
}

View File

@ -132,5 +132,10 @@
"first-vehicle": "Let's create your first vehicle."
}
},
"unsave": "Unsave"
"unsave": "Unsave",
"location-selector": {
"current": "Current Location",
"searching": "Searching...",
"no-results": "No locations found."
}
}

View File

@ -6,35 +6,56 @@
import * as Popover from "$lib/components/ui/popover/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { m } from "$lang/messages";
import { BriefcaseIcon, HomeIcon, LocateIcon, MapPinIcon, SchoolIcon } from "@lucide/svelte";
import { geocode } from "$lib/saved.svelte";
import { reverseGeocode, search, type Feature } from "$lib/services/Search";
const frameworks = [
const locations = [
{
value: "sveltekit",
label: "SvelteKit",
value: "current",
label: m["location-selector.current"](),
icon: LocateIcon
},
{
value: "next.js",
label: "Next.js",
value: "home",
label: m["saved.home"](),
subtext: geocode("home"),
icon: HomeIcon
},
{
value: "nuxt.js",
label: "Nuxt.js",
value: "school",
label: m["saved.school"](),
subtext: geocode("school"),
icon: SchoolIcon
},
{
value: "remix",
label: "Remix",
},
{
value: "astro",
label: "Astro",
},
value: "work",
label: m["saved.work"](),
subtext: geocode("work"),
icon: BriefcaseIcon
}
];
let open = $state(false);
let value = $state("");
let { value = $bindable() } = $props();
let triggerRef = $state<HTMLButtonElement>(null!);
let searchbarText = $state("");
let searchText = $derived.by(debounce(() => searchbarText, 300));
let searching = $state(false);
let searchResults: Feature[] = $state([]);
const selectedValue = $derived(value === "location" ? "My Location" : value);
async function getCoordLabel(value: `${number},${number}`) {
const splitter = value.split(",");
const res = await reverseGeocode({ lat: parseFloat(splitter[0]), lon: parseFloat(splitter[1]) })
if(res.length == 0) return "<unknown>";
const feature = res[0];
return feature.properties.name;
}
const selectedValue = $derived(
new Promise(async r => { r(locations.find((f) => f.value === value)?.label || await getCoordLabel(value)) })
);
// We want to refocus the trigger button when the user selects
// an item from the list so users can continue navigating the
@ -45,61 +66,123 @@
triggerRef.focus();
});
}
function debounce<T>(getter: () => T, delay: number): () => T | undefined {
let value = $state<T>();
let timer: NodeJS.Timeout;
$effect(() => {
const newValue = getter(); // read here to subscribe to it
clearTimeout(timer);
timer = setTimeout(() => (value = newValue), delay);
return () => clearTimeout(timer);
});
return () => value;
}
$effect(() => {
if (!searchText) {
searchResults = [];
return;
}
if (searchText.length > 0) {
searching = true;
search(searchText, 0, 0).then((results) => {
searchResults = results;
searching = false;
});
} else {
searchResults = [];
}
});
</script>
<Popover.Root bind:open>
<Popover.Trigger bind:ref={triggerRef}>
{#snippet child({ props }: { props: Record<string, unknown> })}
{#snippet child({ props })}
<Button
variant="outline"
class="justify-between"
{...props}
role="combobox"
aria-expanded={open}
style="width: 100%; flex-shrink: unset; justify-content: space-between;"
>
{selectedValue || "Select a location..."}
{#await selectedValue then selected}
{selected || "Select..."}
{/await}
<ChevronsUpDownIcon class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[200px] p-0">
<Command.Root>
<Command.Input placeholder="Search..." />
<Command.Root shouldFilter={false}>
<Command.Input placeholder="Search..." bind:value={searchbarText} />
<Command.List>
<Command.Empty>No location found.</Command.Empty>
<Command.Empty>
{#if searching}
{m["location-selector.searching"]()}
{:else}
{m["location-selector.no-results"]()}
{/if}
</Command.Empty>
<Command.Group>
<Command.Item
value="location"
onSelect={() => {
value = "location";
closeAndFocusTrigger();
}}
>
<CheckIcon
class={cn(
"mr-2 size-4",
value !== "location" && "text-transparent",
)}
/>
My Location
</Command.Item>
</Command.Group>
<Command.Group>
{#each frameworks as framework (framework.value)}
{#if searchbarText == ""}
{#each locations as location}
<Command.Item
value={location.value}
onSelect={() => {
value = location.value;
closeAndFocusTrigger();
}}
style="flex-direction: column; align-items: start;"
>
<div style="display: flex; align-items: center; gap: 5px; width: 100%;">
<location.icon
class={cn(
"mr-2 size-4"
)}
/>
{location.label}
<CheckIcon
class={cn(
"mr-2 size-4 ml-auto",
value !== location.value && "text-transparent"
)}
/>
</div>
{#await location.subtext then subtext}
{#if subtext}
<span>{subtext}</span>
{/if}
{/await}
</Command.Item>
{/each}
{/if}
{#each searchResults as result}
{@const resultValue = result.geometry.coordinates[1] + "," + result.geometry.coordinates[0]}
<Command.Item
value={framework.value}
value={resultValue}
onSelect={() => {
value = framework.value;
value = resultValue;
closeAndFocusTrigger();
}}
style="flex-direction: column; align-items: start;"
>
<CheckIcon
class={cn(
"mr-2 size-4",
value !== framework.value && "text-transparent",
)}
/>
{framework.label}
<div style="display: flex; align-items: center; gap: 5px; width: 100%;">
<MapPinIcon
class={cn(
"mr-2 size-4"
)}
/>
{result.properties.name}
<CheckIcon
class={cn(
"mr-2 size-4 ml-auto",
value !== resultValue && "text-transparent"
)}
/>
</div>
<span>{result.properties.street}{result.properties.housenumber ? " " + result.properties.housenumber : ""}, {result.properties.city}</span>
</Command.Item>
{/each}
</Command.Group>

View File

@ -20,6 +20,7 @@
import { location } from "../location.svelte";
import { saved } from "$lib/saved.svelte";
import { m } from "$lang/messages";
import LocationSelect from "../LocationSelect.svelte";
let {
from,
@ -29,7 +30,7 @@
to?: string;
} = $props();
let fromLocation = $state(from || "");
let fromLocation = $state(from || "current");
let toLocation = $state(to || "");
let routes: Trip[] | null = $state(null);
@ -57,44 +58,37 @@
<div class="flex flex-col gap-2 w-full mb-2">
<div class="flex gap-2 items-center w-full">
<CircleDotIcon />
<Input bind:value={fromLocation} />
<LocationSelect bind:value={fromLocation} />
</div>
<div class="flex items-center justify-center w-full">
<CircleArrowDown />
</div>
<div class="flex gap-2 items-center w-full">
<CircleDotIcon />
<Input bind:value={toLocation} />
<LocationSelect bind:value={toLocation} />
</div>
<span>
<!-- eslint-disable-next-line -->
{@html m["sidebar.route.help"]()}
</span>
</div>
<Button
onclick={async () => {
console.log(fromLocation, toLocation);
const FROM: WorldLocation =
fromLocation == "current"
? { lat: location.lat, lon: location.lng }
: fromLocation == "home"
? saved.home
: fromLocation == "work"
? saved.work
: {
lat: parseFloat(fromLocation.split(",")[0]),
lon: parseFloat(fromLocation.split(",")[1]),
};
: saved[fromLocation]
? saved[fromLocation]
: {
lat: parseFloat(fromLocation.split(",")[0]),
lon: parseFloat(fromLocation.split(",")[1]),
};
const TO: WorldLocation =
toLocation == "current"
? { lat: location.lat, lon: location.lng }
: toLocation == "home"
? saved.home
: toLocation == "work"
? saved.work
: {
lat: parseFloat(toLocation.split(",")[0]),
lon: parseFloat(toLocation.split(",")[1]),
};
: saved[toLocation]
? saved[toLocation]
: {
lat: parseFloat(toLocation.split(",")[0]),
lon: parseFloat(toLocation.split(",")[1]),
};
const req = createValhallaRequest(selectedVehicle() ?? DefaultVehicle, [
FROM,
TO,

View File

@ -1,3 +1,5 @@
import { reverseGeocode } from "./services/Search";
export const saved: Record<string, WorldLocation> = $state(
JSON.parse(localStorage.getItem("saved") ?? "{}"),
);
@ -5,3 +7,14 @@ export const saved: Record<string, WorldLocation> = $state(
export function saveLocations() {
localStorage.setItem("saved", JSON.stringify(saved));
}
export async function geocode(name: string) {
const loc = saved[name];
if(!loc) return;
const geocode = await reverseGeocode(loc);
if(geocode.length == 0) {
return;
}
const feature = geocode[0];
return `${feature.properties.street}${feature.properties.housenumber ? (" " + feature.properties.housenumber) : ""}, ${feature.properties.city}`;
}