Skip to content
Brok UI

Loading…

No results

Map

Open source

An interactive MapLibre GL map with free CARTO light/dark basemaps (no API key) that flip with the theme. Boots MapLibre, resolves overlay colours from design tokens, and reads declarative child nodes — <x-ui.map.controls>, <x-ui.map.marker>, <x-ui.map.popup>, <x-ui.map.route>, <x-ui.map.arc>, <x-ui.map.cluster> — plus an optional heatmap layer. Cleans up the instance and observers on teardown.

Version
v1.2.3
Stability
stable
License
MIT
Related
Chart
Network Graph
Organization Chart

Preview

This map requires MapLibre GL. Run `npm install maplibre-gl`; until the bundle loads, a labelled placeholder is shown. Free CARTO basemaps flip with the theme — no API key.

Variant
Size
previews.components.map.default.blade.php Blade
<div class="space-y-4">
    <x-ui.map :center="[2.3522, 48.8566]" :zoom="11" height="420px" label="{{ __('Paris') }}">
        <x-ui.map.controls position="top-right" show-zoom show-compass show-fullscreen />
        <x-ui.map.marker :lng="2.3522" :lat="48.8566" color="primary" :label="__('Paris')">
            <x-slot:popup>
                <p class="font-medium text-foreground">{{ __('Paris') }}</p>
                <p class="text-muted-foreground">{{ __('City of Light') }}</p>
            </x-slot:popup>
        </x-ui.map.marker>
    </x-ui.map>

    <p class="text-sm text-muted-foreground">
        {{ __('This map requires MapLibre GL. Run `npm install maplibre-gl`; until the bundle loads, a labelled placeholder is shown. Free CARTO basemaps flip with the theme — no API key.') }}
    </p>
</div>

Variant options

Dot Current
Pin Current
Icon Current
Badge Current
Avatar Current

Size options

Sm Current
Md Current
Lg Current

Installation

terminal
php artisan ui:add map

Note

This component ships an Alpine behavior module at resources/js/ui/map.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './map.js';

Registry contract

php artisan ui:add map writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/map.blade.php
  • js resources/js/ui/map.js
  • blade resources/views/components/ui/map/marker.blade.php
  • blade resources/views/components/ui/map/popup.blade.php
  • blade resources/views/components/ui/map/route.blade.php
  • blade resources/views/components/ui/map/arc.blade.php
  • blade resources/views/components/ui/map/cluster.blade.php
  • blade resources/views/components/ui/map/controls.blade.php
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs
npm: maplibre-gl@^6.11.1

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

map.md
# Brok UI: Map (`map`)

An interactive MapLibre GL map with free CARTO light/dark basemaps (no API key) that flip with the theme. Boots MapLibre, resolves overlay colours from design tokens, and reads declarative child nodes — <x-ui.map.controls>, <x-ui.map.marker>, <x-ui.map.popup>, <x-ui.map.route>, <x-ui.map.arc>, <x-ui.map.cluster> — plus an optional heatmap layer. Cleans up the instance and observers on teardown.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add map
```

## Usage

```blade
<div class="space-y-4">
    <x-ui.map :center="[2.3522, 48.8566]" :zoom="11" height="420px" label="{{ __('Paris') }}">
        <x-ui.map.controls position="top-right" show-zoom show-compass show-fullscreen />
        <x-ui.map.marker :lng="2.3522" :lat="48.8566" color="primary" :label="__('Paris')">
            <x-slot:popup>
                <p class="font-medium text-foreground">{{ __('Paris') }}</p>
                <p class="text-muted-foreground">{{ __('City of Light') }}</p>
            </x-slot:popup>
        </x-ui.map.marker>
    </x-ui.map>

    <p class="text-sm text-muted-foreground">
        {{ __('This map requires MapLibre GL. Run `npm install maplibre-gl`; until the bundle loads, a labelled placeholder is shown. Free CARTO basemaps flip with the theme — no API key.') }}
    </p>
</div>
```

## Props

- `center` (array, default `[0, 20]`) — Initial camera centre as [longitude, latitude].
- `zoom` (float, default `1.5`) — Initial camera zoom level.
- `minZoom` (mixed|null, default `null`) — Lower zoom bound the camera cannot go past.
- `maxZoom` (mixed|null, default `null`) — Upper zoom bound the camera cannot go past.
- `pitch` (float, default `0`) — Initial camera tilt in degrees.
- `bearing` (float, default `0`) — Initial camera rotation in degrees.
- `interactive` (bool, default `true`) — Allows drag, scroll and zoom interaction; false renders a static, locked map.
- `projection` (mixed|null, default `null`) — globe rounds the world into a sphere at low zoom; mercator renders it flat.
- `styleLight` (mixed|null, default `null`) — Overrides the default light-mode CARTO basemap with any MapLibre style URL or object.
- `styleDark` (mixed|null, default `null`) — Overrides the default dark-mode CARTO basemap with any MapLibre style URL or object.
- `heatmap` (mixed|null, default `null`) — Optional heatmap layer: {data: <GeoJSON>} or {url: <GeoJSON url>}, plus optional weight, intensity, radius and colors (token names, low to high density).
- `fit` (bool, default `false`) — Fits the initial camera to everything declared inside (markers, popups, routes, arcs) instead of using center/zoom.
- `fitPadding` (int, default `48`) — Padding in pixels applied around the fitted bounds when fit is true.
- `height` (string, default `420px`) — CSS height for the map host; any CSS length, since maps have no intrinsic height.
- `label` (string, default `Map`) — Accessible name for the map's labelled region.
- `variant` (dot|pin|icon|badge|avatar, default `dot`) — Documented catalog control used by the preview workbench.
- `size` (sm|md|lg, default `md`) — Documented catalog control used by the preview workbench.
- `lng` (int, default `0`) — Declared by @props in the registry Blade source.
- `lat` (int, default `0`) — Declared by @props in the registry Blade source.
- `color` (string, default `primary`) — Declared by @props in the registry Blade source.
- `image` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `draggable` (bool, default `false`) — Declared by @props in the registry Blade source.
- `anchor` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `pulse` (bool, default `false`) — Declared by @props in the registry Blade source.
- `open` (bool, default `true`) — Declared by @props in the registry Blade source.
- `closeButton` (bool, default `true`) — Declared by @props in the registry Blade source.
- `closeOnClick` (bool, default `false`) — Declared by @props in the registry Blade source.
- `title` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `description` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `maxWidth` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `coordinates` (array, default `[]`) — Declared by @props in the registry Blade source.
- `width` (int, default `4`) — Declared by @props in the registry Blade source.
- `opacity` (float, default `0.85`) — Declared by @props in the registry Blade source.
- `dashed` (bool, default `false`) — Declared by @props in the registry Blade source.
- `casing` (bool, default `true`) — Declared by @props in the registry Blade source.
- `endpoints` (bool, default `false`) — Declared by @props in the registry Blade source.
- `animated` (bool, default `false`) — Declared by @props in the registry Blade source.
- `snap` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `router` (string, default `https:`) — Declared by @props in the registry Blade source.
- `from` (array, default `[]`) — Declared by @props in the registry Blade source.
- `to` (array, default `[]`) — Declared by @props in the registry Blade source.
- `curve` (float, default `0.3`) — Declared by @props in the registry Blade source.
- `url` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `data` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `radius` (int, default `50`) — Declared by @props in the registry Blade source.
- `colors` (array, default `['info', 'primary', 'warning']`) — Declared by @props in the registry Blade source.
- `pointColor` (string, default `primary`) — Declared by @props in the registry Blade source.
- `position` (string, default `top-right`) — Declared by @props in the registry Blade source.
- `showZoom` (bool, default `true`) — Declared by @props in the registry Blade source.
- `showCompass` (bool, default `true`) — Declared by @props in the registry Blade source.
- `showLocate` (bool, default `false`) — Declared by @props in the registry Blade source.
- `showFullscreen` (bool, default `false`) — Declared by @props in the registry Blade source.
- `showScale` (bool, default `false`) — Declared by @props in the registry Blade source.
- `showHome` (bool, default `false`) — Declared by @props in the registry Blade source.

## Use when

- Use when geography, route, clustering, or distance meaningfully affects decisions.
- Showing an interactive, pannable/zoomable geographic map with markers, routes, arcs, clusters or a heatmap layer.
- Needing a basemap that flips between light and dark with the app theme and ships no raw colours.

## Avoid when

- Do not use a map as decoration when location is not central to the task.
- Only use when location changes the decision, not as decorative scenery.
- A static illustrative map or a simple location icon is enough; a plain image is lighter than booting MapLibre.
- The data is not geographic; use chart, network-graph or org-chart instead.

## Anti-patterns

- Using a map as decorative scenery

## Rules

- Use the `<brok:map>` tag (or `<x-ui.map>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/map
- Registry JSON (files, props, contract): https://brokui.dev/r/open/map.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

