Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* Added "Invert Color Scale" toggle to reverse the color gradient direction
* Diverging (three-stop) gradient: new "Gradient middle" toggle and colour picker in the Format pane → General → Custom gradient colors group. When the custom gradient is active, the colour scale interpolates smoothly through the chosen midpoint colour (default: `#767676`). The midpoint uses this default until the user explicitly changes it in the Format pane.
* Clearer separation between the two colour sources. Colorbrewer and the custom gradient were already mutually exclusive, but the active mode was not obvious in the UI. The custom gradient pickers (start / middle / end) are now disabled in the Format pane while Colorbrewer is enabled, and become editable again when it is turned off, so it is always clear which colour source is driving the visual. The gradient middle is a custom-gradient feature and has no effect while Colorbrewer is on. The "Gradient Colors" group was renamed to "Custom gradient colors".
* Added Auto-contrast toggle to Data labels: when enabled, each label's lightness is automatically clamped to remain legible against its cell background colour while preserving the user-picked hue and saturation.

### Bug fixes
* Fixed "Invert Color Scale" and gradient middle colour not being neutralized in high-contrast mode; both features are now automatically disabled when the Power BI high-contrast theme is active to preserve accessibility contrast requirements.
Expand Down
9 changes: 9 additions & 0 deletions capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,15 @@
"type": {
"bool": true
}
},
"autoContrast": {
"type": {
"enumeration": [
{ "value": "Off", "displayNameKey": "Visual_LabelsAutoContrast_Off" },
{ "value": "Soft", "displayNameKey": "Visual_LabelsAutoContrast_Soft" },
{ "value": "Strong", "displayNameKey": "Visual_LabelsAutoContrast_Strong" }
]
}
}
}
},
Expand Down
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"private": true,
"dependencies": {
"d3-array": "^3.2.4",
"d3-color": "^3.1.0",
Comment thread
Demonkratiy marked this conversation as resolved.
"d3-scale": "^4.0.2",
"d3-selection": "^3.0.0",
"d3-transition": "^3.0.1",
Expand All @@ -25,6 +26,7 @@
},
"devDependencies": {
"@types/d3-array": "^3.2.1",
"@types/d3-color": "^3.1.0",
"@types/d3-scale": "^4.0.9",
"@types/d3-selection": "^3.0.11",
"@types/d3-transition": "^3.0.9",
Expand Down
1 change: 1 addition & 0 deletions src/dataInterfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type Quantile<T> = ScaleQuantile<T>;

import IValueFormatter = valueFormatter.IValueFormatter;
import { SettingsModel } from "./settings";

export interface TableHeatMapDataPoint extends ISelectableDataPoint, TooltipEnabledDataPoint {
categoryX: powerbi.PrimitiveValue;
categoryY: powerbi.PrimitiveValue;
Expand Down
180 changes: 174 additions & 6 deletions src/heatmapUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,17 @@ import { ColorHelper } from "powerbi-visuals-utils-colorutils";

import maxBy from "lodash.maxby";

import { color as d3Color, hsl as d3Hsl, lab as d3Lab, RGBColor } from "d3-color";

import { TableHeatMapChartData } from "./dataInterfaces";
import { BaseLabelCardSettings, GeneralSettings, SettingsModel, YAxisLabelsSettings } from "./settings";

export const DimmedOpacity: number = 0.4;
export const DefaultOpacity: number = 1.0;
export const DimmedColor: string = "black";
export const DIMMED_OPACITY: number = 0.4;
export const DEFAULT_OPACITY: number = 1.0;
export const DIMMED_COLOR: string = "black";
Comment thread
ansaganie marked this conversation as resolved.
export const LAB_LIGHT_BG_THRESHOLD: number = 60;
export const DARK_LABEL_LIGHTNESS: number = 0.2;
export const LIGHT_LABEL_LIGHTNESS: number = 0.85;

export function getOpacity(
selected: boolean,
Expand All @@ -46,10 +51,10 @@ export function getOpacity(
hasPartialHighlights: boolean): number {

if ((hasPartialHighlights && !highlight) || (hasSelection && !selected)) {
return DimmedOpacity;
return DIMMED_OPACITY;
}

return DefaultOpacity;
return DEFAULT_OPACITY;
}

export const YAxisAdditionalMargin: number = 5;
Expand Down Expand Up @@ -156,4 +161,167 @@ export function parseSettings(colorHelper: ColorHelper, settingsModel: SettingsM
}

return settingsModel;
}
}

// ---------------------------------------------------------------------------
// WCAG 2.x relative luminance helpers (W3C formula: https://www.w3.org/TR/WCAG20/#relativeluminancedef)
// ---------------------------------------------------------------------------

// sRGB linearization coefficients (IEC 61966-2-1)
const SRGB_CHANNEL_MAX = 255; // maximum 8-bit channel value
const SRGB_LINEARIZATION_THRESHOLD = 0.03928; // below this, use linear segment
const SRGB_LINEAR_DIVISOR = 12.92; // divisor for the linear segment
const SRGB_EXPONENT_OFFSET = 0.055; // offset in the power-law segment
const SRGB_EXPONENT_SCALE = 1.055; // scale in the power-law segment
const SRGB_GAMMA = 2.4; // gamma exponent (IEC 61966-2-1)

// WCAG 2.x relative-luminance coefficients (ITU-R BT.709 primaries)
const WCAG_RED_COEFF = 0.2126;
const WCAG_GREEN_COEFF = 0.7152;
const WCAG_BLUE_COEFF = 0.0722;

// Offset added to both luminances in the WCAG contrast-ratio formula
const WCAG_LUMINANCE_OFFSET = 0.05;

/** WCAG AA contrast ratio target for normal text. */
export const WCAG_AA_CONTRAST_RATIO: number = 4.5;

// formatRgb() rounds r/g/b to integers on output, which can lower the contrast by up to ~0.02.
// The binary search targets this slightly higher value so the rounded output still clears 4.5:1.
const WCAG_AA_BINARY_SEARCH_TARGET: number = WCAG_AA_CONTRAST_RATIO + 0.05;

/**
* WCAG crossover luminance: the background luminance at which dark and light
* text yield equal contrast ratios. Derived from (L+0.05)/0.05 = 1.05/(L+0.05)
* → L ≈ 0.179.
*/
const WCAG_CROSSOVER_LUMINANCE = 0.179;

/** Iterations for the binary-search in Strong mode: 2^-20 ≈ 10^-6 lightness precision. */
const BINARY_SEARCH_ITERATIONS = 20;

/** Auto-contrast mode identifiers — must match the enumeration values in capabilities.json. */
export const AUTO_CONTRAST_MODE_OFF = "Off" as const;
export const AUTO_CONTRAST_MODE_SOFT = "Soft" as const;
export const AUTO_CONTRAST_MODE_STRONG = "Strong" as const;

export type AutoContrastMode =
typeof AUTO_CONTRAST_MODE_OFF |
typeof AUTO_CONTRAST_MODE_SOFT |
typeof AUTO_CONTRAST_MODE_STRONG;

/** sRGB channel 0–255 → linear-light value (IEC 61966-2-1). */
function linearizeChannel(c255: number): number {
const c = c255 / SRGB_CHANNEL_MAX;
return c <= SRGB_LINEARIZATION_THRESHOLD
? c / SRGB_LINEAR_DIVISOR
: ((c + SRGB_EXPONENT_OFFSET) / SRGB_EXPONENT_SCALE) ** SRGB_GAMMA;
}

/** WCAG 2.x relative luminance in [0, 1]. */
function relativeLuminance(rgb: RGBColor): number {
return WCAG_RED_COEFF * linearizeChannel(rgb.r) +
WCAG_GREEN_COEFF * linearizeChannel(rgb.g) +
WCAG_BLUE_COEFF * linearizeChannel(rgb.b);
}

/** WCAG 2.x contrast ratio; inputs are relative luminances. */
function contrastRatioFromLuminances(l1: number, l2: number): number {
const [light, dark] = l1 > l2 ? [l1, l2] : [l2, l1];
return (light + WCAG_LUMINANCE_OFFSET) / (dark + WCAG_LUMINANCE_OFFSET);
}

/**
* Returns the WCAG 2.x contrast ratio between two CSS colour strings,
* or `null` if either is invalid/unparseable.
*/
export function wcagContrastRatio(color1: string, color2: string): number | null {
const c1 = d3Color(color1);
const c2 = d3Color(color2);
if (c1 === null || c2 === null) return null;
return contrastRatioFromLuminances(relativeLuminance(c1.rgb()), relativeLuminance(c2.rgb()));
}

// ---------------------------------------------------------------------------

/**
* Preserves the user's hue/saturation and alpha; clamps only lightness to stay legible on `backgroundColor`.
*
* Note: uses a fixed Lab-lightness threshold (LAB_LIGHT_BG_THRESHOLD) rather than a full WCAG
* luminance-contrast calculation. For highly saturated hues (e.g. yellow on white) the result
* may not meet WCAG AA contrast requirements; the trade-off is intentional — hue and saturation
* are preserved so the user's brand colour identity is retained.
*/
export function getAdaptiveLabelColor(userColor: string, backgroundColor: string): string {
Comment thread
Demonkratiy marked this conversation as resolved.
const bg = d3Color(backgroundColor);
const fg = d3Hsl(userColor);
// Invalid/unsupported inputs -> keep the user-picked color unchanged.
if (bg === null || fg === null || isNaN(fg.l)) {
return userColor;
}
// lab(...).l is perceptual lightness in [0, 100]; high = light background.
fg.l = d3Lab(bg).l > LAB_LIGHT_BG_THRESHOLD ? DARK_LABEL_LIGHTNESS : LIGHT_LABEL_LIGHTNESS;
Comment thread
ansaganie marked this conversation as resolved.
// formatRgb() emits rgba(r,g,b,a) when opacity < 1, preserving any user-set transparency.
return fg.formatRgb();
}

/**
* Strong mode: binary-search HSL lightness until WCAG AA contrast ratio (≥ 4.5:1) is met.
* Preserves hue, saturation, and alpha; only adjusts lightness.
*
* The search starts from the Soft-mode target direction (DARK_LABEL_LIGHTNESS toward 0 for
* light backgrounds, LIGHT_LABEL_LIGHTNESS toward 1 for dark backgrounds), so it makes the
* smallest possible lightness change that achieves the required contrast.
*/
export function getAdaptiveLabelColorStrong(userColor: string, backgroundColor: string): string {
const bgParsed = d3Color(backgroundColor);
const fg = d3Hsl(userColor);
if (bgParsed === null || fg === null || isNaN(fg.l)) {
return userColor;
}
const bgRgb = bgParsed.rgb();
const bgLum = relativeLuminance(bgRgb);
const useDark = bgLum > WCAG_CROSSOVER_LUMINANCE; // dark text on light bg

// Binary-search range:
// dark text → maximise l within [0, DARK_LABEL_LIGHTNESS] (l=0 always satisfies)
// light text → minimise l within [LIGHT_LABEL_LIGHTNESS, 1] (l=1 always satisfies)
let lo = useDark ? 0 : LIGHT_LABEL_LIGHTNESS;
let hi = useDark ? DARK_LABEL_LIGHTNESS : 1;

for (let i = 0; i < BINARY_SEARCH_ITERATIONS; i++) {
const mid = (lo + hi) / 2;
fg.l = mid;
const fgRgb = fg.rgb();
// If the user color has alpha < 1, the perceived text is the alpha-composite of fg over bg.
// Compute luminance on the composited colour so the contrast check reflects what is rendered.
const a = fgRgb.opacity ?? 1;
const compR = fgRgb.r * a + bgRgb.r * (1 - a);
const compG = fgRgb.g * a + bgRgb.g * (1 - a);
const compB = fgRgb.b * a + bgRgb.b * (1 - a);
const fgLum =
WCAG_RED_COEFF * linearizeChannel(compR) +
WCAG_GREEN_COEFF * linearizeChannel(compG) +
WCAG_BLUE_COEFF * linearizeChannel(compB);
if (contrastRatioFromLuminances(fgLum, bgLum) >= WCAG_AA_BINARY_SEARCH_TARGET) {
// Meets the ratio — can relax toward the user's preferred direction
if (useDark) lo = mid; else hi = mid;
} else {
// Fails — push toward the extreme
if (useDark) hi = mid; else lo = mid;
}
}
fg.l = useDark ? lo : hi;
return fg.formatRgb();
}

/**
* Dispatcher: routes to the appropriate contrast algorithm based on `mode`.
* @param mode One of the AUTO_CONTRAST_MODE_* constants.
*/
export function applyAutoContrast(userColor: string, backgroundColor: string, mode: AutoContrastMode): string {
if (mode === AUTO_CONTRAST_MODE_OFF) return userColor;
if (mode === AUTO_CONTRAST_MODE_STRONG) return getAdaptiveLabelColorStrong(userColor, backgroundColor);
return getAdaptiveLabelColor(userColor, backgroundColor); // Soft
}
Comment thread
ansaganie marked this conversation as resolved.

24 changes: 22 additions & 2 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { formattingSettings } from "powerbi-visuals-utils-formattingmodel";
import { formattingSettings, formattingSettingsInterfaces } from "powerbi-visuals-utils-formattingmodel";
type ILocalizedItemMember = formattingSettingsInterfaces.ILocalizedItemMember;

import FormattingSettingsSimpleCard = formattingSettings.SimpleCard;
import FormattingSettingsCompositeCard = formattingSettings.CompositeCard;
Expand Down Expand Up @@ -562,6 +563,25 @@ export class BaseLabelCardSettings extends FormattingSettingsSimpleCard {
}
}

