diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e9be34..6993688 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,5 @@ -name: Release Build +name: release on: release: diff --git a/CHANGELOG.md b/CHANGELOG.md index da3cb18..9400a9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## Changelog +## 8.3.0 + +### Module `legend` +* Added `LegendPosition.TopRight` and `LegendPosition.BottomRight` — horizontal legend with items right-aligned to the chart area; falls back to left-aligned with a navigation arrow on overflow. +* Added matching `legendPosition` string constants `topRight` and `bottomRight`. +* Exported orientation helpers: `isLeft`, `isRight`, `isTop`, `isBottom`, `isTopOrBottom`, `isCentered`, `isRightAligned`. `isTop`/`isBottom` now also match the new right-aligned variants. +* Fixed clipping of vertical centered legends (`LeftCenter`/`RightCenter`) when items overflowed (missing `Math.max(0, ...)` clamp). + ## 8.2.1 * Updated packages diff --git a/package-lock.json b/package-lock.json index 2be57d0..4c450b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "powerbi-visuals-utils-chartutils", - "version": "8.2.1", + "version": "8.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "powerbi-visuals-utils-chartutils", - "version": "8.2.1", + "version": "8.3.0", "license": "MIT", "dependencies": { "d3-array": "^3.2.4", @@ -96,6 +96,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz", "integrity": "sha512-qt/YV149Jman/6AfmlxJ04LMIu8bMoyl3RB91yTFrxQmgbrSvQMy7cI8Q62FHx1t8wJ8B5fu0UDoLwHAhUo1QA==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.21.4", @@ -861,6 +862,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", "dev": true, + "peer": true, "dependencies": { "undici-types": "~6.19.8" } @@ -907,6 +909,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", @@ -1400,6 +1403,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1443,6 +1447,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1902,6 +1907,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2507,6 +2513,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "peer": true, "engines": { "node": ">=12" } @@ -2942,6 +2949,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3224,9 +3232,9 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -3237,7 +3245,8 @@ "type": "opencollective", "url": "https://opencollective.com/fastify" } - ] + ], + "license": "BSD-3-Clause" }, "node_modules/file-entry-cache": { "version": "8.0.0", @@ -3341,9 +3350,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -3351,6 +3360,7 @@ "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -4346,6 +4356,7 @@ "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, + "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -6312,6 +6323,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6413,6 +6425,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6602,6 +6615,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6818,6 +6832,7 @@ "integrity": "sha512-Pkx5joZ9RrdgO5LBkyX1L2ZAJeK/Taz3vqZ9CbcP0wS5LEMx5QkKsEwLl29QJfihZ+DKRBFldzy1O30pJ1MDpA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -6886,6 +6901,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/package.json b/package.json index 187890e..427944d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "powerbi-visuals-utils-chartutils", - "version": "8.2.1", + "version": "8.3.0", "description": "ChartUtils", "main": "lib/index.js", "module": "lib/index.js", diff --git a/src/legend/legend.ts b/src/legend/legend.ts index 22eef5b..d2ab514 100644 --- a/src/legend/legend.ts +++ b/src/legend/legend.ts @@ -46,10 +46,58 @@ export function isLeft(orientation: LegendPosition): boolean { } } +export function isRight(orientation: LegendPosition): boolean { + switch (orientation) { + case LegendPosition.Right: + case LegendPosition.RightCenter: + return true; + default: + return false; + } +} + export function isTop(orientation: LegendPosition): boolean { switch (orientation) { case LegendPosition.Top: case LegendPosition.TopCenter: + case LegendPosition.TopRight: + return true; + default: + return false; + } +} + +export function isBottom(orientation: LegendPosition): boolean { + switch (orientation) { + case LegendPosition.Bottom: + case LegendPosition.BottomCenter: + case LegendPosition.BottomRight: + return true; + default: + return false; + } +} + +export function isTopOrBottom(orientation: LegendPosition): boolean { + return isTop(orientation) || isBottom(orientation); +} + +export function isCentered(orientation: LegendPosition): boolean { + switch (orientation) { + case LegendPosition.TopCenter: + case LegendPosition.BottomCenter: + case LegendPosition.LeftCenter: + case LegendPosition.RightCenter: + return true; + default: + return false; + } +} + +export function isRightAligned(orientation: LegendPosition): boolean { + switch (orientation) { + case LegendPosition.TopRight: + case LegendPosition.BottomRight: return true; default: return false; diff --git a/src/legend/legendInterfaces.ts b/src/legend/legendInterfaces.ts index 783598b..e28bb00 100644 --- a/src/legend/legendInterfaces.ts +++ b/src/legend/legendInterfaces.ts @@ -39,6 +39,8 @@ export enum LegendPosition { BottomCenter, RightCenter, LeftCenter, + TopRight, + BottomRight, } export interface ISelectableDataPoint{ diff --git a/src/legend/legendPosition.ts b/src/legend/legendPosition.ts index 250237b..fd4590f 100644 --- a/src/legend/legendPosition.ts +++ b/src/legend/legendPosition.ts @@ -32,3 +32,5 @@ export const topCenter: string = "TopCenter"; export const bottomCenter: string = "BottomCenter"; export const leftCenter: string = "LeftCenter"; export const rightCenter: string = "RightCenter"; +export const topRight: string = "TopRight"; +export const bottomRight: string = "BottomRight"; diff --git a/src/legend/svgLegend.ts b/src/legend/svgLegend.ts index 248f469..a8d9339 100644 --- a/src/legend/svgLegend.ts +++ b/src/legend/svgLegend.ts @@ -39,6 +39,7 @@ import { } from "powerbi-visuals-utils-svgutils"; import { ILegend, LegendData, LegendDataPoint, LegendPosition } from "./legendInterfaces"; +import { isBottom, isRight, isTopOrBottom, isCentered, isRightAligned } from "./legend"; import * as Markers from "./markers"; @@ -168,14 +169,11 @@ export class SVGLegend implements ILegend { "width", legendViewport.width || (orientation === LegendPosition.None ? 0 : this.parentViewport.width) ); - const isRight = orientation === LegendPosition.Right || orientation === LegendPosition.RightCenter, - isBottom = orientation === LegendPosition.Bottom || orientation === LegendPosition.BottomCenter; - this.svg.style( - "margin-left", isRight ? (this.parentViewport.width - legendViewport.width) + "px" : null + "margin-left", isRight(orientation) ? (this.parentViewport.width - legendViewport.width) + "px" : null ); this.svg.style( - "margin-top", isBottom ? (this.parentViewport.height - legendViewport.height) + "px" : null, + "margin-top", isBottom(orientation) ? (this.parentViewport.height - legendViewport.height) + "px" : null, ); } @@ -185,6 +183,8 @@ export class SVGLegend implements ILegend { case LegendPosition.Bottom: case LegendPosition.TopCenter: case LegendPosition.BottomCenter: + case LegendPosition.TopRight: + case LegendPosition.BottomRight: const pixelHeight = PixelConverter.fromPointToPixel(this.data && this.data.fontSize ? this.data.fontSize : SVGLegend.DefaultFontSizeInPt); @@ -259,7 +259,7 @@ export class SVGLegend implements ILegend { // Adding back the workaround for Legend Left/Right position for Map const mapControls = this.element.getElementsByClassName("mapControl"); - if (mapControls.length > 0 && !this.isTopOrBottom(this.orientation)) { + if (mapControls.length > 0 && !isTopOrBottom(this.orientation)) { for (let i = 0; i < mapControls.length; ++i) { const element = mapControls[i]; element.style.display = "inline-block"; @@ -274,18 +274,22 @@ export class SVGLegend implements ILegend { const group = this.group; - // transform the wrapping group if position is centered - if (this.isCentered(this.orientation)) { + // transform the wrapping group if position is centered or right-aligned + if (isCentered(this.orientation)) { let centerOffset = 0; - if (this.isTopOrBottom(this.orientation)) { + if (isTopOrBottom(this.orientation)) { centerOffset = Math.max(0, (this.parentViewport.width - this.visibleLegendWidth) / 2); group.attr("transform", svgManipulation.translate(centerOffset, 0)); } else { - centerOffset = Math.max((this.parentViewport.height - this.visibleLegendHeight) / 2); + centerOffset = Math.max(0, (this.parentViewport.height - this.visibleLegendHeight) / 2); group.attr("transform", svgManipulation.translate(0, centerOffset)); } } + else if (isRightAligned(this.orientation)) { + const rightOffset = Math.max(0, this.parentViewport.width - this.visibleLegendWidth); + group.attr("transform", svgManipulation.translate(rightOffset, 0)); + } else { group.attr("transform", null); } @@ -444,7 +448,7 @@ export class SVGLegend implements ILegend { const hasTitle = !!title; if (hasTitle) { - const isHorizontal = this.isTopOrBottom(this.orientation); + const isHorizontal = isTopOrBottom(this.orientation); const textProperties = SVGLegend.getTextProperties(title, this.data.fontSize, this.data.fontFamily); let text = title; @@ -498,7 +502,7 @@ export class SVGLegend implements ILegend { let navArrows: NavigationArrow[]; let numberOfItems: number; - if (this.isTopOrBottom(this.orientation)) { + if (isTopOrBottom(this.orientation)) { navArrows = this.isScrollable ? this.calculateHorizontalNavigationArrowsLayout(title) : []; numberOfItems = this.calculateHorizontalLayout(dataPoints, title, navArrows); } @@ -786,6 +790,23 @@ export class SVGLegend implements ILegend { } this.visibleLegendWidth = occupiedWidth; + + // When the legend is right-aligned (TopRight/BottomRight): + // - If everything fits, the group is translated to the right edge in + // drawLegendInternal and items render right-aligned. + // - If items overflow, we unconditionally fall back to left-aligned + // rendering by setting visibleLegendWidth to the full parent width so + // the translation in drawLegendInternal becomes 0. This matches the + // standard Top/Bottom overflow layout. + // When scrolling is enabled (isScrollable), updateNavigationArrowLayout + // will additionally place the "next" arrow at the right edge of the + // viewport (parentViewport.width - LegendArrowWidth); when scrolling + // is disabled, navigationArrows is empty and no arrow is rendered, but + // the left-aligned fallback still applies. + if (isRightAligned(this.orientation) && numberOfItems !== dataPointsLength) { + this.visibleLegendWidth = this.parentViewport.width; + } + this.updateNavigationArrowLayout(navigationArrows, dataPointsLength, numberOfItems); return numberOfItems; @@ -955,30 +976,6 @@ export class SVGLegend implements ILegend { .attr("transform", (d: NavigationArrow) => d.rotateTransform); } - private isTopOrBottom(orientation: LegendPosition): boolean { - switch (orientation) { - case LegendPosition.Top: - case LegendPosition.Bottom: - case LegendPosition.BottomCenter: - case LegendPosition.TopCenter: - return true; - default: - return false; - } - } - - private isCentered(orientation: LegendPosition): boolean { - switch (orientation) { - case LegendPosition.BottomCenter: - case LegendPosition.LeftCenter: - case LegendPosition.RightCenter: - case LegendPosition.TopCenter: - return true; - default: - return false; - } - } - public reset(): void { } private static getTextProperties( diff --git a/test/legendTest.ts b/test/legendTest.ts index d47d46a..0ed5b9c 100644 --- a/test/legendTest.ts +++ b/test/legendTest.ts @@ -715,6 +715,127 @@ describe("legend", () => { expect(iconY + iconHeight / 2).toBeLessThan((labelY * 2 + labelHeight) * 0.6); }); + describe("Centered vertical legend overflow (regression for Math.max(0, ...) clamp)", () => { + function parseTranslateY(transform: string | null): number { + if (!transform) { + return 0; + } + const match = /translate\(\s*[-+0-9.eE]+\s*[, ]\s*([-+0-9.eE]+)/.exec(transform); + return match ? parseFloat(match[1]) : 0; + } + + function expectGroupNotClipped(position: LegendPosition): void { + const legendData = getLotsOfLegendData(); + + legend.changeOrientation(position); + // Force overflow: tall list of items in a short vertical area. + legend.drawLegend({ dataPoints: legendData }, { height: 50, width: 200 }); + + flushAllD3Transitions(); + + const group = element.querySelector("#legendGroup"); + expect(group).not.toBeNull(); + + const translateY = parseTranslateY(group.getAttribute("transform")); + // Before the fix this value went negative (group shifted above the + // SVG, clipping the title and "previous" arrow). + expect(translateY).toBeGreaterThanOrEqual(0); + } + + it("LeftCenter with overflowing items does not translate group above 0", () => { + expectGroupNotClipped(LegendPosition.LeftCenter); + }); + + it("RightCenter with overflowing items does not translate group above 0", () => { + expectGroupNotClipped(LegendPosition.RightCenter); + }); + }); + + describe("Right-aligned horizontal legend (TopRight / BottomRight)", () => { + function parseTranslate(transform: string | null): { x: number; y: number } { + if (!transform) { + return { x: 0, y: 0 }; + } + const match = /translate\(\s*([-+0-9.eE]+)\s*[, ]\s*([-+0-9.eE]+)?/.exec(transform); + if (!match) { + return { x: 0, y: 0 }; + } + return { x: parseFloat(match[1]), y: match[2] ? parseFloat(match[2]) : 0 }; + } + + function fittingDataPoints(): LegendDataPoint[] { + return [ + { label: "A", color: "#ff0000", identity: createSelectionIdentity("a"), selected: false }, + { label: "B", color: "#00ff00", identity: createSelectionIdentity("b"), selected: false }, + ]; + } + + it("TopRight: when items fit, the legend group is translated to the right edge and no nav arrow is rendered", () => { + legend.changeOrientation(LegendPosition.TopRight); + legend.drawLegend({ dataPoints: fittingDataPoints() }, { height: 100, width: 1000 }); + + flushAllD3Transitions(); + + const group = element.querySelector("#legendGroup"); + expect(group).not.toBeNull(); + + const translate = parseTranslate(group.getAttribute("transform")); + // Items fit, so the group should be shifted right by a positive amount. + expect(translate.x).toBeGreaterThan(0); + expect(translate.y).toBe(0); + + // No overflow -> no navigation arrows. + expect(element.querySelectorAll(".navArrow").length).toBe(0); + }); + + it("BottomRight: when items fit, the legend group is translated to the right edge", () => { + legend.changeOrientation(LegendPosition.BottomRight); + legend.drawLegend({ dataPoints: fittingDataPoints() }, { height: 100, width: 1000 }); + + flushAllD3Transitions(); + + const group = element.querySelector("#legendGroup"); + const translate = parseTranslate(group.getAttribute("transform")); + expect(translate.x).toBeGreaterThan(0); + expect(element.querySelectorAll(".navArrow").length).toBe(0); + }); + + it("TopRight: when items overflow, falls back to left-aligned with a navigation arrow", () => { + const legendData = getLotsOfLegendData(); + + legend.changeOrientation(LegendPosition.TopRight); + // Narrow viewport forces overflow. + legend.drawLegend({ dataPoints: legendData }, { height: 100, width: 200 }); + + flushAllD3Transitions(); + + const group = element.querySelector("#legendGroup"); + const translate = parseTranslate(group.getAttribute("transform")); + + // Fallback: group is not translated to the right edge anymore. + expect(translate.x).toBe(0); + + // The "next" navigation arrow should be visible at the right side. + const arrows = element.querySelectorAll(".navArrow"); + expect(arrows.length).toBeGreaterThan(0); + }); + + it("BottomRight: when items overflow, falls back to left-aligned with a navigation arrow", () => { + const legendData = getLotsOfLegendData(); + + legend.changeOrientation(LegendPosition.BottomRight); + legend.drawLegend({ dataPoints: legendData }, { height: 100, width: 200 }); + + flushAllD3Transitions(); + + const group = element.querySelector("#legendGroup"); + const translate = parseTranslate(group.getAttribute("transform")); + + expect(translate.x).toBe(0); + expect(element.querySelectorAll(".navArrow").length).toBeGreaterThan(0); + }); + }); + function validateLegendDOM(expectedData: LegendDataPoint[]): void { let len = expectedData.length; let labels = element.querySelectorAll(".legendText");