mirror of
https://github.com/tabler/tabler.git
synced 2026-10-22 15:47:27 +04:00
Add MapLibre maps: the MapView component, markers, styles, demos and blocks (#3268)
This commit is contained in:
1 parent
86388e4066
commit
a845ab075d
53 files changed
+3463
-198
No files matched your search
@@ -66,6 +66,7 @@ const selector = `#wysiwyg-${id}`
|
||||
|
||||
- `BEGIN … / END …` comments mark the block in the copied HTML. Use the component name in caps.
|
||||
- A named `init…` function plus the `readyState` guard, never a bare call: the script may be parsed after `DOMContentLoaded` has already fired.
|
||||
- No `await` (and no `async` function) inside a captured `<script is:inline>`. The Astro compiler sees the keyword and renders the page content asynchronously, `PageScripts` then drains the registry too early, and **every** captured script of the page disappears with no error. Use a `.then()` chain instead.
|
||||
- The component takes a required `id` prop and derives ids and selectors from it in the frontmatter. Never query by class or by tag — a page renders many instances.
|
||||
|
||||
## 4. Passing data with `define:vars`
|
||||
|
||||
@@ -7,14 +7,17 @@ description: >-
|
||||
|
||||
# The framework JavaScript
|
||||
|
||||
`core/js` builds two shipped bundles. Everything here is public API and is bound by a size budget.
|
||||
`core/js` builds three shipped bundles. Everything here is public API and is bound by a size budget.
|
||||
|
||||
## 1. Two entry points
|
||||
## 1. Three entry points
|
||||
|
||||
| Entry | Ships as | Holds |
|
||||
| -------------------- | ---------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `js/tabler.ts` | `tabler.js` / `.esm.js` (+ `.min`) | plugin initialisers, the Bootstrap components, the `tabler` helper namespace |
|
||||
| `js/tabler-theme.ts` | `tabler-theme.js` (+ variants) | the colour-mode/theme switcher only |
|
||||
| `js/tabler-map.ts` | `tabler-map.js` (+ variants) | the `MapView` component, as an add-on that extends the `tabler` global |
|
||||
|
||||
`tabler-map.js` is loaded after `tabler.js`, and only on pages with a map: a component that few pages use goes in an add-on bundle like this one, not in `tabler.js`. Its UMD build uses `extend` (`core/.build/vite.config.mts`), so it adds its exports to the existing `tabler` global.
|
||||
|
||||
`tabler-theme.js` is loaded right after `<body>` and **not deferred**, so the chosen theme applies before the first paint. It stays tiny on purpose (bundlewatch: 1 kB raw, 800 B minified) — do not add anything to it that is not needed before paint.
|
||||
|
||||
|
||||
+14
-5
@@ -30,7 +30,16 @@ const dataFile = join(repoRoot, 'shared/data/sri.json')
|
||||
|
||||
// The files the docs hand out as CDN tags. The plugin bundles in `dist/libs/` are not here: they
|
||||
// are third-party packages the docs only link to, and each one has its own version.
|
||||
const files = ['dist/css/tabler.min.css', 'dist/css/tabler.rtl.min.css', 'dist/js/tabler.min.js', 'dist/js/tabler-theme.min.js', ...site.cssPlugins.flatMap((plugin) => [`dist/css/tabler-${plugin}.min.css`, `dist/css/tabler-${plugin}.rtl.min.css`])]
|
||||
const files = ['dist/css/tabler.min.css', 'dist/css/tabler.rtl.min.css', 'dist/js/tabler.min.js', 'dist/js/tabler-theme.min.js', 'dist/js/tabler-map.min.js', ...site.cssPlugins.flatMap((plugin) => [`dist/css/tabler-${plugin}.min.css`, `dist/css/tabler-${plugin}.rtl.min.css`])]
|
||||
|
||||
// Files that a release added. An older version does not have them on the CDN, so they are left
|
||||
// out until that release is the current one; the docs render their tags without `integrity`.
|
||||
const addedIn: Record<string, string> = { 'dist/js/tabler-map.min.js': '1.7.0' }
|
||||
|
||||
const isBefore = (version: string, other: string): boolean => version.localeCompare(other, undefined, { numeric: true }) < 0
|
||||
|
||||
/** The files the current version of the package has. */
|
||||
const publishedFiles = (): string[] => files.filter((file) => !(file in addedIn) || !isBefore(site.version, addedIn[file] ?? ''))
|
||||
|
||||
/** One GET, retried once: a single dropped connection should not fail a CI job. */
|
||||
async function get(url: string): Promise<Response> {
|
||||
@@ -70,7 +79,7 @@ async function hashFile(file: string): Promise<string> {
|
||||
}
|
||||
|
||||
async function collectHashes(): Promise<SriData> {
|
||||
const hashes = await Promise.all(files.map(async (file) => [file, await hashFile(file)] as const))
|
||||
const hashes = await Promise.all(publishedFiles().map(async (file) => [file, await hashFile(file)] as const))
|
||||
|
||||
return { version: site.version, algorithm, files: Object.fromEntries(hashes) }
|
||||
}
|
||||
@@ -104,7 +113,7 @@ async function check(): Promise<void> {
|
||||
const current = await collectHashes()
|
||||
const problems: string[] = []
|
||||
|
||||
for (const file of files) {
|
||||
for (const file of publishedFiles()) {
|
||||
if (committed.files[file] !== current.files[file]) {
|
||||
problems.push(`${file}: ${committed.files[file] ?? 'missing'} committed, ${current.files[file]} on the CDN`)
|
||||
}
|
||||
@@ -116,7 +125,7 @@ async function check(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`check:sri: ${files.length} hashes match @tabler/core v${current.version}`)
|
||||
console.log(`check:sri: ${publishedFiles().length} hashes match @tabler/core v${current.version}`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,7 +164,7 @@ async function generate(wait: boolean): Promise<void> {
|
||||
|
||||
writeFileSync(dataFile, `${JSON.stringify(data, null, 2)}\n`)
|
||||
|
||||
console.log(`generate:sri: wrote ${files.length} ${algorithm} hashes for @tabler/core v${data.version}`)
|
||||
console.log(`generate:sri: wrote ${publishedFiles().length} ${algorithm} hashes for @tabler/core v${data.version}`)
|
||||
}
|
||||
|
||||
// Wrapped in main() because the root package is CJS (no top-level await).
|
||||
|
||||
@@ -9,20 +9,26 @@ interface CreateViteConfigOptions {
|
||||
outDir: string
|
||||
banner?: string
|
||||
minify?: boolean | 'esbuild'
|
||||
/** UMD only: add the exports to an existing global of the same name instead of replacing it */
|
||||
extend?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Vite configuration for building libraries
|
||||
*/
|
||||
export function createViteConfig({ entry, name, fileName, formats, outDir, banner, minify = false }: CreateViteConfigOptions): UserConfig {
|
||||
export function createViteConfig({ entry, name, fileName, formats, outDir, banner, minify = false, extend = false }: CreateViteConfigOptions): UserConfig {
|
||||
// Vite 8 (Rolldown) always emits const bindings and no longer accepts the
|
||||
// Rollup-only generatedCode.constBindings option
|
||||
const rollupOutput: { banner?: string } = {}
|
||||
const rollupOutput: { banner?: string; extend?: boolean } = {}
|
||||
|
||||
if (banner) {
|
||||
rollupOutput.banner = banner
|
||||
}
|
||||
|
||||
if (extend) {
|
||||
rollupOutput.extend = true
|
||||
}
|
||||
|
||||
const config: UserConfig = {
|
||||
// Library builds: never copy <root>/public into outDir (see copy-assets.ts).
|
||||
publicDir: false,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@tabler/preview": minor
|
||||
"@tabler/docs": patch
|
||||
---
|
||||
|
||||
Added the Map blocks page with seven blocks, among them a store locator, a delivery tracker and a server status map.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tabler/preview": minor
|
||||
---
|
||||
|
||||
Added marker, popup, route, area, clusters, city switcher, bubble map and globe demos to the maps page.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tabler/core": minor
|
||||
---
|
||||
|
||||
Added `.map-marker` with dot, pin, pulse and color variants, `.map-marker-label`, `.map-popup-card` and `.map-tooltip`.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@tabler/core": minor
|
||||
"@tabler/docs": minor
|
||||
---
|
||||
|
||||
Added the `MapView` component in `tabler-map.js`: `data-bs-toggle="map"` makes a themed MapLibre map with markers and popups.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tabler/core": minor
|
||||
---
|
||||
|
||||
Added `MapView` options for a route with progress, GeoJSON shapes, a 3D globe, control buttons and softer map colors.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tabler/core": minor
|
||||
---
|
||||
|
||||
Added MapLibre styles to `tabler-vendors.css`: popups with no arrow, attribution and `.map-controls` buttons, in light and dark.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@tabler/core": minor
|
||||
"@tabler/preview": minor
|
||||
"@tabler/docs": minor
|
||||
---
|
||||
|
||||
Added MapLibre GL JS 6 to `dist/libs` and moved the map demos and docs to it from Mapbox GL and Google Maps.
|
||||
@@ -104,6 +104,10 @@ const files: FileConfig[] = [
|
||||
file: 'js/tabler-theme.min.js',
|
||||
configPropertyName: 'js-theme',
|
||||
},
|
||||
{
|
||||
file: 'js/tabler-map.min.js',
|
||||
configPropertyName: 'js-map',
|
||||
},
|
||||
]
|
||||
|
||||
function generateSRI(): void {
|
||||
|
||||
@@ -8,7 +8,10 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const baseName = process.env.BASE_NAME || 'tabler'
|
||||
const entryFile = baseName
|
||||
const libraryName = baseName
|
||||
// `tabler-map` is an add-on to the main bundle: its UMD build extends the
|
||||
// `tabler` global instead of creating one of its own.
|
||||
const isAddon = baseName === 'tabler-map'
|
||||
const libraryName = isAddon ? 'tabler' : baseName
|
||||
|
||||
const bannerText = getBanner()
|
||||
|
||||
@@ -26,4 +29,5 @@ export default createViteConfig({
|
||||
outDir: path.resolve(__dirname, '../dist/js'),
|
||||
banner: bannerText,
|
||||
minify: false,
|
||||
extend: isAddon,
|
||||
})
|
||||
Vendored
+3
-2
@@ -1,8 +1,8 @@
|
||||
import type { JQueryStaticLike } from './bootstrap/types'
|
||||
|
||||
// Plugins that are loaded separately, not bundled: IMask (https://imask.js.org),
|
||||
// SortableJS (https://sortablejs.github.io/Sortable/) and Vanilla Calendar Pro
|
||||
// (https://vanilla-calendar.pro). The instance types live in the component
|
||||
// SortableJS (https://sortablejs.github.io/Sortable/), MapLibre GL JS
|
||||
// (https://maplibre.org) and Vanilla Calendar Pro (https://vanilla-calendar.pro). The instance types live in the component
|
||||
// modules, so they are part of the published `dist/types`. This file itself is
|
||||
// not emitted there (tsc does not copy `.d.ts` sources), so the real
|
||||
// `vanilla-calendar-pro` import below is only seen inside this repo.
|
||||
@@ -10,6 +10,7 @@ declare global {
|
||||
interface Window {
|
||||
VanillaCalendarPro?: typeof import('vanilla-calendar-pro')
|
||||
IMask?: new (element: HTMLElement, options: { mask: unknown; lazy?: boolean } & Record<string, unknown>) => import('./input-mask').IMaskInstance
|
||||
maplibregl?: import('./map').MapLibrary
|
||||
Sortable?: new (element: HTMLElement, options?: Record<string, unknown>) => import('./sortable').SortableInstance
|
||||
jQuery?: JQueryStaticLike
|
||||
}
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Tabler map.ts
|
||||
* Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE)
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import BaseComponent from './bootstrap/base-component'
|
||||
import EventHandler from './bootstrap/dom/event-handler'
|
||||
import Manipulator from './bootstrap/dom/manipulator'
|
||||
import SelectorEngine from './bootstrap/dom/selector-engine'
|
||||
import { initAll } from './bootstrap/util/component-functions'
|
||||
import type { ElementSelector } from './bootstrap/types'
|
||||
|
||||
/**
|
||||
* Constants
|
||||
*/
|
||||
|
||||
const NAME = 'map'
|
||||
const DATA_KEY = `bs.${NAME}`
|
||||
const EVENT_KEY = `.${DATA_KEY}`
|
||||
|
||||
const EVENT_LOAD = `load${EVENT_KEY}`
|
||||
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}.data-api`
|
||||
|
||||
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="map"], [data-tblr-toggle="map"]'
|
||||
const SELECTOR_MARKER = ':scope > [data-bs-lng], :scope > [data-tblr-lng]'
|
||||
const SELECTOR_THEME = '[data-bs-theme]'
|
||||
const SELECTOR_ACTION = '[data-bs-map-action], [data-tblr-map-action]'
|
||||
|
||||
const CLASS_NAME_CONTROLS = 'map-controls'
|
||||
const CLASS_NAME_CONTROL = 'btn btn-sm btn-icon'
|
||||
const CLASS_NAME_CONTROL_GROUP = 'btn-group-vertical'
|
||||
|
||||
const LOCATE_ZOOM = 14
|
||||
|
||||
const STYLE_URL = 'https://tiles.openfreemap.org/styles/'
|
||||
|
||||
const POPUP_OFFSET = 16
|
||||
const POPUP_OFFSET_PIN = 40
|
||||
|
||||
const SOURCE_DATA = 'tabler-data'
|
||||
const SOURCE_ROUTE = 'tabler-route'
|
||||
const SOURCE_ROUTE_REST = 'tabler-route-rest'
|
||||
|
||||
// The public API is typed with local copies of the MapLibre GL JS types.
|
||||
// `maplibre-gl` is loaded separately as `window.maplibregl`, so the published
|
||||
// `dist/types` must not import it: a project that never shows a map would
|
||||
// otherwise fail to type-check.
|
||||
type LngLat = [number, number]
|
||||
|
||||
/** The part of a MapLibre GL JS map the `map` getter promises. Cast it to `Map` from `maplibre-gl` for the full type. */
|
||||
type MapInstance = {
|
||||
on(type: string, listener: () => void): unknown
|
||||
loaded(): boolean
|
||||
isStyleLoaded(): boolean | void
|
||||
remove(): void
|
||||
setStyle(style: string): unknown
|
||||
getStyle(): { layers: { id: string }[] }
|
||||
setPaintProperty(layer: string, name: string, value: string): unknown
|
||||
setProjection(projection: { type: string }): unknown
|
||||
zoomIn(): unknown
|
||||
zoomOut(): unknown
|
||||
resetNorthPitch(): unknown
|
||||
getZoom(): number
|
||||
flyTo(options: { center: LngLat; zoom: number }): unknown
|
||||
addSource(id: string, source: Record<string, unknown>): unknown
|
||||
addLayer(layer: Record<string, unknown>): unknown
|
||||
}
|
||||
|
||||
/** The part of a MapLibre GL JS marker the `markers` getter promises. */
|
||||
type MapMarkerInstance = {
|
||||
setLngLat(lngLat: LngLat): MapMarkerInstance
|
||||
setPopup(popup: unknown): MapMarkerInstance
|
||||
addTo(map: MapInstance): MapMarkerInstance
|
||||
togglePopup(): MapMarkerInstance
|
||||
getElement(): HTMLElement
|
||||
remove(): void
|
||||
}
|
||||
|
||||
type MapPopupInstance = {
|
||||
setDOMContent(node: Node): MapPopupInstance
|
||||
setText(text: string): MapPopupInstance
|
||||
}
|
||||
|
||||
type MapLibrary = {
|
||||
Map: new (options: Record<string, unknown>) => MapInstance
|
||||
Marker: new (options: Record<string, unknown>) => MapMarkerInstance
|
||||
Popup: new (options: Record<string, unknown>) => MapPopupInstance
|
||||
}
|
||||
|
||||
type MapAction = 'zoom-in' | 'zoom-out' | 'north' | 'locate' | 'fullscreen'
|
||||
|
||||
type MapTheme = 'auto' | 'light' | 'dark'
|
||||
|
||||
type ComponentConfig = {
|
||||
/**
|
||||
* URL of the MapLibre GL JS module, loaded when `window.maplibregl` is not set.
|
||||
* A relative URL is resolved against the Tabler script; `null` turns the loading off.
|
||||
*/
|
||||
library: string | null
|
||||
/** `[longitude, latitude]`, or the two numbers as a `"13.4,52.5"` string */
|
||||
center: LngLat | string
|
||||
zoom: number
|
||||
/** style URL, or the name of an OpenFreeMap style such as `liberty` */
|
||||
mapStyle: string
|
||||
/** style used in dark mode; `null` keeps `mapStyle` in both modes */
|
||||
mapStyleDark: string | null
|
||||
/** `auto` follows the nearest `data-bs-theme` */
|
||||
mapTheme: MapTheme
|
||||
/** softer colors for the default `positron` and `dark` styles */
|
||||
palette: boolean
|
||||
/** buttons over the map: `true` for zoom, or a list of `zoom`, `north`, `locate` and `fullscreen` */
|
||||
controls: boolean | string
|
||||
/** accessible names of the buttons, by action: `zoom-in`, `zoom-out`, `north`, `locate`, `fullscreen` */
|
||||
labels: Partial<Record<MapAction, string>>
|
||||
/** draws the map as a 3D globe */
|
||||
globe: boolean
|
||||
/** a path drawn as a line: a list of `[longitude, latitude]` points */
|
||||
route: LngLat[] | null
|
||||
/** index of the route point reached so far: the path up to it is solid, the rest is dashed */
|
||||
routeProgress: number | null
|
||||
/** GeoJSON drawn over the map, as an object or the URL of a file: areas, lines and points */
|
||||
geojson: object | string | null
|
||||
/** color of the route and the GeoJSON shapes: a CSS color or a custom property */
|
||||
layerColor: string
|
||||
/** pass-through for any MapLibre GL JS map option */
|
||||
mapOptions: object
|
||||
}
|
||||
|
||||
type ComponentConfigInput = Partial<ComponentConfig> & Record<string, unknown>
|
||||
|
||||
type PaletteRule = [RegExp, Record<string, string>]
|
||||
|
||||
const Default: ComponentConfig = {
|
||||
library: '../libs/maplibre-gl/dist/maplibre-gl.mjs',
|
||||
center: [0, 0],
|
||||
zoom: 1,
|
||||
mapStyle: 'positron',
|
||||
mapStyleDark: 'dark',
|
||||
mapTheme: 'auto',
|
||||
palette: true,
|
||||
controls: false,
|
||||
labels: {},
|
||||
globe: false,
|
||||
route: null,
|
||||
routeProgress: null,
|
||||
geojson: null,
|
||||
layerColor: '--tblr-primary',
|
||||
mapOptions: {},
|
||||
}
|
||||
|
||||
const DefaultType: Record<keyof ComponentConfig, string> = {
|
||||
library: '(null|string)',
|
||||
center: '(array|string)',
|
||||
zoom: 'number',
|
||||
mapStyle: 'string',
|
||||
mapStyleDark: '(null|string)',
|
||||
mapTheme: 'string',
|
||||
palette: 'boolean',
|
||||
controls: '(boolean|string)',
|
||||
labels: 'object',
|
||||
globe: 'boolean',
|
||||
route: '(null|array)',
|
||||
routeProgress: '(null|number)',
|
||||
geojson: '(null|object|string)',
|
||||
layerColor: 'string',
|
||||
mapOptions: 'object',
|
||||
}
|
||||
|
||||
// The buttons each name in the `controls` option stands for
|
||||
const Controls: Record<string, MapAction[]> = {
|
||||
zoom: ['zoom-in', 'zoom-out'],
|
||||
north: ['north'],
|
||||
locate: ['locate'],
|
||||
fullscreen: ['fullscreen'],
|
||||
}
|
||||
|
||||
const Labels: Record<MapAction, string> = {
|
||||
'zoom-in': 'Zoom in',
|
||||
'zoom-out': 'Zoom out',
|
||||
'north': 'Reset north',
|
||||
'locate': 'Show my location',
|
||||
'fullscreen': 'Full screen',
|
||||
}
|
||||
|
||||
// Paint values by layer id of the OpenFreeMap `positron` and `dark` styles:
|
||||
// the first rule that matches a layer is used.
|
||||
const WHITE_HALO = 'rgba(255, 255, 255, 0.8)'
|
||||
const BLACK_HALO = 'rgba(0, 0, 0, 0.7)'
|
||||
|
||||
const Palette: Record<'light' | 'dark', PaletteRule[]> = {
|
||||
light: [
|
||||
[/^background$/, { 'background-color': '#fafaf8' }],
|
||||
[/^(park|landcover_wood)$/, { 'fill-color': 'rgba(234, 241, 233, 0.6)' }],
|
||||
[/^water$/, { 'fill-color': '#d4dadc' }],
|
||||
[/^waterway$/, { 'line-color': '#d1dbdf' }],
|
||||
[/^(landcover_(ice_shelf|glacier)|road_area_pier)$/, { 'fill-color': '#fafaf8' }],
|
||||
[/^landuse_residential$/, { 'fill-color': 'rgba(237, 237, 237, 0.5)' }],
|
||||
[/^building$/, { 'fill-color': '#ededed', 'fill-outline-color': '#dfdfdf' }],
|
||||
[/^(road_pier|railway.*dashline)$/, { 'line-color': '#fafaf8' }],
|
||||
[/^highway_path$/, { 'line-color': '#ededed' }],
|
||||
[/^highway_minor$|_subtle$/, { 'line-color': '#e6e6e6' }],
|
||||
[/_casing$/, { 'line-color': '#dddddd' }],
|
||||
[/^tunnel_.*_inner$/, { 'line-color': '#f3f3f3' }],
|
||||
[/^highway_.*_inner$/, { 'line-color': '#ffffff' }],
|
||||
[/^railway/, { 'line-color': '#e3e3e3' }],
|
||||
[/^boundary_/, { 'line-color': '#e3cdd0' }],
|
||||
[/^waterway_line_label$/, { 'text-color': '#7a96a0' }],
|
||||
[/^water_name/, { 'text-color': '#abb6be' }],
|
||||
[/^highway-name/, { 'text-color': '#9aa5ad', 'text-halo-color': WHITE_HALO }],
|
||||
[/^(airport|label_country|label_state)/, { 'text-color': '#8a99a4', 'text-halo-color': WHITE_HALO }],
|
||||
[/^label_/, { 'text-color': '#697b89', 'text-halo-color': WHITE_HALO }],
|
||||
],
|
||||
dark: [
|
||||
[/^background$/, { 'background-color': '#0e0e0e' }],
|
||||
[/^water$/, { 'fill-color': '#2c353c' }],
|
||||
[/^waterway$/, { 'line-color': 'rgb(63, 90, 109)' }],
|
||||
[/^(landcover_(ice_shelf|glacier)|road_area_pier)$/, { 'fill-color': '#0e0e0e' }],
|
||||
[/^landuse_residential$/, { 'fill-color': 'rgba(0, 0, 0, 0.5)' }],
|
||||
[/^(landcover_wood|landuse_park)$/, { 'fill-color': '#121412' }],
|
||||
[/^building$/, { 'fill-color': '#1a1a1a' }],
|
||||
[/^(road_pier|railway.*dashline)$/, { 'line-color': '#0e0e0e' }],
|
||||
[/^highway_path$|_casing$/, { 'line-color': '#232323' }],
|
||||
[/^highway_minor$/, { 'line-color': 'rgba(65, 71, 88, 0.7)' }],
|
||||
[/^highway_major_inner$/, { 'line-color': 'rgb(65, 71, 88)' }],
|
||||
[/^highway_motorway_inner$/, { 'line-color': 'rgb(83, 86, 102)' }],
|
||||
[/_subtle$/, { 'line-color': 'rgba(65, 71, 88, 0.6)' }],
|
||||
[/^railway/, { 'line-color': '#262626' }],
|
||||
[/^boundary_/, { 'line-color': 'rgb(96, 96, 96)' }],
|
||||
[/^water_name$/, { 'text-color': 'rgb(109, 123, 129)', 'text-halo-color': BLACK_HALO }],
|
||||
[/^highway_name/, { 'text-color': 'rgb(120, 126, 137)', 'text-halo-color': BLACK_HALO }],
|
||||
[/^place_(country|state)/, { 'text-color': 'rgb(118, 126, 137)' }],
|
||||
[/^place_/, { 'text-color': 'rgb(204, 208, 228)' }],
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Class definition
|
||||
*
|
||||
* Wraps MapLibre GL JS (https://maplibre.org). It uses `window.maplibregl`
|
||||
* when a page has loaded the library itself, and otherwise imports the module
|
||||
* the `library` option points at, so the map is created a moment later.
|
||||
*/
|
||||
|
||||
class MapView extends BaseComponent {
|
||||
declare _element: HTMLElement
|
||||
declare _config: ComponentConfig
|
||||
_map: MapInstance | null = null
|
||||
_markers: MapMarkerInstance[] = []
|
||||
_themeObserver: MutationObserver | null = null
|
||||
_readyCallbacks: ((map: MapInstance) => void)[] = []
|
||||
_styleCallbacks: ((map: MapInstance) => void)[] = []
|
||||
|
||||
constructor(element: ElementSelector, config?: ComponentConfigInput) {
|
||||
super(element, config)
|
||||
|
||||
if (!this._element) {
|
||||
return
|
||||
}
|
||||
|
||||
const { library } = this._config
|
||||
|
||||
if (window.maplibregl) {
|
||||
this._initMap(window.maplibregl)
|
||||
} else if (library) {
|
||||
// The specifier is only known at run time, so the bundler must leave this import alone
|
||||
import(/* @vite-ignore */ library).then(
|
||||
(module: MapLibrary) => {
|
||||
// Scripts on the page reach the library the same way as when the page loads it
|
||||
window.maplibregl ??= module
|
||||
|
||||
// Disposed while the library was loading
|
||||
if (this._element) {
|
||||
this._initMap(window.maplibregl)
|
||||
}
|
||||
},
|
||||
() => console.warn(`Tabler map: could not load MapLibre GL JS from "${library}". Set window.maplibregl or the "library" option.`),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Getters
|
||||
static get Default(): ComponentConfig {
|
||||
return Default
|
||||
}
|
||||
|
||||
static get DefaultType(): Record<keyof ComponentConfig, string> {
|
||||
return DefaultType
|
||||
}
|
||||
|
||||
static get NAME(): string {
|
||||
return NAME
|
||||
}
|
||||
|
||||
/** The MapLibre GL JS map, for everything the component does not expose. */
|
||||
get map(): MapInstance | null {
|
||||
return this._map
|
||||
}
|
||||
|
||||
/** The markers created from the child elements, in document order. */
|
||||
get markers(): MapMarkerInstance[] {
|
||||
return this._markers
|
||||
}
|
||||
|
||||
// Static
|
||||
|
||||
/**
|
||||
* Turns a CSS color, or a custom property such as `--tblr-primary`, into a
|
||||
* plain `rgb()` color. MapLibre draws layers on a canvas, so it accepts
|
||||
* neither CSS variables nor the `oklch()` values Tabler colors are written in.
|
||||
*/
|
||||
static color(value: string, element: HTMLElement = document.body): string {
|
||||
const context = document.createElement('canvas').getContext('2d')
|
||||
const probe = element.appendChild(document.createElement('span'))
|
||||
|
||||
probe.style.color = value.startsWith('--') ? `var(${value})` : value
|
||||
|
||||
const { color } = getComputedStyle(probe)
|
||||
|
||||
probe.remove()
|
||||
|
||||
if (!context) {
|
||||
return color
|
||||
}
|
||||
|
||||
context.fillStyle = color
|
||||
context.fillRect(0, 0, 1, 1)
|
||||
|
||||
const [red, green, blue] = context.getImageData(0, 0, 1, 1).data
|
||||
|
||||
return `rgb(${red}, ${green}, ${blue})`
|
||||
}
|
||||
|
||||
// Public
|
||||
|
||||
/** Runs the callback once the MapLibre map exists: right away, or after the library has loaded. */
|
||||
ready(callback: (map: MapInstance) => void): void {
|
||||
if (this._map) {
|
||||
callback(this._map)
|
||||
} else {
|
||||
this._readyCallbacks.push(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the callback each time a map style loads, and right away if one already has.
|
||||
* Layers are part of the style, so this is the place to add them.
|
||||
*/
|
||||
onStyle(callback: (map: MapInstance) => void): void {
|
||||
this._styleCallbacks.push(callback)
|
||||
|
||||
if (this._map?.isStyleLoaded()) {
|
||||
callback(this._map)
|
||||
}
|
||||
}
|
||||
|
||||
zoomIn(): void {
|
||||
this._map?.zoomIn()
|
||||
}
|
||||
|
||||
zoomOut(): void {
|
||||
this._map?.zoomOut()
|
||||
}
|
||||
|
||||
/** Turns the map back to north up, with no tilt. */
|
||||
resetNorth(): void {
|
||||
this._map?.resetNorthPitch()
|
||||
}
|
||||
|
||||
/** Moves the map to the position of the device. The browser asks for permission first. */
|
||||
locate(): void {
|
||||
navigator.geolocation?.getCurrentPosition(({ coords }) => {
|
||||
this._map?.flyTo({ center: [coords.longitude, coords.latitude], zoom: Math.max(this._map.getZoom(), LOCATE_ZOOM) })
|
||||
})
|
||||
}
|
||||
|
||||
toggleFullscreen(): void {
|
||||
if (document.fullscreenElement) {
|
||||
void document.exitFullscreen()
|
||||
} else {
|
||||
void this._element.requestFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._themeObserver?.disconnect()
|
||||
this._themeObserver = null
|
||||
|
||||
for (const marker of this._markers) {
|
||||
marker.remove()
|
||||
}
|
||||
|
||||
this._markers = []
|
||||
this._readyCallbacks = []
|
||||
this._styleCallbacks = []
|
||||
this._map?.remove()
|
||||
this._map = null
|
||||
|
||||
super.dispose()
|
||||
}
|
||||
|
||||
// Private
|
||||
_initMap(library: MapLibrary): void {
|
||||
const { center, zoom, controls, globe, mapOptions } = this._config
|
||||
// The markers are read before MapLibre takes over the element
|
||||
const markerElements = [...this._element.querySelectorAll<HTMLElement>(SELECTOR_MARKER)]
|
||||
|
||||
const map = new library.Map({
|
||||
container: this._element,
|
||||
style: this._getStyle(),
|
||||
center: this._parseLngLat(center),
|
||||
zoom,
|
||||
...mapOptions,
|
||||
})
|
||||
|
||||
this._map = map
|
||||
|
||||
// The palette, the layers and the projection are part of the style, so they
|
||||
// are set again each time a style loads, for example after a theme change.
|
||||
map.on('style.load', () => {
|
||||
this._applyPalette()
|
||||
this._addLayers()
|
||||
|
||||
if (globe) {
|
||||
map.setProjection({ type: 'globe' })
|
||||
}
|
||||
|
||||
for (const callback of this._styleCallbacks) {
|
||||
callback(map)
|
||||
}
|
||||
})
|
||||
|
||||
map.on('load', () => {
|
||||
EventHandler.trigger(this._element, EVENT_LOAD)
|
||||
})
|
||||
|
||||
if (controls) {
|
||||
this._createControls(controls)
|
||||
}
|
||||
|
||||
this._markers = markerElements.map((element) => this._createMarker(library, map, element))
|
||||
this._setupThemeObserver()
|
||||
|
||||
for (const callback of this._readyCallbacks.splice(0)) {
|
||||
callback(map)
|
||||
}
|
||||
}
|
||||
|
||||
// Tabler buttons over the map. The icons come from the stylesheet, by action.
|
||||
_createControls(controls: true | string): void {
|
||||
const container = document.createElement('div')
|
||||
|
||||
container.className = CLASS_NAME_CONTROLS
|
||||
|
||||
for (const name of controls === true ? ['zoom'] : controls.split(',')) {
|
||||
const buttons = Controls[name.trim()] ?? []
|
||||
const group = buttons.length > 1 ? container.appendChild(document.createElement('div')) : container
|
||||
|
||||
if (group !== container) {
|
||||
group.className = CLASS_NAME_CONTROL_GROUP
|
||||
}
|
||||
|
||||
for (const action of buttons) {
|
||||
const button = group.appendChild(document.createElement('button'))
|
||||
|
||||
button.type = 'button'
|
||||
button.className = CLASS_NAME_CONTROL
|
||||
button.setAttribute('data-bs-map-action', action)
|
||||
button.setAttribute('aria-label', this._config.labels[action] ?? Labels[action])
|
||||
}
|
||||
}
|
||||
|
||||
this._element.append(container)
|
||||
}
|
||||
|
||||
_createMarker(library: MapLibrary, map: MapInstance, element: HTMLElement): MapMarkerInstance {
|
||||
const { lng, lat, anchor = 'center', draggable = false, popup: popupText, popupOpen = false } = Manipulator.getDataAttributes(element)
|
||||
const template = element.querySelector<HTMLTemplateElement>(':scope > template')
|
||||
const marker = new library.Marker({ element, anchor, draggable }).setLngLat([Number(lng), Number(lat)])
|
||||
|
||||
// A data attribute is a string, a number or a boolean; anything else is no popup text
|
||||
const text = typeof popupText === 'object' ? '' : String(popupText ?? '')
|
||||
|
||||
if (template || text) {
|
||||
const popup = new library.Popup({
|
||||
offset: anchor === 'bottom' ? POPUP_OFFSET_PIN : POPUP_OFFSET,
|
||||
className: template?.className ?? '',
|
||||
closeButton: Boolean(template && Manipulator.getDataAttribute(template, 'close-button')),
|
||||
maxWidth: 'none',
|
||||
// A popup that is open from the start must not move the focus to itself
|
||||
focusAfterOpen: !popupOpen,
|
||||
})
|
||||
|
||||
if (template) {
|
||||
popup.setDOMContent(template.content)
|
||||
template.remove()
|
||||
} else {
|
||||
popup.setText(text)
|
||||
}
|
||||
|
||||
marker.setPopup(popup)
|
||||
}
|
||||
|
||||
marker.addTo(map)
|
||||
|
||||
if (popupOpen && (template || text)) {
|
||||
marker.togglePopup()
|
||||
}
|
||||
|
||||
return marker
|
||||
}
|
||||
|
||||
_parseLngLat(value: LngLat | string): LngLat {
|
||||
const [lng = 0, lat = 0] = (typeof value === 'string' ? value.split(',') : value).map(Number)
|
||||
|
||||
return [lng, lat]
|
||||
}
|
||||
|
||||
_isDark(): boolean {
|
||||
const { mapTheme } = this._config
|
||||
|
||||
if (mapTheme !== 'auto') {
|
||||
return mapTheme === 'dark'
|
||||
}
|
||||
|
||||
return this._element.closest(SELECTOR_THEME)?.getAttribute('data-bs-theme') === 'dark'
|
||||
}
|
||||
|
||||
_getStyleName(): string {
|
||||
const { mapStyle, mapStyleDark } = this._config
|
||||
|
||||
return this._isDark() && mapStyleDark ? mapStyleDark : mapStyle
|
||||
}
|
||||
|
||||
_getStyle(): string {
|
||||
const style = this._getStyleName()
|
||||
|
||||
// A bare name is an OpenFreeMap style; anything else is a URL
|
||||
return /^[\w-]+$/.test(style) ? `${STYLE_URL}${style}` : style
|
||||
}
|
||||
|
||||
_applyPalette(): void {
|
||||
const style = this._getStyleName()
|
||||
const rules = this._config.palette && (style === 'positron' ? Palette.light : style === 'dark' ? Palette.dark : null)
|
||||
|
||||
if (!rules || !this._map) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const { id } of this._map.getStyle().layers) {
|
||||
const rule = rules.find(([pattern]) => pattern.test(id))
|
||||
|
||||
for (const [property, value] of Object.entries(rule?.[1] ?? {})) {
|
||||
this._map.setPaintProperty(id, property, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The `route` and `geojson` options, drawn in the layer color
|
||||
_addLayers(): void {
|
||||
const { route, routeProgress, geojson, layerColor } = this._config
|
||||
const map = this._map
|
||||
|
||||
if (!map || (!route && !geojson)) {
|
||||
return
|
||||
}
|
||||
|
||||
const color = MapView.color(layerColor, this._element)
|
||||
const layout = { 'line-cap': 'round', 'line-join': 'round' }
|
||||
|
||||
if (geojson) {
|
||||
map.addSource(SOURCE_DATA, { type: 'geojson', data: geojson })
|
||||
map.addLayer({ id: `${SOURCE_DATA}-fill`, type: 'fill', source: SOURCE_DATA, filter: ['==', '$type', 'Polygon'], paint: { 'fill-color': color, 'fill-opacity': 0.16 } })
|
||||
map.addLayer({ id: `${SOURCE_DATA}-line`, type: 'line', source: SOURCE_DATA, filter: ['!=', '$type', 'Point'], layout, paint: { 'line-color': color, 'line-width': 2 } })
|
||||
map.addLayer({
|
||||
id: `${SOURCE_DATA}-point`,
|
||||
type: 'circle',
|
||||
source: SOURCE_DATA,
|
||||
filter: ['==', '$type', 'Point'],
|
||||
paint: { 'circle-color': color, 'circle-radius': 5, 'circle-stroke-width': 2, 'circle-stroke-color': MapView.color('--tblr-bg-surface', this._element) },
|
||||
})
|
||||
}
|
||||
|
||||
if (route) {
|
||||
const line = (coordinates: LngLat[]): Record<string, unknown> => ({ type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates } } })
|
||||
const reached = routeProgress === null ? route.length : routeProgress + 1
|
||||
|
||||
// The part still ahead is dashed, and added first so the solid part covers its start
|
||||
if (reached < route.length) {
|
||||
map.addSource(SOURCE_ROUTE_REST, line(route.slice(reached - 1)))
|
||||
map.addLayer({ id: SOURCE_ROUTE_REST, type: 'line', source: SOURCE_ROUTE_REST, paint: { 'line-color': color, 'line-width': 3, 'line-opacity': 0.6, 'line-dasharray': [1, 2] } })
|
||||
}
|
||||
|
||||
map.addSource(SOURCE_ROUTE, line(route.slice(0, reached)))
|
||||
map.addLayer({ id: SOURCE_ROUTE, type: 'line', source: SOURCE_ROUTE, layout, paint: { 'line-color': color, 'line-width': 3, 'line-opacity': routeProgress === null ? 0.8 : 1 } })
|
||||
}
|
||||
}
|
||||
|
||||
_setupThemeObserver(): void {
|
||||
// Watch the root element as well: `data-bs-theme` may be added to it
|
||||
// later, for example by the theme switcher.
|
||||
const ancestor = this._element.closest(SELECTOR_THEME) ?? document.documentElement
|
||||
|
||||
if (this._config.mapTheme !== 'auto') {
|
||||
return
|
||||
}
|
||||
|
||||
this._themeObserver = new MutationObserver(() => {
|
||||
this._map?.setStyle(this._getStyle())
|
||||
})
|
||||
|
||||
this._themeObserver.observe(ancestor, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-bs-theme'],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data API implementation
|
||||
*/
|
||||
|
||||
// js-docs-start map-init
|
||||
initAll(SELECTOR_DATA_TOGGLE, MapView)
|
||||
|
||||
// A button with `data-bs-map-action` acts on the map it is in, or on the map
|
||||
// its `data-bs-target` points at.
|
||||
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_ACTION, function (this: HTMLElement) {
|
||||
const element = SelectorEngine.getElementFromSelector(this) ?? this.closest<HTMLElement>(SELECTOR_DATA_TOGGLE)
|
||||
const view = element && (MapView.getInstance(element) as MapView | null)
|
||||
const actions: Record<string, () => void> = {
|
||||
'zoom-in': () => view?.zoomIn(),
|
||||
'zoom-out': () => view?.zoomOut(),
|
||||
'north': () => view?.resetNorth(),
|
||||
'locate': () => view?.locate(),
|
||||
'fullscreen': () => view?.toggleFullscreen(),
|
||||
}
|
||||
|
||||
actions[this.getAttribute('data-bs-map-action') ?? this.getAttribute('data-tblr-map-action') ?? '']?.()
|
||||
})
|
||||
// js-docs-end map-init
|
||||
|
||||
export default MapView
|
||||
export type { ComponentConfig as MapConfig, MapInstance, MapLibrary, MapMarkerInstance }
|
||||
@@ -0,0 +1,6 @@
|
||||
// The map component ships as its own bundle, so pages without a map do not
|
||||
// carry it. Load it after `tabler.js`: the UMD build adds `MapView` to the
|
||||
// `tabler` global.
|
||||
import './src/map'
|
||||
|
||||
export { default as MapView } from './src/map'
|
||||
@@ -0,0 +1,637 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { clearFixture, getFixture } from '../helpers/fixture'
|
||||
import MapView from '../../src/map'
|
||||
import type { MapLibrary } from '../../src/map'
|
||||
|
||||
// A stand-in for MapLibre GL JS: it records what the component asks for,
|
||||
// so the tests need no WebGL and no network.
|
||||
type Listener = () => void
|
||||
|
||||
class FakeMap {
|
||||
static last: FakeMap
|
||||
options: Record<string, unknown>
|
||||
listeners: Record<string, Listener[]> = {}
|
||||
styles: string[] = []
|
||||
paint: Record<string, Record<string, string>> = {}
|
||||
calls: string[] = []
|
||||
sources: Record<string, Record<string, unknown>> = {}
|
||||
addedLayers: Record<string, unknown>[] = []
|
||||
flownTo: { center: [number, number]; zoom: number } | null = null
|
||||
projection: { type: string } | null = null
|
||||
removed = false
|
||||
layers = [{ id: 'background' }, { id: 'water' }, { id: 'label_city' }, { id: 'place_city' }, { id: 'unknown_layer' }]
|
||||
|
||||
constructor(options: Record<string, unknown>) {
|
||||
this.options = options
|
||||
this.styles.push(options.style as string)
|
||||
FakeMap.last = this
|
||||
}
|
||||
|
||||
on(type: string, listener: Listener): this {
|
||||
;(this.listeners[type] ??= []).push(listener)
|
||||
return this
|
||||
}
|
||||
|
||||
fire(type: string): void {
|
||||
for (const listener of this.listeners[type] ?? []) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
loaded(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
styleLoaded = false
|
||||
|
||||
isStyleLoaded(): boolean {
|
||||
return this.styleLoaded
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
this.removed = true
|
||||
}
|
||||
|
||||
setStyle(style: string): void {
|
||||
this.styles.push(style)
|
||||
}
|
||||
|
||||
getStyle(): { layers: { id: string }[] } {
|
||||
return { layers: this.layers }
|
||||
}
|
||||
|
||||
setPaintProperty(layer: string, name: string, value: string): void {
|
||||
;(this.paint[layer] ??= {})[name] = value
|
||||
}
|
||||
|
||||
setProjection(projection: { type: string }): void {
|
||||
this.projection = projection
|
||||
}
|
||||
|
||||
zoomIn(): void {
|
||||
this.calls.push('zoomIn')
|
||||
}
|
||||
|
||||
zoomOut(): void {
|
||||
this.calls.push('zoomOut')
|
||||
}
|
||||
|
||||
resetNorthPitch(): void {
|
||||
this.calls.push('resetNorthPitch')
|
||||
}
|
||||
|
||||
addSource(id: string, source: Record<string, unknown>): void {
|
||||
this.sources[id] = source
|
||||
}
|
||||
|
||||
addLayer(layer: Record<string, unknown>): void {
|
||||
this.addedLayers.push(layer)
|
||||
}
|
||||
|
||||
getZoom(): number {
|
||||
return this.options.zoom as number
|
||||
}
|
||||
|
||||
flyTo(options: { center: [number, number]; zoom: number }): void {
|
||||
this.flownTo = options
|
||||
}
|
||||
}
|
||||
|
||||
class FakePopup {
|
||||
options: Record<string, unknown>
|
||||
content: Node | null = null
|
||||
text: string | null = null
|
||||
|
||||
constructor(options: Record<string, unknown>) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
setDOMContent(node: Node): this {
|
||||
this.content = node
|
||||
return this
|
||||
}
|
||||
|
||||
setText(text: string): this {
|
||||
this.text = text
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMarker {
|
||||
static all: FakeMarker[] = []
|
||||
options: Record<string, unknown>
|
||||
lngLat: [number, number] | null = null
|
||||
popup: FakePopup | null = null
|
||||
popupToggled = 0
|
||||
removed = false
|
||||
|
||||
constructor(options: Record<string, unknown>) {
|
||||
this.options = options
|
||||
FakeMarker.all.push(this)
|
||||
}
|
||||
|
||||
setLngLat(lngLat: [number, number]): this {
|
||||
this.lngLat = lngLat
|
||||
return this
|
||||
}
|
||||
|
||||
setPopup(popup: unknown): this {
|
||||
this.popup = popup as FakePopup
|
||||
return this
|
||||
}
|
||||
|
||||
addTo(): this {
|
||||
return this
|
||||
}
|
||||
|
||||
togglePopup(): this {
|
||||
this.popupToggled++
|
||||
return this
|
||||
}
|
||||
|
||||
getElement(): HTMLElement {
|
||||
return this.options.element as HTMLElement
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
this.removed = true
|
||||
}
|
||||
}
|
||||
|
||||
const library = { Map: FakeMap, Marker: FakeMarker, Popup: FakePopup } as unknown as MapLibrary
|
||||
|
||||
describe('MapView', () => {
|
||||
let fixtureEl: HTMLElement
|
||||
|
||||
beforeAll(() => {
|
||||
fixtureEl = getFixture()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
window.maplibregl = library
|
||||
FakeMarker.all = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.maplibregl
|
||||
document.documentElement.removeAttribute('data-bs-theme')
|
||||
clearFixture()
|
||||
})
|
||||
|
||||
const mount = (markup: string): HTMLElement => {
|
||||
fixtureEl.innerHTML = markup
|
||||
return fixtureEl.firstElementChild as HTMLElement
|
||||
}
|
||||
|
||||
describe('NAME', () => {
|
||||
it('should return plugin name', () => {
|
||||
expect(MapView.NAME).toBe('map')
|
||||
})
|
||||
})
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should stay inert without the library and with loading turned off', () => {
|
||||
delete window.maplibregl
|
||||
|
||||
const view = new MapView(mount('<div></div>'), { library: null })
|
||||
|
||||
expect(view.map).toBeNull()
|
||||
expect(view.markers).toEqual([])
|
||||
})
|
||||
|
||||
it('should load the library from the library option and then create the map', async () => {
|
||||
delete window.maplibregl
|
||||
;(window as unknown as { fakeMapLibrary: MapLibrary }).fakeMapLibrary = library
|
||||
|
||||
const element = mount('<div data-bs-zoom="7"><span data-bs-lng="1" data-bs-lat="2"></span></div>')
|
||||
const view = new MapView(element, { library: 'data:text/javascript,export const { Map, Marker, Popup } = window.fakeMapLibrary' })
|
||||
const ready = new Promise((resolve) => view.ready(resolve))
|
||||
|
||||
expect(view.map).toBeNull()
|
||||
|
||||
await ready
|
||||
|
||||
expect(view.map).toBe(FakeMap.last)
|
||||
expect(FakeMap.last.options.zoom).toBe(7)
|
||||
expect(view.markers).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should warn when the library cannot be loaded', async () => {
|
||||
delete window.maplibregl
|
||||
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const view = new MapView(mount('<div></div>'), { library: 'data:text/javascript,throw new Error("no library")' })
|
||||
|
||||
await vi.waitFor(() => expect(warn).toHaveBeenCalled())
|
||||
|
||||
expect(view.map).toBeNull()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('should create a map with the default style and the options from data attributes', () => {
|
||||
const element = mount('<div data-bs-center="13.405,52.52" data-bs-zoom="12"></div>')
|
||||
const view = new MapView(element)
|
||||
|
||||
expect(view.map).toBe(FakeMap.last)
|
||||
expect(FakeMap.last.options.container).toBe(element)
|
||||
expect(FakeMap.last.options.center).toEqual([13.405, 52.52])
|
||||
expect(FakeMap.last.options.zoom).toBe(12)
|
||||
expect(FakeMap.last.options.style).toBe('https://tiles.openfreemap.org/styles/positron')
|
||||
})
|
||||
|
||||
it('should accept the center as an array and pass map options through', () => {
|
||||
new MapView(mount('<div></div>'), { center: [1, 2], mapOptions: { pitch: 30 } })
|
||||
|
||||
expect(FakeMap.last.options.center).toEqual([1, 2])
|
||||
expect(FakeMap.last.options.pitch).toBe(30)
|
||||
})
|
||||
|
||||
it('should use a style URL as it is', () => {
|
||||
new MapView(mount('<div></div>'), { mapStyle: 'https://example.com/style.json' })
|
||||
|
||||
expect(FakeMap.last.options.style).toBe('https://example.com/style.json')
|
||||
})
|
||||
|
||||
it('should fire the load event when the map has loaded', () => {
|
||||
const element = mount('<div></div>')
|
||||
let loaded = 0
|
||||
|
||||
element.addEventListener('load.bs.map', () => loaded++)
|
||||
new MapView(element)
|
||||
FakeMap.last.fire('load')
|
||||
|
||||
expect(loaded).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('theme', () => {
|
||||
it('should start with the dark style under a dark theme', () => {
|
||||
document.documentElement.setAttribute('data-bs-theme', 'dark')
|
||||
new MapView(mount('<div></div>'))
|
||||
|
||||
expect(FakeMap.last.options.style).toBe('https://tiles.openfreemap.org/styles/dark')
|
||||
})
|
||||
|
||||
it('should change the style when the theme changes', async () => {
|
||||
new MapView(mount('<div></div>'))
|
||||
|
||||
document.documentElement.setAttribute('data-bs-theme', 'dark')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(FakeMap.last.styles.at(-1)).toBe('https://tiles.openfreemap.org/styles/dark')
|
||||
|
||||
document.documentElement.removeAttribute('data-bs-theme')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(FakeMap.last.styles.at(-1)).toBe('https://tiles.openfreemap.org/styles/positron')
|
||||
})
|
||||
|
||||
it('should keep a fixed theme', async () => {
|
||||
new MapView(mount('<div data-bs-map-theme="dark"></div>'))
|
||||
|
||||
expect(FakeMap.last.options.style).toBe('https://tiles.openfreemap.org/styles/dark')
|
||||
|
||||
document.documentElement.setAttribute('data-bs-theme', 'light')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(FakeMap.last.styles).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should keep one style in both modes when the dark style is off', () => {
|
||||
document.documentElement.setAttribute('data-bs-theme', 'dark')
|
||||
new MapView(mount('<div></div>'), { mapStyle: 'liberty', mapStyleDark: null })
|
||||
|
||||
expect(FakeMap.last.options.style).toBe('https://tiles.openfreemap.org/styles/liberty')
|
||||
})
|
||||
})
|
||||
|
||||
describe('style load', () => {
|
||||
it('should recolor the layers of the default style', () => {
|
||||
new MapView(mount('<div></div>'))
|
||||
FakeMap.last.fire('style.load')
|
||||
|
||||
expect(FakeMap.last.paint.background).toEqual({ 'background-color': '#fafaf8' })
|
||||
expect(FakeMap.last.paint.water).toEqual({ 'fill-color': '#d4dadc' })
|
||||
expect(FakeMap.last.paint.label_city?.['text-color']).toBe('#697b89')
|
||||
expect(FakeMap.last.paint.unknown_layer).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should use the dark palette for the dark style', () => {
|
||||
document.documentElement.setAttribute('data-bs-theme', 'dark')
|
||||
new MapView(mount('<div></div>'))
|
||||
FakeMap.last.fire('style.load')
|
||||
|
||||
expect(FakeMap.last.paint.background).toEqual({ 'background-color': '#0e0e0e' })
|
||||
expect(FakeMap.last.paint.place_city).toEqual({ 'text-color': 'rgb(204, 208, 228)' })
|
||||
})
|
||||
|
||||
it('should leave other styles and a disabled palette alone', () => {
|
||||
new MapView(mount('<div></div>'), { mapStyle: 'liberty' })
|
||||
FakeMap.last.fire('style.load')
|
||||
expect(FakeMap.last.paint).toEqual({})
|
||||
|
||||
new MapView(mount('<div></div>'), { palette: false })
|
||||
FakeMap.last.fire('style.load')
|
||||
expect(FakeMap.last.paint).toEqual({})
|
||||
})
|
||||
|
||||
it('should set the globe projection each time a style loads', () => {
|
||||
new MapView(mount('<div data-bs-globe="true"></div>'))
|
||||
|
||||
expect(FakeMap.last.projection).toBeNull()
|
||||
|
||||
FakeMap.last.fire('style.load')
|
||||
|
||||
expect(FakeMap.last.projection).toEqual({ type: 'globe' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('markers', () => {
|
||||
it('should turn child elements with coordinates into markers', () => {
|
||||
const element = mount(`
|
||||
<div>
|
||||
<span class="map-marker" data-bs-lng="13.4" data-bs-lat="52.5"></span>
|
||||
<span class="map-marker map-marker-pin" data-bs-lng="2.35" data-bs-lat="48.85" data-bs-anchor="bottom" data-bs-draggable="true"></span>
|
||||
<p>Not a marker</p>
|
||||
</div>`)
|
||||
const view = new MapView(element)
|
||||
|
||||
expect(view.markers).toHaveLength(2)
|
||||
expect(FakeMarker.all[0]?.lngLat).toEqual([13.4, 52.5])
|
||||
expect(FakeMarker.all[0]?.options).toMatchObject({ anchor: 'center', draggable: false })
|
||||
expect(FakeMarker.all[1]?.options).toMatchObject({ anchor: 'bottom', draggable: true })
|
||||
expect(FakeMarker.all[1]?.options.element).toBe(element.querySelector('.map-marker-pin'))
|
||||
expect(FakeMarker.all[0]?.popup).toBeNull()
|
||||
})
|
||||
|
||||
it('should build a text popup from the popup attribute', () => {
|
||||
new MapView(mount('<div><span data-bs-lng="1" data-bs-lat="2" data-bs-popup="Vienna"></span></div>'))
|
||||
|
||||
const popup = FakeMarker.all[0]?.popup
|
||||
|
||||
expect(popup?.text).toBe('Vienna')
|
||||
expect(popup?.options).toMatchObject({ offset: 16, closeButton: false, focusAfterOpen: true })
|
||||
expect(FakeMarker.all[0]?.popupToggled).toBe(0)
|
||||
})
|
||||
|
||||
it('should build a popup from a template and open it when asked', () => {
|
||||
const element = mount(`
|
||||
<div>
|
||||
<span data-bs-lng="1" data-bs-lat="2" data-bs-anchor="bottom" data-bs-popup-open="true">
|
||||
<template class="map-popup-card" data-bs-close-button="true"><strong>Museum</strong></template>
|
||||
</span>
|
||||
</div>`)
|
||||
|
||||
new MapView(element)
|
||||
|
||||
const marker = FakeMarker.all[0]
|
||||
|
||||
expect(marker?.popup?.options).toMatchObject({ offset: 40, className: 'map-popup-card', closeButton: true, focusAfterOpen: false })
|
||||
expect((marker?.popup?.content as DocumentFragment).querySelector('strong')?.textContent).toBe('Museum')
|
||||
expect(marker?.popupToggled).toBe(1)
|
||||
expect(element.querySelector('template')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('controls', () => {
|
||||
const actions = (element: HTMLElement): (string | null)[] => [...element.querySelectorAll('.map-controls button')].map((button) => button.getAttribute('data-bs-map-action'))
|
||||
|
||||
it('should add no buttons by default', () => {
|
||||
const element = mount('<div></div>')
|
||||
|
||||
new MapView(element)
|
||||
|
||||
expect(element.querySelector('.map-controls')).toBeNull()
|
||||
})
|
||||
|
||||
it('should add the zoom buttons as a group', () => {
|
||||
const element = mount('<div data-bs-controls="true"></div>')
|
||||
|
||||
new MapView(element)
|
||||
|
||||
expect(actions(element)).toEqual(['zoom-in', 'zoom-out'])
|
||||
expect(element.querySelectorAll('.map-controls > .btn-group-vertical > .btn.btn-sm.btn-icon')).toHaveLength(2)
|
||||
expect(element.querySelector('[data-bs-map-action="zoom-in"]')?.getAttribute('aria-label')).toBe('Zoom in')
|
||||
expect((element.querySelector('.map-controls button') as HTMLButtonElement).type).toBe('button')
|
||||
})
|
||||
|
||||
it('should add the buttons named in a list and skip unknown names', () => {
|
||||
const element = mount('<div data-bs-controls="zoom, north,locate,fullscreen,unknown"></div>')
|
||||
|
||||
new MapView(element)
|
||||
|
||||
expect(actions(element)).toEqual(['zoom-in', 'zoom-out', 'north', 'locate', 'fullscreen'])
|
||||
expect(element.querySelectorAll('.map-controls > .btn')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should use the labels from the options', () => {
|
||||
const element = mount('<div></div>')
|
||||
|
||||
new MapView(element, { controls: 'zoom', labels: { 'zoom-in': 'Przybliż' } })
|
||||
|
||||
expect(element.querySelector('[data-bs-map-action="zoom-in"]')?.getAttribute('aria-label')).toBe('Przybliż')
|
||||
expect(element.querySelector('[data-bs-map-action="zoom-out"]')?.getAttribute('aria-label')).toBe('Zoom out')
|
||||
})
|
||||
|
||||
it('should run the action of a clicked button on its map', () => {
|
||||
const element = mount('<div data-bs-toggle="map" data-bs-controls="zoom,north"></div>')
|
||||
|
||||
new MapView(element)
|
||||
|
||||
for (const action of ['zoom-in', 'zoom-out', 'north']) {
|
||||
element.querySelector<HTMLElement>(`[data-bs-map-action="${action}"]`)!.click()
|
||||
}
|
||||
|
||||
expect(FakeMap.last.calls).toEqual(['zoomIn', 'zoomOut', 'resetNorthPitch'])
|
||||
})
|
||||
|
||||
it('should let a button outside the map point at it', () => {
|
||||
fixtureEl.innerHTML = '<div id="target-map" data-bs-toggle="map"></div><button type="button" data-bs-map-action="zoom-in" data-bs-target="#target-map"></button>'
|
||||
new MapView(fixtureEl.querySelector<HTMLElement>('#target-map')!)
|
||||
|
||||
fixtureEl.querySelector('button')!.click()
|
||||
|
||||
expect(FakeMap.last.calls).toEqual(['zoomIn'])
|
||||
})
|
||||
|
||||
it('should ignore a button with no map', () => {
|
||||
fixtureEl.innerHTML = '<button type="button" data-bs-map-action="zoom-in"></button>'
|
||||
|
||||
expect(() => fixtureEl.querySelector('button')!.click()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should fly to the position of the device, at least at street level', () => {
|
||||
const view = new MapView(mount('<div data-bs-zoom="4"></div>'))
|
||||
const original = navigator.geolocation.getCurrentPosition.bind(navigator.geolocation)
|
||||
|
||||
navigator.geolocation.getCurrentPosition = (success) => success({ coords: { longitude: 21.01, latitude: 52.23 } } as GeolocationPosition)
|
||||
view.locate()
|
||||
navigator.geolocation.getCurrentPosition = original
|
||||
|
||||
expect(FakeMap.last.flownTo).toEqual({ center: [21.01, 52.23], zoom: 14 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks', () => {
|
||||
it('should run a ready callback right away when the map exists', () => {
|
||||
const view = new MapView(mount('<div></div>'))
|
||||
let received: unknown = null
|
||||
|
||||
view.ready((map) => (received = map))
|
||||
|
||||
expect(received).toBe(FakeMap.last)
|
||||
})
|
||||
|
||||
it('should run a style callback on each style load', () => {
|
||||
const view = new MapView(mount('<div></div>'))
|
||||
let runs = 0
|
||||
|
||||
view.onStyle(() => runs++)
|
||||
|
||||
expect(runs).toBe(0)
|
||||
|
||||
FakeMap.last.fire('style.load')
|
||||
FakeMap.last.fire('style.load')
|
||||
|
||||
expect(runs).toBe(2)
|
||||
})
|
||||
|
||||
it('should run a style callback right away when a style has loaded already', () => {
|
||||
const view = new MapView(mount('<div></div>'))
|
||||
let runs = 0
|
||||
|
||||
FakeMap.last.styleLoaded = true
|
||||
view.onStyle(() => runs++)
|
||||
|
||||
expect(runs).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('layers', () => {
|
||||
it('should add nothing without a route or GeoJSON', () => {
|
||||
new MapView(mount('<div></div>'))
|
||||
FakeMap.last.fire('style.load')
|
||||
|
||||
expect(FakeMap.last.sources).toEqual({})
|
||||
expect(FakeMap.last.addedLayers).toEqual([])
|
||||
})
|
||||
|
||||
it('should draw the route as a line each time a style loads', () => {
|
||||
new MapView(mount('<div data-bs-route="[[13.3,52.5],[13.4,52.6]]"></div>'))
|
||||
FakeMap.last.fire('style.load')
|
||||
|
||||
expect(FakeMap.last.sources['tabler-route']).toEqual({
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[13.3, 52.5],
|
||||
[13.4, 52.6],
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(FakeMap.last.addedLayers).toHaveLength(1)
|
||||
expect(FakeMap.last.addedLayers[0]).toMatchObject({ id: 'tabler-route', type: 'line', source: 'tabler-route' })
|
||||
|
||||
FakeMap.last.fire('style.load')
|
||||
|
||||
expect(FakeMap.last.addedLayers).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should split the route at the point reached so far', () => {
|
||||
new MapView(mount('<div></div>'), {
|
||||
route: [
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
[4, 4],
|
||||
],
|
||||
routeProgress: 1,
|
||||
})
|
||||
FakeMap.last.fire('style.load')
|
||||
|
||||
const coordinates = (id: string): unknown => (FakeMap.last.sources[id]?.data as { geometry: { coordinates: unknown } }).geometry.coordinates
|
||||
|
||||
expect(coordinates('tabler-route')).toEqual([
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
])
|
||||
expect(coordinates('tabler-route-rest')).toEqual([
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
[4, 4],
|
||||
])
|
||||
expect(FakeMap.last.addedLayers.map((layer) => layer.id)).toEqual(['tabler-route-rest', 'tabler-route'])
|
||||
})
|
||||
|
||||
it('should draw GeoJSON as fill, line and point layers in the layer color', () => {
|
||||
const geojson = {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[0, 0],
|
||||
],
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
new MapView(mount('<div></div>'), { geojson, layerColor: '#ff0000' })
|
||||
FakeMap.last.fire('style.load')
|
||||
|
||||
expect(FakeMap.last.sources['tabler-data']).toEqual({ type: 'geojson', data: geojson })
|
||||
expect(FakeMap.last.addedLayers.map((layer) => layer.type)).toEqual(['fill', 'line', 'circle'])
|
||||
expect(FakeMap.last.addedLayers[0]?.paint).toMatchObject({ 'fill-color': 'rgb(255, 0, 0)' })
|
||||
})
|
||||
|
||||
it('should pass a GeoJSON URL through', () => {
|
||||
new MapView(mount('<div data-bs-geojson="/data/zones.geojson"></div>'))
|
||||
FakeMap.last.fire('style.load')
|
||||
|
||||
expect(FakeMap.last.sources['tabler-data']?.data).toBe('/data/zones.geojson')
|
||||
})
|
||||
})
|
||||
|
||||
describe('color', () => {
|
||||
it('should turn a custom property into an rgb color', () => {
|
||||
document.documentElement.style.setProperty('--test-map-color', 'oklch(54.6% 0.1724 254.2deg)')
|
||||
|
||||
expect(MapView.color('--test-map-color')).toMatch(/^rgb\(\d+, \d+, \d+\)$/)
|
||||
expect(MapView.color('#ff0000')).toBe('rgb(255, 0, 0)')
|
||||
|
||||
document.documentElement.style.removeProperty('--test-map-color')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose', () => {
|
||||
it('should remove the map, the markers and the theme observer', async () => {
|
||||
const view = new MapView(mount('<div><span data-bs-lng="1" data-bs-lat="2"></span></div>'))
|
||||
const map = FakeMap.last
|
||||
|
||||
view.dispose()
|
||||
|
||||
expect(map.removed).toBe(true)
|
||||
expect(FakeMarker.all[0]?.removed).toBe(true)
|
||||
|
||||
document.documentElement.setAttribute('data-bs-theme', 'dark')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(map.styles).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('data api', () => {
|
||||
it('should give the same instance for the same element', () => {
|
||||
const element = mount('<div data-bs-toggle="map"></div>')
|
||||
|
||||
expect(MapView.getOrCreateInstance(element)).toBe(MapView.getOrCreateInstance(element))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -55,6 +55,12 @@
|
||||
"js": ["dist/js/tom-select.base.min.js"],
|
||||
"css": ["dist/css/tom-select.bootstrap5.min.css"]
|
||||
},
|
||||
"maplibre-gl": {
|
||||
"npm": "maplibre-gl",
|
||||
"addon": "tabler-map",
|
||||
"css": ["dist/maplibre-gl.css"],
|
||||
"extra": ["dist/maplibre-gl.mjs", "dist/maplibre-gl-shared.mjs", "dist/maplibre-gl-worker.mjs"]
|
||||
},
|
||||
"jsvectormap": {
|
||||
"npm": "jsvectormap",
|
||||
"js": ["dist/jsvectormap.min.js", "dist/maps/world.js", "dist/maps/world-merc.js"],
|
||||
|
||||
+25
-6
@@ -17,12 +17,14 @@
|
||||
"lint:scss:vars": "find-unused-sass-variables scss/ --ignoreFiles scss/_variables-deprecated.scss",
|
||||
"js": "pnpm run js:build && pnpm run js:min && pnpm run js:types",
|
||||
"js:types": "tsc -p tsconfig.types.json",
|
||||
"js:build": "concurrently \"pnpm run js:build:standalone\" \"pnpm run js:build:theme\"",
|
||||
"js:build": "concurrently \"pnpm run js:build:standalone\" \"pnpm run js:build:theme\" \"pnpm run js:build:map\"",
|
||||
"js:build:theme": "cross-env BASE_NAME=tabler-theme vite build --config .build/vite.config.mts",
|
||||
"js:build:map": "cross-env BASE_NAME=tabler-map vite build --config .build/vite.config.mts",
|
||||
"js:build:standalone": "cross-env BASE_NAME=tabler vite build --config .build/vite.config.mts",
|
||||
"js:min": "concurrently \"pnpm run js:min:standalone\" \"pnpm run js:min:theme\"",
|
||||
"js:min": "concurrently \"pnpm run js:min:standalone\" \"pnpm run js:min:theme\" \"pnpm run js:min:map\"",
|
||||
"js:min:standalone": "concurrently \"terser dist/js/tabler.js --compress passes=2,ecma=2020 --ecma 2020 --mangle --source-map \\\"content=dist/js/tabler.js.map,filename=dist/js/tabler.min.js.map,url=tabler.min.js.map\\\" -o dist/js/tabler.min.js\" \"terser dist/js/tabler.esm.js --module --compress passes=2,ecma=2020 --ecma 2020 --mangle --source-map \\\"content=dist/js/tabler.esm.js.map,filename=dist/js/tabler.esm.min.js.map,url=tabler.esm.min.js.map\\\" -o dist/js/tabler.esm.min.js\"",
|
||||
"js:min:theme": "concurrently \"terser dist/js/tabler-theme.js --compress passes=2,ecma=2020 --ecma 2020 --mangle --source-map \\\"content=dist/js/tabler-theme.js.map,filename=dist/js/tabler-theme.min.js.map,url=tabler-theme.min.js.map\\\" -o dist/js/tabler-theme.min.js\" \"terser dist/js/tabler-theme.esm.js --module --compress passes=2,ecma=2020 --ecma 2020 --mangle --source-map \\\"content=dist/js/tabler-theme.esm.js.map,filename=dist/js/tabler-theme.esm.min.js.map,url=tabler-theme.esm.min.js.map\\\" -o dist/js/tabler-theme.esm.min.js\"",
|
||||
"js:min:map": "concurrently \"terser dist/js/tabler-map.js --compress passes=2,ecma=2020 --ecma 2020 --mangle --source-map \\\"content=dist/js/tabler-map.js.map,filename=dist/js/tabler-map.min.js.map,url=tabler-map.min.js.map\\\" -o dist/js/tabler-map.min.js\" \"terser dist/js/tabler-map.esm.js --module --compress passes=2,ecma=2020 --ecma 2020 --mangle --source-map \\\"content=dist/js/tabler-map.esm.js.map,filename=dist/js/tabler-map.esm.min.js.map,url=tabler-map.esm.min.js.map\\\" -o dist/js/tabler-map.esm.min.js\"",
|
||||
"copy": "concurrently \"pnpm run copy:img\" \"pnpm run copy:libs\"",
|
||||
"copy:img": "tsx .build/copy-img.ts",
|
||||
"copy:libs": "tsx .build/copy-libs.ts",
|
||||
@@ -196,19 +198,19 @@
|
||||
},
|
||||
{
|
||||
"path": "./dist/css/tabler-vendors.css",
|
||||
"maxSize": "4 kB"
|
||||
"maxSize": "5.5 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/css/tabler-vendors.min.css",
|
||||
"maxSize": "3.5 kB"
|
||||
"maxSize": "5 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/css/tabler-vendors.rtl.css",
|
||||
"maxSize": "4 kB"
|
||||
"maxSize": "5.5 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/css/tabler-vendors.rtl.min.css",
|
||||
"maxSize": "3.5 kB"
|
||||
"maxSize": "5 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/tabler.js",
|
||||
@@ -226,6 +228,22 @@
|
||||
"path": "./dist/js/tabler.esm.min.js",
|
||||
"maxSize": "42 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/tabler-map.js",
|
||||
"maxSize": "11 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/tabler-map.esm.js",
|
||||
"maxSize": "11 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/tabler-map.min.js",
|
||||
"maxSize": "8 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/tabler-map.esm.min.js",
|
||||
"maxSize": "8 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/tabler-theme.js",
|
||||
"maxSize": "1.3 kB"
|
||||
@@ -276,6 +294,7 @@
|
||||
"jsvectormap": "^1.7.0",
|
||||
"list.js": "^2.3.1",
|
||||
"litepicker": "^2.0.12",
|
||||
"maplibre-gl": "^6.11.2",
|
||||
"nouislider": "^15.8.1",
|
||||
"playwright": "1.62.1",
|
||||
"plyr": "^3.8.4",
|
||||
|
||||
@@ -1341,6 +1341,30 @@ $img-label-backdrop-filter: blur(4px) !default;
|
||||
$img-label-icon-size: 0.75rem !default;
|
||||
// scss-docs-end img-label-variables
|
||||
|
||||
// scss-docs-start map-marker-variables
|
||||
$map-marker-size: 1.75rem !default;
|
||||
$map-marker-dot-size: 1rem !default;
|
||||
$map-marker-bg: var(--primary) !default;
|
||||
$map-marker-color: var(--primary-fg) !default;
|
||||
$map-marker-border-width: 2px !default;
|
||||
$map-marker-border-color: var(--bg-surface) !default;
|
||||
$map-marker-font-size: $h5-font-size !default;
|
||||
$map-marker-icon-size: 1rem !default;
|
||||
$map-marker-shadow: drop-shadow(0 1px 2px rgba($black, 0.3)) !default;
|
||||
$map-popup-card-width: 16rem !default;
|
||||
$map-controls-offset: 0.625rem !default;
|
||||
$map-controls-gap: 0.375rem !default;
|
||||
$map-control-icon-size: 1rem !default;
|
||||
// The shapes of the Tabler icons plus, minus, navigation, current-location and maximize
|
||||
$map-control-icons: (
|
||||
'zoom-in': "<path d='M12 5v14M5 12h14'/>",
|
||||
'zoom-out': "<path d='M5 12h14'/>",
|
||||
'north': "<path d='M12 18.5l7.265 2.463c.196 .077 .42 .032 .57 -.116a.548 .548 0 0 0 .134 -.572l-7.969 -17.275l-7.97 17.275c-.07 .2 -.017 .424 .135 .572c.15 .148 .374 .193 .57 .116l7.265 -2.463'/>",
|
||||
'locate': "<path d='M9 12a3 3 0 1 0 6 0a3 3 0 1 0 -6 0M4 12a8 8 0 1 0 16 0a8 8 0 1 0 -16 0M12 2v2M12 20v2M20 12h2M2 12h2'/>",
|
||||
'fullscreen': "<path d='M4 8v-2a2 2 0 0 1 2 -2h2M4 16v2a2 2 0 0 0 2 2h2M16 4h2a2 2 0 0 1 2 2v2M16 20h2a2 2 0 0 0 2 -2v-2'/>",
|
||||
) !default;
|
||||
// scss-docs-end map-marker-variables
|
||||
|
||||
// Accordion
|
||||
// scss-docs-start accordion-variables
|
||||
$accordion-padding-y: 1rem !default;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@forward 'vendor/tom-select';
|
||||
@forward 'vendor/apexcharts';
|
||||
@forward 'vendor/jsvectormap';
|
||||
@forward 'vendor/maplibre';
|
||||
@forward 'vendor/dropzone';
|
||||
@forward 'vendor/fslightbox';
|
||||
@forward 'vendor/plyr';
|
||||
|
||||
@@ -37,19 +37,27 @@ exports[`css custom-property prefixing > leaves vendor-owned names alone and pre
|
||||
"--plyr-color-main",
|
||||
"--tblr-active-bg",
|
||||
"--tblr-azure",
|
||||
"--tblr-azure-fg",
|
||||
"--tblr-backdrop-bg",
|
||||
"--tblr-bg-forms",
|
||||
"--tblr-bg-surface",
|
||||
"--tblr-bg-surface-inverted",
|
||||
"--tblr-bg-surface-secondary",
|
||||
"--tblr-black",
|
||||
"--tblr-blue",
|
||||
"--tblr-blue-fg",
|
||||
"--tblr-body-bg",
|
||||
"--tblr-body-color",
|
||||
"--tblr-border-color",
|
||||
"--tblr-border-color-translucent",
|
||||
"--tblr-border-radius",
|
||||
"--tblr-border-radius-pill",
|
||||
"--tblr-border-radius-sm",
|
||||
"--tblr-border-style",
|
||||
"--tblr-border-width",
|
||||
"--tblr-btn-active-bg",
|
||||
"--tblr-btn-bg",
|
||||
"--tblr-btn-hover-bg",
|
||||
"--tblr-card-bg",
|
||||
"--tblr-chart-1",
|
||||
"--tblr-chart-2",
|
||||
@@ -63,7 +71,12 @@ exports[`css custom-property prefixing > leaves vendor-owned names alone and pre
|
||||
"--tblr-control-padding-x",
|
||||
"--tblr-control-padding-y",
|
||||
"--tblr-control-placeholder-color",
|
||||
"--tblr-cyan",
|
||||
"--tblr-cyan-fg",
|
||||
"--tblr-danger",
|
||||
"--tblr-danger-fg",
|
||||
"--tblr-dark",
|
||||
"--tblr-dark-fg",
|
||||
"--tblr-fill-primary",
|
||||
"--tblr-focus-ring",
|
||||
"--tblr-focus-ring-color",
|
||||
@@ -82,26 +95,58 @@ exports[`css custom-property prefixing > leaves vendor-owned names alone and pre
|
||||
"--tblr-form-valid-border-color",
|
||||
"--tblr-gray-500",
|
||||
"--tblr-green",
|
||||
"--tblr-green-fg",
|
||||
"--tblr-icon-size",
|
||||
"--tblr-indigo",
|
||||
"--tblr-indigo-fg",
|
||||
"--tblr-info",
|
||||
"--tblr-info-fg",
|
||||
"--tblr-input-icon-padding-start",
|
||||
"--tblr-light",
|
||||
"--tblr-light-fg",
|
||||
"--tblr-lime",
|
||||
"--tblr-lime-fg",
|
||||
"--tblr-map-control-icon",
|
||||
"--tblr-map-marker-bg",
|
||||
"--tblr-map-marker-border-color",
|
||||
"--tblr-map-marker-border-width",
|
||||
"--tblr-map-marker-color",
|
||||
"--tblr-map-marker-font-size",
|
||||
"--tblr-map-marker-icon-size",
|
||||
"--tblr-map-marker-size",
|
||||
"--tblr-muted",
|
||||
"--tblr-muted-fg",
|
||||
"--tblr-orange",
|
||||
"--tblr-orange-fg",
|
||||
"--tblr-pink",
|
||||
"--tblr-pink-fg",
|
||||
"--tblr-primary",
|
||||
"--tblr-primary-200",
|
||||
"--tblr-primary-fg",
|
||||
"--tblr-primary-lt",
|
||||
"--tblr-purple",
|
||||
"--tblr-purple-fg",
|
||||
"--tblr-red",
|
||||
"--tblr-red-fg",
|
||||
"--tblr-secondary",
|
||||
"--tblr-secondary-bg",
|
||||
"--tblr-secondary-fg",
|
||||
"--tblr-shadow-card",
|
||||
"--tblr-shadow-color",
|
||||
"--tblr-shadow-dropdown",
|
||||
"--tblr-shadow-input",
|
||||
"--tblr-signal-color",
|
||||
"--tblr-success",
|
||||
"--tblr-success-fg",
|
||||
"--tblr-teal",
|
||||
"--tblr-teal-fg",
|
||||
"--tblr-tertiary",
|
||||
"--tblr-text-inverted",
|
||||
"--tblr-warning",
|
||||
"--tblr-warning-fg",
|
||||
"--tblr-white",
|
||||
"--tblr-yellow",
|
||||
"--tblr-yellow-fg",
|
||||
"--ts-pr-caret",
|
||||
"--ts-pr-clear-button",
|
||||
]
|
||||
|
||||
Vendored
+290
@@ -0,0 +1,290 @@
|
||||
@use '../config' as *;
|
||||
|
||||
.maplibregl-map {
|
||||
font-family: inherit;
|
||||
font-size: $h5-font-size;
|
||||
line-height: $line-height-base;
|
||||
}
|
||||
|
||||
// Tabler buttons over the map, in the top end corner
|
||||
.map-controls {
|
||||
position: absolute;
|
||||
inset-block-start: #{$map-controls-offset};
|
||||
inset-inline-end: #{$map-controls-offset};
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: #{$map-controls-gap};
|
||||
}
|
||||
|
||||
// The hover and active colors of a button are see-through, which would show
|
||||
// the map under it. For a button inside a map, or in an element laid over one,
|
||||
// keep the button color and draw the hover color as a layer on top.
|
||||
[data-bs-toggle='map'],
|
||||
[data-bs-toggle='map'] ~ *,
|
||||
[data-tblr-toggle='map'],
|
||||
[data-tblr-toggle='map'] ~ * {
|
||||
.btn:hover {
|
||||
background-color: var(--btn-bg);
|
||||
background-image: linear-gradient(var(--btn-hover-bg), var(--btn-hover-bg));
|
||||
}
|
||||
|
||||
.btn:active,
|
||||
.btn.active {
|
||||
background-color: var(--btn-bg);
|
||||
background-image: linear-gradient(var(--btn-active-bg), var(--btn-active-bg));
|
||||
}
|
||||
}
|
||||
|
||||
// A map button with no content gets its icon from its action
|
||||
[data-bs-map-action]:empty::before {
|
||||
width: #{$map-control-icon-size};
|
||||
height: #{$map-control-icon-size};
|
||||
content: '';
|
||||
background: currentColor;
|
||||
mask: var(--map-control-icon) center / contain no-repeat;
|
||||
}
|
||||
|
||||
@each $action, $shape in $map-control-icons {
|
||||
[data-bs-map-action='#{$action}'] {
|
||||
--map-control-icon: #{escape-svg(url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'>#{$shape}</svg>"))};
|
||||
}
|
||||
}
|
||||
|
||||
// Attribution
|
||||
.maplibregl-ctrl.maplibregl-ctrl-attrib {
|
||||
color: var(--secondary);
|
||||
background-color: color-transparent(var(--bg-surface), 0.7);
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-attrib.maplibregl-compact {
|
||||
color: var(--secondary);
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-attrib-button:focus {
|
||||
box-shadow: none;
|
||||
@include focus-ring();
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-scale {
|
||||
color: var(--body-color);
|
||||
background-color: color-transparent(var(--bg-surface), 0.75);
|
||||
border-color: var(--body-color);
|
||||
}
|
||||
|
||||
// Popup: a small bordered panel with no arrow
|
||||
.maplibregl-popup .maplibregl-popup-content {
|
||||
padding: 0.75rem;
|
||||
color: var(--body-color);
|
||||
background: var(--bg-surface);
|
||||
box-shadow:
|
||||
0 0 0 var(--border-width) var(--border-color),
|
||||
#{$box-shadow-md};
|
||||
@include border-radius(var(--border-radius));
|
||||
}
|
||||
|
||||
.maplibregl-popup-tip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// A popup fades in and grows a little as it opens
|
||||
.maplibregl-popup-content {
|
||||
animation: map-popup-in 0.2s ease-out;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes map-popup-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
// Room for the close button, which MapLibre pins to the right edge in both directions
|
||||
.maplibregl-popup .maplibregl-popup-content:has(.maplibregl-popup-close-button) {
|
||||
/* rtl:ignore */
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.maplibregl-popup-close-button {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0;
|
||||
margin: 0.25rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
color: var(--secondary);
|
||||
@include border-radius(var(--border-radius-sm));
|
||||
|
||||
&:hover {
|
||||
color: var(--body-color);
|
||||
background-color: var(--bg-surface-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
// Popup used as a tooltip: a short inverted label, shown on hover
|
||||
.map-tooltip .maplibregl-popup-content {
|
||||
padding: 0.25rem 0.5rem;
|
||||
color: var(--text-inverted);
|
||||
pointer-events: none;
|
||||
background: var(--bg-surface-inverted);
|
||||
box-shadow: #{$box-shadow-md};
|
||||
}
|
||||
|
||||
// Popup that holds a card-like block: an image on top, padded content below
|
||||
.map-popup-card.maplibregl-popup {
|
||||
.maplibregl-popup-content {
|
||||
width: #{$map-popup-card-width};
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.maplibregl-popup-close-button {
|
||||
color: var(--body-color);
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
}
|
||||
|
||||
// Markers written as children of the map element stay hidden until the
|
||||
// component moves them onto the map
|
||||
[data-bs-toggle='map'] > [data-bs-lng],
|
||||
[data-tblr-toggle='map'] > [data-tblr-lng] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Marker drawn in HTML. MapLibre moves the marker with `transform`, so the
|
||||
// shape is drawn by the pseudo-element and the root keeps no transform.
|
||||
.map-marker {
|
||||
// scss-docs-start map-marker-css-vars
|
||||
--map-marker-size: #{$map-marker-size};
|
||||
--map-marker-bg: #{$map-marker-bg};
|
||||
--map-marker-color: #{$map-marker-color};
|
||||
--map-marker-border-width: #{$map-marker-border-width};
|
||||
--map-marker-border-color: #{$map-marker-border-color};
|
||||
--map-marker-font-size: #{$map-marker-font-size};
|
||||
--map-marker-icon-size: #{$map-marker-icon-size};
|
||||
// scss-docs-end map-marker-css-vars
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: var(--map-marker-size);
|
||||
min-height: var(--map-marker-size);
|
||||
padding: var(--map-marker-border-width);
|
||||
font-size: var(--map-marker-font-size);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1;
|
||||
color: var(--map-marker-color);
|
||||
cursor: pointer;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
content: '';
|
||||
background: var(--map-marker-bg);
|
||||
filter: #{$map-marker-shadow};
|
||||
border: var(--map-marker-border-width) solid var(--map-marker-border-color);
|
||||
@include border-radius(var(--border-radius-pill));
|
||||
}
|
||||
|
||||
// On a map the marker is positioned by MapLibre
|
||||
&.maplibregl-marker {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.icon {
|
||||
--icon-size: var(--map-marker-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
.map-marker-dot {
|
||||
--map-marker-size: #{$map-marker-dot-size};
|
||||
}
|
||||
|
||||
// A ring that grows out of the marker and fades, for a live position
|
||||
.map-marker-pulse::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
content: '';
|
||||
background: var(--map-marker-bg);
|
||||
animation: map-marker-pulse 1.6s ease-out infinite;
|
||||
@include border-radius(var(--border-radius-pill));
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes map-marker-pulse {
|
||||
from {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(2.6);
|
||||
}
|
||||
}
|
||||
|
||||
// Short text under a marker
|
||||
.map-marker-label {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
margin-top: 0.125rem;
|
||||
font-size: $h6-font-size;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.2;
|
||||
color: var(--body-color);
|
||||
text-shadow:
|
||||
0 0 2px var(--bg-surface),
|
||||
0 0 2px var(--bg-surface),
|
||||
0 0 4px var(--bg-surface);
|
||||
white-space: nowrap;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
// Teardrop: a square with one sharp corner, turned to point down. Its tip
|
||||
// reaches 20.7% below the circle, so the root is that much taller.
|
||||
.map-marker-pin {
|
||||
box-sizing: content-box;
|
||||
width: var(--map-marker-size);
|
||||
min-width: 0;
|
||||
height: var(--map-marker-size);
|
||||
min-height: 0;
|
||||
padding: 0 0 calc(var(--map-marker-size) * 0.207);
|
||||
|
||||
&::before {
|
||||
bottom: auto;
|
||||
height: var(--map-marker-size);
|
||||
@include border-radius(50% 50% 50% 0);
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
}
|
||||
|
||||
@each $name, $color in $theme-colors {
|
||||
.map-marker-#{$name} {
|
||||
--map-marker-bg: var(--#{$name});
|
||||
--map-marker-color: var(--#{$name}-fg);
|
||||
}
|
||||
}
|
||||
|
||||
// The attribution icon is a black SVG image
|
||||
@if $enable-dark-mode {
|
||||
@include color-mode(dark) {
|
||||
.maplibregl-ctrl-attrib-button {
|
||||
@include autodark-image();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,5 @@
|
||||
"rootDir": "js",
|
||||
"outDir": "dist/types"
|
||||
},
|
||||
"include": ["js/tabler.ts", "js/tabler-theme.ts", "js/src/**/*"]
|
||||
"include": ["js/tabler.ts", "js/tabler-theme.ts", "js/tabler-map.ts", "js/src/**/*"]
|
||||
}
|
||||
@@ -27,9 +27,11 @@ interface Props {
|
||||
columnFullWidth?: boolean
|
||||
hideCode?: boolean
|
||||
codeOnly?: boolean
|
||||
/** no padding around the demo, for content that fills the whole area, such as a map */
|
||||
flush?: boolean
|
||||
}
|
||||
|
||||
const { html: htmlProp, code, raw, overflow = 'auto', background = 'surface-secondary', class: className, height, column, centered, vertical, columnFullWidth, hideCode, codeOnly } = Astro.props
|
||||
const { html: htmlProp, code, raw, overflow = 'auto', background = 'surface-secondary', class: className, height, column, centered, vertical, columnFullWidth, hideCode, codeOnly, flush } = Astro.props
|
||||
|
||||
const backgroundClasses: Record<ExampleBackground, string> = {
|
||||
'surface-secondary': 'bg-surface-secondary',
|
||||
@@ -52,7 +54,7 @@ const hasDemo = !codeOnly
|
||||
const hasCode = !hideCode
|
||||
const exampleClasses = ['example fs-base border', hasCode ? 'rounded-top' : 'rounded', !raw && 'd-flex flex-wrap justify-content-center', `overflow-${overflow}`, 'position-relative', backgroundClasses[background], className]
|
||||
|
||||
const innerClasses = ['p-6 w-full', column && 'd-flex gap-3 flex-column', !column && centered && `d-flex flex-fill flex-wrap gap-2 justify-content-center${vertical ? ' align-items-center flex-column' : ' justify-content-center'}`, columnFullWidth && 'd-flex flex-fill flex-column gap-2']
|
||||
const innerClasses = [flush ? 'p-0' : 'p-6', 'w-full', column && 'd-flex gap-3 flex-column', !column && centered && `d-flex flex-fill flex-wrap gap-2 justify-content-center${vertical ? ' align-items-center flex-column' : ' justify-content-center'}`, columnFullWidth && 'd-flex flex-fill flex-column gap-2']
|
||||
|
||||
const highlighted = hideCode ? '' : await highlightCode(beautifyHtml(code ?? html), 'html')
|
||||
---
|
||||
|
||||
@@ -1,141 +1,432 @@
|
||||
---
|
||||
title: Map
|
||||
summary: A map shows places and markers on an interactive world map. Tabler provides the markup and the setup code, and the map itself is rendered by Mapbox GL JS.
|
||||
description: Show interactive maps with markers using Mapbox GL JS. Tabler provides the container markup and setup code to get you started.
|
||||
summary: A map shows places and markers on an interactive world map. Tabler turns a single element into a themed map, and the map itself is rendered by MapLibre GL JS.
|
||||
description: Show interactive maps with markers, popups and a matching dark mode from plain HTML, using MapLibre GL JS and free map tiles. No account or access token is needed.
|
||||
related: [/ui/plugins/vector-map]
|
||||
source: core/scss/_utilities.scss
|
||||
docs-libs: [maplibre-gl]
|
||||
source: core/scss/vendor/_maplibre.scss
|
||||
css-vars: map-marker
|
||||
classnames:
|
||||
component:
|
||||
- class: ratio
|
||||
desc: Wrapper that keeps the embed at a fixed aspect ratio
|
||||
desc: Wrapper that keeps the map at a fixed aspect ratio
|
||||
- class: map-marker
|
||||
desc: Marker drawn in HTML, a circle that can hold an icon, a count or an avatar
|
||||
part:
|
||||
- class: map-marker-label
|
||||
desc: Short text under a marker
|
||||
- class: map-controls
|
||||
desc: Column of buttons in the top end corner of the map
|
||||
modifier:
|
||||
- class: map-marker-dot
|
||||
desc: Small dot with no content
|
||||
- class: map-marker-pulse
|
||||
desc: Ring that grows out of the marker and fades, for a live position
|
||||
- class: map-marker-pin
|
||||
desc: Teardrop pin that points down, used with the bottom anchor
|
||||
- class: map-popup-card
|
||||
desc: Popup with no padding and a fixed width, for an image above the text
|
||||
- class: map-tooltip
|
||||
desc: Popup shown as a small dark label, for text on hover
|
||||
color:
|
||||
- class: map-marker-{color}
|
||||
desc: Any theme color, for example map-marker-red or map-marker-green
|
||||
size:
|
||||
- class: ratio-{ratio}
|
||||
desc: The aspect to hold, for example ratio-16x9 or ratio-21x9
|
||||
---
|
||||
import Example from '@components/Example.astro';
|
||||
import Map from '@shared/components/demo/Map.astro';
|
||||
import Icon from '@ui/Icon.astro';
|
||||
import CodeDocs from '@components/CodeDocs.astro';
|
||||
import { Code } from 'astro:components';
|
||||
import { site } from '@shared/lib/site.ts';
|
||||
import { cdnJsTag, cdnMapJsTag, cdnPluginSnippet } from '@lib/cdn-snippets.ts';
|
||||
|
||||
## Overview
|
||||
|
||||
The map is a small wrapper around [Mapbox GL JS](https://docs.mapbox.com/mapbox-gl-js/). Tabler gives you the container markup and a short init script. Mapbox draws the tiles, handles zoom, and renders the markers.
|
||||
The map component turns an element into an interactive map. Add `data-bs-toggle="map"` to it, set the center and the zoom with data attributes, and write the markers as child elements. No script is needed.
|
||||
|
||||
A Mapbox account and an access token are needed, because every map load calls the Mapbox API. If you only need a simple map of countries or regions, use the [vector map](/ui/plugins/vector-map) instead. It needs no account and no network calls.
|
||||
The map is drawn by [MapLibre GL JS](https://maplibre.org/maplibre-gl-js/docs/), an open-source map library, which you load next to Tabler. The map tiles come from [OpenFreeMap](https://openfreemap.org/). It needs no account and no access token, and it can be used in commercial projects.
|
||||
|
||||
If you only need a simple map of countries or regions, use the [vector map](/ui/plugins/vector-map) instead. It makes no network calls at all.
|
||||
|
||||
## Installation
|
||||
|
||||
Add the Mapbox GL JS script and stylesheet to your page. Tabler uses version 1.8.0:
|
||||
The map component is not part of `tabler.js`. It ships as a small add-on, `tabler-map.js`, so pages without a map do not carry it. Load the MapLibre stylesheet, and the add-on after `tabler.js`; it adds `MapView` to the `tabler` object:
|
||||
|
||||
<Code
|
||||
lang="html"
|
||||
code={`<link href="https://api.mapbox.com/mapbox-gl-js/v1.8.0/mapbox-gl.css" rel="stylesheet" />
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v1.8.0/mapbox-gl.js"></script>`}
|
||||
code={`<link rel="stylesheet" href="${site.cdnUrl}/dist/libs/maplibre-gl/dist/maplibre-gl.css" />
|
||||
${cdnJsTag()}
|
||||
${cdnMapJsTag()}`}
|
||||
/>
|
||||
|
||||
Then create a free account on [mapbox.com](https://www.mapbox.com/) and copy your public access token. It starts with `pk.`. Set it once, before you create a map:
|
||||
The add-on loads MapLibre GL JS by itself, from `dist/libs/maplibre-gl` next to the Tabler scripts. Tabler uses version 6, which is published as an ES module. When your files are somewhere else, point the `library` option at the module: `data-bs-library="/vendor/maplibre-gl.mjs"`. The library loads two more files from its own folder, `maplibre-gl-shared.mjs` and `maplibre-gl-worker.mjs`, so keep the three together.
|
||||
|
||||
```js
|
||||
mapboxgl.accessToken = 'pk.your-token-here'
|
||||
If the page sets `window.maplibregl` before the add-on runs, the component uses that and loads nothing. This is the way to go with a bundler:
|
||||
|
||||
```bash
|
||||
npm install maplibre-gl
|
||||
```
|
||||
|
||||
<Danger title="Use a public token only">
|
||||
A secret token (`sk.`) must never be sent to the browser. Limit the token to your own domains in the Mapbox dashboard.
|
||||
```js
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
|
||||
window.maplibregl = maplibregl
|
||||
|
||||
const { MapView } = await import('@tabler/core/dist/js/tabler-map.esm.js')
|
||||
```
|
||||
|
||||
<Danger title="Use version 6.4.1 or newer">
|
||||
Older versions, including all of version 5, have a known security issue in the code that cleans the attribution text.
|
||||
</Danger>
|
||||
|
||||
Browsers do not load ES modules from a page opened straight from the disk. Use a local server to see the maps while you work.
|
||||
|
||||
Tabler styles the map controls, popups and attribution to match the interface, in light and dark mode. These styles are in `tabler-vendors.css`, so load it after the MapLibre stylesheet:
|
||||
|
||||
<Code lang="html" code={cdnPluginSnippet(['vendors'])} />
|
||||
|
||||
## Usage
|
||||
|
||||
### Markup
|
||||
### Basic map
|
||||
|
||||
Put the map container inside a `ratio` element, so the map keeps its shape on every screen size. Give the inner element an `id` and the `w-100 h-100` classes.
|
||||
Put the map element inside a `ratio` element, so the map keeps its shape on every screen size. `data-bs-center` takes the longitude first and the latitude second. This is the opposite of what most map services show.
|
||||
|
||||
<Example codeOnly>
|
||||
<div class="ratio ratio-16x9">
|
||||
<div><div id="map-simple" class="w-100 h-100"></div></div>
|
||||
</div>
|
||||
<Example flush>
|
||||
<Map rounded={false} mapId="simple" />
|
||||
</Example>
|
||||
|
||||
Change the shape with the ratio classes, for example `ratio-21x9` for a wide map or `ratio-1x1` for a square one.
|
||||
|
||||
### Init the map
|
||||
### Map styles
|
||||
|
||||
Create the map after the DOM is ready. Pass the id of your container, a style, a zoom level, and a center point.
|
||||
The map uses a light gray style in light mode and a dark one in dark mode. Tabler softens their colors, so markers and data stand out. Set another style with `data-bs-map-style`: the name of an OpenFreeMap style, or the URL of any MapLibre style.
|
||||
|
||||
| Style | Result |
|
||||
| --- | --- |
|
||||
| `positron` | Light gray map. The default in light mode. |
|
||||
| `dark` | Dark gray map. The default in dark mode. |
|
||||
| `liberty` | Street map in color. |
|
||||
| `bright` | Street map with stronger colors. |
|
||||
| `fiord` | Dark blue map. |
|
||||
|
||||
<Example flush>
|
||||
<Map rounded={false} mapId="light" />
|
||||
</Example>
|
||||
|
||||
`data-bs-map-style-dark` sets the style for dark mode. Styles from other providers work the same way, as long as they follow the MapLibre style format. Check the terms of each provider, as many of them need an API key or a paid plan.
|
||||
|
||||
### Dark mode
|
||||
|
||||
The map follows the nearest `data-bs-theme` and changes its style when the theme changes. Markers and popups stay on the map. To keep one look in both modes, set `data-bs-map-theme` to `light` or `dark`.
|
||||
|
||||
### Markers
|
||||
|
||||
Each child element with `data-bs-lng` and `data-bs-lat` becomes a marker. Use the `.map-marker` class to draw it: a circle in the primary color that can hold an icon, a short count or an avatar. Put a `.map-marker-label` element inside the marker for a short text under it.
|
||||
|
||||
Add `.map-marker-dot` for a small dot, or `.map-marker-pin` for a pin that points down. A pin needs `data-bs-anchor="bottom"`, so its tip sits on the place. Change the color with `.map-marker-{color}`.
|
||||
|
||||
<Example flush>
|
||||
<Map rounded={false} mapId="marker-styles" title="Marker styles" center={[52.522, 13.405]} zoom={11}>
|
||||
<span class="map-marker map-marker-dot" data-bs-lng="13.345" data-bs-lat="52.54"></span>
|
||||
<span class="map-marker map-marker-dot map-marker-red" data-bs-lng="13.39" data-bs-lat="52.545"></span>
|
||||
<span class="map-marker" data-bs-lng="13.445" data-bs-lat="52.538"><Icon name="building-store" /><span class="map-marker-label">Store</span></span>
|
||||
<span class="map-marker map-marker-green" data-bs-lng="13.35" data-bs-lat="52.512">12</span>
|
||||
<span class="map-marker map-marker-pin" data-bs-lng="13.405" data-bs-lat="52.522" data-bs-anchor="bottom"><Icon name="home" /></span>
|
||||
<span class="map-marker map-marker-pin map-marker-red" data-bs-lng="13.47" data-bs-lat="52.518" data-bs-anchor="bottom"><Icon name="star" /></span>
|
||||
<span class="map-marker" data-bs-lng="13.43" data-bs-lat="52.508"><span class="avatar avatar-sm" style="background-image: url(/static/avatars/000m.jpg)"></span></span>
|
||||
</Map>
|
||||
</Example>
|
||||
|
||||
Add `data-bs-draggable="true"` to let people move a marker, for example to pick an address. Add `.map-marker-pulse` for a ring that grows out of the marker, for a live position or a place that needs attention.
|
||||
|
||||
HTML markers work well for up to a few hundred places. For more, use [clusters](#clusters).
|
||||
|
||||
### Popups
|
||||
|
||||
Add `data-bs-popup` to a marker for a short text that opens when the marker is clicked. Tabler draws the popup as a small bordered panel with no arrow, and fades it in.
|
||||
|
||||
<Example flush>
|
||||
<Map rounded={false} mapId="markers" />
|
||||
</Example>
|
||||
|
||||
For richer content, put a `<template>` inside the marker. Its content becomes the popup. Add the `map-popup-card` class to the template for a popup with an image: it removes the padding and sets a fixed width, so the image reaches the edges. `data-bs-close-button="true"` on the template adds a close button, and `data-bs-popup-open="true"` on the marker opens the popup from the start.
|
||||
|
||||
<Example flush>
|
||||
<Map rounded={false} mapId="popup-card" title="Popup card" center={[52.546, 13.4]} zoom={11} ratio="4x3">
|
||||
<span class="map-marker map-marker-pin" data-bs-lng="13.4019" data-bs-lat="52.5169" data-bs-anchor="bottom" data-bs-popup-open="true">
|
||||
<Icon name="map-pin" />
|
||||
<template class="map-popup-card" data-bs-close-button="true">
|
||||
<div class="img-responsive img-responsive-21x9" style="background-image: url(/static/photos/a-visit-to-the-bookstore.jpg)"></div>
|
||||
<div class="p-3">
|
||||
<div class="fw-medium">Museum Island</div>
|
||||
<div class="text-secondary mb-3">Five museums on the Spree, open daily.</div>
|
||||
<a href="#" class="btn btn-primary btn-sm">View details</a>
|
||||
</div>
|
||||
</template>
|
||||
</span>
|
||||
</Map>
|
||||
</Example>
|
||||
|
||||
### Controls
|
||||
|
||||
Add `data-bs-controls="true"` for zoom buttons in the top corner of the map. They are regular Tabler buttons, so they follow the theme. For more buttons, pass a list of names:
|
||||
|
||||
| Name | Buttons |
|
||||
| --- | --- |
|
||||
| `zoom` | Zoom in and zoom out. |
|
||||
| `north` | Turns the map back to north up. |
|
||||
| `locate` | Moves the map to the position of the device. The browser asks for permission first. |
|
||||
| `fullscreen` | Shows the map on the whole screen, and back. |
|
||||
|
||||
```html
|
||||
<div class="w-100 h-100" data-bs-toggle="map" data-bs-center="13.405,52.52" data-bs-zoom="12" data-bs-controls="zoom,north,locate,fullscreen"></div>
|
||||
```
|
||||
|
||||
The buttons have English names for screen readers. Change them with the `labels` option, by action:
|
||||
|
||||
```html
|
||||
<div class="w-100 h-100" data-bs-toggle="map" data-bs-controls="true" data-bs-labels='{"zoom-in": "Przybliż", "zoom-out": "Oddal"}'></div>
|
||||
```
|
||||
|
||||
You can also write the buttons yourself. Any button with `data-bs-map-action` works on the map it is inside, or on the map its `data-bs-target` points at. The actions are `zoom-in`, `zoom-out`, `north`, `locate` and `fullscreen`. A button with no content gets a matching icon; put the buttons in a `.map-controls` element to place them in the top corner.
|
||||
|
||||
```html
|
||||
<div class="w-100 h-100" id="map-office" data-bs-toggle="map" data-bs-center="13.405,52.52" data-bs-zoom="12">
|
||||
<div class="map-controls">
|
||||
<button type="button" class="btn btn-sm btn-icon" data-bs-map-action="zoom-in" aria-label="Zoom in"></button>
|
||||
<button type="button" class="btn btn-sm btn-icon" data-bs-map-action="zoom-out" aria-label="Zoom out"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn" data-bs-map-action="locate" data-bs-target="#map-office">Find me</button>
|
||||
```
|
||||
|
||||
### Route
|
||||
|
||||
Draw a path with `data-bs-route`: a list of `[longitude, latitude]` points. The line takes the primary color and is drawn again after a theme change. Mark its ends with markers.
|
||||
|
||||
```html
|
||||
<div class="w-100 h-100" data-bs-toggle="map" data-bs-center="13.4046,52.513" data-bs-zoom="11.6" data-bs-route="[[13.3695,52.5251],[13.4132,52.5219],[13.4397,52.505]]">
|
||||
<span class="map-marker map-marker-dot" data-bs-lng="13.3695" data-bs-lat="52.5251"></span>
|
||||
<span class="map-marker map-marker-pin" data-bs-lng="13.4397" data-bs-lat="52.505" data-bs-anchor="bottom"></span>
|
||||
</div>
|
||||
```
|
||||
|
||||
To show how far along a path something is, add `data-bs-route-progress` with the index of the point reached so far, counted from 0. The path up to that point stays solid and the rest is dashed.
|
||||
|
||||
```html
|
||||
<div class="w-100 h-100" data-bs-toggle="map" data-bs-route="[[13.3695,52.5251],[13.4132,52.5219],[13.4397,52.505]]" data-bs-route-progress="1"></div>
|
||||
```
|
||||
|
||||
### Areas and other shapes
|
||||
|
||||
`data-bs-geojson` draws [GeoJSON](https://geojson.org/) over the map: areas with a light fill and an outline, lines, and points as small circles. Pass the GeoJSON itself, or the URL of a file.
|
||||
|
||||
```html
|
||||
<div class="w-100 h-100" data-bs-toggle="map" data-bs-center="13.405,52.519" data-bs-zoom="11" data-bs-geojson="/data/delivery-zone.geojson"></div>
|
||||
```
|
||||
|
||||
Both use the primary color. Set `data-bs-layer-color` to another color, for example `--tblr-red` or `#d63939`.
|
||||
|
||||
### Globe
|
||||
|
||||
Add `data-bs-globe="true"` to draw the map as a 3D globe. Use a low zoom, between 1 and 2, to see the whole globe. People can turn it by dragging.
|
||||
|
||||
```html
|
||||
<div class="w-100 h-100" data-bs-toggle="map" data-bs-center="62,32" data-bs-zoom="1.3" data-bs-globe="true"></div>
|
||||
```
|
||||
|
||||
### Content over the map
|
||||
|
||||
To put buttons or a small card over the map, place them next to the map element and position them with the utility classes:
|
||||
|
||||
```html
|
||||
<div class="ratio ratio-16x9">
|
||||
<div>
|
||||
<div class="w-100 h-100 rounded" data-bs-toggle="map" data-bs-center="13.405,52.52" data-bs-zoom="10"></div>
|
||||
<div class="btn-list position-absolute top-0 start-0 m-2">
|
||||
<button type="button" class="btn btn-sm btn-pill btn-primary">Berlin</button>
|
||||
<button type="button" class="btn btn-sm btn-pill">Tokyo</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Attribution
|
||||
|
||||
MapLibre adds the credit for the map data in the corner of the map. OpenFreeMap requires it, so do not hide it.
|
||||
|
||||
## Layers
|
||||
|
||||
The route and GeoJSON options cover simple shapes. For anything more, such as clusters, dashed lines or circles sized by a value, take the MapLibre map from the component and use its own API. The `map` property is the [MapLibre map](https://maplibre.org/maplibre-gl-js/docs/API/classes/Map/).
|
||||
|
||||
A layer reads its data from a GeoJSON source. Layers are part of the map style, so add them in `onStyle()`. It runs when the style has loaded, and again each time the style changes, for example on a theme change.
|
||||
|
||||
```js
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
mapboxgl.accessToken = 'pk.your-token-here'
|
||||
const view = tabler.MapView.getInstance('#map-stores')
|
||||
|
||||
const map = new mapboxgl.Map({
|
||||
container: 'map-simple',
|
||||
style: 'mapbox://styles/mapbox/streets-v11',
|
||||
zoom: 13,
|
||||
center: [13.4049, 52.518827],
|
||||
view.onStyle(function (map) {
|
||||
map.addSource('stores', { type: 'geojson', data: stores })
|
||||
map.addLayer({
|
||||
id: 'stores',
|
||||
type: 'circle',
|
||||
source: 'stores',
|
||||
paint: { 'circle-color': tabler.MapView.color('--tblr-primary'), 'circle-radius': 6 },
|
||||
})
|
||||
})
|
||||
|
||||
// Runs once the map exists: the library may load a moment after the page
|
||||
view.ready(function (map) {
|
||||
map.on('click', 'stores', function (event) {
|
||||
console.log(event.features[0].properties)
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
`center` takes the longitude first and the latitude second. This is the Mapbox order, and it is the opposite of what most map services show.
|
||||
For an area, use a `Polygon` geometry with a `fill` layer, and a `line` layer for its outline.
|
||||
|
||||
### Map styles
|
||||
### Theme colors in layers
|
||||
|
||||
Set `style` to any Mapbox style URL. These three are used in the Tabler demos:
|
||||
|
||||
| Style | Result |
|
||||
| --- | --- |
|
||||
| `mapbox://styles/mapbox/streets-v11` | Default street map. |
|
||||
| `mapbox://styles/mapbox/light-v10` | Light map, good under charts and markers. |
|
||||
| `mapbox://styles/mapbox/satellite-v9` | Satellite photos. |
|
||||
|
||||
### Markers
|
||||
|
||||
Add a marker for each place. `setLngLat` takes the same longitude and latitude order as `center`.
|
||||
MapLibre draws layers on a canvas, so a CSS variable does not work in `paint`, and neither do the `oklch()` values Tabler colors are written in. `MapView.color()` turns a variable, or any CSS color, into a plain `rgb()` color. Call it inside `onStyle()`, so the colors are read again after a theme change.
|
||||
|
||||
```js
|
||||
const cities = [
|
||||
[-58.666667, -34.58333333], // Buenos Aires
|
||||
[16.366667, 48.2], // Vienna
|
||||
[116.383333, 39.91666667], // Beijing
|
||||
]
|
||||
tabler.MapView.color('--tblr-primary') // 'rgb(6, 111, 209)'
|
||||
```
|
||||
|
||||
cities.forEach(function (coords) {
|
||||
new mapboxgl.Marker({ color: 'var(--tblr-primary)' }).setLngLat(coords).addTo(map)
|
||||
### Clusters
|
||||
|
||||
Put many points in a GeoJSON source with `cluster: true`. MapLibre then groups nearby points and splits the groups as you zoom in.
|
||||
|
||||
```js
|
||||
map.addSource('stores', { type: 'geojson', data: stores, cluster: true, clusterRadius: 40 })
|
||||
|
||||
// Circle for each cluster, larger for more points
|
||||
map.addLayer({
|
||||
id: 'clusters',
|
||||
type: 'circle',
|
||||
source: 'stores',
|
||||
filter: ['has', 'point_count'],
|
||||
paint: { 'circle-color': tabler.MapView.color('--tblr-primary'), 'circle-radius': ['step', ['get', 'point_count'], 14, 10, 18, 25, 24] },
|
||||
})
|
||||
|
||||
// Number of points in the cluster
|
||||
map.addLayer({
|
||||
id: 'cluster-count',
|
||||
type: 'symbol',
|
||||
source: 'stores',
|
||||
filter: ['has', 'point_count'],
|
||||
layout: { 'text-field': ['get', 'point_count_abbreviated'], 'text-font': ['Noto Sans Regular'], 'text-size': 12 },
|
||||
paint: { 'text-color': '#ffffff' },
|
||||
})
|
||||
```
|
||||
|
||||
The `color` option is written to the `fill` attribute of the marker icon. A CSS variable works there, so `var(--tblr-primary)` keeps the markers in your theme color, including after a theme change.
|
||||
`text-font` has to be a font the map style provides. All OpenFreeMap styles have `Noto Sans Regular`.
|
||||
|
||||
## Examples
|
||||
### Tooltips
|
||||
|
||||
### Map in a card
|
||||
For a short label on hover, add the `map-tooltip` class to a MapLibre popup. It turns the popup into a small dark label.
|
||||
|
||||
Add a card title above the map. This is the usual layout for a small map next to other content.
|
||||
```js
|
||||
const tooltip = new maplibregl.Popup({ closeButton: false, offset: 12, className: 'map-tooltip' })
|
||||
|
||||
<Example codeOnly>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">Simple map</div>
|
||||
<div class="ratio ratio-16x9">
|
||||
<div><div id="map-card" class="w-100 h-100"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Example>
|
||||
map.on('mousemove', 'cities', function (event) {
|
||||
tooltip.setLngLat(event.lngLat).setText(event.features[0].properties.name).addTo(map)
|
||||
})
|
||||
map.on('mouseleave', 'cities', function () {
|
||||
tooltip.remove()
|
||||
})
|
||||
```
|
||||
|
||||
### Full-card map
|
||||
Live versions of a route, an area, clusters, a bubble map, a globe with arcs and a city switcher are on the [maps preview page](https://preview.tabler.io/maps.html). The [map blocks page](https://preview.tabler.io/map-blocks.html) has larger examples: a store locator, a list of homes with prices on the map, a delivery tracker, an address form with a draggable pin, a server status map, a map of live users and a vector map of users by country.
|
||||
|
||||
Drop the card body to let the map fill the whole card. Add `rounded` to the container, so the map follows the card corners.
|
||||
## JavaScript
|
||||
|
||||
<Example codeOnly>
|
||||
<div class="card">
|
||||
<div class="ratio ratio-21x9">
|
||||
<div><div id="map-full" class="w-100 h-100 rounded"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</Example>
|
||||
### Via data attributes
|
||||
|
||||
Live versions of these maps are on the [maps preview page](https://preview.tabler.io/maps.html).
|
||||
An element with `data-bs-toggle="map"` becomes a map when the `tabler-map.js` script loads:
|
||||
|
||||
<CodeDocs name="map-init" file="core/js/src/map.ts" />
|
||||
|
||||
### Via JavaScript
|
||||
|
||||
Create a map from your own code with the same options:
|
||||
|
||||
```js
|
||||
const view = new tabler.MapView('#map', {
|
||||
center: [13.405, 52.52],
|
||||
zoom: 12,
|
||||
controls: true,
|
||||
})
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
Pass options as data attributes, with the name written in kebab case after `data-bs-`, or in the config object.
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `library` | string | MapLibre in `dist/libs` | URL of the MapLibre GL JS module, loaded when `window.maplibregl` is not set. A relative URL starts at the Tabler script. `null` turns the loading off. |
|
||||
| `center` | array, string | `[0, 0]` | Center of the map: `[longitude, latitude]`, or the two numbers in a string such as `"13.405,52.52"`. |
|
||||
| `zoom` | number | `1` | Zoom level, from 0 for the whole world to about 20 for a building. |
|
||||
| `mapStyle` | string | `'positron'` | Name of an OpenFreeMap style, or the URL of a MapLibre style. |
|
||||
| `mapStyleDark` | string | `'dark'` | Style used in dark mode. Set `null` to keep `mapStyle` in both modes. |
|
||||
| `mapTheme` | string | `'auto'` | `'auto'` follows the nearest `[data-bs-theme]`; `'light'` or `'dark'` keeps one look. |
|
||||
| `palette` | boolean | `true` | Softer colors for the `positron` and `dark` styles. Other styles are left as they are. |
|
||||
| `controls` | boolean, string | `false` | Buttons over the map: `true` for zoom, or a list of `zoom`, `north`, `locate` and `fullscreen`. |
|
||||
| `labels` | object | `{}` | Names of the buttons for screen readers, by action: `zoom-in`, `zoom-out`, `north`, `locate`, `fullscreen`. |
|
||||
| `route` | array | `null` | A path drawn as a line: a list of `[longitude, latitude]` points. |
|
||||
| `routeProgress` | number | `null` | Index of the route point reached so far. The path up to it is solid, the rest is dashed. |
|
||||
| `geojson` | object, string | `null` | GeoJSON drawn over the map: an object, or the URL of a file. |
|
||||
| `layerColor` | string | `'--tblr-primary'` | Color of the route and the GeoJSON shapes: a CSS color or a custom property. |
|
||||
| `globe` | boolean | `false` | Draw the map as a 3D globe. |
|
||||
| `mapOptions` | object | `{}` | Any [MapLibre map option](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/MapOptions/), passed straight through. |
|
||||
|
||||
A marker element takes these attributes:
|
||||
|
||||
| Attribute | Description |
|
||||
| --- | --- |
|
||||
| `data-bs-lng`, `data-bs-lat` | Position of the marker. Both are needed. |
|
||||
| `data-bs-anchor` | Part of the marker that sits on the position: `center` by default, `bottom` for a pin. |
|
||||
| `data-bs-draggable` | Set `true` to let people move the marker. |
|
||||
| `data-bs-popup` | Text of a popup that opens when the marker is clicked. |
|
||||
| `data-bs-popup-open` | Set `true` to open the popup from the start. |
|
||||
|
||||
### Methods
|
||||
|
||||
| Name | Description |
|
||||
| --- | --- |
|
||||
| `map` | The MapLibre map, for layers, events and everything else the options do not cover. `null` until the library has loaded. |
|
||||
| `ready(callback)` | Runs the callback with the MapLibre map once it exists. |
|
||||
| `onStyle(callback)` | Runs the callback with the map each time a style loads. The place to add layers. |
|
||||
| `markers` | The MapLibre markers made from the child elements, in document order. |
|
||||
| `zoomIn()`, `zoomOut()` | Zooms the map by one level. |
|
||||
| `resetNorth()` | Turns the map back to north up, with no tilt. |
|
||||
| `locate()` | Moves the map to the position of the device. |
|
||||
| `toggleFullscreen()` | Shows the map on the whole screen, and back. |
|
||||
| `dispose()` | Removes the map, its markers and the instance. |
|
||||
| `MapView.color(value)` | Turns a CSS variable or color into a plain `rgb()` color for a map layer. |
|
||||
| `MapView.getInstance(element)` | Returns the instance for an element, or `null`. |
|
||||
| `MapView.getOrCreateInstance(element)` | Returns the instance, or creates one. |
|
||||
|
||||
### Events
|
||||
|
||||
| Event | Description |
|
||||
| --- | --- |
|
||||
| `load.bs.map` | Fires on the map element once the map has loaded its style and first tiles. |
|
||||
|
||||
## Using Mapbox GL JS
|
||||
|
||||
MapLibre GL JS started as a fork of Mapbox GL JS, so the two share most of their API. The map component works with MapLibre only. To use Mapbox, write the map script yourself: load its script and stylesheet, set `mapboxgl.accessToken` to your public token, and use a `mapbox://` style URL.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- The map is a picture for most users. Give the same information as text near it, for example the address or a list of places.
|
||||
- Add an `aria-label` to the map container, so screen reader users know what the map shows.
|
||||
- Add `role="region"` and an `aria-label` to the map element, so screen reader users know what the map shows.
|
||||
- Do not put an action only on a marker. Repeat it in a normal link or button below the map.
|
||||
- Keep a visible fallback when the script fails to load, for example the address in text.
|
||||
@@ -142,7 +142,7 @@ const editFilePath =
|
||||
|
||||
// docs-libs for this page. In dev, file names stay unchanged
|
||||
// (e.g. dist/plyr.min.js keeps .min).
|
||||
type Lib = { npm?: string; js?: string[]; css?: string[] };
|
||||
type Lib = { npm?: string; js?: string[]; css?: string[]; module?: { file: string; global: string }; addon?: string };
|
||||
const libEntries = Object.entries(libs as Record<string, Lib>);
|
||||
const libUrl = (lib: Lib, file: string) =>
|
||||
isExternal(file)
|
||||
@@ -151,6 +151,10 @@ const libUrl = (lib: Lib, file: string) =>
|
||||
const docsLibCss = libEntries
|
||||
.filter(([name]) => docsLibs.includes(name))
|
||||
.flatMap(([, lib]) => (lib.css ?? []).map((file) => libUrl(lib, file)));
|
||||
const docsLibAddons = libEntries.filter(([name]) => docsLibs.includes(name)).flatMap(([, lib]) => (lib.addon ? [lib.addon] : []));
|
||||
const docsLibModules = libEntries
|
||||
.filter(([name]) => docsLibs.includes(name))
|
||||
.flatMap(([, lib]) => (lib.module ? [`import * as lib from '${libUrl(lib, lib.module.file)}'; window.${lib.module.global} = lib`] : []));
|
||||
const docsLibJs = libEntries
|
||||
.filter(([name]) => docsLibs.includes(name))
|
||||
.flatMap(([, lib]) => (lib.js ?? []).map((file) => libUrl(lib, file)));
|
||||
@@ -517,10 +521,12 @@ const relatedPages = await Promise.all(
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
{docsLibModules.map((code) => <Fragment set:html={`<script type="module">${code}</script>`} />)}
|
||||
|
||||
|
||||
<!-- BEGIN GLOBAL MANDATORY SCRIPTS -->
|
||||
<script is:inline src="/dist/js/tabler.js" defer></script>
|
||||
{docsLibAddons.map((addon) => <script is:inline src={`/dist/js/${addon}.js`} defer />)}
|
||||
<!-- END GLOBAL MANDATORY SCRIPTS -->
|
||||
|
||||
<PageScripts />
|
||||
|
||||
@@ -31,6 +31,9 @@ export const cdnJsTag = (): string => cdnScriptTag('dist/js/tabler.min.js')
|
||||
/** `<script>` for the color mode script, as shown on the Color modes page. */
|
||||
export const cdnThemeJsTag = (): string => cdnScriptTag('dist/js/tabler-theme.min.js')
|
||||
|
||||
/** `<script>` for the map add-on, as shown on the Map page. Loaded after the core bundle. */
|
||||
export const cdnMapJsTag = (): string => cdnScriptTag('dist/js/tabler-map.min.js').replace('></script>', ' defer></script>')
|
||||
|
||||
/** Both core tags, as shown by `<CdnImportPackage />`. */
|
||||
export const cdnPackageSnippet = (): string => `${cdnCssTag()}\n${cdnJsTag()}`
|
||||
|
||||
|
||||
Generated
+166
@@ -198,6 +198,9 @@ importers:
|
||||
litepicker:
|
||||
specifier: ^2.0.12
|
||||
version: 2.0.12
|
||||
maplibre-gl:
|
||||
specifier: ^6.11.2
|
||||
version: 6.11.2
|
||||
nouislider:
|
||||
specifier: ^15.8.1
|
||||
version: 15.8.1
|
||||
@@ -1271,11 +1274,40 @@ packages:
|
||||
resolution: {integrity: sha512-6QEf6yqFbETdwGITKq57aYoPfX/3K8XFNwsAlx0C1M7o8cb79sv1M3w+tWuWvIcSbNqrLF7OD7YpZMVVz335hQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@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@1.0.0':
|
||||
resolution: {integrity: sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==}
|
||||
|
||||
'@mapbox/vector-tile@3.0.0':
|
||||
resolution: {integrity: sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==}
|
||||
|
||||
'@maplibre/geojson-vt@6.1.1':
|
||||
resolution: {integrity: sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==}
|
||||
|
||||
'@maplibre/maplibre-gl-style-spec@26.4.4':
|
||||
resolution: {integrity: sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==}
|
||||
hasBin: true
|
||||
|
||||
'@maplibre/mlt@1.3.0':
|
||||
resolution: {integrity: sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==}
|
||||
|
||||
'@maplibre/vt-pbf@4.3.2':
|
||||
resolution: {integrity: sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==}
|
||||
|
||||
'@mdx-js/mdx@3.1.1':
|
||||
resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==}
|
||||
|
||||
@@ -1770,6 +1802,9 @@ packages:
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/geojson@7946.0.16':
|
||||
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||
|
||||
@@ -2168,6 +2203,9 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
bidi-js@1.1.0:
|
||||
resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==}
|
||||
|
||||
binary-extensions@2.3.0:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2520,6 +2558,9 @@ packages:
|
||||
duplexer@0.1.2:
|
||||
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
|
||||
|
||||
earcut@3.2.4:
|
||||
resolution: {integrity: sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==}
|
||||
|
||||
editorconfig@3.0.2:
|
||||
resolution: {integrity: sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -2865,6 +2906,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'}
|
||||
@@ -3191,6 +3235,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'}
|
||||
@@ -3219,6 +3266,9 @@ packages:
|
||||
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
|
||||
hasBin: true
|
||||
|
||||
kdbush@4.1.0:
|
||||
resolution: {integrity: sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==}
|
||||
|
||||
keyv@5.6.0:
|
||||
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
|
||||
|
||||
@@ -3444,6 +3494,10 @@ packages:
|
||||
make-synchronized@0.8.0:
|
||||
resolution: {integrity: sha512-DZu4lwc0ffoFz581BSQa/BJl+1ZqIkoRQ+VejMlH0VrP4E86StAODnZujZ4sepumQj8rcP7wUnUBGM8Gu+zKUA==}
|
||||
|
||||
maplibre-gl@6.11.2:
|
||||
resolution: {integrity: sha512-Xh06pxoipjX/Ad1sUPGhiNh9go9naCNGZZ1IJm3sIeqK1kYGuMaMDSTIGDQ2igOLfVyukJU7kvEOA089VO7Z7g==}
|
||||
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
|
||||
|
||||
markdown-extensions@2.0.0:
|
||||
resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -3697,6 +3751,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.18:
|
||||
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@@ -3903,6 +3960,10 @@ packages:
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
pbf@5.1.2:
|
||||
resolution: {integrity: sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==}
|
||||
hasBin: true
|
||||
|
||||
piccolore@0.1.3:
|
||||
resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==}
|
||||
|
||||
@@ -3982,6 +4043,9 @@ packages:
|
||||
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
potpack@2.1.0:
|
||||
resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==}
|
||||
|
||||
preact@10.12.1:
|
||||
resolution: {integrity: sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==}
|
||||
|
||||
@@ -4021,6 +4085,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==}
|
||||
|
||||
proxy-from-env@1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
|
||||
@@ -4045,6 +4112,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==}
|
||||
|
||||
@@ -4153,6 +4223,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.12:
|
||||
resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4549,6 +4622,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'}
|
||||
@@ -6046,6 +6122,8 @@ snapshots:
|
||||
tinyglobby: 0.2.17
|
||||
yaml: 2.9.0
|
||||
|
||||
'@mapbox/jsonlint-lines-primitives@2.0.3': {}
|
||||
|
||||
'@mapbox/node-pre-gyp@2.0.3(supports-color@10.2.2)':
|
||||
dependencies:
|
||||
consola: 3.4.2
|
||||
@@ -6059,6 +6137,41 @@ snapshots:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
'@mapbox/point-geometry@1.1.0': {}
|
||||
|
||||
'@mapbox/tiny-sdf@2.2.0': {}
|
||||
|
||||
'@mapbox/unitbezier@1.0.0': {}
|
||||
|
||||
'@mapbox/vector-tile@3.0.0':
|
||||
dependencies:
|
||||
'@mapbox/point-geometry': 1.1.0
|
||||
'@types/geojson': 7946.0.16
|
||||
pbf: 5.1.2
|
||||
|
||||
'@maplibre/geojson-vt@6.1.1':
|
||||
dependencies:
|
||||
kdbush: 4.1.0
|
||||
|
||||
'@maplibre/maplibre-gl-style-spec@26.4.4':
|
||||
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.3.0':
|
||||
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
|
||||
|
||||
'@mdx-js/mdx@3.1.1(supports-color@10.2.2)':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
@@ -6464,6 +6577,8 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/geojson@7946.0.16': {}
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -7002,6 +7117,10 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.11.6: {}
|
||||
|
||||
bidi-js@1.1.0:
|
||||
dependencies:
|
||||
require-from-string: 2.0.2
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
bindings@1.5.0:
|
||||
@@ -7341,6 +7460,8 @@ snapshots:
|
||||
|
||||
duplexer@0.1.2: {}
|
||||
|
||||
earcut@3.2.4: {}
|
||||
|
||||
editorconfig@3.0.2:
|
||||
dependencies:
|
||||
'@one-ini/wasm': 0.2.1
|
||||
@@ -7746,6 +7867,8 @@ snapshots:
|
||||
|
||||
github-slugger@2.0.0: {}
|
||||
|
||||
gl-matrix@3.4.4: {}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
@@ -8126,6 +8249,8 @@ snapshots:
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||
|
||||
json-stringify-pretty-compact@4.0.0: {}
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonc-parser@2.3.1: {}
|
||||
@@ -8144,6 +8269,8 @@ snapshots:
|
||||
dependencies:
|
||||
commander: 8.3.0
|
||||
|
||||
kdbush@4.1.0: {}
|
||||
|
||||
keyv@5.6.0:
|
||||
dependencies:
|
||||
'@keyv/serialize': 1.1.1
|
||||
@@ -8316,6 +8443,27 @@ snapshots:
|
||||
|
||||
make-synchronized@0.8.0: {}
|
||||
|
||||
maplibre-gl@6.11.2:
|
||||
dependencies:
|
||||
'@mapbox/point-geometry': 1.1.0
|
||||
'@mapbox/tiny-sdf': 2.2.0
|
||||
'@mapbox/unitbezier': 1.0.0
|
||||
'@mapbox/vector-tile': 3.0.0
|
||||
'@maplibre/geojson-vt': 6.1.1
|
||||
'@maplibre/maplibre-gl-style-spec': 26.4.4
|
||||
'@maplibre/mlt': 1.3.0
|
||||
'@maplibre/vt-pbf': 4.3.2
|
||||
'@types/geojson': 7946.0.16
|
||||
bidi-js: 1.1.0
|
||||
earcut: 3.2.4
|
||||
gl-matrix: 3.4.4
|
||||
kdbush: 4.1.0
|
||||
murmurhash-js: 1.0.0
|
||||
pbf: 5.1.2
|
||||
potpack: 2.1.0
|
||||
quickselect: 3.0.0
|
||||
tinyqueue: 3.0.0
|
||||
|
||||
markdown-extensions@2.0.0: {}
|
||||
|
||||
markdown-it@14.3.0:
|
||||
@@ -8864,6 +9012,8 @@ snapshots:
|
||||
|
||||
muggle-string@0.4.1: {}
|
||||
|
||||
murmurhash-js@1.0.0: {}
|
||||
|
||||
nanoid@3.3.18: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
@@ -9064,6 +9214,10 @@ snapshots:
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pbf@5.1.2:
|
||||
dependencies:
|
||||
resolve-protobuf-schema: 2.1.0
|
||||
|
||||
piccolore@0.1.3: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
@@ -9131,6 +9285,8 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
potpack@2.1.0: {}
|
||||
|
||||
preact@10.12.1: {}
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
@@ -9160,6 +9316,8 @@ snapshots:
|
||||
|
||||
proto-list@1.2.4: {}
|
||||
|
||||
protocol-buffers-schema@3.6.1: {}
|
||||
|
||||
proxy-from-env@1.1.0: {}
|
||||
|
||||
pstree.remy@1.1.8: {}
|
||||
@@ -9179,6 +9337,8 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
quickselect@3.0.0: {}
|
||||
|
||||
radix3@1.1.2: {}
|
||||
|
||||
rangetouch@2.0.1: {}
|
||||
@@ -9331,6 +9491,10 @@ snapshots:
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
resolve-protobuf-schema@2.1.0:
|
||||
dependencies:
|
||||
protocol-buffers-schema: 3.6.1
|
||||
|
||||
resolve@1.22.12:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -9858,6 +10022,8 @@ snapshots:
|
||||
fdir: 6.5.0(picomatch@4.0.7)
|
||||
picomatch: 4.0.7
|
||||
|
||||
tinyqueue@3.0.0: {}
|
||||
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# Google Maps JavaScript API key for the map demo page (map-fullsize).
|
||||
# Optional. Restrict the key by HTTP referrer in the Google Cloud console.
|
||||
GOOGLE_MAPS_KEY=
|
||||
|
||||
# Mapbox public access token (pk.…) for the Mapbox GL map demos.
|
||||
# Optional. Restrict the token by URL in the Mapbox account settings.
|
||||
MAPBOX_KEY=
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
|
||||
import MapStoreLocator from '@components/cards/MapStoreLocator.astro'
|
||||
import MapDeliveryTracker from '@components/cards/MapDeliveryTracker.astro'
|
||||
import MapLiveUsers from '@components/cards/MapLiveUsers.astro'
|
||||
import MapUsersByCountry from '@components/cards/MapUsersByCountry.astro'
|
||||
import MapAddressPicker from '@components/cards/MapAddressPicker.astro'
|
||||
import MapServerStatus from '@components/cards/MapServerStatus.astro'
|
||||
import MapProperties from '@components/cards/MapProperties.astro'
|
||||
import DocsLink from '@ui/DocsLink.astro'
|
||||
---
|
||||
|
||||
<DefaultLayout title="Map blocks" pageMenu="plugins.map-blocks" pageLibs={['maplibre-gl', 'jsvectormap']} description="Ready-made blocks that pair a map with a list, a card or a table.">
|
||||
<DocsLink slot="page-header-actions" path="/ui/components/map" />
|
||||
|
||||
<div class="row row-cards">
|
||||
<div class="col-12">
|
||||
<MapStoreLocator />
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<MapDeliveryTracker />
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<MapLiveUsers />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<MapProperties />
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<MapAddressPicker />
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<MapServerStatus />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<MapUsersByCountry />
|
||||
</div>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
@@ -1,27 +1,8 @@
|
||||
---
|
||||
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
|
||||
import CaptureScript from '@shared/components/CaptureScript.astro'
|
||||
---
|
||||
|
||||
<DefaultLayout title="Full Size Map" pageHeader={false} pageMenu="plugins.map-fullsize" pageLibs={['google-maps']} navbarPosition="vertical" wrapperFull>
|
||||
<DefaultLayout title="Full Size Map" pageHeader={false} pageMenu="plugins.map-fullsize" pageLibs={['maplibre-gl']} navbarPosition="vertical" wrapperFull>
|
||||
<h1 class="visually-hidden">Full Size Map</h1>
|
||||
<div class="map flex-fill" id="map-google"></div>
|
||||
<CaptureScript>
|
||||
<!-- BEGIN MAP SCRIPT -->
|
||||
<script is:inline>
|
||||
window.tabler_map ??= {}
|
||||
|
||||
function initMap() {
|
||||
const map = new google.maps.Map(document.getElementById('map-google'), {
|
||||
center: { lat: -34.397, lng: 150.644 },
|
||||
zoom: 8,
|
||||
})
|
||||
|
||||
window.tabler_map['map-google'] = map
|
||||
}
|
||||
|
||||
document.readyState !== 'loading' ? initMap() : document.addEventListener('DOMContentLoaded', initMap, { once: true })
|
||||
</script>
|
||||
<!-- END MAP SCRIPT -->
|
||||
</CaptureScript>
|
||||
<div class="map flex-fill" id="map-fullsize" role="region" aria-label="Map of Berlin" data-bs-toggle="map" data-bs-center="13.4049,52.518827" data-bs-zoom="12" data-bs-controls="zoom,north,locate,fullscreen"></div>
|
||||
</DefaultLayout>
|
||||
+371
-1
@@ -5,13 +5,78 @@ import Card from '@ui/Card.astro'
|
||||
import CardBody from '@ui/CardBody.astro'
|
||||
import maps from '@data/maps.json'
|
||||
import CardTitle from '@ui/CardTitle.astro'
|
||||
import CardSubtitle from '@ui/CardSubtitle.astro'
|
||||
import DocsLink from '@ui/DocsLink.astro'
|
||||
import Avatar from '@ui/Avatar.astro'
|
||||
import Icon from '@ui/Icon.astro'
|
||||
import { staticPath } from '@shared/lib/assets'
|
||||
import CaptureScript from '@shared/components/CaptureScript.astro'
|
||||
|
||||
type MapData = { title?: string; card?: boolean }
|
||||
const mapEntries = Object.entries(maps as Record<string, MapData>)
|
||||
|
||||
const route = [
|
||||
[13.3695, 52.5251],
|
||||
[13.3777, 52.5208],
|
||||
[13.388, 52.5186],
|
||||
[13.3989, 52.5176],
|
||||
[13.405, 52.5192],
|
||||
[13.4132, 52.5219],
|
||||
[13.4215, 52.5165],
|
||||
[13.43, 52.5105],
|
||||
[13.4397, 52.505],
|
||||
]
|
||||
|
||||
const area = [
|
||||
[13.37, 52.53],
|
||||
[13.42, 52.535],
|
||||
[13.445, 52.52],
|
||||
[13.43, 52.505],
|
||||
[13.385, 52.503],
|
||||
[13.365, 52.515],
|
||||
[13.37, 52.53],
|
||||
]
|
||||
|
||||
const cities = [
|
||||
{ name: 'Berlin', lng: 13.405, lat: 52.52, users: 820 },
|
||||
{ name: 'London', lng: -0.1276, lat: 51.5072, users: 1240 },
|
||||
{ name: 'New York', lng: -74.006, lat: 40.7128, users: 1580 },
|
||||
{ name: 'Tokyo', lng: 139.6917, lat: 35.6895, users: 960 },
|
||||
{ name: 'São Paulo', lng: -46.6333, lat: -23.5505, users: 540 },
|
||||
{ name: 'Sydney', lng: 151.2093, lat: -33.8688, users: 310 },
|
||||
{ name: 'Lagos', lng: 3.3792, lat: 6.5244, users: 420 },
|
||||
{ name: 'Mumbai', lng: 72.8777, lat: 19.076, users: 1100 },
|
||||
]
|
||||
|
||||
// Stores scattered around a few European cities; the offsets are fixed, so every build draws the same map
|
||||
const storeCities: [number, number][] = [
|
||||
[13.405, 52.52],
|
||||
[2.3522, 48.8566],
|
||||
[-0.1276, 51.5072],
|
||||
[12.4964, 41.9028],
|
||||
[-3.7038, 40.4168],
|
||||
[21.0122, 52.2297],
|
||||
[16.3738, 48.2082],
|
||||
[4.9041, 52.3676],
|
||||
]
|
||||
const stores = storeCities.flatMap(([lng, lat], city) => Array.from({ length: 5 + (city % 4) }, (_, index) => [lng + Math.sin(city * 7 + index * 3) * 0.6, lat + Math.cos(city * 5 + index * 2) * 0.4]))
|
||||
|
||||
const hub = { name: 'London', lng: -0.1276, lat: 51.5072 }
|
||||
const destinations = [
|
||||
{ name: 'Tokyo', lng: 139.6917, lat: 35.6895 },
|
||||
{ name: 'Mumbai', lng: 72.8777, lat: 19.076 },
|
||||
{ name: 'Singapore', lng: 103.8198, lat: 1.3521 },
|
||||
{ name: 'Nairobi', lng: 36.8219, lat: -1.2921 },
|
||||
]
|
||||
|
||||
const places = [
|
||||
{ name: 'Museum Island', text: 'Five museums on the Spree, open daily.', photo: 'a-visit-to-the-bookstore.jpg', lat: 52.5169, lng: 13.4019, open: true },
|
||||
{ name: 'Bellevue Palace', text: 'Residence by the Tiergarten park.', photo: 'book-on-the-grass.jpg', lat: 52.5176, lng: 13.3531, open: false },
|
||||
{ name: 'Weissensee', text: 'A lake with a beach in the north-east.', photo: 'brainstorming-session-with-creative-designers.jpg', lat: 52.5546, lng: 13.4633, open: false },
|
||||
]
|
||||
---
|
||||
|
||||
<DefaultLayout title="Maps" pageMenu="plugins.maps" pageLibs={['mapbox']} description="Interactive Mapbox maps — plain, styled, or full-card with custom markers.">
|
||||
<DefaultLayout title="Maps" pageMenu="plugins.maps" pageLibs={['maplibre-gl']} description="Interactive MapLibre maps — plain, styled, with custom markers and popups.">
|
||||
<DocsLink slot="page-header-actions" path="/ui/components/map" />
|
||||
<div class="row row-cards">
|
||||
{
|
||||
@@ -34,5 +99,310 @@ const mapEntries = Object.entries(maps as Record<string, MapData>)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
<div class="col-lg-6">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Marker styles</CardTitle>
|
||||
<CardSubtitle>Draw markers in HTML with <code>.map-marker</code>: a dot, a circle with an icon or a count, a pin, or an avatar. Add <code>.map-marker-label</code> for a short text, and <code>.map-marker-pulse</code> for a live position.</CardSubtitle>
|
||||
<Map mapId="marker-styles" title="Marker styles" center={[52.522, 13.405]} zoom={11} ratio="4x3">
|
||||
<span class="map-marker map-marker-dot" data-bs-lng="13.345" data-bs-lat="52.54"></span>
|
||||
<span class="map-marker map-marker-dot map-marker-red" data-bs-lng="13.39" data-bs-lat="52.545"></span>
|
||||
<span class="map-marker" data-bs-lng="13.445" data-bs-lat="52.538"><Icon name="building-store" /><span class="map-marker-label">Store</span></span>
|
||||
<span class="map-marker map-marker-green" data-bs-lng="13.35" data-bs-lat="52.512">12</span>
|
||||
<span class="map-marker map-marker-dot map-marker-green map-marker-pulse" data-bs-lng="13.375" data-bs-lat="52.535"></span>
|
||||
<span class="map-marker map-marker-pin" data-bs-lng="13.405" data-bs-lat="52.52" data-bs-anchor="bottom"><Icon name="home" /></span>
|
||||
<span class="map-marker map-marker-pin map-marker-red" data-bs-lng="13.47" data-bs-lat="52.518" data-bs-anchor="bottom"><Icon name="star" /></span>
|
||||
<span class="map-marker" data-bs-lng="13.43" data-bs-lat="52.503"><Avatar size="sm" src={staticPath('avatars/000m.jpg')} /></span>
|
||||
</Map>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Popup card</CardTitle>
|
||||
<CardSubtitle>Put the popup content in a <code><template></code> inside the marker. <code>.map-popup-card</code> makes room for an image.</CardSubtitle>
|
||||
<Map mapId="popup-card" title="Popup card" center={[52.546, 13.4]} zoom={11} ratio="4x3">
|
||||
{
|
||||
places.map((place) => (
|
||||
<span class="map-marker map-marker-pin" data-bs-lng={place.lng} data-bs-lat={place.lat} data-bs-anchor="bottom" data-bs-popup-open={place.open ? 'true' : undefined}>
|
||||
<Icon name="map-pin" />
|
||||
<template class="map-popup-card" data-bs-close-button="true">
|
||||
<div class="img-responsive img-responsive-21x9" style={`background-image: url(${staticPath(`photos/${place.photo}`)})`} />
|
||||
<div class="p-3">
|
||||
<div class="fw-medium">{place.name}</div>
|
||||
<div class="text-secondary mb-3">{place.text}</div>
|
||||
<a href="#" class="btn btn-primary btn-sm">
|
||||
View details
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</Map>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Route</CardTitle>
|
||||
<CardSubtitle>Draw a path with <code>data-bs-route</code>, a list of points, and mark its ends with markers.</CardSubtitle>
|
||||
<Map mapId="route" title="Route" center={[52.513, 13.4046]} zoom={11.6} route={route}>
|
||||
<span class="map-marker map-marker-dot" data-bs-lng="13.3695" data-bs-lat="52.5251"></span>
|
||||
<span class="map-marker map-marker-pin" data-bs-lng="13.4397" data-bs-lat="52.505" data-bs-anchor="bottom"><Icon name="map-pin" /></span>
|
||||
</Map>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Area</CardTitle>
|
||||
<CardSubtitle>Show a zone, a line or points with <code>data-bs-geojson</code>: a GeoJSON object, or the URL of a file.</CardSubtitle>
|
||||
<Map mapId="area" title="Area" center={[52.519, 13.405]} zoom={11} geojson={{ type: 'Feature', geometry: { type: 'Polygon', coordinates: [area] } }} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Shapes from a file</CardTitle>
|
||||
<CardSubtitle>
|
||||
Point <code>data-bs-geojson</code> at a GeoJSON file to draw its areas, lines and points. <code>data-bs-layer-color</code> sets their color, and <code>data-bs-labels</code> names the buttons.
|
||||
</CardSubtitle>
|
||||
<Map mapId="geojson-file" title="Shapes from a file" center={[52.5135, 13.39]} zoom={12.2} ratio="21x9" geojson={staticPath('maps/berlin-places.geojson')} layerColor="--tblr-green" controls labels={{ 'zoom-in': 'Closer', 'zoom-out': 'Farther' }} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Clusters</CardTitle>
|
||||
<CardSubtitle>Group many points into clusters that split as you zoom in. Click a cluster to zoom to it.</CardSubtitle>
|
||||
<Map mapId="clusters" title="Clusters" center={[48.5, 9]} zoom={3} controls="zoom,fullscreen" />
|
||||
<CaptureScript>
|
||||
<script is:inline define:vars={{ stores }}>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const view = tabler.MapView.getInstance('#map-clusters')
|
||||
|
||||
view.onStyle((map) => {
|
||||
const color = tabler.MapView.color('--tblr-primary')
|
||||
const border = tabler.MapView.color('--tblr-bg-surface')
|
||||
|
||||
map.addSource('stores', {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: stores.map((coordinates) => ({ type: 'Feature', geometry: { type: 'Point', coordinates } })) },
|
||||
cluster: true,
|
||||
clusterRadius: 40,
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'clusters',
|
||||
type: 'circle',
|
||||
source: 'stores',
|
||||
filter: ['has', 'point_count'],
|
||||
paint: { 'circle-color': color, 'circle-radius': ['step', ['get', 'point_count'], 14, 10, 18, 25, 24], 'circle-stroke-width': 2, 'circle-stroke-color': border },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'cluster-count',
|
||||
type: 'symbol',
|
||||
source: 'stores',
|
||||
filter: ['has', 'point_count'],
|
||||
layout: { 'text-field': ['get', 'point_count_abbreviated'], 'text-font': ['Noto Sans Regular'], 'text-size': 12 },
|
||||
paint: { 'text-color': tabler.MapView.color('--tblr-primary-fg') },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'store',
|
||||
type: 'circle',
|
||||
source: 'stores',
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: { 'circle-color': color, 'circle-radius': 6, 'circle-stroke-width': 2, 'circle-stroke-color': border },
|
||||
})
|
||||
})
|
||||
|
||||
view.ready((map) => {
|
||||
map.on('click', 'clusters', (event) => {
|
||||
const cluster = event.features[0]
|
||||
|
||||
map
|
||||
.getSource('stores')
|
||||
.getClusterExpansionZoom(cluster.properties.cluster_id)
|
||||
.then((zoom) => map.easeTo({ center: cluster.geometry.coordinates, zoom }))
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</CaptureScript>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>City switcher</CardTitle>
|
||||
<CardSubtitle>Put buttons over the map and move it to a place with <code>flyTo</code>.</CardSubtitle>
|
||||
<Map mapId="cities" title="City switcher" center={[52.52, 13.405]} zoom={10}>
|
||||
<div slot="overlay" class="btn-list position-absolute top-0 start-0 w-auto h-auto m-2" role="group" aria-label="City" id="map-cities-switcher">
|
||||
{
|
||||
cities.slice(0, 4).map((city, index) => (
|
||||
<button type="button" class:list={['btn btn-sm btn-pill', index === 0 && 'btn-primary']} aria-pressed={index === 0 ? 'true' : 'false'} data-lng={city.lng} data-lat={city.lat}>
|
||||
{city.name}
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</Map>
|
||||
<CaptureScript>
|
||||
<script is:inline>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const view = tabler.MapView.getInstance('#map-cities')
|
||||
const buttons = document.querySelectorAll('#map-cities-switcher .btn')
|
||||
|
||||
buttons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
buttons.forEach((item) => {
|
||||
item.classList.toggle('btn-primary', item === button)
|
||||
item.setAttribute('aria-pressed', item === button)
|
||||
})
|
||||
view.map.flyTo({ center: [Number(button.dataset.lng), Number(button.dataset.lat)], zoom: 10 })
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</CaptureScript>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-12">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Bubble map</CardTitle>
|
||||
<CardSubtitle>Size circles by a value, for example users per city. Hover a circle to see the number.</CardSubtitle>
|
||||
<Map mapId="bubbles" title="Bubble map" center={[20, 20]} zoom={1} ratio="21x9" />
|
||||
<CaptureScript>
|
||||
<script is:inline define:vars={{ cities }}>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const view = tabler.MapView.getInstance('#map-bubbles')
|
||||
|
||||
view.onStyle((map) => {
|
||||
const color = tabler.MapView.color('--tblr-primary')
|
||||
|
||||
map.addSource('cities', {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: cities.map((city) => ({ type: 'Feature', properties: city, geometry: { type: 'Point', coordinates: [city.lng, city.lat] } })) },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'cities',
|
||||
type: 'circle',
|
||||
source: 'cities',
|
||||
paint: { 'circle-color': color, 'circle-opacity': 0.6, 'circle-radius': ['interpolate', ['linear'], ['get', 'users'], 0, 4, 1600, 24] },
|
||||
})
|
||||
})
|
||||
|
||||
// The handlers need the map and the library, which may load a moment after the page
|
||||
view.ready((map) => {
|
||||
const tooltip = new maplibregl.Popup({ closeButton: false, offset: 12, className: 'map-tooltip' })
|
||||
|
||||
map.on('mousemove', 'cities', (event) => {
|
||||
const city = event.features[0].properties
|
||||
|
||||
map.getCanvas().style.cursor = 'pointer'
|
||||
tooltip.setLngLat([city.lng, city.lat]).setText(`${city.name}: ${city.users} users`).addTo(map)
|
||||
})
|
||||
map.on('mouseleave', 'cities', () => {
|
||||
map.getCanvas().style.cursor = ''
|
||||
tooltip.remove()
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</CaptureScript>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Globe</CardTitle>
|
||||
<CardSubtitle>Show the map as a 3D globe with <code>data-bs-globe="true"</code>, and connect places with arcs.</CardSubtitle>
|
||||
<Map mapId="globe" title="Globe" center={[32, 62]} zoom={1.3} ratio="4x3" globe>
|
||||
<span class="map-marker map-marker-dot" data-bs-lng={hub.lng} data-bs-lat={hub.lat}>
|
||||
<span class="map-marker-label">{hub.name}</span>
|
||||
</span>
|
||||
{
|
||||
destinations.map((place) => (
|
||||
<span class="map-marker map-marker-dot" style="--tblr-map-marker-size: 0.625rem" data-bs-lng={place.lng} data-bs-lat={place.lat}>
|
||||
<span class="map-marker-label">{place.name}</span>
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</Map>
|
||||
<CaptureScript>
|
||||
<script is:inline define:vars={{ hub, destinations }}>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const view = tabler.MapView.getInstance('#map-globe')
|
||||
|
||||
// Points along the shortest path between two places on the globe
|
||||
const arc = (from, to, steps = 64) => {
|
||||
const [lng1, lat1, lng2, lat2] = [from.lng, from.lat, to.lng, to.lat].map((degrees) => (degrees * Math.PI) / 180)
|
||||
const angle = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
|
||||
|
||||
return Array.from({ length: steps + 1 }, (_, step) => {
|
||||
const start = Math.sin((1 - step / steps) * angle) / Math.sin(angle)
|
||||
const end = Math.sin((step / steps) * angle) / Math.sin(angle)
|
||||
const x = start * Math.cos(lat1) * Math.cos(lng1) + end * Math.cos(lat2) * Math.cos(lng2)
|
||||
const y = start * Math.cos(lat1) * Math.sin(lng1) + end * Math.cos(lat2) * Math.sin(lng2)
|
||||
const z = start * Math.sin(lat1) + end * Math.sin(lat2)
|
||||
|
||||
return [(Math.atan2(y, x) * 180) / Math.PI, (Math.atan2(z, Math.hypot(x, y)) * 180) / Math.PI]
|
||||
})
|
||||
}
|
||||
|
||||
view.onStyle((map) => {
|
||||
// Hide the place names of the map style, so only the marker labels are left
|
||||
map
|
||||
.getStyle()
|
||||
.layers.filter((layer) => layer.type === 'symbol')
|
||||
.forEach((layer) => map.setLayoutProperty(layer.id, 'visibility', 'none'))
|
||||
|
||||
map.addSource('arcs', {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: destinations.map((place) => ({ type: 'Feature', geometry: { type: 'LineString', coordinates: arc(hub, place) } })) },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'arcs',
|
||||
type: 'line',
|
||||
source: 'arcs',
|
||||
paint: { 'line-color': tabler.MapView.color('--tblr-primary'), 'line-width': 1.5, 'line-dasharray': [2, 2] },
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</CaptureScript>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Draggable marker</CardTitle>
|
||||
<CardSubtitle>Add <code>data-bs-draggable="true"</code> to let people move a marker, for example to pick an address.</CardSubtitle>
|
||||
<Map mapId="draggable" title="Draggable marker" center={[52.52, 13.405]} zoom={12} ratio="4x3">
|
||||
<span class="map-marker map-marker-pin" data-bs-lng="13.405" data-bs-lat="52.52" data-bs-anchor="bottom" data-bs-draggable="true"><Icon name="map-pin" /></span>
|
||||
</Map>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
@@ -73,11 +73,14 @@ const themeAttributes = themeDataAttributes({ ...theme, 'navbar-position': navba
|
||||
// whatever core's own watcher last emitted, which is unminified.
|
||||
const min = import.meta.env.DEV ? '' : '.min'
|
||||
|
||||
type Lib = { npm?: string; js?: string[]; css?: string[] }
|
||||
type Lib = { npm?: string; js?: string[]; css?: string[]; module?: { file: string; global: string }; addon?: string }
|
||||
const pageLibEntries = Object.entries(libs as Record<string, Lib>).filter(([name]) => pageLibs.includes(name))
|
||||
const libHref = (lib: Lib, file: string) => `/dist/libs/${lib.npm}/${file}`
|
||||
const libCssFiles = pageLibEntries.flatMap(([, lib]) => (lib.css ?? []).map((file) => libHref(lib, file)))
|
||||
const libJsFiles = pageLibEntries.flatMap(([, lib]) => (lib.js ?? []).map((file) => libHref(lib, file)))
|
||||
// ES-module-only libraries: imported in a module script and exposed as a global (see BaseLayout)
|
||||
const libAddons = pageLibEntries.flatMap(([, lib]) => (lib.addon ? [lib.addon] : []))
|
||||
const libModules = pageLibEntries.flatMap(([, lib]) => (lib.module ? [`import * as lib from '${libHref(lib, lib.module.file)}'; window.${lib.module.global} = lib`] : []))
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -90,12 +93,13 @@ const libJsFiles = pageLibEntries.flatMap(([, lib]) => (lib.js ?? []).map((file)
|
||||
|
||||
<link rel="icon" href="./favicon-dev.ico" type="image/x-icon" />
|
||||
|
||||
{/* Library styles first, so the Tabler overrides in tabler-vendors.css win (same order as BaseLayout) */}
|
||||
{libCssFiles.map((href) => <link href={href} rel="stylesheet" />)}
|
||||
|
||||
<link href={`./dist/css/tabler${min}.css`} rel="stylesheet" />
|
||||
|
||||
{stylePlugins.map((plugin) => <link href={`./dist/css/tabler-${plugin}${min}.css`} rel="stylesheet" />)}
|
||||
|
||||
{libCssFiles.map((href) => <link href={href} rel="stylesheet" />)}
|
||||
|
||||
<!-- Inter for the captures: Tabler ships system font stacks (no web font),
|
||||
and the body's cv03/cv04/cv11 feature settings were tuned for Inter, so the
|
||||
pictures show the face the design was drawn in — the same on every machine
|
||||
@@ -177,7 +181,9 @@ const libJsFiles = pageLibEntries.flatMap(([, lib]) => (lib.js ?? []).map((file)
|
||||
</nav>
|
||||
|
||||
{libJsFiles.map((src) => <script is:inline src={src} defer />)}
|
||||
{libModules.map((code) => <Fragment set:html={`<script type="module">${code}</script>`} />)}
|
||||
<script is:inline src={`/dist/js/tabler${min}.js`} defer></script>
|
||||
{libAddons.map((addon) => <script is:inline src={`/dist/js/${addon}${min}.js`} defer />)}
|
||||
|
||||
<PageScripts />
|
||||
|
||||
@@ -188,7 +194,21 @@ const libJsFiles = pageLibEntries.flatMap(([, lib]) => (lib.js ?? []).map((file)
|
||||
if (document.readyState !== 'loading') resolve(undefined)
|
||||
else document.addEventListener('DOMContentLoaded', () => resolve(undefined), { once: true })
|
||||
})
|
||||
Promise.all([document.fonts.ready, domReady]).then(() => {
|
||||
// Maps draw their tiles after the page is ready, so wait until each one has loaded.
|
||||
// The first check is delayed, so the maps exist by then.
|
||||
const mapsReady = () =>
|
||||
new Promise((resolve) => {
|
||||
const check = () => {
|
||||
const { MapView } = (window as { tabler?: { MapView?: { getInstance(element: Element): { map: { loaded(): boolean } | null } | null } } }).tabler ?? {}
|
||||
const maps = [...document.querySelectorAll('[data-bs-toggle="map"]')].map((element) => MapView?.getInstance(element)?.map)
|
||||
|
||||
if (maps.every((map) => map?.loaded())) resolve(undefined)
|
||||
else setTimeout(check, 100)
|
||||
}
|
||||
|
||||
setTimeout(check, 100)
|
||||
})
|
||||
Promise.all([document.fonts.ready, domReady, mapsReady()]).then(() => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => (document.documentElement.dataset.screenshotReady = 'true')))
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -45,11 +45,14 @@ const rtlSuffix = rtl ? '.rtl' : ''
|
||||
// build instead of hardcoding one variant.
|
||||
const min = import.meta.env.DEV ? '' : '.min'
|
||||
|
||||
type Lib = { npm?: string; js?: string[]; css?: string[] }
|
||||
type Lib = { npm?: string; js?: string[]; css?: string[]; module?: { file: string; global: string }; addon?: string }
|
||||
const pageLibEntries = Object.entries(libs as Record<string, Lib>).filter(([name]) => pageLibs.includes(name))
|
||||
const libHref = (lib: Lib, file: string) => `/dist/libs/${lib.npm}/${file}`
|
||||
const libCssFiles = pageLibEntries.flatMap(([, lib]) => (lib.css ?? []).map((file) => libHref(lib, file)))
|
||||
const libJsFiles = pageLibEntries.flatMap(([, lib]) => (lib.js ?? []).map((file) => libHref(lib, file)))
|
||||
// ES-module-only libraries: imported in a module script and exposed as a global (see BaseLayout)
|
||||
const libAddons = pageLibEntries.flatMap(([, lib]) => (lib.addon ? [lib.addon] : []))
|
||||
const libModules = pageLibEntries.flatMap(([, lib]) => (lib.module ? [`import * as lib from '${libHref(lib, lib.module.file)}'; window.${lib.module.global} = lib`] : []))
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -62,12 +65,13 @@ const libJsFiles = pageLibEntries.flatMap(([, lib]) => (lib.js ?? []).map((file)
|
||||
|
||||
<link rel="icon" href="./favicon-dev.ico" type="image/x-icon" />
|
||||
|
||||
{/* Library styles first, so the Tabler overrides in tabler-vendors.css win (same order as BaseLayout) */}
|
||||
{libCssFiles.map((href) => <link href={href} rel="stylesheet" />)}
|
||||
|
||||
<link href={`./dist/css/tabler${rtlSuffix}${min}.css`} rel="stylesheet" />
|
||||
|
||||
{stylePlugins.map((plugin) => <link href={`./dist/css/tabler-${plugin}${rtlSuffix}${min}.css`} rel="stylesheet" />)}
|
||||
|
||||
{libCssFiles.map((href) => <link href={href} rel="stylesheet" />)}
|
||||
|
||||
<!-- Inter for the captures: Tabler ships system font stacks (no web font),
|
||||
and the body's cv03/cv04/cv11 feature settings were tuned for Inter, so the
|
||||
pictures show the face the design was drawn in — the same on every machine
|
||||
@@ -168,7 +172,9 @@ const libJsFiles = pageLibEntries.flatMap(([, lib]) => (lib.js ?? []).map((file)
|
||||
</div>
|
||||
|
||||
{libJsFiles.map((src) => <script is:inline src={src} defer />)}
|
||||
{libModules.map((code) => <Fragment set:html={`<script type="module">${code}</script>`} />)}
|
||||
<script is:inline src={`/dist/js/tabler${min}.js`} defer></script>
|
||||
{libAddons.map((addon) => <script is:inline src={`/dist/js/${addon}${min}.js`} defer />)}
|
||||
|
||||
<!-- components that render via CaptureScript (e.g. Chart) queue their init script
|
||||
into shared/lib/page-scripts instead of inlining it where used; this drains it. -->
|
||||
@@ -185,7 +191,21 @@ const libJsFiles = pageLibEntries.flatMap(([, lib]) => (lib.js ?? []).map((file)
|
||||
if (document.readyState !== 'loading') resolve(undefined)
|
||||
else document.addEventListener('DOMContentLoaded', () => resolve(undefined), { once: true })
|
||||
})
|
||||
Promise.all([document.fonts.ready, domReady]).then(() => {
|
||||
// Maps draw their tiles after the page is ready, so wait until each one has loaded.
|
||||
// The first check is delayed, so the maps exist by then.
|
||||
const mapsReady = () =>
|
||||
new Promise((resolve) => {
|
||||
const check = () => {
|
||||
const { MapView } = (window as { tabler?: { MapView?: { getInstance(element: Element): { map: { loaded(): boolean } | null } | null } } }).tabler ?? {}
|
||||
const maps = [...document.querySelectorAll('[data-bs-toggle="map"]')].map((element) => MapView?.getInstance(element)?.map)
|
||||
|
||||
if (maps.every((map) => map?.loaded())) resolve(undefined)
|
||||
else setTimeout(check, 100)
|
||||
}
|
||||
|
||||
setTimeout(check, 100)
|
||||
})
|
||||
Promise.all([document.fonts.ready, domReady, mapsReady()]).then(() => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => (document.documentElement.dataset.screenshotReady = 'true')))
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
// Delivery tracker block: a map with the route of a delivery, solid where the
|
||||
// courier has been and dashed ahead, the courier and the destination as
|
||||
// markers, and the order status in a card over the map.
|
||||
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
|
||||
import MapDeliveryTracker from '@components/cards/MapDeliveryTracker.astro'
|
||||
---
|
||||
|
||||
<ScreenshotLayout title="Map: delivery tracker" columns={1.8} pageLibs={['maplibre-gl']}>
|
||||
<MapDeliveryTracker />
|
||||
</ScreenshotLayout>
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
// Live users block: a world map with a circle for each city, sized by the
|
||||
// number of active users, and the total in a card over the map.
|
||||
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
|
||||
import MapLiveUsers from '@components/cards/MapLiveUsers.astro'
|
||||
---
|
||||
|
||||
<ScreenshotLayout title="Map: live users" columns={1.8} pageLibs={['maplibre-gl']}>
|
||||
<MapLiveUsers />
|
||||
</ScreenshotLayout>
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
// Store locator block: a searchable list of stores next to a map. The first
|
||||
// store is selected, so its row is highlighted and its popup is open.
|
||||
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
|
||||
import MapStoreLocator from '@components/cards/MapStoreLocator.astro'
|
||||
---
|
||||
|
||||
<ScreenshotLayout title="Map: store locator" columns={3} pageLibs={['maplibre-gl']}>
|
||||
<MapStoreLocator />
|
||||
</ScreenshotLayout>
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
// An address form next to a map: dragging the pin fills the position field,
|
||||
// for an address the text alone does not pin down.
|
||||
import Map from '../demo/Map.astro'
|
||||
import CaptureScript from '../CaptureScript.astro'
|
||||
import Card from '@ui/Card.astro'
|
||||
import CardBody from '@ui/CardBody.astro'
|
||||
import CardHeader from '@ui/CardHeader.astro'
|
||||
import CardTitle from '@ui/CardTitle.astro'
|
||||
import Icon from '@ui/Icon.astro'
|
||||
---
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Delivery address</CardTitle>
|
||||
</CardHeader>
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-5 border-end">
|
||||
<CardBody>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="address-street">Street</label>
|
||||
<input type="text" class="form-control" id="address-street" value="Karl-Liebknecht-Straße 9" />
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-4">
|
||||
<label class="form-label" for="address-postcode">Postal code</label>
|
||||
<input type="text" class="form-control" id="address-postcode" value="10178" />
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<label class="form-label" for="address-city">City</label>
|
||||
<input type="text" class="form-control" id="address-city" value="Berlin" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="address-position">Position</label>
|
||||
<input type="text" class="form-control" id="address-position" value="52.52190, 13.40940" readonly />
|
||||
<small class="form-hint">Drag the pin on the map to the entrance.</small>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary">Save address</button>
|
||||
</CardBody>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<Map mapId="address" title="Delivery address" center={[52.5219, 13.4094]} zoom={15} height="24rem" rounded={false} controls>
|
||||
<span class="map-marker map-marker-pin" data-bs-lng="13.4094" data-bs-lat="52.5219" data-bs-anchor="bottom" data-bs-draggable="true"><Icon name="map-pin" /></span>
|
||||
</Map>
|
||||
</div>
|
||||
</div>
|
||||
<CaptureScript>
|
||||
<script is:inline>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const view = tabler.MapView.getInstance('#map-address')
|
||||
|
||||
// The marker exists once the map does, which may be a moment after the page has loaded
|
||||
view.ready(() => {
|
||||
const marker = view.markers[0]
|
||||
|
||||
marker.on('drag', () => {
|
||||
const { lng, lat } = marker.getLngLat()
|
||||
|
||||
document.getElementById('address-position').value = `${lat.toFixed(5)}, ${lng.toFixed(5)}`
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</CaptureScript>
|
||||
</Card>
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
// A delivery on a map: the route, the courier position and the order status.
|
||||
import Map from '../demo/Map.astro'
|
||||
import Avatar from '@ui/Avatar.astro'
|
||||
import Card from '@ui/Card.astro'
|
||||
import CardBody from '@ui/CardBody.astro'
|
||||
import CardHeader from '@ui/CardHeader.astro'
|
||||
import CardTitle from '@ui/CardTitle.astro'
|
||||
import Icon from '@ui/Icon.astro'
|
||||
import Progress from '@ui/Progress.astro'
|
||||
import Subheader from '@ui/Subheader.astro'
|
||||
import { staticPath } from '@shared/lib/assets'
|
||||
|
||||
// The courier is at the sixth point of the route: the part behind them is done
|
||||
const route = [
|
||||
[13.3695, 52.5251],
|
||||
[13.3777, 52.5208],
|
||||
[13.388, 52.5186],
|
||||
[13.3989, 52.5176],
|
||||
[13.405, 52.5192],
|
||||
[13.4132, 52.5219],
|
||||
[13.4215, 52.5165],
|
||||
[13.43, 52.5105],
|
||||
[13.4397, 52.505],
|
||||
]
|
||||
const courierAt = 5
|
||||
---
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Delivery tracker</CardTitle>
|
||||
</CardHeader>
|
||||
<Map mapId="delivery" title="Delivery tracker" center={[52.5215, 13.4046]} zoom={11.6} height="20rem" rounded={false} route={route} routeProgress={courierAt}>
|
||||
<div slot="overlay" class="card card-sm position-absolute top-0 start-0 m-3 shadow" style="width: 15rem">
|
||||
<div class="card-body">
|
||||
<Subheader>Order #4821</Subheader>
|
||||
<div class="h3 mb-2">Arriving in 12 min</div>
|
||||
<Progress value={62} size="sm" color="green" />
|
||||
</div>
|
||||
</div>
|
||||
<span class="map-marker map-marker-dot" data-bs-lng={route[0]?.[0]} data-bs-lat={route[0]?.[1]}></span>
|
||||
<span class="map-marker map-marker-green map-marker-pulse" data-bs-lng={route[courierAt]?.[0]} data-bs-lat={route[courierAt]?.[1]}><Icon name="truck-delivery" /></span>
|
||||
<span class="map-marker map-marker-pin" data-bs-lng={route.at(-1)?.[0]} data-bs-lat={route.at(-1)?.[1]} data-bs-anchor="bottom"><Icon name="home" /></span>
|
||||
</Map>
|
||||
<CardBody>
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto">
|
||||
<Avatar src={staticPath('avatars/002m.jpg')} />
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="fw-medium">Mateusz Nowak</div>
|
||||
<div class="text-secondary">Your courier</div>
|
||||
</div>
|
||||
<div class="col-auto text-end">
|
||||
<div class="fw-medium">3.4 km</div>
|
||||
<div class="text-secondary">left to go</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="#" class="btn btn-icon" aria-label="Call the courier"><Icon name="phone" /></a>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
// A world map of active users, with the total in a card over the map.
|
||||
import Map from '../demo/Map.astro'
|
||||
import CaptureScript from '../CaptureScript.astro'
|
||||
import Card from '@ui/Card.astro'
|
||||
import CardBody from '@ui/CardBody.astro'
|
||||
import CardHeader from '@ui/CardHeader.astro'
|
||||
import CardTitle from '@ui/CardTitle.astro'
|
||||
import Subheader from '@ui/Subheader.astro'
|
||||
import Trending from '@ui/Trending.astro'
|
||||
|
||||
const cities = [
|
||||
{ name: 'Berlin', lng: 13.405, lat: 52.52, users: 820 },
|
||||
{ name: 'London', lng: -0.1276, lat: 51.5072, users: 1240 },
|
||||
{ name: 'New York', lng: -74.006, lat: 40.7128, users: 1580 },
|
||||
{ name: 'Tokyo', lng: 139.6917, lat: 35.6895, users: 960 },
|
||||
{ name: 'São Paulo', lng: -46.6333, lat: -23.5505, users: 540 },
|
||||
{ name: 'Sydney', lng: 151.2093, lat: -33.8688, users: 310 },
|
||||
{ name: 'Lagos', lng: 3.3792, lat: 6.5244, users: 420 },
|
||||
{ name: 'Mumbai', lng: 72.8777, lat: 19.076, users: 1100 },
|
||||
]
|
||||
---
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Live users</CardTitle>
|
||||
</CardHeader>
|
||||
<Map mapId="live-users" title="Live users" center={[32, 30]} zoom={0.45} height="20rem" rounded={false}>
|
||||
<div slot="overlay" class="card card-sm position-absolute top-0 end-0 m-3 shadow">
|
||||
<div class="card-body">
|
||||
<Subheader>Active users</Subheader>
|
||||
<div class="h1 mb-1">3,544</div>
|
||||
<div class="text-secondary"><Trending value="+12.5" /> vs last hour</div>
|
||||
</div>
|
||||
</div>
|
||||
</Map>
|
||||
<CaptureScript>
|
||||
<script is:inline define:vars={{ cities }}>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const view = tabler.MapView.getInstance('#map-live-users')
|
||||
|
||||
view.onStyle((map) => {
|
||||
map.addSource('cities', {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: cities.map((city) => ({ type: 'Feature', properties: city, geometry: { type: 'Point', coordinates: [city.lng, city.lat] } })) },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'cities',
|
||||
type: 'circle',
|
||||
source: 'cities',
|
||||
paint: { 'circle-color': tabler.MapView.color('--tblr-primary'), 'circle-opacity': 0.6, 'circle-radius': ['interpolate', ['linear'], ['get', 'users'], 0, 3, 1600, 14] },
|
||||
})
|
||||
})
|
||||
|
||||
// The handlers need the map and the library, which may load a moment after the page
|
||||
view.ready((map) => {
|
||||
const tooltip = new maplibregl.Popup({ closeButton: false, offset: 12, className: 'map-tooltip' })
|
||||
|
||||
map.on('mousemove', 'cities', (event) => {
|
||||
const city = event.features[0].properties
|
||||
|
||||
map.getCanvas().style.cursor = 'pointer'
|
||||
tooltip.setLngLat([city.lng, city.lat]).setText(`${city.name}: ${city.users} users`).addTo(map)
|
||||
})
|
||||
map.on('mouseleave', 'cities', () => {
|
||||
map.getCanvas().style.cursor = ''
|
||||
tooltip.remove()
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</CaptureScript>
|
||||
<CardBody>
|
||||
<div class="row text-center">
|
||||
<div class="col">
|
||||
<div class="fw-medium">1,580</div>
|
||||
<div class="text-secondary">New York</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="fw-medium">1,240</div>
|
||||
<div class="text-secondary">London</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="fw-medium">1,100</div>
|
||||
<div class="text-secondary">Mumbai</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
// A list of homes next to a map with their prices as markers: picking a home
|
||||
// in the list, or its price on the map, selects both.
|
||||
import Map from '../demo/Map.astro'
|
||||
import CaptureScript from '../CaptureScript.astro'
|
||||
import Card from '@ui/Card.astro'
|
||||
import CardHeader from '@ui/CardHeader.astro'
|
||||
import CardTitle from '@ui/CardTitle.astro'
|
||||
import Icon from '@ui/Icon.astro'
|
||||
import { staticPath } from '@shared/lib/assets'
|
||||
|
||||
const homes = [
|
||||
{ title: 'Loft in Mitte', price: '$420k', beds: 2, baths: 1, area: 78, photo: 'contemporary-black-and-white-home-decor.jpg', lng: 13.3989, lat: 52.5246 },
|
||||
{ title: 'Flat by the park', price: '$365k', beds: 2, baths: 1, area: 64, photo: 'blue-sofa-with-pillows-in-a-designer-living-room-interior.jpg', lng: 13.4336, lat: 52.5287 },
|
||||
{ title: 'Studio in Kreuzberg', price: '$240k', beds: 1, baths: 1, area: 38, photo: 'elegant-home-office-with-golden-accessories.jpg', lng: 13.4107, lat: 52.4993 },
|
||||
{ title: 'Family home', price: '$690k', beds: 4, baths: 2, area: 142, photo: 'home-office-desk-with-macbook-iphone-calendar-watch-and-organizer.jpg', lng: 13.3458, lat: 52.5083 },
|
||||
]
|
||||
---
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Homes for sale</CardTitle>
|
||||
</CardHeader>
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-5 border-end">
|
||||
<div class="list-group list-group-flush overflow-auto" id="homes-list" style="height: 26rem">
|
||||
{
|
||||
homes.map((home, index) => (
|
||||
<button type="button" class:list={['list-group-item list-group-item-action', index === 0 && 'active']}>
|
||||
<div class="row g-3 align-items-center">
|
||||
<div class="col-auto">
|
||||
<span class="avatar avatar-lg" style={`background-image: url(${staticPath(`photos/${home.photo}`)})`} />
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="fw-medium">{home.price}</div>
|
||||
<div>{home.title}</div>
|
||||
<div class="text-secondary small mt-1">
|
||||
<Icon name="bed" class="icon-inline" /> {home.beds}
|
||||
<Icon name="bath" class="icon-inline ms-2" /> {home.baths}
|
||||
<Icon name="ruler-2" class="icon-inline ms-2" /> {home.area} m²
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<Map mapId="homes" title="Homes for sale" center={[52.515, 13.39]} zoom={11.4} height="26rem" rounded={false} controls>
|
||||
{
|
||||
homes.map((home, index) => (
|
||||
<span class:list={['map-marker px-2', index === 0 ? 'map-marker-dark' : 'map-marker-primary']} data-bs-lng={home.lng} data-bs-lat={home.lat}>
|
||||
{home.price}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</Map>
|
||||
</div>
|
||||
</div>
|
||||
<CaptureScript>
|
||||
<script is:inline>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const view = tabler.MapView.getInstance('#map-homes')
|
||||
const items = [...document.querySelectorAll('#homes-list .list-group-item')]
|
||||
|
||||
// The markers are in the same order as the homes in the list
|
||||
view.ready((map) => {
|
||||
const select = (index, move) => {
|
||||
items.forEach((item, position) => item.classList.toggle('active', position === index))
|
||||
view.markers.forEach((marker, position) => {
|
||||
marker.getElement().classList.toggle('map-marker-dark', position === index)
|
||||
marker.getElement().classList.toggle('map-marker-primary', position !== index)
|
||||
})
|
||||
|
||||
if (move) {
|
||||
map.flyTo({ center: view.markers[index].getLngLat(), zoom: 13 })
|
||||
}
|
||||
}
|
||||
|
||||
items.forEach((item, index) => item.addEventListener('click', () => select(index, true)))
|
||||
view.markers.forEach((marker, index) =>
|
||||
marker.getElement().addEventListener('click', () => {
|
||||
select(index, false)
|
||||
items[index].scrollIntoView({ block: 'nearest' })
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</CaptureScript>
|
||||
</Card>
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
// A status page map: one marker per region, colored by its state, with the
|
||||
// regions listed in a table below. A region with a problem pulses.
|
||||
import Map from '../demo/Map.astro'
|
||||
import Card from '@ui/Card.astro'
|
||||
import CardHeader from '@ui/CardHeader.astro'
|
||||
import CardTitle from '@ui/CardTitle.astro'
|
||||
import StatusDot from '@ui/StatusDot.astro'
|
||||
|
||||
const states = {
|
||||
ok: { label: 'Operational', color: 'green' },
|
||||
slow: { label: 'Degraded', color: 'yellow' },
|
||||
down: { label: 'Outage', color: 'red' },
|
||||
}
|
||||
|
||||
const regions: { name: string; code: string; state: keyof typeof states; latency: string; uptime: string; lng: number; lat: number }[] = [
|
||||
{ name: 'Frankfurt', code: 'eu-central', state: 'ok', latency: '24 ms', uptime: '99.99%', lng: 8.6821, lat: 50.1109 },
|
||||
{ name: 'London', code: 'eu-west', state: 'ok', latency: '31 ms', uptime: '99.98%', lng: -0.1276, lat: 51.5072 },
|
||||
{ name: 'Virginia', code: 'us-east', state: 'slow', latency: '212 ms', uptime: '99.71%', lng: -77.4874, lat: 39.0438 },
|
||||
{ name: 'São Paulo', code: 'sa-east', state: 'ok', latency: '48 ms', uptime: '99.95%', lng: -46.6333, lat: -23.5505 },
|
||||
{ name: 'Singapore', code: 'ap-southeast', state: 'down', latency: '-', uptime: '98.20%', lng: 103.8198, lat: 1.3521 },
|
||||
{ name: 'Sydney', code: 'ap-south', state: 'ok', latency: '36 ms', uptime: '99.97%', lng: 151.2093, lat: -33.8688 },
|
||||
]
|
||||
---
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Server status</CardTitle>
|
||||
</CardHeader>
|
||||
<Map mapId="servers" title="Server status" center={[20, 40]} zoom={0.45} height="20rem" rounded={false}>
|
||||
{regions.map((region) => <span class:list={['map-marker map-marker-dot', `map-marker-${states[region.state].color}`, region.state !== 'ok' && 'map-marker-pulse']} data-bs-lng={region.lng} data-bs-lat={region.lat} data-bs-popup={`${region.name}: ${states[region.state].label}`} />)}
|
||||
</Map>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Region</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Latency</th>
|
||||
<th class="text-end">Uptime</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
regions.map((region) => (
|
||||
<tr>
|
||||
<td>
|
||||
{region.name} <span class="text-secondary">{region.code}</span>
|
||||
</td>
|
||||
<td>
|
||||
<StatusDot color={states[region.state].color} animated={region.state !== 'ok'} class="me-2" />
|
||||
{states[region.state].label}
|
||||
</td>
|
||||
<td class="text-end">{region.latency}</td>
|
||||
<td class="text-end">{region.uptime}</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
// A list of stores next to a map: picking a store moves the map and opens its popup.
|
||||
import Map from '../demo/Map.astro'
|
||||
import CaptureScript from '../CaptureScript.astro'
|
||||
import Badge from '@ui/Badge.astro'
|
||||
import Card from '@ui/Card.astro'
|
||||
import CardHeader from '@ui/CardHeader.astro'
|
||||
import CardTitle from '@ui/CardTitle.astro'
|
||||
import Icon from '@ui/Icon.astro'
|
||||
|
||||
const stores = [
|
||||
{ name: 'Tabler Store Mitte', address: 'Friedrichstraße 112, 10117 Berlin', distance: '0.4 km', open: true, lng: 13.3889, lat: 52.5246 },
|
||||
{ name: 'Tabler Store Alexanderplatz', address: 'Karl-Liebknecht-Straße 9, 10178 Berlin', distance: '1.2 km', open: true, lng: 13.4094, lat: 52.5219 },
|
||||
{ name: 'Tabler Store Kreuzberg', address: 'Oranienstraße 34, 10999 Berlin', distance: '2.6 km', open: true, lng: 13.4197, lat: 52.5009 },
|
||||
{ name: 'Tabler Store Charlottenburg', address: 'Kantstraße 54, 10627 Berlin', distance: '4.8 km', open: false, lng: 13.3087, lat: 52.5063 },
|
||||
{ name: 'Tabler Store Prenzlauer Berg', address: 'Kastanienallee 21, 10435 Berlin', distance: '2.1 km', open: true, lng: 13.4098, lat: 52.5385 },
|
||||
{ name: 'Tabler Store Friedrichshain', address: 'Warschauer Straße 58, 10243 Berlin', distance: '3.9 km', open: false, lng: 13.4492, lat: 52.5083 },
|
||||
]
|
||||
---
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Store locator</CardTitle>
|
||||
</CardHeader>
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-4 border-end">
|
||||
<div class="p-3 border-bottom">
|
||||
<div class="input-icon">
|
||||
<span class="input-icon-addon"><Icon name="search" /></span>
|
||||
<input type="search" class="form-control" id="stores-search" placeholder="Search stores…" aria-label="Search stores" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group list-group-flush overflow-auto" id="stores-list" style="height: 24rem">
|
||||
{
|
||||
stores.map((store, index) => (
|
||||
<button type="button" class:list={['list-group-item list-group-item-action', index === 0 && 'active']}>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="fw-medium">{store.name}</span>
|
||||
<Badge color={store.open ? 'green' : 'secondary'} light class="ms-auto" text={store.open ? 'Open' : 'Closed'} />
|
||||
</div>
|
||||
<div class="text-secondary">{store.address}</div>
|
||||
<div class="text-secondary small mt-1">
|
||||
<Icon name="map-pin" class="icon-inline" /> {store.distance}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-8">
|
||||
<Map mapId="stores" title="Store locator" center={[52.519, 13.39]} zoom={11.3} height="28.5rem" rounded={false} controls>
|
||||
{
|
||||
stores.map((store) => (
|
||||
<span class="map-marker map-marker-pin" data-bs-lng={store.lng} data-bs-lat={store.lat} data-bs-anchor="bottom">
|
||||
<Icon name="map-pin" />
|
||||
<template>
|
||||
<div class="fw-medium">{store.name}</div>
|
||||
<div class="text-secondary">{store.address}</div>
|
||||
</template>
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</Map>
|
||||
</div>
|
||||
</div>
|
||||
<CaptureScript>
|
||||
<script is:inline>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// The markers exist once the map does, which may be a moment after the page has loaded
|
||||
tabler.MapView.getInstance('#map-stores').ready((map) => {
|
||||
// The markers are in the same order as the stores in the list
|
||||
const { markers } = tabler.MapView.getInstance('#map-stores')
|
||||
const items = [...document.querySelectorAll('#stores-list .list-group-item')]
|
||||
|
||||
const select = (index, move) => {
|
||||
items.forEach((item, position) => item.classList.toggle('active', position === index))
|
||||
markers.forEach((marker, position) => marker.getPopup().isOpen() !== (position === index) && marker.togglePopup())
|
||||
|
||||
if (move) {
|
||||
map.flyTo({ center: markers[index].getLngLat(), zoom: 13 })
|
||||
}
|
||||
}
|
||||
|
||||
items.forEach((item, index) => item.addEventListener('click', () => select(index, true)))
|
||||
markers.forEach((marker, index) =>
|
||||
marker.getElement().addEventListener('click', (event) => {
|
||||
event.stopPropagation()
|
||||
select(index, false)
|
||||
items[index].scrollIntoView({ block: 'nearest' })
|
||||
}),
|
||||
)
|
||||
|
||||
// Search hides the stores, and their markers, that do not match
|
||||
document.getElementById('stores-search').addEventListener('input', (event) => {
|
||||
const query = event.target.value.trim().toLowerCase()
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const hidden = !item.textContent.toLowerCase().includes(query)
|
||||
|
||||
item.hidden = hidden
|
||||
markers[index].getElement().hidden = hidden
|
||||
})
|
||||
})
|
||||
|
||||
select(0, false)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</CaptureScript>
|
||||
</Card>
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
// A vector world map shaded by users, next to a table of the top countries.
|
||||
import MapVector from '../demo/MapVector.astro'
|
||||
import Card from '@ui/Card.astro'
|
||||
import CardBody from '@ui/CardBody.astro'
|
||||
import CardHeader from '@ui/CardHeader.astro'
|
||||
import CardTitle from '@ui/CardTitle.astro'
|
||||
import Flag from '@ui/Flag.astro'
|
||||
import Progress from '@ui/Progress.astro'
|
||||
import type { FlagCountry } from '@shared/lib/tokens'
|
||||
|
||||
const countries: { name: string; flag: FlagCountry; users: string; share: number }[] = [
|
||||
{ name: 'United States', flag: 'us', users: '12,480', share: 32 },
|
||||
{ name: 'Brazil', flag: 'br', users: '7,120', share: 18 },
|
||||
{ name: 'Germany', flag: 'de', users: '5,930', share: 15 },
|
||||
{ name: 'India', flag: 'in', users: '4,360', share: 11 },
|
||||
{ name: 'Australia', flag: 'au', users: '3,540', share: 9 },
|
||||
{ name: 'Poland', flag: 'pl', users: '2,210', share: 6 },
|
||||
]
|
||||
---
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Users by country</CardTitle>
|
||||
</CardHeader>
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-7 border-end">
|
||||
<CardBody>
|
||||
<MapVector mapId="world" ratio="16x9" />
|
||||
</CardBody>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Country</th>
|
||||
<th class="text-end">Users</th>
|
||||
<th class="w-33">Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
countries.map((country) => (
|
||||
<tr>
|
||||
<td>
|
||||
<Flag flag={country.flag} size="xs" class="me-2" />
|
||||
{country.name}
|
||||
</td>
|
||||
<td class="text-end">{country.users}</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="text-secondary">{country.share}%</span>
|
||||
<Progress value={country.share} size="sm" class="flex-fill" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -1,77 +1,91 @@
|
||||
---
|
||||
// Mapbox GL map. Access token from the MAPBOX_KEY environment variable (.env or
|
||||
// the host's settings), never from the source. Renders nothing unless
|
||||
// maps[mapId] exists; without a token the container renders but the map is not
|
||||
// initialised (Mapbox GL throws on a missing token).
|
||||
// A map drawn by the Tabler `MapView` component (core/js/src/map.ts) on top of
|
||||
// MapLibre GL JS: the markup below is all a page needs, there is no script.
|
||||
// Markers go in the default slot, as elements with `data-bs-lng` and
|
||||
// `data-bs-lat`; content laid over the map goes in the `overlay` slot.
|
||||
// Renders nothing unless maps[mapId] exists or `center` is set.
|
||||
import maps from '@data/maps.json';
|
||||
import CaptureScript from '../CaptureScript.astro';
|
||||
|
||||
interface Props {
|
||||
/** Key into the maps data */
|
||||
/** Key into the maps data; also the suffix of the container id */
|
||||
mapId: string;
|
||||
ratio?: string;
|
||||
/** for a map with no entry in the maps data: [latitude, longitude] */
|
||||
center?: [number, number];
|
||||
zoom?: number;
|
||||
title?: string;
|
||||
style?: string;
|
||||
/** buttons over the map: true for zoom, or a list such as "zoom,north,fullscreen" */
|
||||
controls?: boolean | string;
|
||||
/** draws the map as a 3D globe instead of a flat map */
|
||||
globe?: boolean;
|
||||
/** rounded corners; turn off when the map fills a box that has its own corners */
|
||||
rounded?: boolean;
|
||||
/** fixed height (any CSS length) instead of an aspect ratio */
|
||||
height?: string;
|
||||
/** a path drawn as a line: [longitude, latitude] points */
|
||||
route?: number[][];
|
||||
/** index of the route point reached so far: the rest of the path is dashed */
|
||||
routeProgress?: number;
|
||||
/** color of the route and the GeoJSON shapes */
|
||||
layerColor?: string;
|
||||
/** accessible names of the control buttons, by action */
|
||||
labels?: Record<string, string>;
|
||||
/** GeoJSON drawn over the map: an object, or the URL of a file */
|
||||
geojson?: object | string;
|
||||
}
|
||||
|
||||
const { mapId, ratio } = Astro.props;
|
||||
|
||||
const mapboxKey = import.meta.env.MAPBOX_KEY ?? process.env.MAPBOX_KEY ?? '';
|
||||
const { mapId, ratio, center: centerProp, zoom: zoomProp, title, style: styleProp, controls: controlsProp, globe = false, rounded = true, height, route, routeProgress, layerColor, labels, geojson } = Astro.props;
|
||||
|
||||
type MapData = {
|
||||
title?: string;
|
||||
title?: string | undefined;
|
||||
ratio?: string;
|
||||
card?: boolean;
|
||||
style?: string;
|
||||
zoom?: number;
|
||||
/** OpenFreeMap style name: positron (default), liberty, bright, dark or fiord */
|
||||
style?: string | undefined;
|
||||
/** style used in dark mode, `dark` by default */
|
||||
darkStyle?: string;
|
||||
/** buttons over the map: true for zoom, or a list such as "zoom,north,fullscreen" */
|
||||
controls?: boolean | string | undefined;
|
||||
zoom?: number | undefined;
|
||||
center?: [number, number];
|
||||
markers?: { name?: string; center: [number, number] }[];
|
||||
};
|
||||
|
||||
const data = (maps as unknown as Record<string, MapData>)[mapId];
|
||||
|
||||
const mapContainerId = `map-${mapId}`;
|
||||
const style = data ? (data.style ?? 'streets-v11') : undefined;
|
||||
const zoom = data?.zoom ?? 13;
|
||||
const center = data?.center ? [data.center[1], data.center[0]] : [13.4049, 52.518827];
|
||||
const markers = (data?.markers ?? []).map((marker) => [marker.center[1], marker.center[0]]);
|
||||
const data: MapData | undefined = (maps as unknown as Record<string, MapData>)[mapId] ?? (centerProp ? { title, center: centerProp, zoom: zoomProp, style: styleProp, controls: controlsProp } : undefined);
|
||||
|
||||
// The data keeps [latitude, longitude]; the component takes longitude first
|
||||
const center = data?.center ? `${data.center[1]},${data.center[0]}` : '13.4049,52.518827';
|
||||
const ratioClass = data?.ratio ?? ratio ?? '16x9';
|
||||
---
|
||||
|
||||
{
|
||||
data && (
|
||||
<div class={`ratio ratio-${ratioClass}`}>
|
||||
<div>
|
||||
<div id={mapContainerId} class={`w-100 h-100${data.card ? ' rounded' : ''}`} role="img" aria-label={data.title ?? 'Map'} />
|
||||
<div class:list={height ? 'position-relative' : `ratio ratio-${ratioClass}`} style={height ? `height: ${height}` : undefined}>
|
||||
<div class:list={[height && 'h-100']}>
|
||||
<div
|
||||
id={`map-${mapId}`}
|
||||
class:list={['w-100 h-100', rounded && 'rounded']}
|
||||
role="region"
|
||||
aria-label={data.title ?? 'Map'}
|
||||
data-bs-toggle="map"
|
||||
data-bs-center={center}
|
||||
data-bs-zoom={data.zoom ?? 13}
|
||||
data-bs-map-style={data.style}
|
||||
data-bs-map-style-dark={data.darkStyle}
|
||||
data-bs-controls={data.controls ? String(data.controls) : undefined}
|
||||
data-bs-globe={globe ? 'true' : undefined}
|
||||
data-bs-route={route ? JSON.stringify(route) : undefined}
|
||||
data-bs-route-progress={routeProgress}
|
||||
data-bs-layer-color={layerColor}
|
||||
data-bs-labels={labels ? JSON.stringify(labels) : undefined}
|
||||
data-bs-geojson={typeof geojson === 'object' ? JSON.stringify(geojson) : geojson}
|
||||
>
|
||||
{(data.markers ?? []).map((marker) => <span class="map-marker map-marker-dot" data-bs-lng={marker.center[1]} data-bs-lat={marker.center[0]} data-bs-popup={marker.name} />)}
|
||||
<slot />
|
||||
</div>
|
||||
<slot name="overlay" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
data && mapboxKey && (
|
||||
<CaptureScript>
|
||||
<!-- BEGIN MAP -->
|
||||
<script is:inline define:vars={{ mapboxKey, mapContainerId, style, zoom, center, markers }}>
|
||||
window.tabler_map ??= {};
|
||||
|
||||
function initMap() {
|
||||
mapboxgl.accessToken = mapboxKey;
|
||||
const map = new mapboxgl.Map({
|
||||
container: mapContainerId,
|
||||
style: `mapbox://styles/mapbox/${style}`,
|
||||
zoom,
|
||||
center,
|
||||
});
|
||||
|
||||
markers.forEach((coords) => {
|
||||
new mapboxgl.Marker({ color: 'var(--tblr-primary)' }).setLngLat(coords).addTo(map);
|
||||
});
|
||||
|
||||
window.tabler_map[mapContainerId] = map;
|
||||
}
|
||||
|
||||
document.readyState !== 'loading' ? initMap() : document.addEventListener('DOMContentLoaded', initMap, { once: true });
|
||||
</script>
|
||||
<!-- END MAP -->
|
||||
</CaptureScript>
|
||||
)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ const mapOptions = data
|
||||
initial: {
|
||||
fill: 'var(--tblr-bg-surface-secondary)',
|
||||
stroke: data.filled ? '#fff' : 'var(--tblr-border-color)',
|
||||
strokeWidth: data.filled ? 1 : 2,
|
||||
strokeWidth: 1,
|
||||
},
|
||||
},
|
||||
zoomOnScroll: Boolean(data.zoom),
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
{
|
||||
"simple": {
|
||||
"title": "Simple map"
|
||||
"title": "Simple map",
|
||||
"controls": true
|
||||
},
|
||||
"light": {
|
||||
"title": "Light map style",
|
||||
"title": "Street map style",
|
||||
"center": [
|
||||
51.5285582,
|
||||
-0.2416782
|
||||
],
|
||||
"zoom": 11,
|
||||
"style": "light-v10"
|
||||
"style": "liberty"
|
||||
},
|
||||
"markers": {
|
||||
"title": "Light map style",
|
||||
"title": "Map with markers",
|
||||
"center": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"zoom": 1,
|
||||
"style": "light-v10",
|
||||
"style": "positron",
|
||||
"card": true,
|
||||
"markers": [
|
||||
{
|
||||
@@ -72,7 +73,8 @@
|
||||
0
|
||||
],
|
||||
"zoom": 1,
|
||||
"style": "satellite-v9",
|
||||
"style": "bright",
|
||||
"darkStyle": "fiord",
|
||||
"card": true
|
||||
}
|
||||
}
|
||||
@@ -588,6 +588,11 @@
|
||||
"url": "map-fullsize.html",
|
||||
"title": "Map fullsize"
|
||||
},
|
||||
"map-blocks": {
|
||||
"url": "map-blocks.html",
|
||||
"title": "Map blocks",
|
||||
"badge": "New"
|
||||
},
|
||||
"maps-vector": {
|
||||
"url": "maps-vector.html",
|
||||
"title": "Map vector"
|
||||
|
||||
@@ -103,6 +103,13 @@
|
||||
"description": "Lightweight, no-dependency date range picker. Kept in dist/libs for older projects; use the Datepicker plugin instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "MapLibre GL JS",
|
||||
"npm": "maplibre-gl",
|
||||
"url": "https://maplibre.org/maplibre-gl-js/docs/",
|
||||
"description": "Open-source JavaScript library for interactive vector maps on the web, with no access token.",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
{
|
||||
"name": "Mapbox GL JS",
|
||||
"lib": "mapbox",
|
||||
|
||||
@@ -39,19 +39,21 @@ const isProduction = process.env.VERCEL_ENV === 'production'
|
||||
// RTL swaps tabler/plugin stylesheets for their .rtl.css variants (demo stays LTR)
|
||||
const rtlSuffix = rtl ? '.rtl' : ''
|
||||
|
||||
type Lib = { npm?: string; js?: string[]; css?: string[]; head?: boolean }
|
||||
type Lib = { npm?: string; js?: string[]; css?: string[]; head?: boolean; module?: { file: string; global: string }; addon?: string }
|
||||
const unknownLibs = pageLibs.filter((name) => !(name in libs))
|
||||
if (unknownLibs.length > 0) {
|
||||
throw new Error(`BaseLayout: unknown pageLibs ${unknownLibs.map((name) => `"${name}"`).join(', ')} — use a key from core/libs.json`)
|
||||
}
|
||||
const pageLibEntries = Object.entries(libs as Record<string, Lib>).filter(([name]) => pageLibs.includes(name))
|
||||
// Google Maps API key comes from the environment (GOOGLE_MAPS_KEY in .env or the
|
||||
// host's settings), never from the source. Without it the script loads keyless.
|
||||
const googleMapsKey = import.meta.env.GOOGLE_MAPS_KEY ?? process.env.GOOGLE_MAPS_KEY ?? ''
|
||||
// External URLs (http/https) are emitted verbatim (with the maps-key placeholder
|
||||
// replaced); local files as ./dist/libs/{npm}/{file}.
|
||||
const libHref = (lib: Lib, file: string) => (/^https?:\/\//.test(file) ? file.replace(googleMapsKey ? 'GOOGLE_MAPS_KEY' : '?key=GOOGLE_MAPS_KEY', googleMapsKey) : `${base}/dist/libs/${lib.npm}/${file}`)
|
||||
// External URLs (http/https) are emitted verbatim; local files as ./dist/libs/{npm}/{file}.
|
||||
const libHref = (lib: Lib, file: string) => (/^https?:\/\//.test(file) ? file : `${base}/dist/libs/${lib.npm}/${file}`)
|
||||
const libCssFiles = pageLibEntries.flatMap(([, lib]) => (lib.css ?? []).map((file) => libHref(lib, file)))
|
||||
// Libraries published as ES modules only: imported in a module script and
|
||||
// handed to the classic page scripts as a global. Module scripts are deferred,
|
||||
// so the global is set before DOMContentLoaded.
|
||||
const libModules = pageLibEntries.flatMap(([, lib]) => (lib.module ? [`import * as lib from '${libHref(lib, lib.module.file)}'\n window.${lib.module.global} = lib`] : []))
|
||||
// Tabler add-on bundles (dist/js) that a library needs, loaded after tabler.js
|
||||
const libAddons = pageLibEntries.flatMap(([, lib]) => (lib.addon ? [lib.addon] : []))
|
||||
const libJsFiles = (head: boolean) => pageLibEntries.filter(([, lib]) => Boolean(lib.head) === head).flatMap(([, lib]) => (lib.js ?? []).map((file) => libHref(lib, file)))
|
||||
---
|
||||
|
||||
@@ -133,9 +135,11 @@ const libJsFiles = (head: boolean) => pageLibEntries.filter(([, lib]) => Boolean
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
{libModules.map((code) => <Fragment set:html={`<script type="module">\n ${code}\n </script>`} />)}
|
||||
|
||||
<!-- BEGIN GLOBAL MANDATORY SCRIPTS -->
|
||||
<script is:inline src={`${base}/dist/js/tabler${min}.js`} defer></script>
|
||||
{libAddons.map((addon) => <script is:inline src={`${base}/dist/js/${addon}${min}.js`} defer />)}
|
||||
<!-- END GLOBAL MANDATORY SCRIPTS -->
|
||||
|
||||
<PageScripts />
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": { "name": "Tiergarten" },
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[[13.336, 52.5165], [13.3445, 52.5195], [13.3755, 52.5165], [13.3765, 52.5115], [13.3625, 52.5085], [13.3385, 52.5095], [13.336, 52.5165]]]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": { "name": "Riverside walk" },
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": [[13.3765, 52.5195], [13.3885, 52.5205], [13.3975, 52.5215], [13.4035, 52.5185], [13.4115, 52.5145], [13.4255, 52.5115], [13.4395, 52.5055]]
|
||||
}
|
||||
},
|
||||
{ "type": "Feature", "properties": { "name": "Brandenburg Gate" }, "geometry": { "type": "Point", "coordinates": [13.3777, 52.5163] } },
|
||||
{ "type": "Feature", "properties": { "name": "Museum Island" }, "geometry": { "type": "Point", "coordinates": [13.4019, 52.5169] } },
|
||||
{ "type": "Feature", "properties": { "name": "East Side Gallery" }, "geometry": { "type": "Point", "coordinates": [13.4397, 52.505] } }
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"globalEnv": ["NODE_ENV", "VERCEL_ENV", "POSTHOG_KEY", "DOCSEARCH_API_KEY", "DOCSEARCH_APP_ID", "DOCSEARCH_INDEX_NAME", "GOOGLE_MAPS_KEY", "MAPBOX_KEY"],
|
||||
"globalEnv": ["NODE_ENV", "VERCEL_ENV", "POSTHOG_KEY", "DOCSEARCH_API_KEY", "DOCSEARCH_APP_ID", "DOCSEARCH_INDEX_NAME"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
|
||||
Reference in new issue
Block a user