diff --git a/.changeset/vector-map-plugin.md b/.changeset/vector-map-plugin.md index 396814f66..19dfc60a3 100644 --- a/.changeset/vector-map-plugin.md +++ b/.changeset/vector-map-plugin.md @@ -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. diff --git a/core/js/src/vector-map.ts b/core/js/src/vector-map.ts index 99f9c396d..5a8f9e767 100644 --- a/core/js/src/vector-map.ts +++ b/core/js/src/vector-map.ts @@ -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 = { 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 { diff --git a/core/js/tests/unit/vector-map.spec.ts b/core/js/tests/unit/vector-map.spec.ts index 8e3c6a4ef..553940c2c 100644 --- a/core/js/tests/unit/vector-map.spec.ts +++ b/core/js/tests/unit/vector-map.spec.ts @@ -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 = `
` + + 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 = '
' + + 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 = '
' + + 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 = `
` + + 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, diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss index 556e7948b..32f6d8b03 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -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; diff --git a/core/scss/ui/_vector-map.scss b/core/scss/ui/_vector-map.scss index fab0304b6..d3c71aa6d 100644 --- a/core/scss/ui/_vector-map.scss +++ b/core/scss/ui/_vector-map.scss @@ -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. diff --git a/docs/content/ui/plugins/vector-map.mdx b/docs/content/ui/plugins/vector-map.mdx index 6260804e6..77b320db8 100644 --- a/docs/content/ui/plugins/vector-map.mdx +++ b/docs/content/ui/plugins/vector-map.mdx @@ -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.
+### 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. + + +
+
+ +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 +
+``` + +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. | diff --git a/preview/pages/maps-vector.astro b/preview/pages/maps-vector.astro index 04a22ae75..4252c7257 100644 --- a/preview/pages/maps-vector.astro +++ b/preview/pages/maps-vector.astro @@ -34,6 +34,14 @@ import DocsLink from '@ui/DocsLink.astro' +
+ + + Map with a color scale + + + +
diff --git a/shared/components/demo/MapVector.astro b/shared/components/demo/MapVector.astro index 7ba9989a2..63249e8c7 100644 --- a/shared/components/demo/MapVector.astro +++ b/shared/components/demo/MapVector.astro @@ -6,6 +6,7 @@ interface MapData { map: string color?: string zoom?: boolean + colors?: string[] values?: Record 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)} diff --git a/shared/data/maps-vector.json b/shared/data/maps-vector.json index f1642e523..03d847764 100644 --- a/shared/data/maps-vector.json +++ b/shared/data/maps-vector.json @@ -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 + } } }