overlays.blade.php Blade
{{-- Overlays: a courier pin with a pinging halo, a price badge, a route whose
     dash flows toward the drop-off, and a reset-view control. The halo and the
     flowing dash stop under reduced motion; the reset flies back only when
     motion is allowed and jumps otherwise. --}}
<x-ui.map :center="[4.8952, 52.3702]" :zoom="13" height="420px" fit :label="__('Courier route in Amsterdam')">
    <x-ui.map.controls position="top-right" show-zoom :show-compass="false" show-home />

    <x-ui.map.route
        :coordinates="[[4.8834, 52.3667], [4.8897, 52.3731], [4.8994, 52.3738], [4.9078, 52.3667]]"
        color="primary"
        animated
        endpoints
    />

    <x-ui.map.marker :lng="4.8994" :lat="52.3738" variant="pin" color="primary" pulse :label="__('Courier')">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
            <circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" />
        </svg>
    </x-ui.map.marker>

    <x-ui.map.marker :lng="4.9078" :lat="52.3667" variant="badge" color="success" :label="__('Drop-off, € 12 tip')">
        € 12
    </x-ui.map.marker>
</x-ui.map>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
center array [0, 20] Initial camera centre as [longitude, latitude].
zoom float 1.5 Initial camera zoom level.
minZoom mixed | null null Lower zoom bound the camera cannot go past.
maxZoom mixed | null null Upper zoom bound the camera cannot go past.
pitch float 0 Initial camera tilt in degrees.
bearing float 0 Initial camera rotation in degrees.
interactive bool true Allows drag, scroll and zoom interaction; false renders a static, locked map.
projection mixed | null null globe rounds the world into a sphere at low zoom; mercator renders it flat.
styleLight mixed | null null Overrides the default light-mode CARTO basemap with any MapLibre style URL or object.
styleDark mixed | null null Overrides the default dark-mode CARTO basemap with any MapLibre style URL or object.
heatmap mixed | null null Optional heatmap layer: {data: <GeoJSON>} or {url: <GeoJSON url>}, plus optional weight, intensity, radius and colors (token names, low to high density).
fit bool false Fits the initial camera to everything declared inside (markers, popups, routes, arcs) instead of using center/zoom.
fitPadding int 48 Padding in pixels applied around the fitted bounds when fit is true.
height string 420px CSS height for the map host; any CSS length, since maps have no intrinsic height.
label string Map Accessible name for the map's labelled region.
variant dot | pin | icon | badge | avatar dot Documented catalog control used by the preview workbench.
size sm | md | lg md Documented catalog control used by the preview workbench.
lng int 0 Declared by @props in the registry Blade source.
lat int 0 Declared by @props in the registry Blade source.
color string primary Declared by @props in the registry Blade source.
image mixed | null null Declared by @props in the registry Blade source.
draggable bool false Declared by @props in the registry Blade source.
anchor mixed | null null Declared by @props in the registry Blade source.
pulse bool false Declared by @props in the registry Blade source.
open bool true Declared by @props in the registry Blade source.
closeButton bool true Declared by @props in the registry Blade source.
closeOnClick bool false Declared by @props in the registry Blade source.
title mixed | null null Declared by @props in the registry Blade source.
description mixed | null null Declared by @props in the registry Blade source.
maxWidth mixed | null null Declared by @props in the registry Blade source.
coordinates array [] Declared by @props in the registry Blade source.
width int 4 Declared by @props in the registry Blade source.
opacity float 0.85 Declared by @props in the registry Blade source.
dashed bool false Declared by @props in the registry Blade source.
casing bool true Declared by @props in the registry Blade source.
endpoints bool false Declared by @props in the registry Blade source.
animated bool false Declared by @props in the registry Blade source.
snap mixed | null null Declared by @props in the registry Blade source.
router string https: Declared by @props in the registry Blade source.
from array [] Declared by @props in the registry Blade source.
to array [] Declared by @props in the registry Blade source.
curve float 0.3 Declared by @props in the registry Blade source.
url mixed | null null Declared by @props in the registry Blade source.
data mixed | null null Declared by @props in the registry Blade source.
radius int 50 Declared by @props in the registry Blade source.
colors array ['info', 'primary', 'warning'] Declared by @props in the registry Blade source.
pointColor string primary Declared by @props in the registry Blade source.
position string top-right Declared by @props in the registry Blade source.
showZoom bool true Declared by @props in the registry Blade source.
showCompass bool true Declared by @props in the registry Blade source.
showLocate bool false Declared by @props in the registry Blade source.
showFullscreen bool false Declared by @props in the registry Blade source.
showScale bool false Declared by @props in the registry Blade source.
showHome bool false Declared by @props in the registry Blade source.

Slots

  • default — Declarative child nodes: any mix of <x-ui.map.marker>, <x-ui.map.popup>, <x-ui.map.route>, <x-ui.map.arc>, <x-ui.map.cluster> and <x-ui.map.controls>, read after the map loads.
  • popup — On <x-ui.map.marker>: a popup shown when that marker is clicked, filled with markup instead of the plain title/description props.
  • x-ui.map.marker — Installed subcomponent from the registry item.
  • x-ui.map.popup — Installed subcomponent from the registry item.
  • x-ui.map.route — Installed subcomponent from the registry item.
  • x-ui.map.arc — Installed subcomponent from the registry item.
  • x-ui.map.cluster — Installed subcomponent from the registry item.
  • x-ui.map.controls — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

map map-arc map-cluster map-controls map-marker map-popup map-route

Behavior

  • Boots MapLibre GL into the canvas element and reads every declarative child node's data-* attributes after load, then adds the matching layers/markers/controls; children render visually hidden and never paint their own markup directly.
  • map-marker clones its element into a MapLibre DOM marker at [lng, lat] with dot/pin/icon/badge/avatar variants, and optionally opens a popup on click.
  • map-route and map-arc each add a line layer (a straight or road-snapped polyline, or a curved great-circle arc); map-cluster adds a clustered GeoJSON point source that zooms in on click; map-controls adds native MapLibre navigation/geolocate/fullscreen/scale controls plus an optional reset-view button.
  • Every overlay colour is a semantic token name resolved to a real colour from CSS variables at runtime, so the map re-themes itself when the app switches between light and dark.
  • Cleans up the MapLibre instance and its observers on teardown.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, reducedMotion.

Guidance

Geospatial view

Support a decision that depends on geography or distance.

Use when

  • Use when geography, route, clustering, or distance meaningfully affects decisions.
  • Showing an interactive, pannable/zoomable geographic map with markers, routes, arcs, clusters or a heatmap layer.
  • Needing a basemap that flips between light and dark with the app theme and ships no raw colours.

Avoid when

  • Do not use a map as decoration when location is not central to the task.
  • Only use when location changes the decision, not as decorative scenery.
  • A static illustrative map or a simple location icon is enough; a plain image is lighter than booting MapLibre.
  • The data is not geographic; use chart, network-graph or org-chart instead.

Use instead

  • Location list or distance table

Anti-patterns

  • Using a map as decorative scenery
