Skip to content
Open
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 2.1.4.0
* Fixed X-axis category labels overlapping after the visual is resized
* Fixed the browser context menu appearing when right-clicking X-axis categories
* Fixed data labels overlapping dots in neighboring columns
* Added a tooltip with the full category name to truncated X-axis labels

## 2.1.3.0
* Removed an unused `tooltips` data role reference from capabilities
* Updated CI workflows (trigger on default branch, refreshed action versions, Node 20/22, npm cache, concurrency)
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@microsoft/powerbi-visuals-dotplot",
"version": "2.1.3.0",
"version": "2.1.4.0",
"private": true,
"description": "A dot plot is used to show a representation of the distribution of frequencies. It is most often used to show counts of an occurrence.",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion pbiviz.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"displayName": "Dot Plot",
"guid": "DotPlot1442374105856",
"visualClassName": "DotPlot",
"version": "2.1.3.0",
"version": "2.1.4.0",
"description": "A dot plot is used to show a representation of the distribution of frequencies. It is most often used to show counts of an occurrence.",
"supportUrl": "https://community.powerbi.com",
"gitHubUrl": "https://github.com/Microsoft/powerbi-visuals-dotplot"
Expand Down
18 changes: 18 additions & 0 deletions src/behavior.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface SelectableDataPoint extends BaseDataPoint {
export interface DotplotBehaviorOptions {
dataPoints: DotPlotDataGroup[];
columns: d3Selection<SVGGElement, DotPlotDataGroup, any, any>;
xAxisTicks: d3Selection<SVGGElement, number, any, any>;
clearCatcher: d3Selection<any, any, any, any>;
isHighContrastMode: boolean;
hasHighlights: boolean;
Expand Down Expand Up @@ -105,6 +106,23 @@ export class DotplotBehavior {
});
});

this.options.xAxisTicks.on("contextmenu", (event: MouseEvent, index: number) => {
event.preventDefault();
event.stopPropagation();

const dataPoint: DotPlotDataGroup | undefined = this.options.dataPoints[index];
const emptySelection = {
"measures": [],
"dataMap": {
}
};

this.selectionManager.showContextMenu(dataPoint ? dataPoint.identity : emptySelection, {
x: event.clientX,
y: event.clientY
});
});

