chore: init

This commit is contained in:
2025-06-20 17:08:14 +02:00
commit 30fb523cf7
186 changed files with 13188 additions and 0 deletions

18
src/lib/POIIcons.ts Normal file
View File

@@ -0,0 +1,18 @@
import { BriefcaseMedicalIcon, CarIcon, ChefHatIcon, CroissantIcon, DrillIcon, FlameIcon, FuelIcon, HamburgerIcon, PackageIcon, SchoolIcon, SquareParkingIcon, StoreIcon } from "@lucide/svelte";
import type { Component } from "svelte";
export const POIIcons: Record<string, Component> = {
"amenity=school": SchoolIcon,
"amenity=doctors": BriefcaseMedicalIcon,
"amenity=parking": SquareParkingIcon,
"shop=doityourself": DrillIcon,
"shop=car": CarIcon,
"amenity=fuel": FuelIcon,
"shop=supermarket": StoreIcon,
"amenity=parcel_locker": PackageIcon,
"amenity=fire_station": FlameIcon,
"shop=kiosk": StoreIcon,
"amenity=restaurant": ChefHatIcon,
"amenity=fast_food": HamburgerIcon,
"shop=bakery": CroissantIcon
};

View File

@@ -0,0 +1,169 @@
<script lang="ts">
import * as Drawer from "$lib/components/ui/drawer/index.js";
import { BikeIcon, CarIcon, PlusCircleIcon, SaveIcon, TractorIcon, TruckIcon, XIcon } from "@lucide/svelte";
import Button, { buttonVariants } from "../ui/button/button.svelte";
import { DefaultVehicle, isValidFuel, selectVehicle, setVehicles, vehicles, type Vehicle, type VehicleType } from "$lib/vehicles/vehicles.svelte";
import type { Snippet } from "svelte";
import * as Select from "../ui/select";
import Input from "../ui/input/input.svelte";
import EvConnectorSelect from "./EVConnectorSelect.svelte";
let open = $state(false);
let { children }: { children: Snippet } = $props();
function getVehicleIcon(type: VehicleType) {
switch (type) {
case "car":
return CarIcon;
case "motor_scooter":
return TractorIcon;
case "bicycle":
return BikeIcon;
case "motorcycle":
return BikeIcon;
case "truck":
return TruckIcon;
default:
return TractorIcon; // Default icon if no match
}
}
let vehicle: Vehicle = $state({
name: "",
type: "motor_scooter",
legalMaxSpeed: 45,
actualMaxSpeed: 45,
emissionClass: "euro_5",
fuelType: "diesel",
preferredFuel: "Diesel"
});
</script>
<Drawer.Root bind:open={open}>
<Drawer.Trigger class={buttonVariants({ variant: "secondary", class: "w-full p-5" })}>
{@render children()}
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Add Vehicle</Drawer.Title>
</Drawer.Header>
<div class="p-4 pt-0 flex flex-col gap-2">
<div class="flex gap-2">
<Select.Root type="single" bind:value={vehicle.type}>
<Select.Trigger class="w-[180px]">
{@const Icon = getVehicleIcon(vehicle.type)}
<Icon />
{vehicle.type === "car" ? "Car" : vehicle.type === "motor_scooter" ? "Moped" : "?"}
</Select.Trigger>
<Select.Content>
<Select.Item value="car">
<CarIcon />
Car
</Select.Item>
<Select.Item value="motor_scooter">
<TractorIcon />
Moped
</Select.Item>
</Select.Content>
</Select.Root>
<Input
type="text"
placeholder="Vehicle Name"
bind:value={vehicle.name}
class="w-full"
aria-label="Vehicle Name"
aria-required="true"
/>
</div>
<div class="flex justify-around mt-4">
<span>Legal Speed</span>
<span>/</span>
<span>Actual Speed</span>
</div>
<div class="flex gap-2">
<Input
type="number"
placeholder="Legal Speed"
bind:value={vehicle.legalMaxSpeed}
class="w-full text-center"
aria-label="Legal Max Speed"
aria-required="true"
/>
<Input
type="number"
placeholder="Actual Speed"
bind:value={vehicle.actualMaxSpeed}
class="w-full text-center"
aria-label="Actual Max Speed"
aria-required="true"
/>
</div>
<div class="flex justify-around mt-4">
<span>Fuel Type</span>
<span>/</span>
<span>Preferred Fuel</span>
</div>
<div class="flex gap-2">
<Select.Root type="single" bind:value={vehicle.fuelType}>
<Select.Trigger class="w-full">
{vehicle.fuelType === "diesel" ? "Diesel" : vehicle.fuelType === "petrol" ? "Petrol" : "Electric"}
</Select.Trigger>
<Select.Content>
<Select.Item value="diesel">Diesel</Select.Item>
<Select.Item value="petrol">Petrol</Select.Item>
<Select.Item value="electric">Electric</Select.Item>
</Select.Content>
</Select.Root>
<Select.Root type="single" bind:value={vehicle.preferredFuel}>
<Select.Trigger class="w-full">
{vehicle.preferredFuel}
</Select.Trigger>
<Select.Content>
{#if vehicle.fuelType === "diesel"}
<Select.Item value="Diesel">Diesel</Select.Item>
{:else if vehicle.fuelType === "petrol"}
<Select.Item value="Super">Super</Select.Item>
<Select.Item value="Super E10">Super E10</Select.Item>
{:else if vehicle.fuelType === "electric"}
<EvConnectorSelect />
{/if}
</Select.Content>
</Select.Root>
</div>
</div>
<Drawer.Footer>
<Button onclick={() => {
open = false;
if (vehicle.name.trim() === "") {
alert("Please enter a vehicle name.");
return;
}
if (vehicle.legalMaxSpeed <= 0 || vehicle.actualMaxSpeed <= 0) {
alert("Please enter valid speeds.");
return;
}
if(!isValidFuel(vehicle)) {
alert("Please select a valid fuel type and preferred fuel.");
return;
}
setVehicles([...vehicles, vehicle]);
selectVehicle(vehicle);
location.reload(); // TODO
}}>
<SaveIcon />
Save
</Button>
<Button variant="secondary" onclick={() => {
open = false;
}}>
<XIcon />
Cancel
</Button>
</Drawer.Footer>
</Drawer.Content>
</Drawer.Root>

View File

@@ -0,0 +1,10 @@
<script>
import { EVConnectors } from "$lib/vehicles/vehicles.svelte";
import * as Select from "../ui/select";
</script>
{#each EVConnectors as connector}
<Select.Item value={connector}>
{connector}
</Select.Item>
{/each}

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check";
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
import { tick } from "svelte";
import * as Command from "$lib/components/ui/command/index.js";
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";
const frameworks = [
{
value: "sveltekit",
label: "SvelteKit",
},
{
value: "next.js",
label: "Next.js",
},
{
value: "nuxt.js",
label: "Nuxt.js",
},
{
value: "remix",
label: "Remix",
},
{
value: "astro",
label: "Astro",
},
];
let open = $state(false);
let value = $state("");
let triggerRef = $state<HTMLButtonElement>(null!);
const selectedValue = $derived(
value === "location" ? "My Location" : value
);
// We want to refocus the trigger button when the user selects
// an item from the list so users can continue navigating the
// rest of the form with the keyboard.
function closeAndFocusTrigger() {
open = false;
tick().then(() => {
triggerRef.focus();
});
}
</script>
<Popover.Root bind:open>
<Popover.Trigger bind:ref={triggerRef}>
{#snippet child({ props }: { props: Record<string, any> })}
<Button
variant="outline"
class="justify-between"
{...props}
role="combobox"
aria-expanded={open}
>
{selectedValue || "Select a location..."}
<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.List>
<Command.Empty>No location found.</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}
<Command.Item
value={framework.value}
onSelect={() => {
value = framework.value;
closeAndFocusTrigger();
}}
>
<CheckIcon
class={cn(
"mr-2 size-4",
value !== framework.value && "text-transparent"
)}
/>
{framework.label}
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>

View File

@@ -0,0 +1,180 @@
<script lang="ts">
import { onMount } from "svelte";
import {
GeoJSONSource,
GeolocateControl,
Hash,
LineLayer,
MapLibre,
Marker,
Protocol,
} from "svelte-maplibre-gl";
import { view } from "./sidebar.svelte";
import { geolocate, map, pin } from "./map.svelte";
import {
drawAllRoutes,
fetchRoute,
routing,
} from "$lib/services/navigation/routing.svelte";
import { createValhallaRequest } from "$lib/vehicles/ValhallaVehicles";
import { ROUTING_SERVER } from "$lib/services/hosts";
onMount(() => {
window.addEventListener("resize", map.updateMapPadding);
map.updateMapPadding();
});
</script>
<Protocol
scheme="tiles"
loadFn={async (params) => {
console.log(params.url);
const url = params.url.replace("tiles://", "").replace("tiles.openfreemap.org/", "");
const path = url.split("/")[0];
if (path == "natural_earth") {
const t = await fetch("https://tiles.openfreemap.org/" + url);
if (t.status == 200) {
const buffer = await t.arrayBuffer();
return { data: buffer };
} else {
throw new Error(`Tile fetch error: ${t.statusText}`);
}
} else if (path == "planet") {
const t = await fetch("https://tiles.openfreemap.org/" + url);
if (t.status == 200) {
const buffer = await t.arrayBuffer();
return { data: buffer };
} else {
throw new Error(`Tile fetch error: ${t.statusText}`);
}
} else {
throw new Error("Invalid tiles protocol path");
}
}}
/>
<MapLibre
class="w-full h-full"
style="/style.json"
bind:map={map.value}
padding={map.padding}
onload={async () => {
map.updateMapPadding();
}}
onclick={(e) => {
if (view.current.type == "main" || view.current.type == "info") {
pin.dropPin(e.lngLat.lat, e.lngLat.lng);
pin.showInfo();
}
}}
>
<!-- <Hash /> -->
<GeolocateControl
positionOptions={{
enableHighAccuracy: true,
}}
trackUserLocation={true}
autoTrigger={true}
ongeolocate={(e: GeolocationPosition) => {
const speed = Math.round((e.coords.speed || 0) * 3.6); // In km/h
const accuracy = Math.round(e.coords.accuracy);
geolocate.currentLocation = {
lat: e.coords.latitude,
lon: e.coords.longitude,
};
// $inspect(`Geolocation: ${e.coords.latitude}, ${e.coords.longitude} (Speed: ${speed} km/h, Accuracy: ${accuracy} m)`);
}}
/>
{#if pin.isDropped}
<Marker lnglat={{ lat: pin.lat, lng: pin.lng }} />
{/if}
{#if routing.geojson.routePast}
<GeoJSONSource id="route-past" data={routing.geojson.routePast}>
<LineLayer
id="route-past-border"
source="route-past"
layout={{ "line-join": "round", "line-cap": "round" }}
paint={{
"line-color": "#FFFFFF",
"line-width": 13,
}}
></LineLayer>
<LineLayer
id="route-past"
source="route-past"
layout={{ "line-join": "round", "line-cap": "round" }}
paint={{
"line-color": "#acacac",
"line-width": 8,
}}
></LineLayer>
</GeoJSONSource>
{/if}
{#if routing.geojson.al0}
<GeoJSONSource id="al0" data={routing.geojson.al0}>
<LineLayer
id="al0-border"
source="al0"
layout={{ "line-join": "round", "line-cap": "round" }}
paint={{
"line-color": "#FFFFFF",
"line-width": 13,
}}
></LineLayer>
<LineLayer
id="al0"
source="al0"
layout={{ "line-join": "round", "line-cap": "round" }}
paint={{
"line-color": "#94aad4",
"line-width": 8,
}}
></LineLayer>
</GeoJSONSource>
{/if}
{#if routing.geojson.al1}
<GeoJSONSource id="al1" data={routing.geojson.al1}>
<LineLayer
id="al1-border"
source="al1"
layout={{ "line-join": "round", "line-cap": "round" }}
paint={{
"line-color": "#FFFFFF",
"line-width": 13,
}}
></LineLayer>
<LineLayer
id="al1"
source="al1"
layout={{ "line-join": "round", "line-cap": "round" }}
paint={{
"line-color": "#94aad4",
"line-width": 8,
}}
></LineLayer>
</GeoJSONSource>
{/if}
{#if routing.geojson.route}
<GeoJSONSource id="route" data={routing.geojson.route}>
<LineLayer
id="route-border"
source="route"
layout={{ "line-join": "round", "line-cap": "round" }}
paint={{
"line-color": "#FFFFFF",
"line-width": 13,
}}
></LineLayer>
<LineLayer
id="route"
source="route"
layout={{ "line-join": "round", "line-cap": "round" }}
paint={{
"line-color": "#3478f6",
"line-width": 8,
}}
></LineLayer>
</GeoJSONSource>
{/if}
</MapLibre>

View File

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { hasCapability, type Capabilities } from "$lib/services/lnv";
import type { Snippet } from "svelte";
let { capability, children }: {
capability: Capabilities[number];
children: Snippet;
} = $props();
</script>
{#await hasCapability(capability) then has}
{#if has}
{@render children()}
{/if}
{:catch error}
<!-- user is likely offline -->
{/await}

View File

@@ -0,0 +1,204 @@
<script lang="ts">
import { onMount, type Component } from "svelte";
import InvalidSidebar from "./sidebar/InvalidSidebar.svelte";
import { searchbar, view } from "./sidebar.svelte";
import MainSidebar from "./sidebar/MainSidebar.svelte";
import InfoSidebar from "./sidebar/InfoSidebar.svelte";
import RouteSidebar from "./sidebar/RouteSidebar.svelte";
import { map } from "./map.svelte";
import TripSidebar from "./sidebar/TripSidebar.svelte";
import Input from "../ui/input/input.svelte";
import { HomeIcon, SettingsIcon, UserIcon } from "@lucide/svelte";
import Button from "../ui/button/button.svelte";
import { search, type Feature } from "$lib/services/Search";
import SearchSidebar from "./sidebar/SearchSidebar.svelte";
const views: {[key: string]: Component<any>} = {
main: MainSidebar,
info: InfoSidebar,
route: RouteSidebar,
trip: TripSidebar,
search: SearchSidebar
};
let isDragging = false;
let startY = 0;
let startHeight = 0;
let sidebarHeight = $state(200);
let CurrentView = $derived(views[view.current.type] || InvalidSidebar);
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;
}
let loading = $state(false);
let searchText = $derived.by(debounce(() => searchbar.text, 300));
let searchResults: Feature[] = $state([]);
$effect(() => {
if(!searchText) {
searchResults = [];
if(view.current.type == "search") view.switch("main");
return;
}
if (searchText.length > 0) {
loading = true;
search(searchText, 0, 0).then(results => {
searchResults = results;
loading = false;
view.switch("search", {
results: searchResults,
query: searchText
})
});
} else {
searchResults = [];
}
});
</script>
<div id="floating-search">
<Input class="h-10"
placeholder="Search..." bind:value={searchbar.text} />
</div>
<div id="sidebar" style={window.innerWidth < 768 ? `height: ${sidebarHeight}px` : ""}>
{#if window.innerWidth < 768}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div role="button" id="grabber" style="height: 10px; cursor: grab; display: flex; justify-content: center; align-items: center; touch-action: none;" ontouchstart={(e) => {
isDragging = true;
startY = e.touches[0].clientY;
startHeight = sidebarHeight;
}} ontouchmove={(e) => {
if(!isDragging) return;
e.preventDefault();
const deltaY = e.touches[0].clientY - startY;
let newHeight = Math.max(100, startHeight - deltaY);
const snapPoint = 200;
const snapThreshold = 20;
if (Math.abs(newHeight - snapPoint) < snapThreshold) {
newHeight = snapPoint;
}
sidebarHeight = newHeight;
map.updateMapPadding();
}} ontouchend={() => {
if(!isDragging) return;
isDragging = false;
}}>
<div style="height: 8px; background-color: #acacac; width: 40%; border-radius: 15px;"></div>
</div>
{/if}
<CurrentView {...view.current.props}></CurrentView>
</div>
<div id="navigation">
<button>
<HomeIcon />
</button>
<button>
<UserIcon />
</button>
<button>
<SettingsIcon />
</button>
</div>
<style>
#sidebar {
background-color: hsla(0, 0%, 5%, 0.6);
backdrop-filter: blur(5px);
color: white;
padding: 15px;
width: calc(25% - 20px);
max-width: calc(25% - 20px);
overflow-y: scroll;
height: calc(100vh - 20px - 40px - 10px - 50px);
max-height: calc(100vh - 20px);
position: fixed;
top: calc(40px + 10px);
left: 0;
z-index: 10;
margin: 10px;
border-radius: 15px;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
#floating-search {
position: fixed;
margin: 10px;
top: 0;
left: 0;
z-index: 20;
width: calc(25% - 20px);
background-color: hsla(0, 0%, 5%, 0.6);
backdrop-filter: blur(5px);
border-radius: 10px;
}
#navigation {
position: fixed;
bottom: 0;
left: 0;
z-index: 50;
background-color: hsla(0, 0%, 5%, 0.9);
backdrop-filter: blur(5px);
margin-bottom: 0;
padding: 10px;
margin: 10px;
width: calc(25% - 20px);
/* border-top-left-radius: 15px;
border-top-right-radius: 15px; */
height: 50px;
border-bottom-left-radius: 15px;
border-bottom-right-radius: 15px;
display: flex;
justify-content: space-around;
}
@media (max-width: 768px) {
#sidebar {
position: fixed;
top: unset;
bottom: 50px;
left: 0;
/* min-width: calc(100% - 20px);
max-width: calc(100% - 20px); */
min-width: calc(100%);
max-width: calc(100%);
width: calc(100% - 20px);
height: 200px;
margin: unset;
/* margin-left: 10px;
margin-right: 10px; */
border-radius: 0;
border-top-left-radius: 15px;
border-top-right-radius: 15px;
padding-top: 5px; /* for the grabber */
}
#floating-search {
width: calc(100% - 20px);
}
#navigation {
margin: 0;
width: calc(100%);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
</style>

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import * as Drawer from "$lib/components/ui/drawer/index.js";
import { BikeIcon, CarIcon, PlusCircleIcon, TractorIcon, TruckIcon } from "@lucide/svelte";
import Button, { buttonVariants } from "../ui/button/button.svelte";
import { DefaultVehicle, selectedVehicle, selectVehicle, vehicles, type VehicleType } from "$lib/vehicles/vehicles.svelte";
import AddVehicleDrawer from "./AddVehicleDrawer.svelte";
let open = $state(false);
function getVehicleIcon(type: VehicleType) {
switch (type) {
case "car":
return CarIcon;
case "motor_scooter":
return TractorIcon;
case "bicycle":
return BikeIcon;
case "motorcycle":
return BikeIcon;
case "truck":
return TruckIcon;
default:
return TractorIcon; // Default icon if no match
}
}
</script>
<Drawer.Root bind:open={open}>
<Drawer.Trigger class={buttonVariants({ variant: "secondary", class: "w-full p-5" })}>
{@const vehicle = selectedVehicle() ?? DefaultVehicle}
{@const Icon = getVehicleIcon(vehicle.type)}
<Icon />
{vehicle.name}
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Vehicle Selector</Drawer.Title>
<Drawer.Description>Select your vehicle to customize routing just for you.</Drawer.Description>
</Drawer.Header>
<div class="p-4 pt-0 flex flex-col gap-2">
{#each vehicles as vehicle}
<Button variant={selectedVehicle() === vehicle ? "default" : "secondary"} class="w-full p-5" onclick={() => {selectVehicle(vehicle); open = false;}}>
{@const Icon = getVehicleIcon(vehicle.type)}
<Icon />
{vehicle.name}
</Button>
{/each}
<AddVehicleDrawer>
<Button variant="secondary" class="w-full p-5">
<PlusCircleIcon />
Add Vehicle
</Button>
</AddVehicleDrawer>
</div>
</Drawer.Content>
</Drawer.Root>

View File

@@ -0,0 +1,41 @@
<script>
import Badge from "$lib/components/ui/badge/badge.svelte";
import { getStations } from "$lib/services/MTSK";
let { tags, lat, lng } = $props();
</script>
<h3 class="text-lg font-bold mt-2">Fuel Types</h3>
<ul class="flex gap-2 flex-wrap">
{#each Object.entries(tags).filter(([key]) => key.startsWith("fuel:")) as [key, tag]}
<!-- <li>{key.replace("fuel:", "")}: {tag}</li> -->
<Badge>
{key.replace("fuel:", "")}
{#if tag !== "yes"}
({tag})
{/if}
</Badge>
{/each}
</ul>
<h3 class="text-lg font-bold mt-2">Prices</h3>
{#await getStations(lat, lng)}
<p>Loading fuel prices...</p>
{:then stations}
{#if stations.stations.length > 0}
{@const station = stations.stations[0]}
{#if station.diesel}
<p>Diesel: {station.diesel}</p>
{/if}
{#if station.e10}
<p>E10: {station.e10}</p>
{/if}
{#if station.e5}
<p>E5: {station.e5}</p>
{/if}
{:else}
<p>No fuel prices available.</p>
{/if}
{:catch err}
<p>Error loading fuel prices: {err.message}</p>
{/await}

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import Input from "$lib/components/ui/input/input.svelte";
import { LNV_SERVER } from "$lib/services/hosts";
import { ai } from "$lib/services/lnv";
import { SparklesIcon } from "@lucide/svelte";
let { lat, lon } = $props();
let question = $state("");
function getText(res: string) {
console.log("Response from MapAI:", res);
const chunks = res.split("\n");
let text = "";
for (const chunk of chunks) {
if(chunk.startsWith("0:")) {
text += JSON.parse(chunk.substring(2).trim());
}
}
return text;
}
</script>
<div class="flex gap-2 mt-2 p-2 border-border border-solid border-2 rounded-lg">
<SparklesIcon />
<div class="flex gap-2 flex-col w-full">
{#await ai(question, { lat, lon })}
<p>Loading...</p>
{:then data}
{@const text = getText(data)}
<p>{text}</p>
{:catch error}
<p>Error: {error.message}</p>
{/await}
<Input
type="text"
value={""}
placeholder="Ask a question about this place..." onchange={(e) => {
question = (e.target! as any).value;
}} />
</div>
</div>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import Badge from "$lib/components/ui/badge/badge.svelte";
import opening_hours from "opening_hours";
let { hours, lat, lon }: { hours: string, lat: number, lon: number } = $props();
const oh = $derived.by(() => {
return new opening_hours(hours, {
lat, lon, address: {
country_code: "de", // Default to Germany, can be overridden if needed
state: "NRW", // Default to North Rhine-Westphalia, can be overridden if needed
}
});
})
</script>
<h3 class="text-lg font-bold mt-2">
Opening Hours
{#if oh.getState()}
<Badge>Open</Badge>
{:else}
<Badge variant="destructive">Closed</Badge>
{/if}
</h3>
<p>{hours}</p>
<!-- todo -->

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import * as Avatar from "$lib/components/ui/avatar";
import { Button } from "$lib/components/ui/button";
import { getReviews, postReview } from "$lib/services/lnv";
import Stars from "./Stars.svelte";
let { lat, lng }: { lat: number, lng: number } = $props();
</script>
<h3 class="text-lg font-bold mt-2">Reviews</h3>
{#await getReviews({lat, lon: lng}) then reviews}
{#if reviews.length > 0}
<ul class="list-disc pl-5">
{#each reviews as review}
<li class="flex justify-center gap-2 mb-2 flex-col">
<div class="flex items-center gap-2">
<Avatar.Root>
<!-- <Avatar.Image src="https://github.com/shadcn.png" alt="@shadcn" /> -->
<Avatar.Fallback>{review.username}</Avatar.Fallback>
</Avatar.Root>
<Stars rating={review.rating} />
</div>
<span>{review.comment}</span>
</li>
{/each}
</ul>
{:else}
<p>No reviews available.</p>
{/if}
<Button variant="secondary" onclick={async () => {
const rating = prompt("Enter your rating (1-5):");
const comment = prompt("Enter your review comment:");
if (rating && comment) {
console.log(`Rating: ${rating}, Comment: ${comment}`);
await postReview({ lat, lon: lng }, {
rating: parseInt(rating, 10),
comment
})
alert("Thank you for your review!");
} else {
alert("Review submission cancelled.");
}
}} disabled>Write a review</Button><br>
{:catch error}
<p>Error loading reviews: {error.message}</p>
{/await}

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { StarIcon } from "@lucide/svelte";
let { rating }: { rating: number } = $props();
</script>
{#if rating == 0}
<StarIcon />
<StarIcon />
<StarIcon />
<StarIcon />
<StarIcon />
{:else if rating == 1}
<StarIcon class="fill-white" />
<StarIcon />
<StarIcon />
<StarIcon />
<StarIcon />
{:else if rating == 2}
<StarIcon class="fill-white" />
<StarIcon class="fill-white" />
<StarIcon />
<StarIcon />
<StarIcon />
{:else if rating == 3}
<StarIcon class="fill-white" />
<StarIcon class="fill-white" />
<StarIcon class="fill-white" />
<StarIcon />
<StarIcon />
{:else if rating == 4}
<StarIcon class="fill-white" />
<StarIcon class="fill-white" />
<StarIcon class="fill-white" />
<StarIcon class="fill-white" />
<StarIcon />
{:else if rating == 5}
<StarIcon class="fill-white" />
<StarIcon class="fill-white" />
<StarIcon class="fill-white" />
<StarIcon class="fill-white" />
<StarIcon class="fill-white" />
{/if}

View File

@@ -0,0 +1,76 @@
import { reverseGeocode } from "$lib/services/Search";
import { view } from "./sidebar.svelte";
export const geolocate = $state({
currentLocation: null as WorldLocation | null,
})
export const map = $state({
value: undefined as maplibregl.Map | undefined,
updateMapPadding: () => {
if(document.querySelector<HTMLDivElement>("#sidebar") == null) {
map._setPadding({
top: 0,
right: 0,
bottom: 0,
left: 0
});
return;
}
console.log("Updating map padding");
if (window.innerWidth < 768) {
const calculatedSidebarHeight = document.querySelector<HTMLDivElement>("#sidebar")!.getBoundingClientRect().height;
map._setPadding({
top: 50,
right: 0,
bottom: calculatedSidebarHeight + 50,
left: 0
});
return;
}
const calculatedSidebarWidth = document.querySelector<HTMLDivElement>("#sidebar")!.getBoundingClientRect().width;
map._setPadding({
top: 0,
right: 0,
bottom: 0,
left: calculatedSidebarWidth,
});
},
padding: {
top: 0,
right: 0,
bottom: 0,
left: 0
},
_setPadding: (_padding: { top: number, right: number, bottom: number, left: number }) => {
map.padding = _padding;
if (map.value) {
map.value.setPadding(map.padding);
}
}
});
export const pin = $state({
isDropped: false,
lat: 0,
lng: 0,
dropPin: (lat: number, lng: number) => {
pin.isDropped = true;
pin.lat = lat;
pin.lng = lng;
},
liftPin: () => {
pin.isDropped = false;
pin.lat = 0;
pin.lng = 0;
},
showInfo: async () => {
if(!pin.isDropped) return;
// const res = await reverseGeocode({ lat: pin.lat, lon: pin.lng });
// if(res.length > 0) {
// const feature = res[0];
// view.switch("info", { feature });
// }
view.switch("info", { lat: pin.lat, lng: pin.lng });
}
})

View File

@@ -0,0 +1,26 @@
export type View = {
type: string;
props?: Record<string, any>;
}
export const view = $state({
current: { type: "main" } as View,
history: [] as View[],
back: () => {
if (view.history.length > 0) {
view.current = view.history.pop()!;
} else {
view.current = { type: "main" } as View; // Reset to main view if history is empty
}
},
switch: (to: string, props?: Record<string, any>) => {
if (view.current.type !== to) {
view.history.push(view.current);
}
view.current = { type: to, props } as View;
}
});
export const searchbar = $state({
text: ""
})

View File

@@ -0,0 +1,196 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { POIIcons } from "$lib/POIIcons";
import { OVERPASS_SERVER } from "$lib/services/hosts";
import { BriefcaseIcon, EllipsisIcon, GlobeIcon, HomeIcon, MailIcon, PhoneIcon, RouteIcon } from "@lucide/svelte";
import { pin } from "../map.svelte";
import SidebarHeader from "./SidebarHeader.svelte";
import { fetchPOI, type OverpassElement } from "$lib/services/Overpass";
import OpeningHours from "../info/OpeningHours.svelte";
import Badge from "$lib/components/ui/badge/badge.svelte";
import FuelStation from "../info/FuelStation.svelte";
import { view } from "../sidebar.svelte";
import * as Popover from "$lib/components/ui/popover";
import Reviews from "../info/Reviews.svelte";
import MapAi from "../info/MapAI.svelte";
import { hasCapability } from "$lib/services/lnv";
import RequiresCapability from "../RequiresCapability.svelte";
// let { feature }: { feature: Feature } = $props();
// let Icon = $derived(POIIcons[feature.properties.osm_key + "=" + feature.properties.osm_value]);
let { lat, lng }: { lat: number, lng: number } = $props();
function getIcon(tags: Record<string, string>): typeof POIIcons[keyof typeof POIIcons] | null {
const key = Object.keys(tags).find(k => k.startsWith("amenity") || k.startsWith("shop"));
if (key && POIIcons[key + "=" + tags[key]]) {
return POIIcons[key + "=" + tags[key]];
}
return null;
}
function getDistance(aLat: number, aLon: number, lat: number, lon: number): number {
const R = 6371e3; // Earth radius in meters
const φ1 = lat * Math.PI / 180;
const φ2 = aLat * Math.PI / 180;
const Δφ = (aLat - lat) * Math.PI / 180;
const Δλ = (aLon - lon) * Math.PI / 180;
const a = Math.sin(Δφ/2)**2 + Math.cos(φ1)*Math.cos(φ2)*Math.sin(Δλ/2)**2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function sortByDistance(elements: OverpassElement[], lat: number, lng: number): OverpassElement[] {
return elements.sort((a: OverpassElement, b: OverpassElement) => {
const aLoc = a.center || a;
const bLoc = b.center || b;
return getDistance(aLoc.lat!, aLoc.lon!, lat, lng) - getDistance(bLoc.lat!, bLoc.lon!, lat, lng);
});
}
</script>
{#await fetchPOI(lat, lng, 20)}
<SidebarHeader onback={() => {
pin.liftPin();
}}>
Dropped Pin
</SidebarHeader>
<p>Loading...</p>
{:then res}
{#if res.elements.length === 0}
<SidebarHeader onback={() => {
pin.liftPin();
}}>
Dropped Pin
</SidebarHeader>
<span style="color: #acacac;">&copy; OpenStreetMap</span>
<pre>{JSON.stringify(res, null, 2)}</pre>
{:else}
{@const elements = sortByDistance(res.elements, lat, lng)}
{@const tags = elements[0].tags}
{@const firstElement = elements[0]}
{@const ellat = firstElement.center?.lat || firstElement.lat!}
{@const ellng = firstElement.center?.lon || firstElement.lon!}
<SidebarHeader onback={() => {
pin.liftPin();
}}>
{#if getIcon(tags)}
{@const Icon = getIcon(tags)}
<Icon />
{/if}
{tags.name || (tags["addr:street"] ? (tags["addr:street"] + " " + tags["addr:housenumber"]) : "")}
</SidebarHeader>
<div id="actions">
<Button onclick={() => {
view.switch("route", {
to: lat + "," + lng,
})
}}>
<RouteIcon />
Route
</Button>
{#if tags.email || tags["contact:email"]}
<Button variant="secondary" href={`mailto:${tags.email || tags["contact:email"]}`} target="_blank">
<MailIcon />
Email
</Button>
{/if}
{#if tags.website || tags["contact:website"]}
<Button variant="secondary" href={tags.website || tags["contact:website"]} target="_blank">
<GlobeIcon />
Website
</Button>
{/if}
{#if tags.phone || tags["contact:phone"]}
<Button variant="secondary" href={`tel:${tags.phone || tags["contact:phone"]}`} target="_blank">
<PhoneIcon />
Call
</Button>
{/if}
<Popover.Root>
<Popover.Trigger>
<Button variant="secondary">
<EllipsisIcon />
More
</Button>
</Popover.Trigger>
<Popover.Content>
<div class="flex flex-col gap-2">
<Button variant="outline" onclick={() => {
localStorage.setItem("saved.home", JSON.stringify({ lat, lon: lng }));
}}>
<HomeIcon />
Set as Home
</Button>
<Button variant="outline" onclick={() => {
localStorage.setItem("saved.work", JSON.stringify({ lat, lon: lng }));
}}>
<BriefcaseIcon />
Set as Work
</Button>
</div>
</Popover.Content>
</Popover.Root>
</div>
<RequiresCapability capability="ai">
<MapAi lat={ellat} lon={ellng} />
</RequiresCapability>
<!--
"addr:city": "Hagen",
"addr:housenumber": "12",
"addr:postcode": "58135",
"addr:street": -->
<p class="mt-2">{tags["addr:street"]} {tags["addr:housenumber"]}</p>
<p>{tags["addr:postcode"]} {tags["addr:city"]}</p>
{#if tags.opening_hours}
<OpeningHours hours={tags.opening_hours} {lat} lon={lng} />
{/if}
{#if tags.amenity == "fuel"}
<RequiresCapability capability="fuel">
<FuelStation {tags} {lat} {lng} />
</RequiresCapability>
{/if}
<!-- any payment:* tag -->
{#if Object.keys(tags).some(key => key.startsWith("payment:"))}
<h3 class="text-lg font-bold mt-2">Payment Methods</h3>
<ul style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
{#each Object.entries(tags).filter(([key]) => key.startsWith("payment:")) as [key, value]}
<!-- <li>{key.replace("payment:", "")}: {value}</li> -->
<Badge>{key.replace("payment:", "")}</Badge>
{/each}
</ul>
{/if}
<RequiresCapability capability="reviews">
<Reviews lat={ellat} lng={ellng} />
</RequiresCapability>
<span style="color: #acacac;">&copy; OpenStreetMap</span>
<pre>{JSON.stringify(elements, null, 2)}</pre>
{/if}
{:catch err}
<SidebarHeader onback={() => {
pin.liftPin();
}}>
Dropped Pin
</SidebarHeader>
<p>Error: {err.message}</p>
{/await}
<style>
#actions {
display: flex;
gap: 0.5rem;
overflow-y: scroll;
}
</style>

View File

@@ -0,0 +1,2 @@
<h1>Error</h1>
<p>Invalid sidebar configuration.</p>

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import { BriefcaseIcon, HomeIcon } from "@lucide/svelte";
import { Button } from "../../ui/button";
import { Input } from "../../ui/input";
import { fly } from "svelte/transition";
import { circInOut } from "svelte/easing";
import { search, type Feature } from "$lib/services/Search";
import { view } from "../sidebar.svelte";
import { map, pin } from "../map.svelte";
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;
}
let typedText = $state("");
let loading = $state(false);
let searchText = $derived.by(debounce(() => typedText, 300));
let searchResults: Feature[] = $state([]);
$effect(() => {
if(!searchText) {
searchResults = [];
return;
}
if (searchText.length > 0) {
loading = true;
search(searchText, 0, 0).then(results => {
searchResults = results;
loading = false;
});
} else {
searchResults = [];
}
});
$inspect("searchText", searchText);
</script>
<div id="search-progress" style="min-height: calc(3px + 3px); width: 100%; min-height: 3ch;">
{#if loading}
LOADING
{/if}
</div>
<Input placeholder="Search..." bind:value={typedText} class="mb-2" />
{#if searchResults.length == 0}
<div id="saved" in:fly={{ y: 20, duration: 200, easing: circInOut }}>
<Button variant="secondary" class="flex-1" onclick={() => {
const home = localStorage.getItem("saved.home");
if(!home) {
alert("No home location saved.");
return;
}
const {lat, lon} = JSON.parse(home);
pin.dropPin(lat, lon);
pin.showInfo();
map.value?.flyTo({
center: [lon, lat],
zoom: 19
});
}}>
<HomeIcon />
Home
</Button>
<Button variant="secondary" class="flex-1" onclick={() => {
const work = localStorage.getItem("saved.work");
if(!work) {
alert("No home location saved.");
return;
}
const {lat, lon} = JSON.parse(work);
pin.dropPin(lat, lon);
pin.showInfo();
map.value?.flyTo({
center: [lon, lat],
zoom: 19
});
}}>
<BriefcaseIcon />
Work
</Button>
</div>
{:else}
<div id="results" in:fly={{ y: 20, duration: 200, easing: circInOut }}>
{#each searchResults as result}
<Button variant="secondary" class="flex-1" onclick={() => {
// view.switch("info", { feature: result });
pin.dropPin(result.geometry.coordinates[1], result.geometry.coordinates[0]);
pin.showInfo();
map.value?.flyTo({
center: [result.geometry.coordinates[0], result.geometry.coordinates[1]],
zoom: 19
});
}}>
{result.properties.name}
</Button>
{/each}
</div>
{/if}
<style>
#saved {
display: flex;
gap: 0.5rem;
/* justify-content: space-evenly; */
width: 100%;
max-width: 100%;
}
</style>

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import { BriefcaseIcon, HomeIcon } from "@lucide/svelte";
import { Button } from "../../ui/button";
import { fly } from "svelte/transition";
import { circInOut } from "svelte/easing";
import { map, pin } from "../map.svelte";
import VehicleSelector from "../VehicleSelector.svelte";
import Post from "../Post.svelte";
</script>
<div id="saved" class="mt-2 mb-2" in:fly={{ y: 20, duration: 200, easing: circInOut }}>
<Button variant="secondary" class="flex-1" onclick={() => {
const home = localStorage.getItem("saved.home");
if(!home) {
alert("No home location saved.");
return;
}
const {lat, lon} = JSON.parse(home);
pin.dropPin(lat, lon);
pin.showInfo();
map.value?.flyTo({
center: [lon, lat],
zoom: 19
});
}}>
<HomeIcon />
Home
</Button>
<Button variant="secondary" class="flex-1" onclick={() => {
const work = localStorage.getItem("saved.work");
if(!work) {
alert("No home location saved.");
return;
}
const {lat, lon} = JSON.parse(work);
pin.dropPin(lat, lon);
pin.showInfo();
map.value?.flyTo({
center: [lon, lat],
zoom: 19
});
}}>
<BriefcaseIcon />
Work
</Button>
</div>
<VehicleSelector />
<div>
<h2>In your area</h2>
<Post />
</div>
<style>
#saved {
display: flex;
gap: 0.5rem;
/* justify-content: space-evenly; */
width: 100%;
max-width: 100%;
}
</style>

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import { CircleArrowDown, CircleDotIcon, StarIcon } from "@lucide/svelte";
import LocationSelect from "../LocationSelect.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import SidebarHeader from "./SidebarHeader.svelte";
import { Button } from "$lib/components/ui/button";
import { createValhallaRequest } from "$lib/vehicles/ValhallaVehicles";
import { drawAllRoutes, fetchRoute, removeAllRoutes, zoomToPoints } from "$lib/services/navigation/routing.svelte";
import { ROUTING_SERVER } from "$lib/services/hosts";
import { map } from "../map.svelte";
import { view } from "../sidebar.svelte";
import { DefaultVehicle, selectedVehicle } from "$lib/vehicles/vehicles.svelte";
let { from, to }: {
from?: string,
to?: string
} = $props();
let fromLocation = $state(from || "");
let toLocation = $state(to || "");
let routes: Trip[] | null = $state(null);
function formatTime(seconds: number) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
// const secs = seconds % 60;
return `${hours != 0 ? hours + "h " : ""}${minutes}min`;
}
</script>
<SidebarHeader onback={() => {
removeAllRoutes();
}}>
Route
</SidebarHeader>
<span>Driving with <strong>{(selectedVehicle() ?? DefaultVehicle).name}</strong></span>
<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} />
</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} />
</div>
</div>
<Button onclick={async () => {
const FROM: WorldLocation = fromLocation == "home" ? JSON.parse(localStorage.getItem("saved.home")!)
: fromLocation == "work" ? JSON.parse(localStorage.getItem("saved.work")!)
: {
lat: parseFloat(fromLocation.split(",")[0]),
lon: parseFloat(fromLocation.split(",")[1])
};
const TO: WorldLocation = toLocation == "home" ? JSON.parse(localStorage.getItem("saved.home")!)
: toLocation == "work" ? JSON.parse(localStorage.getItem("saved.work")!)
: {
lat: parseFloat(toLocation.split(",")[0]),
lon: parseFloat(toLocation.split(",")[1])
};
const req = createValhallaRequest(selectedVehicle() ?? DefaultVehicle, [FROM, TO]);
const res = await fetchRoute(ROUTING_SERVER, req);
routes = [
res.trip,
];
for(const alternate of res.alternates) {
if(alternate.trip) {
routes.push(alternate.trip);
}
}
drawAllRoutes(routes);
zoomToPoints(FROM, TO, map.value!);
}}>Calculate</Button>
{#if routes}
<div class="mt-2 flex gap-2 flex-col">
{#each routes as route, i (route?.summary?.length)}
<Button variant="secondary" onclick={() => {
view.switch("trip", { route });
}}>
{#if i == 0}
<StarIcon />
{/if}
{route.summary.length}km - {formatTime(Math.round(route.summary.time))}
</Button>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import Button from "$lib/components/ui/button/button.svelte";
import { circInOut } from "svelte/easing";
import { fly } from "svelte/transition";
import { map, pin } from "../map.svelte";
import type { Feature } from "$lib/services/Search";
import SidebarHeader from "./SidebarHeader.svelte";
import { searchbar } from "../sidebar.svelte";
let { results, query }: {
results: Feature[],
query: string
} = $props();
</script>
<SidebarHeader onback={() => {
searchbar.text = "";
}}>
Search Results
</SidebarHeader>
<div id="results" class="mt-2" in:fly={{ y: 20, duration: 200, easing: circInOut }}>
{#each results as result}
<Button variant="secondary" class="flex-1" onclick={() => {
// view.switch("info", { feature: result });
pin.dropPin(result.geometry.coordinates[1], result.geometry.coordinates[0]);
pin.showInfo();
map.value?.flyTo({
center: [result.geometry.coordinates[0], result.geometry.coordinates[1]],
zoom: 19
});
}}>
{result.properties.name}
</Button>
{/each}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import Button from "$lib/components/ui/button/button.svelte";
import type { Snippet } from "svelte";
import { view } from "../sidebar.svelte";
let { children, onback }: {
children: Snippet,
onback?: () => void,
} = $props();
</script>
<div class="flex gap-2 items-center mb-2">
<Button variant="outline" onclick={() => {
view.back();
if (onback) {
onback();
}
}}>&lt;</Button>
<h2 class="text-lg font-bold flex gap-2 items-center">{@render children?.()}</h2>
</div>

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import { onMount } from "svelte";
import SidebarHeader from "./SidebarHeader.svelte";
import { drawRoute, removeAllRoutes, startRoute } from "$lib/services/navigation/routing.svelte";
import { Button } from "$lib/components/ui/button";
import { RouteIcon, SaveIcon, SendIcon } from "@lucide/svelte";
import { map } from "../map.svelte";
let { route }: {
route: Trip
} = $props();
onMount(() => {
removeAllRoutes();
drawRoute(route);
})
</script>
<SidebarHeader onback={() => {
removeAllRoutes();
}}>
Trip Details
</SidebarHeader>
<div id="actions" class="flex gap-2">
<Button onclick={async () => {
await startRoute(route);
requestAnimationFrame(() => {
map.updateMapPadding();
})
}}>
<RouteIcon />
Start Navigation
</Button>
<Button variant="secondary" disabled>
<SaveIcon />
Save
</Button>
<Button variant="secondary" disabled>
<SendIcon />
Send
</Button>
</div>
<div class="flex flex-col gap-2 mt-2">
{#each route.legs[0].maneuvers as maneuver}
<li>
{maneuver.instruction}
</li>
{/each}
</div>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.FallbackProps = $props();
</script>
<AvatarPrimitive.Fallback
bind:ref
data-slot="avatar-fallback"
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.ImageProps = $props();
</script>
<AvatarPrimitive.Image
bind:ref
data-slot="avatar-image"
class={cn("aspect-square size-full", className)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.RootProps = $props();
</script>
<AvatarPrimitive.Root
bind:ref
data-slot="avatar"
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
{...restProps}
/>

View File

@@ -0,0 +1,13 @@
import Root from "./avatar.svelte";
import Image from "./avatar-image.svelte";
import Fallback from "./avatar-fallback.svelte";
export {
Root,
Image,
Fallback,
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback,
};

View File

@@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>

View File

@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View File

@@ -0,0 +1,80 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
outline:
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("font-semibold leading-none", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import type { Command as CommandPrimitive, Dialog as DialogPrimitive } from "bits-ui";
import type { Snippet } from "svelte";
import Command from "./command.svelte";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
open = $bindable(false),
ref = $bindable(null),
value = $bindable(""),
title = "Command Palette",
description = "Search for a command to run",
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.RootProps> &
WithoutChildrenOrChild<CommandPrimitive.RootProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
title?: string;
description?: string;
} = $props();
</script>
<Dialog.Root bind:open {...restProps}>
<Dialog.Header class="sr-only">
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>{description}</Dialog.Description>
</Dialog.Header>
<Dialog.Content class="overflow-hidden p-0" {portalProps}>
<Command
class="**:data-[slot=command-input-wrapper]:h-12 [&_[data-command-group]:not([hidden])_~[data-command-group]]:pt-0 [&_[data-command-group]]:px-2 [&_[data-command-input-wrapper]_svg]:h-5 [&_[data-command-input-wrapper]_svg]:w-5 [&_[data-command-input]]:h-12 [&_[data-command-item]]:px-2 [&_[data-command-item]]:py-3 [&_[data-command-item]_svg]:h-5 [&_[data-command-item]_svg]:w-5"
{...restProps}
bind:value
bind:ref
{children}
/>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.EmptyProps = $props();
</script>
<CommandPrimitive.Empty
bind:ref
data-slot="command-empty"
class={cn("py-6 text-center text-sm", className)}
{...restProps}
/>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { Command as CommandPrimitive, useId } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
heading,
value,
...restProps
}: CommandPrimitive.GroupProps & {
heading?: string;
} = $props();
</script>
<CommandPrimitive.Group
bind:ref
data-slot="command-group"
class={cn("text-foreground overflow-hidden p-1", className)}
value={value ?? heading ?? `----${useId()}`}
{...restProps}
>
{#if heading}
<CommandPrimitive.GroupHeading
class="text-muted-foreground px-2 py-1.5 text-xs font-medium"
>
{heading}
</CommandPrimitive.GroupHeading>
{/if}
<CommandPrimitive.GroupItems {children} />
</CommandPrimitive.Group>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import SearchIcon from "@lucide/svelte/icons/search";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value = $bindable(""),
...restProps
}: CommandPrimitive.InputProps = $props();
</script>
<div class="flex h-9 items-center gap-2 border-b px-3" data-slot="command-input-wrapper">
<SearchIcon class="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
class={cn(
"placeholder:text-muted-foreground outline-hidden flex h-10 w-full rounded-md bg-transparent py-3 text-sm disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:ref
{...restProps}
bind:value
/>
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.ItemProps = $props();
</script>
<CommandPrimitive.Item
bind:ref
data-slot="command-item"
class={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.LinkItemProps = $props();
</script>
<CommandPrimitive.LinkItem
bind:ref
data-slot="command-item"
class={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.ListProps = $props();
</script>
<CommandPrimitive.List
bind:ref
data-slot="command-list"
class={cn("max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden", className)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.SeparatorProps = $props();
</script>
<CommandPrimitive.Separator
bind:ref
data-slot="command-separator"
class={cn("bg-border -mx-1 h-px", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="command-shortcut"
class={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...restProps}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(""),
class: className,
...restProps
}: CommandPrimitive.RootProps = $props();
</script>
<CommandPrimitive.Root
bind:value
bind:ref
data-slot="command"
class={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,40 @@
import { Command as CommandPrimitive } from "bits-ui";
import Root from "./command.svelte";
import Dialog from "./command-dialog.svelte";
import Empty from "./command-empty.svelte";
import Group from "./command-group.svelte";
import Item from "./command-item.svelte";
import Input from "./command-input.svelte";
import List from "./command-list.svelte";
import Separator from "./command-separator.svelte";
import Shortcut from "./command-shortcut.svelte";
import LinkItem from "./command-link-item.svelte";
const Loading = CommandPrimitive.Loading;
export {
Root,
Dialog,
Empty,
Group,
Item,
LinkItem,
Input,
List,
Separator,
Shortcut,
Loading,
//
Root as Command,
Dialog as CommandDialog,
Empty as CommandEmpty,
Group as CommandGroup,
Item as CommandItem,
LinkItem as CommandLinkItem,
Input as CommandInput,
List as CommandList,
Separator as CommandSeparator,
Shortcut as CommandShortcut,
Loading as CommandLoading,
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
} = $props();
</script>
<Dialog.Portal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...restProps}
>
{@render children?.()}
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden absolute right-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</Dialog.Portal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn("text-lg font-semibold leading-none", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />

View File

@@ -0,0 +1,37 @@
import { Dialog as DialogPrimitive } from "bits-ui";
import Title from "./dialog-title.svelte";
import Footer from "./dialog-footer.svelte";
import Header from "./dialog-header.svelte";
import Overlay from "./dialog-overlay.svelte";
import Content from "./dialog-content.svelte";
import Description from "./dialog-description.svelte";
import Trigger from "./dialog-trigger.svelte";
import Close from "./dialog-close.svelte";
const Root = DialogPrimitive.Root;
const Portal = DialogPrimitive.Portal;
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
let { ref = $bindable(null), ...restProps }: DrawerPrimitive.CloseProps = $props();
</script>
<DrawerPrimitive.Close bind:ref data-slot="drawer-close" {...restProps} />

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
import DrawerOverlay from "./drawer-overlay.svelte";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
portalProps,
children,
...restProps
}: DrawerPrimitive.ContentProps & {
portalProps?: DrawerPrimitive.PortalProps;
} = $props();
</script>
<DrawerPrimitive.Portal {...portalProps}>
<DrawerOverlay />
<DrawerPrimitive.Content
bind:ref
data-slot="drawer-content"
class={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...restProps}
>
<div
class="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block"
></div>
{@render children?.()}
</DrawerPrimitive.Content>
</DrawerPrimitive.Portal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DrawerPrimitive.DescriptionProps = $props();
</script>
<DrawerPrimitive.Description
bind:ref
data-slot="drawer-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="drawer-footer"
class={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="drawer-header"
class={cn("flex flex-col gap-1.5 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
let {
shouldScaleBackground = true,
open = $bindable(false),
activeSnapPoint = $bindable(null),
...restProps
}: DrawerPrimitive.RootProps = $props();
</script>
<DrawerPrimitive.NestedRoot {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DrawerPrimitive.OverlayProps = $props();
</script>
<DrawerPrimitive.Overlay
bind:ref
data-slot="drawer-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DrawerPrimitive.TitleProps = $props();
</script>
<DrawerPrimitive.Title
bind:ref
data-slot="drawer-title"
class={cn("text-foreground font-semibold", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
let { ref = $bindable(null), ...restProps }: DrawerPrimitive.TriggerProps = $props();
</script>
<DrawerPrimitive.Trigger bind:ref data-slot="drawer-trigger" {...restProps} />

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
let {
shouldScaleBackground = true,
open = $bindable(false),
activeSnapPoint = $bindable(null),
...restProps
}: DrawerPrimitive.RootProps = $props();
</script>
<DrawerPrimitive.Root {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />

View File

@@ -0,0 +1,41 @@
import { Drawer as DrawerPrimitive } from "vaul-svelte";
import Root from "./drawer.svelte";
import Content from "./drawer-content.svelte";
import Description from "./drawer-description.svelte";
import Overlay from "./drawer-overlay.svelte";
import Footer from "./drawer-footer.svelte";
import Header from "./drawer-header.svelte";
import Title from "./drawer-title.svelte";
import NestedRoot from "./drawer-nested.svelte";
import Close from "./drawer-close.svelte";
import Trigger from "./drawer-trigger.svelte";
const Portal: typeof DrawerPrimitive.Portal = DrawerPrimitive.Portal;
export {
Root,
NestedRoot,
Content,
Description,
Overlay,
Footer,
Header,
Title,
Trigger,
Portal,
Close,
//
Root as Drawer,
NestedRoot as DrawerNestedRoot,
Content as DrawerContent,
Description as DrawerDescription,
Overlay as DrawerOverlay,
Footer as DrawerFooter,
Header as DrawerHeader,
Title as DrawerTitle,
Trigger as DrawerTrigger,
Portal as DrawerPortal,
Close as DrawerClose,
};

View File

@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot="input"
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot="input"
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}

View File

@@ -0,0 +1,17 @@
import { Popover as PopoverPrimitive } from "bits-ui";
import Content from "./popover-content.svelte";
import Trigger from "./popover-trigger.svelte";
const Root = PopoverPrimitive.Root;
const Close = PopoverPrimitive.Close;
export {
Root,
Content,
Trigger,
Close,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
};

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Popover as PopoverPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = "center",
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: PopoverPrimitive.PortalProps;
} = $props();
</script>
<PopoverPrimitive.Portal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-popover-content-transform-origin) outline-hidden z-50 w-72 rounded-md border p-4 shadow-md",
className
)}
{...restProps}
/>
</PopoverPrimitive.Portal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Popover as PopoverPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
}: PopoverPrimitive.TriggerProps = $props();
</script>
<PopoverPrimitive.Trigger
bind:ref
data-slot="popover-trigger"
class={cn("", className)}
{...restProps}
/>

View File

@@ -0,0 +1,37 @@
import { Select as SelectPrimitive } from "bits-ui";
import Group from "./select-group.svelte";
import Label from "./select-label.svelte";
import Item from "./select-item.svelte";
import Content from "./select-content.svelte";
import Trigger from "./select-trigger.svelte";
import Separator from "./select-separator.svelte";
import ScrollDownButton from "./select-scroll-down-button.svelte";
import ScrollUpButton from "./select-scroll-up-button.svelte";
import GroupHeading from "./select-group-heading.svelte";
const Root = SelectPrimitive.Root;
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
};

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: SelectPrimitive.PortalProps;
} = $props();
</script>
<SelectPrimitive.Portal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
data-slot="select-content"
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"h-(--bits-select-anchor-height) min-w-(--bits-select-anchor-width) w-full scroll-my-1 p-1"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</SelectPrimitive.GroupHeading>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group data-slot="select-group" {...restProps} />

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute right-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronDownIcon class="size-4" />
</SelectPrimitive.ScrollDownButton>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronUpIcon class="size-4" />
</SelectPrimitive.ScrollUpButton>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...restProps}
/>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 shadow-xs flex w-fit select-none items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
</SelectPrimitive.Trigger>

View File

@@ -0,0 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot="separator"
class={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
className
)}
{...restProps}
/>

68
src/lib/services/MTSK.ts Normal file
View File

@@ -0,0 +1,68 @@
import { LNV_SERVER } from "./hosts";
import { hasCapability } from "./lnv";
type Station = {
id: string;
name: string;
brand: string;
street: string;
place: string;
lat: number;
lng: number;
dist: number;
diesel: number | false;
e5: number | false;
e10: number | false;
isOpen: boolean;
houseNumber: string;
postCode: number;
}
type StationsResponse = {
ok: boolean;
license: "CC BY 4.0 - https:\/\/creativecommons.tankerkoenig.de";
data: "MTS-K";
status: string;
stations: Station[];
};
type StationDetails = {
openingTimes: StationOpeningTime[];
overrides: string[];
wholeDay: boolean;
} & Station;
type StationOpeningTime = {
text: string;
start: string;
end: string;
};
type StationDetailsResponse = {
ok: boolean;
license: "CC BY 4.0 - https:\/\/creativecommons.tankerkoenig.de";
data: "MTS-K";
status: string;
station: StationDetails;
};
export async function getStations(lat: number, lon: number): Promise<StationsResponse> {
if(!await hasCapability("fuel")) {
throw new Error("Fuel capability is not available");
}
return await fetch(`${LNV_SERVER}/fuel/list?lat=${lat}&lng=${lon}&rad=1&sort=dist&type=all`).then(res => res.json());
}
export async function getPrices(id: string) { // TODO: add type
if(!await hasCapability("fuel")) {
throw new Error("Fuel capability is not available");
}
return await fetch(`${LNV_SERVER}/fuel/prices?ids=${id}`).then(res => res.json());
}
export async function getStationDetails(id: string): Promise<StationDetailsResponse> {
if(!await hasCapability("fuel")) {
throw new Error("Fuel capability is not available");
}
return await fetch(`${LNV_SERVER}/fuel/detail?id=${id}`).then(res => res.json());
}

View File

@@ -0,0 +1,65 @@
import { OVERPASS_SERVER } from "./hosts";
export type OverpassResult = {
elements: OverpassElement[];
};
export type OverpassElement = {
type: "node" | "way" | "relation";
id: number;
tags: Record<string, string>;
lat?: number; // Only for nodes
lon?: number; // Only for nodes
nodes?: number[]; // Only for ways
center?: {
lat: number; // Only for relations
lon: number; // Only for relations
};
};
/**
[out:json];
{{geocodeArea:<City>}}->.searchArea;
// nwr(area.searchArea);
(
node(area.searchArea)["amenity"]["name"];
way(area.searchArea)["amenity"]["name"];
relation(area.searchArea)["amenity"]["name"];
node(area.searchArea)["shop"]["name"];
way(area.searchArea)["shop"]["name"];
relation(area.searchArea)["shop"]["name"];
node(area.searchArea)["building"]["building"!="garage"];
way(area.searchArea)["building"]["building"!="garage"];
node(area.searchArea)["amenity"="parking"];
way(area.searchArea)["amenity"="parking"];
);
out geom;
[out:json];
{{geocodeArea:<City>}};
out geom;
*/
export async function fetchPOI(
lat: number,
lon: number,
radius: number,
) {
return await fetch(OVERPASS_SERVER, {
method: "POST",
body: `[out:json];
(
node(around:${radius}, ${lat}, ${lon})["amenity"]["name"];
way(around:${radius}, ${lat}, ${lon})["amenity"]["name"];
relation(around:${radius}, ${lat}, ${lon})["amenity"]["name"];
node(around:${radius}, ${lat}, ${lon})["shop"]["name"];
way(around:${radius}, ${lat}, ${lon})["shop"]["name"];
relation(around:${radius}, ${lat}, ${lon})["shop"]["name"];
node(around:${radius}, ${lat}, ${lon})["building"]["building"!="garage"];
way(around:${radius}, ${lat}, ${lon})["building"]["building"!="garage"];
node(around:${radius}, ${lat}, ${lon})["amenity"="parking"];
way(around:${radius}, ${lat}, ${lon})["amenity"="parking"];
);
out center tags;`
}).then(res => res.json() as Promise<OverpassResult>);
}

View File

@@ -0,0 +1,76 @@
// import { Contacts } from "@capacitor-community/contacts";
import { SEARCH_SERVER } from "./hosts";
// import { Capacitor } from "@capacitor/core";
export type Feature = {
type: "Feature",
geometry: {
coordinates: [number, number],
type: "Point"
},
properties: {
osm_key: string;
osm_value: string;
osm_id: number,
city: string,
country: string,
name: string,
street: string,
housenumber: string,
type: string,
// There is more, but not needed atm
}
}
export async function searchPlaces(query: string, lat: number, lon: number): Promise<Feature[]> {
const res = await fetch(SEARCH_SERVER + "/api/?q=" + query + "&lat=" + lat + "&lon=" + lon).then((res) => res.json());
return res.features;
}
export async function reverseGeocode(coord: WorldLocation): Promise<Feature[]> {
const res = await fetch(SEARCH_SERVER + "/reverse?lat=" + coord.lat + "&lon=" + coord.lon).then((res) => res.json());
return res.features;
}
export async function search(query: string, lat: number, lon: number): Promise<Feature[]> {
if(query.startsWith("@")) {
// if(Capacitor.isNativePlatform()) {
// return await searchContacts(query, lat, lon);
// }
return [];
} else {
return await searchPlaces(query, lat, lon);
}
}
// export async function searchContacts(query: string, lat: number, lon: number): Promise<Feature[]> {
// console.log("Fetching contacts");
// const allContacts = await Contacts.getContacts({
// projection: {
// name: true,
// postalAddresses: true
// }
// });
// console.log("Got contacts");
// console.log(allContacts.contacts.map((contact) => contact.name?.display + " " + contact.postalAddresses?.[0]?.street));
// const contacts = allContacts.contacts.filter((contact) => {
// return contact.name?.display?.toLowerCase().includes(query.substring(1).toLowerCase());
// });
// console.log(contacts.map((contact) => contact.name?.display + " " + contact.postalAddresses?.[0]?.street));
// const res = [];
// for (const contact of contacts) {
// const address = contact.postalAddresses?.[0];
// if (!address) continue;
// console.log("Fetching addr for " + contact.name?.display);
// // Search for the address
// const addressString = (address.street || "") + " " + (address.city || "") + " " + (address.country || "");
// const addressRes = await searchPlaces(addressString, lat, lon);
// console.log(addressRes);
// if (addressRes.length > 0) {
// const feature = addressRes[0];
// feature.properties.name = contact.name?.display || "";
// res.push(feature);
// }
// }
// return res;
// }

View File

@@ -0,0 +1,7 @@
export const MAP_SERVER = "https://tiles.openfreemap.org/styles/liberty";
// export const MAP_SERVER = "https://tiles.map.picoscratch.de/styles/ofm/liberty.json";
export const ROUTING_SERVER = "https://valhalla1.openstreetmap.de/";
// export const ROUTING_SERVER = "https://routing.map.picoscratch.de";
export const SEARCH_SERVER = "https://photon.komoot.io/";
export const OVERPASS_SERVER = "https://overpass-api.de/api/interpreter";
export const LNV_SERVER = "http://localhost:3000/api";

100
src/lib/services/lnv.ts Normal file
View File

@@ -0,0 +1,100 @@
import { createAuthClient } from "better-auth/client";
import { usernameClient } from "better-auth/client/plugins";
import { LNV_SERVER } from "./hosts";
import type { User } from "better-auth";
export const authClient = createAuthClient({
baseURL: LNV_SERVER + "/auth",
plugins: [
usernameClient()
]
})
export type Capabilities = ("auth" | "reviews" | "ai" | "fuel")[];
export let capabilities: Capabilities = [];
export async function fetchConfig() {
const res = await fetch(LNV_SERVER + "/config");
if (!res.ok) {
throw new Error(`Failed to fetch capabilities: ${res.statusText}`);
}
const data = await res.json();
return data as { name: string; version: string; capabilities: Capabilities };
}
export async function getCapabilities() {
if (capabilities.length === 0) {
const config = await fetchConfig();
capabilities = config.capabilities;
}
return capabilities;
}
export async function hasCapability(capability: Capabilities[number]): Promise<boolean> {
const caps = await getCapabilities();
return caps.includes(capability);
}
export type Review = {
user_id: string;
username: string;
rating: number;
comment: string;
}
export async function getReviews(location: WorldLocation) {
if(!await hasCapability("reviews")) {
throw new Error("Reviews capability is not available");
}
const res = await fetch(LNV_SERVER + `/reviews?lat=${location.lat}&lon=${location.lon}`);
if (!res.ok) {
throw new Error(`Failed to fetch reviews: ${res.statusText}`);
}
const data = await res.json();
return data as Review[];
}
export async function postReview(location: WorldLocation, review: Omit<Review, 'user_id' | 'username'>) {
if(!await hasCapability("reviews")) {
throw new Error("Reviews capability is not available");
}
const session = await authClient.getSession();
if (session.error) {
throw new Error("User is not authenticated");
}
const res = await fetch(LNV_SERVER + `/review`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${session.data?.session.token}`
},
body: JSON.stringify({
...review,
lat: location.lat,
lon: location.lon
})
});
if (!res.ok) {
throw new Error(`Failed to post review: ${res.statusText}`);
}
return await res.json();
}
export async function ai(query: string, location?: WorldLocation) {
if(!await hasCapability("ai")) {
throw new Error("AI capability is not available");
}
const res = await fetch(LNV_SERVER + `/ai`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
text: query,
coords: location
})
});
if (!res.ok) {
throw new Error(`Failed to get AI response: ${res.statusText}`);
}
return await res.text();
}

View File

@@ -0,0 +1,70 @@
<script lang="ts">
let { lane }: { lane: Lane } = $props();
const knownDirections = [2, 4, 8, 16, 32, 64, 128, 256, 512, 1024];
async function fetchImage(bit: number) {
if (knownDirections.includes(bit)) {
return await fetch(`/img/lanes/${bit}.svg`).then(res => res.text());
} else {
return `<span>${bit}</span>`;
}
}
function loadImage(node: HTMLElement, bit: number) {
fetchImage(bit).then(img => {
node.innerHTML = img;
});
}
</script>
<div class="lane">
{#each Array(10).fill(0).map((_, i) => 1 << i) as bit}
{#if lane.directions & bit}
<div
class="lane-image {lane.valid & bit ? 'valid' : ''} {lane.active & bit ? 'active' : ''}"
use:loadImage={bit}
></div>
{/if}
{/each}
</div>
<style>
.lane {
display: flex;
}
:global(.lane svg) {
width: 30px;
}
:global(.lane svg path) {
stroke: #6b6b6b;
stroke-width: 3;
}
:global(.lane .active svg path) {
stroke: #fff;
}
:global(.lane .valid svg path) {
stroke: #c0c0c0;
}
:global(.lane-image > span) {
font-size: 2rem;
font-weight: bold;
color: #812020;
}
:global(.lane-image.active > span) {
font-size: 2rem;
font-weight: bold;
color: #ff0000;
}
:global(.lane-image.valid > span) {
font-size: 2rem;
font-weight: bold;
color: #cc2c2c;
}
</style>

View File

@@ -0,0 +1,35 @@
const knownDirections = [2, 4, 8, 16, 32, 64, 128, 256, 512, 1024];
export async function displayLane(lane: Lane) {
const laneDiv = document.createElement("div");
laneDiv.className = "lane";
// Fetch all direction images from the bitmask
for (let i = 0; i < 10; i++) {
if (lane.directions & (1 << i)) {
const bit = 1 << i;
// Check if the bit is in the known directions
let img = "";
if (knownDirections.includes(bit)) {
img = await fetch(`/img/lanes/${bit}.svg`).then(res => res.text());
} else {
img = `<span>${bit}</span>`;
}
const isValid = lane.valid & bit;
const isActive = lane.active & bit;
// Create a DOM element
const laneImage = document.createElement("div");
laneImage.className = "lane-image";
laneImage.innerHTML = img;
// Add the class to the laneDiv
if (isValid) {
laneImage.classList.add("valid");
}
if (isActive) {
laneImage.classList.add("active");
}
// Add the lane image to the lane div
laneDiv.appendChild(laneImage);
}
}
return laneDiv;
}

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import LaneDisplay from "./LaneDisplay.svelte";
let { lanes }: { lanes: Lane[] | undefined } = $props();
</script>
{#if lanes}
<div id="lanes">
{#each lanes as lane}
<LaneDisplay {lane} />
{/each}
</div>
{/if}
<style>
#lanes {
background-color: #1a1a1a;
padding: 10px;
display: flex;
justify-content: space-around;
}
</style>

View File

@@ -0,0 +1,46 @@
export const maneuverTypes = [
"none",
"start",
"startRight",
"startLeft",
"destination",
"destinationRight",
"destinationLeft",
"becomes",
"continue",
"slightRight",
"right",
"sharpRight",
"uTurnRight",
"uTurnLeft",
"sharpLeft",
"left",
"slightLeft",
"rampStraight",
"rampRight",
"rampLeft",
"exitRight",
"exitLeft",
"stayStraight",
"stayRight",
"stayLeft",
"merge",
"roundaboutEnter",
"roundaboutExit",
"ferryEnter",
"ferryExit",
"transit",
"transitTransfer",
"transitRemainsOn",
"transitConnectionStart",
"transitConnectionTransfer",
"transitConnectionDestination",
"postTransitConnectionDestination",
"mergeRight",
"mergeLeft",
"elevatorEnter",
"stepsEnter",
"escalatorEnter",
"buildingEnter",
"buildingExit",
];

View File

@@ -0,0 +1,345 @@
// @ts-nocheck
import maplibregl from "maplibre-gl";
// import { maneuverTypes } from "./Maneuver";
import { hideRouteStatus, updateRouteStatus } from "../../components/routestatus";
import { NavigationLayer, removeAllNavigationLayers, updateNavigationLayer } from "./NavigationLayers";
import { updateMapPadding } from "../../main";
import say from "../TTS";
import { ROUTING_SERVER } from "../servers";
import { createValhallaRequest } from "./ValhallaRequest";
import { Vehicle } from "../../components/vehicles";
import { KeepAwake } from "@capacitor-community/keep-awake";
import { getCurrentViewName, getSidebarView } from "../../components/sidebar/SidebarRegistry";
// import { displayLane } from "./LaneDisplay";
export async function fetchRoute(vehicle: Vehicle, from: WorldLocation, to: WorldLocation): Promise<RouteResult> {
// const req = {
// locations: [
// from,
// to,
// ],
// costing: "motor_scooter",
// units: "kilometers",
// language: "de-DE",
// alternates: 2,
// costing_options: {
// "motor_scooter": {
// top_speed: 45,
// },
// },
// };
const req = createValhallaRequest(vehicle, [from, to]);
try {
const res = await fetch(
ROUTING_SERVER + "/route?json=" + JSON.stringify(req),
).then((res) => res.json());
console.log(res);
return res;
} catch (e) {
console.error(e);
alert(e);
throw new Error("Error fetching route");
}
}
let currentTrip: Trip | null = null;
let instructionIdx = 0;
let currentManeuver: Maneuver | null = null;
let fromMarker: maplibregl.Marker;
let toMarker: maplibregl.Marker;
export function getCurrentTrip() {
return currentTrip;
}
export function getCurrentManeuver() {
return currentManeuver;
}
function drawRoute(trip: Trip, name: NavigationLayer) {
const geometry = decodePolyline(trip.legs[0].shape);
console.log(geometry);
updateNavigationLayer(name, geometry);
}
export async function findRoute(vehicle: Vehicle, from: WorldLocation, to: WorldLocation): Promise<Trip[]> {
fromMarker = new maplibregl.Marker()
.setLngLat([from.lon, from.lat])
.addTo(window.glmap);
toMarker = new maplibregl.Marker()
.setLngLat([to.lon, to.lat])
.addTo(window.glmap);
const route = await fetchRoute(vehicle, from, to);
let routes = [route.trip];
if(route.alternates) {
for(let i = 0; i < route.alternates.length; i++) {
routes.push(route.alternates[i].trip);
}
}
drawAllRoutes(routes);
return routes;
}
export async function drawAllRoutes(trips: Trip[]) {
removeAllNavigationLayers();
// if (result.alternates) {
// for (let i = 0; i < result.alternates.length; i++) {
// // @ts-ignore
// drawRoute(result.alternates[i].trip, "al" + i);
// }
// }
// drawRoute(result.trip, "route");
for (let i = 1; i < trips.length; i++) {
// @ts-ignore
drawRoute(trips[i], "al" + (i - 1));
}
drawRoute(trips[0], "route");
}
export async function drawSingleRoute(trip: Trip) {
removeAllNavigationLayers();
drawRoute(trip, "route");
}
function getUserLocation(): WorldLocation {
// @ts-ignore
const lnglat = window.geolocate._userLocationDotMarker.getLngLat();
return { lat: lnglat.lat, lon: lnglat.lng };
}
// let putMarker = false;
let pastRoute: WorldLocation[] = [];
// Check if the location is on the line between from and to (3 meter tolerance)
function isOnLine(location: WorldLocation, from: WorldLocation, to: WorldLocation) {
// Convert the 6-meter tolerance to degrees (approximation)
const tolerance = 6 / 111320; // 1 degree latitude ≈ 111.32 km
// Calculate the vector components
const dx = to.lon - from.lon;
const dy = to.lat - from.lat;
// Calculate the projection of the location onto the line segment
const t = ((location.lon - from.lon) * dx + (location.lat - from.lat) * dy) / (dx * dx + dy * dy);
// Clamp t to the range [0, 1] to ensure the projection is on the segment
const clampedT = Math.max(0, Math.min(1, t));
// Calculate the closest point on the line segment
const closestPoint = {
lon: from.lon + clampedT * dx,
lat: from.lat + clampedT * dy,
};
// Calculate the distance from the location to the closest point
const distance = Math.sqrt(
Math.pow(location.lon - closestPoint.lon, 2) + Math.pow(location.lat - closestPoint.lat, 2)
);
// Check if the distance is within the tolerance
return distance <= tolerance;
}
function isOnShape(location: WorldLocation, shape: WorldLocation[]) {
// Check if the location is on the line between from and to (3 meter tolerance)
for (let i = 0; i < shape.length - 1; i++) {
if (isOnLine(location, shape[i], shape[i + 1])) {
return true;
}
}
return false;
}
let int: number | null = null;
export async function startNavigation(trip: Trip) {
console.log("Start navigation");
await KeepAwake.keepAwake();
document.querySelector<HTMLDivElement>("#map-overlay")!.style.display = "";
document.querySelector<HTMLBodyElement>("body")!.classList.add("isInTrip");
updateMapPadding();
instructionIdx = 0;
pastRoute = [];
// Delete all route and alternate route layers (if they exist)
removeAllNavigationLayers();
// Add this route to the layer
const geometry = decodePolyline(trip.legs[0].shape);
updateNavigationLayer("route", geometry);
// @ts-ignore The types are not correct
int = setInterval(() => {
if(instructionIdx != 0) {
// Only continue if the user location is at the end shape index of the current maneuver
if(currentManeuver == null) {
return;
}
const bgi = currentManeuver.begin_shape_index;
const location = getUserLocation();
const polyline = decodePolyline(trip.legs[0].shape);
// const begin = polyline[bgi];
// const distance = Math.sqrt(
// Math.pow(location.lat - begin.lat, 2) + Math.pow(location.lon - begin.lon, 2),
// );
// console.log(distance);
// // If within 3 meters of the end of the maneuver, go to the next maneuver
// if (distance > 0.00003) {
// return;
// }
// Check if rerouting is needed/the user is off the route
if (!isOnShape(location, polyline)) {
console.log("Off route!");
updateRouteStatus({
time: trip.summary.time,
distance: trip.summary.length,
currentManeuver: {
...currentManeuver,
instruction: "Off route!",
},
});
// TODO: Implement rerouting
} else {
updateRouteStatus({
time: trip.summary.time,
distance: trip.summary.length,
currentManeuver
});
}
// Check if the user finished the maneuver
if (!isOnShape(location, polyline.slice(bgi))) {
return;
}
// Update past route (for the past route line)
// It needs to basically connect to the now removed part of the route
// This is a bad example: pastRoute.push(...polyline.slice(0, bgi)); because it only adds a half of the route
// This is a better example:
// pastRoute.push(...polyline.slice(0, bgi + 1));
pastRoute = polyline.slice(0, bgi + 1);
updateNavigationLayer("route-past", pastRoute.flat())
// Remove from shape begin to end from the route line
const newShape = polyline.slice(bgi);
updateNavigationLayer("route", newShape);
}
instructionIdx++;
// instructionIdx = 6;
if (trip.legs[0].maneuvers.length <= instructionIdx) {
stopNavigation();
return;
}
const maneuver = trip.legs[0].maneuvers[instructionIdx];
updateRouteStatus({
time: trip.summary.time,
distance: trip.summary.length,
currentManeuver: trip.legs[0].maneuvers[instructionIdx],
}, maneuver.lanes);
currentManeuver = maneuver;
// document.querySelector<HTMLDivElement>("#lanes")!.innerHTML = "";
// if(currentManeuver.lanes) {
// console.log("Lanes: ", currentManeuver.lanes);
// for(let i = 0; i < currentManeuver.lanes.length; i++) {
// const lane = currentManeuver.lanes[i];
// displayLane(lane).then((laneDiv) => {
// // Add the lane div to the lanes container
// document.querySelector<HTMLDivElement>("#lanes")!.appendChild(laneDiv);
// });
// }
// }
// say(maneuver.instruction)
// Say the verbal post instruction of the previous maneuver
if (instructionIdx > 0) {
const prevManeuver = trip.legs[0].maneuvers[instructionIdx - 1];
if (prevManeuver.verbal_post_transition_instruction) {
console.log("Saying: " + prevManeuver.verbal_post_transition_instruction);
say(prevManeuver.verbal_post_transition_instruction);
}
}
}, 1000);
updateRouteStatus({
time: trip.summary.time,
distance: trip.summary.length,
currentManeuver: trip.legs[0].maneuvers[0],
}, trip.legs[0].maneuvers[0].lanes);
currentTrip = trip;
}
export async function stopNavigation() {
if(int) clearInterval(int);
await KeepAwake.allowSleep();
hideRouteStatus();
document.querySelector<HTMLBodyElement>("body")!.classList.remove("isInTrip");
updateMapPadding();
currentTrip = null;
currentManeuver = null;
removeAllNavigationLayers();
try {
fromMarker.remove();
toMarker.remove();
} catch (e) {}
getSidebarView(getCurrentViewName()!).switchTo();
}
export function kmToString(km: number) {
if (km < 1) {
return Math.round(km * 100) + "m";
}
return Math.round(km) + "km";
}
export function decodePolyline(encoded: string): WorldLocation[] {
let points = [];
let index = 0;
let len = encoded.length;
let lat = 0;
let lng = 0;
while (index < len) {
let shift = 0;
let result = 0;
let byte;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
let deltaLat = (result & 1) ? ~(result >> 1) : (result >> 1);
lat += deltaLat;
shift = 0;
result = 0;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
let deltaLng = (result & 1) ? ~(result >> 1) : (result >> 1);
lng += deltaLng;
// Convert the latitude and longitude to decimal format with six digits of precision
points.push({
lat: lat / 1000000, // Divide by 1,000,000 for six digits of precision
lon: lng / 1000000, // Divide by 1,000,000 for six digits of precision
});
}
return points;
}

View File

@@ -0,0 +1,462 @@
export type ValhallaCosting =
| "auto"
| "bicycle"
| "bus"
| "bikeshare"
| "truck"
| "taxi"
| "motor_scooter"
| "multimodal"
| "pedestrian";
export type ValhallaRequest = {
locations: WorldLocation[];
costing: ValhallaCosting;
units: "miles" | "kilometers";
language: string;
alternates: number;
costing_options: ValhallaCostingOptions;
turn_lanes: boolean;
};
export type GeneralCostingOptions = {
/**
* A penalty applied when transitioning between roads that do not have consistent
* naming - in other words, no road names in common. This penalty can be used to
* create simpler routes that tend to have fewer maneuvers or narrative guidance
* instructions.
* @default 5 seconds
*/
maneuver_penalty?: number;
/**
* A cost applied when a gate with undefined or private access is encountered.
* This cost is added to the estimated time / elapsed time.
* @default 30 seconds
*/
gate_cost?: number;
/**
* A penalty applied when a gate with no access information is on the road.
* @default 300 seconds
*/
gate_penalty?: number;
/**
* A penalty applied when entering an road which is only allowed to enter if
* necessary to reach the destination.
*/
destination_only_penalty?: number;
/**
* A cost applied when encountering an international border. This cost is added to the
* estimated and elapsed times.
* @default 600 seconds (10 minutes)
*/
country_crossing_cost?: number;
/**
* A penalty applied for a country crossing. This penalty can be used to create paths
* that avoid spanning country boundaries.
* @default 0
*/
country_crossing_penalty?: number;
/**
* A penalty applied for transition to generic service road.
* @default 0 for trucks, 15 for cars, buses, motor scooters and motorcycles
*/
service_penalty?: number;
};
export type AutomobileCostingOptions = {
/**
* A penalty applied when a gate or bollard with access=private is encountered.
* @default 450 seconds
*/
private_access_penalty?: number;
/**
* A cost applied when a toll booth is encountered. This cost is added to the
* estimated and elapsed times.
* @default 15 seconds
*/
toll_booth_cost?: number;
/**
* A penalty applied to the cost when a toll booth is encountered.
* This penalty can be used to create paths that avoid toll roads.
* @default 0
*/
toll_booth_penalty?: number;
/**
* A cost applied when entering a ferry. This cost is added to the estimated
* and elapsed times.
* @default 300 seconds (5 minutes)
*/
ferry_cost?: number;
/**
* This value indicates the willingness to take ferries. This is a range of values
* between 0 and 1. Values near 0 attempt to avoid ferries and values near 1 will
* favor ferries. Note that sometimes ferries are required to complete a route
* so values of 0 are not guaranteed to avoid ferries entirely.
* @default 0.5
*/
use_ferry?: number;
/**
* This value indicates the willingness to take highways. This is a range of values
* between 0 and 1. Values near 0 attempt to avoid highways and values near 1 will
* favor highways. Note that sometimes highways are required to complete a route
* so values of 0 are not guaranteed to avoid highways entirely.
* @default 1.0
*/
use_highways?: number;
/**
* This value indicates the willingness to take roads with tolls. This is a range of
* values between 0 and 1. Values near 0 attempt to avoid tolls and values near 1 will
* not attempt to avoid them. Note that sometimes roads with tolls are required to
* complete a route so values of 0 are not guaranteed to avoid them entirely.
* @default 0.5
*/
use_tolls?: number;
/**
* This value indicates the willingness to take living streets. This is a range of
* values between 0 and 1. Values near 0 attempt to avoid living streets and values
* near 1 will favor living streets. Note that sometimes living streets are required
* to complete a route so values of 0 are not guaranteed to avoid living streets entirely.
* @default 0 for trucks, 0.1 for cars, buses, motor scooters and motorcycles
*/
use_living_streets?: number;
/**
* This value indicates the willingness to take track roads. This is a range of values
* between 0 and 1. Values near 0 attempt to avoid tracks and values near 1 will favor
* tracks a little bit. Note that sometimes tracks are required to complete a route so values
* of 0 are not guaranteed to avoid tracks entirely.
* @default 0 for autos, 0.5 for motor scooters and motorcycles
*/
use_tracks?: number;
/**
* A factor that modifies (multiplies) the cost when generic service roads
* are encountered.
* @default 1
*/
service_factor?: number;
/**
* Changes the metric to quasi-shortest, i.e. purely distance-based costing.
* Note, this will disable all other costings & penalties. Also note, shortest will not
* disable hierarchy pruning, leading to potentially sub-optimal routes for some costing models.
* @default false
*/
shortest?: boolean;
/**
* A factor that allows controlling the contribution of distance and time to the route costs.
* The value is in range between 0 and 1, where 0 only takes time into account (default)
* and 1 only distance. A factor of 0.5 will weight them roughly equally.
* Note: this costing is currently only available for auto costing.
*/
use_distance?: boolean;
/**
* Disable hierarchies to calculate the actual optimal route.
* Note: This could be quite a performance drainer so there is a upper limit of distance.
* If the upper limit is exceeded, this option will always be false.
* @default false
*/
disable_hierarchy_pruning?: boolean;
/**
* Top speed the vehicle can go. Also used to avoid roads with higher speeds than this value.
* top_speed must be between 10 and 252 KPH.
* @default 120 KPH for truck, 140 KPH for auto and bus, 45 KPH for motor_scooter
*/
top_speed?: number;
/**
* Fixed speed the vehicle can go. Used to override the calculated speed.
* Can be useful if speed of vehicle is known. fixed_speed must be between 1 and 252 KPH.
* The default value is 0 KPH which disables fixed speed and falls back to the standard
* calculated speed based on the road attribution.
* @default 0
*/
fixed_speed?: number;
/**
* If set to true, ignores all closures, marked due to live traffic closures, during routing.
* Note: This option cannot be set if location.search_filter.exclude_closures is also specified
* in the request and will return an error if it is.
* @default false
*/
ignore_closures?: boolean;
/**
* A factor that penalizes the cost when traversing a closed edge
* (eg: if search_filter.exclude_closures is false for origin and/or destination location
* and the route starts/ends on closed edges). Its value can range from 1.0 - don't penalize closed edges,
* to 10.0 - apply high cost penalty to closed edges.
* Note: This factor is applicable only for motorized modes of transport, i.e auto, motorcycle, motor_scooter, bus, truck & taxi.
* @default 9.0
*/
closure_factor?: number;
/**
* If set to true, ignores any restrictions (e.g. turn/dimensional/conditional restrictions).
* Especially useful for matching GPS traces to the road network regardless of restrictions.
* @default false
*/
ignore_restrictions?: boolean;
/**
* If set to true, ignores one-way restrictions. Especially useful for matching GPS traces
* to the road network ignoring uni-directional traffic rules.
* Not included in ignore_restrictions option.
* @default false
*/
ignore_oneways?: boolean;
/**
* Similar to ignore_restrictions, but will respect restrictions that impact vehicle safety,
* such as weight and size restrictions.
* @default false
*/
ignore_non_vehicular_restrictions?: boolean;
/**
* Will ignore mode-specific access tags. Especially useful for matching GPS traces to the
* road network regardless of restrictions.
* @default false
*/
ignore_access?: boolean;
/**
* Will ignore construction tags. Only works when the include_construction option is set
* before building the graph. Useful for planning future routes.
* @default false
*/
ignore_construction?: boolean;
/**
* Will determine which speed sources are used, if available. A list of strings with the following possible values:
* @default ["freeflow", "constrained", "predicted", "current"]
*/
speed_types?: ("freeflow" | "constrained" | "predicted" | "current")[];
/**
* Pass custom hierarchy limits along with this request (read more about the tile hierarchy here).
* Needs to be an object with mandatory keys 1 and 2, each value is another object containing
* numerical values for max_up_transitions and expand_within_distance. The service may either
* clamp these values or disallow modifying hierarchy limits via the request parameters entirely.
* @todo
*/
hierarchy_limits?: void;
} & GeneralCostingOptions;
export type OtherCostingOptions = {
/**
* The height of the vehicle (in meters).
* @default 1.9 for car, bus, taxi and 4.11 for truck
*/
height?: number;
/**
* The width of the vehicle (in meters).
* @default 1.6 for car, bus, taxi and 2.6 for truck
*/
width?: number;
/**
* This value indicates whether or not the path may include unpaved roads.
* If exclude_unpaved is set to 1 it is allowed to start and end with unpaved roads,
* but is not allowed to have them in the middle of the route path, otherwise they are allowed.
* @default false
*/
exclude_unpaved?: boolean;
/**
* A boolean value which indicates the desire to avoid routes with cash-only tolls.
* @default false
*/
exclude_cash_only_tolls?: boolean;
/**
* A boolean value which indicates the desire to include HOV roads with a 2-occupant requirement
* in the route when advantageous.
* @default false
*/
include_hov2?: boolean;
/**
* A boolean value which indicates the desire to include HOV roads with a 3-occupant requirement
* in the route when advantageous.
* @default false
*/
include_hov3?: boolean;
/**
* A boolean value which indicates the desire to include tolled HOV roads which require the driver
* to pay a toll if the occupant requirement isn't met.
* @default false
*/
include_hot?: boolean;
};
/**
* The type of the bicycle.
* Road: a road-style bicycle with narrow tires that is generally lightweight and designed for speed on paved surfaces.
* Hybrid or City: a bicycle made mostly for city riding or casual riding on roads and paths with good surfaces.
* Cross: a cyclo-cross bicycle, which is similar to a road bicycle but with wider tires suitable to rougher surfaces.
* Mountain: a mountain bicycle suitable for most surfaces but generally heavier and slower on paved surfaces.
*/
export type BicycleType = "Road" | "Hybrid" | "City" | "Mountain";
export type BicycleCostingOptions = {
/**
* @default "Hybrid"
*/
bicycle_type?: BicycleType;
/**
* Cycling speed is the average travel speed along smooth, flat roads.
* This is meant to be the speed a rider can comfortably maintain over the desired distance of the route.
* It can be modified (in the costing method) by surface type in conjunction with bicycle type and (coming soon)
* by hilliness of the road section. When no speed is specifically provided, the default speed is determined
* by the bicycle type and are as follows: Road = 25 KPH (15.5 MPH), Cross = 20 KPH (13 MPH),
* Hybrid/City = 18 KPH (11.5 MPH), and Mountain = 16 KPH (10 MPH).
*/
cycling_speed?: number;
/**
* A cyclist's propensity to use roads alongside other vehicles.
* This is a range of values from 0 to 1, where 0 attempts to avoid roads
* and stay on cycleways and paths, and 1 indicates the rider is more
* comfortable riding on roads. Based on the use_roads factor, roads with
* certain classifications and higher speeds are penalized in an attempt to
* avoid them when finding the best path.
* @default 0.5
*/
use_roads?: number;
/**
* A cyclist's desire to tackle hills in their routes.
* This is a range of values from 0 to 1, where 0 attempts to avoid
* hills and steep grades even if it means a longer (time and distance) path,
* while 1 indicates the rider does not fear hills and steeper grades.
* Based on the use_hills factor, penalties are applied to roads based on
* elevation change and grade. These penalties help the path avoid hilly
* roads in favor of flatter roads or less steep grades where available.
* Note that it is not always possible to find alternate paths to avoid
* hills (for example when route locations are in mountainous areas).
* @default 0.5
*/
use_hills?: number;
/**
* This value indicates the willingness to take ferries.
* This is a range of values between 0 and 1.
* Values near 0 attempt to avoid ferries and values near 1 will
* favor ferries. Note that sometimes ferries are required to complete
* a route so values of 0 are not guaranteed to avoid ferries entirely.
* @default 0.5
*/
use_ferry?: number;
/**
* This value indicates the willingness to take living streets.
* This is a range of values between 0 and 1. Values near 0 attempt
* to avoid living streets and values from 0.5 to 1 will currently
* have no effect on route selection. Note that sometimes living
* streets are required to complete a route so values of 0 are not
* guaranteed to avoid living streets entirely.
* @default 0.5
*/
use_living_streets?: number;
/**
* This value is meant to represent how much a cyclist wants to avoid
* roads with poor surfaces relative to the bicycle type being used.
* This is a range of values between 0 and 1. When the value is 0,
* there is no penalization of roads with different surface types;
* only bicycle speed on each surface is taken into account. As
* the value approaches 1, roads with poor surfaces for the bike are
* penalized heavier so that they are only taken if they significantly
* improve travel time. When the value is equal to 1, all bad surfaces
* are completely disallowed from routing, including start and end points.
* @default 0.25
*/
avoid_bad_surfaces?: number;
/**
* This value is useful when bikeshare is chosen as travel mode.
* It is meant to give the time will be used to return a rental bike.
* This value will be displayed in the final directions and used to
* calculate the whole duration.
* @default 120 seconds
*/
bss_return_cost?: number;
/**
* This value is useful when bikeshare is chosen as travel mode.
* It is meant to describe the potential effort to return a rental bike.
* This value won't be displayed and used only inside of the algorithm.
*/
bss_return_penalty?: number;
/**
* Changes the metric to quasi-shortest, i.e. purely distance-based costing.
* Note, this will disable all other costings & penalties. Also note, shortest
* will not disable hierarchy pruning, leading to potentially sub-optimal routes
* for some costing models.
* @default false
*/
shortest?: boolean;
};
export type BikeshareCostingOptions = {}; // TODO
export type MotorScooterCostingOptions = {
/**
* A rider's propensity to use primary roads.
* This is a range of values from 0 to 1, where 0
* attempts to avoid primary roads, and 1 indicates
* the rider is more comfortable riding on primary roads.
* Based on the use_primary factor, roads with certain
* classifications and higher speeds are penalized in an
* attempt to avoid them when finding the best path.
* @default 0.5
*/
use_primary?: boolean;
/**
* A rider's desire to tackle hills in their routes.
* This is a range of values from 0 to 1, where 0
* attempts to avoid hills and steep grades even if
* it means a longer (time and distance) path, while 1
* indicates the rider does not fear hills and steeper grades.
* Based on the use_hills factor, penalties are applied
* to roads based on elevation change and grade. These
* penalties help the path avoid hilly roads in favor of flatter
* roads or less steep grades where available. Note that it is
* not always possible to find alternate paths to avoid hills
* (for example when route locations are in mountainous areas).
* @default 0.5
*/
use_hills?: boolean;
} & AutomobileCostingOptions;
export type MultimodalCostingOptions = {}; // TODO
export type PedestrianCostingOptions = {}; // TODO
export type TruckCostingOptions = {
/**
* The length of the truck (in meters).
* @default 21.64
*/
length?: number;
/**
* The weight of the truck (in metric tons).
* @default 21.77
*/
weight?: number;
/**
* The axle load of the truck (in metric tons).
* @default 9.07
*/
axle_load?: number;
/**
* The axle count of the truck.
* @default 5
*/
axle_count?: number;
/**
* A value indicating if the truck is carrying hazardous materials.
* @default false
*/
hazmat?: boolean;
/**
* A penalty applied to roads with no HGV/truck access.
* If set to a value less than 43200 seconds, HGV will
* be allowed on these roads and the penalty applies.
* Default 43200, i.e. trucks are not allowed.
* @default 43200
*/
hgv_no_access_penalty?: number;
/**
* A penalty (in seconds) which is applied when going to residential or service roads.
* @default 30 seconds
*/
low_class_penalty?: number;
/**
* This value is a range of values from 0 to 1, where 0 indicates a light preference
* for streets marked as truck routes, and 1 indicates that streets not marked as truck
* routes should be avoided. This information is derived from the hgv=designated tag.
* Note that even with values near 1, there is no guarantee the returned route will
* include streets marked as truck routes.
* @default 0
*/
use_truck_route?: boolean;
};
export type ValhallaCostingOptions = {
auto?: AutomobileCostingOptions & OtherCostingOptions;
bicycle?: BicycleCostingOptions;
bus?: AutomobileCostingOptions & OtherCostingOptions;
bikeshare?: BikeshareCostingOptions;
truck?: AutomobileCostingOptions & OtherCostingOptions & TruckCostingOptions;
taxi?: OtherCostingOptions;
motor_scooter?: MotorScooterCostingOptions;
multimodal?: MultimodalCostingOptions;
pedestrian?: PedestrianCostingOptions;
};

View File

@@ -0,0 +1,81 @@
type Language = "de-DE" | "en-US";
type WorldLocation = { lat: number; lon: number };
type Units = "kilometers" | "miles";
type RouteResult = {
alternates?: {
trip: Trip;
}[];
trip: Trip;
}
type Trip = {
language: Language;
legs: Leg[];
status: number;
status_message: string;
summary: Summary;
units: Units;
locations: WorldLocation[];
};
type Leg = {
maneuvers: Maneuver[];
shape: string;
summary: Summary;
locations: WorldLocation[];
}
type Summary = {
cost: number;
has_ferry: boolean;
has_highway: boolean;
has_time_restrictions: boolean;
has_toll: boolean;
length: number;
max_lat: number;
max_lon: number;
min_lat: number;
min_lon: number;
time: number;
}
/**
* Direction bitmask:
* 0 = Empty
* 1 = None
* 2 = Through/Straight X
* 4 = SharpLeft X
* 8 = Left X
* 16 = SlightLeft X
* 32 = SlightRight X
* 64 = Right X
* 128 = SharpRight X
* 256 = Reverse/U-turn X
* 512 = MergeToLeft
* 1024 = MergeToRight
*/
type Lane = {
directions: number;
valid: number;
active: number;
};
type Maneuver = {
bearing_after: number;
begin_shape_index: number;
cost: number;
end_shape_index: number;
instruction: string;
length: number;
street_names?: string[];
time: number;
travel_mode: string;
travel_type: string;
type: number;
verbal_multi_cue: boolean;
verbal_post_transition_instruction: string;
verbal_pre_transition_instruction: string;
verbal_succinct_transition_instruction: string;
lanes?: Lane[];
};

Some files were not shown because too many files have changed in this diff Show More