Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
288 lines
8.1 KiB
Svelte
288 lines
8.1 KiB
Svelte
<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;
|
|
}
|
|
|
|
let {
|
|
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>
|