From 703a88a60b67f9fd6e3580f3d348c74c764a609d Mon Sep 17 00:00:00 2001 From: PA <45755573+EquinetPaul@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:20:28 +0100 Subject: [PATCH 1/4] feat(axes): add fixed label area width option to both Y axes Adds a Label Area Sizing option (Auto / Fixed) to the Y axis and the secondary Y axis. Auto keeps the upstream behaviour of sizing the label area from the rendered labels. Fixed reserves exactly the configured pixel width (clamped to half of the viewport so the plot area cannot collapse on small tiles), so stacked visuals keep their plot areas aligned when filters change the magnitude of the displayed values. Labels wider than the reserved area are ellipsised through the existing getTailoredTextOrDefault helper. --- CHANGELOG.md | 8 +- capabilities.json | 34 ++ package-lock.json | 4 +- package.json | 2 +- pbiviz.json | 2 +- specs/yAxisFixedLabelWidth.spec.ts | 312 ++++++++++++++++++ .../descriptors/axis/yAxisDescriptor.ts | 75 ++++- src/settings/settings.ts | 8 + src/visualComponent/axes/yAxisComponent.ts | 41 ++- stringResources/en-US/resources.resjson | 4 + 10 files changed, 471 insertions(+), 19 deletions(-) create mode 100644 specs/yAxisFixedLabelWidth.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ae556a9..f4411da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,12 @@ +## 3.1.2.0 +### Features +* Added a Label Area Sizing option (Auto / Fixed) to the Y axis and the secondary Y axis to reserve a fixed pixel width for axis labels, so the plot area keeps the same position when filters change the magnitude of the displayed values + ## 3.1.1.0 ### Fixes * Fixed other lines being recolored when changing a single line's color (default palette colors are now reserved per line regardless of overrides) -* Fixed X-axis tick labels overlapping when the visual is narrowed and the axis font size is increased - the axis now redistributes to fewer, wider-spaced ticks that fit their available space, falling back to per-label truncation only when a single tick still can't fit -* Fixed visual not activating when clicking on chart lines or empty SVG canvas area +* Fixed X-axis tick labels overlapping when the visual is narrowed and the axis font size is increased - the axis now redistributes to fewer, wider-spaced ticks that fit their available space, falling back to per-label truncation only when a single tick still can't fit +* Fixed visual not activating when clicking on chart lines or empty SVG canvas area ## 3.1.0.0 ### Features diff --git a/capabilities.json b/capabilities.json index ff6db98..46ddcda 100644 --- a/capabilities.json +++ b/capabilities.json @@ -1162,6 +1162,23 @@ }, "placeHolderText": "Auto", "suppressFormatPainterCopy": true + }, + "fixedLabelWidthMode": { + "type": { + "enumeration": [ + { + "value": "auto" + }, + { + "value": "fixed" + } + ] + } + }, + "fixedLabelWidth": { + "type": { + "numeric": true + } } } }, @@ -1229,6 +1246,23 @@ }, "placeHolderText": "Auto", "suppressFormatPainterCopy": true + }, + "fixedLabelWidthMode": { + "type": { + "enumeration": [ + { + "value": "auto" + }, + { + "value": "fixed" + } + ] + } + }, + "fixedLabelWidth": { + "type": { + "numeric": true + } } } }, diff --git a/package-lock.json b/package-lock.json index b8f6198..a03ccb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@microsoft/powerbi-visuals-powerkpi", - "version": "3.1.1.0", + "version": "3.1.2.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@microsoft/powerbi-visuals-powerkpi", - "version": "3.1.1.0", + "version": "3.1.2.0", "license": "MIT", "dependencies": { "d3-array": "^3.2.4", diff --git a/package.json b/package.json index 5bb37d6..78914c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/powerbi-visuals-powerkpi", - "version": "3.1.1.0", + "version": "3.1.2.0", "private": true, "description": "A powerful KPI Indicator with multi-line chart and labels for current date, value and variances. Rich customization options, including current status labels and symbols, trend and comparison lines, multiple variances, auto-scaling, text formatting, and density controls. Highly flexible for use in large, detailed report tiles or tiny, summary dashboard tiles.", "main": "index.js", diff --git a/pbiviz.json b/pbiviz.json index 7d7b185..cc73ba2 100644 --- a/pbiviz.json +++ b/pbiviz.json @@ -4,7 +4,7 @@ "displayName": "Power KPI", "guid": "powerKPI462CE5C2666F4EC8A8BDD7E5587320A3", "visualClassName": "PowerKPI", - "version": "3.1.1.0", + "version": "3.1.2.0", "description": "A powerful KPI Indicator with multi-line chart and labels for current date, value and variances. Rich customization options, including current status labels and symbols, trend and comparison lines, multiple variances, auto-scaling, text formatting, and density controls. Highly flexible for use in large, detailed report tiles or tiny, summary dashboard tiles.", "supportUrl": "https://aka.ms/customvisualscommunity", "gitHubUrl": "https://github.com/Microsoft/PowerBI-visuals-PowerKPI" diff --git a/specs/yAxisFixedLabelWidth.spec.ts b/specs/yAxisFixedLabelWidth.spec.ts new file mode 100644 index 0000000..350a444 --- /dev/null +++ b/specs/yAxisFixedLabelWidth.spec.ts @@ -0,0 +1,312 @@ +/* + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import powerbi from "powerbi-visuals-api"; +import { select as d3Select } from "d3-selection"; +import { Selection } from "d3-selection"; + +import { testDom, createColorPalette } from "powerbi-visuals-utils-testutils"; +import { IMargin } from "powerbi-visuals-utils-svgutils"; + +import { YAxisComponent, IYAxisComponentRenderOptions } from "../src/visualComponent/axes/yAxisComponent"; +import { IDataRepresentationAxis } from "../src/dataRepresentation/dataRepresentationAxis"; +import { DataRepresentationScale } from "../src/dataRepresentation/dataRepresentationScale"; +import { DataRepresentationTypeEnum } from "../src/dataRepresentation/dataRepresentationType"; +import { Settings } from "../src/settings/settings"; +import { FixedLabelWidthMode, YAxisDescriptor } from "../src/settings/descriptors/axis/yAxisDescriptor"; +import { DisplayUnitsType } from "../src/settings/descriptors/numberDescriptorBase"; + +/** + * Builds a numeric Y axis for the [min, max] range - the kind of axis whose rendered + * label width depends directly on the magnitude of the displayed values. + */ +function buildAxis(min: number, max: number): IDataRepresentationAxis { + const scale: DataRepresentationScale = DataRepresentationScale + .create() + .domain([min, max], DataRepresentationTypeEnum.NumberType); + + return { + format: null, + max, + min, + scale, + }; +} + +/** + * Applies the Fixed label area sizing with the given pixel width to a Y-axis card. + */ +function applyFixedLabelWidth(axisSettings: YAxisDescriptor, width: number): void { + axisSettings.fixedLabelWidthMode.value = axisSettings.getNewComplexValue( + FixedLabelWidthMode.fixed, + axisSettings.fixedLabelWidthMode.items, + ); + + axisSettings.fixedLabelWidth.value = width; +} + +function renderYAxis( + viewport: powerbi.IViewport, + axis: IDataRepresentationAxis, + configureSettings?: (axisSettings: YAxisDescriptor) => void, +): { component: YAxisComponent; rootElement: Element } { + const settings: Settings = new Settings(); + + // Display units are disabled so the full-length numbers drive the measured label width + settings.yAxis.displayUnits.value = DisplayUnitsType.None; + + if (configureSettings) { + configureSettings(settings.yAxis); + } + + const rootElement: HTMLElement = testDom(viewport.height.toString(), viewport.width.toString()); + const element: Selection = d3Select(rootElement); + + const component: YAxisComponent = new YAxisComponent({ element }); + + const margin: IMargin = { top: 0, right: 0, bottom: 0, left: 0 }; + + const options: IYAxisComponentRenderOptions = { + axis, + colorPalette: createColorPalette(), + locale: "en-US", + margin, + settings: settings.yAxis, + viewport, + }; + + component.preRender(options); + component.render(options); + + return { component, rootElement }; +} + +/** + * getTailoredTextOrDefault marks truncated labels with a "..." suffix. + */ +function isTruncated(text: string): boolean { + return text.indexOf("...") !== -1; +} + +function getRenderedTickTexts(rootElement: Element): Array<{ text: string; textWidth: number }> { + // The Y-axis svg element is class-prefixed by BaseComponent (powerKpi_visualYAxis) and is the + // only rendered component under the test DOM root, so the ticks can be queried directly + const textElements: NodeListOf = rootElement.querySelectorAll(".tick text"); + + return Array.from(textElements).map((textElement: SVGTextElement) => ({ + text: textElement.textContent || "", + textWidth: textElement.getBoundingClientRect().width, + })); +} + +describe("YAxisComponent fixed label width", () => { + const viewport: powerbi.IViewport = { width: 600, height: 300 }; + + const smallMagnitudeAxis: IDataRepresentationAxis = buildAxis(0, 10); + const largeMagnitudeAxis: IDataRepresentationAxis = buildAxis(0, 8888888888); + + describe("Auto sizing (default)", () => { + it("should reserve different widths for labels of different magnitudes", () => { + const smallWidth: number = renderYAxis(viewport, smallMagnitudeAxis).component.getViewport().width; + const largeWidth: number = renderYAxis(viewport, largeMagnitudeAxis).component.getViewport().width; + + // Sanity baseline: without the feature the reserved width follows the data, + // which is exactly the movement the Fixed mode is meant to eliminate + expect(smallWidth).toBeGreaterThan(0); + expect(largeWidth).toBeGreaterThan(smallWidth); + }); + + it("should not truncate labels when there is enough space", () => { + const { rootElement } = renderYAxis(viewport, largeMagnitudeAxis); + + const ticks = getRenderedTickTexts(rootElement); + + expect(ticks.length).toBeGreaterThan(0); + + ticks.forEach((tick) => { + expect(isTruncated(tick.text)).toBe(false); + }); + }); + }); + + describe("Fixed sizing", () => { + it("should reserve the same width regardless of the label magnitude", () => { + const smallWidth: number = renderYAxis( + viewport, + smallMagnitudeAxis, + (axisSettings: YAxisDescriptor) => applyFixedLabelWidth(axisSettings, 80), + ).component.getViewport().width; + + const largeWidth: number = renderYAxis( + viewport, + largeMagnitudeAxis, + (axisSettings: YAxisDescriptor) => applyFixedLabelWidth(axisSettings, 80), + ).component.getViewport().width; + + expect(smallWidth).toBe(largeWidth); + }); + + it("should reserve exactly the configured width - a delta in the setting is a delta in the reserved width", () => { + const width80: number = renderYAxis( + viewport, + largeMagnitudeAxis, + (axisSettings: YAxisDescriptor) => applyFixedLabelWidth(axisSettings, 80), + ).component.getViewport().width; + + const width100: number = renderYAxis( + viewport, + largeMagnitudeAxis, + (axisSettings: YAxisDescriptor) => applyFixedLabelWidth(axisSettings, 100), + ).component.getViewport().width; + + expect(width100 - width80).toBe(20); + }); + + it("should not shrink the reserved width when labels are short", () => { + const autoWidth: number = renderYAxis(viewport, smallMagnitudeAxis).component.getViewport().width; + + const fixedWidth: number = renderYAxis( + viewport, + smallMagnitudeAxis, + (axisSettings: YAxisDescriptor) => applyFixedLabelWidth(axisSettings, 150), + ).component.getViewport().width; + + expect(fixedWidth).toBeGreaterThan(autoWidth); + }); + + it("should ellipsis labels wider than the reserved width and keep them inside it", () => { + const fixedWidth: number = 30; + + const { rootElement } = renderYAxis( + viewport, + largeMagnitudeAxis, + (axisSettings: YAxisDescriptor) => applyFixedLabelWidth(axisSettings, fixedWidth), + ); + + const ticks = getRenderedTickTexts(rootElement); + + expect(ticks.length).toBeGreaterThan(0); + + const truncatedTickCount: number = ticks.filter((tick) => isTruncated(tick.text)).length; + + expect(truncatedTickCount).toBeGreaterThan(0); + + ticks.forEach((tick) => { + // A small tolerance accounts for anti-aliasing/measurement rounding + const tolerance: number = 2; + + expect(tick.textWidth).toBeLessThanOrEqual(fixedWidth + tolerance); + }); + }); + + it("should clamp the reserved width to half of the viewport on small tiles", () => { + const smallViewport: powerbi.IViewport = { width: 100, height: 90 }; + + const clampedWidth: number = renderYAxis( + smallViewport, + largeMagnitudeAxis, + (axisSettings: YAxisDescriptor) => applyFixedLabelWidth(axisSettings, 400), + ).component.getViewport().width; + + // min(400, 100 / 2) and min(50, 100 / 2) both reserve exactly half of the viewport + const halfViewportWidth: number = renderYAxis( + smallViewport, + largeMagnitudeAxis, + (axisSettings: YAxisDescriptor) => applyFixedLabelWidth(axisSettings, smallViewport.width / 2), + ).component.getViewport().width; + + expect(clampedWidth).toBe(halfViewportWidth); + expect(isFinite(clampedWidth)).toBe(true); + expect(clampedWidth).toBeLessThan(smallViewport.width); + }); + + it("should not produce NaN transforms on small tiles", () => { + const smallViewport: powerbi.IViewport = { width: 100, height: 90 }; + + const { rootElement } = renderYAxis( + smallViewport, + largeMagnitudeAxis, + (axisSettings: YAxisDescriptor) => applyFixedLabelWidth(axisSettings, 400), + ); + + const transformedElements: NodeListOf = rootElement.querySelectorAll("[transform]"); + + expect(transformedElements.length).toBeGreaterThan(0); + + Array.from(transformedElements).forEach((transformedElement: Element) => { + expect(transformedElement.getAttribute("transform")).not.toContain("NaN"); + }); + }); + + it("should reserve no width when the axis is hidden", () => { + const hiddenWidth: number = renderYAxis( + viewport, + largeMagnitudeAxis, + (axisSettings: YAxisDescriptor) => { + applyFixedLabelWidth(axisSettings, 80); + + axisSettings.show.value = false; + }, + ).component.getViewport().width; + + expect(hiddenWidth).toBe(0); + }); + }); + + describe("YAxisDescriptor parse", () => { + function parseFixedLabelWidth(value: number): number { + const settings: Settings = new Settings(); + + settings.yAxis.fixedLabelWidth.value = value; + + settings.parseSettings({ width: 600, height: 400 }); + + return settings.yAxis.fixedLabelWidth.value; + } + + it("should clamp a persisted width above the maximum down to the maximum", () => { + expect(parseFixedLabelWidth(1000)).toBe(400); + }); + + it("should clamp a persisted negative width up to the minimum", () => { + expect(parseFixedLabelWidth(-5)).toBe(0); + }); + }); + + describe("secondary Y axis", () => { + it("should be configurable independently from the primary Y axis", () => { + const settings: Settings = new Settings(); + + applyFixedLabelWidth(settings.yAxis, 60); + applyFixedLabelWidth(settings.secondaryYAxis, 120); + + expect(settings.yAxis.fixedLabelWidth.value).toBe(60); + expect(settings.secondaryYAxis.fixedLabelWidth.value).toBe(120); + expect(settings.yAxis.isLabelWidthFixed()).toBe(true); + expect(settings.secondaryYAxis.isLabelWidthFixed()).toBe(true); + }); + }); +}); diff --git a/src/settings/descriptors/axis/yAxisDescriptor.ts b/src/settings/descriptors/axis/yAxisDescriptor.ts index 8512a90..c0c1c28 100644 --- a/src/settings/descriptors/axis/yAxisDescriptor.ts +++ b/src/settings/descriptors/axis/yAxisDescriptor.ts @@ -27,9 +27,29 @@ import powerbi from "powerbi-visuals-api"; import { formattingSettings } from "powerbi-visuals-utils-formattingmodel"; +import ILocalizationManager = powerbi.extensibility.ILocalizationManager; +import { IDescriptorParserOptions } from "../baseDescriptor"; import { AxisDescriptor } from "./axisDescriptor"; +export enum FixedLabelWidthMode { + auto = "auto", + fixed = "fixed", +} + +const fixedLabelWidthModeOptions = [ + { + value: FixedLabelWidthMode.auto, + displayName: "Auto", + displayNameKey: "Visual_Auto" + }, + { + value: FixedLabelWidthMode.fixed, + displayName: "Fixed", + displayNameKey: "Visual_Fixed" + } +] + export class YAxisDescriptor extends AxisDescriptor { public min = new formattingSettings.NumUpDown({ name: "min", @@ -42,6 +62,32 @@ export class YAxisDescriptor extends AxisDescriptor { value: NaN, }); + public fixedLabelWidthMode = new formattingSettings.ItemDropdown({ + name: "fixedLabelWidthMode", + displayNameKey: "Visual_Label_Area_Sizing", + descriptionKey: "Visual_Label_Area_Sizing_Description", + items: fixedLabelWidthModeOptions, + value: fixedLabelWidthModeOptions.filter(el => el.value === FixedLabelWidthMode.auto)[0] + }); + + protected minFixedLabelWidth: number = 0; + protected maxFixedLabelWidth: number = 400; + public fixedLabelWidth = new formattingSettings.NumUpDown({ + name: "fixedLabelWidth", + displayNameKey: "Visual_Label_Area_Width", + value: 60, + options: { + minValue: { + type: powerbi.visuals.ValidatorType.Min, + value: this.minFixedLabelWidth, + }, + maxValue: { + type: powerbi.visuals.ValidatorType.Max, + value: this.maxFixedLabelWidth, + }, + } + }); + constructor( name: string, displayNameKey: string, @@ -57,9 +103,36 @@ export class YAxisDescriptor extends AxisDescriptor { this.fontColor, this.percentile, this.min, - this.max + this.max, + this.fixedLabelWidthMode, + this.fixedLabelWidth ] this.name = name; this.displayNameKey = displayNameKey; } + + public parse(options: IDescriptorParserOptions) { + super.parse(options); + + // The formatting pane validators only guard UI input, so values persisted by + // older reports or set through the API are clamped here, the same way + // NumberDescriptorBase clamps precision + if (this.fixedLabelWidth.value < this.fixedLabelWidth.options.minValue.value) { + this.fixedLabelWidth.value = this.fixedLabelWidth.options.minValue.value; + } + if (this.fixedLabelWidth.value > this.fixedLabelWidth.options.maxValue.value) { + this.fixedLabelWidth.value = this.fixedLabelWidth.options.maxValue.value; + } + } + + public isLabelWidthFixed(): boolean { + return this.fixedLabelWidthMode.value.value === FixedLabelWidthMode.fixed; + } + + public setLocalizedDisplayName(localizationManager: ILocalizationManager) { + super.setLocalizedDisplayName(localizationManager); + fixedLabelWidthModeOptions.forEach(option => { + option.displayName = localizationManager.getDisplayName(option.displayNameKey) + }); + } } diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 4b90e5f..e4c3f8f 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -197,6 +197,7 @@ export class Settings extends formattingSettings.Model { this.filterLineProperties(dataRepresentation); this.filterKPIIndicatorProperties(dataRepresentation); this.filterKPIIndicatorValueProperties(); + this.filterYAxisProperties(); this.filterSettingsPropertiesByAxisType(axisType); this.setLocalizedDisplayNames(localizationManager); this.hideColorPickers(isHighContrast); @@ -262,6 +263,13 @@ export class Settings extends formattingSettings.Model { this.kpiIndicatorValue.fontColor.visible = !this.kpiIndicatorValue.matchKPIColor.value; } + private filterYAxisProperties() { + // The pixel width input only applies when the label area sizing is Fixed + [this.yAxis, this.secondaryYAxis].forEach((axis: YAxisDescriptor) => { + axis.fixedLabelWidth.visible = axis.isLabelWidthFixed(); + }); + } + private filterSettingsPropertiesByAxisType(axisType: DataRepresentationTypeEnum) { const settingsToFilterByAxis = [ this.kpiIndicatorValue, diff --git a/src/visualComponent/axes/yAxisComponent.ts b/src/visualComponent/axes/yAxisComponent.ts index eab58aa..515957f 100644 --- a/src/visualComponent/axes/yAxisComponent.ts +++ b/src/visualComponent/axes/yAxisComponent.ts @@ -151,23 +151,37 @@ export class YAxisComponent return; } - this.maxLabelWidth = settings.isElementShown() - ? labelMeasurementService.getLabelWidth( - this.getTicks(), - this.formatter, - settings.fontSizeInPx, - settings.font.fontFamily.value, - ) - : 0; - const availableWidth: number = viewport.width / 2; let shouldLabelsBeTruncated: boolean = false; - if (this.maxLabelWidth > availableWidth) { - this.maxLabelWidth = availableWidth; + if (settings.isLabelWidthFixed()) { + // Fixed sizing reserves exactly the configured width, whether the rendered + // labels are narrower or wider, so the plot area always starts at the same + // offset. It is clamped to half of the viewport - the same limit Auto + // sizing applies - so the plot area cannot collapse on small viewports. + // Labels are always routed through the truncation formatter below, which + // returns them unchanged when they fit and ellipsises them when they don't + this.maxLabelWidth = settings.isElementShown() + ? Math.min(settings.fixedLabelWidth.value, availableWidth) + : 0; shouldLabelsBeTruncated = true; + } else { + this.maxLabelWidth = settings.isElementShown() + ? labelMeasurementService.getLabelWidth( + this.getTicks(), + this.formatter, + settings.fontSizeInPx, + settings.font.fontFamily.value, + ) + : 0; + + if (this.maxLabelWidth > availableWidth) { + this.maxLabelWidth = availableWidth; + + shouldLabelsBeTruncated = true; + } } this.element @@ -187,9 +201,12 @@ export class YAxisComponent const formattedLabel: string = this.formatter.format(item); if (shouldLabelsBeTruncated) { + // In Auto sizing maxLabelWidth has been clamped to availableWidth whenever + // truncation is on, so truncating to it preserves the upstream behaviour; + // in Fixed sizing it is the configured width the labels must not exceed return textMeasurementService.getTailoredTextOrDefault( labelMeasurementService.getTextProperties(formattedLabel, settings.fontSizeInPx, settings.font.fontFamily.value), - availableWidth, + this.maxLabelWidth, ); } diff --git a/stringResources/en-US/resources.resjson b/stringResources/en-US/resources.resjson index a577063..3bee319 100644 --- a/stringResources/en-US/resources.resjson +++ b/stringResources/en-US/resources.resjson @@ -62,12 +62,16 @@ "Visual_Alingment": "Alingment", "Visual_Min": "Min", "Visual_Max": "Max", + "Visual_Label_Area_Sizing": "Label Area Sizing", + "Visual_Label_Area_Sizing_Description": "Auto sizes the label area to the rendered labels. Fixed reserves a constant width so the plot area keeps the same position when the data changes.", + "Visual_Label_Area_Width": "Label Area Width (px)", "Visual_Match_KPI_Indicator_Color": "Match KPI Indicator Color", "Visual_KPI_Background_Color": "Background Match KPI Color", "Visual_KPI_Indexed_Indicator": "    Indicator", "Visual_KPI_Indexed_Value": "    Value", "Visual_KPI_Color_Segment": "Data Point Starts KPI Color Segment", "Visual_Auto": "Auto", + "Visual_Fixed": "Fixed", "Visual_Auto_Scale": "Auto Scale", "Visual_Tooltip_Label": "Tooltip Label", "Visual_None": "None", From c83472796cef8d9314d1375edf25325d23ee48f1 Mon Sep 17 00:00:00 2001 From: PA <45755573+EquinetPaul@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:07:51 +0100 Subject: [PATCH 2/4] feat(line): add a Scatter option to the line type setting Adds a third value to the Line > Type dropdown that renders each data point as a standalone circle instead of a connecting path. The new ScatterComponent is a sibling of LineComponent and AreaComponent behind the existing ComboComponent dispatch, and reuses the current value dot sizing (thickness * radius factor) and per-point colors, so KPI color segments, selection, highlight and high contrast keep working unchanged. Points without a value are skipped rather than interpolated over. The line shape settings that cannot apply to standalone points (interpolation and line style) are hidden when Scatter is selected. --- CHANGELOG.md | 4 + capabilities.json | 3 +- package-lock.json | 4 +- package.json | 2 +- pbiviz.json | 2 +- specs/scatterLineType.spec.ts | 190 ++++++++++++++++++ .../descriptors/line/lineDescriptor.ts | 5 + src/settings/descriptors/line/lineTypes.ts | 1 + src/settings/settings.ts | 8 +- src/visualComponent/chartComponent.ts | 1 + src/visualComponent/combo/comboComponent.ts | 10 +- src/visualComponent/combo/scatterComponent.ts | 132 ++++++++++++ stringResources/en-US/resources.resjson | 1 + 13 files changed, 355 insertions(+), 8 deletions(-) create mode 100644 specs/scatterLineType.spec.ts create mode 100644 src/visualComponent/combo/scatterComponent.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f4411da..bfb0c99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.1.3.0 +### Features +* Added a Scatter option to the Line Type setting, rendering each data point as a standalone dot without a connecting line + ## 3.1.2.0 ### Features * Added a Label Area Sizing option (Auto / Fixed) to the Y axis and the secondary Y axis to reserve a fixed pixel width for axis labels, so the plot area keeps the same position when filters change the magnitude of the displayed values diff --git a/capabilities.json b/capabilities.json index 46ddcda..2eda7c2 100644 --- a/capabilities.json +++ b/capabilities.json @@ -884,7 +884,8 @@ "type": { "enumeration": [ { "value": "line" }, - { "value": "area"}] + { "value": "area"}, + { "value": "scatter"}] } }, "thickness": { diff --git a/package-lock.json b/package-lock.json index a03ccb4..fa06418 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@microsoft/powerbi-visuals-powerkpi", - "version": "3.1.2.0", + "version": "3.1.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@microsoft/powerbi-visuals-powerkpi", - "version": "3.1.2.0", + "version": "3.1.3.0", "license": "MIT", "dependencies": { "d3-array": "^3.2.4", diff --git a/package.json b/package.json index 78914c8..00d098d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/powerbi-visuals-powerkpi", - "version": "3.1.2.0", + "version": "3.1.3.0", "private": true, "description": "A powerful KPI Indicator with multi-line chart and labels for current date, value and variances. Rich customization options, including current status labels and symbols, trend and comparison lines, multiple variances, auto-scaling, text formatting, and density controls. Highly flexible for use in large, detailed report tiles or tiny, summary dashboard tiles.", "main": "index.js", diff --git a/pbiviz.json b/pbiviz.json index cc73ba2..b8ee0fe 100644 --- a/pbiviz.json +++ b/pbiviz.json @@ -4,7 +4,7 @@ "displayName": "Power KPI", "guid": "powerKPI462CE5C2666F4EC8A8BDD7E5587320A3", "visualClassName": "PowerKPI", - "version": "3.1.2.0", + "version": "3.1.3.0", "description": "A powerful KPI Indicator with multi-line chart and labels for current date, value and variances. Rich customization options, including current status labels and symbols, trend and comparison lines, multiple variances, auto-scaling, text formatting, and density controls. Highly flexible for use in large, detailed report tiles or tiny, summary dashboard tiles.", "supportUrl": "https://aka.ms/customvisualscommunity", "gitHubUrl": "https://github.com/Microsoft/PowerBI-visuals-PowerKPI" diff --git a/specs/scatterLineType.spec.ts b/specs/scatterLineType.spec.ts new file mode 100644 index 0000000..3b88663 --- /dev/null +++ b/specs/scatterLineType.spec.ts @@ -0,0 +1,190 @@ +/* + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import powerbi from "powerbi-visuals-api"; +import { select as d3Select } from "d3-selection"; +import { Selection } from "d3-selection"; + +import { testDom, createColorPalette } from "powerbi-visuals-utils-testutils"; + +import { ComboComponent, IComboComponentRenderOptions } from "../src/visualComponent/combo/comboComponent"; +import { IDataRepresentationPoint, IDataRepresentationPointGradientColor } from "../src/dataRepresentation/dataRepresentationPoint"; +import { IDataRepresentationSeries } from "../src/dataRepresentation/dataRepresentationSeries"; +import { DataRepresentationScale } from "../src/dataRepresentation/dataRepresentationScale"; +import { DataRepresentationTypeEnum } from "../src/dataRepresentation/dataRepresentationType"; +import { LineInterpolation, LineStyle, LineType } from "../src/settings/descriptors/line/lineTypes"; + +const seriesColor: string = "#01B8AA"; + +function buildPoints(yValues: number[]): IDataRepresentationPoint[] { + return yValues.map((y: number, index: number) => ({ + color: seriesColor, + kpiIndex: NaN, + x: index, + y, + })); +} + +/** + * Builds the minimal series shape the chart marks read: the points themselves, the + * gradient colour segments the line/area paths are built from, and the Y scale. + */ +function buildSeries(points: IDataRepresentationPoint[]): IDataRepresentationSeries { + const validYValues: number[] = points + .map((point: IDataRepresentationPoint) => point.y) + .filter((y: number) => y !== null && !isNaN(y)); + + const gradientPoints: IDataRepresentationPointGradientColor[] = [{ + color: seriesColor, + points, + }]; + + return { + color: seriesColor, + gradientPoints, + hasSelection: false, + points, + selected: false, + y: { + format: null, + max: Math.max(...validYValues), + min: Math.min(...validYValues), + scale: DataRepresentationScale + .create() + .domain([Math.min(...validYValues), Math.max(...validYValues)], DataRepresentationTypeEnum.NumberType), + }, + } as IDataRepresentationSeries; +} + +function renderCombo( + lineType: LineType, + yValues: number[] = [10, 20, 30, 40], +): Element { + const viewport: powerbi.IViewport = { width: 400, height: 300 }; + + const rootElement: HTMLElement = testDom(viewport.height.toString(), viewport.width.toString()); + const element: Selection = d3Select(rootElement); + + const points: IDataRepresentationPoint[] = buildPoints(yValues); + const series: IDataRepresentationSeries = buildSeries(points); + + const options: IComboComponentRenderOptions = { + areaOpacity: 0.5, + colorPalette: createColorPalette(), + gradientPoints: series.gradientPoints, + interpolation: LineInterpolation.linear, + lineStyle: LineStyle.solidLine, + lineType, + opacity: 1, + radiusFactor: 1.4, + series, + thickness: 2, + viewport, + x: DataRepresentationScale + .create() + .domain([0, points.length - 1], DataRepresentationTypeEnum.NumberType), + y: series.y.scale, + }; + + const comboComponent: ComboComponent = new ComboComponent({ element }); + + comboComponent.render(options); + + return rootElement; +} + +function getCircles(rootElement: Element): SVGCircleElement[] { + return Array.from(rootElement.querySelectorAll("circle")); +} + +function getPaths(rootElement: Element): SVGPathElement[] { + return Array.from(rootElement.querySelectorAll("path")); +} + +describe("Scatter line type", () => { + it("should render one circle per data point and no line path", () => { + const yValues: number[] = [10, 20, 30, 40]; + + const rootElement: Element = renderCombo(LineType.scatter, yValues); + + expect(getCircles(rootElement).length).toBe(yValues.length); + expect(getPaths(rootElement).length).toBe(0); + }); + + it("should render a line path and no circles for the Line type", () => { + const rootElement: Element = renderCombo(LineType.line); + + expect(getPaths(rootElement).length).toBe(1); + expect(getCircles(rootElement).length).toBe(0); + }); + + it("should render an area path plus its line and no circles for the Area type", () => { + const rootElement: Element = renderCombo(LineType.area); + + // AreaComponent draws the filled area and then delegates to LineComponent + expect(getPaths(rootElement).length).toBe(2); + expect(getCircles(rootElement).length).toBe(0); + }); + + it("should place every circle at finite coordinates", () => { + const rootElement: Element = renderCombo(LineType.scatter); + + const circles: SVGCircleElement[] = getCircles(rootElement); + + expect(circles.length).toBeGreaterThan(0); + + circles.forEach((circle: SVGCircleElement) => { + const cx: number = parseFloat(circle.getAttribute("cx")); + const cy: number = parseFloat(circle.getAttribute("cy")); + const r: number = parseFloat(circle.getAttribute("r")); + + expect(isFinite(cx)).toBe(true); + expect(isFinite(cy)).toBe(true); + expect(r).toBeGreaterThan(0); + }); + }); + + it("should skip points without a value instead of placing circles at NaN coordinates", () => { + const yValues: number[] = [10, null, 30, NaN, 50]; + + const rootElement: Element = renderCombo(LineType.scatter, yValues); + + // Only the three points that carry a value are drawn + expect(getCircles(rootElement).length).toBe(3); + + getCircles(rootElement).forEach((circle: SVGCircleElement) => { + expect(circle.getAttribute("cy")).not.toContain("NaN"); + }); + }); + + it("should colour the circles with the series colour", () => { + const rootElement: Element = renderCombo(LineType.scatter); + + getCircles(rootElement).forEach((circle: SVGCircleElement) => { + expect(circle.style.fill).toBeTruthy(); + }); + }); +}); diff --git a/src/settings/descriptors/line/lineDescriptor.ts b/src/settings/descriptors/line/lineDescriptor.ts index fa0e6e9..9602ea4 100644 --- a/src/settings/descriptors/line/lineDescriptor.ts +++ b/src/settings/descriptors/line/lineDescriptor.ts @@ -94,6 +94,11 @@ const lineTypeOptions = [ value: LineType.area, displayName: "Area", displayNameKey: "Visual_Area" + }, + { + value: LineType.scatter, + displayName: "Scatter", + displayNameKey: "Visual_Scatter" } ] diff --git a/src/settings/descriptors/line/lineTypes.ts b/src/settings/descriptors/line/lineTypes.ts index a0198dd..2d4fa3c 100644 --- a/src/settings/descriptors/line/lineTypes.ts +++ b/src/settings/descriptors/line/lineTypes.ts @@ -22,6 +22,7 @@ export enum LineType { line = "line", area = "area", column = "column", + scatter = "scatter", } export enum LineColorMode { diff --git a/src/settings/settings.ts b/src/settings/settings.ts index e4c3f8f..4f8e7b3 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -248,9 +248,13 @@ export class Settings extends formattingSettings.Model { this.line.container.containerItems.forEach(containerItem => { const currentSettings = this.line.getCurrentSettings((containerItem as IKeyedContainerItem).key); - containerItem.slices.filter(el => el.name === "interpolation")[0].visible = !currentSettings.shouldMatchKpiColor; + // Scatter draws standalone points, so the settings shaping the connecting + // line have no effect on it + const isScatter: boolean = currentSettings.lineType === LineType.scatter; + containerItem.slices.filter(el => el.name === "interpolation")[0].visible = !currentSettings.shouldMatchKpiColor && !isScatter; containerItem.slices.filter(el => el.name === "dataPointStartsKpiColorSegment")[0].visible = currentSettings.shouldMatchKpiColor; - containerItem.slices.filter(el => el.name === "interpolationWithColorizedLine")[0].visible = currentSettings.shouldMatchKpiColor; + containerItem.slices.filter(el => el.name === "interpolationWithColorizedLine")[0].visible = currentSettings.shouldMatchKpiColor && !isScatter; + containerItem.slices.filter(el => el.name === "lineStyle")[0].visible = !isScatter; containerItem.slices.filter(el => el.name === "rawAreaOpacity")[0].visible = currentSettings.lineType === LineType.area; }) } diff --git a/src/visualComponent/chartComponent.ts b/src/visualComponent/chartComponent.ts index 9872f82..2f57e2a 100644 --- a/src/visualComponent/chartComponent.ts +++ b/src/visualComponent/chartComponent.ts @@ -141,6 +141,7 @@ export class ChartComponent extends BaseContainerComponent< lineStyle, lineType, opacity, + radiusFactor: settings.dots.radiusFactor.value, series: currentSeries, thickness, viewport, diff --git a/src/visualComponent/combo/comboComponent.ts b/src/visualComponent/combo/comboComponent.ts index 0cae9cb..62e869c 100644 --- a/src/visualComponent/combo/comboComponent.ts +++ b/src/visualComponent/combo/comboComponent.ts @@ -39,7 +39,12 @@ import { LineComponent, } from "./lineComponent"; -export interface IComboComponentRenderOptions extends IAreaComponentRenderOptions { +import { + IScatterComponentRenderOptions, + ScatterComponent, +} from "./scatterComponent"; + +export interface IComboComponentRenderOptions extends IAreaComponentRenderOptions, IScatterComponentRenderOptions { lineType: LineType; } @@ -86,6 +91,9 @@ export class ComboComponent extends BaseContainerComponent< case LineType.area: { return new AreaComponent(this.constructorOptions); } + case LineType.scatter: { + return new ScatterComponent(this.constructorOptions); + } case LineType.column: default: { return new LineComponent(this.constructorOptions); diff --git a/src/visualComponent/combo/scatterComponent.ts b/src/visualComponent/combo/scatterComponent.ts new file mode 100644 index 0000000..8b29481 --- /dev/null +++ b/src/visualComponent/combo/scatterComponent.ts @@ -0,0 +1,132 @@ +/** + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import { Selection } from "d3-selection"; +import { CssConstants } from "powerbi-visuals-utils-svgutils"; + +import { DataRepresentationScale } from "../../dataRepresentation/dataRepresentationScale"; +import { BaseComponent } from "../base/baseComponent"; +import { IVisualComponentConstructorOptions } from "../base/visualComponentConstructorOptions"; + +import { IDataRepresentationPoint } from "../../dataRepresentation/dataRepresentationPoint"; + +import { ILineComponentRenderOptions } from "./lineComponent"; + +export interface IScatterComponentRenderOptions extends ILineComponentRenderOptions { + radiusFactor: number; +} + +export class ScatterComponent extends BaseComponent { + private className: string = "scatterComponent"; + private pointSelector: CssConstants.ClassAndSelector = this.getSelectorWithPrefix(`${this.className}_point`); + + private pointSelection: Selection; + + constructor(options: IVisualComponentConstructorOptions) { + super(); + + this.initElement( + options.element, + this.className, + "g", + ); + + this.constructorOptions = { + ...options, + element: this.element, + }; + } + + public render(options: IScatterComponentRenderOptions): void { + const { + x, + y, + viewport, + thickness, + radiusFactor, + series, + colorPalette + } = options; + + this.renderOptions = options; + + const xScale: DataRepresentationScale = x + .copy() + .range([0, viewport.width]); + + const yScale: DataRepresentationScale = y + .copy() + .range([viewport.height, 0]); + + // Scatter draws one circle per data point instead of a connecting path, so + // points without a value are dropped rather than interpolated over - keeping + // them would place circles at NaN coordinates. + const points: IDataRepresentationPoint[] = series + && series.points.filter((point: IDataRepresentationPoint) => { + return point.y !== null && !isNaN(point.y); + }) + || []; + + const pointSelection: Selection = this.element + .selectAll(this.pointSelector.selectorName) + .data(points); + + pointSelection + .exit() + .remove(); + + const isHighContrast: boolean = colorPalette.isHighContrast; + + this.pointSelection = pointSelection.enter() + .append("svg:circle") + .classed(this.pointSelector.className, true) + .on("click", (event) => this.clickHandler(event)) + .merge(pointSelection) + .attr("cx", (point: IDataRepresentationPoint) => xScale.scale(point.x)) + .attr("cy", (point: IDataRepresentationPoint) => yScale.scale(point.y)) + // The same radius formula as the current value dot, so both are sized + // consistently by the line thickness and the dots radius factor. + .attr("r", thickness * radiusFactor) + .style("fill", (point: IDataRepresentationPoint) => isHighContrast ? colorPalette.foreground.value : point.color); + + this.highlight(series && series.hasSelection); + } + + public destroy(): void { + this.pointSelection = null; + + super.destroy(); + } + + public highlight(hasSelection: boolean): void { + this.updateElementOpacity( + this.pointSelection, + this.renderOptions && this.renderOptions.opacity, + this.renderOptions && this.renderOptions.series && this.renderOptions.series.selected, + hasSelection, + ); + } +} diff --git a/stringResources/en-US/resources.resjson b/stringResources/en-US/resources.resjson index 3bee319..0dcf981 100644 --- a/stringResources/en-US/resources.resjson +++ b/stringResources/en-US/resources.resjson @@ -14,6 +14,7 @@ "Visual_Data_Labels": "Data Labels", "Visual_Line": "Line", "Visual_Area": "Area", + "Visual_Scatter": "Scatter", "Visual_Dots": "Dots", "Visual_Legend": "Legend", "Visual_Axis": "Axis", From 7471b363d3c659388f96a1adf7ffe6350eb4b661 Mon Sep 17 00:00:00 2001 From: PA <45755573+EquinetPaul@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:14:49 +0100 Subject: [PATCH 3/4] feat(axes): add Min and Max settings to the X axis Pins the X axis range instead of fitting it to the data, mirroring what the Y axis cards already offer. The boundaries are applied to the axis representation just before the scale is built, the same way postProcess applies the Y boundaries: an empty setting keeps the computed bound and an inverted pair is reordered so the scale never receives a reversed domain. The input matches the axis type - numeric fields on a numeric axis, date fields on a date axis, neither on a categorical axis, whose domain is a list of categories rather than a range. Date boundaries are typed as text because the formatting API exposes no date property type; an entry that cannot be read as a date is ignored so a typo falls back to the computed bound instead of collapsing the axis. --- CHANGELOG.md | 4 + capabilities.json | 28 +++ package-lock.json | 4 +- package.json | 2 +- pbiviz.json | 2 +- specs/xAxisBoundaries.spec.ts | 172 ++++++++++++++++++ src/converter/dataConverter.ts | 34 ++++ .../descriptors/axis/xAxisDescriptor.ts | 95 +++++++++- src/settings/settings.ts | 13 +- stringResources/en-US/resources.resjson | 1 + 10 files changed, 348 insertions(+), 7 deletions(-) create mode 100644 specs/xAxisBoundaries.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bfb0c99..c65e57c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.1.4.0 +### Features +* Added Min and Max settings to the X axis, pinning the axis range instead of fitting it to the data - numeric fields on a numeric axis, date fields on a date axis + ## 3.1.3.0 ### Features * Added a Scatter option to the Line Type setting, rendering each data point as a standalone dot without a connecting line diff --git a/capabilities.json b/capabilities.json index 2eda7c2..a6e14c5 100644 --- a/capabilities.json +++ b/capabilities.json @@ -1096,6 +1096,34 @@ { "value": "1" } ] } + }, + "min": { + "type": { + "numeric": true + }, + "placeHolderText": "Auto", + "suppressFormatPainterCopy": true + }, + "max": { + "type": { + "numeric": true + }, + "placeHolderText": "Auto", + "suppressFormatPainterCopy": true + }, + "minDate": { + "type": { + "text": true + }, + "placeHolderText": "Auto", + "suppressFormatPainterCopy": true + }, + "maxDate": { + "type": { + "text": true + }, + "placeHolderText": "Auto", + "suppressFormatPainterCopy": true } } }, diff --git a/package-lock.json b/package-lock.json index fa06418..255affc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@microsoft/powerbi-visuals-powerkpi", - "version": "3.1.3.0", + "version": "3.1.4.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@microsoft/powerbi-visuals-powerkpi", - "version": "3.1.3.0", + "version": "3.1.4.0", "license": "MIT", "dependencies": { "d3-array": "^3.2.4", diff --git a/package.json b/package.json index 00d098d..848d572 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/powerbi-visuals-powerkpi", - "version": "3.1.3.0", + "version": "3.1.4.0", "private": true, "description": "A powerful KPI Indicator with multi-line chart and labels for current date, value and variances. Rich customization options, including current status labels and symbols, trend and comparison lines, multiple variances, auto-scaling, text formatting, and density controls. Highly flexible for use in large, detailed report tiles or tiny, summary dashboard tiles.", "main": "index.js", diff --git a/pbiviz.json b/pbiviz.json index b8ee0fe..5258bef 100644 --- a/pbiviz.json +++ b/pbiviz.json @@ -4,7 +4,7 @@ "displayName": "Power KPI", "guid": "powerKPI462CE5C2666F4EC8A8BDD7E5587320A3", "visualClassName": "PowerKPI", - "version": "3.1.3.0", + "version": "3.1.4.0", "description": "A powerful KPI Indicator with multi-line chart and labels for current date, value and variances. Rich customization options, including current status labels and symbols, trend and comparison lines, multiple variances, auto-scaling, text formatting, and density controls. Highly flexible for use in large, detailed report tiles or tiny, summary dashboard tiles.", "supportUrl": "https://aka.ms/customvisualscommunity", "gitHubUrl": "https://github.com/Microsoft/PowerBI-visuals-PowerKPI" diff --git a/specs/xAxisBoundaries.spec.ts b/specs/xAxisBoundaries.spec.ts new file mode 100644 index 0000000..f539fdc --- /dev/null +++ b/specs/xAxisBoundaries.spec.ts @@ -0,0 +1,172 @@ +/* + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import { DataRepresentationTypeEnum } from "../src/dataRepresentation/dataRepresentationType"; +import { Settings } from "../src/settings/settings"; +import { XAxisDescriptor } from "../src/settings/descriptors/axis/xAxisDescriptor"; + +function createXAxisSettings(): XAxisDescriptor { + return new Settings().xAxis; +} + +describe("XAxisDescriptor boundaries", () => { + describe("numeric axis", () => { + const type: DataRepresentationTypeEnum = DataRepresentationTypeEnum.NumberType; + + it("should report no boundary while the inputs are left empty", () => { + const xAxis: XAxisDescriptor = createXAxisSettings(); + + expect(xAxis.getMin(type)).toBeUndefined(); + expect(xAxis.getMax(type)).toBeUndefined(); + }); + + it("should report the numeric values the author typed", () => { + const xAxis: XAxisDescriptor = createXAxisSettings(); + + xAxis.min.value = -10; + xAxis.max.value = 250; + + expect(xAxis.getMin(type)).toBe(-10); + expect(xAxis.getMax(type)).toBe(250); + }); + + it("should treat zero as a real boundary rather than an empty input", () => { + const xAxis: XAxisDescriptor = createXAxisSettings(); + + xAxis.min.value = 0; + + expect(xAxis.getMin(type)).toBe(0); + }); + + it("should ignore the date inputs", () => { + const xAxis: XAxisDescriptor = createXAxisSettings(); + + xAxis.minDate.value = "2024-01-31"; + + expect(xAxis.getMin(type)).toBeUndefined(); + }); + }); + + describe("date axis", () => { + const type: DataRepresentationTypeEnum = DataRepresentationTypeEnum.DateType; + + it("should report no boundary while the inputs are left empty", () => { + const xAxis: XAxisDescriptor = createXAxisSettings(); + + expect(xAxis.getMin(type)).toBeUndefined(); + expect(xAxis.getMax(type)).toBeUndefined(); + }); + + it("should parse the dates the author typed", () => { + const xAxis: XAxisDescriptor = createXAxisSettings(); + + xAxis.minDate.value = "2024-01-31"; + xAxis.maxDate.value = "2024-12-01"; + + expect((xAxis.getMin(type) as Date).getTime()).toBe(new Date("2024-01-31").getTime()); + expect((xAxis.getMax(type) as Date).getTime()).toBe(new Date("2024-12-01").getTime()); + }); + + it("should ignore an entry that cannot be read as a date", () => { + const xAxis: XAxisDescriptor = createXAxisSettings(); + + xAxis.minDate.value = "not a date"; + + expect(xAxis.getMin(type)).toBeUndefined(); + }); + + it("should ignore the numeric inputs", () => { + const xAxis: XAxisDescriptor = createXAxisSettings(); + + xAxis.min.value = 42; + + expect(xAxis.getMin(type)).toBeUndefined(); + }); + }); + + describe("categorical axis", () => { + const type: DataRepresentationTypeEnum = DataRepresentationTypeEnum.StringType; + + it("should never report a boundary, whichever input is filled in", () => { + const xAxis: XAxisDescriptor = createXAxisSettings(); + + xAxis.min.value = 1; + xAxis.max.value = 2; + xAxis.minDate.value = "2024-01-31"; + xAxis.maxDate.value = "2024-12-01"; + + expect(xAxis.getMin(type)).toBeUndefined(); + expect(xAxis.getMax(type)).toBeUndefined(); + }); + }); + + describe("formatting pane visibility", () => { + function getVisibility(axisType: DataRepresentationTypeEnum) { + const settings: Settings = new Settings(); + + settings.filterFormattingProperties( + null, + axisType, + { getDisplayName: (key: string) => key } as any, + false, + ); + + return { + max: settings.xAxis.max.visible, + maxDate: settings.xAxis.maxDate.visible, + min: settings.xAxis.min.visible, + minDate: settings.xAxis.minDate.visible, + }; + } + + it("should offer the numeric inputs only on a numeric axis", () => { + const visibility = getVisibility(DataRepresentationTypeEnum.NumberType); + + expect(visibility.min).toBe(true); + expect(visibility.max).toBe(true); + expect(visibility.minDate).toBe(false); + expect(visibility.maxDate).toBe(false); + }); + + it("should offer the date inputs only on a date axis", () => { + const visibility = getVisibility(DataRepresentationTypeEnum.DateType); + + expect(visibility.minDate).toBe(true); + expect(visibility.maxDate).toBe(true); + expect(visibility.min).toBe(false); + expect(visibility.max).toBe(false); + }); + + it("should offer neither pair on a categorical axis", () => { + const visibility = getVisibility(DataRepresentationTypeEnum.StringType); + + expect(visibility.min).toBe(false); + expect(visibility.max).toBe(false); + expect(visibility.minDate).toBe(false); + expect(visibility.maxDate).toBe(false); + }); + }); +}); diff --git a/src/converter/dataConverter.ts b/src/converter/dataConverter.ts index 3b1b2a9..c019dd5 100644 --- a/src/converter/dataConverter.ts +++ b/src/converter/dataConverter.ts @@ -42,6 +42,7 @@ import { IDataRepresentationPointIndexed } from "../dataRepresentation/dataRepre import { DataRepresentationScale } from "../dataRepresentation/dataRepresentationScale"; import { DataRepresentationTypeEnum } from "../dataRepresentation/dataRepresentationType"; import { AxisType } from "../settings/descriptors/axis/axisDescriptor"; +import { XAxisDescriptor } from "../settings/descriptors/axis/xAxisDescriptor"; import { YAxisDescriptor } from "../settings/descriptors/axis/yAxisDescriptor"; import { IKPIIndicatorSettings } from "../settings/descriptors/kpi/kpiIndicatorsListDescriptor"; import { Settings } from "../settings/settings"; @@ -390,6 +391,8 @@ export class DataConverter extends VarianceConverter implements IConverter { dataRepresentation.x.values = axisCategory.values as DataRepresentationAxisValueType[]; + this.applyXAxisBoundaries(dataRepresentation, settings.xAxis); + this.getXAxisScale( dataRepresentation.x.scale, dataRepresentation.x.min, @@ -701,6 +704,37 @@ export class DataConverter extends VarianceConverter implements IConverter { : kpiIndex; } + /** + * Replaces the computed X boundaries with the ones pinned in the X axis card, + * the same way postProcess applies the Y axis boundaries. A boundary left empty + * keeps its computed value, and an inverted pair is reordered so that the scale + * never receives a reversed domain. + */ + private applyXAxisBoundaries( + dataRepresentation: IDataRepresentation, + xAxisSettings: XAxisDescriptor, + ): void { + const type: DataRepresentationTypeEnum = dataRepresentation.x.axisType; + + const min: DataRepresentationAxisValueType = xAxisSettings.getMin(type); + const max: DataRepresentationAxisValueType = xAxisSettings.getMax(type); + + if (min === undefined && max === undefined) { + return; + } + + const newMin: DataRepresentationAxisValueType = min !== undefined + ? min + : dataRepresentation.x.min; + + const newMax: DataRepresentationAxisValueType = max !== undefined + ? max + : dataRepresentation.x.max; + + dataRepresentation.x.min = newMin <= newMax ? newMin : newMax; + dataRepresentation.x.max = newMin <= newMax ? newMax : newMin; + } + private getXAxisScale( scale: DataRepresentationScale, min: DataRepresentationAxisValueType, diff --git a/src/settings/descriptors/axis/xAxisDescriptor.ts b/src/settings/descriptors/axis/xAxisDescriptor.ts index c57542e..3eda65c 100644 --- a/src/settings/descriptors/axis/xAxisDescriptor.ts +++ b/src/settings/descriptors/axis/xAxisDescriptor.ts @@ -29,6 +29,9 @@ import ILocalizationManager = powerbi.extensibility.ILocalizationManager; import { formattingSettings } from "powerbi-visuals-utils-formattingmodel"; +import { DataRepresentationAxisValueType } from "../../../dataRepresentation/dataRepresentationAxisValueType"; +import { DataRepresentationTypeEnum } from "../../../dataRepresentation/dataRepresentationType"; + import { AxisDescriptor, AxisType, @@ -55,17 +58,105 @@ export class XAxisDescriptor extends AxisDescriptor { value: typeOptions[0] }); + public min = new formattingSettings.NumUpDown({ + name: "min", + displayNameKey: "Visual_Min", + value: NaN, + }); + public max = new formattingSettings.NumUpDown({ + name: "max", + displayNameKey: "Visual_Max", + value: NaN, + }); + + // The formatting API exposes no date property type, so date boundaries are typed + // as text and parsed here. Numeric axes keep the numeric inputs above. + public minDate = new formattingSettings.TextInput({ + name: "minDate", + displayNameKey: "Visual_Min", + descriptionKey: "Visual_Axis_Date_Boundary_Description", + value: "", + placeholder: "" + }); + public maxDate = new formattingSettings.TextInput({ + name: "maxDate", + displayNameKey: "Visual_Max", + descriptionKey: "Visual_Axis_Date_Boundary_Description", + value: "", + placeholder: "" + }); + constructor( - viewportToBeHidden: powerbi.IViewport, + viewportToBeHidden: powerbi.IViewport, viewportToIncreaseDensity: powerbi.IViewport ) { super(viewportToBeHidden, viewportToIncreaseDensity, true) - this.slices = [this.font, this.fontColor, this.displayUnits, this.percentile, this.type] + this.slices = [ + this.font, + this.fontColor, + this.displayUnits, + this.percentile, + this.type, + this.min, + this.max, + this.minDate, + this.maxDate + ] this.name = "xAxis"; this.displayNameKey = "Visual_X_Axis"; } + public getMin(type: DataRepresentationTypeEnum): DataRepresentationAxisValueType { + return this.getBoundary(type, this.min.value, this.minDate.value); + } + + public getMax(type: DataRepresentationTypeEnum): DataRepresentationAxisValueType { + return this.getBoundary(type, this.max.value, this.maxDate.value); + } + + /** + * Returns the boundary the author pinned for the current axis type, or undefined + * when it is left empty - in which case the axis keeps its data driven bound. + */ + private getBoundary( + type: DataRepresentationTypeEnum, + numericValue: number, + dateValue: string, + ): DataRepresentationAxisValueType { + switch (type) { + case DataRepresentationTypeEnum.NumberType: { + return numericValue === null || isNaN(numericValue) + ? undefined + : numericValue; + } + case DataRepresentationTypeEnum.DateType: { + return this.parseDate(dateValue); + } + default: { + // A categorical axis is a list of categories rather than a range, + // so it has no boundary to pin + return undefined; + } + } + } + + /** + * An entry that is empty or cannot be read as a date is ignored, so a typo never + * collapses the axis - it simply falls back to the computed boundary. + */ + private parseDate(value: string): Date { + if (!value) { + return undefined; + } + + const date: Date = new Date(value); + + return isNaN(date.getTime()) + ? undefined + : date; + } + public getNewType(value: AxisType) { return this.getNewComplexValue(value, typeOptions) } diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 4f8e7b3..6132a24 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -291,10 +291,21 @@ export class Settings extends formattingSettings.Model { card.displayUnits.visible = !shouldNumericPropertiesBeHidden; card.precision.visible = !shouldNumericPropertiesBeHidden; - card.format.visible = + card.format.visible = axisType == DataRepresentationTypeEnum.NumberType || axisType === DataRepresentationTypeEnum.DateType; }) + + // The X axis boundaries are typed with the input matching the axis type: + // numeric fields for a numeric axis, date fields for a date axis. A + // categorical axis has no range to bound, so neither pair is offered. + const isNumberAxis: boolean = axisType === DataRepresentationTypeEnum.NumberType; + const isDateAxis: boolean = axisType === DataRepresentationTypeEnum.DateType; + + this.xAxis.min.visible = isNumberAxis; + this.xAxis.max.visible = isNumberAxis; + this.xAxis.minDate.visible = isDateAxis; + this.xAxis.maxDate.visible = isDateAxis; } private setLocalizedDisplayNames(localizationManager: ILocalizationManager) { diff --git a/stringResources/en-US/resources.resjson b/stringResources/en-US/resources.resjson index 0dcf981..cdcbd1f 100644 --- a/stringResources/en-US/resources.resjson +++ b/stringResources/en-US/resources.resjson @@ -63,6 +63,7 @@ "Visual_Alingment": "Alingment", "Visual_Min": "Min", "Visual_Max": "Max", + "Visual_Axis_Date_Boundary_Description": "Date boundary, for example 2024-01-31. Leave empty to fit the axis to the data.", "Visual_Label_Area_Sizing": "Label Area Sizing", "Visual_Label_Area_Sizing_Description": "Auto sizes the label area to the rendered labels. Fixed reserves a constant width so the plot area keeps the same position when the data changes.", "Visual_Label_Area_Width": "Label Area Width (px)", From d2ced2b5396d35b4d62bdd1396b03d0c9f27d7bf Mon Sep 17 00:00:00 2001 From: PA <45755573+EquinetPaul@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:24:22 +0100 Subject: [PATCH 4/4] feat(zoom): add a Zoom Slider card for the X and Y axes Adds a Zoom Slider formatting card, off by default, that shows a zoom slider on the X axis, the Y axis or both. Each slider narrows its axis to the range kept between its two handles, applied to the domain next to the boundaries the author pinned, so zooming stays relative to the axis actually on screen. The X axis slider is offered only on a date or a numeric axis: a categorical axis has no range to narrow, its domain being the list of categories. The guard sits both in the pane and at render time, since the field can be swapped for a text column while the setting stays on. Each slider spans the drawing area rather than the plot container, and its track is inset by a handle radius so a handle at either end stays inside the slider. The zoom is view state: it lives in the visual across renders, is never persisted, and resets whenever Power BI pushes an update. The chart is rebuilt when a drag ends rather than on every pointer move, since a rebuild re-runs the whole data pipeline. Also confines the chart marks to the drawing area. A narrowed range still maps every point through the scale, so the points outside it land far beyond the drawing area, and nothing clipped that: the chart SVG is set to overflow: visible and the only clipping boundary is the plot, which the Y axis lives inside. The marks were painted across the Y axis labels and out to the edge of the tile - 1428 px left of the plot at 1024x768 with a pinned axis, 362 px at 300x200 after one drag. The clip covers the drawing area grown by the margin reserved for dot radii, and applies to the marks only, so the labels, the hover dots, the vertical line and the clear catcher keep their behaviour. It guards every way the range can be narrowed, including the Y axis Min/Max the visual already offered, where the overflow was a pre-existing defect. --- CHANGELOG.md | 6 + capabilities.json | 19 + package-lock.json | 4 +- package.json | 2 +- pbiviz.json | 2 +- specs/plotLayoutOverflow.spec.ts | 297 ++++++++++++++++ specs/zoomSlider.spec.ts | 333 ++++++++++++++++++ src/converter/converterOptions.ts | 4 +- src/converter/dataConverter.ts | 58 ++- src/dataRepresentation/dataRepresentation.ts | 4 + .../dataRepresentationZoom.ts | 89 +++++ src/event/eventName.ts | 1 + .../descriptors/zoomSliderDescriptor.ts | 62 ++++ src/settings/settings.ts | 11 +- src/visual.ts | 47 ++- src/visualComponent/chartComponent.ts | 10 + src/visualComponent/plotComponent.ts | 72 +++- src/visualComponent/svgComponent.ts | 31 +- src/visualComponent/zoomSliderComponent.ts | 323 +++++++++++++++++ stringResources/en-US/resources.resjson | 1 + styles/styles.less | 20 ++ 21 files changed, 1376 insertions(+), 20 deletions(-) create mode 100644 specs/plotLayoutOverflow.spec.ts create mode 100644 specs/zoomSlider.spec.ts create mode 100644 src/dataRepresentation/dataRepresentationZoom.ts create mode 100644 src/settings/descriptors/zoomSliderDescriptor.ts create mode 100644 src/visualComponent/zoomSliderComponent.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c65e57c..9919462 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 3.1.5.0 +### Features +* Added a Zoom Slider card, off by default, showing a zoom slider on the X axis, the Y axis or both. The slider narrows the axis range the same way the built in visuals do; the chart is rebuilt when the drag ends and the zoom resets when the data is refreshed. The X axis slider is offered only on a date or numeric axis, a categorical one having no range to narrow +### Fixes +* Fixed the chart being painted outside its drawing area, over the Y axis labels and past the edge of the visual, whenever the axis range is narrower than the data - a pinned X axis Min/Max, a zoom range, or the Y axis Min/Max that the visual already offered + ## 3.1.4.0 ### Features * Added Min and Max settings to the X axis, pinning the axis range instead of fitting it to the data - numeric fields on a numeric axis, date fields on a date axis diff --git a/capabilities.json b/capabilities.json index a6e14c5..0685ec1 100644 --- a/capabilities.json +++ b/capabilities.json @@ -1038,6 +1038,25 @@ } } }, + "zoomSlider": { + "properties": { + "show": { + "type": { + "bool": true + } + }, + "showForXAxis": { + "type": { + "bool": true + } + }, + "showForYAxis": { + "type": { + "bool": true + } + } + } + }, "xAxis": { "properties": { "show": { diff --git a/package-lock.json b/package-lock.json index 255affc..0fae626 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@microsoft/powerbi-visuals-powerkpi", - "version": "3.1.4.0", + "version": "3.1.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@microsoft/powerbi-visuals-powerkpi", - "version": "3.1.4.0", + "version": "3.1.5.0", "license": "MIT", "dependencies": { "d3-array": "^3.2.4", diff --git a/package.json b/package.json index 848d572..0708bbb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/powerbi-visuals-powerkpi", - "version": "3.1.4.0", + "version": "3.1.5.0", "private": true, "description": "A powerful KPI Indicator with multi-line chart and labels for current date, value and variances. Rich customization options, including current status labels and symbols, trend and comparison lines, multiple variances, auto-scaling, text formatting, and density controls. Highly flexible for use in large, detailed report tiles or tiny, summary dashboard tiles.", "main": "index.js", diff --git a/pbiviz.json b/pbiviz.json index 5258bef..15064c0 100644 --- a/pbiviz.json +++ b/pbiviz.json @@ -4,7 +4,7 @@ "displayName": "Power KPI", "guid": "powerKPI462CE5C2666F4EC8A8BDD7E5587320A3", "visualClassName": "PowerKPI", - "version": "3.1.4.0", + "version": "3.1.5.0", "description": "A powerful KPI Indicator with multi-line chart and labels for current date, value and variances. Rich customization options, including current status labels and symbols, trend and comparison lines, multiple variances, auto-scaling, text formatting, and density controls. Highly flexible for use in large, detailed report tiles or tiny, summary dashboard tiles.", "supportUrl": "https://aka.ms/customvisualscommunity", "gitHubUrl": "https://github.com/Microsoft/PowerBI-visuals-PowerKPI" diff --git a/specs/plotLayoutOverflow.spec.ts b/specs/plotLayoutOverflow.spec.ts new file mode 100644 index 0000000..17fcd1a --- /dev/null +++ b/specs/plotLayoutOverflow.spec.ts @@ -0,0 +1,297 @@ +/* + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import powerbi from "powerbi-visuals-api"; + +import { DataBuilder } from "./dataBuilder"; +import { VisualBuilder } from "./visualBuilder"; + +/** + * Guards the plot layout: whatever is turned on in the formatting pane, the plot must + * lay its parts out side by side inside its own box. A regression here shows up in a + * report as axis labels sitting on top of the chart, or as parts spilling out of the + * visual, which no unit test on a single component would catch. + */ + +// Anti aliasing and sub pixel rounding make exact comparisons brittle; a tolerance of +// one pixel is small enough that a real overlap still fails. +const tolerance: number = 1; + +function getPlotElement(root: HTMLElement): HTMLElement { + return root.querySelector(".powerKpi_plot"); +} + +function getRects(plotElement: HTMLElement): { [name: string]: DOMRect } { + const selectors: { [name: string]: string } = { + chart: "svg.powerKpi_svgComponent", + xAxis: ".visualXAxis", + yAxis: "svg.powerKpi_visualYAxis", + }; + + const rects: { [name: string]: DOMRect } = {}; + + Object.keys(selectors).forEach((name: string) => { + const element: Element = plotElement.querySelector(selectors[name]); + + if (element) { + rects[name] = element.getBoundingClientRect(); + } + }); + + return rects; +} + +function expectWithin(child: DOMRect, container: DOMRect, label: string): void { + expect(`${label} left: ${child.left >= container.left - tolerance}`).toBe(`${label} left: true`); + expect(`${label} right: ${child.right <= container.right + tolerance}`).toBe(`${label} right: true`); + expect(`${label} top: ${child.top >= container.top - tolerance}`).toBe(`${label} top: true`); + expect(`${label} bottom: ${child.bottom <= container.bottom + tolerance}`).toBe(`${label} bottom: true`); +} + +function runLayoutChecks(root: HTMLElement): void { + const plotElement: HTMLElement = getPlotElement(root); + + expect(plotElement).not.toBeNull(); + + const plotRect: DOMRect = plotElement.getBoundingClientRect(); + const rects = getRects(plotElement); + + expect(rects.chart).toBeDefined(); + expect(rects.chart.width).toBeGreaterThan(0); + expect(rects.chart.height).toBeGreaterThan(0); + + Object.keys(rects).forEach((name: string) => { + expectWithin(rects[name], plotRect, name); + }); + + // The Y axis must sit to the left of the chart, never on top of it + if (rects.yAxis && rects.yAxis.width > 0) { + expect(`yAxis before chart: ${rects.yAxis.right <= rects.chart.left + tolerance}`) + .toBe("yAxis before chart: true"); + } +} + +describe("Plot layout", () => { + /** + * The shared DataBuilder fills its series with getRandomNumbers between -MAX_VALUE + * and +MAX_VALUE. Those collapse every chart path to a zero sized box and leave the + * Y axis without ticks, which silently makes any assertion on rendered marks vacuous. + * A realistic ramp is built here instead, leaving the shared builder alone for the + * specs that rely on its current shape. + */ + function createDataView(): powerbi.DataView { + const dataBuilder: DataBuilder = new DataBuilder(); + + dataBuilder.seriesValues = [ + dataBuilder.dates.map((unused: Date, index: number) => index * 1.3), + ]; + + // The plot hides itself when no group is bound, so both roles are requested + return dataBuilder.getDataView(["Axis", "Values"]); + } + + function update( + objects: powerbi.DataViewObjects, + callback: (root: HTMLElement, visualBuilder: VisualBuilder) => void, + done: DoneFn, + width: number = 1024, + height: number = 768, + ): void { + const visualBuilder: VisualBuilder = new VisualBuilder(width, height); + + const dataView: powerbi.DataView = createDataView(); + + dataView.metadata.objects = objects; + + visualBuilder.updateRenderTimeout( + dataView, + () => { + callback(visualBuilder.element, visualBuilder); + + done(); + }, + ); + } + + // A report tile is often far smaller than the design surface, and that is where the + // space each part reserves stops adding up, so every size is exercised. + const sizes: Array<{ width: number; height: number }> = [ + { width: 1024, height: 768 }, + { width: 640, height: 480 }, + { width: 400, height: 300 }, + { width: 300, height: 200 }, + { width: 200, height: 150 }, + ]; + + const configurations: Array<{ name: string; objects: powerbi.DataViewObjects }> = [ + { name: "the default settings", objects: {} }, + { + name: "the X zoom slider on", + objects: { zoomSlider: { show: true, showForXAxis: true, showForYAxis: false } }, + }, + { + name: "the Y zoom slider on", + objects: { zoomSlider: { show: true, showForXAxis: false, showForYAxis: true } }, + }, + { + name: "both zoom sliders on", + objects: { zoomSlider: { show: true, showForXAxis: true, showForYAxis: true } }, + }, + ]; + + configurations.forEach((configuration) => { + sizes.forEach((size) => { + it(`should lay the plot out inside the visual with ${configuration.name} at ${size.width}x${size.height}`, (done) => { + update(configuration.objects, runLayoutChecks, done, size.width, size.height); + }); + }); + }); + + /** + * Narrowing the domain - a pinned Min/Max or a zoom range - leaves every point outside + * the range mapped through the scale all the same, so it lands far outside the drawing + * area. Only the clip stops it from being painted over the Y axis labels and past the + * edge of the tile. + * + * The invariant is checked on the clip itself rather than on the marks: a clip changes + * what is painted, but neither getBoundingClientRect nor a hit test reports it - boxes + * ignore clipping outright, and a thin line stroke is almost never sampled by a grid. + */ + function expectChartClippedToDrawingArea(root: HTMLElement): void { + const plotElement: HTMLElement = getPlotElement(root); + + const chartGroup: Element = plotElement.querySelector(".powerKpi_multiShapeComponent"); + const chartSvg: Element = plotElement.querySelector("svg.powerKpi_svgComponent"); + + const clipReference: string = chartGroup.getAttribute("clip-path") || ""; + + expect(clipReference).toMatch(/^url\(#powerKpi_chartClip_\d+\)$/); + + // "url(#id)" -> "#id" + const clipRect: Element = plotElement.querySelector(`${clipReference.slice(4, -1)} rect`); + + expect(clipRect).not.toBeNull(); + + // The clip rect lives in the SVG user space, whose origin is the content box + const svgRect: DOMRect = chartSvg.getBoundingClientRect(); + const paddingLeft: number = parseFloat(window.getComputedStyle(chartSvg).paddingLeft) || 0; + + const clipLeft: number = svgRect.left + paddingLeft + parseFloat(clipRect.getAttribute("x")); + const clipRight: number = clipLeft + parseFloat(clipRect.getAttribute("width")); + + expect(`clip starts inside the chart: ${clipLeft >= svgRect.left - tolerance}`) + .toBe("clip starts inside the chart: true"); + expect(`clip ends inside the chart: ${clipRight <= svgRect.right + tolerance}`) + .toBe("clip ends inside the chart: true"); + + // Which means nothing the chart paints can ever reach the Y axis strip + const yAxisRect: DOMRect = plotElement + .querySelector("svg.powerKpi_visualYAxis") + .getBoundingClientRect(); + + if (yAxisRect.width > 0) { + expect(`Y axis clear of the chart clip: ${yAxisRect.right <= clipLeft + tolerance}`) + .toBe("Y axis clear of the chart clip: true"); + } + } + + [ + { width: 1024, height: 768 }, + { width: 400, height: 300 }, + { width: 300, height: 200 }, + { width: 250, height: 180 }, + ].forEach((size) => { + it(`should confine the chart to the drawing area when the X axis is pinned at ${size.width}x${size.height}`, (done) => { + update( + // The data spans 2016-01-01 to 2016-01-10; this keeps the middle only + { xAxis: { minDate: "2016-01-04", maxDate: "2016-01-06" } }, + expectChartClippedToDrawingArea, + done, + size.width, + size.height, + ); + }); + + it(`should confine the chart to the drawing area after a zoom at ${size.width}x${size.height}`, (done) => { + update( + { zoomSlider: { show: true, showForXAxis: true, showForYAxis: false } }, + (root: HTMLElement, visualBuilder: VisualBuilder) => { + // Drives the visual's own zoom entry point, the one a finished drag calls + (visualBuilder.instance as any).applyZoom("x", { start: 0.4, end: 0.6 }); + + expectChartClippedToDrawingArea(root); + }, + done, + size.width, + size.height, + ); + }); + }); + + it("should actually narrow the axis when boundaries are set, so the check above is not vacuous", (done) => { + update( + { xAxis: { minDate: "2016-01-04", maxDate: "2016-01-06" } }, + (root: HTMLElement) => { + const labels: string[] = Array + .from(getPlotElement(root).querySelectorAll(".visualXAxisContainer .tick text")) + .map((element: Element) => element.textContent); + + expect(labels.length).toBeGreaterThan(0); + + // The data spans the 1st to the 10th; none of the days outside the + // requested range may still be labelled + ["1 ", "2 ", "8 ", "9 "].forEach((excludedDay: string) => { + expect(`${excludedDay}labelled: ${labels.some((label) => label.startsWith(excludedDay))}`) + .toBe(`${excludedDay}labelled: false`); + }); + }, + done, + 400, + 300, + ); + }); + + it("should keep each zoom slider inside the plot", (done) => { + update( + { zoomSlider: { show: true, showForXAxis: true, showForYAxis: true } }, + (root: HTMLElement) => { + const plotElement: HTMLElement = getPlotElement(root); + const plotRect: DOMRect = plotElement.getBoundingClientRect(); + + const sliders: Element[] = Array.from( + plotElement.querySelectorAll("svg.powerKpi_zoomSliderComponent"), + ); + + expect(sliders.length).toBe(2); + + sliders.forEach((slider: Element, index: number) => { + expectWithin(slider.getBoundingClientRect(), plotRect, `slider ${index}`); + }); + }, + done, + ); + }); +}); diff --git a/specs/zoomSlider.spec.ts b/specs/zoomSlider.spec.ts new file mode 100644 index 0000000..d94cd61 --- /dev/null +++ b/specs/zoomSlider.spec.ts @@ -0,0 +1,333 @@ +/* + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import powerbi from "powerbi-visuals-api"; +import { dispatch, Dispatch } from "d3-dispatch"; +import { select as d3Select } from "d3-selection"; +import { Selection } from "d3-selection"; + +import { testDom } from "powerbi-visuals-utils-testutils"; + +import { EventName } from "../src/event/eventName"; +import { Settings } from "../src/settings/settings"; +import { ZoomSliderAxis, ZoomSliderComponent } from "../src/visualComponent/zoomSliderComponent"; + +import { DataRepresentationTypeEnum } from "../src/dataRepresentation/dataRepresentationType"; + +import { + applyZoomToInterval, + createDefaultZoom, + isFullRange, + isZoomableAxisType, + IDataRepresentationZoomRange, +} from "../src/dataRepresentation/dataRepresentationZoom"; + +const viewport: powerbi.IViewport = { width: 400, height: 300 }; + +interface IRenderedSlider { + component: ZoomSliderComponent; + eventDispatcher: Dispatch; + svgElement: SVGSVGElement; +} + +/** + * The track is inset by one handle radius at each end so a handle at an extreme stays + * inside the slider; the expected pixel positions below account for that inset. + */ +const handleRadius: number = 5; + +function expectedPosition(fraction: number, length: number, isMirrored: boolean = false): number { + const positionAlongTrack: number = handleRadius + (fraction * (length - (2 * handleRadius))); + + return isMirrored + ? length - positionAlongTrack + : positionAlongTrack; +} + +function renderSlider( + axis: ZoomSliderAxis, + range: IDataRepresentationZoomRange, + isShown: boolean = true, + offset: number = 0, +): IRenderedSlider { + const rootElement: HTMLElement = testDom(viewport.height.toString(), viewport.width.toString()); + const element: Selection = d3Select(rootElement); + + const eventDispatcher: Dispatch = dispatch(...Object.keys(EventName)); + + const component: ZoomSliderComponent = new ZoomSliderComponent({ element, eventDispatcher }); + + component.render({ axis, isShown, offset, range, viewport }); + + return { + component, + eventDispatcher, + svgElement: rootElement.querySelector("svg"), + }; +} + +function getHandles(svgElement: SVGSVGElement): SVGCircleElement[] { + return Array.from(svgElement.querySelectorAll("circle")); +} + +describe("Zoom slider", () => { + describe("zoom range arithmetic", () => { + it("should leave an interval untouched for the full range", () => { + expect(applyZoomToInterval(0, 100, { start: 0, end: 1 })).toEqual({ min: 0, max: 100 }); + }); + + it("should keep the requested fraction of the interval", () => { + expect(applyZoomToInterval(0, 100, { start: 0.25, end: 0.75 })).toEqual({ min: 25, max: 75 }); + }); + + it("should reorder an inverted range instead of returning a reversed interval", () => { + expect(applyZoomToInterval(0, 100, { start: 0.75, end: 0.25 })).toEqual({ min: 25, max: 75 }); + }); + + it("should leave a degenerate interval untouched", () => { + expect(applyZoomToInterval(10, 10, { start: 0.25, end: 0.75 })).toEqual({ min: 10, max: 10 }); + expect(applyZoomToInterval(NaN, 100, { start: 0.25, end: 0.75 })).toEqual({ min: NaN, max: 100 } as any); + }); + + it("should recognise a missing or full range", () => { + expect(isFullRange(null)).toBe(true); + expect(isFullRange({ start: 0, end: 1 })).toBe(true); + expect(isFullRange({ start: 0.1, end: 1 })).toBe(false); + }); + + it("should default to the full range on both axes", () => { + expect(createDefaultZoom()).toEqual({ x: { start: 0, end: 1 }, y: { start: 0, end: 1 } }); + }); + }); + + describe("formatting card", () => { + it("should be off by default, so an existing report renders unchanged", () => { + const settings: Settings = new Settings(); + + expect(settings.zoomSlider.show.value).toBe(false); + expect(settings.zoomSlider.isShownForXAxis()).toBe(false); + expect(settings.zoomSlider.isShownForYAxis()).toBe(false); + }); + + it("should let each axis be toggled independently once the card is on", () => { + const settings: Settings = new Settings(); + + settings.zoomSlider.show.value = true; + + // The X axis is the one enabled by default within the card + expect(settings.zoomSlider.isShownForXAxis()).toBe(true); + expect(settings.zoomSlider.isShownForYAxis()).toBe(false); + + settings.zoomSlider.showForYAxis.value = true; + + expect(settings.zoomSlider.isShownForYAxis()).toBe(true); + }); + }); + + describe("axis types a zoom slider applies to", () => { + it("should accept a date or a numeric axis", () => { + expect(isZoomableAxisType(DataRepresentationTypeEnum.DateType)).toBe(true); + expect(isZoomableAxisType(DataRepresentationTypeEnum.NumberType)).toBe(true); + }); + + it("should refuse a categorical axis, which has no range to narrow", () => { + expect(isZoomableAxisType(DataRepresentationTypeEnum.StringType)).toBe(false); + expect(isZoomableAxisType(DataRepresentationTypeEnum.None)).toBe(false); + }); + + it("should hide the X axis toggle from the pane on a categorical axis", () => { + const settings: Settings = new Settings(); + + settings.filterFormattingProperties( + null, + DataRepresentationTypeEnum.StringType, + { getDisplayName: (key: string) => key } as any, + false, + ); + + expect(settings.zoomSlider.showForXAxis.visible).toBe(false); + // The Y axis is always numeric, so its toggle stays available + expect(settings.zoomSlider.showForYAxis.visible).not.toBe(false); + }); + + it("should keep the X axis toggle on a date axis", () => { + const settings: Settings = new Settings(); + + settings.filterFormattingProperties( + null, + DataRepresentationTypeEnum.DateType, + { getDisplayName: (key: string) => key } as any, + false, + ); + + expect(settings.zoomSlider.showForXAxis.visible).toBe(true); + }); + }); + + describe("rendering", () => { + it("should place the horizontal handles at the ends of the kept range", () => { + const { svgElement } = renderSlider(ZoomSliderAxis.x, { start: 0.25, end: 0.75 }); + + const [startHandle, endHandle] = getHandles(svgElement); + + expect(parseFloat(startHandle.getAttribute("cx"))).toBeCloseTo(expectedPosition(0.25, viewport.width), 1); + expect(parseFloat(endHandle.getAttribute("cx"))).toBeCloseTo(expectedPosition(0.75, viewport.width), 1); + }); + + it("should mirror the vertical handles, the start of the axis being its bottom", () => { + const { svgElement } = renderSlider(ZoomSliderAxis.y, { start: 0.25, end: 0.75 }); + + const [startHandle, endHandle] = getHandles(svgElement); + + expect(parseFloat(startHandle.getAttribute("cy"))).toBeCloseTo(expectedPosition(0.25, viewport.height, true), 1); + expect(parseFloat(endHandle.getAttribute("cy"))).toBeCloseTo(expectedPosition(0.75, viewport.height, true), 1); + }); + + it("should keep the handles inside the slider at both ends of the range", () => { + [ZoomSliderAxis.x, ZoomSliderAxis.y].forEach((axis: ZoomSliderAxis) => { + const { svgElement } = renderSlider(axis, { start: 0, end: 1 }); + + const isHorizontal: boolean = axis === ZoomSliderAxis.x; + const length: number = isHorizontal ? viewport.width : viewport.height; + + getHandles(svgElement).forEach((handle: SVGCircleElement) => { + const position: number = parseFloat(handle.getAttribute(isHorizontal ? "cx" : "cy")); + + expect(position - handleRadius).toBeGreaterThanOrEqual(0); + expect(position + handleRadius).toBeLessThanOrEqual(length); + }); + }); + }); + + it("should shift itself by the offset so it lines up with the drawing area", () => { + const horizontal = renderSlider(ZoomSliderAxis.x, { start: 0, end: 1 }, true, 42); + const vertical = renderSlider(ZoomSliderAxis.y, { start: 0, end: 1 }, true, 17); + + expect(horizontal.svgElement.style.marginLeft).toBe("42px"); + expect(horizontal.svgElement.style.marginTop).toBe(""); + + expect(vertical.svgElement.style.marginTop).toBe("17px"); + expect(vertical.svgElement.style.marginLeft).toBe(""); + }); + + it("should render a track and the kept range", () => { + const { svgElement } = renderSlider(ZoomSliderAxis.x, { start: 0, end: 1 }); + + expect(svgElement.querySelectorAll("line").length).toBe(2); + expect(getHandles(svgElement).length).toBe(2); + }); + + it("should reserve its band only on the axis it belongs to", () => { + const horizontal = renderSlider(ZoomSliderAxis.x, { start: 0, end: 1 }); + const vertical = renderSlider(ZoomSliderAxis.y, { start: 0, end: 1 }); + + expect(horizontal.component.getViewport().height).toBe(ZoomSliderComponent.Thickness); + expect(horizontal.component.getViewport().width).toBe(0); + + expect(vertical.component.getViewport().width).toBe(ZoomSliderComponent.Thickness); + expect(vertical.component.getViewport().height).toBe(0); + }); + + it("should reserve nothing while it is turned off", () => { + const { component } = renderSlider(ZoomSliderAxis.x, { start: 0, end: 1 }, false); + + expect(component.getViewport()).toEqual({ height: 0, width: 0 }); + }); + }); + + describe("dragging", () => { + function drag(rendered: IRenderedSlider, handleIndex: number, targetFraction: number): void { + const handle: SVGCircleElement = getHandles(rendered.svgElement)[handleIndex]; + const bounds: DOMRect = rendered.svgElement.getBoundingClientRect(); + + handle.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + + window.dispatchEvent(new PointerEvent("pointermove", { + bubbles: true, + clientX: bounds.left + (bounds.width * targetFraction), + clientY: bounds.top + (bounds.height * (1 - targetFraction)), + })); + + window.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })); + } + + it("should report the new range once the drag ends", () => { + const rendered: IRenderedSlider = renderSlider(ZoomSliderAxis.x, { start: 0, end: 1 }); + + let reportedAxis: ZoomSliderAxis = null; + let reportedRange: IDataRepresentationZoomRange = null; + + rendered.eventDispatcher.on(EventName.onZoom, (axis, range) => { + reportedAxis = axis; + reportedRange = range; + }); + + drag(rendered, 0, 0.4); + + expect(reportedAxis).toBe(ZoomSliderAxis.x); + expect(reportedRange.start).toBeCloseTo(0.4, 1); + expect(reportedRange.end).toBe(1); + }); + + it("should not report anything before the drag ends", () => { + const rendered: IRenderedSlider = renderSlider(ZoomSliderAxis.x, { start: 0, end: 1 }); + + let reportCount: number = 0; + + rendered.eventDispatcher.on(EventName.onZoom, () => { reportCount += 1; }); + + const handle: SVGCircleElement = getHandles(rendered.svgElement)[0]; + const bounds: DOMRect = rendered.svgElement.getBoundingClientRect(); + + handle.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + window.dispatchEvent(new PointerEvent("pointermove", { + bubbles: true, + clientX: bounds.left + (bounds.width * 0.4), + })); + + // The handle has moved, but the chart is only rebuilt on pointer up + expect(reportCount).toBe(0); + expect(parseFloat(getHandles(rendered.svgElement)[0].getAttribute("cx"))).toBeGreaterThan(0); + + window.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })); + + expect(reportCount).toBe(1); + }); + + it("should keep the handles from crossing", () => { + const rendered: IRenderedSlider = renderSlider(ZoomSliderAxis.x, { start: 0, end: 0.5 }); + + let reportedRange: IDataRepresentationZoomRange = null; + + rendered.eventDispatcher.on(EventName.onZoom, (_, range) => { reportedRange = range; }); + + // Drags the start handle well past the end handle + drag(rendered, 0, 0.9); + + expect(reportedRange.start).toBeLessThan(reportedRange.end); + }); + }); +}); diff --git a/src/converter/converterOptions.ts b/src/converter/converterOptions.ts index 37b6669..7f04a59 100644 --- a/src/converter/converterOptions.ts +++ b/src/converter/converterOptions.ts @@ -28,13 +28,15 @@ import powerbi from "powerbi-visuals-api"; import { Settings } from "../settings/settings"; import { AxisType } from "../settings/descriptors/axis/axisDescriptor"; - +import { IDataRepresentationZoom } from "../dataRepresentation/dataRepresentationZoom"; + export interface ConverterOptions { dataView: powerbi.DataView; viewport: powerbi.IViewport; hasSelection: boolean; settings: Settings; locale: string; + zoom?: IDataRepresentationZoom; } export interface AxisOptions { diff --git a/src/converter/dataConverter.ts b/src/converter/dataConverter.ts index c019dd5..5cb2bec 100644 --- a/src/converter/dataConverter.ts +++ b/src/converter/dataConverter.ts @@ -61,6 +61,13 @@ import { } from "../dataRepresentation/dataRepresentationPoint"; import { IDataRepresentationAxisBase } from "../dataRepresentation/dataRepresentationAxis"; +import { + applyZoomToInterval, + createDefaultZoom, + isFullRange, + isZoomableAxisType, + IDataRepresentationZoomRange, +} from "../dataRepresentation/dataRepresentationZoom"; import { DataRepresentationAxisValueType } from "../dataRepresentation/dataRepresentationAxisValueType"; import DataViewObjects = powerbi.DataViewObjects; @@ -132,7 +139,8 @@ export class DataConverter extends VarianceConverter implements IConverter { viewport, hasSelection, settings, - locale + locale, + zoom } = options; const { @@ -158,6 +166,7 @@ export class DataConverter extends VarianceConverter implements IConverter { variances: [], viewport, locale, + zoom: zoom || createDefaultZoom(), x: { axisType, format: undefined, @@ -393,6 +402,8 @@ export class DataConverter extends VarianceConverter implements IConverter { this.applyXAxisBoundaries(dataRepresentation, settings.xAxis); + this.applyXAxisZoom(dataRepresentation); + this.getXAxisScale( dataRepresentation.x.scale, dataRepresentation.x.min, @@ -469,8 +480,16 @@ export class DataConverter extends VarianceConverter implements IConverter { seriesGroup.y.max as number, ); - seriesGroup.y.min = Math.min(yMin, yMax); - seriesGroup.y.max = Math.max(yMin, yMax); + // The zoom range narrows what the author pinned, so zooming stays + // relative to the axis actually on screen + const zoomedY = applyZoomToInterval( + Math.min(yMin, yMax), + Math.max(yMin, yMax), + dataRepresentation.zoom?.y, + ); + + seriesGroup.y.min = zoomedY.min; + seriesGroup.y.max = zoomedY.max; seriesGroup.y.scale.domain( [seriesGroup.y.min, seriesGroup.y.max], @@ -705,10 +724,35 @@ export class DataConverter extends VarianceConverter implements IConverter { } /** - * Replaces the computed X boundaries with the ones pinned in the X axis card, - * the same way postProcess applies the Y axis boundaries. A boundary left empty - * keeps its computed value, and an inverted pair is reordered so that the scale - * never receives a reversed domain. + * Narrows the X domain to the range the zoom slider keeps. A continuous axis is + * narrowed arithmetically - dates through their time value - while a categorical + * axis is handled by getZoomedCategories, its domain being the category list. + */ + private applyXAxisZoom(dataRepresentation: IDataRepresentation): void { + const range: IDataRepresentationZoomRange = dataRepresentation.zoom.x; + const type: DataRepresentationTypeEnum = dataRepresentation.x.axisType; + + if (isFullRange(range) || !isZoomableAxisType(type)) { + return; + } + + const zoomed = applyZoomToInterval( + Number(dataRepresentation.x.min), + Number(dataRepresentation.x.max), + range, + ); + + const isDate: boolean = type === DataRepresentationTypeEnum.DateType; + + dataRepresentation.x.min = isDate ? new Date(zoomed.min) : zoomed.min; + dataRepresentation.x.max = isDate ? new Date(zoomed.max) : zoomed.max; + } + + /** + * Replaces the computed X boundaries with the ones pinned in the X axis card, the + * same way postProcess applies the Y axis boundaries. A boundary left empty keeps its + * computed value, and an inverted pair is reordered so that the scale never receives + * a reversed domain. */ private applyXAxisBoundaries( dataRepresentation: IDataRepresentation, diff --git a/src/dataRepresentation/dataRepresentation.ts b/src/dataRepresentation/dataRepresentation.ts index 8613bcc..7a1cb11 100644 --- a/src/dataRepresentation/dataRepresentation.ts +++ b/src/dataRepresentation/dataRepresentation.ts @@ -34,6 +34,7 @@ import { import { Settings } from "../settings/settings"; import { IDataRepresentationX } from "./dataRepresentationAxis"; +import { IDataRepresentationZoom } from "./dataRepresentationZoom"; export interface IDataRepresentation { series: IDataRepresentationSeries[]; @@ -47,4 +48,7 @@ export interface IDataRepresentation { x: IDataRepresentationX; locale: string; isGrouped?: boolean; + // The range each zoom slider keeps. Held by the visual across renders rather + // than persisted, so it behaves like the zoom of the built in visuals + zoom: IDataRepresentationZoom; } diff --git a/src/dataRepresentation/dataRepresentationZoom.ts b/src/dataRepresentation/dataRepresentationZoom.ts new file mode 100644 index 0000000..f1c090e --- /dev/null +++ b/src/dataRepresentation/dataRepresentationZoom.ts @@ -0,0 +1,89 @@ +/** + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import { DataRepresentationTypeEnum } from "./dataRepresentationType"; + +/** + * The range a zoom slider keeps, as fractions of the full axis range: 0 is the + * start of the axis and 1 its end. It is view state rather than data, so it is + * held by the visual for the lifetime of the session and never persisted. + */ +export interface IDataRepresentationZoomRange { + start: number; + end: number; +} + +export interface IDataRepresentationZoom { + x: IDataRepresentationZoomRange; + y: IDataRepresentationZoomRange; +} + +export const fullZoomRange: IDataRepresentationZoomRange = { + end: 1, + start: 0, +}; + +export function createDefaultZoom(): IDataRepresentationZoom { + return { + x: { ...fullZoomRange }, + y: { ...fullZoomRange }, + }; +} + +/** + * A zoom slider narrows a continuous range, so it only applies to a date or numeric axis. + * A categorical axis has no range to narrow - its domain is the list of categories. + */ +export function isZoomableAxisType(type: DataRepresentationTypeEnum): boolean { + return type === DataRepresentationTypeEnum.DateType + || type === DataRepresentationTypeEnum.NumberType; +} + +export function isFullRange(range: IDataRepresentationZoomRange): boolean { + return !range + || (range.start <= fullZoomRange.start && range.end >= fullZoomRange.end); +} + +/** + * Applies a zoom range to a numeric interval. Dates are handled by their numeric + * time value, so the same arithmetic covers both continuous axis types. + */ +export function applyZoomToInterval( + min: number, + max: number, + range: IDataRepresentationZoomRange, +): { min: number; max: number } { + if (isFullRange(range) || !isFinite(min) || !isFinite(max) || max <= min) { + return { max, min }; + } + + const span: number = max - min; + + return { + max: min + (span * Math.min(1, Math.max(range.start, range.end))), + min: min + (span * Math.max(0, Math.min(range.start, range.end))), + }; +} diff --git a/src/event/eventName.ts b/src/event/eventName.ts index af07c1f..2d71dc7 100644 --- a/src/event/eventName.ts +++ b/src/event/eventName.ts @@ -30,4 +30,5 @@ export enum EventName { onClearSelection = "onClearSelection", onHighlight = "onHighlight", onContextMenu = "onContextMenu", + onZoom = "onZoom", } diff --git a/src/settings/descriptors/zoomSliderDescriptor.ts b/src/settings/descriptors/zoomSliderDescriptor.ts new file mode 100644 index 0000000..da1b093 --- /dev/null +++ b/src/settings/descriptors/zoomSliderDescriptor.ts @@ -0,0 +1,62 @@ +/** + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import { formattingSettings } from "powerbi-visuals-utils-formattingmodel"; + +import { ShowDescriptor } from "./autoHiding/showDescriptor"; + +export class ZoomSliderDescriptor extends ShowDescriptor { + public showForXAxis = new formattingSettings.ToggleSwitch({ + name: "showForXAxis", + displayNameKey: "Visual_X_Axis", + value: true + }); + + public showForYAxis = new formattingSettings.ToggleSwitch({ + name: "showForYAxis", + displayNameKey: "Visual_Y_Axis", + value: false + }); + + constructor() { + super(); + + // Off by default, so a report built before this feature renders unchanged + this.show.value = false; + + this.name = "zoomSlider"; + this.displayNameKey = "Visual_Zoom_Slider"; + this.slices = [this.showForXAxis, this.showForYAxis]; + } + + public isShownForXAxis(): boolean { + return this.isElementShown() && this.showForXAxis.value; + } + + public isShownForYAxis(): boolean { + return this.isElementShown() && this.showForYAxis.value; + } +} diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 6132a24..f9dc8f5 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -48,6 +48,7 @@ import { LayoutDescriptor } from "./descriptors/layoutDescriptor"; import { LegendDescriptor } from "./descriptors/legendDescriptor"; import { LineDescriptor, IKeyedContainerItem } from "./descriptors/line/lineDescriptor"; import { SubtitleDescriptor } from "./descriptors/subtitleDescriptor"; +import { ZoomSliderDescriptor } from "./descriptors/zoomSliderDescriptor"; import { XAxisDescriptor } from "./descriptors/axis/xAxisDescriptor"; import { YAxisDescriptor } from "./descriptors/axis/yAxisDescriptor"; import { AxisReferenceLineDescriptor } from "./descriptors/axis/referenceLine/axisReferenceLineDescriptor"; @@ -57,6 +58,7 @@ import { TooltipValueDescriptor } from "./descriptors/tooltip/tooltipValueDescri import { IDataRepresentation } from "../dataRepresentation/dataRepresentation"; import { LineType } from "./descriptors/line/lineTypes"; import { DataRepresentationTypeEnum } from "../dataRepresentation/dataRepresentationType"; +import { isZoomableAxisType } from "../dataRepresentation/dataRepresentationZoom"; const kpiCaptionViewport: powerbi.IViewport = { height: 90, @@ -154,6 +156,7 @@ export class Settings extends formattingSettings.Model { "Visual_Second_Tooltip_KPI_Indicator_Value" ); public tooltipValues: TooltipValueDescriptor = new TooltipValueDescriptor(); + public zoomSlider: ZoomSliderDescriptor = new ZoomSliderDescriptor(); public cards = [ this.layout, this.subtitle, this.kpiIndicator, this.kpiIndicatorValue, this.kpiIndicatorLabel, this.secondKPIIndicatorValue, this.secondKPIIndicatorLabel, @@ -161,7 +164,7 @@ export class Settings extends formattingSettings.Model { this.labels, this.line, this.legend, this.xAxis, this.yAxis, this.secondaryYAxis, this.referenceLineOfXAxis, this.referenceLineOfYAxis, this.secondaryReferenceLineOfYAxis, this.tooltipLabel, this.tooltipVariance, - this.secondTooltipVariance, this.tooltipValues + this.secondTooltipVariance, this.tooltipValues, this.zoomSlider ] constructor() { @@ -198,6 +201,7 @@ export class Settings extends formattingSettings.Model { this.filterKPIIndicatorProperties(dataRepresentation); this.filterKPIIndicatorValueProperties(); this.filterYAxisProperties(); + this.filterZoomSliderProperties(axisType); this.filterSettingsPropertiesByAxisType(axisType); this.setLocalizedDisplayNames(localizationManager); this.hideColorPickers(isHighContrast); @@ -274,6 +278,11 @@ export class Settings extends formattingSettings.Model { }); } + private filterZoomSliderProperties(axisType: DataRepresentationTypeEnum) { + // Zooming narrows a continuous range, so a categorical X axis is not offered one + this.zoomSlider.showForXAxis.visible = isZoomableAxisType(axisType); + } + private filterSettingsPropertiesByAxisType(axisType: DataRepresentationTypeEnum) { const settingsToFilterByAxis = [ this.kpiIndicatorValue, diff --git a/src/visual.ts b/src/visual.ts index d60e7a7..2f61e41 100644 --- a/src/visual.ts +++ b/src/visual.ts @@ -41,6 +41,12 @@ import { IConverter } from "./converter/converter"; import { DataConverter } from "./converter/dataConverter"; import { IDataRepresentation } from "./dataRepresentation/dataRepresentation"; import { EventName } from "./event/eventName"; +import { + createDefaultZoom, + IDataRepresentationZoom, + IDataRepresentationZoomRange, +} from "./dataRepresentation/dataRepresentationZoom"; +import { ZoomSliderAxis } from "./visualComponent/zoomSliderComponent"; import { IVisualComponent } from "./visualComponent/base/visualComponent"; import { IVisualComponentRenderOptions } from "./visualComponent/base/visualComponentRenderOptions"; import { MainComponent } from "./visualComponent/mainComponent"; @@ -83,6 +89,8 @@ export class PowerKPI implements IVisual { private localizationManager: ILocalizationManager; private settings: Settings; + private zoom: IDataRepresentationZoom = createDefaultZoom(); + private updateOptions: VisualUpdateOptions; private formattingSettingsService: FormattingSettingsService; private colorPalette: ISandboxExtendedColorPalette; private host: IVisualHost; @@ -114,10 +122,32 @@ export class PowerKPI implements IVisual { rootElement, tooltipService: options.host.tooltipService, }); + + this.eventDispatcher.on( + EventName.onZoom, + (axis: ZoomSliderAxis, range: IDataRepresentationZoomRange) => this.applyZoom(axis, range), + ); } public update(options: VisualUpdateOptions): void { this.events.renderingStarted(options); + + // A zoom range only makes sense against the data it was picked on, so it is + // reset whenever Power BI pushes a new update + this.zoom = createDefaultZoom(); + this.updateOptions = options; + + this.convertAndRender(options); + + this.events.renderingFinished(options); + } + + /** + * Rebuilds the data representation and renders it. The viewport is reduced in + * place while a render walks the component tree, so a re-render has to start from + * a freshly converted representation rather than from the previous one. + */ + private convertAndRender(options: VisualUpdateOptions): void { this.settings = this.formattingSettingsService.populateFormattingSettingsModel(Settings, options.dataViews[0]); const dataView: powerbi.DataView = options && options.dataViews && options.dataViews[0]; @@ -141,7 +171,8 @@ export class PowerKPI implements IVisual { hasSelection: this.interactivityService && this.interactivityService.hasSelection(), viewport, settings: this.settings, - locale: this.host.locale + locale: this.host.locale, + zoom: this.zoom }); if (this.interactivityService) { this.interactivityService.applySelectionStateToData(dataRepresentation.series); @@ -157,7 +188,19 @@ export class PowerKPI implements IVisual { } this.render(dataRepresentation); - this.events.renderingFinished(options); + } + + private applyZoom(axis: ZoomSliderAxis, range: IDataRepresentationZoomRange): void { + if (!this.updateOptions) { + return; + } + + this.zoom = { + ...this.zoom, + [axis]: range, + }; + + this.convertAndRender(this.updateOptions); } public render(dataRepresentation: IDataRepresentation): void { diff --git a/src/visualComponent/chartComponent.ts b/src/visualComponent/chartComponent.ts index 2f57e2a..969c8ee 100644 --- a/src/visualComponent/chartComponent.ts +++ b/src/visualComponent/chartComponent.ts @@ -75,6 +75,16 @@ export class ChartComponent extends BaseContainerComponent< } } + /** + * Restricts the marks to the given clip path. The domain can be narrower than the + * data - a pinned boundary or a zoom range - and a point outside it is still mapped + * through the scale, so without this it would be painted over the axis and past the + * edge of the visual. + */ + public applyClipPath(clipPathId: string): void { + this.element.attr("clip-path", `url(#${clipPathId})`); + } + public render(options: IVisualComponentRenderOptions): void { const { data: { sortedSeries, viewport, x, settings }, colorPalette } = options; diff --git a/src/visualComponent/plotComponent.ts b/src/visualComponent/plotComponent.ts index fb596cc..6362006 100644 --- a/src/visualComponent/plotComponent.ts +++ b/src/visualComponent/plotComponent.ts @@ -51,15 +51,25 @@ import { SvgComponent, } from "./svgComponent"; +import { + IZoomSliderComponentRenderOptions, + ZoomSliderAxis, + ZoomSliderComponent, +} from "./zoomSliderComponent"; + +import { isZoomableAxisType } from "../dataRepresentation/dataRepresentationZoom"; + export class PlotComponent extends BaseContainerComponent< IVisualComponentConstructorOptions, IVisualComponentRenderOptions, - IVisualComponentRenderOptions | IXAxisComponentRenderOptions | IYAxisComponentRenderOptions + IVisualComponentRenderOptions | IXAxisComponentRenderOptions | IYAxisComponentRenderOptions | IZoomSliderComponentRenderOptions > { private xAxisComponent: IAxisComponent; private yAxisComponent: IAxisComponent; private secondaryYAxisComponent: IAxisComponent; private svgComponent: IVisualComponent; + private xZoomSliderComponent: ZoomSliderComponent; + private yZoomSliderComponent: ZoomSliderComponent; private additionalWidthOffset: number = 5; @@ -78,16 +88,23 @@ export class PlotComponent extends BaseContainerComponent< this.hide(); + // The plot lays its children out in DOM order, so the vertical zoom slider is + // created first to sit left of the Y axis, and the horizontal one last so that + // it wraps onto its own full width row underneath the X axis. + this.yZoomSliderComponent = new ZoomSliderComponent(this.constructorOptions); this.yAxisComponent = new YAxisComponent(this.constructorOptions); this.svgComponent = new SvgComponent(this.constructorOptions); this.secondaryYAxisComponent = new YAxisComponent(this.constructorOptions); this.xAxisComponent = new XAxisComponent(this.constructorOptions); + this.xZoomSliderComponent = new ZoomSliderComponent(this.constructorOptions); this.components = [ + this.yZoomSliderComponent, this.yAxisComponent, this.svgComponent, this.secondaryYAxisComponent, this.xAxisComponent, + this.xZoomSliderComponent, ]; } @@ -100,10 +117,12 @@ export class PlotComponent extends BaseContainerComponent< groups: [firstGroup, secondGroup], viewport, locale, + zoom, settings: { xAxis, yAxis, secondaryYAxis, + zoomSlider, }, }, colorPalette @@ -158,9 +177,24 @@ export class PlotComponent extends BaseContainerComponent< this.secondaryYAxisComponent.getViewport().height, ); + // Each slider is rendered once the space it reserves is known; its own band is + // taken out of the chart area first so the two never overlap. The horizontal one + // is offered only on an axis that has a range to narrow, never on a categorical + // one, and the guard is applied here as well as in the pane: the field can be + // swapped for a text column while the setting stays on. + const isXAxisZoomable: boolean = isZoomableAxisType(x.axisType); + + const xZoomSliderHeight: number = zoomSlider.isShownForXAxis() && isXAxisZoomable + ? ZoomSliderComponent.Thickness + : 0; + + const yZoomSliderWidth: number = zoomSlider.isShownForYAxis() + ? ZoomSliderComponent.Thickness + : 0; + const height: number = Math.max( 0, - reducedViewport.height - xAxisViewport.height - maxYAxisHeight, + reducedViewport.height - xAxisViewport.height - maxYAxisHeight - xZoomSliderHeight, ); this.yAxisComponent.render({ @@ -198,13 +232,41 @@ export class PlotComponent extends BaseContainerComponent< - yAxisViewport.width - secondaryYAxisViewport.width - leftOffset - - rightOffset, + - rightOffset + - yZoomSliderWidth, ); + // Both sliders span the drawing area rather than the whole plot: the same + // margins the chart applies are taken off their length, and the space the + // components before them occupy is added back as an offset. Without it the + // horizontal slider would start under the Y axis labels and the vertical one + // would start above the top of the chart. + this.yZoomSliderComponent.render({ + axis: ZoomSliderAxis.y, + isShown: zoomSlider.isShownForYAxis(), + offset: margin.top, + range: zoom.y, + viewport: { + height: Math.max(0, height - margin.top - margin.bottom), + width: yZoomSliderWidth, + }, + }); + + this.xZoomSliderComponent.render({ + axis: ZoomSliderAxis.x, + isShown: zoomSlider.isShownForXAxis() && isXAxisZoomable, + offset: yZoomSliderWidth + yAxisViewport.width + leftOffset + margin.left, + range: zoom.x, + viewport: { + height: xZoomSliderHeight, + width: Math.max(0, width - margin.left - margin.right), + }, + }); + this.xAxisComponent.render({ additionalMargin: { bottom: 0, - left: yAxisViewport.width + leftOffset, + left: yZoomSliderWidth + yAxisViewport.width + leftOffset, right: 0, top: 0, }, @@ -248,6 +310,8 @@ export class PlotComponent extends BaseContainerComponent< this.yAxisComponent = null; this.secondaryYAxisComponent = null; this.svgComponent = null; + this.xZoomSliderComponent = null; + this.yZoomSliderComponent = null; } private getOffset(xAxisWidth: number, yAxisWidth: number): number { diff --git a/src/visualComponent/svgComponent.ts b/src/visualComponent/svgComponent.ts index d64f9bb..6ece89c 100644 --- a/src/visualComponent/svgComponent.ts +++ b/src/visualComponent/svgComponent.ts @@ -65,6 +65,10 @@ export interface ISvgComponentRenderOptions extends IVisualComponentRenderOption additionalMargin: IMargin; } +// Power BI renders every tile of a page into the same document, so the clip path of one +// visual would capture the charts of the others if the id were shared. +let svgComponentCount: number = 0; + export class SvgComponent extends BaseContainerComponent< IVisualComponentConstructorOptions, ISvgComponentRenderOptions, @@ -76,7 +80,8 @@ export class SvgComponent extends BaseContainerComponent< private yAxisReferenceLineComponent: IVisualComponent; private secondaryYAxisReferenceLineComponent: IVisualComponent; - private chartComponent: IVisualComponent; + private chartComponent: ChartComponent; + private chartClipRectElement: Selection; private labelsComponent: IVisualComponent; private dynamicComponents: Array> = []; @@ -125,6 +130,16 @@ export class SvgComponent extends BaseContainerComponent< }), ]; + const clipPathId: string = `powerKpi_chartClip_${svgComponentCount++}`; + + this.chartClipRectElement = this.element + .append("defs") + .append("clipPath") + .attr("id", clipPathId) + .append("rect"); + + this.chartComponent.applyClipPath(clipPathId); + this.bindEvents(); if (this.constructorOptions.eventDispatcher) { @@ -161,6 +176,7 @@ export class SvgComponent extends BaseContainerComponent< .attr("height", reducedViewport.height); this.updateMargin(margin, additionalMargin); + this.updateChartClip(reducedViewport, margin); this.positions = this.getPositions(reducedViewport, values, scale); @@ -232,6 +248,19 @@ export class SvgComponent extends BaseContainerComponent< this.element.on("touchend", () => this.pointerLeaveHandler()); } + /** + * Sizes the area the marks are confined to. It is the drawing area grown by the + * margin the converter reserves for dot radii, so a dot sitting on the very first or + * last position keeps its overhang, exactly as it does without a clip. + */ + private updateChartClip(reducedViewport: powerbi.IViewport, margin: IMargin): void { + this.chartClipRectElement + .attr("x", -margin.left) + .attr("y", -margin.top) + .attr("width", Math.max(0, reducedViewport.width + margin.left + margin.right)) + .attr("height", Math.max(0, reducedViewport.height + margin.top + margin.bottom)); + } + private updateMargin(margin: IMargin, additionalMargin: IMargin): void { this.element .style("padding-top", pixelConverter.toString(margin.top + additionalMargin.top)) diff --git a/src/visualComponent/zoomSliderComponent.ts b/src/visualComponent/zoomSliderComponent.ts new file mode 100644 index 0000000..e0e88c6 --- /dev/null +++ b/src/visualComponent/zoomSliderComponent.ts @@ -0,0 +1,323 @@ +/** + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import powerbi from "powerbi-visuals-api"; +import { Selection } from "d3-selection"; +import { pixelConverter } from "powerbi-visuals-utils-typeutils"; + +import { EventName } from "../event/eventName"; +import { BaseComponent } from "./base/baseComponent"; +import { IVisualComponentConstructorOptions } from "./base/visualComponentConstructorOptions"; +import { IVisualComponentViewport } from "./base/visualComponent"; + +import { + IDataRepresentationZoomRange, + fullZoomRange, +} from "../dataRepresentation/dataRepresentationZoom"; + +export enum ZoomSliderAxis { + x = "x", + y = "y", +} + +export interface IZoomSliderComponentRenderOptions { + axis: ZoomSliderAxis; + isShown: boolean; + range: IDataRepresentationZoomRange; + viewport: powerbi.IViewport; + // Distance between the start of the plot and the start of the drawing area, so the + // slider lines up with the axis it drives rather than with the plot container + offset: number; +} + +type Handle = "start" | "end"; + +export class ZoomSliderComponent extends BaseComponent< + IVisualComponentConstructorOptions, + IZoomSliderComponentRenderOptions +> { + /** + * The band the slider occupies across the axis it belongs to. It matches the + * footprint the built in visuals reserve for their zoom slider. + */ + public static readonly Thickness: number = 20; + + private static readonly HandleRadius: number = 5; + private static readonly TrackThickness: number = 2; + + /** + * The two handles may not cross, and a fully collapsed range would leave the + * chart with an empty domain, so they keep this much of the axis between them. + */ + private static readonly MinimumRange: number = 0.02; + + private className: string = "zoomSliderComponent"; + + private trackElement: Selection; + private rangeElement: Selection; + private startHandleElement: Selection; + private endHandleElement: Selection; + + private currentRange: IDataRepresentationZoomRange = { ...fullZoomRange }; + private axis: ZoomSliderAxis = ZoomSliderAxis.x; + private length: number = 0; + + private draggedHandle: Handle = null; + private readonly onPointerMove = (event: PointerEvent) => this.handlePointerMove(event); + private readonly onPointerUp = () => this.handlePointerUp(); + + constructor(options: IVisualComponentConstructorOptions) { + super(); + + this.initElement( + options.element, + this.className, + "svg", + ); + + this.constructorOptions = { + ...options, + element: this.element, + }; + + this.trackElement = this.element.append("line").classed(this.getClassNameWithPrefix("zoomSlider_track"), true); + this.rangeElement = this.element.append("line").classed(this.getClassNameWithPrefix("zoomSlider_range"), true); + + this.startHandleElement = this.appendHandle("start"); + this.endHandleElement = this.appendHandle("end"); + } + + public render(options: IZoomSliderComponentRenderOptions): void { + const { axis, isShown, range, viewport, offset } = options; + + this.renderOptions = options; + + if (!isShown) { + this.hide(); + + return; + } + + this.show(); + + this.axis = axis; + this.currentRange = { ...range }; + + const isHorizontal: boolean = axis === ZoomSliderAxis.x; + + this.length = Math.max(0, isHorizontal ? viewport.width : viewport.height); + + this.updateViewport({ + height: isHorizontal ? ZoomSliderComponent.Thickness : this.length, + width: isHorizontal ? this.length : ZoomSliderComponent.Thickness, + }); + + // The slider is a plain flex item, so it is shifted into place with a margin on + // the side it runs along, leaving the other side untouched + this.element + .style("margin-left", isHorizontal ? pixelConverter.toString(offset) : null) + .style("margin-top", isHorizontal ? null : pixelConverter.toString(offset)); + + this.updateGeometry(); + } + + public getViewport(): IVisualComponentViewport { + const isHorizontal: boolean = this.axis === ZoomSliderAxis.x; + + if (!this.isShown) { + return { height: 0, width: 0 }; + } + + return { + height: isHorizontal ? ZoomSliderComponent.Thickness : 0, + width: isHorizontal ? 0 : ZoomSliderComponent.Thickness, + }; + } + + public destroy(): void { + this.detachDragListeners(); + + this.trackElement = null; + this.rangeElement = null; + this.startHandleElement = null; + this.endHandleElement = null; + + super.destroy(); + } + + private appendHandle(handle: Handle): Selection { + return this.element + .append("circle") + .classed(this.getClassNameWithPrefix("zoomSlider_handle"), true) + .attr("r", ZoomSliderComponent.HandleRadius) + .on("pointerdown", (event: PointerEvent) => this.handlePointerDown(event, handle)); + } + + /** + * Places the track, the kept range and both handles. A fraction of 0 is the start + * of the axis; on a vertical slider that is the bottom, so the pixel position is + * mirrored to match the direction the Y axis grows in. + */ + private updateGeometry(): void { + const isHorizontal: boolean = this.axis === ZoomSliderAxis.x; + const center: number = ZoomSliderComponent.Thickness / 2; + + const trackStart: number = this.getPosition(0); + const trackEnd: number = this.getPosition(1); + + const startPosition: number = this.getPosition(this.currentRange.start); + const endPosition: number = this.getPosition(this.currentRange.end); + + if (isHorizontal) { + this.setLine(this.trackElement, trackStart, center, trackEnd, center); + this.setLine(this.rangeElement, startPosition, center, endPosition, center); + + this.startHandleElement.attr("cx", startPosition).attr("cy", center); + this.endHandleElement.attr("cx", endPosition).attr("cy", center); + } else { + this.setLine(this.trackElement, center, trackStart, center, trackEnd); + this.setLine(this.rangeElement, center, startPosition, center, endPosition); + + this.startHandleElement.attr("cx", center).attr("cy", startPosition); + this.endHandleElement.attr("cx", center).attr("cy", endPosition); + } + + this.element + .attr("stroke-width", ZoomSliderComponent.TrackThickness); + } + + private setLine( + element: Selection, + x1: number, + y1: number, + x2: number, + y2: number, + ): void { + element + .attr("x1", x1) + .attr("y1", y1) + .attr("x2", x2) + .attr("y2", y2); + } + + /** + * The track is inset by one handle radius at each end: a handle sitting on the very + * first or last position would otherwise be drawn half outside the slider. + */ + private getUsableLength(length: number): number { + return Math.max(0, length - (2 * ZoomSliderComponent.HandleRadius)); + } + + private getPosition(fraction: number): number { + const clamped: number = Math.max(0, Math.min(1, fraction)); + + const positionAlongTrack: number = ZoomSliderComponent.HandleRadius + + (clamped * this.getUsableLength(this.length)); + + return this.axis === ZoomSliderAxis.x + ? positionAlongTrack + : this.length - positionAlongTrack; + } + + private getFraction(event: PointerEvent): number { + const bounds: DOMRect = (this.element.node() as SVGElement).getBoundingClientRect(); + + const isHorizontal: boolean = this.axis === ZoomSliderAxis.x; + + const usableLength: number = this.getUsableLength(isHorizontal ? bounds.width : bounds.height); + + if (usableLength <= 0) { + return 0; + } + + const positionAlongTrack: number = isHorizontal + ? event.clientX - bounds.left - ZoomSliderComponent.HandleRadius + : bounds.bottom - event.clientY - ZoomSliderComponent.HandleRadius; + + return positionAlongTrack / usableLength; + } + + private handlePointerDown(event: PointerEvent, handle: Handle): void { + event.preventDefault(); + event.stopPropagation(); + + this.draggedHandle = handle; + + window.addEventListener("pointermove", this.onPointerMove); + window.addEventListener("pointerup", this.onPointerUp); + } + + /** + * The handle follows the pointer straight away, but the chart itself is only + * rebuilt once the drag ends: rebuilding it on every move would re-run the whole + * data pipeline for each frame. + */ + private handlePointerMove(event: PointerEvent): void { + if (!this.draggedHandle) { + return; + } + + const fraction: number = this.getFraction(event); + + if (this.draggedHandle === "start") { + this.currentRange.start = Math.max( + 0, + Math.min(fraction, this.currentRange.end - ZoomSliderComponent.MinimumRange), + ); + } else { + this.currentRange.end = Math.min( + 1, + Math.max(fraction, this.currentRange.start + ZoomSliderComponent.MinimumRange), + ); + } + + this.updateGeometry(); + } + + private handlePointerUp(): void { + if (!this.draggedHandle) { + return; + } + + this.draggedHandle = null; + + this.detachDragListeners(); + + if (this.constructorOptions && this.constructorOptions.eventDispatcher) { + this.constructorOptions.eventDispatcher.call( + EventName.onZoom, + undefined, + this.axis, + { ...this.currentRange }, + ); + } + } + + private detachDragListeners(): void { + window.removeEventListener("pointermove", this.onPointerMove); + window.removeEventListener("pointerup", this.onPointerUp); + } +} diff --git a/stringResources/en-US/resources.resjson b/stringResources/en-US/resources.resjson index cdcbd1f..1cfd25c 100644 --- a/stringResources/en-US/resources.resjson +++ b/stringResources/en-US/resources.resjson @@ -64,6 +64,7 @@ "Visual_Min": "Min", "Visual_Max": "Max", "Visual_Axis_Date_Boundary_Description": "Date boundary, for example 2024-01-31. Leave empty to fit the axis to the data.", + "Visual_Zoom_Slider": "Zoom Slider", "Visual_Label_Area_Sizing": "Label Area Sizing", "Visual_Label_Area_Sizing_Description": "Auto sizes the label area to the rendered labels. Fixed reserves a constant width so the plot area keeps the same position when the data changes.", "Visual_Label_Area_Width": "Label Area Width (px)", diff --git a/styles/styles.less b/styles/styles.less index aa42cee..b328fe7 100644 --- a/styles/styles.less +++ b/styles/styles.less @@ -371,6 +371,26 @@ .flexOrder(1); } + .powerKpi_zoomSliderComponent { + // The track is inset by a handle radius, so nothing needs to bleed outside + overflow: hidden; + flex-shrink: 0; + + .powerKpi_zoomSlider_track { + stroke: #d9d9d9; + } + + .powerKpi_zoomSlider_range { + stroke: #909090; + } + + .powerKpi_zoomSlider_handle { + fill: #ffffff; + stroke: #909090; + cursor: pointer; + } + } + .powerKpi_verticalLineComponent { .verticalLine { stroke: #666;