Anatomy
root map-arc map-cluster map-controls map-marker map-popup map-route
Theming hooks
map-marker

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
none
  • The root carries role="region" with aria-label from label, so the map is announced as a named landmark rather than an opaque canvas.
  • MapLibre's own injected controls (zoom, compass, geolocate, fullscreen) stay keyboard-focusable, so the canvas is never marked aria-hidden despite being decorative.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<div wire:ignore>
    <div class="space-y-4">
        <x-ui.map :center="[2.3522, 48.8566]" :zoom="11" height="420px" label="{{ __('Paris') }}">
            <x-ui.map.controls position="top-right" show-zoom show-compass show-fullscreen />
            <x-ui.map.marker :lng="2.3522" :lat="48.8566" color="primary" :label="__('Paris')">
                <x-slot:popup>
                    <p class="font-medium text-foreground">{{ __('Paris') }}</p>
                    <p class="text-muted-foreground">{{ __('City of Light') }}</p>
                </x-slot:popup>
            </x-ui.map.marker>
        </x-ui.map>
    
        <p class="text-sm text-muted-foreground">
            {{ __('This map requires MapLibre GL. Run `npm install maplibre-gl`; until the bundle loads, a labelled placeholder is shown. Free CARTO basemaps flip with the theme — no API key.') }}
        </p>
    </div>
</div>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/map.blade.php Blade
@props([
    // Initial camera. center is [longitude, latitude].
    'center' => [0, 20],
    'zoom' => 1.5,
    'minZoom' => null,
    'maxZoom' => null,
    'pitch' => 0,
    'bearing' => 0,
    // Allow drag/scroll/zoom interaction. false renders a static, locked map.
    'interactive' => true,
    // 'globe' rounds the world into a sphere at low zoom; 'mercator' is flat.
    'projection' => null,
    // Override the default CARTO basemaps (any MapLibre style URL or object).
    'styleLight' => null,
    'styleDark' => null,
    // Optional heatmap layer. Pass ['data' => <GeoJSON FeatureCollection>] or
    // ['url' => '<GeoJSON url>'], plus optional 'weight' (feature property),
    // 'intensity', 'radius' and 'colors' (token names, low→high density).
    'heatmap' => null,
    // Fit the initial camera to everything declared inside (markers, popups,
    // routes, arcs) instead of `center`/`zoom`, with this padding in px.
    'fit' => false,
    'fitPadding' => 48,
    // CSS height for the map host (maps have no intrinsic height). Any CSS length.
    'height' => '420px',
    'label' => 'Map',
])

@php
    // Everything the behaviour layer needs is serialised here so no map config
    // leaks into the markup as raw values. Overlay colours are token NAMES
    // (e.g. 'primary'), resolved to real colours from CSS variables at runtime,
    // so the map themes itself and never ships a raw colour.
    $config = [
        'center' => array_map('floatval', array_values((array) $center)),
        'zoom' => (float) $zoom,
        'minZoom' => $minZoom !== null ? (float) $minZoom : null,
        'maxZoom' => $maxZoom !== null ? (float) $maxZoom : null,
        'pitch' => (float) $pitch,
        'bearing' => (float) $bearing,
        'interactive' => (bool) $interactive,
        'projection' => $projection,
        'styleLight' => $styleLight,
        'styleDark' => $styleDark,
        'heatmap' => is_array($heatmap) ? $heatmap : null,
        'fit' => (bool) $fit,
        'fitPadding' => (int) $fitPadding,
        'labels' => [
            'loading' => __('Loading map…'),
        ],
    ];
@endphp

<div
    x-data="uiMap(@js($config))"
    x-ref="root"
    data-slot="map"
    role="region"
    aria-label="{{ $label }}"
    style="height: {{ $height }}"
    {{ $attributes->merge(['class' => 'relative w-full overflow-hidden rounded-lg border border-border bg-muted']) }}
>
    {{-- MapLibre paints into this element. Kept full-bleed so it measures a real
         size on first render. The map's own labelled region (role/aria-label on
         the root) names it; MapLibre's injected controls are keyboard-accessible,
         so this must NOT be aria-hidden (that would hide focusable controls —
         axe: aria-hidden-focus). --}}
    <div x-ref="canvas" class="absolute inset-0 h-full w-full"></div>

    {{-- Declarative children (markers/routes/arcs/clusters/popups/controls).
         They render as hidden config nodes; the behaviour layer reads their
         data-* attributes after the map loads, then drives MapLibre. --}}
    <div x-ref="defs" hidden>{{ $slot }}</div>

    {{-- Loading / fallback overlay shown until MapLibre has initialised (so a
         page without the npm dependency degrades to a labelled placeholder). --}}
    <div
        x-show="!ready"
        x-cloak
        class="absolute inset-0 z-10 flex items-center justify-center bg-muted text-sm text-muted-foreground"
    >
        <span x-text="error || @js($config['labels']['loading'])"></span>
    </div>

    {{-- Theme the MapLibre popup as a popover surface so its content (which uses
         text-foreground) keeps contrast in dark mode instead of light text on the
         library's hard-coded white. hsl(var()) in <style> is the repo's safe
         token form (not an inline style — never trips the raw-colour guard). --}}
    <style>
        .maplibregl-popup-content {
            background: var(--popover-color, hsl(var(--popover)));
            color: var(--popover-foreground-color, hsl(var(--popover-foreground)));
            border: 1px solid var(--border-color, hsl(var(--border)));
            border-radius: var(--radius);
        }
        .maplibregl-popup-content { padding: 0.75rem; box-shadow: var(--shadow-lg); }
        .maplibregl-popup-close-button {
            position: absolute; inset-inline-end: 0.25rem; top: 0.25rem;
            display: inline-flex; align-items: center; justify-content: center;
            width: 1.5rem; height: 1.5rem; padding: 0; border-radius: 9999px;
            font-size: 1rem; line-height: 1;
            color: var(--muted-foreground-color, hsl(var(--muted-foreground)));
            transition: background-color 150ms, color 150ms;
        }
        .maplibregl-popup-close-button:hover {
            background: var(--muted-color, hsl(var(--muted)));
            color: var(--foreground-color, hsl(var(--foreground)));
        }
        .maplibregl-popup-close-button:focus-visible {
            outline: 2px solid var(--ring-color, hsl(var(--ring)));
            outline-offset: 1px;
        }
        .maplibregl-popup-close-button:focus:not(:focus-visible) { outline: none; }
        /* The house glyph is a mask so it takes the foreground token, like the
           popup text, instead of a baked colour. */
        .maplibregl-ctrl button.maplibregl-ctrl-home .maplibregl-ctrl-icon {
            background-color: var(--foreground-color, hsl(var(--foreground)));
            -webkit-mask: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 11 12 3l9 8'/%3E%3Cpath d='M5 10v10h5v-6h4v6h5V10'/%3E%3C/svg%3E") center / 18px no-repeat;
            mask: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 11 12 3l9 8'/%3E%3Cpath d='M5 10v10h5v-6h4v6h5V10'/%3E%3C/svg%3E") center / 18px no-repeat;
        }
        .ui-map-popup-tooltip .maplibregl-popup-content { padding: 0.375rem 0.625rem; border-radius: 9999px; }
        .ui-map-popup-card .maplibregl-popup-content { overflow: hidden; }
        .maplibregl-popup-anchor-top .maplibregl-popup-tip,
        .maplibregl-popup-anchor-top-left .maplibregl-popup-tip,
        .maplibregl-popup-anchor-top-right .maplibregl-popup-tip { border-bottom-color: var(--popover-color, hsl(var(--popover))); }
        .maplibregl-popup-anchor-bottom .maplibregl-popup-tip,
        .maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip,
        .maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip { border-top-color: var(--popover-color, hsl(var(--popover))); }
        .maplibregl-popup-anchor-left .maplibregl-popup-tip { border-right-color: var(--popover-color, hsl(var(--popover))); }
        .maplibregl-popup-anchor-right .maplibregl-popup-tip { border-left-color: var(--popover-color, hsl(var(--popover))); }
    </style>
</div>
resources/js/ui/map.js JS
/**
 * Map behaviour — an interactive MapLibre GL wrapper.
 *
 * Owns the MapLibre instance and the whole "declarative children" contract: the
 * sibling components (map-controls, map-marker, map-popup, map-route, map-arc,
 * map-cluster) render hidden config nodes inside the map; this reads their
 * data-* attributes after load and drives MapLibre. Heatmap layers come from the
 * map's own `heatmap` config.
 *
 * Theming: free CARTO basemaps (positron / dark-matter — no API key) flip with
 * the app's `.dark` class. Overlay colours are token NAMES resolved from CSS
 * variables at runtime (so the map never ships a raw colour). On a theme change
 * the basemap is swapped and the vector overlays are rebuilt; DOM markers/popups
 * persist across the swap.
 *
 * Lazy-loads `maplibre-gl` (JS + CSS) so pages without a map pay nothing. Tears
 * down markers, popups, observers and the map instance on destroy().
 *
 * Self-registers on `alpine:init`. Plain JS served by the registry — no Blade.
 */
