Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a14c770
Enhance TableHeatMap with Gradient Middle Feature
ansaganie May 20, 2026
ba14d84
Update gradient middle color handling in TableHeatMap
ansaganie May 22, 2026
c5d164d
Revert: remove diverging gradient middle feature
ansaganie May 30, 2026
4ba4f22
Merge branch 'main' of https://github.com/ansaganie/powerbi-visuals-h…
ansaganie May 30, 2026
fa462bc
feat: add gradient middle color feature with toggle and picker in For…
ansaganie Jun 1, 2026
f7cca15
feat: add minimum width check for grid size calculation and remove de…
ansaganie Jun 1, 2026
91095a2
feat: centralize color resolution logic for gradient calculations in …
ansaganie Jun 1, 2026
666cc4a
feat: enhance gradient color handling and improve test descriptions f…
ansaganie Jun 1, 2026
cf16b59
feat: improve colorbrewer palette handling and update test descriptio…
ansaganie Jun 2, 2026
2c7081a
fix: update key for gradient middle activation in resource file
ansaganie Jun 4, 2026
ffc6a96
feat: add minimum limit for bucket count with gradient middle and upd…
ansaganie Jun 4, 2026
5f58aa5
refactor: update test descriptions for middle color handling in gradi…
ansaganie Jun 4, 2026
b9ee930
fix: reorder slices in gradientScaleGroup for improved clarity
ansaganie Jun 8, 2026
09871f4
fix: correct grid height adjustment factor and set max bucket count l…
ansaganie Jun 9, 2026
13aa1e0
test: update assertions to check for non-empty labelDOMItems in Table…
ansaganie Jun 10, 2026
a71d3bc
refactor: update stroke handling in settings model and adjust visual …
ansaganie Jun 11, 2026
55de5ca
fix: update gradient middle color calculation
ansaganie Jun 11, 2026
1c223ea
fix: update formatting settings import
ansaganie Jun 11, 2026
0b3ce30
chore: update CHANGELOG.md to reflect new features, bug fixes, and co…
ansaganie Jun 11, 2026
bce7e56
fix: remove unreachable code blocks
ansaganie Jun 12, 2026
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,23 @@

### 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.

### 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.
* Fixed bucket count upper bound not being restored when switching from a Colorbrewer palette back to the custom gradient mode; the maximum was previously stuck at the palette's supported range rather than resetting to 18.
* Fixed gradient middle anchor being skewed left-of-centre for even bucket counts; the three-stop domain now uses a fractional midpoint so both odd and even counts produce a symmetric diverging scale.

### Code quality
* Renamed internal constant `AdditionalSpaceForColorbrewerCells` → `GridHeightAdjustmentFactor` to reflect that the padding applies in all rendering modes.
* `GeneralSettings.stroke` converted from a `static` mutable field to an instance field on `GeneralSettings`; high-contrast and non-high-contrast paths now reset it on every render, eliminating cross-render state leakage.
* `SettingsModel.cards` type widened from `FormattingSettingsSimpleCard[]` to `FormattingSettingsCard[]` (`SimpleCard | CompositeCard`) to correctly reflect that `GeneralSettings` extends `CompositeCard`.
* Replaced tautological `expect(querySelectorAll(…)).toBeTruthy()` assertions in unit tests with `expect(…length).toBeGreaterThan(0)`.

### 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**.

## 4.0.0.0

