Clusters and Popups
Data and layer configuration derived from MapLibre cluster Example.
<script lang="ts">
import MapLibre from 'svelte-maplibre/MapLibre.svelte';
import GeoJSON from 'svelte-maplibre/GeoJSON.svelte';
import CodeSample from '$site/CodeSample.svelte';
import code from './+page.svelte?raw';
import { mapClasses } from '../styles';
import CircleLayer from 'svelte-maplibre/CircleLayer.svelte';
import SymbolLayer from 'svelte-maplibre/SymbolLayer.svelte';
import { hoverStateFilter } from 'svelte-maplibre/filters';
import Popup from 'svelte-maplibre/Popup.svelte';
import ClusterPopup from '../ClusterPopup.svelte';
import clusterPopupCode from '../ClusterPopup.svelte?raw';
import earthquakes from '$site/earthquakes.geojson?url';
import type {
ClusterFeatureProperties,
ClusterProperties,
SingleProperties,
} from '../cluster_feature_properties';
import type { Feature, Geometry } from 'geojson';
import type { LayerClickInfo } from 'svelte-maplibre';
let clickedFeature: ClusterFeatureProperties | null | undefined = $state();
let openOn: 'click' | 'dblclick' | 'contextmenu' | 'hover' = $state('hover');
</script>
<p>
Data and layer configuration derived from <a
href="https://maplibre.org/maplibre-gl-js-docs/example/cluster/">MapLibre cluster Example.</a
>
</p>
<fieldset class="mb-2 flex gap-x-4 self-start border border-gray-300 px-2">
<legend>Show popup on</legend>
<label><input type="radio" bind:group={openOn} value="hover" /> Hover</label>
<label><input type="radio" bind:group={openOn} value="click" /> Click</label>
<label><input type="radio" bind:group={openOn} value="dblclick" /> Double Click</label>
<label
><input type="radio" bind:group={openOn} value="contextmenu" /> Context Menu (right-click)</label
>
</fieldset>
<MapLibre
style="https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
class={mapClasses}
zoomOnDoubleClick={openOn !== 'dblclick'}
standardControls
>
<GeoJSON
id="earthquakes"
data={earthquakes}
cluster={{
radius: 50,
maxZoom: 14,
properties: {
// Sum the `mag` property from all the points in each cluster.
total_mag: ['+', ['get', 'mag']],
},
}}
>
<CircleLayer
id="cluster_circles"
applyToClusters
hoverCursor="pointer"
paint={{
// Use step expressions (https://maplibre.org/maplibre-gl-js-docs/style-spec/#expressions-step)
// with three steps to implement three types of circles:
// * Blue, 20px circles when point count is less than 100
// * Yellow, 30px circles when point count is between 100 and 750
// * Pink, 40px circles when point count is greater than or equal to 750
'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 100, '#f1f075', 750, '#f28cb1'],
'circle-radius': ['step', ['get', 'point_count'], 20, 100, 30, 750, 40],
'circle-stroke-color': '#f00',
'circle-stroke-width': 1,
'circle-stroke-opacity': hoverStateFilter(0, 1),
}}
manageHoverState
onclick={(e: LayerClickInfo<Feature<Geometry, ClusterProperties>>) =>
(clickedFeature = e.features?.[0]?.properties)}
>
<Popup {openOn} closeOnClickInside>
{#snippet children({ data }: { data: Feature<Geometry, ClusterProperties> | undefined })}
<ClusterPopup feature={data ?? undefined} />
{/snippet}
</Popup>
</CircleLayer>
<SymbolLayer
id="cluster_labels"
interactive={false}
applyToClusters
layout={{
'text-field': [
'format',
['get', 'point_count_abbreviated'],
{},
'\n',
{},
[
'number-format',
['/', ['get', 'total_mag'], ['get', 'point_count']],
{
'max-fraction-digits': 2,
},
],
{ 'font-scale': 0.8 },
],
'text-size': 12,
'text-offset': [0, -0.1],
}}
/>
<CircleLayer
id="earthquakes_circle"
applyToClusters={false}
hoverCursor="pointer"
paint={{
'circle-color': '#11b4da',
'circle-radius': 4,
'circle-stroke-width': 1,
'circle-stroke-color': '#fff',
}}
onclick={(e: LayerClickInfo<Feature<Geometry, SingleProperties>>) =>
(clickedFeature = e.features?.[0]?.properties)}
>
<Popup {openOn} closeOnClickInside>
{#snippet children({ data }: { data: Feature<Geometry, SingleProperties> | undefined })}
{@const props = data?.properties}
{#if props}
<p>
Date: <span class="font-medium text-gray-800"
>{new Date(props.time).toLocaleDateString()}</span
>
</p>
<p>Magnitude: <span class="font-medium text-gray-800">{props.mag}</span></p>
<p>
Tsunami: <span class="font-medium text-gray-800">{props.tsunami ? 'Yes' : 'No'}</span>
</p>
{/if}
{/snippet}
</Popup>
</CircleLayer>
</GeoJSON>
</MapLibre>
{#if clickedFeature}
{#if clickedFeature.cluster}
<p>
Number of Earthquakes:
<span class="font-bold text-gray-800">{clickedFeature['point_count']}</span>
</p>
<p>
Average Magnitude:
<span class="font-bold text-gray-800">
{(clickedFeature.total_mag / clickedFeature.point_count).toFixed(2)}
</span>
</p>
{:else}
<p>Magnitude: <span class="font-bold text-gray-800">{clickedFeature.mag}</span></p>
{/if}
{/if}<!-- File: ClusterPopup.svelte -->
<script lang="ts">
import { getSource, getMapContext } from 'svelte-maplibre/context.svelte.js';
import type { Feature, Geometry } from 'geojson';
import type { GeoJSONSource } from 'maplibre-gl';
import type { ClusterProperties, SingleProperties } from './cluster_feature_properties.js';
const { map } = $derived(getMapContext());
const source = getSource();
interface Props {
feature: Feature<Geometry, ClusterProperties> | undefined;
}
let { feature }: Props = $props();
let innerFeaturesPromise = $derived.by(async () => {
if (!map || !source?.value || !feature) {
return [];
}
const features = ((await (map.getSource(source.value) as GeoJSONSource)?.getClusterLeaves(
feature.properties.cluster_id,
10000,
0
)) ?? []) as Feature<Geometry, SingleProperties>[];
features.sort((a, b) => {
return b.properties.time - a.properties.time;
});
return features;
});
// Use this instead of an await template tag to avoid flickering
let innerFeatures: Feature<Geometry, SingleProperties>[] = $state([]);
$effect(() => {
innerFeaturesPromise.then((f) => (innerFeatures = f));
});
</script>
<p>Most recent quakes</p>
{#each innerFeatures.slice(0, 10) as feat}
<div class="text-sm">
{new Date(feat.properties.time).toLocaleDateString()} - {feat.properties.mag}
</div>
{/each}