export const AUTO_CONTRAST_OPTION_OFF: ILocalizedItemMember = { displayNameKey: "Visual_LabelsAutoContrast_Off", value: "Off" };
const AUTO_CONTRAST_OPTION_SOFT: ILocalizedItemMember = { displayNameKey: "Visual_LabelsAutoContrast_Soft", value: "Soft" };
const AUTO_CONTRAST_OPTION_STRONG: ILocalizedItemMember = { displayNameKey: "Visual_LabelsAutoContrast_Strong", value: "Strong" };
const autoContrastOptions: ILocalizedItemMember[] = [AUTO_CONTRAST_OPTION_OFF, AUTO_CONTRAST_OPTION_SOFT, AUTO_CONTRAST_OPTION_STRONG];

export class DataLabelsCardSettings extends BaseLabelCardSettings {
public autoContrast = new formattingSettings.ItemDropdown({
name: "autoContrast",
displayNameKey: "Visual_LabelsAutoContrast",
items: autoContrastOptions,
value: AUTO_CONTRAST_OPTION_SOFT, // default: Soft
});
Comment thread
ansaganie marked this conversation as resolved.

constructor(name: string, displayNameKey: string, isShown: boolean = true) {
super(name, displayNameKey, isShown);
this.slices = [this.font, this.fill, this.autoContrast];
}
}

