diff --git a/CHANGELOG.md b/CHANGELOG.md index a9cf633..76c9150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,12 @@ ### New feature * Add new option "Data Gaps" to detect missing days in time-series data and display a warning icon when gaps are found. The icon is shown by default and appears automatically whenever gaps are detected. * Add Data Gaps controls to toggle the icon, adjust its colors, and set a custom message. +* Add a landing page (with the visual icon) shown when no fields are added, guiding the user to add Date and Values. +* Add empty-state messages for missing Date/Values fields, invalid or blank dates, and data with no valid values. +* Add a "Show start date" option in the KPI card to display the reference date next to the days count (e.g. "545 days (since 1/1/2024)"). +### Fixes +* Fix "Change start date": the start date is now matched by calendar day, a missing date falls back to the next available date, and an out-of-range date falls back to the closest available date instead of showing a 0% change. A hint icon explains any adjustment. ## 3.0.1.0 ### Fixes * Add bold, italic and underline to sparkline value diff --git a/capabilities.json b/capabilities.json index f03a7cc..4d5891c 100644 --- a/capabilities.json +++ b/capabilities.json @@ -279,6 +279,11 @@ "bool": true } }, + "showStartDate": { + "type": { + "bool": true + } + }, "percentCalcDateStr": { "type": { "text": true @@ -1065,5 +1070,7 @@ ] }, "supportsMultiVisualSelection": true, + "supportsLandingPage": true, + "supportsEmptyDataView": true, "privileges": [] } \ No newline at end of file diff --git a/specs/common.spec.ts b/specs/common.spec.ts index 9a00b1d..6d5b10d 100644 --- a/specs/common.spec.ts +++ b/specs/common.spec.ts @@ -51,6 +51,7 @@ import { } from "../src/converter/data/dataRepresentation"; import { isValueValid } from "../src/utils/isValueValid"; +import { isValidDate } from "../src/utils/isValidDate"; import { DataGapDetector, IDataGapResult } from "../src/utils/dataGapDetector"; import { DataConverter } from "../src/converter/data/dataConverter"; @@ -1123,6 +1124,25 @@ describe("Multi KPI", () => { expect(isValueValid(-Infinity)).toBeFalsy(); }); }); + + describe("isValidDate", () => { + it("should return true for a valid date", () => { + expect(isValidDate(new Date(2018, 0, 1))).toBeTruthy(); + }); + + it("should return false for an invalid date", () => { + expect(isValidDate(new Date("not-a-date"))).toBeFalsy(); + }); + + it("should return false for a non-date value", () => { + expect(isValidDate("2018-01-01")).toBeFalsy(); + }); + + it("should return false for null or undefined", () => { + expect(isValidDate(null)).toBeFalsy(); + expect(isValidDate(undefined)).toBeFalsy(); + }); + }); }); describe("DataFormatter", () => { @@ -1187,34 +1207,86 @@ describe("Multi KPI", () => { expect(dataPoint).toBe(defaultDataPoint); }); - it("should return defaultDataPoint if there's no the closest dataPoint", () => { + it("should return the first dataPoint if the date is before the first dataPoint", () => { + const firstDataPoint: IDataRepresentationPoint = { + index: 0, + x: new Date(2018, 8, 8), + y: 200, + }; + const dataPoint: IDataRepresentationPoint = dataConverter.findClosestDataPointByDate( - [{ - index: 0, - x: new Date(2018, 8, 8), - y: 200, - }], + [firstDataPoint], new Date(2016, 1, 1), defaultDataPoint, ); - expect(dataPoint).toBe(defaultDataPoint); + expect(dataPoint).toBe(firstDataPoint); }); - it("should return the closest dataPoint by date", () => { + it("should return the next available dataPoint when there's no exact match", () => { const firstDataPoint: IDataRepresentationPoint = { index: 0, x: new Date(2018, 8, 8), y: 200, }; + const secondDataPoint: IDataRepresentationPoint = { + index: 1, + x: new Date(2018, 8, 20), + y: 300, + }; + const dataPoint: IDataRepresentationPoint = dataConverter.findClosestDataPointByDate( - [firstDataPoint], - new Date(2018, 9, 9), + [firstDataPoint, secondDataPoint], + new Date(2018, 8, 15), defaultDataPoint, ); - expect(dataPoint).toBe(firstDataPoint); + expect(dataPoint).toBe(secondDataPoint); + }); + + it("should match the dataPoint on the same calendar day ignoring the time component", () => { + const firstDataPoint: IDataRepresentationPoint = { + index: 0, + x: new Date(2018, 8, 8), + y: 200, + }; + + const secondDataPoint: IDataRepresentationPoint = { + index: 1, + x: new Date(2018, 8, 20), + y: 300, + }; + + const dataPoint: IDataRepresentationPoint = dataConverter.findClosestDataPointByDate( + [firstDataPoint, secondDataPoint], + new Date(2018, 8, 20, 13, 45), + defaultDataPoint, + ); + + expect(dataPoint).toBe(secondDataPoint); + }); + + it("should return the latest dataPoint if the date is after the last dataPoint", () => { + const firstDataPoint: IDataRepresentationPoint = { + index: 0, + x: new Date(2018, 8, 8), + y: 200, + }; + + const secondDataPoint: IDataRepresentationPoint = { + index: 1, + x: new Date(2018, 8, 20), + y: 300, + }; + + const dataPoint: IDataRepresentationPoint = dataConverter.findClosestDataPointByDate( + [firstDataPoint, secondDataPoint], + new Date(2019, 0, 1), + defaultDataPoint, + ); + + expect(dataPoint).toBe(secondDataPoint); }); }); }); diff --git a/src/converter/data/dataConverter.ts b/src/converter/data/dataConverter.ts index 44e2202..971050d 100644 --- a/src/converter/data/dataConverter.ts +++ b/src/converter/data/dataConverter.ts @@ -72,6 +72,7 @@ import { } from "../data/dataFormatter"; import { DataGapDetector, IDataGapResult } from "../../utils/dataGapDetector"; +import { isValidDate } from "../../utils/isValidDate"; export interface IColumnGroup { name: string; @@ -139,30 +140,82 @@ export class DataConverter implements IConverter= targetDay) { + return dataPoint; } } - return closestDataPoint || defaultDataPoint; + // The requested date is after the last available date, so there is no next date. + // Fall back to the closest (latest) available date. + return dataPoints[dataPoints.length - 1]; + } + + private getDayValue(date: Date): number { + return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()); + } + + // Flags when the user typed a value into the start date input that is not a parseable date, + // so the visual can hint that the entry was ignored instead of silently using the first point. + private detectInvalidStartDateInput(dataRepresentation: IDataRepresentation, settings: Settings): void { + const rawValue: string = settings.kpi.startDate.value; + + if (rawValue && !settings.kpi.percentCalcDate && !dataRepresentation.percentCalcDate) { + dataRepresentation.startDateAdjustment = { + isAdjusted: true, + isOutOfRange: false, + isInvalidInput: true, + requestedText: rawValue, + }; + } + } + + // Flags when a user-selected start date does not match the resolved data point + // (either because that day is missing from the series or it falls outside the range), + // so the visual can surface a hint. The first affected series is reported. + private detectStartDateAdjustment( + dataRepresentation: IDataRepresentation, + series: IDataRepresentationSeries, + startDataPoint: IDataRepresentationPoint, + ): void { + const requestedDate: Date = dataRepresentation.percentCalcDate; + + if (!isValidDate(requestedDate)) { + return; + } + + if (!startDataPoint || !startDataPoint.x || !series.points.length) { + return; + } + + const requestedDay: number = this.getDayValue(requestedDate); + const actualDay: number = this.getDayValue(startDataPoint.x); + + if (requestedDay === actualDay || dataRepresentation.startDateAdjustment?.isAdjusted) { + return; + } + + const firstDay: number = this.getDayValue(series.points[0].x); + const lastDay: number = this.getDayValue(series.points[series.points.length - 1].x); + + dataRepresentation.startDateAdjustment = { + isAdjusted: true, + requestedDate, + actualDate: startDataPoint.x, + isOutOfRange: requestedDay < firstDay || requestedDay > lastDay, + }; } public isDataViewValid(dataView: DataView): boolean { @@ -417,6 +470,12 @@ export class DataConverter implements IConverter { if (series?.current?.x) { @@ -468,6 +527,8 @@ export class DataConverter implements IConverter; @@ -84,6 +89,8 @@ export class MultiKpi implements powerbi.extensibility.visual.IVisual { this.host = host; + this.element = element; + this.localizationManager = options.host.createLocalizationManager(); this.formattingSettingsService = new FormattingSettingsService(this.localizationManager); @@ -119,6 +126,7 @@ export class MultiKpi implements powerbi.extensibility.visual.IVisual { scaleService: new ScaleService(element), colorPalette: host.colorPalette, tooltipServiceWrapper: this.tooltipServiceWrapper, + localizationManager: this.localizationManager, }); this.selectionManager = this.host.createSelectionManager(); @@ -141,25 +149,40 @@ export class MultiKpi implements powerbi.extensibility.visual.IVisual { try { this.host.eventService.renderingStarted(options); - const dataView: powerbi.DataView = options?.dataViews?.[0]; + if (this.handleLandingPage(options)) { + this.rootComponent.hide?.(); + this.renderNoDataMessage(null); + } else { + const dataView: powerbi.DataView = options?.dataViews?.[0]; - this.viewport = this.getViewport(options?.viewport); + this.viewport = this.getViewport(options?.viewport); - this.settings = this.formattingSettingsService.populateFormattingSettingsModel(Settings, dataView); + this.settings = this.formattingSettingsService.populateFormattingSettingsModel(Settings, dataView); - this.dataRepresentation = this.dataConverter.convert({ - dataView, - settings: this.settings, - viewport: this.viewport, - }); + this.dataRepresentation = this.dataConverter.convert({ + dataView, + settings: this.settings, + viewport: this.viewport, + }); + + this.settings.parse(this.host.colorPalette, this.localizationManager); - this.settings.parse(this.host.colorPalette, this.localizationManager); + const noDataMessage: string = this.getNoDataMessage(dataView, this.dataRepresentation); - this.render( - this.dataRepresentation, - this.settings, - this.viewport, - ); + if (noDataMessage) { + this.rootComponent.hide?.(); + this.renderNoDataMessage(noDataMessage); + } else { + this.renderNoDataMessage(null); + this.rootComponent.show?.(); + + this.render( + this.dataRepresentation, + this.settings, + this.viewport, + ); + } + } } catch (ex) { this.host.eventService.renderingFailed(options, JSON.stringify(ex)); } @@ -170,6 +193,111 @@ export class MultiKpi implements powerbi.extensibility.visual.IVisual { return this.formattingSettingsService.buildFormattingModel(this.settings); } + private getNoDataMessage(dataView: powerbi.DataView, data: IDataRepresentation): string | null { + const hasDate: boolean = !!dataView?.categorical?.categories?.[0]?.values?.length; + const hasValues: boolean = !!dataView?.categorical?.values?.length; + + if (!hasDate && !hasValues) { + return this.localizationManager.getDisplayName("Visual_EmptyState_AddDateAndValues"); + } + + if (!hasDate) { + return this.localizationManager.getDisplayName("Visual_EmptyState_AddDate"); + } + + if (!hasValues) { + return this.localizationManager.getDisplayName("Visual_EmptyState_AddValues"); + } + + if (!this.hasValidDate(dataView)) { + return this.localizationManager.getDisplayName("Visual_EmptyState_InvalidDate"); + } + + if (!data?.series?.length || !this.hasValidData(data)) { + return this.localizationManager.getDisplayName("Visual_EmptyState_NoValidData"); + } + + return null; + } + + private hasValidData(data: IDataRepresentation): boolean { + return data.series.some((series) => + series?.points?.some((point) => point != null && !isNaN(point.y)), + ); + } + + private hasValidDate(dataView: powerbi.DataView): boolean { + const categories: powerbi.DataViewCategoryColumn[] | undefined = dataView?.categorical?.categories; + + if (!categories?.length) { + return false; + } + + const dateCategory: powerbi.DataViewCategoryColumn = + categories.find((category) => category?.source?.roles?.dateColumn) ?? categories[0]; + + return !!dateCategory?.values?.some( + (value) => isValidDate(value), + ); + } + + private handleLandingPage(options: powerbi.extensibility.visual.VisualUpdateOptions): boolean { + const hasData: boolean = !!options?.dataViews?.[0]?.metadata?.columns?.length; + + if (!hasData) { + if (!this.isLandingPageOn) { + this.isLandingPageOn = true; + this.landingPageRemoved = false; + this.landingPage = this.createLandingPage(); + this.element.appendChild(this.landingPage); + } + + return true; + } + + if (this.isLandingPageOn && !this.landingPageRemoved) { + this.isLandingPageOn = false; + this.landingPageRemoved = true; + + if (this.landingPage) { + this.landingPage.remove(); + this.landingPage = null; + } + } + + return false; + } + + private createLandingPage(): HTMLElement { + const container: HTMLElement = document.createElement("div"); + container.classList.add("multiKpi_landingPage"); + + const icon: HTMLElement = document.createElement("div"); + icon.classList.add("multiKpi_landingPage_icon"); + container.appendChild(icon); + + const title: HTMLElement = document.createElement("div"); + title.classList.add("multiKpi_landingPage_title"); + title.textContent = this.localizationManager.getDisplayName("Visual_LandingPage_Title"); + container.appendChild(title); + + const description: HTMLElement = document.createElement("div"); + description.classList.add("multiKpi_landingPage_description"); + description.textContent = this.localizationManager.getDisplayName("Visual_LandingPage_Description"); + container.appendChild(description); + + return container; + } + + private renderNoDataMessage(message: string | null): void { + d3Select(this.element) + .selectAll("div.multiKpi_emptyState") + .data(message ? [message] : []) + .join("div") + .classed("multiKpi_emptyState", true) + .text((text: string) => text); + } + private render( data: IDataRepresentation, settings: Settings, diff --git a/src/settings/descriptors/kpi/kpiDescriptor.ts b/src/settings/descriptors/kpi/kpiDescriptor.ts index 14dc9b8..8a0813a 100644 --- a/src/settings/descriptors/kpi/kpiDescriptor.ts +++ b/src/settings/descriptors/kpi/kpiDescriptor.ts @@ -24,15 +24,25 @@ * THE SOFTWARE. */ +import { formattingSettings } from "powerbi-visuals-utils-formattingmodel"; +import ToggleSwitch = formattingSettings.ToggleSwitch; + import { KpiBaseDescriptor } from "./kpiBaseDescriptor"; export class KpiDescriptor extends KpiBaseDescriptor { public name: string = "kpi"; public displayNameKey: string = "Visual_KPI"; + public showStartDate: ToggleSwitch = new ToggleSwitch({ + name: "showStartDate", + displayNameKey: "Visual_ShowStartDate", + value: false, + }); + constructor(){ super(); + this.slices.push(this.showStartDate); this.slices.push(this.startDate); } diff --git a/src/utils/isValidDate.ts b/src/utils/isValidDate.ts new file mode 100644 index 0000000..12d4d91 --- /dev/null +++ b/src/utils/isValidDate.ts @@ -0,0 +1,3 @@ +export function isValidDate(value: unknown): value is Date { + return value instanceof Date && !isNaN(value.getTime()); +} diff --git a/src/visualComponent/mainChart/chartLabelComponent.ts b/src/visualComponent/mainChart/chartLabelComponent.ts index e8cd493..a420d2f 100644 --- a/src/visualComponent/mainChart/chartLabelComponent.ts +++ b/src/visualComponent/mainChart/chartLabelComponent.ts @@ -137,7 +137,9 @@ export class ChartLabelComponent extends ChartLabelBaseComponent Settings; tooltipServiceWrapper?: ITooltipServiceWrapper; + localizationManager?: ILocalizationManager; } diff --git a/stringResources/en-US/resources.resjson b/stringResources/en-US/resources.resjson index 5f6f252..6000346 100644 --- a/stringResources/en-US/resources.resjson +++ b/stringResources/en-US/resources.resjson @@ -11,6 +11,17 @@ "Visual_CalculateDifference": "Calculate difference", "Visual_CalculateDifferenceDescription": "Calculates a difference instead of growth", "Visual_ChangeStartDate": "Change start date", + "Visual_ShowStartDate": "Show start date on chart", + "Visual_EmptyState_AddDateAndValues": "This visual needs data to display. Add a Date field and at least one Values field to get started.", + "Visual_EmptyState_AddDate": "Add a Date field so the visual can plot your values over time.", + "Visual_EmptyState_AddValues": "Add at least one Values field to see the KPI and its trend.", + "Visual_EmptyState_InvalidDate": "The Date field contains invalid or blank dates. Please provide a column with valid date values.", + "Visual_EmptyState_NoValidData": "No data available to display. The selected Date or Values field contains only empty, blank or invalid entries.", + "Visual_StartDateHint_OutOfRange": "The selected start date ${1} is outside the data range. Falling back to the closest available date ${2} to calculate the change instead.", + "Visual_StartDateHint_Gap": "The selected start date ${1} is missing from the data (gap in the series). Using the next available date ${2} to calculate the change instead.", + "Visual_StartDateHint_Invalid": "The start date you entered (${1}) is not a valid date, so it was ignored.", + "Visual_LandingPage_Title": "Welcome to Multi KPI", + "Visual_LandingPage_Description": "Drag a Date field and one or more Values fields onto the visual to bring your KPIs and trends to life.", "Visual_Color": "Color", "Visual_ColorAll": "Color (for all only)", "Visual_Columns": "Columns", diff --git a/styles/styles.less b/styles/styles.less index e89c992..401e8c2 100644 --- a/styles/styles.less +++ b/styles/styles.less @@ -33,6 +33,57 @@ user-select: none; } +.multiKpi_emptyState { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 100%; + height: 100%; + padding: 0 12px; + text-align: center; + font-family: "Segoe UI", wf_standard-font, helvetica, arial, sans-serif; + font-size: 12px; + color: #666666; +} + +.multiKpi_landingPage { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 100%; + height: 100%; + padding: 12px 24px; + text-align: center; + font-family: "Segoe UI", wf_standard-font, helvetica, arial, sans-serif; + color: #666666; + + .multiKpi_landingPage_icon { + width: 96px; + height: 96px; + margin-bottom: 16px; + background-image: data-uri('../assets/icon.svg'); + background-repeat: no-repeat; + background-position: center; + background-size: contain; + } + + .multiKpi_landingPage_title { + font-size: 16px; + font-weight: 600; + color: #333333; + margin-bottom: 6px; + } + + .multiKpi_landingPage_description { + font-size: 12px; + line-height: 1.4; + max-width: 360px; + } +} + @font-face { font-family: 'MultiKPIGlyph'; src: data-uri('../fonts/PowerVisuals.eot') format('embedded-opentype'), @@ -331,6 +382,19 @@ padding: 0 0.2em 0 0.2em; } + + .multiKpi_startDateHint { + .multiKpi_glyphIcon(); + .flexOrder(5); + text-align: right; + + &::before { + content: '\E71B'; + } + + color: rgb(232, 17, 35) !important; + padding: 0 0.2em 0 0.2em; + } } .multiKpi_axisComponent {