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