document.addEventListener('alpine:init', () => {
    const STYLE_LIGHT = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
    const STYLE_DARK = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';

    // Stable ids for the vector overlay sources/layers this wrapper manages, so
    // a theme rebuild can remove the old ones before re-adding.
    const OVERLAY_PREFIX = 'ui-map-';

    window.Alpine.data('uiMap', (config = {}) => ({
        center: Array.isArray(config.center) ? config.center : [0, 20],
        zoom: typeof config.zoom === 'number' ? config.zoom : 1.5,
        cfg: config,

        ready: false,
        error: '',

        map: null,
        maplibre: null,
        _markers: [],
        _popups: [],
        _layerIds: [],
        _sourceIds: [],
        _loaded: false,
        _reduced: false,
        _themeObs: null,

        async init() {
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

            const maplibre = window.maplibregl ?? (await this.loadMaplibre());
            if (!maplibre) return;
            this.maplibre = maplibre;

            try {
                this.buildMap();
            } catch (e) {
                this.error = String(e?.message ?? e);
            }
        },

        async loadMaplibre() {
            try {
                // CSS first (controls/popups are unstyled without it), then the lib.
                await import('maplibre-gl/dist/maplibre-gl.css').catch(() => {});
                const [mod, workerSrc, sharedUrl] = await Promise.all([
                    import('maplibre-gl'),
                    // MapLibre GL (5.x+) ships its worker as its own module,
                    // `maplibre-gl-worker.mjs`, which statically imports a sibling
                    // `maplibre-gl-shared.mjs` chunk by a bare relative specifier —
                    // correct only when both files still sit side by side, as they do
                    // in the published package (see MapLibre's "Getting Started" CDN
                    // snippet, which loads the package straight off unpkg). A bundler
                    // hashes and relocates each file independently, so once
                    // maplibre-gl-worker.mjs is bundled, its relative import 404s
                    // ("Worker failed to load"). We pull the worker's own source as
                    // text (`?raw`) so we can patch that one import to the shared
                    // chunk's real, bundler-emitted URL, then hand MapLibre the
                    // patched source as a blob: URL via setWorkerUrl() (see
                    // maplibre-gl/src/index.ts). This works the same way under the
                    // docs app's Vite build and a consumer Laravel+Vite build — no
                    // bundler-specific config needed.
                    import('maplibre-gl/dist/maplibre-gl-worker.mjs?raw')
                        .then((m) => m.default ?? m)
                        .catch(() => null),
                    import('maplibre-gl/dist/maplibre-gl-shared.mjs?url')
                        .then((m) => m.default ?? m)
                        .catch(() => null),
                ]);
                const maplibre = mod.default ?? mod;
                if (workerSrc && sharedUrl && typeof maplibre.setWorkerUrl === 'function') {
                    // A blob: module worker fails to resolve a root-relative import
                    // specifier ("/build/assets/...", what Vite's `?url` import gives
                    // back when the app has no CDN base) — it must be fully qualified
                    // with an origin, unlike a same-document <script type="module">.
                    const absoluteSharedUrl = new URL(sharedUrl, import.meta.url).href;
                    const patched = workerSrc.replace('./maplibre-gl-shared.mjs', absoluteSharedUrl);
                    const blobUrl = URL.createObjectURL(new Blob([patched], { type: 'text/javascript' }));
                    maplibre.setWorkerUrl(blobUrl);
                }
                window.maplibregl = maplibre;
                return maplibre;
            } catch (e) {
                this.error = String(e?.message ?? e);
                return null;
            }
        },

        styleUrl() {
            const dark = document.documentElement.classList.contains('dark');
            if (dark) return this.cfg.styleDark ?? STYLE_DARK;
            return this.cfg.styleLight ?? STYLE_LIGHT;
        },

        buildMap() {
            const opts = {
                container: this.$refs.canvas,
                style: this.styleUrl(),
                center: this.center,
                zoom: this.zoom,
                pitch: this.cfg.pitch ?? 0,
                bearing: this.cfg.bearing ?? 0,
                interactive: this.cfg.interactive !== false,
                attributionControl: { compact: true },
                fadeDuration: this._reduced ? 0 : 300,
            };
            if (this.cfg.minZoom != null) opts.minZoom = this.cfg.minZoom;
            if (this.cfg.maxZoom != null) opts.maxZoom = this.cfg.maxZoom;

            const map = new this.maplibre.Map(opts);
            this.map = map;

            map.on('load', () => {
                this._loaded = true;
                if (this.cfg.projection && map.setProjection) {
                    try {
                        map.setProjection({ type: this.cfg.projection });
                    } catch (e) {
                        /* projection unsupported on this MapLibre build — ignore */
                    }
                }
                this.applyMarkers();
                this.applyPopups();
                this.rebuildLayers();
                if (this.cfg.fit) this.fitToContent();
                this._home = { center: map.getCenter(), zoom: map.getZoom(), pitch: map.getPitch(), bearing: map.getBearing() };
                this.applyControls();
                this.ready = true;
            });

            // Fires after every setStyle() too — used to re-add vector overlays
            // when the basemap is swapped on a theme change.
            map.on('style.load', () => {
                if (this._loaded) this.rebuildLayers();
            });

            map.on('error', (e) => {
                // Tile/network errors shouldn't blank the whole component.
                if (!this.ready) this.error = String(e?.error?.message ?? '');
            });

            this.watchTheme();
        },

        watchTheme() {
            let dark = document.documentElement.classList.contains('dark');
            this._themeObs = new MutationObserver(() => {
                const next = document.documentElement.classList.contains('dark');
                if (next === dark || !this.map) return;
                dark = next;
                this.map.setStyle(this.styleUrl());
            });
            this._themeObs.observe(document.documentElement, {
                attributes: true,
                attributeFilter: ['class'],
            });
        },

        /* ---- colour resolution ------------------------------------------ */

        // Resolve a token NAME ('primary', 'success', …) to a real colour via the
        // CSS custom property it maps to. Passes through explicit colours and the
        // 'transparent' keyword. Never returns a baked palette value.
        resolveColor(name, fallback = 'primary') {
            const n = String(name ?? '').trim();
            if (!n) return this.resolveColor(fallback);
            // Tokens first: `background` is also a legacy CSS system-colour
            // keyword, so `CSS.supports` alone would pass the bare word through
            // and MapLibre would reject it.
            const resolved = window.UI?.resolveColor?.(n, this.$refs.root, '') || this.readToken(n);
            if (resolved) return resolved;
            if (n === 'transparent' || CSS.supports('color', n)) {
                return n;
            }

            return window.UI?.resolveColor?.(fallback, this.$refs.root, '') || this.readToken(fallback) || 'currentColor';
        },

        // Fallback when the shared token runtime (`ui/index.js`) is not loaded:
        // read the custom property straight off the host and wrap bare HSL
        // channels. MapLibre rejects `currentColor`, so this must return a real
        // colour or nothing.
        readToken(name) {
            const base = String(name).replace(/^--(?:color-)?/, '').replace(/-color$/, '');
            const styles = getComputedStyle(this.$refs.root);
            for (const prop of [`--${base}-color`, `--color-${base}`, `--${base}`]) {
                const raw = styles.getPropertyValue(prop).trim();
                if (!raw) continue;
                const value = /^-?\d*\.?\d+\s+-?\d*\.?\d+%\s+-?\d*\.?\d+%/.test(raw) ? `hsl(${raw})` : raw;
                if (CSS.supports('color', value)) return value;
            }

            return '';
        },

        /* ---- declarative child readers ---------------------------------- */

        defs(selector) {
            const root = this.$refs.defs;
            return root ? Array.from(root.querySelectorAll(selector)) : [];
        },

        num(el, key, fallback) {
            const v = parseFloat(el.dataset[key]);
            return Number.isFinite(v) ? v : fallback;
        },

        json(el, key, fallback) {
            try {
                return el.dataset[key] ? JSON.parse(el.dataset[key]) : fallback;
            } catch (e) {
                return fallback;
            }
        },

        // Every coordinate the declarative children carry, so the camera can
        // frame them all.
        fitToContent() {
            const points = [];
            this.defs('[data-map-marker], [data-map-popup]').forEach((el) => points.push([this.num(el, 'lng', 0), this.num(el, 'lat', 0)]));
            this.defs('[data-map-route]').forEach((el) => points.push(...this.json(el, 'coordinates', [])));
            this.defs('[data-map-arc]').forEach((el) => {
                const from = this.json(el, 'from', null);
                const to = this.json(el, 'to', null);
                if (from && to) points.push(from, to);
            });
            if (points.length < 2) return;
            const lngs = points.map((p) => p[0]);
            const lats = points.map((p) => p[1]);
            this.map.fitBounds(
                [
                    [Math.min(...lngs), Math.min(...lats)],
                    [Math.max(...lngs), Math.max(...lats)],
                ],
                { padding: this.cfg.fitPadding ?? 48, duration: 0, maxZoom: 15 },
            );
        },

        applyControls() {
            const M = this.maplibre;
            this.defs('[data-map-controls]').forEach((el) => {
                const pos = el.dataset.position || 'top-right';
                if (el.dataset.zoom === '1' || el.dataset.compass === '1') {
                    this.map.addControl(
                        new M.NavigationControl({
                            showZoom: el.dataset.zoom === '1',
                            showCompass: el.dataset.compass === '1',
                            visualizePitch: true,
                        }),
                        pos,
                    );
                }
                if (el.dataset.locate === '1') {
                    this.map.addControl(
                        new M.GeolocateControl({
                            positionOptions: { enableHighAccuracy: true },
                            trackUserLocation: true,
                        }),
                        pos,
                    );
                }
                if (el.dataset.fullscreen === '1') {
                    this.map.addControl(new M.FullscreenControl(), pos);
                }
                if (el.dataset.home === '1') {
                    this.map.addControl(this.homeControl(), pos);
                }
                if (el.dataset.scale === '1') {
                    this.map.addControl(new M.ScaleControl({ unit: 'metric' }), 'bottom-left');
                }
            });
        },

        // A MapLibre IControl that flies back to the initial camera. Styled by
        // MapLibre's own control CSS so it sits with the zoom buttons.
        homeControl() {
            const self = this;

            return {
                onAdd() {
                    this._container = document.createElement('div');
                    this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group';
                    const button = document.createElement('button');
                    button.type = 'button';
                    button.className = 'maplibregl-ctrl-home';
                    button.title = 'Reset view';
                    button.setAttribute('aria-label', 'Reset view');
                    const icon = document.createElement('span');
                    icon.className = 'maplibregl-ctrl-icon';
                    icon.setAttribute('aria-hidden', 'true');
                    button.appendChild(icon);
                    button.addEventListener('click', () => {
                        if (!self.map || !self._home) return;
                        self.map[self._reduced ? 'jumpTo' : 'flyTo']({ ...self._home, essential: true });
                    });
                    this._container.appendChild(button);

                    return this._container;
                },
                onRemove() {
                    this._container?.remove();
                },
            };
        },

        applyMarkers() {
            const M = this.maplibre;
            this.defs('[data-map-marker]').forEach((el) => {
                const lng = this.num(el, 'lng', 0);
                const lat = this.num(el, 'lat', 0);
                const tpl = el.querySelector('[data-marker-el]');
                const element = tpl ? tpl.cloneNode(true) : undefined;
                if (element) element.removeAttribute('data-marker-el');

                const marker = new M.Marker({
                    element,
                    draggable: el.dataset.draggable === '1',
                    anchor: el.dataset.anchor || 'center',
                })
                    .setLngLat([lng, lat])
                    .addTo(this.map);

                const popupTpl = el.querySelector('[data-marker-popup]');
                if (popupTpl) {
                    // A pin's popup lifts clear of the head; other variants
                    // offset by their own half size.
                    const offset = el.dataset.variant === 'pin' ? { bottom: [0, -36], top: [0, 4], left: [12, -18], right: [-12, -18] } : 16;
                    const popup = new M.Popup({ offset, closeButton: true, focusAfterOpen: true }).setDOMContent(
                        this.fromTemplate(popupTpl),
                    );
                    marker.setPopup(popup);
                }
                if (el.dataset.label) {
                    // The marker element is focusable and opens a popup, so give it
                    // a button role — aria-label is prohibited on a roleless element.
                    const markerEl = marker.getElement();
                    markerEl.setAttribute('role', 'button');
                    markerEl.setAttribute('aria-label', el.dataset.label);
                    markerEl.setAttribute('title', el.dataset.label);
                }
                this._markers.push(marker);
            });
        },

        applyPopups() {
            const M = this.maplibre;
            this.defs('[data-map-popup]').forEach((el) => {
                if (el.dataset.open === '0') return;
                const popup = new M.Popup({
                    closeButton: el.dataset.closeButton !== '0',
                    closeOnClick: el.dataset.closeOnClick === '1',
                    anchor: el.dataset.anchor || undefined,
                    offset: 12,
                    maxWidth: el.dataset.maxWidth || '240px',
                    className: el.dataset.variant ? `ui-map-popup-${el.dataset.variant}` : undefined,
                    // A popup that is simply there on load must not steal
                    // focus (and paint a focus ring on its close button).
                    focusAfterOpen: false,
                })
                    .setLngLat([this.num(el, 'lng', 0), this.num(el, 'lat', 0)])
                    .setDOMContent(this.fromTemplate(el.querySelector('[data-popup-content]')))
                    .addTo(this.map);
                this._popups.push(popup);
            });
        },

        // Popup bodies are always authored as <template> nodes by the marker /
        // popup children, so we clone the inert DocumentFragment (no innerHTML
        // sink — nothing to inject into) rather than parsing a string.
        fromTemplate(tpl) {
            const wrap = document.createElement('div');
            wrap.className = 'ui-map-popup text-sm text-foreground';
            if (tpl && 'content' in tpl) wrap.appendChild(tpl.content.cloneNode(true));
            return wrap;
        },

        /* ---- vector overlays (rebuilt on theme change) ------------------ */

        rebuildLayers() {
            this.clearLayers();
            this.applyRoutes();
            this.applyArcs();
            this.applyClusters();
            this.applyHeatmap();
        },

        addManaged(id, source, layer) {
            if (this.map.getSource(id)) this.removeManaged(id);
            this.map.addSource(id, source);
            this._sourceIds.push(id);
            (Array.isArray(layer) ? layer : [layer]).forEach((l) => {
                this.map.addLayer(l);
                this._layerIds.push(l.id);
            });
        },

        removeManaged(id) {
            this._layerIds
                .filter((lid) => lid.startsWith(id))
                .forEach((lid) => this.map.getLayer(lid) && this.map.removeLayer(lid));
            this._layerIds = this._layerIds.filter((lid) => !lid.startsWith(id));
            if (this.map.getSource(id)) this.map.removeSource(id);
            this._sourceIds = this._sourceIds.filter((sid) => sid !== id);
        },

        clearLayers() {
            [...this._sourceIds].forEach((id) => this.removeManaged(id));
            this._animated?.clear();
        },

        applyRoutes() {
            this.defs('[data-map-route]').forEach((el, i) => {
                const waypoints = this.json(el, 'coordinates', []);
                if (!waypoints.length) return;
                const id = `${OVERLAY_PREFIX}route-${i}`;
                const dashed = el.dataset.dashed === '1';
                const animated = el.dataset.animated === '1' && !this._reduced;
                const color = this.resolveColor(el.dataset.color);
                const width = this.num(el, 'width', 4);
                const bg = this.resolveColor('background');
                // A snapped route keeps its street geometry across theme rebuilds.
                const coords = el._snapped ?? waypoints;

                const layers = [];
                if (el.dataset.casing === '1') {
                    layers.push({
                        id: `${id}-casing`,
                        type: 'line',
                        source: id,
                        layout: { 'line-cap': 'round', 'line-join': 'round' },
                        paint: { 'line-color': bg, 'line-width': width + 3, 'line-opacity': 0.9 },
                    });
                }
                layers.push({
                    id,
                    type: 'line',
                    source: id,
                    layout: { 'line-cap': animated ? 'butt' : 'round', 'line-join': 'round' },
                    paint: {
                        'line-color': color,
                        'line-width': width,
                        'line-opacity': this.num(el, 'opacity', 0.85),
                        ...(dashed || animated ? { 'line-dasharray': [2, 2] } : {}),
                    },
                });
                if (el.dataset.endpoints === '1') {
                    layers.push({
                        id: `${id}-ends`,
                        type: 'circle',
                        source: `${id}-ends`,
                        paint: {
                            'circle-radius': width + 2,
                            'circle-color': ['case', ['==', ['get', 'end'], 'start'], bg, color],
                            'circle-stroke-color': color,
                            'circle-stroke-width': 2.5,
                        },
                    });
                }

                const line = (c) => ({ type: 'Feature', geometry: { type: 'LineString', coordinates: c } });
                const ends = (c) => ({
                    type: 'FeatureCollection',
                    features: [
                        { type: 'Feature', properties: { end: 'start' }, geometry: { type: 'Point', coordinates: c[0] } },
                        { type: 'Feature', properties: { end: 'finish' }, geometry: { type: 'Point', coordinates: c[c.length - 1] } },
                    ],
                });

                if (el.dataset.endpoints === '1') {
                    this.addManaged(`${id}-ends`, { type: 'geojson', data: ends(coords) }, []);
                }
                this.addManaged(id, { type: 'geojson', data: line(coords) }, layers);

                if (animated) this.animateDash(id);

                if (el.dataset.snap && !el._snapped) {
                    this.snapRoute(el, waypoints).then((snapped) => {
                        if (!snapped || !this.map) return;
                        el._snapped = snapped;
                        this.map.getSource(id)?.setData(line(snapped));
                        this.map.getSource(`${id}-ends`)?.setData(ends(snapped));
                    });
                }
            });
        },

        // Ask an OSRM-compatible router for the street geometry through the
        // waypoints. Any failure (offline, quota, bad profile) leaves the
        // straight polyline in place — the route never disappears.
        async snapRoute(el, waypoints) {
            const profile = { driving: 'driving', cycling: 'cycling', walking: 'walking' }[el.dataset.snap] ?? 'driving';
            const path = waypoints.map(([lng, lat]) => `${lng},${lat}`).join(';');
            const url = `${el.dataset.router}/route/v1/${profile}/${path}?overview=full&geometries=geojson`;
            try {
                const res = await fetch(url, { signal: AbortSignal.timeout?.(8000) });
                if (!res.ok) return null;
                const body = await res.json();
                const coords = body?.routes?.[0]?.geometry?.coordinates;

                return Array.isArray(coords) && coords.length > 1 ? coords : null;
            } catch (e) {
                return null;
            }
        },

        // Flowing dash: step the dash pattern through its phases (the MapLibre
        // "animate a line" technique). One loop serves every animated route.
        animateDash(layerId) {
            this._animated ??= new Set();
            this._animated.add(layerId);
            if (this._dashRaf) return;
            const patterns = [
                [0, 4, 3], [0.5, 4, 2.5], [1, 4, 2], [1.5, 4, 1.5], [2, 4, 1], [2.5, 4, 0.5],
                [3, 4, 0], [0, 0.5, 3, 3.5], [0, 1, 3, 3], [0, 1.5, 3, 2.5], [0, 2, 3, 2], [0, 2.5, 3, 1.5], [0, 3, 3, 1], [0, 3.5, 3, 0.5],
            ];
            let step = 0;
            let last = 0;
            const tick = (now) => {
                this._dashRaf = requestAnimationFrame(tick);
                if (now - last < 60 || !this.map) return;
                last = now;
                step = (step + 1) % patterns.length;
                this._animated.forEach((id) => {
                    if (this.map.getLayer(id)) this.map.setPaintProperty(id, 'line-dasharray', patterns[step]);
                });
            };
            this._dashRaf = requestAnimationFrame(tick);
        },

        applyArcs() {
            const features = this.defs('[data-map-arc]')
                .map((el) => {
                    const from = this.json(el, 'from', null);
                    const to = this.json(el, 'to', null);
                    if (!from || !to) return null;
                    return {
                        type: 'Feature',
                        properties: {
                            color: this.resolveColor(el.dataset.color),
                            width: this.num(el, 'width', 2),
                            dashed: el.dataset.dashed === '1' ? 1 : 0,
                        },
                        geometry: {
                            type: 'LineString',
                            coordinates: this.greatCircle(from, to, this.num(el, 'curve', 0.3)),
                        },
                    };
                })
                .filter(Boolean);
            if (!features.length) return;

            const id = `${OVERLAY_PREFIX}arcs`;
            this.addManaged(
                id,
                { type: 'geojson', data: { type: 'FeatureCollection', features } },
                {
                    id,
                    type: 'line',
                    source: id,
                    layout: { 'line-cap': 'round', 'line-join': 'round' },
                    paint: {
                        'line-color': ['get', 'color'],
                        'line-width': ['get', 'width'],
                        'line-opacity': 0.85,
                    },
                },
            );
        },

        // Slerp along the great circle between two [lng,lat] points, then lift the
        // mid-section so the line bows into an arc (curve = bow height factor).
        greatCircle(from, to, curve = 0.3, steps = 64) {
            const rad = Math.PI / 180;
            const deg = 180 / Math.PI;
            const [lng1, lat1] = from.map((v) => v * rad);
            const [lng2, lat2] = to.map((v) => v * rad);
            const d =
                2 *
                Math.asin(
                    Math.sqrt(
                        Math.sin((lat2 - lat1) / 2) ** 2 +
                            Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2,
                    ),
                );
            if (d === 0) return [from, to];
            const out = [];
            for (let i = 0; i <= steps; i++) {
                const f = i / steps;
                const A = Math.sin((1 - f) * d) / Math.sin(d);
                const B = Math.sin(f * d) / Math.sin(d);
                const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2);
                const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2);
                const z = A * Math.sin(lat1) + B * Math.sin(lat2);
                let lat = Math.atan2(z, Math.sqrt(x * x + y * y));
                const lng = Math.atan2(y, x);
                // Bow toward the poles, strongest at the midpoint.
                lat += curve * d * Math.sin(f * Math.PI);
                out.push([lng * deg, lat * deg]);
            }
            return out;
        },

        applyClusters() {
            this.defs('[data-map-cluster]').forEach((el, i) => {
                const data = el.dataset.url || this.json(el, 'geojson', null);
                if (!data) return;
                const id = `${OVERLAY_PREFIX}cluster-${i}`;
                const colors = this.json(el, 'colors', ['info', 'primary', 'warning']).map((c) =>
                    this.resolveColor(c),
                );
                const point = this.resolveColor(el.dataset.pointColor || 'primary');
                const bg = this.resolveColor('background');

                this.addManaged(
                    id,
                    {
                        type: 'geojson',
                        data,
                        cluster: true,
                        clusterRadius: this.num(el, 'radius', 50),
                        clusterMaxZoom: this.num(el, 'maxZoom', 14),
                    },
                    [
                        {
                            id: `${id}-clusters`,
                            type: 'circle',
                            source: id,
                            filter: ['has', 'point_count'],
                            paint: {
                                'circle-color': [
                                    'step',
                                    ['get', 'point_count'],
                                    colors[0],
                                    25,
                                    colors[1] ?? colors[0],
                                    100,
                                    colors[2] ?? colors[1] ?? colors[0],
                                ],
                                'circle-radius': ['step', ['get', 'point_count'], 16, 25, 22, 100, 30],
                                'circle-stroke-width': 2,
                                'circle-stroke-color': bg,
                            },
                        },
                        {
                            id: `${id}-count`,
                            type: 'symbol',
                            source: id,
                            filter: ['has', 'point_count'],
                            layout: {
                                'text-field': '{point_count_abbreviated}',
                                'text-size': 12,
                                'text-font': ['Open Sans Bold', 'Noto Sans Bold'],
                            },
                            paint: { 'text-color': bg },
                        },
                        {
                            id: `${id}-point`,
                            type: 'circle',
                            source: id,
                            filter: ['!', ['has', 'point_count']],
                            paint: {
                                'circle-color': point,
                                'circle-radius': 6,
                                'circle-stroke-width': 1.5,
                                'circle-stroke-color': bg,
                            },
                        },
                    ],
                );

                // Click a cluster to zoom into it.
                this.map.on('click', `${id}-clusters`, (e) => {
                    const f = this.map.queryRenderedFeatures(e.point, { layers: [`${id}-clusters`] })[0];
                    if (!f) return;
                    const src = this.map.getSource(id);
                    src.getClusterExpansionZoom(f.properties.cluster_id).then((zoom) => {
                        if (zoom != null) this.map.easeTo({ center: f.geometry.coordinates, zoom });
                    });
                });
                const hover = (on) => () => (this.map.getCanvas().style.cursor = on ? 'pointer' : '');
                this.map.on('mouseenter', `${id}-clusters`, hover(true));
                this.map.on('mouseleave', `${id}-clusters`, hover(false));
            });
        },

        applyHeatmap() {
            const h = this.cfg.heatmap;
            if (!h || (!h.data && !h.url)) return;
            const id = `${OVERLAY_PREFIX}heat`;
            const weight = h.weight || null;
            const intensity = h.intensity ?? 1;
            const radius = h.radius ?? 30;
            const stops = (h.colors ?? ['info', 'primary', 'warning', 'destructive']).map((c) =>
                this.resolveColor(c),
            );

            const ramp = ['interpolate', ['linear'], ['heatmap-density'], 0, 'transparent'];
            stops.forEach((c, idx) => ramp.push((idx + 1) / stops.length, c));

            this.addManaged(
                id,
                { type: 'geojson', data: h.url || h.data },
                {
                    id,
                    type: 'heatmap',
                    source: id,
                    paint: {
                        ...(weight
                            ? {
                                  'heatmap-weight': [
                                      'interpolate',
                                      ['linear'],
                                      ['get', weight],
                                      0,
                                      0,
                                      6,
                                      1,
                                  ],
                              }
                            : {}),
                        'heatmap-intensity': [
                            'interpolate',
                            ['linear'],
                            ['zoom'],
                            0,
                            intensity,
                            9,
                            intensity * 3,
                        ],
                        'heatmap-color': ramp,
                        'heatmap-radius': [
                            'interpolate',
                            ['linear'],
                            ['zoom'],
                            0,
                            radius * 0.6,
                            9,
                            radius,
                        ],
                        'heatmap-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.9, 11, 0.5],
                    },
                },
            );
        },

        /* ---- teardown --------------------------------------------------- */

        destroy() {
            if (this._dashRaf) cancelAnimationFrame(this._dashRaf);
            this._dashRaf = 0;
            if (this._themeObs) this._themeObs.disconnect();
            this._markers.forEach((m) => m.remove());
            this._popups.forEach((p) => p.remove());
            this._markers = [];
            this._popups = [];
            if (this.map) {
                this.map.remove();
                this.map = null;
            }
        },
    }));
});
resources/views/components/ui/map/marker.blade.php Blade
@props([
    'lng' => 0,
    'lat' => 0,
    // Token name for the pin colour: primary | success | warning | destructive | info | muted.
    'color' => 'primary',
    // dot (a ringed disc on the coordinate) | pin (a teardrop whose tip is the
    // coordinate, with the slot — an icon or a digit — inside) | icon (a round
    // disc holding the slot) | badge (a pill of text, e.g. a price) | avatar
    // (a ringed photo — pass `image`).
    'variant' => 'dot',
    'size' => 'md',
    // avatar only: the image URL. Falls back to the slot (e.g. initials).
    'image' => null,
    'draggable' => false,
    // MapLibre anchor for the element relative to its coordinate. Defaults to
    // the variant's natural anchor (bottom for a pin, center otherwise).
    'anchor' => null,
    'label' => null,
    // Adds a soft pinging halo (disabled automatically under reduced motion via CSS).
    'pulse' => false,
])

