From 86037e28643a3382db3059971d76c357bb3de77b Mon Sep 17 00:00:00 2001 From: Daniel Jilg Date: Thu, 16 Jan 2025 15:00:10 +0100 Subject: [PATCH] Add options to configure the chart in the query --- .../ChartAggregationConfiguration.swift | 28 ++++ .../ChartConfiguration.swift | 42 +++++ .../ChartConfigurationOptions.swift | 145 ++++++++++++++++++ .../Query/CustomQuery.swift | 7 + .../ChartAggregtionConfigurationTests.swift | 38 +++++ .../QueryTests/ChartConfigurationTests.swift | 40 +++++ Tests/QueryTests/ChartOptionsTests.swift | 114 ++++++++++++++ Tests/QueryTests/CustomQueryTests.swift | 28 ++++ 8 files changed, 442 insertions(+) create mode 100644 Sources/DataTransferObjects/Chart Configuration/ChartAggregationConfiguration.swift create mode 100644 Sources/DataTransferObjects/Chart Configuration/ChartConfiguration.swift create mode 100644 Sources/DataTransferObjects/Chart Configuration/ChartConfigurationOptions.swift create mode 100644 Tests/QueryTests/ChartAggregtionConfigurationTests.swift create mode 100644 Tests/QueryTests/ChartConfigurationTests.swift create mode 100644 Tests/QueryTests/ChartOptionsTests.swift diff --git a/Sources/DataTransferObjects/Chart Configuration/ChartAggregationConfiguration.swift b/Sources/DataTransferObjects/Chart Configuration/ChartAggregationConfiguration.swift new file mode 100644 index 0000000..1b66e99 --- /dev/null +++ b/Sources/DataTransferObjects/Chart Configuration/ChartAggregationConfiguration.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Configuration for aggregations in a chart. +/// +/// Maps to "seriesConfiguration" internally in the charting library. +/// +/// Subset of https://echarts.apache.org/en/option.html#series-line +public struct ChartAggregationConfiguration: Codable, Equatable { + public var startAngle: Int? + public var endAngle: Int? + public var radius: [String]? + public var center: [String]? + public var stack: String? + + public init( + startAngle: Int? = nil, + endAngle: Int? = nil, + radius: [String]? = nil, + center: [String]? = nil, + stack: String? = nil + ) { + self.startAngle = startAngle + self.endAngle = endAngle + self.radius = radius + self.center = center + self.stack = stack + } +} diff --git a/Sources/DataTransferObjects/Chart Configuration/ChartConfiguration.swift b/Sources/DataTransferObjects/Chart Configuration/ChartConfiguration.swift new file mode 100644 index 0000000..73831ed --- /dev/null +++ b/Sources/DataTransferObjects/Chart Configuration/ChartConfiguration.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Display configuration for charts. Overrides various default display options. +/// +/// Not hashable, because we don't want to include these values in the cache, as cached calculation results won't change based on these values. +public struct ChartConfiguration: Codable, Equatable { + /// The display mode for the chart. + public var displayMode: ChartDisplayMode? + + /// Enable dark mode for this chart + public var darkMode: Bool? + + /// Global chart settings + public var options: ChartConfigurationOptions? + + /// Applied to every single aggregation and post-aggregation in the chart + public var aggregationConfiguration: ChartAggregationConfiguration? + + public init( + displayMode: ChartDisplayMode? = nil, + darkMode: Bool? = nil, + options: ChartConfigurationOptions? = nil, + aggregationConfiguration: ChartAggregationConfiguration? = nil + ) { + self.displayMode = displayMode + self.darkMode = darkMode + self.options = options + self.aggregationConfiguration = aggregationConfiguration + } +} + +public enum ChartDisplayMode: String, Codable { + case raw + case barChart + case lineChart + case pieChart + case funnelChart + case experimentChart + case matrix + case sankey + case lineChartRace +} diff --git a/Sources/DataTransferObjects/Chart Configuration/ChartConfigurationOptions.swift b/Sources/DataTransferObjects/Chart Configuration/ChartConfigurationOptions.swift new file mode 100644 index 0000000..21523d4 --- /dev/null +++ b/Sources/DataTransferObjects/Chart Configuration/ChartConfigurationOptions.swift @@ -0,0 +1,145 @@ +import Foundation + +/// Options for configuring a chart in our charting library +/// +/// Subset of echart's options https://echarts.apache.org/en/option.html +public struct ChartConfigurationOptions: Codable, Equatable { + /// Whether to enable animation. + public var animation: Bool? + + /// Duration of the animation in milliseconds. + public var animationDuration: Int? + + /// Easing function of the animation. + public var animationEasing: EasingFunction? + + /// Show a tooltip for this chart + public var tooltip: ToolTipConfiguration? + + public var grid: GridConfiguration? + + public var xAxis: AxisOptions? + public var yAxis: AxisOptions? + + public init( + animation: Bool? = nil, + animationDuration: Int? = nil, + animationEasing: EasingFunction? = nil, + tooltip: ToolTipConfiguration? = nil, + grid: GridConfiguration? = nil, + xAxis: AxisOptions? = nil, + yAxis: AxisOptions? = nil + ) { + self.animation = animation + self.animationDuration = animationDuration + self.animationEasing = animationEasing + self.tooltip = tooltip + self.grid = grid + self.xAxis = xAxis + self.yAxis = yAxis + } +} + +public struct ToolTipConfiguration: Codable, Equatable { + public var show: Bool? + + public init(show: Bool? = nil) { + self.show = show + } +} + +public struct GridConfiguration: Codable, Equatable { + public var top: Int? + public var bottom: Int? + public var left: Int? + public var right: Int? + public var containLabel: Bool? + + public init( + top: Int? = nil, + bottom: Int? = nil, + left: Int? = nil, + right: Int? = nil, + containLabel: Bool? = nil + ) { + self.top = top + self.bottom = bottom + self.left = left + self.right = right + self.containLabel = containLabel + } +} + +public enum EasingFunction: String, Codable { + case linear + case quadraticIn + case quadraticOut + case quadraticInOut + case cubicIn + case cubicOut + case cubicInOut + case quarticIn + case quarticOut + case quarticInOut + case quinticIn + case quinticOut + case quinticInOut + case sinusoidalIn + case sinusoidalOut + case sinusoidalInOut + case exponentialIn + case exponentialOut + case exponentialInOut + case circularIn + case circularOut + case circularInOut + case elasticIn + case elasticOut + case elasticInOut + case backIn + case backOut + case backInOut + case bounceIn + case bounceOut + case bounceInOut +} + +public struct AxisOptions: Codable, Equatable { + /// Set this to false to prevent the axis from showing. + public var show: Bool? + public var position: Position? + public var type: AxisType? + public var name: String? + /// Set this to true to invert the axis. + public var inverse: Bool? + + public init( + show: Bool? = nil, + position: AxisOptions.Position? = nil, + type: AxisOptions.AxisType? = nil, + name: String? = nil, + inverse: Bool? = nil + ) { + self.show = show + self.position = position + self.type = type + self.name = name + self.inverse = inverse + } + + public enum Position: String, Codable, Equatable { + case top + case bottom + } + + public enum AxisType: String, Codable, Equatable { + /// Numerical axis, suitable for continuous data. + case value + /// Category axis, suitable for discrete category data. + case category + /// Time axis, suitable for continuous time series data. As compared to value axis, it has a better formatting for time and a different tick calculation method. For example, it decides to use month, week, day or hour for tick based on the range of span. + case time + /// Log axis, suitable for log data. Stacked bar or line series with type: 'log' axes may lead to significant visual errors and may have unintended effects in certain circumstances. Their use should be avoided. + case log + } +} diff --git a/Sources/DataTransferObjects/Query/CustomQuery.swift b/Sources/DataTransferObjects/Query/CustomQuery.swift index 24e9ded..ad45d89 100644 --- a/Sources/DataTransferObjects/Query/CustomQuery.swift +++ b/Sources/DataTransferObjects/Query/CustomQuery.swift @@ -23,6 +23,7 @@ public struct CustomQuery: Codable, Hashable, Equatable { postAggregations: [PostAggregator]? = nil, limit: Int? = nil, context: QueryContext? = nil, + chartConfiguration: ChartConfiguration? = nil, valueFormatter: ValueFormatter? = nil, threshold: Int? = nil, metric: TopNMetricSpec? = nil, @@ -58,6 +59,7 @@ public struct CustomQuery: Codable, Hashable, Equatable { self.postAggregations = postAggregations self.limit = limit self.context = context + self.chartConfiguration = chartConfiguration self.valueFormatter = valueFormatter self.threshold = threshold self.metric = metric @@ -91,6 +93,7 @@ public struct CustomQuery: Codable, Hashable, Equatable { postAggregations: [PostAggregator]? = nil, limit: Int? = nil, context: QueryContext? = nil, + chartConfiguration: ChartConfiguration? = nil, valueFormatter: ValueFormatter? = nil, threshold: Int? = nil, metric: TopNMetricSpec? = nil, @@ -122,6 +125,7 @@ public struct CustomQuery: Codable, Hashable, Equatable { self.postAggregations = postAggregations self.limit = limit self.context = context + self.chartConfiguration = chartConfiguration self.valueFormatter = valueFormatter self.threshold = threshold self.metric = metric @@ -194,6 +198,7 @@ public struct CustomQuery: Codable, Hashable, Equatable { public var postAggregations: [PostAggregator]? public var limit: Int? public var context: QueryContext? + public var chartConfiguration: ChartConfiguration? public var valueFormatter: ValueFormatter? /// Only for topN Queries: An integer defining the N in the topN (i.e. how many results you want in the top list) @@ -257,6 +262,7 @@ public struct CustomQuery: Codable, Hashable, Equatable { hasher.combine(postAggregations) hasher.combine(limit) hasher.combine(context) + // chartConfiguration deliberately not included in hash, because we don't want to invalidate caches based on chart configuration hasher.combine(valueFormatter) hasher.combine(threshold) hasher.combine(metric) @@ -294,6 +300,7 @@ public struct CustomQuery: Codable, Hashable, Equatable { postAggregations = try container.decodeIfPresent([PostAggregator].self, forKey: CustomQuery.CodingKeys.postAggregations) limit = try container.decodeIfPresent(Int.self, forKey: CustomQuery.CodingKeys.limit) context = try container.decodeIfPresent(QueryContext.self, forKey: CustomQuery.CodingKeys.context) + chartConfiguration = try container.decodeIfPresent(ChartConfiguration.self, forKey: CustomQuery.CodingKeys.chartConfiguration) valueFormatter = try container.decodeIfPresent(ValueFormatter.self, forKey: CustomQuery.CodingKeys.valueFormatter) threshold = try container.decodeIfPresent(Int.self, forKey: CustomQuery.CodingKeys.threshold) dimension = try container.decodeIfPresent(DimensionSpec.self, forKey: CustomQuery.CodingKeys.dimension) diff --git a/Tests/QueryTests/ChartAggregtionConfigurationTests.swift b/Tests/QueryTests/ChartAggregtionConfigurationTests.swift new file mode 100644 index 0000000..889faca --- /dev/null +++ b/Tests/QueryTests/ChartAggregtionConfigurationTests.swift @@ -0,0 +1,38 @@ +// +// HavingTests.swift +// DataTransferObjects +// +// Created by Daniel Jilg on 09.01.25. +// + +import DataTransferObjects +import XCTest + +class ChartAggregtionConfigurationTests: XCTestCase { + func testChartAggregationConfiguration() throws { + let aggregationConfiguration = ChartAggregationConfiguration( + startAngle: 12, + endAngle: 13, + radius: ["12%", "50%"], + center: ["0%", "12%"], + stack: "hello" + ) + + let encodedAggregationConfiguration = """ + { + "center": ["0%", "12%"], + "endAngle": 13, + "radius": ["12%", "50%"], + "stack": "hello", + "startAngle": 12 + } + """ + .filter { !$0.isWhitespace } + + let encoded = try JSONEncoder.telemetryEncoder.encode(aggregationConfiguration) + XCTAssertEqual(String(data: encoded, encoding: .utf8)!, encodedAggregationConfiguration) + + let decoded = try JSONDecoder.telemetryDecoder.decode(ChartAggregationConfiguration.self, from: encoded) + XCTAssertEqual(aggregationConfiguration, decoded) + } +} diff --git a/Tests/QueryTests/ChartConfigurationTests.swift b/Tests/QueryTests/ChartConfigurationTests.swift new file mode 100644 index 0000000..743e1d3 --- /dev/null +++ b/Tests/QueryTests/ChartConfigurationTests.swift @@ -0,0 +1,40 @@ +// +// HavingTests.swift +// DataTransferObjects +// +// Created by Daniel Jilg on 09.01.25. +// + +import DataTransferObjects +import XCTest + +class ChartConfigurationTests: XCTestCase { + func testChartConfiguration() throws { + let chartConfiguration = ChartConfiguration( + displayMode: .barChart, + darkMode: false, + options: .init(animation: false), + aggregationConfiguration: .init(stack: "hello") + ) + + let encodedChartConfiguration = """ + { + "aggregationConfiguration": { + "stack": "hello" + }, + "darkMode": false, + "displayMode": "barChart", + "options": { + "animation": false + } + } + """ + .filter { !$0.isWhitespace } + + let encoded = try JSONEncoder.telemetryEncoder.encode(chartConfiguration) + XCTAssertEqual(String(data: encoded, encoding: .utf8)!, encodedChartConfiguration) + + let decoded = try JSONDecoder.telemetryDecoder.decode(ChartConfiguration.self, from: encoded) + XCTAssertEqual(chartConfiguration, decoded) + } +} diff --git a/Tests/QueryTests/ChartOptionsTests.swift b/Tests/QueryTests/ChartOptionsTests.swift new file mode 100644 index 0000000..22b43e2 --- /dev/null +++ b/Tests/QueryTests/ChartOptionsTests.swift @@ -0,0 +1,114 @@ +// +// HavingTests.swift +// DataTransferObjects +// +// Created by Daniel Jilg on 09.01.25. +// + +import DataTransferObjects +import XCTest + +class ChartOptionsTests: XCTestCase { + func testAxiOptions() throws { + let axisOptions = AxisOptions(show: false, position: .bottom, type: .time, name: "testAxis", inverse: false) + + let encodedAxisOptions = """ + { + "inverse": false, + "name": "testAxis", + "position": "bottom", + "show": false, + "type": "time" + } + """ + .filter { !$0.isWhitespace } + + let encoded = try JSONEncoder.telemetryEncoder.encode(axisOptions) + XCTAssertEqual(String(data: encoded, encoding: .utf8)!, encodedAxisOptions) + + let decoded = try JSONDecoder.telemetryDecoder.decode(AxisOptions.self, from: encoded) + XCTAssertEqual(axisOptions, decoded) + } + + func testGridConfiguration() throws { + let gridConfiguration = GridConfiguration(top: 12, bottom: 13, left: 14, right: 15, containLabel: false) + + let encodedGridConfiguration = """ + { + "bottom": 13, + "containLabel": false, + "left": 14, + "right": 15, + "top": 12 + } + """ + .filter { !$0.isWhitespace } + + let encoded = try JSONEncoder.telemetryEncoder.encode(gridConfiguration) + XCTAssertEqual(String(data: encoded, encoding: .utf8)!, encodedGridConfiguration) + + let decoded = try JSONDecoder.telemetryDecoder.decode(GridConfiguration.self, from: encoded) + XCTAssertEqual(gridConfiguration, decoded) + } + + func testTooltipConfiguration() throws { + let tooltipConfiguration = ToolTipConfiguration(show: true) + + let encodedTooltipConfiguration = """ + { + "show": true + } + """ + .filter { !$0.isWhitespace } + + let encoded = try JSONEncoder.telemetryEncoder.encode(tooltipConfiguration) + XCTAssertEqual(String(data: encoded, encoding: .utf8)!, encodedTooltipConfiguration) + + let decoded = try JSONDecoder.telemetryDecoder.decode(ToolTipConfiguration.self, from: encoded) + XCTAssertEqual(tooltipConfiguration, decoded) + } + + func testChartConfigurationOptions() throws { + let chartConfigurationOptions = ChartConfigurationOptions( + animation: true, + animationDuration: 2500, + animationEasing: .cubicInOut, + tooltip: .init(show: false), + grid: .init(top: 12, bottom: 13, left: 14, right: 15, containLabel: true), + xAxis: .init(show: true, position: .bottom, type: .category, name: "test", inverse: false), + yAxis: nil + ) + + let encodedChartConfigurationOptions = """ + { + "animation": true, + "animationDuration": 2500, + "animationEasing": "cubicInOut", + "grid": { + "bottom": 13, + "containLabel": true, + "left": 14, + "right": 15, + "top": 12 + }, + "tooltip": { + "show": false + }, + "xAxis": { + "inverse": false, + "name": "test", + "position": "bottom", + "show": true, + "type": "category" + } + } + """ + .filter { !$0.isWhitespace } + + let encoded = try JSONEncoder.telemetryEncoder.encode(chartConfigurationOptions) + XCTAssertEqual(String(data: encoded, encoding: .utf8)!, encodedChartConfigurationOptions) + + let decoded = try JSONDecoder.telemetryDecoder.decode(ChartConfigurationOptions.self, from: encoded) + XCTAssertEqual(chartConfigurationOptions, decoded) + } +} diff --git a/Tests/QueryTests/CustomQueryTests.swift b/Tests/QueryTests/CustomQueryTests.swift index 0b43ae2..14b0c6b 100644 --- a/Tests/QueryTests/CustomQueryTests.swift +++ b/Tests/QueryTests/CustomQueryTests.swift @@ -437,4 +437,32 @@ final class CustomQueryTests: XCTestCase { XCTAssertEqual(expectedOutput, String(data: encodedOutput, encoding: .utf8)!) } + + func testChartConfiguration() throws { + let customQuery = CustomQuery( + queryType: .timeseries, + dataSource: "telemetry-signals", + granularity: .all, + chartConfiguration: .init(displayMode: .barChart, darkMode: false) + ) + + let encodedCustomQuery = """ + { + "chartConfiguration": { + "darkMode": false, + "displayMode": "barChart" + }, + "dataSource": "telemetry-signals", + "granularity": "all", + "queryType": "timeseries" + } + """ + .filter { !$0.isWhitespace } + + let encoded = try JSONEncoder.telemetryEncoder.encode(customQuery) + XCTAssertEqual(String(data: encoded, encoding: .utf8)!, encodedCustomQuery) + + let decoded = try JSONDecoder.telemetryDecoder.decode(CustomQuery.self, from: encoded) + XCTAssertEqual(customQuery, decoded) + } }