Expand Down
14 changes: 14 additions & 0 deletions capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,20 @@
}
}
},
"activateGradientMiddle": {
"type": {
"bool": true
}
},
"gradientMiddle": {
"type": {
"fill": {
"solid": {
"color": true
}
}
}
},
"gradientEnd": {
"type": {
"fill": {
Expand Down
143 changes: 143 additions & 0 deletions src/heatmapUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import powerbi from "powerbi-visuals-api";

import { textMeasurementService } from "powerbi-visuals-utils-formattingutils";

import { pixelConverter as PixelConverter } from "powerbi-visuals-utils-typeutils";
import { ColorHelper } from "powerbi-visuals-utils-colorutils";

import maxBy from "lodash.maxby";

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

export const DimmedOpacity: number = 0.4;
export const DefaultOpacity: number = 1.0;
export const DimmedColor: string = "black";
Expand All @@ -38,4 +50,135 @@ export function getOpacity(
}

return DefaultOpacity;
}

export const YAxisAdditionalMargin: number = 5;
export const GridHeightAdjustmentFactor: number = 2;
export const ConstGridMinHeight: number = 5;
export const ConstGridMinWidth: number = 1;
Comment thread
Demonkratiy marked this conversation as resolved.
export const CellMaxHeightLimit: number = 300;
export const CellMaxWidthFactorLimit: number = 15;

export function isDataViewValid(dataView: powerbi.DataView): boolean {
return !!(dataView.categorical?.categories && dataView.categorical?.values);
}

export function textLimit(text: string, limit: number): string {
if (text.length > limit) {
return ((text || "").substring(0, limit).trim()) + "\u2026";
}

return text;
}

export function getYAxisWidth(chartData: TableHeatMapChartData, settings: YAxisLabelsSettings): number {
let maxLengthText: powerbi.PrimitiveValue = maxBy(chartData.categoryY, (d) => String(d).length) || "";

maxLengthText = textLimit(maxLengthText.toString(), settings.maxTextSymbol.value);

return settings.show.value ? textMeasurementService.measureSvgTextWidth({
fontSize: PixelConverter.toString(settings.fontSize.value),
text: maxLengthText.trim(),
fontFamily: settings.fontFamily.value.toString()
}) + YAxisAdditionalMargin : 0;
}

export function getXAxisHeight(chartData: TableHeatMapChartData, settings: BaseLabelCardSettings): number {
const categoryX: string[] = chartData.categoryX.map(x => x?.toString() ?? "");
const maxLengthText: powerbi.PrimitiveValue = maxBy(categoryX, "length") || "";

return settings.show.value ? textMeasurementService.measureSvgTextHeight({
fontSize: PixelConverter.toString(settings.fontSize.value),
text: maxLengthText.toString().trim(),
fontFamily: settings.fontFamily.value.toString()
}) : 0;
}

export function getYAxisHeight(chartData: TableHeatMapChartData, settings: YAxisLabelsSettings): number {
const maxLengthText: powerbi.PrimitiveValue = maxBy(chartData.categoryY, (d) => String(d).length) || "";

return textMeasurementService.measureSvgTextHeight({
fontSize: PixelConverter.toString(settings.fontSize.value),
text: maxLengthText.toString().trim(),
fontFamily: settings.fontFamily.value.toString()
});
}

export function calculateGridSizeHeight(
viewportHeight: number,
xAxisHeight: number,
categoryYLength: number,
marginTop: number,
marginBottom: number
): number {
const gridSizeHeight: number = Math.floor(
(viewportHeight - marginTop - xAxisHeight - marginBottom - YAxisAdditionalMargin) /
(categoryYLength + GridHeightAdjustmentFactor)
);

return Math.max(ConstGridMinHeight, Math.min(gridSizeHeight, CellMaxHeightLimit));
}

export function calculateGridSizeWidth(
viewportWidth: number,
yAxisWidth: number,
categoryXLength: number,
gridSizeHeight: number
): number {
if (categoryXLength <= 0) {
return ConstGridMinWidth;
}
const gridSizeWidth: number = Math.floor((viewportWidth - yAxisWidth) / categoryXLength);

return Math.max(ConstGridMinWidth, Math.min(gridSizeWidth, gridSizeHeight * CellMaxWidthFactorLimit));
}
Comment thread
ansaganie marked this conversation as resolved.

/**
* 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(
Comment thread
Demonkratiy marked this conversation as resolved.
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] };
}
Comment thread
ansaganie marked this conversation as resolved.
return { startColor: gradientStart, endColor: gradientEnd };
}

export function parseSettings(colorHelper: ColorHelper, settingsModel: SettingsModel): SettingsModel {
if (colorHelper.isHighContrast) {
const foregroundColor: string = colorHelper.getThemeColor("foreground");
const backgroundColor: string = colorHelper.getThemeColor("background");

settingsModel.labels.show.value = true;
settingsModel.labels.fill.value.value = foregroundColor;

settingsModel.xAxisLabels.fill.value.value = foregroundColor;
settingsModel.yAxisLabels.fill.value.value = foregroundColor;

settingsModel.general.enableColorbrewer.value = false;
settingsModel.general.activateGradientMiddle.value = false;
settingsModel.general.gradientStart.value.value = backgroundColor;
settingsModel.general.gradientEnd.value.value = backgroundColor;
settingsModel.general.stroke = foregroundColor;
settingsModel.general.textColor = foregroundColor;
} else {
settingsModel.general.stroke = "#E6E6E6";
settingsModel.general.textColor = "#AAAAAA";
Comment thread
Demonkratiy marked this conversation as resolved.
}

return settingsModel;
Comment thread
ansaganie marked this conversation as resolved.
}
Comment thread
ansaganie marked this conversation as resolved.
Comment thread
ansaganie marked this conversation as resolved.
Comment thread
Demonkratiy marked this conversation as resolved.
Loading
Loading