Skip to content
Merged
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
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

### New features
* Added "Invert Color Scale" toggle to reverse the color gradient direction
* Diverging (three-stop) gradient: new "Add gradient middle" toggle and "Gradient middle" colour picker in the Format pane → General → Gradient Colors group. When enabled, 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.
* 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".
Comment thread
Demonkratiy marked this conversation as resolved.

### 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 All @@ -18,7 +19,7 @@
### Other
* Upgraded powerbi-visuals-tools from ^6.1.1 to ^7.0.3
* Added unit tests for invertColorScale and getOpacity utility
* Format pane General card restructured into three named groups: **Colorbrewer**, **Gradient Colors**, and **Additional settings**.
* Format pane General card restructured into three named groups: **Colorbrewer**, **Custom gradient colors**, and **Additional settings**.

## 4.0.0.0

Expand Down
2 changes: 1 addition & 1 deletion pbiviz.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"visual": {
"name": "TableHeatMap",
"displayName": "Table Heatmap 4.1.0.0",
"displayName": "Table Heatmap",
"guid": "TableHeatMap1443716069308",
"visualClassName": "TableHeatMap",
"version": "4.1.0.0",
Expand Down
29 changes: 2 additions & 27 deletions src/heatmapUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ import { ColorHelper } from "powerbi-visuals-utils-colorutils";

import maxBy from "lodash.maxby";

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

export const DimmedOpacity: number = 0.4;
export const DefaultOpacity: number = 1.0;
Expand Down Expand Up @@ -133,31 +133,6 @@ export function calculateGridSizeWidth(
return Math.max(ConstGridMinWidth, Math.min(gridSizeWidth, gridSizeHeight * CellMaxWidthFactorLimit));
}

/**
* Returns the start and end colours for the active colour source (colorbrewer palette or
* user-defined gradient). Called by `initColors` to resolve the two anchor colours before
* building a two- or three-stop scale.
*/
export function resolveStartEndColors(
colorbrewerEnable: boolean,
colorbrewerScale: string,
numBuckets: number,
gradientStart: string,
gradientEnd: string
): { startColor: string; endColor: string } {
if (colorbrewerEnable) {
const palette: IColorArray = colorbrewer[colorbrewerScale] || colorbrewer.Reds;
const colors: string[] | undefined = palette[numBuckets] ?? colorbrewer.Reds[numBuckets];
if (!colors || colors.length === 0) {
// numBuckets is outside the supported range for all palettes;
// fall back to the user gradient endpoints so we never dereference undefined.
return { startColor: gradientStart, endColor: gradientEnd };
}
return { startColor: colors[0], endColor: colors[colors.length - 1] };
}
return { startColor: gradientStart, endColor: gradientEnd };
}

export function parseSettings(colorHelper: ColorHelper, settingsModel: SettingsModel): SettingsModel {
if (colorHelper.isHighContrast) {
const foregroundColor: string = colorHelper.getThemeColor("foreground");
Expand Down
6 changes: 5 additions & 1 deletion src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ export class GeneralSettings extends FormattingSettingsCompositeCard {

public activateGradientMiddle = new formattingSettings.ToggleSwitch({
name: "activateGradientMiddle",
displayNameKey: "Visual_ActivateGradientMiddle",
displayNameKey: "Visual_GradientMiddle",
value: false,
});

Expand Down Expand Up @@ -479,6 +479,10 @@ export class GeneralSettings extends FormattingSettingsCompositeCard {
public groups: FormattingSettingsGroup[] = [this.paletteGroup, this.gradientGroup, this.gradientScaleGroup];

public onPreProcess(): void {
// Colorbrewer and the custom gradient are mutually exclusive color sources. When
// colorbrewer is enabled the palette fully defines the colors, so the custom gradient
// group is disabled to make it clear those pickers have no effect in this mode.
this.gradientGroup.disabled = this.enableColorbrewer.value;
this.gradientMiddle.visible = this.activateGradientMiddle.value;
}
}
Expand Down
47 changes: 17 additions & 30 deletions src/visual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ import {
getYAxisWidth,
isDataViewValid,
parseSettings,
resolveStartEndColors,
textLimit
} from "./heatmapUtils";

Expand Down Expand Up @@ -449,19 +448,9 @@ export class TableHeatMap implements IVisual {
const maxDataValue: number = d3Max(chartData.dataPoints, (d: TableHeatMapDataPoint) => d.value as number);

// Base palette as defined by the active source (colorbrewer or custom gradient),
// without invert applied. Used both for rendering and for syncing gradient pickers.
// without invert applied.
const baseColors: string[] = this.initColors(settingsModel);

// Sync the gradient pickers with the current colorbrewer palette so that if the
// user later disables colorbrewer, they inherit sensible start/end values.
// Done only in colorbrewer mode: in custom gradient mode the pickers ARE the source
// of truth, and writing back would (a) leak inverted colors when invert is on and
// (b) cause a slow off-by-one drift of gradientEnd toward gradientStart.
if (settingsModel.general.enableColorbrewer.value) {
settingsModel.general.gradientStart.value.value = baseColors[0];
settingsModel.general.gradientEnd.value.value = baseColors[baseColors.length - 1];
}

// Invert is a render-only transformation applied as the final step so that toggling
// it never mutates user-visible settings (gradient pickers stay stable).
const colors: string[] = settingsModel.general.invertColorScale.value
Expand Down Expand Up @@ -495,15 +484,22 @@ export class TableHeatMap implements IVisual {
const activateGradientMiddle: boolean = settingsModel.general.activateGradientMiddle.value;
const numBuckets: number = settingsModel.CurrentBucketCount;

if (activateGradientMiddle) {
const { startColor, endColor } = resolveStartEndColors(
colorbrewerEnable,
colorbrewerScale,
numBuckets,
settingsModel.general.gradientStart.value.value,
settingsModel.general.gradientEnd.value.value
);
// Colorbrewer takes precedence: when it is enabled the palette fully defines the colors,
// and the custom gradient pickers (start/middle/end) are disabled in the Format pane.
if (colorbrewerEnable) {
const currentColorbrewer: IColorArray | undefined = colorbrewerScale ? colorbrewer[colorbrewerScale] : undefined;
const palette: string[] | undefined = (currentColorbrewer ? currentColorbrewer[numBuckets] : undefined)
?? colorbrewer.Reds[numBuckets];
if (palette && palette.length > 0) {
// Copy to avoid leaking mutations into the shared colorbrewer table by reference.
return palette.slice();
}
}

// Custom three-stop (diverging) gradient.
if (activateGradientMiddle) {
const startColor: string = settingsModel.general.gradientStart.value.value;
const endColor: string = settingsModel.general.gradientEnd.value.value;
const middleColor: string = settingsModel.general.gradientMiddle.value.value;
const mid: number = (numBuckets - 1) / 2;
const domain: number[] = [0, mid, numBuckets - 1];
Expand All @@ -518,16 +514,7 @@ export class TableHeatMap implements IVisual {
return colors;
}

if (colorbrewerEnable) {
const currentColorbrewer: IColorArray | undefined = colorbrewerScale ? colorbrewer[colorbrewerScale] : undefined;
const palette: string[] | undefined = (currentColorbrewer ? currentColorbrewer[numBuckets] : undefined)
?? colorbrewer.Reds[numBuckets];
if (palette && palette.length > 0) {
// Copy to avoid leaking mutations into the shared colorbrewer table by reference.
return palette.slice();
}
}

// Custom two-stop gradient.
const startColor: string = settingsModel.general.gradientStart.value.value;
const endColor: string = settingsModel.general.gradientEnd.value.value;
const colorScale: LinearColorScale = createLinearColorScale([0, numBuckets], [startColor, endColor], true);
Expand Down
3 changes: 1 addition & 2 deletions stringResources/en-US/resources.resjson
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"Visual_ActivateGradientMiddle": "Add gradient middle",
"Visual_Category": "Category",
"Visual_DataLabels": "Data labels",
"Visual_Description_DisplayAllLabelsAnyway": "Display all labels anyway",
Expand All @@ -10,7 +9,7 @@
"Visual_General": "General",
"Visual_General_Additional": "Additional settings",
"Visual_General_Colorbrewer": "Colorbrewer",
"Visual_General_Gradient": "Gradient Colors",
"Visual_General_Gradient": "Custom gradient colors",
"Visual_General_Granularity": "Granularity",
"Visual_GradientEnd": "Gradient end",
"Visual_GradientMiddle": "Gradient middle",
Expand Down
119 changes: 99 additions & 20 deletions test/visualTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -835,26 +835,6 @@ describe("TableHeatmap", () => {
}, AnimationTimeout);
}, AnimationTimeout);
});

it("should keep gradient pickers in sync with the base (non-inverted) palette in colorbrewer mode", (done) => {
const base = { enableColorbrewer: true, colorbrewer: "Reds", buckets: 5 };

dataView.metadata.objects = { general: { ...base, invertColorScale: false } };
visualBuilder.updateRenderTimeout(dataView, () => {
const baseline = readGradientPickers();

dataView.metadata.objects = { general: { ...base, invertColorScale: true } };
visualBuilder.updateRenderTimeout(dataView, () => {
// Pickers must show the SAME endpoints as in the non-inverted render —
// they preview the base palette so the user has predictable defaults
// when switching to custom gradient mode.
const afterInvert = readGradientPickers();
expect(areColorsEqual(afterInvert.start, baseline.start)).toBeTrue();
expect(areColorsEqual(afterInvert.end, baseline.end)).toBeTrue();
done();
}, AnimationTimeout);
}, AnimationTimeout);
});
});

describe("activateGradientMiddle", () => {
Expand Down Expand Up @@ -963,6 +943,105 @@ describe("TableHeatmap", () => {

});

describe("colorbrewer vs custom gradient", () => {
beforeEach(() => {
dataView = defaultDataViewBuilder.getDataViewWithSeries();
});

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getCellFills = (): string[] =>
Array.from(visualBuilder.rects!).map((r) => getComputedStyle(r).fill);

it("does not call persistProperties (no persist-based syncing)", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const spy = spyOn((visualBuilder as any).visualHost, "persistProperties");
dataView.metadata.objects = { general: { enableColorbrewer: true, colorbrewer: "Reds", buckets: 5 } };
visualBuilder.update(dataView);
dataView.metadata.objects = { general: { enableColorbrewer: true, colorbrewer: "Blues", buckets: 5 } };
visualBuilder.update(dataView);
expect(spy).not.toHaveBeenCalled();
});

it("disables the custom gradient group when colorbrewer is enabled", (done) => {
dataView.metadata.objects = { general: { enableColorbrewer: true, colorbrewer: "Reds", buckets: 5 } };
visualBuilder.updateRenderTimeout(dataView, () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const model = (visualBuilder as any).visual.getFormattingModel();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const general = model.cards.find((c: any) => c.uid === "general-card");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const gradientGroup = general.groups.find((g: any) => g.uid === "gradientGroup-group");
expect(gradientGroup.disabled).toBeTrue();
done();
}, AnimationTimeout);
});

it("enables the custom gradient group when colorbrewer is disabled", (done) => {
dataView.metadata.objects = { general: { enableColorbrewer: false } };
visualBuilder.updateRenderTimeout(dataView, () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const model = (visualBuilder as any).visual.getFormattingModel();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const general = model.cards.find((c: any) => c.uid === "general-card");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const gradientGroup = general.groups.find((g: any) => g.uid === "gradientGroup-group");
expect(gradientGroup.disabled).toBeFalse();
done();
}, AnimationTimeout);
});

it("ignores the gradient middle and uses the colorbrewer palette when colorbrewer is enabled", (done) => {
// Colorbrewer palette render.
dataView.metadata.objects = { general: { enableColorbrewer: true, colorbrewer: "Reds", buckets: 5 } };
visualBuilder.updateRenderTimeout(dataView, () => {
const paletteFills = getCellFills();

// Same palette, but with the (custom) gradient middle turned on and a contrasting
// middle color: it must have NO effect because colorbrewer takes precedence.
dataView.metadata.objects = {
general: {
enableColorbrewer: true,
colorbrewer: "Reds",
buckets: 5,
activateGradientMiddle: true,
gradientMiddle: { solid: { color: "#00FF00" } },
},
};
visualBuilder.updateRenderTimeout(dataView, () => {
const withMiddleFills = getCellFills();
withMiddleFills.forEach((fill, i) => {
expect(areColorsEqual(fill, paletteFills[i])).toBeTrue();
});
done();
}, AnimationTimeout);
}, AnimationTimeout);
});

it("uses the custom three-stop gradient when colorbrewer is disabled and middle is active", (done) => {
const base = {
enableColorbrewer: false,
gradientStart: { solid: { color: "#FF0000" } },
gradientMiddle: { solid: { color: "#00FF00" } },
gradientEnd: { solid: { color: "#0000FF" } },
};

// Two-stop render (middle off).
dataView.metadata.objects = { general: { ...base, activateGradientMiddle: false } };
visualBuilder.updateRenderTimeout(dataView, () => {
const twoStopFills = getCellFills();

// Three-stop render (middle on) must differ — the middle color reshapes the scale.
dataView.metadata.objects = { general: { ...base, activateGradientMiddle: true } };
visualBuilder.updateRenderTimeout(dataView, () => {
const threeStopFills = getCellFills();
const changed = threeStopFills.filter((fill, i) => !areColorsEqual(fill, twoStopFills[i])).length;
expect(changed).toBeGreaterThan(0);
done();
}, AnimationTimeout);
}, AnimationTimeout);
});
});

describe("utils:getOpacity", () => {
it("returns DefaultOpacity when no selection or highlights are active", () => {
expect(getOpacity(false, false, false, false)).toBe(DefaultOpacity);
Expand Down
Loading