Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions packages/highcharts-theme/src/color-axis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import type { ColorAxisOptions } from "highcharts";
import {
getCSSColorTokenFromElement,
type RgbColor,
} from "./compute-css-tokens";

const DEFAULT_COLORS: Record<string, RgbColor> = {
"--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;
27 changes: 27 additions & 0 deletions packages/highcharts-theme/src/compute-css-tokens.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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),
};
};
52 changes: 52 additions & 0 deletions packages/highcharts-theme/src/examples/Heatmap.tsx
Original file line number Diff line number Diff line change
@@ -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<HeatmapProps> = ({
patterns = false,
options = heatmapOptions,
}) => {
const chartRef = useRef<HighchartsReact.RefObject>(null);

const chartOptions = useChart(chartRef, options, {
colorAxis: heatmapColorConfig,
});

return (
<div
className={clsx("highcharts-theme-salt", {
"salt-fill-patterns": patterns,
})}
>
<HighchartsReact
highcharts={Highcharts}
options={chartOptions}
ref={chartRef}
/>
</div>
);
};

export default HeatmapChart;
50 changes: 50 additions & 0 deletions packages/highcharts-theme/src/examples/SingleColorHeatmap.tsx
Original file line number Diff line number Diff line change
@@ -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<SingleColorHeatmapProps> = ({
patterns = false,
options = singleColorHeatmapOptions,
}) => {
const chartRef = useRef<HighchartsReact.RefObject>(null);

const chartOptions = useChart(chartRef, options, {
colorAxis: singleColorConfig,
});

return (
<div
className={clsx("highcharts-theme-salt", {
"salt-fill-patterns": patterns,
})}
>
<HighchartsReact
highcharts={Highcharts}
options={chartOptions}
ref={chartRef}
/>
</div>
);
};

export default SingleColorHeatmapChart;
Loading
Loading