From 4199e6b60afcbb1e9c87c189b8bcdc16b5bd8454 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 20 Jul 2026 16:18:50 +1000 Subject: [PATCH 01/10] feat(leaflet): add Leaflet map integration --- .../leaflet/2.api/1.script-leaflet-map.md | 22 ++ .../scripts/leaflet/2.api/2.tile-layer.md | 10 + .../content/scripts/leaflet/2.api/3.marker.md | 10 + docs/content/scripts/leaflet/2.api/4.popup.md | 8 + .../scripts/leaflet/2.api/5.geojson.md | 10 + .../leaflet/2.api/6.use-script-leaflet.md | 17 + docs/content/scripts/leaflet/index.md | 103 +++++++ package.json | 2 + packages/script/THIRD_PARTY_LICENSES.md | 13 + packages/script/package.json | 9 + packages/script/src/registry-logos.ts | 1 + packages/script/src/registry-types.json | 291 ++++++++++++++++++ packages/script/src/registry.ts | 9 + .../Leaflet/ScriptLeafletGeoJson.vue | 62 ++++ .../components/Leaflet/ScriptLeafletMap.vue | 225 ++++++++++++++ .../Leaflet/ScriptLeafletMarker.vue | 97 ++++++ .../components/Leaflet/ScriptLeafletPopup.vue | 108 +++++++ .../Leaflet/ScriptLeafletTileLayer.vue | 53 ++++ .../components/Leaflet/useLeafletResource.ts | 84 +++++ packages/script/src/runtime/leaflet-styles.ts | 34 ++ .../script/src/runtime/registry/leaflet.ts | 44 +++ .../script/src/runtime/registry/schemas.ts | 9 + packages/script/src/runtime/types.ts | 4 +- packages/script/src/script-meta.ts | 4 + playground/nuxt.config.ts | 1 + playground/pages/index.vue | 1 + playground/pages/third-parties/leaflet.vue | 143 +++++++++ pnpm-lock.yaml | 25 ++ pnpm-workspace.yaml | 2 + scripts/generate-registry-types.ts | 5 + .../leaflet-components.nuxt.test.ts | 131 ++++++++ test/nuxt-runtime/leaflet-map.nuxt.test.ts | 121 ++++++++ test/types/types.test-d.ts | 2 + test/unit/leaflet-lifecycle.test.ts | 96 ++++++ test/unit/leaflet-registry.test.ts | 40 +++ test/unit/leaflet-styles.test.ts | 43 +++ 36 files changed, 1838 insertions(+), 1 deletion(-) create mode 100644 docs/content/scripts/leaflet/2.api/1.script-leaflet-map.md create mode 100644 docs/content/scripts/leaflet/2.api/2.tile-layer.md create mode 100644 docs/content/scripts/leaflet/2.api/3.marker.md create mode 100644 docs/content/scripts/leaflet/2.api/4.popup.md create mode 100644 docs/content/scripts/leaflet/2.api/5.geojson.md create mode 100644 docs/content/scripts/leaflet/2.api/6.use-script-leaflet.md create mode 100644 docs/content/scripts/leaflet/index.md create mode 100644 packages/script/THIRD_PARTY_LICENSES.md create mode 100644 packages/script/src/runtime/components/Leaflet/ScriptLeafletGeoJson.vue create mode 100644 packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue create mode 100644 packages/script/src/runtime/components/Leaflet/ScriptLeafletMarker.vue create mode 100644 packages/script/src/runtime/components/Leaflet/ScriptLeafletPopup.vue create mode 100644 packages/script/src/runtime/components/Leaflet/ScriptLeafletTileLayer.vue create mode 100644 packages/script/src/runtime/components/Leaflet/useLeafletResource.ts create mode 100644 packages/script/src/runtime/leaflet-styles.ts create mode 100644 packages/script/src/runtime/registry/leaflet.ts create mode 100644 playground/pages/third-parties/leaflet.vue create mode 100644 test/nuxt-runtime/leaflet-components.nuxt.test.ts create mode 100644 test/nuxt-runtime/leaflet-map.nuxt.test.ts create mode 100644 test/unit/leaflet-lifecycle.test.ts create mode 100644 test/unit/leaflet-registry.test.ts create mode 100644 test/unit/leaflet-styles.test.ts diff --git a/docs/content/scripts/leaflet/2.api/1.script-leaflet-map.md b/docs/content/scripts/leaflet/2.api/1.script-leaflet-map.md new file mode 100644 index 000000000..b8de67a63 --- /dev/null +++ b/docs/content/scripts/leaflet/2.api/1.script-leaflet-map.md @@ -0,0 +1,22 @@ +--- +title: +--- + +The map facade reserves layout space during SSR, loads Leaflet when triggered, creates the `L.Map`, and provides it to child components. + +::script-types{script-key="leaflet" filter="ScriptLeafletMap"} +:: + +`center` and `zoom` are reactive. Use `v-model:center` or `v-model:zoom` when you also want to receive user pan and zoom changes. + +```vue + +``` + +The `placeholder`, `awaitingLoad`, `loading`, and `error` slots customize each loading state. The default error state is visible and announced with `role="alert"`. diff --git a/docs/content/scripts/leaflet/2.api/2.tile-layer.md b/docs/content/scripts/leaflet/2.api/2.tile-layer.md new file mode 100644 index 000000000..5a5530ee6 --- /dev/null +++ b/docs/content/scripts/leaflet/2.api/2.tile-layer.md @@ -0,0 +1,10 @@ +--- +title: +--- + +Adds an `L.TileLayer` to the nearest parent map. Nuxt Scripts does not choose a tile provider for you. + +::script-types{script-key="leaflet" filter="ScriptLeafletTileLayer"} +:: + +Always follow the selected provider's terms and pass its required attribution through `options.attribution`. diff --git a/docs/content/scripts/leaflet/2.api/3.marker.md b/docs/content/scripts/leaflet/2.api/3.marker.md new file mode 100644 index 000000000..9af678abc --- /dev/null +++ b/docs/content/scripts/leaflet/2.api/3.marker.md @@ -0,0 +1,10 @@ +--- +title: +--- + +Adds a reactive `L.Marker`. Supply a unique `alt` so the generated marker image has a useful accessible name. + +::script-types{script-key="leaflet" filter="ScriptLeafletMarker"} +:: + +Nest a [``{lang="html"}](/scripts/leaflet/api/popup) in the default slot to bind it to the marker. diff --git a/docs/content/scripts/leaflet/2.api/4.popup.md b/docs/content/scripts/leaflet/2.api/4.popup.md new file mode 100644 index 000000000..4f26a8c5e --- /dev/null +++ b/docs/content/scripts/leaflet/2.api/4.popup.md @@ -0,0 +1,8 @@ +--- +title: +--- + +Renders slotted HTML in an `L.Popup`. Nest it inside a marker, or pass `position` for a standalone popup. Use the reactive `open` prop to control visibility. + +::script-types{script-key="leaflet" filter="ScriptLeafletPopup"} +:: diff --git a/docs/content/scripts/leaflet/2.api/5.geojson.md b/docs/content/scripts/leaflet/2.api/5.geojson.md new file mode 100644 index 000000000..0f2e37562 --- /dev/null +++ b/docs/content/scripts/leaflet/2.api/5.geojson.md @@ -0,0 +1,10 @@ +--- +title: +--- + +Adds inline GeoJSON through `L.geoJSON`. Replacing `data` clears and repopulates the existing layer; changing `options.style` updates its path style. + +::script-types{script-key="leaflet" filter="ScriptLeafletGeoJson"} +:: + +Remote fetching is intentionally outside the component. Fetch and validate data with `useFetch`, then pass the resulting object to `data` so loading and failure states remain explicit. diff --git a/docs/content/scripts/leaflet/2.api/6.use-script-leaflet.md b/docs/content/scripts/leaflet/2.api/6.use-script-leaflet.md new file mode 100644 index 000000000..012f62e04 --- /dev/null +++ b/docs/content/scripts/leaflet/2.api/6.use-script-leaflet.md @@ -0,0 +1,17 @@ +--- +title: useScriptLeaflet +--- + +Use `useScriptLeaflet()`{lang="ts"} when you need direct access to the Leaflet namespace or are building your own map component. + +::script-types{script-key="leaflet" filter="useScriptLeaflet"} +:: + +```ts +const { onLoaded } = useScriptLeaflet() + +onLoaded(({ L }) => { + const bounds = L.latLngBounds(points) + // Use the Leaflet API directly. +}) +``` diff --git a/docs/content/scripts/leaflet/index.md b/docs/content/scripts/leaflet/index.md new file mode 100644 index 000000000..322336a6e --- /dev/null +++ b/docs/content/scripts/leaflet/index.md @@ -0,0 +1,103 @@ +--- +title: Leaflet +description: Add lightweight, provider-agnostic interactive maps to Nuxt with lazy loading. +links: + - label: useScriptLeaflet + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/leaflet.ts + size: xs + - label: "" + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue + size: xs +--- + +[Leaflet](https://leafletjs.com/) is an open-source JavaScript library for interactive maps. It provides the map renderer and interaction model; you choose the tile provider, attribution, geocoding, and routing services separately. + +Nuxt Scripts supports Leaflet 1.9.4 through the [`useScriptLeaflet()`{lang="ts"}](/scripts/leaflet/api/use-script-leaflet) composable and a small set of declarative components. First-party mode bundles the SDK by default, while Nuxt Scripts embeds the styles and marker images locally. + +::script-types{exclude-components} +:: + +## Setup + +Install Leaflet's types, then enable the registry entry: + +```bash +pnpm add -D @types/leaflet +``` + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + registry: { + leaflet: { trigger: false }, + }, + }, +}) +``` + +## Quick start + +Leaflet does not include map imagery. This example explicitly chooses OpenStreetMap's standard tile service and keeps its required visible attribution. + +```vue + + + +``` + +The default `visible` trigger defers the Leaflet SDK and all tile requests until the map approaches the viewport. The component reserves its dimensions during SSR to avoid layout shift. + +::callout{color="amber"} +OpenStreetMap's standard tile servers are donation-funded and have no SLA. Keep attribution visible, do not prefetch or download tiles for offline use, and review the [tile usage policy](https://operations.osmfoundation.org/policies/tiles/) before production use. Higher-volume apps should select a suitable commercial or self-hosted provider. +:: + +## Components + +- [``{lang="html"}](/scripts/leaflet/api/script-leaflet-map) creates the map and controls lazy loading. +- [``{lang="html"}](/scripts/leaflet/api/tile-layer) connects an explicit tile provider. +- [``{lang="html"}](/scripts/leaflet/api/marker) adds accessible, reactive markers. +- [``{lang="html"}](/scripts/leaflet/api/popup) binds slotted HTML to a marker or coordinate. +- [``{lang="html"}](/scripts/leaflet/api/geojson) renders inline GeoJSON. + +## Accessibility + +Interactive maps retain Leaflet's keyboard controls. Give the map a useful `aria-label` and each marker a unique `alt`. For a purely decorative map, set `:interactive="false"`; the component disables input, marks the map hidden from assistive technology, and makes its descendants inert. + +## Custom styles + +Nuxt Scripts injects the default stylesheet only when the SDK begins loading. If your app already supplies Leaflet CSS, disable the embedded copy: + +```vue + +``` + +With strict inline-style CSP rules, use `inject-styles="false"` and load your allowed stylesheet through Nuxt's `css` configuration. diff --git a/package.json b/package.json index 48012c181..759fda1e0 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,10 @@ "@nuxt/test-utils": "catalog:", "@nuxtjs/partytown": "catalog:", "@paypal/paypal-js": "catalog:", + "@types/geojson": "catalog:", "@types/google.maps": "catalog:", "@types/jest-image-snapshot": "catalog:", + "@types/leaflet": "catalog:", "@types/node": "catalog:", "@types/youtube": "catalog:", "@vue/test-utils": "catalog:", diff --git a/packages/script/THIRD_PARTY_LICENSES.md b/packages/script/THIRD_PARTY_LICENSES.md new file mode 100644 index 000000000..077506d70 --- /dev/null +++ b/packages/script/THIRD_PARTY_LICENSES.md @@ -0,0 +1,13 @@ +# Third-party licenses + +## Leaflet 1.9.4 + +Copyright (c) 2010-2023, Vladimir Agafonkin +Copyright (c) 2010-2011, CloudMade + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/script/package.json b/packages/script/package.json index 7ba949302..e995d91a5 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -49,6 +49,7 @@ "nuxt-scripts": "./bin/cli.mjs" }, "files": [ + "THIRD_PARTY_LICENSES.md", "bin", "dist" ], @@ -77,7 +78,9 @@ "@paypal/paypal-js": "^8.1.2 || ^9.0.0", "@speedcurve/lux": "^4.4.3", "@stripe/stripe-js": "^7.0.0 || ^8.0.0 || ^9.0.0", + "@types/geojson": "^7946.0.0", "@types/google.maps": "^3.58.1", + "@types/leaflet": "^1.9.0", "@types/vimeo__player": "^2.18.3", "@types/youtube": "^0.1.0", "@unhead/vue": "^2.0.3 || ^3.0.0", @@ -96,9 +99,15 @@ "@stripe/stripe-js": { "optional": true }, + "@types/geojson": { + "optional": true + }, "@types/google.maps": { "optional": true }, + "@types/leaflet": { + "optional": true + }, "@types/vimeo__player": { "optional": true }, diff --git a/packages/script/src/registry-logos.ts b/packages/script/src/registry-logos.ts index 780d4d93f..3a0495529 100644 --- a/packages/script/src/registry-logos.ts +++ b/packages/script/src/registry-logos.ts @@ -43,6 +43,7 @@ export const LOGOS = { vimeoPlayer: ``, youtubePlayer: ``, googleMaps: ``, + leaflet: ``, blueskyEmbed: ``, instagramEmbed: ``, xEmbed: { diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 7a9da743a..efa04596f 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -650,6 +650,73 @@ "code": "interface ScriptIntercomSlots {\n default?: (props: { ready: boolean }) => any\n awaitingLoad?: () => any\n loading?: () => any\n error?: () => any\n}" } ], + "leaflet": [ + { + "name": "LeafletOptions", + "kind": "const", + "code": "export const LeafletOptions = object({\n /**\n * Inject the Leaflet 1.9.4 styles and default marker images when the script\n * begins loading. Disable this when supplying your own Leaflet stylesheet.\n * @default true\n */\n injectStyles: optional(boolean()),\n})" + }, + { + "name": "LeafletApi", + "kind": "interface", + "code": "export interface LeafletApi {\n L: typeof Leaflet\n}" + }, + { + "name": "ScriptLeafletGeoJsonProps", + "kind": "interface", + "code": "interface ScriptLeafletGeoJsonProps {\n /** GeoJSON object, feature, or feature collection. Replace it to update the layer. */\n data: GeoJsonObject | GeoJsonObject[]\n /** Options passed to `L.geoJSON`. */\n options?: Leaflet.GeoJSONOptions\n}" + }, + { + "name": "ScriptLeafletGeoJsonEvents", + "kind": "interface", + "code": "interface ScriptLeafletGeoJsonEvents {\n click: Leaflet.LeafletMouseEvent\n dblclick: Leaflet.LeafletMouseEvent\n mousedown: Leaflet.LeafletMouseEvent\n mouseover: Leaflet.LeafletMouseEvent\n mouseout: Leaflet.LeafletMouseEvent\n contextmenu: Leaflet.LeafletMouseEvent\n layeradd: Leaflet.LayerEvent\n layerremove: Leaflet.LayerEvent\n}" + }, + { + "name": "ScriptLeafletMapProps", + "kind": "interface", + "code": "interface ScriptLeafletMapProps ScriptLeafletMapProps" + }, + { + "name": "ScriptLeafletMarkerProps", + "kind": "interface", + "code": "interface ScriptLeafletMarkerProps {\n /** Reactive marker position. */\n position: Leaflet.LatLngExpression\n /** Unique text alternative for the marker icon. */\n alt?: string\n /** Tooltip text for the marker icon. */\n title?: string\n /** Options passed to `L.marker`. */\n options?: Leaflet.MarkerOptions\n}" + }, + { + "name": "ScriptLeafletMarkerEvents", + "kind": "interface", + "code": "interface ScriptLeafletMarkerEvents {\n click: Leaflet.LeafletMouseEvent\n dblclick: Leaflet.LeafletMouseEvent\n mousedown: Leaflet.LeafletMouseEvent\n mouseover: Leaflet.LeafletMouseEvent\n mouseout: Leaflet.LeafletMouseEvent\n contextmenu: Leaflet.LeafletMouseEvent\n dragstart: Leaflet.DragEndEvent\n drag: Leaflet.LeafletEvent\n dragend: Leaflet.DragEndEvent\n move: Leaflet.LeafletEvent\n}" + }, + { + "name": "ScriptLeafletMarkerSlots", + "kind": "interface", + "code": "interface ScriptLeafletMarkerSlots {\n default?: () => any\n}" + }, + { + "name": "ScriptLeafletPopupProps", + "kind": "interface", + "code": "interface ScriptLeafletPopupProps {\n /** Position for a standalone popup. Omit when nested inside a marker. */\n position?: Leaflet.LatLngExpression\n /** Whether the popup is open. @default false */\n open?: boolean\n /** Options passed to `L.popup`. */\n options?: Leaflet.PopupOptions\n}" + }, + { + "name": "ScriptLeafletPopupEvents", + "kind": "interface", + "code": "interface ScriptLeafletPopupEvents {\n open: Leaflet.LeafletEvent\n close: Leaflet.LeafletEvent\n}" + }, + { + "name": "ScriptLeafletPopupSlots", + "kind": "interface", + "code": "interface ScriptLeafletPopupSlots {\n default?: () => any\n}" + }, + { + "name": "ScriptLeafletTileLayerProps", + "kind": "interface", + "code": "interface ScriptLeafletTileLayerProps {\n /** Tile URL template, for example `https://tile.openstreetmap.org/{z}/{x}/{y}.png`. */\n url: string\n /** Tile layer options. Include the provider's required `attribution`. */\n options?: Leaflet.TileLayerOptions\n}" + }, + { + "name": "ScriptLeafletTileLayerEvents", + "kind": "interface", + "code": "interface ScriptLeafletTileLayerEvents {\n loading: Leaflet.LeafletEvent\n load: Leaflet.LeafletEvent\n tileloadstart: Leaflet.TileEvent\n tileload: Leaflet.TileEvent\n tileabort: Leaflet.TileEvent\n tileerror: Leaflet.TileErrorEvent\n}" + } + ], "lemon-squeezy": [ { "name": "LemonSqueezyEventPayload", @@ -1292,6 +1359,15 @@ "required": false } ], + "LeafletOptions": [ + { + "name": "injectStyles", + "type": "boolean", + "required": false, + "description": "Inject the Leaflet 1.9.4 styles and default marker images when the script begins loading. Disable this when supplying your own Leaflet stylesheet.", + "defaultValue": "true" + } + ], "AhrefsAnalyticsOptions": [ { "name": "key", @@ -3263,6 +3339,221 @@ "description": "Fired when the DOM mouseup event is fired on the rectangle." } ], + "ScriptLeafletGeoJsonProps": [ + { + "name": "data", + "type": "GeoJsonObject | GeoJsonObject[]", + "required": true + }, + { + "name": "options", + "type": "Leaflet.GeoJSONOptions", + "required": false + } + ], + "ScriptLeafletGeoJsonEvents": [ + { + "name": "click", + "type": "Leaflet.LeafletMouseEvent", + "required": false + }, + { + "name": "dblclick", + "type": "Leaflet.LeafletMouseEvent", + "required": false + }, + { + "name": "mousedown", + "type": "Leaflet.LeafletMouseEvent", + "required": false + }, + { + "name": "mouseover", + "type": "Leaflet.LeafletMouseEvent", + "required": false + }, + { + "name": "mouseout", + "type": "Leaflet.LeafletMouseEvent", + "required": false + }, + { + "name": "contextmenu", + "type": "Leaflet.LeafletMouseEvent", + "required": false + }, + { + "name": "layeradd", + "type": "Leaflet.LayerEvent", + "required": false + }, + { + "name": "layerremove", + "type": "Leaflet.LayerEvent", + "required": false + } + ], + "ScriptLeafletMarkerProps": [ + { + "name": "position", + "type": "Leaflet.LatLngExpression", + "required": true + }, + { + "name": "alt", + "type": "string", + "required": false + }, + { + "name": "title", + "type": "string", + "required": false + }, + { + "name": "options", + "type": "Leaflet.MarkerOptions", + "required": false + } + ], + "ScriptLeafletMarkerEvents": [ + { + "name": "click", + "type": "Leaflet.LeafletMouseEvent", + "required": false + }, + { + "name": "dblclick", + "type": "Leaflet.LeafletMouseEvent", + "required": false + }, + { + "name": "mousedown", + "type": "Leaflet.LeafletMouseEvent", + "required": false + }, + { + "name": "mouseover", + "type": "Leaflet.LeafletMouseEvent", + "required": false + }, + { + "name": "mouseout", + "type": "Leaflet.LeafletMouseEvent", + "required": false + }, + { + "name": "contextmenu", + "type": "Leaflet.LeafletMouseEvent", + "required": false + }, + { + "name": "dragstart", + "type": "Leaflet.DragEndEvent", + "required": false + }, + { + "name": "drag", + "type": "Leaflet.LeafletEvent", + "required": false + }, + { + "name": "dragend", + "type": "Leaflet.DragEndEvent", + "required": false + }, + { + "name": "move", + "type": "Leaflet.LeafletEvent", + "required": false + } + ], + "ScriptLeafletMarkerSlots": [ + { + "name": "default", + "type": "-", + "required": false + } + ], + "ScriptLeafletPopupProps": [ + { + "name": "position", + "type": "Leaflet.LatLngExpression", + "required": false + }, + { + "name": "open", + "type": "boolean", + "required": false + }, + { + "name": "options", + "type": "Leaflet.PopupOptions", + "required": false + } + ], + "ScriptLeafletPopupEvents": [ + { + "name": "open", + "type": "Leaflet.LeafletEvent", + "required": false + }, + { + "name": "close", + "type": "Leaflet.LeafletEvent", + "required": false + } + ], + "ScriptLeafletPopupSlots": [ + { + "name": "default", + "type": "-", + "required": false + } + ], + "ScriptLeafletTileLayerProps": [ + { + "name": "url", + "type": "string", + "required": true + }, + { + "name": "options", + "type": "Leaflet.TileLayerOptions", + "required": false + } + ], + "ScriptLeafletTileLayerEvents": [ + { + "name": "loading", + "type": "Leaflet.LeafletEvent", + "required": false + }, + { + "name": "load", + "type": "Leaflet.LeafletEvent", + "required": false + }, + { + "name": "tileloadstart", + "type": "Leaflet.TileEvent", + "required": false + }, + { + "name": "tileload", + "type": "Leaflet.TileEvent", + "required": false + }, + { + "name": "tileabort", + "type": "Leaflet.TileEvent", + "required": false + }, + { + "name": "tileerror", + "type": "Leaflet.TileErrorEvent", + "required": false + } + ], "ScriptBlueskyEmbedProps": [ { "name": "postUrl", diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 591d7889b..669764c70 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -32,6 +32,7 @@ import { HotjarOptions, InstagramEmbedOptions, IntercomOptions, + LeafletOptions, LinkedInInsightOptions, MatomoAnalyticsOptions, MetaPixelOptions, @@ -158,6 +159,7 @@ export const registryMeta: RegistryScriptMeta[] = [ m('vimeoPlayer', 'Vimeo Player', 'video', 'useScriptVimeoPlayer', { bundle: true, proxy: true }, PRIVACY_IP_ONLY), // content m('googleMaps', 'Google Maps', 'content', 'useScriptGoogleMaps', {}, null), + m('leaflet', 'Leaflet', 'content', 'useScriptLeaflet', { bundle: true }, null), m('instagramEmbed', 'Instagram Embed', 'content', false, {}, null), m('xEmbed', 'X Embed', 'content', false, {}, null), m('blueskyEmbed', 'Bluesky Embed', 'content', false, {}, null), @@ -695,6 +697,13 @@ export async function registry(resolve?: (path: string) => Promise): Pro { route: '/_scripts/proxy/google-maps-geocode', handler: './runtime/server/google-maps-geocode-proxy', requiresSigning: true }, ], }), + def('leaflet', { + schema: LeafletOptions, + label: 'Leaflet', + src: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js', + category: 'content', + bundle: true, + }), def('blueskyEmbed', { composableName: false, schema: BlueskyEmbedOptions, diff --git a/packages/script/src/runtime/components/Leaflet/ScriptLeafletGeoJson.vue b/packages/script/src/runtime/components/Leaflet/ScriptLeafletGeoJson.vue new file mode 100644 index 000000000..2d43e14fc --- /dev/null +++ b/packages/script/src/runtime/components/Leaflet/ScriptLeafletGeoJson.vue @@ -0,0 +1,62 @@ + + + diff --git a/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue b/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue new file mode 100644 index 000000000..19977e4c8 --- /dev/null +++ b/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/packages/script/src/runtime/components/Leaflet/ScriptLeafletMarker.vue b/packages/script/src/runtime/components/Leaflet/ScriptLeafletMarker.vue new file mode 100644 index 000000000..3c4fb3e95 --- /dev/null +++ b/packages/script/src/runtime/components/Leaflet/ScriptLeafletMarker.vue @@ -0,0 +1,97 @@ + + + diff --git a/packages/script/src/runtime/components/Leaflet/ScriptLeafletPopup.vue b/packages/script/src/runtime/components/Leaflet/ScriptLeafletPopup.vue new file mode 100644 index 000000000..7fd71714a --- /dev/null +++ b/packages/script/src/runtime/components/Leaflet/ScriptLeafletPopup.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/packages/script/src/runtime/components/Leaflet/ScriptLeafletTileLayer.vue b/packages/script/src/runtime/components/Leaflet/ScriptLeafletTileLayer.vue new file mode 100644 index 000000000..3c448e0a2 --- /dev/null +++ b/packages/script/src/runtime/components/Leaflet/ScriptLeafletTileLayer.vue @@ -0,0 +1,53 @@ + + + diff --git a/packages/script/src/runtime/components/Leaflet/useLeafletResource.ts b/packages/script/src/runtime/components/Leaflet/useLeafletResource.ts new file mode 100644 index 000000000..ff3e27a96 --- /dev/null +++ b/packages/script/src/runtime/components/Leaflet/useLeafletResource.ts @@ -0,0 +1,84 @@ +import type * as Leaflet from 'leaflet' +import type { InjectionKey, ShallowRef } from 'vue' +import { inject, onUnmounted, shallowRef, watch } from 'vue' + +export type LeafletNamespace = typeof Leaflet + +export interface LeafletMapContext { + map: ShallowRef + leaflet: ShallowRef +} + +export const LEAFLET_MAP_INJECTION_KEY = Symbol('leaflet-map') as InjectionKey + +export const LEAFLET_MARKER_INJECTION_KEY = Symbol('leaflet-marker') as InjectionKey<{ + marker: ShallowRef +}> + +export interface LeafletResourceContext { + map: Leaflet.Map + leaflet: LeafletNamespace +} + +export function bindLeafletEvents( + target: Leaflet.Evented, + emit: (event: any, ...args: any[]) => void, + events: readonly string[], +): void { + for (const event of events) + target.on(event, payload => emit(event, payload)) +} + +/** + * Creates a Leaflet resource once its parent map and any component-specific + * DOM refs are ready, then removes it synchronously during unmount. + */ +export function useLeafletResource({ + ready, + create, + cleanup, +}: { + ready?: () => boolean + create: (context: LeafletResourceContext) => T + cleanup: (resource: T, context: LeafletResourceContext) => void +}): ShallowRef { + const mapContext = inject(LEAFLET_MAP_INJECTION_KEY, undefined) + const resource = shallowRef() as ShallowRef + let creating = false + let resourceContext: LeafletResourceContext | undefined + + if (import.meta.dev && !mapContext) { + console.warn('[nuxt-scripts] Leaflet child components must be placed inside .') + } + + const stop = watch( + () => [mapContext?.map.value, mapContext?.leaflet.value, ready?.() ?? true] as const, + ([map, leaflet, isReady]) => { + if (!map || !leaflet || !isReady || resource.value || creating) + return + + creating = true + try { + resourceContext = { map, leaflet } + resource.value = create(resourceContext) + } + catch (error) { + console.error('[nuxt-scripts] Leaflet resource creation failed:', error) + } + finally { + creating = false + } + }, + { immediate: true }, + ) + + onUnmounted(() => { + stop() + if (resource.value && resourceContext) + cleanup(resource.value, resourceContext) + resource.value = undefined + resourceContext = undefined + }) + + return resource +} diff --git a/packages/script/src/runtime/leaflet-styles.ts b/packages/script/src/runtime/leaflet-styles.ts new file mode 100644 index 000000000..e451c81e5 --- /dev/null +++ b/packages/script/src/runtime/leaflet-styles.ts @@ -0,0 +1,34 @@ +import type * as Leaflet from 'leaflet' + +const LEAFLET_STYLE_ID = 'nuxt-scripts-leaflet-styles' + +/** + * Leaflet 1.9.4 styles with its referenced images embedded as data URIs. + * Leaflet is Copyright (c) 2010-2023 Vladimir Agafonkin and + * Copyright (c) 2010-2011 CloudMade, licensed under BSD-2-Clause. + * The complete notice is retained in packages/script/THIRD_PARTY_LICENSES.md. + */ +export const LEAFLET_CSS = '/* required styles */\r\n\r\n.leaflet-pane,\r\n.leaflet-tile,\r\n.leaflet-marker-icon,\r\n.leaflet-marker-shadow,\r\n.leaflet-tile-container,\r\n.leaflet-pane > svg,\r\n.leaflet-pane > canvas,\r\n.leaflet-zoom-box,\r\n.leaflet-image-layer,\r\n.leaflet-layer {\r\n\tposition: absolute;\r\n\tleft: 0;\r\n\ttop: 0;\r\n\t}\r\n.leaflet-container {\r\n\toverflow: hidden;\r\n\t}\r\n.leaflet-tile,\r\n.leaflet-marker-icon,\r\n.leaflet-marker-shadow {\r\n\t-webkit-user-select: none;\r\n\t -moz-user-select: none;\r\n\t user-select: none;\r\n\t -webkit-user-drag: none;\r\n\t}\r\n/* Prevents IE11 from highlighting tiles in blue */\r\n.leaflet-tile::selection {\r\n\tbackground: transparent;\r\n}\r\n/* Safari renders non-retina tile on retina better with this, but Chrome is worse */\r\n.leaflet-safari .leaflet-tile {\r\n\timage-rendering: -webkit-optimize-contrast;\r\n\t}\r\n/* hack that prevents hw layers "stretching" when loading new tiles */\r\n.leaflet-safari .leaflet-tile-container {\r\n\twidth: 1600px;\r\n\theight: 1600px;\r\n\t-webkit-transform-origin: 0 0;\r\n\t}\r\n.leaflet-marker-icon,\r\n.leaflet-marker-shadow {\r\n\tdisplay: block;\r\n\t}\r\n/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */\r\n/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */\r\n.leaflet-container .leaflet-overlay-pane svg {\r\n\tmax-width: none !important;\r\n\tmax-height: none !important;\r\n\t}\r\n.leaflet-container .leaflet-marker-pane img,\r\n.leaflet-container .leaflet-shadow-pane img,\r\n.leaflet-container .leaflet-tile-pane img,\r\n.leaflet-container img.leaflet-image-layer,\r\n.leaflet-container .leaflet-tile {\r\n\tmax-width: none !important;\r\n\tmax-height: none !important;\r\n\twidth: auto;\r\n\tpadding: 0;\r\n\t}\r\n\r\n.leaflet-container img.leaflet-tile {\r\n\t/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */\r\n\tmix-blend-mode: plus-lighter;\r\n}\r\n\r\n.leaflet-container.leaflet-touch-zoom {\r\n\t-ms-touch-action: pan-x pan-y;\r\n\ttouch-action: pan-x pan-y;\r\n\t}\r\n.leaflet-container.leaflet-touch-drag {\r\n\t-ms-touch-action: pinch-zoom;\r\n\t/* Fallback for FF which doesn\'t support pinch-zoom */\r\n\ttouch-action: none;\r\n\ttouch-action: pinch-zoom;\r\n}\r\n.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {\r\n\t-ms-touch-action: none;\r\n\ttouch-action: none;\r\n}\r\n.leaflet-container {\r\n\t-webkit-tap-highlight-color: transparent;\r\n}\r\n.leaflet-container a {\r\n\t-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);\r\n}\r\n.leaflet-tile {\r\n\tfilter: inherit;\r\n\tvisibility: hidden;\r\n\t}\r\n.leaflet-tile-loaded {\r\n\tvisibility: inherit;\r\n\t}\r\n.leaflet-zoom-box {\r\n\twidth: 0;\r\n\theight: 0;\r\n\t-moz-box-sizing: border-box;\r\n\t box-sizing: border-box;\r\n\tz-index: 800;\r\n\t}\r\n/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */\r\n.leaflet-overlay-pane svg {\r\n\t-moz-user-select: none;\r\n\t}\r\n\r\n.leaflet-pane { z-index: 400; }\r\n\r\n.leaflet-tile-pane { z-index: 200; }\r\n.leaflet-overlay-pane { z-index: 400; }\r\n.leaflet-shadow-pane { z-index: 500; }\r\n.leaflet-marker-pane { z-index: 600; }\r\n.leaflet-tooltip-pane { z-index: 650; }\r\n.leaflet-popup-pane { z-index: 700; }\r\n\r\n.leaflet-map-pane canvas { z-index: 100; }\r\n.leaflet-map-pane svg { z-index: 200; }\r\n\r\n.leaflet-vml-shape {\r\n\twidth: 1px;\r\n\theight: 1px;\r\n\t}\r\n.lvml {\r\n\tbehavior: url(#default#VML);\r\n\tdisplay: inline-block;\r\n\tposition: absolute;\r\n\t}\r\n\r\n\r\n/* control positioning */\r\n\r\n.leaflet-control {\r\n\tposition: relative;\r\n\tz-index: 800;\r\n\tpointer-events: visiblePainted; /* IE 9-10 doesn\'t have auto */\r\n\tpointer-events: auto;\r\n\t}\r\n.leaflet-top,\r\n.leaflet-bottom {\r\n\tposition: absolute;\r\n\tz-index: 1000;\r\n\tpointer-events: none;\r\n\t}\r\n.leaflet-top {\r\n\ttop: 0;\r\n\t}\r\n.leaflet-right {\r\n\tright: 0;\r\n\t}\r\n.leaflet-bottom {\r\n\tbottom: 0;\r\n\t}\r\n.leaflet-left {\r\n\tleft: 0;\r\n\t}\r\n.leaflet-control {\r\n\tfloat: left;\r\n\tclear: both;\r\n\t}\r\n.leaflet-right .leaflet-control {\r\n\tfloat: right;\r\n\t}\r\n.leaflet-top .leaflet-control {\r\n\tmargin-top: 10px;\r\n\t}\r\n.leaflet-bottom .leaflet-control {\r\n\tmargin-bottom: 10px;\r\n\t}\r\n.leaflet-left .leaflet-control {\r\n\tmargin-left: 10px;\r\n\t}\r\n.leaflet-right .leaflet-control {\r\n\tmargin-right: 10px;\r\n\t}\r\n\r\n\r\n/* zoom and fade animations */\r\n\r\n.leaflet-fade-anim .leaflet-popup {\r\n\topacity: 0;\r\n\t-webkit-transition: opacity 0.2s linear;\r\n\t -moz-transition: opacity 0.2s linear;\r\n\t transition: opacity 0.2s linear;\r\n\t}\r\n.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {\r\n\topacity: 1;\r\n\t}\r\n.leaflet-zoom-animated {\r\n\t-webkit-transform-origin: 0 0;\r\n\t -ms-transform-origin: 0 0;\r\n\t transform-origin: 0 0;\r\n\t}\r\nsvg.leaflet-zoom-animated {\r\n\twill-change: transform;\r\n}\r\n\r\n.leaflet-zoom-anim .leaflet-zoom-animated {\r\n\t-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);\r\n\t -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);\r\n\t transition: transform 0.25s cubic-bezier(0,0,0.25,1);\r\n\t}\r\n.leaflet-zoom-anim .leaflet-tile,\r\n.leaflet-pan-anim .leaflet-tile {\r\n\t-webkit-transition: none;\r\n\t -moz-transition: none;\r\n\t transition: none;\r\n\t}\r\n\r\n.leaflet-zoom-anim .leaflet-zoom-hide {\r\n\tvisibility: hidden;\r\n\t}\r\n\r\n\r\n/* cursors */\r\n\r\n.leaflet-interactive {\r\n\tcursor: pointer;\r\n\t}\r\n.leaflet-grab {\r\n\tcursor: -webkit-grab;\r\n\tcursor: -moz-grab;\r\n\tcursor: grab;\r\n\t}\r\n.leaflet-crosshair,\r\n.leaflet-crosshair .leaflet-interactive {\r\n\tcursor: crosshair;\r\n\t}\r\n.leaflet-popup-pane,\r\n.leaflet-control {\r\n\tcursor: auto;\r\n\t}\r\n.leaflet-dragging .leaflet-grab,\r\n.leaflet-dragging .leaflet-grab .leaflet-interactive,\r\n.leaflet-dragging .leaflet-marker-draggable {\r\n\tcursor: move;\r\n\tcursor: -webkit-grabbing;\r\n\tcursor: -moz-grabbing;\r\n\tcursor: grabbing;\r\n\t}\r\n\r\n/* marker & overlays interactivity */\r\n.leaflet-marker-icon,\r\n.leaflet-marker-shadow,\r\n.leaflet-image-layer,\r\n.leaflet-pane > svg path,\r\n.leaflet-tile-container {\r\n\tpointer-events: none;\r\n\t}\r\n\r\n.leaflet-marker-icon.leaflet-interactive,\r\n.leaflet-image-layer.leaflet-interactive,\r\n.leaflet-pane > svg path.leaflet-interactive,\r\nsvg.leaflet-image-layer.leaflet-interactive path {\r\n\tpointer-events: visiblePainted; /* IE 9-10 doesn\'t have auto */\r\n\tpointer-events: auto;\r\n\t}\r\n\r\n/* visual tweaks */\r\n\r\n.leaflet-container {\r\n\tbackground: #ddd;\r\n\toutline-offset: 1px;\r\n\t}\r\n.leaflet-container a {\r\n\tcolor: #0078A8;\r\n\t}\r\n.leaflet-zoom-box {\r\n\tborder: 2px dotted #38f;\r\n\tbackground: rgba(255,255,255,0.5);\r\n\t}\r\n\r\n\r\n/* general typography */\r\n.leaflet-container {\r\n\tfont-family: "Helvetica Neue", Arial, Helvetica, sans-serif;\r\n\tfont-size: 12px;\r\n\tfont-size: 0.75rem;\r\n\tline-height: 1.5;\r\n\t}\r\n\r\n\r\n/* general toolbar styles */\r\n\r\n.leaflet-bar {\r\n\tbox-shadow: 0 1px 5px rgba(0,0,0,0.65);\r\n\tborder-radius: 4px;\r\n\t}\r\n.leaflet-bar a {\r\n\tbackground-color: #fff;\r\n\tborder-bottom: 1px solid #ccc;\r\n\twidth: 26px;\r\n\theight: 26px;\r\n\tline-height: 26px;\r\n\tdisplay: block;\r\n\ttext-align: center;\r\n\ttext-decoration: none;\r\n\tcolor: black;\r\n\t}\r\n.leaflet-bar a,\r\n.leaflet-control-layers-toggle {\r\n\tbackground-position: 50% 50%;\r\n\tbackground-repeat: no-repeat;\r\n\tdisplay: block;\r\n\t}\r\n.leaflet-bar a:hover,\r\n.leaflet-bar a:focus {\r\n\tbackground-color: #f4f4f4;\r\n\t}\r\n.leaflet-bar a:first-child {\r\n\tborder-top-left-radius: 4px;\r\n\tborder-top-right-radius: 4px;\r\n\t}\r\n.leaflet-bar a:last-child {\r\n\tborder-bottom-left-radius: 4px;\r\n\tborder-bottom-right-radius: 4px;\r\n\tborder-bottom: none;\r\n\t}\r\n.leaflet-bar a.leaflet-disabled {\r\n\tcursor: default;\r\n\tbackground-color: #f4f4f4;\r\n\tcolor: #bbb;\r\n\t}\r\n\r\n.leaflet-touch .leaflet-bar a {\r\n\twidth: 30px;\r\n\theight: 30px;\r\n\tline-height: 30px;\r\n\t}\r\n.leaflet-touch .leaflet-bar a:first-child {\r\n\tborder-top-left-radius: 2px;\r\n\tborder-top-right-radius: 2px;\r\n\t}\r\n.leaflet-touch .leaflet-bar a:last-child {\r\n\tborder-bottom-left-radius: 2px;\r\n\tborder-bottom-right-radius: 2px;\r\n\t}\r\n\r\n/* zoom control */\r\n\r\n.leaflet-control-zoom-in,\r\n.leaflet-control-zoom-out {\r\n\tfont: bold 18px \'Lucida Console\', Monaco, monospace;\r\n\ttext-indent: 1px;\r\n\t}\r\n\r\n.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {\r\n\tfont-size: 22px;\r\n\t}\r\n\r\n\r\n/* layers control */\r\n\r\n.leaflet-control-layers {\r\n\tbox-shadow: 0 1px 5px rgba(0,0,0,0.4);\r\n\tbackground: #fff;\r\n\tborder-radius: 5px;\r\n\t}\r\n.leaflet-control-layers-toggle {\r\n\tbackground-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAaCAQAAAADQ4RFAAACf0lEQVR4AY1UM3gkARTePdvdoTxXKc+qTl3aU5U6b2Kbkz3Gtq3Zw6ziLGNPzrYx7946Tr6/ee/XeCQ4D3ykPtL5tHno4n0d/h3+xfuWHGLX81cn7r0iTNzjr7LrlxCqPtkbTQEHeqOrTy4Yyt3VCi/IOB0v7rVC7q45Q3Gr5K6jt+3Gl5nCoDD4MtO+j96Wu8atmhGqcNGHObuf8OM/x3AMx38+4Z2sPqzCxRFK2aF2e5Jol56XTLyggAMTL56XOMoS1W4pOyjUcGGQdZxU6qRh7B9Zp+PfpOFlqt0zyDZckPi1ttmIp03jX8gyJ8a/PG2yutpS/Vol7peZIbZcKBAEEheEIAgFbDkz5H6Zrkm2hVWGiXKiF4Ycw0RWKdtC16Q7qe3X4iOMxruonzegJzWaXFrU9utOSsLUmrc0YjeWYjCW4PDMADElpJSSQ0vQvA1Tm6/JlKnqFs1EGyZiFCqnRZTEJJJiKRYzVYzJck2Rm6P4iH+cmSY0YzimYa8l0EtTODFWhcMIMVqdsI2uiTvKmTisIDHJ3od5GILVhBCarCfVRmo4uTjkhrhzkiBV7SsaqS+TzrzM1qpGGUFt28pIySQHR6h7F6KSwGWm97ay+Z+ZqMcEjEWebE7wxCSQwpkhJqoZA5ivCdZDjJepuJ9IQjGGUmuXJdBFUygxVqVsxFsLMbDe8ZbDYVCGKxs+W080max1hFCarCfV+C1KATwcnvE9gRRuMP2prdbWGowm1KB1y+zwMMENkM755cJ2yPDtqhTI6ED1M/82yIDtC/4j4BijjeObflpO9I9MwXTCsSX8jWAFeHr05WoLTJ5G8IQVS/7vwR6ohirYM7f6HzYpogfS3R2OAAAAAElFTkSuQmCC);\r\n\twidth: 36px;\r\n\theight: 36px;\r\n\t}\r\n.leaflet-retina .leaflet-control-layers-toggle {\r\n\tbackground-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADQAAAA0CAQAAABvcdNgAAAEsklEQVR4AWL4TydIhpZK1kpWOlg0w3ZXP6D2soBtG42jeI6ZmQTHzAxiTbSJsYLjO9HhP+WOmcuhciVnmHVQcJnp7DFvScowZorad/+V/fVzMdMT2g9Cv9guXGv/7pYOrXh2U+RRR3dSd9JRx6bIFc/ekqHI29JC6pJ5ZEh1yWkhkbcFeSjxgx3L2m1cb1C7bceyxA+CNjT/Ifff+/kDk2u/w/33/IeCMOSaWZ4glosqT3DNnNZQ7Cs58/3Ce5HL78iZH/vKVIaYlqzfdLu8Vi7dnvUbEza5Idt36tquZFldl6N5Z/POLof0XLK61mZCmJSWjVF9tEjUluu74IUXvgttuVIHE7YxSkaYhJZam7yiM9Pv82JYfl9nptxZaxMJE4YSPty+vF0+Y2up9d3wwijfjZbabqm/3bZ9ecKHsiGmRflnn1MW4pjHf9oLufyn2z3y1D6n8g8TZhxyzipLNPnAUpsOiuWimg52psrTZYnOWYNDTMuWBWa0tJb4rgq1UvmutpaYEbZlwU3CLJm/ayYjHW5/h7xWLn9Hh1vepDkyf7dE7MtT5LR4e7yYpHrkhOUpEfssBLq2pPhAqoSWKUkk7EDqkmK6RrCEzqDjhNDWNE+XSMvkJRDWlZTmCW0l0PHQGRZY5t1L83kT0Y3l2SItk5JAWHl2dCOBm+fPu3fo5/3v61RMCO9Jx2EEYYhb0rmNQMX/vm7gqOEJLcXTGw3CAuRNeyaPWwjR8PRqKQ1PDA/dpv+on9Shox52WFnx0KY8onHayrJzm87i5h9xGw/tfkev0jGsQizqezUKjk12hBMKJ4kbCqGPVNXudyyrShovGw5CgxsRICxF6aRmSjlBnHRzg7Gx8fKqEubI2rahQYdR1YgDIRQO7JvQyD52hoIQx0mxa0ODtW2Iozn1le2iIRdzwWewedyZzewidueOGqlsn1MvcnQpuVwLGG3/IR1hIKxCjelIDZ8ldqWz25jWAsnldEnK0Zxro19TGVb2ffIZEsIO89EIEDvKMPrzmBOQcKQ+rroye6NgRRxqR4U8EAkz0CL6uSGOm6KQCdWjvjRiSP1BPalCRS5iQYiEIvxuBMJEWgzSoHADcVMuN7IuqqTeyUPq22qFimFtxDyBBJEwNyt6TM88blFHao/6tWWhuuOM4SAK4EI4QmFHA+SEyWlp4EQoJ13cYGzMu7yszEIBOm2rVmHUNqwAIQabISNMRstmdhNWcFLsSm+0tjJH1MdRxO5Nx0WDMhCtgD6OKgZeljJqJKc9po8juskR9XN0Y1lZ3mWjLR9JCO1jRDMd0fpYC2VnvjBSEFg7wBENc0R9HFlb0xvF1+TBEpF68d+DHR6IOWVv2BECtxo46hOFUBd/APU57WIoEwJhIi2CdpyZX0m93BZicktMj1AS9dClteUFAUNUIEygRZCtik5zSxI9MubTBH1GOiHsiLJ3OCoSZkILa9PxiN0EbvhsAo8tdAf9Seepd36lGWHmtNANTv5Jd0z4QYyeo/UEJqxKRpg5LZx6btLPsOaEmdMyxYdlc8LMaJnikDlhclqmPiQnTEpLUIZEwkRagjYkEibQErwhkTAKCLQEbUgkzJQWc/0PstHHcfEdQ+UAAAAASUVORK5CYII=);\r\n\tbackground-size: 26px 26px;\r\n\t}\r\n.leaflet-touch .leaflet-control-layers-toggle {\r\n\twidth: 44px;\r\n\theight: 44px;\r\n\t}\r\n.leaflet-control-layers .leaflet-control-layers-list,\r\n.leaflet-control-layers-expanded .leaflet-control-layers-toggle {\r\n\tdisplay: none;\r\n\t}\r\n.leaflet-control-layers-expanded .leaflet-control-layers-list {\r\n\tdisplay: block;\r\n\tposition: relative;\r\n\t}\r\n.leaflet-control-layers-expanded {\r\n\tpadding: 6px 10px 6px 6px;\r\n\tcolor: #333;\r\n\tbackground: #fff;\r\n\t}\r\n.leaflet-control-layers-scrollbar {\r\n\toverflow-y: scroll;\r\n\toverflow-x: hidden;\r\n\tpadding-right: 5px;\r\n\t}\r\n.leaflet-control-layers-selector {\r\n\tmargin-top: 2px;\r\n\tposition: relative;\r\n\ttop: 1px;\r\n\t}\r\n.leaflet-control-layers label {\r\n\tdisplay: block;\r\n\tfont-size: 13px;\r\n\tfont-size: 1.08333em;\r\n\t}\r\n.leaflet-control-layers-separator {\r\n\theight: 0;\r\n\tborder-top: 1px solid #ddd;\r\n\tmargin: 5px -10px 5px -6px;\r\n\t}\r\n\r\n/* Default icon URLs */\r\n.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */\r\n\tbackground-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAApCAYAAADAk4LOAAAFgUlEQVR4Aa1XA5BjWRTN2oW17d3YaZtr2962HUzbDNpjszW24mRt28p47v7zq/bXZtrp/lWnXr337j3nPCe85NcypgSFdugCpW5YoDAMRaIMqRi6aKq5E3YqDQO3qAwjVWrD8Ncq/RBpykd8oZUb/kaJutow8r1aP9II0WmLKLIsJyv1w/kqw9Ch2MYdB++12Onxee/QMwvf4/Dk/Lfp/i4nxTXtOoQ4pW5Aj7wpici1A9erdAN2OH64x8OSP9j3Ft3b7aWkTg/Fm91siTra0f9on5sQr9INejH6CUUUpavjFNq1B+Oadhxmnfa8RfEmN8VNAsQhPqF55xHkMzz3jSmChWU6f7/XZKNH+9+hBLOHYozuKQPxyMPUKkrX/K0uWnfFaJGS1QPRtZsOPtr3NsW0uyh6NNCOkU3Yz+bXbT3I8G3xE5EXLXtCXbbqwCO9zPQYPRTZ5vIDXD7U+w7rFDEoUUf7ibHIR4y6bLVPXrz8JVZEql13trxwue/uDivd3fkWRbS6/IA2bID4uk0UpF1N8qLlbBlXs4Ee7HLTfV1j54APvODnSfOWBqtKVvjgLKzF5YdEk5ewRkGlK0i33Eofffc7HT56jD7/6U+qH3Cx7SBLNntH5YIPvODnyfIXZYRVDPqgHtLs5ABHD3YzLuespb7t79FY34DjMwrVrcTuwlT55YMPvOBnRrJ4VXTdNnYug5ucHLBjEpt30701A3Ts+HEa73u6dT3FNWwflY86eMHPk+Yu+i6pzUpRrW7SNDg5JHR4KapmM5Wv2E8Tfcb1HoqqHMHU+uWDD7zg54mz5/2BSnizi9T1Dg4QQXLToGNCkb6tb1NU+QAlGr1++eADrzhn/u8Q2YZhQVlZ5+CAOtqfbhmaUCS1ezNFVm2imDbPmPng5wmz+gwh+oHDce0eUtQ6OGDIyR0uUhUsoO3vfDmmgOezH0mZN59x7MBi++WDL1g/eEiU3avlidO671bkLfwbw5XV2P8Pzo0ydy4t2/0eu33xYSOMOD8hTf4CrBtGMSoXfPLchX+J0ruSePw3LZeK0juPJbYzrhkH0io7B3k164hiGvawhOKMLkrQLyVpZg8rHFW7E2uHOL888IBPlNZ1FPzstSJM694fWr6RwpvcJK60+0HCILTBzZLFNdtAzJaohze60T8qBzyh5ZuOg5e7uwQppofEmf2++DYvmySqGBuKaicF1blQjhuHdvCIMvp8whTTfZzI7RldpwtSzL+F1+wkdZ2TBOW2gIF88PBTzD/gpeREAMEbxnJcaJHNHrpzji0gQCS6hdkEeYt9DF/2qPcEC8RM28Hwmr3sdNyht00byAut2k3gufWNtgtOEOFGUwcXWNDbdNbpgBGxEvKkOQsxivJx33iow0Vw5S6SVTrpVq11ysA2Rp7gTfPfktc6zhtXBBC+adRLshf6sG2RfHPZ5EAc4sVZ83yCN00Fk/4kggu40ZTvIEm5g24qtU4KjBrx/BTTH8ifVASAG7gKrnWxJDcU7x8X6Ecczhm3o6YicvsLXWfh3Ch1W0k8x0nXF+0fFxgt4phz8QvypiwCCFKMqXCnqXExjq10beH+UUA7+nG6mdG/Pu0f3LgFcGrl2s0kNNjpmoJ9o4B29CMO8dMT4Q5ox8uitF6fqsrJOr8qnwNbRzv6hSnG5wP+64C7h9lp30hKNtKdWjtdkbuPA19nJ7Tz3zR/ibgARbhb4AlhavcBebmTHcFl2fvYEnW0ox9xMxKBS8btJ+KiEbq9zA4RthQXDhPa0T9TEe69gWupwc6uBUphquXgf+/FrIjweHQS4/pduMe5ERUMHUd9xv8ZR98CxkS4F2n3EUrUZ10EYNw7BWm9x1GiPssi3GgiGRDKWRYZfXlON+dfNbM+GgIwYdwAAAAASUVORK5CYII=);\r\n\t}\r\n\r\n\r\n/* attribution and scale controls */\r\n\r\n.leaflet-container .leaflet-control-attribution {\r\n\tbackground: #fff;\r\n\tbackground: rgba(255, 255, 255, 0.8);\r\n\tmargin: 0;\r\n\t}\r\n.leaflet-control-attribution,\r\n.leaflet-control-scale-line {\r\n\tpadding: 0 5px;\r\n\tcolor: #333;\r\n\tline-height: 1.4;\r\n\t}\r\n.leaflet-control-attribution a {\r\n\ttext-decoration: none;\r\n\t}\r\n.leaflet-control-attribution a:hover,\r\n.leaflet-control-attribution a:focus {\r\n\ttext-decoration: underline;\r\n\t}\r\n.leaflet-attribution-flag {\r\n\tdisplay: inline !important;\r\n\tvertical-align: baseline !important;\r\n\twidth: 1em;\r\n\theight: 0.6669em;\r\n\t}\r\n.leaflet-left .leaflet-control-scale {\r\n\tmargin-left: 5px;\r\n\t}\r\n.leaflet-bottom .leaflet-control-scale {\r\n\tmargin-bottom: 5px;\r\n\t}\r\n.leaflet-control-scale-line {\r\n\tborder: 2px solid #777;\r\n\tborder-top: none;\r\n\tline-height: 1.1;\r\n\tpadding: 2px 5px 1px;\r\n\twhite-space: nowrap;\r\n\t-moz-box-sizing: border-box;\r\n\t box-sizing: border-box;\r\n\tbackground: rgba(255, 255, 255, 0.8);\r\n\ttext-shadow: 1px 1px #fff;\r\n\t}\r\n.leaflet-control-scale-line:not(:first-child) {\r\n\tborder-top: 2px solid #777;\r\n\tborder-bottom: none;\r\n\tmargin-top: -2px;\r\n\t}\r\n.leaflet-control-scale-line:not(:first-child):not(:last-child) {\r\n\tborder-bottom: 2px solid #777;\r\n\t}\r\n\r\n.leaflet-touch .leaflet-control-attribution,\r\n.leaflet-touch .leaflet-control-layers,\r\n.leaflet-touch .leaflet-bar {\r\n\tbox-shadow: none;\r\n\t}\r\n.leaflet-touch .leaflet-control-layers,\r\n.leaflet-touch .leaflet-bar {\r\n\tborder: 2px solid rgba(0,0,0,0.2);\r\n\tbackground-clip: padding-box;\r\n\t}\r\n\r\n\r\n/* popup */\r\n\r\n.leaflet-popup {\r\n\tposition: absolute;\r\n\ttext-align: center;\r\n\tmargin-bottom: 20px;\r\n\t}\r\n.leaflet-popup-content-wrapper {\r\n\tpadding: 1px;\r\n\ttext-align: left;\r\n\tborder-radius: 12px;\r\n\t}\r\n.leaflet-popup-content {\r\n\tmargin: 13px 24px 13px 20px;\r\n\tline-height: 1.3;\r\n\tfont-size: 13px;\r\n\tfont-size: 1.08333em;\r\n\tmin-height: 1px;\r\n\t}\r\n.leaflet-popup-content p {\r\n\tmargin: 17px 0;\r\n\tmargin: 1.3em 0;\r\n\t}\r\n.leaflet-popup-tip-container {\r\n\twidth: 40px;\r\n\theight: 20px;\r\n\tposition: absolute;\r\n\tleft: 50%;\r\n\tmargin-top: -1px;\r\n\tmargin-left: -20px;\r\n\toverflow: hidden;\r\n\tpointer-events: none;\r\n\t}\r\n.leaflet-popup-tip {\r\n\twidth: 17px;\r\n\theight: 17px;\r\n\tpadding: 1px;\r\n\r\n\tmargin: -10px auto 0;\r\n\tpointer-events: auto;\r\n\r\n\t-webkit-transform: rotate(45deg);\r\n\t -moz-transform: rotate(45deg);\r\n\t -ms-transform: rotate(45deg);\r\n\t transform: rotate(45deg);\r\n\t}\r\n.leaflet-popup-content-wrapper,\r\n.leaflet-popup-tip {\r\n\tbackground: white;\r\n\tcolor: #333;\r\n\tbox-shadow: 0 3px 14px rgba(0,0,0,0.4);\r\n\t}\r\n.leaflet-container a.leaflet-popup-close-button {\r\n\tposition: absolute;\r\n\ttop: 0;\r\n\tright: 0;\r\n\tborder: none;\r\n\ttext-align: center;\r\n\twidth: 24px;\r\n\theight: 24px;\r\n\tfont: 16px/24px Tahoma, Verdana, sans-serif;\r\n\tcolor: #757575;\r\n\ttext-decoration: none;\r\n\tbackground: transparent;\r\n\t}\r\n.leaflet-container a.leaflet-popup-close-button:hover,\r\n.leaflet-container a.leaflet-popup-close-button:focus {\r\n\tcolor: #585858;\r\n\t}\r\n.leaflet-popup-scrolled {\r\n\toverflow: auto;\r\n\t}\r\n\r\n.leaflet-oldie .leaflet-popup-content-wrapper {\r\n\t-ms-zoom: 1;\r\n\t}\r\n.leaflet-oldie .leaflet-popup-tip {\r\n\twidth: 24px;\r\n\tmargin: 0 auto;\r\n\r\n\t-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";\r\n\tfilter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);\r\n\t}\r\n\r\n.leaflet-oldie .leaflet-control-zoom,\r\n.leaflet-oldie .leaflet-control-layers,\r\n.leaflet-oldie .leaflet-popup-content-wrapper,\r\n.leaflet-oldie .leaflet-popup-tip {\r\n\tborder: 1px solid #999;\r\n\t}\r\n\r\n\r\n/* div icon */\r\n\r\n.leaflet-div-icon {\r\n\tbackground: #fff;\r\n\tborder: 1px solid #666;\r\n\t}\r\n\r\n\r\n/* Tooltip */\r\n/* Base styles for the element that has a tooltip */\r\n.leaflet-tooltip {\r\n\tposition: absolute;\r\n\tpadding: 6px;\r\n\tbackground-color: #fff;\r\n\tborder: 1px solid #fff;\r\n\tborder-radius: 3px;\r\n\tcolor: #222;\r\n\twhite-space: nowrap;\r\n\t-webkit-user-select: none;\r\n\t-moz-user-select: none;\r\n\t-ms-user-select: none;\r\n\tuser-select: none;\r\n\tpointer-events: none;\r\n\tbox-shadow: 0 1px 3px rgba(0,0,0,0.4);\r\n\t}\r\n.leaflet-tooltip.leaflet-interactive {\r\n\tcursor: pointer;\r\n\tpointer-events: auto;\r\n\t}\r\n.leaflet-tooltip-top:before,\r\n.leaflet-tooltip-bottom:before,\r\n.leaflet-tooltip-left:before,\r\n.leaflet-tooltip-right:before {\r\n\tposition: absolute;\r\n\tpointer-events: none;\r\n\tborder: 6px solid transparent;\r\n\tbackground: transparent;\r\n\tcontent: "";\r\n\t}\r\n\r\n/* Directions */\r\n\r\n.leaflet-tooltip-bottom {\r\n\tmargin-top: 6px;\r\n}\r\n.leaflet-tooltip-top {\r\n\tmargin-top: -6px;\r\n}\r\n.leaflet-tooltip-bottom:before,\r\n.leaflet-tooltip-top:before {\r\n\tleft: 50%;\r\n\tmargin-left: -6px;\r\n\t}\r\n.leaflet-tooltip-top:before {\r\n\tbottom: 0;\r\n\tmargin-bottom: -12px;\r\n\tborder-top-color: #fff;\r\n\t}\r\n.leaflet-tooltip-bottom:before {\r\n\ttop: 0;\r\n\tmargin-top: -12px;\r\n\tmargin-left: -6px;\r\n\tborder-bottom-color: #fff;\r\n\t}\r\n.leaflet-tooltip-left {\r\n\tmargin-left: -6px;\r\n}\r\n.leaflet-tooltip-right {\r\n\tmargin-left: 6px;\r\n}\r\n.leaflet-tooltip-left:before,\r\n.leaflet-tooltip-right:before {\r\n\ttop: 50%;\r\n\tmargin-top: -6px;\r\n\t}\r\n.leaflet-tooltip-left:before {\r\n\tright: 0;\r\n\tmargin-right: -12px;\r\n\tborder-left-color: #fff;\r\n\t}\r\n.leaflet-tooltip-right:before {\r\n\tleft: 0;\r\n\tmargin-left: -12px;\r\n\tborder-right-color: #fff;\r\n\t}\r\n\r\n/* Printing */\r\n\r\n@media print {\r\n\t/* Prevent printers from removing background-images of controls. */\r\n\t.leaflet-control {\r\n\t\t-webkit-print-color-adjust: exact;\r\n\t\tprint-color-adjust: exact;\r\n\t\t}\r\n\t}\r\n' + +export const LEAFLET_DEFAULT_ICON_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAApCAYAAADAk4LOAAAFgUlEQVR4Aa1XA5BjWRTN2oW17d3YaZtr2962HUzbDNpjszW24mRt28p47v7zq/bXZtrp/lWnXr337j3nPCe85NcypgSFdugCpW5YoDAMRaIMqRi6aKq5E3YqDQO3qAwjVWrD8Ncq/RBpykd8oZUb/kaJutow8r1aP9II0WmLKLIsJyv1w/kqw9Ch2MYdB++12Onxee/QMwvf4/Dk/Lfp/i4nxTXtOoQ4pW5Aj7wpici1A9erdAN2OH64x8OSP9j3Ft3b7aWkTg/Fm91siTra0f9on5sQr9INejH6CUUUpavjFNq1B+Oadhxmnfa8RfEmN8VNAsQhPqF55xHkMzz3jSmChWU6f7/XZKNH+9+hBLOHYozuKQPxyMPUKkrX/K0uWnfFaJGS1QPRtZsOPtr3NsW0uyh6NNCOkU3Yz+bXbT3I8G3xE5EXLXtCXbbqwCO9zPQYPRTZ5vIDXD7U+w7rFDEoUUf7ibHIR4y6bLVPXrz8JVZEql13trxwue/uDivd3fkWRbS6/IA2bID4uk0UpF1N8qLlbBlXs4Ee7HLTfV1j54APvODnSfOWBqtKVvjgLKzF5YdEk5ewRkGlK0i33Eofffc7HT56jD7/6U+qH3Cx7SBLNntH5YIPvODnyfIXZYRVDPqgHtLs5ABHD3YzLuespb7t79FY34DjMwrVrcTuwlT55YMPvOBnRrJ4VXTdNnYug5ucHLBjEpt30701A3Ts+HEa73u6dT3FNWwflY86eMHPk+Yu+i6pzUpRrW7SNDg5JHR4KapmM5Wv2E8Tfcb1HoqqHMHU+uWDD7zg54mz5/2BSnizi9T1Dg4QQXLToGNCkb6tb1NU+QAlGr1++eADrzhn/u8Q2YZhQVlZ5+CAOtqfbhmaUCS1ezNFVm2imDbPmPng5wmz+gwh+oHDce0eUtQ6OGDIyR0uUhUsoO3vfDmmgOezH0mZN59x7MBi++WDL1g/eEiU3avlidO671bkLfwbw5XV2P8Pzo0ydy4t2/0eu33xYSOMOD8hTf4CrBtGMSoXfPLchX+J0ruSePw3LZeK0juPJbYzrhkH0io7B3k164hiGvawhOKMLkrQLyVpZg8rHFW7E2uHOL888IBPlNZ1FPzstSJM694fWr6RwpvcJK60+0HCILTBzZLFNdtAzJaohze60T8qBzyh5ZuOg5e7uwQppofEmf2++DYvmySqGBuKaicF1blQjhuHdvCIMvp8whTTfZzI7RldpwtSzL+F1+wkdZ2TBOW2gIF88PBTzD/gpeREAMEbxnJcaJHNHrpzji0gQCS6hdkEeYt9DF/2qPcEC8RM28Hwmr3sdNyht00byAut2k3gufWNtgtOEOFGUwcXWNDbdNbpgBGxEvKkOQsxivJx33iow0Vw5S6SVTrpVq11ysA2Rp7gTfPfktc6zhtXBBC+adRLshf6sG2RfHPZ5EAc4sVZ83yCN00Fk/4kggu40ZTvIEm5g24qtU4KjBrx/BTTH8ifVASAG7gKrnWxJDcU7x8X6Ecczhm3o6YicvsLXWfh3Ch1W0k8x0nXF+0fFxgt4phz8QvypiwCCFKMqXCnqXExjq10beH+UUA7+nG6mdG/Pu0f3LgFcGrl2s0kNNjpmoJ9o4B29CMO8dMT4Q5ox8uitF6fqsrJOr8qnwNbRzv6hSnG5wP+64C7h9lp30hKNtKdWjtdkbuPA19nJ7Tz3zR/ibgARbhb4AlhavcBebmTHcFl2fvYEnW0ox9xMxKBS8btJ+KiEbq9zA4RthQXDhPa0T9TEe69gWupwc6uBUphquXgf+/FrIjweHQS4/pduMe5ERUMHUd9xv8ZR98CxkS4F2n3EUrUZ10EYNw7BWm9x1GiPssi3GgiGRDKWRYZfXlON+dfNbM+GgIwYdwAAAAASUVORK5CYII=' +export const LEAFLET_DEFAULT_ICON_RETINA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAABSCAMAAAAhFXfZAAAC91BMVEVMaXEzeak2f7I4g7g3g7cua5gzeKg8hJo3grY4g7c3grU0gLI2frE0daAubJc2gbQwd6QzeKk2gLMtd5sxdKIua5g1frA2f7IydaM0e6w2fq41fK01eqo3grgubJgta5cxdKI1f7AydaQydaMxc6EubJgvbJkwcZ4ubZkwcJwubZgubJcydqUydKIxapgubJctbJcubZcubJcvbJYubJcvbZkubJctbJctbZcubJg2f7AubJcrbZcubJcubJcua5g3grY0fq8ubJcubJdEkdEwhsw6i88vhswuhcsuhMtBjMgthMsrg8srgss6is8qgcs8i9A9iMYtg8spgcoogMo7hcMngMonf8olfso4gr8kfck5iM8jfMk4iM8he8k1fro7itAgesk2hs8eecgzfLcofssdeMg0hc4cd8g2hcsxeLQbdsgZdcgxeLImfcszhM0vda4xgckzhM4xg84wf8Yxgs4udKsvfcQucqhUndROmdM1fK0wcZ8vb5w0eqpQm9MzeKhXoNVcpdYydKNWn9VZotVKltJFjsIwcJ1Rms9OlslLmtH///8+kc9epdYzd6dbo9VHkMM2f7FHmNBClM8ydqVcpNY9hro3gLM9hLczealQmcw3fa46f7A8gLMxc6I3eagyc6FIldJMl9JSnNRSntNNl9JPnNJFi75UnM9ZodVKksg8kM45jc09e6ZHltFBk883gbRBh7pDk9EwcaBzn784g7dKkcY2i81Om9M7j85Llc81is09g7Q4grY/j9A0eqxKmdFFltBEjcXf6fFImdBCiLxJl9FGlNFBi78yiMxVndEvbpo6js74+vx+psPP3+o/ks5HkcpGmNCjwdZCkNDM3ehYoNJEls+lxNkxh8xHks0+jdC1zd5Lg6r+/v/H2ufz9/o3jM3t8/edvdM/k89Th61OiLBSjbZklbaTt9BfptdjmL1AicBHj8hGk9FAgK1dkLNTjLRekrdClc/k7fM0icy0y9tgp9c4jc2NtM9Dlc8zicxeXZn3AAAAQ3RSTlMAHDdTb4yPA+LtnEQmC4L2EmHqB7XA0d0sr478x4/Yd5i1zOfyPkf1sLVq4Nh3FvjxopQ2/STNuFzUwFIwxKaejILpIBEV9wAABhVJREFUeF6s1NdyFEcYBeBeoQIhRAkLlRDGrhIgY3BJL8CVeKzuyXFzzjkn5ZxzzuScg3PO8cKzu70JkO0LfxdTU//pM9vTu7Xgf6KqOVTb9X7toRrVEfBf1HTVjZccrT/2by1VV928Yty9ZbVuucdz90frG8DBjl9pVApbOstvmMuvVgaNXSfAAd6pGxpy6yxf5ph43pS/4f3uoaGm2rdu72S9xzOvMymkZFq/ptDrk90mhW7e4zl7HLzhxGWPR20xmSxJ/VqldG5m9XhaVOA1DadsNh3Pu5L2N6QtPO/32JpqQBVVk20oy/Pi2s23WEvyfHbe1thadVQttvm7Llf65gGmXK67XtupyoM7HQhmXdLS8oGWJNeOJ3C5fG5XCEJnkez3/oFdsvgJ4l2ANZwhrJKk/7OSXa+3Vw2WJMlKnGkobouYk6T0TyX30klOUnTD9HJ5qpckL3EW/w4XF3Xd0FGywXUrstrclVsqz5Pd/sXFYyDnPdrLcQODmGOK47IZb4CmibmMn+MYRzFZ5jg33ZL/EJrWcszHmANy3ARBK/IXtciJy8VsitPSdE3uuHxzougojcUdr8/32atnz/ev3f/K5wtpxUTpcaI45zusVDpYtZi+jg0oU9b3x74h7+n9ABvYEZeKaVq0sh0AtLKsFtqNBdeT0MrSzwwlq9+x6xAO4tgOtSzbCjrNQQiNvQUbUEubvzBUeGw26yDCsRHCoLkTHDa7IdOLIThs/gHvChszh2CimE8peRs47cxANI0lYNB5y1DljpOF0IhzBDPOZnDOqYYbeGKECbPzWnXludPphw5c2YBq5zlwXphIbO4VDCZ0gnPfUO1TwZoYwAs2ExPCedAu9DAjfQUjzITQb3jNj0KG2Sgt6BHaQUdYzWz+XmBktOHwanXjaSTcwwziBcuMOtwBmqPrTOxFQR/DRKKPqyur0aiW6cULYsx6tBm0jXpR/AUWR6HRq9WVW6MRhIq5jLyjbaCTDCijyYJNpCajdyobP/eTw0iexBAKkJ3gA5KcQb2zBXsIBckn+xVv8jkZSaEFHE+jFEleAEfayRU0MouNoBmB/L50Ai/HSLIHxcrpCvnhSQAuakKp2C/YbCylJjXRVy/z3+Kv/RrNcCo+WUzlVEhzKffnTQnxeN9fWF88fiNCUdSTsaufaChKWInHeysygfpIqagoakW+vV20J8uyl6TyNKEZWV4oRSPyCkWpgOLSbkCObT8o2r6tlG58HQquf6O0v50tB7JM7F4EORd2dx/K0w/KHsVkLPaoYrwgP/y7krr3SSMA4zj+OBgmjYkxcdIJQyQRKgg2viX9Hddi9UBb29LrKR7CVVEEEXWojUkXNyfTNDE14W9gbHJNuhjDettN3ZvbOvdOqCD3Jp/9l+/wJE+9PkYGjx/fqkys3S2rMozM/o2106rfMUINo6hVqz+eu/hd1c4xTg0TAfy5kV+4UG6+IthHTU9woWmxuKNbTfuCSfovBCxq7EtHqvYL4Sm6F8GVxsSXHMQ07TOi1DKtZxjWaaIyi4CXWjxPccUw8WVbMYY5wxC1mzEyXMJWkllpRloi+Kkoq69sxBTlElF6aAxYUbjXNlhlDZilDnM4U5SlN5biRsRHnbx3mbeWjEh4mEyiuJDl5XcWVmX5GvNkFgLWZM5qwsop4/AWfLhU1cR7k1VVvcYCWRkOI6Xy5gmnphCYIkvzuNYzHzosq2oNk2RtSs8khfUOfHIDgR6ysYBaMpl4uEgk2U/oJTs9AaTSwma7dT69geAE2ZpEjUsn2ieJNHeKfrI3EcAGJ2ZaNgVuC8EBctCLc57P5u5led6IOBkIYkuQMrmmjChs4VkfOerHqSBkPzZlhe06RslZ3zMjk2sscqKwY0RcjKK+LWbzd7KiHhkncs/siFJ+V5eXxD34B8nVuJEpGJNmxN2gH3vSvp7J70tF+D1Ej8qUJD1TkErAND2GZwTFg/LubvmgiBG3SOvdlsqFQrkEzJCL1rstlnVFROixZoDDSuXQFHESwVGlcuQcMb/b42NgjLowh5MTDFE3vNB5qStRIErdCQEh6pLPR92anSUb/wAIhldAaDMpGgAAAABJRU5ErkJggg==' +export const LEAFLET_DEFAULT_SHADOW_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACkAAAApCAQAAAACach9AAACMUlEQVR4Ae3ShY7jQBAE0Aoz/f9/HTMzhg1zrdKUrJbdx+Kd2nD8VNudfsL/Th///dyQN2TH6f3y/BGpC379rV+S+qqetBOxImNQXL8JCAr2V4iMQXHGNJxeCfZXhSRBcQMfvkOWUdtfzlLgAENmZDcmo2TVmt8OSM2eXxBp3DjHSMFutqS7SbmemzBiR+xpKCNUIRkdkkYxhAkyGoBvyQFEJEefwSmmvBfJuJ6aKqKWnAkvGZOaZXTUgFqYULWNSHUckZuR1HIIimUExutRxwzOLROIG4vKmCKQt364mIlhSyzAf1m9lHZHJZrlAOMMztRRiKimp/rpdJDc9Awry5xTZCte7FHtuS8wJgeYGrex28xNTd086Dik7vUMscQOa8y4DoGtCCSkAKlNwpgNtphjrC6MIHUkR6YWxxs6Sc5xqn222mmCRFzIt8lEdKx+ikCtg91qS2WpwVfBelJCiQJwvzixfI9cxZQWgiSJelKnwBElKYtDOb2MFbhmUigbReQBV0Cg4+qMXSxXSyGUn4UbF8l+7qdSGnTC0XLCmahIgUHLhLOhpVCtw4CzYXvLQWQbJNmxoCsOKAxSgBJno75avolkRw8iIAFcsdc02e9iyCd8tHwmeSSoKTowIgvscSGZUOA7PuCN5b2BX9mQM7S0wYhMNU74zgsPBj3HU7wguAfnxxjFQGBE6pwN+GjME9zHY7zGp8wVxMShYX9NXvEWD3HbwJf4giO4CFIQxXScH1/TM+04kkBiAAAAAElFTkSuQmCC' + +export function ensureLeafletStyles(): void { + if (typeof document === 'undefined' || document.getElementById(LEAFLET_STYLE_ID)) + return + + const style = document.createElement('style') + style.id = LEAFLET_STYLE_ID + style.dataset.nuxtScriptsLeaflet = '1' + style.textContent = LEAFLET_CSS + document.head.append(style) +} + +export function configureLeafletDefaultIcons(leaflet: typeof Leaflet): void { + leaflet.Icon.Default.mergeOptions({ + iconUrl: LEAFLET_DEFAULT_ICON_URL, + iconRetinaUrl: LEAFLET_DEFAULT_ICON_RETINA_URL, + shadowUrl: LEAFLET_DEFAULT_SHADOW_URL, + }) +} diff --git a/packages/script/src/runtime/registry/leaflet.ts b/packages/script/src/runtime/registry/leaflet.ts new file mode 100644 index 000000000..62e32f62e --- /dev/null +++ b/packages/script/src/runtime/registry/leaflet.ts @@ -0,0 +1,44 @@ +import type * as Leaflet from 'leaflet' +import type { RegistryScriptInput } from '#nuxt-scripts/types' +import { configureLeafletDefaultIcons, ensureLeafletStyles } from '../leaflet-styles' +import { useRegistryScript } from '../utils' +import { LeafletOptions } from './schemas' + +export { LeafletOptions } + +export type LeafletInput = RegistryScriptInput + +export interface LeafletApi { + L: typeof Leaflet +} + +declare global { + interface Window extends LeafletApi {} +} + +export function useScriptLeaflet(_options?: LeafletInput) { + return useRegistryScript('leaflet', (options, context) => { + const injectStyles = options?.injectStyles !== false + const usesDefaultSource = !context.scriptInput?.src + return { + scriptInput: { + src: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js', + ...(usesDefaultSource + ? { + integrity: 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=', + crossorigin: 'anonymous', + } + : {}), + }, + clientInit: injectStyles ? ensureLeafletStyles : undefined, + schema: import.meta.dev ? LeafletOptions : undefined, + scriptOptions: { + use() { + if (injectStyles) + configureLeafletDefaultIcons(window.L) + return { L: window.L } + }, + }, + } + }, _options) +} diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index d5dd19ff2..f9b9d5dd8 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -19,6 +19,15 @@ export const gcmConsentState = object({ region: optional(array(string())), }) +export const LeafletOptions = object({ + /** + * Inject the Leaflet 1.9.4 styles and default marker images when the script + * begins loading. Disable this when supplying your own Leaflet stylesheet. + * @default true + */ + injectStyles: optional(boolean()), +}) + export const AhrefsAnalyticsOptions = object({ /** * Your Ahrefs Web Analytics project key. Set as the `data-key` attribute diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 63293ed48..9ce3aa69d 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -24,6 +24,7 @@ import type { GravatarInput } from './registry/gravatar' import type { HotjarInput } from './registry/hotjar' import type { InstagramEmbedInput } from './registry/instagram-embed' import type { IntercomInput } from './registry/intercom' +import type { LeafletInput } from './registry/leaflet' import type { LemonSqueezyInput } from './registry/lemon-squeezy' import type { LinkedInInsightInput } from './registry/linkedin-insight' import type { MatomoAnalyticsInput } from './registry/matomo-analytics' @@ -262,6 +263,7 @@ export interface ScriptRegistry { googleAdsense?: GoogleAdsenseInput googleAnalytics?: GoogleAnalyticsInput googleMaps?: GoogleMapsInput + leaflet?: LeafletInput googleRecaptcha?: GoogleRecaptchaInput googleSignIn?: GoogleSignInInput lemonSqueezy?: LemonSqueezyInput @@ -299,7 +301,7 @@ export interface ScriptRegistry { export type BuiltInRegistryScriptKey = | 'ahrefsAnalytics' | 'bingUet' | 'blueskyEmbed' | 'calendly' | 'carbonAds' | 'crisp' | 'clarity' | 'cloudflareWebAnalytics' | 'databuddyAnalytics' | 'metaPixel' | 'fathomAnalytics' | 'instagramEmbed' - | 'plausibleAnalytics' | 'googleAdsense' | 'googleAnalytics' | 'googleMaps' + | 'plausibleAnalytics' | 'googleAdsense' | 'googleAnalytics' | 'googleMaps' | 'leaflet' | 'googleRecaptcha' | 'googleSignIn' | 'lemonSqueezy' | 'googleTagManager' | 'hotjar' | 'intercom' | 'linkedinInsight' | 'paypal' | 'posthog' | 'matomoAnalytics' | 'mixpanelAnalytics' | 'rybbitAnalytics' | 'redditPixel' | 'segment' | 'stripe' | 'tiktokPixel' diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts index 5148ebe13..18eb3420d 100644 --- a/packages/script/src/script-meta.ts +++ b/packages/script/src/script-meta.ts @@ -177,6 +177,10 @@ export const scriptMeta = { urls: [], // Dynamic URL with API key trackedData: [], }, + leaflet: { + urls: ['https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'], + trackedData: [], + }, // CDN npm: { diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index f65edd794..70def1422 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -114,6 +114,7 @@ export default defineNuxtConfig({ // Maps googleMaps: { trigger: 'manual' }, + leaflet: { trigger: false }, // Other gravatar: { trigger: 'manual' }, diff --git a/playground/pages/index.vue b/playground/pages/index.vue index 1cdcd0cd2..254ddbc29 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -45,6 +45,7 @@ function getPlaygroundPath(script: any): string | null { 'vimeo-player': '/third-parties/vimeo/nuxt-scripts', 'youtube-player': '/third-parties/youtube/nuxt-scripts', 'google-maps': '/third-parties/google-maps/nuxt-scripts', + 'leaflet': '/third-parties/leaflet', 'google-recaptcha': '/third-parties/google-recaptcha/nuxt-scripts', 'calendly': '/third-parties/calendly/nuxt-scripts', 'usercentrics': '/third-parties/usercentrics/nuxt-scripts', diff --git a/playground/pages/third-parties/leaflet.vue b/playground/pages/third-parties/leaflet.vue new file mode 100644 index 000000000..326d7dcc8 --- /dev/null +++ b/playground/pages/third-parties/leaflet.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7da4c1f8b..50b3e1c52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,12 +45,18 @@ catalogs: '@speedcurve/lux': specifier: ^4.4.3 version: 4.4.3 + '@types/geojson': + specifier: ^7946.0.16 + version: 7946.0.16 '@types/google.maps': specifier: ^3.65.2 version: 3.65.2 '@types/jest-image-snapshot': specifier: ^6.4.1 version: 6.4.1 + '@types/leaflet': + specifier: ^1.9.21 + version: 1.9.21 '@types/node': specifier: ^26.1.1 version: 26.1.1 @@ -196,12 +202,18 @@ importers: '@paypal/paypal-js': specifier: 'catalog:' version: 10.0.3 + '@types/geojson': + specifier: 'catalog:' + version: 7946.0.16 '@types/google.maps': specifier: 'catalog:' version: 3.65.2 '@types/jest-image-snapshot': specifier: 'catalog:' version: 6.4.1 + '@types/leaflet': + specifier: 'catalog:' + version: 1.9.21 '@types/node': specifier: 'catalog:' version: 26.1.1 @@ -328,9 +340,15 @@ importers: '@stripe/stripe-js': specifier: ^7.0.0 || ^8.0.0 || ^9.0.0 version: 8.11.0 + '@types/geojson': + specifier: ^7946.0.0 + version: 7946.0.16 '@types/google.maps': specifier: ^3.58.1 version: 3.65.2 + '@types/leaflet': + specifier: ^1.9.0 + version: 1.9.21 '@types/vimeo__player': specifier: ^2.18.3 version: 2.18.3 @@ -3343,6 +3361,9 @@ packages: '@types/katex@0.16.8': resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/leaflet@1.9.21': + resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -10950,6 +10971,10 @@ snapshots: '@types/katex@0.16.8': {} + '@types/leaflet@1.9.21': + dependencies: + '@types/geojson': 7946.0.16 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4e87f04f8..704762a67 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -59,8 +59,10 @@ catalog: '@shikijs/langs': ^4.3.1 '@shikijs/themes': ^4.3.1 '@speedcurve/lux': ^4.4.3 + '@types/geojson': ^7946.0.16 '@types/google.maps': ^3.65.2 '@types/jest-image-snapshot': ^6.4.1 + '@types/leaflet': ^1.9.21 '@types/node': ^26.1.1 '@types/youtube': ^0.3.0 '@vue/test-utils': ^2.4.11 diff --git a/scripts/generate-registry-types.ts b/scripts/generate-registry-types.ts index 14ca0bdb3..f1a7f770b 100644 --- a/scripts/generate-registry-types.ts +++ b/scripts/generate-registry-types.ts @@ -639,6 +639,11 @@ const componentToSlug: Record = { ScriptGoogleMapsRectangle: 'google-maps', ScriptGoogleMapsOverlayView: 'google-maps', ScriptGoogleMapsGeoJson: 'google-maps', + ScriptLeafletMap: 'leaflet', + ScriptLeafletTileLayer: 'leaflet', + ScriptLeafletMarker: 'leaflet', + ScriptLeafletPopup: 'leaflet', + ScriptLeafletGeoJson: 'leaflet', ScriptCarbonAds: 'carbon-ads', ScriptCrisp: 'crisp', ScriptIntercom: 'intercom', diff --git a/test/nuxt-runtime/leaflet-components.nuxt.test.ts b/test/nuxt-runtime/leaflet-components.nuxt.test.ts new file mode 100644 index 000000000..c7b05d577 --- /dev/null +++ b/test/nuxt-runtime/leaflet-components.nuxt.test.ts @@ -0,0 +1,131 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { h, nextTick, shallowRef } from 'vue' +import ScriptLeafletGeoJson from '../../packages/script/src/runtime/components/Leaflet/ScriptLeafletGeoJson.vue' +import ScriptLeafletMarker from '../../packages/script/src/runtime/components/Leaflet/ScriptLeafletMarker.vue' +import ScriptLeafletPopup from '../../packages/script/src/runtime/components/Leaflet/ScriptLeafletPopup.vue' +import ScriptLeafletTileLayer from '../../packages/script/src/runtime/components/Leaflet/ScriptLeafletTileLayer.vue' +import { LEAFLET_MAP_INJECTION_KEY } from '../../packages/script/src/runtime/components/Leaflet/useLeafletResource' + +function evented(overrides: Record = {}) { + const target: Record = { + on: vi.fn(() => target), + off: vi.fn(() => target), + remove: vi.fn(() => target), + addTo: vi.fn(() => target), + ...overrides, + } + return target +} + +function createLeafletMock() { + const tileLayer = evented({ setUrl: vi.fn(), setOpacity: vi.fn(), setZIndex: vi.fn(), redraw: vi.fn() }) + const marker = evented({ + bindPopup: vi.fn(), + unbindPopup: vi.fn(), + openPopup: vi.fn(), + closePopup: vi.fn(), + setLatLng: vi.fn(), + setIcon: vi.fn(), + setOpacity: vi.fn(), + setZIndexOffset: vi.fn(), + getElement: vi.fn(), + }) + const popup = evented({ + setContent: vi.fn(() => popup), + setLatLng: vi.fn(() => popup), + openOn: vi.fn(() => popup), + close: vi.fn(() => popup), + update: vi.fn(), + options: {}, + }) + const geoJson = evented({ clearLayers: vi.fn(), addData: vi.fn(), setStyle: vi.fn() }) + const leaflet = { + tileLayer: vi.fn(() => tileLayer), + marker: vi.fn(() => marker), + popup: vi.fn(() => popup), + geoJSON: vi.fn(() => geoJson), + Util: { setOptions: vi.fn() }, + } + return { leaflet, tileLayer, marker, popup, geoJson } +} + +function provideMap(leaflet: any) { + return { + provide: { + [LEAFLET_MAP_INJECTION_KEY as symbol]: { + map: shallowRef({ id: 'map' } as any), + leaflet: shallowRef(leaflet), + }, + }, + } +} + +describe('leaflet components', () => { + beforeEach(() => vi.clearAllMocks()) + + it('creates, updates, and removes a tile layer', async () => { + const mocks = createLeafletMock() + const wrapper = mount(ScriptLeafletTileLayer, { + props: { + url: 'https://tiles.example/{z}/{x}/{y}.png', + options: { attribution: 'Example' }, + }, + global: provideMap(mocks.leaflet), + }) + await nextTick() + + expect(mocks.leaflet.tileLayer).toHaveBeenCalledWith( + 'https://tiles.example/{z}/{x}/{y}.png', + { attribution: 'Example' }, + ) + expect(mocks.tileLayer.addTo).toHaveBeenCalled() + + await wrapper.setProps({ url: 'https://new.example/{z}/{x}/{y}.png' }) + expect(mocks.tileLayer.setUrl).toHaveBeenCalledWith('https://new.example/{z}/{x}/{y}.png') + + wrapper.unmount() + expect(mocks.tileLayer.remove).toHaveBeenCalledOnce() + }) + + it('composes a popup inside an accessible marker', async () => { + const mocks = createLeafletMock() + const wrapper = mount(ScriptLeafletMarker, { + props: { + position: [-37.8136, 144.9631], + alt: 'Melbourne office', + }, + slots: { + default: () => h(ScriptLeafletPopup, { open: true }, () => 'Hello Melbourne'), + }, + global: provideMap(mocks.leaflet), + }) + await nextTick() + await nextTick() + + expect(mocks.leaflet.marker).toHaveBeenCalledWith([-37.8136, 144.9631], expect.objectContaining({ alt: 'Melbourne office' })) + expect(mocks.marker.bindPopup).toHaveBeenCalledWith(mocks.popup) + expect(mocks.marker.openPopup).toHaveBeenCalled() + + wrapper.unmount() + expect(mocks.marker.unbindPopup).toHaveBeenCalled() + expect(mocks.marker.remove).toHaveBeenCalled() + }) + + it('replaces GeoJSON data without recreating the layer', async () => { + const mocks = createLeafletMock() + const initial = { type: 'FeatureCollection', features: [] } as const + const wrapper = mount(ScriptLeafletGeoJson, { + props: { data: initial }, + global: provideMap(mocks.leaflet), + }) + await nextTick() + + const next = { type: 'Point', coordinates: [144.9631, -37.8136] } as const + await wrapper.setProps({ data: next }) + + expect(mocks.geoJson.clearLayers).toHaveBeenCalledOnce() + expect(mocks.geoJson.addData).toHaveBeenCalledWith(next) + expect(mocks.leaflet.geoJSON).toHaveBeenCalledOnce() + }) +}) diff --git a/test/nuxt-runtime/leaflet-map.nuxt.test.ts b/test/nuxt-runtime/leaflet-map.nuxt.test.ts new file mode 100644 index 000000000..e1779f265 --- /dev/null +++ b/test/nuxt-runtime/leaflet-map.nuxt.test.ts @@ -0,0 +1,121 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import ScriptLeafletMap from '../../packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue' + +const scriptState = vi.hoisted(() => ({ + callbacks: [] as Array<(instance: any) => void>, + load: vi.fn(() => Promise.resolve()), + status: undefined as any, +})) + +vi.mock('#nuxt-scripts/composables/useScriptTriggerElement', () => ({ + useScriptTriggerElement: vi.fn(() => 'onNuxtReady'), +})) + +vi.mock('#nuxt-scripts/registry/leaflet', async () => { + const { ref } = await vi.importActual('vue') + scriptState.status = ref('awaitingLoad') + return { + useScriptLeaflet: vi.fn(() => ({ + load: scriptState.load, + status: scriptState.status, + onLoaded: (callback: (instance: any) => void) => scriptState.callbacks.push(callback), + })), + } +}) + +function createLeafletMock() { + const events = new Map void>() + const center = { equals: vi.fn(() => false) } + const map: Record = { + setView: vi.fn(() => map), + on: vi.fn((name: string, callback: (event: any) => void) => { + events.set(name, callback) + return map + }), + getCenter: vi.fn(() => center), + panTo: vi.fn(() => map), + getZoom: vi.fn(() => 13), + setZoom: vi.fn(() => map), + off: vi.fn(() => map), + remove: vi.fn(() => map), + } + const leaflet = { + map: vi.fn(() => map), + latLng: vi.fn(value => value), + } + return { leaflet, map, center, events } +} + +describe('scriptLeafletMap', () => { + beforeEach(() => { + scriptState.callbacks.length = 0 + scriptState.status.value = 'awaitingLoad' + vi.clearAllMocks() + }) + + it('initializes, updates, emits model values, and cleans up the map', async () => { + const mocks = createLeafletMock() + const wrapper = mount(ScriptLeafletMap, { + props: { + center: [-37.8136, 144.9631], + zoom: 13, + width: 800, + height: 500, + }, + }) + await nextTick() + + expect(scriptState.callbacks).toHaveLength(1) + scriptState.callbacks[0]!({ L: mocks.leaflet }) + await nextTick() + + expect(mocks.leaflet.map).toHaveBeenCalledWith(expect.any(HTMLElement), {}) + expect(mocks.map.setView).toHaveBeenCalledWith([-37.8136, 144.9631], 13, { animate: false }) + expect(wrapper.emitted('ready')).toHaveLength(1) + expect(wrapper.attributes('style')).toContain('aspect-ratio: 800 / 500') + + await wrapper.setProps({ center: [-37.8304, 144.9796], zoom: 14 }) + expect(mocks.map.panTo).toHaveBeenCalledWith([-37.8304, 144.9796]) + expect(mocks.map.setZoom).toHaveBeenCalledWith(14) + + mocks.events.get('moveend')?.({ type: 'moveend' }) + mocks.events.get('zoomend')?.({ type: 'zoomend' }) + expect(wrapper.emitted('update:center')?.[0]).toEqual([mocks.center]) + expect(wrapper.emitted('update:zoom')?.[0]).toEqual([13]) + + wrapper.unmount() + expect(mocks.map.off).toHaveBeenCalled() + expect(mocks.map.remove).toHaveBeenCalled() + }) + + it('normalizes pixel string dimensions into a valid aspect ratio', async () => { + const wrapper = mount(ScriptLeafletMap, { + props: { + center: [0, 0], + width: '800px', + height: '500px', + }, + }) + + expect(wrapper.attributes('style')).toContain('aspect-ratio: 800 / 500') + wrapper.unmount() + }) + + it('renders a visible error and makes decorative maps inert', async () => { + const wrapper = mount(ScriptLeafletMap, { + props: { + center: [0, 0], + interactive: false, + }, + }) + + scriptState.status.value = 'error' + await nextTick() + + expect(wrapper.get('[role="alert"]').text()).toBe('The map could not be loaded.') + expect(wrapper.emitted('error')).toHaveLength(1) + expect(wrapper.get('[aria-hidden="true"]').attributes()).toHaveProperty('inert') + }) +}) diff --git a/test/types/types.test-d.ts b/test/types/types.test-d.ts index 4b9c1163a..b0a90b763 100644 --- a/test/types/types.test-d.ts +++ b/test/types/types.test-d.ts @@ -28,6 +28,7 @@ describe('module options registry', () => { expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() @@ -69,6 +70,7 @@ describe('module options registry', () => { // Verify specific input properties survive (not collapsed to unknown) type ObjectForm = Exclude expectTypeOf['apiKey']>().not.toBeNever() + expectTypeOf['injectStyles']>().not.toBeNever() expectTypeOf['id']>().not.toBeNever() expectTypeOf['id']>().not.toBeNever() }) diff --git a/test/unit/leaflet-lifecycle.test.ts b/test/unit/leaflet-lifecycle.test.ts new file mode 100644 index 000000000..502e263e9 --- /dev/null +++ b/test/unit/leaflet-lifecycle.test.ts @@ -0,0 +1,96 @@ +/** + * @vitest-environment happy-dom + */ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import { defineComponent, h, nextTick, onBeforeUnmount, provide, shallowRef } from 'vue' +import { LEAFLET_MAP_INJECTION_KEY, useLeafletResource } from '../../packages/script/src/runtime/components/Leaflet/useLeafletResource' + +function createProvider(immediate = true, clearBeforeChildren = false) { + const map = shallowRef(immediate ? { id: 'map' } : undefined) + const leaflet = shallowRef(immediate ? { version: '1.9.4' } : undefined) + + const Provider = defineComponent({ + setup(_, { slots }) { + provide(LEAFLET_MAP_INJECTION_KEY, { map, leaflet }) + if (clearBeforeChildren) { + onBeforeUnmount(() => { + map.value = undefined + leaflet.value = undefined + }) + } + return () => h('div', slots.default?.()) + }, + }) + + return { Provider, map, leaflet } +} + +describe('useLeafletResource', () => { + it('creates a resource after the map becomes ready', async () => { + const { Provider, map, leaflet } = createProvider(false) + const create = vi.fn(() => ({ id: 'marker' })) + + const Child = defineComponent({ + setup() { + return { resource: useLeafletResource({ create, cleanup: vi.fn() }) } + }, + render: () => h('span'), + }) + + const wrapper = mount(Provider, { slots: { default: () => h(Child) } }) + await nextTick() + expect(create).not.toHaveBeenCalled() + + map.value = { id: 'map' } + leaflet.value = { version: '1.9.4' } + await nextTick() + + expect(create).toHaveBeenCalledOnce() + wrapper.unmount() + }) + + it('cleans up from its captured context even when the parent clears refs first', async () => { + const { Provider } = createProvider(true, true) + const resource = { id: 'tile-layer' } + const cleanup = vi.fn() + + const Child = defineComponent({ + setup() { + useLeafletResource({ create: () => resource, cleanup }) + return () => h('span') + }, + }) + + const wrapper = mount(Provider, { slots: { default: () => h(Child) } }) + await nextTick() + wrapper.unmount() + + expect(cleanup).toHaveBeenCalledWith(resource, { + map: { id: 'map' }, + leaflet: { version: '1.9.4' }, + }) + }) + + it('waits for an additional DOM readiness condition', async () => { + const { Provider } = createProvider() + const ready = shallowRef(false) + const create = vi.fn(() => ({ id: 'popup' })) + + const Child = defineComponent({ + setup() { + useLeafletResource({ ready: () => ready.value, create, cleanup: vi.fn() }) + return () => h('span') + }, + }) + + const wrapper = mount(Provider, { slots: { default: () => h(Child) } }) + await nextTick() + expect(create).not.toHaveBeenCalled() + + ready.value = true + await nextTick() + expect(create).toHaveBeenCalledOnce() + wrapper.unmount() + }) +}) diff --git a/test/unit/leaflet-registry.test.ts b/test/unit/leaflet-registry.test.ts new file mode 100644 index 000000000..d319a6603 --- /dev/null +++ b/test/unit/leaflet-registry.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('nuxt/app', () => ({ + createError: (input: { message: string }) => new Error(input.message), + useRuntimeConfig: () => ({ public: { scripts: {} } }), +})) + +vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({ + useScript: vi.fn((input, options) => ({ input, options })), +})) + +describe('useScriptLeaflet', () => { + beforeEach(() => vi.clearAllMocks()) + + it('uses the pinned stable build with official integrity metadata', async () => { + const { useScriptLeaflet } = await import('../../packages/script/src/runtime/registry/leaflet') + const { useScript } = await import('../../packages/script/src/runtime/composables/useScript') + + useScriptLeaflet() + + expect(vi.mocked(useScript).mock.calls.at(-1)?.[0]).toMatchObject({ + src: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js', + integrity: 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=', + crossorigin: 'anonymous', + }) + }) + + it('does not apply the pinned integrity hash to a custom source', async () => { + const { useScriptLeaflet } = await import('../../packages/script/src/runtime/registry/leaflet') + const { useScript } = await import('../../packages/script/src/runtime/composables/useScript') + + useScriptLeaflet({ + scriptInput: { src: 'https://cdn.example.com/leaflet.js' }, + }) + + const input = vi.mocked(useScript).mock.calls.at(-1)?.[0] as Record + expect(input.src).toBe('https://cdn.example.com/leaflet.js') + expect(input).not.toHaveProperty('integrity') + }) +}) diff --git a/test/unit/leaflet-styles.test.ts b/test/unit/leaflet-styles.test.ts new file mode 100644 index 000000000..afd95c278 --- /dev/null +++ b/test/unit/leaflet-styles.test.ts @@ -0,0 +1,43 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + configureLeafletDefaultIcons, + ensureLeafletStyles, + LEAFLET_CSS, + LEAFLET_DEFAULT_ICON_RETINA_URL, + LEAFLET_DEFAULT_ICON_URL, + LEAFLET_DEFAULT_SHADOW_URL, +} from '../../packages/script/src/runtime/leaflet-styles' + +describe('leaflet styles', () => { + afterEach(() => { + document.querySelector('[data-nuxt-scripts-leaflet]')?.remove() + }) + + it('injects styles once and embeds all image assets', () => { + ensureLeafletStyles() + ensureLeafletStyles() + + const styles = document.querySelectorAll('[data-nuxt-scripts-leaflet]') + expect(styles).toHaveLength(1) + expect(styles[0]?.textContent).toBe(LEAFLET_CSS) + expect(LEAFLET_CSS).toContain('data:image/png;base64,') + expect(LEAFLET_CSS).not.toContain('url(images/') + }) + + it('configures Leaflet default icons without asset requests', () => { + const mergeOptions = vi.fn() + + configureLeafletDefaultIcons({ + Icon: { Default: { mergeOptions } }, + } as any) + + expect(mergeOptions).toHaveBeenCalledWith({ + iconUrl: LEAFLET_DEFAULT_ICON_URL, + iconRetinaUrl: LEAFLET_DEFAULT_ICON_RETINA_URL, + shadowUrl: LEAFLET_DEFAULT_SHADOW_URL, + }) + }) +}) From e3ca96c7d2dd32b322f8a236311580a66a7e08d6 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 20 Jul 2026 16:29:03 +1000 Subject: [PATCH 02/10] fix(leaflet): type the loaded API callback --- .../script/src/runtime/components/Leaflet/ScriptLeafletMap.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue b/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue index 19977e4c8..3b159b13f 100644 --- a/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue +++ b/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue @@ -126,7 +126,7 @@ function mapOptions(): Leaflet.MapOptions { } onMounted(() => { - onLoaded((instance) => { + onLoaded((instance: { L: typeof Leaflet }) => { if (isUnmounted || !mapEl.value) return From 3d9daa2ffde6c54ed194f469d55f06e35b05dc6b Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 20 Jul 2026 16:42:07 +1000 Subject: [PATCH 03/10] fix(leaflet): preserve loader errors and event types --- packages/script/src/registry-types.json | 4 ++-- .../components/Leaflet/ScriptLeafletMap.vue | 14 ++++++-------- .../components/Leaflet/ScriptLeafletMarker.vue | 2 +- test/nuxt-runtime/leaflet-map.nuxt.test.ts | 7 ++++++- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index efa04596f..70e2e2c9e 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -684,7 +684,7 @@ { "name": "ScriptLeafletMarkerEvents", "kind": "interface", - "code": "interface ScriptLeafletMarkerEvents {\n click: Leaflet.LeafletMouseEvent\n dblclick: Leaflet.LeafletMouseEvent\n mousedown: Leaflet.LeafletMouseEvent\n mouseover: Leaflet.LeafletMouseEvent\n mouseout: Leaflet.LeafletMouseEvent\n contextmenu: Leaflet.LeafletMouseEvent\n dragstart: Leaflet.DragEndEvent\n drag: Leaflet.LeafletEvent\n dragend: Leaflet.DragEndEvent\n move: Leaflet.LeafletEvent\n}" + "code": "interface ScriptLeafletMarkerEvents {\n click: Leaflet.LeafletMouseEvent\n dblclick: Leaflet.LeafletMouseEvent\n mousedown: Leaflet.LeafletMouseEvent\n mouseover: Leaflet.LeafletMouseEvent\n mouseout: Leaflet.LeafletMouseEvent\n contextmenu: Leaflet.LeafletMouseEvent\n dragstart: Leaflet.LeafletEvent\n drag: Leaflet.LeafletEvent\n dragend: Leaflet.DragEndEvent\n move: Leaflet.LeafletEvent\n}" }, { "name": "ScriptLeafletMarkerSlots", @@ -3448,7 +3448,7 @@ }, { "name": "dragstart", - "type": "Leaflet.DragEndEvent", + "type": "Leaflet.LeafletEvent", "required": false }, { diff --git a/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue b/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue index 3b159b13f..b32a7b8c2 100644 --- a/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue +++ b/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue @@ -80,7 +80,7 @@ defineSlots() const rootEl = useTemplateRef('rootEl') const mapEl = useTemplateRef('mapEl') const trigger = useScriptTriggerElement({ trigger: props.trigger, el: rootEl }) -const { load, status, onLoaded } = useScriptLeaflet({ +const { load, status, onLoaded, onError } = useScriptLeaflet({ injectStyles: props.injectStyles, scriptOptions: { trigger }, }) @@ -91,6 +91,11 @@ const isMapReady = shallowRef(false) const loadError = shallowRef(new Error('Leaflet failed to load')) let isUnmounted = false +onError((error?: Error) => { + loadError.value = error ?? new Error('Leaflet failed to load') + emit('error', loadError.value) +}) + const exposed: ScriptLeafletMapExpose = { leaflet, map, load } defineExpose(exposed) provide(LEAFLET_MAP_INJECTION_KEY, { leaflet, map }) @@ -140,13 +145,6 @@ onMounted(() => { }) }) -watch(status, (value) => { - if (value !== 'error') - return - loadError.value = new Error('Leaflet failed to load') - emit('error', loadError.value) -}) - watch(() => props.center, (center) => { if (!map.value || !leaflet.value) return diff --git a/packages/script/src/runtime/components/Leaflet/ScriptLeafletMarker.vue b/packages/script/src/runtime/components/Leaflet/ScriptLeafletMarker.vue index 3c4fb3e95..fcd8f6b12 100644 --- a/packages/script/src/runtime/components/Leaflet/ScriptLeafletMarker.vue +++ b/packages/script/src/runtime/components/Leaflet/ScriptLeafletMarker.vue @@ -21,7 +21,7 @@ const emit = defineEmits<{ mouseover: [event: Leaflet.LeafletMouseEvent] mouseout: [event: Leaflet.LeafletMouseEvent] contextmenu: [event: Leaflet.LeafletMouseEvent] - dragstart: [event: Leaflet.DragEndEvent] + dragstart: [event: Leaflet.LeafletEvent] drag: [event: Leaflet.LeafletEvent] dragend: [event: Leaflet.DragEndEvent] move: [event: Leaflet.LeafletEvent] diff --git a/test/nuxt-runtime/leaflet-map.nuxt.test.ts b/test/nuxt-runtime/leaflet-map.nuxt.test.ts index e1779f265..58504fd46 100644 --- a/test/nuxt-runtime/leaflet-map.nuxt.test.ts +++ b/test/nuxt-runtime/leaflet-map.nuxt.test.ts @@ -5,6 +5,7 @@ import ScriptLeafletMap from '../../packages/script/src/runtime/components/Leafl const scriptState = vi.hoisted(() => ({ callbacks: [] as Array<(instance: any) => void>, + errorCallbacks: [] as Array<(error?: Error) => void>, load: vi.fn(() => Promise.resolve()), status: undefined as any, })) @@ -21,6 +22,7 @@ vi.mock('#nuxt-scripts/registry/leaflet', async () => { load: scriptState.load, status: scriptState.status, onLoaded: (callback: (instance: any) => void) => scriptState.callbacks.push(callback), + onError: (callback: (error?: Error) => void) => scriptState.errorCallbacks.push(callback), })), } }) @@ -51,6 +53,7 @@ function createLeafletMock() { describe('scriptLeafletMap', () => { beforeEach(() => { scriptState.callbacks.length = 0 + scriptState.errorCallbacks.length = 0 scriptState.status.value = 'awaitingLoad' vi.clearAllMocks() }) @@ -111,11 +114,13 @@ describe('scriptLeafletMap', () => { }, }) + const loadFailure = new Error('Leaflet request failed') + scriptState.errorCallbacks[0]!(loadFailure) scriptState.status.value = 'error' await nextTick() expect(wrapper.get('[role="alert"]').text()).toBe('The map could not be loaded.') - expect(wrapper.emitted('error')).toHaveLength(1) + expect(wrapper.emitted('error')?.[0]).toEqual([loadFailure]) expect(wrapper.get('[aria-hidden="true"]').attributes()).toHaveProperty('inert') }) }) From 88363b9656fdf085c916d8df4dc75672f713af79 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 20 Jul 2026 19:18:31 +1000 Subject: [PATCH 04/10] feat(maplibre): add vector map integration --- .../maplibre/2.api/1.script-maplibre-map.md | 28 + .../scripts/maplibre/2.api/2.marker.md | 10 + .../content/scripts/maplibre/2.api/3.popup.md | 8 + .../maplibre/2.api/4.navigation-control.md | 8 + .../scripts/maplibre/2.api/5.geojson.md | 12 + .../maplibre/2.api/6.use-script-maplibre.md | 17 + docs/content/scripts/maplibre/index.md | 148 ++++ package.json | 1 + packages/script/THIRD_PARTY_LICENSES.md | 111 +++ packages/script/package.json | 4 + packages/script/src/registry-logos.ts | 1 + packages/script/src/registry-types.json | 808 +++++++++++++++++- packages/script/src/registry.ts | 10 + .../MapLibre/ScriptMapLibreGeoJson.vue | 95 ++ .../components/MapLibre/ScriptMapLibreMap.vue | 304 +++++++ .../MapLibre/ScriptMapLibreMarker.vue | 98 +++ .../ScriptMapLibreNavigationControl.vue | 28 + .../MapLibre/ScriptMapLibrePopup.vue | 108 +++ .../MapLibre/useMapLibreResource.ts | 73 ++ .../script/src/runtime/maplibre-styles.ts | 23 + .../script/src/runtime/registry/maplibre.ts | 46 + .../script/src/runtime/registry/schemas.ts | 19 + packages/script/src/runtime/types.ts | 4 +- packages/script/src/script-meta.ts | 4 + playground/nuxt.config.ts | 1 + playground/pages/index.vue | 1 + playground/pages/third-parties/maplibre.vue | 199 +++++ pnpm-lock.yaml | 545 +++++------- pnpm-workspace.yaml | 1 + scripts/generate-registry-types.ts | 116 ++- .../maplibre-components.nuxt.test.ts | 193 +++++ test/nuxt-runtime/maplibre-map.nuxt.test.ts | 144 ++++ test/types/types.test-d.ts | 2 + test/unit/maplibre-lifecycle.test.ts | 74 ++ test/unit/maplibre-registry.test.ts | 40 + test/unit/maplibre-styles.test.ts | 43 + 36 files changed, 2921 insertions(+), 406 deletions(-) create mode 100644 docs/content/scripts/maplibre/2.api/1.script-maplibre-map.md create mode 100644 docs/content/scripts/maplibre/2.api/2.marker.md create mode 100644 docs/content/scripts/maplibre/2.api/3.popup.md create mode 100644 docs/content/scripts/maplibre/2.api/4.navigation-control.md create mode 100644 docs/content/scripts/maplibre/2.api/5.geojson.md create mode 100644 docs/content/scripts/maplibre/2.api/6.use-script-maplibre.md create mode 100644 docs/content/scripts/maplibre/index.md create mode 100644 packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeoJson.vue create mode 100644 packages/script/src/runtime/components/MapLibre/ScriptMapLibreMap.vue create mode 100644 packages/script/src/runtime/components/MapLibre/ScriptMapLibreMarker.vue create mode 100644 packages/script/src/runtime/components/MapLibre/ScriptMapLibreNavigationControl.vue create mode 100644 packages/script/src/runtime/components/MapLibre/ScriptMapLibrePopup.vue create mode 100644 packages/script/src/runtime/components/MapLibre/useMapLibreResource.ts create mode 100644 packages/script/src/runtime/maplibre-styles.ts create mode 100644 packages/script/src/runtime/registry/maplibre.ts create mode 100644 playground/pages/third-parties/maplibre.vue create mode 100644 test/nuxt-runtime/maplibre-components.nuxt.test.ts create mode 100644 test/nuxt-runtime/maplibre-map.nuxt.test.ts create mode 100644 test/unit/maplibre-lifecycle.test.ts create mode 100644 test/unit/maplibre-registry.test.ts create mode 100644 test/unit/maplibre-styles.test.ts diff --git a/docs/content/scripts/maplibre/2.api/1.script-maplibre-map.md b/docs/content/scripts/maplibre/2.api/1.script-maplibre-map.md new file mode 100644 index 000000000..c2ddb383f --- /dev/null +++ b/docs/content/scripts/maplibre/2.api/1.script-maplibre-map.md @@ -0,0 +1,28 @@ +--- +title: +--- + +The map facade reserves layout space during SSR, loads MapLibre when triggered, creates the `Map`, and provides it to child components. + +::script-types{script-key="maplibre" filter="ScriptMapLibreMap"} +:: + +`mapStyle`, `center`, `zoom`, `bearing`, and `pitch` are reactive. Use their `v-model` bindings when you also want to receive camera changes made by the user. + +```vue + + + +``` + +The `placeholder`, `awaitingLoad`, `loading`, and `error` slots customize each loading state. The default error state is visible and announced with `role="alert"`. diff --git a/docs/content/scripts/maplibre/2.api/2.marker.md b/docs/content/scripts/maplibre/2.api/2.marker.md new file mode 100644 index 000000000..532c72aeb --- /dev/null +++ b/docs/content/scripts/maplibre/2.api/2.marker.md @@ -0,0 +1,10 @@ +--- +title: +--- + +Adds a reactive MapLibre `Marker`. Supply a unique `aria-label` so its generated DOM element has a useful accessible name. + +::script-types{script-key="maplibre" filter="ScriptMapLibreMarker"} +:: + +Nest a [``{lang="html"}](/scripts/maplibre/api/popup) in the default slot to bind it to the marker. Use `options.draggable` and the drag events for an editable marker. diff --git a/docs/content/scripts/maplibre/2.api/3.popup.md b/docs/content/scripts/maplibre/2.api/3.popup.md new file mode 100644 index 000000000..a83c74fec --- /dev/null +++ b/docs/content/scripts/maplibre/2.api/3.popup.md @@ -0,0 +1,8 @@ +--- +title: +--- + +Renders slotted HTML in a MapLibre `Popup`. Nest it inside a marker, or pass `position` for a standalone popup. Use the reactive `open` prop to control visibility. + +::script-types{script-key="maplibre" filter="ScriptMapLibrePopup"} +:: diff --git a/docs/content/scripts/maplibre/2.api/4.navigation-control.md b/docs/content/scripts/maplibre/2.api/4.navigation-control.md new file mode 100644 index 000000000..89f80e740 --- /dev/null +++ b/docs/content/scripts/maplibre/2.api/4.navigation-control.md @@ -0,0 +1,8 @@ +--- +title: +--- + +Adds MapLibre's navigation control to the nearest parent map. Use `options` to choose whether zoom, compass, and pitch visualization controls are shown. + +::script-types{script-key="maplibre" filter="ScriptMapLibreNavigationControl"} +:: diff --git a/docs/content/scripts/maplibre/2.api/5.geojson.md b/docs/content/scripts/maplibre/2.api/5.geojson.md new file mode 100644 index 000000000..9196ebb6a --- /dev/null +++ b/docs/content/scripts/maplibre/2.api/5.geojson.md @@ -0,0 +1,12 @@ +--- +title: +--- + +Adds a GeoJSON source and one or more MapLibre style layers. Replacing `data` updates the existing source; changing layer or source options rebuilds resources in a controlled order. + +::script-types{script-key="maplibre" filter="ScriptMapLibreGeoJson"} +:: + +Each layer gets the component's `source-id` unless it explicitly supplies another source. The component also restores its source and layers after the map style changes. + +Remote fetching is intentionally outside the component. Fetch and validate data with `useFetch`, then pass the resulting object to `data` so loading and failure states remain explicit. diff --git a/docs/content/scripts/maplibre/2.api/6.use-script-maplibre.md b/docs/content/scripts/maplibre/2.api/6.use-script-maplibre.md new file mode 100644 index 000000000..b91878280 --- /dev/null +++ b/docs/content/scripts/maplibre/2.api/6.use-script-maplibre.md @@ -0,0 +1,17 @@ +--- +title: useScriptMapLibre +--- + +Use `useScriptMapLibre()`{lang="ts"} when you need direct access to the MapLibre namespace or are building your own map component. + +::script-types{script-key="maplibre" filter="useScriptMapLibre"} +:: + +```ts +const { onLoaded } = useScriptMapLibre() + +onLoaded(({ maplibregl }) => { + const bounds = new maplibregl.LngLatBounds(points) + // Use the MapLibre API directly. +}) +``` diff --git a/docs/content/scripts/maplibre/index.md b/docs/content/scripts/maplibre/index.md new file mode 100644 index 000000000..ae52ede8e --- /dev/null +++ b/docs/content/scripts/maplibre/index.md @@ -0,0 +1,148 @@ +--- +title: MapLibre GL JS +description: Add lazy-loaded, provider-agnostic vector maps to Nuxt with MapLibre GL JS. +links: + - label: useScriptMapLibre + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/maplibre.ts + size: xs + - label: "" + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/components/MapLibre/ScriptMapLibreMap.vue + size: xs +--- + +[MapLibre GL JS](https://maplibre.org/maplibre-gl-js/docs/) is an open-source WebGL renderer for interactive vector maps. It renders the map and handles interaction; you choose the style, tile source, attribution, geocoding, and routing services separately. + +Nuxt Scripts supports MapLibre GL JS 5.24 through the [`useScriptMapLibre()`{lang="ts"}](/scripts/maplibre/api/use-script-maplibre) composable and declarative components for common map resources. First-party mode can bundle the JavaScript SDK. Nuxt Scripts loads the required MapLibre stylesheet separately by default. + +::script-types{exclude-components} +:: + +## Setup + +Install MapLibre for its TypeScript definitions, then enable the registry entry: + +```bash +pnpm add -D maplibre-gl +``` + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + registry: { + maplibre: { trigger: false }, + }, + }, +}) +``` + +## Quick start + +Pass a MapLibre style URL or an inline style specification. This example uses MapLibre's public demonstration style. + +```vue + + + +``` + +MapLibre coordinates use `[longitude, latitude]` order. This is the reverse of the `[latitude, longitude]` convention used by Leaflet. + +The default `visible` trigger defers the SDK, style, and tile requests until the map approaches the viewport. The component reserves its dimensions during SSR to avoid layout shift. + +::callout{color="amber"} +The MapLibre demo tiles are intended for examples. Choose a hosted or self-hosted tile service for production, follow its terms, and retain the attribution required by your data and style providers. +:: + +## MapLibre, OpenMapTiles, and OpenStreetMap + +These projects solve different parts of the mapping stack: + +- **MapLibre GL JS** is the browser renderer supported by this integration. +- **OpenMapTiles** is an open vector-tile schema, toolchain, and collection of styles. Point `map-style` at an OpenMapTiles-compatible style URL to use it with MapLibre. +- **OpenStreetMap** supplies open geographic data. Its public standard tile service is not a general-purpose production CDN. + +You can use MapLibre with OpenMapTiles from a hosted provider, serve an OpenMapTiles style and tiles yourself, or supply any compatible MapLibre style. The renderer does not lock you to a particular provider. + +## Components + +- [``{lang="html"}](/scripts/maplibre/api/script-maplibre-map) creates the map and controls lazy loading. +- [``{lang="html"}](/scripts/maplibre/api/marker) adds an accessible, reactive marker. +- [``{lang="html"}](/scripts/maplibre/api/popup) binds slotted HTML to a marker or coordinate. +- [``{lang="html"}](/scripts/maplibre/api/navigation-control) adds zoom, compass, and pitch controls. +- [``{lang="html"}](/scripts/maplibre/api/geojson) manages a GeoJSON source and its style layers. + +## Accessibility + +Give each interactive map a useful `aria-label`. Because the rendered geography is a WebGL canvas, use the `description` slot for essential locations, routes, or links that must be available to assistive technology. Give each marker a unique `aria-label`. + +For a purely decorative map, set `:interactive="false"`. The component disables input, removes the map from the accessibility tree, and makes its descendants inert. + +## Styles and Content Security Policy + +By default, Nuxt Scripts injects MapLibre's version-pinned stylesheet from UNPKG when the SDK begins loading. To serve it through your own build instead: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + css: ['maplibre-gl/dist/maplibre-gl.css'], +}) +``` + +```vue + +``` + +The standard MapLibre build creates a Blob worker and needs matching `worker-src` and `child-src` CSP directives. For a stricter policy, self-host MapLibre's CSP build and worker: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + registry: { + maplibre: { + scriptInput: { src: '/maplibre-gl-csp.js' }, + }, + }, + }, +}) +``` + +```vue + +``` + +See MapLibre's [CSP directives](https://maplibre.org/maplibre-gl-js/docs/#csp-directives) for the complete policy requirements. diff --git a/package.json b/package.json index 759fda1e0..9e32021b9 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "h3": "catalog:", "happy-dom": "catalog:", "jest-image-snapshot": "catalog:", + "maplibre-gl": "catalog:", "nuxt": "catalog:", "ofetch": "catalog:", "ohash": "catalog:", diff --git a/packages/script/THIRD_PARTY_LICENSES.md b/packages/script/THIRD_PARTY_LICENSES.md index 077506d70..078d0ad66 100644 --- a/packages/script/THIRD_PARTY_LICENSES.md +++ b/packages/script/THIRD_PARTY_LICENSES.md @@ -11,3 +11,114 @@ Redistribution and use in source and binary forms, with or without modification, 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +## MapLibre GL JS 5.24.0 + +Copyright (c) 2023, MapLibre contributors + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +- Neither the name of MapLibre GL JS nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +### Contains code from mapbox-gl-js v1.13 and earlier + +Version v1.13 of mapbox-gl-js and earlier are licensed under a BSD-3-Clause license. + +Copyright (c) 2020, Mapbox + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +- Neither the name of Mapbox GL JS nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +### Contains code from glfx.js + +Copyright (C) 2011 by Evan Wallace + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +### Contains a portion of d3-color + +Copyright 2010-2016 Mike Bostock + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +- Neither the name of the author nor the names of contributors may be used to + endorse or promote products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/script/package.json b/packages/script/package.json index e995d91a5..f986c9767 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -84,6 +84,7 @@ "@types/vimeo__player": "^2.18.3", "@types/youtube": "^0.1.0", "@unhead/vue": "^2.0.3 || ^3.0.0", + "maplibre-gl": "^5.24.0", "posthog-js": "^1.0.0" }, "peerDependenciesMeta": { @@ -117,6 +118,9 @@ "@unhead/vue": { "optional": true }, + "maplibre-gl": { + "optional": true + }, "posthog-js": { "optional": true } diff --git a/packages/script/src/registry-logos.ts b/packages/script/src/registry-logos.ts index 3a0495529..af67135e6 100644 --- a/packages/script/src/registry-logos.ts +++ b/packages/script/src/registry-logos.ts @@ -44,6 +44,7 @@ export const LOGOS = { youtubePlayer: ``, googleMaps: ``, leaflet: ``, + maplibre: ``, blueskyEmbed: ``, instagramEmbed: ``, xEmbed: { diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 70e2e2c9e..5f1cb1663 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -358,7 +358,17 @@ { "name": "ScriptGoogleMapsProps", "kind": "interface", - "code": "interface ScriptGoogleMapsProps ScriptGoogleMapsProps" + "code": "interface ScriptGoogleMapsProps {\n /**\n * Defines the trigger event to load the script.\n * @default ['mouseenter', 'mouseover', 'mousedown']\n */\n trigger?: ElementScriptTrigger\n /**\n * Defines the Google Maps API key. Must have access to the Static Maps API as well.\n */\n apiKey?: string\n /**\n * A latitude / longitude of where to focus the map.\n *\n * @deprecated Pass `center` via `mapOptions` instead. The top-level `center`\n * prop will be removed in a future major version. When both are set,\n * `mapOptions.center` wins.\n * @see https://scripts.nuxt.com/docs/migration-guide/v0-to-v1\n */\n center?: google.maps.LatLng | google.maps.LatLngLiteral | `${string},${string}`\n /**\n * Zoom level for the map (0-21). Reactive: changing this will update the map.\n *\n * @deprecated Pass `zoom` via `mapOptions` instead. The top-level `zoom`\n * prop will be removed in a future major version. When both are set,\n * `mapOptions.zoom` wins.\n * @see https://scripts.nuxt.com/docs/migration-guide/v0-to-v1\n */\n zoom?: number\n /**\n * Options for the map.\n */\n mapOptions?: google.maps.MapOptions\n /**\n * Defines the region of the map.\n */\n region?: string\n /**\n * Defines the language of the map.\n */\n language?: string\n /**\n * Defines the version of google maps js API.\n */\n version?: string\n /**\n * Defines the width of the map.\n * @default 640\n */\n width?: number | string\n /**\n * Defines the height of the map.\n * @default 400\n */\n height?: number | string\n /**\n * Customize the root element attributes.\n */\n rootAttrs?: HTMLAttributes & ReservedProps & Record\n /**\n * Map IDs for light and dark color modes.\n * When provided, the map will automatically switch styles based on color mode.\n * Requires @nuxtjs/color-mode or manual colorMode prop.\n */\n mapIds?: { light?: string, dark?: string }\n /**\n * Manual color mode control. When provided, overrides auto-detection from @nuxtjs/color-mode.\n * Accepts 'light' or 'dark'.\n */\n colorMode?: 'light' | 'dark'\n}" + }, + { + "name": "ScriptGoogleMapsEvents", + "kind": "interface", + "code": "interface ScriptGoogleMapsEvents {\n ready: ScriptGoogleMapsExpose\n error: -\n}" + }, + { + "name": "ScriptGoogleMapsSlots", + "kind": "interface", + "code": "interface ScriptGoogleMapsSlots {\n default?: () => any\n loading?: () => any\n awaitingLoad?: () => any\n error?: () => any\n placeholder?: () => any\n}" }, { "name": "ScriptGoogleMapsCircleProps", @@ -433,7 +443,12 @@ { "name": "ScriptGoogleMapsOverlayViewProps", "kind": "interface", - "code": "interface ScriptGoogleMapsOverlayViewProps ScriptGoogleMapsOverlayViewProps" + "code": "interface ScriptGoogleMapsOverlayViewProps {\n /**\n * Geographic position for the overlay. Falls back to parent marker position if omitted.\n *\n * Accepts either a plain `LatLngLiteral` (`{ lat, lng }`) or a\n * `google.maps.LatLng` instance.\n * @see https://developers.google.com/maps/documentation/javascript/reference/overlay-view#OverlayView\n */\n position?: google.maps.LatLng | google.maps.LatLngLiteral\n /**\n * Initial open state for the uncontrolled mode (when `v-model:open` is not\n * bound). When omitted, the overlay opens on mount, matching v0 behaviour.\n *\n * Has no effect when `v-model:open` is used; pass an initial value to the\n * bound ref instead.\n * @default true\n */\n defaultOpen?: boolean\n /**\n * Anchor point of the overlay relative to its position.\n * @default 'bottom-center'\n */\n anchor?: ScriptGoogleMapsOverlayAnchor\n /**\n * Pixel offset from the anchor position.\n */\n offset?: { x: number, y: number }\n /**\n * The map pane on which to render the overlay.\n * @default 'floatPane'\n * @see https://developers.google.com/maps/documentation/javascript/reference/overlay-view#MapPanes\n */\n pane?: ScriptGoogleMapsOverlayPane\n /**\n * CSS z-index for the overlay element.\n */\n zIndex?: number\n /**\n * Whether to block map click and gesture events from passing through the overlay.\n * @default true\n */\n blockMapInteraction?: boolean\n /**\n * Pan the map so the overlay is fully visible when opened, similar to InfoWindow behavior.\n * Set to `true` for default 40px padding, or a number for custom padding.\n * @default true\n */\n panOnOpen?: boolean | number\n /**\n * Automatically hide the overlay when its parent marker joins a cluster (on zoom out).\n * Only applies when nested inside a ScriptGoogleMapsMarkerClusterer.\n * @default true\n */\n hideWhenClustered?: boolean\n}" + }, + { + "name": "ScriptGoogleMapsOverlayViewSlots", + "kind": "interface", + "code": "interface ScriptGoogleMapsOverlayViewSlots {\n default?: () => any\n}" }, { "name": "ScriptGoogleMapsPolygonProps", @@ -674,7 +689,17 @@ { "name": "ScriptLeafletMapProps", "kind": "interface", - "code": "interface ScriptLeafletMapProps ScriptLeafletMapProps" + "code": "interface ScriptLeafletMapProps {\n /**\n * Defines when the Leaflet script loads.\n * @default 'visible'\n */\n trigger?: ElementScriptTrigger\n /** Initial and reactively controlled map center. */\n center: Leaflet.LatLngExpression\n /** Initial and reactively controlled zoom level. @default 13 */\n zoom?: number\n /** Options passed to `L.map`. `center` and `zoom` use their dedicated props. */\n options?: Leaflet.MapOptions\n /** Inject Leaflet's styles and embedded default marker images. @default true */\n injectStyles?: boolean\n /** Width reserved before the map loads. @default 640 */\n width?: number | string\n /** Height reserved before the map loads. @default 400 */\n height?: number | string\n /** Accessible name for an interactive map. @default 'Interactive map' */\n ariaLabel?: string\n /** Disable map input and remove it from the accessibility tree when decorative. @default true */\n interactive?: boolean\n /** Attributes applied to the outer layout container. */\n rootAttrs?: HTMLAttributes & ReservedProps & Record\n}" + }, + { + "name": "ScriptLeafletMapEvents", + "kind": "interface", + "code": "interface ScriptLeafletMapEvents {\n ready: ScriptLeafletMapExpose\n error: Error\n click: Leaflet.LeafletMouseEvent\n move: Leaflet.LeafletEvent\n moveend: Leaflet.LeafletEvent\n zoom: Leaflet.LeafletEvent\n zoomend: Leaflet.LeafletEvent\n update:center: Leaflet.LatLng\n update:zoom: number\n}" + }, + { + "name": "ScriptLeafletMapSlots", + "kind": "interface", + "code": "interface ScriptLeafletMapSlots {\n default?: () => any\n loading?: () => any\n awaitingLoad?: () => any\n error?: (props: { error: Error }) => any\n placeholder?: () => any\n}" }, { "name": "ScriptLeafletMarkerProps", @@ -771,6 +796,73 @@ "code": "export interface LinkedInInsightApi {\n lintrk: LintrkFns & { q?: unknown[] }\n}" } ], + "maplibre": [ + { + "name": "MapLibreOptions", + "kind": "const", + "code": "export const MapLibreOptions = object({\n /**\n * Inject the MapLibre GL JS 5.24.0 stylesheet when the script begins\n * loading. Disable this when supplying the stylesheet through Nuxt.\n * @default true\n */\n injectStyles: optional(boolean()),\n /**\n * Stylesheet URL used when `injectStyles` is enabled.\n * @default 'https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.css'\n */\n stylesheetUrl: optional(string()),\n /**\n * Worker URL for the CSP-compatible MapLibre build. Pair this with a custom\n * `scriptInput.src` that loads `maplibre-gl-csp.js`.\n */\n workerUrl: optional(string()),\n})" + }, + { + "name": "MapLibreApi", + "kind": "interface", + "code": "export interface MapLibreApi {\n maplibregl: typeof MapLibre\n}" + }, + { + "name": "ScriptMapLibreGeoJsonProps", + "kind": "interface", + "code": "interface ScriptMapLibreGeoJsonProps {\n /** Stable MapLibre source ID. */\n sourceId: string\n /** Inline GeoJSON data or a URL returning GeoJSON. */\n data: GeoJSON | string\n /** GeoJSON source options. `type` and `data` are supplied by the component. */\n sourceOptions?: Omit\n /** Style layers backed by this source. */\n layers: ScriptMapLibreGeoJsonLayer[]\n /** Existing layer ID before which the layers are inserted. */\n beforeId?: string\n}" + }, + { + "name": "ScriptMapLibreMapProps", + "kind": "interface", + "code": "interface ScriptMapLibreMapProps {\n /**\n * Defines when the MapLibre script loads.\n * @default 'visible'\n */\n trigger?: ElementScriptTrigger\n /** MapLibre style URL or inline style specification. */\n mapStyle: string | MapLibre.StyleSpecification\n /** Initial and reactively controlled map center. */\n center: MapLibre.LngLatLike\n /** Initial and reactively controlled zoom level. @default 12 */\n zoom?: number\n /** Initial and reactively controlled bearing in degrees. @default 0 */\n bearing?: number\n /** Initial and reactively controlled pitch in degrees. @default 0 */\n pitch?: number\n /** Options passed to `new maplibregl.Map()`. Dedicated props take precedence. */\n options?: Omit\n /** Inject MapLibre's stylesheet when the script begins loading. @default true */\n injectStyles?: boolean\n /** Custom MapLibre stylesheet URL. */\n stylesheetUrl?: string\n /** Worker URL used with MapLibre's CSP-compatible build. */\n workerUrl?: string\n /** Width reserved before the map loads. @default 640 */\n width?: number | string\n /** Height reserved before the map loads. @default 400 */\n height?: number | string\n /** Accessible name for an interactive map. @default 'Interactive map' */\n ariaLabel?: string\n /** Disable map input and remove it from the accessibility tree when decorative. @default true */\n interactive?: boolean\n /** Attributes applied to the outer layout container. */\n rootAttrs?: HTMLAttributes & ReservedProps & Record\n}" + }, + { + "name": "ScriptMapLibreMapEvents", + "kind": "interface", + "code": "interface ScriptMapLibreMapEvents {\n ready: ScriptMapLibreMapExpose\n error: Error\n click: MapLibre.MapEventType['click']\n move: MapLibre.MapEventType['move']\n moveend: MapLibre.MapEventType['moveend']\n zoom: MapLibre.MapEventType['zoom']\n zoomend: MapLibre.MapEventType['zoomend']\n rotate: MapLibre.MapEventType['rotate']\n rotateend: MapLibre.MapEventType['rotateend']\n pitch: MapLibre.MapEventType['pitch']\n pitchend: MapLibre.MapEventType['pitchend']\n update:center: MapLibre.LngLat\n update:zoom: number\n update:bearing: number\n update:pitch: number\n}" + }, + { + "name": "ScriptMapLibreMapSlots", + "kind": "interface", + "code": "interface ScriptMapLibreMapSlots {\n default?: () => any\n loading?: () => any\n awaitingLoad?: () => any\n error?: (props: { error: Error }) => any\n placeholder?: () => any\n description?: () => any\n}" + }, + { + "name": "ScriptMapLibreMarkerProps", + "kind": "interface", + "code": "interface ScriptMapLibreMarkerProps {\n /** Reactive marker position in `[longitude, latitude]` order. */\n position: MapLibre.LngLatLike\n /** Accessible name for the marker element. */\n ariaLabel?: string\n /** Tooltip text for the marker element. */\n title?: string\n /** Options passed to `new maplibregl.Marker()`. */\n options?: MapLibre.MarkerOptions\n}" + }, + { + "name": "ScriptMapLibreMarkerEvents", + "kind": "interface", + "code": "interface ScriptMapLibreMarkerEvents {\n click: MouseEvent\n dragstart: MapLibre.Event\n drag: MapLibre.Event\n dragend: MapLibre.Event\n}" + }, + { + "name": "ScriptMapLibreMarkerSlots", + "kind": "interface", + "code": "interface ScriptMapLibreMarkerSlots {\n default?: () => any\n}" + }, + { + "name": "ScriptMapLibreNavigationControlProps", + "kind": "interface", + "code": "interface ScriptMapLibreNavigationControlProps {\n /** Position of the navigation control. */\n position?: MapLibre.ControlPosition\n /** Options passed to `new maplibregl.NavigationControl()`. */\n options?: MapLibre.NavigationControlOptions\n}" + }, + { + "name": "ScriptMapLibrePopupProps", + "kind": "interface", + "code": "interface ScriptMapLibrePopupProps {\n /** Position for a standalone popup. Omit when nested inside a marker. */\n position?: MapLibre.LngLatLike\n /** Whether the popup is open. @default false */\n open?: boolean\n /** Options passed to `new maplibregl.Popup()`. */\n options?: MapLibre.PopupOptions\n}" + }, + { + "name": "ScriptMapLibrePopupEvents", + "kind": "interface", + "code": "interface ScriptMapLibrePopupEvents {\n open: MapLibre.Event\n close: MapLibre.Event\n}" + }, + { + "name": "ScriptMapLibrePopupSlots", + "kind": "interface", + "code": "interface ScriptMapLibrePopupSlots {\n default?: () => any\n}" + } + ], "matomo-analytics": [ { "name": "MatomoAnalyticsOptions", @@ -1368,6 +1460,28 @@ "defaultValue": "true" } ], + "MapLibreOptions": [ + { + "name": "injectStyles", + "type": "boolean", + "required": false, + "description": "Inject the MapLibre GL JS 5.24.0 stylesheet when the script begins loading. Disable this when supplying the stylesheet through Nuxt.", + "defaultValue": "true" + }, + { + "name": "stylesheetUrl", + "type": "string", + "required": false, + "description": "Stylesheet URL used when `injectStyles` is enabled.", + "defaultValue": "'https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.css'" + }, + { + "name": "workerUrl", + "type": "string", + "required": false, + "description": "Worker URL for the CSP-compatible MapLibre build. Pair this with a custom `scriptInput.src` that loads `maplibre-gl-csp.js`." + } + ], "AhrefsAnalyticsOptions": [ { "name": "key", @@ -2745,6 +2859,135 @@ "defaultValue": "'g'" } ], + "ScriptGoogleMapsProps": [ + { + "name": "trigger", + "type": "ElementScriptTrigger", + "required": false, + "description": "Defines the trigger event to load the script.", + "defaultValue": "['mouseenter', 'mouseover', 'mousedown']" + }, + { + "name": "apiKey", + "type": "string", + "required": false, + "description": "Defines the Google Maps API key. Must have access to the Static Maps API as well." + }, + { + "name": "center", + "type": "google.maps.LatLng | google.maps.LatLngLiteral | `${string},${string}`", + "required": false, + "description": "A latitude / longitude of where to focus the map. prop will be removed in a future major version. When both are set, `mapOptions.center` wins." + }, + { + "name": "zoom", + "type": "number", + "required": false, + "description": "Zoom level for the map (0-21). Reactive: changing this will update the map. prop will be removed in a future major version. When both are set, `mapOptions.zoom` wins." + }, + { + "name": "mapOptions", + "type": "google.maps.MapOptions", + "required": false, + "description": "Options for the map." + }, + { + "name": "region", + "type": "string", + "required": false, + "description": "Defines the region of the map." + }, + { + "name": "language", + "type": "string", + "required": false, + "description": "Defines the language of the map." + }, + { + "name": "version", + "type": "string", + "required": false, + "description": "Defines the version of google maps js API." + }, + { + "name": "width", + "type": "number | string", + "required": false, + "description": "Defines the width of the map.", + "defaultValue": "640" + }, + { + "name": "height", + "type": "number | string", + "required": false, + "description": "Defines the height of the map.", + "defaultValue": "400" + }, + { + "name": "rootAttrs", + "type": "HTMLAttributes & ReservedProps & Record", + "required": false, + "description": "Customize the root element attributes." + }, + { + "name": "mapIds", + "type": "{ light?: string, dark?: string }", + "required": false, + "description": "Map IDs for light and dark color modes. When provided, the map will automatically switch styles based on color mode. Requires @nuxtjs/color-mode or manual colorMode prop." + }, + { + "name": "colorMode", + "type": "'light' | 'dark'", + "required": false, + "description": "Manual color mode control. When provided, overrides auto-detection from @nuxtjs/color-mode. Accepts 'light' or 'dark'." + } + ], + "ScriptGoogleMapsEvents": [ + { + "name": "ready", + "type": "ScriptGoogleMapsExpose", + "required": false, + "description": "Fired when the Google Maps instance is fully loaded and ready to use. Provides access to the maps API." + }, + { + "name": "error", + "type": "-", + "required": false, + "description": "Fired when the Google Maps script fails to load." + } + ], + "ScriptGoogleMapsSlots": [ + { + "name": "default", + "type": "-", + "required": false, + "description": "Default slot for rendering child components (e.g. markers, info windows) that depend on the map being ready." + }, + { + "name": "loading", + "type": "-", + "required": false, + "description": "Slot displayed while the map is loading. Can be used to show a custom loading indicator." + }, + { + "name": "awaitingLoad", + "type": "-", + "required": false, + "description": "Slot displayed when the script is awaiting user interaction to load (based on the `trigger` prop)." + }, + { + "name": "error", + "type": "-", + "required": false, + "description": "Slot displayed if the script fails to load." + }, + { + "name": "placeholder", + "type": "-", + "required": false, + "description": "Slot displayed as a placeholder before the map is ready. Useful for showing a static map or skeleton." + } + ], "ScriptGoogleMapsCircleProps": [ { "name": "options", @@ -3099,12 +3342,79 @@ } ], "ScriptGoogleMapsOverlayViewProps": [ + { + "name": "position", + "type": "google.maps.LatLng | google.maps.LatLngLiteral", + "required": false, + "description": "Geographic position for the overlay. Falls back to parent marker position if omitted. Accepts either a plain `LatLngLiteral` (`{ lat, lng }`) or a `google.maps.LatLng` instance." + }, + { + "name": "defaultOpen", + "type": "boolean", + "required": false, + "description": "Initial open state for the uncontrolled mode (when `v-model:open` is not bound). When omitted, the overlay opens on mount, matching v0 behaviour. Has no effect when `v-model:open` is used; pass an initial value to the bound ref instead.", + "defaultValue": "true" + }, + { + "name": "anchor", + "type": "ScriptGoogleMapsOverlayAnchor", + "required": false, + "description": "Anchor point of the overlay relative to its position.", + "defaultValue": "'bottom-center'" + }, + { + "name": "offset", + "type": "{ x: number, y: number }", + "required": false, + "description": "Pixel offset from the anchor position." + }, + { + "name": "pane", + "type": "ScriptGoogleMapsOverlayPane", + "required": false, + "description": "The map pane on which to render the overlay.", + "defaultValue": "'floatPane'" + }, + { + "name": "zIndex", + "type": "number", + "required": false, + "description": "CSS z-index for the overlay element." + }, + { + "name": "blockMapInteraction", + "type": "boolean", + "required": false, + "description": "Whether to block map click and gesture events from passing through the overlay.", + "defaultValue": "true" + }, + { + "name": "panOnOpen", + "type": "boolean | number", + "required": false, + "description": "Pan the map so the overlay is fully visible when opened, similar to InfoWindow behavior. Set to `true` for default 40px padding, or a number for custom padding.", + "defaultValue": "true" + }, + { + "name": "hideWhenClustered", + "type": "boolean", + "required": false, + "description": "Automatically hide the overlay when its parent marker joins a cluster (on zoom out). Only applies when nested inside a ScriptGoogleMapsMarkerClusterer.", + "defaultValue": "true" + }, { "name": "v-model:open", "type": "boolean", "required": false } ], + "ScriptGoogleMapsOverlayViewSlots": [ + { + "name": "default", + "type": "-", + "required": false + } + ], "ScriptGoogleMapsPolygonProps": [ { "name": "options", @@ -3393,71 +3703,199 @@ "required": false } ], - "ScriptLeafletMarkerProps": [ + "ScriptLeafletMapProps": [ { - "name": "position", + "name": "trigger", + "type": "ElementScriptTrigger", + "required": false, + "description": "Defines when the Leaflet script loads.", + "defaultValue": "'visible'" + }, + { + "name": "center", "type": "Leaflet.LatLngExpression", "required": true }, { - "name": "alt", - "type": "string", + "name": "zoom", + "type": "number", "required": false }, { - "name": "title", - "type": "string", + "name": "options", + "type": "Leaflet.MapOptions", "required": false }, { - "name": "options", - "type": "Leaflet.MarkerOptions", + "name": "injectStyles", + "type": "boolean", "required": false - } - ], - "ScriptLeafletMarkerEvents": [ + }, { - "name": "click", - "type": "Leaflet.LeafletMouseEvent", + "name": "width", + "type": "number | string", "required": false }, { - "name": "dblclick", - "type": "Leaflet.LeafletMouseEvent", + "name": "height", + "type": "number | string", "required": false }, { - "name": "mousedown", - "type": "Leaflet.LeafletMouseEvent", + "name": "ariaLabel", + "type": "string", "required": false }, { - "name": "mouseover", - "type": "Leaflet.LeafletMouseEvent", + "name": "interactive", + "type": "boolean", "required": false }, { - "name": "mouseout", - "type": "Leaflet.LeafletMouseEvent", + "name": "rootAttrs", + "type": "HTMLAttributes & ReservedProps & Record", + "required": false + } + ], + "ScriptLeafletMapEvents": [ + { + "name": "ready", + "type": "ScriptLeafletMapExpose", "required": false }, { - "name": "contextmenu", + "name": "error", + "type": "Error", + "required": false + }, + { + "name": "click", "type": "Leaflet.LeafletMouseEvent", "required": false }, { - "name": "dragstart", + "name": "move", "type": "Leaflet.LeafletEvent", "required": false }, { - "name": "drag", + "name": "moveend", "type": "Leaflet.LeafletEvent", "required": false }, { - "name": "dragend", + "name": "zoom", + "type": "Leaflet.LeafletEvent", + "required": false + }, + { + "name": "zoomend", + "type": "Leaflet.LeafletEvent", + "required": false + }, + { + "name": "update:center", + "type": "Leaflet.LatLng", + "required": false + }, + { + "name": "update:zoom", + "type": "number", + "required": false + } + ], + "ScriptLeafletMapSlots": [ + { + "name": "default", + "type": "-", + "required": false + }, + { + "name": "loading", + "type": "-", + "required": false + }, + { + "name": "awaitingLoad", + "type": "-", + "required": false + }, + { + "name": "error", + "type": "{ error: Error }", + "required": false + }, + { + "name": "placeholder", + "type": "-", + "required": false + } + ], + "ScriptLeafletMarkerProps": [ + { + "name": "position", + "type": "Leaflet.LatLngExpression", + "required": true + }, + { + "name": "alt", + "type": "string", + "required": false + }, + { + "name": "title", + "type": "string", + "required": false + }, + { + "name": "options", + "type": "Leaflet.MarkerOptions", + "required": false + } + ], + "ScriptLeafletMarkerEvents": [ + { + "name": "click", + "type": "Leaflet.LeafletMouseEvent", + "required": false + }, + { + "name": "dblclick", + "type": "Leaflet.LeafletMouseEvent", + "required": false + }, + { + "name": "mousedown", + "type": "Leaflet.LeafletMouseEvent", + "required": false + }, + { + "name": "mouseover", + "type": "Leaflet.LeafletMouseEvent", + "required": false + }, + { + "name": "mouseout", + "type": "Leaflet.LeafletMouseEvent", + "required": false + }, + { + "name": "contextmenu", + "type": "Leaflet.LeafletMouseEvent", + "required": false + }, + { + "name": "dragstart", + "type": "Leaflet.LeafletEvent", + "required": false + }, + { + "name": "drag", + "type": "Leaflet.LeafletEvent", + "required": false + }, + { + "name": "dragend", "type": "Leaflet.DragEndEvent", "required": false }, @@ -3554,6 +3992,320 @@ "required": false } ], + "ScriptMapLibreGeoJsonProps": [ + { + "name": "sourceId", + "type": "string", + "required": true + }, + { + "name": "data", + "type": "GeoJSON | string", + "required": true + }, + { + "name": "sourceOptions", + "type": "Omit", + "required": false + }, + { + "name": "layers", + "type": "ScriptMapLibreGeoJsonLayer[]", + "required": true + }, + { + "name": "beforeId", + "type": "string", + "required": false + } + ], + "ScriptMapLibreMapProps": [ + { + "name": "trigger", + "type": "ElementScriptTrigger", + "required": false, + "description": "Defines when the MapLibre script loads.", + "defaultValue": "'visible'" + }, + { + "name": "mapStyle", + "type": "string | MapLibre.StyleSpecification", + "required": true + }, + { + "name": "center", + "type": "MapLibre.LngLatLike", + "required": true + }, + { + "name": "zoom", + "type": "number", + "required": false + }, + { + "name": "bearing", + "type": "number", + "required": false + }, + { + "name": "pitch", + "type": "number", + "required": false + }, + { + "name": "options", + "type": "Omit", + "required": false + }, + { + "name": "injectStyles", + "type": "boolean", + "required": false + }, + { + "name": "stylesheetUrl", + "type": "string", + "required": false + }, + { + "name": "workerUrl", + "type": "string", + "required": false + }, + { + "name": "width", + "type": "number | string", + "required": false + }, + { + "name": "height", + "type": "number | string", + "required": false + }, + { + "name": "ariaLabel", + "type": "string", + "required": false + }, + { + "name": "interactive", + "type": "boolean", + "required": false + }, + { + "name": "rootAttrs", + "type": "HTMLAttributes & ReservedProps & Record", + "required": false + } + ], + "ScriptMapLibreMapEvents": [ + { + "name": "ready", + "type": "ScriptMapLibreMapExpose", + "required": false + }, + { + "name": "error", + "type": "Error", + "required": false + }, + { + "name": "click", + "type": "MapLibre.MapEventType['click']", + "required": false + }, + { + "name": "move", + "type": "MapLibre.MapEventType['move']", + "required": false + }, + { + "name": "moveend", + "type": "MapLibre.MapEventType['moveend']", + "required": false + }, + { + "name": "zoom", + "type": "MapLibre.MapEventType['zoom']", + "required": false + }, + { + "name": "zoomend", + "type": "MapLibre.MapEventType['zoomend']", + "required": false + }, + { + "name": "rotate", + "type": "MapLibre.MapEventType['rotate']", + "required": false + }, + { + "name": "rotateend", + "type": "MapLibre.MapEventType['rotateend']", + "required": false + }, + { + "name": "pitch", + "type": "MapLibre.MapEventType['pitch']", + "required": false + }, + { + "name": "pitchend", + "type": "MapLibre.MapEventType['pitchend']", + "required": false + }, + { + "name": "update:center", + "type": "MapLibre.LngLat", + "required": false + }, + { + "name": "update:zoom", + "type": "number", + "required": false + }, + { + "name": "update:bearing", + "type": "number", + "required": false + }, + { + "name": "update:pitch", + "type": "number", + "required": false + } + ], + "ScriptMapLibreMapSlots": [ + { + "name": "default", + "type": "-", + "required": false + }, + { + "name": "loading", + "type": "-", + "required": false + }, + { + "name": "awaitingLoad", + "type": "-", + "required": false + }, + { + "name": "error", + "type": "{ error: Error }", + "required": false + }, + { + "name": "placeholder", + "type": "-", + "required": false + }, + { + "name": "description", + "type": "-", + "required": false + } + ], + "ScriptMapLibreMarkerProps": [ + { + "name": "position", + "type": "MapLibre.LngLatLike", + "required": true + }, + { + "name": "ariaLabel", + "type": "string", + "required": false + }, + { + "name": "title", + "type": "string", + "required": false + }, + { + "name": "options", + "type": "MapLibre.MarkerOptions", + "required": false + } + ], + "ScriptMapLibreMarkerEvents": [ + { + "name": "click", + "type": "MouseEvent", + "required": false + }, + { + "name": "dragstart", + "type": "MapLibre.Event", + "required": false + }, + { + "name": "drag", + "type": "MapLibre.Event", + "required": false + }, + { + "name": "dragend", + "type": "MapLibre.Event", + "required": false + } + ], + "ScriptMapLibreMarkerSlots": [ + { + "name": "default", + "type": "-", + "required": false + } + ], + "ScriptMapLibreNavigationControlProps": [ + { + "name": "position", + "type": "MapLibre.ControlPosition", + "required": false + }, + { + "name": "options", + "type": "MapLibre.NavigationControlOptions", + "required": false + } + ], + "ScriptMapLibrePopupProps": [ + { + "name": "position", + "type": "MapLibre.LngLatLike", + "required": false + }, + { + "name": "open", + "type": "boolean", + "required": false + }, + { + "name": "options", + "type": "MapLibre.PopupOptions", + "required": false + } + ], + "ScriptMapLibrePopupEvents": [ + { + "name": "open", + "type": "MapLibre.Event", + "required": false + }, + { + "name": "close", + "type": "MapLibre.Event", + "required": false + } + ], + "ScriptMapLibrePopupSlots": [ + { + "name": "default", + "type": "-", + "required": false + } + ], "ScriptBlueskyEmbedProps": [ { "name": "postUrl", diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 669764c70..d7e6734fc 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -34,6 +34,7 @@ import { IntercomOptions, LeafletOptions, LinkedInInsightOptions, + MapLibreOptions, MatomoAnalyticsOptions, MetaPixelOptions, MixpanelAnalyticsOptions, @@ -160,6 +161,7 @@ export const registryMeta: RegistryScriptMeta[] = [ // content m('googleMaps', 'Google Maps', 'content', 'useScriptGoogleMaps', {}, null), m('leaflet', 'Leaflet', 'content', 'useScriptLeaflet', { bundle: true }, null), + m('maplibre', 'MapLibre GL JS', 'content', 'useScriptMapLibre', { bundle: true }, null), m('instagramEmbed', 'Instagram Embed', 'content', false, {}, null), m('xEmbed', 'X Embed', 'content', false, {}, null), m('blueskyEmbed', 'Bluesky Embed', 'content', false, {}, null), @@ -704,6 +706,14 @@ export async function registry(resolve?: (path: string) => Promise): Pro category: 'content', bundle: true, }), + def('maplibre', { + composableName: 'useScriptMapLibre', + schema: MapLibreOptions, + label: 'MapLibre GL JS', + src: 'https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.js', + category: 'content', + bundle: true, + }), def('blueskyEmbed', { composableName: false, schema: BlueskyEmbedOptions, diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeoJson.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeoJson.vue new file mode 100644 index 000000000..7888b3dae --- /dev/null +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeoJson.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreMap.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreMap.vue new file mode 100644 index 000000000..9e9622936 --- /dev/null +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreMap.vue @@ -0,0 +1,304 @@ + + + + + + + diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreMarker.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreMarker.vue new file mode 100644 index 000000000..2826e9de0 --- /dev/null +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreMarker.vue @@ -0,0 +1,98 @@ + + + diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreNavigationControl.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreNavigationControl.vue new file mode 100644 index 000000000..a80a69731 --- /dev/null +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreNavigationControl.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibrePopup.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibrePopup.vue new file mode 100644 index 000000000..d6292078f --- /dev/null +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibrePopup.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/packages/script/src/runtime/components/MapLibre/useMapLibreResource.ts b/packages/script/src/runtime/components/MapLibre/useMapLibreResource.ts new file mode 100644 index 000000000..55dafe083 --- /dev/null +++ b/packages/script/src/runtime/components/MapLibre/useMapLibreResource.ts @@ -0,0 +1,73 @@ +import type * as MapLibre from 'maplibre-gl' +import type { InjectionKey, ShallowRef } from 'vue' +import { inject, onUnmounted, shallowRef, watch } from 'vue' + +export interface MapLibreMapContext { + map: ShallowRef + maplibre: ShallowRef +} + +export const MAPLIBRE_MAP_INJECTION_KEY = Symbol('maplibre-map') as InjectionKey + +export const MAPLIBRE_MARKER_INJECTION_KEY = Symbol('maplibre-marker') as InjectionKey<{ + marker: ShallowRef +}> + +export interface MapLibreResourceContext { + map: MapLibre.Map + maplibre: typeof MapLibre +} + +/** + * Creates a MapLibre resource after its parent map and any component-specific + * DOM refs are ready, then removes it synchronously during unmount. + */ +export function useMapLibreResource({ + ready, + create, + cleanup, +}: { + ready?: () => boolean + create: (context: MapLibreResourceContext) => T + cleanup: (resource: T, context: MapLibreResourceContext) => void +}): ShallowRef { + const mapContext = inject(MAPLIBRE_MAP_INJECTION_KEY, undefined) + const resource = shallowRef() as ShallowRef + let creating = false + let resourceContext: MapLibreResourceContext | undefined + + if (import.meta.dev && !mapContext) { + console.warn('[nuxt-scripts] MapLibre child components must be placed inside .') + } + + const stop = watch( + () => [mapContext?.map.value, mapContext?.maplibre.value, ready?.() ?? true] as const, + ([map, maplibre, isReady]) => { + if (!map || !maplibre || !isReady || resource.value || creating) + return + + creating = true + try { + resourceContext = { map, maplibre } + resource.value = create(resourceContext) + } + catch (error) { + console.error('[nuxt-scripts] MapLibre resource creation failed:', error) + } + finally { + creating = false + } + }, + { immediate: true }, + ) + + onUnmounted(() => { + stop() + if (resource.value && resourceContext) + cleanup(resource.value, resourceContext) + resource.value = undefined + resourceContext = undefined + }) + + return resource +} diff --git a/packages/script/src/runtime/maplibre-styles.ts b/packages/script/src/runtime/maplibre-styles.ts new file mode 100644 index 000000000..275db42d5 --- /dev/null +++ b/packages/script/src/runtime/maplibre-styles.ts @@ -0,0 +1,23 @@ +export const MAPLIBRE_STYLESHEET_URL = 'https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.css' +export const MAPLIBRE_STYLESHEET_INTEGRITY = 'sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK' + +const MAPLIBRE_STYLE_ID = 'nuxt-scripts-maplibre-styles' + +/** Injects MapLibre's required control and marker stylesheet once. */ +export function ensureMapLibreStyles(stylesheetUrl = MAPLIBRE_STYLESHEET_URL): void { + if (typeof document === 'undefined' || document.getElementById(MAPLIBRE_STYLE_ID)) + return + + const link = document.createElement('link') + link.id = MAPLIBRE_STYLE_ID + link.dataset.nuxtScriptsMaplibre = '1' + link.rel = 'stylesheet' + link.href = stylesheetUrl + + if (stylesheetUrl === MAPLIBRE_STYLESHEET_URL) { + link.integrity = MAPLIBRE_STYLESHEET_INTEGRITY + link.crossOrigin = 'anonymous' + } + + document.head.append(link) +} diff --git a/packages/script/src/runtime/registry/maplibre.ts b/packages/script/src/runtime/registry/maplibre.ts new file mode 100644 index 000000000..d7e98bc40 --- /dev/null +++ b/packages/script/src/runtime/registry/maplibre.ts @@ -0,0 +1,46 @@ +import type * as MapLibre from 'maplibre-gl' +import type { RegistryScriptInput } from '#nuxt-scripts/types' +import { ensureMapLibreStyles, MAPLIBRE_STYLESHEET_URL } from '../maplibre-styles' +import { useRegistryScript } from '../utils' +import { MapLibreOptions } from './schemas' + +export { MapLibreOptions } + +export type MapLibreInput = RegistryScriptInput + +export interface MapLibreApi { + maplibregl: typeof MapLibre +} + +declare global { + interface Window extends MapLibreApi {} +} + +export function useScriptMapLibre(_options?: MapLibreInput) { + return useRegistryScript('maplibre', (options, context) => { + const injectStyles = options?.injectStyles !== false + const stylesheetUrl = options?.stylesheetUrl || MAPLIBRE_STYLESHEET_URL + const usesDefaultSource = !context.scriptInput?.src + + return { + scriptInput: { + src: 'https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.js', + ...(usesDefaultSource + ? { + integrity: 'sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp', + crossorigin: 'anonymous', + } + : {}), + }, + clientInit: injectStyles ? () => ensureMapLibreStyles(stylesheetUrl) : undefined, + schema: import.meta.dev ? MapLibreOptions : undefined, + scriptOptions: { + use() { + if (options?.workerUrl) + window.maplibregl.setWorkerUrl(options.workerUrl) + return { maplibregl: window.maplibregl } + }, + }, + } + }, _options) +} diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index f9b9d5dd8..ea7594bd4 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -28,6 +28,25 @@ export const LeafletOptions = object({ injectStyles: optional(boolean()), }) +export const MapLibreOptions = object({ + /** + * Inject the MapLibre GL JS 5.24.0 stylesheet when the script begins + * loading. Disable this when supplying the stylesheet through Nuxt. + * @default true + */ + injectStyles: optional(boolean()), + /** + * Stylesheet URL used when `injectStyles` is enabled. + * @default 'https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.css' + */ + stylesheetUrl: optional(string()), + /** + * Worker URL for the CSP-compatible MapLibre build. Pair this with a custom + * `scriptInput.src` that loads `maplibre-gl-csp.js`. + */ + workerUrl: optional(string()), +}) + export const AhrefsAnalyticsOptions = object({ /** * Your Ahrefs Web Analytics project key. Set as the `data-key` attribute diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 9ce3aa69d..77ccb5934 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -27,6 +27,7 @@ import type { IntercomInput } from './registry/intercom' import type { LeafletInput } from './registry/leaflet' import type { LemonSqueezyInput } from './registry/lemon-squeezy' import type { LinkedInInsightInput } from './registry/linkedin-insight' +import type { MapLibreInput } from './registry/maplibre' import type { MatomoAnalyticsInput } from './registry/matomo-analytics' import type { MetaPixelInput } from './registry/meta-pixel' import type { MixpanelAnalyticsInput } from './registry/mixpanel-analytics' @@ -264,6 +265,7 @@ export interface ScriptRegistry { googleAnalytics?: GoogleAnalyticsInput googleMaps?: GoogleMapsInput leaflet?: LeafletInput + maplibre?: MapLibreInput googleRecaptcha?: GoogleRecaptchaInput googleSignIn?: GoogleSignInInput lemonSqueezy?: LemonSqueezyInput @@ -301,7 +303,7 @@ export interface ScriptRegistry { export type BuiltInRegistryScriptKey = | 'ahrefsAnalytics' | 'bingUet' | 'blueskyEmbed' | 'calendly' | 'carbonAds' | 'crisp' | 'clarity' | 'cloudflareWebAnalytics' | 'databuddyAnalytics' | 'metaPixel' | 'fathomAnalytics' | 'instagramEmbed' - | 'plausibleAnalytics' | 'googleAdsense' | 'googleAnalytics' | 'googleMaps' | 'leaflet' + | 'plausibleAnalytics' | 'googleAdsense' | 'googleAnalytics' | 'googleMaps' | 'leaflet' | 'maplibre' | 'googleRecaptcha' | 'googleSignIn' | 'lemonSqueezy' | 'googleTagManager' | 'hotjar' | 'intercom' | 'linkedinInsight' | 'paypal' | 'posthog' | 'matomoAnalytics' | 'mixpanelAnalytics' | 'rybbitAnalytics' | 'redditPixel' | 'segment' | 'stripe' | 'tiktokPixel' diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts index 18eb3420d..ddab98994 100644 --- a/packages/script/src/script-meta.ts +++ b/packages/script/src/script-meta.ts @@ -181,6 +181,10 @@ export const scriptMeta = { urls: ['https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'], trackedData: [], }, + maplibre: { + urls: ['https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.js'], + trackedData: [], + }, // CDN npm: { diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 70def1422..7939d1ede 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -115,6 +115,7 @@ export default defineNuxtConfig({ // Maps googleMaps: { trigger: 'manual' }, leaflet: { trigger: false }, + maplibre: { trigger: false }, // Other gravatar: { trigger: 'manual' }, diff --git a/playground/pages/index.vue b/playground/pages/index.vue index 254ddbc29..14268fdb2 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -46,6 +46,7 @@ function getPlaygroundPath(script: any): string | null { 'youtube-player': '/third-parties/youtube/nuxt-scripts', 'google-maps': '/third-parties/google-maps/nuxt-scripts', 'leaflet': '/third-parties/leaflet', + 'maplibre-gl-js': '/third-parties/maplibre', 'google-recaptcha': '/third-parties/google-recaptcha/nuxt-scripts', 'calendly': '/third-parties/calendly/nuxt-scripts', 'usercentrics': '/third-parties/usercentrics/nuxt-scripts', diff --git a/playground/pages/third-parties/maplibre.vue b/playground/pages/third-parties/maplibre.vue new file mode 100644 index 000000000..570b8669c --- /dev/null +++ b/playground/pages/third-parties/maplibre.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50b3e1c52..9a78bb195 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ catalogs: magic-string: specifier: ^1.0.0 version: 1.0.0 + maplibre-gl: + specifier: ^5.24.0 + version: 5.24.0 nuxt: specifier: ^4.5.0 version: 4.5.0 @@ -183,7 +186,7 @@ importers: devDependencies: '@antfu/eslint-config': specifier: 'catalog:' - version: 9.1.0(@typescript-eslint/rule-tester@8.57.2(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.62.0(supports-color@10.2.2)(typescript@6.0.3))(@typescript-eslint/utils@8.62.0(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@vue/compiler-sfc@3.5.39)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(happy-dom@20.10.6)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) + version: 9.1.0(@typescript-eslint/rule-tester@8.57.2(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.62.0(supports-color@10.2.2)(typescript@6.0.3))(@typescript-eslint/utils@8.62.0(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@vue/compiler-sfc@3.5.40)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(happy-dom@20.10.6)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) '@googlemaps/markerclusterer': specifier: 'catalog:' version: 2.6.2 @@ -244,9 +247,12 @@ importers: jest-image-snapshot: specifier: 'catalog:' version: 6.5.2 + maplibre-gl: + specifier: 'catalog:' + version: 5.24.0 nuxt: specifier: 'catalog:' - version: 4.5.0(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(crossws@0.4.6(srvx@0.11.22))(db0@0.3.4)(esbuild@0.28.1)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.0)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.7(typescript@6.0.3))(yaml@2.9.0) + version: 4.5.0(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.40)(cac@6.7.14)(crossws@0.4.6(srvx@0.11.22))(db0@0.3.4)(esbuild@0.28.1)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.0)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.7(typescript@6.0.3))(yaml@2.9.0) ofetch: specifier: 'catalog:' version: 1.5.1 @@ -376,6 +382,9 @@ importers: magic-string: specifier: 'catalog:' version: 1.0.0 + maplibre-gl: + specifier: ^5.24.0 + version: 5.24.0 ofetch: specifier: 'catalog:' version: 1.5.1 @@ -1431,11 +1440,47 @@ packages: '@kwsites/promise-deferred@1.1.1': resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + '@mapbox/jsonlint-lines-primitives@2.0.3': + resolution: {integrity: sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==} + engines: {node: '>= 22'} + '@mapbox/node-pre-gyp@2.0.3': resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} engines: {node: '>=18'} hasBin: true + '@mapbox/point-geometry@1.1.0': + resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==} + + '@mapbox/tiny-sdf@2.2.0': + resolution: {integrity: sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==} + + '@mapbox/unitbezier@0.0.1': + resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==} + + '@mapbox/unitbezier@1.0.0': + resolution: {integrity: sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==} + + '@mapbox/vector-tile@2.0.5': + resolution: {integrity: sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==} + + '@mapbox/whoots-js@3.1.0': + resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==} + engines: {node: '>=6.0.0'} + + '@maplibre/geojson-vt@6.1.1': + resolution: {integrity: sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==} + + '@maplibre/maplibre-gl-style-spec@24.10.0': + resolution: {integrity: sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==} + hasBin: true + + '@maplibre/mlt@1.1.12': + resolution: {integrity: sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==} + + '@maplibre/vt-pbf@4.3.2': + resolution: {integrity: sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -4614,6 +4659,9 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + earcut@3.2.3: + resolution: {integrity: sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -5140,10 +5188,6 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - fuse.js@7.4.2: - resolution: {integrity: sha512-LVbzjD4WA6UP5B1UnP8wuaXJiLnqMdM/E4fiJXTJ5haJ5b/MBNsK29h2fm6swEoQaVQjvYFWKLE2RanyZIoRVQ==} - engines: {node: '>=10'} - fuse.js@7.5.0: resolution: {integrity: sha512-sQtrEfA+ez/3G0cCZecF70oqpCRttCexYUG4mUrtWL49ULUzUyxokt5kyqwtKzj1270RaKih+hcP3qLcumccow==} engines: {node: '>=10'} @@ -5184,6 +5228,9 @@ packages: github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + gl-matrix@3.4.4: + resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -5514,6 +5561,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-pretty-compact@4.0.0: + resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -5703,6 +5753,10 @@ packages: magicast@0.5.3: resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + maplibre-gl@5.24.0: + resolution: {integrity: sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==} + engines: {node: '>=16.14.0', npm: '>=8.1.0'} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -5889,6 +5943,9 @@ packages: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -5949,6 +6006,9 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + murmurhash-js@1.0.0: + resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==} + nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -6201,6 +6261,14 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pbf@4.0.2: + resolution: {integrity: sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==} + hasBin: true + + pbf@5.1.2: + resolution: {integrity: sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==} + hasBin: true + perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} @@ -6594,6 +6662,9 @@ packages: posthog-js@1.404.1: resolution: {integrity: sha512-ck/HOMKuZ6yefUk5OX+h2s9mUWBsnu0mGDUAWjIwAmQQrloZPShzOhOWuEuZQuMnCKORBFRVGsBAuzsl66cBJA==} + potpack@2.1.0: + resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==} + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} @@ -6671,6 +6742,9 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + protocol-buffers-schema@3.6.1: + resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -6687,6 +6761,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quickselect@3.0.0: + resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} + radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} @@ -6766,6 +6843,9 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve-protobuf-schema@2.1.0: + resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -7135,6 +7215,9 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyqueue@3.0.0: + resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} + tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} @@ -7856,7 +7939,7 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@antfu/eslint-config@9.1.0(@typescript-eslint/rule-tester@8.57.2(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.62.0(supports-color@10.2.2)(typescript@6.0.3))(@typescript-eslint/utils@8.62.0(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@vue/compiler-sfc@3.5.39)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(happy-dom@20.10.6)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))': + '@antfu/eslint-config@9.1.0(@typescript-eslint/rule-tester@8.57.2(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.62.0(supports-color@10.2.2)(typescript@6.0.3))(@typescript-eslint/utils@8.62.0(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@vue/compiler-sfc@3.5.40)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(happy-dom@20.10.6)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 1.6.0 @@ -7888,7 +7971,7 @@ snapshots: eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2)) eslint-plugin-vue: 10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2)))(@typescript-eslint/parser@8.62.0(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(vue-eslint-parser@10.4.1(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)) eslint-plugin-yml: 3.5.0(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.39)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.40)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2)) globals: 17.7.0 local-pkg: 1.2.1 parse-gitignore: 2.0.0 @@ -8723,6 +8806,8 @@ snapshots: '@kwsites/promise-deferred@1.1.1': {} + '@mapbox/jsonlint-lines-primitives@2.0.3': {} + '@mapbox/node-pre-gyp@2.0.3(supports-color@10.2.2)': dependencies: consola: 3.4.2 @@ -8736,6 +8821,45 @@ snapshots: - encoding - supports-color + '@mapbox/point-geometry@1.1.0': {} + + '@mapbox/tiny-sdf@2.2.0': {} + + '@mapbox/unitbezier@0.0.1': {} + + '@mapbox/unitbezier@1.0.0': {} + + '@mapbox/vector-tile@2.0.5': + dependencies: + '@mapbox/point-geometry': 1.1.0 + '@types/geojson': 7946.0.16 + pbf: 4.0.2 + + '@mapbox/whoots-js@3.1.0': {} + + '@maplibre/geojson-vt@6.1.1': + dependencies: + kdbush: 4.1.0 + + '@maplibre/maplibre-gl-style-spec@24.10.0': + dependencies: + '@mapbox/jsonlint-lines-primitives': 2.0.3 + '@mapbox/unitbezier': 1.0.0 + json-stringify-pretty-compact: 4.0.0 + minimist: 1.2.8 + quickselect: 3.0.0 + tinyqueue: 3.0.0 + + '@maplibre/mlt@1.1.12': + dependencies: + '@mapbox/point-geometry': 1.1.0 + + '@maplibre/vt-pbf@4.3.2': + dependencies: + '@mapbox/point-geometry': 1.1.0 + '@types/geojson': 7946.0.16 + pbf: 5.1.2 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -8787,7 +8911,7 @@ snapshots: debug: 4.4.3(supports-color@10.2.2) defu: 6.1.7 exsolve: 1.1.0 - fuse.js: 7.4.2 + fuse.js: 7.5.0 fzf: 0.5.2 giget: 3.3.0 jiti: 2.7.0 @@ -9140,93 +9264,6 @@ snapshots: - vue - vue-tsc - '@nuxt/nitro-server@4.5.0(0c2726d7ef5e48e6da8c29a9a800b78c)': - dependencies: - '@nuxt/devalue': 2.0.2 - '@nuxt/kit': 4.5.0(magic-string@1.0.0)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) - '@unhead/vue': 3.1.8(crossws@0.4.6(srvx@0.11.22))(esbuild@0.28.1)(lightningcss@1.32.0)(rolldown@1.2.0)(rollup@4.62.2)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)) - '@vue/shared': 3.5.39 - consola: 3.4.2 - defu: 6.1.7 - destr: 2.0.5 - devalue: 5.8.1 - errx: 0.1.0 - escape-string-regexp: 5.0.0 - exsolve: 1.1.0 - h3: 1.15.11 - impound: 1.1.5(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - klona: 2.0.6 - mocked-exports: 0.1.1 - nitropack: 2.13.4(oxc-parser@0.140.0)(rolldown@1.2.0)(srvx@0.11.22)(supports-color@10.2.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - nostics: 1.2.0 - nuxt: 4.5.0(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(crossws@0.4.6(srvx@0.11.22))(db0@0.3.4)(esbuild@0.28.1)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.0)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.7(typescript@6.0.3))(yaml@2.9.0) - nypm: 0.6.8 - ohash: 2.0.11 - pathe: 2.0.3 - rou3: 0.9.1 - std-env: 4.2.0 - ufo: 1.6.4 - unctx: 3.0.0(magic-string@1.0.0)(oxc-parser@0.140.0)(rolldown@1.2.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) - unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2)) - vue: 3.5.40(typescript@6.0.3) - vue-bundle-renderer: 2.3.1 - vue-devtools-stub: 0.1.0 - optionalDependencies: - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@electric-sql/pglite' - - '@farmfe/core' - - '@libsql/client' - - '@modelcontextprotocol/sdk' - - '@netlify/blobs' - - '@planetscale/database' - - '@rspack/core' - - '@unhead/cli' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - bare-abort-controller - - bare-buffer - - better-sqlite3 - - bufferutil - - bun-types-no-globals - - crossws - - db0 - - drizzle-orm - - encoding - - esbuild - - idb-keyval - - ioredis - - lightningcss - - magic-string - - magicast - - mysql2 - - oxc-parser - - react-native-b4a - - rolldown - - rollup - - sqlite3 - - srvx - - supports-color - - typescript - - unloader - - unplugin - - uploadthing - - utf-8-validate - - vite - - webpack - - xml2js - '@nuxt/nitro-server@4.5.0(820405d62d6b0fade415ae1d39660802)': dependencies: '@nuxt/devalue': 2.0.2 @@ -9403,22 +9440,13 @@ snapshots: '@nuxt/schema@4.5.0': dependencies: - '@vue/shared': 3.5.39 + '@vue/shared': 3.5.40 defu: 6.1.7 nostics: 1.2.0 pathe: 2.0.3 pkg-types: 2.3.1 std-env: 4.2.0 - '@nuxt/telemetry@2.8.0(@nuxt/kit@4.5.0(magic-string@0.30.21)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))))': - dependencies: - '@nuxt/kit': 4.5.0(magic-string@0.30.21)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) - citty: 0.2.2 - consola: 3.4.2 - ofetch: 2.0.0-alpha.3 - rc9: 3.0.1 - std-env: 4.2.0 - '@nuxt/telemetry@2.8.0(@nuxt/kit@4.5.0(magic-string@1.0.0)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))))': dependencies: '@nuxt/kit': 4.5.0(magic-string@1.0.0)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) @@ -9721,68 +9749,6 @@ snapshots: - vue-tsc - yaml - '@nuxt/vite-builder@4.5.0(f6f6245e0155cb5d37cf2dcbd9f178e7)': - dependencies: - '@nuxt/kit': 4.5.0(magic-string@1.0.0)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) - '@vitejs/plugin-vue': 6.0.8(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)) - '@vitejs/plugin-vue-jsx': 5.1.6(supports-color@10.2.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)) - autoprefixer: 10.5.4(postcss@8.5.19) - consola: 3.4.2 - cssnano: 8.0.2(postcss@8.5.19) - defu: 6.1.7 - escape-string-regexp: 5.0.0 - exsolve: 1.1.0 - get-port-please: 3.2.0 - jiti: 2.7.0 - js-tokens: 10.0.0 - knitwork: 1.3.0 - mlly: 1.8.2 - mocked-exports: 0.1.1 - nuxt: 4.5.0(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(crossws@0.4.6(srvx@0.11.22))(db0@0.3.4)(esbuild@0.28.1)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.0)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.7(typescript@6.0.3))(yaml@2.9.0) - nypm: 0.6.8 - pathe: 2.0.3 - pkg-types: 2.3.1 - postcss: 8.5.19 - rolldown-string: 0.3.1(rolldown@1.2.0) - seroval: 1.5.5 - std-env: 4.2.0 - ufo: 1.6.4 - unenv: 2.0.0-rc.24 - vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - vite-node: 6.0.0(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - vite-plugin-checker: 0.14.4(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.7(typescript@6.0.3)) - vue: 3.5.40(typescript@6.0.3) - vue-bundle-renderer: 2.3.1 - optionalDependencies: - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) - rolldown: 1.2.0 - rollup-plugin-visualizer: 7.0.1(rolldown@1.2.0)(rollup@4.62.2) - transitivePeerDependencies: - - '@biomejs/biome' - - '@types/node' - - '@vitejs/devtools' - - esbuild - - eslint - - less - - magic-string - - magicast - - meow - - optionator - - oxc-parser - - oxlint - - sass - - sass-embedded - - stylelint - - stylus - - sugarss - - supports-color - - terser - - tsx - - typescript - - unplugin - - vue-tsc - - yaml - '@nuxtjs/color-mode@4.0.1(magic-string@0.30.21)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))': dependencies: '@nuxt/kit': 4.5.0(magic-string@0.30.21)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) @@ -10700,7 +10666,7 @@ snapshots: '@tiptap/extension-bubble-menu@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)': dependencies: - '@floating-ui/dom': 1.7.6 + '@floating-ui/dom': 1.8.0 '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) '@tiptap/pm': 3.24.0 @@ -10737,7 +10703,7 @@ snapshots: '@tiptap/extension-drag-handle@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/extension-collaboration@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)(@tiptap/y-tiptap@3.0.6(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30))(@tiptap/extension-node-range@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)(@tiptap/y-tiptap@3.0.6(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))': dependencies: - '@floating-ui/dom': 1.7.6 + '@floating-ui/dom': 1.8.0 '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) '@tiptap/extension-collaboration': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)(@tiptap/y-tiptap@3.0.6(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30) '@tiptap/extension-node-range': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) @@ -11692,7 +11658,7 @@ snapshots: '@vue-macros/common@3.1.3(vue@3.5.40(typescript@6.0.3))': dependencies: - '@vue/compiler-sfc': 3.5.39 + '@vue/compiler-sfc': 3.5.40 ast-kit: 2.2.0 local-pkg: 1.2.1 magic-string-ast: 1.0.3 @@ -11702,7 +11668,7 @@ snapshots: '@vue-macros/common@3.1.3(vue@3.5.40(typescript@7.0.2))': dependencies: - '@vue/compiler-sfc': 3.5.39 + '@vue/compiler-sfc': 3.5.40 ast-kit: 2.2.0 local-pkg: 1.2.1 magic-string-ast: 1.0.3 @@ -12604,6 +12570,8 @@ snapshots: duplexer@0.1.2: {} + earcut@3.2.3: {} + eastasianwidth@0.2.0: {} editorconfig@1.0.7: @@ -12980,9 +12948,9 @@ snapshots: natural-compare: 1.4.0 yaml-eslint-parser: 2.0.0 - eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.39)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2)): + eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.40)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2)): dependencies: - '@vue/compiler-sfc': 3.5.39 + '@vue/compiler-sfc': 3.5.40 eslint: 10.7.0(jiti@2.7.0)(supports-color@10.2.2) eslint-scope@9.1.2: @@ -13268,8 +13236,6 @@ snapshots: function-bind@1.1.2: {} - fuse.js@7.4.2: {} - fuse.js@7.5.0: {} fzf@0.5.2: {} @@ -13294,6 +13260,8 @@ snapshots: github-slugger@2.0.0: {} + gl-matrix@3.4.4: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -13639,6 +13607,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-pretty-compact@4.0.0: {} + json5@2.2.3: {} jsonc-eslint-parser@3.1.0: @@ -13848,6 +13818,28 @@ snapshots: '@babel/types': 7.29.7 source-map-js: 1.2.1 + maplibre-gl@5.24.0: + dependencies: + '@mapbox/jsonlint-lines-primitives': 2.0.3 + '@mapbox/point-geometry': 1.1.0 + '@mapbox/tiny-sdf': 2.2.0 + '@mapbox/unitbezier': 0.0.1 + '@mapbox/vector-tile': 2.0.5 + '@mapbox/whoots-js': 3.1.0 + '@maplibre/geojson-vt': 6.1.1 + '@maplibre/maplibre-gl-style-spec': 24.10.0 + '@maplibre/mlt': 1.1.12 + '@maplibre/vt-pbf': 4.3.2 + '@types/geojson': 7946.0.16 + earcut: 3.2.3 + gl-matrix: 3.4.4 + kdbush: 4.1.0 + murmurhash-js: 1.0.0 + pbf: 4.0.2 + potpack: 2.1.0 + quickselect: 3.0.0 + tinyqueue: 3.0.0 + markdown-table@3.0.4: {} marked@17.0.6: {} @@ -14232,6 +14224,8 @@ snapshots: dependencies: brace-expansion: 2.1.1 + minimist@1.2.8: {} + minipass@7.1.3: {} minizlib@3.1.0: @@ -14295,6 +14289,8 @@ snapshots: muggle-string@0.4.1: {} + murmurhash-js@1.0.0: {} + nanoid@3.3.15: {} nanotar@0.3.0: {} @@ -14473,147 +14469,6 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt@4.5.0(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(crossws@0.4.6(srvx@0.11.22))(db0@0.3.4)(esbuild@0.28.1)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.0)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.7(typescript@6.0.3))(yaml@2.9.0): - dependencies: - '@dxup/nuxt': 0.5.3(esbuild@0.28.1)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - '@nuxt/cli': 3.37.0(@nuxt/schema@4.5.0)(cac@6.7.14)(magicast@0.5.3)(supports-color@10.2.2) - '@nuxt/devtools': 3.2.4(magic-string@1.0.0)(oxc-parser@0.140.0)(rolldown@1.2.0)(supports-color@10.2.2)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)) - '@nuxt/kit': 4.5.0(magic-string@1.0.0)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) - '@nuxt/nitro-server': 4.5.0(0c2726d7ef5e48e6da8c29a9a800b78c) - '@nuxt/schema': 4.5.0 - '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.5.0(magic-string@1.0.0)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))) - '@nuxt/vite-builder': 4.5.0(f6f6245e0155cb5d37cf2dcbd9f178e7) - '@unhead/vue': 3.1.8(crossws@0.4.6(srvx@0.11.22))(esbuild@0.28.1)(lightningcss@1.32.0)(rolldown@1.2.0)(rollup@4.62.2)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)) - '@vue/shared': 3.5.39 - chokidar: 5.0.0 - compatx: 0.2.0 - consola: 3.4.2 - cookie-es: 3.1.1 - defu: 6.1.7 - devalue: 5.8.1 - errx: 0.1.0 - escape-string-regexp: 5.0.0 - exsolve: 1.1.0 - fnv1a-64: 0.1.1 - hookable: 6.1.1 - ignore: 7.0.6 - impound: 1.1.5(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - jiti: 2.7.0 - klona: 2.0.6 - knitwork: 1.3.0 - magic-string: 1.0.0 - mlly: 1.8.2 - nanotar: 0.3.0 - nostics: 1.2.0 - nypm: 0.6.8 - object-identity: 0.2.3 - ofetch: 1.5.1 - ohash: 2.0.11 - on-change: 6.0.2 - oxc-walker: 1.0.0(esbuild@0.28.1)(oxc-parser@0.140.0)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - pathe: 2.0.3 - perfect-debounce: 2.1.0 - picomatch: 4.0.5 - pkg-types: 2.3.1 - rolldown: 1.2.0 - rolldown-string: 0.3.1(rolldown@1.2.0) - rou3: 0.9.1 - scule: 1.3.0 - semver: 7.8.5 - std-env: 4.2.0 - tinyglobby: 0.2.17 - ufo: 1.6.4 - ultrahtml: 1.7.0 - uncrypto: 0.1.3 - unctx: 3.0.0(magic-string@1.0.0)(oxc-parser@0.140.0)(rolldown@1.2.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) - undici: 8.7.0 - unhead: 3.1.8(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - unimport: 6.3.0(esbuild@0.28.1)(oxc-parser@0.140.0)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - unrouting: 0.1.7 - untyped: 2.0.0 - vue: 3.5.40(typescript@6.0.3) - vue-router: 5.2.0(@vue/compiler-sfc@3.5.39)(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)) - optionalDependencies: - '@parcel/watcher': 2.5.6 - '@types/node': 26.1.1 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@babel/plugin-proposal-decorators' - - '@babel/plugin-syntax-jsx' - - '@babel/plugin-syntax-typescript' - - '@biomejs/biome' - - '@capacitor/preferences' - - '@deno/kv' - - '@electric-sql/pglite' - - '@farmfe/core' - - '@libsql/client' - - '@modelcontextprotocol/sdk' - - '@netlify/blobs' - - '@pinia/colada' - - '@planetscale/database' - - '@rollup/plugin-babel' - - '@rspack/core' - - '@unhead/cli' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - '@vitejs/devtools' - - '@vue/compiler-sfc' - - aws4fetch - - bare-abort-controller - - bare-buffer - - better-sqlite3 - - bufferutil - - bun-types-no-globals - - cac - - commander - - crossws - - db0 - - drizzle-orm - - encoding - - esbuild - - eslint - - idb-keyval - - ioredis - - less - - lightningcss - - magicast - - meow - - mysql2 - - optionator - - oxc-parser - - oxlint - - pinia - - react-native-b4a - - rollup - - rollup-plugin-visualizer - - sass - - sass-embedded - - sqlite3 - - srvx - - stylelint - - stylus - - sugarss - - supports-color - - terser - - tsx - - typescript - - unloader - - uploadthing - - utf-8-validate - - vite - - vue-tsc - - webpack - - xml2js - - yaml - nuxt@4.5.0(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.40)(cac@6.7.14)(crossws@0.4.6(srvx@0.11.22))(db0@0.3.4)(esbuild@0.28.1)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.0)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.7(typescript@6.0.3))(yaml@2.9.0): dependencies: '@dxup/nuxt': 0.5.3(esbuild@0.28.1)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) @@ -14622,7 +14477,7 @@ snapshots: '@nuxt/kit': 4.5.0(magic-string@1.0.0)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) '@nuxt/nitro-server': 4.5.0(820405d62d6b0fade415ae1d39660802) '@nuxt/schema': 4.5.0 - '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.5.0(magic-string@0.30.21)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))) + '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.5.0(magic-string@1.0.0)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))) '@nuxt/vite-builder': 4.5.0(130e475b1cf64a30882972aac6bc8869) '@unhead/vue': 3.1.8(crossws@0.4.6(srvx@0.11.22))(esbuild@0.28.1)(lightningcss@1.32.0)(rolldown@1.2.0)(rollup@4.62.2)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)) '@vue/shared': 3.5.39 @@ -15164,6 +15019,14 @@ snapshots: pathe@2.0.3: {} + pbf@4.0.2: + dependencies: + resolve-protobuf-schema: 2.1.0 + + pbf@5.1.2: + dependencies: + resolve-protobuf-schema: 2.1.0 + perfect-debounce@2.1.0: {} picocolors@1.1.1: {} @@ -15550,6 +15413,8 @@ snapshots: query-selector-shadow-dom: 1.0.1 web-vitals: 5.3.0 + potpack@2.1.0: {} + powershell-utils@0.1.0: {} preact@10.29.3: {} @@ -15655,6 +15520,8 @@ snapshots: proto-list@1.2.4: {} + protocol-buffers-schema@3.6.1: {} + punycode@2.3.1: {} quansync@0.2.11: {} @@ -15665,6 +15532,8 @@ snapshots: queue-microtask@1.2.3: {} + quickselect@3.0.0: {} + radix3@1.1.2: {} range-parser@1.3.0: {} @@ -15755,6 +15624,10 @@ snapshots: resolve-pkg-maps@1.0.0: {} + resolve-protobuf-schema@2.1.0: + dependencies: + protocol-buffers-schema: 3.6.1 + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -16183,6 +16056,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyqueue@3.0.0: {} + tinyrainbow@3.1.0: {} to-regex-range@5.0.1: @@ -16804,40 +16679,6 @@ snapshots: transitivePeerDependencies: - supports-color - vue-router@5.2.0(@vue/compiler-sfc@3.5.39)(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)): - dependencies: - '@babel/generator': 8.0.0 - '@vue-macros/common': 3.1.3(vue@3.5.40(typescript@6.0.3)) - '@vue/devtools-api': 8.1.5 - ast-walker-scope: 0.9.0 - chokidar: 5.0.0 - json5: 2.2.3 - local-pkg: 1.2.1 - magic-string: 0.30.21 - mlly: 1.8.2 - muggle-string: 0.4.1 - nostics: 1.2.0 - pathe: 2.0.3 - picomatch: 4.0.5 - scule: 1.3.0 - tinyglobby: 0.2.17 - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - unplugin-utils: 0.3.2 - vue: 3.5.40(typescript@6.0.3) - yaml: 2.9.0 - optionalDependencies: - '@vue/compiler-sfc': 3.5.39 - vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - transitivePeerDependencies: - - '@farmfe/core' - - '@rspack/core' - - bun-types-no-globals - - esbuild - - rolldown - - rollup - - unloader - - webpack - vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)): dependencies: '@babel/generator': 8.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 704762a67..dfde29cb6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -77,6 +77,7 @@ catalog: happy-dom: ^20.10.6 jest-image-snapshot: ^6.5.2 magic-string: ^1.0.0 + maplibre-gl: ^5.24.0 nuxt: ^4.5.0 ofetch: ^1.5.1 ohash: ^2.0.11 diff --git a/scripts/generate-registry-types.ts b/scripts/generate-registry-types.ts index f1a7f770b..2d2327278 100644 --- a/scripts/generate-registry-types.ts +++ b/scripts/generate-registry-types.ts @@ -253,12 +253,42 @@ function extractDeclarations(source: string, fileName: string): ExtractedDeclara // --- Component props & events extraction --- const SCRIPT_SETUP_RE = /]*\bsetup\b[^>]*>([\s\S]*?)<\/script>/ +const SCRIPT_RE = /]*)>([\s\S]*?)<\/script>/g +const SETUP_ATTRIBUTE_RE = /\bsetup\b/ function extractScriptSetup(vueSource: string): string | null { const match = vueSource.match(SCRIPT_SETUP_RE) return match?.[1] ?? null } +function extractScript(vueSource: string): string | null { + for (const match of vueSource.matchAll(SCRIPT_RE)) { + if (!SETUP_ATTRIBUTE_RE.test(match[1]!)) + return match[2] ?? null + } + return null +} + +interface NamedTypeDeclaration { + node: any + source: string +} + +function extractNamedTypeDeclarations(source: string | null, fileName: string): Map { + const declarations = new Map() + if (!source) + return declarations + + const { program } = parseSync(fileName, source) + for (const programNode of program.body) { + const node = programNode.type === 'ExportNamedDeclaration' ? programNode.declaration : programNode + if (node?.type !== 'TSInterfaceDeclaration' && node?.type !== 'TSTypeAliasDeclaration') + continue + declarations.set(node.id.name, { node, source }) + } + return declarations +} + interface ComponentMeta { code: string defaults: Record @@ -309,18 +339,21 @@ function resolveTSType(node: any, source: string): string { return source.slice(node.start, node.end) } -function extractPropsFields(typeNode: any, source: string, fullSource?: string): SchemaFieldMeta[] { - if (!typeNode || typeNode.type !== 'TSTypeLiteral') +function extractPropsFields(typeNode: any, source: string): SchemaFieldMeta[] { + const members = typeNode?.type === 'TSTypeLiteral' + ? typeNode.members + : typeNode?.type === 'TSInterfaceBody' + ? typeNode.body + : undefined + if (!members) return [] // Parse JSDoc comments from the type literal source text - const typeCode = fullSource - ? fullSource.slice(typeNode.start, typeNode.end) - : source.slice(typeNode.start, typeNode.end) + const typeCode = source.slice(typeNode.start, typeNode.end) const comments = parseSchemaComments(typeCode) const fields: SchemaFieldMeta[] = [] - for (const member of typeNode.members || []) { + for (const member of members) { if (member.type !== 'TSPropertySignature') continue const name = member.key?.name || member.key?.value @@ -338,7 +371,7 @@ function extractPropsFields(typeNode: any, source: string, fullSource?: string): return fields } -function extractComponentMeta(scriptSource: string, fileName: string): ComponentMeta | null { +function extractComponentMeta(scriptSource: string, fileName: string, namedTypes = new Map()): ComponentMeta | null { const { program } = parseSync(fileName, scriptSource) let propsResult: { code: string, defaults: Record, fields: SchemaFieldMeta[] } | null = null const events: SchemaFieldMeta[] = [] @@ -346,6 +379,19 @@ function extractComponentMeta(scriptSource: string, fileName: string): Component const slots: SchemaFieldMeta[] = [] const constArrays: Record = {} + function resolveTypeNode(typeNode: any): { node: any, source: string } { + if (typeNode?.type !== 'TSTypeReference' || typeNode.typeName?.type !== 'Identifier') + return { node: typeNode, source: scriptSource } + + const declaration = namedTypes.get(typeNode.typeName.name) + if (!declaration) + return { node: typeNode, source: scriptSource } + + if (declaration.node.type === 'TSInterfaceDeclaration') + return { node: declaration.node.body, source: declaration.source } + return { node: declaration.node.typeAnnotation, source: declaration.source } + } + // First pass: collect `as const` arrays for event name resolution walk(program, { enter(node) { @@ -401,17 +447,23 @@ function extractComponentMeta(scriptSource: string, fileName: string): Component return } - const typeArg = node.typeArguments?.params?.[0] - if (!typeArg) + const rawTypeArg = node.typeArguments?.params?.[0] + if (!rawTypeArg) return + const { node: typeArg, source: typeSource } = resolveTypeNode(rawTypeArg) // Parse call signatures or object syntax from TSTypeLiteral - if (typeArg.type === 'TSTypeLiteral') { + const members = typeArg.type === 'TSTypeLiteral' + ? typeArg.members + : typeArg.type === 'TSInterfaceBody' + ? typeArg.body + : undefined + if (members) { // Parse JSDoc comments from the emits type literal - const emitsCode = scriptSource.slice(typeArg.start, typeArg.end) + const emitsCode = typeSource.slice(typeArg.start, typeArg.end) const emitsComments = parseSchemaComments(emitsCode) - for (const member of typeArg.members || []) { + for (const member of members) { // Object syntax: { click: [payload: Type] } or { close: [] } if (member.type === 'TSPropertySignature') { const eventName = member.key?.name || member.key?.value @@ -426,7 +478,7 @@ function extractComponentMeta(scriptSource: string, fileName: string): Component // Named tuple: [payload: Type] → TSNamedTupleMember with elementType const el = elements[0] const typeNode = el.elementType || el.typeAnnotation || el - payloadType = resolveTSType(typeNode, scriptSource) + payloadType = resolveTSType(typeNode, typeSource) } events.push({ name: eventName, @@ -456,7 +508,7 @@ function extractComponentMeta(scriptSource: string, fileName: string): Component const refName = objType.exprName?.name if (refName && constArrays[refName]) { const payloadType = params.length > 1 - ? resolveTSType(params[1]?.typeAnnotation?.typeAnnotation, scriptSource) + ? resolveTSType(params[1]?.typeAnnotation?.typeAnnotation, typeSource) : undefined for (const eventName of constArrays[refName]) { events.push({ @@ -471,7 +523,7 @@ function extractComponentMeta(scriptSource: string, fileName: string): Component // Literal string event: (event: 'ready', ...): void else if (eventType?.type === 'TSLiteralType' && (eventType.literal?.type === 'StringLiteral' || eventType.literal?.type === 'Literal')) { const payloadType = params.length > 1 - ? resolveTSType(params[1]?.typeAnnotation?.typeAnnotation, scriptSource) + ? resolveTSType(params[1]?.typeAnnotation?.typeAnnotation, typeSource) : undefined events.push({ name: eventType.literal.value, @@ -486,12 +538,18 @@ function extractComponentMeta(scriptSource: string, fileName: string): Component // defineSlots<{...}>() if (node.callee?.name === 'defineSlots') { - const typeArg = node.typeArguments?.params?.[0] - if (typeArg?.type === 'TSTypeLiteral') { - const slotsCode = scriptSource.slice(typeArg.start, typeArg.end) + const rawTypeArg = node.typeArguments?.params?.[0] + const { node: typeArg, source: typeSource } = resolveTypeNode(rawTypeArg) + const members = typeArg?.type === 'TSTypeLiteral' + ? typeArg.members + : typeArg?.type === 'TSInterfaceBody' + ? typeArg.body + : undefined + if (members) { + const slotsCode = typeSource.slice(typeArg.start, typeArg.end) const slotsComments = parseSchemaComments(slotsCode) - for (const member of typeArg.members || []) { + for (const member of members) { if (member.type !== 'TSPropertySignature') continue const slotName = member.key?.name || member.key?.value @@ -505,7 +563,7 @@ function extractComponentMeta(scriptSource: string, fileName: string): Component const param = fnType.params[0] const paramType = param?.typeAnnotation?.typeAnnotation if (paramType) { - const resolved = resolveTSType(paramType, scriptSource) + const resolved = resolveTSType(paramType, typeSource) // Avoid leaking unresolvable `typeof` references from runtime variables propsType = resolved.includes('typeof') ? 'object' : resolved } @@ -537,12 +595,13 @@ function extractComponentMeta(scriptSource: string, fileName: string): Component if (!definePropsCall) return - const typeArg = definePropsCall.typeArguments?.params?.[0] - if (!typeArg) + const rawTypeArg = definePropsCall.typeArguments?.params?.[0] + if (!rawTypeArg) return + const { node: typeArg, source: typeSource } = resolveTypeNode(rawTypeArg) - const code = scriptSource.slice(typeArg.start, typeArg.end) - const fields = extractPropsFields(typeArg, scriptSource, scriptSource) + const code = typeSource.slice(typeArg.start, typeArg.end) + const fields = extractPropsFields(typeArg, typeSource) const defaults: Record = {} if (defaultsObj?.type === 'ObjectExpression') { @@ -615,7 +674,9 @@ for (const filePath of componentFiles) { continue const fileName = filePath.split('/').pop()! - const meta = extractComponentMeta(scriptSetup, fileName.replace('.vue', '.ts')) + const normalScript = extractScript(vueSource) + const namedTypes = extractNamedTypeDeclarations(normalScript, fileName.replace('.vue', '.types.ts')) + const meta = extractComponentMeta(scriptSetup, fileName.replace('.vue', '.ts'), namedTypes) if (meta) { componentMetas[fileName.replace('.vue', '')] = meta } @@ -644,6 +705,11 @@ const componentToSlug: Record = { ScriptLeafletMarker: 'leaflet', ScriptLeafletPopup: 'leaflet', ScriptLeafletGeoJson: 'leaflet', + ScriptMapLibreMap: 'maplibre', + ScriptMapLibreMarker: 'maplibre', + ScriptMapLibrePopup: 'maplibre', + ScriptMapLibreGeoJson: 'maplibre', + ScriptMapLibreNavigationControl: 'maplibre', ScriptCarbonAds: 'carbon-ads', ScriptCrisp: 'crisp', ScriptIntercom: 'intercom', diff --git a/test/nuxt-runtime/maplibre-components.nuxt.test.ts b/test/nuxt-runtime/maplibre-components.nuxt.test.ts new file mode 100644 index 000000000..03875c6e2 --- /dev/null +++ b/test/nuxt-runtime/maplibre-components.nuxt.test.ts @@ -0,0 +1,193 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { h, nextTick, shallowRef } from 'vue' +import ScriptMapLibreGeoJson from '../../packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeoJson.vue' +import ScriptMapLibreMarker from '../../packages/script/src/runtime/components/MapLibre/ScriptMapLibreMarker.vue' +import ScriptMapLibreNavigationControl from '../../packages/script/src/runtime/components/MapLibre/ScriptMapLibreNavigationControl.vue' +import ScriptMapLibrePopup from '../../packages/script/src/runtime/components/MapLibre/ScriptMapLibrePopup.vue' +import { MAPLIBRE_MAP_INJECTION_KEY } from '../../packages/script/src/runtime/components/MapLibre/useMapLibreResource' + +function evented(overrides: Record = {}) { + const events = new Map void>() + const target: Record = { + on: vi.fn((name: string, callback: (event: any) => void) => { + events.set(name, callback) + return target + }), + off: vi.fn(() => target), + ...overrides, + } + return { target, events } +} + +function createMapLibreMock() { + const markerElement = document.createElement('div') + const markerEvented = evented() + const marker = Object.assign(markerEvented.target, { + setLngLat: vi.fn(() => marker), + addTo: vi.fn(() => marker), + remove: vi.fn(() => marker), + getElement: vi.fn(() => markerElement), + setPopup: vi.fn(() => marker), + togglePopup: vi.fn(() => marker), + setDraggable: vi.fn(() => marker), + setRotation: vi.fn(() => marker), + setRotationAlignment: vi.fn(() => marker), + setPitchAlignment: vi.fn(() => marker), + setOffset: vi.fn(() => marker), + }) + + const popupEvented = evented() + const popup = Object.assign(popupEvented.target, { + options: {}, + setDOMContent: vi.fn(() => popup), + setLngLat: vi.fn(() => popup), + addTo: vi.fn(() => popup), + isOpen: vi.fn(() => false), + remove: vi.fn(() => popup), + setMaxWidth: vi.fn(() => popup), + setOffset: vi.fn(() => popup), + }) + + const control = { id: 'navigation' } + const styleEvents = new Map void>() + const source = { type: 'geojson', setData: vi.fn() } + const layers = new Set() + let hasSource = false + const map: Record = { + on: vi.fn((name: string, callback: () => void) => { + styleEvents.set(name, callback) + return map + }), + off: vi.fn(() => map), + isStyleLoaded: vi.fn(() => true), + addSource: vi.fn(() => { + hasSource = true + return map + }), + getSource: vi.fn(() => hasSource ? source : undefined), + removeSource: vi.fn(() => { + hasSource = false + return map + }), + addLayer: vi.fn((layer: { id: string }) => { + layers.add(layer.id) + return map + }), + getLayer: vi.fn((id: string) => layers.has(id) ? { id } : undefined), + removeLayer: vi.fn((id: string) => { + layers.delete(id) + return map + }), + addControl: vi.fn(() => map), + hasControl: vi.fn(() => true), + removeControl: vi.fn(() => map), + } + + function MarkerConstructor() { + return marker + } + + function PopupConstructor() { + return popup + } + + function NavigationControlConstructor() { + return control + } + + const maplibre = { + Marker: vi.fn(MarkerConstructor), + Popup: vi.fn(PopupConstructor), + NavigationControl: vi.fn(NavigationControlConstructor), + } + return { maplibre, map, marker, markerElement, popup, control, source, styleEvents } +} + +function provideMap(maplibre: any, map: any) { + return { + provide: { + [MAPLIBRE_MAP_INJECTION_KEY as symbol]: { + map: shallowRef(map), + maplibre: shallowRef(maplibre), + }, + }, + } +} + +describe('mapLibre components', () => { + beforeEach(() => vi.clearAllMocks()) + + it('composes an accessible marker and popup', async () => { + const mocks = createMapLibreMock() + const wrapper = mount(ScriptMapLibreMarker, { + props: { + position: [144.9631, -37.8136], + ariaLabel: 'Melbourne CBD', + }, + slots: { + default: () => h(ScriptMapLibrePopup, { open: true }, () => 'Hello Melbourne'), + }, + global: provideMap(mocks.maplibre, mocks.map), + }) + await nextTick() + await nextTick() + + expect(mocks.maplibre.Marker).toHaveBeenCalledOnce() + expect(mocks.marker.setLngLat).toHaveBeenCalledWith([144.9631, -37.8136]) + expect(mocks.markerElement.getAttribute('aria-label')).toBe('Melbourne CBD') + expect(mocks.marker.setPopup).toHaveBeenCalledWith(mocks.popup) + expect(mocks.marker.togglePopup).toHaveBeenCalledOnce() + + wrapper.unmount() + expect(mocks.marker.setPopup).toHaveBeenCalledWith(null) + expect(mocks.marker.remove).toHaveBeenCalledOnce() + }) + + it('adds reactive GeoJSON source data and style layers', async () => { + const mocks = createMapLibreMock() + const initial = { type: 'FeatureCollection', features: [] } as const + const wrapper = mount(ScriptMapLibreGeoJson, { + props: { + sourceId: 'melbourne', + data: initial, + layers: [{ id: 'melbourne-fill', type: 'fill', paint: { 'fill-color': '#396cb2' } }], + }, + global: provideMap(mocks.maplibre, mocks.map), + }) + await nextTick() + + expect(mocks.map.addSource).toHaveBeenCalledWith('melbourne', { + type: 'geojson', + data: initial, + }) + expect(mocks.map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ + id: 'melbourne-fill', + source: 'melbourne', + }), undefined) + + const next = { type: 'Point', coordinates: [144.9631, -37.8136] } as const + await wrapper.setProps({ data: next }) + expect(mocks.source.setData).toHaveBeenCalledWith(next) + + mocks.styleEvents.get('style.load')?.() + expect(mocks.map.addSource).toHaveBeenCalledTimes(2) + + wrapper.unmount() + expect(mocks.map.removeLayer).toHaveBeenCalledWith('melbourne-fill') + expect(mocks.map.removeSource).toHaveBeenCalledWith('melbourne') + }) + + it('adds and removes a navigation control', async () => { + const mocks = createMapLibreMock() + const wrapper = mount(ScriptMapLibreNavigationControl, { + props: { position: 'top-right' }, + global: provideMap(mocks.maplibre, mocks.map), + }) + await nextTick() + + expect(mocks.map.addControl).toHaveBeenCalledWith(mocks.control, 'top-right') + wrapper.unmount() + expect(mocks.map.removeControl).toHaveBeenCalledWith(mocks.control) + }) +}) diff --git a/test/nuxt-runtime/maplibre-map.nuxt.test.ts b/test/nuxt-runtime/maplibre-map.nuxt.test.ts new file mode 100644 index 000000000..e8b1299c7 --- /dev/null +++ b/test/nuxt-runtime/maplibre-map.nuxt.test.ts @@ -0,0 +1,144 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import ScriptMapLibreMap from '../../packages/script/src/runtime/components/MapLibre/ScriptMapLibreMap.vue' + +const scriptState = vi.hoisted(() => ({ + callbacks: [] as Array<(instance: any) => void>, + errorCallbacks: [] as Array<(error?: Error) => void>, + load: vi.fn(() => Promise.resolve()), + status: undefined as any, +})) + +vi.mock('#nuxt-scripts/composables/useScriptTriggerElement', () => ({ + useScriptTriggerElement: vi.fn(() => Promise.resolve()), +})) + +vi.mock('#nuxt-scripts/registry/maplibre', async () => { + const { ref } = await vi.importActual('vue') + scriptState.status = ref('awaitingLoad') + return { + useScriptMapLibre: vi.fn(() => ({ + load: scriptState.load, + status: scriptState.status, + onLoaded: (callback: (instance: any) => void) => scriptState.callbacks.push(callback), + onError: (callback: (error?: Error) => void) => scriptState.errorCallbacks.push(callback), + })), + } +}) + +function createMapLibreMock() { + const events = new Map void>() + const onceEvents = new Map void>() + const center = { lng: 144.9631, lat: -37.8136 } + const map: Record = { + on: vi.fn((name: string, callback: (event: any) => void) => { + events.set(name, callback) + return map + }), + once: vi.fn((name: string, callback: (event: any) => void) => { + onceEvents.set(name, callback) + return map + }), + getCenter: vi.fn(() => center), + getZoom: vi.fn(() => 12), + getBearing: vi.fn(() => 0), + getPitch: vi.fn(() => 0), + jumpTo: vi.fn(() => map), + setStyle: vi.fn(() => map), + resize: vi.fn(() => map), + remove: vi.fn(), + } + + function MapConstructor() { + return map + } + + const maplibregl = { + Map: vi.fn(MapConstructor), + LngLat: { convert: vi.fn(value => ({ lng: value[0], lat: value[1] })) }, + } + return { maplibregl, map, center, events, onceEvents } +} + +describe('scriptMapLibreMap', () => { + beforeEach(() => { + scriptState.callbacks.length = 0 + scriptState.errorCallbacks.length = 0 + scriptState.status.value = 'awaitingLoad' + vi.clearAllMocks() + }) + + it('initializes, updates, emits model values, and cleans up the map', async () => { + const mocks = createMapLibreMock() + const wrapper = mount(ScriptMapLibreMap, { + props: { + mapStyle: 'https://demotiles.maplibre.org/style.json', + center: [144.9631, -37.8136], + zoom: 12, + width: 800, + height: 500, + }, + slots: { + description: () => 'Melbourne streets and landmarks', + }, + }) + await nextTick() + + expect(scriptState.callbacks).toHaveLength(1) + scriptState.callbacks[0]!({ maplibregl: mocks.maplibregl }) + await nextTick() + + expect(mocks.maplibregl.Map).toHaveBeenCalledWith(expect.objectContaining({ + center: [144.9631, -37.8136], + container: expect.any(HTMLElement), + interactive: true, + style: 'https://demotiles.maplibre.org/style.json', + zoom: 12, + })) + expect(wrapper.attributes('style')).toContain('aspect-ratio: 800 / 500') + expect(wrapper.get('[role="region"]').attributes('aria-describedby')).toBeTruthy() + + mocks.onceEvents.get('load')?.({ type: 'load' }) + await nextTick() + expect(wrapper.emitted('ready')).toHaveLength(1) + expect(mocks.map.resize).toHaveBeenCalledOnce() + + await wrapper.setProps({ center: [144.9796, -37.8304], zoom: 13, bearing: 20, pitch: 30 }) + expect(mocks.map.jumpTo).toHaveBeenCalledWith({ center: { lng: 144.9796, lat: -37.8304 } }) + expect(mocks.map.jumpTo).toHaveBeenCalledWith({ zoom: 13 }) + expect(mocks.map.jumpTo).toHaveBeenCalledWith({ bearing: 20 }) + expect(mocks.map.jumpTo).toHaveBeenCalledWith({ pitch: 30 }) + + mocks.events.get('moveend')?.({ type: 'moveend' }) + mocks.events.get('zoomend')?.({ type: 'zoomend' }) + mocks.events.get('rotateend')?.({ type: 'rotateend' }) + mocks.events.get('pitchend')?.({ type: 'pitchend' }) + expect(wrapper.emitted('update:center')?.[0]).toEqual([mocks.center]) + expect(wrapper.emitted('update:zoom')?.[0]).toEqual([12]) + expect(wrapper.emitted('update:bearing')?.[0]).toEqual([0]) + expect(wrapper.emitted('update:pitch')?.[0]).toEqual([0]) + + wrapper.unmount() + expect(mocks.map.remove).toHaveBeenCalledOnce() + }) + + it('renders script errors and makes decorative maps inert', async () => { + const wrapper = mount(ScriptMapLibreMap, { + props: { + mapStyle: 'https://demotiles.maplibre.org/style.json', + center: [0, 0], + interactive: false, + }, + }) + + const loadFailure = new Error('MapLibre request failed') + scriptState.errorCallbacks[0]!(loadFailure) + scriptState.status.value = 'error' + await nextTick() + + expect(wrapper.get('[role="alert"]').text()).toBe('The map could not be loaded.') + expect(wrapper.emitted('error')?.[0]).toEqual([loadFailure]) + expect(wrapper.get('[aria-hidden="true"]').attributes()).toHaveProperty('inert') + }) +}) diff --git a/test/types/types.test-d.ts b/test/types/types.test-d.ts index b0a90b763..1159dc678 100644 --- a/test/types/types.test-d.ts +++ b/test/types/types.test-d.ts @@ -29,6 +29,7 @@ describe('module options registry', () => { expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() @@ -71,6 +72,7 @@ describe('module options registry', () => { type ObjectForm = Exclude expectTypeOf['apiKey']>().not.toBeNever() expectTypeOf['injectStyles']>().not.toBeNever() + expectTypeOf['workerUrl']>().not.toBeNever() expectTypeOf['id']>().not.toBeNever() expectTypeOf['id']>().not.toBeNever() }) diff --git a/test/unit/maplibre-lifecycle.test.ts b/test/unit/maplibre-lifecycle.test.ts new file mode 100644 index 000000000..bf4d69aea --- /dev/null +++ b/test/unit/maplibre-lifecycle.test.ts @@ -0,0 +1,74 @@ +/** + * @vitest-environment happy-dom + */ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import { defineComponent, h, nextTick, onBeforeUnmount, provide, shallowRef } from 'vue' +import { MAPLIBRE_MAP_INJECTION_KEY, useMapLibreResource } from '../../packages/script/src/runtime/components/MapLibre/useMapLibreResource' + +function createProvider(immediate = true, clearBeforeChildren = false) { + const map = shallowRef(immediate ? { id: 'map' } : undefined) + const maplibre = shallowRef(immediate ? { version: '5.24.0' } : undefined) + + const Provider = defineComponent({ + setup(_, { slots }) { + provide(MAPLIBRE_MAP_INJECTION_KEY, { map, maplibre }) + if (clearBeforeChildren) { + onBeforeUnmount(() => { + map.value = undefined + maplibre.value = undefined + }) + } + return () => h('div', slots.default?.()) + }, + }) + + return { Provider, map, maplibre } +} + +describe('useMapLibreResource', () => { + it('creates a resource after the map becomes ready', async () => { + const { Provider, map, maplibre } = createProvider(false) + const create = vi.fn(() => ({ id: 'marker' })) + + const Child = defineComponent({ + setup() { + return { resource: useMapLibreResource({ create, cleanup: vi.fn() }) } + }, + render: () => h('span'), + }) + + const wrapper = mount(Provider, { slots: { default: () => h(Child) } }) + await nextTick() + expect(create).not.toHaveBeenCalled() + + map.value = { id: 'map' } + maplibre.value = { version: '5.24.0' } + await nextTick() + + expect(create).toHaveBeenCalledOnce() + wrapper.unmount() + }) + + it('cleans up from its captured context after the parent clears refs', async () => { + const { Provider } = createProvider(true, true) + const resource = { id: 'control' } + const cleanup = vi.fn() + + const Child = defineComponent({ + setup() { + useMapLibreResource({ create: () => resource, cleanup }) + return () => h('span') + }, + }) + + const wrapper = mount(Provider, { slots: { default: () => h(Child) } }) + await nextTick() + wrapper.unmount() + + expect(cleanup).toHaveBeenCalledWith(resource, { + map: { id: 'map' }, + maplibre: { version: '5.24.0' }, + }) + }) +}) diff --git a/test/unit/maplibre-registry.test.ts b/test/unit/maplibre-registry.test.ts new file mode 100644 index 000000000..facd3c258 --- /dev/null +++ b/test/unit/maplibre-registry.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('nuxt/app', () => ({ + createError: (input: { message: string }) => new Error(input.message), + useRuntimeConfig: () => ({ public: { scripts: {} } }), +})) + +vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({ + useScript: vi.fn((input, options) => ({ input, options })), +})) + +describe('useScriptMapLibre', () => { + beforeEach(() => vi.clearAllMocks()) + + it('uses the pinned stable build with integrity metadata', async () => { + const { useScriptMapLibre } = await import('../../packages/script/src/runtime/registry/maplibre') + const { useScript } = await import('../../packages/script/src/runtime/composables/useScript') + + useScriptMapLibre() + + expect(vi.mocked(useScript).mock.calls.at(-1)?.[0]).toMatchObject({ + src: 'https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.js', + integrity: 'sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp', + crossorigin: 'anonymous', + }) + }) + + it('does not apply the pinned integrity hash to a custom source', async () => { + const { useScriptMapLibre } = await import('../../packages/script/src/runtime/registry/maplibre') + const { useScript } = await import('../../packages/script/src/runtime/composables/useScript') + + useScriptMapLibre({ + scriptInput: { src: 'https://cdn.example.com/maplibre.js' }, + }) + + const input = vi.mocked(useScript).mock.calls.at(-1)?.[0] as Record + expect(input.src).toBe('https://cdn.example.com/maplibre.js') + expect(input).not.toHaveProperty('integrity') + }) +}) diff --git a/test/unit/maplibre-styles.test.ts b/test/unit/maplibre-styles.test.ts new file mode 100644 index 000000000..769d6ae93 --- /dev/null +++ b/test/unit/maplibre-styles.test.ts @@ -0,0 +1,43 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ensureMapLibreStyles, + MAPLIBRE_STYLESHEET_INTEGRITY, + MAPLIBRE_STYLESHEET_URL, +} from '../../packages/script/src/runtime/maplibre-styles' + +describe('mapLibre styles', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('injects the pinned stylesheet once with integrity metadata', () => { + let style: HTMLLinkElement | undefined + const append = vi.spyOn(document.head, 'append').mockImplementation((node) => { + style = node as HTMLLinkElement + }) + vi.spyOn(document, 'getElementById').mockImplementation(() => style ?? null) + + ensureMapLibreStyles() + ensureMapLibreStyles() + + expect(append).toHaveBeenCalledOnce() + expect(style?.href).toBe(MAPLIBRE_STYLESHEET_URL) + expect(style?.integrity).toBe(MAPLIBRE_STYLESHEET_INTEGRITY) + expect(style?.crossOrigin).toBe('anonymous') + }) + + it('does not apply pinned integrity metadata to a custom stylesheet', () => { + let style: HTMLLinkElement | undefined + vi.spyOn(document.head, 'append').mockImplementation((node) => { + style = node as HTMLLinkElement + }) + + ensureMapLibreStyles('https://cdn.example.com/maplibre.css') + + expect(style?.href).toBe('https://cdn.example.com/maplibre.css') + expect(style?.integrity).toBeFalsy() + }) +}) From 789b22b03f4188d9b32f65e92de21185244f0bc3 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 20 Jul 2026 19:18:47 +1000 Subject: [PATCH 05/10] fix(leaflet): clean up children before map removal --- .../src/runtime/components/Leaflet/ScriptLeafletMap.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue b/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue index b32a7b8c2..fe7dd1a4a 100644 --- a/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue +++ b/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue @@ -59,7 +59,7 @@ export interface ScriptLeafletMapSlots { diff --git a/packages/script/src/runtime/components/Leaflet/ScriptLeafletMarker.vue b/packages/script/src/runtime/components/Leaflet/ScriptLeafletMarker.vue index fcd8f6b12..bcb183b55 100644 --- a/packages/script/src/runtime/components/Leaflet/ScriptLeafletMarker.vue +++ b/packages/script/src/runtime/components/Leaflet/ScriptLeafletMarker.vue @@ -3,18 +3,7 @@ import type * as Leaflet from 'leaflet' import { inject, onMounted, provide, watch } from 'vue' import { bindLeafletEvents, LEAFLET_MAP_INJECTION_KEY, LEAFLET_MARKER_INJECTION_KEY, useLeafletResource } from './useLeafletResource' -const props = defineProps<{ - /** Reactive marker position. */ - position: Leaflet.LatLngExpression - /** Unique text alternative for the marker icon. */ - alt?: string - /** Tooltip text for the marker icon. */ - title?: string - /** Options passed to `L.marker`. */ - options?: Leaflet.MarkerOptions -}>() - -const emit = defineEmits<{ +interface ScriptLeafletMarkerEmits { click: [event: Leaflet.LeafletMouseEvent] dblclick: [event: Leaflet.LeafletMouseEvent] mousedown: [event: Leaflet.LeafletMouseEvent] @@ -25,8 +14,21 @@ const emit = defineEmits<{ drag: [event: Leaflet.LeafletEvent] dragend: [event: Leaflet.DragEndEvent] move: [event: Leaflet.LeafletEvent] +} + +const props = defineProps<{ + /** Reactive marker position. */ + position: Leaflet.LatLngExpression + /** Unique text alternative for the marker icon. */ + alt?: string + /** Tooltip text for the marker icon. */ + title?: string + /** Options passed to `L.marker`. */ + options?: Leaflet.MarkerOptions }>() +const emit = defineEmits() + defineSlots<{ default?: () => any }>() @@ -45,7 +47,7 @@ function markerOptions(): Leaflet.MarkerOptions { const marker = useLeafletResource({ create({ leaflet, map }) { const instance = leaflet.marker(props.position, markerOptions()) - bindLeafletEvents(instance, emit as any, markerEvents) + bindLeafletEvents(instance, emit, markerEvents) return instance.addTo(map) }, cleanup(instance) { diff --git a/packages/script/src/runtime/components/Leaflet/ScriptLeafletTileLayer.vue b/packages/script/src/runtime/components/Leaflet/ScriptLeafletTileLayer.vue index 3c448e0a2..afd5e3d70 100644 --- a/packages/script/src/runtime/components/Leaflet/ScriptLeafletTileLayer.vue +++ b/packages/script/src/runtime/components/Leaflet/ScriptLeafletTileLayer.vue @@ -3,29 +3,31 @@ import type * as Leaflet from 'leaflet' import { inject, watch } from 'vue' import { bindLeafletEvents, LEAFLET_MAP_INJECTION_KEY, useLeafletResource } from './useLeafletResource' -const props = defineProps<{ - /** Tile URL template, for example `https://tile.openstreetmap.org/{z}/{x}/{y}.png`. */ - url: string - /** Tile layer options. Include the provider's required `attribution`. */ - options?: Leaflet.TileLayerOptions -}>() - -const emit = defineEmits<{ +interface ScriptLeafletTileLayerEmits { loading: [event: Leaflet.LeafletEvent] load: [event: Leaflet.LeafletEvent] tileloadstart: [event: Leaflet.TileEvent] tileload: [event: Leaflet.TileEvent] tileabort: [event: Leaflet.TileEvent] tileerror: [event: Leaflet.TileErrorEvent] +} + +const props = defineProps<{ + /** Tile URL template, for example `https://tile.openstreetmap.org/{z}/{x}/{y}.png`. */ + url: string + /** Tile layer options. Include the provider's required `attribution`. */ + options?: Leaflet.TileLayerOptions }>() +const emit = defineEmits() + const mapContext = inject(LEAFLET_MAP_INJECTION_KEY, undefined) const tileEvents = ['loading', 'load', 'tileloadstart', 'tileload', 'tileabort', 'tileerror'] as const const tileLayer = useLeafletResource({ create({ leaflet, map }) { const layer = leaflet.tileLayer(props.url, props.options) - bindLeafletEvents(layer, emit as any, tileEvents) + bindLeafletEvents(layer, emit, tileEvents) return layer.addTo(map) }, cleanup(layer) { diff --git a/packages/script/src/runtime/components/Leaflet/useLeafletResource.ts b/packages/script/src/runtime/components/Leaflet/useLeafletResource.ts index ff3e27a96..77dac44b4 100644 --- a/packages/script/src/runtime/components/Leaflet/useLeafletResource.ts +++ b/packages/script/src/runtime/components/Leaflet/useLeafletResource.ts @@ -20,13 +20,22 @@ export interface LeafletResourceContext { leaflet: LeafletNamespace } -export function bindLeafletEvents( +type UnionToIntersection = (T extends unknown ? (value: T) => void : never) extends (value: infer I) => void ? I : never + +type LeafletEmit = UnionToIntersection<{ + [TEvent in keyof TEvents]: TEvents[TEvent] extends unknown[] + ? (event: TEvent, ...args: TEvents[TEvent]) => void + : never +}[keyof TEvents]> + +export function bindLeafletEvents( target: Leaflet.Evented, - emit: (event: any, ...args: any[]) => void, - events: readonly string[], + emit: LeafletEmit, + events: readonly (keyof TEvents & string)[], ): void { + const emitEvent = emit as (event: keyof TEvents & string, payload: Leaflet.LeafletEvent) => void for (const event of events) - target.on(event, payload => emit(event, payload)) + target.on(event, payload => emitEvent(event, payload)) } /** diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeoJson.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeoJson.vue index 7888b3dae..5bae6b755 100644 --- a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeoJson.vue +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreGeoJson.vue @@ -19,7 +19,7 @@ import { toRaw, watch } from 'vue' import { useMapLibreResource } from './useMapLibreResource' const props = defineProps<{ - /** Stable MapLibre source ID. */ + /** MapLibre source ID. Changing it rebuilds the owned source and layers. */ sourceId: string /** Inline GeoJSON data or a URL returning GeoJSON. */ data: GeoJSON | string @@ -32,6 +32,7 @@ const props = defineProps<{ }>() let ownedLayerIds: string[] = [] +let ownedSourceId: string | undefined function removeOwnedResources(map: MapLibreGl.Map): void { for (const layerId of [...ownedLayerIds].reverse()) { @@ -39,8 +40,9 @@ function removeOwnedResources(map: MapLibreGl.Map): void { map.removeLayer(layerId) } ownedLayerIds = [] - if (map.getSource(props.sourceId)) - map.removeSource(props.sourceId) + if (ownedSourceId && map.getSource(ownedSourceId)) + map.removeSource(ownedSourceId) + ownedSourceId = undefined } function syncResources(map: MapLibreGl.Map): void { @@ -48,19 +50,27 @@ function syncResources(map: MapLibreGl.Map): void { return removeOwnedResources(map) - map.addSource(props.sourceId, { - ...toRaw(props.sourceOptions), - type: 'geojson', - data: toRaw(props.data), - }) + const sourceId = props.sourceId + try { + map.addSource(sourceId, { + ...toRaw(props.sourceOptions), + type: 'geojson', + data: toRaw(props.data), + }) + ownedSourceId = sourceId - for (const layer of props.layers) { - const nextLayer = { - ...toRaw(layer), - source: layer.source || props.sourceId, - } as MapLibreGl.LayerSpecification - map.addLayer(nextLayer, props.beforeId) - ownedLayerIds.push(nextLayer.id) + for (const layer of props.layers) { + const nextLayer = { + ...toRaw(layer), + source: layer.source || sourceId, + } as MapLibreGl.LayerSpecification + map.addLayer(nextLayer, props.beforeId) + ownedLayerIds.push(nextLayer.id) + } + } + catch (error) { + removeOwnedResources(map) + throw error } } @@ -83,7 +93,7 @@ watch(() => props.data, (data) => { (source as MapLibreGl.GeoJSONSource).setData(toRaw(data)) }, { deep: 2 }) -watch(() => [props.layers, props.sourceOptions, props.beforeId] as const, () => { +watch(() => [props.sourceId, props.layers, props.sourceOptions, props.beforeId] as const, () => { if (geoJson.value) syncResources(geoJson.value.map) }, { deep: 3 }) diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreMap.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreMap.vue index 9e9622936..0642dc8f8 100644 --- a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreMap.vue +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreMap.vue @@ -113,6 +113,8 @@ const maplibre = shallowRef() as VueShallowRef const map = shallowRef() const isMapReady = shallowRef(false) const loadError = shallowRef(new Error('MapLibre failed to load')) +const initializationError = shallowRef() +const hasError = computed(() => status.value === 'error' || !!initializationError.value) let isUnmounted = false onError((error?: Error) => { @@ -161,25 +163,35 @@ onMounted(() => { return maplibre.value = instance.maplibregl - const mapInstance = new instance.maplibregl.Map({ - ...toRaw(props.options), - container: mapEl.value, - style: toRaw(props.mapStyle), - center: toRaw(props.center), - zoom: props.zoom, - bearing: props.bearing, - pitch: props.pitch, - interactive: props.interactive, - }) - bindMapEvents(mapInstance) - map.value = mapInstance - mapInstance.once('load', () => { - if (isUnmounted) - return - isMapReady.value = true - mapInstance.resize() - emit('ready', exposed) - }) + let mapInstance: MapLibre.Map | undefined + try { + mapInstance = new instance.maplibregl.Map({ + ...toRaw(props.options), + container: mapEl.value, + style: toRaw(props.mapStyle), + center: toRaw(props.center), + zoom: props.zoom, + bearing: props.bearing, + pitch: props.pitch, + interactive: props.interactive, + }) + bindMapEvents(mapInstance) + map.value = mapInstance + mapInstance.once('load', () => { + if (isUnmounted) + return + isMapReady.value = true + mapInstance!.resize() + emit('ready', exposed) + }) + } + catch (error) { + mapInstance?.remove() + const cause = error instanceof Error ? error : new Error('MapLibre map initialization failed') + initializationError.value = cause + loadError.value = cause + emit('error', cause) + } }) }) @@ -274,16 +286,16 @@ onUnmounted(() => {
- - - - - - + +

