Add a scale of several colors to the vector map

- data-bs-colors takes two to five colors, mixed in CSS with color-mix()
This commit is contained in:
codecalm committed 2026-10-05 14:02:52 +02:00
1 parent b24064c5d1
commit b5662881d1
9 files changed
+343 -1

No files matched your search

+1 -1
View File
@@ -4,4 +4,4 @@
"@tabler/docs": minor
---
Added the `VectorMap` plugin in `tabler-vector-map.js`: an SVG world map with values, markers, lines, tooltips and zoom.
Added the `VectorMap` plugin in `tabler-vector-map.js`: an SVG world map with values, color scales, markers, lines, tooltips and zoom.
+44
View File
@@ -54,6 +54,7 @@ type ComponentConfig = {
values: VectorMapValues
min: number | null
max: number | null
colors: string[]
markers: VectorMapMarker[]
lines: VectorMapLine[]
tooltip: VectorMapTooltip
@@ -95,6 +96,7 @@ 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_ZOOMABLE = 'vector-map-zoomable'
const CLASS_NAME_DRAGGING = 'vector-map-dragging'
const CLASS_NAME_CONTROLS = 'vector-map-controls'
@@ -132,6 +134,11 @@ const SELECTOR_DATA_TOGGLE = `[data-bs-toggle="${NAME}"], [data-tblr-toggle="${N
// 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
const SVG_NS = 'http://www.w3.org/2000/svg'
const Default: ComponentConfig = {
@@ -139,6 +146,7 @@ const Default: ComponentConfig = {
values: {},
min: null,
max: null,
colors: [],
markers: [],
lines: [],
tooltip: true,
@@ -155,6 +163,7 @@ const DefaultType: Record<keyof ComponentConfig, string> = {
values: 'object',
min: '(number|null)',
max: '(number|null)',
colors: 'array',
markers: 'array',
lines: 'array',
tooltip: '(boolean|function)',
@@ -203,6 +212,13 @@ const toMarkers = (input: unknown): VectorMapMarker[] => {
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[]) : []) {
@@ -353,6 +369,7 @@ class VectorMap extends BaseComponent {
this._getSvg()?.remove()
this._element.append(svg)
this._renderControls()
this._applyColors()
this._setView({ x: 0, y: 0, scale: 1 })
this._applyValues()
@@ -385,6 +402,8 @@ class VectorMap extends BaseComponent {
this._getSvg()?.remove()
this._getControls()?.remove()
this._tooltip?.remove()
this._config.colors = []
this._applyColors()
this._element.classList.remove(CLASS_NAME_ZOOMABLE, CLASS_NAME_DRAGGING)
this._element.removeEventListener('wheel', this._onWheel)
super.dispose()
@@ -393,6 +412,7 @@ class VectorMap extends BaseComponent {
// 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
@@ -739,6 +759,30 @@ class VectorMap extends BaseComponent {
}
}
// Hands the colours of the scale to the stylesheet, which mixes the colour
// of every region from them and from its share. Nothing is computed here.
_applyColors(): void {
const { colors } = this._config
const { style } = this._element
for (let index = 0; index < COLORS_MAX; index++) {
const color = colors[index]
if (color === undefined) {
style.removeProperty(`${CSS_VAR_COLOR}${index + 1}`)
} else {
style.setProperty(`${CSS_VAR_COLOR}${index + 1}`, color)
}
}
if (colors.length > 0) {
style.setProperty(CSS_VAR_COLORS, String(colors.length))
} else {
style.removeProperty(CSS_VAR_COLORS)
}
this._element.classList.toggle(CLASS_NAME_COLORS, colors.length > 0)
}
// Shades every region by where its value sits between `min` and `max`. Only
// that 0 to 1 ratio is written here; the colour is mixed in CSS.
_applyValues(): void {
+58
View File
@@ -168,6 +168,64 @@ describe('VectorMap', () => {
})
})
describe('colors', () => {
const colorVar = (index: number): string => el().style.getPropertyValue(`--tblr-vector-map-color-${index}`)
it('should hand a scale of colors to the stylesheet', () => {
fixtureEl.innerHTML = `<div class="vector-map" data-bs-colors='["red", "yellow", "green"]'></div>`
new VectorMap(el(), { map: squares, values: { AA: 1, BB: 2, CC: 3 } })
expect(el().classList.contains('vector-map-colors')).toBe(true)
expect(el().style.getPropertyValue('--tblr-vector-map-colors')).toBe('3')
expect(colorVar(1)).toBe('red')
expect(colorVar(2)).toBe('yellow')
expect(colorVar(3)).toBe('green')
expect(colorVar(4)).toBe('')
// The share of a region stays the same, only the stylesheet reads it differently
expect(ratio('BB')).toBe('0.5')
})
it('should keep the single color with fewer than two colors', () => {
fixtureEl.innerHTML = '<div class="vector-map"></div>'
new VectorMap(el(), { map: squares, colors: ['red'] })
expect(el().classList.contains('vector-map-colors')).toBe(false)
expect(el().style.getPropertyValue('--tblr-vector-map-colors')).toBe('')
expect(colorVar(1)).toBe('')
})
it('should use the first five colors', () => {
fixtureEl.innerHTML = '<div class="vector-map"></div>'
new VectorMap(el(), { map: squares, colors: ['#111', '#222', '#333', '#444', '#555', '#666'] })
expect(el().style.getPropertyValue('--tblr-vector-map-colors')).toBe('5')
expect(colorVar(5)).toBe('#555')
expect(colorVar(6)).toBe('')
})
it('should drop the scale when render() no longer has one, and on dispose', () => {
fixtureEl.innerHTML = `<div class="vector-map" data-bs-colors='["red", "green"]'></div>`
const map = new VectorMap(el(), { map: squares })
el().removeAttribute('data-bs-colors')
map.render()
expect(el().classList.contains('vector-map-colors')).toBe(false)
expect(colorVar(1)).toBe('')
el().setAttribute('data-bs-colors', '["red", "green"]')
map.render()
expect(colorVar(2)).toBe('green')
map.dispose()
expect(el().classList.contains('vector-map-colors')).toBe(false)
expect(colorVar(2)).toBe('')
})
})
describe('points', () => {
const islands: VectorMapData = {
...squares,
+1
View File
@@ -2271,6 +2271,7 @@ $vector-map-region-fill: var(--border-color) !default;
$vector-map-region-stroke: var(--bg-surface) !default;
$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-marker-size: 0.5rem !default;
$vector-map-marker-color: var(--vector-map-color) !default;
+16
View File
@@ -82,6 +82,22 @@
vector-effect: non-scaling-stroke;
}
// A scale of several colours, from `--vector-map-color-1` for the lowest value
// to the last one for the highest. `--vector-map-colors` says how many there
// are. Every further colour is mixed over the result so far: not at all below
// its part of the scale, more and more inside it, and fully above it, so each
// part runs between two neighbours. A colour past the last one never gets a
// share, which is why one rule serves any number of them.
.vector-map-colors .vector-map-region[data-value] {
$color: var(--vector-map-color-1, transparent);
@for $i from 2 through $vector-map-colors-max {
$color: color-mix(in oklab, #{$color}, var(--vector-map-color-#{$i}, transparent) calc(clamp(0, var(--vector-map-value, 1) * (var(--vector-map-colors, 2) - 1) - #{$i - 2}, 1) * 100%));
}
--vector-map-region-color: #{$color};
}
// A country too small for a shape: a dot in the colour of the region, with the
// ring of a marker around it. Without a value it says nothing and only adds
// noise next to the markers, so it is left out.
+24
View File
@@ -11,6 +11,9 @@ classnames:
component:
- class: vector-map
desc: Box that holds the map, as wide as its parent
style:
- class: vector-map-colors
desc: Shades the values with a scale of several colors, set by JavaScript for data-bs-colors
part:
- class: vector-map-svg
desc: The rendered SVG, added by JavaScript
@@ -96,6 +99,24 @@ The map uses the primary color. Set `--tblr-vector-map-color` to change it.
<div class="vector-map" data-bs-toggle="vector-map" data-bs-label="Growth by country" data-bs-min="0" data-bs-values='{"US": 4, "CA": 7, "BR": 9, "DE": 3, "PL": 8, "IN": 10, "CN": 6, "AU": 5}' style="--tblr-vector-map-color: var(--tblr-green)"></div>
</Example>
### Color scale
One color shows how much. A scale of several colors also shows good and bad, or cold and hot. Pass two to five colors in `data-bs-colors`, as a JSON array. The first color is for the lowest value and the last one for the highest. The values between them get a mix of the two nearest colors.
<Example>
<div class="vector-map" data-bs-toggle="vector-map" data-bs-label="Customer satisfaction by country" data-bs-min="0" data-bs-max="100" data-bs-colors='["var(--tblr-red)", "var(--tblr-yellow)", "var(--tblr-green)"]' data-bs-values='{"US": 82, "CA": 91, "MX": 48, "BR": 35, "AR": 22, "GB": 74, "DE": 96, "FR": 63, "ES": 55, "PL": 88, "RU": 12, "IN": 41, "CN": 58, "JP": 79, "AU": 100, "ZA": 28, "EG": 6, "NG": 17, "SA": 67, "ID": 45}'></div>
</Example>
Any CSS color works, also a Tabler color such as `var(--tblr-red)`. The browser mixes the colors with `color-mix()`, so they follow the theme and you can change them later with CSS.
The plugin writes the colors to `--tblr-vector-map-color-1`, `--tblr-vector-map-color-2` and so on, and their number to `--tblr-vector-map-colors`. To set a scale without JavaScript options, add the `vector-map-colors` class and these properties yourself:
```html
<div class="vector-map vector-map-colors" data-bs-toggle="vector-map" style="--tblr-vector-map-colors: 2; --tblr-vector-map-color-1: var(--tblr-blue); --tblr-vector-map-color-2: var(--tblr-red)"></div>
```
With a scale, the lowest value gets the first color in full, so `--tblr-vector-map-value-min` is not used. Markers keep the color from `--tblr-vector-map-color`.
## Markers
Markers point at single places, such as offices or customers. Pass them in `data-bs-markers` as a JSON array. Each marker has a `lat`, a `lng` and an optional `name`. A marker keeps its size when the map is resized or zoomed.
@@ -145,6 +166,7 @@ Set options with data attributes or pass them to the constructor. For a data att
| `values` | object | `{}` | A number per region code. |
| `min` | number, null | `null` | Value that gets the lightest shade. The lowest value when `null`. |
| `max` | number, null | `null` | Value that gets the full color. The highest value when `null`. |
| `colors` | array | `[]` | Two to five colors of a scale, from the lowest value to the highest. |
| `markers` | array | `[]` | Places as `{ name, lat, lng }`. |
| `lines` | array | `[]` | Connections as `{ from, to }`, by marker name. |
| `tooltip` | boolean, function | `true` | Shows a tooltip, or builds its text. |
@@ -213,6 +235,8 @@ The map reads its colors and sizes from custom properties on the element. Change
| `--tblr-vector-map-region-stroke` | `var(--tblr-bg-surface)` | Border between countries. |
| `--tblr-vector-map-region-stroke-width` | `1px` | Width of the border. |
| `--tblr-vector-map-value-min` | `0.1` | Share of the color the lowest value gets, from 0 to 1. |
| `--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-marker-size` | `0.5rem` | Diameter of a marker. |
| `--tblr-vector-map-marker-color` | `var(--tblr-vector-map-color)` | Color of a marker. |
+8
View File
@@ -34,6 +34,14 @@ import DocsLink from '@ui/DocsLink.astro'
</CardBody>
</Card>
</div>
<div class="col-md-6">
<Card>
<CardBody>
<CardTitle>Map with a color scale</CardTitle>
<MapVector mapId="world-colors" />
</CardBody>
</Card>
</div>
<div class="col-md-6">
<Card>
<CardBody>
+2
View File
@@ -6,6 +6,7 @@ interface MapData {
map: string
color?: string
zoom?: boolean
colors?: string[]
values?: Record<string, number>
markers?: { name: string; lat: number; lng: number }[]
lines?: { from: string; to: string }[]
@@ -30,6 +31,7 @@ const color = colorProp ?? data?.color ?? 'primary'
data-bs-map={data.map}
data-bs-label={data.title}
data-bs-zoom={data.zoom ? 'true' : undefined}
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)}
data-bs-lines={data.lines && JSON.stringify(data.lines)}
+189
View File
@@ -344,5 +344,194 @@
"RO": 2,
"CZ": 5
}
},
"world-colors": {
"title": "Map with a color scale",
"map": "world",
"colors": ["var(--tblr-red)", "var(--tblr-yellow)", "var(--tblr-green)"],
"values": {
"AF": 2,
"AL": 2,
"DZ": 4,
"AO": 3,
"AG": 1,
"AR": 5,
"AM": 1,
"AU": 7,
"AT": 5,
"AZ": 3,
"BS": 1,
"BH": 2,
"BD": 4,
"BB": 1,
"BY": 3,
"BE": 5,
"BZ": 1,
"BJ": 1,
"BT": 1,
"BO": 2,
"BA": 2,
"BW": 2,
"BR": 8,
"BN": 2,
"BG": 2,
"BF": 1,
"BI": 1,
"KH": 2,
"CM": 2,
"CA": 7,
"CV": 1,
"CF": 1,
"TD": 1,
"CL": 4,
"CN": 9,
"CO": 5,
"KM": 1,
"CD": 2,
"CG": 2,
"CR": 2,
"CI": 2,
"HR": 3,
"CY": 2,
"CZ": 4,
"DK": 5,
"DJ": 1,
"DM": 1,
"DO": 3,
"EC": 3,
"EG": 5,
"SV": 2,
"GQ": 2,
"ER": 1,
"EE": 2,
"ET": 2,
"FJ": 1,
"FI": 5,
"FR": 8,
"GA": 2,
"GM": 1,
"GE": 2,
"DE": 8,
"GH": 2,
"GR": 5,
"GD": 1,
"GT": 2,
"GN": 1,
"GW": 1,
"GY": 1,
"HT": 1,
"HN": 2,
"HK": 5,
"HU": 4,
"IS": 2,
"IN": 7,
"ID": 6,
"IR": 5,
"IQ": 3,
"IE": 5,
"IL": 5,
"IT": 8,
"JM": 2,
"JP": 9,
"JO": 2,
"KZ": 4,
"KE": 2,
"KI": 1,
"KR": 6,
"KW": 4,
"KG": 1,
"LA": 1,
"LV": 2,
"LB": 2,
"LS": 1,
"LR": 1,
"LY": 3,
"LT": 2,
"LU": 3,
"MK": 1,
"MG": 1,
"MW": 1,
"MY": 5,
"MV": 1,
"ML": 1,
"MT": 1,
"MR": 1,
"MU": 1,
"MX": 7,
"MD": 1,
"MN": 1,
"ME": 1,
"MA": 3,
"MZ": 2,
"MM": 2,
"NA": 2,
"NP": 2,
"NL": 6,
"NZ": 4,
"NI": 1,
"NE": 1,
"NG": 5,
"NO": 5,
"OM": 3,
"PK": 4,
"PA": 2,
"PG": 1,
"PY": 2,
"PE": 4,
"PH": 4,
"PL": 10,
"PT": 5,
"QA": 4,
"RO": 4,
"RU": 7,
"RW": 1,
"WS": 1,
"ST": 1,
"SA": 5,
"SN": 2,
"RS": 2,
"SC": 1,
"SL": 1,
"SG": 5,
"SK": 3,
"SI": 2,
"SB": 1,
"ZA": 5,
"ES": 7,
"LK": 2,
"KN": 1,
"LC": 1,
"VC": 1,
"SD": 3,
"SR": 1,
"SZ": 1,
"SE": 5,
"CH": 6,
"SY": 3,
"TW": 5,
"TJ": 1,
"TZ": 2,
"TH": 5,
"TL": 1,
"TG": 1,
"TO": 1,
"TT": 2,
"TN": 2,
"TR": 6,
"TM": 1,
"UG": 2,
"UA": 4,
"AE": 5,
"GB": 8,
"US": 10,
"UY": 2,
"UZ": 2,
"VU": 1,
"VE": 5,
"VN": 4,
"YE": 2,
"ZM": 2,
"ZW": 1
}
}
}