@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $variant = $styles['normalizeVariant']($variant);
    $variant = in_array($variant, ['dot', 'pin', 'icon', 'badge', 'avatar'], true) ? $variant : 'dot';
    $tones = $styles['map-marker']['tones'];
    $tone = $tones[$color] ?? $tones['primary'];
    $text = $styles['map-marker']['text'][$color] ?? $styles['map-marker']['text']['primary'];
    $size = in_array($size, ['sm', 'md', 'lg'], true) ? $size : 'md';
    $sizes = $styles['map-marker']['sizes'][$size];
    $anchor ??= $variant === 'pin' ? 'bottom' : 'center';
@endphp

{{-- Declarative config node for <x-ui.map>. The map clones [data-marker-el] into
     a MapLibre DOM marker at [lng,lat]; an optional <x-slot:popup> becomes the
     marker's popup. The colour is a token name resolved through _styles, so no
     raw colour ever ships in markup. --}}
<div
    data-slot="map-marker"
    data-map-marker
    data-variant="{{ $variant }}"
    data-lng="{{ $lng }}"
    data-lat="{{ $lat }}"
    data-draggable="{{ $draggable ? '1' : '0' }}"
    data-anchor="{{ $anchor }}"
    @if($label) data-label="{{ $label }}" @endif
    hidden
    {{ $attributes->merge(['class' => 'hidden']) }}