this.options.clearCatcher.on("contextmenu", (event: MouseEvent) => {
event.preventDefault();
const emptySelection = {
Expand Down
39 changes: 29 additions & 10 deletions src/visual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ import VisualConstructorOptions = powerbi.extensibility.visual.VisualConstructor
import VisualUpdateOptions = powerbi.extensibility.visual.VisualUpdateOptions;

// d3
import { Axis as d3Axis } from "d3-axis";
import { Selection as d3Selection, select as d3Select } from "d3-selection";
import {
ScaleLogarithmic as d3LogScale,
Expand Down Expand Up @@ -120,6 +119,7 @@ export class DotPlot implements IVisual {
private static AxisSelector: ClassAndSelector = createClassAndSelector("axisGraphicsContext");
private static XAxisSelector: ClassAndSelector = createClassAndSelector("x axis");
private static CircleSelector: ClassAndSelector = createClassAndSelector("circleSelector");
private static TickSelector: ClassAndSelector = createClassAndSelector("tick");
private static TickTextSelector: ClassAndSelector = createClassAndSelector("tick text");
private static XAxisLabelSelector: ClassAndSelector = createClassAndSelector("xAxisLabel");

Expand Down Expand Up @@ -547,6 +547,7 @@ export class DotPlot implements IVisual {

const behaviorOptions: DotplotBehaviorOptions = {
columns: dotGroupSelection,
xAxisTicks: this.xAxisSelection.selectAll<SVGGElement, number>(`g${DotPlot.TickSelector.selectorName}`),
clearCatcher: this.clearCatcher,
isHighContrastMode: this.colorHelper.isHighContrast,
dataPoints: this.data.dataGroups,
Expand Down Expand Up @@ -597,6 +598,8 @@ export class DotPlot implements IVisual {
.style("font-style", this.formattingSettings.labels.font.italic.value ? "italic" : "normal")
.style("font-weight", this.formattingSettings.labels.font.bold.value ? "bold" : "normal")
.style("text-decoration", this.formattingSettings.labels.font.underline.value ? "underline" : "none");

this.removeLabelsOverlappingDots(labels);
}
}
else {
Expand Down Expand Up @@ -714,6 +717,25 @@ export class DotPlot implements IVisual {
};
}

private removeLabelsOverlappingDots(labels: d3Selection<SVGTextElement, DotPlotDataGroup, SVGGElement, unknown>): void {
// Dots in a column share one x-range, so a single rect per column stands in for all its dots.
const dotRects: DOMRect[] = this.dotPlot

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getBoundingClientRect() is called once per circle, and every label is then tested against every
dot. With diameter = 2 * radius + 1 and maxDots = floor(dotsTotalHeight / diameter), the circle
count is roughly categories * viewportHeight / (2 * radius + 1) - at radius 1 and many categories
that is thousands of layout reads plus an O(labels x dots) scan on every update().

Dots in a column share the same x-range and form a single vertical stack, so one rect per column
group is usually enough:

const dotRects: DOMRect[] = this.dotPlot
    .selectAll<SVGGElement, DotPlotDataGroup>(DotPlot.PlotGroupSelector.selectorName)
    .nodes()
    .map((group: SVGGElement) => group.getBoundingClientRect());

That brings it down to O(labels x categories). Caveat: the group box also covers the ~1px
ExtraDiameter gaps between stacked dots, so the check becomes marginally stricter.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - swapped the per-circle rects for one rect per column group, exactly as you sketched.

I kept it testing every label against every group rect rather than only the label's own column. I did try the narrower version, and it does bring a couple of labels back, but those labels then sit on the neighbouring column's dots - which is the thing this function exists to prevent in the first place.

While I was in here I measured where the labels actually go, since "labels disappear even where there is room" had been pointed at this function. On the large-value case (10 categories, radius 15, no display units, precision 5):

  • 10 labels get laid out
  • chartutils' own hideCollidedLabels (label vs label) takes it down to 5
  • this function (label vs dot) takes it from 5 to 3

So most of the culling does not happen here at all - it is the label-to-label pass inside chartutils. Worth knowing before anyone tries to make this function less eager, because that alone cannot get past 5.

I also tried capping the label text to the column pitch so labels stop reaching into their neighbours. It genuinely works on paper: all 10 labels come back and every overlap check stays clean. But the rendered result is $9... $8... $7... across the board, every value truncated to its first digit. For a value label that is worse than showing fewer of them, so I backed it out. Ellipsising is the right answer for the category axis, which already does it, and the wrong answer for the numbers.

.selectAll<SVGGElement, DotPlotDataGroup>(DotPlot.PlotGroupSelector.selectorName)
.nodes()
.map((group: SVGGElement) => group.getBoundingClientRect());

const overlappingLabels: SVGTextElement[] = labels.nodes().filter((label: SVGTextElement) => {
const labelRect: DOMRect = label.getBoundingClientRect();

return dotRects.some((dotRect: DOMRect) => labelRect.left < dotRect.right
&& labelRect.right > dotRect.left
&& labelRect.top < dotRect.bottom
&& labelRect.bottom > dotRect.top);
});

overlappingLabels.forEach((label: SVGTextElement) => label.remove());
}

private clear(): void {
this.dotPlot
.selectAll("*")
Expand Down Expand Up @@ -810,10 +832,8 @@ export class DotPlot implements IVisual {
this.data.maxLabelWidth / DotPlot.MiddleLabelWidth,
height));

const xAxis: d3Axis<any> = this.xAxisProperties.axis.tickFormat(function (d) { return d.x; });

this.xAxisSelection
.call(xAxis)
.call(this.xAxisProperties.axis)
.selectAll(`g${DotPlot.TickTextSelector.selectorName}`)
.style("fill", this.formattingSettings.categoryAxis.labelColor.value.value);

Expand All @@ -824,13 +844,12 @@ export class DotPlot implements IVisual {
.style("stroke", this.formattingSettings.categoryAxis.labelColor.value.value);
}

this.xAxisSelection
.selectAll(`${DotPlot.TickTextSelector.selectorName} title`)
.remove();

// A hidden axis renders empty tick text, which has no geometry to hover and no a11y presence.
if (this.formattingSettings.categoryAxis.show.value) {
this.xAxisSelection.selectAll(DotPlot.TickTextSelector.selectorName)
.text((index: number) => {
return this.data.dataGroups[index]
&& this.data.dataGroups[index].category.value;
});
} else {
this.xAxisSelection.selectAll(DotPlot.TickTextSelector.selectorName)
.append("title")
.text((index: number) => {
Expand Down
39 changes: 39 additions & 0 deletions test/visualData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,45 @@ export class DotPlotData extends TestDataViewBuilder {
"Miss Emmaline"
];

public static LargeValueCategories: string[] = [
"Canada",
"Ireland",
"Netherlands",
"United States",
"Germany",
"Denmark",
"Switzerland",
"Australia",
"New Zealand",
"Singapore"
];

public static LargeValues: number[] = [
97950,
91360,
82150,
75650,
62440,
59070,
54610,
54460,
24910,
17520
];

public static UnevenStackValues: number[] = [
99000,
10,
10,
10,
99000,
10,
10,
10,
99000,
10
];

public valuesCategory: string[] = [
"William",
"Olivia",
Expand Down
Loading
Loading