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

29
src/App.svelte Normal file
View File

@@ -0,0 +1,29 @@
<script lang="ts">
import "./app.css";
import { GeolocateControl, Hash, MapLibre } from "svelte-maplibre-gl";
import Sidebar from "./lib/components/lnv/Sidebar.svelte";
import { onMount } from "svelte";
import Map from "$lib/components/lnv/Map.svelte";
import { routing } from "$lib/services/navigation/routing.svelte";
import LanesDisplay from "$lib/services/navigation/LanesDisplay.svelte";
import { checkWebGL } from "$lib/webgl";
onMount(() => {
if(!checkWebGL()) {
alert("WebGL is not supported in your browser. Please try a different browser.");
return;
}
});
</script>
{#if !routing.currentTrip}
<Sidebar></Sidebar>
{:else}
<div class="fixed top-4 left-4 z-50 w-3/4 h-10 bg-background text-white">
{routing.currentTripInfo.currentManeuver?.instruction}
<LanesDisplay
lanes={routing.currentTripInfo.currentManeuver?.lanes}
/>
</div>
{/if}
<Map></Map>

166
src/app.css Normal file
View File

@@ -0,0 +1,166 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1.0000 0 0);
--foreground: oklch(0.2644 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.2644 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.2644 0 0);
--primary: oklch(0.3261 0 0);
--primary-foreground: oklch(0.9886 0 0);
--secondary: oklch(0.9772 0 0);
--secondary-foreground: oklch(0.3261 0 0);
--muted: oklch(0.9772 0 0);
--muted-foreground: oklch(0.6460 0 0);
--accent: oklch(0.9772 0 0);
--accent-foreground: oklch(0.3261 0 0);
--destructive: oklch(0.6201 0.2092 25.7747);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.9404 0 0);
--input: oklch(0.9404 0 0);
--ring: oklch(0.7716 0 0);
--chart-1: oklch(0.8241 0.1251 84.4866);
--chart-2: oklch(0.8006 0.1116 203.6044);
--chart-3: oklch(0.4198 0.1693 266.7798);
--chart-4: oklch(0.9214 0.0762 125.5777);
--chart-5: oklch(0.9151 0.1032 116.1913);
--sidebar: oklch(0.9886 0 0);
--sidebar-foreground: oklch(0.2644 0 0);
--sidebar-primary: oklch(0.3261 0 0);
--sidebar-primary-foreground: oklch(0.9886 0 0);
--sidebar-accent: oklch(0.9772 0 0);
--sidebar-accent-foreground: oklch(0.3261 0 0);
--sidebar-border: oklch(0.9404 0 0);
--sidebar-ring: oklch(0.7716 0 0);
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--radius: 0.625rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
}
.dark {
--background: oklch(0.1405 0.0044 285.8238);
--foreground: oklch(0.9848 0 0);
--card: oklch(0.1405 0.0044 285.8238);
--card-foreground: oklch(0.9848 0 0);
--popover: oklch(0.1405 0.0044 285.8238);
--popover-foreground: oklch(0.9848 0 0);
--primary: oklch(0.5111 0.2152 266.7098);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.2741 0.0055 286.0329);
--secondary-foreground: oklch(0.9848 0 0);
--muted: oklch(0.2741 0.0055 286.0329);
--muted-foreground: oklch(0.7119 0.0129 286.0684);
--accent: oklch(0.5111 0.2152 266.7098);
--accent-foreground: oklch(0.9848 0 0);
--destructive: oklch(0.3959 0.1331 25.7205);
--destructive-foreground: oklch(0.9710 0.0127 17.3758);
--border: oklch(0.2741 0.0055 286.0329);
--input: oklch(0.2741 0.0055 286.0329);
--ring: oklch(0.8709 0.0055 286.2853);
--chart-1: oklch(0.5292 0.1931 262.1292);
--chart-2: oklch(0.6983 0.1337 165.4626);
--chart-3: oklch(0.7232 0.1500 60.6307);
--chart-4: oklch(0.6192 0.2037 312.7283);
--chart-5: oklch(0.6123 0.2093 6.3856);
--sidebar: oklch(0.2103 0.0059 285.8835);
--sidebar-foreground: oklch(0.9676 0.0013 286.3752);
--sidebar-primary: oklch(0.4878 0.2170 264.3876);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.2741 0.0055 286.0329);
--sidebar-accent-foreground: oklch(0.9676 0.0013 286.3752);
--sidebar-border: oklch(0.2741 0.0055 286.0329);
--sidebar-ring: oklch(0.8709 0.0055 286.2853);
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--radius: 0.625rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
#app {
height: 100vh;
color-scheme: dark;
}

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;
}

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