>
    @if ($variant === 'pin')
        {{-- Teardrop pin: the tip sits on the coordinate (anchor bottom). The
             slot (an icon, a number) sits in the head on a page-coloured disc. --}}
        <div data-marker-el class="relative flex {{ $sizes['pin'] }} cursor-pointer flex-col items-center {{ $text }} drop-shadow-md">
            @if($pulse)
                <span class="absolute bottom-0 left-1/2 size-4 -translate-x-1/2 translate-y-1/2 rounded-full {{ $tone }} opacity-50 motion-safe:animate-ping"></span>
            @endif
            <svg viewBox="0 0 24 32" class="w-full" fill="currentColor" aria-hidden="true">
                <path d="M12 0C5.4 0 0 5.3 0 11.9c0 8.4 10.6 18.6 11.1 19.1a1.3 1.3 0 0 0 1.8 0C13.4 30.5 24 20.3 24 11.9 24 5.3 18.6 0 12 0Z" />
                <circle cx="12" cy="12" r="7.5" class="fill-background" />
            </svg>
            <span class="absolute left-1/2 top-[37.5%] flex size-[45%] -translate-x-1/2 -translate-y-1/2 items-center justify-center text-foreground [&>svg]:size-full">
                {{ $slot }}
            </span>
        </div>
    @elseif ($variant === 'icon')
        <div data-marker-el class="relative flex {{ $sizes['icon'] }} cursor-pointer items-center justify-center rounded-full {{ $tone }} text-primary-foreground shadow-md ring-2 ring-background [&>svg]:size-[55%]">
            @if($pulse)
                <span class="absolute inset-0 rounded-full {{ $tone }} opacity-60 motion-safe:animate-ping"></span>
            @endif
            <span class="relative flex items-center justify-center [&>svg]:size-full">{{ $slot }}</span>
        </div>
    @elseif ($variant === 'badge')
        <div data-marker-el class="relative inline-flex {{ $sizes['badge'] }} cursor-pointer items-center gap-1 whitespace-nowrap rounded-full {{ $tone }} font-semibold text-primary-foreground shadow-md ring-2 ring-background transition-transform hover:scale-105 motion-reduce:transition-none">
            {{ $slot }}
        </div>
    @elseif ($variant === 'avatar')
        <div data-marker-el class="relative flex {{ $sizes['avatar'] }} cursor-pointer items-center justify-center overflow-hidden rounded-full bg-muted text-xs font-semibold text-foreground shadow-md ring-2 ring-offset-2 ring-offset-background {{ str_replace('text-', 'ring-', $text) }}">
            @if ($image)
                <img src="{{ $image }}" alt="" class="size-full object-cover" loading="lazy" />
            @else
                {{ $slot }}
            @endif
        </div>
    @else
        {{-- The focusable element is a 24px hit box (the WCAG 2.5.8 floor) with
             the smaller visible disc centred inside it: the map canvas is itself
             focusable, so a bare 12–20px dot has no spacing exemption. --}}
        <div data-marker-el class="relative flex size-6 cursor-pointer items-center justify-center text-primary-foreground">
            <span class="absolute inline-flex {{ $sizes['dot'] }} rounded-full {{ $tone }} shadow ring-2 ring-background"></span>
            @if($pulse)
                <span class="absolute inline-flex {{ $sizes['dot'] }} motion-safe:animate-ping rounded-full {{ $tone }} opacity-60"></span>
            @endif
            {{ $slot }}
        </div>
    @endif

    @isset($popup)
        <template data-marker-popup>{{ $popup }}</template>
    @endisset
