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
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:
@@ -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>
|
|
||||||
@@ -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,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user