diff --git a/packages/highcharts-theme/src/color-axis.ts b/packages/highcharts-theme/src/color-axis.ts new file mode 100644 index 00000000000..0626ba1f8ef --- /dev/null +++ b/packages/highcharts-theme/src/color-axis.ts @@ -0,0 +1,189 @@ +import type { ColorAxisOptions } from "highcharts"; +import { + getCSSColorTokenFromElement, + type RgbColor, +} from "./compute-css-tokens"; + +const DEFAULT_COLORS: Record = { + "--salt-category-1-dataviz": { r: 70, g: 118, b: 191 }, + "--salt-category-2-dataviz": { r: 171, g: 101, b: 40 }, + "--salt-category-3-dataviz": { r: 159, g: 85, b: 194 }, + "--salt-category-4-dataviz": { r: 42, g: 130, b: 133 }, + "--salt-category-5-dataviz": { r: 105, g: 118, b: 148 }, + "--salt-sentiment-negative-dataviz": { r: 187, g: 61, b: 75 }, + "--salt-sentiment-neutral-dataviz": { r: 95, g: 108, b: 138 }, + // "--salt-sentiment-positive-dataviz": { r: 20, g: 120, b: 84 }, +}; + +const resolveToken = (token: string, element?: Element | null): RgbColor => { + if (element) { + const resolved = getCSSColorTokenFromElement(token, element); + if (resolved) return resolved; + } + return DEFAULT_COLORS[token] ?? { r: 70, g: 118, b: 191 }; +}; + +const clamp = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, value)); + +const toRgba = ({ r, g, b }: RgbColor, alpha: number): string => + `rgba(${r}, ${g}, ${b}, ${clamp(alpha, 0, 1)})`; + +const buildOpacityRamp = ( + color: RgbColor, + fromPos: number, + toPos: number, + fromAlpha: number, + toAlpha: number, + steps: number, +): Array<[number, string]> => { + const n = Math.max(2, steps); + const stops: Array<[number, string]> = []; + + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + stops.push([ + fromPos + (toPos - fromPos) * t, + toRgba(color, fromAlpha + (toAlpha - fromAlpha) * t), + ]); + } + + return stops; +}; + +export const buildColorAxis = ( + config: ColorAxisConfig, + element?: Element | null, +): ColorAxisOptions => { + if ("colorToken" in config) { + const { + colorToken, + min, + max, + steps = 10, + minOpacity = 0.15, + maxOpacity = 1, + } = config; + const color = resolveToken(colorToken, element); + + return { + min, + max, + showInLegend: true, + stops: buildOpacityRamp(color, 0, 1, minOpacity, maxOpacity, steps), + }; + } + + const { + lowColorToken, + highColorToken, + min, + max, + threshold, + lowSteps = 4, + highSteps = 4, + lowMinOpacity = 0.15, + lowMaxOpacity = 1, + highMinOpacity = 0.15, + highMaxOpacity = 1, + } = config; + + const range = max - min; + const split = range === 0 ? 0.5 : clamp((threshold - min) / range, 0, 1); + + const lowColor = resolveToken(lowColorToken, element); + const highColor = resolveToken(highColorToken, element); + + return { + min, + max, + showInLegend: true, + stops: [ + ...buildOpacityRamp( + lowColor, + 0, + split, + lowMaxOpacity, + lowMinOpacity, + lowSteps, + ), + ...buildOpacityRamp( + highColor, + split, + 1, + highMinOpacity, + highMaxOpacity, + highSteps, + ), + ], + }; +}; + +/** + * Sequential (single-hue) color axis. + * + * Produces a gradient that varies the opacity of a single Salt color token + * from `minOpacity` at `min` to `maxOpacity` at `max`, giving a light-to-dark + * ramp of one hue. Useful for heatmaps where all values are of the same + * sentiment (e.g. volume, count). + */ +export interface SingleColorAxisConfig { + /** Salt CSS custom property name for the hue, e.g. `"--salt-category-1-dataviz"`. */ + colorToken: string; + /** Lower bound of the data range (maps to position 0 in the gradient). */ + min: number; + /** Upper bound of the data range (maps to position 1 in the gradient). */ + max: number; + /** Number of discrete opacity stops generated between `min` and `max`. @defaultValue 10 */ + steps?: number; + /** Opacity at the `min` end of the ramp (0–1). @defaultValue 0.15 */ + minOpacity?: number; + /** Opacity at the `max` end of the ramp (0–1). @defaultValue 1 */ + maxOpacity?: number; +} + +/** + * Divergent (two-hue) color axis split at a threshold. + * + * Values below the `threshold` are rendered with `lowColorToken` and values + * above it with `highColorToken`. Each side has its own opacity ramp: the + * colour is most vivid at the extremes (`min` / `max`) and fades to near- + * transparent at the `threshold`, creating a visual "zero-point" in the middle. + * + * Typical use: positive/negative performance where green fades in below zero + * and red fades in above zero. + */ +export interface ThresholdColorAxisConfig { + /** Salt CSS custom property for the "low" side (values between `min` and `threshold`). */ + lowColorToken: string; + /** Salt CSS custom property for the "high" side (values between `threshold` and `max`). */ + highColorToken: string; + /** Lower bound of the data range (maps to position 0 in the gradient). */ + min: number; + /** Upper bound of the data range (maps to position 1 in the gradient). */ + max: number; + /** + * The data value at which the colour switches from `lowColorToken` to + * `highColorToken`. Normalised to a `[0, 1]` position within the gradient + * via `(threshold - min) / (max - min)`. + */ + threshold: number; + /** Number of opacity stops on the low side (`min` → `threshold`). @defaultValue 4 */ + lowSteps?: number; + /** Number of opacity stops on the high side (`threshold` → `max`). @defaultValue 4 */ + highSteps?: number; + /** Opacity of `lowColorToken` closest to the threshold (the faint end). @defaultValue 0.15 */ + lowMinOpacity?: number; + /** Opacity of `lowColorToken` at `min` (the vivid end). @defaultValue 1 */ + lowMaxOpacity?: number; + /** Opacity of `highColorToken` closest to the threshold (the faint end). @defaultValue 0.15 */ + highMinOpacity?: number; + /** Opacity of `highColorToken` at `max` (the vivid end). @defaultValue 1 */ + highMaxOpacity?: number; +} + +/** + * Discriminated union: provide `colorToken` for a single-hue sequential ramp, + * or `lowColorToken` + `highColorToken` + `threshold` for a divergent ramp. + */ +export type ColorAxisConfig = SingleColorAxisConfig | ThresholdColorAxisConfig; diff --git a/packages/highcharts-theme/src/compute-css-tokens.ts b/packages/highcharts-theme/src/compute-css-tokens.ts index abfd050ace8..3635b0f5864 100644 --- a/packages/highcharts-theme/src/compute-css-tokens.ts +++ b/packages/highcharts-theme/src/compute-css-tokens.ts @@ -1,8 +1,16 @@ +export interface RgbColor { + r: number; + g: number; + b: number; +} + const parseCSSNumber = (value?: string | null): number | undefined => { const parsedNumber = Number.parseFloat(value?.trim() ?? ""); return Number.isFinite(parsedNumber) ? parsedNumber : undefined; }; +const RGB_RE = /(\d{1,3})\D+(\d{1,3})\D+(\d{1,3})/; + /** * Read multiple CSS custom properties as numbers using a single computedStyle lookup. * Returns an object keyed by the provided variable names. @@ -26,3 +34,22 @@ export const getCSSTokensFromElement = ( } return result; }; + +// Convert css variable into rgb +export const getCSSColorTokenFromElement = ( + tokenName: string, + element: Element, +): RgbColor | undefined => { + const view = element.ownerDocument?.defaultView; + if (!view?.getComputedStyle) return undefined; + + const raw = view.getComputedStyle(element).getPropertyValue(tokenName).trim(); + const m = RGB_RE.exec(raw); + if (!m) return undefined; + + return { + r: Number.parseInt(m[1], 10), + g: Number.parseInt(m[2], 10), + b: Number.parseInt(m[3], 10), + }; +}; diff --git a/packages/highcharts-theme/src/examples/Heatmap.tsx b/packages/highcharts-theme/src/examples/Heatmap.tsx new file mode 100644 index 00000000000..9267c095a44 --- /dev/null +++ b/packages/highcharts-theme/src/examples/Heatmap.tsx @@ -0,0 +1,52 @@ +import { + type ThresholdColorAxisConfig, + useChart, +} from "@salt-ds/highcharts-theme"; +import { clsx } from "clsx"; +import Highcharts, { type Options } from "highcharts"; +import heatmap from "highcharts/modules/heatmap"; +import HighchartsReact from "highcharts-react-official"; +import { type FC, useRef } from "react"; +import { heatmapOptions } from "./dependencies"; + +heatmap(Highcharts); + +const heatmapColorConfig: ThresholdColorAxisConfig = { + lowColorToken: "--salt-sentiment-negative-dataviz", + highColorToken: "--salt-sentiment-positive-dataviz", + min: -50, + max: 50, + threshold: -25, +}; + +export interface HeatmapProps { + patterns?: boolean; + options: Options; +} + +const HeatmapChart: FC = ({ + patterns = false, + options = heatmapOptions, +}) => { + const chartRef = useRef(null); + + const chartOptions = useChart(chartRef, options, { + colorAxis: heatmapColorConfig, + }); + + return ( +
+ +
+ ); +}; + +export default HeatmapChart; diff --git a/packages/highcharts-theme/src/examples/SingleColorHeatmap.tsx b/packages/highcharts-theme/src/examples/SingleColorHeatmap.tsx new file mode 100644 index 00000000000..b85da1ed8bd --- /dev/null +++ b/packages/highcharts-theme/src/examples/SingleColorHeatmap.tsx @@ -0,0 +1,50 @@ +import { + type SingleColorAxisConfig, + useChart, +} from "@salt-ds/highcharts-theme"; +import { clsx } from "clsx"; +import Highcharts, { type Options } from "highcharts"; +import heatmap from "highcharts/modules/heatmap"; +import HighchartsReact from "highcharts-react-official"; +import { type FC, useRef } from "react"; +import { singleColorHeatmapOptions } from "./dependencies"; + +heatmap(Highcharts); + +const singleColorConfig: SingleColorAxisConfig = { + colorToken: "--salt-category-4-dataviz", + min: 0, + max: 100, +}; + +export interface SingleColorHeatmapProps { + patterns?: boolean; + options: Options; +} + +const SingleColorHeatmapChart: FC = ({ + patterns = false, + options = singleColorHeatmapOptions, +}) => { + const chartRef = useRef(null); + + const chartOptions = useChart(chartRef, options, { + colorAxis: singleColorConfig, + }); + + return ( +
+ +
+ ); +}; + +export default SingleColorHeatmapChart; diff --git a/packages/highcharts-theme/src/examples/dependencies/heatmapOptions.ts b/packages/highcharts-theme/src/examples/dependencies/heatmapOptions.ts new file mode 100644 index 00000000000..c2440d663ca --- /dev/null +++ b/packages/highcharts-theme/src/examples/dependencies/heatmapOptions.ts @@ -0,0 +1,150 @@ +import type { Options } from "highcharts"; + +export const heatmapOptions: Options = { + chart: { + type: "heatmap", + styledMode: true, + }, + title: { + text: "Sample heatmap", + }, + xAxis: { + type: "category", + startOnTick: false, + endOnTick: false, + categories: [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ], + }, + yAxis: { + type: "category", + startOnTick: false, + endOnTick: false, + reversed: true, + categories: [ + "Consumer", + "Financials", + "Healthcare", + "Real Estate", + "Technology", + ], + title: { + text: null, + }, + }, + tooltip: { + enabled: false, + }, + plotOptions: { + heatmap: { + colsize: 1, + rowsize: 1, + borderWidth: 1, + pointPadding: 0, + dataLabels: { + enabled: true, + inside: true, + align: "center", + verticalAlign: "middle", + style: { + textOutline: "none", + }, + formatter: function () { + return typeof this.point.value === "number" + ? String(this.point.value) + : ""; + }, + }, + }, + }, + legend: { + layout: "horizontal", + align: "right", + symbolWidth: 200, + symbolHeight: 12, + }, + colorAxis: { + showInLegend: true, + min: -50, + max: 50, + }, + series: [ + { + name: "Performance", + type: "heatmap", + data: [ + [0, 0, -42], + [0, 1, 4], + [0, 2, 38], + [0, 3, -3], + [0, 4, 50], + [1, 0, 2], + [1, 1, -28], + [1, 2, 5], + [1, 3, 45], + [1, 4, 0], + [2, 0, 6], + [2, 1, 3], + [2, 2, -4], + [2, 3, 11], + [2, 4, -50], + [3, 0, -35], + [3, 1, 10], + [3, 2, 1], + [3, 3, 7], + [3, 4, -5], + [4, 0, 5], + [4, 1, -1], + [4, 2, 48], + [4, 3, 2], + [4, 4, 33], + [5, 0, 10], + [5, 1, 42], + [5, 2, -6], + [5, 3, 4], + [5, 4, 8], + [6, 0, -2], + [6, 1, 3], + [6, 2, 11], + [6, 3, -46], + [6, 4, 1], + [7, 0, 4], + [7, 1, -18], + [7, 2, 6], + [7, 3, 50], + [7, 4, 3], + [8, 0, 36], + [8, 1, 2], + [8, 2, -10], + [8, 3, 5], + [8, 4, -30], + [9, 0, -3], + [9, 1, 9], + [9, 2, 4], + [9, 3, -7], + [9, 4, 11], + [10, 0, 7], + [10, 1, -50], + [10, 2, 50], + [10, 3, 0], + [10, 4, -1], + [11, 0, 1], + [11, 1, 25], + [11, 2, -40], + [11, 3, 8], + [11, 4, 4], + ], + }, + ], +}; diff --git a/packages/highcharts-theme/src/examples/dependencies/index.ts b/packages/highcharts-theme/src/examples/dependencies/index.ts index 1f4428daf01..c2798dca035 100644 --- a/packages/highcharts-theme/src/examples/dependencies/index.ts +++ b/packages/highcharts-theme/src/examples/dependencies/index.ts @@ -6,8 +6,10 @@ export { bulletOptions } from "./bulletOptions"; export { candlestickOptions } from "./candlestickOptions"; export { columnOptions } from "./columnOptions"; export { donutOptions } from "./donutOptions"; +export { heatmapOptions } from "./heatmapOptions"; export { lineOptions } from "./lineOptions"; export { pieOptions } from "./pieOptions"; export { scatterOptions } from "./scatterOptions"; +export { singleColorHeatmapOptions } from "./singleColorHeatmapOptions"; export { stackedBarOptions } from "./stackedBarOptions"; export { waterfallOptions } from "./waterfallOptions"; diff --git a/packages/highcharts-theme/src/examples/dependencies/singleColorHeatmapOptions.ts b/packages/highcharts-theme/src/examples/dependencies/singleColorHeatmapOptions.ts new file mode 100644 index 00000000000..8de687d5fcc --- /dev/null +++ b/packages/highcharts-theme/src/examples/dependencies/singleColorHeatmapOptions.ts @@ -0,0 +1,132 @@ +import type { Options } from "highcharts"; + +export const singleColorHeatmapOptions: Options = { + chart: { + type: "heatmap", + styledMode: true, + }, + title: { + text: "Sales volume by region", + }, + xAxis: { + type: "category", + startOnTick: false, + endOnTick: false, + categories: [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ], + }, + yAxis: { + type: "category", + startOnTick: false, + endOnTick: false, + reversed: true, + categories: ["EMEA", "APAC", "AMER", "LATAM"], + title: { + text: null, + }, + }, + tooltip: { + enabled: false, + }, + plotOptions: { + heatmap: { + colsize: 1, + rowsize: 1, + borderWidth: 1, + pointPadding: 0, + dataLabels: { + enabled: true, + inside: true, + align: "center", + verticalAlign: "middle", + style: { + textOutline: "none", + }, + formatter: function () { + return typeof this.point.value === "number" + ? String(this.point.value) + : ""; + }, + }, + }, + }, + legend: { + layout: "horizontal", + align: "right", + symbolWidth: 200, + symbolHeight: 12, + }, + colorAxis: { + showInLegend: true, + min: 0, + max: 100, + }, + series: [ + { + name: "Volume", + type: "heatmap", + data: [ + [0, 0, 72], + [0, 1, 45], + [0, 2, 88], + [0, 3, 30], + [1, 0, 65], + [1, 1, 52], + [1, 2, 74], + [1, 3, 18], + [2, 0, 80], + [2, 1, 60], + [2, 2, 92], + [2, 3, 40], + [3, 0, 55], + [3, 1, 70], + [3, 2, 63], + [3, 3, 25], + [4, 0, 90], + [4, 1, 48], + [4, 2, 100], + [4, 3, 35], + [5, 0, 78], + [5, 1, 85], + [5, 2, 67], + [5, 3, 50], + [6, 0, 42], + [6, 1, 95], + [6, 2, 58], + [6, 3, 22], + [7, 0, 68], + [7, 1, 75], + [7, 2, 82], + [7, 3, 15], + [8, 0, 83], + [8, 1, 38], + [8, 2, 96], + [8, 3, 55], + [9, 0, 50], + [9, 1, 62], + [9, 2, 71], + [9, 3, 44], + [10, 0, 76], + [10, 1, 88], + [10, 2, 54], + [10, 3, 10], + [11, 0, 60], + [11, 1, 33], + [11, 2, 79], + [11, 3, 28], + ], + }, + ], +}; diff --git a/packages/highcharts-theme/src/examples/index.ts b/packages/highcharts-theme/src/examples/index.ts index 28a211353ba..d1fc9671be8 100644 --- a/packages/highcharts-theme/src/examples/index.ts +++ b/packages/highcharts-theme/src/examples/index.ts @@ -6,8 +6,10 @@ export { default as BulletChart } from "./BulletChart"; export { default as CandlestickChart } from "./CandlestickChart"; export { default as ColumnChart } from "./ColumnChart"; export { default as DonutChart } from "./DonutChart"; +export { default as Heatmap } from "./Heatmap"; export { default as LineChart } from "./LineChart"; export { default as PieChart } from "./PieChart"; export { default as ScatterChart } from "./ScatterChart"; +export { default as SingleColorHeatmap } from "./SingleColorHeatmap"; export { default as StackedBarChart } from "./StackedBarChart"; export { default as WaterfallChart } from "./WaterfallChart"; diff --git a/packages/highcharts-theme/src/index.ts b/packages/highcharts-theme/src/index.ts index 1d6a6b45915..bbf7be7b46b 100644 --- a/packages/highcharts-theme/src/index.ts +++ b/packages/highcharts-theme/src/index.ts @@ -1,2 +1,8 @@ +export { + buildColorAxis, + type ColorAxisConfig, + type SingleColorAxisConfig, + type ThresholdColorAxisConfig, +} from "./color-axis"; export { saltPatternDef } from "./patterns"; -export { useChart } from "./useChart"; +export { type UseChartConfig, useChart } from "./useChart"; diff --git a/packages/highcharts-theme/src/useChart.ts b/packages/highcharts-theme/src/useChart.ts index c54d8b91181..d827a407628 100644 --- a/packages/highcharts-theme/src/useChart.ts +++ b/packages/highcharts-theme/src/useChart.ts @@ -1,40 +1,62 @@ -import { useDensity, useIsomorphicLayoutEffect } from "@salt-ds/core"; +import { useDensity } from "@salt-ds/core"; import { useWindow } from "@salt-ds/window"; import type { Options } from "highcharts"; import Highcharts from "highcharts"; import type HighchartsReact from "highcharts-react-official"; -import { type RefObject, useRef, useState } from "react"; +import { type RefObject, useEffect, useRef, useState } from "react"; +import { buildColorAxis, type ColorAxisConfig } from "./color-axis"; import { getDefaultOptions } from "./default-options"; +export interface UseChartConfig { + colorAxis?: ColorAxisConfig; +} + export const useChart = ( chartRef: RefObject, chartOptions: Options, + config?: UseChartConfig, + containerRef?: RefObject, ) => { const density = useDensity(); const targetWindow = useWindow(); - const hostElementRef = useRef(null); + const configRef = useRef(config); + configRef.current = config; + + const colorAxisKey = JSON.stringify(config?.colorAxis); const [mergedOptions, setMergedOptions] = useState(() => { const defaults = getDefaultOptions(density); - return Highcharts.merge(defaults, chartOptions); + const colorAxisOptions = config?.colorAxis + ? { colorAxis: buildColorAxis(config.colorAxis) } + : {}; + return Highcharts.merge(defaults, colorAxisOptions, chartOptions); }); - useIsomorphicLayoutEffect(() => { - const chart = chartRef.current?.chart as Highcharts.Chart | null; - const container = chart?.container ?? null; - - if (container) { - hostElementRef.current = container; - } - - const elementUsed = - hostElementRef.current ?? targetWindow?.document?.documentElement; - - const defaults = getDefaultOptions(density, elementUsed); - - setMergedOptions(Highcharts.merge(defaults, chartOptions)); - }, [density, chartOptions, targetWindow, chartRef]); + useEffect(() => { + const hostElement = + containerRef?.current ?? + (chartRef.current?.chart as Highcharts.Chart | null)?.container ?? + targetWindow?.document?.documentElement ?? + null; + + const defaults = getDefaultOptions(density, hostElement); + const currentConfig = configRef.current; + const colorAxisOptions = currentConfig?.colorAxis + ? { colorAxis: buildColorAxis(currentConfig.colorAxis, hostElement) } + : {}; + + setMergedOptions( + Highcharts.merge(defaults, colorAxisOptions, chartOptions), + ); + }, [ + density, + chartOptions, + targetWindow, + chartRef, + colorAxisKey, + containerRef, + ]); return mergedOptions; }; diff --git a/packages/highcharts-theme/stories/highcharts-theme.stories.tsx b/packages/highcharts-theme/stories/highcharts-theme.stories.tsx index 2b1a131bd99..d7d6bbb2565 100644 --- a/packages/highcharts-theme/stories/highcharts-theme.stories.tsx +++ b/packages/highcharts-theme/stories/highcharts-theme.stories.tsx @@ -10,9 +10,11 @@ import { CandlestickChart as CandlestickChartComponent, ColumnChart as ColumnChartComponent, DonutChart as DonutChartComponent, + Heatmap as HeatmapComponent, LineChart as LineChartComponent, PieChart as PieChartComponent, ScatterChart as ScatterChartComponent, + SingleColorHeatmap as SingleColorHeatmapComponent, StackedBarChart as StackedBarChartComponent, WaterfallChart as WaterfallChartComponent, } from "../src/examples"; @@ -25,9 +27,11 @@ import { candlestickOptions, columnOptions, donutOptions, + heatmapOptions, lineOptions, pieOptions, scatterOptions, + singleColorHeatmapOptions, stackedBarOptions, waterfallOptions, } from "../src/examples/dependencies"; @@ -163,3 +167,19 @@ export const WaterfallChart = { options: waterfallOptions, }, }; + +export const Heatmap = { + render: (args: ChartStoryArgs) => , + args: { + patterns: false, + options: heatmapOptions, + }, +}; + +export const SingleColorHeatmap = { + render: (args: ChartStoryArgs) => , + args: { + patterns: false, + options: singleColorHeatmapOptions, + }, +};