mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
Add the MapView component and move the map demos to it
This commit is contained in:
1 parent
7c9f73f169
commit
5539bdbba2
21 files changed
+1094
-690
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@tabler/core": minor
|
||||
"@tabler/preview": minor
|
||||
"@tabler/docs": minor
|
||||
---
|
||||
|
||||
Added the `MapView` component: `data-bs-toggle="map"` turns an element into a themed MapLibre map with HTML markers and popups.
|
||||
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,416 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* 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 { 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 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 STYLE_URL = 'https://tiles.openfreemap.org/styles/'
|
||||
|
||||
const POPUP_OFFSET = 16
|
||||
const POPUP_OFFSET_PIN = 40
|
||||
|
||||
// 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
|
||||
remove(): void
|
||||
setStyle(style: string): unknown
|
||||
getStyle(): { layers: { id: string }[] }
|
||||
setPaintProperty(layer: string, name: string, value: string): unknown
|
||||
setProjection(projection: { type: string }): unknown
|
||||
addControl(control: 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
|
||||
NavigationControl: new () => unknown
|
||||
}
|
||||
|
||||
type MapTheme = 'auto' | 'light' | 'dark'
|
||||
|
||||
type ComponentConfig = {
|
||||
/** `[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
|
||||
/** zoom and compass buttons */
|
||||
controls: boolean
|
||||
/** draws the map as a 3D globe */
|
||||
globe: boolean
|
||||
/** 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 = {
|
||||
center: [0, 0],
|
||||
zoom: 1,
|
||||
mapStyle: 'positron',
|
||||
mapStyleDark: 'dark',
|
||||
mapTheme: 'auto',
|
||||
palette: true,
|
||||
controls: false,
|
||||
globe: false,
|
||||
mapOptions: {},
|
||||
}
|
||||
|
||||
const DefaultType: Record<keyof ComponentConfig, string> = {
|
||||
center: '(array|string)',
|
||||
zoom: 'number',
|
||||
mapStyle: 'string',
|
||||
mapStyleDark: '(null|string)',
|
||||
mapTheme: 'string',
|
||||
palette: 'boolean',
|
||||
controls: 'boolean',
|
||||
globe: 'boolean',
|
||||
mapOptions: 'object',
|
||||
}
|
||||
|
||||
// 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), loaded separately as
|
||||
* `window.maplibregl`. Without the library the component is inert.
|
||||
*/
|
||||
|
||||
class MapView extends BaseComponent {
|
||||
declare _element: HTMLElement
|
||||
declare _config: ComponentConfig
|
||||
_map: MapInstance | null = null
|
||||
_markers: MapMarkerInstance[] = []
|
||||
_themeObserver: MutationObserver | null = null
|
||||
|
||||
constructor(element: ElementSelector, config?: ComponentConfigInput) {
|
||||
super(element, config)
|
||||
|
||||
if (!this._element || !window.maplibregl) {
|
||||
return
|
||||
}
|
||||
|
||||
this._initMap(window.maplibregl)
|
||||
}
|
||||
|
||||
// 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
|
||||
dispose(): void {
|
||||
this._themeObserver?.disconnect()
|
||||
this._themeObserver = null
|
||||
|
||||
for (const marker of this._markers) {
|
||||
marker.remove()
|
||||
}
|
||||
|
||||
this._markers = []
|
||||
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 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()
|
||||
|
||||
if (globe) {
|
||||
map.setProjection({ type: 'globe' })
|
||||
}
|
||||
})
|
||||
|
||||
map.on('load', () => {
|
||||
EventHandler.trigger(this._element, EVENT_LOAD)
|
||||
})
|
||||
|
||||
if (controls) {
|
||||
map.addControl(new library.NavigationControl())
|
||||
}
|
||||
|
||||
this._markers = markerElements.map((element) => this._createMarker(library, map, element))
|
||||
this._setupThemeObserver()
|
||||
}
|
||||
|
||||
_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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_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)
|
||||
// js-docs-end map-init
|
||||
|
||||
export default MapView
|
||||
export type { ComponentConfig as MapConfig, MapInstance, MapLibrary, MapMarkerInstance }
|
||||
@@ -4,6 +4,7 @@ import './src/confetti'
|
||||
import './src/countup'
|
||||
import './src/datepicker'
|
||||
import './src/input-mask'
|
||||
import './src/map'
|
||||
import './src/dropdown'
|
||||
import './src/sidebar'
|
||||
import './src/tooltip'
|
||||
@@ -26,6 +27,7 @@ export { default as Confetti } from './src/confetti'
|
||||
export { default as CountUp } from './src/countup'
|
||||
export { default as Datepicker } from './src/datepicker'
|
||||
export { default as InputMask } from './src/input-mask'
|
||||
export { default as MapView } from './src/map'
|
||||
export { default as OtpInput } from './src/otp-input'
|
||||
export { default as Sortable } from './src/sortable'
|
||||
export { default as Sparkline } from './src/sparkline'
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } 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>> = {}
|
||||
controls: unknown[] = []
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
addControl(control: unknown): void {
|
||||
this.controls.push(control)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
class FakeNavigationControl {}
|
||||
|
||||
const library = { Map: FakeMap, Marker: FakeMarker, Popup: FakePopup, NavigationControl: FakeNavigationControl } 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', () => {
|
||||
delete window.maplibregl
|
||||
|
||||
const view = new MapView(mount('<div></div>'))
|
||||
|
||||
expect(view.map).toBeNull()
|
||||
expect(view.markers).toEqual([])
|
||||
})
|
||||
|
||||
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 add the navigation control when asked', () => {
|
||||
new MapView(mount('<div data-bs-controls="true"></div>'))
|
||||
|
||||
expect(FakeMap.last.controls).toHaveLength(1)
|
||||
expect(FakeMap.last.controls[0]).toBeInstanceOf(FakeNavigationControl)
|
||||
})
|
||||
|
||||
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('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))
|
||||
})
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -212,7 +212,7 @@
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/tabler.js",
|
||||
"maxSize": "59 kB"
|
||||
"maxSize": "61 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/tabler.min.js",
|
||||
@@ -220,7 +220,7 @@
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/tabler.esm.js",
|
||||
"maxSize": "59 kB"
|
||||
"maxSize": "61 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/tabler.esm.min.js",
|
||||
|
||||
Vendored
+7
@@ -125,6 +125,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
+186
-366
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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 MapLibre GL JS.
|
||||
description: Show interactive maps with markers using MapLibre GL JS and free map tiles. No account or access token is needed.
|
||||
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]
|
||||
docs-libs: [maplibre-gl]
|
||||
source: core/scss/vendor/_maplibre.scss
|
||||
@@ -33,22 +33,22 @@ classnames:
|
||||
---
|
||||
import Example from '@components/Example.astro';
|
||||
import Map from '@shared/components/demo/Map.astro';
|
||||
import MapPalette from '@shared/components/demo/MapPalette.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';
|
||||
|
||||
<MapPalette />
|
||||
|
||||
## Overview
|
||||
|
||||
The map is a small wrapper around [MapLibre GL JS](https://maplibre.org/maplibre-gl-js/docs/), an open-source map library. Tabler gives you the container markup and a short init script. MapLibre 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.
|
||||
|
||||
The demos load their map tiles 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.
|
||||
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
|
||||
|
||||
MapLibre GL JS is published as an ES module. Tabler uses version 6. Load the stylesheet, then import the library in a module script:
|
||||
MapLibre GL JS is published as an ES module. Tabler uses version 6. Load the stylesheet, then import the library in a module script, before the Tabler script:
|
||||
|
||||
<Code
|
||||
lang="html"
|
||||
@@ -56,14 +56,15 @@ MapLibre GL JS is published as an ES module. Tabler uses version 6. Load the sty
|
||||
<script type="module">
|
||||
import * as maplibregl from '${site.cdnUrl}/dist/libs/maplibre-gl/dist/maplibre-gl.mjs'
|
||||
|
||||
// Makes the library available to the other scripts on the page
|
||||
// The map component looks for the library here
|
||||
window.maplibregl = maplibregl
|
||||
</script>`}
|
||||
</script>
|
||||
<script src="${site.cdnUrl}/dist/js/tabler.min.js" defer></script>`}
|
||||
/>
|
||||
|
||||
A module script runs before the `DOMContentLoaded` event, so the code examples on this page can use the `maplibregl` global. The library loads two more files from the same folder, `maplibre-gl-shared.mjs` and `maplibre-gl-worker.mjs`, so keep the three together when you host them yourself.
|
||||
The library loads two more files from the same folder, `maplibre-gl-shared.mjs` and `maplibre-gl-worker.mjs`, so keep the three together when you host them yourself.
|
||||
|
||||
With a bundler, install the package and import it in your code:
|
||||
With a bundler, install the package and set the global before you import Tabler:
|
||||
|
||||
```bash
|
||||
npm install maplibre-gl
|
||||
@@ -72,6 +73,8 @@ npm install maplibre-gl
|
||||
```js
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
|
||||
window.maplibregl = maplibregl
|
||||
```
|
||||
|
||||
<Danger title="Use version 6.4.1 or newer">
|
||||
@@ -89,177 +92,182 @@ Tabler styles the map controls, popups and attribution to match the interface, i
|
||||
|
||||
## 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.
|
||||
|
||||
<Example codeOnly>
|
||||
<div class="ratio ratio-16x9">
|
||||
<div><div id="map-simple" class="w-100 h-100"></div></div>
|
||||
</div>
|
||||
</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
|
||||
|
||||
Create the map after the DOM is ready. Pass the id of your container, a style, a zoom level, and a center point.
|
||||
|
||||
```js
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const map = new maplibregl.Map({
|
||||
container: 'map-simple',
|
||||
style: 'https://tiles.openfreemap.org/styles/positron',
|
||||
zoom: 13,
|
||||
center: [13.4049, 52.518827],
|
||||
})
|
||||
})
|
||||
```
|
||||
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 flush>
|
||||
<Map rounded={false} mapId="simple" />
|
||||
</Example>
|
||||
|
||||
`center` takes the longitude first and the latitude second. This is the opposite of what most map services show.
|
||||
Change the shape with the ratio classes, for example `ratio-21x9` for a wide map or `ratio-1x1` for a square one. Add `data-bs-controls="true"` for the zoom and compass buttons.
|
||||
|
||||
### Map styles
|
||||
|
||||
Set `style` to the URL of any MapLibre style. OpenFreeMap has five of them:
|
||||
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 |
|
||||
| --- | --- |
|
||||
| `https://tiles.openfreemap.org/styles/positron` | Light gray map, used by the Tabler demos with [softer colors](#map-colors). Markers and data stand out on it. |
|
||||
| `https://tiles.openfreemap.org/styles/dark` | Dark gray map, used by the demos in dark mode. |
|
||||
| `https://tiles.openfreemap.org/styles/liberty` | Street map in color. |
|
||||
| `https://tiles.openfreemap.org/styles/bright` | Street map with stronger colors. |
|
||||
| `https://tiles.openfreemap.org/styles/fiord` | Dark blue map. |
|
||||
| `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>
|
||||
|
||||
### Map colors
|
||||
`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.
|
||||
|
||||
The Tabler demos make the `positron` and `dark` styles softer: near-white land, pale water and muted labels in light mode, and blue-gray water and roads in dark mode. They do it by changing the paint values of the style layers after the style loads:
|
||||
### Dark mode
|
||||
|
||||
```js
|
||||
map.on('style.load', function () {
|
||||
map.setPaintProperty('background', 'background-color', '#fafaf8')
|
||||
map.setPaintProperty('water', 'fill-color', '#d4dadc')
|
||||
map.setPaintProperty('boundary_2', 'line-color', '#e3cdd0')
|
||||
})
|
||||
```
|
||||
|
||||
The full list of colors for both modes is in the `tablerMapPalette()` function in the source of the [maps preview page](https://preview.tabler.io/maps.html). Copy it to get the same look. The layer names belong to the OpenFreeMap styles, so the function works only with them.
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
Add a marker for each place. `setLngLat` takes the same longitude and latitude order as `center`. The demos use a small dot, drawn with the `.map-marker` and `.map-marker-dot` classes.
|
||||
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.
|
||||
|
||||
```js
|
||||
const cities = [
|
||||
[-58.666667, -34.58333333], // Buenos Aires
|
||||
[16.366667, 48.2], // Vienna
|
||||
[116.383333, 39.91666667], // Beijing
|
||||
]
|
||||
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}`.
|
||||
|
||||
cities.forEach(function (coords) {
|
||||
const element = document.createElement('span')
|
||||
element.className = 'map-marker map-marker-dot'
|
||||
<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>
|
||||
|
||||
new maplibregl.Marker({ element: element }).setLngLat(coords).addTo(map)
|
||||
})
|
||||
```
|
||||
Add `data-bs-draggable="true"` to let people move a marker, for example to pick an address.
|
||||
|
||||
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.
|
||||
|
||||
<Example flush>
|
||||
<Map rounded={false} mapId="markers" />
|
||||
</Example>
|
||||
|
||||
Without `element`, MapLibre draws its own pin. Its `color` option accepts a CSS variable, for example `var(--tblr-primary)`.
|
||||
|
||||
### Custom markers
|
||||
|
||||
To draw a marker in HTML, create an element with the `.map-marker` class and pass it as `element`. The marker is a circle in the primary color. Put an icon, a short count or an avatar inside it.
|
||||
|
||||
```js
|
||||
const element = document.createElement('span')
|
||||
element.className = 'map-marker map-marker-green'
|
||||
element.textContent = '12'
|
||||
|
||||
new maplibregl.Marker({ element: element }).setLngLat([13.35, 52.512]).addTo(map)
|
||||
```
|
||||
|
||||
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 `anchor: 'bottom'`, so its tip sits on the place. Change the color with `.map-marker-{color}`.
|
||||
|
||||
```js
|
||||
const pin = document.createElement('span')
|
||||
pin.className = 'map-marker map-marker-pin map-marker-red'
|
||||
|
||||
new maplibregl.Marker({ element: pin, anchor: 'bottom' }).setLngLat([13.47, 52.518]).addTo(map)
|
||||
```
|
||||
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="marker-styles" title="Marker styles" center={[52.522, 13.405]} zoom={11}>
|
||||
<div data-lng="13.345" data-lat="52.54"><span class="map-marker map-marker-dot"></span></div>
|
||||
<div data-lng="13.39" data-lat="52.545"><span class="map-marker map-marker-dot map-marker-red"></span></div>
|
||||
<div data-lng="13.445" data-lat="52.538"><span class="map-marker"><Icon name="building-store" /><span class="map-marker-label">Store</span></span></div>
|
||||
<div data-lng="13.35" data-lat="52.512"><span class="map-marker map-marker-green">12</span></div>
|
||||
<div data-lng="13.405" data-lat="52.522" data-anchor="bottom"><span class="map-marker map-marker-pin"><Icon name="home" /></span></div>
|
||||
<div data-lng="13.47" data-lat="52.518" data-anchor="bottom"><span class="map-marker map-marker-pin map-marker-red"><Icon name="star" /></span></div>
|
||||
<div data-lng="13.43" data-lat="52.508"><span class="map-marker"><span class="avatar avatar-sm" style="background-image: url(/static/avatars/000m.jpg)"></span></span></div>
|
||||
<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>
|
||||
|
||||
You can also write the markers in the page and move them to the map. The preview pages do this: each marker sits in a hidden element with `data-lng` and `data-lat`, and a short loop passes it to MapLibre.
|
||||
### 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 id="map-markers" hidden>
|
||||
<div data-lng="13.405" data-lat="52.522">
|
||||
<span class="map-marker map-marker-pin">...</span>
|
||||
<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
|
||||
|
||||
Lines, areas and large sets of points are drawn as map layers. For these, 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 the `style.load` event. It runs when the map starts and again each time the style changes, for example on a theme change.
|
||||
|
||||
```js
|
||||
document.querySelectorAll('#map-markers > [data-lng]').forEach(function (item) {
|
||||
new maplibregl.Marker({ element: item.firstElementChild, anchor: 'bottom' })
|
||||
.setLngLat([Number(item.dataset.lng), Number(item.dataset.lat)])
|
||||
.addTo(map)
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const map = tabler.MapView.getInstance('#map-route').map
|
||||
|
||||
map.on('style.load', function () {
|
||||
map.addSource('route', {
|
||||
type: 'geojson',
|
||||
data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [[13.3695, 52.5251], [13.4132, 52.5219], [13.4397, 52.505]] } },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'route',
|
||||
type: 'line',
|
||||
source: 'route',
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': tabler.MapView.color('--tblr-primary'), 'line-width': 3 },
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Draggable markers
|
||||
For an area, use a `Polygon` geometry with a `fill` layer, and a `line` layer for its outline.
|
||||
|
||||
Pass `draggable: true` to let people move a marker, for example to pick an address. Read the new position in the `dragend` event.
|
||||
### Theme colors in layers
|
||||
|
||||
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 `style.load`, so the colors are read again after a theme change.
|
||||
|
||||
```js
|
||||
const marker = new maplibregl.Marker({ element: pin, anchor: 'bottom', draggable: true }).setLngLat([13.405, 52.52]).addTo(map)
|
||||
tabler.MapView.color('--tblr-primary') // 'rgb(6, 111, 209)'
|
||||
```
|
||||
|
||||
marker.on('dragend', function () {
|
||||
console.log(marker.getLngLat())
|
||||
### 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' },
|
||||
})
|
||||
```
|
||||
|
||||
### Popups
|
||||
|
||||
Attach a popup to a marker with `setPopup`. It opens when the marker is clicked. Click a marker on the map above to see one. Tabler draws the popup as a small bordered panel with no arrow.
|
||||
|
||||
```js
|
||||
new maplibregl.Marker({ element: element })
|
||||
.setLngLat([16.366667, 48.2])
|
||||
.setPopup(new maplibregl.Popup({ offset: 16, closeButton: false }).setText('Vienna'))
|
||||
.addTo(map)
|
||||
```
|
||||
|
||||
Leave out `closeButton: false` to show a close button in the corner. The popup also closes on a click on the map.
|
||||
`text-font` has to be a font the map style provides. All OpenFreeMap styles have `Noto Sans Regular`.
|
||||
|
||||
### Tooltips
|
||||
|
||||
For a short label on hover, add the `map-tooltip` class to a popup. It turns the popup into a small dark label.
|
||||
For a short label on hover, add the `map-tooltip` class to a MapLibre popup. It turns the popup into a small dark label.
|
||||
|
||||
```js
|
||||
const tooltip = new maplibregl.Popup({ closeButton: false, offset: 12, className: 'map-tooltip' })
|
||||
@@ -272,266 +280,78 @@ map.on('mouseleave', 'cities', function () {
|
||||
})
|
||||
```
|
||||
|
||||
Use `setHTML` instead of `setText` for richer content. Only pass HTML you trust, as it is not escaped.
|
||||
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 delivery tracker, a map of live users and a vector map of users by country.
|
||||
|
||||
### Popup card
|
||||
## JavaScript
|
||||
|
||||
For a popup with an image, add the `map-popup-card` class with the `className` option. It removes the padding and sets a fixed width, so the image reaches the edges. Keep the content in a `<template>` and pass it with `setDOMContent`.
|
||||
### Via data attributes
|
||||
|
||||
```html
|
||||
<template id="popup-museum">
|
||||
<div class="img-responsive img-responsive-21x9" style="background-image: url(...)"></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>
|
||||
```
|
||||
An element with `data-bs-toggle="map"` becomes a map when the Tabler 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 popup = new maplibregl.Popup({ offset: 40, className: 'map-popup-card', maxWidth: 'none' })
|
||||
.setDOMContent(document.getElementById('popup-museum').content)
|
||||
|
||||
new maplibregl.Marker({ element: pin, anchor: 'bottom' }).setLngLat([13.4019, 52.5169]).setPopup(popup).addTo(map)
|
||||
```
|
||||
|
||||
<Example flush>
|
||||
<Map rounded={false} mapId="popup-card" title="Popup card" center={[52.546, 13.4]} zoom={11} ratio="4x3">
|
||||
<div data-lng="13.4019" data-lat="52.5169" data-anchor="bottom" data-open>
|
||||
<span class="map-marker map-marker-pin"><Icon name="map-pin" /></span>
|
||||
<template class="map-popup-card">
|
||||
<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>
|
||||
</div>
|
||||
</Map>
|
||||
</Example>
|
||||
|
||||
### Controls
|
||||
|
||||
Add the zoom and compass buttons with `NavigationControl`. They go to the top right corner by default.
|
||||
|
||||
```js
|
||||
map.addControl(new maplibregl.NavigationControl())
|
||||
```
|
||||
|
||||
MapLibre also has `FullscreenControl`, `GeolocateControl` and `ScaleControl`. Pass a corner as the second argument, for example `'bottom-left'`.
|
||||
|
||||
### Dark mode
|
||||
|
||||
A map style does not change with the page theme on its own. Pick the style from the `data-bs-theme` attribute, and set it again when the attribute changes:
|
||||
|
||||
```js
|
||||
const themeStyle = function () {
|
||||
const dark = document.documentElement.getAttribute('data-bs-theme') === 'dark'
|
||||
return 'https://tiles.openfreemap.org/styles/' + (dark ? 'dark' : 'positron')
|
||||
}
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: 'map-simple',
|
||||
style: themeStyle(),
|
||||
zoom: 13,
|
||||
center: [13.4049, 52.518827],
|
||||
})
|
||||
|
||||
new MutationObserver(function () {
|
||||
map.setStyle(themeStyle())
|
||||
}).observe(document.documentElement, { attributeFilter: ['data-bs-theme'] })
|
||||
```
|
||||
|
||||
Markers and popups stay on the map when the style changes. The maps on this page follow the theme this way.
|
||||
|
||||
### Layers
|
||||
|
||||
Lines, areas and large sets of points are drawn as layers. A layer reads its data from a GeoJSON source. Layers are part of the map style, so add them in the `style.load` event. It runs when the map starts and again each time the style changes, for example on a theme change.
|
||||
|
||||
```js
|
||||
map.on('style.load', function () {
|
||||
map.addSource('route', {
|
||||
type: 'geojson',
|
||||
data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [[13.3695, 52.5251], [13.4132, 52.5219], [13.4397, 52.505]] } },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'route',
|
||||
type: 'line',
|
||||
source: 'route',
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': '#066fd1', 'line-width': 3, 'line-opacity': 0.8 },
|
||||
})
|
||||
const view = new tabler.MapView('#map', {
|
||||
center: [13.405, 52.52],
|
||||
zoom: 12,
|
||||
controls: true,
|
||||
})
|
||||
```
|
||||
|
||||
For an area, use a `Polygon` geometry with a `fill` layer, and a `line` layer for its outline:
|
||||
### Options
|
||||
|
||||
```js
|
||||
map.addLayer({ id: 'area-fill', type: 'fill', source: 'area', paint: { 'fill-color': '#066fd1', 'fill-opacity': 0.16 } })
|
||||
map.addLayer({ id: 'area-outline', type: 'line', source: 'area', paint: { 'line-color': '#066fd1', 'line-width': 2 } })
|
||||
```
|
||||
Pass options as data attributes, with the name written in kebab case after `data-bs-`, or in the config object.
|
||||
|
||||
### Theme colors in layers
|
||||
| Name | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `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 | `false` | Show the zoom and compass buttons. |
|
||||
| `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. |
|
||||
|
||||
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. Turn the variable into a plain `rgb()` color first:
|
||||
A marker element takes these attributes:
|
||||
|
||||
```js
|
||||
function mapColor(name) {
|
||||
const context = document.createElement('canvas').getContext('2d')
|
||||
const probe = document.body.appendChild(document.createElement('span'))
|
||||
| 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. |
|
||||
|
||||
probe.style.color = 'var(' + name + ')'
|
||||
context.fillStyle = getComputedStyle(probe).color
|
||||
context.fillRect(0, 0, 1, 1)
|
||||
probe.remove()
|
||||
### Methods
|
||||
|
||||
const pixel = context.getImageData(0, 0, 1, 1).data
|
||||
| Name | Description |
|
||||
| --- | --- |
|
||||
| `map` | The MapLibre map, for layers, events and everything else the options do not cover. |
|
||||
| `markers` | The MapLibre markers made from the child elements, in document order. |
|
||||
| `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. |
|
||||
|
||||
return 'rgb(' + pixel[0] + ', ' + pixel[1] + ', ' + pixel[2] + ')'
|
||||
}
|
||||
### Events
|
||||
|
||||
map.addLayer({ id: 'route', type: 'line', source: 'route', paint: { 'line-color': mapColor('--tblr-primary'), 'line-width': 3 } })
|
||||
```
|
||||
|
||||
Call it inside `style.load`, so the colors are read again after a theme change.
|
||||
|
||||
### Clusters
|
||||
|
||||
HTML markers work well for up to a few hundred places. For more, put the 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': '#066fd1', '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' },
|
||||
})
|
||||
|
||||
// Single points
|
||||
map.addLayer({ id: 'store', type: 'circle', source: 'stores', filter: ['!', ['has', 'point_count']], paint: { 'circle-color': '#066fd1', 'circle-radius': 6 } })
|
||||
```
|
||||
|
||||
`text-font` has to be a font the map style provides. All OpenFreeMap styles have `Noto Sans Regular`.
|
||||
|
||||
### Bubble map
|
||||
|
||||
To size circles by a value, read it from the feature with `['get', ...]` and map it to a radius with `interpolate`:
|
||||
|
||||
```js
|
||||
map.addLayer({
|
||||
id: 'cities',
|
||||
type: 'circle',
|
||||
source: 'cities',
|
||||
paint: {
|
||||
'circle-color': '#066fd1',
|
||||
'circle-opacity': 0.6,
|
||||
'circle-radius': ['interpolate', ['linear'], ['get', 'users'], 0, 4, 1600, 24],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Content over the map
|
||||
|
||||
To put buttons or a small card over the map, place them next to the map container and position them with the utility classes:
|
||||
|
||||
```html
|
||||
<div class="ratio ratio-16x9">
|
||||
<div>
|
||||
<div id="map-cities" class="w-100 h-100 rounded"></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>
|
||||
```
|
||||
|
||||
### Globe
|
||||
|
||||
MapLibre can draw the map as a 3D globe. Set the projection in the `style.load` event, as it is part of the map style. People can turn the globe by dragging it.
|
||||
|
||||
```js
|
||||
map.on('style.load', function () {
|
||||
map.setProjection({ type: 'globe' })
|
||||
})
|
||||
```
|
||||
|
||||
Use a low zoom, between 1 and 2, to see the whole globe. To connect two places with an arc, draw a line layer through points on the shortest path between them. The [maps preview page](https://preview.tabler.io/maps.html) has a short function that returns these points.
|
||||
|
||||
### Moving the map
|
||||
|
||||
Move to another place with `flyTo`, for example from a row of buttons:
|
||||
|
||||
```js
|
||||
button.addEventListener('click', function () {
|
||||
map.flyTo({ center: [139.6917, 35.6895], zoom: 10 })
|
||||
})
|
||||
```
|
||||
|
||||
Live versions of the route, area, clusters, bubble map and city switcher are on the [maps preview page](https://preview.tabler.io/maps.html).
|
||||
|
||||
### Attribution
|
||||
|
||||
MapLibre adds the credit for the map data in the corner of the map. OpenFreeMap requires it, so do not hide it.
|
||||
|
||||
## Examples
|
||||
|
||||
### Map in a card
|
||||
|
||||
Add a card title above the map. This is the usual layout for a small map next to other content.
|
||||
|
||||
<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>
|
||||
|
||||
### Full-card map
|
||||
|
||||
Drop the card body to let the map fill the whole card. Add `rounded` to the container, so the map follows the card corners.
|
||||
|
||||
<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>
|
||||
|
||||
Live versions of these maps are on the [maps preview page](https://preview.tabler.io/maps.html).
|
||||
|
||||
### Blocks
|
||||
|
||||
The [map blocks preview page](https://preview.tabler.io/map-blocks.html) has larger examples that pair a map with other components: a store locator with a searchable list, a delivery tracker with a route and a status card over the map, a world map of live users, and a vector map of users by country next to a table.
|
||||
| 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. To use Mapbox instead, load its script and stylesheet, replace `maplibregl` with `mapboxgl`, set `mapboxgl.accessToken` to your public token, and use a `mapbox://` style URL.
|
||||
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.
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
|
||||
import MapPalette from '@components/demo/MapPalette.astro'
|
||||
import MapStoreLocator from '@components/cards/MapStoreLocator.astro'
|
||||
import MapDeliveryTracker from '@components/cards/MapDeliveryTracker.astro'
|
||||
import MapLiveUsers from '@components/cards/MapLiveUsers.astro'
|
||||
@@ -10,7 +9,6 @@ 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" />
|
||||
<MapPalette />
|
||||
|
||||
<div class="row row-cards">
|
||||
<div class="col-12">
|
||||
|
||||
@@ -1,42 +1,8 @@
|
||||
---
|
||||
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
|
||||
import CaptureScript from '@shared/components/CaptureScript.astro'
|
||||
import MapPalette from '@components/demo/MapPalette.astro'
|
||||
---
|
||||
|
||||
<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>
|
||||
<MapPalette />
|
||||
<div class="map flex-fill" id="map-fullsize" role="img" aria-label="Map of Berlin"></div>
|
||||
<CaptureScript>
|
||||
<!-- BEGIN MAP SCRIPT -->
|
||||
<script is:inline>
|
||||
window.tabler_map ??= {}
|
||||
|
||||
function initMap() {
|
||||
const theme = () => (document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'dark' : 'light')
|
||||
const themeStyle = () => `https://tiles.openfreemap.org/styles/${theme() === 'dark' ? 'dark' : 'positron'}`
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: 'map-fullsize',
|
||||
style: themeStyle(),
|
||||
zoom: 12,
|
||||
center: [13.4049, 52.518827],
|
||||
})
|
||||
|
||||
map.addControl(new maplibregl.NavigationControl())
|
||||
|
||||
// Softer map colors, set again each time the style loads
|
||||
map.on('style.load', () => tablerMapPalette(map, theme()))
|
||||
|
||||
// Change the map style when the page theme changes
|
||||
new MutationObserver(() => map.setStyle(themeStyle())).observe(document.documentElement, { attributeFilter: ['data-bs-theme'] })
|
||||
|
||||
window.tabler_map['map-fullsize'] = 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="true"></div>
|
||||
</DefaultLayout>
|
||||
+36
-44
@@ -1,7 +1,6 @@
|
||||
---
|
||||
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
|
||||
import Map from '@components/demo/Map.astro'
|
||||
import MapPalette from '@components/demo/MapPalette.astro'
|
||||
import Card from '@ui/Card.astro'
|
||||
import CardBody from '@ui/CardBody.astro'
|
||||
import maps from '@data/maps.json'
|
||||
@@ -79,7 +78,6 @@ const places = [
|
||||
|
||||
<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" />
|
||||
<MapPalette />
|
||||
<div class="row row-cards">
|
||||
{
|
||||
mapEntries.map(([mapId, data]) =>
|
||||
@@ -108,13 +106,13 @@ const places = [
|
||||
<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.</CardSubtitle>
|
||||
<Map mapId="marker-styles" title="Marker styles" center={[52.522, 13.405]} zoom={11} ratio="4x3">
|
||||
<div data-lng="13.345" data-lat="52.54"><span class="map-marker map-marker-dot"></span></div>
|
||||
<div data-lng="13.39" data-lat="52.545"><span class="map-marker map-marker-dot map-marker-red"></span></div>
|
||||
<div data-lng="13.445" data-lat="52.538"><span class="map-marker"><Icon name="building-store" /><span class="map-marker-label">Store</span></span></div>
|
||||
<div data-lng="13.35" data-lat="52.512"><span class="map-marker map-marker-green">12</span></div>
|
||||
<div data-lng="13.405" data-lat="52.52" data-anchor="bottom"><span class="map-marker map-marker-pin"><Icon name="home" /></span></div>
|
||||
<div data-lng="13.47" data-lat="52.518" data-anchor="bottom"><span class="map-marker map-marker-pin map-marker-red"><Icon name="star" /></span></div>
|
||||
<div data-lng="13.43" data-lat="52.503"><span class="map-marker"><Avatar size="sm" src={staticPath('avatars/000m.jpg')} /></span></div>
|
||||
<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.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>
|
||||
@@ -124,15 +122,13 @@ const places = [
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Popup card</CardTitle>
|
||||
<CardSubtitle>Put the popup content in a <code><template></code> next to the marker. <code>.map-popup-card</code> makes room for an image.</CardSubtitle>
|
||||
<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) => (
|
||||
<div data-lng={place.lng} data-lat={place.lat} data-anchor="bottom" data-open={place.open ? '' : undefined}>
|
||||
<span class="map-marker map-marker-pin">
|
||||
<Icon name="map-pin" />
|
||||
</span>
|
||||
<template class="map-popup-card">
|
||||
<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>
|
||||
@@ -142,7 +138,7 @@ const places = [
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</Map>
|
||||
@@ -156,13 +152,13 @@ const places = [
|
||||
<CardTitle>Route</CardTitle>
|
||||
<CardSubtitle>Draw a path with a GeoJSON line layer, and mark its ends with markers.</CardSubtitle>
|
||||
<Map mapId="route" title="Route" center={[52.513, 13.4046]} zoom={11.6}>
|
||||
<div data-lng="13.3695" data-lat="52.5251"><span class="map-marker map-marker-dot"></span></div>
|
||||
<div data-lng="13.4397" data-lat="52.505" data-anchor="bottom"><span class="map-marker map-marker-pin"><Icon name="map-pin" /></span></div>
|
||||
<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>
|
||||
<CaptureScript>
|
||||
<script is:inline define:vars={{ route }}>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const map = window.tabler_map['map-route']
|
||||
const map = tabler.MapView.getInstance('#map-route').map
|
||||
|
||||
// Layers are part of the style, so add them again each time the style loads
|
||||
map.on('style.load', () => {
|
||||
@@ -172,7 +168,7 @@ const places = [
|
||||
type: 'line',
|
||||
source: 'route',
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': tablerMapColor('--tblr-primary'), 'line-width': 3, 'line-opacity': 0.8 },
|
||||
paint: { 'line-color': tabler.MapView.color('--tblr-primary'), 'line-width': 3, 'line-opacity': 0.8 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -191,10 +187,10 @@ const places = [
|
||||
<CaptureScript>
|
||||
<script is:inline define:vars={{ area }}>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const map = window.tabler_map['map-area']
|
||||
const map = tabler.MapView.getInstance('#map-area').map
|
||||
|
||||
map.on('style.load', () => {
|
||||
const color = tablerMapColor('--tblr-primary')
|
||||
const color = tabler.MapView.color('--tblr-primary')
|
||||
|
||||
map.addSource('area', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'Polygon', coordinates: [area] } } })
|
||||
map.addLayer({ id: 'area-fill', type: 'fill', source: 'area', paint: { 'fill-color': color, 'fill-opacity': 0.16 } })
|
||||
@@ -216,11 +212,11 @@ const places = [
|
||||
<CaptureScript>
|
||||
<script is:inline define:vars={{ stores }}>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const map = window.tabler_map['map-clusters']
|
||||
const map = tabler.MapView.getInstance('#map-clusters').map
|
||||
|
||||
map.on('style.load', () => {
|
||||
const color = tablerMapColor('--tblr-primary')
|
||||
const border = tablerMapColor('--tblr-bg-surface')
|
||||
const color = tabler.MapView.color('--tblr-primary')
|
||||
const border = tabler.MapView.color('--tblr-bg-surface')
|
||||
|
||||
map.addSource('stores', {
|
||||
type: 'geojson',
|
||||
@@ -241,7 +237,7 @@ const places = [
|
||||
source: 'stores',
|
||||
filter: ['has', 'point_count'],
|
||||
layout: { 'text-field': ['get', 'point_count_abbreviated'], 'text-font': ['Noto Sans Regular'], 'text-size': 12 },
|
||||
paint: { 'text-color': tablerMapColor('--tblr-primary-fg') },
|
||||
paint: { 'text-color': tabler.MapView.color('--tblr-primary-fg') },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'store',
|
||||
@@ -286,7 +282,7 @@ const places = [
|
||||
<CaptureScript>
|
||||
<script is:inline>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const map = window.tabler_map['map-cities']
|
||||
const map = tabler.MapView.getInstance('#map-cities').map
|
||||
const buttons = document.querySelectorAll('#map-cities-switcher .btn')
|
||||
|
||||
buttons.forEach((button) => {
|
||||
@@ -314,11 +310,11 @@ const places = [
|
||||
<CaptureScript>
|
||||
<script is:inline define:vars={{ cities }}>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const map = window.tabler_map['map-bubbles']
|
||||
const map = tabler.MapView.getInstance('#map-bubbles').map
|
||||
const tooltip = new maplibregl.Popup({ closeButton: false, offset: 12, className: 'map-tooltip' })
|
||||
|
||||
map.on('style.load', () => {
|
||||
const color = tablerMapColor('--tblr-primary')
|
||||
const color = tabler.MapView.color('--tblr-primary')
|
||||
|
||||
map.addSource('cities', {
|
||||
type: 'geojson',
|
||||
@@ -353,27 +349,23 @@ const places = [
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Globe</CardTitle>
|
||||
<CardSubtitle>Show the map as a 3D globe with <code>setProjection</code>, and connect places with arcs.</CardSubtitle>
|
||||
<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>
|
||||
<div data-lng={hub.lng} data-lat={hub.lat}>
|
||||
<span class="map-marker map-marker-dot">
|
||||
<span class="map-marker-label">{hub.name}</span>
|
||||
</span>
|
||||
</div>
|
||||
<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) => (
|
||||
<div data-lng={place.lng} data-lat={place.lat}>
|
||||
<span class="map-marker map-marker-dot" style="--tblr-map-marker-size: 0.625rem">
|
||||
<span class="map-marker-label">{place.name}</span>
|
||||
</span>
|
||||
</div>
|
||||
<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 map = window.tabler_map['map-globe']
|
||||
const map = tabler.MapView.getInstance('#map-globe').map
|
||||
|
||||
// Points along the shortest path between two places on the globe
|
||||
const arc = (from, to, steps = 64) => {
|
||||
@@ -406,7 +398,7 @@ const places = [
|
||||
id: 'arcs',
|
||||
type: 'line',
|
||||
source: 'arcs',
|
||||
paint: { 'line-color': tablerMapColor('--tblr-primary'), 'line-width': 1.5, 'line-dasharray': [2, 2] },
|
||||
paint: { 'line-color': tabler.MapView.color('--tblr-primary'), 'line-width': 1.5, 'line-dasharray': [2, 2] },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -420,9 +412,9 @@ const places = [
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Draggable marker</CardTitle>
|
||||
<CardSubtitle>Add <code>draggable: true</code> to let people move a marker, for example to pick an address.</CardSubtitle>
|
||||
<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">
|
||||
<div data-lng="13.405" data-lat="52.52" data-anchor="bottom" data-draggable><span class="map-marker map-marker-pin"><Icon name="map-pin" /></span></div>
|
||||
<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>
|
||||
|
||||
@@ -193,13 +193,14 @@ const libModules = pageLibEntries.flatMap(([, lib]) => (lib.module ? [`import *
|
||||
else document.addEventListener('DOMContentLoaded', () => resolve(undefined), { once: true })
|
||||
})
|
||||
// Maps draw their tiles after the page is ready, so wait until each one has loaded.
|
||||
// The first check is delayed: the maps are created in DOMContentLoaded handlers.
|
||||
// The first check is delayed, so the maps exist by then.
|
||||
const mapsReady = () =>
|
||||
new Promise((resolve) => {
|
||||
const check = () => {
|
||||
const maps = Object.values((window as { tabler_map?: Record<string, { loaded(): boolean }> }).tabler_map ?? {})
|
||||
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)
|
||||
if (maps.every((map) => map?.loaded())) resolve(undefined)
|
||||
else setTimeout(check, 100)
|
||||
}
|
||||
|
||||
|
||||
@@ -190,13 +190,14 @@ const libModules = pageLibEntries.flatMap(([, lib]) => (lib.module ? [`import *
|
||||
else document.addEventListener('DOMContentLoaded', () => resolve(undefined), { once: true })
|
||||
})
|
||||
// Maps draw their tiles after the page is ready, so wait until each one has loaded.
|
||||
// The first check is delayed: the maps are created in DOMContentLoaded handlers.
|
||||
// The first check is delayed, so the maps exist by then.
|
||||
const mapsReady = () =>
|
||||
new Promise((resolve) => {
|
||||
const check = () => {
|
||||
const maps = Object.values((window as { tabler_map?: Record<string, { loaded(): boolean }> }).tabler_map ?? {})
|
||||
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)
|
||||
if (maps.every((map) => map?.loaded())) resolve(undefined)
|
||||
else setTimeout(check, 100)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
// 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 MapPalette from '@components/demo/MapPalette.astro'
|
||||
import MapDeliveryTracker from '@components/cards/MapDeliveryTracker.astro'
|
||||
---
|
||||
|
||||
<ScreenshotLayout title="Map: delivery tracker" columns={1.8} pageLibs={['maplibre-gl']}>
|
||||
<MapPalette />
|
||||
<MapDeliveryTracker />
|
||||
</ScreenshotLayout>
|
||||
@@ -2,11 +2,9 @@
|
||||
// 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 MapPalette from '@components/demo/MapPalette.astro'
|
||||
import MapLiveUsers from '@components/cards/MapLiveUsers.astro'
|
||||
---
|
||||
|
||||
<ScreenshotLayout title="Map: live users" columns={1.8} pageLibs={['maplibre-gl']}>
|
||||
<MapPalette />
|
||||
<MapLiveUsers />
|
||||
</ScreenshotLayout>
|
||||
@@ -2,11 +2,9 @@
|
||||
// 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 MapPalette from '@components/demo/MapPalette.astro'
|
||||
import MapStoreLocator from '@components/cards/MapStoreLocator.astro'
|
||||
---
|
||||
|
||||
<ScreenshotLayout title="Map: store locator" columns={3} pageLibs={['maplibre-gl']}>
|
||||
<MapPalette />
|
||||
<MapStoreLocator />
|
||||
</ScreenshotLayout>
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
// A delivery on a map: the route, the courier position and the order status.
|
||||
// Needs <MapPalette /> on the page, before this card.
|
||||
import Map from '../demo/Map.astro'
|
||||
import CaptureScript from '../CaptureScript.astro'
|
||||
import Avatar from '@ui/Avatar.astro'
|
||||
@@ -40,22 +39,18 @@ const courierAt = 5
|
||||
<Progress value={62} size="sm" color="green" />
|
||||
</div>
|
||||
</div>
|
||||
<div data-lng={route[0]?.[0]} data-lat={route[0]?.[1]}><span class="map-marker map-marker-dot"></span></div>
|
||||
<div data-lng={route[courierAt]?.[0]} data-lat={route[courierAt]?.[1]}>
|
||||
<span class="map-marker map-marker-green"><Icon name="truck-delivery" /></span>
|
||||
</div>
|
||||
<div data-lng={route.at(-1)?.[0]} data-lat={route.at(-1)?.[1]} data-anchor="bottom">
|
||||
<span class="map-marker map-marker-pin"><Icon name="home" /></span>
|
||||
</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" 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>
|
||||
<CaptureScript>
|
||||
<script is:inline define:vars={{ route, courierAt }}>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const map = window.tabler_map['map-delivery']
|
||||
const map = tabler.MapView.getInstance('#map-delivery').map
|
||||
const line = (coordinates) => ({ type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates } } })
|
||||
|
||||
map.on('style.load', () => {
|
||||
const color = tablerMapColor('--tblr-primary')
|
||||
const color = tabler.MapView.color('--tblr-primary')
|
||||
|
||||
// The part of the route still to drive is dashed, the part already driven is solid
|
||||
map.addSource('route-left', line(route.slice(courierAt)))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
// A world map of active users, with the total in a card over the map.
|
||||
// Needs <MapPalette /> on the page, before this card.
|
||||
import Map from '../demo/Map.astro'
|
||||
import CaptureScript from '../CaptureScript.astro'
|
||||
import Card from '@ui/Card.astro'
|
||||
@@ -38,7 +37,7 @@ const cities = [
|
||||
<CaptureScript>
|
||||
<script is:inline define:vars={{ cities }}>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const map = window.tabler_map['map-live-users']
|
||||
const map = tabler.MapView.getInstance('#map-live-users').map
|
||||
const tooltip = new maplibregl.Popup({ closeButton: false, offset: 12, className: 'map-tooltip' })
|
||||
|
||||
map.on('style.load', () => {
|
||||
@@ -50,7 +49,7 @@ const cities = [
|
||||
id: 'cities',
|
||||
type: 'circle',
|
||||
source: 'cities',
|
||||
paint: { 'circle-color': tablerMapColor('--tblr-primary'), 'circle-opacity': 0.6, 'circle-radius': ['interpolate', ['linear'], ['get', 'users'], 0, 3, 1600, 14] },
|
||||
paint: { 'circle-color': tabler.MapView.color('--tblr-primary'), 'circle-opacity': 0.6, 'circle-radius': ['interpolate', ['linear'], ['get', 'users'], 0, 3, 1600, 14] },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
// A list of stores next to a map: picking a store moves the map and opens its popup.
|
||||
// Needs <MapPalette /> on the page, before this card.
|
||||
import Map from '../demo/Map.astro'
|
||||
import CaptureScript from '../CaptureScript.astro'
|
||||
import Badge from '@ui/Badge.astro'
|
||||
@@ -34,11 +33,9 @@ const stores = [
|
||||
<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']} data-lng={store.lng} data-lat={store.lat} data-address={store.address}>
|
||||
<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" data-name>
|
||||
{store.name}
|
||||
</span>
|
||||
<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>
|
||||
@@ -51,31 +48,27 @@ const stores = [
|
||||
</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 />
|
||||
<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', () => {
|
||||
const map = window.tabler_map['map-stores']
|
||||
const items = [...document.querySelectorAll('#stores-list [data-lng]')]
|
||||
|
||||
// One marker with a popup for each store in the list
|
||||
const markers = items.map((item) => {
|
||||
const element = document.createElement('span')
|
||||
const popup = document.createElement('div')
|
||||
|
||||
element.className = 'map-marker map-marker-pin'
|
||||
element.innerHTML = item.querySelector('.icon').outerHTML
|
||||
popup.innerHTML = '<div class="fw-medium"></div><div class="text-secondary"></div>'
|
||||
popup.firstChild.textContent = item.querySelector('[data-name]').textContent
|
||||
popup.lastChild.textContent = item.dataset.address
|
||||
|
||||
return new maplibregl.Marker({ element, anchor: 'bottom' })
|
||||
.setLngLat([Number(item.dataset.lng), Number(item.dataset.lat)])
|
||||
.setPopup(new maplibregl.Popup({ offset: 40, closeButton: false, focusAfterOpen: false }).setDOMContent(popup))
|
||||
.addTo(map)
|
||||
})
|
||||
// The markers are in the same order as the stores in the list
|
||||
const { map, 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))
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
---
|
||||
// MapLibre GL map on OpenFreeMap tiles: no account and no access token.
|
||||
// The map follows the page theme: `style` in light mode, `darkStyle` in dark.
|
||||
// With <MapPalette /> on the page, the default styles get softer colors.
|
||||
// Markers written in HTML go in the default slot: each child has `data-lng`
|
||||
// and `data-lat`, holds the marker element, and optionally a `<template>` with
|
||||
// the popup content. Renders nothing unless maps[mapId] exists or `center` is set.
|
||||
// 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; also the suffix of the container id */
|
||||
@@ -44,19 +42,9 @@ type MapData = {
|
||||
};
|
||||
|
||||
const data: MapData | undefined = (maps as unknown as Record<string, MapData>)[mapId] ?? (centerProp ? { title, center: centerProp, zoom: zoomProp, style: styleProp, controls: controlsProp } : undefined);
|
||||
const hasHtmlMarkers = Astro.slots.has('default');
|
||||
|
||||
const mapContainerId = `map-${mapId}`;
|
||||
const styleUrl = (name: string) => `https://tiles.openfreemap.org/styles/${name}`;
|
||||
const style = styleUrl(data?.style ?? 'positron');
|
||||
const darkStyle = styleUrl(data?.darkStyle ?? 'dark');
|
||||
// The softer palette (MapPalette.astro) is made for these two styles only
|
||||
const palette = { light: !data?.style || data.style === 'positron', dark: !data?.darkStyle || data.darkStyle === 'dark' };
|
||||
const controls = data?.controls ?? false;
|
||||
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) => ({ name: marker.name, coords: [marker.center[1], marker.center[0]] }));
|
||||
|
||||
// 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';
|
||||
---
|
||||
|
||||
@@ -64,86 +52,24 @@ const ratioClass = data?.ratio ?? ratio ?? '16x9';
|
||||
data && (
|
||||
<div class:list={height ? 'position-relative' : `ratio ratio-${ratioClass}`} style={height ? `height: ${height}` : undefined}>
|
||||
<div class:list={[height && 'h-100']}>
|
||||
<div id={mapContainerId} class:list={['w-100 h-100', rounded && 'rounded']} role="img" aria-label={data.title ?? 'Map'} />
|
||||
<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 ? 'true' : undefined}
|
||||
data-bs-globe={globe ? 'true' : undefined}
|
||||
>
|
||||
{(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 && hasHtmlMarkers && (
|
||||
<div id={`${mapContainerId}-markers`} hidden>
|
||||
<slot />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
data && (
|
||||
<CaptureScript>
|
||||
<!-- BEGIN MAP -->
|
||||
<script is:inline define:vars={{ mapContainerId, style, darkStyle, palette, controls, globe, zoom, center, markers }}>
|
||||
window.tabler_map ??= {};
|
||||
|
||||
function initMap() {
|
||||
const theme = () => (document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'dark' : 'light');
|
||||
const themeStyle = () => (theme() === 'dark' ? darkStyle : style);
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: mapContainerId,
|
||||
style: themeStyle(),
|
||||
zoom,
|
||||
center,
|
||||
});
|
||||
|
||||
// Softer map colors, when the page has the palette script
|
||||
map.on('style.load', () => {
|
||||
if (window.tablerMapPalette && palette[theme()]) {
|
||||
window.tablerMapPalette(map, theme());
|
||||
}
|
||||
});
|
||||
|
||||
if (controls) {
|
||||
map.addControl(new maplibregl.NavigationControl());
|
||||
}
|
||||
|
||||
if (globe) {
|
||||
// The projection is part of the style, so set it again each time the style loads
|
||||
map.on('style.load', () => map.setProjection({ type: 'globe' }));
|
||||
}
|
||||
|
||||
markers.forEach((marker) => {
|
||||
const element = document.createElement('span');
|
||||
|
||||
element.className = 'map-marker map-marker-dot';
|
||||
new maplibregl.Marker({ element }).setLngLat(marker.coords).setPopup(new maplibregl.Popup({ offset: 16, closeButton: false }).setText(marker.name)).addTo(map);
|
||||
});
|
||||
|
||||
// Markers written in HTML: the first child is the marker, a <template> is its popup
|
||||
document.querySelectorAll(`#${mapContainerId}-markers > [data-lng]`).forEach((item) => {
|
||||
const anchor = item.dataset.anchor ?? 'center';
|
||||
const marker = new maplibregl.Marker({ element: item.firstElementChild, anchor, draggable: 'draggable' in item.dataset });
|
||||
const popup = item.querySelector('template');
|
||||
|
||||
if (popup) {
|
||||
marker.setPopup(new maplibregl.Popup({ offset: anchor === 'bottom' ? 40 : 16, className: popup.className, maxWidth: 'none', focusAfterOpen: !('open' in item.dataset) }).setDOMContent(popup.content));
|
||||
}
|
||||
|
||||
marker.setLngLat([Number(item.dataset.lng), Number(item.dataset.lat)]).addTo(map);
|
||||
|
||||
if (popup && 'open' in item.dataset) {
|
||||
marker.togglePopup();
|
||||
}
|
||||
});
|
||||
|
||||
// Change the map style when the page theme changes
|
||||
new MutationObserver(() => map.setStyle(themeStyle())).observe(document.documentElement, { attributeFilter: ['data-bs-theme'] });
|
||||
|
||||
window.tabler_map[mapContainerId] = map;
|
||||
}
|
||||
|
||||
document.readyState !== 'loading' ? initMap() : document.addEventListener('DOMContentLoaded', initMap, { once: true });
|
||||
</script>
|
||||
<!-- END MAP -->
|
||||
</CaptureScript>
|
||||
)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
---
|
||||
// Softer colors for the OpenFreeMap `positron` and `dark` map styles: near-white
|
||||
// land, pale water and muted labels. Defines `window.tablerMapPalette(map, mode)`;
|
||||
// put it on the page once, before the maps. `Map.astro` calls it on each style load.
|
||||
// Also defines `window.tablerMapColor(name)`, which turns a CSS variable into a
|
||||
// plain rgb() color for a map layer.
|
||||
import CaptureScript from '../CaptureScript.astro';
|
||||
---
|
||||
|
||||
<CaptureScript>
|
||||
<!-- BEGIN MAP PALETTE -->
|
||||
<script is:inline>
|
||||
// MapLibre draws on a canvas, so a layer needs a plain rgb() color instead of a CSS variable
|
||||
window.tablerMapColor = (name) => {
|
||||
const context = document.createElement('canvas').getContext('2d');
|
||||
const probe = document.body.appendChild(document.createElement('span'));
|
||||
|
||||
probe.style.color = `var(${name})`;
|
||||
context.fillStyle = getComputedStyle(probe).color;
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
probe.remove();
|
||||
|
||||
const [red, green, blue] = context.getImageData(0, 0, 1, 1).data;
|
||||
|
||||
return `rgb(${red}, ${green}, ${blue})`;
|
||||
};
|
||||
|
||||
// Paint values by layer id: the first rule that matches a layer is used
|
||||
window.tablerMapPalette = (map, mode) => {
|
||||
const palettes = {
|
||||
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)$/, { 'fill-color': '#fafaf8' }],
|
||||
[/^landuse_residential$/, { 'fill-color': 'rgba(237, 237, 237, 0.5)' }],
|
||||
[/^building$/, { 'fill-color': '#ededed', 'fill-outline-color': '#dfdfdf' }],
|
||||
[/^road_area_pier$/, { 'fill-color': '#fafaf8' }],
|
||||
[/^road_pier$/, { 'line-color': '#fafaf8' }],
|
||||
[/^highway_path$/, { 'line-color': '#ededed' }],
|
||||
[/^highway_minor$/, { 'line-color': '#e6e6e6' }],
|
||||
[/_casing$/, { 'line-color': '#dddddd' }],
|
||||
[/^tunnel_.*_inner$/, { 'line-color': '#f3f3f3' }],
|
||||
[/^highway_.*_inner$/, { 'line-color': '#ffffff' }],
|
||||
[/_subtle$/, { 'line-color': '#e6e6e6' }],
|
||||
[/^railway.*dashline$/, { 'line-color': '#fafaf8' }],
|
||||
[/^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': 'rgba(255, 255, 255, 0.8)' }],
|
||||
[/^(airport|label_country|label_state)/, { 'text-color': '#8a99a4', 'text-halo-color': 'rgba(255, 255, 255, 0.8)' }],
|
||||
[/^label_/, { 'text-color': '#697b89', 'text-halo-color': 'rgba(255, 255, 255, 0.8)' }],
|
||||
],
|
||||
dark: [
|
||||
[/^background$/, { 'background-color': '#0e0e0e' }],
|
||||
[/^water$/, { 'fill-color': '#2c353c' }],
|
||||
[/^waterway$/, { 'line-color': 'rgb(63, 90, 109)' }],
|
||||
[/^landcover_(ice_shelf|glacier)$/, { '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_area_pier$/, { 'fill-color': '#0e0e0e' }],
|
||||
[/^road_pier$/, { 'line-color': '#0e0e0e' }],
|
||||
[/^highway_path$/, { 'line-color': '#232323' }],
|
||||
[/^highway_minor$/, { 'line-color': 'rgba(65, 71, 88, 0.7)' }],
|
||||
[/_casing$/, { 'line-color': '#232323' }],
|
||||
[/^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.*dashline$/, { 'line-color': '#0e0e0e' }],
|
||||
[/^railway/, { 'line-color': '#262626' }],
|
||||
[/^boundary_/, { 'line-color': 'rgb(96, 96, 96)' }],
|
||||
[/^water_name$/, { 'text-color': 'rgb(109, 123, 129)', 'text-halo-color': 'rgba(0, 0, 0, 0.7)' }],
|
||||
[/^highway_name/, { 'text-color': 'rgb(120, 126, 137)', 'text-halo-color': 'rgba(0, 0, 0, 0.7)' }],
|
||||
[/^place_(country|state)/, { 'text-color': 'rgb(118, 126, 137)' }],
|
||||
[/^place_/, { 'text-color': 'rgb(204, 208, 228)' }],
|
||||
],
|
||||
};
|
||||
|
||||
map.getStyle().layers.forEach((layer) => {
|
||||
const rule = palettes[mode].find(([pattern]) => pattern.test(layer.id));
|
||||
|
||||
if (rule) {
|
||||
Object.entries(rule[1]).forEach(([property, value]) => map.setPaintProperty(layer.id, property, value));
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
<!-- END MAP PALETTE -->
|
||||
</CaptureScript>
|
||||
Reference in new issue
Block a user