Build and work with livewire-mapcn map components, including markers, popups, routes with progress, arcs, GeoJSON overlays, clusters, 3D view/custom controls, and reactive Livewire map interactions.
php artisan boost:add-skill kwasii/livewire-mapcn
Save this content to: .claude/skills/livewire-mapcn-development/SKILL.md
# livewire-mapcn Development
## When to use this skill
Use this skill when:
- Adding interactive maps to a Laravel Livewire page or component
- Working with markers, popups, tooltips, route lines, arcs, GeoJSON overlays, or cluster layers
- Showing route progress (deliveries, trips), flight/trade arcs, or region polygons
- Adding a 3D view toggle or custom map controls
- Displaying directions or animated routes on a map
- Building reactive UI that responds to map events (clicks, drags, pans, zooms)
- Dynamically updating map data without a full Livewire re-render
- Troubleshooting map rendering, asset loading, or Livewire event issues
## Package Overview
`kwasii/livewire-mapcn` provides MapLibre GL JS-powered Blade components that integrate natively with Livewire's reactivity system. Alpine.js ships with Livewire; Tailwind is **not** required (the package CSS styles everything). MapLibre GL JS v6 (ESM-only) is imported by `@livewireMapScripts` and exposed as `window.maplibregl`.
Components inside `<x-map>` are reactive: when the Livewire component re-renders, markers, routes, arcs, GeoJSON and clusters update in place (the map uses `wire:ignore.self`, so the camera is kept). Use `wire:key` in loops. Never wrap `<x-map>` in `wire:ignore`.
> **Critical: Coordinate order is `[lng, lat]`** — longitude first, latitude second. This matches MapLibre GL JS conventions, not the typical `[lat, lng]` order you may expect.
## Setup Checklist
1. `composer require kwasii/livewire-mapcn`
2. Publish config (optional): `php artisan vendor:publish --tag=livewire-mapcn-config`
3. Publish assets (optional): `php artisan vendor:publish --tag=livewire-mapcn-assets`
4. Add to Blade layout:
```blade
<head>
@livewireMapStyles
</head>
<body>
...
@livewireScripts
@livewireMapScripts
</body>
```
Assets are automatically served via package routes — publishing is not required unless you need to self-host or customize delivery.
## Component Hierarchy
```
<x-map> ← root, required, wraps everything
<x-map-controls /> ← navigation, locate, fullscreen, scale, 3D toggle
<x-map-control> ← custom control markup (use $map inside)
<x-map-marker> ← marker at [lat, lng]
<x-marker-content /> ← fully custom HTML marker icon
<x-marker-label /> ← text label near marker
<x-marker-popup /> ← click-to-open popup
<x-marker-tooltip /> ← hover tooltip
</x-map-marker>
<x-map-popup /> ← standalone popup at fixed coords
<x-map-cluster-layer /> ← clustered GeoJSON point layer
<x-map-route> ← single polyline / OSRM route (+ progress)
<x-map-route-marker /> ← marker at start / end / progress / fraction
</x-map-route>
<x-map-arc /> ← curved connections between point pairs
<x-map-geojson /> ← polygons / lines / points from GeoJSON
<x-map-route-group /> ← multiple selectable routes
<x-map-route-list /> ← route selection UI panel (overlaid)
</x-map>
```
## Configuration Reference
`config/livewire-mapcn.php`:
| Key | Default | Description |
|-----|---------|-------------|
| `default_provider` | `'carto-positron'` | Default tile provider |
| `dark_provider` | `'carto-dark-matter'` | Tile provider for dark theme |
| `default_height` | `'full'` | CSS height fallback |
| `default_zoom` | `7` | Initial zoom fallback |
| `default_center` | `[0, 0]` | Center `[lng, lat]` fallback |
| `osrm_url` | `'https://router.project-osrm.org'` | OSRM routing server |
| `inject_assets` | `'route'` | `'route'` or `'published'` |
| `load_from_cdn` | `true` | Load MapLibre from CDN |
| `maplibre_version` | `'6.14.0'` | MapLibre GL JS version |
| `cdn_url` | `null` | MapLibre JS URL; `.mjs` loads as ES module, else classic script (e.g. v5 UMD) |
| `cdn_css_url` | `null` | MapLibre CSS URL (derived from version when null) |
| `follow_system_theme` | `false` | With `theme="auto"`, also follow `prefers-color-scheme` |
| `carto_license` | `'non-commercial'` | Informational only |
| `cluster_popup_view` | `null` | Blade view used as the default cluster popup (`{property}` placeholders) |
| `custom_events` | `[]` | Global extra MapLibre events to forward as `map:*` |
## Built-in Tile Providers
| Key | Description |
|-----|-------------|
| `carto-positron` | Minimal light (default) |
| `carto-voyager` | Light with road detail |
| `carto-dark-matter` | Dark |
| `openfreemap-liberty` | OpenFreeMap with 3D buildings (pair with `:pitch`) |
| `openfreemap-bright` | OpenFreeMap bright |
| `openfreemap-positron` | OpenFreeMap positron |
| `osm-raster` | OpenStreetMap raster tiles |
| `blank` | No basemap (GeoJSON-only maps); same as the `blank` prop |
Pass a full MapLibre style JSON URL (or a style array) to the `style` prop to use a custom tile source instead; it applies to both themes unless `dark-style` is set.
## Common Patterns
### Basic responsive map
```blade
<x-map :center="[-0.09, 51.5]" :zoom="13" height="500px" provider="carto-positron">
<x-map-controls position="top-right" :locate="true" :fullscreen="true" />
</x-map>
```
### Markers from Livewire state
```blade
<x-map :center="$mapCenter" :zoom="$zoom">
@foreach ($locations as $loc)
<x-map-marker :lat="$loc['lat']" :lng="$loc['lng']" :id="$loc['id']">
<x-marker-label :text="$loc['name']" position="top" />
<x-marker-tooltip :text="$loc['category']" />
<x-marker-popup>
<strong>{{ $loc['name'] }}</strong>
<p>{{ $loc['address'] }}</p>
</x-marker-popup>
</x-map-marker>
@endforeach
</x-map>
```
### Custom HTML marker icon
```blade
<x-map-marker :lat="51.5" :lng="-0.09">
<x-marker-content>
<div class="bg-blue-500 text-white rounded-full w-10 h-10 flex items-center justify-center shadow-lg">
📍
</div>
</x-marker-content>
<x-marker-popup>Custom icon marker</x-marker-popup>
</x-map-marker>
```
### Draggable marker → Livewire event
```blade
<x-map :center="$center" :zoom="13" :events="['marker:drag-end']">
<x-map-marker :lat="$lat" :lng="$lng" :draggable="true" id="pin" />
</x-map>
```
```php
// Livewire component
#[On('map:marker-drag-end')]
public function onDragEnd(string $id, float $lat, float $lng): void
{
$this->lat = $lat;
$this->lng = $lng;
}
```
### Standalone popup at coordinates
```blade
<x-map :center="[-0.09, 51.5]" :zoom="13">
<x-map-popup :lat="51.5" :lng="-0.09" :open="true" max-width="250px">
<p class="font-semibold">London</p>
</x-map-popup>
</x-map>
```
### Cluster layer from PHP array
```blade
<x-map :center="[-0.09, 51.5]" :zoom="10">
<x-map-cluster-layer
:data="$locations"
cluster-color="#3b82f6"
:cluster-max-zoom="14"
:cluster-radius="50"
:show-count="true"
>
<x-slot:popup>
<div class="p-3 space-y-1">
<h3 class="font-semibold text-sm">{name}</h3>
<p class="text-xs text-gray-500">{address}</p>
<p class="text-[11px] text-gray-400">{lat}, {lng}</p>
</div>
</x-slot:popup>
</x-map-cluster-layer>
</x-map>
```
The `$locations` array format:
```php
$locations = [
['lat' => 51.505, 'lng' => -0.09, 'properties' => ['name' => 'Office A', 'address' => '10 Downing St']],
['lat' => 51.51, 'lng' => -0.10, 'properties' => ['name' => 'Office B', 'address' => '22 Baker St']],
];
```
In the `<x-slot:popup>`, use `{propertyName}` placeholders. `{lat}` and `{lng}` are always available.
### Cluster layer from GeoJSON URL
```blade
<x-map-cluster-layer
url="https://example.com/api/locations.geojson"
cluster-color="#ef4444"
popup-property="title"
/>
```
### Dynamic cluster data update (no re-render)
```php
use Kwasii\LivewireMapcn\Support\GeoJSON;
// In Livewire component
public function filterLocations(string $category): void
{
$filtered = $this->locations->where('category', $category)->toArray();
$this->dispatch(
"map:update-cluster-data-{$this->clusterId}",
GeoJSON::fromArray($filtered)
);
}
```
### Simple OSRM route
```blade
<x-map :center="[-0.11, 51.505]" :zoom="13">
<x-map-route
:coordinates="[[-0.12, 51.51], [-0.10, 51.50]]"
:fetch-directions="true"
directions-profile="driving"
color="#1A56DB"
:width="4"
:with-stops="true"
/>
</x-map>
```
### Animated dashed route
```blade
<x-map-route
:coordinates="$routeCoords"
:fetch-directions="true"
directions-profile="walking"
:dash-array="[5, 3]"
line-cap="butt"
:animate="true"
:animate-duration="3000"
/>
```
### Route with alternatives + selection panel
```blade
<x-map :center="[-0.11, 51.505]" :zoom="13">
<x-map-route
id="main-route"
:coordinates="$coords"
:fetch-directions="true"
:alternatives="true"
:max-alternatives="2"
/>
<x-map-route-list
route-id="main-route"
position="top-left"
title="Route Options"
:show-distance="true"
:show-duration="true"
:show-fastest-badge="true"
:show-time-diff="true"
/>
</x-map>
```
### Route group (multiple predefined routes)
```php
// Livewire component
public array $routes = [
[
'id' => 'route-a',
'coordinates' => [[-0.12, 51.51], [-0.11, 51.51], [-0.10, 51.50]],
'color' => '#ef4444',
],
[
'id' => 'route-b',
'coordinates' => [[-0.12, 51.51], [-0.115, 51.515], [-0.10, 51.50]],
'color' => '#3b82f6',
],
];
```
```blade
<x-map :center="[-0.11, 51.505]" :zoom="14">
<x-map-route-group
id="trip-routes"
:routes="$routes"
:selected-route="$selectedRoute"
:fit-bounds="true"
:clickable="true"
:with-stops="true"
:fetch-directions="true"
directions-profile="driving"
/>
<x-map-route-list
route-id="trip-routes"
position="top-left"
title="Choose Route"
/>
</x-map>
```
Listen for selection changes:
```php
#[On('map:route-group-selection-changed')]
public function onRouteSelected(string $groupId, int $routeIndex): void
{
$this->selectedRoute = $routeIndex;
}
```
### Dynamically update route coordinates
```php
$this->dispatch("map:update-route-data-{$routeId}", [
'coordinates' => $newCoordinates,
]);
```
### Dynamically update route group
```php
$this->dispatch("map:update-route-group-{$groupId}", [
'routes' => $updatedRoutes,
'selectedRoute' => 1,
]);
```
### Fly to / programmatic map control
```php
// Smooth animated pan + zoom
$this->dispatch('map:fly-to', [
'center' => [-0.09, 51.5],
'zoom' => 14,
'essential' => true,
]);
// Instant move (no animation)
$this->dispatch('map:jump-to', ['center' => [-0.09, 51.5], 'zoom' => 12]);
// Fit map to a bounding box
$this->dispatch('map:fit-bounds', [
'bounds' => [[-0.2, 51.4], [0.0, 51.6]],
'padding' => 50,
]);
// Call any MapLibre GL JS method
$this->dispatch('map:call', ['method' => 'setMaxZoom', 'args' => [18]]);
```
### Handle map click to drop a marker
```blade
<x-map :center="$center" :zoom="13" :events="['click']">
@if($selectedLat)
<x-map-marker :lat="$selectedLat" :lng="$selectedLng" />
@endif
</x-map>
```
```php
#[On('map:click')]
public function onMapClick(float $lat, float $lng): void
{
$this->selectedLat = $lat;
$this->selectedLng = $lng;
}
```
### Reactive viewport tracking with Alpine.js
```blade
<div x-data="{ zoom: 13, lat: 51.5, lng: -0.09 }">
<x-map
:center="[-0.09, 51.5]"
:zoom="13"
@map:move="lat = $event.detail.lat; lng = $event.detail.lng"
@map:zoom="zoom = $event.detail.zoom"
/>
<div>Zoom: <span x-text="zoom.toFixed(1)"></span></div>
</div>
```
### Dark/light theme
```blade
<x-map
:center="$center"
theme="auto"
provider="carto-positron"
dark-style="https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
/>
```
`theme="auto"` reads a `dark`/`light` class or `data-theme` on `<html>` (and `prefers-color-scheme` when `follow_system_theme` is on). Custom layers are re-added automatically after a theme swap. Use `theme="light"` or `theme="dark"` to force a mode. Provide `light-style` and `dark-style` props for separate style URLs per theme.
### Route progress with a vehicle marker
Keep progress client-first: `progress-model` follows an Alpine property, so dragging a slider makes no server requests. Entangle it (deferred) to keep `$progress` in sync on the next request. Do **not** use `wire:model.live` on a progress slider.
```blade
<div x-data="{ progress: $wire.entangle('progress') }">
<x-map-route id="delivery" :coordinates="$stops" fetch-directions
color="#94a3b8" :width="5" :dash-array="[0.5, 1.5]"
progress-model="progress" progress-color="#2563eb" fit-bounds>
<x-map-route-marker at="start" />
<x-map-route-marker at="end" />
<x-map-route-marker at="progress" align-to-route>
<x-marker-content><div class="vehicle">🚚</div></x-marker-content>
</x-map-route-marker>
</x-map-route>
<input type="range" min="0" max="1" step="0.01" x-model.number="progress">
<button type="button" x-on:click="$mapRoute('delivery').setProgress(1, 1500)">Play</button>
</div>
```
For occasional server-driven updates use `:progress="$progress"` (optionally `:progress-duration="500"`). To animate from PHP without a re-render:
```php
$this->dispatch('map:route-progress-delivery', progress: 1, duration: 1500);
```
### Arcs (flights, trade flows)
```php
public array $flights = [
['id' => 'acc-lhr', 'from' => [-0.17, 5.60], 'to' => [-0.45, 51.47], 'city' => 'London', 'mode' => 'air'],
['id' => 'tem-rtm', 'from' => [0.02, 5.63], 'to' => [4.0, 51.95], 'city' => 'Rotterdam', 'mode' => 'sea'],
];
```
```blade
<x-map projection="globe" :zoom="1.2">
<x-map-arc id="flights" :data="$flights" :curvature="0.25"
:paint="['line-color' => ['match', ['get', 'mode'], 'air', '#a78bfa', 'sea', '#34d399', '#888']]"
:hover-paint="['line-width' => 3, 'line-opacity' => 1]">
<x-slot:tooltip>{city} by {mode}</x-slot:tooltip>
</x-map-arc>
</x-map>
```
```php
#[On('map:arc-clicked')]
public function arcClicked(array $arc): void { /* $arc is the original datum */ }
```
### GeoJSON overlay
```blade
<x-map :center="[-122.41, 37.77]" :zoom="12">
<x-map-geojson id="area" :data="$area" interactive
:fill-paint="['fill-color' => '#3b82f6', 'fill-opacity' => 0.25]"
:fill-hover-paint="['fill-opacity' => 0.45]"
:line-paint="['line-color' => '#2563eb', 'line-width' => 2]">
<x-slot:popup><strong>{name}</strong></x-slot:popup>
</x-map-geojson>
</x-map>
```
Pass `false` to `fill-paint`, `line-paint` or `circle-paint` to drop a layer. Use `<x-map blank>` for choropleths without a basemap. `data` may also be a URL.
### 3D view and custom controls
```blade
<x-map provider="openfreemap-liberty" :center="[-0.1276, 51.5072]" :zoom="15.5" :pitch="55">
<x-map-controls three-d :locate="false" />
<x-map-control position="top-left">
<button type="button" x-on:click="$map.easeTo({ pitch: 60, bearing: -20, duration: 1000 })">3D view</button>
<button type="button" x-on:click="$map.easeTo({ pitch: 0, bearing: 0, duration: 1000 })">Top down</button>
</x-map-control>
</x-map>
```
`$map` is an Alpine magic returning the MapLibre instance of the enclosing map. From JS outside a map use `LivewireMap.getMap('map-id')`.
## Events Reference
### Outbound (package → Livewire/Alpine)
**Map:**
| Event | Payload | Notes |
|-------|---------|-------|
| `map:loaded` | — | Map ready |
| `map:click` | `lat, lng, detail` | |
| `map:double-click` | `lat, lng, detail` | |
| `map:right-click` | `lat, lng, detail` | |
| `map:move` | `lat, lng` | Throttled 100ms |
| `map:center-changed` | `lat, lng` | After movement ends |
| `map:zoom` | `zoom` | Throttled 100ms |
| `map:zoom-changed` | `zoom` | After zoom ends |
| `map:bounds-changed` | `bounds` | |
| `map:drag-end` | `lat, lng` | |
| `map:bearing-changed` | `bearing` | Throttled 100ms |
| `map:pitch-changed` | `pitch` | Throttled 100ms |
| `map:style-loaded` | `style, dark` | After every style (re)load |
**Locate:** `map:locate-success` (lat, lng, accuracy), `map:locate-error` (error)
**Markers:** `map:marker-clicked` (id,lat,lng), `map:marker-drag-start`, `map:marker-drag`, `map:marker-drag-end`, `map:marker-mouseenter`, `map:marker-mouseleave`, `map:marker-popup-open` (id), `map:marker-popup-close` (id)
**Popups:** `map:popup-open` (id), `map:popup-close` (id)
**Routes:** `map:route-clicked` (id), `map:route-mouseenter` (id), `map:route-mouseleave` (id), `map:route-directions-ready` (id, distance, duration), `map:route-directions-error` (id, error), `map:route-alternative-selected` (id, alternativeIndex), `map:route-updated` (id)
**Route group:** `map:route-group-selection-changed` (groupId, routeIndex)
**Route list:** `map:route-list-selected` (routeIndex)
**Clusters:** `map:cluster-clicked` (cluster_id, lat, lng, count), `map:cluster-expanded` (cluster_id, zoom), `map:cluster-point-clicked` (properties, lat, lng)
**Arcs:** `map:arc-clicked`, `map:arc-hovered` (id, arc or null, lat, lng)
**GeoJSON:** `map:geojson-clicked`, `map:geojson-hovered` (id, feature or null, lat, lng)
**Other:** `map:viewport-changed` (center, zoom, bearing, pitch), `map:theme-changed` (dark), `map:3d-toggled` (enabled), `map:style-reloaded`
Every event detail includes `mapId`.
### Inbound commands (Livewire → map)
Add `mapId` to any map command to target a single map.
| Command | Payload | Description |
|---------|---------|-------------|
| `map:fly-to` | `center, zoom, bearing, pitch, essential` | Animated pan/zoom |
| `map:ease-to` | `center, zoom, bearing, pitch, duration` | Eased camera move |
| `map:set-center` | `center` | |
| `map:set-theme` | `theme` (light, dark, auto) | |
| `map:route-progress-{id}` | `progress, duration` | Set/animate route progress |
| `map:select-route-{id}` | `index` | Select an OSRM alternative |
| `map:update-arc-data-{id}` | `data` | Replace arc data |
| `map:update-geojson-{id}` | GeoJSON | Replace GeoJSON data |
| `map:jump-to` | `center, zoom, bearing, pitch` | Instant move |
| `map:fit-bounds` | `bounds, padding, maxZoom` | Fit to bounding box |
| `map:set-zoom` | `zoom` | |
| `map:set-bearing` | `bearing` | |
| `map:set-pitch` | `pitch` | |
| `map:set-style` | `style` | Change tile style |
| `map:resize` | — | Force resize (hidden containers) |
| `map:force-animate` | — | Re-trigger route animation |
| `map:call` | `method, args` | Any MapLibre GL JS method |
| `map:update-route-data-{id}` | `coordinates` | Update route path |
| `map:update-cluster-data-{id}` | GeoJSON FeatureCollection | Update cluster points |
| `map:update-route-group-{id}` | `routes, selectedRoute, ...` | Update route group |
## GeoJSON Helper
`Kwasii\LivewireMapcn\Support\GeoJSON::fromArray()` converts PHP arrays to GeoJSON FeatureCollections:
```php
use Kwasii\LivewireMapcn\Support\GeoJSON;
// From lat/lng array
$geoJson = GeoJSON::fromArray([
['lat' => 51.505, 'lng' => -0.09, 'properties' => ['name' => 'Location A']],
['lat' => 51.51, 'lng' => -0.10, 'properties' => ['name' => 'Location B']],
]);
// Raw GeoJSON Feature objects pass through unchanged
$geoJson = GeoJSON::fromArray([
['type' => 'Feature', 'geometry' => [...], 'properties' => [...]],
]);
```
## Performance Tips
- **Large datasets** (100+ points): Always use `<x-map-cluster-layer>` instead of individual `<x-map-marker>` components.
- **Dynamic updates**: Use `map:update-cluster-data-{id}`, `map:update-route-data-{id}`, `map:update-route-group-{id}` to push changes without Livewire re-rendering the component tree.
- **Data transport**: cluster, arc, GeoJSON and route-group data ships in a JSON `<script>` block (no attribute size limits). `:max-features-to-inline` is deprecated and ignored.
- **Throttled events**: `map:move`, `map:zoom`, `map:bearing-changed`, `map:pitch-changed` fire at 100ms intervals. Use `map:center-changed`, `map:zoom-changed` for post-interaction final values.
- **Cluster tuning**: Increase `:cluster-radius` (e.g., `80`) for fewer clusters and faster rendering. Set `:cluster-max-zoom="12"` to stop unclustering at lower zoom levels. Use `:tolerance="0.8"` to simplify geometries at the cost of precision.
- **Buffer**: `:buffer="512"` reduces tile-edge artefacts during fast panning (uses more memory).
- **Reactivity**: `<x-map>` uses `wire:ignore.self` (the MapLibre canvas is fully ignored). Child components are morphed and diffed; only changed markers/layers are touched, and the camera is preserved. Give maps an explicit `id` only when you need to target them.
## Troubleshooting
| Problem | Fix |
|---------|-----|
| Map not rendering | Ensure `@livewireMapStyles` is in `<head>` and `@livewireMapScripts` is before `</body>` |
| 404 on assets | Check `inject_assets` config; if `'route'`, package routes must not be blocked by middleware |
| Markers not updating | Make sure the map is not wrapped in `wire:ignore`, and use `wire:key` on markers rendered in loops |
| MapLibre not loading after upgrading | Set `cdn_url` to `null` (or a `.mjs` URL); MapLibre v6 has no UMD build. Re-publish views if you published them |
| Directions not loading | Test OSRM server is reachable; set a self-hosted `osrm_url` for production |
| Map blank on resize | Dispatch `map:resize` from Livewire after showing a previously hidden container |
| Routes wrong geometry | Confirm coordinates are `[lng, lat]` order — not `[lat, lng]` |
| Need commercial basemaps | CARTO basemaps are free for non-commercial use only; use OpenFreeMap providers or your own `style` |
Use this skill when:
kwasii/livewire-mapcn provides MapLibre GL JS-powered Blade components that integrate natively with Livewire's reactivity system. Alpine.js ships with Livewire; Tailwind is not required (the package CSS styles everything). MapLibre GL JS v6 (ESM-only) is imported by @livewireMapScripts and exposed as window.maplibregl.
Components inside <x-map> are reactive: when the Livewire component re-renders, markers, routes, arcs, GeoJSON and clusters update in place (the map uses wire:ignore.self, so the camera is kept). Use wire:key in loops. Never wrap <x-map> in wire:ignore.
Critical: Coordinate order is
[lng, lat]— longitude first, latitude second. This matches MapLibre GL JS conventions, not the typical[lat, lng]order you may expect.
composer require kwasii/livewire-mapcnphp artisan vendor:publish --tag=livewire-mapcn-configphp artisan vendor:publish --tag=livewire-mapcn-assets<head>
@livewireMapStyles
</head>
<body>
...
@livewireScripts
@livewireMapScripts
</body>
Assets are automatically served via package routes — publishing is not required unless you need to self-host or customize delivery.
<x-map> ← root, required, wraps everything
<x-map-controls /> ← navigation, locate, fullscreen, scale, 3D toggle
<x-map-control> ← custom control markup (use $map inside)
<x-map-marker> ← marker at [lat, lng]
<x-marker-content /> ← fully custom HTML marker icon
<x-marker-label /> ← text label near marker
<x-marker-popup /> ← click-to-open popup
<x-marker-tooltip /> ← hover tooltip
</x-map-marker>
<x-map-popup /> ← standalone popup at fixed coords
<x-map-cluster-layer /> ← clustered GeoJSON point layer
<x-map-route> ← single polyline / OSRM route (+ progress)
<x-map-route-marker /> ← marker at start / end / progress / fraction
</x-map-route>
<x-map-arc /> ← curved connections between point pairs
<x-map-geojson /> ← polygons / lines / points from GeoJSON
<x-map-route-group /> ← multiple selectable routes
<x-map-route-list /> ← route selection UI panel (overlaid)
</x-map>
config/livewire-mapcn.php:
| Key | Default | Description |
|---|---|---|
default_provider |
'carto-positron' |
Default tile provider |
dark_provider |
'carto-dark-matter' |
Tile provider for dark theme |
default_height |
'full' |
CSS height fallback |
default_zoom |
7 |
Initial zoom fallback |
default_center |
[0, 0] |
Center [lng, lat] fallback |
osrm_url |
'https://router.project-osrm.org' |
OSRM routing server |
inject_assets |
'route' |
'route' or 'published' |
load_from_cdn |
true |
Load MapLibre from CDN |
maplibre_version |
'6.14.0' |
MapLibre GL JS version |
cdn_url |
null |
MapLibre JS URL; .mjs loads as ES module, else classic script (e.g. v5 UMD) |
cdn_css_url |
null |
MapLibre CSS URL (derived from version when null) |
follow_system_theme |
false |
With theme="auto", also follow prefers-color-scheme |
carto_license |
'non-commercial' |
Informational only |
cluster_popup_view |
null |
Blade view used as the default cluster popup ({property} placeholders) |
custom_events |
[] |
Global extra MapLibre events to forward as map:* |
| Key | Description |
|---|---|
carto-positron |
Minimal light (default) |
carto-voyager |
Light with road detail |
carto-dark-matter |
Dark |
openfreemap-liberty |
OpenFreeMap with 3D buildings (pair with :pitch) |
openfreemap-bright |
OpenFreeMap bright |
openfreemap-positron |
OpenFreeMap positron |
osm-raster |
OpenStreetMap raster tiles |
blank |
No basemap (GeoJSON-only maps); same as the blank prop |
Pass a full MapLibre style JSON URL (or a style array) to the style prop to use a custom tile source instead; it applies to both themes unless dark-style is set.
<x-map :center="[-0.09, 51.5]" :zoom="13" height="500px" provider="carto-positron">
<x-map-controls position="top-right" :locate="true" :fullscreen="true" />
</x-map>
<x-map :center="$mapCenter" :zoom="$zoom">
@foreach ($locations as $loc)
<x-map-marker :lat="$loc['lat']" :lng="$loc['lng']" :id="$loc['id']">
<x-marker-label :text="$loc['name']" position="top" />
<x-marker-tooltip :text="$loc['category']" />
<x-marker-popup>
<strong>{{ $loc['name'] }}</strong>
<p>{{ $loc['address'] }}</p>
</x-marker-popup>
</x-map-marker>
@endforeach
</x-map>
<x-map-marker :lat="51.5" :lng="-0.09">
<x-marker-content>
<div class="bg-blue-500 text-white rounded-full w-10 h-10 flex items-center justify-center shadow-lg">
📍
</div>
</x-marker-content>
<x-marker-popup>Custom icon marker</x-marker-popup>
</x-map-marker>
<x-map :center="$center" :zoom="13" :events="['marker:drag-end']">
<x-map-marker :lat="$lat" :lng="$lng" :draggable="true" id="pin" />
</x-map>
// Livewire component
#[On('map:marker-drag-end')]
public function onDragEnd(string $id, float $lat, float $lng): void
{
$this->lat = $lat;
$this->lng = $lng;
}
<x-map :center="[-0.09, 51.5]" :zoom="13">
<x-map-popup :lat="51.5" :lng="-0.09" :open="true" max-width="250px">
<p class="font-semibold">London</p>
</x-map-popup>
</x-map>
<x-map :center="[-0.09, 51.5]" :zoom="10">
<x-map-cluster-layer
:data="$locations"
cluster-color="#3b82f6"
:cluster-max-zoom="14"
:cluster-radius="50"
:show-count="true"
>
<x-slot:popup>
<div class="p-3 space-y-1">
<h3 class="font-semibold text-sm">{name}</h3>
<p class="text-xs text-gray-500">{address}</p>
<p class="text-[11px] text-gray-400">{lat}, {lng}</p>
</div>
</x-slot:popup>
</x-map-cluster-layer>
</x-map>
The $locations array format:
$locations = [
['lat' => 51.505, 'lng' => -0.09, 'properties' => ['name' => 'Office A', 'address' => '10 Downing St']],
['lat' => 51.51, 'lng' => -0.10, 'properties' => ['name' => 'Office B', 'address' => '22 Baker St']],
];
In the <x-slot:popup>, use {propertyName} placeholders. {lat} and {lng} are always available.
<x-map-cluster-layer
url="https://example.com/api/locations.geojson"
cluster-color="#ef4444"
popup-property="title"
/>
use Kwasii\LivewireMapcn\Support\GeoJSON;
// In Livewire component
public function filterLocations(string $category): void
{
$filtered = $this->locations->where('category', $category)->toArray();
$this->dispatch(
"map:update-cluster-data-{$this->clusterId}",
GeoJSON::fromArray($filtered)
);
}
<x-map :center="[-0.11, 51.505]" :zoom="13">
<x-map-route
:coordinates="[[-0.12, 51.51], [-0.10, 51.50]]"
:fetch-directions="true"
directions-profile="driving"
color="#1A56DB"
:width="4"
:with-stops="true"
/>
</x-map>
<x-map-route
:coordinates="$routeCoords"
:fetch-directions="true"
directions-profile="walking"
:dash-array="[5, 3]"
line-cap="butt"
:animate="true"
:animate-duration="3000"
/>
<x-map :center="[-0.11, 51.505]" :zoom="13">
<x-map-route
id="main-route"
:coordinates="$coords"
:fetch-directions="true"
:alternatives="true"
:max-alternatives="2"
/>
<x-map-route-list
route-id="main-route"
position="top-left"
title="Route Options"
:show-distance="true"
:show-duration="true"
:show-fastest-badge="true"
:show-time-diff="true"
/>
</x-map>
// Livewire component
public array $routes = [
[
'id' => 'route-a',
'coordinates' => [[-0.12, 51.51], [-0.11, 51.51], [-0.10, 51.50]],
'color' => '#ef4444',
],
[
'id' => 'route-b',
'coordinates' => [[-0.12, 51.51], [-0.115, 51.515], [-0.10, 51.50]],
'color' => '#3b82f6',
],
];
<x-map :center="[-0.11, 51.505]" :zoom="14">
<x-map-route-group
id="trip-routes"
:routes="$routes"
:selected-route="$selectedRoute"
:fit-bounds="true"
:clickable="true"
:with-stops="true"
:fetch-directions="true"
directions-profile="driving"
/>
<x-map-route-list
route-id="trip-routes"
position="top-left"
title="Choose Route"
/>
</x-map>
Listen for selection changes:
#[On('map:route-group-selection-changed')]
public function onRouteSelected(string $groupId, int $routeIndex): void
{
$this->selectedRoute = $routeIndex;
}
$this->dispatch("map:update-route-data-{$routeId}", [
'coordinates' => $newCoordinates,
]);
$this->dispatch("map:update-route-group-{$groupId}", [
'routes' => $updatedRoutes,
'selectedRoute' => 1,
]);
// Smooth animated pan + zoom
$this->dispatch('map:fly-to', [
'center' => [-0.09, 51.5],
'zoom' => 14,
'essential' => true,
]);
// Instant move (no animation)
$this->dispatch('map:jump-to', ['center' => [-0.09, 51.5], 'zoom' => 12]);
// Fit map to a bounding box
$this->dispatch('map:fit-bounds', [
'bounds' => [[-0.2, 51.4], [0.0, 51.6]],
'padding' => 50,
]);
// Call any MapLibre GL JS method
$this->dispatch('map:call', ['method' => 'setMaxZoom', 'args' => [18]]);
<x-map :center="$center" :zoom="13" :events="['click']">
@if($selectedLat)
<x-map-marker :lat="$selectedLat" :lng="$selectedLng" />
@endif
</x-map>
#[On('map:click')]
public function onMapClick(float $lat, float $lng): void
{
$this->selectedLat = $lat;
$this->selectedLng = $lng;
}
<div x-data="{ zoom: 13, lat: 51.5, lng: -0.09 }">
<x-map
:center="[-0.09, 51.5]"
:zoom="13"
@map:move="lat = $event.detail.lat; lng = $event.detail.lng"
@map:zoom="zoom = $event.detail.zoom"
/>
<div>Zoom: <span x-text="zoom.toFixed(1)"></span></div>
</div>
<x-map
:center="$center"
theme="auto"
provider="carto-positron"
dark-style="https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
/>
theme="auto" reads a dark/light class or data-theme on <html> (and prefers-color-scheme when follow_system_theme is on). Custom layers are re-added automatically after a theme swap. Use theme="light" or theme="dark" to force a mode. Provide light-style and dark-style props for separate style URLs per theme.
Keep progress client-first: progress-model follows an Alpine property, so dragging a slider makes no server requests. Entangle it (deferred) to keep $progress in sync on the next request. Do not use wire:model.live on a progress slider.
<div x-data="{ progress: $wire.entangle('progress') }">
<x-map-route id="delivery" :coordinates="$stops" fetch-directions
color="#94a3b8" :width="5" :dash-array="[0.5, 1.5]"
progress-model="progress" progress-color="#2563eb" fit-bounds>
<x-map-route-marker at="start" />
<x-map-route-marker at="end" />
<x-map-route-marker at="progress" align-to-route>
<x-marker-content><div class="vehicle">🚚</div></x-marker-content>
</x-map-route-marker>
</x-map-route>
<input type="range" min="0" max="1" step="0.01" x-model.number="progress">
<button type="button" x-on:click="$mapRoute('delivery').setProgress(1, 1500)">Play</button>
</div>
For occasional server-driven updates use :progress="$progress" (optionally :progress-duration="500"). To animate from PHP without a re-render:
$this->dispatch('map:route-progress-delivery', progress: 1, duration: 1500);
public array $flights = [
['id' => 'acc-lhr', 'from' => [-0.17, 5.60], 'to' => [-0.45, 51.47], 'city' => 'London', 'mode' => 'air'],
['id' => 'tem-rtm', 'from' => [0.02, 5.63], 'to' => [4.0, 51.95], 'city' => 'Rotterdam', 'mode' => 'sea'],
];
<x-map projection="globe" :zoom="1.2">
<x-map-arc id="flights" :data="$flights" :curvature="0.25"
:paint="['line-color' => ['match', ['get', 'mode'], 'air', '#a78bfa', 'sea', '#34d399', '#888']]"
:hover-paint="['line-width' => 3, 'line-opacity' => 1]">
<x-slot:tooltip>{city} by {mode}</x-slot:tooltip>
</x-map-arc>
</x-map>
#[On('map:arc-clicked')]
public function arcClicked(array $arc): void { /* $arc is the original datum */ }
<x-map :center="[-122.41, 37.77]" :zoom="12">
<x-map-geojson id="area" :data="$area" interactive
:fill-paint="['fill-color' => '#3b82f6', 'fill-opacity' => 0.25]"
:fill-hover-paint="['fill-opacity' => 0.45]"
:line-paint="['line-color' => '#2563eb', 'line-width' => 2]">
<x-slot:popup><strong>{name}</strong></x-slot:popup>
</x-map-geojson>
</x-map>
Pass false to fill-paint, line-paint or circle-paint to drop a layer. Use <x-map blank> for choropleths without a basemap. data may also be a URL.
<x-map provider="openfreemap-liberty" :center="[-0.1276, 51.5072]" :zoom="15.5" :pitch="55">
<x-map-controls three-d :locate="false" />
<x-map-control position="top-left">
<button type="button" x-on:click="$map.easeTo({ pitch: 60, bearing: -20, duration: 1000 })">3D view</button>
<button type="button" x-on:click="$map.easeTo({ pitch: 0, bearing: 0, duration: 1000 })">Top down</button>
</x-map-control>
</x-map>
$map is an Alpine magic returning the MapLibre instance of the enclosing map. From JS outside a map use LivewireMap.getMap('map-id').
Map:
| Event | Payload | Notes |
|---|---|---|
map:loaded |
— | Map ready |
map:click |
lat, lng, detail |
|
map:double-click |
lat, lng, detail |
|
map:right-click |
lat, lng, detail |
|
map:move |
lat, lng |
Throttled 100ms |
map:center-changed |
lat, lng |
After movement ends |
map:zoom |
zoom |
Throttled 100ms |
map:zoom-changed |
zoom |
After zoom ends |
map:bounds-changed |
bounds |
|
map:drag-end |
lat, lng |
|
map:bearing-changed |
bearing |
Throttled 100ms |
map:pitch-changed |
pitch |
Throttled 100ms |
map:style-loaded |
style, dark |
After every style (re)load |
Locate: map:locate-success (lat, lng, accuracy), map:locate-error (error)
Markers: map:marker-clicked (id,lat,lng), map:marker-drag-start, map:marker-drag, map:marker-drag-end, map:marker-mouseenter, map:marker-mouseleave, map:marker-popup-open (id), map:marker-popup-close (id)
Popups: map:popup-open (id), map:popup-close (id)
Routes: map:route-clicked (id), map:route-mouseenter (id), map:route-mouseleave (id), map:route-directions-ready (id, distance, duration), map:route-directions-error (id, error), map:route-alternative-selected (id, alternativeIndex), map:route-updated (id)
Route group: map:route-group-selection-changed (groupId, routeIndex)
Route list: map:route-list-selected (routeIndex)
Clusters: map:cluster-clicked (cluster_id, lat, lng, count), map:cluster-expanded (cluster_id, zoom), map:cluster-point-clicked (properties, lat, lng)
Arcs: map:arc-clicked, map:arc-hovered (id, arc or null, lat, lng)
GeoJSON: map:geojson-clicked, map:geojson-hovered (id, feature or null, lat, lng)
Other: map:viewport-changed (center, zoom, bearing, pitch), map:theme-changed (dark), map:3d-toggled (enabled), map:style-reloaded
Every event detail includes mapId.
Add mapId to any map command to target a single map.
| Command | Payload | Description |
|---|---|---|
map:fly-to |
center, zoom, bearing, pitch, essential |
Animated pan/zoom |
map:ease-to |
center, zoom, bearing, pitch, duration |
Eased camera move |
map:set-center |
center |
|
map:set-theme |
theme (light, dark, auto) |
|
map:route-progress-{id} |
progress, duration |
Set/animate route progress |
map:select-route-{id} |
index |
Select an OSRM alternative |
map:update-arc-data-{id} |
data |
Replace arc data |
map:update-geojson-{id} |
GeoJSON | Replace GeoJSON data |
map:jump-to |
center, zoom, bearing, pitch |
Instant move |
map:fit-bounds |
bounds, padding, maxZoom |
Fit to bounding box |
map:set-zoom |
zoom |
|
map:set-bearing |
bearing |
|
map:set-pitch |
pitch |
|
map:set-style |
style |
Change tile style |
map:resize |
— | Force resize (hidden containers) |
map:force-animate |
— | Re-trigger route animation |
map:call |
method, args |
Any MapLibre GL JS method |
map:update-route-data-{id} |
coordinates |
Update route path |
map:update-cluster-data-{id} |
GeoJSON FeatureCollection | Update cluster points |
map:update-route-group-{id} |
routes, selectedRoute, ... |
Update route group |
Kwasii\LivewireMapcn\Support\GeoJSON::fromArray() converts PHP arrays to GeoJSON FeatureCollections:
use Kwasii\LivewireMapcn\Support\GeoJSON;
// From lat/lng array
$geoJson = GeoJSON::fromArray([
['lat' => 51.505, 'lng' => -0.09, 'properties' => ['name' => 'Location A']],
['lat' => 51.51, 'lng' => -0.10, 'properties' => ['name' => 'Location B']],
]);
// Raw GeoJSON Feature objects pass through unchanged
$geoJson = GeoJSON::fromArray([
['type' => 'Feature', 'geometry' => [...], 'properties' => [...]],
]);
<x-map-cluster-layer> instead of individual <x-map-marker> components.map:update-cluster-data-{id}, map:update-route-data-{id}, map:update-route-group-{id} to push changes without Livewire re-rendering the component tree.<script> block (no attribute size limits). :max-features-to-inline is deprecated and ignored.map:move, map:zoom, map:bearing-changed, map:pitch-changed fire at 100ms intervals. Use map:center-changed, map:zoom-changed for post-interaction final values.:cluster-radius (e.g., 80) for fewer clusters and faster rendering. Set :cluster-max-zoom="12" to stop unclustering at lower zoom levels. Use :tolerance="0.8" to simplify geometries at the cost of precision.:buffer="512" reduces tile-edge artefacts during fast panning (uses more memory).<x-map> uses wire:ignore.self (the MapLibre canvas is fully ignored). Child components are morphed and diffed; only changed markers/layers are touched, and the camera is preserved. Give maps an explicit id only when you need to target them.| Problem | Fix |
|---|---|
| Map not rendering | Ensure @livewireMapStyles is in <head> and @livewireMapScripts is before </body> |
| 404 on assets | Check inject_assets config; if 'route', package routes must not be blocked by middleware |
| Markers not updating | Make sure the map is not wrapped in wire:ignore, and use wire:key on markers rendered in loops |
| MapLibre not loading after upgrading | Set cdn_url to null (or a .mjs URL); MapLibre v6 has no UMD build. Re-publish views if you published them |
| Directions not loading | Test OSRM server is reachable; set a self-hosted osrm_url for production |
| Map blank on resize | Dispatch map:resize from Livewire after showing a previously hidden container |
| Routes wrong geometry | Confirm coordinates are [lng, lat] order — not [lat, lng] |
| Need commercial basemaps | CARTO basemaps are free for non-commercial use only; use OpenFreeMap providers or your own style |
How can I help you explore Laravel packages today?