/** * -------------------------------------------------------------------------- * Tabler vector-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 { initAll } from './bootstrap/util/component-functions' import type { ComponentConfig as BaseConfig, ElementSelector } from './bootstrap/types' import { prefersReducedMotion } from './util/prefers-reduced-motion' export type VectorMapRegion = { name: string /** SVG path data, already projected into the viewBox of the map */ path: string /** Too small for a shape at the scale of the map: `path` is a single point, drawn as a dot */ point?: boolean } export type VectorMapData = { width: number height: number /** How the shapes were projected, to place a point by its coordinates */ projection: { type: 'miller'; scale: number; translate: number[] } /** Regions by their code, for example ISO 3166-1 alpha-2 for countries */ regions: Record } export type VectorMapMarker = { /** Shown in the tooltip, and what a line refers to */ name?: string lat: number lng: number } export type VectorMapLine = { /** Names of the two markers the line joins */ from: string to: string } /** What the pointer is over when a tooltip is about to show */ export type VectorMapTooltipItem = { type: 'region'; code: string; name: string; value?: number } | { type: 'marker'; name: string } type VectorMapValues = Record type VectorMapTooltip = boolean | ((item: VectorMapTooltipItem) => string) type VectorMapLegend = boolean | ((value: number) => string) type VectorMapSelect = 'none' | 'single' | 'multiple' type Point = [number, number] // The part of the map in sight: its top left corner and how many times it is magnified type View = { x: number; y: number; scale: number } type ComponentConfig = { map: string | VectorMapData values: VectorMapValues min: number | null max: number | null colors: string[] markers: VectorMapMarker[] lines: VectorMapLine[] tooltip: VectorMapTooltip legend: VectorMapLegend select: VectorMapSelect selected: string[] zoom: boolean zoomMax: number zoomOnScroll: boolean zoomInLabel: string zoomOutLabel: string label: string | null } type ComponentConfigInput = Partial /** * Constants */ const NAME = 'vector-map' const DATA_KEY = `bs.${NAME}` const EVENT_KEY = `.${DATA_KEY}` const EVENT_RENDERED = `rendered${EVENT_KEY}` const EVENT_UPDATED = `updated${EVENT_KEY}` const EVENT_REGION_CLICK = `regionclick${EVENT_KEY}` const EVENT_MARKER_CLICK = `markerclick${EVENT_KEY}` const EVENT_SELECTED = `selected${EVENT_KEY}` const EVENT_POINTERMOVE = `pointermove${EVENT_KEY}` const EVENT_POINTERLEAVE = `pointerleave${EVENT_KEY}` const EVENT_POINTERDOWN = `pointerdown${EVENT_KEY}` const EVENT_POINTERUP = `pointerup${EVENT_KEY}` const EVENT_POINTERCANCEL = `pointercancel${EVENT_KEY}` const EVENT_DBLCLICK = `dblclick${EVENT_KEY}` const EVENT_CLICK = `click${EVENT_KEY}` const CLASS_NAME_SVG = 'vector-map-svg' const CLASS_NAME_REGION = 'vector-map-region' const CLASS_NAME_POINT = 'vector-map-region-point' const CLASS_NAME_POINT_HALO = 'vector-map-region-point-halo' const CLASS_NAME_POINT_DOT = 'vector-map-region-point-dot' const CLASS_NAME_LINE = 'vector-map-line' const CLASS_NAME_MARKER = 'vector-map-marker' const CLASS_NAME_MARKER_HALO = 'vector-map-marker-halo' const CLASS_NAME_MARKER_DOT = 'vector-map-marker-dot' const CLASS_NAME_TOOLTIP = 'vector-map-tooltip' const CLASS_NAME_COLORS = 'vector-map-colors' const CLASS_NAME_LEGEND = 'vector-map-legend' const CLASS_NAME_LEGEND_LABEL = 'vector-map-legend-label' const CLASS_NAME_LEGEND_SCALE = 'vector-map-legend-scale' const CLASS_NAME_LEGEND_STEP = 'vector-map-legend-step' const CLASS_NAME_SELECTABLE = 'vector-map-selectable' const CLASS_NAME_SELECTED = 'vector-map-region-selected' const CLASS_NAME_ZOOMABLE = 'vector-map-zoomable' const CLASS_NAME_DRAGGING = 'vector-map-dragging' const CLASS_NAME_CONTROLS = 'vector-map-controls' const CLASS_NAME_CONTROL_GROUP = 'btn-group-vertical' const CLASS_NAME_CONTROL = 'btn btn-sm btn-icon' const ATTRIBUTE_REGION = 'data-region' const ATTRIBUTE_VALUE = 'data-value' const ATTRIBUTE_MARKER = 'data-marker' const ATTRIBUTE_ACTION = 'data-bs-vector-map-action' const SELECTOR_TOOLTIP_TARGET = `.${CLASS_NAME_REGION}, .${CLASS_NAME_MARKER}` const SELECTOR_ACTION = `[${ATTRIBUTE_ACTION}]` // What a selected shape is moved in front of, so its outline is not covered // by the shapes around it but stays under everything drawn over the map const SELECTOR_OVER_SHAPES = `.${CLASS_NAME_POINT}, .${CLASS_NAME_LINE}, .${CLASS_NAME_MARKER}` const SELECT_NONE = 'none' const SELECT_MULTIPLE = 'multiple' // How far a pointer has to move, in pixels, before a press becomes a drag // and stops being a click const DRAG_THRESHOLD = 4 const ACTION_ZOOM_IN = 'zoom-in' const ACTION_ZOOM_OUT = 'zoom-out' // One step of a button or a double click const ZOOM_STEP = 2 const ZOOM_DURATION = 200 // How much one unit of wheel movement magnifies. A pinch on a trackpad comes // as a wheel event with `ctrlKey` and much smaller numbers. const WHEEL_SPEED = 0.002 const WHEEL_SPEED_PINCH = 0.01 const ICON_ZOOM_IN = 'M12 5v14M5 12h14' const ICON_ZOOM_OUT = 'M5 12h14' // How far a line bends away from the straight one, as a share of its length const LINE_CURVE = 0.2 const RADIANS = Math.PI / 180 const SELECTOR_DATA_TOGGLE = `[data-bs-toggle="${NAME}"], [data-tblr-toggle="${NAME}"]` // Declared on `.vector-map-region` in scss/ui/_vector-map.scss, where it gets // the `--tblr-` prefix at build time. const CSS_VAR_VALUE = '--tblr-vector-map-value' // The colours of a scale, numbered from 1, and how many of them there are. // scss/ui/_vector-map.scss mixes them, for at most this many. const CSS_VAR_COLOR = '--tblr-vector-map-color-' const CSS_VAR_COLORS = '--tblr-vector-map-colors' const COLORS_MAX = 5 // Enough slices for the bar of the legend to read as one smooth gradient const LEGEND_STEPS = 24 const SVG_NS = 'http://www.w3.org/2000/svg' const Default: ComponentConfig = { map: 'world', values: {}, min: null, max: null, colors: [], markers: [], lines: [], tooltip: true, legend: false, select: 'none', selected: [], zoom: false, zoomMax: 8, zoomOnScroll: true, zoomInLabel: 'Zoom in', zoomOutLabel: 'Zoom out', label: null, } const DefaultType: Record = { map: '(string|object)', values: 'object', min: '(number|null)', max: '(number|null)', colors: 'array', markers: 'array', lines: 'array', tooltip: '(boolean|function)', legend: '(boolean|function)', select: 'string', selected: 'array', zoom: 'boolean', zoomMax: 'number', zoomOnScroll: 'boolean', zoomInLabel: 'string', zoomOutLabel: 'string', label: '(string|null)', } /** * Helpers */ const svgEl = (tag: K): SVGElementTagNameMap[K] => document.createElementNS(SVG_NS, tag) // Keeps the entries that are a real number, so a `null` or a typo in the data // leaves that region without a value instead of painting it as zero. const toValues = (input: unknown): VectorMapValues => { const values: VectorMapValues = {} if (typeof input !== 'object' || input === null) { return values } for (const [code, value] of Object.entries(input)) { if (typeof value === 'number' && Number.isFinite(value)) { values[code] = value } } return values } const isRecord = (input: unknown): input is Record => typeof input === 'object' && input !== null // Keeps the markers with real coordinates; one with a typo is left out. const toMarkers = (input: unknown): VectorMapMarker[] => { const markers: VectorMapMarker[] = [] for (const item of Array.isArray(input) ? (input as unknown[]) : []) { if (isRecord(item) && typeof item.lat === 'number' && typeof item.lng === 'number' && Number.isFinite(item.lat) && Number.isFinite(item.lng)) { markers.push({ lat: item.lat, lng: item.lng, ...(typeof item.name === 'string' ? { name: item.name } : {}) }) } } return markers } // A scale needs two colours at least; with one, or none, the map keeps the // single colour of `--tblr-vector-map-color`. const toColors = (input: unknown): string[] => { const colors = (Array.isArray(input) ? (input as unknown[]) : []).filter((color): color is string => typeof color === 'string' && color.trim() !== '') return colors.length < 2 ? [] : colors.slice(0, COLORS_MAX) } const toLines = (input: unknown): VectorMapLine[] => { const lines: VectorMapLine[] = [] for (const item of Array.isArray(input) ? (input as unknown[]) : []) { if (isRecord(item) && typeof item.from === 'string' && typeof item.to === 'string') { lines.push({ from: item.from, to: item.to }) } } return lines } // The same projection the map file was generated with (.build/generate-maps.ts) const project = ({ projection }: VectorMapData, lng: number, lat: number): Point => { const { scale, translate } = projection const y = 1.25 * Math.log(Math.tan(Math.PI / 4 + 0.4 * lat * RADIANS)) return [lng * RADIANS * scale + translate[0], translate[1] - y * scale] } const round = (value: number): number => Math.round(value * 10) / 10 const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max) /** * Class definition * * A map drawn as inline SVG from a map file, with regions shaded by value, * markers placed by their coordinates and lines between them: * *
* * The shapes come projected from the map file (`dist/js/maps/world.js`), so * only the markers are projected here. Colours and sizes come from the * `--tblr-vector-map-*` custom properties. * * With `data-bs-zoom="true"` the map can be magnified with its buttons, the * wheel, a double click or a pinch, and dragged around. */ class VectorMap extends BaseComponent { declare _element: HTMLElement declare _config: ComponentConfig _userConfig: ComponentConfigInput = {} _map: VectorMapData | null = null _tooltip: HTMLElement | null = null _view: View = { x: 0, y: 0, scale: 1 } _frame = 0 // Pointers that are down on the map, with where each was last seen _pointers = new Map() // Whether the pointers that are down have moved the map. A click that ends // a drag is not a click on a region. _dragged = false // The codes of the selected regions; filled from `selected` on the first render _selected: Set | null = null /** Maps added with `addMap()`, by name */ static maps: Record = {} constructor(element: ElementSelector, config?: ComponentConfigInput) { super(element, config) if (!this._element) { return } // Kept so that render() can re-read the data attributes on top of it. this._userConfig = { ...config } this.render() EventHandler.on(this._element, EVENT_POINTERMOVE, (event: PointerEvent) => { this._onPointerMove(event) }) EventHandler.on(this._element, EVENT_POINTERLEAVE, () => { this._hideTooltip() }) EventHandler.on(this._element, EVENT_POINTERDOWN, (event: PointerEvent) => { this._onPointerDown(event) }) for (const eventName of [EVENT_POINTERUP, EVENT_POINTERCANCEL]) { EventHandler.on(this._element, eventName, (event: PointerEvent) => { this._onPointerUp(event) }) } // `wheel` is not among the native events EventHandler knows, and the // listener must not be passive to keep the page from scrolling. this._element.addEventListener('wheel', this._onWheel, { passive: false }) EventHandler.on(this._element, EVENT_CLICK, (event: MouseEvent) => { this._onClick(event) }) EventHandler.on(this._element, EVENT_DBLCLICK, (event: MouseEvent) => { if (this._config.zoom && this._isOnMap(event)) { this._zoomBy(ZOOM_STEP, this._toMapPoint(event.clientX, event.clientY), true) } }) EventHandler.on(this._element, EVENT_CLICK, SELECTOR_ACTION, (event: Event) => { const button = (event.target as Element).closest(SELECTOR_ACTION) if (button?.getAttribute(ATTRIBUTE_ACTION) === ACTION_ZOOM_IN) { this.zoomIn() } else { this.zoomOut() } }) } // Getters static get Default(): ComponentConfig { return Default } static get DefaultType(): Record { return DefaultType } static get NAME(): string { return NAME } // Static static addMap(name: string, data: VectorMapData): void { VectorMap.maps[name] = data } // Public render(): void { this._config = this._getConfig(this._userConfig) as ComponentConfig const map = (this._map = this._getMap()) const { label } = this._config const svg = svgEl('svg') svg.setAttribute('class', CLASS_NAME_SVG) svg.setAttribute('viewBox', `0 0 ${map.width} ${map.height}`) // Without a label the map says nothing to a screen reader, so it is hidden // rather than announced as an unnamed image. if (label) { svg.setAttribute('role', 'img') svg.setAttribute('aria-label', label) } else { svg.setAttribute('aria-hidden', 'true') } // The points go in after the shapes, so a small country shows on top of // the large one around it. const regions = Object.entries(map.regions) for (const [code, region] of [...regions.filter(([, { point }]) => !point), ...regions.filter(([, { point }]) => point)]) { svg.append(region.point ? this._createPoint(code, region) : this._createShape(code, region)) } this._renderMarkers(svg, map) cancelAnimationFrame(this._frame) this._pointers.clear() this._hideTooltip() this._getSvg()?.remove() this._element.append(svg) this._renderControls() this._applyColors() this._applySelected() this._setView({ x: 0, y: 0, scale: 1 }) this._applyValues() EventHandler.trigger(this._element, EVENT_RENDERED) } update(values: VectorMapValues): void { this._userConfig.values = values this._config = this._getConfig(this._userConfig) as ComponentConfig this._applyValues() EventHandler.trigger(this._element, EVENT_UPDATED) } // The codes of the selected regions, in the order they were selected getSelected(): string[] { return [...(this._selected ?? [])] } // Selects exactly these regions. Codes that are not on the map are left // out, and all but the first when only one region can be selected. setSelected(codes: string[]): void { this._selected = this._toSelected(codes) this._applySelected() EventHandler.trigger(this._element, EVENT_SELECTED, { selected: this.getSelected() }) } zoomIn(): void { this._zoomBy(ZOOM_STEP, null, true) } zoomOut(): void { this._zoomBy(1 / ZOOM_STEP, null, true) } // Back to the whole map reset(): void { this._animateTo({ x: 0, y: 0, scale: 1 }) } dispose(): void { cancelAnimationFrame(this._frame) this._getSvg()?.remove() this._getControls()?.remove() this._getLegend()?.remove() this._tooltip?.remove() this._config.colors = [] this._applyColors() this._element.classList.remove(CLASS_NAME_ZOOMABLE, CLASS_NAME_DRAGGING, CLASS_NAME_SELECTABLE) this._element.removeEventListener('wheel', this._onWheel) super.dispose() } // Private _configAfterMerge(config: BaseConfig): BaseConfig { config.values = toValues(config.values) config.colors = toColors(config.colors) config.markers = toMarkers(config.markers) config.lines = toLines(config.lines) return config } _getSvg(): SVGSVGElement | null { return this._element.querySelector(`.${CLASS_NAME_SVG}`) } _getMap(): VectorMapData { const { map } = this._config if (typeof map !== 'string') { return map } // `tablerVectorMaps` is what a map file loaded with a `