The map could not be loaded.

+ + + + diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreMarker.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreMarker.vue index 2826e9de0..0211b67a7 100644 --- a/packages/script/src/runtime/components/MapLibre/ScriptMapLibreMarker.vue +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibreMarker.vue @@ -10,7 +10,7 @@ const props = defineProps<{ ariaLabel?: string /** Tooltip text for the marker element. */ title?: string - /** Options passed to `new maplibregl.Marker()`. */ + /** Options passed to `new maplibregl.Marker()`. Options with public setters also update reactively. */ options?: MapLibre.MarkerOptions }>() @@ -26,20 +26,20 @@ defineSlots<{ }>() let clickHandler: ((event: MouseEvent) => void) | undefined +let markerClassNames = new Set((props.options?.className || '').split(/\s+/).filter(Boolean)) +let fallbackAriaLabel: string | null = null const dragStartHandler = (event: MapLibre.Event) => emit('dragstart', event) const dragHandler = (event: MapLibre.Event) => emit('drag', event) const dragEndHandler = (event: MapLibre.Event) => emit('dragend', event) function syncMarkerElement(instance: MapLibre.Marker): void { const element = instance.getElement() - if (props.ariaLabel) { + if (props.ariaLabel) element.setAttribute('aria-label', props.ariaLabel) - element.setAttribute('role', 'img') - } - else { + else if (fallbackAriaLabel) + element.setAttribute('aria-label', fallbackAriaLabel) + else element.removeAttribute('aria-label') - element.removeAttribute('role') - } if (props.title) element.setAttribute('title', props.title) else @@ -49,6 +49,8 @@ function syncMarkerElement(instance: MapLibre.Marker): void { const marker = useMapLibreResource({ create({ maplibre, map }) { const instance = new maplibre.Marker(props.options).setLngLat(props.position).addTo(map) + markerClassNames = new Set((props.options?.className || '').split(/\s+/).filter(Boolean)) + fallbackAriaLabel = instance.getElement().getAttribute('aria-label') instance.on('dragstart', dragStartHandler) instance.on('drag', dragHandler) instance.on('dragend', dragEndHandler) @@ -61,6 +63,7 @@ const marker = useMapLibreResource({ if (clickHandler) instance.getElement().removeEventListener('click', clickHandler) clickHandler = undefined + fallbackAriaLabel = null instance.off('dragstart', dragStartHandler) instance.off('drag', dragHandler) instance.off('dragend', dragEndHandler) @@ -82,14 +85,32 @@ watch(() => props.title, () => marker.value && syncMarkerElement(marker.value)) watch(() => props.options, (options) => { if (!marker.value || !options) return - marker.value.setDraggable(options.draggable) - marker.value.setRotation(options.rotation) - marker.value.setRotationAlignment(options.rotationAlignment) - marker.value.setPitchAlignment(options.pitchAlignment) - if (options.offset) + if (options.draggable !== undefined) + marker.value.setDraggable(options.draggable) + if (options.rotation !== undefined) + marker.value.setRotation(options.rotation) + if (options.rotationAlignment !== undefined) + marker.value.setRotationAlignment(options.rotationAlignment) + if (options.pitchAlignment !== undefined) + marker.value.setPitchAlignment(options.pitchAlignment) + if (options.offset !== undefined) marker.value.setOffset(options.offset) - if (options.opacity !== undefined) - marker.value.getElement().style.opacity = String(options.opacity) + if (options.opacity !== undefined || options.opacityWhenCovered !== undefined) + marker.value.setOpacity(options.opacity, options.opacityWhenCovered) + if (options.subpixelPositioning !== undefined) + marker.value.setSubpixelPositioning(options.subpixelPositioning) + if (options.className !== undefined) { + const nextClassNames = new Set(options.className.split(/\s+/).filter(Boolean)) + for (const className of markerClassNames) { + if (!nextClassNames.has(className)) + marker.value.removeClassName(className) + } + for (const className of nextClassNames) { + if (!markerClassNames.has(className)) + marker.value.addClassName(className) + } + markerClassNames = nextClassNames + } }, { deep: 2 }) diff --git a/packages/script/src/runtime/components/MapLibre/ScriptMapLibrePopup.vue b/packages/script/src/runtime/components/MapLibre/ScriptMapLibrePopup.vue index d6292078f..732ee1ccd 100644 --- a/packages/script/src/runtime/components/MapLibre/ScriptMapLibrePopup.vue +++ b/packages/script/src/runtime/components/MapLibre/ScriptMapLibrePopup.vue @@ -8,7 +8,7 @@ const props = withDefaults(defineProps<{ position?: MapLibre.LngLatLike /** Whether the popup is open. @default false */ open?: boolean - /** Options passed to `new maplibregl.Popup()`. */ + /** Options passed to `new maplibregl.Popup()`. Options with public setters also update reactively. */ options?: MapLibre.PopupOptions }>(), { open: false, @@ -27,6 +27,7 @@ const markerContext = inject(MAPLIBRE_MARKER_INJECTION_KEY, undefined) const content = useTemplateRef('content') let boundMarker: MapLibre.Marker | undefined let popupMap: MapLibre.Map | undefined +let popupClassNames = new Set((props.options?.className || '').split(/\s+/).filter(Boolean)) const openHandler = (event: MapLibre.Event) => emit('open', event) const closeHandler = (event: MapLibre.Event) => emit('close', event) @@ -49,6 +50,7 @@ const popup = useMapLibreResource({ create({ maplibre, map }) { popupMap = map const instance = new maplibre.Popup(props.options).setDOMContent(content.value!) + popupClassNames = new Set((props.options?.className || '').split(/\s+/).filter(Boolean)) instance.on('open', openHandler) instance.on('close', closeHandler) @@ -83,11 +85,26 @@ watch(() => props.position, (position) => { watch(() => props.options, (options) => { if (!popup.value || !options) return - Object.assign(popup.value.options, options) - if (options.maxWidth) + if (options.maxWidth !== undefined) popup.value.setMaxWidth(options.maxWidth) - if (options.offset) + if (options.offset !== undefined) popup.value.setOffset(options.offset) + if (options.padding !== undefined) + popup.value.setPadding(options.padding) + if (options.subpixelPositioning !== undefined) + popup.value.setSubpixelPositioning(options.subpixelPositioning) + if (options.className !== undefined) { + const nextClassNames = new Set(options.className.split(/\s+/).filter(Boolean)) + for (const className of popupClassNames) { + if (!nextClassNames.has(className)) + popup.value.removeClassName(className) + } + for (const className of nextClassNames) { + if (!popupClassNames.has(className)) + popup.value.addClassName(className) + } + popupClassNames = nextClassNames + } }, { deep: 2 }) defineExpose({ popup }) diff --git a/packages/script/src/runtime/maplibre-styles.ts b/packages/script/src/runtime/maplibre-styles.ts index 275db42d5..89364772b 100644 --- a/packages/script/src/runtime/maplibre-styles.ts +++ b/packages/script/src/runtime/maplibre-styles.ts @@ -5,15 +5,21 @@ const MAPLIBRE_STYLE_ID = 'nuxt-scripts-maplibre-styles' /** Injects MapLibre's required control and marker stylesheet once. */ export function ensureMapLibreStyles(stylesheetUrl = MAPLIBRE_STYLESHEET_URL): void { - if (typeof document === 'undefined' || document.getElementById(MAPLIBRE_STYLE_ID)) + if (typeof document === 'undefined') return const link = document.createElement('link') - link.id = MAPLIBRE_STYLE_ID - link.dataset.nuxtScriptsMaplibre = '1' link.rel = 'stylesheet' link.href = stylesheetUrl + const stylesheets = document.querySelectorAll('link[rel~="stylesheet"][href]') + if ([...stylesheets].some(stylesheet => stylesheet.href === link.href)) + return + + const injectedStylesheets = document.querySelectorAll('[data-nuxt-scripts-maplibre]') + link.id = injectedStylesheets.length ? `${MAPLIBRE_STYLE_ID}-${injectedStylesheets.length + 1}` : MAPLIBRE_STYLE_ID + link.dataset.nuxtScriptsMaplibre = '1' + if (stylesheetUrl === MAPLIBRE_STYLESHEET_URL) { link.integrity = MAPLIBRE_STYLESHEET_INTEGRITY link.crossOrigin = 'anonymous' diff --git a/playground/pages/third-parties/maplibre.vue b/playground/pages/third-parties/maplibre.vue index 570b8669c..75007a9cf 100644 --- a/playground/pages/third-parties/maplibre.vue +++ b/playground/pages/third-parties/maplibre.vue @@ -1,8 +1,9 @@ + + +``` + +Use CSS Grid to place the list beside the map on wide screens and above it on small screens. Keep the text list in the document even if your design makes the map the main visual. + ## Components - [``{lang="html"}](/scripts/leaflet/api/script-leaflet-map) creates the map and controls lazy loading. diff --git a/docs/content/scripts/maplibre/index.md b/docs/content/scripts/maplibre/index.md index 5d0a38c9c..f836e9ad6 100644 --- a/docs/content/scripts/maplibre/index.md +++ b/docs/content/scripts/maplibre/index.md @@ -39,7 +39,7 @@ export default defineNuxtConfig({ ## Quick start -Pass a MapLibre style URL or an inline style specification. This example uses MapLibre's public demonstration style. +Pass a MapLibre style URL or an inline style specification. This example uses OpenFreeMap's keyless Liberty style. ```vue + + +``` + +Keep order status, checkpoint names, and estimated times in normal HTML outside the canvas. The `description` slot connects the essential route summary to the map for assistive technology. + ## MapLibre, OpenMapTiles, and OpenStreetMap These projects solve different parts of the mapping stack: diff --git a/packages/script/src/runtime/components/Leaflet/ScriptLeafletGeoJson.vue b/packages/script/src/runtime/components/Leaflet/ScriptLeafletGeoJson.vue index 333d57262..013e8e29c 100644 --- a/packages/script/src/runtime/components/Leaflet/ScriptLeafletGeoJson.vue +++ b/packages/script/src/runtime/components/Leaflet/ScriptLeafletGeoJson.vue @@ -61,4 +61,5 @@ defineExpose({ geoJson }) diff --git a/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue b/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue index e3e83d0e6..252eb0546 100644 --- a/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue +++ b/packages/script/src/runtime/components/Leaflet/ScriptLeafletMap.vue @@ -162,7 +162,7 @@ watch(() => props.center, (center) => { return const next = leaflet.value.latLng(toRaw(center)) if (!map.value.getCenter().equals(next)) - map.value.panTo(next) + map.value.panTo(next, { animate: false }) }, { deep: 1 }) watch(() => props.zoom, (zoom) => { @@ -216,13 +216,19 @@ onUnmounted(() => {