fix: knip clean — delete remaining dead map files, add knip config, unexport internal types
CI / Frontend deps check (push) Successful in 21s
CI / Go build (push) Successful in 36s
CI / Go vulnerabilities (push) Successful in 36s
CI / Frontend build (push) Successful in 53s
CI / Knip (push) Successful in 29s
CI / go mod tidy (push) Successful in 20s
CI / Frontend QC (audit) (push) Successful in 41s
CI / Go vet (push) Successful in 1m5s
CI / Frontend QC (typecheck) (push) Successful in 1m0s
CI / golangci-lint (push) Successful in 1m21s
CI / Frontend QC (lint) (push) Successful in 1m18s
CI / Tests (prod) (push) Successful in 1m39s
CI / Svelte strict check (push) Successful in 1m29s
CI / Tests (dev) (push) Successful in 1m59s
CI / Race (prod) (push) Successful in 3m26s
CI / Race (dev) (push) Successful in 4m55s

Delete 6 unused map components and use-map.svelte.ts hook.
Create knip.json to handle worker entry points and dynamic imports.
Unexport UserRole/DecodedToken types (only used internally).
Remove resolved ignoreDependencies entries.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-07-09 19:45:14 +01:00
co-authored by Sisyphus
parent 407de74b51
commit 0668438ab0
9 changed files with 12 additions and 1118 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": ["src/lib/workers/*.ts"],
"project": ["src/**"],
"ignore": ["src/lib/components/ui/**/index.ts", "src/lib/types/**"],
"ignoreDependencies": ["heic2any"],
"svelte": {
"entry": ["src/routes/**"]
}
}
@@ -1,303 +0,0 @@
<script lang="ts" module>
import type MapLibreGL from 'maplibre-gl';
export type MapArcDatum = {
/** Unique identifier for this arc. Required for hover state tracking. */
id: string | number;
/** Start coordinate as [longitude, latitude]. */
from: [number, number];
/** End coordinate as [longitude, latitude]. */
to: [number, number];
};
export type MapArcEvent<T extends MapArcDatum = MapArcDatum> = {
arc: T;
longitude: number;
latitude: number;
originalEvent: MapLibreGL.MapMouseEvent;
};
type MapArcLinePaint = NonNullable<MapLibreGL.LineLayerSpecification['paint']>;
type MapArcLineLayout = NonNullable<MapLibreGL.LineLayerSpecification['layout']>;
export type MapArcProps<T extends MapArcDatum = MapArcDatum> = {
/** Array of arcs to render. Each arc must have a unique `id`. */
data: T[];
/** Optional unique identifier prefix for the arc source/layers. */
id?: string;
/**
* How far each arc bows away from a straight line. `0` renders straight lines;
* higher values bend further. Negative values bend to the opposite side. (default: 0.2)
*/
curvature?: number;
/** Number of samples used to render each curve. Higher = smoother. (default: 64) */
samples?: number;
/** MapLibre paint properties for the arc layer. */
paint?: MapArcLinePaint;
/** MapLibre layout properties for the arc layer. */
layout?: MapArcLineLayout;
/** Paint properties applied to the arc currently under the cursor. */
hoverPaint?: MapArcLinePaint;
/** Callback when an arc is clicked. */
onclick?: (e: MapArcEvent<T>) => void;
/** Callback fired when the hovered arc changes. */
onhover?: (e: MapArcEvent<T> | null) => void;
/** Whether arcs respond to mouse events. (default: true) */
interactive?: boolean;
/** Optional MapLibre layer id to insert the arc layers before. */
beforeId?: string;
};
</script>
<script lang="ts" generics="T extends MapArcDatum = MapArcDatum">
import { useMap } from '$lib/hooks/use-map.svelte.js';
const {
data,
id: propId,
curvature = 0.2,
samples = 64,
paint,
layout,
hoverPaint,
onclick,
onhover,
interactive = true,
beforeId
}: MapArcProps<T> = $props();
const DEFAULT_PAINT: NonNullable<MapLibreGL.LineLayerSpecification['paint']> = {
'line-color': '#4285F4',
'line-width': 2,
'line-opacity': 0.85
};
const DEFAULT_LAYOUT: NonNullable<MapLibreGL.LineLayerSpecification['layout']> = {
'line-join': 'round',
'line-cap': 'round'
};
const ARC_HIT_MIN_WIDTH = 12;
const ARC_HIT_PADDING = 6;
const autoId = $state(Math.random().toString(36).slice(2));
const id = $derived(propId ?? autoId);
const sourceId = $derived(`arc-source-${id}`);
const layerId = $derived(`arc-layer-${id}`);
const hitLayerId = $derived(`arc-hit-layer-${id}`);
const { map, isLoaded } = useMap();
function buildArcCoordinates(
from: [number, number],
to: [number, number],
curvature: number,
samples: number
): [number, number][] {
const [x0, y0] = from;
const [x2, y2] = to;
const dx = x2 - x0;
const dy = y2 - y0;
const distance = Math.hypot(dx, dy);
if (distance === 0 || curvature === 0) return [from, to];
const mx = (x0 + x2) / 2;
const my = (y0 + y2) / 2;
const nx = -dy / distance;
const ny = dx / distance;
const offset = distance * curvature;
const cx = mx + nx * offset;
const cy = my + ny * offset;
const points: [number, number][] = [];
const segments = Math.max(2, Math.floor(samples));
for (let i = 0; i <= segments; i++) {
const t = i / segments;
const inv = 1 - t;
const x = inv * inv * x0 + 2 * inv * t * cx + t * t * x2;
const y = inv * inv * y0 + 2 * inv * t * cy + t * t * y2;
points.push([x, y]);
}
return points;
}
function mergeArcPaint(
base: NonNullable<MapLibreGL.LineLayerSpecification['paint']>,
hover: NonNullable<MapLibreGL.LineLayerSpecification['paint']> | undefined
): NonNullable<MapLibreGL.LineLayerSpecification['paint']> {
if (!hover) return base;
const merged: Record<string, unknown> = { ...base };
for (const [key, hoverValue] of Object.entries(hover)) {
if (hoverValue === undefined) continue;
const baseValue = merged[key];
merged[key] =
baseValue === undefined
? hoverValue
: ['case', ['boolean', ['feature-state', 'hover'], false], hoverValue, baseValue];
}
return merged as NonNullable<MapLibreGL.LineLayerSpecification['paint']>;
}
const geoJSON = $derived.by<GeoJSON.FeatureCollection<GeoJSON.LineString>>(() => ({
type: 'FeatureCollection',
features: data.map((arc) => {
const { from, to, id: arcId, ...properties } = arc;
return {
id: typeof arcId === 'number' ? arcId : undefined,
type: 'Feature' as const,
properties: { ...properties, _arc_id: String(arcId) },
geometry: {
type: 'LineString' as const,
coordinates: buildArcCoordinates(from, to, curvature, samples)
}
};
})
}));
const mergedPaint = $derived(mergeArcPaint({ ...DEFAULT_PAINT, ...paint }, hoverPaint));
const mergedLayout = $derived({ ...DEFAULT_LAYOUT, ...layout });
const hitWidth = $derived(() => {
const w = paint?.['line-width'] ?? DEFAULT_PAINT['line-width'];
const base = typeof w === 'number' ? w : ARC_HIT_MIN_WIDTH;
return Math.max((base as number) + ARC_HIT_PADDING, ARC_HIT_MIN_WIDTH);
});
let hoveredArcId: string | null = null;
function getArcById(arcId: string): T | undefined {
return data.find((a) => String(a.id) === arcId);
}
$effect(() => {
if (!map || !isLoaded) return;
const currentSourceId = sourceId;
const currentLayerId = layerId;
const currentHitLayerId = hitLayerId;
if (!map.getSource(currentSourceId)) {
map.addSource(currentSourceId, {
type: 'geojson',
data: geoJSON,
promoteId: '_arc_id'
});
map.addLayer(
{
id: currentLayerId,
type: 'line',
source: currentSourceId,
layout: mergedLayout,
paint: mergedPaint
},
beforeId
);
if (interactive) {
map.addLayer(
{
id: currentHitLayerId,
type: 'line',
source: currentSourceId,
layout: mergedLayout,
paint: { 'line-color': 'transparent', 'line-width': hitWidth() }
},
beforeId
);
}
}
return () => {
try {
if (map.getLayer(currentHitLayerId)) map.removeLayer(currentHitLayerId);
if (map.getLayer(currentLayerId)) map.removeLayer(currentLayerId);
if (map.getSource(currentSourceId)) map.removeSource(currentSourceId);
} catch {
// ignore
}
};
});
// Update GeoJSON data reactively
$effect(() => {
if (!map || !isLoaded) return;
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource | undefined;
if (source) source.setData(geoJSON);
});
// Update paint reactively
$effect(() => {
if (!map || !isLoaded) return;
if (map.getLayer(layerId)) {
for (const [key, value] of Object.entries(mergedPaint)) {
map.setPaintProperty(layerId, key as keyof MapArcLinePaint, value);
}
}
});
// Wire up interaction events
$effect(() => {
if (!map || !isLoaded || !interactive) return;
const targetLayer = hitLayerId;
const handleClick = (e: MapLibreGL.MapMouseEvent) => {
if (!onclick) return;
const features = map.queryRenderedFeatures(e.point, { layers: [targetLayer] });
if (!features.length) return;
const arcId = features[0].properties?._arc_id;
if (!arcId) return;
const arc = getArcById(arcId);
if (!arc) return;
onclick({ arc, longitude: e.lngLat.lng, latitude: e.lngLat.lat, originalEvent: e });
};
const handleMouseMove = (e: MapLibreGL.MapMouseEvent) => {
const features = map.queryRenderedFeatures(e.point, { layers: [targetLayer] });
const arcId = features.length ? features[0].properties?._arc_id : null;
if (arcId === hoveredArcId) return;
if (hoveredArcId !== null) {
map.setFeatureState({ source: sourceId, id: hoveredArcId }, { hover: false });
hoveredArcId = null;
}
if (arcId) {
map.setFeatureState({ source: sourceId, id: arcId }, { hover: true });
hoveredArcId = arcId;
map.getCanvas().style.cursor = 'pointer';
if (onhover) {
const arc = getArcById(arcId);
if (arc) {
onhover({ arc, longitude: e.lngLat.lng, latitude: e.lngLat.lat, originalEvent: e });
}
}
} else {
map.getCanvas().style.cursor = '';
if (onhover) onhover(null);
}
};
const handleMouseLeave = () => {
if (hoveredArcId !== null) {
map.setFeatureState({ source: sourceId, id: hoveredArcId }, { hover: false });
hoveredArcId = null;
}
map.getCanvas().style.cursor = '';
if (onhover) onhover(null);
};
map.on('click', targetLayer, handleClick);
map.on('mousemove', targetLayer, handleMouseMove);
map.on('mouseleave', targetLayer, handleMouseLeave);
return () => {
map.off('click', targetLayer, handleClick);
map.off('mousemove', targetLayer, handleMouseMove);
map.off('mouseleave', targetLayer, handleMouseLeave);
};
});
</script>
@@ -1,287 +0,0 @@
<script lang="ts" generics="P extends GeoJSON.GeoJsonProperties">
import { getContext } from 'svelte';
import MapLibreGL from 'maplibre-gl';
import { generateUUID } from '$lib/utils/uuid';
interface Props {
/** GeoJSON FeatureCollection data or URL to fetch GeoJSON from */
data: string | GeoJSON.FeatureCollection<GeoJSON.Point, P>;
/** Maximum zoom level to cluster points on (default: 14) */
clusterMaxZoom?: number;
/** Radius of each cluster when clustering points in pixels (default: 50) */
clusterRadius?: number;
/** Colors for cluster circles: [small, medium, large] based on point count (default: ["#22c55e", "#eab308", "#ef4444"]) */
clusterColors?: [string, string, string];
/** Point count thresholds for color/size steps: [medium, large] (default: [100, 750]) */
clusterThresholds?: [number, number];
/** Color for unclustered individual points (default: "#3b82f6") */
pointColor?: string;
/** Callback when an unclustered point is clicked */
onpointclick?: (
feature: GeoJSON.Feature<GeoJSON.Point, P>,
coordinates: [number, number]
) => void;
/** Callback when a cluster is clicked. If not provided, zooms into the cluster */
onclusterclick?: (clusterId: number, coordinates: [number, number], pointCount: number) => void;
}
const {
data,
clusterMaxZoom = 14,
clusterRadius = 50,
clusterColors = ['#22c55e', '#eab308', '#ef4444'],
clusterThresholds = [100, 750],
pointColor = '#3b82f6',
onpointclick,
onclusterclick
}: Props = $props();
const mapCtx = getContext<{
getMap: () => MapLibreGL.Map | null;
isStyleReady: () => boolean;
}>('map');
const id = generateUUID();
const sourceId = $derived(`cluster-source-${id}`);
const clusterLayerId = $derived(`clusters-${id}`);
const clusterCountLayerId = $derived(`cluster-count-${id}`);
const unclusteredLayerId = $derived(`unclustered-point-${id}`);
// Add source and layers when map is ready
$effect(() => {
const map = mapCtx.getMap();
const loaded = mapCtx.isStyleReady();
if (!loaded || !map) return;
// Remove existing layers and source if they exist
try {
if (map.getLayer(clusterCountLayerId)) map.removeLayer(clusterCountLayerId);
if (map.getLayer(unclusteredLayerId)) map.removeLayer(unclusteredLayerId);
if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId);
if (map.getSource(sourceId)) map.removeSource(sourceId);
} catch {
// ignore
}
// Add clustered GeoJSON source
map.addSource(sourceId, {
type: 'geojson',
data,
cluster: true,
clusterMaxZoom,
clusterRadius
});
// Add cluster circles layer
map.addLayer({
id: clusterLayerId,
type: 'circle',
source: sourceId,
filter: ['has', 'point_count'],
paint: {
'circle-color': [
'step',
['get', 'point_count'],
clusterColors[0],
clusterThresholds[0],
clusterColors[1],
clusterThresholds[1],
clusterColors[2]
],
'circle-radius': [
'step',
['get', 'point_count'],
20,
clusterThresholds[0],
30,
clusterThresholds[1],
40
],
'circle-stroke-width': 1,
'circle-stroke-color': '#fff',
'circle-opacity': 0.85
}
});
// Add cluster count text layer
map.addLayer({
id: clusterCountLayerId,
type: 'symbol',
source: sourceId,
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-font': ['Open Sans'],
'text-size': 12
},
paint: {
'text-color': '#fff'
}
});
// Add unclustered point layer
map.addLayer({
id: unclusteredLayerId,
type: 'circle',
source: sourceId,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': pointColor,
'circle-radius': 5,
'circle-stroke-width': 2,
'circle-stroke-color': '#fff'
}
});
return () => {
try {
if (map.getLayer(clusterCountLayerId)) map.removeLayer(clusterCountLayerId);
if (map.getLayer(unclusteredLayerId)) map.removeLayer(unclusteredLayerId);
if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId);
if (map.getSource(sourceId)) map.removeSource(sourceId);
} catch {
// ignore
}
};
});
// Update source data when data prop changes (only for non-URL data)
$effect(() => {
const map = mapCtx.getMap();
const loaded = mapCtx.isStyleReady();
if (!loaded || !map || typeof data === 'string') return;
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource | undefined;
if (source) {
source.setData(data);
}
});
// Update layer styles when props change
$effect(() => {
const map = mapCtx.getMap();
const loaded = mapCtx.isStyleReady();
if (!loaded || !map) return;
// Update cluster layer colors and sizes
if (map.getLayer(clusterLayerId)) {
map.setPaintProperty(clusterLayerId, 'circle-color', [
'step',
['get', 'point_count'],
clusterColors[0],
clusterThresholds[0],
clusterColors[1],
clusterThresholds[1],
clusterColors[2]
]);
map.setPaintProperty(clusterLayerId, 'circle-radius', [
'step',
['get', 'point_count'],
20,
clusterThresholds[0],
30,
clusterThresholds[1],
40
]);
}
// Update unclustered point layer color
if (map.getLayer(unclusteredLayerId)) {
map.setPaintProperty(unclusteredLayerId, 'circle-color', pointColor);
}
});
// Handle click events
$effect(() => {
const map = mapCtx.getMap();
const loaded = mapCtx.isStyleReady();
if (!loaded || !map) return;
// Cluster click handler - zoom into cluster
const handleClusterClick = async (
e: MapLibreGL.MapMouseEvent & {
features?: MapLibreGL.MapGeoJSONFeature[];
}
) => {
const features = map.queryRenderedFeatures(e.point, {
layers: [clusterLayerId]
});
if (!features.length) return;
const feature = features[0];
const clusterId = feature.properties?.cluster_id as number;
const pointCount = feature.properties?.point_count as number;
const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [number, number];
if (onclusterclick) {
onclusterclick(clusterId, coordinates, pointCount);
} else {
// Default behavior: zoom to cluster expansion zoom
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;
const zoom = await source.getClusterExpansionZoom(clusterId);
map.easeTo({
center: coordinates,
zoom
});
}
};
// Unclustered point click handler
const handlePointClick = (
e: MapLibreGL.MapMouseEvent & {
features?: MapLibreGL.MapGeoJSONFeature[];
}
) => {
if (!onpointclick || !e.features?.length) return;
const feature = e.features[0];
const coordinates = (feature.geometry as GeoJSON.Point).coordinates.slice() as [
number,
number
];
// Handle world copies
while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
}
onpointclick(feature as unknown as GeoJSON.Feature<GeoJSON.Point, P>, coordinates);
};
// Cursor style handlers
const handleMouseEnterCluster = () => {
map.getCanvas().style.cursor = 'pointer';
};
const handleMouseLeaveCluster = () => {
map.getCanvas().style.cursor = '';
};
const handleMouseEnterPoint = () => {
if (onpointclick) {
map.getCanvas().style.cursor = 'pointer';
}
};
const handleMouseLeavePoint = () => {
map.getCanvas().style.cursor = '';
};
map.on('click', clusterLayerId, handleClusterClick);
map.on('click', unclusteredLayerId, handlePointClick);
map.on('mouseenter', clusterLayerId, handleMouseEnterCluster);
map.on('mouseleave', clusterLayerId, handleMouseLeaveCluster);
map.on('mouseenter', unclusteredLayerId, handleMouseEnterPoint);
map.on('mouseleave', unclusteredLayerId, handleMouseLeavePoint);
return () => {
map.off('click', clusterLayerId, handleClusterClick);
map.off('click', unclusteredLayerId, handlePointClick);
map.off('mouseenter', clusterLayerId, handleMouseEnterCluster);
map.off('mouseleave', clusterLayerId, handleMouseLeaveCluster);
map.off('mouseenter', unclusteredLayerId, handleMouseEnterPoint);
map.off('mouseleave', unclusteredLayerId, handleMouseLeavePoint);
};
});
</script>
@@ -1,179 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte';
import MapLibreGL, { type PopupOptions } from 'maplibre-gl';
import { cn } from '$lib/utils.js';
import X from '@lucide/svelte/icons/x';
interface Props {
longitude: number;
latitude: number;
children?: import('svelte').Snippet;
class?: string;
closeButton?: boolean;
onclose?: () => void;
offset?: PopupOptions['offset'];
anchor?: PopupOptions['anchor'];
closeOnClick?: boolean;
closeOnMove?: boolean;
focusAfterOpen?: boolean;
maxWidth?: string;
}
const {
longitude,
latitude,
children,
class: className,
closeButton = false,
onclose,
offset = 16,
anchor,
closeOnClick,
closeOnMove,
focusAfterOpen,
maxWidth
}: Props = $props();
const mapCtx = getContext<{
getMap: () => MapLibreGL.Map | null;
isLoaded: () => boolean;
}>('map');
const markerCtx =
getContext<{
isDraggable?: () => boolean;
}>('marker') || {};
let popup: MapLibreGL.Popup | null = null;
let wrapperElement: HTMLDivElement | null = $state(null);
// Create popup when map is ready
// eslint-disable-next-line svelte/no-dom-manipulating
$effect(() => {
const map = mapCtx.getMap();
const loaded = mapCtx.isLoaded();
if (!loaded || !map || !wrapperElement) return;
// Validate coordinates
if (
typeof longitude !== 'number' ||
typeof latitude !== 'number' ||
Number.isNaN(longitude) ||
Number.isNaN(latitude)
) {
return;
}
// Create popup container
const container = document.createElement('div');
// Build popup options
const popupOptions: PopupOptions = {
offset,
closeButton: false,
className: 'maplibre-popup-transparent'
};
// If marker is draggable, preserve popup state during movement
if (markerCtx.isDraggable?.()) {
popupOptions.closeOnMove = false;
}
if (anchor !== undefined) popupOptions.anchor = anchor;
if (closeOnClick !== undefined) popupOptions.closeOnClick = closeOnClick;
if (closeOnMove !== undefined) popupOptions.closeOnMove = closeOnMove;
if (focusAfterOpen !== undefined) popupOptions.focusAfterOpen = focusAfterOpen;
// Create popup
const popupInstance = new MapLibreGL.Popup(popupOptions)
.setDOMContent(container)
.setLngLat([longitude, latitude])
.addTo(map);
if (maxWidth) {
popupInstance.setMaxWidth(maxWidth);
} else {
popupInstance.setMaxWidth('none');
}
popup = popupInstance;
// Handle close event
const handleClose = () => onclose?.();
popupInstance.on('close', handleClose);
// Move content to popup container
while (wrapperElement.firstChild) {
container.appendChild(wrapperElement.firstChild);
}
return () => {
popupInstance.off('close', handleClose);
// Move content back
while (container.firstChild) {
wrapperElement?.appendChild(container.firstChild);
}
if (popupInstance.isOpen()) {
popupInstance.remove();
}
popup = null;
};
});
// Update position when coordinates change
$effect(() => {
if (
popup &&
typeof longitude === 'number' &&
typeof latitude === 'number' &&
!Number.isNaN(longitude) &&
!Number.isNaN(latitude)
) {
popup.setLngLat([longitude, latitude]);
}
});
function handleClose() {
popup?.remove();
onclose?.();
}
</script>
<div bind:this={wrapperElement} style="display: contents;">
<div
class={cn(
'relative max-w-62 rounded-md border bg-popover p-3 text-popover-foreground shadow-md',
'animate-in duration-200 ease-out fade-in-0 zoom-in-95',
className
)}
>
{#if closeButton}
<button
type="button"
onclick={handleClose}
aria-label="Close popup"
class="absolute top-0.5 right-0.5 z-10 inline-flex size-5 cursor-pointer items-center justify-center rounded-sm text-foreground transition-colors hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X class="size-3.5" />
</button>
{/if}
{@render children?.()}
</div>
</div>
<style>
:global(.maplibre-popup-transparent .maplibregl-popup-content) {
background: transparent;
box-shadow: none;
padding: 0;
}
:global(.maplibre-popup-transparent .maplibregl-popup-tip) {
display: none;
}
</style>
@@ -1,185 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte';
import MapLibreGL from 'maplibre-gl';
import { generateUUID } from '$lib/utils/uuid';
interface Props {
/** Optional unique identifier for the route layer */
id?: string;
/** Array of [longitude, latitude] coordinate pairs defining the route */
coordinates: [number, number][];
/** Line color as CSS color value (default: "#4285F4") */
color?: string;
/** Line width in pixels (default: 3) */
width?: number;
/** Line opacity from 0 to 1 (default: 0.8) */
opacity?: number;
/** Dash pattern [dash length, gap length] for dashed lines */
dashArray?: [number, number];
/** Callback when the route line is clicked */
onclick?: () => void;
/** Callback when mouse enters the route line */
onmouseenter?: () => void;
/** Callback when mouse leaves the route line */
onmouseleave?: () => void;
/** Whether the route is interactive - shows pointer cursor on hover (default: true) */
interactive?: boolean;
}
const {
coordinates,
color = '#4285F4',
width = 3,
opacity = 0.8,
dashArray,
onclick,
onmouseenter,
onmouseleave,
interactive = true,
id = generateUUID()
}: Props = $props();
const mapCtx = getContext<{
getMap: () => MapLibreGL.Map | null;
isStyleReady: () => boolean;
}>('map');
const sourceId = $derived(`route-source-${id}`);
const layerId = $derived(`route-layer-${id}`);
// Add route when map is ready
$effect(() => {
const map = mapCtx.getMap();
const loaded = mapCtx.isStyleReady();
if (!loaded || !map || coordinates.length < 2) return;
// Remove existing layer and source if they exist
if (map.getLayer(layerId)) map.removeLayer(layerId);
if (map.getSource(sourceId)) map.removeSource(sourceId);
// Add source
map.addSource(sourceId, {
type: 'geojson',
data: {
type: 'Feature',
properties: {},
geometry: {
type: 'LineString',
coordinates
}
}
});
// Build paint options with transition definitions
// Use default values here - they'll be updated by the paint property effect
const paint: MapLibreGL.LineLayerSpecification['paint'] = {
'line-color': '#94a3b8', // Start with gray (unselected color)
'line-width': 5, // Start with unselected width
'line-opacity': 0.6, // Start with unselected opacity
'line-color-transition': { duration: 300, delay: 0 },
'line-width-transition': { duration: 300, delay: 0 },
'line-opacity-transition': { duration: 300, delay: 0 }
};
if (dashArray) {
paint['line-dasharray'] = dashArray;
}
// Add layer
map.addLayer({
id: layerId,
type: 'line',
source: sourceId,
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint
});
return () => {
try {
if (map.getLayer(layerId)) map.removeLayer(layerId);
if (map.getSource(sourceId)) map.removeSource(sourceId);
} catch {
// Ignore errors during cleanup
}
};
});
// Update route data when coordinates change
$effect(() => {
const map = mapCtx.getMap();
const loaded = mapCtx.isStyleReady();
if (!loaded || !map || coordinates.length < 2) return;
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource | undefined;
if (source) {
source.setData({
type: 'Feature',
properties: {},
geometry: {
type: 'LineString',
coordinates
}
});
}
});
// Update paint properties when they change
$effect(() => {
const map = mapCtx.getMap();
const loaded = mapCtx.isStyleReady();
if (!loaded || !map || !map.getLayer(layerId)) return;
map.setPaintProperty(layerId, 'line-color', color);
map.setPaintProperty(layerId, 'line-width', width);
map.setPaintProperty(layerId, 'line-opacity', opacity);
if (dashArray) {
map.setPaintProperty(layerId, 'line-dasharray', dashArray);
}
// Move selected routes to top (when opacity is 1, it's selected)
if (opacity === 1) {
try {
map.moveLayer(layerId);
} catch {
// Layer might not exist yet
}
}
});
// Handle click and hover events
$effect(() => {
const map = mapCtx.getMap();
const loaded = mapCtx.isStyleReady();
if (!loaded || !map || !interactive) return;
const handleClick = () => {
onclick?.();
};
const handleMouseEnter = () => {
map.getCanvas().style.cursor = 'pointer';
onmouseenter?.();
};
const handleMouseLeave = () => {
map.getCanvas().style.cursor = '';
onmouseleave?.();
};
map.on('click', layerId, handleClick);
map.on('mouseenter', layerId, handleMouseEnter);
map.on('mouseleave', layerId, handleMouseLeave);
return () => {
map.off('click', layerId, handleClick);
map.off('mouseenter', layerId, handleMouseEnter);
map.off('mouseleave', layerId, handleMouseLeave);
};
});
</script>
@@ -1,27 +0,0 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
interface Props {
children?: import('svelte').Snippet;
class?: string;
position?: 'top' | 'bottom';
}
const { children, class: className, position = 'top' }: Props = $props();
const positionClasses = {
top: 'bottom-full mb-1',
bottom: 'top-full mt-1'
};
</script>
<div
class={cn(
'absolute left-1/2 -translate-x-1/2 whitespace-nowrap',
'text-[10px] font-medium text-foreground',
positionClasses[position],
className
)}
>
{@render children?.()}
</div>
@@ -1,107 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte';
import MapLibreGL, { type PopupOptions } from 'maplibre-gl';
import { cn } from '$lib/utils.js';
interface Props {
children?: import('svelte').Snippet;
class?: string;
offset?: PopupOptions['offset'];
anchor?: PopupOptions['anchor'];
}
const { children, class: className, offset = 16, anchor }: Props = $props();
const markerCtx = getContext<{
getMarker: () => MapLibreGL.Marker | null;
getElement: () => HTMLDivElement | null;
getMap: () => MapLibreGL.Map | null;
isReady: () => boolean;
}>('marker');
let wrapperElement: HTMLDivElement | null = $state(null);
// Create tooltip popup when marker is ready
// eslint-disable-next-line svelte/no-dom-manipulating
$effect(() => {
const marker = markerCtx.getMarker();
const markerElement = markerCtx.getElement();
const map = markerCtx.getMap();
const ready = markerCtx.isReady();
if (!ready || !marker || !markerElement || !map || !wrapperElement) return;
// Create popup container
const container = document.createElement('div');
// Build popup options
const popupOptions: PopupOptions = {
offset,
closeOnClick: true,
closeButton: false,
className: 'maplibre-popup-transparent'
};
if (anchor !== undefined) popupOptions.anchor = anchor;
// Create popup
const popupInstance = new MapLibreGL.Popup(popupOptions)
.setMaxWidth('none')
.setDOMContent(container);
// Move content to popup container
while (wrapperElement.firstChild) {
container.appendChild(wrapperElement.firstChild);
}
// Show on hover
const handleMouseEnter = () => {
popupInstance.setLngLat(marker.getLngLat()).addTo(map);
};
const handleMouseLeave = () => {
popupInstance.remove();
};
markerElement.addEventListener('mouseenter', handleMouseEnter);
markerElement.addEventListener('mouseleave', handleMouseLeave);
return () => {
markerElement.removeEventListener('mouseenter', handleMouseEnter);
markerElement.removeEventListener('mouseleave', handleMouseLeave);
// Move content back
while (container.firstChild) {
wrapperElement?.appendChild(container.firstChild);
}
popupInstance.remove();
};
});
</script>
<div bind:this={wrapperElement} style="display: contents;">
<div
class={cn(
'pointer-events-none rounded-md bg-foreground px-2 py-1 text-xs text-balance text-background shadow-md',
'animate-in duration-200 ease-out fade-in-0 zoom-in-95',
className
)}
>
{@render children?.()}
</div>
</div>
<style>
:global(.maplibre-popup-transparent .maplibregl-popup-content) {
background: transparent;
box-shadow: none;
padding: 0;
}
:global(.maplibre-popup-transparent .maplibregl-popup-tip) {
display: none;
}
</style>
-28
View File
@@ -1,28 +0,0 @@
import { getContext } from 'svelte';
import MapLibreGL from 'maplibre-gl';
type MapContext = {
getMap: () => MapLibreGL.Map | null;
isLoaded: () => boolean;
isStyleReady: () => boolean;
};
export function useMap() {
const mapCtx = getContext<MapContext>('map');
const map = $derived.by(() => mapCtx?.getMap() ?? null);
const isLoaded = $derived.by(() => mapCtx?.isLoaded() ?? false);
const isStyleReady = $derived.by(() => mapCtx?.isStyleReady() ?? false);
return {
get map() {
return map;
},
get isLoaded() {
return isLoaded;
},
get isStyleReady() {
return isStyleReady;
}
};
}
+2 -2
View File
@@ -2,9 +2,9 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
export type UserRole = 'unverified_email' | 'verified_email' | 'admin' | 'guest' | 'affiliate'; type UserRole = 'unverified_email' | 'verified_email' | 'admin' | 'guest' | 'affiliate';
export interface DecodedToken { interface DecodedToken {
user_id: string; user_id: string;
role: UserRole; role: UserRole;
exp: number; exp: number;