diff --git a/CHANGELOG.md b/CHANGELOG.md index fdad0d5..c0f9add 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 3.1.0.0 +### New feature +* Add new option "Data Gaps" to detect missing days in time-series data and display a warning icon when gaps are found. +* Add Data Gaps controls to toggle the icon, adjust its colors, and set a custom message. + ## 3.0.1.0 ### Fixes * Add bold, italic and underline to sparkline value diff --git a/capabilities.json b/capabilities.json index e9eb95a..3aa3b72 100644 --- a/capabilities.json +++ b/capabilities.json @@ -908,6 +908,38 @@ } } }, + "dataGap": { + "properties": { + "isShown": { + "type": { + "bool": true + } + }, + "gapMessage": { + "type": { + "text": true + } + }, + "color": { + "type": { + "fill": { + "solid": { + "color": true + } + } + } + }, + "backgroundColor": { + "type": { + "fill": { + "solid": { + "color": true + } + } + } + } + } + }, "printMode": { "properties": { "show": { diff --git a/package-lock.json b/package-lock.json index 3b9dd8f..a52e4b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@microsoft/powerbi-visuals-multikpi", - "version": "3.0.1.0", + "version": "3.1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@microsoft/powerbi-visuals-multikpi", - "version": "3.0.1.0", + "version": "3.1.0.0", "license": "MIT", "dependencies": { "@typescript-eslint/eslint-plugin": "^8.46.2", diff --git a/package.json b/package.json index 3734f3b..feabdf3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/powerbi-visuals-multikpi", - "version": "3.0.1.0", + "version": "3.1.0.0", "private": true, "description": "Shows a KPI metric along with other metrics as sparklines", "scripts": { diff --git a/pbiviz.json b/pbiviz.json index a651285..b277f1d 100644 --- a/pbiviz.json +++ b/pbiviz.json @@ -1,10 +1,10 @@ { "visual": { "name": "MultiKpi", - "displayName": "Multi KPI 3.0.1.0", + "displayName": "Multi KPI 3.1.0.0", "guid": "multiKpiEA8DA325489E436991F0E411F2D85FF3", "visualClassName": "MultiKpi", - "version": "3.0.1.0", + "version": "3.1.0.0", "description": "Shows a KPI metric along with other metrics as sparklines", "supportUrl": "https://aka.ms/customvisualscommunity", "gitHubUrl": "https://github.com/Microsoft/PowerBI-visuals-MultiKPI" diff --git a/specs/common.spec.ts b/specs/common.spec.ts index 34874fb..631fe23 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 { DataGapDetector, IDataGapResult } from "../src/utils/dataGapDetector"; import { DataConverter } from "../src/converter/data/dataConverter"; import { getFormattedValueWithFallback } from "../src/converter/data/dataFormatter"; @@ -63,6 +64,115 @@ import { SubtitleWarningComponent } from "../src/visualComponent/subtitleWarning import { MultiKpiBuilder } from "./multiKpiBuilder"; describe("Multi KPI", () => { + describe("Version 2.4.0 Changes", () => { + describe("DataGapDetector", () => { + describe("detectGaps", () => { + it("should return no gaps for single point", () => { + const points: IDataRepresentationPoint[] = [ + { x: new Date(2023, 0, 1), y: 100, index: 0 } + ]; + + const result: IDataGapResult = DataGapDetector.detectGaps(points); + + expect(result.hasGaps).toBeFalsy(); + expect(result.totalMissingDays).toBe(0); + expect(result.gaps).toEqual([]); + }); + + it("should return no gaps for consecutive days with valid values", () => { + const points: IDataRepresentationPoint[] = [ + { x: new Date(2023, 0, 1), y: 100, index: 0 }, + { x: new Date(2023, 0, 2), y: 200, index: 1 }, + { x: new Date(2023, 0, 3), y: 300, index: 2 } + ]; + + const result: IDataGapResult = DataGapDetector.detectGaps(points); + + expect(result.hasGaps).toBeFalsy(); + expect(result.totalMissingDays).toBe(0); + expect(result.gaps).toEqual([]); + }); + + it("should detect gap with missing day between valid points", () => { + const points: IDataRepresentationPoint[] = [ + { x: new Date(2023, 0, 1), y: 100, index: 0 }, + { x: new Date(2023, 0, 3), y: 300, index: 1 } + ]; + + const result: IDataGapResult = DataGapDetector.detectGaps(points); + + expect(result.hasGaps).toBeTruthy(); + expect(result.totalMissingDays).toBe(1); + expect(result.gaps.length).toBe(1); + expect(result.gaps[0].missingDays).toBe(1); + }); + + it("should detect multiple days gap", () => { + const points: IDataRepresentationPoint[] = [ + { x: new Date(2023, 0, 1), y: 100, index: 0 }, + { x: new Date(2023, 0, 5), y: 500, index: 1 } + ]; + + const result: IDataGapResult = DataGapDetector.detectGaps(points); + + expect(result.hasGaps).toBeTruthy(); + expect(result.totalMissingDays).toBe(3); + expect(result.gaps.length).toBe(1); + expect(result.gaps[0].missingDays).toBe(3); + }); + + it("should handle invalid values as gaps", () => { + const points: IDataRepresentationPoint[] = [ + { x: new Date(2023, 0, 1), y: 100, index: 0 }, + { x: new Date(2023, 0, 2), y: NaN, index: 1 }, + { x: new Date(2023, 0, 3), y: 300, index: 2 } + ]; + + const result: IDataGapResult = DataGapDetector.detectGaps(points); + + expect(result.hasGaps).toBeTruthy(); + expect(result.totalMissingDays).toBeGreaterThan(0); + }); + + it("should handle points with zero values as valid", () => { + const points: IDataRepresentationPoint[] = [ + { x: new Date(2023, 0, 1), y: 100, index: 0 }, + { x: new Date(2023, 0, 2), y: 0, index: 1 }, + { x: new Date(2023, 0, 3), y: 300, index: 2 } + ]; + + const result: IDataGapResult = DataGapDetector.detectGaps(points); + + expect(result.hasGaps).toBeFalsy(); + expect(result.totalMissingDays).toBe(0); + expect(result.gaps).toEqual([]); + }); + + it("should handle large gaps correctly", () => { + const points: IDataRepresentationPoint[] = [ + { x: new Date(2023, 0, 1), y: 100, index: 0 }, + { x: new Date(2023, 1, 1), y: 200, index: 1 } // About 31 days gap + ]; + + const result: IDataGapResult = DataGapDetector.detectGaps(points); + + expect(result.hasGaps).toBeTruthy(); + expect(result.totalMissingDays).toBeGreaterThan(25); // Allow some tolerance + expect(result.gaps.length).toBe(1); + }); + }); + + describe("formatGapMessage", () => { + it("should format message with placeholder replacement", () => { + const template: string = "Warning: ${1} days of data are missing"; + const result: string = DataGapDetector.formatGapMessage(template, 5); + + expect(result).toBe("Warning: 5 days of data are missing"); + }); + }); + }); + }); + describe("Version 2.3.0 Changes", () => { describe("DataFormatter", () => { it("should return N/A if a variance is not valid", () => { diff --git a/src/converter/data/dataConverter.ts b/src/converter/data/dataConverter.ts index 955fb54..44e2202 100644 --- a/src/converter/data/dataConverter.ts +++ b/src/converter/data/dataConverter.ts @@ -71,6 +71,8 @@ import { getFormattedValueWithFallback, } from "../data/dataFormatter"; +import { DataGapDetector, IDataGapResult } from "../../utils/dataGapDetector"; + export interface IColumnGroup { name: string; values: PrimitiveValue[]; @@ -410,6 +412,11 @@ export class DataConverter implements IConverter { if (series?.current?.x) { @@ -418,6 +425,19 @@ export class DataConverter implements IConverter 1) { + const gapResult: IDataGapResult = DataGapDetector.detectGaps(series.points); + + dataRepresentation.dataGapInfo.seriesGaps[series.name] = { + hasGaps: gapResult.hasGaps, + totalMissingDays: gapResult.totalMissingDays + }; + + if (gapResult.hasGaps) { + dataRepresentation.dataGapInfo.hasGaps = true; + dataRepresentation.dataGapInfo.totalMissingDays += gapResult.totalMissingDays; + } + } series.x.initialMin = series.x.min; series.x.initialMax = series.x.max; diff --git a/src/converter/data/dataRepresentation.ts b/src/converter/data/dataRepresentation.ts index bf0da4f..770a9ed 100644 --- a/src/converter/data/dataRepresentation.ts +++ b/src/converter/data/dataRepresentation.ts @@ -103,4 +103,11 @@ export interface IDataRepresentation { subtitle?: string; viewport: IViewport; viewportSize: ViewportSize; + dataGapInfo?: IDataGapInfo; +} + +export interface IDataGapInfo { + hasGaps: boolean; + totalMissingDays: number; + seriesGaps: { [seriesName: string]: { hasGaps: boolean; totalMissingDays: number; } }; } diff --git a/src/settings/descriptors/dataGapDescriptor.ts b/src/settings/descriptors/dataGapDescriptor.ts new file mode 100644 index 0000000..f0fd68f --- /dev/null +++ b/src/settings/descriptors/dataGapDescriptor.ts @@ -0,0 +1,81 @@ +/** + * 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 ToggleSwitch = formattingSettings.ToggleSwitch; +import FormattingSettingsSlice = formattingSettings.Slice; +import TextInput = formattingSettings.TextInput; +import ColorPicker = formattingSettings.ColorPicker; + +import ISandboxExtendedColorPalette = powerbi.extensibility.ISandboxExtendedColorPalette; + +import { BaseDescriptor } from "./baseDescriptor"; + +export class DataGapDescriptor extends BaseDescriptor { + public name: string = "dataGap"; + public displayNameKey: string = "Visual_DataGap"; + public descriptionKey: string = "Visual_DataGapDescription"; + + public defaultColorValue: string = "#ffeb3b"; + public defaultBackgroundValue: string = ""; + + public gapMessage: TextInput = new TextInput({ + name: "gapMessage", + displayNameKey: "Visual_DataGapMessage", + descriptionKey: "Visual_DataGapMessageDescription", + value: "⚠️ ${1} missing days detected", + placeholder: "Enter gap message template" + }); + + public backgroundColor: ColorPicker = new ColorPicker({ + name: "backgroundColor", + displayNameKey: "Visual_BackgroundColor", + value: {value: this.defaultBackgroundValue} + }); + + public color: ColorPicker = new ColorPicker({ + name: "color", + displayNameKey: "Visual_Color", + value: { value: this.defaultColorValue } + }); + + public slices: FormattingSettingsSlice[] = [ + this.gapMessage, + this.backgroundColor, + this.color + ]; + + topLevelSlice: ToggleSwitch = this.isShown; + + public processHighContrastMode(colorPalette: ISandboxExtendedColorPalette): void { + const isHighContrast: boolean = colorPalette.isHighContrast; + + this.color.visible = isHighContrast ? false : this.color.visible; + this.color.value = isHighContrast ? colorPalette.foreground : this.color.value; + + this.backgroundColor.visible = isHighContrast ? false : this.backgroundColor.visible; + this.backgroundColor.value = isHighContrast ? colorPalette.foreground : this.backgroundColor.value; + } +} \ No newline at end of file diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 5852d44..288feb5 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -36,6 +36,7 @@ import FormattingSettingsCard = formattingSettings.Cards; import { AxisDescriptor } from "./descriptors/axisDescriptor"; import { PrintDescriptor } from "./descriptors/printDescriptor"; import { ChartDescriptor } from "./descriptors/chartDescriptor"; +import { DataGapDescriptor } from "./descriptors/dataGapDescriptor"; import { DateDescriptor } from "./descriptors/dateDescriptor"; import { GridDescriptor } from "./descriptors/gridDescriptor"; import { KpiDescriptor } from "./descriptors/kpi/kpiDescriptor"; @@ -71,6 +72,7 @@ export class Settings extends FormattingSettingsModel { public sparklineValue: SparklineValueDescriptor = new SparklineValueDescriptor(); public subtitle: SubtitleContainerItem = new SubtitleContainerItem(); public staleData: StaleDataDescriptor = new StaleDataDescriptor(); + public dataGap: DataGapDescriptor = new DataGapDescriptor(); public printMode: PrintDescriptor = new PrintDescriptor(); public cards: FormattingSettingsCard[] = [ @@ -79,7 +81,7 @@ export class Settings extends FormattingSettingsModel { this.kpi, this.kpiOnHover, this.grid, this.sparkline, this.sparklineLabel, this.sparklineChart, this.sparklineValue, this.sparklineYAxis, - this.subtitle, this.staleData, this.printMode + this.subtitle, this.staleData, this.dataGap, this.printMode ] public parse(colorPalette: ISandboxExtendedColorPalette, localizationManager: ILocalizationManager): void { @@ -89,6 +91,7 @@ export class Settings extends FormattingSettingsModel { if (!this.subtitle.show.value) { this.staleData.isShown.value = false; + this.dataGap.isShown.value = false; } this.cards.forEach((card) => { diff --git a/src/utils/dataGapDetector.ts b/src/utils/dataGapDetector.ts new file mode 100644 index 0000000..bc7b5bb --- /dev/null +++ b/src/utils/dataGapDetector.ts @@ -0,0 +1,103 @@ +/** + * 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 { IDataRepresentationPoint } from "../converter/data/dataRepresentation"; + +export interface IDataGapResult { + hasGaps: boolean; + totalMissingDays: number; + gaps: { startDate: Date; endDate: Date; missingDays: number }[]; +} + + +export class DataGapDetector { + private static oneDayInMs: number = 24 * 60 * 60 * 1000; + + public static detectGaps(points: IDataRepresentationPoint[]): IDataGapResult { + const result: IDataGapResult = { hasGaps: false, totalMissingDays: 0, gaps: [] }; + if (!points || points.length < 2) return result; + + // Sort by date (oldest → newest) + const sorted = [...points].sort((a, b) => a.x.getTime() - b.x.getTime()); + const valid = sorted.filter(p => this.isValid(p)); + if (valid.length < 2) return result; // If almost all points are invalid, we cannot detect gaps + + let currentGapStart: Date | null = null; + let gapDays = 0; + + for (let i = 1; i < sorted.length; i++) { + const prev = sorted[i - 1]; + const curr = sorted[i]; + const isPrevValid = this.isValid(prev); + const isCurrValid = this.isValid(curr); + const days = this.daysBetween(prev.x, curr.x); + + // A gap happens if: + // - previous or current value is invalid, OR + // - more than 1 day is missing between the dates + if (!isPrevValid || !isCurrValid || days > 1) { + if (!currentGapStart) currentGapStart = prev.x; + gapDays += days > 1 ? days - 1 : 1; // If multiple days are missing, count the exact gap.If the date is present but the value is invalid (NaN/null), count it as 1 invalid data point. + } + // No gap → close the previous gap if one was started + else if (currentGapStart) { + // Move one day forward/back to mark the missing period clearly + result.gaps.push({ + startDate: new Date(currentGapStart.getTime() + this.oneDayInMs), + endDate: new Date(curr.x.getTime() - this.oneDayInMs), + missingDays: gapDays + }); + result.totalMissingDays += gapDays; + currentGapStart = null; + gapDays = 0; + } + } + + // If the gap continues until the last point, close it here + if (currentGapStart && gapDays > 0) { + const last = sorted[sorted.length - 1]; + result.gaps.push({ startDate: currentGapStart, endDate: last.x, missingDays: gapDays }); + result.totalMissingDays += gapDays; + } + + result.hasGaps = result.gaps.length > 0; + return result; + } + + // Checks if a value is usable + private static isValid(p: IDataRepresentationPoint): boolean { + const y = p?.y; + return y != null && isFinite(y) && !isNaN(y); + } + + private static daysBetween(a: Date, b: Date): number { + return Math.ceil((b.getTime() - a.getTime()) / this.oneDayInMs); + } + + public static formatGapMessage(template: string, totalMissingDays: number): string { + return (template || `⚠️ ${totalMissingDays} missing days detected`) + .replace("${1}", totalMissingDays.toString()); + } +} \ No newline at end of file diff --git a/src/visualComponent/rootComponent.ts b/src/visualComponent/rootComponent.ts index 3c99c84..0eac407 100644 --- a/src/visualComponent/rootComponent.ts +++ b/src/visualComponent/rootComponent.ts @@ -209,9 +209,11 @@ export class RootComponent extends BaseContainerComponent< series: data.series, staleDataDifference: data.staleDateDifference, staleDataSettings: settings.staleData, + dataGapSettings: settings.dataGap, subtitleSettings: settings.subtitle, warningState: data.warningState, subtitle: data.subtitle, + dataRepresentation: data, }); const subtitleComponentHeight: number = this.subtitleComponent.getViewport().height; diff --git a/src/visualComponent/subtitleWarningComponent.ts b/src/visualComponent/subtitleWarningComponent.ts index 2cf75d3..9417807 100644 --- a/src/visualComponent/subtitleWarningComponent.ts +++ b/src/visualComponent/subtitleWarningComponent.ts @@ -28,10 +28,12 @@ type Selection = d3Selection; import powerbi from "powerbi-visuals-api"; import { CssConstants } from "powerbi-visuals-utils-svgutils"; -import { IDataRepresentationSeries } from "../converter/data/dataRepresentation"; +import { IDataRepresentation, IDataRepresentationSeries } from "../converter/data/dataRepresentation"; import { StaleDataDescriptor } from "../settings/descriptors/staleDataDescriptor"; +import { DataGapDescriptor } from "../settings/descriptors/dataGapDescriptor"; import { ISubtitleComponentRenderOptions, SubtitleComponent } from "./subtitleComponent"; import { IVisualComponentConstructorOptions } from "./visualComponentConstructorOptions"; +import { DataGapDetector } from "../utils/dataGapDetector"; import VisualTooltipDataItem = powerbi.extensibility.VisualTooltipDataItem; import { SubtitleBaseContainerItem } from "../settings/descriptors/subtitleBaseDescriptor"; @@ -41,7 +43,9 @@ export interface ISubtitleWarningComponentRenderOptions extends ISubtitleCompone staleDataDifference: number; subtitleSettings: SubtitleBaseContainerItem; staleDataSettings: StaleDataDescriptor; + dataGapSettings: DataGapDescriptor; series: IDataRepresentationSeries[]; + dataRepresentation: IDataRepresentation; } interface IIcon { @@ -55,6 +59,7 @@ interface IIcon { export class SubtitleWarningComponent extends SubtitleComponent { private warningSelector: CssConstants.ClassAndSelector = this.getSelectorWithPrefix("warning"); private dataAgeSelector: CssConstants.ClassAndSelector = this.getSelectorWithPrefix("dataAge"); + private dataGapSelector: CssConstants.ClassAndSelector = this.getSelectorWithPrefix("dataGap"); constructor(options: IVisualComponentConstructorOptions) { super(options); @@ -65,15 +70,18 @@ export class SubtitleWarningComponent extends SubtitleComponent { public render(options: ISubtitleWarningComponentRenderOptions): void { const { staleDataSettings, + dataGapSettings, subtitleSettings, warningState, series, staleDataDifference, + dataRepresentation, } = options; this.renderWarningMessage(warningState, subtitleSettings.warningText.value); super.render(options); this.renderStaleData(staleDataSettings, series, staleDataDifference); + this.renderDataGapWarning(dataGapSettings, dataRepresentation); } private renderWarningMessage(warningState: number, warningText: string): void { @@ -160,7 +168,7 @@ export class SubtitleWarningComponent extends SubtitleComponent { this.renderIcon({ backgroundColor: backgroundColor.value.value, color: color.value.value, - isShown: isShown && isDataStale, + isShown: isShown.value && isDataStale, selector: this.dataAgeSelector, tooltipItems, }); @@ -203,4 +211,55 @@ export class SubtitleWarningComponent extends SubtitleComponent { () => tooltipItems ? tooltipItems : null ); } + + private renderDataGapWarning( + dataGapSettings: DataGapDescriptor, + dataRepresentation: IDataRepresentation + ): void { + const { + backgroundColor, + color, + isShown, + gapMessage, + } = dataGapSettings; + + const hasGaps = dataRepresentation.dataGapInfo?.hasGaps ?? false; + const totalMissingDays = dataRepresentation.dataGapInfo?.totalMissingDays ?? 0; + + let tooltipItems: VisualTooltipDataItem[]; + + if (hasGaps && totalMissingDays > 0) { + const formattedMessage = DataGapDetector.formatGapMessage(gapMessage.value, totalMissingDays); + + if (dataRepresentation.dataGapInfo?.seriesGaps) { + const seriesWithGaps = Object.entries(dataRepresentation.dataGapInfo.seriesGaps) + .filter(([, gapInfo]) => gapInfo.hasGaps); + + if (seriesWithGaps.length > 1) { + tooltipItems = seriesWithGaps.map(([seriesName, gapInfo]) => ({ + displayName: seriesName, + value: DataGapDetector.formatGapMessage(gapMessage.value, gapInfo.totalMissingDays), + })); + } else { + tooltipItems = [{ + displayName: null, + value: formattedMessage, + }]; + } + } else { + tooltipItems = [{ + displayName: null, + value: formattedMessage, + }]; + } + } + + this.renderIcon({ + backgroundColor: backgroundColor.value.value, + color: color.value.value, + isShown: isShown.value && hasGaps, + selector: this.dataGapSelector, + tooltipItems: tooltipItems || [], + }); + } } diff --git a/stringResources/en-US/resources.resjson b/stringResources/en-US/resources.resjson index 857ffca..dbe78fe 100644 --- a/stringResources/en-US/resources.resjson +++ b/stringResources/en-US/resources.resjson @@ -24,6 +24,10 @@ "Visual_DateFontSize": "Date font size", "Visual_DeductThresholdDays": "Deduct threshold days", "Visual_DisplayUnits": "Display units", + "Visual_DataGap": "Data gaps", + "Visual_DataGapDescription": "Detect missing days in data (works only if data Subtitle setting is on)", + "Visual_DataGapMessage": "Gap message", + "Visual_DataGapMessageDescription": "Message template for data gaps. Use ${1} for number of missing days", "Visual_Font": "Font", "Visual_FontColor": "Font color", "Visual_FontFamily": "Font family", @@ -80,5 +84,5 @@ "Visual_VarianceFontSize": "Variance font size", "Visual_Warning": "Warning", "Visual_YAxis": "Y-axis", - "Visual_ZeroLine": "Zero line" + "Visual_ZeroLine": "Zero line", } \ No newline at end of file diff --git a/styles/styles.less b/styles/styles.less index 019ca5e..99108b8 100644 --- a/styles/styles.less +++ b/styles/styles.less @@ -319,6 +319,18 @@ padding: 0 0.2em 0 0.2em; } + + .multiKpi_dataGap { + .multiKpi_glyphIcon(); + .flexOrder(4); + text-align: right; + + &::before { + content: '\E70F'; + } + + padding: 0 0.2em 0 0.2em; + } } .multiKpi_axisComponent {