</div>
resources/views/components/ui/map/popup.blade.php Blade
@props([
    'lng' => 0,
    'lat' => 0,
    // Render the popup on load. Set false to declare one the map opens later.
    'open' => true,
    'closeButton' => true,
    'closeOnClick' => false,
    // Optional MapLibre anchor: top | bottom | left | right | top-left | …
    'anchor' => null,
    // default (a bordered popover) | card (an image on top, title + description
    // below — a listing/place card) | tooltip (a compact label with no close
    // button, for hover-style callouts).
    'variant' => 'default',
    'title' => null,
    'description' => null,
    // card only: image URL shown edge to edge above the text.
    'image' => null,
    // Maximum width as any CSS length (MapLibre's maxWidth option).
    'maxWidth' => null,
])

@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $variant = $styles['normalizeVariant']($variant);
    $variant = in_array($variant, ['default', 'card', 'tooltip'], true) ? $variant : 'default';
    if ($variant === 'tooltip') {
        $closeButton = false;
    }
    $maxWidth ??= $variant === 'card' ? '260px' : '240px';
@endphp

{{-- Standalone popup config node for <x-ui.map> — not attached to a marker. The
     map reads these data-* values and the [data-popup-content] template after
     load, then shows a MapLibre popup at [lng,lat]. --}}
