Svelte Maplibre

Clusters and Popups

Data and layer configuration derived from MapLibre cluster Example.

Show popup on
<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}

Back to Examples

Github