From 5539bdbba281506b2b7ed26638b5a3d8ebcbaff6 Mon Sep 17 00:00:00 2001 From: codecalm Date: Sun, 4 Oct 2026 22:49:30 +0200 Subject: [PATCH] Add the MapView component and move the map demos to it --- .changeset/map-view-component.md | 7 + core/js/src/global.d.ts | 5 +- core/js/src/map.ts | 416 +++++++++++++ core/js/tabler.ts | 2 + core/js/tests/unit/map.spec.ts | 378 ++++++++++++ core/package.json | 4 +- core/scss/vendor/_maplibre.scss | 7 + docs/content/ui/components/map.mdx | 552 ++++++------------ preview/pages/map-blocks.astro | 2 - preview/pages/map-fullsize.astro | 36 +- preview/pages/maps.astro | 80 ++- screenshots/layouts/ScreenshotAppLayout.astro | 7 +- screenshots/layouts/ScreenshotLayout.astro | 7 +- screenshots/pages/map-delivery-tracker.astro | 2 - screenshots/pages/map-live-users.astro | 2 - screenshots/pages/map-store-locator.astro | 2 - .../components/cards/MapDeliveryTracker.astro | 15 +- shared/components/cards/MapLiveUsers.astro | 5 +- shared/components/cards/MapStoreLocator.astro | 43 +- shared/components/demo/Map.astro | 120 +--- shared/components/demo/MapPalette.astro | 92 --- 21 files changed, 1094 insertions(+), 690 deletions(-) create mode 100644 .changeset/map-view-component.md create mode 100644 core/js/src/map.ts create mode 100644 core/js/tests/unit/map.spec.ts delete mode 100644 shared/components/demo/MapPalette.astro diff --git a/.changeset/map-view-component.md b/.changeset/map-view-component.md new file mode 100644 index 000000000..16a03f1d6 --- /dev/null +++ b/.changeset/map-view-component.md @@ -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. diff --git a/core/js/src/global.d.ts b/core/js/src/global.d.ts index 9b331a7d2..84aa38e79 100644 --- a/core/js/src/global.d.ts +++ b/core/js/src/global.d.ts @@ -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) => import('./input-mask').IMaskInstance + maplibregl?: import('./map').MapLibrary Sortable?: new (element: HTMLElement, options?: Record) => import('./sortable').SortableInstance jQuery?: JQueryStaticLike } diff --git a/core/js/src/map.ts b/core/js/src/map.ts new file mode 100644 index 000000000..e96afb0b2 --- /dev/null +++ b/core/js/src/map.ts @@ -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) => MapInstance + Marker: new (options: Record) => MapMarkerInstance + Popup: new (options: Record) => 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 & Record + +type PaletteRule = [RegExp, Record] + +const Default: ComponentConfig = { + center: [0, 0], + zoom: 1, + mapStyle: 'positron', + mapStyleDark: 'dark', + mapTheme: 'auto', + palette: true, + controls: false, + globe: false, + mapOptions: {}, +} + +const DefaultType: Record = { + 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 { + 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(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(':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 } diff --git a/core/js/tabler.ts b/core/js/tabler.ts index ef8eadf58..048b0dae7 100644 --- a/core/js/tabler.ts +++ b/core/js/tabler.ts @@ -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' diff --git a/core/js/tests/unit/map.spec.ts b/core/js/tests/unit/map.spec.ts new file mode 100644 index 000000000..8d7aaa392 --- /dev/null +++ b/core/js/tests/unit/map.spec.ts @@ -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 + listeners: Record = {} + styles: string[] = [] + paint: Record> = {} + 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) { + 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 + content: Node | null = null + text: string | null = null + + constructor(options: Record) { + 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 + lngLat: [number, number] | null = null + popup: FakePopup | null = null + popupToggled = 0 + removed = false + + constructor(options: Record) { + 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('
')) + + 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('
') + 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('
'), { 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('
'), { 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('
')) + + 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('
') + 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('
')) + + 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('
')) + + 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('
')) + + 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('
'), { 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('
')) + 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('
')) + 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('
'), { mapStyle: 'liberty' }) + FakeMap.last.fire('style.load') + expect(FakeMap.last.paint).toEqual({}) + + new MapView(mount('
'), { 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('
')) + + 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(` +
+ + +

Not a marker

+
`) + 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('
')) + + 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(` +
+ + + +
`) + + 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('
')) + 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('
') + + expect(MapView.getOrCreateInstance(element)).toBe(MapView.getOrCreateInstance(element)) + }) + }) +}) diff --git a/core/package.json b/core/package.json index d4945725e..e86def4d7 100644 --- a/core/package.json +++ b/core/package.json @@ -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", diff --git a/core/scss/vendor/_maplibre.scss b/core/scss/vendor/_maplibre.scss index 600249ccd..942472ae9 100644 --- a/core/scss/vendor/_maplibre.scss +++ b/core/scss/vendor/_maplibre.scss @@ -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 { diff --git a/docs/content/ui/components/map.mdx b/docs/content/ui/components/map.mdx index 243d7c80f..865bfb546 100644 --- a/docs/content/ui/components/map.mdx +++ b/docs/content/ui/components/map.mdx @@ -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'; - - ## 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: 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 -`} + +`} /> -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 ``` @@ -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. - - -
-
-
-
- -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. -`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. | -### 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' + + + + + Store + 12 + + + + + - 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. -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 `