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.
Preview
Paris
City of Light
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.
<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
Size options
Installation
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:
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.
-
resources/views/components/ui/map.blade.php -
resources/js/ui/map.js -
resources/views/components/ui/map/marker.blade.php -
resources/views/components/ui/map/popup.blade.php -
resources/views/components/ui/map/route.blade.php -
resources/views/components/ui/map/arc.blade.php -
resources/views/components/ui/map/cluster.blade.php -
resources/views/components/ui/map/controls.blade.php
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejsnpm: 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.
# 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: 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<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.
@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>
/**
* 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;
}
},
}));
});
@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>
@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>
@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>
@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>
@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>
@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