diff --git a/CHANGELOG.md b/CHANGELOG.md index b1f096d..1e51b56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) 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 0f68817..f75dab1 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,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 = { diff --git a/src/visual.ts b/src/visual.ts index 4e69ca8..d928e41 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,6 +547,7 @@ export class DotPlot implements IVisual { const behaviorOptions: DotplotBehaviorOptions = { columns: dotGroupSelection, + xAxisTicks: this.xAxisSelection.selectAll(`g${DotPlot.TickSelector.selectorName}`), clearCatcher: this.clearCatcher, isHighContrastMode: this.colorHelper.isHighContrast, dataPoints: this.data.dataGroups, @@ -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 { @@ -714,6 +717,25 @@ 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.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("*") @@ -810,10 +832,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 +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) => { diff --git a/test/visualData.ts b/test/visualData.ts index a244445..d791c91 100644 --- a/test/visualData.ts +++ b/test/visualData.ts @@ -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", diff --git a/test/visualTest.ts b/test/visualTest.ts index d51c48f..3f2a426 100644 --- a/test/visualTest.ts +++ b/test/visualTest.ts @@ -81,6 +81,67 @@ 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(); + + 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 + geometryTolerance); + }); + }); + + 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("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); + + 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).toHaveBeenCalledTimes(1); + expect(showContextMenuSpy.calls.mostRecent().args[0]).toEqual({ measures: [], dataMap: {} }); + expect(event.defaultPrevented).toBeTrue(); + }); + it("should correctly render duplicates in categories", done => { dataView.categorical!.categories![0].values[1] = dataView.categorical!.categories![0].values[0]; @@ -256,6 +317,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: { @@ -279,9 +345,10 @@ 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; @@ -297,9 +364,8 @@ describe("DotPlot", () => { visualBuilder.xAxisTicks .map(e => e.querySelector("text")!) .forEach(e => { - const title = e.querySelector("title"); - expect(title).toBeDefined(); - expect(title!.textContent).toBeTruthy(); + expect(e.querySelectorAll("title").length).toBe(0); + expect(getTickLabelText(e)).toBe(""); }); }); @@ -443,6 +509,95 @@ describe("DotPlot", () => { }); }); + 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; + defaultDataViewBuilder.valuesValue = DotPlotData.LargeValues; + dataView = defaultDataViewBuilder.getDataView(); + dataView.categorical!.values![0].source.format = "$0"; + 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).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(); + 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); + }); + }); + + 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).toBe(2); + 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];