Custom Markers with Clusters
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 Popup from 'svelte-maplibre/Popup.svelte';
import ClusterPopup from '../ClusterPopup.svelte';
import clusterPopupCode from '../ClusterPopup.svelte?raw';
import MarkerLayer from 'svelte-maplibre/MarkerLayer.svelte';
import quakeImageUrl from '$site/earthquake.png';
import tsunamiImageUrl from '$site/tsunami.png';
import earthquakes from '$site/earthquakes.geojson?url';
import type {
ClusterFeatureProperties,
ClusterProperties,
SingleProperties,
} from '../cluster_feature_properties.js';
let clickedFeature: ClusterFeatureProperties | 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']],
},
}}
>
<MarkerLayer applyToClusters asButton onclick={(e) => (clickedFeature = e.feature?.properties)}>
{#snippet children({
feature,
}: {
feature: GeoJSON.Feature<GeoJSON.Point, ClusterProperties>;
})}
<div class="rounded-full bg-orange-200 p-1">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
><path
fill="currentColor"
d="M14 11.5A2.5 2.5 0 0 0 16.5 9A2.5 2.5 0 0 0 14 6.5A2.5 2.5 0 0 0 11.5 9a2.5 2.5 0 0 0 2.5 2.5M14 2c3.86 0 7 3.13 7 7c0 5.25-7 13-7 13S7 14.25 7 9a7 7 0 0 1 7-7M5 9c0 4.5 5.08 10.66 6 11.81L10 22S3 14.25 3 9c0-3.17 2.11-5.85 5-6.71C6.16 3.94 5 6.33 5 9Z"
/></svg
>
</div>
<Popup {openOn} closeOnClickInside>
<ClusterPopup {feature} />
</Popup>
{/snippet}
</MarkerLayer>
<MarkerLayer
applyToClusters={false}
anchor="bottom"
asButton
onclick={(e) => (clickedFeature = e.feature?.properties)}
>
{#snippet children({
feature,
}: {
feature: GeoJSON.Feature<GeoJSON.Point, SingleProperties>;
})}
<img src={feature.properties?.tsunami ? tsunamiImageUrl : quakeImageUrl} alt="Earthquake" />
<Popup {openOn} closeOnClickInside>
{@const props = feature.properties}
<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>
</Popup>
{/snippet}
</MarkerLayer>
</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}