├── .gitignore ├── .travis.yml ├── .vscode ├── launch.json └── tasks.json ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── SECURITY.md ├── index.ts ├── launch.json ├── package-lock.json ├── package.json ├── src ├── common │ ├── changeDetection.ts │ ├── chartChange.ts │ ├── chartInfo.ts │ ├── chartModels.ts │ ├── errors │ │ ├── errorCode.ts │ │ └── errors.ts │ ├── kustoChartHelper.ts │ └── utilities.ts ├── transformers │ ├── chartAggregation.ts │ ├── limitVisResults.ts │ └── seriesVisualize.ts └── visualizers │ ├── IVisualizer.ts │ ├── IVisualizerOptions.ts │ └── highcharts │ ├── chartTypeOptions.ts │ ├── charts │ ├── area.ts │ ├── bar.ts │ ├── chart.ts │ ├── chartFactory.ts │ ├── column.ts │ ├── donut.ts │ ├── line.ts │ ├── percentageArea.ts │ ├── percentageBar.ts │ ├── percentageColumn.ts │ ├── pie.ts │ ├── scatter.ts │ ├── stackedArea.ts │ ├── stackedBar.ts │ ├── stackedColumn.ts │ ├── unstackedArea.ts │ ├── unstackedBar.ts │ └── unstackedColumn.ts │ ├── common │ ├── formatter.ts │ └── utilities.ts │ ├── highchartsDateFormatToCommon.ts │ ├── highchartsVisualizer.ts │ └── themes │ ├── darkTheme.ts │ ├── lightTheme.ts │ └── themes.ts ├── tasks.json ├── test ├── changeDetection.test.ts ├── highcharts │ └── chart.test.ts ├── kustoChartHelper.test.ts ├── limitVisResults.test.ts ├── mocks │ └── visualizerMock.ts ├── seriesVisualize.test.ts └── utilities.test.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # profiling files 12 | chrome-profiler-events.json 13 | speed-measure-plugin.json 14 | 15 | # IDEs and editors 16 | /.idea 17 | .project 18 | .classpath 19 | .c9/ 20 | *.launch 21 | .settings/ 22 | *.sublime-workspace 23 | 24 | # IDE - VSCode 25 | .vscode/* 26 | !.vscode/settings.json 27 | !.vscode/tasks.json 28 | !.vscode/launch.json 29 | !.vscode/extensions.json 30 | .history/* 31 | 32 | # misc 33 | /.sass-cache 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | npm-debug.log 38 | yarn-error.log 39 | testem.log 40 | /typings 41 | 42 | # System Files 43 | .DS_Store 44 | Thumbs.db 45 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language : node_js 2 | node_js : 3 | - stable 4 | install: 5 | - npm install 6 | script: 7 | - npm test -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug Jest Tests", 9 | "type": "node", 10 | "request": "launch", 11 | "runtimeArgs": [ 12 | "--inspect-brk", 13 | "${workspaceRoot}/node_modules/jest/bin/jest.js", 14 | "--runInBand" 15 | ], 16 | "preLaunchTask": "build", 17 | "console": "integratedTerminal", 18 | "internalConsoleOptions": "neverOpen", 19 | "port": 9229 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "build", 8 | "type": "npm", 9 | "script": "build", 10 | "group": "build", 11 | "problemMatcher": [] 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # adx-query-charts 2 | [](https://travis-ci.org/microsoft/adx-query-charts) [](https://badge.fury.io/js/adx-query-charts) 3 | 4 | Draw charts from Azure Data Explorer queries 5 | 6 | ## Installation 7 | npm install adx-query-charts 8 | 9 | ## Dependencies 10 | 1. [lodash](https://www.npmjs.com/package/lodash): `npm i lodash` 11 | 2. [css-element-queries](https://www.npmjs.com/package/css-element-queries): `npm i css-element-queries` 12 | 3. [highcharts](https://www.npmjs.com/package/highcharts): `npm i highcharts` 13 | Please note: Highcharts/Highstock libraries are free to use with Log Analytics/adx-query-charts. If you plan to use Highcharts separately, in your own project, you must obtain a license: follow the link − [License and Pricing](https://shop.highsoft.com/highcharts). 14 | 15 | ## Usage 16 | ```typescript 17 | import * as Charts from 'adx-query-charts'; 18 | 19 | const highchartsVisualizer = new Charts.HighchartsVisualizer(); 20 | const chartHelper = chartHelper = new Charts.KustoChartHelper('chart-elem-id', highchartsVisualizer); 21 | const chartOptions: Charts.IChartOptions = { 22 | chartType: Charts.ChartType.UnstackedColumn, 23 | columnsSelection: { 24 | xAxis: { name: 'timestamp', type: Charts.DraftColumnType.DateTime }, 25 | yAxes: [{ name: 'requestCount', type: Charts.DraftColumnType.Int }] 26 | } 27 | }; 28 | 29 | // Draw the chart - the chart will be drawn inside an element with 'chart-elem-id' id 30 | chartHelper.draw(queryResultData, chartOptions); 31 | ``` 32 | ## API 33 | 34 | ### KustoChartHelper 35 | | Method: | Description: | Input: | Return value: | 36 | | ------------------------ |-------------------------- | ----------------------------------------------------------------------------- | ---------------- | 37 | | draw | Draw the chart | [IQueryResultData](#IQueryResultData) - The original query result data[IChartOptions](#IChartOptions) - The information required to draw the chart | Promise<[IChartInfo](#IChartInfo)> | 38 | | changeTheme | Change the theme of an existing chart | [ChartTheme](#ChartTheme) - The theme to apply | Promise<void> | 39 | | getSupportedColumnTypes | Get the supported column types for the axes and the split-byfor a specific chart type | [ChartType](#ChartType) - The type of the chart | [ISupportedColumnTypes](#ISupportedColumnTypes) | 40 | | getSupportedColumnsInResult | Get the supported columns from the query result data for the axes and the split-by for a specific chart type | [IQueryResultData](#IQueryResultData) - The original query result data [ChartType](#ChartType) - The type of the chart | [ISupportedColumns](#ISupportedColumns) | 41 | | getDefaultSelection | Get the default columns selection from the query result data.Select the default columns for the axes and the split-by for drawing a default chart of a specific chart type. | [IQueryResultData](#IQueryResultData) - The original query result data [ChartType](#ChartType) - The type of the chart[ISupportedColumns](#ISupportedColumns) - (Optional) The list of the supported column types for the axes and the split-by | [ColumnsSelection](#ColumnsSelection) | 42 | | downloadChartJPGImage | Download the chart as JPG image | (error: Error) => void - [Optional] A callback that will be called if the module failed to export the chart image | void | 43 | 44 | ### IChartOptions 45 | | Option name: | Type: | Details: | Default value: | 46 | | ------------------- |-------------------- | --------------------------------------------- | ----------------| 47 | | chartType | [ChartType](#ChartType) | Mandatory. The type of the chart to draw | | 48 | | columnsSelection | [ColumnsSelection](#ColumnsSelection)| The columns selection for the Axes and the split-by of the chart | If not provided, default columns will be selected. See: getDefaultSelection method| 49 | | maxUniqueXValues | number | The maximum number of the unique X-axis values.The chart will show the biggest values, and the rest will be aggregated to a separate data point.| 100 | 50 | | exceedMaxDataPointLabel| string | The label of the data point that contains the aggregated value of all the X-axis values that exceed the 'maxUniqueXValues'| 'OTHER' | 51 | | aggregationType | [AggregationType](#AggregationType) | Multiple rows with the same values for the X-axis and the split-by will be aggregated using a function of this type.For example, assume we get the following query result data:['2016-08-02T10:00:00Z', 'Chrome 51.0', 15], ['2016-08-02T10:00:00Z', 'Internet Explorer 9.0', 4]When drawing a chart with columnsSelection = { xAxis: timestamp, yAxes: count_ }, and aggregationType = AggregationType.Sum we need to aggregate the values of the same timestamp value and return one row with ["2016-08-02T10:00:00Z", 19] | AggregationType.Sum | 52 | | title | string | The title of the chart | | 53 | | legendOptions | [ILegendOptions](#ILegendOptions) | The legend configuration options | | 54 | | yMinimumValue | number | The minimum value to be displayed on the y-axis.If not provided, the minimum value is automatically calculated. | | 55 | | yMaximumValue | number | The maximum value to be displayed on the y-axis.If not provided, the maximum value is automatically calculated. | | 56 | | animationDurationMS | number | The duration of the animation for chart rendering.The animation can be disabled by setting it to 0. | 1000 | 57 | | fontFamily | string | Chart labels font family | 'az_ea_font, wf_segoe-ui_normal, \"Segoe UI\", \"Segoe WP\", Tahoma, Arial, sans-serif' | 58 | | chartTheme | [ChartTheme](#ChartTheme) | The theme of the chart | ChartTheme.Light | 59 | | getUtcOffset | Function(dateValue: number): number | Callback that is used to get the desired offset from UTC in minutes for date value. Used to handle timezone.The offset will be added to the original UTC date from the query results data.For example:For 'South Africa Standard Time' timezone return -120 and the displayed date will be:'11/25/2019, 02:00 PM' instead of '11/25/2019, 04:00 PM'See time zone [info](https://msdn.microsoft.com/en-us/library/ms912391(v=winembedded.11)If dateFormatter wasn't provided, the callback will be also used for the X axis labels and the tooltip header. Otherwise - it will only be used for positioning the x-axis. Callback inputs: dateValue - The time value in milliseconds since midnight, January 1, 1970 UTC of the date from the query result. For example: 1574666160000 represents '2019-11-25T07:16:00.000Z'Callback return value: The desired offset from UTC in hours | If not provided, the utcOffset will be 0 | 60 | | dateFormatter | Function(dateValue: number, defaultFormat: DateFormat): string | Callback that is used to format the date values both in the axis and the tooltipCallback inputs: dateValue - The original date value in milliseconds since midnight, January 1, 1970 UTC [DateFormat](#DateFormat) - The default format of the labelCallback return value: The string represents the display value of the dateValue| If not provided - the default formatting will apply | 61 | | numberFormatter | Function(numberValue: number): string | Callback that is used to format number values both in the axis and the tooltipCallback inputs: numberValue - The original numberCallback return value: The string represents the display value of the numberValue |If not provided - the default formatting will apply | 62 | | xAxisTitleFormatter | Function(xAxisColumn: IColumn): string | Callback that is used to get the xAxis titleCallback inputs: [IColumn](#IColumn) - The x-axis columnCallback return value: The desired x-axis title | If not provided - the xAxis title will be the xAxis column name | 63 | | yAxisTitleFormatter | Function(yAxisColumns: IColumn[]): string | Callback that is used to get the yAxis titleCallback inputs: [IColumn[]](#IColumn) - The y-axis columnsCallback return value: The desired y-axis title | If not provided - the yAxis title will be the first yAxis column name | 64 | | updateCustomOptions | Function(originalOptions: any): void | Callback that is called to allow altering the options of the external charting library before rendering the chart.Used to allow flexibility and control of the external charting library.USE WITH CAUTION changing the original options might break the functionality / backward compatibility when using a different IVisualizer or upgrading the charting library.Validating the updated options is the user's responsibility.For official chart options - please make contribution to the base code.Callback inputs: originalOptions - The custom charting options that are given to the external charting library | | 65 | | onFinishDataTransformation | Function(dataTransformationInfo: IDataTransformationInfo) : Promise<boolean> | Callback that is called when all the data transformations required to draw the chart are finishedCallback inputs: [IDataTransformationInfo](#IDataTransformationInfo) - The information regarding the applied transformations on the original query resultsCallback return value: The promise that is used to continue/stop drawing the chart When provided, the drawing of the chart will be suspended until this promise will be resolved When resolved with true - the chart will continue the drawing When resolved with false - the chart drawing will be canceled | | 66 | | onFinishDrawing | Function(chartInfo: IChartInfo) : void | Callback that is called when the chart drawing is finished Callback inputs: [IChartInfo](#IChartInfo) - The information regarding the chart | | | 67 | | onFinishChartAnimation | Function(chartInfo: IChartInfo) : void | Callback that is called when the chart animation is finished Callback inputs: [IChartInfo](#IChartInfo) - The information regarding the chart | | | 68 | | onDataPointClicked | Function(dataPoint: IDataPoint) : void | When this callback is provided, the chart data points will be clickable.The callback will be called when chart's data point will be clicked, providing the clicked data point information.Callback inputs: [IDataPoint](#IDataPoint) - The information regarding the columns and values of the clicked data point. Note that the value of a date-time column in the dataPoint object will be its numeric value - Date.valueOf(). | | | 69 | 70 | ### IDataTransformationInfo 71 | | Option name: | Type: | Details: | 72 | | -------------------------- |------------------------------------- | -------------------------------------------------------------------------------------------------- | 73 | | numberOfDataPoints | number | The amount of the data points that will be drawn for the chart | 74 | | isPartialData | boolean | True if the chart presents partial data from the original query resultsThe chart data will be partial when the maximum number of the unique X-axis values exceed the 'maxUniqueXValues' in [IChartOptions](#IChartOptions) | 75 | | isAggregationApplied | boolean | True if aggregation was applied on the original query results in order to draw the chartSee 'aggregationType' in [IChartOptions](#IChartOptions) for more details | 76 | 77 | ### IChartInfo 78 | | Option name: | Type: | Details: | 79 | | -------------------------- |----------------------------------------------------- | -------------------------------------------------------------------------------------------------- | 80 | | dataTransformationInfo | [IDataTransformationInfo](#IDataTransformationInfo) | The information regarding the applied transformations on the original query results | 81 | | status | [DrawChartStatus](#DrawChartStatus) | The status of the draw action | 82 | | error | [ChartError](#ChartError) | [Optional] The error information in case that the draw action failed | 83 | 84 | ### ILegendOptions 85 | | Option name: | Type: | Details: | 86 | | ------------ | ----------------------------------- | -------------------------------------------------------------------------------------------------- | 87 | | isEnabled | boolean | [Optional] Set to false if you want to hide the legend. [Default value: true (show legend)] | 88 | | position | [LegendPosition](#LegendPosition) | [Optional] The position of the legend (relative to the chart). [Default value: Bottom] | 89 | 90 | ### ChartType 91 | ```typescript 92 | enum ChartType { 93 | Line, 94 | Scatter, 95 | UnstackedArea, 96 | StackedArea, 97 | PercentageArea, 98 | UnstackedColumn, 99 | StackedColumn, 100 | PercentageColumn, 101 | UnstackedBar, 102 | StackedBar, 103 | PercentageBar, 104 | Pie, 105 | Donut, 106 | } 107 | ``` 108 | 109 | ### Columns selection per chart type 110 | | Chart type: | X-axis: | Y-axis: | Split-by: | 111 | | ------------------------------------------------------------------------------------------------------------------------ | ------------------ | --------------- | ------------------ | 112 | | Line / Scatter UnstackedArea / StackedArea / PercentageArea UnstackedColumn / StackedColumn / PercentageColumn UnstackedBar / StackedBar / PercentageBar | [Single selection]DateTime / Int / Long Decimal / Real / String | [If split-by column is selected: y-axis restricted to single selection] [If split-by column is not selected: y-axis can be single/multi selection] Int / Long / Decimal / Real | [Single selection] String | 113 | | Pie / Donut | [Single selection] String | [Single selection] Int / Long / Decimal / Real | [Single / Multi selection] String / DateTime / Bool | 114 | 115 | 116 | ### ColumnsSelection 117 | ```typescript 118 | interface IColumn { 119 | name: string; 120 | type: DraftColumnType; 121 | } 122 | 123 | class ColumnsSelection { 124 | xAxis: IColumn; 125 | yAxes: IColumn[]; 126 | splitBy?: IColumn[]; 127 | } 128 | ``` 129 | 130 | See [Columns selection per chart type](#Columns selection per chart type) 131 | 132 | ### AggregationType 133 | ```typescript 134 | enum AggregationType { 135 | Sum, 136 | Average, 137 | Min, 138 | Max 139 | } 140 | ``` 141 | 142 | ### ChartTheme 143 | ```typescript 144 | enum ChartTheme { 145 | Dark, 146 | Light 147 | } 148 | ``` 149 | 150 | ### ErrorCode 151 | ```typescript 152 | enum ErrorCode { 153 | InvalidQueryResultData, 154 | InvalidColumnsSelection, 155 | UnsupportedTypeInColumnsSelection, 156 | InvalidChartContainerElementId, 157 | InvalidDate, 158 | FailedToCreateVisualization, 159 | EmptyPie 160 | } 161 | ``` 162 | 163 | ### CustomError 164 | ```typescript 165 | class ChartError extends Error { 166 | errorCode: ErrorCode; 167 | } 168 | ``` 169 | See [ErrorCode](#ErrorCode) 170 | 171 | ### DateFormat 172 | ```typescript 173 | 174 | export enum DateFormat { 175 | FullDate // The full date and time. For example: 12/7/2019, 2:30:00.600 176 | Time // The full time, without the milliseconds. For example: 2:30:00 177 | FullTime // The full time, including the milliseconds. For example: 2:30:00.600 178 | HourAndMinute // The hours and minutes. For example: 2:30 179 | MonthAndDay // The month and day. For example: July 12th 180 | MonthAndYear // The month and day. For example: July 2019 181 | Year // The year. For example: 2019 182 | } 183 | ``` 184 | ### DrawChartStatus 185 | ```typescript 186 | 187 | export enum DrawChartStatus { 188 | Success = 'Success', // Successfully drawn the chart 189 | Failed = 'Failed', // There was an error while trying to draw the chart 190 | Canceled = 'Canceled' // The chart drawing was canceled 191 | } 192 | ``` 193 | See 'onFinishDataTransformation' return value in [IChartOptions](#IChartOptions) for more information regarding drawing cancellation 194 | 195 | ### IColumn 196 | ```typescript 197 | type IRowValue = string | number; 198 | type ISeriesRowValue = IRowValue | string[] | number[]; 199 | type IRow = IRowValue[]; 200 | type ISeriesRow = ISeriesRowValue[]; 201 | 202 | interface IColumn { 203 | name: string; 204 | type: DraftColumnType; 205 | } 206 | ``` 207 | 208 | ### IQueryResultData 209 | ```typescript 210 | interface IQueryResultData { 211 | rows: IRow[] | ISeriesRow[]; 212 | columns: IColumn[]; 213 | } 214 | ``` 215 | See [IColumn](#IColumn) 216 | 217 | ### ISupportedColumns 218 | ```typescript 219 | interface ISupportedColumns { 220 | xAxis: IColumn[]; 221 | yAxis: IColumn[]; 222 | splitBy: IColumn[]; 223 | } 224 | ``` 225 | See [IColumn](#IColumn) 226 | 227 | ### DraftColumnType 228 | See: https://kusto.azurewebsites.net/docs/query/scalar-data-types/index.html 229 | ```typescript 230 | enum DraftColumnType { 231 | Bool, 232 | DateTime, 233 | Decimal, 234 | Dynamic, 235 | Guid, 236 | Int, 237 | Long, 238 | Real, 239 | String, 240 | TimeSpan 241 | } 242 | ``` 243 | ### ISupportedColumnTypes 244 | ```typescript 245 | interface ISupportedColumnTypes { 246 | xAxis: DraftColumnType[]; 247 | yAxis: DraftColumnType[]; 248 | splitBy: DraftColumnType[]; 249 | } 250 | ``` 251 | See [DraftColumnType](#DraftColumnType) 252 | 253 | ### IDataPoint 254 | ```typescript 255 | export interface IDataPointInfo { 256 | column: IColumn; 257 | value: IRowValue; 258 | } 259 | 260 | export interface IDataPoint { 261 | x: IDataPointInfo; 262 | y: IDataPointInfo; 263 | splitBy?: IDataPointInfo; 264 | } 265 | ``` 266 | 267 | ### LegendPosition 268 | ```typescript 269 | export enum LegendPosition { 270 | Bottom, 271 | Right 272 | } 273 | ``` 274 | 275 | ## Test 276 | Unit tests are written using [Jest](https://jestjs.io/). 277 | 278 | ```sh 279 | Run tests: npm run test 280 | ``` 281 | 282 | # Contributing 283 | 284 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 285 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 286 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. 287 | 288 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 289 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 290 | provided by the bot. You will only need to do this once across all repos using our CLA. 291 | 292 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 293 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 294 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets Microsoft's [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)) of a security vulnerability, please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export * from './src/common/chartModels'; 4 | export { KustoChartHelper } from './src/common/kustoChartHelper'; 5 | export { ErrorCode } from './src/common/errors/errorCode'; 6 | export { ChartError } from './src/common/errors/errors'; 7 | export { HighchartsVisualizer } from './src/visualizers/highcharts/highchartsVisualizer'; -------------------------------------------------------------------------------- /launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Jest Tests", 8 | "runtimeArgs": [ 9 | "--inspect-brk", 10 | "${workspaceRoot}/node_modules/jest/bin/jest.js", 11 | "--runInBand" 12 | ], 13 | "preLaunchTask": "build", 14 | "console": "integratedTerminal", 15 | "internalConsoleOptions": "neverOpen", 16 | "port": 9229 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "adx-query-charts", 3 | "version": "1.1.60", 4 | "description": "Draw charts from Azure Data Explorer queries", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "prepublish": "npm run build && npm run test", 10 | "test": "npm run build && jest", 11 | "test:watch": "jest --watch", 12 | "test:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --runInBand" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/microsoft/adx-query-charts.git" 17 | }, 18 | "keywords": [], 19 | "author": { 20 | "name": "Violet Voronetzky", 21 | "email": "sigalvo@microsoft.com" 22 | }, 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/microsoft/adx-query-charts/issues" 26 | }, 27 | "homepage": "https://github.com/microsoft/adx-query-charts#readme", 28 | "files": [ 29 | "dist/**/*" 30 | ], 31 | "devDependencies": { 32 | "typescript": "^3.7.2", 33 | "@types/jest": "^24.0.22", 34 | "jest": "^24.9.0", 35 | "@types/lodash": "^4.14.135", 36 | "acorn": "^7.1.1" 37 | }, 38 | "dependencies": { 39 | "css-element-queries": "^1.2.3", 40 | "highcharts": "9.0.1", 41 | "lodash": "^4.17.21" 42 | }, 43 | "jest": { 44 | "testMatch": [ 45 | "**/dist/**/?(*.)(spec|test).js?(x)" 46 | ], 47 | "moduleFileExtensions": [ 48 | "ts", 49 | "tsx", 50 | "js", 51 | "jsx", 52 | "json", 53 | "node" 54 | ] 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/common/changeDetection.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | //#region Imports 4 | 5 | import * as _ from 'lodash'; 6 | import { IQueryResultData, IChartOptions } from './chartModels'; 7 | import { ChartChange, Changes } from './chartChange'; 8 | 9 | //#endregion Imports 10 | 11 | export class ChangeDetection { 12 | public static detectChanges(oldQueryResultData: IQueryResultData, oldChartOptions: IChartOptions, newQueryResultData: IQueryResultData, newChartOptions: IChartOptions): Changes { 13 | if(!oldChartOptions) { 14 | return null; // First initialization 15 | } 16 | 17 | const changes: Changes = new Changes(); 18 | 19 | if (oldQueryResultData !== newQueryResultData) { 20 | changes.addChange(ChartChange.QueryData); 21 | } 22 | 23 | if(ChangeDetection.isColumnsSelectionChanged(newChartOptions, oldChartOptions)) { 24 | changes.addChange(ChartChange.ColumnsSelection); 25 | } 26 | 27 | if(oldChartOptions.chartType !== newChartOptions.chartType) { 28 | changes.addChange(ChartChange.ChartType); 29 | } 30 | 31 | if(oldChartOptions.aggregationType !== newChartOptions.aggregationType) { 32 | changes.addChange(ChartChange.AggregationType); 33 | } 34 | 35 | return changes; 36 | } 37 | 38 | private static isColumnsSelectionChanged(newChartOptions: IChartOptions, oldChartOptions: IChartOptions): boolean { 39 | const oldSelection = oldChartOptions.columnsSelection; 40 | const oldSelectedColumns = [oldSelection.xAxis].concat(oldSelection.yAxes).concat(oldSelection.splitBy || []); 41 | const newSelection = newChartOptions.columnsSelection; 42 | const newSelectedColumns = [newSelection.xAxis].concat(newSelection.yAxes).concat(newSelection.splitBy || []); 43 | 44 | if(oldSelectedColumns.length !== newSelectedColumns.length) { 45 | return true; 46 | } 47 | 48 | return !_.isEqual(_.sortBy(oldSelectedColumns, 'name'), _.sortBy(newSelectedColumns, 'name')); 49 | } 50 | } -------------------------------------------------------------------------------- /src/common/chartChange.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export enum ChartChange { 4 | QueryData = 'QueryData', 5 | ColumnsSelection = 'ColumnsSelection', 6 | ChartType = 'ChartType', 7 | AggregationType = 'AggregationType' 8 | } 9 | 10 | export type ChangesMap = { [key in ChartChange]+?: boolean}; 11 | 12 | export class Changes { 13 | public count: number; 14 | private changesMap: ChangesMap; 15 | 16 | public constructor() { 17 | this.changesMap = {}; 18 | this.count = 0; 19 | } 20 | 21 | public addChange(chartChange: ChartChange): void { 22 | this.changesMap[chartChange] = true; 23 | this.count++; 24 | } 25 | 26 | public isPendingChange(chartChange: ChartChange): boolean { 27 | return !!this.changesMap[chartChange]; 28 | } 29 | } -------------------------------------------------------------------------------- /src/common/chartInfo.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { IChartInfo, IDataTransformationInfo, DrawChartStatus } from "./chartModels"; 4 | import { ChartError } from "./errors/errors"; 5 | 6 | export class ChartInfo implements IChartInfo { 7 | public dataTransformationInfo: IDataTransformationInfo = { 8 | numberOfDataPoints: 0, 9 | isPartialData: false, 10 | isAggregationApplied: false 11 | }; 12 | 13 | public status: DrawChartStatus = DrawChartStatus.Success; 14 | public error?: ChartError; 15 | } -------------------------------------------------------------------------------- /src/common/chartModels.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { ChartError } from "./errors/errors"; 4 | 5 | //#region Draft contracts 6 | 7 | // See: https://kusto.azurewebsites.net/docs/query/scalar-data-types/index.html 8 | export enum DraftColumnType { 9 | Bool = 'bool', 10 | DateTime = 'datetime', 11 | Decimal = 'decimal', 12 | Dynamic = 'dynamic', 13 | Guid = 'guid', 14 | Int = 'int', 15 | Long = 'long', 16 | Real = 'real', 17 | String = 'string', 18 | TimeSpan = 'timespan' 19 | } 20 | 21 | //#endregion Draft contracts 22 | 23 | export type IRowValue = string | number; 24 | export type ISeriesRowValue = IRowValue | string[] | number[]; 25 | export type IRow = IRowValue[]; 26 | export type ISeriesRow = ISeriesRowValue[]; 27 | 28 | export interface IColumn { 29 | name: string; 30 | type: DraftColumnType; 31 | } 32 | 33 | export interface IQueryResultData { 34 | rows: IRow[] | ISeriesRow[]; 35 | columns: IColumn[]; 36 | } 37 | 38 | export interface IDataPointInfo { 39 | column: IColumn; 40 | value: IRowValue; 41 | } 42 | 43 | export interface IDataPoint { 44 | x: IDataPointInfo; 45 | y: IDataPointInfo; 46 | splitBy?: IDataPointInfo; 47 | } 48 | 49 | export enum ChartType { 50 | Line = 'Line', 51 | Scatter = 'Scatter', 52 | UnstackedArea = 'UnstackedArea', 53 | StackedArea = 'StackedArea', 54 | PercentageArea = 'PercentageArea', 55 | UnstackedColumn = 'UnstackedColumn', 56 | StackedColumn = 'StackedColumn', 57 | PercentageColumn = 'PercentageColumn', 58 | UnstackedBar = 'UnstackedBar', 59 | StackedBar = 'StackedBar', 60 | PercentageBar = 'PercentageBar', 61 | Pie = 'Pie', 62 | Donut = 'Donut', 63 | } 64 | 65 | export enum AggregationType { 66 | Sum = 'Sum', 67 | Average = 'Average', 68 | Min = 'Min', 69 | Max = 'Max' 70 | } 71 | 72 | export enum DateFormat { 73 | FullDate = 'FullDate', // The full date and time. For example: 12/7/2019, 2:30:00.600 74 | Time = 'Time', // The full time, without the milliseconds. For example: 2:30:00 75 | FullTime = 'FullTime', // The full time, including the milliseconds. For example: 2:30:00.600 76 | HourAndMinute = 'HourAndMinute', // The hours and minutes. For example: 2:30 77 | MonthAndDay = 'MonthAndDay', // The month and day. For example: July 12th 78 | MonthAndYear = 'MonthAndYear', // The month and day. For example: July 2019 79 | Year = 'Year' // The year. For example: 2019 80 | } 81 | 82 | export enum ChartTheme { 83 | Dark = 'Dark', 84 | Light = 'Light' 85 | } 86 | 87 | export enum DrawChartStatus { 88 | Success = 'Success', // Successfully drawn the chart 89 | Failed = 'Failed', // There was an error while trying to draw the chart 90 | Canceled = 'Canceled' // The chart drawing was canceled. See onFinishDataTransformation return value for more information regarding drawing cancellation 91 | } 92 | 93 | export enum LegendPosition { 94 | Bottom = 'Bottom', 95 | Right = 'Right' 96 | } 97 | 98 | export interface ISupportedColumnTypes { 99 | xAxis: DraftColumnType[]; 100 | yAxis: DraftColumnType[]; 101 | splitBy: DraftColumnType[]; 102 | } 103 | 104 | export interface ISupportedColumns { 105 | xAxis: IColumn[]; 106 | yAxis: IColumn[]; 107 | splitBy: IColumn[]; 108 | } 109 | 110 | export interface IAxesInfo { 111 | xAxis: T; 112 | yAxes: T[]; 113 | splitBy?: T[]; 114 | } 115 | 116 | export class ColumnsSelection implements IAxesInfo { 117 | public xAxis: IColumn; 118 | public yAxes: IColumn[]; 119 | public splitBy?: IColumn[]; 120 | } 121 | 122 | export interface IDataTransformationInfo { 123 | /** 124 | * The amount of the data points that will be drawn for the chart 125 | */ 126 | numberOfDataPoints: number; 127 | 128 | /** 129 | * True if the chart presents partial data from the original query results 130 | * The chart data will be partial when the maximum number of the unique X-axis values exceed the 'maxUniqueXValues' in 'IChartOptions' 131 | */ 132 | isPartialData: boolean; 133 | 134 | /** 135 | * True if aggregation was applied on the original query results in order to draw the chart 136 | * See 'aggregationType' in 'IChartOptions' for more details 137 | */ 138 | isAggregationApplied: boolean; 139 | } 140 | 141 | export interface IChartInfo { 142 | /** 143 | * The information regarding the applied transformations on the original query results 144 | */ 145 | dataTransformationInfo: IDataTransformationInfo; 146 | 147 | /** 148 | * The status of the draw action 149 | */ 150 | status: DrawChartStatus; 151 | 152 | /** 153 | * The error information in case that the draw action failed 154 | */ 155 | error?: ChartError; 156 | } 157 | 158 | /** 159 | * The information required to draw the chart 160 | */ 161 | export interface IChartOptions { 162 | /** 163 | * The type of the chart to draw 164 | */ 165 | chartType: ChartType; 166 | 167 | /** 168 | * The columns selection for the Axes and the split-by of the chart 169 | * If not provided, default columns will be selected. See: getDefaultSelection method 170 | */ 171 | columnsSelection?: ColumnsSelection; 172 | 173 | /** 174 | * The maximum number of the unique X-axis values. 175 | * The chart will show the biggest values, and the rest will be aggregated to a separate data point. 176 | * 177 | * [Default value: 100] 178 | */ 179 | maxUniqueXValues?: number; 180 | 181 | /** 182 | * The label of the data point that contains the aggregated value of all the X-axis values that exceed the 'maxUniqueXValues' 183 | * 184 | * [Default value: 'OTHER'] 185 | */ 186 | exceedMaxDataPointLabel?: string; 187 | 188 | /** 189 | * Multiple rows with the same values for the X-axis and the split-by will be aggregated using a function of this type. 190 | * For example, assume we get the following query result data: 191 | * timestamp | client_Browser | count_ 192 | * 2016-08-02T10:00:00Z | Chrome 51.0 | 15 193 | * 2016-08-02T10:00:00Z | Internet Explorer 9.0 | 4 194 | * When drawing a chart with columnsSelection = { xAxis: timestamp, yAxes: count_ }, and aggregationType = AggregationType.Sum 195 | * we need to aggregate the values of the same timestamp value and return one row with ["2016-08-02T10:00:00Z", 19] 196 | * 197 | * [Default value: AggregationType.Sum] 198 | */ 199 | aggregationType?: AggregationType; 200 | 201 | /** 202 | * The chart's title 203 | */ 204 | title?: string; 205 | 206 | /** 207 | * The theme of the chart 208 | * [Default value: ChartTheme.Light] 209 | */ 210 | chartTheme?: ChartTheme; 211 | 212 | /** 213 | * The legend configuration options 214 | */ 215 | legendOptions?: ILegendOptions; 216 | 217 | /** 218 | * The minimum value to be displayed on the y-axis 219 | * If not provided, the minimum value is automatically calculated 220 | */ 221 | yMinimumValue?: number; 222 | 223 | /** 224 | * The maximum value to be displayed on y-axis 225 | * If not provided, the maximum value is automatically calculated 226 | */ 227 | yMaximumValue?: number; 228 | 229 | /** 230 | * The duration of the animation for chart rendering. 231 | * The animation can be disabled by setting it to 0. 232 | * [Default value: 1000] 233 | */ 234 | animationDurationMS?: number; 235 | 236 | /** 237 | * Chart labels font family 238 | * [Default value: az_ea_font, wf_segoe-ui_normal, "Segoe UI", "Segoe WP", Tahoma, Arial, sans-serif] 239 | */ 240 | fontFamily?: string; 241 | 242 | /** 243 | * Callback that is used to get the desired offset from UTC in minutes for date value. Used to handle timezone. 244 | * The offset will be added to the original UTC date from the query results data. 245 | * If dateFormatter wasn't provided, the callback will be also used for the X axis labels and the tooltip header. Otherwise - it will only be used for positioning the x-axis. 246 | * Callback inputs: 247 | * @param dateValue - The original date value in milliseconds since midnight, January 1, 1970 UTC. For example: 1574666160000 represents '2019-11-25T07:16:00.000Z' 248 | * Callback return value: 249 | * @returns The desired offset from UTC in minutes for date value. For example: 250 | * For 'South Africa Standard Time' timezone return -120 and the displayed date will be '11/25/2019, 02:00 PM' instead of '11/25/2019, 04:00 PM' 251 | * See time zone info: https://msdn.microsoft.com/en-us/library/ms912391(v=winembedded.11).aspx 252 | * [Default value: () => { return 0; }] 253 | */ 254 | getUtcOffset?: (dateValue: number) => number; 255 | 256 | /** 257 | * Callback that is used to format the date values both in the axis and the tooltip. If not provided - the default formatting will apply. 258 | * Callback inputs: 259 | * @param dateValue - The original date value in milliseconds since midnight, January 1, 1970 UTC. For example: 1574666160000 represents '2019-11-25T07:16:00.000Z' 260 | * @param defaultFormat - The default format of the label. 261 | * Callback return value: 262 | * @returns The string represents the display value of the dateValue 263 | */ 264 | dateFormatter?: (dateValue: number, defaultFormat: DateFormat) => string; 265 | 266 | /** 267 | * Callback that is used to format number values both in the axis and the tooltip. If isn't provided - the default formatting will apply. 268 | * Callback inputs: 269 | * @param numberValue - The original number 270 | * Callback return value: 271 | * @returns The string represents the display value of the numberValue 272 | */ 273 | numberFormatter?: (numberValue: number) => string; 274 | 275 | /** 276 | * Callback that is used to get the xAxis title. If isn't provided - the xAxis title will be the xAxis column name. 277 | * Callback inputs: 278 | * @param xAxisColumn - The x-axis column 279 | * Callback return value: 280 | * @returns The desired x-axis title 281 | */ 282 | xAxisTitleFormatter?: (xAxisColumn: IColumn) => string; 283 | 284 | /** 285 | * Callback that is used to get the yAxis title. If isn't provided - the yAxis title will be the first yAxis column name. 286 | * Callback inputs: 287 | * @param yAxisColumns - The y-axis columns 288 | * Callback return value: 289 | * @returns The desired y-axis title 290 | */ 291 | yAxisTitleFormatter?: (yAxisColumns: IColumn[]) => string; 292 | 293 | /** 294 | * Callback that is called to allow altering the options of the external charting library before rendering the chart. 295 | * Used to allow flexibility and control of the external charting library. 296 | * USE WITH CAUTION - changing the original options might break the functionality / backward compatibility when using a different IVisualizer or upgrading the charting library. 297 | * Validating the updated options is the user's responsibility. 298 | * For official chart options - please make contribution to the base code. 299 | * Callback inputs: 300 | * @param originalOptions - The custom charting options that are given to the external charting library. 301 | */ 302 | updateCustomOptions?: (originalOptions: any) => void; 303 | 304 | /** 305 | * Callback that is called when all the data transformations required to draw the chart are finished. 306 | * Callback inputs: 307 | * @param IChartInfo - The information regarding the applied transformations on the original query results 308 | * Callback return value: 309 | * @returns The promise that is used to continue/stop drawing the chart. 310 | * When provided, the drawing of the chart will be suspended until this promise will be resolved. 311 | * When resolved with true - the chart will continue the drawing. 312 | * When resolved with false - the chart drawing will be canceled. 313 | */ 314 | onFinishDataTransformation?: (dataTransformationInfo: IDataTransformationInfo) => Promise; 315 | 316 | /** 317 | * Callback that is called when the chart drawing is finished. 318 | * Callback inputs: 319 | * @param chartInfo - The information regarding the chart 320 | */ 321 | onFinishDrawing?: (chartInfo: IChartInfo) => void; 322 | 323 | /** 324 | * Callback that is called when the chart animation is finished. 325 | * Callback inputs: 326 | * @param chartInfo - The information regarding the chart 327 | */ 328 | onFinishChartAnimation?: (chartInfo: IChartInfo) => void; 329 | 330 | /** 331 | * When this callback is provided, the chart data points will be clickable. 332 | * The callback will be called when chart's data point will be clicked, providing the clicked data point information. 333 | * Callback inputs: 334 | * @param dataPoint - The information regarding the columns and values of the clicked data point. 335 | * Note that the value of a date-time column in the dataPoint object will be its numeric value - Date.valueOf(). 336 | */ 337 | onDataPointClicked?: (dataPoint: IDataPoint) => void; 338 | } 339 | 340 | export interface ILegendOptions { 341 | /** 342 | * When set to false the legend is hidden, otherwise the legend is visible 343 | * [Default value: true (show legend)] 344 | */ 345 | isEnabled?: boolean; 346 | 347 | /** 348 | * The position of the legend (relative to the chart) 349 | * [Default value: Bottom] 350 | */ 351 | position?: LegendPosition; 352 | } 353 | 354 | export interface IChartHelper { 355 | /** 356 | * Draw the chart asynchronously 357 | * @param queryResultData - The original query result data 358 | * @param chartOptions - The information required to draw the chart 359 | * @returns Promise that is resolved when the chart is finished drawing. The promise will be resolved with information regarding the draw action 360 | */ 361 | draw(queryResultData: IQueryResultData, chartOptions: IChartOptions): Promise; 362 | 363 | /** 364 | * Change the theme of an existing chart 365 | * @param newTheme - The theme to apply 366 | * @returns Promise that is resolved when the theme is applied 367 | */ 368 | changeTheme(newTheme: ChartTheme): Promise; 369 | 370 | /** 371 | * Get the supported column types for the axes and the split-by for a specific chart type 372 | * @param chartType - The type of the chart 373 | */ 374 | getSupportedColumnTypes(chartType: ChartType): ISupportedColumnTypes; 375 | 376 | /** 377 | * Get the supported columns from the query result data for the axes and the split-by for a specific chart type 378 | * @param queryResultData - The original query result data 379 | * @param chartType - The type of the chart 380 | */ 381 | getSupportedColumnsInResult(queryResultData: IQueryResultData, chartType: ChartType): ISupportedColumns; 382 | 383 | /** 384 | * Get the default columns selection from the query result data. 385 | * Select the default columns for the axes and the split-by for drawing a default chart of a specific chart type. 386 | * @param queryResultData - The original query result data 387 | * @param chartType - The type of the chart 388 | * @param supportedColumnsForChart - [Optional] The list of the supported column types for the axes and the split-by 389 | */ 390 | getDefaultSelection(queryResultData: IQueryResultData, chartType: ChartType, supportedColumnsForChart?: ISupportedColumns): ColumnsSelection; 391 | 392 | /** 393 | * Download the chart as JPG image 394 | * @param onError - [Optional] A callback that will be called if the module failed to export the chart image 395 | */ 396 | downloadChartJPGImage(onError?: (error: ChartError) => void): void; 397 | } -------------------------------------------------------------------------------- /src/common/errors/errorCode.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export enum ErrorCode { 4 | InvalidQueryResultData = 'InvalidQueryResultData', 5 | InvalidColumnsSelection = 'InvalidColumnsSelection', 6 | UnsupportedTypeInColumnsSelection = 'UnsupportedTypeInColumnsSelection', 7 | InvalidChartContainerElementId = 'InvalidChartContainerElementId', 8 | InvalidDate = 'InvalidDate', 9 | FailedToCreateVisualization = 'FailedToCreateVisualization', 10 | EmptyPie = 'EmptyPie' 11 | } -------------------------------------------------------------------------------- /src/common/errors/errors.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { ErrorCode } from './errorCode'; 4 | 5 | export class ChartError extends Error { 6 | public errorCode: ErrorCode; 7 | 8 | public constructor(message: string, errorCode: ErrorCode) { 9 | super(message); 10 | 11 | this.name = name; 12 | this.errorCode = errorCode; 13 | } 14 | } 15 | 16 | export class InvalidInputError extends ChartError { 17 | public name = 'Invalid Input'; 18 | } 19 | 20 | export class VisualizerError extends ChartError { 21 | public name = 'Failed to create the visualization'; 22 | } 23 | 24 | export class EmptyPieError extends ChartError { 25 | public constructor() { 26 | super("The pie chart can't be drawn since it contains only empty data (zero / null / undefined)", ErrorCode.EmptyPie); 27 | } 28 | } -------------------------------------------------------------------------------- /src/common/kustoChartHelper.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | //#region Imports 4 | 5 | import * as _ from 'lodash'; 6 | import { IChartHelper, IQueryResultData, ChartType, DraftColumnType, ISupportedColumnTypes, IColumn, ISupportedColumns, ColumnsSelection, IChartOptions, AggregationType, ChartTheme, IChartInfo, DrawChartStatus, LegendPosition } from './chartModels'; 7 | import { SeriesVisualize } from '../transformers/seriesVisualize'; 8 | import { LimitVisResultsSingleton, LimitedResults, ILimitAndAggregateParams } from '../transformers/limitVisResults'; 9 | import { IVisualizer } from '../visualizers/IVisualizer'; 10 | import { Utilities } from './utilities'; 11 | import { ChartChange } from './chartChange'; 12 | import { ChangeDetection } from './changeDetection'; 13 | import { ChartInfo } from './chartInfo'; 14 | import { IVisualizerOptions } from '../visualizers/IVisualizerOptions'; 15 | import { InvalidInputError, ChartError } from './errors/errors'; 16 | import { ErrorCode } from './errors/errorCode'; 17 | 18 | //#endregion Imports 19 | 20 | interface ITransformedQueryResultData { 21 | data: IQueryResultData; 22 | limitedResults: LimitedResults; 23 | } 24 | 25 | type ResolveFn = (value?: IChartInfo | PromiseLike) => void; 26 | 27 | export class KustoChartHelper implements IChartHelper { 28 | //#region Public members 29 | 30 | public transformedQueryResultData: IQueryResultData; 31 | public isResolveAsSeries: boolean = false; 32 | 33 | //#endregion Public members 34 | 35 | //#region Private members 36 | 37 | private static readonly maxDefaultYAxesSelection: number = 4; 38 | 39 | private static readonly defaultChartOptions: IChartOptions = { 40 | chartType: ChartType.UnstackedColumn, 41 | columnsSelection: undefined, 42 | maxUniqueXValues: 100, 43 | exceedMaxDataPointLabel: 'OTHER', 44 | aggregationType: AggregationType.Sum, 45 | chartTheme: ChartTheme.Light, 46 | animationDurationMS: 1000, 47 | getUtcOffset: () => { return 0; }, 48 | fontFamily: `az_ea_font, wf_segoe-ui_normal, "Segoe UI", "Segoe WP", Tahoma, Arial, sans-serif`, 49 | legendOptions: { 50 | isEnabled: true, 51 | position: LegendPosition.Bottom 52 | } 53 | } 54 | 55 | private readonly seriesVisualize: SeriesVisualize; 56 | private readonly elementId: string; 57 | private readonly visualizer: IVisualizer; 58 | 59 | private queryResultData: IQueryResultData; // The original query result data 60 | private options: IChartOptions; 61 | private chartInfo: IChartInfo; 62 | 63 | //#endregion Private members 64 | 65 | //#region Constructor 66 | 67 | public constructor(elementId: string, visualizer: IVisualizer) { 68 | this.elementId = elementId; 69 | this.visualizer = visualizer; 70 | this.seriesVisualize = SeriesVisualize.getInstance(); 71 | } 72 | 73 | //#endregion Constructor 74 | 75 | //#region Public methods 76 | 77 | public draw(queryResultData: IQueryResultData, chartOptions: IChartOptions): Promise { 78 | return new Promise((resolve, reject) => { 79 | try { 80 | this.verifyInput(queryResultData, chartOptions); 81 | 82 | // Update the chart options with defaults for optional values that weren't provided 83 | chartOptions = this.updateDefaultChartOptions(queryResultData, chartOptions); 84 | 85 | // Detect the changes from the current chart 86 | const changes = ChangeDetection.detectChanges(this.queryResultData, this.options, queryResultData, chartOptions); 87 | 88 | // Update current options and data 89 | this.options = { ...chartOptions}; 90 | this.queryResultData = queryResultData; 91 | 92 | // First initialization / query data change / columns selection change / aggregation type change 93 | if (!changes || changes.isPendingChange(ChartChange.QueryData) || changes.isPendingChange(ChartChange.ColumnsSelection) || changes.isPendingChange(ChartChange.AggregationType)) { 94 | this.chartInfo = new ChartInfo(); 95 | 96 | // Apply query data transformation 97 | const transformed = this.transformQueryResultData(queryResultData, chartOptions); 98 | 99 | this.transformedQueryResultData = transformed.data; 100 | this.chartInfo.dataTransformationInfo.isAggregationApplied = transformed.limitedResults.isAggregationApplied; 101 | this.chartInfo.dataTransformationInfo.isPartialData = transformed.limitedResults.isPartialData; 102 | } 103 | 104 | const visualizerOptions: IVisualizerOptions = { 105 | elementId: this.elementId, 106 | queryResultData: this.transformedQueryResultData, 107 | chartOptions: chartOptions, 108 | chartInfo: this.chartInfo 109 | }; 110 | 111 | let drawChartPromise: Promise; 112 | 113 | // First initialization 114 | if (!changes) { 115 | drawChartPromise = this.visualizer.drawNewChart(visualizerOptions); 116 | } else { // Change existing chart 117 | drawChartPromise = this.visualizer.updateExistingChart(visualizerOptions, changes); 118 | } 119 | 120 | drawChartPromise 121 | .then(() => { 122 | this.finishDrawing(resolve, chartOptions); 123 | }) 124 | .catch((ex) => { 125 | this.onError(resolve, chartOptions, ex); 126 | }); 127 | } catch (ex) { 128 | this.onError(resolve, chartOptions, ex); 129 | } 130 | }); 131 | } 132 | 133 | public changeTheme(newTheme: ChartTheme): Promise { 134 | return new Promise((resolve, reject) => { 135 | if (this.options && this.options.chartTheme !== newTheme) { 136 | this.visualizer.changeTheme(newTheme) 137 | .then(() => { 138 | this.options.chartTheme = newTheme; 139 | resolve(); 140 | }); 141 | } else { 142 | resolve(); 143 | } 144 | }); 145 | } 146 | 147 | public getSupportedColumnTypes(chartType: ChartType): ISupportedColumnTypes { 148 | switch (chartType) { 149 | case ChartType.Pie: 150 | case ChartType.Donut: { 151 | return { 152 | xAxis: [DraftColumnType.String], 153 | yAxis: [DraftColumnType.Int, DraftColumnType.Long, DraftColumnType.Decimal, DraftColumnType.Real], 154 | splitBy: [DraftColumnType.String, DraftColumnType.DateTime, DraftColumnType.Bool] 155 | } 156 | } 157 | 158 | default: { 159 | return { 160 | xAxis: [DraftColumnType.DateTime, DraftColumnType.Int, DraftColumnType.Long, DraftColumnType.Decimal, DraftColumnType.Real, DraftColumnType.String], 161 | yAxis: [DraftColumnType.Int, DraftColumnType.Long, DraftColumnType.Decimal, DraftColumnType.Real], 162 | splitBy: [DraftColumnType.String] 163 | } 164 | } 165 | } 166 | } 167 | 168 | public getSupportedColumnsInResult(queryResultData: IQueryResultData, chartType: ChartType): ISupportedColumns { 169 | const transformedQueryResultData: IQueryResultData = this.tryResolveResultsAsSeries(queryResultData); 170 | const supportedColumnTypes: ISupportedColumnTypes = this.getSupportedColumnTypes(chartType); 171 | 172 | return { 173 | xAxis: this.getSupportedColumns(transformedQueryResultData, supportedColumnTypes.xAxis), 174 | yAxis: this.getSupportedColumns(transformedQueryResultData, supportedColumnTypes.yAxis), 175 | splitBy: this.getSupportedColumns(transformedQueryResultData, supportedColumnTypes.splitBy) 176 | } 177 | } 178 | 179 | public getDefaultSelection(queryResultData: IQueryResultData, chartType: ChartType, supportedColumnsForChart?: ISupportedColumns): ColumnsSelection { 180 | if (!supportedColumnsForChart) { 181 | supportedColumnsForChart = this.getSupportedColumnsInResult(queryResultData, chartType); 182 | } 183 | 184 | const columnsSelection = new ColumnsSelection(); 185 | const supportedXAxisColumns = supportedColumnsForChart.xAxis; 186 | const supportedYAxisColumns = supportedColumnsForChart.yAxis; 187 | 188 | if (!supportedXAxisColumns || supportedXAxisColumns.length === 0 || !supportedYAxisColumns || supportedYAxisColumns.length === 0) { 189 | return columnsSelection; // Not enough supported columns - return empty selection 190 | } 191 | 192 | if (supportedYAxisColumns.length === 1) { 193 | columnsSelection.yAxes = supportedYAxisColumns; // If only 1 column is supported as y-axis column - select it 194 | } 195 | 196 | columnsSelection.xAxis = this.selectDefaultXAxis(supportedXAxisColumns, columnsSelection); 197 | if (!columnsSelection.xAxis) { 198 | return columnsSelection; 199 | } 200 | 201 | const defaultSplitBy = this.selectDefaultSplitByColumn(supportedColumnsForChart.splitBy, columnsSelection, chartType); 202 | 203 | columnsSelection.splitBy = defaultSplitBy ? [defaultSplitBy] : null; 204 | if (!columnsSelection.yAxes) { 205 | columnsSelection.yAxes = this.selectDefaultYAxes(supportedYAxisColumns, columnsSelection, chartType); 206 | } 207 | 208 | return columnsSelection; 209 | } 210 | 211 | public downloadChartJPGImage() { 212 | this.visualizer.downloadChartJPGImage(); 213 | } 214 | 215 | //#endregion Public methods 216 | 217 | //#region Private methods 218 | 219 | /** 220 | * Convert the query result data to an object that the chart can be drawn with. 221 | * @param queryResultData Original query result data 222 | * @param chartOptions 223 | * @returns transformed data if the transformation succeeded. Otherwise - returns null 224 | */ 225 | private transformQueryResultData(queryResultData: IQueryResultData, chartOptions: IChartOptions): ITransformedQueryResultData { 226 | // Try to resolve results as series 227 | const resolvedAsSeriesData: IQueryResultData = this.tryResolveResultsAsSeries(queryResultData); 228 | 229 | // Update the chart options with defaults for optional values that weren't provided 230 | chartOptions = this.updateDefaultChartOptions(resolvedAsSeriesData, chartOptions); 231 | 232 | const chartColumns: IColumn[] = []; 233 | const indexOfXAxisColumn: number[] = []; 234 | const xAxisColumn = chartOptions.columnsSelection.xAxis; 235 | 236 | // X-Axis 237 | let notFoundColumns: IColumn[] = this.addColumnsIfExistInResult([xAxisColumn], resolvedAsSeriesData, indexOfXAxisColumn, chartColumns); 238 | 239 | this.throwInvalidColumnsSelectionIfNeeded(notFoundColumns, 'x-axis', resolvedAsSeriesData); 240 | 241 | // Get all the indexes for all the splitBy columns 242 | const splitByColumnsSelection = chartOptions.columnsSelection.splitBy; 243 | const indexesOfSplitByColumns: number[] = []; 244 | 245 | if (splitByColumnsSelection) { 246 | notFoundColumns = this.addColumnsIfExistInResult(splitByColumnsSelection, resolvedAsSeriesData, indexesOfSplitByColumns, chartColumns); 247 | 248 | this.throwInvalidColumnsSelectionIfNeeded(notFoundColumns, 'split-by', resolvedAsSeriesData); 249 | } 250 | 251 | // Get all the indexes for all the y fields 252 | const indexesOfYAxes: number[] = []; 253 | 254 | notFoundColumns = this.addColumnsIfExistInResult(chartOptions.columnsSelection.yAxes, resolvedAsSeriesData, indexesOfYAxes, chartColumns); 255 | 256 | this.throwInvalidColumnsSelectionIfNeeded(notFoundColumns, 'y-axes', resolvedAsSeriesData); 257 | 258 | // Create transformed rows for visualization 259 | const limitAndAggregateParams: ILimitAndAggregateParams = { 260 | queryResultData: resolvedAsSeriesData, 261 | axesIndexes: { 262 | xAxis: indexOfXAxisColumn[0], 263 | yAxes: indexesOfYAxes, 264 | splitBy: indexesOfSplitByColumns 265 | }, 266 | xColumnType: chartOptions.columnsSelection.xAxis.type, 267 | aggregationType: chartOptions.aggregationType, 268 | maxUniqueXValues: chartOptions.maxUniqueXValues, 269 | otherStr: chartOptions.exceedMaxDataPointLabel 270 | } 271 | 272 | const limitedResults: LimitedResults = LimitVisResultsSingleton.limitAndAggregateRows(limitAndAggregateParams); 273 | 274 | return { 275 | data: { 276 | rows: limitedResults.rows, 277 | columns: chartColumns 278 | }, 279 | limitedResults: limitedResults 280 | } 281 | } 282 | 283 | private throwInvalidColumnsSelectionIfNeeded(notFoundColumns: IColumn[], axesStr: string, queryResultData: IQueryResultData) { 284 | if (notFoundColumns.length > 0) { 285 | const errorMessage: string = 286 | `One or more of the selected ${axesStr} columns don't exist in the query result data: 287 | ${this.getColumnsStr(notFoundColumns)} 288 | columns in query data: 289 | ${this.getColumnsStr(queryResultData.columns)}`; 290 | 291 | throw new InvalidInputError(errorMessage, ErrorCode.InvalidColumnsSelection); 292 | } 293 | } 294 | 295 | private getColumnsStr(columns: IColumn[]): string { 296 | const columnsStr: string = _.map(columns, (column) => { 297 | return `name = '${column.name}' type = '${column.type}'`; 298 | }).join(', '); 299 | 300 | return columnsStr; 301 | } 302 | 303 | private tryResolveResultsAsSeries(queryResultData: IQueryResultData): IQueryResultData { 304 | const resolvedAsSeriesData: IQueryResultData = this.seriesVisualize.tryResolveResultsAsSeries(queryResultData); 305 | 306 | return resolvedAsSeriesData || queryResultData; 307 | } 308 | 309 | private getSupportedColumns(queryResultData: IQueryResultData, supportedTypes: DraftColumnType[]): IColumn[] { 310 | const supportedColumns: IColumn[] = queryResultData.columns.filter((column: IColumn) => { 311 | return supportedTypes.indexOf(column.type) !== -1; 312 | }); 313 | 314 | return supportedColumns; 315 | } 316 | 317 | private selectDefaultXAxis(supportedColumns: IColumn[], currentSelection: ColumnsSelection): IColumn { 318 | const updatedSupportedColumns = this.removeSelectedColumns(supportedColumns, currentSelection); 319 | 320 | if (updatedSupportedColumns.length === 0) { 321 | return null; 322 | } 323 | 324 | // Select the first DateTime column if exists 325 | for (let i = 0; i < updatedSupportedColumns.length; i++) { 326 | const column: IColumn = updatedSupportedColumns[i]; 327 | 328 | if (column.type === DraftColumnType.DateTime) { 329 | return column; 330 | } 331 | } 332 | 333 | // If DateTime column doesn't exist - select the first supported column 334 | return updatedSupportedColumns[0]; 335 | } 336 | 337 | private selectDefaultYAxes(supportedColumns: IColumn[], currentSelection: ColumnsSelection, chartType: ChartType): IColumn[] { 338 | if (!supportedColumns || supportedColumns.length === 0) { 339 | return null; 340 | } 341 | 342 | 343 | // Remove the selected columns from the supported columns 344 | const updatedSupportedColumns = this.removeSelectedColumns(supportedColumns, currentSelection); 345 | 346 | if (updatedSupportedColumns.length === 0) { 347 | return null; 348 | } 349 | 350 | let numberOfDefaultYAxes: number = 1; 351 | 352 | // The y-axis is a single select when there is split-by, or for Pie / Donut charts 353 | if (!Utilities.isPieOrDonut(chartType) && !currentSelection.splitBy) { 354 | numberOfDefaultYAxes = KustoChartHelper.maxDefaultYAxesSelection; 355 | } 356 | 357 | const selectedYAxes: IColumn[] = updatedSupportedColumns.slice(0, numberOfDefaultYAxes); 358 | 359 | return selectedYAxes; 360 | } 361 | 362 | private selectDefaultSplitByColumn(supportedColumns: IColumn[], currentSelection: ColumnsSelection, chartType: ChartType): IColumn { 363 | // Pie / Donut chart default is without a splitBy column 364 | if (!supportedColumns || supportedColumns.length === 0 || Utilities.isPieOrDonut(chartType)) { 365 | return null; 366 | } 367 | 368 | // Remove the selected columns from the supported columns 369 | const updatedSupportedColumns = this.removeSelectedColumns(supportedColumns, currentSelection); 370 | 371 | if (updatedSupportedColumns.length > 0) { 372 | return updatedSupportedColumns[0]; 373 | } 374 | 375 | return null; 376 | } 377 | 378 | /** 379 | * Removes the columns that are already selected from the supportedColumns array in order to prevent the selection of the same column in different axes 380 | * @param supportedColumns - The list of the supported columns for current axis 381 | * @param currentSelection - The list of the columns that are already selected 382 | * @returns - The supportedColumns after the removal of the selected columns 383 | */ 384 | private removeSelectedColumns(supportedColumns: IColumn[], currentSelection: ColumnsSelection): IColumn[] { 385 | const selectedColumns: IColumn[] = _.concat(currentSelection.xAxis || [], currentSelection.yAxes || [], currentSelection.splitBy || []); 386 | 387 | // No columns are selected - do nothing 388 | if (selectedColumns.length === 0) { 389 | return supportedColumns; 390 | } 391 | 392 | // Remove the selected columns from the supported columns 393 | const updatedSupportedColumns = supportedColumns.filter(supported => _.findIndex(selectedColumns, (selected) => selected.name === supported.name) === -1); 394 | 395 | return updatedSupportedColumns; 396 | } 397 | 398 | /** 399 | * Search for certain columns in the 'queryResultData'. If the column exist: 400 | * 1. Add the column name and type to the 'chartColumns' array 401 | * 2. Add it's index in the queryResultData to the 'indexes' array 402 | * @param columnsToAdd - The columns that we want to search in the 'queryResultData' 403 | * @param queryResultData - The original query result data 404 | * @param indexes - The array that the existing columns index will be added to 405 | * @param chartColumns - The array that the existing columns will be added to 406 | * 407 | * @returns An array of the columns that don't exist in the queryResultData. If all columns exist - the array will be empty. 408 | */ 409 | private addColumnsIfExistInResult(columnsToAdd: IColumn[], queryResultData: IQueryResultData, indexes: number[], chartColumns: IColumn[]): IColumn[] { 410 | const notFoundColumns: IColumn[] = []; 411 | 412 | for (let i = 0; i < columnsToAdd.length; ++i) { 413 | const column = columnsToAdd[i]; 414 | const indexOfColumn = Utilities.getColumnIndex(queryResultData, column); 415 | 416 | if (indexOfColumn < 0) { 417 | notFoundColumns.push(column); 418 | 419 | continue; 420 | } 421 | 422 | indexes.push(indexOfColumn); 423 | const originalColumn = queryResultData.columns[indexOfColumn]; 424 | 425 | // Add each column name and type to the chartColumns 426 | chartColumns.push(originalColumn); 427 | } 428 | 429 | return notFoundColumns; 430 | } 431 | 432 | private updateDefaultChartOptions(queryResultData: IQueryResultData, chartOptions: IChartOptions): IChartOptions { 433 | const updatedChartOptions: IChartOptions = _.merge({}, KustoChartHelper.defaultChartOptions, chartOptions); 434 | 435 | // Apply default columns selection if columns selection wasn't provided 436 | if (!updatedChartOptions.columnsSelection) { 437 | updatedChartOptions.columnsSelection = this.getDefaultSelection(queryResultData, updatedChartOptions.chartType); 438 | 439 | if (!updatedChartOptions.columnsSelection.xAxis || !updatedChartOptions.columnsSelection.yAxes || updatedChartOptions.columnsSelection.yAxes.length === 0) { 440 | throw new InvalidInputError( 441 | "Wasn't able to create default columns selection. Probably there are not enough columns to create the chart. Try using the 'getSupportedColumnsInResult' method", 442 | ErrorCode.InvalidQueryResultData); 443 | } 444 | } 445 | 446 | if (updatedChartOptions.title) { 447 | updatedChartOptions.title = Utilities.escapeStr(updatedChartOptions.title) as string; 448 | } 449 | 450 | return updatedChartOptions; 451 | } 452 | 453 | private verifyInput(queryResultData: IQueryResultData, chartOptions: IChartOptions): void { 454 | if (!queryResultData) { 455 | throw new InvalidInputError("The queryResultData can't be empty", ErrorCode.InvalidQueryResultData); 456 | } else if (!queryResultData.rows || !queryResultData.columns) { 457 | throw new InvalidInputError("The queryResultData must contain rows and columns", ErrorCode.InvalidQueryResultData); 458 | } 459 | 460 | this.verifyColumnsSelection(chartOptions, queryResultData); 461 | 462 | // Make sure the columns selection is supported 463 | const supportedColumnTypes = this.getSupportedColumnTypes(chartOptions.chartType); 464 | 465 | this.verifyColumnTypeIsSupported(supportedColumnTypes.xAxis, chartOptions.columnsSelection.xAxis, chartOptions, 'x-axis'); 466 | 467 | chartOptions.columnsSelection.yAxes.forEach((yAxis) => { 468 | this.verifyColumnTypeIsSupported(supportedColumnTypes.yAxis, yAxis, chartOptions, 'y-axes'); 469 | }); 470 | 471 | if (chartOptions.columnsSelection.splitBy) { 472 | chartOptions.columnsSelection.splitBy.forEach((splitBy) => { 473 | this.verifyColumnTypeIsSupported(supportedColumnTypes.splitBy, splitBy, chartOptions, 'split-by'); 474 | }); 475 | } 476 | } 477 | 478 | private verifyColumnsSelection(chartOptions: IChartOptions, queryResultData: IQueryResultData): void { 479 | let invalidColumnsSelectionErrorMessage: string; 480 | 481 | if(chartOptions.columnsSelection) { 482 | invalidColumnsSelectionErrorMessage = "Invalid columnsSelection."; 483 | } else { 484 | // If columns selection wasn't provided - make sure the default selection can apply 485 | chartOptions.columnsSelection = this.getDefaultSelection(queryResultData, chartOptions.chartType); 486 | invalidColumnsSelectionErrorMessage = "Wasn't able to apply default columnsSelection."; 487 | } 488 | 489 | if(!chartOptions.columnsSelection.xAxis || !chartOptions.columnsSelection.yAxes || chartOptions.columnsSelection.yAxes.length === 0) { 490 | throw new InvalidInputError(invalidColumnsSelectionErrorMessage + " The columnsSelection must contain at least 1 x-axis and y-axis column", ErrorCode.InvalidColumnsSelection); 491 | } 492 | } 493 | 494 | private verifyColumnTypeIsSupported(supportedTypes: DraftColumnType[], column: IColumn, chartOptions: IChartOptions, axisStr: string): void { 495 | if (supportedTypes.indexOf(column.type) < 0) { 496 | const supportedStr = supportedTypes.join(', '); 497 | 498 | throw new InvalidInputError( 499 | `Invalid columnsSelection. The type '${column.type}' isn't supported for ${axisStr} of ${chartOptions.chartType}. The supported column types are: ${supportedStr}`, 500 | ErrorCode.UnsupportedTypeInColumnsSelection); 501 | } 502 | } 503 | 504 | private finishDrawing(resolve: ResolveFn, chartOptions: IChartOptions): void { 505 | if (chartOptions.onFinishDrawing) { 506 | chartOptions.onFinishDrawing(this.chartInfo); 507 | } 508 | 509 | resolve(this.chartInfo); 510 | } 511 | 512 | private onError(resolve: ResolveFn, chartOptions: IChartOptions, error: ChartError): void { 513 | this.chartInfo = new ChartInfo(); 514 | this.chartInfo.status = DrawChartStatus.Failed; 515 | this.chartInfo.error = error; 516 | this.finishDrawing(resolve, chartOptions); 517 | } 518 | 519 | //#endregion Private methods 520 | } -------------------------------------------------------------------------------- /src/common/utilities.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as _ from 'lodash'; 4 | import { IQueryResultData, IColumn, DraftColumnType, ChartType, IRowValue } from './chartModels'; 5 | 6 | export class Utilities { 7 | // Returns the index of the column with the same name and type in the columns array 8 | public static getColumnIndex(queryResultData: IQueryResultData, columnToFind: IColumn): number { 9 | const columns: IColumn[] = queryResultData && queryResultData.columns; 10 | 11 | if (!columns) { 12 | return -1; 13 | } 14 | 15 | for (let i = 0; i < columns.length; i++) { 16 | const currentColumn: IColumn = columns[i]; 17 | 18 | if (Utilities.areColumnsEqual(currentColumn, columnToFind)) { 19 | return i; 20 | } 21 | } 22 | 23 | return -1; 24 | } 25 | 26 | /** 27 | * Returns the stored time value in milliseconds since midnight, January 1, 1970 UTC 28 | * @param dateStr - The string value that represents the date 29 | * @returns The date value in milliseconds since midnight, January 1, 1970 UTC 30 | */ 31 | public static getDateValue(dateStr: string): number { 32 | if (!Utilities.isValidDate(dateStr)) { 33 | return null; 34 | } 35 | 36 | return new Date(dateStr).valueOf(); 37 | } 38 | 39 | public static isValidDate(str: string): boolean { 40 | const date = new Date(str); 41 | 42 | return date && date.toString() !== 'Invalid Date'; 43 | } 44 | 45 | public static isNumeric(columnType: DraftColumnType): boolean { 46 | return columnType === DraftColumnType.Int || 47 | columnType === DraftColumnType.Long || 48 | columnType === DraftColumnType.Real || 49 | columnType === DraftColumnType.Decimal; 50 | } 51 | 52 | public static isDate(columnType: DraftColumnType): boolean { 53 | return columnType === DraftColumnType.DateTime || 54 | columnType === DraftColumnType.TimeSpan; 55 | } 56 | 57 | public static areColumnsEqual(first: IColumn, second: IColumn): boolean { 58 | let columnsEqual: boolean = first.name == second.name; 59 | 60 | // Check type equality 61 | if(columnsEqual) { 62 | columnsEqual = first.type == second.type || (Utilities.isNumeric(first.type) && Utilities.isNumeric(second.type)); 63 | } 64 | 65 | return columnsEqual; 66 | } 67 | 68 | public static isPieOrDonut(chartType: ChartType): boolean { 69 | return chartType === ChartType.Pie || chartType === ChartType.Donut; 70 | } 71 | 72 | public static escapeStr(value: IRowValue): IRowValue { 73 | // Don't escape non-string or timestamp values 74 | if (typeof (value) !== 'string' || Utilities.isValidDate(value)) { 75 | return value; 76 | } 77 | 78 | return _.escape(value); 79 | } 80 | } -------------------------------------------------------------------------------- /src/transformers/chartAggregation.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | //#region Imports 4 | 5 | import { AggregationType } from '../common/chartModels'; 6 | 7 | //#endregion Imports 8 | 9 | export type IAggregationMethod = (valuesToAggregate: number[]) => number; 10 | 11 | export class ChartAggregation { 12 | //#region Private members 13 | 14 | private static aggregationTypeToMethod: { [key in AggregationType]: IAggregationMethod } = { 15 | [AggregationType.Sum]: ChartAggregation.sum, 16 | [AggregationType.Average]: ChartAggregation.average, 17 | [AggregationType.Min]: ChartAggregation.minimum, 18 | [AggregationType.Max]: ChartAggregation.maximum 19 | }; 20 | 21 | //#region Private members 22 | 23 | //#region Public methods 24 | 25 | public static getAggregationMethod(aggregationType: AggregationType): IAggregationMethod { 26 | const aggregationTypeToMethod = ChartAggregation.aggregationTypeToMethod; 27 | 28 | return aggregationTypeToMethod[aggregationType] || aggregationTypeToMethod[AggregationType.Sum]; 29 | } 30 | 31 | //#endregion Public methods 32 | 33 | //#region Aggregation methods 34 | 35 | private static sum(values: number[]): number { 36 | let sum = 0; 37 | 38 | values.forEach((value: number) => { 39 | sum += value; 40 | }); 41 | 42 | return sum; 43 | } 44 | 45 | private static average(values: number[]): number { 46 | const sum: number = ChartAggregation.sum(values); 47 | 48 | return sum / values.length; 49 | } 50 | 51 | private static minimum(values: number[]): number { 52 | return Math.min.apply(Math, values); 53 | } 54 | 55 | private static maximum(values: number[]): number { 56 | return Math.max.apply(Math, values); 57 | } 58 | 59 | //#endregion Aggregation methods 60 | } -------------------------------------------------------------------------------- /src/transformers/limitVisResults.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | //#region Imports 4 | 5 | import * as _ from 'lodash'; 6 | import { DraftColumnType, IQueryResultData, AggregationType, IRow, IRowValue, IAxesInfo } from '../common/chartModels'; 7 | import { Utilities } from '../common/utilities'; 8 | import { ChartAggregation, IAggregationMethod } from './chartAggregation'; 9 | 10 | //#endregion Imports 11 | 12 | export class LimitedResults { 13 | public constructor( 14 | public rows: any[] = [], 15 | public isAggregationApplied: boolean = false, 16 | public isPartialData: boolean = false) { } 17 | } 18 | 19 | export interface ILimitAndAggregateParams { 20 | queryResultData: IQueryResultData; 21 | axesIndexes: IAxesInfo; 22 | xColumnType: DraftColumnType; 23 | aggregationType: AggregationType; 24 | maxUniqueXValues: number; 25 | otherStr: string; 26 | } 27 | 28 | interface IInternalParams extends ILimitAndAggregateParams { 29 | aggregationMethod: IAggregationMethod; 30 | } 31 | 32 | interface IRowsTotalCount { 33 | limitedColumnValue: any; 34 | yValues: any[]; 35 | totalYValue?: number; 36 | } 37 | 38 | interface IAggregationResult { 39 | rows: IRow[]; 40 | isAggregated: boolean; 41 | } 42 | 43 | interface IAggregatedRowInfo { 44 | order: number; // We want to save the rows order after the aggregation 45 | transformedRow: IRow; // The x-axis value and the split-by values after aggregation and escaping (to avoid XSS) 46 | yValues: any[]; 47 | } 48 | 49 | interface IRowsTotalCountHash { [key: string]: IRowsTotalCount } 50 | 51 | export class _LimitVisResults { 52 | //#region Public methods 53 | 54 | /** 55 | * 1. Remove rows when the number of unique X-axis values exceeds 'maxUniqueXValues'. 56 | * The method will take the biggest 'maxUniqueXValues' X-axis values, and all other X-axis values will be summed and added as 'Others' 57 | * 2. Escape all row values to avoid XSS 58 | * 3. Perform aggregation on rows with the same X, Y, and SplitBy values. 59 | */ 60 | public limitAndAggregateRows(params: ILimitAndAggregateParams): LimitedResults { 61 | const limitedResults = new LimitedResults(); 62 | const aggregationMethod = ChartAggregation.getAggregationMethod(params.aggregationType); 63 | const internalParams: IInternalParams = { ...{ aggregationMethod: aggregationMethod }, ...params } 64 | 65 | this.limitAllRows(internalParams, limitedResults); 66 | this.aggregateAndEscapeRows(internalParams, limitedResults); 67 | 68 | // Sort the x-Axis only if it's a date time column 69 | if (params.xColumnType === DraftColumnType.DateTime) { 70 | // Remove empty date values since they can't be placed on the x-axis timeline, then sort by the date 71 | limitedResults.rows = _.sortBy(limitedResults.rows.filter(row => row[0] != null), (row) => { 72 | return new Date(row[0]).valueOf(); 73 | }); 74 | } 75 | 76 | return limitedResults; 77 | } 78 | 79 | //#endregion Public methods 80 | 81 | //#region Private methods 82 | 83 | private getHashCode(str: string): number { 84 | const strLength: number = str.length; 85 | 86 | if (strLength === 0) { 87 | return 0; 88 | } 89 | 90 | let hash: number = 0; 91 | 92 | for (let i = 0; i < strLength; i++) { 93 | const charCode: number = str.charCodeAt(i); 94 | 95 | hash = ((hash << 5) - hash) + charCode; 96 | hash |= 0; // Convert to 32bit integer 97 | } 98 | 99 | return hash; 100 | } 101 | 102 | private getKey(row: IRowValue[]): string { 103 | // Creating a key made of the x-field and the split-by-fields 104 | let key = ''; 105 | const separator = '_'; 106 | 107 | for (let columnIndex = 0; columnIndex < row.length; ++columnIndex) { 108 | const val = row[columnIndex] || ''; 109 | 110 | key += val + separator; 111 | } 112 | 113 | const hashCode = this.getHashCode(key); 114 | 115 | return hashCode.toString(); 116 | } 117 | 118 | private applyAggregation(aggregatedRowsInfo: IAggregatedRowInfo[], aggregationMethod: IAggregationMethod): IAggregationResult { 119 | const aggregatedRows = []; 120 | let aggregationApplied = false; 121 | 122 | aggregatedRowsInfo.forEach((aggregatedRowData: IAggregatedRowInfo) => { 123 | const aggregatedRow = aggregatedRowData.transformedRow; 124 | 125 | aggregatedRowData.yValues.forEach((yValues) => { 126 | // If any aggregation is applied, raise flag 127 | if (yValues.length > 1) { 128 | aggregationApplied = true; 129 | } 130 | 131 | // In case there are no valid values, return undefined 132 | const aggregatedYValue = yValues.length > 0 ? aggregationMethod(yValues) : undefined; 133 | 134 | aggregatedRow.push(aggregatedYValue); 135 | }); 136 | 137 | aggregatedRows.push(aggregatedRow); 138 | }); 139 | 140 | return { 141 | rows: aggregatedRows, 142 | isAggregated: aggregationApplied 143 | } 144 | } 145 | 146 | private limitOriginalRows(params: IInternalParams, limitedResults: LimitedResults, indexOfLimitedColumn: number, rowsToDisplayHash: any, createOtherColumn: boolean = false): void { 147 | const otherRows = []; 148 | 149 | // All the rows that were limited, will be count as 'Other' 150 | if (createOtherColumn) { 151 | const otherRow: any = []; 152 | 153 | otherRow[indexOfLimitedColumn] = params.otherStr; 154 | params.axesIndexes.yAxes.forEach((yIndex) => { 155 | otherRow[yIndex] = []; 156 | }); 157 | 158 | otherRows.push(otherRow); 159 | } 160 | 161 | const limitedRows = []; 162 | 163 | // Add only the rows with the biggest count, all others will we counted as the 'Other' row 164 | limitedResults.rows.forEach((row, i) => { 165 | if (rowsToDisplayHash.hasOwnProperty(row[indexOfLimitedColumn])) { 166 | limitedRows.push(row); 167 | } else { 168 | const rowClone = _.clone(row); 169 | 170 | if (createOtherColumn) { 171 | const otherRow = otherRows[0]; 172 | 173 | params.axesIndexes.yAxes.forEach((yIndex) => { 174 | otherRow[yIndex].push(row[yIndex]); 175 | }); 176 | } else { 177 | rowClone[indexOfLimitedColumn] = params.otherStr; 178 | limitedRows.push(rowClone); 179 | } 180 | } 181 | }); 182 | 183 | if (createOtherColumn) { 184 | const otherRow = otherRows[0]; 185 | 186 | // Aggregate all Y Values 187 | params.axesIndexes.yAxes.forEach((yIndex) => { 188 | otherRow[yIndex] = params.aggregationMethod(otherRow[yIndex]); 189 | }); 190 | } 191 | 192 | limitedResults.rows = limitedRows.concat(otherRows); 193 | } 194 | 195 | private limitRows(params: IInternalParams, limitedResults: LimitedResults, indexOfLimitedColumn: number, createOtherColumn: boolean = false): void { 196 | const rows = limitedResults.rows; 197 | 198 | if (rows.length <= params.maxUniqueXValues) { 199 | return; 200 | } 201 | 202 | const totalCountRowsHash: IRowsTotalCountHash = {}; 203 | let totalCountRowsHashLength = 0; 204 | 205 | // Aggregate the total count for each unique value 206 | rows.forEach((row) => { 207 | const limitedColumnValue = row[indexOfLimitedColumn]; 208 | const yValues = _.map(params.axesIndexes.yAxes, (yIndex: number) => { 209 | return row[yIndex]; 210 | }); 211 | 212 | if (!totalCountRowsHash.hasOwnProperty(limitedColumnValue)) { 213 | totalCountRowsHash[limitedColumnValue] = { limitedColumnValue: limitedColumnValue, yValues: yValues }; 214 | totalCountRowsHashLength++; 215 | } else { 216 | const prevYValues = totalCountRowsHash[limitedColumnValue].yValues; 217 | 218 | totalCountRowsHash[limitedColumnValue].yValues = prevYValues.concat(yValues); 219 | } 220 | }); 221 | 222 | if (totalCountRowsHashLength <= params.maxUniqueXValues) { 223 | return; 224 | } 225 | 226 | // Apply the aggregation for all the yValues of the same key 227 | for (const key in totalCountRowsHash) { 228 | if (totalCountRowsHash.hasOwnProperty(key)) { 229 | const totalCountRow = totalCountRowsHash[key]; 230 | 231 | totalCountRow.totalYValue = params.aggregationMethod(totalCountRow.yValues); 232 | } 233 | } 234 | 235 | // Sort the unique values by the total count 236 | const sortedTotalCountRows = _.sortBy(totalCountRowsHash, (row: any) => { 237 | return (-1) * row.totalYValue; 238 | }); 239 | 240 | // Leave only the biggest maxUniqueXValues unique values 241 | const rowsToDisplayArr: IRowsTotalCount[] = sortedTotalCountRows.splice(0, params.maxUniqueXValues); 242 | const rowsToDisplayHash = {}; 243 | 244 | // Convert the limited total count array to a hash 245 | _.forEach(rowsToDisplayArr, (limitedTotalCountRow) => { 246 | rowsToDisplayHash[limitedTotalCountRow.limitedColumnValue] = true; 247 | }); 248 | 249 | this.limitOriginalRows(params, limitedResults, indexOfLimitedColumn, rowsToDisplayHash, createOtherColumn); 250 | } 251 | 252 | private limitXValues(params: IInternalParams, limitedResults: LimitedResults): void { 253 | const originalRows = params.queryResultData.rows; 254 | 255 | limitedResults.rows = originalRows; 256 | limitedResults.isPartialData = false; 257 | 258 | // Don't limit date/numeric X values 259 | if (Utilities.isDate(params.xColumnType) || Utilities.isNumeric(params.xColumnType)) { 260 | return; 261 | } 262 | 263 | this.limitRows(params, limitedResults, /*indexOfLimitedColumn*/ params.axesIndexes.xAxis, /*createOtherColumn*/ true); 264 | 265 | // Mark that the X values were limited 266 | if (limitedResults.rows.length < originalRows.length) { 267 | limitedResults.isPartialData = true; 268 | } 269 | } 270 | 271 | private limitAllRows(params: IInternalParams, limitedResults: LimitedResults): void { 272 | this.limitXValues(params, limitedResults); 273 | 274 | const splitByIndexes = params.axesIndexes.splitBy || []; 275 | 276 | splitByIndexes.forEach((indexOfSplitByColumn: number) => { 277 | this.limitRows(params, limitedResults, indexOfSplitByColumn); 278 | }); 279 | } 280 | 281 | /** 282 | * Performs _.escape for all the rows values. In addition: 283 | * There are cases where we have multiple rows with the same values for the x-field and the split-by fields if exists. 284 | * In these cases we need to use one aggregated row instead where the y-value is aggregated. 285 | * For example, assume we get the following results to the query 286 | * requests | limit 20 | summarize count() by bin(timestamp, 1h), client_Browser 287 | * timestamp | client_Browser | count_ 288 | * 2016-08-02T10:00:00Z | Chrome 51.0 | 15 289 | * 2016-08-02T10:00:00Z | Internet Explorer 9.0 | 4 290 | * If the user chose to show the results where x-field == timestamp, y-field == count_, no split by, we need to aggregate the 291 | * values of the same timestamp value and return one row with ["2016-08-02T10:00:00Z", 19]. 292 | * The algorithm we use here, calculates for each row in the results a hash code for the x-field-value and the split-by-fields-values. 293 | * Then we aggregate the y-Values in rows that correspond to the same hash code. 294 | */ 295 | private aggregateAndEscapeRows(params: IInternalParams, limitedResults: LimitedResults): void { 296 | const aggregatedRowInfoMap: { [rowKey: string]: IAggregatedRowInfo } = {}; 297 | 298 | limitedResults.rows.forEach((row: IRow, index: number) => { 299 | const xValue = row[params.axesIndexes.xAxis]; 300 | const transformedRow = [Utilities.escapeStr(xValue)]; 301 | 302 | // Add all split-by values 303 | const splitByIndexes = params.axesIndexes.splitBy || []; 304 | 305 | splitByIndexes.forEach((splitByIndex) => { 306 | transformedRow.push(Utilities.escapeStr(row[splitByIndex])); 307 | }); 308 | 309 | const key = this.getKey(transformedRow); 310 | 311 | if (!aggregatedRowInfoMap.hasOwnProperty(key)) { 312 | aggregatedRowInfoMap[key] = { 313 | order: index, // We want to save the rows order after the aggregation 314 | transformedRow: transformedRow, // Row containing the x-axis value and the split-by values 315 | yValues: [] 316 | }; 317 | 318 | params.axesIndexes.yAxes.forEach((yValue) => { 319 | aggregatedRowInfoMap[key].yValues.push([]); 320 | }); 321 | } 322 | 323 | // Add the Y-values, to be later aggregated 324 | params.axesIndexes.yAxes.forEach((yIndex: number, i: number) => { 325 | const yValue = row[yIndex]; 326 | 327 | // Ignore undefined/null values 328 | if (yValue != undefined) { 329 | const yValues = aggregatedRowInfoMap[key].yValues[i]; 330 | 331 | yValues.push(Utilities.escapeStr(yValue)); 332 | } 333 | }); 334 | }); 335 | 336 | 337 | let aggregatedRowInfo: IAggregatedRowInfo[]; 338 | 339 | if(params.xColumnType === DraftColumnType.DateTime) { 340 | aggregatedRowInfo = _.values(aggregatedRowInfoMap); 341 | } else { 342 | // Restore rows order 343 | aggregatedRowInfo = _.sortBy(aggregatedRowInfoMap, 'order'); 344 | } 345 | 346 | const aggregationResult: IAggregationResult = this.applyAggregation(aggregatedRowInfo, params.aggregationMethod); 347 | 348 | limitedResults.isAggregationApplied = aggregationResult.isAggregated; 349 | limitedResults.rows = aggregationResult.rows; 350 | } 351 | 352 | //#endregion Private methods 353 | } 354 | 355 | /** 356 | * export a Singleton class 357 | */ 358 | export const LimitVisResultsSingleton = new _LimitVisResults(); -------------------------------------------------------------------------------- /src/transformers/seriesVisualize.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | //#region Imports 4 | 5 | import * as _ from 'lodash'; 6 | import { IQueryResultData, DraftColumnType, IColumn } from '../common/chartModels'; 7 | import { Utilities } from '../common/utilities'; 8 | 9 | //#endregion Imports 10 | 11 | interface ISeriesColumnInfo { 12 | isSeries: boolean; 13 | dateTimeIndices?: number[]; 14 | numberIndices?: number[]; 15 | } 16 | 17 | interface ISeriesColumn extends IColumn { 18 | arrayItemsLength?: number; 19 | arrayItemsType?: DraftColumnType; 20 | validatedForSeries?: boolean; 21 | } 22 | 23 | /* 24 | * Singleton class 25 | * This class transforms results that represents time series to such form that will enable their visualization. 26 | * First, the class checks if the results represents time series and only if they are - transform it. 27 | * The transformation is being made by multiplying the rows. 28 | * 29 | * Example: 30 | * 31 | * Original results: 32 | * ["2016-11-10T06:00:00.0000000Z","2016-11-10T07:00:00.0000000Z"] [10, 20] Seg1 33 | * ["2016-11-10T06:00:00.0000000Z","2016-11-10T07:00:00.0000000Z"] [30, 40] Seg2 34 | * 35 | * Transform to: 36 | * "2016-11-10T06:00:00.0000000Z" 10 Seg1 37 | * "2016-11-10T07:00:00.0000000Z" 20 Seg1 38 | * "2016-11-10T06:00:00.0000000Z" 30 Seg2 39 | * "2016-11-10T07:00:00.0000000Z" 40 Seg2 40 | */ 41 | export class SeriesVisualize { 42 | //#region Private static members 43 | 44 | private static instance: SeriesVisualize; 45 | 46 | //#endregion Private static members 47 | 48 | //#region Private constructor 49 | 50 | private constructor() { } 51 | 52 | //#endregion Private constructor 53 | 54 | //#region Public static methods 55 | 56 | public static getInstance(): SeriesVisualize { 57 | if (!SeriesVisualize.instance) { 58 | SeriesVisualize.instance = new SeriesVisualize(); 59 | } 60 | 61 | return SeriesVisualize.instance; 62 | } 63 | 64 | //#endregion Public static methods 65 | 66 | //#region Public methods 67 | 68 | /** 69 | * Tries to resolve the results as series. 70 | * If the first row data doesn't match series result -> return immediately. 71 | * When succeeds to resolve as series, construct the new query data and return it. 72 | * @param queryResultData - The original query result data 73 | * @returns The series info with the updated query result data if the results are resolved as a series. 74 | * In this case the results are updated by expanding the original queryResult.rows, and adding the series column if needed. 75 | * Otherwise, returns null 76 | */ 77 | public tryResolveResultsAsSeries(queryResultData: IQueryResultData): IQueryResultData { 78 | if (!queryResultData || !queryResultData.rows || !queryResultData.columns) { 79 | return null; 80 | } 81 | 82 | const clonedData = _.cloneDeep(queryResultData); 83 | let newRows = clonedData.rows; 84 | const newColumns: ISeriesColumn[] = clonedData.columns; 85 | 86 | // Check if the first row matches a series pattern 87 | // The assumption is that most of the non-series results will be detected at this early stage 88 | // If it matches a series pattern - gets the indexes of the series columns 89 | const seriesColumnsInformation = this.isSeriesPattern(newColumns, newRows); 90 | 91 | if (!seriesColumnsInformation.isSeries) { 92 | return null; 93 | } 94 | 95 | const dateTimeIndices = seriesColumnsInformation.dateTimeIndices; 96 | const numbersIndices = seriesColumnsInformation.numberIndices; 97 | 98 | // Validates and updates the columns that suspected as series 99 | // Updates newColumns, dateTimeIndices and numbersIndices 100 | this.validateAndUpdateSeriesColumns(newColumns, newRows, dateTimeIndices, numbersIndices); 101 | 102 | // Mark all validated fields 103 | let validatedColumnsCount = 0; 104 | const arrayLength = newColumns[dateTimeIndices[0]].arrayItemsLength; 105 | const seriesColumnsIndices = dateTimeIndices.concat(numbersIndices); 106 | 107 | for (let i = 0; i < seriesColumnsIndices.length; i++) { 108 | const columnIndex = seriesColumnsIndices[i]; 109 | const column = newColumns[columnIndex]; 110 | 111 | if (column.arrayItemsLength === arrayLength) { 112 | column.type = column.arrayItemsType; 113 | column.validatedForSeries = true; 114 | validatedColumnsCount++; 115 | } 116 | } 117 | 118 | // If we don't have at least 2 array dimensions - we won't be able to render the chart 119 | if (validatedColumnsCount < 2) { 120 | return null; 121 | } 122 | 123 | // Check if a string column exists to be used for segmentation of the results 124 | const isStringColumnExist = newColumns.some((column: ISeriesColumn) => { 125 | return !column.validatedForSeries && column.type === DraftColumnType.String; 126 | }); 127 | 128 | // If a string column doesn't exist - create a synthetic one 129 | if (!isStringColumnExist && newRows.length > 1) { 130 | this.addSeriesColumn(newRows, newColumns); 131 | } 132 | 133 | // Makes the expansion of the results 134 | newRows = this.expandAllRowsForSeries(newRows, newColumns, arrayLength); 135 | 136 | return { 137 | rows: newRows, 138 | columns: newColumns 139 | } 140 | } 141 | 142 | //#endregion Public methods 143 | 144 | //#region Private methods 145 | 146 | /* Gets the type of the item. 147 | * if it's a number - return 'Real' 148 | * if it's a string - check if it represents datetime. 149 | * Otherwise, return undefined. 150 | */ 151 | private getItemFieldType(value: any): DraftColumnType { 152 | const type = typeof value; 153 | 154 | switch (type) { 155 | case 'number': { 156 | return DraftColumnType.Real; 157 | } 158 | case 'string': { 159 | if (Utilities.isValidDate(value)) { 160 | return DraftColumnType.DateTime; 161 | } else { 162 | return DraftColumnType.String; 163 | } 164 | } 165 | default: { 166 | return undefined; 167 | } 168 | } 169 | } 170 | 171 | /** 172 | * Runs over all the columns that suspected as a series of timestamps or numbers and checks if all values fulfill: 173 | * 1. All value are arrays. 174 | * 2. All arrays are with the same size. 175 | * 3. All items in all array are of the same type. 176 | * If all fulfilled - add type and length information to the column. 177 | * Filters from dateTimeIndices and numbersIndices any columns Indices that are not valid series. 178 | * 179 | * @param newColumns - query result columns 180 | * @param newRows - query result rows 181 | * @param dateTimeIndices - The indices of the columns that are suspected as dateTimes series. 182 | * @param numbersIndices - The indices of the columns that are suspected as numbers series. 183 | */ 184 | private validateAndUpdateSeriesColumns(newColumns: ISeriesColumn[], newRows: any[], dateTimeIndices: number[], numbersIndices: number[]): void { 185 | dateTimeIndices = dateTimeIndices.filter((index: number) => { 186 | return this.validateAndUpdateSeriesColumn(newColumns, newRows, index); 187 | }); 188 | 189 | numbersIndices = numbersIndices.filter((index: number) => { 190 | return this.validateAndUpdateSeriesColumn(newColumns, newRows, index); 191 | }); 192 | } 193 | 194 | /** 195 | * If the column is dynamic - Runs through all rows and checks if all values fulfill: 196 | * 1. All value are arrays. 197 | * 2. All arrays are with the same size. 198 | * 3. All items in all array are of the same type. 199 | * If all fulfilled - add type and length information to the column. 200 | * @param columns - query result columns 201 | * @param rows - query result rows 202 | * @param columnIndex - The index of the column we're working on 203 | * @returns True if the column is validated as series 204 | */ 205 | private validateAndUpdateSeriesColumn(columns: ISeriesColumn[], rows: any[], columnIndex: number): boolean { 206 | const column = columns[columnIndex]; 207 | 208 | // The column is not dynamic - leave it as is 209 | if (column.type !== DraftColumnType.Dynamic) { 210 | return false; 211 | } 212 | 213 | // The column is defined as dynamic by kusto and has type string 214 | // Try parse this column for all rows and check if all the rows are valid arrays of a specific type 215 | // If such type was found - add the type and the array sizes to the column info 216 | let columnType; 217 | let columnArrayLength; 218 | 219 | for (let j = 0; j < rows.length; j++) { 220 | let currentValues; 221 | 222 | try { 223 | currentValues = JSON.parse(rows[j][columnIndex]); 224 | } catch (ex) { 225 | // Value is not a valid json - return with no info about the column 226 | return false; 227 | } 228 | 229 | if (!Array.isArray(currentValues) || currentValues.length === 0) { 230 | // Value is not an array - return with no info about the column 231 | return false; 232 | } 233 | 234 | // Checks that all rows have the same size of array 235 | if (columnArrayLength !== undefined && currentValues.length !== columnArrayLength) { 236 | // Values are not with same length as the previous arrays - return with no info about the column 237 | return false; 238 | } else { 239 | columnArrayLength = currentValues.length; 240 | } 241 | 242 | for (let i = 0; i < currentValues.length; i++) { 243 | // Allow null values in this flow, the null values won't be displayed in the chart, so we can ignore them 244 | if (currentValues[i] == null) { 245 | continue; 246 | } 247 | 248 | const type: DraftColumnType = this.getItemFieldType(currentValues[i]); 249 | 250 | if (type === undefined) { 251 | // Type not recognized - return with no information about the column 252 | return false; 253 | } 254 | 255 | if (columnType !== undefined && type !== columnType) { 256 | // Type not match previous types - return with no information about the column 257 | return false; 258 | } else { 259 | columnType = type; 260 | } 261 | } 262 | } 263 | 264 | column.arrayItemsType = columnType; 265 | column.arrayItemsLength = columnArrayLength; 266 | 267 | return true; 268 | } 269 | 270 | /** 271 | * Expands a specific row from the original results to number of rows specified in of 'arraySizes' 272 | * @param row - query result row 273 | * @param columns - query result columns 274 | * @param arraySizes - The number of new rows to create. This is the length of the timestamp array found. 275 | * @returns The new created rows. 276 | * Example: 277 | * 278 | * Original row: 279 | * ["2016-11-10T06:00:00.0000000Z","2016-11-10T07:00:00.0000000Z"] [10, 20] Seg1 280 | * 281 | * Transform to: 282 | * "2016-11-10T06:00:00.0000000Z" 10 Seg1 283 | * "2016-11-10T07:00:00.0000000Z" 20 Seg1 284 | */ 285 | private expandRowForSeries(row: any, columns: ISeriesColumn[], arraySizes: number): any[] { 286 | // Create an array of the new rows - each rows is initialized as an empty array 287 | const newRows = _.times(arraySizes, _.constant(0)).map(() => { return []; }); 288 | 289 | for (let i = 0; i < columns.length; i++) { 290 | const column = columns[i]; 291 | const value = row[i]; 292 | 293 | // Expands the values - each in a new row 294 | if (column.validatedForSeries) { 295 | try { 296 | // Extract the values and continue to next field 297 | const values = JSON.parse(value); 298 | 299 | for (let j = 0; j < arraySizes; j++) { 300 | newRows[j].push(values[j]); 301 | } 302 | 303 | continue; 304 | } catch (ex) { 305 | // Ignore 306 | } 307 | } 308 | 309 | // Copy the value from the original row 310 | for (let j = 0; j < arraySizes; j++) { 311 | newRows[j].push(value); 312 | } 313 | } 314 | 315 | return newRows; 316 | } 317 | 318 | /** 319 | * Expands all rows from the original result, each row to number of rows specified in of 'arraySizes' 320 | * 321 | * @param rows - query result rows 322 | * @param columns - query result columns 323 | * @param arraySizes - The number of new rows to create. This is the length of the timestamp array found. 324 | * @returns the new created rows. 325 | */ 326 | private expandAllRowsForSeries(rows: any[], columns: ISeriesColumn[], arraySizes: number): any[] { 327 | let newRows = []; 328 | 329 | rows.forEach((row) => { 330 | newRows = newRows.concat(this.expandRowForSeries(row, columns, arraySizes)); 331 | }); 332 | 333 | return newRows; 334 | } 335 | 336 | /** 337 | * Returns the first index of an array which its value !== null. 338 | * @param valuesArray - array of values. can be strings,numbers .. 339 | * @returns number, the first index i that holds the equation "valuesArray[i] !== null" , returns 0 if the array is full with 'null' values. 340 | */ 341 | private getFirstNotNullIndex(valuesArray: any[]): number { 342 | for (let i = 0; i < valuesArray.length; i++) { 343 | if (valuesArray[i] !== null) { 344 | return i; 345 | } 346 | } 347 | 348 | return 0; 349 | } 350 | 351 | /** 352 | * Checks whether the results are in the form of series - based on the first row only. 353 | * Later, in tryResolveResultsAsSeries we'll verify that all rows are valid for the series. 354 | * This is the immediate check to avoid non-series results from parsing all inner values. 355 | * @param columns - query result columns 356 | * @param rows - query result rows 357 | * @returns True if the first row results are in the form of series. 358 | */ 359 | private isSeriesPattern(columns: ISeriesColumn[], rows: any[]): ISeriesColumnInfo { 360 | if (!rows || rows.length === 0 || !columns || columns.length === 0) { 361 | return { 362 | isSeries: false 363 | } 364 | } 365 | 366 | const firstRow = rows[0]; 367 | const numberArraysIndices: number[] = []; 368 | const dateTimeArraysIndices: number[] = []; 369 | 370 | for (let i = 0; i < columns.length; i++) { 371 | const column: ISeriesColumn = columns[i]; 372 | 373 | if (column.type === DraftColumnType.Dynamic) { 374 | let valuesArray; 375 | 376 | try { 377 | valuesArray = JSON.parse(firstRow[i]); 378 | } catch (ex) { 379 | // Value is not a valid json - return with no info about the field. 380 | continue; 381 | } 382 | 383 | if (Array.isArray(valuesArray) && valuesArray.length > 0) { 384 | const firstNotNullIndexOfArray = this.getFirstNotNullIndex(valuesArray); 385 | const type = this.getItemFieldType(valuesArray[firstNotNullIndexOfArray]); 386 | 387 | if (type === DraftColumnType.DateTime) { 388 | dateTimeArraysIndices.push(i); 389 | } else if (type === DraftColumnType.Real) { 390 | numberArraysIndices.push(i); 391 | } 392 | } 393 | } 394 | } 395 | 396 | return { 397 | isSeries: dateTimeArraysIndices.length > 0 && numberArraysIndices.length > 0, 398 | dateTimeIndices: dateTimeArraysIndices, 399 | numberIndices: numberArraysIndices 400 | } 401 | } 402 | 403 | /** 404 | * Add a new column to the results - this column will separate the different series. 405 | * Each series will have different value for this column. 406 | * This column should be added only if there is no other column that can be used for split. 407 | * @param rows - query result rows 408 | * @param columns - query result columns 409 | */ 410 | private addSeriesColumn(rows: any[], columns: ISeriesColumn[]): void { 411 | const seriesColumn: IColumn = { 412 | name: 'time_series', 413 | type: DraftColumnType.String 414 | } 415 | 416 | columns.push(seriesColumn); 417 | 418 | rows.forEach((row, index) => { 419 | row.push('timeSeries_' + (index + 1)); 420 | }); 421 | } 422 | 423 | //#endregion Private methods 424 | } -------------------------------------------------------------------------------- /src/visualizers/IVisualizer.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | //#region Imports 4 | 5 | import { IVisualizerOptions } from './IVisualizerOptions'; 6 | import { ChartTheme } from '../common/chartModels'; 7 | import { Changes } from '../common/chartChange'; 8 | 9 | //#endregion Imports 10 | 11 | export interface IVisualizer { 12 | /** 13 | * Draw the chart on an existing DOM element 14 | * @param options - The information required to the visualizer to draw the chart 15 | * @returns Promise that is resolved when the chart is finished drawing 16 | */ 17 | drawNewChart(options: IVisualizerOptions): Promise; 18 | 19 | /** 20 | * Update an existing chart 21 | * @param options - The information required to the visualizer to draw the chart 22 | * @param changes - The changes to apply 23 | * @returns Promise that is resolved when the chart is finished drawing 24 | */ 25 | updateExistingChart(options: IVisualizerOptions, changes: Changes): Promise; 26 | 27 | /** 28 | * Change the theme of an existing chart 29 | * @param newTheme - The theme to apply 30 | * @returns Promise that is resolved when the theme is applied 31 | */ 32 | changeTheme(newTheme: ChartTheme): Promise; 33 | 34 | /** 35 | * Download the chart as JPG image 36 | * @param onError - [Optional] A callback that will be called if the module failed to export the chart image 37 | */ 38 | downloadChartJPGImage(onError?: (error: Error) => void): void; 39 | } -------------------------------------------------------------------------------- /src/visualizers/IVisualizerOptions.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { IQueryResultData, IChartOptions, IChartInfo } from "../common/chartModels"; 4 | 5 | export interface IVisualizerOptions { 6 | elementId: string; 7 | queryResultData: IQueryResultData; 8 | chartOptions: IChartOptions; 9 | chartInfo: IChartInfo; 10 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/chartTypeOptions.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // See: https://api.highcharts.com/highcharts/plotOptions 4 | export const PERCENTAGE = 'percent'; 5 | export const STACKED = 'normal'; 6 | export const UNSTACKED = undefined; -------------------------------------------------------------------------------- /src/visualizers/highcharts/charts/area.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Chart } from './chart'; 4 | import { OptionsStackingValue } from 'highcharts'; 5 | 6 | export abstract class Area extends Chart { 7 | //#region Methods override 8 | 9 | protected getChartType(): string { 10 | return 'area'; 11 | }; 12 | 13 | protected plotOptions(): Highcharts.PlotOptions { 14 | return { 15 | area: { 16 | stacking: this.getStackingOption(), 17 | connectNulls: true 18 | } 19 | } 20 | } 21 | 22 | //#endregion Methods override 23 | 24 | protected abstract getStackingOption(): OptionsStackingValue; 25 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/charts/bar.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Chart } from './chart'; 4 | import { OptionsStackingValue } from 'highcharts'; 5 | 6 | export abstract class Bar extends Chart { 7 | //#region Methods override 8 | 9 | protected getChartType(): string { 10 | return 'bar'; 11 | }; 12 | 13 | protected plotOptions(): Highcharts.PlotOptions { 14 | return { 15 | bar: { 16 | stacking: this.getStackingOption() 17 | } 18 | } 19 | } 20 | 21 | //#endregion Methods override 22 | 23 | protected abstract getStackingOption(): OptionsStackingValue; 24 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/charts/chart.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | //#region Imports 4 | 5 | import * as _ from 'lodash'; 6 | import * as Highcharts from 'highcharts'; 7 | import { HC_Utilities } from '../common/utilities'; 8 | import { IVisualizerOptions } from '../../IVisualizerOptions'; 9 | import { Utilities } from '../../../common/utilities'; 10 | import { IColumn, IRowValue, IChartOptions, IDataPoint } from '../../../common/chartModels'; 11 | import { InvalidInputError } from '../../../common/errors/errors'; 12 | import { ErrorCode } from '../../../common/errors/errorCode'; 13 | import { Formatter } from '../common/formatter'; 14 | 15 | //#endregion Imports 16 | 17 | export interface ICategoriesAndSeries { 18 | categories?: IRowValue[]; 19 | series: any[]; 20 | } 21 | 22 | export abstract class Chart { 23 | private defaultPlotOptions: Highcharts.PlotOptions; 24 | 25 | public constructor(chartOptions: IChartOptions) { 26 | this.defaultPlotOptions = this.getDefaultPlotOptions(chartOptions); 27 | } 28 | 29 | //#region Virtual methods 30 | 31 | public /*virtual*/ getStandardCategoriesAndSeries(options: IVisualizerOptions): ICategoriesAndSeries { 32 | const chartOptions = options.chartOptions; 33 | const xColumn: IColumn = chartOptions.columnsSelection.xAxis; 34 | const isDatetimeAxis: boolean = Utilities.isDate(xColumn.type); 35 | const xAxisColumnIndex: number = Utilities.getColumnIndex(options.queryResultData, xColumn); 36 | const yAxesIndexes = _.map(chartOptions.columnsSelection.yAxes, (yAxisColumn) => { 37 | return Utilities.getColumnIndex(options.queryResultData, yAxisColumn); 38 | }); 39 | 40 | const categoriesAndSeries: ICategoriesAndSeries = { 41 | series: [], 42 | categories: isDatetimeAxis ? undefined : [] 43 | }; 44 | 45 | const seriesMap = {}; 46 | 47 | options.queryResultData.rows.forEach((row) => { 48 | let xAxisValue: IRowValue = row[xAxisColumnIndex]; 49 | 50 | // If the x-axis is a date, convert its value to milliseconds as this is what expected by 'Highcharts' 51 | if(isDatetimeAxis) { 52 | xAxisValue = Utilities.getDateValue(xAxisValue); 53 | 54 | if(!xAxisValue) { 55 | throw new InvalidInputError(`The x-axis value '${row[xAxisColumnIndex]}' is an invalid date`, ErrorCode.InvalidDate); 56 | } 57 | } else { 58 | categoriesAndSeries.categories.push(xAxisValue); 59 | } 60 | 61 | _.forEach(yAxesIndexes, (yAxisIndex, i) => { 62 | const yAxisColumnName = chartOptions.columnsSelection.yAxes[i].name; 63 | const yAxisValue = HC_Utilities.getYValue(options.queryResultData.columns, row, yAxisIndex); 64 | 65 | if(!seriesMap[yAxisColumnName]) { 66 | seriesMap[yAxisColumnName] = []; 67 | } 68 | 69 | const data = isDatetimeAxis? [xAxisValue, yAxisValue] : yAxisValue; 70 | 71 | seriesMap[yAxisColumnName].push(data); 72 | }); 73 | }); 74 | 75 | for (let yAxisColumnName in seriesMap) { 76 | categoriesAndSeries.series.push({ 77 | name: yAxisColumnName, 78 | data: seriesMap[yAxisColumnName] 79 | }); 80 | } 81 | 82 | return categoriesAndSeries; 83 | } 84 | 85 | public /*virtual*/ getSplitByCategoriesAndSeries(options: IVisualizerOptions): ICategoriesAndSeries { 86 | const xColumn: IColumn = options.chartOptions.columnsSelection.xAxis; 87 | const isDatetimeAxis: boolean = Utilities.isDate(xColumn.type); 88 | const xAxisColumnIndex: number = Utilities.getColumnIndex(options.queryResultData, xColumn); 89 | 90 | if(isDatetimeAxis) { 91 | return this.getSplitByCategoriesAndSeriesForDateXAxis(options, xAxisColumnIndex); 92 | } 93 | 94 | const columnsSelection = options.chartOptions.columnsSelection; 95 | const yAxisColumn = columnsSelection.yAxes[0]; 96 | const splitByColumn = columnsSelection.splitBy[0]; 97 | const yAxisColumnIndex = Utilities.getColumnIndex(options.queryResultData, yAxisColumn); 98 | const splitByColumnIndex = Utilities.getColumnIndex(options.queryResultData, splitByColumn); 99 | const uniqueXValues = {}; 100 | const uniqueSplitByValues = {}; 101 | const categoriesAndSeries: ICategoriesAndSeries = { 102 | series: [], 103 | categories: [] 104 | }; 105 | 106 | options.queryResultData.rows.forEach((row) => { 107 | const xValue = row[xAxisColumnIndex]; 108 | const yValue = HC_Utilities.getYValue(options.queryResultData.columns, row, yAxisColumnIndex); 109 | const splitByValue = row[splitByColumnIndex]; 110 | 111 | if(!uniqueXValues[xValue]) { 112 | uniqueXValues[xValue] = true; 113 | 114 | // Populate X-Axis 115 | categoriesAndSeries.categories.push(xValue); 116 | } 117 | 118 | if(!uniqueSplitByValues[splitByValue]) { 119 | uniqueSplitByValues[splitByValue] = {}; 120 | } 121 | 122 | uniqueSplitByValues[splitByValue][xValue] = yValue; 123 | }); 124 | 125 | // Populate Split by 126 | for (let splitByValue in uniqueSplitByValues) { 127 | const currentSeries = { 128 | name: splitByValue, 129 | data: [] 130 | }; 131 | 132 | const xValueToYValueMap = uniqueSplitByValues[splitByValue]; 133 | 134 | // Set a split-by value for each unique x value 135 | categoriesAndSeries.categories.forEach((xValue) => { 136 | let yValue = xValueToYValueMap[xValue]; 137 | 138 | if(yValue === undefined) { 139 | yValue = null; 140 | } 141 | 142 | currentSeries.data.push(yValue); 143 | }); 144 | 145 | categoriesAndSeries.series.push(currentSeries); 146 | } 147 | 148 | return categoriesAndSeries; 149 | } 150 | 151 | public /*virtual*/ sortSeriesByName(series: any[]): any[] { 152 | const sortedSeries = _.sortBy(series, 'name'); 153 | 154 | return sortedSeries; 155 | } 156 | 157 | public /*virtual*/ getChartTypeOptions(): Highcharts.Options { 158 | return { 159 | chart: { 160 | type: this.getChartType() 161 | }, 162 | plotOptions: { ...this.defaultPlotOptions, ...this.plotOptions() } 163 | }; 164 | } 165 | 166 | public /*virtual*/ getChartTooltipFormatter(chartOptions: IChartOptions): Highcharts.TooltipFormatterCallbackFunction { 167 | const self = this; 168 | 169 | return function () { 170 | const dataPoint: IDataPoint = self.getDataPoint(chartOptions, this.point); 171 | 172 | // X axis 173 | const xColumnTitle: string = chartOptions.xAxisTitleFormatter ? chartOptions.xAxisTitleFormatter(dataPoint.x.column) : undefined; 174 | let tooltip: string = Formatter.getSingleTooltip(chartOptions, dataPoint.x.column, dataPoint.x.value, xColumnTitle); 175 | 176 | // Y axis 177 | tooltip += Formatter.getSingleTooltip(chartOptions, dataPoint.y.column, dataPoint.y.value); 178 | 179 | // Split by 180 | if(dataPoint.splitBy) { 181 | tooltip += Formatter.getSingleTooltip(chartOptions, dataPoint.splitBy.column, dataPoint.splitBy.value); 182 | } 183 | 184 | return '' + tooltip + ''; 185 | } 186 | } 187 | 188 | public /*virtual*/ verifyInput(options: IVisualizerOptions): void { 189 | const columnSelection = options.chartOptions.columnsSelection; 190 | 191 | if(columnSelection.splitBy && columnSelection.splitBy.length > 1) { 192 | throw new InvalidInputError(`Multiple split-by columns selection isn't allowed for ${options.chartOptions.chartType}`, ErrorCode.InvalidColumnsSelection); 193 | } 194 | } 195 | 196 | protected /*virtual*/ getDataPoint(chartOptions: IChartOptions, point: Highcharts.Point): IDataPoint { 197 | // Y axis 198 | const yAxes = chartOptions.columnsSelection.yAxes; 199 | let yAxisColumn; 200 | 201 | if (yAxes.length === 1) { 202 | yAxisColumn = yAxes[0]; 203 | } else { // Multiple y-axes - find the current y column 204 | const yColumnIndex = _.findIndex(yAxes, (col) => { 205 | return col.name === point.series.name 206 | }); 207 | 208 | yAxisColumn = yAxes[yColumnIndex]; 209 | } 210 | 211 | const dataPointInfo: IDataPoint = { 212 | x: { 213 | column: chartOptions.columnsSelection.xAxis, 214 | value: point.category 215 | }, 216 | y: { 217 | column: yAxisColumn, 218 | value: point.y 219 | }, 220 | }; 221 | 222 | // Split by 223 | const splitBy = chartOptions.columnsSelection.splitBy; 224 | 225 | if(splitBy && splitBy.length > 0) { 226 | dataPointInfo.splitBy = { 227 | column: splitBy[0], 228 | value: point.series.name 229 | } 230 | } 231 | 232 | return dataPointInfo; 233 | } 234 | 235 | //#endregion Virtual methods 236 | 237 | //#region Abstract methods 238 | 239 | protected abstract getChartType(): string; 240 | 241 | protected abstract plotOptions(): Highcharts.PlotOptions; 242 | 243 | //#endregion Abstract methods 244 | 245 | //#region Private methods 246 | 247 | private getSplitByCategoriesAndSeriesForDateXAxis(options: IVisualizerOptions, xAxisColumnIndex: number): ICategoriesAndSeries { 248 | const columnsSelection = options.chartOptions.columnsSelection; 249 | const yAxisColumn = columnsSelection.yAxes[0]; 250 | const splitByColumn = columnsSelection.splitBy[0]; 251 | const yAxisColumnIndex = Utilities.getColumnIndex(options.queryResultData, yAxisColumn); 252 | const splitByColumnIndex = Utilities.getColumnIndex(options.queryResultData, splitByColumn); 253 | const splitByMap = {}; 254 | const series = []; 255 | 256 | options.queryResultData.rows.forEach((row) => { 257 | const splitByValue: string = row[splitByColumnIndex]; 258 | const yValue = HC_Utilities.getYValue(options.queryResultData.columns, row, yAxisColumnIndex); 259 | const dateOriginalValue: string = row[xAxisColumnIndex]; 260 | 261 | // For date a-axis, convert its value to milliseconds as this is what expected by Highcharts 262 | const dateNumericValue: number = Utilities.getDateValue(dateOriginalValue); 263 | 264 | if(!dateNumericValue) { 265 | throw new InvalidInputError(`The x-axis value '${dateOriginalValue}' is an invalid date`, ErrorCode.InvalidDate); 266 | } 267 | 268 | if(!splitByMap[splitByValue]) { 269 | splitByMap[splitByValue] = []; 270 | } 271 | 272 | splitByMap[splitByValue].push([dateNumericValue, yValue]); 273 | }); 274 | 275 | for (let splitByValue in splitByMap) { 276 | series.push({ 277 | name: splitByValue, 278 | data: splitByMap[splitByValue] 279 | }); 280 | } 281 | 282 | return { 283 | series: series 284 | } 285 | } 286 | 287 | private getDefaultPlotOptions(chartOptions: IChartOptions): Highcharts.PlotOptions { 288 | const defaultPlotOptions = { 289 | series: { 290 | animation: { 291 | duration: chartOptions.animationDurationMS 292 | }, 293 | marker: { 294 | radius: 2 // The radius of the chart's point marker 295 | } 296 | } 297 | }; 298 | 299 | if (chartOptions.onDataPointClicked) { 300 | const self = this; 301 | const clickableSeries: Highcharts.PlotSeriesOptions = { 302 | cursor: 'pointer', 303 | point: { 304 | events: { 305 | click: function () { 306 | const dataPoint: IDataPoint = self.getDataPoint(chartOptions, /*point*/ this); 307 | 308 | chartOptions.onDataPointClicked(dataPoint); 309 | } 310 | } 311 | } 312 | } 313 | 314 | _.merge(defaultPlotOptions.series, clickableSeries); 315 | } 316 | 317 | return defaultPlotOptions; 318 | } 319 | 320 | //#endregion Private methods 321 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/charts/chartFactory.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | //#region Imports 4 | 5 | import { ChartType, IChartOptions } from '../../../common/chartModels'; 6 | import { Chart } from './chart'; 7 | import { Line } from './line'; 8 | import { Scatter } from './scatter'; 9 | import { UnstackedArea } from './unstackedArea'; 10 | import { StackedArea } from './stackedArea'; 11 | import { PercentageArea } from './percentageArea'; 12 | import { UnstackedColumn } from './unstackedColumn'; 13 | import { StackedColumn } from './stackedColumn'; 14 | import { PercentageColumn } from './percentageColumn'; 15 | import { UnstackedBar } from './unstackedBar'; 16 | import { StackedBar } from './stackedBar'; 17 | import { PercentageBar } from './percentageBar'; 18 | import { Pie } from './pie'; 19 | import { Donut } from './donut'; 20 | 21 | //#endregion Imports 22 | 23 | export class ChartFactory { 24 | public static create(chartType: ChartType, chartOptions: IChartOptions): Chart { 25 | switch (chartType) { 26 | case ChartType.Line: { 27 | return new Line(chartOptions); 28 | } 29 | case ChartType.Scatter: { 30 | return new Scatter(chartOptions); 31 | } 32 | case ChartType.UnstackedArea: { 33 | return new UnstackedArea(chartOptions); 34 | } 35 | case ChartType.StackedArea: { 36 | return new StackedArea(chartOptions); 37 | } 38 | case ChartType.PercentageArea: { 39 | return new PercentageArea(chartOptions); 40 | } 41 | case ChartType.UnstackedColumn: { 42 | return new UnstackedColumn(chartOptions); 43 | } 44 | case ChartType.StackedColumn: { 45 | return new StackedColumn(chartOptions); 46 | } 47 | case ChartType.PercentageColumn: { 48 | return new PercentageColumn(chartOptions); 49 | } 50 | case ChartType.UnstackedBar: { 51 | return new UnstackedBar(chartOptions); 52 | } 53 | case ChartType.StackedBar: { 54 | return new StackedBar(chartOptions); 55 | } 56 | case ChartType.PercentageBar: { 57 | return new PercentageBar(chartOptions); 58 | } 59 | case ChartType.Pie: { 60 | return new Pie(chartOptions); 61 | } 62 | case ChartType.Donut: { 63 | return new Donut(chartOptions); 64 | } 65 | default: { 66 | return new UnstackedColumn(chartOptions); 67 | } 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/charts/column.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Chart } from './chart'; 4 | import { OptionsStackingValue } from 'highcharts'; 5 | 6 | export abstract class Column extends Chart { 7 | //#region Methods override 8 | 9 | protected getChartType(): string { 10 | return 'column'; 11 | }; 12 | 13 | protected plotOptions(): Highcharts.PlotOptions { 14 | return { 15 | column: { 16 | stacking: this.getStackingOption() 17 | } 18 | } 19 | } 20 | 21 | //#endregion Methods override 22 | 23 | protected abstract getStackingOption(): OptionsStackingValue; 24 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/charts/donut.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Pie } from './pie'; 4 | 5 | export class Donut extends Pie { 6 | //#region Methods override 7 | 8 | protected getInnerSize(): any { 9 | return '40%'; 10 | } 11 | 12 | //#endregion Methods override 13 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/charts/line.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Chart } from './chart'; 4 | import { UNSTACKED } from '../chartTypeOptions'; 5 | 6 | export class Line extends Chart { 7 | //#region Methods override 8 | 9 | protected getChartType(): string { 10 | return 'line'; 11 | }; 12 | 13 | protected plotOptions(): Highcharts.PlotOptions { 14 | return { 15 | line: { 16 | stacking: UNSTACKED 17 | } 18 | } 19 | } 20 | 21 | //#endregion Methods override 22 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/charts/percentageArea.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Area } from './area'; 4 | import { PERCENTAGE } from '../chartTypeOptions'; 5 | import { OptionsStackingValue } from 'highcharts'; 6 | 7 | export class PercentageArea extends Area { 8 | //#region Methods override 9 | 10 | protected getStackingOption(): OptionsStackingValue { 11 | return PERCENTAGE; 12 | } 13 | 14 | //#endregion Methods override 15 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/charts/percentageBar.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Bar } from './bar'; 4 | import { PERCENTAGE } from '../chartTypeOptions'; 5 | import { OptionsStackingValue } from 'highcharts'; 6 | 7 | export class PercentageBar extends Bar { 8 | //#region Methods override 9 | 10 | protected getStackingOption(): OptionsStackingValue { 11 | return PERCENTAGE; 12 | } 13 | 14 | //#endregion Methods override 15 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/charts/percentageColumn.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Column } from './column'; 4 | import { PERCENTAGE } from '../chartTypeOptions'; 5 | import { OptionsStackingValue } from 'highcharts'; 6 | 7 | export class PercentageColumn extends Column { 8 | //#region Methods override 9 | 10 | protected getStackingOption(): OptionsStackingValue { 11 | return PERCENTAGE; 12 | } 13 | 14 | //#endregion Methods override 15 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/charts/pie.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as _ from 'lodash'; 4 | import { HC_Utilities } from '../common/utilities'; 5 | import { Chart, ICategoriesAndSeries } from './chart'; 6 | import { Formatter } from '../common/formatter'; 7 | import { IVisualizerOptions } from '../../IVisualizerOptions'; 8 | import { Utilities } from '../../../common/utilities'; 9 | import { IColumn, IChartOptions, IDataPoint } from '../../../common/chartModels'; 10 | import { InvalidInputError, EmptyPieError } from '../../../common/errors/errors'; 11 | import { ErrorCode } from '../../../common/errors/errorCode'; 12 | import { chart } from 'highcharts'; 13 | 14 | interface IPieSeriesData { 15 | name: string; 16 | y: number; 17 | } 18 | 19 | interface IPieSeries { 20 | name: string; 21 | data: IPieSeriesData[]; 22 | } 23 | 24 | export class Pie extends Chart { 25 | //#region Methods override 26 | 27 | public /*override*/ getStandardCategoriesAndSeries(options: IVisualizerOptions): ICategoriesAndSeries { 28 | const xColumn: IColumn = options.chartOptions.columnsSelection.xAxis; 29 | const xAxisColumnIndex: number = Utilities.getColumnIndex(options.queryResultData, xColumn); 30 | const yAxisColumn = options.chartOptions.columnsSelection.yAxes[0]; // We allow only 1 yAxis in pie charts 31 | const yAxisColumnIndex = Utilities.getColumnIndex(options.queryResultData, yAxisColumn); 32 | 33 | // Build the data for the pie 34 | const pieSeries: IPieSeries = { 35 | name: yAxisColumn.name, 36 | data: [] 37 | } 38 | 39 | options.queryResultData.rows.forEach((row) => { 40 | const xAxisValue = row[xAxisColumnIndex]; 41 | const yAxisValue = HC_Utilities.getYValue(options.queryResultData.columns, row, yAxisColumnIndex); 42 | 43 | // Ignore empty/zero y values since they can't be placed on the pie chart 44 | if(yAxisValue) { 45 | pieSeries.data.push({ 46 | name: xAxisValue, 47 | y: yAxisValue 48 | }); 49 | } 50 | }); 51 | 52 | this.validateNonEmptyPie(pieSeries); 53 | 54 | return { 55 | series: [pieSeries] 56 | } 57 | } 58 | 59 | public /*override*/ getSplitByCategoriesAndSeries(options: IVisualizerOptions): ICategoriesAndSeries { 60 | const yAxisColumn = options.chartOptions.columnsSelection.yAxes[0]; // We allow only 1 yAxis in pie charts 61 | const yAxisColumnIndex = Utilities.getColumnIndex(options.queryResultData, yAxisColumn); 62 | const keyIndexes = this.getAllPieKeyIndexes(options); 63 | 64 | // Build the data for the multi-level pie 65 | let pieData = {}; 66 | let pieLevelData = pieData; 67 | 68 | options.queryResultData.rows.forEach((row) => { 69 | const yAxisValue = HC_Utilities.getYValue(options.queryResultData.columns, row, yAxisColumnIndex); 70 | 71 | // Ignore empty/zero y values since they can't be placed on the pie chart 72 | if (yAxisValue) { 73 | keyIndexes.forEach((keyIndex) => { 74 | const keyValue: string = row[keyIndex]; 75 | let keysMap = pieLevelData[keyValue]; 76 | 77 | if(!keysMap) { 78 | pieLevelData[keyValue] = { 79 | drillDown: {}, 80 | y: 0 81 | }; 82 | } 83 | 84 | pieLevelData[keyValue].y += yAxisValue; 85 | pieLevelData = pieLevelData[keyValue].drillDown; 86 | }); 87 | 88 | pieLevelData = pieData; 89 | } 90 | }); 91 | 92 | const series = this.spreadMultiLevelSeries(options, pieData); 93 | 94 | return { 95 | series: series 96 | } 97 | } 98 | 99 | public /*override*/ sortSeriesByName(series: any[]): any[] { 100 | const allData = _.flatMap(series, 'data'); 101 | const sortedData = _.sortBy(allData, 'name'); 102 | 103 | sortedData.forEach((data, i) => { 104 | data.legendIndex = ++i; 105 | }); 106 | 107 | return series; 108 | } 109 | 110 | public /*override*/ getChartTooltipFormatter(chartOptions: IChartOptions): Highcharts.TooltipFormatterCallbackFunction { 111 | const self = this; 112 | 113 | return function () { 114 | const context: Highcharts.TooltipFormatterContextObject = this; 115 | const dataPoint: IDataPoint = self.getDataPoint(chartOptions, context.point); 116 | 117 | // Key 118 | let keyColumn: IColumn = dataPoint.x.column; 119 | let keyColumnName: string = keyColumn.name; 120 | 121 | if (keyColumn === chartOptions.columnsSelection.xAxis) { 122 | keyColumnName = chartOptions.xAxisTitleFormatter ? chartOptions.xAxisTitleFormatter(keyColumn) : undefined; 123 | } 124 | 125 | let tooltip: string = Formatter.getSingleTooltip(chartOptions, keyColumn, dataPoint.x.value, keyColumnName); 126 | 127 | // Y axis 128 | tooltip += Formatter.getSingleTooltip(chartOptions, dataPoint.y.column, dataPoint.y.value, /*columnName*/ undefined, self.getPercentageSuffix(context)); 129 | 130 | return '' + tooltip + ''; 131 | } 132 | } 133 | 134 | public /*override*/ verifyInput(options: IVisualizerOptions): void { 135 | const columnSelection = options.chartOptions.columnsSelection; 136 | 137 | if(columnSelection.yAxes.length > 1) { 138 | throw new InvalidInputError(`Multiple y-axis columns selection isn't allowed for ${options.chartOptions.chartType}`, ErrorCode.InvalidColumnsSelection); 139 | } 140 | } 141 | 142 | protected /*override*/ getChartType(): string { 143 | return 'pie'; 144 | }; 145 | 146 | protected /*override*/ plotOptions(): Highcharts.PlotOptions { 147 | const self = this; 148 | 149 | return { 150 | pie: { 151 | innerSize: this.getInnerSize(), 152 | showInLegend: true, 153 | dataLabels: { 154 | formatter: function() { 155 | return `${this.point.name}${self.getPercentageSuffix(this)}`; 156 | }, 157 | style: { 158 | textOverflow: 'ellipsis' 159 | } 160 | } 161 | } 162 | } 163 | } 164 | 165 | protected /*override*/ getDataPoint(chartOptions: IChartOptions, point: Highcharts.Point): IDataPoint { 166 | const xColumn: IColumn = chartOptions.columnsSelection.xAxis; 167 | const splitBy = chartOptions.columnsSelection.splitBy; 168 | let seriesColumn: IColumn; 169 | 170 | // Try to find series column in the split-by columns 171 | if (splitBy && splitBy.length > 0) { 172 | // Find the current key column 173 | const keyColumnIndex = _.findIndex(splitBy, (col) => { 174 | return col.name === point.series.name; 175 | }); 176 | 177 | seriesColumn = splitBy[keyColumnIndex]; 178 | } 179 | 180 | // If the series column isn't one of the splitBy columns -> it's the x axis column 181 | if (!seriesColumn) { 182 | seriesColumn = xColumn; 183 | } 184 | 185 | const dataPointInfo: IDataPoint = { 186 | x: { 187 | column: seriesColumn, 188 | value: point.name 189 | }, 190 | y: { 191 | column: chartOptions.columnsSelection.yAxes[0], // We allow only 1 y axis in pie chart 192 | value: point.y 193 | } 194 | }; 195 | 196 | return dataPointInfo; 197 | } 198 | 199 | //#endregion Methods override 200 | 201 | protected getInnerSize(): string { 202 | return '0'; 203 | } 204 | 205 | //#region Private methods 206 | 207 | private spreadMultiLevelSeries(options: IVisualizerOptions, pieData: any, level: number = 0, series: any[] = []): IPieSeries[] { 208 | const chartOptions = options.chartOptions; 209 | const levelsCount = chartOptions.columnsSelection.splitBy.length + 1; 210 | const firstLevelSize = Math.round(100 / levelsCount); 211 | 212 | for (let key in pieData) { 213 | let currentSeries = series[level]; 214 | let pieLevelValue = pieData[key]; 215 | 216 | if(!currentSeries) { 217 | let column = (level === 0) ? chartOptions.columnsSelection.xAxis : chartOptions.columnsSelection.splitBy[level - 1]; 218 | 219 | currentSeries = { 220 | name: column.name, 221 | data: [] 222 | }; 223 | 224 | if(level === 0) { 225 | currentSeries.size = `${firstLevelSize}%`; 226 | } else { 227 | const prevLevelSizeStr = series[level - 1].size; 228 | const prevLevelSize = Number(prevLevelSizeStr.substring(0, 2)); 229 | 230 | currentSeries.size = `${prevLevelSize + 10}%`; 231 | currentSeries.innerSize = `${prevLevelSize}%`; 232 | } 233 | 234 | // We do not show labels for multi-level pie 235 | currentSeries.dataLabels = { 236 | enabled: false 237 | } 238 | 239 | series.push(currentSeries); 240 | } 241 | 242 | currentSeries.data.push({ 243 | name: key, 244 | y: pieLevelValue.y 245 | }); 246 | 247 | this.validateNonEmptyPie(currentSeries); 248 | 249 | let drillDown = pieLevelValue.drillDown; 250 | 251 | if(!_.isEmpty(drillDown)) { 252 | this.spreadMultiLevelSeries(options, drillDown, level + 1, series); 253 | } 254 | } 255 | 256 | return series; 257 | } 258 | 259 | /** 260 | * Returns an array that includes all the indexes of the columns that represent a pie slice key 261 | * @param chartOptions 262 | */ 263 | private getAllPieKeyIndexes(options: IVisualizerOptions) { 264 | const xColumn: IColumn = options.chartOptions.columnsSelection.xAxis; 265 | const xAxisColumnIndex: number = Utilities.getColumnIndex(options.queryResultData, xColumn); 266 | const keyIndexes = [xAxisColumnIndex]; 267 | 268 | options.chartOptions.columnsSelection.splitBy.forEach((splitByColumn) => { 269 | keyIndexes.push(Utilities.getColumnIndex(options.queryResultData, splitByColumn)); 270 | }); 271 | 272 | return keyIndexes; 273 | } 274 | 275 | private validateNonEmptyPie(pieSeries: IPieSeries): void { 276 | // Make sure that the pie data contains non-empty values (not only zero / null / undefined / negative values), otherwise the pie can't be drawn 277 | const isEmptyPie: boolean = _.every(pieSeries.data, (currentData) => !currentData.y || currentData.y < 0); 278 | 279 | if(isEmptyPie) { 280 | throw new EmptyPieError(); 281 | } 282 | } 283 | 284 | private getPercentageSuffix(context: { percentage?: number }): string { 285 | const percentageRoundValue = Number(Math.round((context.percentage + 'e2')) + 'e-2'); // Round the percentage to up to 2 decimal points 286 | 287 | return ` (${percentageRoundValue}%)`; 288 | } 289 | 290 | //#endregion Private methods 291 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/charts/scatter.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Chart } from './chart'; 4 | import { UNSTACKED } from '../chartTypeOptions'; 5 | 6 | export class Scatter extends Chart { 7 | //#region Methods override 8 | 9 | protected getChartType(): string { 10 | return 'scatter'; 11 | }; 12 | 13 | protected plotOptions(): Highcharts.PlotOptions { 14 | return { 15 | scatter: { 16 | stacking: UNSTACKED 17 | } 18 | } 19 | } 20 | 21 | //#endregion Methods override 22 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/charts/stackedArea.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Area } from './area'; 4 | import { STACKED } from '../chartTypeOptions'; 5 | import { OptionsStackingValue } from 'highcharts'; 6 | 7 | export class StackedArea extends Area { 8 | //#region Methods override 9 | 10 | protected getStackingOption(): OptionsStackingValue { 11 | return STACKED; 12 | } 13 | 14 | //#endregion Methods override 15 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/charts/stackedBar.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Bar } from './bar'; 4 | import { STACKED } from '../chartTypeOptions'; 5 | import { OptionsStackingValue } from 'highcharts'; 6 | 7 | export class StackedBar extends Bar { 8 | //#region Methods override 9 | 10 | protected getStackingOption(): OptionsStackingValue { 11 | return STACKED; 12 | } 13 | 14 | //#endregion Methods override 15 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/charts/stackedColumn.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Column } from './column'; 4 | import { STACKED } from '../chartTypeOptions'; 5 | import { OptionsStackingValue } from 'highcharts'; 6 | 7 | export class StackedColumn extends Column { 8 | //#region Methods override 9 | 10 | protected getStackingOption(): OptionsStackingValue { 11 | return STACKED; 12 | } 13 | 14 | //#endregion Methods override 15 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/charts/unstackedArea.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Area } from './area'; 4 | import { UNSTACKED } from '../chartTypeOptions'; 5 | import { OptionsStackingValue } from 'highcharts'; 6 | 7 | export class UnstackedArea extends Area { 8 | //#region Methods override 9 | 10 | protected getStackingOption(): OptionsStackingValue { 11 | return UNSTACKED; 12 | } 13 | 14 | //#endregion Methods override 15 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/charts/unstackedBar.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Bar } from './bar'; 4 | import { UNSTACKED } from '../chartTypeOptions'; 5 | import { OptionsStackingValue } from 'highcharts'; 6 | 7 | export class UnstackedBar extends Bar { 8 | //#region Methods override 9 | 10 | protected getStackingOption(): OptionsStackingValue { 11 | return UNSTACKED; 12 | } 13 | 14 | //#endregion Methods override 15 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/charts/unstackedColumn.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Column } from './column'; 4 | import { UNSTACKED } from '../chartTypeOptions'; 5 | import { OptionsStackingValue } from 'highcharts'; 6 | 7 | export class UnstackedColumn extends Column { 8 | //#region Methods override 9 | 10 | protected getStackingOption(): OptionsStackingValue { 11 | return UNSTACKED; 12 | } 13 | 14 | //#endregion Methods override 15 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/common/formatter.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as _ from 'lodash'; 4 | import { HighchartsDateFormatToCommon } from "../highchartsDateFormatToCommon"; 5 | import { DraftColumnType, DateFormat, IColumn, IChartOptions } from "../../../common/chartModels"; 6 | import { Utilities } from "../../../common/utilities"; 7 | 8 | export class Formatter { 9 | public static getSingleTooltip(chartOptions: IChartOptions, column: IColumn, originalValue: any, columnName?: string, valueSuffix: string = ''): string { 10 | const maxLabelWidth: number = 100; 11 | let escapedColumnName = Utilities.escapeStr(columnName || column.name); 12 | let formattedValue = ''; 13 | 14 | if(originalValue != undefined) { 15 | formattedValue = Formatter.getFormattedValue(chartOptions, originalValue, column.type); 16 | 17 | // Truncate the value if it's too long 18 | if(originalValue.length > maxLabelWidth) { 19 | formattedValue = formattedValue.slice(0, maxLabelWidth) + '...'; 20 | } 21 | } 22 | 23 | return `${escapedColumnName}: ${formattedValue + valueSuffix}`; 24 | } 25 | 26 | public static getLabelsFormatter(chartOptions: IChartOptions, column: IColumn, useHTML: boolean): Highcharts.FormatterCallbackFunction> { 27 | return function() { 28 | const dataPoint = this; 29 | const value = dataPoint.value; 30 | const formattedValue = Formatter.getFormattedValue(chartOptions, value, column.type, HighchartsDateFormatToCommon[dataPoint['dateTimeLabelFormat']]); 31 | 32 | return useHTML ? `${formattedValue}` : formattedValue; 33 | } 34 | } 35 | 36 | private static getFormattedValue(chartOptions: IChartOptions, originalValue: any, columnType: DraftColumnType, dateFormat: DateFormat = DateFormat.FullDate): string { 37 | if(chartOptions.numberFormatter && Utilities.isNumeric(columnType) && typeof originalValue === 'number') { 38 | return chartOptions.numberFormatter(originalValue); 39 | } else if(Utilities.isDate(columnType)) { 40 | return chartOptions.dateFormatter ? chartOptions.dateFormatter(originalValue, dateFormat) : new Date(originalValue).toString(); 41 | } 42 | 43 | return originalValue != null ? originalValue.toString() : ''; 44 | } 45 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/common/utilities.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { DraftColumnType, IColumn, IRow } from "../../../common/chartModels"; 4 | 5 | export class HC_Utilities { 6 | public static getYValue(columns: IColumn[], row: IRow, yAxisIndex: number): number { 7 | const column = columns[yAxisIndex]; 8 | let originalValue = row[yAxisIndex]; 9 | 10 | // If the y value is undefined - convert to null since Highcharts don't support numeric undefined values 11 | if(originalValue === undefined) { 12 | originalValue = null; 13 | } 14 | 15 | // Highcharts support only numeric y-axis data. If the y-axis isn't a number (can be a string that represents a number "0.005" for example) - convert it to number 16 | if(typeof originalValue === 'string') { 17 | if(column.type === DraftColumnType.Decimal) { 18 | return parseFloat(originalValue); 19 | } else if (column.type === DraftColumnType.Int) { 20 | return parseInt(originalValue); 21 | } 22 | 23 | return Number(originalValue); 24 | } 25 | 26 | return originalValue; 27 | } 28 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/highchartsDateFormatToCommon.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { DateFormat } from '../../common/chartModels'; 4 | 5 | // See: https://api.highcharts.com/highcharts/xAxis.dateTimeLabelFormats 6 | export const HighchartsDateFormatToCommon = { 7 | '%H:%M:%S.%L': DateFormat.FullTime, 8 | '%H:%M:%S': DateFormat.Time, 9 | '%H:%M': DateFormat.HourAndMinute, 10 | '%e. %b': DateFormat.MonthAndDay, 11 | '%b \'%y': DateFormat.MonthAndYear, 12 | '%Y': DateFormat.Year 13 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/highchartsVisualizer.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | //#region Imports 4 | 5 | import * as _ from 'lodash'; 6 | import * as Highcharts from 'highcharts'; 7 | import HC_exporting from 'highcharts/modules/exporting'; 8 | import HC_offlineExporting from 'highcharts/modules/offline-exporting'; 9 | import HC_Accessibility from 'highcharts/modules/accessibility'; 10 | import { ResizeSensor } from 'css-element-queries'; 11 | import { Chart } from './charts/chart'; 12 | import { IVisualizer } from '../IVisualizer'; 13 | import { IVisualizerOptions } from '../IVisualizerOptions'; 14 | import { ChartFactory } from './charts/chartFactory'; 15 | import { ChartTheme, IChartOptions, DrawChartStatus, LegendPosition } from '../../common/chartModels'; 16 | import { Changes, ChartChange } from '../../common/chartChange'; 17 | import { Utilities } from '../../common/utilities'; 18 | import { Themes } from './themes/themes'; 19 | import { InvalidInputError, VisualizerError } from '../../common/errors/errors'; 20 | import { ErrorCode } from '../../common/errors/errorCode'; 21 | import { Formatter } from './common/formatter'; 22 | 23 | //#endregion Imports 24 | 25 | type ResolveFn = (value?: void | PromiseLike) => void; 26 | type RejectFn = (ex: any) => void; 27 | 28 | export class HighchartsVisualizer implements IVisualizer { 29 | private options: IVisualizerOptions; 30 | private highchartsChart: Highcharts.Chart; 31 | private basicHighchartsOptions: Highcharts.Options; 32 | private themeOptions: Highcharts.Options; 33 | private currentChart: Chart; 34 | private chartContainerResizeSensor: ResizeSensor; 35 | 36 | public constructor() { 37 | // init Highcharts exporting modules 38 | HC_exporting(Highcharts); 39 | HC_offlineExporting(Highcharts); 40 | 41 | // init Highcharts accessibility module 42 | HC_Accessibility(Highcharts); 43 | } 44 | 45 | public drawNewChart(options: IVisualizerOptions): Promise { 46 | return new Promise((resolve, reject) => { 47 | try { 48 | this.verifyInput(options); 49 | const chartOptions = options.chartOptions; 50 | 51 | this.options = options; 52 | this.currentChart = ChartFactory.create(chartOptions.chartType, options.chartOptions); 53 | this.currentChart.verifyInput(options); 54 | this.basicHighchartsOptions = this.getHighchartsOptions(options); 55 | this.themeOptions = Themes.getThemeOptions(chartOptions.chartTheme); 56 | this.onFinishDataTransformation(options, resolve, reject); 57 | } catch (ex) { 58 | reject(ex); 59 | } 60 | }); 61 | } 62 | 63 | public updateExistingChart(options: IVisualizerOptions, changes: Changes): Promise { 64 | return new Promise((resolve, reject) => { 65 | try { 66 | this.verifyInput(options); 67 | 68 | // Make sure that there is an existing chart 69 | const chartContainer = document.querySelector('#' + this.options.elementId); 70 | const isChartExist = chartContainer && chartContainer.children.length > 0; 71 | const isChartTypeTheOnlyChange = changes.count === 1 && changes.isPendingChange(ChartChange.ChartType); 72 | 73 | if(isChartExist && isChartTypeTheOnlyChange) { 74 | const oldChart = this.currentChart; 75 | const newChart = ChartFactory.create(options.chartOptions.chartType, options.chartOptions); 76 | 77 | // We update the existing chart options only if the new chart categories and series builder method is the same as the previous chart's method 78 | if(oldChart.getSplitByCategoriesAndSeries === newChart.getSplitByCategoriesAndSeries && 79 | oldChart.getStandardCategoriesAndSeries === newChart.getStandardCategoriesAndSeries) { 80 | this.currentChart = newChart; 81 | this.options = options; 82 | 83 | // Build the options that need to be updated 84 | let newOptions: Highcharts.Options = this.currentChart.getChartTypeOptions(); 85 | 86 | // Apply the changes 87 | this.highchartsChart.update(newOptions); 88 | 89 | // Save the new options 90 | this.basicHighchartsOptions = _.merge({}, this.basicHighchartsOptions, newOptions); 91 | 92 | this.onFinishDrawingChart(resolve, options); 93 | 94 | return; 95 | } 96 | } 97 | 98 | // Every other change - Redraw the chart 99 | this.drawNewChart(options) 100 | .then(() => { 101 | resolve(); 102 | }) 103 | .catch((ex) => { 104 | reject(ex); 105 | }); 106 | } catch (ex) { 107 | reject(ex); 108 | } 109 | }); 110 | } 111 | 112 | public changeTheme(newTheme: ChartTheme): Promise { 113 | const options = this.options; 114 | 115 | return new Promise((resolve, reject) => { 116 | // No existing chart / the theme wasn't changed - do nothing 117 | if(!this.currentChart || options.chartOptions.chartTheme === newTheme) { 118 | resolve(); 119 | 120 | return; 121 | } 122 | 123 | // Update new theme options 124 | this.options.chartOptions.chartTheme = newTheme; 125 | this.themeOptions = Themes.getThemeOptions(newTheme); 126 | 127 | // Re-draw the a new chart with the new theme 128 | this.draw(options, resolve, reject); 129 | }); 130 | } 131 | 132 | public downloadChartJPGImage(onError?: (error: Error) => void): void { 133 | if(!this.highchartsChart) { 134 | return; // No existing chart - do nothing 135 | } 136 | 137 | const exportingOptions: Highcharts.ExportingOptions = { 138 | type: 'image/jpeg', 139 | error: (options: Highcharts.ExportingOptions, err: Error) => { 140 | if(onError) { 141 | onError(err); 142 | } 143 | } 144 | }; 145 | 146 | this.highchartsChart.exportChart(exportingOptions, /*chartOptions*/ {}); 147 | } 148 | 149 | //#region Private methods 150 | 151 | private draw(options: IVisualizerOptions, resolve: ResolveFn, reject: RejectFn): void { 152 | try { 153 | const elementId = options.elementId; 154 | const highchartsOptions = _.merge({}, this.basicHighchartsOptions, this.themeOptions); 155 | const updateCustomOptionsFn = options.chartOptions.updateCustomOptions; 156 | 157 | // Allow changing the chart options externally before rendering the chart 158 | if(updateCustomOptionsFn && typeof updateCustomOptionsFn === 'function') { 159 | updateCustomOptionsFn(highchartsOptions); 160 | } 161 | 162 | this.destroyExistingChart(); 163 | 164 | // Draw the chart 165 | this.highchartsChart = Highcharts.chart(elementId, highchartsOptions, () => { 166 | this.handleResize(); 167 | this.onFinishDrawingChart(resolve, options); 168 | }); 169 | } catch(ex) { 170 | reject(new VisualizerError(ex.message, ErrorCode.FailedToCreateVisualization)); 171 | } 172 | } 173 | 174 | private onFinishDrawingChart(resolve: ResolveFn, options: IVisualizerOptions): void { 175 | // Mark that the chart drawing was finished 176 | resolve(); 177 | 178 | // If onFinishChartAnimation callback was given, call it after the animation duration 179 | const finishChartAnimationCallback = options.chartOptions.onFinishChartAnimation; 180 | 181 | if(finishChartAnimationCallback) { 182 | setTimeout(() => { 183 | finishChartAnimationCallback(options.chartInfo); 184 | }, options.chartOptions.animationDurationMS + 200); 185 | } 186 | } 187 | 188 | // Highcharts handle resize only on window resize, we need to handle resize when the chart's container size changes 189 | private handleResize(): void { 190 | const chartContainer = document.querySelector('#' + this.options.elementId); 191 | 192 | if(this.chartContainerResizeSensor) { 193 | // Remove the previous resize sensor, and stop listening to resize events 194 | this.chartContainerResizeSensor.detach(); 195 | } 196 | 197 | this.chartContainerResizeSensor = new ResizeSensor(chartContainer, () => { 198 | this.highchartsChart.reflow(); 199 | }); 200 | } 201 | 202 | private getHighchartsOptions(options: IVisualizerOptions): Highcharts.Options { 203 | const chartOptions = options.chartOptions; 204 | const isDatetimeAxis = Utilities.isDate(chartOptions.columnsSelection.xAxis.type); 205 | let animation; 206 | 207 | if(options.chartOptions.animationDurationMS === 0) { 208 | animation = false; 209 | } 210 | 211 | let highchartsOptions: Highcharts.Options = { 212 | credits: { 213 | enabled: false // Hide the Highcharts watermark on the right corner of the chart 214 | }, 215 | chart: { 216 | displayErrors: false, 217 | animation: animation, 218 | style: { 219 | fontFamily: options.chartOptions.fontFamily 220 | } 221 | }, 222 | time: { 223 | getTimezoneOffset: this.options.chartOptions.getUtcOffset 224 | }, 225 | title: { 226 | text: chartOptions.title 227 | }, 228 | xAxis: this.getXAxis(isDatetimeAxis, chartOptions), 229 | yAxis: this.getYAxis(chartOptions), 230 | tooltip: { 231 | formatter: this.currentChart.getChartTooltipFormatter(chartOptions), 232 | shared: false, 233 | useHTML: true 234 | }, 235 | legend: this.getLegendOptions(chartOptions), 236 | exporting: { 237 | buttons: { 238 | contextButton: { 239 | enabled: false 240 | } 241 | }, 242 | fallbackToExportServer: false 243 | } 244 | }; 245 | 246 | const categoriesAndSeries = this.getCategoriesAndSeries(options); 247 | const chartTypeOptions = this.currentChart.getChartTypeOptions(); 248 | 249 | highchartsOptions = _.merge(highchartsOptions, chartTypeOptions, categoriesAndSeries); 250 | 251 | return highchartsOptions; 252 | } 253 | 254 | private getXAxis(isDatetimeAxis: boolean, chartOptions: IChartOptions): Highcharts.XAxisOptions { 255 | const useHTML: boolean = true; 256 | 257 | return { 258 | type: isDatetimeAxis ? 'datetime' : undefined, 259 | labels: { 260 | formatter: Formatter.getLabelsFormatter(chartOptions, chartOptions.columnsSelection.xAxis, useHTML), 261 | useHTML: useHTML, 262 | style: { 263 | 'whiteSpace': 'nowrap', 264 | 'max-width': '100px', 265 | 'overflow': 'hidden', 266 | 'text-overflow': 'ellipsis', 267 | } 268 | }, 269 | title: { 270 | text: this.getXAxisTitle(chartOptions), 271 | align: 'middle' 272 | } 273 | } 274 | } 275 | 276 | private getYAxis(chartOptions: IChartOptions): Highcharts.YAxisOptions { 277 | const firstYAxis = this.options.chartOptions.columnsSelection.yAxes[0]; 278 | const yAxisOptions: Highcharts.YAxisOptions = { 279 | title: { 280 | text: this.getYAxisTitle(chartOptions) 281 | }, 282 | labels: { 283 | formatter: Formatter.getLabelsFormatter(chartOptions, firstYAxis, /*useHTML*/ false) 284 | }, 285 | } 286 | 287 | if(chartOptions.yMinimumValue != null) { 288 | yAxisOptions.min = chartOptions.yMinimumValue; 289 | } 290 | 291 | if(chartOptions.yMaximumValue != null) { 292 | yAxisOptions.max = chartOptions.yMaximumValue; 293 | } 294 | 295 | return yAxisOptions; 296 | } 297 | 298 | private getYAxisTitle(chartOptions: IChartOptions): string { 299 | const yAxisColumns = chartOptions.columnsSelection.yAxes; 300 | const yAxisTitleFormatter = chartOptions.yAxisTitleFormatter; 301 | 302 | if(yAxisTitleFormatter) { 303 | let yAxisTitle = yAxisTitleFormatter(yAxisColumns); 304 | let escapedYAxisTitle = Utilities.escapeStr(yAxisTitle); 305 | 306 | return escapedYAxisTitle as string; 307 | } 308 | 309 | return yAxisColumns[0].name; 310 | } 311 | 312 | private getXAxisTitle(chartOptions: IChartOptions): string { 313 | const xAxisColumn = chartOptions.columnsSelection.xAxis; 314 | const xAxisTitleFormatter = chartOptions.xAxisTitleFormatter; 315 | 316 | if(xAxisTitleFormatter) { 317 | let xAxisTitle = xAxisTitleFormatter(xAxisColumn); 318 | let escapedXAxisTitle = Utilities.escapeStr(xAxisTitle); 319 | 320 | return escapedXAxisTitle as string; 321 | } 322 | 323 | return xAxisColumn.name; 324 | } 325 | 326 | private destroyExistingChart(): void { 327 | if(this.highchartsChart) { 328 | try { 329 | this.highchartsChart.destroy(); 330 | } catch(err) { 331 | // Do nothing - this means that the chart object was already destroyed by Highcharts 332 | } 333 | } 334 | } 335 | 336 | private getCategoriesAndSeries(options: IVisualizerOptions): Highcharts.Options { 337 | const columnsSelection = options.chartOptions.columnsSelection; 338 | let categoriesAndSeries; 339 | 340 | if(columnsSelection.splitBy && columnsSelection.splitBy.length > 0) { 341 | categoriesAndSeries = this.currentChart.getSplitByCategoriesAndSeries(options); 342 | } else { 343 | categoriesAndSeries = this.currentChart.getStandardCategoriesAndSeries(options); 344 | } 345 | 346 | return { 347 | xAxis: { 348 | categories: categoriesAndSeries.categories 349 | }, 350 | series: this.currentChart.sortSeriesByName(categoriesAndSeries.series) 351 | } 352 | } 353 | 354 | private onFinishDataTransformation(options: IVisualizerOptions, resolve: ResolveFn, reject: RejectFn): void { 355 | // Calculate the number of data points 356 | const dataTransformationInfo = options.chartInfo.dataTransformationInfo; 357 | 358 | dataTransformationInfo.numberOfDataPoints = 0; 359 | 360 | this.basicHighchartsOptions.series.forEach((currentSeries) => { 361 | dataTransformationInfo.numberOfDataPoints += currentSeries['data'].length; 362 | }); 363 | 364 | if(options.chartOptions.onFinishDataTransformation) { 365 | const drawChartPromise = options.chartOptions.onFinishDataTransformation(dataTransformationInfo); 366 | 367 | // Continue drawing the chart only after drawChartPromise is resolved with true 368 | drawChartPromise 369 | .then((continueDraw: boolean) => { 370 | if(continueDraw) { 371 | this.draw(options, resolve, reject); 372 | } else { 373 | options.chartInfo.status = DrawChartStatus.Canceled; 374 | resolve(); // Resolve without drawing the chart 375 | } 376 | }); 377 | } else { 378 | // Draw the chart 379 | this.draw(options, resolve, reject); 380 | } 381 | } 382 | 383 | private verifyInput(options: IVisualizerOptions): void { 384 | const elementId = options.elementId; 385 | 386 | if(!elementId) { 387 | throw new InvalidInputError("The elementId option can't be empty", ErrorCode.InvalidChartContainerElementId); 388 | } 389 | 390 | 391 | // Make sure that there is an existing chart container element before drawing the chart 392 | if(!document.querySelector(`#${elementId}`)) { 393 | throw new InvalidInputError(`Element with the id '${elementId}' doesn't exist on the DOM`, ErrorCode.InvalidChartContainerElementId); 394 | } 395 | 396 | const columnSelection = options.chartOptions.columnsSelection; 397 | 398 | if(columnSelection.yAxes.length > 1 && columnSelection.splitBy && columnSelection.splitBy.length > 0) { 399 | throw new InvalidInputError("When there are multiple y-axis columns, split-by column isn't allowed", ErrorCode.InvalidColumnsSelection); 400 | } 401 | } 402 | 403 | private getLegendOptions(chartOptions: IChartOptions): Highcharts.LegendOptions { 404 | const legendOptions: Highcharts.LegendOptions = { 405 | enabled: chartOptions.legendOptions.isEnabled, 406 | accessibility: { 407 | enabled: true, 408 | keyboardNavigation: { 409 | enabled: true 410 | } 411 | } 412 | }; 413 | 414 | if (chartOptions.legendOptions.position === LegendPosition.Bottom) { 415 | legendOptions.width = '100%'; 416 | legendOptions.maxHeight = this.getLegendMaxHeight(); 417 | legendOptions.itemDistance = 35; // To allow text spacing (accessibility) See: https://msazure.visualstudio.com/One/_workitems/edit/9255411 418 | } else { // Right 419 | legendOptions.layout = 'vertical' 420 | legendOptions.verticalAlign = 'top'; 421 | legendOptions.align = 'right'; 422 | legendOptions.itemWidth = 100; 423 | legendOptions.margin = 5; 424 | legendOptions.padding = 2; 425 | 426 | // To fix missing tooltip on legend items. See: https://support.highcharts.com/support/tickets/7053 427 | Highcharts.wrap(Highcharts.AccessibilityComponent.prototype, 'updateProxyButtonPosition', function (proceed, btn) { 428 | const ret = proceed.apply(this, Array.prototype.slice.call(arguments, 1)); 429 | 430 | btn.style.width = '12px'; 431 | 432 | return ret; 433 | }); 434 | } 435 | 436 | return legendOptions; 437 | } 438 | 439 | private getLegendMaxHeight(): number { 440 | let legendMaxHeight: number = 70; // Default 441 | const chartContainer: Element = document.querySelector('#' + this.options.elementId); 442 | 443 | if(chartContainer && chartContainer.clientHeight) { 444 | legendMaxHeight = chartContainer.clientHeight / 5; 445 | } 446 | 447 | return legendMaxHeight; 448 | } 449 | 450 | //#endregion Private methods 451 | } -------------------------------------------------------------------------------- /src/visualizers/highcharts/themes/darkTheme.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const textMainColor: string = '#ffffff'; 4 | const labelsColor: string = '#e0e0e3'; 5 | const lineColor: string = '#707073'; 6 | const minorGridLineColor: string = '#505053'; 7 | const dataLabelsColor: string = '#f0f0f3'; 8 | const hiddenStyleColor: string = '#606063'; 9 | const strokeColor: string = '000000'; 10 | 11 | export const DarkThemeOptions: Highcharts.Options = { 12 | colors: ['#0078D4', '#CB4936', '#BEDFFF', '#038387', '#F6C0FF', '#D90086', '#A4E1D2', '#6E5DB7', '#C7D3FF', '#FFCA8A'], 13 | chart: { 14 | backgroundColor: '#111111', 15 | }, 16 | title: { 17 | style: { 18 | color: textMainColor 19 | } 20 | }, 21 | xAxis: { 22 | gridLineColor: '#E10420', 23 | labels: { 24 | style: { 25 | color: labelsColor 26 | } 27 | }, 28 | lineColor: lineColor, 29 | minorGridLineColor: minorGridLineColor, 30 | tickColor: lineColor, 31 | title: { 32 | style: { 33 | color: textMainColor 34 | } 35 | } 36 | }, 37 | yAxis: { 38 | gridLineColor: lineColor, 39 | labels: { 40 | style: { 41 | color: labelsColor 42 | } 43 | }, 44 | lineColor: lineColor, 45 | minorGridLineColor: minorGridLineColor, 46 | tickColor: lineColor, 47 | title: { 48 | style: { 49 | color: textMainColor 50 | } 51 | } 52 | }, 53 | tooltip: { 54 | backgroundColor: 'rgba(2, 2, 2, 0.85)', 55 | style: { 56 | color: '#faf9f8' 57 | } 58 | }, 59 | plotOptions: { 60 | series: { 61 | dataLabels: { 62 | color: dataLabelsColor 63 | }, 64 | marker: { 65 | lineColor: '#333' 66 | } 67 | }, 68 | boxplot: { 69 | fillColor: minorGridLineColor 70 | }, 71 | errorbar: { 72 | color: 'white' 73 | } 74 | }, 75 | legend: { 76 | backgroundColor: 'rgba(0, 0, 0, 0.5)', 77 | itemStyle: { 78 | color: labelsColor 79 | }, 80 | itemHoverStyle: { 81 | color: '#FFF' 82 | }, 83 | itemHiddenStyle: { 84 | color: hiddenStyleColor 85 | }, 86 | title: { 87 | style: { 88 | color: '#C0C0C0' 89 | } 90 | }, 91 | navigation: { 92 | style: { 93 | fontWeight: 'bold', 94 | color: textMainColor, 95 | } 96 | } 97 | }, 98 | credits: { 99 | style: { 100 | color: '#666' 101 | } 102 | }, 103 | drilldown: { 104 | activeAxisLabelStyle: { 105 | color: dataLabelsColor 106 | }, 107 | activeDataLabelStyle: { 108 | color: dataLabelsColor 109 | } 110 | }, 111 | navigation: { 112 | buttonOptions: { 113 | symbolStroke: '#DDDDDD', 114 | theme: { 115 | fill: minorGridLineColor 116 | } 117 | } 118 | }, 119 | // scroll charts 120 | rangeSelector: { 121 | buttonTheme: { 122 | fill: minorGridLineColor, 123 | stroke: strokeColor, 124 | style: { 125 | color: '#CCC' 126 | }, 127 | states: { 128 | hover: { 129 | fill: lineColor, 130 | stroke: strokeColor, 131 | style: { 132 | color: 'white' 133 | } 134 | }, 135 | select: { 136 | fill: '#000003', 137 | stroke: strokeColor, 138 | style: { 139 | color: 'white' 140 | } 141 | } 142 | } 143 | }, 144 | inputBoxBorderColor: minorGridLineColor, 145 | inputStyle: { 146 | backgroundColor: '#333', 147 | color: 'silver' 148 | }, 149 | labelStyle: { 150 | color: 'silver' 151 | } 152 | }, 153 | navigator: { 154 | handles: { 155 | backgroundColor: '#666', 156 | borderColor: '#AAA' 157 | }, 158 | outlineColor: '#CCC', 159 | maskFill: 'rgba(255,255,255,0.1)', 160 | series: { 161 | color: '#7798BF', 162 | lineColor: '#A6C7ED' 163 | }, 164 | xAxis: { 165 | gridLineColor: minorGridLineColor 166 | } 167 | }, 168 | scrollbar: { 169 | barBackgroundColor: '#808083', 170 | barBorderColor: '#808083', 171 | buttonArrowColor: '#CCC', 172 | buttonBackgroundColor: hiddenStyleColor, 173 | buttonBorderColor: hiddenStyleColor, 174 | rifleColor: '#FFF', 175 | trackBackgroundColor: '#404043', 176 | trackBorderColor: '#404043' 177 | } 178 | }; -------------------------------------------------------------------------------- /src/visualizers/highcharts/themes/lightTheme.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export const LightThemeOptions: Highcharts.Options = { 4 | colors: ['#0078D4', '#EF6950', '#00188F', '#00A2AD', '#4B003F', '#E3008C', '#022F22', '#917EDB', '#001D3F', '#502006'], 5 | tooltip: { 6 | backgroundColor: 'rgba(255, 255, 255, 0.85)' 7 | }, 8 | }; -------------------------------------------------------------------------------- /src/visualizers/highcharts/themes/themes.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as _ from 'lodash'; 4 | import * as Highcharts from 'highcharts'; 5 | import { ChartTheme } from '../../../common/chartModels'; 6 | import { DarkThemeOptions } from './darkTheme'; 7 | import { LightThemeOptions } from './lightTheme'; 8 | 9 | export class Themes { 10 | private static commonThemeTypeToHighcharts: { [key in ChartTheme]: Highcharts.Options; } = { 11 | [ChartTheme.Light]: LightThemeOptions, 12 | [ChartTheme.Dark]: DarkThemeOptions 13 | } 14 | 15 | private static defaultThemeOptions: Highcharts.Options = { 16 | title: { 17 | style: { 18 | fontSize: '20px' 19 | } 20 | }, 21 | yAxis: { 22 | tickWidth: 1, 23 | }, 24 | plotOptions: { 25 | series: { 26 | dataLabels: { 27 | style: { 28 | fontSize: '13px' 29 | } 30 | } 31 | } 32 | } 33 | }; 34 | 35 | public static getThemeOptions(theme: ChartTheme): Highcharts.Options { 36 | const themeOptions = Themes.commonThemeTypeToHighcharts[theme]; 37 | 38 | return _.merge({}, Themes.defaultThemeOptions, themeOptions); 39 | } 40 | } -------------------------------------------------------------------------------- /tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "build", 9 | "group": "build", 10 | "problemMatcher": [] 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /test/changeDetection.test.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { DraftColumnType, IQueryResultData, IChartOptions, IColumn, ChartType } from '../src/common/chartModels'; 4 | import { ChangeDetection } from '../src/common/changeDetection'; 5 | import { Changes, ChartChange } from '../src/common/chartChange'; 6 | 7 | describe('Unit tests for ChangeDetection', () => { 8 | //#region Tests 9 | 10 | describe('Validate detectChanges method', () => { 11 | const columns: IColumn[] = [ 12 | { name: 'country', type: DraftColumnType.String }, 13 | { name: 'city', type: DraftColumnType.String }, 14 | { name: 'request_count', type: DraftColumnType.Int }, 15 | { name: 'timestamp', type: DraftColumnType.DateTime }, 16 | { name: 'name', type: DraftColumnType.String }, 17 | { name: 'age', type: DraftColumnType.Int }, 18 | ]; 19 | 20 | it('When only the columns selection order was changed - chart changes is empty', () => { 21 | const oldQueryResultData: IQueryResultData = { rows: [], columns: columns }; 22 | const oldChartOptions: IChartOptions = { 23 | chartType: ChartType.Line, 24 | columnsSelection: { 25 | xAxis: columns[0], 26 | yAxes: [columns[1], columns[2], columns[3]] 27 | } 28 | }; 29 | 30 | const newQueryResultData: IQueryResultData = oldQueryResultData; 31 | const newChartOptions: IChartOptions = { 32 | chartType: ChartType.Line, 33 | columnsSelection: { 34 | xAxis: columns[0], 35 | yAxes: [columns[2], columns[1], columns[3]] 36 | } 37 | }; 38 | 39 | // Act 40 | const result: Changes = ChangeDetection.detectChanges(oldQueryResultData, oldChartOptions, newQueryResultData, newChartOptions); 41 | const expected = { 42 | count: 0, 43 | changesMap: {} 44 | }; 45 | 46 | // Assert 47 | expect(result.count).toEqual(expected.count); 48 | expect(result['changesMap']).toEqual(expected.changesMap); 49 | }); 50 | 51 | it('When only the chart type was changed - chart changes is as expected', () => { 52 | const oldQueryResultData: IQueryResultData = { rows: [], columns: columns }; 53 | const oldChartOptions: IChartOptions = { 54 | chartType: ChartType.Line, 55 | columnsSelection: { 56 | xAxis: columns[0], 57 | yAxes: [columns[1], columns[2]], 58 | splitBy: [columns[3]] 59 | } 60 | }; 61 | 62 | const newQueryResultData: IQueryResultData = oldQueryResultData; 63 | const newChartOptions: IChartOptions = { 64 | chartType: ChartType.StackedArea, 65 | columnsSelection: { 66 | xAxis: columns[0], 67 | yAxes: [columns[1], columns[2]], 68 | splitBy: [columns[3]] 69 | } 70 | }; 71 | 72 | // Act 73 | const result: Changes = ChangeDetection.detectChanges(oldQueryResultData, oldChartOptions, newQueryResultData, newChartOptions); 74 | const expected = { 75 | count: 1, 76 | changesMap: { 77 | [ChartChange.ChartType]: true 78 | } 79 | }; 80 | 81 | // Assert 82 | expect(result.count).toEqual(expected.count); 83 | expect(result['changesMap']).toEqual(expected.changesMap); 84 | }); 85 | 86 | it('When there was columns selection change - chart changes is as expected', () => { 87 | const oldQueryResultData: IQueryResultData = { rows: [], columns: columns }; 88 | const oldChartOptions: IChartOptions = { 89 | chartType: ChartType.Line, 90 | columnsSelection: { 91 | xAxis: columns[0], 92 | yAxes: [columns[1], columns[2]], 93 | splitBy: [columns[3]] 94 | } 95 | }; 96 | 97 | const newQueryResultData: IQueryResultData = oldQueryResultData; 98 | const newChartOptions: IChartOptions = { 99 | chartType: ChartType.Line, 100 | columnsSelection: { 101 | xAxis: columns[0], 102 | yAxes: [columns[1], columns[5]], 103 | splitBy: [columns[3]] 104 | } 105 | }; 106 | 107 | // Act 108 | const result: Changes = ChangeDetection.detectChanges(oldQueryResultData, oldChartOptions, newQueryResultData, newChartOptions); 109 | const expected = { 110 | count: 1, 111 | changesMap: { 112 | [ChartChange.ColumnsSelection]: true 113 | } 114 | }; 115 | 116 | // Assert 117 | expect(result.count).toEqual(expected.count); 118 | expect(result['changesMap']).toEqual(expected.changesMap); 119 | }); 120 | 121 | it('When there was both columns selection and chart type change - chart changes is as expected', () => { 122 | const oldQueryResultData: IQueryResultData = { rows: [], columns: columns }; 123 | const oldChartOptions: IChartOptions = { 124 | chartType: ChartType.Line, 125 | columnsSelection: { 126 | xAxis: columns[0], 127 | yAxes: [columns[1], columns[2]], 128 | splitBy: [columns[3]] 129 | } 130 | }; 131 | 132 | const newQueryResultData: IQueryResultData = oldQueryResultData; 133 | const newChartOptions: IChartOptions = { 134 | chartType: ChartType.Pie, 135 | columnsSelection: { 136 | xAxis: columns[0], 137 | yAxes: [columns[1], columns[5]], 138 | splitBy: [columns[3]] 139 | } 140 | }; 141 | 142 | // Act 143 | const result: Changes = ChangeDetection.detectChanges(oldQueryResultData, oldChartOptions, newQueryResultData, newChartOptions); 144 | const expected = { 145 | count: 2, 146 | changesMap: { 147 | [ChartChange.ColumnsSelection]: true, 148 | [ChartChange.ChartType]: true 149 | } 150 | }; 151 | 152 | // Assert 153 | expect(result.count).toEqual(expected.count); 154 | expect(result['changesMap']).toEqual(expected.changesMap); 155 | }); 156 | 157 | it('When only the query result data was changed - chart changes is as expected', () => { 158 | const oldQueryResultData: IQueryResultData = { rows: [], columns: columns }; 159 | const oldChartOptions: IChartOptions = { 160 | chartType: ChartType.Line, 161 | columnsSelection: { 162 | xAxis: columns[0], 163 | yAxes: [columns[1]] 164 | } 165 | }; 166 | 167 | const newQueryResultData: IQueryResultData = { rows: [], columns: [ columns[0], columns[1], columns[2] ] }; 168 | const newChartOptions: IChartOptions = { 169 | chartType: ChartType.Line, 170 | columnsSelection: { 171 | xAxis: columns[0], 172 | yAxes: [columns[1]] 173 | } 174 | }; 175 | 176 | // Act 177 | const result: Changes = ChangeDetection.detectChanges(oldQueryResultData, oldChartOptions, newQueryResultData, newChartOptions); 178 | const expected = { 179 | count: 1, 180 | changesMap: { 181 | [ChartChange.QueryData]: true 182 | } 183 | }; 184 | 185 | // Assert 186 | expect(result.count).toEqual(expected.count); 187 | expect(result['changesMap']).toEqual(expected.changesMap); 188 | }); 189 | }); 190 | 191 | //#endregion Tests 192 | }); -------------------------------------------------------------------------------- /test/kustoChartHelper.test.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { DraftColumnType, ChartType, IQueryResultData, ISupportedColumns, IColumn, ColumnsSelection } from '../src/common/chartModels'; 4 | import { KustoChartHelper } from '../src/common/kustoChartHelper'; 5 | import { VisualizerMock } from './mocks/visualizerMock'; 6 | 7 | describe('Unit tests for KustoChartHelper', () => { 8 | //#region Private members 9 | 10 | let kustoChartHelper: KustoChartHelper; 11 | const dateTimeColumn: IColumn = { name: 'dateTime', type: DraftColumnType.DateTime }; 12 | const countryStrColumn: IColumn = { name: 'country', type: DraftColumnType.String }; 13 | const cityStrColumn: IColumn = { name: 'city', type: DraftColumnType.String }; 14 | const countDecimalColumn: IColumn = { name: 'count', type: DraftColumnType.Decimal }; 15 | const secondCountIntColumn: IColumn = { name: 'secondCount', type: DraftColumnType.Int }; 16 | const thirdCountIntColumn: IColumn = { name: 'thirdCount', type: DraftColumnType.Int }; 17 | const queryResultData: IQueryResultData = { 18 | rows: [], 19 | columns: [ 20 | countDecimalColumn, 21 | dateTimeColumn, 22 | secondCountIntColumn, 23 | thirdCountIntColumn, 24 | cityStrColumn, 25 | countryStrColumn 26 | ] 27 | }; 28 | 29 | //#endregion Private members 30 | 31 | //#region Generate mocks and defaults 32 | 33 | beforeEach(() => { 34 | kustoChartHelper = new KustoChartHelper('dummy-element-id', new VisualizerMock()); 35 | }); 36 | 37 | //#endregion Generate mocks and defaults 38 | 39 | //#region Tests 40 | 41 | describe('Validate getDefaultSelection method', () => { 42 | it("When there are supported columns for x, y, and splitBy - they are selected as expected", () => { 43 | const supportedColumnsForChart: ISupportedColumns = { 44 | xAxis: [dateTimeColumn, countryStrColumn, secondCountIntColumn], 45 | yAxis: [secondCountIntColumn], 46 | splitBy: [countryStrColumn] 47 | }; 48 | 49 | const expectedResult: ColumnsSelection = { 50 | xAxis: dateTimeColumn, 51 | yAxes: [secondCountIntColumn], 52 | splitBy: [countryStrColumn] 53 | } 54 | 55 | // Act 56 | const result = kustoChartHelper.getDefaultSelection(queryResultData, ChartType.StackedColumn, supportedColumnsForChart); 57 | 58 | // Assert 59 | expect(result).toEqual(expectedResult); 60 | }); 61 | 62 | it("When there is intersection between supported columns for x and y - they are selected as expected", () => { 63 | const supportedColumnsForChart: ISupportedColumns = { 64 | xAxis: [countDecimalColumn, countryStrColumn, secondCountIntColumn, cityStrColumn], 65 | yAxis: [countDecimalColumn, secondCountIntColumn], 66 | splitBy: [countryStrColumn, cityStrColumn] 67 | }; 68 | 69 | const expectedResult: ColumnsSelection = { 70 | xAxis: countDecimalColumn, 71 | yAxes: [secondCountIntColumn], 72 | splitBy: [countryStrColumn] 73 | } 74 | 75 | // Act 76 | const result = kustoChartHelper.getDefaultSelection(queryResultData, ChartType.StackedColumn, supportedColumnsForChart); 77 | 78 | // Assert 79 | expect(result).toEqual(expectedResult); 80 | }); 81 | 82 | it("When there are multiple columns for y and no split-by column - there is multi y-axis selection", () => { 83 | const supportedColumnsForChart: ISupportedColumns = { 84 | xAxis: [countDecimalColumn, dateTimeColumn, secondCountIntColumn, thirdCountIntColumn], 85 | yAxis: [secondCountIntColumn, countDecimalColumn, thirdCountIntColumn], 86 | splitBy: [] 87 | }; 88 | 89 | const expectedResult: ColumnsSelection = { 90 | xAxis: dateTimeColumn, 91 | yAxes: [secondCountIntColumn, countDecimalColumn, thirdCountIntColumn], 92 | splitBy: null 93 | } 94 | 95 | // Act 96 | const result = kustoChartHelper.getDefaultSelection(queryResultData, ChartType.StackedColumn, supportedColumnsForChart); 97 | 98 | // Assert 99 | expect(result).toEqual(expectedResult); 100 | }); 101 | 102 | it("When there are multiple columns for y and split-by columns - there is single y-axes selection", () => { 103 | const supportedColumnsForChart: ISupportedColumns = { 104 | xAxis: [cityStrColumn, countDecimalColumn, dateTimeColumn, secondCountIntColumn, thirdCountIntColumn], 105 | yAxis: [secondCountIntColumn, countDecimalColumn, thirdCountIntColumn], 106 | splitBy: [cityStrColumn] 107 | }; 108 | 109 | const expectedResult: ColumnsSelection = { 110 | xAxis: dateTimeColumn, 111 | yAxes: [secondCountIntColumn], 112 | splitBy: [cityStrColumn] 113 | } 114 | 115 | // Act 116 | const result = kustoChartHelper.getDefaultSelection(queryResultData, ChartType.StackedColumn, supportedColumnsForChart); 117 | 118 | // Assert 119 | expect(result).toEqual(expectedResult); 120 | }); 121 | 122 | it("When there is only 1 supported column for y - it's selected", () => { 123 | const queryResultData: IQueryResultData = { 124 | rows: [], 125 | columns: [ 126 | countDecimalColumn, 127 | cityStrColumn 128 | ] 129 | } 130 | 131 | const supportedColumnsForChart: ISupportedColumns = { 132 | xAxis: [countDecimalColumn, cityStrColumn], 133 | yAxis: [countDecimalColumn], 134 | splitBy: [cityStrColumn] 135 | }; 136 | 137 | const expectedResult: ColumnsSelection = { 138 | xAxis: cityStrColumn, 139 | yAxes: [countDecimalColumn], 140 | splitBy: null 141 | } 142 | 143 | // Act 144 | const result = kustoChartHelper.getDefaultSelection(queryResultData, ChartType.StackedColumn, supportedColumnsForChart); 145 | 146 | // Assert 147 | expect(result).toEqual(expectedResult); 148 | }); 149 | 150 | it("When there is no enough supported columns for y axis - there is no selection", () => { 151 | const supportedColumnsForChart: ISupportedColumns = { 152 | xAxis: [countDecimalColumn], 153 | yAxis: [], 154 | splitBy: [] 155 | }; 156 | 157 | const expectedResult: ColumnsSelection = new ColumnsSelection(); // Empty selection 158 | 159 | // Act 160 | const result = kustoChartHelper.getDefaultSelection(queryResultData, ChartType.StackedColumn, supportedColumnsForChart); 161 | 162 | // Assert 163 | expect(result).toEqual(expectedResult); 164 | }); 165 | 166 | it("When there is no enough supported columns for x axis - there is no selection", () => { 167 | const supportedColumnsForChart: ISupportedColumns = { 168 | xAxis: [], 169 | yAxis: [countDecimalColumn], 170 | splitBy: [] 171 | }; 172 | 173 | const expectedResult: ColumnsSelection = new ColumnsSelection(); // Empty selection 174 | 175 | // Act 176 | const result = kustoChartHelper.getDefaultSelection(queryResultData, ChartType.StackedColumn, supportedColumnsForChart); 177 | 178 | // Assert 179 | expect(result).toEqual(expectedResult); 180 | }); 181 | 182 | it("When the chart type is pie / donut, there is single y selection, and no split-by selection", () => { 183 | const supportedColumnsForChart: ISupportedColumns = { 184 | xAxis: [countryStrColumn, cityStrColumn], 185 | yAxis: [secondCountIntColumn, countDecimalColumn, thirdCountIntColumn], 186 | splitBy: [countryStrColumn, cityStrColumn] 187 | }; 188 | 189 | const expectedResult: ColumnsSelection = { 190 | xAxis: countryStrColumn, 191 | yAxes: [secondCountIntColumn], 192 | splitBy: null 193 | } 194 | 195 | // Act 196 | const result = kustoChartHelper.getDefaultSelection(queryResultData, ChartType.Donut, supportedColumnsForChart); 197 | 198 | // Assert 199 | expect(result).toEqual(expectedResult); 200 | }); 201 | }); 202 | 203 | describe('Validate transformQueryResultData method', () => { 204 | it("When the columns selection input is valid, the query result transformed as expected", () => { 205 | // Input 206 | const queryResultData = { 207 | rows: [ 208 | ['Israel', '1988-06-26T00:00:00Z', 'Jerusalem', 500], 209 | ['Israel', '2000-06-26T00:00:00Z', 'Herzliya', 1000], 210 | ['United States', '2000-06-26T00:00:00Z', 'Boston', 200], 211 | ['Israel', '2000-06-26T00:00:00Z', 'Tel Aviv', 10], 212 | ['United States', '2000-06-26T00:00:00Z', 'New York', 100], 213 | ['Japan', '2019-05-25T00:00:00Z', 'Tokyo', 20], 214 | ['United States', '2019-05-25T00:00:00Z', 'Atlanta', 300], 215 | ['United States', '2019-05-25T00:00:00Z', 'Redmond', 20] 216 | ], 217 | columns: [ 218 | { name: 'country', type: DraftColumnType.String }, 219 | { name: 'date', type: DraftColumnType.DateTime }, 220 | { name: 'city', type: DraftColumnType.String }, 221 | { name: 'request_count', type: DraftColumnType.Int }, 222 | ] 223 | }; 224 | 225 | const chartOptions = { 226 | columnsSelection: { 227 | xAxis: queryResultData.columns[1], 228 | yAxes: [queryResultData.columns[3]] 229 | } 230 | }; 231 | 232 | // Act 233 | const result = kustoChartHelper['transformQueryResultData'](queryResultData, chartOptions); 234 | const aggregatedRows = [ 235 | ["1988-06-26T00:00:00Z", 500], 236 | ["2000-06-26T00:00:00Z", 1310], 237 | ["2019-05-25T00:00:00Z", 340] 238 | ]; 239 | 240 | const expected = { 241 | data: { 242 | rows: aggregatedRows, 243 | columns: [queryResultData.columns[1], queryResultData.columns[3]] 244 | }, 245 | limitedResults: { 246 | rows: aggregatedRows, 247 | isAggregationApplied: true, 248 | isPartialData: false 249 | } 250 | }; 251 | 252 | // Assert 253 | expect(result).toEqual(expected); 254 | }); 255 | 256 | it("When the columns selection and query results both numeric, but different types - the input is valid, the query result transformed as expected", () => { 257 | // Input 258 | const queryResultData = { 259 | rows: [ 260 | ['Israel', '2000-05-24T00:00:00Z', 100, 10], 261 | ['United States', '2000-05-25T00:00:00Z', 80, 8], 262 | ['Japan', '2019-05-26T00:00:00Z', 20, 2] 263 | ], 264 | columns: [ 265 | { name: 'country', type: DraftColumnType.String }, 266 | { name: 'date', type: DraftColumnType.DateTime }, 267 | { name: 'request_count', type: DraftColumnType.Int }, 268 | { name: 'second_count', type: DraftColumnType.Real }, 269 | ] 270 | }; 271 | 272 | const chartOptions = { 273 | columnsSelection: { 274 | xAxis: queryResultData.columns[1], 275 | yAxes: [{ name: 'request_count', type: DraftColumnType.Decimal }, { name: 'second_count', type: DraftColumnType.Long }] 276 | } 277 | }; 278 | 279 | // Act 280 | const result = kustoChartHelper['transformQueryResultData'](queryResultData, chartOptions); 281 | const aggregatedRows = [ 282 | ['2000-05-24T00:00:00Z', 100, 10], 283 | ['2000-05-25T00:00:00Z', 80, 8], 284 | ['2019-05-26T00:00:00Z', 20, 2] 285 | ]; 286 | 287 | const expected = { 288 | data: { 289 | rows: aggregatedRows, 290 | columns: [queryResultData.columns[1], queryResultData.columns[2], queryResultData.columns[3]] 291 | }, 292 | limitedResults: { 293 | rows: aggregatedRows, 294 | isAggregationApplied: false, 295 | isPartialData: false 296 | } 297 | }; 298 | 299 | // Assert 300 | expect(result).toEqual(expected); 301 | }); 302 | 303 | it("When the x-axis columns selection input is invalid", () => { 304 | // Input 305 | const queryResultData = { 306 | rows: [], 307 | columns: [ 308 | { name: 'date', type: DraftColumnType.DateTime }, 309 | { name: 'city', type: DraftColumnType.String }, 310 | { name: 'request_count', type: DraftColumnType.Int }, 311 | ] 312 | }; 313 | 314 | const chartOptions = { 315 | columnsSelection: { 316 | xAxis: { name: 'date', type: 'TimeSpan' }, 317 | yAxes: [{ name: 'request_count', type: DraftColumnType.Int }], 318 | } 319 | }; 320 | 321 | // Act 322 | let errorMessage; 323 | 324 | try { 325 | kustoChartHelper['transformQueryResultData'](queryResultData, chartOptions); 326 | } catch(err) { 327 | errorMessage = err.message; 328 | } 329 | 330 | const expected: string = 331 | `One or more of the selected x-axis columns don't exist in the query result data: 332 | name = 'date' type = 'TimeSpan' 333 | columns in query data: 334 | name = 'date' type = 'datetime', name = 'city' type = 'string', name = 'request_count' type = 'int'`; 335 | 336 | // Assert 337 | expect(errorMessage).toEqual(expected); 338 | }); 339 | 340 | it("When the y-axes columns selection input is invalid", () => { 341 | // Input 342 | const queryResultData = { 343 | rows: [], 344 | columns: [ 345 | { name: 'date', type: DraftColumnType.DateTime }, 346 | { name: 'duration', type: DraftColumnType.Long }, 347 | { name: 'request_count', type: DraftColumnType.Int }, 348 | ] 349 | }; 350 | 351 | const chartOptions = { 352 | columnsSelection: { 353 | xAxis: { name: 'date', type: DraftColumnType.DateTime }, 354 | yAxes: [{ name: 'duration', type: 'Date' }, { name: 'count', type: DraftColumnType.Int }] 355 | } 356 | }; 357 | 358 | // Act 359 | let errorMessage; 360 | 361 | try { 362 | kustoChartHelper['transformQueryResultData'](queryResultData, chartOptions); 363 | } catch(err) { 364 | errorMessage = err.message; 365 | } 366 | 367 | const expected: string = 368 | `One or more of the selected y-axes columns don't exist in the query result data: 369 | name = 'duration' type = 'Date', name = 'count' type = 'int' 370 | columns in query data: 371 | name = 'date' type = 'datetime', name = 'duration' type = 'long', name = 'request_count' type = 'int'`; 372 | 373 | // Assert 374 | expect(errorMessage).toEqual(expected); 375 | }); 376 | 377 | it("When the split-by columns selection input is invalid", () => { 378 | // Input 379 | const queryResultData = { 380 | rows: [], 381 | columns: [ 382 | { name: 'date', type: DraftColumnType.DateTime }, 383 | { name: 'duration', type: DraftColumnType.Long }, 384 | { name: 'request_count', type: DraftColumnType.Int }, 385 | { name: 'city', type: DraftColumnType.String }, 386 | { name: 'country', type: DraftColumnType.String }, 387 | ] 388 | }; 389 | 390 | const chartOptions = { 391 | columnsSelection: { 392 | xAxis: { name: 'date', type: DraftColumnType.DateTime }, 393 | yAxes: [{ name: 'duration', type: DraftColumnType.Long }, { name: 'request_count', type: DraftColumnType.Int }], 394 | splitBy: [{ name: 'country', type: DraftColumnType.String }, { name: 'city', type: DraftColumnType.Int }], 395 | } 396 | }; 397 | 398 | // Act 399 | let errorMessage; 400 | 401 | try { 402 | kustoChartHelper['transformQueryResultData'](queryResultData, chartOptions); 403 | } catch(err) { 404 | errorMessage = err.message; 405 | } 406 | 407 | const expected: string = 408 | `One or more of the selected split-by columns don't exist in the query result data: 409 | name = 'city' type = 'int' 410 | columns in query data: 411 | name = 'date' type = 'datetime', name = 'duration' type = 'long', name = 'request_count' type = 'int', name = 'city' type = 'string', name = 'country' type = 'string'`; 412 | 413 | // Assert 414 | expect(errorMessage).toEqual(expected); 415 | }); 416 | }); 417 | 418 | //#endregion Tests 419 | }); -------------------------------------------------------------------------------- /test/mocks/visualizerMock.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { IVisualizer } from "../../src/visualizers/IVisualizer"; 4 | import { IVisualizerOptions } from "../../src/visualizers/IVisualizerOptions"; 5 | import { Changes } from "../../src/common/chartChange"; 6 | import { ChartTheme } from "../../src/common/chartModels"; 7 | 8 | //#region Imports 9 | 10 | //#endregion Imports 11 | 12 | export class VisualizerMock implements IVisualizer { 13 | public drawNewChart(options: IVisualizerOptions): Promise { 14 | return new Promise((resolve, reject) => { 15 | resolve(); 16 | }); 17 | } 18 | 19 | public updateExistingChart(options: IVisualizerOptions, changes: Changes): Promise { 20 | return new Promise((resolve, reject) => { 21 | resolve(); 22 | }); 23 | } 24 | 25 | public changeTheme(newTheme: ChartTheme): Promise { 26 | return new Promise((resolve, reject) => { 27 | resolve(); 28 | }); 29 | } 30 | 31 | public downloadChartJPGImage(onError?: (error: Error) => void): void { 32 | // Do nothing 33 | } 34 | } -------------------------------------------------------------------------------- /test/utilities.test.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Utilities } from '../src/common/utilities'; 4 | import { DraftColumnType } from '../src/common/chartModels'; 5 | 6 | describe('Unit tests for Utilities', () => { 7 | //#region Tests 8 | 9 | describe('Validate getColumnIndex method', () => { 10 | const columns = [ 11 | { name: 'country', type: DraftColumnType.String }, 12 | { name: 'country', type: DraftColumnType.Long }, 13 | { name: 'percentage', type: DraftColumnType.Decimal }, 14 | { name: 'request_count', type: DraftColumnType.Int }, 15 | ]; 16 | 17 | it("Validate getColumnIndex: column doesn't exist - incompatible types", () => { 18 | const queryResultData = { 19 | rows: [], 20 | columns: columns 21 | } 22 | 23 | const columnToFind = { name: 'country', type: DraftColumnType.Guid }; 24 | 25 | // Act 26 | const result = Utilities.getColumnIndex(queryResultData, columnToFind); 27 | 28 | // Assert 29 | expect(result).toEqual(-1); 30 | }); 31 | 32 | it("Validate getColumnIndex: column doesn't exist - incompatible names", () => { 33 | const queryResultData = { 34 | rows: [], 35 | columns: columns 36 | } 37 | 38 | const columnToFind = { name: 'country_', type: DraftColumnType.String }; 39 | 40 | // Act 41 | const result = Utilities.getColumnIndex(queryResultData, columnToFind); 42 | 43 | // Assert 44 | expect(result).toEqual(-1); 45 | }); 46 | 47 | it("Validate getColumnIndex: column doesn't exist - incompatible type and name", () => { 48 | const queryResultData = { 49 | rows: [], 50 | columns: columns 51 | } 52 | 53 | const columnToFind = { name: 'country_', type: DraftColumnType.Guid }; 54 | 55 | // Act 56 | const result = Utilities.getColumnIndex(queryResultData, columnToFind); 57 | 58 | // Assert 59 | expect(result).toEqual(-1); 60 | }); 61 | 62 | it("Validate getColumnIndex: column exists when there are two columns with the same name", () => { 63 | const queryResultData = { 64 | rows: [], 65 | columns: columns 66 | } 67 | 68 | const columnToFind = { name: 'country', type: DraftColumnType.Long }; 69 | 70 | // Act 71 | const result = Utilities.getColumnIndex(queryResultData, columnToFind); 72 | 73 | // Assert 74 | expect(result).toEqual(1); 75 | }); 76 | 77 | it("Validate getColumnIndex: column exists", () => { 78 | const queryResultData = { 79 | rows: [], 80 | columns: columns 81 | } 82 | 83 | const columnToFind = { name: 'request_count', type: DraftColumnType.Int }; 84 | 85 | // Act 86 | const result = Utilities.getColumnIndex(queryResultData, columnToFind); 87 | 88 | // Assert 89 | expect(result).toEqual(3); 90 | }); 91 | }); 92 | 93 | //#endregion Tests 94 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ "es2018", "dom" ], 4 | "target": "es5", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "declaration": true, 8 | "rootDir": ".", 9 | "outDir": "./dist", 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "noImplicitAny": false, 13 | "removeComments": false, 14 | "noEmitOnError": true, 15 | "skipLibCheck": true, 16 | "typeRoots": [ "node_modules/@types", "definitelyTyped" ], 17 | "sourceMap": true 18 | } 19 | } --------------------------------------------------------------------------------