diff --git a/CHANGELOG.md b/CHANGELOG.md index ae556a9..9919462 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,26 @@ +## 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 + +## 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 + ## 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..0685ec1 100644 --- a/capabilities.json +++ b/capabilities.json @@ -884,7 +884,8 @@ "type": { "enumeration": [ { "value": "line" }, - { "value": "area"}] + { "value": "area"}, + { "value": "scatter"}] } }, "thickness": { @@ -1037,6 +1038,25 @@ } } }, + "zoomSlider": { + "properties": { + "show": { + "type": { + "bool": true + } + }, + "showForXAxis": { + "type": { + "bool": true + } + }, + "showForYAxis": { + "type": { + "bool": true + } + } + } + }, "xAxis": { "properties": { "show": { @@ -1095,6 +1115,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 } } }, @@ -1162,6 +1210,23 @@ }, "placeHolderText": "Auto", "suppressFormatPainterCopy": true + }, + "fixedLabelWidthMode": { + "type": { + "enumeration": [ + { + "value": "auto" + }, + { + "value": "fixed" + } + ] + } + }, + "fixedLabelWidth": { + "type": { + "numeric": true + } } } }, @@ -1229,6 +1294,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..0fae626 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.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@microsoft/powerbi-visuals-powerkpi", - "version": "3.1.1.0", + "version": "3.1.5.0", "license": "MIT", "dependencies": { "d3-array": "^3.2.4", diff --git a/package.json b/package.json index 5bb37d6..0708bbb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/powerbi-visuals-powerkpi", - "version": "3.1.1.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 7d7b185..15064c0 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.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/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/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/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/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 3b1b2a9..5cb2bec 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"; @@ -60,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; @@ -131,7 +139,8 @@ export class DataConverter extends VarianceConverter implements IConverter { viewport, hasSelection, settings, - locale + locale, + zoom } = options; const { @@ -157,6 +166,7 @@ export class DataConverter extends VarianceConverter implements IConverter { variances: [], viewport, locale, + zoom: zoom || createDefaultZoom(), x: { axisType, format: undefined, @@ -390,6 +400,10 @@ export class DataConverter extends VarianceConverter implements IConverter { dataRepresentation.x.values = axisCategory.values as DataRepresentationAxisValueType[]; + this.applyXAxisBoundaries(dataRepresentation, settings.xAxis); + + this.applyXAxisZoom(dataRepresentation); + this.getXAxisScale( dataRepresentation.x.scale, dataRepresentation.x.min, @@ -466,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], @@ -701,6 +723,62 @@ export class DataConverter extends VarianceConverter implements IConverter { : kpiIndex; } + /** + * 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, + 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/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/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/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/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/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 4b90e5f..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() { @@ -197,6 +200,8 @@ export class Settings extends formattingSettings.Model { this.filterLineProperties(dataRepresentation); this.filterKPIIndicatorProperties(dataRepresentation); this.filterKPIIndicatorValueProperties(); + this.filterYAxisProperties(); + this.filterZoomSliderProperties(axisType); this.filterSettingsPropertiesByAxisType(axisType); this.setLocalizedDisplayNames(localizationManager); this.hideColorPickers(isHighContrast); @@ -247,9 +252,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; }) } @@ -262,6 +271,18 @@ 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 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, @@ -279,10 +300,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/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/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/src/visualComponent/chartComponent.ts b/src/visualComponent/chartComponent.ts index 9872f82..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; @@ -141,6 +151,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/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 a577063..1cfd25c 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", @@ -62,12 +63,18 @@ "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_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)", "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", 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;