mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
Add click events and country selection to the vector map
- regionclick, markerclick and selected events - select option with single and multiple modes, getSelected() and setSelected() - a drag starts after 4 px, so a click on a zoomable map reaches the country
This commit is contained in:
1 parent
81621e7d97
commit
7a06c66954
10 files changed
+405
-15
No files matched your search
@@ -4,4 +4,4 @@
|
||||
"@tabler/docs": minor
|
||||
---
|
||||
|
||||
Added the `VectorMap` plugin: an SVG world map with values, color scales, a legend, markers, lines, tooltips and zoom.
|
||||
Added the `VectorMap` plugin: an SVG world map with values, color scales, a legend, markers, lines, selection, tooltips and zoom.
|
||||
+138
-10
@@ -46,6 +46,7 @@ export type VectorMapTooltipItem = { type: 'region'; code: string; name: string;
|
||||
type VectorMapValues = Record<string, number>
|
||||
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 }
|
||||
@@ -60,6 +61,8 @@ type ComponentConfig = {
|
||||
lines: VectorMapLine[]
|
||||
tooltip: VectorMapTooltip
|
||||
legend: VectorMapLegend
|
||||
select: VectorMapSelect
|
||||
selected: string[]
|
||||
zoom: boolean
|
||||
zoomMax: number
|
||||
zoomOnScroll: boolean
|
||||
@@ -80,6 +83,9 @@ 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}`
|
||||
@@ -103,6 +109,8 @@ 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'
|
||||
@@ -116,6 +124,16 @@ 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'
|
||||
@@ -159,6 +177,8 @@ const Default: ComponentConfig = {
|
||||
lines: [],
|
||||
tooltip: true,
|
||||
legend: false,
|
||||
select: 'none',
|
||||
selected: [],
|
||||
zoom: false,
|
||||
zoomMax: 8,
|
||||
zoomOnScroll: true,
|
||||
@@ -177,6 +197,8 @@ const DefaultType: Record<keyof ComponentConfig, string> = {
|
||||
lines: 'array',
|
||||
tooltip: '(boolean|function)',
|
||||
legend: '(boolean|function)',
|
||||
select: 'string',
|
||||
selected: 'array',
|
||||
zoom: 'boolean',
|
||||
zoomMax: 'number',
|
||||
zoomOnScroll: 'boolean',
|
||||
@@ -279,6 +301,11 @@ class VectorMap extends BaseComponent {
|
||||
_frame = 0
|
||||
// Pointers that are down on the map, with where each was last seen
|
||||
_pointers = new Map<number, Point>()
|
||||
// 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<string> | null = null
|
||||
|
||||
/** Maps added with `addMap()`, by name */
|
||||
static maps: Record<string, VectorMapData> = {}
|
||||
@@ -312,6 +339,9 @@ class VectorMap extends BaseComponent {
|
||||
// `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)
|
||||
@@ -380,6 +410,7 @@ class VectorMap extends BaseComponent {
|
||||
this._element.append(svg)
|
||||
this._renderControls()
|
||||
this._applyColors()
|
||||
this._applySelected()
|
||||
this._setView({ x: 0, y: 0, scale: 1 })
|
||||
this._applyValues()
|
||||
|
||||
@@ -394,6 +425,19 @@ class VectorMap extends BaseComponent {
|
||||
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)
|
||||
}
|
||||
@@ -415,7 +459,7 @@ class VectorMap extends BaseComponent {
|
||||
this._tooltip?.remove()
|
||||
this._config.colors = []
|
||||
this._applyColors()
|
||||
this._element.classList.remove(CLASS_NAME_ZOOMABLE, CLASS_NAME_DRAGGING)
|
||||
this._element.classList.remove(CLASS_NAME_ZOOMABLE, CLASS_NAME_DRAGGING, CLASS_NAME_SELECTABLE)
|
||||
this._element.removeEventListener('wheel', this._onWheel)
|
||||
super.dispose()
|
||||
}
|
||||
@@ -671,17 +715,12 @@ class VectorMap extends BaseComponent {
|
||||
return
|
||||
}
|
||||
|
||||
if (this._pointers.size === 0) {
|
||||
this._dragged = false
|
||||
}
|
||||
|
||||
this._pointers.set(event.pointerId, [event.clientX, event.clientY])
|
||||
cancelAnimationFrame(this._frame)
|
||||
|
||||
// The map keeps getting the moves of this pointer when it leaves the map.
|
||||
// Capturing throws for a pointer the browser does not know, which is what
|
||||
// an event made in a script has.
|
||||
try {
|
||||
this._getSvg()?.setPointerCapture(event.pointerId)
|
||||
} catch {
|
||||
// The drag still works while the pointer stays over the map
|
||||
}
|
||||
}
|
||||
|
||||
_onPointerUp(event: PointerEvent): void {
|
||||
@@ -700,6 +739,26 @@ class VectorMap extends BaseComponent {
|
||||
}
|
||||
|
||||
const current: Point = [event.clientX, event.clientY]
|
||||
|
||||
// A press that barely moves is a click, not a drag. Until the pointer has
|
||||
// moved far enough the map stays put, and the click reaches the region.
|
||||
if (!this._dragged) {
|
||||
if (this._pointers.size === 1 && Math.hypot(current[0] - previous[0], current[1] - previous[1]) < DRAG_THRESHOLD) {
|
||||
return
|
||||
}
|
||||
|
||||
this._dragged = true
|
||||
|
||||
// The map keeps getting the moves of this pointer when it leaves the
|
||||
// map. Capturing throws for a pointer the browser does not know, which
|
||||
// is what an event made in a script has.
|
||||
try {
|
||||
this._getSvg()?.setPointerCapture(event.pointerId)
|
||||
} catch {
|
||||
// The drag still works while the pointer stays over the map
|
||||
}
|
||||
}
|
||||
|
||||
this._pointers.set(event.pointerId, current)
|
||||
this._element.classList.add(CLASS_NAME_DRAGGING)
|
||||
this._hideTooltip()
|
||||
@@ -770,6 +829,75 @@ class VectorMap extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
_toSelected(codes: unknown): Set<string> {
|
||||
const { select } = this._config
|
||||
const known = (Array.isArray(codes) ? (codes as unknown[]) : []).filter((code): code is string => typeof code === 'string' && this._map?.regions[code] !== undefined)
|
||||
|
||||
if (select === SELECT_NONE) {
|
||||
return new Set()
|
||||
}
|
||||
|
||||
return new Set(select === SELECT_MULTIPLE ? known : known.slice(0, 1))
|
||||
}
|
||||
|
||||
// Marks the selected regions for the stylesheet. A shape that gets selected
|
||||
// moves after the other shapes, where nothing covers its outline.
|
||||
_applySelected(): void {
|
||||
const svg = this._getSvg()
|
||||
if (!svg) {
|
||||
return
|
||||
}
|
||||
|
||||
this._selected ??= this._toSelected(this._config.selected)
|
||||
this._element.classList.toggle(CLASS_NAME_SELECTABLE, this._config.select !== SELECT_NONE)
|
||||
|
||||
const over = svg.querySelector(SELECTOR_OVER_SHAPES)
|
||||
for (const region of svg.querySelectorAll<SVGElement>(`.${CLASS_NAME_REGION}`)) {
|
||||
const selected = this._selected.has(region.getAttribute(ATTRIBUTE_REGION) ?? '')
|
||||
if (selected && !region.classList.contains(CLASS_NAME_SELECTED) && !region.classList.contains(CLASS_NAME_POINT)) {
|
||||
svg.insertBefore(region, over)
|
||||
}
|
||||
|
||||
region.classList.toggle(CLASS_NAME_SELECTED, selected)
|
||||
}
|
||||
}
|
||||
|
||||
// A click on a region or on a marker becomes an event of the map. A click on
|
||||
// a region also selects it, unless the event was prevented.
|
||||
_onClick(event: MouseEvent): void {
|
||||
if (this._dragged || !this._isOnMap(event) || !this._map) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = event.target as Element
|
||||
const marker = target.closest(`.${CLASS_NAME_MARKER}`)
|
||||
if (marker) {
|
||||
const index = [...this._element.querySelectorAll(`.${CLASS_NAME_MARKER}`)].indexOf(marker)
|
||||
EventHandler.trigger(this._element, EVENT_MARKER_CLICK, { ...this._config.markers[index], relatedTarget: marker })
|
||||
return
|
||||
}
|
||||
|
||||
const region = target.closest(`.${CLASS_NAME_REGION}`)
|
||||
const code = region?.getAttribute(ATTRIBUTE_REGION)
|
||||
if (!region || !code) {
|
||||
return
|
||||
}
|
||||
|
||||
const value = this._config.values[code]
|
||||
const clickEvent = EventHandler.trigger(this._element, EVENT_REGION_CLICK, { code, name: this._map.regions[code]?.name ?? code, ...(value === undefined ? {} : { value }), relatedTarget: region })
|
||||
if (clickEvent?.defaultPrevented || this._config.select === SELECT_NONE) {
|
||||
return
|
||||
}
|
||||
|
||||
// A click on a selected region takes it out again
|
||||
const selected = this.getSelected()
|
||||
const rest = selected.filter((other) => other !== code)
|
||||
const next = rest.length < selected.length ? rest : this._config.select === SELECT_MULTIPLE ? [...selected, code] : [code]
|
||||
this._selected = new Set(next)
|
||||
this._applySelected()
|
||||
EventHandler.trigger(this._element, EVENT_SELECTED, { selected: next, code })
|
||||
}
|
||||
|
||||
_getLegend(): HTMLElement | null {
|
||||
return this._element.querySelector<HTMLElement>(`.${CLASS_NAME_LEGEND}`)
|
||||
}
|
||||
|
||||
@@ -226,6 +226,183 @@ describe('VectorMap', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('click', () => {
|
||||
it('should fire regionclick with the code, the name and the value of the region', () => {
|
||||
fixtureEl.innerHTML = '<div class="vector-map"></div>'
|
||||
new VectorMap(el(), { map: squares, values: { AA: 5 } })
|
||||
|
||||
const spy = vi.fn()
|
||||
el().addEventListener('regionclick.bs.vector-map', spy)
|
||||
region('AA').dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
region('BB').dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
expect(spy.mock.calls[0][0]).toMatchObject({ code: 'AA', name: 'First', value: 5, relatedTarget: region('AA') })
|
||||
expect(spy.mock.calls[1][0]).toMatchObject({ code: 'BB', name: 'Second' })
|
||||
expect('value' in spy.mock.calls[1][0]).toBe(false)
|
||||
})
|
||||
|
||||
it('should fire markerclick with the marker, and no regionclick', () => {
|
||||
fixtureEl.innerHTML = '<div class="vector-map"></div>'
|
||||
new VectorMap(el(), {
|
||||
map: squares,
|
||||
markers: [
|
||||
{ name: 'One', lat: 0, lng: 0 },
|
||||
{ name: 'Two', lat: 1, lng: 2 },
|
||||
],
|
||||
})
|
||||
|
||||
const markerSpy = vi.fn()
|
||||
const regionSpy = vi.fn()
|
||||
el().addEventListener('markerclick.bs.vector-map', markerSpy)
|
||||
el().addEventListener('regionclick.bs.vector-map', regionSpy)
|
||||
el()
|
||||
.querySelectorAll('.vector-map-marker-dot')[1]
|
||||
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
|
||||
expect(markerSpy.mock.calls[0][0]).toMatchObject({ name: 'Two', lat: 1, lng: 2 })
|
||||
expect(regionSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not take the click that ends a drag for a click on a region', () => {
|
||||
fixtureEl.innerHTML = '<div class="vector-map" style="width: 300px" data-bs-zoom="true"></div>'
|
||||
new VectorMap(el(), { map: squares, zoomMax: 2 })
|
||||
el().dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: -5000 }))
|
||||
|
||||
const spy = vi.fn()
|
||||
el().addEventListener('regionclick.bs.vector-map', spy)
|
||||
const pointer = (type: string, x: number): void => {
|
||||
region('BB').dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 1, pointerType: 'mouse', button: 0, clientX: x, clientY: 50 }))
|
||||
}
|
||||
|
||||
pointer('pointerdown', 100)
|
||||
pointer('pointermove', 60)
|
||||
pointer('pointerup', 60)
|
||||
region('BB').dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
|
||||
// A press that moves by a pixel or two is still a click
|
||||
const viewBox = el().querySelector('svg')!.getAttribute('viewBox')
|
||||
pointer('pointerdown', 100)
|
||||
pointer('pointermove', 102)
|
||||
pointer('pointerup', 102)
|
||||
region('BB').dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
expect(el().querySelector('svg')!.getAttribute('viewBox')).toBe(viewBox)
|
||||
})
|
||||
})
|
||||
|
||||
describe('select', () => {
|
||||
const click = (code: string): void => {
|
||||
region(code).dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
}
|
||||
|
||||
const isSelected = (code: string): boolean => region(code).classList.contains('vector-map-region-selected')
|
||||
|
||||
it('should not select anything by default', () => {
|
||||
fixtureEl.innerHTML = '<div class="vector-map"></div>'
|
||||
const map = new VectorMap(el(), { map: squares, selected: ['AA'] })
|
||||
|
||||
click('BB')
|
||||
|
||||
expect(map.getSelected()).toEqual([])
|
||||
expect(isSelected('AA')).toBe(false)
|
||||
expect(el().classList.contains('vector-map-selectable')).toBe(false)
|
||||
})
|
||||
|
||||
it('should select one region at a time, and take it out on a second click', () => {
|
||||
fixtureEl.innerHTML = '<div class="vector-map" data-bs-select="single"></div>'
|
||||
const map = new VectorMap(el(), { map: squares })
|
||||
const spy = vi.fn()
|
||||
el().addEventListener('selected.bs.vector-map', spy)
|
||||
|
||||
expect(el().classList.contains('vector-map-selectable')).toBe(true)
|
||||
|
||||
click('AA')
|
||||
expect(map.getSelected()).toEqual(['AA'])
|
||||
expect(isSelected('AA')).toBe(true)
|
||||
expect(spy.mock.calls[0][0]).toMatchObject({ selected: ['AA'], code: 'AA' })
|
||||
|
||||
click('BB')
|
||||
expect(map.getSelected()).toEqual(['BB'])
|
||||
expect(isSelected('AA')).toBe(false)
|
||||
|
||||
click('BB')
|
||||
expect(map.getSelected()).toEqual([])
|
||||
expect(spy).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should select several regions', () => {
|
||||
fixtureEl.innerHTML = `<div class="vector-map" data-bs-select="multiple" data-bs-selected='["CC", "ZZ"]'></div>`
|
||||
const map = new VectorMap(el(), { map: squares })
|
||||
|
||||
expect(map.getSelected()).toEqual(['CC'])
|
||||
|
||||
click('AA')
|
||||
expect(map.getSelected()).toEqual(['CC', 'AA'])
|
||||
|
||||
click('CC')
|
||||
expect(map.getSelected()).toEqual(['AA'])
|
||||
expect(isSelected('CC')).toBe(false)
|
||||
})
|
||||
|
||||
it('should keep the first of several codes when one region can be selected', () => {
|
||||
fixtureEl.innerHTML = '<div class="vector-map"></div>'
|
||||
const map = new VectorMap(el(), { map: squares, select: 'single', selected: ['BB', 'CC'] })
|
||||
|
||||
expect(map.getSelected()).toEqual(['BB'])
|
||||
})
|
||||
|
||||
it('should not select when regionclick is prevented', () => {
|
||||
fixtureEl.innerHTML = '<div class="vector-map" data-bs-select="single"></div>'
|
||||
const map = new VectorMap(el(), { map: squares })
|
||||
el().addEventListener('regionclick.bs.vector-map', (event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
click('AA')
|
||||
|
||||
expect(map.getSelected()).toEqual([])
|
||||
})
|
||||
|
||||
it('should set the selection from code', () => {
|
||||
fixtureEl.innerHTML = '<div class="vector-map" data-bs-select="multiple"></div>'
|
||||
const map = new VectorMap(el(), { map: squares })
|
||||
const spy = vi.fn()
|
||||
el().addEventListener('selected.bs.vector-map', spy)
|
||||
|
||||
map.setSelected(['AA', 'CC', 'nowhere'])
|
||||
expect(map.getSelected()).toEqual(['AA', 'CC'])
|
||||
expect(spy.mock.calls[0][0]).toMatchObject({ selected: ['AA', 'CC'] })
|
||||
|
||||
map.setSelected([])
|
||||
expect(isSelected('AA')).toBe(false)
|
||||
})
|
||||
|
||||
it('should draw a selected shape after the other shapes, under the markers', () => {
|
||||
fixtureEl.innerHTML = '<div class="vector-map" data-bs-select="single"></div>'
|
||||
new VectorMap(el(), { map: squares, markers: [{ name: 'One', lat: 0, lng: 0 }] })
|
||||
|
||||
click('AA')
|
||||
|
||||
const children = [...el().querySelector('svg')!.children]
|
||||
expect(children.indexOf(region('AA'))).toBeGreaterThan(children.indexOf(region('CC')))
|
||||
expect(children.indexOf(region('AA'))).toBeLessThan(children.indexOf(el().querySelector('.vector-map-marker')!))
|
||||
})
|
||||
|
||||
it('should keep the selection over update() and render()', () => {
|
||||
fixtureEl.innerHTML = '<div class="vector-map" data-bs-select="single"></div>'
|
||||
const map = new VectorMap(el(), { map: squares })
|
||||
|
||||
click('BB')
|
||||
map.update({ AA: 1 })
|
||||
map.render()
|
||||
|
||||
expect(map.getSelected()).toEqual(['BB'])
|
||||
expect(isSelected('BB')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('legend', () => {
|
||||
const legend = (): HTMLElement | null => el().querySelector<HTMLElement>('.vector-map-legend')
|
||||
const labels = (): string[] => [...el().querySelectorAll('.vector-map-legend-label')].map((label) => label.textContent ?? '')
|
||||
|
||||
+4
-4
@@ -247,19 +247,19 @@
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/tabler-vector-map.js",
|
||||
"maxSize": "11.5 kB"
|
||||
"maxSize": "13 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/tabler-vector-map.esm.js",
|
||||
"maxSize": "11.5 kB"
|
||||
"maxSize": "13 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/tabler-vector-map.min.js",
|
||||
"maxSize": "8 kB"
|
||||
"maxSize": "9 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/tabler-vector-map.esm.min.js",
|
||||
"maxSize": "8 kB"
|
||||
"maxSize": "9 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/maps/world.js",
|
||||
|
||||
@@ -2273,6 +2273,8 @@ $vector-map-region-stroke-width: 1px !default;
|
||||
$vector-map-value-min: 0.1 !default;
|
||||
$vector-map-colors-max: 5 !default;
|
||||
$vector-map-point-size: 0.375rem !default;
|
||||
$vector-map-selected-stroke: var(--body-color) !default;
|
||||
$vector-map-selected-stroke-width: 2px !default;
|
||||
$vector-map-marker-size: 0.5rem !default;
|
||||
$vector-map-marker-color: var(--vector-map-color) !default;
|
||||
$vector-map-marker-halo-color: var(--bg-surface) !default;
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
--vector-map-region-stroke-width: #{$vector-map-region-stroke-width};
|
||||
--vector-map-value-min: #{$vector-map-value-min};
|
||||
--vector-map-point-size: #{$vector-map-point-size};
|
||||
--vector-map-selected-stroke: #{$vector-map-selected-stroke};
|
||||
--vector-map-selected-stroke-width: #{$vector-map-selected-stroke-width};
|
||||
--vector-map-marker-size: #{$vector-map-marker-size};
|
||||
--vector-map-marker-color: #{$vector-map-marker-color};
|
||||
--vector-map-marker-halo-color: #{$vector-map-marker-halo-color};
|
||||
@@ -198,3 +200,18 @@
|
||||
flex: 1;
|
||||
background: var(--vector-map-region-color);
|
||||
}
|
||||
|
||||
// Regions can be clicked to select them
|
||||
.vector-map-selectable .vector-map-region {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// A selected region gets an outline, and keeps the colour of its value
|
||||
.vector-map-region-selected {
|
||||
stroke: var(--vector-map-selected-stroke);
|
||||
stroke-width: var(--vector-map-selected-stroke-width);
|
||||
|
||||
.vector-map-region-point-halo {
|
||||
stroke: var(--vector-map-selected-stroke);
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,44 @@ Add `data-bs-zoom="true"` to let users look closer. The map gets two buttons, an
|
||||
|
||||
While the pointer is over a zoomable map, the wheel zooms the map and does not scroll the page. Set `data-bs-zoom-on-scroll="false"` to keep the wheel for the page. On a touch screen a finger on the map moves the map, not the page, so leave some room around a large map.
|
||||
|
||||
## Selecting countries
|
||||
|
||||
Set `data-bs-select` to let users pick countries with a click. With `single` a click on another country moves the selection. With `multiple` every click adds a country. A click on a selected country takes it out again. Pass the countries that start selected in `data-bs-selected`.
|
||||
|
||||
<Example>
|
||||
<div class="vector-map" data-bs-toggle="vector-map" data-bs-select="multiple" data-bs-selected='["PL", "FR", "BR"]' data-bs-label="Countries to compare"></div>
|
||||
</Example>
|
||||
|
||||
A selected country gets an outline and keeps the color of its value. Change the outline with `--tblr-vector-map-selected-stroke` and `--tblr-vector-map-selected-stroke-width`.
|
||||
|
||||
The map fires `selected.bs.vector-map` after every change. Read the selection from the event, or with `getSelected()`:
|
||||
|
||||
```js
|
||||
const element = document.getElementById('compare');
|
||||
|
||||
element.addEventListener('selected.bs.vector-map', (event) => {
|
||||
console.log(event.selected); // ['PL', 'FR', 'BR', 'DE']
|
||||
});
|
||||
|
||||
tabler.VectorMap.getInstance(element).setSelected(['US', 'CA']);
|
||||
```
|
||||
|
||||
## Clicks
|
||||
|
||||
The map fires `regionclick.bs.vector-map` when a country is clicked and `markerclick.bs.vector-map` when a marker is clicked, also when nothing can be selected. Use them to open a page or to filter a table. A drag on a zoomable map is not a click.
|
||||
|
||||
```js
|
||||
element.addEventListener('regionclick.bs.vector-map', (event) => {
|
||||
console.log(event.code, event.name, event.value); // 'PL', 'Poland', 54
|
||||
});
|
||||
|
||||
element.addEventListener('markerclick.bs.vector-map', (event) => {
|
||||
console.log(event.name, event.lat, event.lng);
|
||||
});
|
||||
```
|
||||
|
||||
Call `event.preventDefault()` in `regionclick.bs.vector-map` to keep that click from changing the selection.
|
||||
|
||||
## Tooltip
|
||||
|
||||
A tooltip shows the name of the country or marker under the pointer, with the value when the country has one. Set `data-bs-tooltip="false"` to turn it off.
|
||||
@@ -198,6 +236,8 @@ Set options with data attributes or pass them to the constructor. For a data att
|
||||
| `lines` | array | `[]` | Connections as `{ from, to }`, by marker name. |
|
||||
| `tooltip` | boolean, function | `true` | Shows a tooltip, or builds its text. |
|
||||
| `legend` | boolean, function | `false` | Shows the scale under the map, or formats its two numbers. |
|
||||
| `select` | string | `'none'` | Lets users select countries: `'none'`, `'single'` or `'multiple'`. |
|
||||
| `selected` | array | `[]` | Codes of the countries that start selected. |
|
||||
| `zoom` | boolean | `false` | Lets users zoom and drag the map. |
|
||||
| `zoomMax` | number | `8` | The largest magnification. |
|
||||
| `zoomOnScroll` | boolean | `true` | Zooms with the mouse wheel. Needs `zoom`. |
|
||||
@@ -229,6 +269,8 @@ visits.update({ PL: 61, DE: 77, FR: 70 });
|
||||
| --- | --- |
|
||||
| `update(values)` | Shades the regions with new values. Fires `updated.bs.vector-map`. |
|
||||
| `render()` | Reads the attributes again and draws the map from the start. Fires `rendered.bs.vector-map`. |
|
||||
| `getSelected()` | Returns the codes of the selected countries. |
|
||||
| `setSelected(codes)` | Selects exactly these countries. Fires `selected.bs.vector-map`. |
|
||||
| `zoomIn()` | Zooms in one step, around the middle of the view. |
|
||||
| `zoomOut()` | Zooms out one step. |
|
||||
| `reset()` | Goes back to the whole map. |
|
||||
@@ -241,6 +283,9 @@ visits.update({ PL: 61, DE: 77, FR: 70 });
|
||||
| --- | --- |
|
||||
| `rendered.bs.vector-map` | Fired on the element after every render. |
|
||||
| `updated.bs.vector-map` | Fired on the element after `update()`. |
|
||||
| `regionclick.bs.vector-map` | Fired when a country is clicked. Has `code`, `name`, `value` when the country has one, and the element in `relatedTarget`. Can be prevented. |
|
||||
| `markerclick.bs.vector-map` | Fired when a marker is clicked. Has `name`, `lat`, `lng` and the element in `relatedTarget`. |
|
||||
| `selected.bs.vector-map` | Fired after the selection changes. Has the codes in `selected`, and the clicked `code` when a click made the change. |
|
||||
|
||||
## Styling a single country
|
||||
|
||||
@@ -266,6 +311,8 @@ The map reads its colors and sizes from custom properties on the element. Change
|
||||
| `--tblr-vector-map-color-1` to `-5` | none | Colors of a scale, from the lowest value to the highest. |
|
||||
| `--tblr-vector-map-colors` | none | Number of colors in the scale. |
|
||||
| `--tblr-vector-map-point-size` | `0.375rem` | Diameter of the dot of a small country. |
|
||||
| `--tblr-vector-map-selected-stroke` | `var(--tblr-body-color)` | Outline of a selected country. |
|
||||
| `--tblr-vector-map-selected-stroke-width` | `2px` | Width of that outline. |
|
||||
| `--tblr-vector-map-marker-size` | `0.5rem` | Diameter of a marker. |
|
||||
| `--tblr-vector-map-marker-color` | `var(--tblr-vector-map-color)` | Color of a marker. |
|
||||
| `--tblr-vector-map-marker-halo-color` | `var(--tblr-bg-surface)` | Color of the ring around a marker and around the dot of a small country. |
|
||||
@@ -308,6 +355,7 @@ Three territories have no ISO code. The map uses `XK` for Kosovo, `XC` for North
|
||||
- The data behind the map has to be reachable another way. Put a table of the same values next to or below the map. It serves screen reader users and anyone who prints the page.
|
||||
- The tooltip is for the pointer only. Do not put anything there that is not in the text version too.
|
||||
- Shades of one color are hard to tell apart for many users. Turn on the legend, and for exact numbers add a table.
|
||||
- Countries and markers can be clicked with a pointer only. They are not in the tab order, because a world map would be two hundred tab stops. When a click or a selection does something, offer the same choice in another control, such as a list of checkboxes or a select, and keep the two in step with `setSelected()`.
|
||||
- The zoom buttons work with the keyboard and have accessible names. Change the names with `data-bs-zoom-in-label` and `data-bs-zoom-out-label` when the page is not in English.
|
||||
|
||||
## Variables
|
||||
|
||||
@@ -42,6 +42,14 @@ import DocsLink from '@ui/DocsLink.astro'
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Map with selection</CardTitle>
|
||||
<MapVector mapId="world-select" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<Card>
|
||||
<CardBody>
|
||||
|
||||
@@ -8,6 +8,8 @@ interface MapData {
|
||||
zoom?: boolean
|
||||
colors?: string[]
|
||||
legend?: boolean
|
||||
select?: 'single' | 'multiple'
|
||||
selected?: string[]
|
||||
values?: Record<string, number>
|
||||
markers?: { name: string; lat: number; lng: number }[]
|
||||
lines?: { from: string; to: string }[]
|
||||
@@ -36,6 +38,8 @@ const legend = legendProp ?? data?.legend ?? false
|
||||
data-bs-label={data.title}
|
||||
data-bs-zoom={data.zoom ? 'true' : undefined}
|
||||
data-bs-legend={legend ? 'true' : undefined}
|
||||
data-bs-select={data.select}
|
||||
data-bs-selected={data.selected && JSON.stringify(data.selected)}
|
||||
data-bs-colors={data.colors && JSON.stringify(data.colors)}
|
||||
data-bs-values={data.values && JSON.stringify(data.values)}
|
||||
data-bs-markers={data.markers && JSON.stringify(data.markers)}
|
||||
|
||||
@@ -534,5 +534,11 @@
|
||||
"ZW": 1
|
||||
},
|
||||
"legend": true
|
||||
},
|
||||
"world-select": {
|
||||
"title": "Map with selection",
|
||||
"map": "world",
|
||||
"select": "multiple",
|
||||
"selected": ["PL", "FR", "BR", "AU"]
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user