From 3a494dd004a753b8d528942984ed39e1da45fb08 Mon Sep 17 00:00:00 2001 From: Ansagan Islamgali Date: Fri, 24 Jul 2026 15:50:08 +0500 Subject: [PATCH 1/7] fix: prevent X-axis category labels from overlapping and add tests for large values --- CHANGELOG.md | 1 + src/visual.ts | 12 ++---------- test/visualData.ts | 26 ++++++++++++++++++++++++ test/visualTest.ts | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1f096d..91fc25c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## 2.1.3.0 +* Fixed X-axis category labels overlapping after the visual is resized * 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) * Run Karma tests with `--no-sandbox` so the build passes on current Ubuntu runners diff --git a/src/visual.ts b/src/visual.ts index 4e69ca8..e722c05 100644 --- a/src/visual.ts +++ b/src/visual.ts @@ -810,10 +810,8 @@ export class DotPlot implements IVisual { this.data.maxLabelWidth / DotPlot.MiddleLabelWidth, height)); - const xAxis: d3Axis = 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); @@ -824,13 +822,7 @@ export class DotPlot implements IVisual { .style("stroke", this.formattingSettings.categoryAxis.labelColor.value.value); } - 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 { + if (!this.formattingSettings.categoryAxis.show.value) { this.xAxisSelection.selectAll(DotPlot.TickTextSelector.selectorName) .append("title") .text((index: number) => { diff --git a/test/visualData.ts b/test/visualData.ts index a244445..74a7278 100644 --- a/test/visualData.ts +++ b/test/visualData.ts @@ -47,6 +47,32 @@ 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 valuesCategory: string[] = [ "William", "Olivia", diff --git a/test/visualTest.ts b/test/visualTest.ts index d51c48f..6b16765 100644 --- a/test/visualTest.ts +++ b/test/visualTest.ts @@ -81,6 +81,24 @@ describe("DotPlot", () => { }); }); + it("xAxis tick labels do not overlap in a reduced viewport", () => { + visualBuilder = new DotPlotBuilder(300, 250); + defaultDataViewBuilder.valuesCategory = DotPlotData.ValuesCategoryLongNames; + dataView = defaultDataViewBuilder.getDataView(); + + visualBuilder.updateFlushAllD3Transitions(dataView); + + const tickRects: DOMRect[] = visualBuilder.xAxisTickText + .map((element: SVGTextElement) => element.getBoundingClientRect()) + .filter((rect: DOMRect) => rect.width > 0) + .sort((left: DOMRect, right: DOMRect) => left.left - right.left); + + expect(tickRects.length).toBeGreaterThan(1); + tickRects.slice(1).forEach((right: DOMRect, index: number) => { + expect(tickRects[index].right).toBeLessThanOrEqual(right.left); + }); + }); + it("should correctly render duplicates in categories", done => { dataView.categorical!.categories![0].values[1] = dataView.categorical!.categories![0].values[0]; @@ -443,6 +461,37 @@ describe("DotPlot", () => { }); }); + it("saved radius-15 labels do not overlap dots with large formatted values", () => { + const geometryTolerance: number = 0.5; + visualBuilder = new DotPlotBuilder(620, 300); + defaultDataViewBuilder.valuesCategory = DotPlotData.LargeValueCategories; + defaultDataViewBuilder.valuesValue = DotPlotData.LargeValues; + dataView = defaultDataViewBuilder.getDataView(); + dataView.categorical!.values![0].source.format = "$0"; + dataView.metadata.objects = { + dataPoint: { + radius: 15 + }, + labels: { + show: true, + labelDisplayUnits: 1000, + labelPrecision: 2, + orientation: DotPlotLabelsOrientation.Horizontal + } + }; + + visualBuilder.updateFlushAllD3Transitions(dataView); + + const labels: SVGTextElement[] = visualBuilder.dataLabels; + expect(labels.length).toBe(DotPlotData.LargeValueCategories.length); + labels.forEach((element: SVGTextElement) => { + const datum: DotPlotDataGroup = d3Select(element).datum() as DotPlotDataGroup; + const labelRect: DOMRect = element.getBoundingClientRect(); + const groupRect: DOMRect = visualBuilder.dotGroups[datum.index].getBoundingClientRect(); + expect(labelRect.bottom).toBeLessThanOrEqual(groupRect.top + geometryTolerance); + }); + }); + const orientations: DotPlotLabelsOrientation[] = [DotPlotLabelsOrientation.Horizontal, DotPlotLabelsOrientation.Vertical]; const radii: number[] = [1, 5, 10, 15]; const fontSize: number[] = [8, 12, 16]; From e793e3e2bf8716d70095894e9e9195b22941afd2 Mon Sep 17 00:00:00 2001 From: Ansagan Islamgali Date: Fri, 24 Jul 2026 15:51:05 +0500 Subject: [PATCH 2/7] fix: add context menu support for X-axis ticks and update tests --- CHANGELOG.md | 1 + src/behavior.ts | 10 ++++++++++ src/visual.ts | 1 + test/visualTest.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 38 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91fc25c..d754794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## 2.1.3.0 * Fixed X-axis category labels overlapping after the visual is resized +* Fixed the browser context menu appearing when right-clicking X-axis categories * 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) * Run Karma tests with `--no-sandbox` so the build passes on current Ubuntu runners diff --git a/src/behavior.ts b/src/behavior.ts index 0f68817..18a9344 100644 --- a/src/behavior.ts +++ b/src/behavior.ts @@ -48,6 +48,7 @@ export interface SelectableDataPoint extends BaseDataPoint { export interface DotplotBehaviorOptions { dataPoints: DotPlotDataGroup[]; columns: d3Selection; + xAxisTicks: d3Selection; clearCatcher: d3Selection; isHighContrastMode: boolean; hasHighlights: boolean; @@ -105,6 +106,15 @@ export class DotplotBehavior { }); }); + this.options.xAxisTicks.on("contextmenu", (event: MouseEvent, index: number) => { + event.preventDefault(); + event.stopPropagation(); + this.selectionManager.showContextMenu(this.options.dataPoints[index].identity, { + x: event.clientX, + y: event.clientY + }); + }); + this.options.clearCatcher.on("contextmenu", (event: MouseEvent) => { event.preventDefault(); const emptySelection = { diff --git a/src/visual.ts b/src/visual.ts index e722c05..c76f90f 100644 --- a/src/visual.ts +++ b/src/visual.ts @@ -547,6 +547,7 @@ export class DotPlot implements IVisual { const behaviorOptions: DotplotBehaviorOptions = { columns: dotGroupSelection, + xAxisTicks: this.xAxisSelection.selectAll("g.tick"), clearCatcher: this.clearCatcher, isHighContrastMode: this.colorHelper.isHighContrast, dataPoints: this.data.dataGroups, diff --git a/test/visualTest.ts b/test/visualTest.ts index 6b16765..0b77b5a 100644 --- a/test/visualTest.ts +++ b/test/visualTest.ts @@ -99,6 +99,32 @@ describe("DotPlot", () => { }); }); + it("xAxis tick opens the Power BI context menu", () => { + visualBuilder = new DotPlotBuilder(300, 250); + const selectionManager = visualBuilder.visualHost.createSelectionManager(); + const showContextMenuSpy = spyOn(selectionManager, "showContextMenu").and.callThrough(); + const bubbledContextMenuSpy = jasmine.createSpy("bubbledContextMenu"); + visualBuilder.element.addEventListener("contextmenu", bubbledContextMenuSpy); + visualBuilder.updateFlushAllD3Transitions(dataView); + + const tick: SVGGElement = visualBuilder.xAxisTicks[1]; + const dataGroupIndex: number = d3Select(tick).datum() as number; + const expectedIdentity = (d3Select(visualBuilder.dotGroups[dataGroupIndex]).datum() as DotPlotDataGroup).identity; + const event = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 25, + clientY: 50 + }); + + const dispatchResult: boolean = tick.dispatchEvent(event); + + expect(showContextMenuSpy).toHaveBeenCalledOnceWith(expectedIdentity, { x: 25, y: 50 }); + expect(event.defaultPrevented).toBeTrue(); + expect(dispatchResult).toBeFalse(); + expect(bubbledContextMenuSpy).not.toHaveBeenCalled(); + }); + it("should correctly render duplicates in categories", done => { dataView.categorical!.categories![0].values[1] = dataView.categorical!.categories![0].values[0]; From 12ab4c0b6fb8e19e591723baef3602a2dbd90110 Mon Sep 17 00:00:00 2001 From: Ansagan Islamgali Date: Tue, 28 Jul 2026 10:41:32 +0500 Subject: [PATCH 3/7] fix: prevent overlapping labels and dots in DotPlot visualization --- src/visual.ts | 21 +++++++++++++++++++++ test/visualTest.ts | 33 ++++++++++++++++++++++++++------- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/visual.ts b/src/visual.ts index c76f90f..94ff4d1 100644 --- a/src/visual.ts +++ b/src/visual.ts @@ -598,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 { @@ -715,6 +717,25 @@ export class DotPlot implements IVisual { }; } + private removeLabelsOverlappingDots(labels: d3Selection): void { + const dotRects: DOMRect[] = this.dotPlot + .selectAll("circle") + .nodes() + .map((dot: SVGCircleElement) => dot.getBoundingClientRect()); + + labels.nodes().forEach((label: SVGTextElement) => { + const labelRect: DOMRect = label.getBoundingClientRect(); + const overlapsDot: boolean = dotRects.some((dotRect: DOMRect) => labelRect.left < dotRect.right + && labelRect.right > dotRect.left + && labelRect.top < dotRect.bottom + && labelRect.bottom > dotRect.top); + + if (overlapsDot) { + label.remove(); + } + }); + } + private clear(): void { this.dotPlot .selectAll("*") diff --git a/test/visualTest.ts b/test/visualTest.ts index 0b77b5a..9e91ab3 100644 --- a/test/visualTest.ts +++ b/test/visualTest.ts @@ -487,7 +487,7 @@ describe("DotPlot", () => { }); }); - it("saved radius-15 labels do not overlap dots with large formatted values", () => { + it("saved radius-15 layout does not overlap labels, dots, or X-axis text", () => { const geometryTolerance: number = 0.5; visualBuilder = new DotPlotBuilder(620, 300); defaultDataViewBuilder.valuesCategory = DotPlotData.LargeValueCategories; @@ -500,8 +500,9 @@ describe("DotPlot", () => { }, labels: { show: true, - labelDisplayUnits: 1000, - labelPrecision: 2, + fontSize: 15, + labelDisplayUnits: 1, + labelPrecision: 5, orientation: DotPlotLabelsOrientation.Horizontal } }; @@ -509,12 +510,30 @@ describe("DotPlot", () => { visualBuilder.updateFlushAllD3Transitions(dataView); const labels: SVGTextElement[] = visualBuilder.dataLabels; - expect(labels.length).toBe(DotPlotData.LargeValueCategories.length); + const dots: SVGCircleElement[] = Array.from(visualBuilder.dotGroups) + .flatMap((group: SVGGElement) => Array.from(group.querySelectorAll("circle"))); + expect(labels.length).toBeGreaterThan(0); + expect(dots.length).toBeGreaterThan(0); labels.forEach((element: SVGTextElement) => { - const datum: DotPlotDataGroup = d3Select(element).datum() as DotPlotDataGroup; const labelRect: DOMRect = element.getBoundingClientRect(); - const groupRect: DOMRect = visualBuilder.dotGroups[datum.index].getBoundingClientRect(); - expect(labelRect.bottom).toBeLessThanOrEqual(groupRect.top + geometryTolerance); + dots.forEach((dot: SVGCircleElement) => { + const dotRect: DOMRect = dot.getBoundingClientRect(); + const overlaps: boolean = labelRect.left < dotRect.right - geometryTolerance + && labelRect.right > dotRect.left + geometryTolerance + && labelRect.top < dotRect.bottom - geometryTolerance + && labelRect.bottom > dotRect.top + geometryTolerance; + expect(overlaps).toBeFalse(); + }); + }); + + const tickRects: DOMRect[] = visualBuilder.xAxisTickText + .map((element: SVGTextElement) => element.getBoundingClientRect()) + .filter((rect: DOMRect) => rect.width > 0) + .sort((left: DOMRect, right: DOMRect) => left.left - right.left); + + expect(tickRects.length).toBeGreaterThan(1); + tickRects.slice(1).forEach((right: DOMRect, index: number) => { + expect(tickRects[index].right).toBeLessThanOrEqual(right.left + geometryTolerance); }); }); From d3a496075c60a06b0b0d24b3977dd77a58a79f58 Mon Sep 17 00:00:00 2001 From: Ansagan Islamgali Date: Tue, 28 Jul 2026 16:37:25 +0500 Subject: [PATCH 4/7] fix: update version to 2.1.4.0 and address overlapping data labels and context menu issues --- CHANGELOG.md | 5 ++++- package-lock.json | 4 ++-- package.json | 2 +- pbiviz.json | 2 +- src/behavior.ts | 8 +++++++- src/visual.ts | 4 +++- test/visualTest.ts | 25 +++++++++++++++++++++---- 7 files changed, 39 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d754794..2500bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ -## 2.1.3.0 +## 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 + +## 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) * Run Karma tests with `--no-sandbox` so the build passes on current Ubuntu runners diff --git a/package-lock.json b/package-lock.json index 2ddda9c..94a6a51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@microsoft/powerbi-visuals-dotplot", - "version": "2.1.3.0", + "version": "2.1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@microsoft/powerbi-visuals-dotplot", - "version": "2.1.3.0", + "version": "2.1.4.0", "license": "MIT", "dependencies": { "d3-axis": "^3.0.0", diff --git a/package.json b/package.json index 5f94d90..37adbbb 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/pbiviz.json b/pbiviz.json index d92bb76..4bf1619 100644 --- a/pbiviz.json +++ b/pbiviz.json @@ -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" diff --git a/src/behavior.ts b/src/behavior.ts index 18a9344..42bd700 100644 --- a/src/behavior.ts +++ b/src/behavior.ts @@ -109,7 +109,13 @@ export class DotplotBehavior { this.options.xAxisTicks.on("contextmenu", (event: MouseEvent, index: number) => { event.preventDefault(); event.stopPropagation(); - this.selectionManager.showContextMenu(this.options.dataPoints[index].identity, { + + const dataPoint: DotPlotDataGroup | undefined = this.options.dataPoints[index]; + if (!dataPoint) { + return; + } + + this.selectionManager.showContextMenu(dataPoint.identity, { x: event.clientX, y: event.clientY }); diff --git a/src/visual.ts b/src/visual.ts index 94ff4d1..8a5a77a 100644 --- a/src/visual.ts +++ b/src/visual.ts @@ -846,7 +846,9 @@ export class DotPlot implements IVisual { if (!this.formattingSettings.categoryAxis.show.value) { this.xAxisSelection.selectAll(DotPlot.TickTextSelector.selectorName) - .append("title") + .selectAll("title") + .data((index: number) => [index]) + .join("title") .text((index: number) => { return this.data.dataGroups[index] && this.data.dataGroups[index].category.value; diff --git a/test/visualTest.ts b/test/visualTest.ts index 9e91ab3..d5427e0 100644 --- a/test/visualTest.ts +++ b/test/visualTest.ts @@ -82,6 +82,7 @@ describe("DotPlot", () => { }); it("xAxis tick labels do not overlap in a reduced viewport", () => { + const geometryTolerance: number = 0.5; visualBuilder = new DotPlotBuilder(300, 250); defaultDataViewBuilder.valuesCategory = DotPlotData.ValuesCategoryLongNames; dataView = defaultDataViewBuilder.getDataView(); @@ -95,7 +96,7 @@ describe("DotPlot", () => { expect(tickRects.length).toBeGreaterThan(1); tickRects.slice(1).forEach((right: DOMRect, index: number) => { - expect(tickRects[index].right).toBeLessThanOrEqual(right.left); + expect(tickRects[index].right).toBeLessThanOrEqual(right.left + geometryTolerance); }); }); @@ -125,6 +126,21 @@ describe("DotPlot", () => { expect(bubbledContextMenuSpy).not.toHaveBeenCalled(); }); + it("xAxis tick ignores context menu events for invalid category indices", () => { + const selectionManager = visualBuilder.visualHost.createSelectionManager(); + const showContextMenuSpy = spyOn(selectionManager, "showContextMenu").and.callThrough(); + visualBuilder.updateFlushAllD3Transitions(dataView); + + const tick: SVGGElement = visualBuilder.xAxisTicks[0]; + d3Select(tick).datum(visualBuilder.dotGroups.length); + const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + + tick.dispatchEvent(event); + + expect(showContextMenuSpy).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBeTrue(); + }); + it("should correctly render duplicates in categories", done => { dataView.categorical!.categories![0].values[1] = dataView.categorical!.categories![0].values[0]; @@ -330,6 +346,7 @@ describe("DotPlot", () => { (dataView.metadata.objects as any).categoryAxis.show = false; + visualBuilder.updateFlushAllD3Transitions(dataView); visualBuilder.updateFlushAllD3Transitions(dataView); visualBuilder.xAxisTicks @@ -341,9 +358,9 @@ describe("DotPlot", () => { visualBuilder.xAxisTicks .map(e => e.querySelector("text")!) .forEach(e => { - const title = e.querySelector("title"); - expect(title).toBeDefined(); - expect(title!.textContent).toBeTruthy(); + const titles = e.querySelectorAll("title"); + expect(titles.length).toBe(1); + expect(titles[0].textContent).toBeTruthy(); }); }); From ec8df6bede57aa154bedfad133736580e4fe4195 Mon Sep 17 00:00:00 2001 From: Ansagan Islamgali Date: Tue, 28 Jul 2026 16:53:31 +0500 Subject: [PATCH 5/7] fix: improve label overlap detection by grouping dots and checking nearby dots --- src/visual.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/visual.ts b/src/visual.ts index 8a5a77a..dbf31d9 100644 --- a/src/visual.ts +++ b/src/visual.ts @@ -718,14 +718,25 @@ export class DotPlot implements IVisual { } private removeLabelsOverlappingDots(labels: d3Selection): void { - const dotRects: DOMRect[] = this.dotPlot - .selectAll("circle") + const dotRectsByGroup: Map = new Map(); + + this.dotPlot + .selectAll(DotPlot.PlotGroupSelector.selectorName) .nodes() - .map((dot: SVGCircleElement) => dot.getBoundingClientRect()); + .forEach((group: SVGGElement) => { + const dataGroup: DotPlotDataGroup = d3Select(group).datum(); + const dotRects: DOMRect[] = Array.from(group.querySelectorAll("circle")) + .map((dot: SVGCircleElement) => dot.getBoundingClientRect()); + + dotRectsByGroup.set(dataGroup.index, dotRects); + }); labels.nodes().forEach((label: SVGTextElement) => { + const dataGroup: DotPlotDataGroup = d3Select(label).datum(); const labelRect: DOMRect = label.getBoundingClientRect(); - const overlapsDot: boolean = dotRects.some((dotRect: DOMRect) => labelRect.left < dotRect.right + const nearbyDotRects: DOMRect[] = [dataGroup.index - 1, dataGroup.index, dataGroup.index + 1] + .flatMap((index: number) => dotRectsByGroup.get(index) || []); + const overlapsDot: boolean = nearbyDotRects.some((dotRect: DOMRect) => labelRect.left < dotRect.right && labelRect.right > dotRect.left && labelRect.top < dotRect.bottom && labelRect.bottom > dotRect.top); From f3f4e5387213553ed30b1e6ebe7727637f46f439 Mon Sep 17 00:00:00 2001 From: Ansagan Islamgali Date: Wed, 29 Jul 2026 12:53:31 +0500 Subject: [PATCH 6/7] fix: add tooltip for truncated X-axis labels and improve label overlap handling --- CHANGELOG.md | 1 + src/visual.ts | 48 ++++++++++++++---------------------------- test/visualData.ts | 13 ++++++++++++ test/visualTest.ts | 52 ++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 78 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2500bc4..1e51b56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ * 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 diff --git a/src/visual.ts b/src/visual.ts index dbf31d9..038c69f 100644 --- a/src/visual.ts +++ b/src/visual.ts @@ -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, @@ -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"); @@ -547,7 +547,7 @@ export class DotPlot implements IVisual { const behaviorOptions: DotplotBehaviorOptions = { columns: dotGroupSelection, - xAxisTicks: this.xAxisSelection.selectAll("g.tick"), + xAxisTicks: this.xAxisSelection.selectAll(`g${DotPlot.TickSelector.selectorName}`), clearCatcher: this.clearCatcher, isHighContrastMode: this.colorHelper.isHighContrast, dataPoints: this.data.dataGroups, @@ -718,33 +718,21 @@ export class DotPlot implements IVisual { } private removeLabelsOverlappingDots(labels: d3Selection): void { - const dotRectsByGroup: Map = new Map(); - - this.dotPlot - .selectAll(DotPlot.PlotGroupSelector.selectorName) + const dotRects: DOMRect[] = this.dotPlot + .selectAll(DotPlot.CircleSelector.selectorName) .nodes() - .forEach((group: SVGGElement) => { - const dataGroup: DotPlotDataGroup = d3Select(group).datum(); - const dotRects: DOMRect[] = Array.from(group.querySelectorAll("circle")) - .map((dot: SVGCircleElement) => dot.getBoundingClientRect()); - - dotRectsByGroup.set(dataGroup.index, dotRects); - }); + .map((dot: SVGCircleElement) => dot.getBoundingClientRect()); - labels.nodes().forEach((label: SVGTextElement) => { - const dataGroup: DotPlotDataGroup = d3Select(label).datum(); + const overlappingLabels: SVGTextElement[] = labels.nodes().filter((label: SVGTextElement) => { const labelRect: DOMRect = label.getBoundingClientRect(); - const nearbyDotRects: DOMRect[] = [dataGroup.index - 1, dataGroup.index, dataGroup.index + 1] - .flatMap((index: number) => dotRectsByGroup.get(index) || []); - const overlapsDot: boolean = nearbyDotRects.some((dotRect: DOMRect) => labelRect.left < dotRect.right + + return dotRects.some((dotRect: DOMRect) => labelRect.left < dotRect.right && labelRect.right > dotRect.left && labelRect.top < dotRect.bottom && labelRect.bottom > dotRect.top); - - if (overlapsDot) { - label.remove(); - } }); + + overlappingLabels.forEach((label: SVGTextElement) => label.remove()); } private clear(): void { @@ -855,16 +843,12 @@ export class DotPlot implements IVisual { .style("stroke", this.formattingSettings.categoryAxis.labelColor.value.value); } - if (!this.formattingSettings.categoryAxis.show.value) { - this.xAxisSelection.selectAll(DotPlot.TickTextSelector.selectorName) - .selectAll("title") - .data((index: number) => [index]) - .join("title") - .text((index: number) => { - return this.data.dataGroups[index] - && this.data.dataGroups[index].category.value; - }); - } + this.xAxisSelection.selectAll(DotPlot.TickTextSelector.selectorName) + .append("title") + .text((index: number) => { + return this.data.dataGroups[index] + && this.data.dataGroups[index].category.value; + }); this.xAxisSelection .selectAll("line") diff --git a/test/visualData.ts b/test/visualData.ts index 74a7278..d791c91 100644 --- a/test/visualData.ts +++ b/test/visualData.ts @@ -73,6 +73,19 @@ export class DotPlotData extends TestDataViewBuilder { 17520 ]; + public static UnevenStackValues: number[] = [ + 99000, + 10, + 10, + 10, + 99000, + 10, + 10, + 10, + 99000, + 10 + ]; + public valuesCategory: string[] = [ "William", "Olivia", diff --git a/test/visualTest.ts b/test/visualTest.ts index d5427e0..200115d 100644 --- a/test/visualTest.ts +++ b/test/visualTest.ts @@ -316,6 +316,11 @@ describe("DotPlot", () => { describe("Format settings test", () => { describe("X-axis", () => { + const getTickLabelText = (element: SVGTextElement): string => Array.from(element.childNodes) + .filter((node: ChildNode) => node.nodeType === Node.TEXT_NODE) + .map((node: ChildNode) => node.textContent) + .join(""); + beforeEach(() => { dataView.metadata.objects = { categoryAxis: { @@ -339,14 +344,14 @@ describe("DotPlot", () => { visualBuilder.xAxisTicks .map(e => e.querySelector("text")!) .forEach((e: SVGTextElement) => { - expect(e.children.length).toBe(0); - expect(e.tagName).not.toBe("title"); - expect(e.textContent!).toBeTruthy(); + const titles = e.querySelectorAll("title"); + expect(titles.length).toBe(1); + expect(titles[0].textContent).toBeTruthy(); + expect(getTickLabelText(e)).toBeTruthy(); }); (dataView.metadata.objects as any).categoryAxis.show = false; - visualBuilder.updateFlushAllD3Transitions(dataView); visualBuilder.updateFlushAllD3Transitions(dataView); visualBuilder.xAxisTicks @@ -361,6 +366,7 @@ describe("DotPlot", () => { const titles = e.querySelectorAll("title"); expect(titles.length).toBe(1); expect(titles[0].textContent).toBeTruthy(); + expect(getTickLabelText(e)).toBe(""); }); }); @@ -554,6 +560,44 @@ describe("DotPlot", () => { }); }); + it("wide labels do not overlap dots when stack heights are uneven", () => { + const geometryTolerance: number = 0.5; + visualBuilder = new DotPlotBuilder(400, 300); + defaultDataViewBuilder.valuesValue = DotPlotData.UnevenStackValues; + dataView = defaultDataViewBuilder.getDataView(); + dataView.metadata.objects = { + dataPoint: { + radius: 15 + }, + labels: { + show: true, + fontSize: 15, + labelDisplayUnits: 1, + labelPrecision: 5, + orientation: DotPlotLabelsOrientation.Horizontal + } + }; + + visualBuilder.updateFlushAllD3Transitions(dataView); + + const labels: SVGTextElement[] = visualBuilder.dataLabels; + const dots: SVGCircleElement[] = Array.from(visualBuilder.dotGroups) + .flatMap((group: SVGGElement) => Array.from(group.querySelectorAll("circle"))); + expect(labels.length).toBeGreaterThan(1); + expect(dots.length).toBeGreaterThan(0); + labels.forEach((element: SVGTextElement) => { + const labelRect: DOMRect = element.getBoundingClientRect(); + dots.forEach((dot: SVGCircleElement) => { + const dotRect: DOMRect = dot.getBoundingClientRect(); + const overlaps: boolean = labelRect.left < dotRect.right - geometryTolerance + && labelRect.right > dotRect.left + geometryTolerance + && labelRect.top < dotRect.bottom - geometryTolerance + && labelRect.bottom > dotRect.top + geometryTolerance; + expect(overlaps).toBeFalse(); + }); + }); + }); + const orientations: DotPlotLabelsOrientation[] = [DotPlotLabelsOrientation.Horizontal, DotPlotLabelsOrientation.Vertical]; const radii: number[] = [1, 5, 10, 15]; const fontSize: number[] = [8, 12, 16]; From 6072d07bc990be07b89d036c929d3e09399c18bb Mon Sep 17 00:00:00 2001 From: Ansagan Islamgali Date: Thu, 13 Aug 2026 19:17:46 +0500 Subject: [PATCH 7/7] fix: update context menu handling for invalid category indices and improve label rendering logic --- src/behavior.ts | 10 ++++++---- src/visual.ts | 24 ++++++++++++++++-------- test/visualTest.ts | 14 +++++++------- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src/behavior.ts b/src/behavior.ts index 42bd700..f75dab1 100644 --- a/src/behavior.ts +++ b/src/behavior.ts @@ -111,11 +111,13 @@ export class DotplotBehavior { event.stopPropagation(); const dataPoint: DotPlotDataGroup | undefined = this.options.dataPoints[index]; - if (!dataPoint) { - return; - } + const emptySelection = { + "measures": [], + "dataMap": { + } + }; - this.selectionManager.showContextMenu(dataPoint.identity, { + this.selectionManager.showContextMenu(dataPoint ? dataPoint.identity : emptySelection, { x: event.clientX, y: event.clientY }); diff --git a/src/visual.ts b/src/visual.ts index 038c69f..d928e41 100644 --- a/src/visual.ts +++ b/src/visual.ts @@ -718,10 +718,11 @@ export class DotPlot implements IVisual { } private removeLabelsOverlappingDots(labels: d3Selection): 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 - .selectAll(DotPlot.CircleSelector.selectorName) + .selectAll(DotPlot.PlotGroupSelector.selectorName) .nodes() - .map((dot: SVGCircleElement) => dot.getBoundingClientRect()); + .map((group: SVGGElement) => group.getBoundingClientRect()); const overlappingLabels: SVGTextElement[] = labels.nodes().filter((label: SVGTextElement) => { const labelRect: DOMRect = label.getBoundingClientRect(); @@ -843,12 +844,19 @@ export class DotPlot implements IVisual { .style("stroke", this.formattingSettings.categoryAxis.labelColor.value.value); } - this.xAxisSelection.selectAll(DotPlot.TickTextSelector.selectorName) - .append("title") - .text((index: number) => { - return this.data.dataGroups[index] - && this.data.dataGroups[index].category.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) + .append("title") + .text((index: number) => { + return this.data.dataGroups[index] + && this.data.dataGroups[index].category.value; + }); + } this.xAxisSelection .selectAll("line") diff --git a/test/visualTest.ts b/test/visualTest.ts index 200115d..3f2a426 100644 --- a/test/visualTest.ts +++ b/test/visualTest.ts @@ -126,7 +126,7 @@ describe("DotPlot", () => { expect(bubbledContextMenuSpy).not.toHaveBeenCalled(); }); - it("xAxis tick ignores context menu events for invalid category indices", () => { + it("xAxis tick opens the empty context menu for invalid category indices", () => { const selectionManager = visualBuilder.visualHost.createSelectionManager(); const showContextMenuSpy = spyOn(selectionManager, "showContextMenu").and.callThrough(); visualBuilder.updateFlushAllD3Transitions(dataView); @@ -137,7 +137,8 @@ describe("DotPlot", () => { tick.dispatchEvent(event); - expect(showContextMenuSpy).not.toHaveBeenCalled(); + expect(showContextMenuSpy).toHaveBeenCalledTimes(1); + expect(showContextMenuSpy.calls.mostRecent().args[0]).toEqual({ measures: [], dataMap: {} }); expect(event.defaultPrevented).toBeTrue(); }); @@ -363,9 +364,7 @@ describe("DotPlot", () => { visualBuilder.xAxisTicks .map(e => e.querySelector("text")!) .forEach(e => { - const titles = e.querySelectorAll("title"); - expect(titles.length).toBe(1); - expect(titles[0].textContent).toBeTruthy(); + expect(e.querySelectorAll("title").length).toBe(0); expect(getTickLabelText(e)).toBe(""); }); }); @@ -535,7 +534,8 @@ describe("DotPlot", () => { const labels: SVGTextElement[] = visualBuilder.dataLabels; const dots: SVGCircleElement[] = Array.from(visualBuilder.dotGroups) .flatMap((group: SVGGElement) => Array.from(group.querySelectorAll("circle"))); - expect(labels.length).toBeGreaterThan(0); + expect(labels.length).toBe(3); + expect(labels.map((element: SVGTextElement) => element.textContent)).toContain("$97950.00000"); expect(dots.length).toBeGreaterThan(0); labels.forEach((element: SVGTextElement) => { const labelRect: DOMRect = element.getBoundingClientRect(); @@ -583,7 +583,7 @@ describe("DotPlot", () => { const labels: SVGTextElement[] = visualBuilder.dataLabels; const dots: SVGCircleElement[] = Array.from(visualBuilder.dotGroups) .flatMap((group: SVGGElement) => Array.from(group.querySelectorAll("circle"))); - expect(labels.length).toBeGreaterThan(1); + expect(labels.length).toBe(2); expect(dots.length).toBeGreaterThan(0); labels.forEach((element: SVGTextElement) => { const labelRect: DOMRect = element.getBoundingClientRect();