export class YAxisLabelsSettings extends BaseLabelCardSettings {
private static TextSymbolMinValue: number = 0;
private static TextSymbolMaxValue: number = 50;
Expand All @@ -587,7 +607,7 @@ export class YAxisLabelsSettings extends BaseLabelCardSettings {
}

export class SettingsModel extends FormattingSettingsModel {
public labels: BaseLabelCardSettings = new BaseLabelCardSettings("labels", "Visual_DataLabels", false);
public labels: DataLabelsCardSettings = new DataLabelsCardSettings("labels", "Visual_DataLabels", false);
public xAxisLabels: BaseLabelCardSettings = new BaseLabelCardSettings("xAxisLabels", "Visual_XAxis");
public yAxisLabels: YAxisLabelsSettings = new YAxisLabelsSettings("yAxisLabels", "Visual_YAxis");
public general: GeneralSettings = new GeneralSettings();
Expand Down
37 changes: 35 additions & 2 deletions src/visual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {

import {
BaseLabelCardSettings,
DataLabelsCardSettings,
SettingsModel,
colorbrewer
} from "./settings";
Expand All @@ -83,6 +84,10 @@ import {
calculateGridSizeHeight,
calculateGridSizeWidth,
CellMaxHeightLimit,
applyAutoContrast,
AUTO_CONTRAST_MODE_OFF,
AUTO_CONTRAST_MODE_SOFT,
AutoContrastMode,
getXAxisHeight,
getYAxisHeight,
getYAxisWidth,
Expand Down Expand Up @@ -577,7 +582,7 @@ export class TableHeatMap implements IVisual {

private renderLabels(renderOptions: IRenderOptions): Selection<TableHeatMapDataPoint> {
const { chartData, settingsModel, xOffset, yOffset, gridSizeHeight, gridSizeWidth } = renderOptions;
const labelSettings: BaseLabelCardSettings = settingsModel.labels;
const labelSettings: DataLabelsCardSettings = settingsModel.labels;

const maxDataText = chartData.dataPoints.reduce((max: string, dp: TableHeatMapDataPoint) => {
const val = dp.valueStr || "";
Expand All @@ -592,6 +597,13 @@ export class TableHeatMap implements IVisual {

const textRect: SVGRect = textMeasurementService.measureSvgTextRect(textProperties);

// Cache adapted colors by (userColor|backgroundColor) key; avoids redundant Lab/HSL
// conversions for the same background bucket on every label within a single render pass.
const adaptiveLabelColorCache = new Map<string, string>();
// .value → ILocalizedItemMember (selected option); .value.value → the raw EnumMemberValue string.
// Fall back to Soft (the configured default) if the value is absent.
const autoContrastMode = (labelSettings.autoContrast.value?.value as AutoContrastMode | undefined) ?? AUTO_CONTRAST_MODE_SOFT;

const heatMapDataLables: Selection<TableHeatMapDataPoint> = this.mainGraphics
.selectAll(TableHeatMap.ClsHeatMapDataLabels.selectorName)
.data(chartData.dataPoints)
Expand All @@ -605,7 +617,28 @@ export class TableHeatMap implements IVisual {
})
.style("text-anchor", TableHeatMap.ConstMiddle)
.call(this.applyFontStylesToLabels(labelSettings))
.style("fill", labelSettings.fill.value.value)
.style("fill", (dataPoint: TableHeatMapDataPoint) => {
const userColor: string = labelSettings.fill.value.value;
if (autoContrastMode === AUTO_CONTRAST_MODE_OFF) {
return userColor;
}
const value = dataPoint.value;
if (typeof value !== "number" || !isFinite(value)) {
return userColor;
}
const backgroundColor: string = renderOptions.colorScale(value);
if (!backgroundColor) {
return userColor;
}
const cacheKey = `${userColor}|${backgroundColor}`;
const cached = adaptiveLabelColorCache.get(cacheKey);
if (cached !== undefined) {
return cached;
}
const adapted = applyAutoContrast(userColor, backgroundColor, autoContrastMode);
adaptiveLabelColorCache.set(cacheKey, adapted);
return adapted;
})
Comment thread
ansaganie marked this conversation as resolved.
Comment thread
ansaganie marked this conversation as resolved.
Comment thread
ansaganie marked this conversation as resolved.
.text((dataPoint: TableHeatMapDataPoint) => {
let textValue: string = valueFormatter.format(dataPoint.value);
textProperties.text = textValue;
Expand Down
Loading
Loading