<div
    data-slot="map-popup"
    data-map-popup
    data-variant="{{ $variant }}"
    data-lng="{{ $lng }}"
    data-lat="{{ $lat }}"
    data-open="{{ $open ? '1' : '0' }}"
    data-close-button="{{ $closeButton ? '1' : '0' }}"
    data-close-on-click="{{ $closeOnClick ? '1' : '0' }}"
    data-max-width="{{ $maxWidth }}"
    @if($anchor) data-anchor="{{ $anchor }}" @endif
    hidden
    {{ $attributes->merge(['class' => 'hidden text-foreground']) }}
>
    <template data-popup-content>
        @if ($variant === 'card')
            @if ($image)
                <img src="{{ $image }}" alt="" class="-mx-3 -mt-3 mb-3 block aspect-[16/10] w-[calc(100%+1.5rem)] max-w-none object-cover" loading="lazy" />
            @endif
        @endif
        @if ($title)
            <p class="{{ $variant === 'tooltip' ? 'text-xs font-medium' : 'text-sm font-semibold' }} text-foreground">{{ $title }}</p>
        @endif
        @if ($description)
            <p class="{{ $variant === 'tooltip' ? 'text-2xs' : 'text-sm' }} text-muted-foreground">{{ $description }}</p>
        @endif
        {{ $slot }}
    </template>
</div>
resources/views/components/ui/map/route.blade.php Blade
@props([
    // Ordered list of [longitude, latitude] pairs the line passes through.
    'coordinates' => [],
    // Token name for the line colour: primary | success | warning | destructive | info | muted.
    'color' => 'primary',
    'width' => 4,
    'opacity' => 0.85,
    'dashed' => false,
    // Draw a page-coloured casing under the line so it stays legible over
    // busy streets.
    'casing' => true,
    // Mark the first and last coordinate with a ring / disc.
    'endpoints' => false,
    // Animate a flowing dash along the line (off under reduced motion).
    'animated' => false,
    // Snap the line to the road network: the coordinates become waypoints and
    // the drawn geometry comes from an OSRM-compatible router. `driving`,
    // `cycling` or `walking`; null keeps the straight polyline. The public demo
    // router is fine for previews — point `router` at your own for production.
    'snap' => null,
    'router' => 'https://router.project-osrm.org',
])

{{-- Polyline config node for <x-ui.map>. The map reads [coordinates] and adds a
     GeoJSON line layer; the colour is a token name resolved at runtime. --}}
<div
    data-slot="map-route"
    data-map-route
    data-coordinates="{{ json_encode(array_values($coordinates)) }}"
    data-color="{{ $color }}"
    data-width="{{ $width }}"
    data-opacity="{{ $opacity }}"
    data-dashed="{{ $dashed ? '1' : '0' }}"
    data-casing="{{ $casing ? '1' : '0' }}"
    data-endpoints="{{ $endpoints ? '1' : '0' }}"
    data-animated="{{ $animated ? '1' : '0' }}"
    @if ($snap) data-snap="{{ $snap }}" data-router="{{ rtrim($router, '/') }}" @endif
    hidden
    {{ $attributes->merge(['class' => 'hidden text-foreground']) }}
></div>
resources/views/components/ui/map/arc.blade.php Blade
@props([
    // [longitude, latitude] endpoints.
    'from' => [],
    'to' => [],
    // Token name for the arc colour: primary | success | warning | destructive | info | muted.
    'color' => 'primary',
    'width' => 2,
    'dashed' => false,
    // Bow height factor (0 = straight great-circle, ~0.3 = a gentle arc).
    'curve' => 0.3,
])

{{-- Great-circle arc config node for <x-ui.map>. The map slerps a curved line
     between [from] and [to] and adds it as a line layer; colour is a token name. --}}
<div
    data-slot="map-arc"
    data-map-arc
    data-from="{{ json_encode(array_values($from)) }}"
    data-to="{{ json_encode(array_values($to)) }}"
    data-color="{{ $color }}"
    data-width="{{ $width }}"
    data-dashed="{{ $dashed ? '1' : '0' }}"
    data-curve="{{ $curve }}"
    hidden
    {{ $attributes->merge(['class' => 'hidden text-foreground']) }}
></div>
resources/views/components/ui/map/cluster.blade.php Blade
@props([
    // GeoJSON source URL (FeatureCollection of Points)…
    'url' => null,
    // …or inline GeoJSON data (array). One of url/data is required.
    'data' => null,
    'radius' => 50,
    'maxZoom' => 14,
    // Cluster bubble colours, low → high density (token names).
    'colors' => ['info', 'primary', 'warning'],
    // Unclustered single-point colour (token name).
    'pointColor' => 'primary',
])

{{-- Clustered point-source config node for <x-ui.map>. The map adds a clustered
     GeoJSON source plus cluster / count / point layers, wires cluster-click to
     zoom, and colours everything from the token names given here. --}}
<div
    data-slot="map-cluster"
    data-map-cluster
    @if($url) data-url="{{ $url }}" @endif
    @if($data !== null) data-geojson="{{ json_encode($data) }}" @endif
    data-radius="{{ $radius }}"
    data-max-zoom="{{ $maxZoom }}"
    data-colors="{{ json_encode(array_values($colors)) }}"
    data-point-color="{{ $pointColor }}"
    hidden
    {{ $attributes->merge(['class' => 'hidden text-foreground']) }}
></div>
resources/views/components/ui/map/controls.blade.php Blade
@props([
    // Corner placement: top-left | top-right | bottom-left | bottom-right.
    'position' => 'top-right',
    'showZoom' => true,
    'showCompass' => true,
    'showLocate' => false,
    'showFullscreen' => false,
    'showScale' => false,
    // A "reset view" button that flies back to the initial camera (or the
    // fitted bounds when the map was told to `fit`).
    'showHome' => false,
])

{{-- Declarative config node for <x-ui.map>. Renders nothing visible; the map's
     behaviour layer reads these data-* flags after the map loads and adds the
     matching native MapLibre controls (NavigationControl, GeolocateControl,
     FullscreenControl, ScaleControl) plus the wrapper's own reset-view button. --}}
<div
    data-slot="map-controls"
    data-map-controls
    data-position="{{ $position }}"
    data-zoom="{{ $showZoom ? '1' : '0' }}"
    data-compass="{{ $showCompass ? '1' : '0' }}"
    data-locate="{{ $showLocate ? '1' : '0' }}"
    data-fullscreen="{{ $showFullscreen ? '1' : '0' }}"
    data-scale="{{ $showScale ? '1' : '0' }}"
    data-home="{{ $showHome ? '1' : '0' }}"
    hidden
    {{ $attributes->merge(['class' => 'hidden text-muted-foreground']) }}
></div>

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.2.3
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0