diff --git a/CHANGELOG.md b/CHANGELOG.md index c2add669f..5b70f6a83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Query results can be drawn as native bar, line, area and scatter charts from the loaded rows. Date and timestamp columns plot on a real time axis. Axis and series controls stack when the pane is narrow, hover shows exact values, and the chart keeps its type and axes across a page turn, a sort and a re-run. A result past the plotting limit still draws what fits and says how much. The status bar keeps the row count and pagination in Chart mode. This is a Starter feature. - Every database operation TablePro authorizes is written to a local execution log, including the ones the AI assistant and MCP clients ask for, with the statement stored as a digest rather than as text. Records are hash chained, so an edited, reordered or removed entry can be detected. The log stays on the Mac and is not synced. - An administrator can set a minimum Safe Mode level for every connection through a macOS configuration profile, so a managed Mac cannot be dropped below it. A connection set stricter keeps its own level, since the policy is a floor rather than a ceiling. The control shows as managed instead of editable. - Plugins signed by other developers can be installed. TablePro used to refuse any plugin bundle it had not signed itself, so the only way to publish a driver was through the TablePro repository. A bundle signed with a Developer ID and notarized by Apple now installs after you agree to trust that developer by name, and the prompt says plainly that a database plugin runs as part of TablePro and can read the credentials of every connection you open. Trust is recorded per developer rather than per plugin, so their updates install without asking again, and you can withdraw it. Unsigned and ad-hoc signed bundles are still refused. @@ -19,6 +20,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Cmd+F` on a table tab used to toggle the filter panel, which meant it closed the panel when it was already open and never searched anything. The filter panel keeps `Cmd+Option+F` and its funnel button in the status bar. - Find Next and Find Previous work on the data grid when its find bar is open, instead of staying dimmed on a table tab. +### Fixed + +- Timestamps written with a space before the time zone offset, or with fractional seconds, are now shown in your chosen date format instead of as raw text. PostgreSQL `timestamptz` and MySQL `DATETIME(6)` values used to slip through unformatted while the same instant written in ISO form was formatted. + ## [0.66.0] - 2026-08-19 ### Added diff --git a/TablePro/Core/Services/Formatting/DatabaseDateParser.swift b/TablePro/Core/Services/Formatting/DatabaseDateParser.swift new file mode 100644 index 000000000..3f2eac3c5 --- /dev/null +++ b/TablePro/Core/Services/Formatting/DatabaseDateParser.swift @@ -0,0 +1,62 @@ +// +// DatabaseDateParser.swift +// TablePro +// +// The date spellings TablePro's drivers put on the wire, in one place. Cells arrive as text, so +// both the grid's display formatting and the chart's temporal axis have to recover a Date from +// the same strings. +// + +import Foundation + +/// Not thread safe: `DateFormatter` is not `Sendable`, so each consumer owns an instance inside its +/// own isolation domain rather than sharing one. +final class DatabaseDateParser { + /// Tried in order until one succeeds. Formats without a zone marker are read in the user's + /// time zone, because a database value like `2024-03-01 12:00:00` is naive and must display + /// as written. Formats with a zone marker carry their own offset. + /// + /// Coverage is measured, not assumed: `Z` accepts `Z`, `+0700` and `+07:00` when parsing, and + /// `SSSSSS` accepts any number of fractional digits, so nine patterns cover every spelling + /// MySQL, PostgreSQL, SQLite and SQL Server produce. + private static let formats: [(pattern: String, hasTimeZone: Bool)] = [ + ("yyyy-MM-dd HH:mm:ss", false), + ("yyyy-MM-dd'T'HH:mm:ss", false), + ("yyyy-MM-dd'T'HH:mm:ssZ", true), + ("yyyy-MM-dd'T'HH:mm:ss.SSSZ", true), + ("yyyy-MM-dd", false), + ("HH:mm:ss", false), + ("yyyy-MM-dd HH:mm:ssXXXXX", true), + ("yyyy-MM-dd HH:mm:ss.SSSSSSXXXXX", true), + ("yyyy-MM-dd HH:mm:ss.SSSSSS", false), + ] + + private let parsers: [DateFormatter] + + /// Consecutive cells in one column share a wire format, so the last winner is tried first. + private var lastSuccessfulIndex = 0 + + init() { + parsers = Self.formats.map { format in + let parser = DateFormatter() + parser.dateFormat = format.pattern + parser.locale = Locale(identifier: "en_US_POSIX") + parser.calendar = Calendar(identifier: .gregorian) + parser.timeZone = format.hasTimeZone ? TimeZone(secondsFromGMT: 0) : TimeZone.current + return parser + } + } + + func date(from text: String) -> Date? { + if let date = parsers[lastSuccessfulIndex].date(from: text) { + return date + } + for index in parsers.indices where index != lastSuccessfulIndex { + if let date = parsers[index].date(from: text) { + lastSuccessfulIndex = index + return date + } + } + return nil + } +} diff --git a/TablePro/Core/Services/Formatting/DateFormattingService.swift b/TablePro/Core/Services/Formatting/DateFormattingService.swift index 48cb3cdbb..c9366f309 100644 --- a/TablePro/Core/Services/Formatting/DateFormattingService.swift +++ b/TablePro/Core/Services/Formatting/DateFormattingService.swift @@ -23,12 +23,7 @@ final class DateFormattingService { /// Current date format option private(set) var currentFormat: DateFormatOption - /// Parsers for common database date formats (ISO 8601, MySQL, PostgreSQL, SQLite) - private let parsers: [DateFormatter] - - /// Index of the parser that succeeded most recently. Tried first on the next parse - /// because consecutive cells in the same column share the same wire format. - private var lastSuccessfulParserIndex: Int = 0 + private let parser = DatabaseDateParser() /// Cache for formatted date strings to avoid repeated parsing private let formatCache = NSCache() @@ -41,7 +36,6 @@ final class DateFormattingService { self.formatter = Self.createFormatter(format: DateFormatOption.iso8601.formatString) self.dateOnlyFormatter = Self.createFormatter(format: DateFormatOption.iso8601.dateOnlyFormatString) self.timeOnlyFormatter = Self.createFormatter(format: DateFormatOption.iso8601.timeOnlyFormatString) - self.parsers = Self.createParsers() formatCache.countLimit = 100_000 } @@ -76,22 +70,13 @@ final class DateFormattingService { return cached.length == 0 ? nil : cached as String } - if let date = parsers[lastSuccessfulParserIndex].date(from: dateString) { - let result = targetFormatter.string(from: date) - formatCache.setObject(result as NSString, forKey: cacheKey) - return result - } - for index in parsers.indices where index != lastSuccessfulParserIndex { - if let date = parsers[index].date(from: dateString) { - lastSuccessfulParserIndex = index - let result = targetFormatter.string(from: date) - formatCache.setObject(result as NSString, forKey: cacheKey) - return result - } + guard let date = parser.date(from: dateString) else { + formatCache.setObject("" as NSString, forKey: cacheKey) + return nil } - - formatCache.setObject("" as NSString, forKey: cacheKey) - return nil + let result = targetFormatter.string(from: date) + formatCache.setObject(result as NSString, forKey: cacheKey) + return result } private func formatter(for columnType: ColumnType?) -> DateFormatter { @@ -128,31 +113,4 @@ final class DateFormattingService { formatter.timeZone = TimeZone.current return formatter } - - /// Create parsers for common database date formats - /// Parsers are tried in order until one successfully parses the input. - /// Formats WITHOUT explicit timezone info use the user's local timezone - /// (database values like `2024-03-01 12:00:00` are naive — display as-is). - /// Formats WITH timezone markers (`Z`, `+0000`) parse the embedded offset. - /// - Returns: Array of DateFormatters for parsing - private static func createParsers() -> [DateFormatter] { - // (format, hasTimezone) — formats with timezone markers parse UTC/offset; - // naive formats use user's local timezone so display matches the raw value. - let formats: [(String, Bool)] = [ - ("yyyy-MM-dd HH:mm:ss", false), // MySQL/PostgreSQL timestamp (most common) - ("yyyy-MM-dd'T'HH:mm:ss", false), // ISO 8601 (no timezone) - ("yyyy-MM-dd'T'HH:mm:ssZ", true), // ISO 8601 with timezone - ("yyyy-MM-dd'T'HH:mm:ss.SSSZ", true), // ISO 8601 with milliseconds and timezone - ("yyyy-MM-dd", false), // Date only (MySQL DATE, PostgreSQL DATE) - ("HH:mm:ss", false), // Time only (MySQL TIME) - ] - - return formats.map { format, hasTimezone in - let parser = DateFormatter() - parser.dateFormat = format - parser.locale = Locale(identifier: "en_US_POSIX") - parser.timeZone = hasTimezone ? TimeZone(secondsFromGMT: 0) : TimeZone.current - return parser - } - } } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift index 04978c509..e384c19a1 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift @@ -60,10 +60,11 @@ extension MainSplitViewController { /// One Find command, and the responder chain decides what finding means. A focused editor declares /// the same selectors on its own `TextViewController`, closer to the first responder, so it searches /// its own text without this class knowing which editor is focused. Reaching here means no editor - /// holds focus: a table tab filters its rows, and every other tab hands the find to the editor the - /// window is built around, so Cmd+F still reaches the query text while the grid or sidebar has focus. + /// holds focus: an active table result grid filters its rows, and every other view hands the find + /// to the editor the window is built around, so Cmd+F still reaches the query text while the grid + /// or sidebar has focus. @objc func performFind(_ sender: Any?) { - guard commandActions?.isTableTab == true else { + guard commandActions?.canUseTableResultCommands == true else { EditorEventRouter.shared.showFindPanelForKeyWindow() return } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 6c0b34dc4..286a61b42 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -17,7 +17,7 @@ struct MenuValidationContext: Equatable { var hasSelectedWorkspace = false var isConnected = false var isReadOnly = false - var isTableTab = false + var canUseTableResultCommands = false /// Save As writes the selected tab's SQL, so it needs a query tab and not merely a connection. var isQueryTab = false /// Export Results exports the selected tab's rows, so an empty grid has nothing to offer. @@ -28,6 +28,9 @@ struct MenuValidationContext: Equatable { var hasPendingChanges = false var hasDataPendingChanges = false var hasRowSelection = false + /// Copy with headers and copy as JSON read the result grid's columns, so they need the data + /// grid's selection specifically, not the structure grid's. + var hasDataGridRowSelection = false var hasTableSelection = false /// Whether the window-level `paste:` fallback would actually paste. AppKit hands a disabled /// item its key equivalent regardless, so an item enabled over a handler that returns at its @@ -144,7 +147,7 @@ extension MainSplitViewController: NSMenuItemValidation { case #selector(truncateTable(_:)): return context.isConnected && context.hasTableSelection && !context.isReadOnly case #selector(performFind(_:)): - return context.hasEditorForFind || (context.isConnected && context.isTableTab) + return context.hasEditorForFind || (context.isConnected && context.canUseTableResultCommands) case #selector(findNext(_:)), #selector(findPrevious(_:)): return context.hasEditorForFind || context.hasActiveGridFind case #selector(undo(_:)): @@ -153,10 +156,11 @@ extension MainSplitViewController: NSMenuItemValidation { return context.canRedo case #selector(copy(_:)): return context.hasRowSelection || context.hasTableSelection - case #selector(copySelectedRows(_:)), - #selector(copyRowsWithHeaders(_:)), - #selector(copyRowsAsJson(_:)): + case #selector(copySelectedRows(_:)): return context.hasRowSelection + case #selector(copyRowsWithHeaders(_:)), + #selector(copyRowsAsJson(_:)): + return context.hasDataGridRowSelection case #selector(paste(_:)): return context.isConnected && context.canPasteRows case #selector(delete(_:)): @@ -188,7 +192,7 @@ extension MainSplitViewController: NSMenuItemValidation { return context.isConnected case #selector(toggleFilterBar(_:)): - return context.isConnected && context.isTableTab + return context.isConnected && context.canUseTableResultCommands case #selector(pinResult(_:)): return context.canPinResultTab case #selector(useFlatSidebarLayout(_:)), #selector(useTreeSidebarLayout(_:)): @@ -211,7 +215,7 @@ extension MainSplitViewController: NSMenuItemValidation { hasSelectedWorkspace: workspaces.selectedConnectionId != nil, isConnected: isConnected, isReadOnly: actions.isReadOnly, - isTableTab: actions.isTableTab, + canUseTableResultCommands: actions.canUseTableResultCommands, isQueryTab: actions.isQueryTab, hasResultRows: actions.hasResultRows, isCurrentTabEditable: actions.isCurrentTabEditable, @@ -220,6 +224,7 @@ extension MainSplitViewController: NSMenuItemValidation { hasPendingChanges: actions.hasPendingChanges, hasDataPendingChanges: actions.hasDataPendingChanges, hasRowSelection: actions.hasRowSelection, + hasDataGridRowSelection: actions.hasDataGridRowSelection, hasTableSelection: actions.hasTableSelection, canPasteRows: actions.canPasteRows, canCloseOtherTabs: actions.canCloseOtherTabs, diff --git a/TablePro/Core/Services/Query/ResultChartProjection.swift b/TablePro/Core/Services/Query/ResultChartProjection.swift new file mode 100644 index 000000000..7461d4e8c --- /dev/null +++ b/TablePro/Core/Services/Query/ResultChartProjection.swift @@ -0,0 +1,57 @@ +// +// ResultChartProjection.swift +// TablePro +// + +import Foundation + +struct ResultChartProjection: Equatable, Sendable { + enum XValue: Hashable, Sendable { + case category(String) + case number(Double) + case date(Date) + } + + enum SeriesValue: Equatable, Hashable, Sendable { + case value(String) + case missing + + /// The legend entry and the hover callout name the same series, so they read it from here. + var displayName: String { + switch self { + case .value(let raw): return raw + case .missing: return String(localized: "No value") + } + } + } + + /// A bound the projection ran into. The points already gathered are kept and drawn: a result one + /// row over a cap is a chart with a note, not an error card. + enum Limit: Equatable, Sendable { + case points(limit: Int) + case series(limit: Int) + case inspectedRows(limit: Int) + } + + struct Point: Identifiable, Equatable, Sendable { + let sourceIndex: Int + let x: XValue + let y: Double + let rawX: String + let rawY: String + let series: SeriesValue? + let barGroup: Int + let lineGroup: Int + + var id: Int { sourceIndex } + } + + let points: [Point] + let xAxisKind: ResultChartColumn.AxisKind + let limits: [Limit] + let loadedRowCount: Int + let skippedRowCount: Int + let xAxisLabel: String + let yAxisLabel: String + let seriesLabel: String? +} diff --git a/TablePro/Core/Services/Query/ResultChartProjector.swift b/TablePro/Core/Services/Query/ResultChartProjector.swift new file mode 100644 index 000000000..98d6b383c --- /dev/null +++ b/TablePro/Core/Services/Query/ResultChartProjector.swift @@ -0,0 +1,306 @@ +// +// ResultChartProjector.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +actor ResultChartProjector { + static let shared = ResultChartProjector() + static let maximumPointCount = 2_000 + static let maximumSeriesCount = 20 + static let maximumInspectedRowCount = 50_000 + static let maximumLabelLength = 512 + static let maximumNumericLength = 256 + + private let dateParser = DatabaseDateParser() + + private enum XOccurrenceKey: Hashable { + case category(String) + case number(String) + case date(Date) + } + + private struct GroupKey: Hashable { + let series: Int + let occurrence: Int + } + + private struct BarOccurrenceKey: Hashable { + let x: XOccurrenceKey + let series: Int + } + + private enum LineSeriesKey: Hashable { + case ungrouped + case series(ResultChartProjection.SeriesValue) + } + + private struct RowValues { + let x: ResultChartProjection.XValue + let rawX: String + let y: Double + let rawY: String + } + + /// Cancellation is the only failure this can report, and saying so in the signature keeps it + /// that way: a caller cannot be handed an error state that never arrives. + func project( + tableRows: TableRows, + configuration: ResultChartConfiguration.Resolved + ) async throws(CancellationError) -> ResultChartProjection { + var points: [ResultChartProjection.Point] = [] + points.reserveCapacity(min(tableRows.rows.count, Self.maximumPointCount)) + var seriesOrdinals: [ResultChartProjection.SeriesValue: Int] = [:] + var xOccurrences: [BarOccurrenceKey: Int] = [:] + var barGroupOrdinals: [GroupKey: Int] = [:] + var lineGroupOrdinals: [GroupKey: Int] = [:] + var lineBreakGenerations: [LineSeriesKey: Int] = [:] + var limits: [ResultChartProjection.Limit] = [] + var skippedRowCount = 0 + + rows: for (sourceIndex, row) in tableRows.rows.enumerated() { + if sourceIndex == Self.maximumInspectedRowCount { + limits.append(.inspectedRows(limit: Self.maximumInspectedRowCount)) + break + } + if sourceIndex.isMultiple(of: 256), Task.isCancelled { + throw CancellationError() + } + + guard let values = rowValues(in: row, sourceIndex: sourceIndex, configuration: configuration) else { + skippedRowCount += 1 + recordLineBreak( + for: row, + seriesColumn: configuration.seriesColumn, + seriesOrdinals: seriesOrdinals, + generations: &lineBreakGenerations + ) + continue + } + + let series: ResultChartProjection.SeriesValue? + let seriesOrdinal: Int + if let seriesColumn = configuration.seriesColumn { + guard let cell = cell(in: row, at: seriesColumn.index), + let value = seriesValue(from: cell) + else { + skippedRowCount += 1 + recordLineBreak( + for: row, + seriesColumn: seriesColumn, + seriesOrdinals: seriesOrdinals, + generations: &lineBreakGenerations + ) + continue + } + series = value + if let ordinal = seriesOrdinals[value] { + seriesOrdinal = ordinal + } else { + guard seriesOrdinals.count < Self.maximumSeriesCount else { + appendOnce(.series(limit: Self.maximumSeriesCount), to: &limits) + continue + } + seriesOrdinal = seriesOrdinals.count + seriesOrdinals[value] = seriesOrdinal + } + } else { + series = nil + seriesOrdinal = 0 + } + + if points.count == Self.maximumPointCount { + limits.append(.points(limit: Self.maximumPointCount)) + break rows + } + + let xKey = stableXKey(values.x, raw: values.rawX) + let occurrenceKey = BarOccurrenceKey(x: xKey, series: seriesOrdinal) + xOccurrences[occurrenceKey, default: 0] += 1 + let occurrence = xOccurrences[occurrenceKey, default: 1] + let barGroupKey = GroupKey(series: seriesOrdinal, occurrence: occurrence) + let lineSeriesKey = series.map(LineSeriesKey.series) ?? .ungrouped + let lineGroupKey = GroupKey( + series: seriesOrdinal, + occurrence: lineBreakGenerations[lineSeriesKey, default: 0] + ) + points.append(ResultChartProjection.Point( + sourceIndex: sourceIndex, + x: values.x, + y: values.y, + rawX: values.rawX, + rawY: values.rawY, + series: series, + barGroup: ordinal(for: barGroupKey, in: &barGroupOrdinals), + lineGroup: ordinal(for: lineGroupKey, in: &lineGroupOrdinals) + )) + } + + return ResultChartProjection( + points: points, + xAxisKind: configuration.xAxisKind, + limits: limits, + loadedRowCount: tableRows.rows.count, + skippedRowCount: skippedRowCount, + xAxisLabel: configuration.xColumn?.displayName ?? String(localized: "Row Number"), + yAxisLabel: configuration.yColumn.displayName, + seriesLabel: configuration.seriesColumn?.displayName + ) + } + + private func appendOnce( + _ limit: ResultChartProjection.Limit, + to limits: inout [ResultChartProjection.Limit] + ) { + guard !limits.contains(limit) else { return } + limits.append(limit) + } + + private func rowValues( + in row: Row, + sourceIndex: Int, + configuration: ResultChartConfiguration.Resolved + ) -> RowValues? { + guard let x = xValue(from: row, sourceIndex: sourceIndex, column: configuration.xColumn), + let yCell = cell(in: row, at: configuration.yColumn.index), + let y = numericValue(from: yCell) + else { + return nil + } + return RowValues(x: x.value, rawX: x.raw, y: y.value, rawY: y.raw) + } + + private func xValue( + from row: Row, + sourceIndex: Int, + column: ResultChartColumn? + ) -> (value: ResultChartProjection.XValue, raw: String)? { + guard let column else { + let number = sourceIndex + 1 + return (.number(Double(number)), String(number)) + } + guard let cell = cell(in: row, at: column.index) else { return nil } + + switch column.xAxisKind { + case .category: + guard case .text(let raw) = cell, + raw.utf8.count <= Self.maximumLabelLength else { return nil } + return (.category(raw), raw) + case .number: + guard let number = numericValue(from: cell) else { return nil } + return (.number(number.value), number.raw) + case .date: + guard case .text(let raw) = cell, + raw.utf8.count <= Self.maximumLabelLength, + let date = dateParser.date(from: raw.trimmingCharacters(in: .whitespacesAndNewlines)) + else { + return nil + } + return (.date(date), raw) + case nil: + return nil + } + } + + /// Swift Charts plots `Double`, so the value the chart draws is the value validated here. + /// `Decimal` would add a second conversion through `NSDecimalNumber`, which is not correctly + /// rounded and rejected 28% of ordinary two-decimal money values. + private func numericValue(from cell: PluginCellValue) -> (value: Double, raw: String)? { + guard case .text(let raw) = cell else { return nil } + guard raw.utf8.count <= Self.maximumNumericLength else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let normalized = JsonNumberNormalizer.numberLiteral(from: trimmed), + let value = Double(normalized), + value.isFinite, + canonicalNumericKey(normalized) == canonicalNumericKey(String(value)) + else { + return nil + } + return (value, raw) + } + + private func canonicalNumericKey(_ value: String) -> String? { + guard let normalized = JsonNumberNormalizer.numberLiteral(from: value) else { return nil } + let isNegative = normalized.first == "-" + let unsigned = isNegative ? normalized.dropFirst() : Substring(normalized) + let exponentParts = unsigned.split(separator: "e", maxSplits: 1, omittingEmptySubsequences: false) + guard let mantissa = exponentParts.first, + exponentParts.count < 2 || Int(exponentParts[1]) != nil + else { + return nil + } + + let explicitExponent = exponentParts.count == 2 ? Int(exponentParts[1]) ?? 0 : 0 + let decimalParts = mantissa.split(separator: ".", maxSplits: 1, omittingEmptySubsequences: false) + let fractionalCount = decimalParts.count == 2 ? decimalParts[1].count : 0 + var digits = Array(decimalParts.joined()) + while digits.first == "0" { digits.removeFirst() } + guard !digits.isEmpty else { return "0" } + + var exponent = explicitExponent - fractionalCount + while digits.last == "0" { + digits.removeLast() + exponent += 1 + } + return "\(isNegative ? "-" : "")\(String(digits))e\(exponent)" + } + + private func seriesValue(from cell: PluginCellValue) -> ResultChartProjection.SeriesValue? { + switch cell { + case .null: + return .missing + case .text(let value): + guard value.utf8.count <= Self.maximumLabelLength else { return nil } + return .value(value) + case .bytes: + return nil + } + } + + /// A skipped row is a hole in the line, so the next point starts a new segment rather than the + /// line being drawn straight through it. When the series cell is the unreadable one the row + /// cannot be attributed, so every series seen so far breaks: the alternative is drawing over a + /// discontinuity in whichever line it really belonged to. + private func recordLineBreak( + for row: Row, + seriesColumn: ResultChartColumn?, + seriesOrdinals: [ResultChartProjection.SeriesValue: Int], + generations: inout [LineSeriesKey: Int] + ) { + guard let seriesColumn else { + generations[.ungrouped, default: 0] += 1 + return + } + guard let cell = cell(in: row, at: seriesColumn.index), + let series = seriesValue(from: cell) + else { + for value in seriesOrdinals.keys { + generations[.series(value), default: 0] += 1 + } + return + } + generations[.series(series), default: 0] += 1 + } + + private func cell(in row: Row, at index: Int) -> PluginCellValue? { + guard row.values.indices.contains(index) else { return nil } + return row.values[index] + } + + private func stableXKey(_ value: ResultChartProjection.XValue, raw: String) -> XOccurrenceKey { + switch value { + case .category: return .category(raw) + case .number: return .number(canonicalNumericKey(raw) ?? raw) + case .date(let date): return .date(date) + } + } + + private func ordinal(for key: GroupKey, in ordinals: inout [GroupKey: Int]) -> Int { + if let ordinal = ordinals[key] { return ordinal } + let ordinal = ordinals.count + ordinals[key] = ordinal + return ordinal + } +} diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index df0bf6a47..cb308e663 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -7,6 +7,22 @@ enum ResultsViewMode: String, Equatable { case data case structure case json + case chart + + /// How much of the loaded result the mode is showing, and how to load more. A chart draws the + /// same buffer the grid does, so it needs the same scope controls: a warning that the chart is + /// incomplete is only useful next to the control that completes it. + var showsResultScope: Bool { + self != .structure + } + + var showsColumnControls: Bool { + self == .data || self == .json + } + + var showsRowFilters: Bool { + self == .data || self == .json + } } struct QueryTab: Identifiable, Equatable { @@ -27,6 +43,7 @@ struct QueryTab: Identifiable, Equatable { var findState: TabFindState var columnLayout: ColumnLayoutState var pagination: PaginationState + var chartConfiguration: ResultChartConfiguration var hasUserInteraction: Bool var schemaVersion: Int var metadataVersion: Int @@ -72,6 +89,7 @@ struct QueryTab: Identifiable, Equatable { self.findState = TabFindState() self.columnLayout = ColumnLayoutState() self.pagination = PaginationState() + self.chartConfiguration = ResultChartConfiguration() self.hasUserInteraction = false self.schemaVersion = 0 self.metadataVersion = 0 @@ -112,6 +130,7 @@ struct QueryTab: Identifiable, Equatable { columnContentWidths: persisted.columnContentWidths ) self.pagination = PaginationState(pageSize: defaultPageSize) + self.chartConfiguration = ResultChartConfiguration() self.hasUserInteraction = false self.schemaVersion = 0 self.metadataVersion = 0 @@ -223,6 +242,7 @@ struct QueryTab: Identifiable, Equatable { && lhs.paginationVersion == rhs.paginationVersion && lhs.pagination == rhs.pagination && lhs.sortState == rhs.sortState + && lhs.chartConfiguration == rhs.chartConfiguration && lhs.display == rhs.display && lhs.tableContext.isEditable == rhs.tableContext.isEditable && lhs.tableContext.isView == rhs.tableContext.isView diff --git a/TablePro/Models/Query/ResultChartColumn.swift b/TablePro/Models/Query/ResultChartColumn.swift new file mode 100644 index 000000000..e04c3a74d --- /dev/null +++ b/TablePro/Models/Query/ResultChartColumn.swift @@ -0,0 +1,84 @@ +// +// ResultChartColumn.swift +// TablePro +// + +import Foundation + +/// Identifies a result column by name rather than by position, so a chart configuration survives a +/// re-execution that reorders the SELECT list. The occurrence disambiguates duplicate names. +struct ResultChartColumnID: Hashable, Sendable { + let name: String + let occurrence: Int +} + +struct ResultChartColumn: Identifiable, Equatable, Sendable { + /// The three primitives Swift Charts can plot: String, Double and Date. + enum AxisKind: Equatable, Sendable { + case category + case number + case date + } + + let id: ResultChartColumnID + let index: Int + let displayName: String + let type: ColumnType + let isPrimaryKey: Bool + + var name: String { id.name } + + var xAxisKind: AxisKind? { + switch type { + case .integer, .decimal: + return .number + case .date, .timestamp, .datetime: + return .date + case .text, .boolean, .enumType, .set: + return .category + case .blob, .json, .spatial, .array: + return nil + } + } + + var supportsY: Bool { + switch type { + case .integer, .decimal: + return true + default: + return false + } + } + + var supportsSeries: Bool { + switch type { + case .text, .boolean, .enumType, .set: + return true + default: + return false + } + } + + static func columns(in tableRows: TableRows, primaryKeyColumns: Set = []) -> [ResultChartColumn] { + var occurrences: [String: Int] = [:] + let totals = tableRows.columns.reduce(into: [String: Int]()) { result, name in + result[name, default: 0] += 1 + } + + return tableRows.columns.enumerated().map { index, name in + occurrences[name, default: 0] += 1 + let occurrence = occurrences[name, default: 1] + let displayName = totals[name, default: 0] > 1 ? "\(name) (\(occurrence))" : name + let type = index < tableRows.columnTypes.count + ? tableRows.columnTypes[index] + : .text(rawType: nil) + return ResultChartColumn( + id: ResultChartColumnID(name: name, occurrence: occurrence), + index: index, + displayName: displayName, + type: type, + isPrimaryKey: primaryKeyColumns.contains(name) + ) + } + } +} diff --git a/TablePro/Models/Query/ResultChartConfiguration.swift b/TablePro/Models/Query/ResultChartConfiguration.swift new file mode 100644 index 000000000..6849222c7 --- /dev/null +++ b/TablePro/Models/Query/ResultChartConfiguration.swift @@ -0,0 +1,60 @@ +// +// ResultChartConfiguration.swift +// TablePro +// + +import Foundation + +/// The user's chart choices for a tab. Columns are named rather than indexed, so paging, sorting and +/// re-running keep the axes while an edited SELECT list cannot silently chart a different column that +/// happens to land on the same position. A choice that no longer resolves is kept, not erased, so it +/// comes back when the column does. +struct ResultChartConfiguration: Equatable, Hashable, Sendable { + var chartType: ResultChartType + var xColumn: ResultChartColumnID? + var yColumn: ResultChartColumnID? + var seriesColumn: ResultChartColumnID? + + init( + chartType: ResultChartType = .bar, + xColumn: ResultChartColumnID? = nil, + yColumn: ResultChartColumnID? = nil, + seriesColumn: ResultChartColumnID? = nil + ) { + self.chartType = chartType + self.xColumn = xColumn + self.yColumn = yColumn + self.seriesColumn = seriesColumn + } + + /// The first numeric column that is not part of the primary key. A leading auto-increment key + /// plots as a straight diagonal, which tells the reader nothing about their data. + static func defaultYColumn(in columns: [ResultChartColumn]) -> ResultChartColumn? { + columns.first { $0.supportsY && !$0.isPrimaryKey } ?? columns.first(where: \.supportsY) + } + + func resolved(in columns: [ResultChartColumn]) -> Resolved? { + let resolvedY = columns.first { $0.id == yColumn && $0.supportsY } + ?? Self.defaultYColumn(in: columns) + guard let resolvedY else { return nil } + + return Resolved( + chartType: chartType, + xColumn: columns.first { $0.id == xColumn && $0.xAxisKind != nil }, + yColumn: resolvedY, + seriesColumn: columns.first { $0.id == seriesColumn && $0.supportsSeries } + ) + } + + struct Resolved: Equatable, Sendable { + let chartType: ResultChartType + let xColumn: ResultChartColumn? + let yColumn: ResultChartColumn + let seriesColumn: ResultChartColumn? + + /// No X column means the row number, which is a numeric axis. + var xAxisKind: ResultChartColumn.AxisKind { + xColumn?.xAxisKind ?? .number + } + } +} diff --git a/TablePro/Models/Query/ResultChartType.swift b/TablePro/Models/Query/ResultChartType.swift new file mode 100644 index 000000000..0915a7f5b --- /dev/null +++ b/TablePro/Models/Query/ResultChartType.swift @@ -0,0 +1,33 @@ +// +// ResultChartType.swift +// TablePro +// + +import Foundation + +enum ResultChartType: String, CaseIterable, Hashable, Identifiable, Sendable { + case bar + case line + case area + case scatter + + var id: Self { self } + + var displayName: String { + switch self { + case .bar: return String(localized: "Bar") + case .line: return String(localized: "Line") + case .area: return String(localized: "Area") + case .scatter: return String(localized: "Scatter") + } + } + + var systemImage: String { + switch self { + case .bar: return "chart.bar.xaxis" + case .line: return "chart.xyaxis.line" + case .area: return "chart.xyaxis.line" + case .scatter: return "chart.dots.scatter" + } + } +} diff --git a/TablePro/Models/Settings/ProFeature.swift b/TablePro/Models/Settings/ProFeature.swift index d4c122fc7..5789db22b 100644 --- a/TablePro/Models/Settings/ProFeature.swift +++ b/TablePro/Models/Settings/ProFeature.swift @@ -14,6 +14,7 @@ internal enum ProFeature: String, CaseIterable { case envVarReferences case linkedFolders case queryInsights + case resultCharts case teamCatalog case teamLibrary @@ -21,6 +22,8 @@ internal enum ProFeature: String, CaseIterable { switch self { case .queryInsights: return String(localized: "Query Insights") + case .resultCharts: + return String(localized: "Result Charts") case .iCloudSync: return String(localized: "iCloud Sync") case .encryptedExport: @@ -40,6 +43,8 @@ internal enum ProFeature: String, CaseIterable { switch self { case .queryInsights: return "chart.bar.xaxis" + case .resultCharts: + return "chart.xyaxis.line" case .iCloudSync: return "icloud" case .encryptedExport: @@ -59,6 +64,8 @@ internal enum ProFeature: String, CaseIterable { switch self { case .queryInsights: return String(localized: "See which queries you run most, which run slowest, and which got slower.") + case .resultCharts: + return String(localized: "Turn loaded query results into native bar, line, area, and scatter charts.") case .iCloudSync: return String(localized: "Sync connections, settings, and favorites across your Macs.") case .encryptedExport: @@ -77,7 +84,7 @@ internal enum ProFeature: String, CaseIterable { /// The lowest license tier that unlocks this feature. var requiredTier: LicenseTier { switch self { - case .iCloudSync, .encryptedExport, .envVarReferences, .linkedFolders, .queryInsights: + case .iCloudSync, .encryptedExport, .envVarReferences, .linkedFolders, .queryInsights, .resultCharts: return .starter case .teamCatalog, .teamLibrary: return .team diff --git a/TablePro/Models/UI/GridSelectionOwner.swift b/TablePro/Models/UI/GridSelectionOwner.swift index 51d333496..6c85be3d4 100644 --- a/TablePro/Models/UI/GridSelectionOwner.swift +++ b/TablePro/Models/UI/GridSelectionOwner.swift @@ -18,6 +18,7 @@ internal enum GridSelectionOwner: Equatable { guard let tabType else { return .none } if tabType == .createTable { return .schemaGrid } if resultsViewMode == .structure { return .schemaGrid } + if resultsViewMode == .chart { return .none } switch tabType { case .table, .query: return .dataGrid diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 46dddc5ae..c7ca8ddda 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -109189,6 +109189,790 @@ } } } + }, + "%1$@ chart of %2$@ by %3$@ with %4$d points" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ chart of %2$@ by %3$@ with %#@points@" + }, + "substitutions" : { + "points" : { + "argNum" : 4, + "formatSpecifier" : "d", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%arg point" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%arg points" + } + } + } + } + } + } + }, + "tr" : { + "stringUnit" : { + "value" : "%3$@ ölçütüne göre %2$@ için %4$d noktalı %1$@ grafiği", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Biểu đồ %1$@ của %2$@ theo %3$@ với %4$d điểm", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "包含 %4$d 个点的%1$@图:按%3$@显示%2$@", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "包含 %4$d 個點的%1$@圖:依%3$@顯示%2$@", + "state" : "translated" + } + } + } + }, + "Showing the first %1$@ points of %2$@ loaded rows" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yüklenen %2$@ satırın ilk %1$@ noktası gösteriliyor" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang hiển thị %1$@ điểm đầu tiên trong %2$@ hàng đã tải" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示已加载 %2$@ 行中的前 %1$@ 个点" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "顯示已載入 %2$@ 列中的前 %1$@ 個點" + } + } + } + }, + "Showing the first %@ series" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İlk %@ seri gösteriliyor" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang hiển thị %@ chuỗi đầu tiên" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示前 %@ 个系列" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "顯示前 %@ 個系列" + } + } + } + }, + "Charting the first %1$@ of %2$@ loaded rows" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yüklenen %2$@ satırın ilk %1$@ tanesi grafiğe alınıyor" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang vẽ %1$d trong số %2$@ hàng đã tải đầu tiên" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "绘制已加载 %2$@ 行中的前 %1$@ 行" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "繪製已載入 %2$@ 列中的前 %1$@ 列" + } + } + } + }, + "%d skipped" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "value" : "%d atlandı", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "đã bỏ qua %d", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "已跳过 %d 行", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "已略過 %d 列", + "state" : "translated" + } + } + } + }, + "Area" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "value" : "Alan", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Miền", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "面积", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "面積", + "state" : "translated" + } + } + } + }, + "Chart" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "value" : "Grafik", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Biểu đồ", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "图表", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "圖表", + "state" : "translated" + } + } + } + }, + "Chart Type" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "value" : "Grafik Türü", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Loại biểu đồ", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "图表类型", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "圖表類型", + "state" : "translated" + } + } + } + }, + "Charts draw the rows the result pane has loaded. Grid selection, Find, hidden columns, and value filters do not change the chart." : { + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grafikler sonuç bölmesinin yüklediği satırları çizer. Tablo seçimi, Bul, gizli sütunlar ve değer filtreleri grafiği değiştirmez." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Biểu đồ vẽ các hàng mà khung kết quả đã tải. Lựa chọn trong lưới, Tìm, cột ẩn và bộ lọc giá trị không làm thay đổi biểu đồ." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "图表绘制结果面板已加载的行。网格选择、查找、隐藏列和值过滤器不会改变图表。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "圖表繪製結果面板已載入的列。表格選取、尋找、隱藏欄和值篩選器不會改變圖表。" + } + } + } + }, + "Chart data scope" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "value" : "Grafik veri kapsamı", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Phạm vi dữ liệu biểu đồ", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "图表数据范围", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "圖表資料範圍", + "state" : "translated" + } + } + } + }, + "Choose Column" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "value" : "Sütun Seç", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Chọn cột", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "选择列", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "選擇欄", + "state" : "translated" + } + } + } + }, + "Execute a query to chart its loaded rows." : { + "localizations" : { + "tr" : { + "stringUnit" : { + "value" : "Yüklenen satırları grafikte göstermek için bir sorgu çalıştırın.", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Chạy truy vấn để vẽ biểu đồ từ các hàng đã tải.", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "执行查询以绘制已加载行的图表。", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "執行查詢以繪製已載入列的圖表。", + "state" : "translated" + } + } + } + }, + "Line" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "value" : "Çizgi", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Đường", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "折线", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "折線", + "state" : "translated" + } + } + } + }, + "No Numeric Column" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sayısal Sütun Yok" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có cột số" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有数值列" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "沒有數值欄" + } + } + } + }, + "Charts need a numeric column for the Y axis. This result has none." : { + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grafikler Y ekseni için sayısal bir sütuna ihtiyaç duyar. Bu sonuçta yok." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Biểu đồ cần một cột số cho trục Y. Kết quả này không có cột nào." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "图表的 Y 轴需要一个数值列,此结果没有。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "圖表的 Y 軸需要一個數值欄,此結果沒有。" + } + } + } + }, + "The selected axes contain only null, binary, or invalid values." : { + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seçilen eksenler yalnızca null, ikili veya geçersiz değerler içeriyor." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Các trục đã chọn chỉ chứa giá trị null, nhị phân hoặc không hợp lệ." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "所选坐标轴只包含空值、二进制或无效值。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "所選座標軸只包含空值、二進位或無效值。" + } + } + } + }, + "No Chartable Rows" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "value" : "Grafiğe Uygun Satır Yok", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Không có hàng để vẽ biểu đồ", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "没有可绘制的行", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "沒有可繪製的列", + "state" : "translated" + } + } + } + }, + "No value" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "value" : "Değer yok", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Không có giá trị", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "无值", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "無值", + "state" : "translated" + } + } + } + }, + "Result Charts" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "value" : "Sonuç Grafikleri", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Biểu đồ kết quả", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "结果图表", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "結果圖表", + "state" : "translated" + } + } + } + }, + "Row" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "value" : "Satır", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Hàng", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "行", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "列", + "state" : "translated" + } + } + } + }, + "Scatter" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "value" : "Dağılım", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Phân tán", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "散点", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "散佈", + "state" : "translated" + } + } + } + }, + "Series" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "value" : "Seri", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Chuỗi", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "系列", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "數列", + "state" : "translated" + } + } + } + }, + "Turn loaded query results into native bar, line, area, and scatter charts." : { + "localizations" : { + "tr" : { + "stringUnit" : { + "value" : "Yüklenen sorgu sonuçlarını yerel çubuk, çizgi, alan ve dağılım grafiklerine dönüştürün.", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Chuyển kết quả truy vấn đã tải thành biểu đồ cột, đường, miền và phân tán theo chuẩn hệ thống.", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "将已加载的查询结果转换为原生柱状图、折线图、面积图和散点图。", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "將已載入的查詢結果轉換為原生長條圖、折線圖、面積圖和散佈圖。", + "state" : "translated" + } + } + } + }, + "X Axis" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "value" : "X Ekseni", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Trục X", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "X 轴", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "X 軸", + "state" : "translated" + } + } + } + }, + "Y Axis" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "value" : "Y Ekseni", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Trục Y", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "Y 轴", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "Y 軸", + "state" : "translated" + } + } + } + }, + "%d more" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d daha" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "thêm %d" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还有 %d 个" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "還有 %d 個" + } + } + } + }, + "Building chart" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grafik oluşturuluyor" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang tạo biểu đồ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在生成图表" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在產生圖表" + } + } + } } }, "version" : "1.1" diff --git a/TablePro/Views/Components/ProFeatureGate.swift b/TablePro/Views/Components/ProFeatureGate.swift index ab6487c5f..d9ec5b76e 100644 --- a/TablePro/Views/Components/ProFeatureGate.swift +++ b/TablePro/Views/Components/ProFeatureGate.swift @@ -72,6 +72,7 @@ struct ProFeatureGateModifier: ViewModifier { case .unlicensed: Text("\(feature.displayName) requires a Pro license") .font(.headline) + .accessibilityIdentifier("pro-feature-gate-\(feature.rawValue)") Text(feature.featureDescription) .font(.subheadline) .foregroundStyle(.secondary) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index b1b015334..55a55e1c2 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -593,6 +593,32 @@ struct MainEditorContentView: View { columnLayout: tab.columnLayout ) .id(tab.id) + case .chart: + resultTabBarSection(tab: tab) + if let explain = tab.display.activeExplainResult { + QueryPlanResultView( + rawText: explain.explainRawText ?? "", + executionTime: explain.executionTime, + plan: explain.queryPlan + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let resultSet = tab.display.activeResultSet { + ResultChartView( + configuration: chartConfigurationBinding(for: tab), + tableRows: resolvedTableRows(for: tab), + primaryKeyColumns: Set(tab.tableContext.primaryKeyColumns), + tabId: tab.id, + resultSetId: resultSet.id, + dataRevision: coordinator.tabSessionRegistry.session(for: tab.id)?.dataRevision ?? 0, + isUnlocked: LicenseManager.shared.isFeatureAvailable(.resultCharts) + ) + } else { + ContentUnavailableView( + String(localized: "No Data"), + systemImage: "chart.bar.xaxis", + description: Text(String(localized: "Execute a query to chart its loaded rows.")) + ) + } case .data: resultTabBarSection(tab: tab) @@ -856,6 +882,19 @@ struct MainEditorContentView: View { ) } + /// The chart's choices belong to the tab, not to the result set: a page turn, a sort or a + /// re-execute builds a new `ResultSet`, and the axes have to outlive it. + private func chartConfigurationBinding(for tab: QueryTab) -> Binding { + Binding( + get: { tab.chartConfiguration }, + set: { newValue in + if let index = tabManager.selectedTabIndex { + tabManager.mutate(at: index) { $0.chartConfiguration = newValue } + } + } + ) + } + private func columnLayoutBinding(for tab: QueryTab) -> Binding { let tabId = tab.id return Binding( diff --git a/TablePro/Views/Main/Child/MainStatusBarView.swift b/TablePro/Views/Main/Child/MainStatusBarView.swift index afcf19954..c0c5207aa 100644 --- a/TablePro/Views/Main/Child/MainStatusBarView.swift +++ b/TablePro/Views/Main/Child/MainStatusBarView.swift @@ -48,7 +48,13 @@ struct MainStatusBarView: View { @State private var showColumnPopover = false private var isStructureMode: Bool { viewMode == .structure } - private var showsDataChrome: Bool { !isStructureMode } + + /// Chart mode shows the row range so its plotting notices have a denominator, but it has no grid + /// and nothing clears the grid's selection on a mode change, so a carried-over count would + /// replace that range with a selection the user cannot see. + private var reportedSelectionCount: Int { + viewMode.showsColumnControls ? selectedRowIndices.count : 0 + } static func showsAddRow(viewMode: ResultsViewMode, canAddRow: Bool) -> Bool { viewMode == .data && canAddRow @@ -82,20 +88,22 @@ struct MainStatusBarView: View { Label("Data", systemImage: "tablecells").tag(ResultsViewMode.data) Label("Structure", systemImage: "list.bullet.rectangle").tag(ResultsViewMode.structure) Label("JSON", systemImage: "curlybraces").tag(ResultsViewMode.json) + Label("Chart", systemImage: "chart.xyaxis.line").tag(ResultsViewMode.chart) } .labelsHidden() .pickerStyle(.segmented) - .frame(width: 260) + .frame(width: 340) .controlSize(.small) .accessibilityIdentifier("results-view-mode-picker") } else if snapshot.hasColumns { Picker(String(localized: "View Mode"), selection: $viewMode) { Label("Data", systemImage: "tablecells").tag(ResultsViewMode.data) Label("JSON", systemImage: "curlybraces").tag(ResultsViewMode.json) + Label("Chart", systemImage: "chart.xyaxis.line").tag(ResultsViewMode.chart) } .labelsHidden() .pickerStyle(.segmented) - .frame(width: 140) + .frame(width: 220) .controlSize(.small) .accessibilityIdentifier("results-view-mode-picker") } @@ -103,7 +111,7 @@ struct MainStatusBarView: View { Spacer() - if showsDataChrome, snapshot.hasRows { + if viewMode.showsResultScope, snapshot.hasRows { HStack(spacing: 4) { if snapshot.pagination.isLoadingMore { ProgressView() @@ -114,7 +122,7 @@ struct MainStatusBarView: View { .foregroundStyle(.secondary) .accessibilityLabel(String(localized: "Loading more rows")) } else { - Text(snapshot.rowInfoText(selectedCount: selectedRowIndices.count)) + Text(snapshot.rowInfoText(selectedCount: reportedSelectionCount)) .font(.caption) .foregroundStyle(.secondary) } @@ -170,7 +178,7 @@ struct MainStatusBarView: View { structureFooterControls(state: structureState.footer) } - if showsDataChrome { + if viewMode.showsColumnControls { if Self.showsAddRow(viewMode: viewMode, canAddRow: onAddRow != nil), let onAddRow { Button { onAddRow() @@ -214,43 +222,45 @@ struct MainStatusBarView: View { ) } } + } - if snapshot.tabType == .table, snapshot.hasTableName { - Toggle(isOn: Binding( - get: { filterState.isVisible }, - set: { _ in onToggleFilters() } - )) { - HStack(spacing: 4) { - Image(systemName: filterState.hasAppliedFilters - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease.circle") - Text("Filters") - if filterState.hasAppliedFilters { - Text("(\(filterState.appliedFilters.count))") - .foregroundStyle(.secondary) - } + if viewMode.showsRowFilters, snapshot.tabType == .table, snapshot.hasTableName { + Toggle(isOn: Binding( + get: { filterState.isVisible }, + set: { _ in onToggleFilters() } + )) { + HStack(spacing: 4) { + Image(systemName: filterState.hasAppliedFilters + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") + Text("Filters") + if filterState.hasAppliedFilters { + Text("(\(filterState.appliedFilters.count))") + .foregroundStyle(.secondary) } } - .toggleStyle(.button) - .controlSize(.small) - .help(filterToggleHelp) - .accessibilityLabel(String(localized: "Filters")) - .accessibilityAddTraits(filterState.isVisible ? .isSelected : []) } + .toggleStyle(.button) + .controlSize(.small) + .help(filterToggleHelp) + .accessibilityLabel(String(localized: "Filters")) + .accessibilityAddTraits(filterState.isVisible ? .isSelected : []) + } - if snapshot.tabType == .table, snapshot.hasTableName, snapshot.showsPaginationControls { - PaginationControlsView( - pagination: snapshot.pagination, - loadedRowCount: snapshot.rowCount, - onFirst: paginationCallbacks.onFirst, - onPrevious: paginationCallbacks.onPrevious, - onNext: paginationCallbacks.onNext, - onLast: paginationCallbacks.onLast, - onPageSizeChange: paginationCallbacks.onPageSizeChange, - onShowAll: paginationCallbacks.onShowAll, - onGoToPage: paginationCallbacks.onGoToPage - ) - } + if viewMode.showsResultScope, snapshot.tabType == .table, snapshot.hasTableName, + snapshot.showsPaginationControls + { + PaginationControlsView( + pagination: snapshot.pagination, + loadedRowCount: snapshot.rowCount, + onFirst: paginationCallbacks.onFirst, + onPrevious: paginationCallbacks.onPrevious, + onNext: paginationCallbacks.onNext, + onLast: paginationCallbacks.onLast, + onPageSizeChange: paginationCallbacks.onPageSizeChange, + onShowAll: paginationCallbacks.onShowAll, + onGoToPage: paginationCallbacks.onGoToPage + ) } } } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index ce2ee9ffc..cd807e01e 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -193,10 +193,10 @@ final class MainContentCommandActions { // a column / index / FK row depending on the active Structure sub-tab. // The data tab routes through MainContentCoordinator.addNewRow which // calls RowEditingCoordinator.addNewRow (data-only). - if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure { - coordinator?.structureActions?.addRow?() - } else { - coordinator?.addNewRow() + switch selectionOwner { + case .schemaGrid: coordinator?.structureActions?.addRow?() + case .dataGrid: coordinator?.addNewRow() + case .none: break } } @@ -204,24 +204,33 @@ final class MainContentCommandActions { coordinator?.dataTabDelegate?.tableViewCoordinator?.currentRowSelection() ?? selectionState.indices } - /// `selectionState` is shared with the structure and new-table grids, so a row command - /// has to confirm the data grid owns the selection before it acts on data rows. - private var dataGridOwnsSelection: Bool { - GridSelectionOwner.resolve( + /// `selectionState` is shared with the structure and new-table grids, and nothing clears it when + /// the result mode changes, so its indices only mean something once this says whose grid they + /// came from. Every row command routes through it rather than re-deriving the answer. + /// + /// A Create Table tab publishes into the same channel but has no structure handler behind it, + /// so claiming ownership there would make its commands silently inert and would shadow the + /// table-deletion fallback the sidebar still needs. Ownership counts only where someone can act. + private var selectionOwner: GridSelectionOwner { + let owner = GridSelectionOwner.resolve( tabType: coordinator?.tabManager.selectedTab?.tabType, resultsViewMode: coordinator?.tabManager.selectedTab?.display.resultsViewMode - ) == .dataGrid + ) + guard owner == .schemaGrid, coordinator?.structureActions == nil else { return owner } + return .none } + private var dataGridOwnsSelection: Bool { selectionOwner == .dataGrid } + func deleteSelectedRows(rowIndices: Set? = nil) { let fromDataGrid = rowIndices != nil - if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure { + if selectionOwner == .schemaGrid { coordinator?.structureActions?.removeRow?() return } - let indices = rowIndices ?? resolvedRowSelection() + let indices = dataGridOwnsSelection ? (rowIndices ?? resolvedRowSelection()) : [] if !indices.isEmpty { coordinator?.deleteSelectedRows(indices: indices) } else if !fromDataGrid, !selectedTables.wrappedValue.isEmpty { @@ -252,10 +261,10 @@ final class MainContentCommandActions { } func copySelectedRows() { - if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure { - coordinator?.structureActions?.copyRows?() - } else { - coordinator?.copySelectedRowsToClipboard(indices: resolvedRowSelection()) + switch selectionOwner { + case .schemaGrid: coordinator?.structureActions?.copyRows?() + case .dataGrid: coordinator?.copySelectedRowsToClipboard(indices: resolvedRowSelection()) + case .none: break } } @@ -265,14 +274,15 @@ final class MainContentCommandActions { } func copySelectedRowsAsJson() { + guard dataGridOwnsSelection else { return } coordinator?.copySelectedRowsAsJson(indices: resolvedRowSelection()) } func pasteRows() { - if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure { - coordinator?.structureActions?.pasteRows?() - } else { - coordinator?.pasteRows() + switch selectionOwner { + case .schemaGrid: coordinator?.structureActions?.pasteRows?() + case .dataGrid: coordinator?.pasteRows() + case .none: break } } @@ -351,15 +361,24 @@ final class MainContentCommandActions { } var isCurrentTabEditable: Bool { - coordinator?.tabManager.selectedTab?.tableContext.isEditable == true + guard let tab = coordinator?.tabManager.selectedTab, selectionOwner != .none else { return false } + return tab.tableContext.isEditable } - var isTableTab: Bool { - coordinator?.toolbarState.isTableTab ?? false + /// Find and the filter panel act on the result grid, so they need a table tab that is showing + /// one. Chart mode is not, and neither is Structure, whose own grid has its own commands. + var canUseTableResultCommands: Bool { + guard coordinator?.toolbarState.isTableTab == true, + let viewMode = coordinator?.tabManager.selectedTab?.display.resultsViewMode + else { + return false + } + return viewMode.showsRowFilters } var hasActiveGridFind: Bool { - guard isTableTab, let findState = coordinator?.tabManager.selectedTab?.findState else { return false } + guard canUseTableResultCommands, + let findState = coordinator?.tabManager.selectedTab?.findState else { return false } return findState.isVisible && !findState.matches.isEmpty } @@ -370,10 +389,14 @@ final class MainContentCommandActions { guard !safeModeLevel.blocksAllWrites, let tab = coordinator?.tabManager.selectedTab else { return false } - guard tab.display.resultsViewMode != .structure else { + switch selectionOwner { + case .schemaGrid: return coordinator?.structureActions?.pasteRows != nil && TableStructureView.canPasteStructureRows + case .dataGrid: + return tab.tabType == .table && isCurrentTabEditable && ClipboardService.shared.hasText + case .none: + return false } - return tab.tabType == .table && isCurrentTabEditable && ClipboardService.shared.hasText } /// The two facts Save As and Export Results actually turn on. Their menu items used to be @@ -389,7 +412,14 @@ final class MainContentCommandActions { } var hasRowSelection: Bool { - !resolvedRowSelection().isEmpty + selectionOwner != .none && !resolvedRowSelection().isEmpty + } + + /// Copy with headers and copy as JSON read the data grid's columns, so they are only meaningful + /// when the data grid owns the indices. The structure grid has its own plain copy and nothing + /// else; handing it these would read a structure row's position into the result rows. + var hasDataGridRowSelection: Bool { + dataGridOwnsSelection && !resolvedRowSelection().isEmpty } var hasTableSelection: Bool { @@ -800,13 +830,12 @@ final class MainContentCommandActions { // MARK: - Filter Operations (Group A — Called Directly) func toggleFilterPanel() { - guard let coordinator = coordinator, - coordinator.tabManager.selectedTab?.tabType == .table else { return } + guard canUseTableResultCommands, let coordinator else { return } coordinator.toggleFilterPanel() } func showFindBar() { - guard let coordinator, coordinator.tabManager.selectedTab?.tabType == .table else { return } + guard canUseTableResultCommands, let coordinator else { return } coordinator.findCoordinator.show() } diff --git a/TablePro/Views/Results/ResultChartAxisPicker.swift b/TablePro/Views/Results/ResultChartAxisPicker.swift new file mode 100644 index 000000000..b55b5d46b --- /dev/null +++ b/TablePro/Views/Results/ResultChartAxisPicker.swift @@ -0,0 +1,30 @@ +// +// ResultChartAxisPicker.swift +// TablePro +// + +import SwiftUI + +struct ResultChartAxisPicker: View { + let title: String + @Binding var selection: ResultChartColumnID? + let columns: [ResultChartColumn] + let noneLabel: String + /// The Y axis has no "no column" state while any numeric column exists, because the chart falls + /// back to a default. Offering the row anyway gives a choice that silently re-plots something + /// else, so it appears only when there is genuinely nothing to choose. + let allowsNone: Bool + let accessibilityIdentifier: String + + var body: some View { + Picker(title, selection: $selection) { + if allowsNone || columns.isEmpty { + Text(noneLabel).tag(ResultChartColumnID?.none) + } + ForEach(columns) { column in + Text(column.displayName).tag(Optional(column.id)) + } + } + .accessibilityIdentifier(accessibilityIdentifier) + } +} diff --git a/TablePro/Views/Results/ResultChartCanvas.swift b/TablePro/Views/Results/ResultChartCanvas.swift new file mode 100644 index 000000000..799b3e871 --- /dev/null +++ b/TablePro/Views/Results/ResultChartCanvas.swift @@ -0,0 +1,450 @@ +// +// ResultChartCanvas.swift +// TablePro +// + +import Charts +import SwiftUI + +struct ResultChartCanvas: View { + /// Solid first, then patterns that stay legible in monochrome and to a colour-blind reader, + /// because colour alone does not distinguish a series. + private static let seriesDashPatterns: [[CGFloat]] = [ + [], [6, 3], [2, 3], [8, 3, 2, 3], [1, 3], [10, 4], + ] + + let projection: ResultChartProjection + let chartType: ResultChartType + + private let selectionIndex: ResultChartSelectionIndex + private let seriesNames: [String] + + @State private var selectedCategory: String? + @State private var selectedNumber: Double? + @State private var selectedDate: Date? + + init(projection: ResultChartProjection, chartType: ResultChartType) { + self.projection = projection + self.chartType = chartType + selectionIndex = ResultChartSelectionIndex(projection: projection) + seriesNames = Self.orderedSeriesNames(in: projection) + } + + var body: some View { + Group { + switch projection.xAxisKind { + case .category: categoricalChart + case .number: numericChart + case .date: dateChart + } + } + .chartYAxis { + AxisMarks(position: .leading) { + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color(nsColor: .separatorColor).opacity(0.55)) + AxisTick(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color(nsColor: .separatorColor)) + AxisValueLabel() + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } + } + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 7)) { + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color(nsColor: .separatorColor).opacity(0.55)) + AxisTick(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color(nsColor: .separatorColor)) + AxisValueLabel(collisionResolution: .greedy(minimumSpacing: 8)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } + } + .chartPlotStyle { plotArea in + plotArea + .background( + Color(nsColor: .controlBackgroundColor).opacity(0.45), + in: .rect(cornerRadius: 8) + ) + } + .accessibilityElement(children: .contain) + .accessibilityLabel(accessibilitySummary) + .accessibilityIdentifier("result-chart") + } + + private var categoricalChart: some View { + let selection = selectionIndex.selection(forCategory: selectedCategory) + return legend(Chart { + ForEach(projection.points) { point in + if case .category(let x) = point.x { + mark(for: point, x: x) + } + } + if let selection, case .category(let x) = selection.x { + selectionMark(at: x, selection: selection) + } + }) + .chartXSelection(value: $selectedCategory) + } + + private var numericChart: some View { + let selection = selectionIndex.selection(nearestNumber: selectedNumber) + return legend(Chart { + ForEach(projection.points) { point in + if case .number(let x) = point.x { + mark(for: point, x: x) + } + } + if let selection, case .number(let x) = selection.x { + selectionMark(at: x, selection: selection) + } + }) + .chartXSelection(value: $selectedNumber) + } + + private var dateChart: some View { + let selection = selectionIndex.selection(nearestDate: selectedDate) + return legend(Chart { + ForEach(projection.points) { point in + if case .date(let x) = point.x { + mark(for: point, x: x) + } + } + if let selection, case .date(let x) = selection.x { + selectionMark(at: x, selection: selection) + } + }) + .chartXSelection(value: $selectedDate) + } + + /// The per-series stroke lives on the chart's line-style scale, not on the mark: a constant + /// `lineStyle` applied after `lineStyle(by:)` replaces it, which leaves colour as the only thing + /// telling two series apart. + @ViewBuilder + private func legend(_ chart: some View) -> some View { + let styled = chart + .chartLegend(projection.seriesLabel == nil ? .hidden : .visible) + .chartLegend(position: .bottom, alignment: .leading, spacing: 10) + if seriesNames.isEmpty { + styled + } else { + styled.chartLineStyleScale(range: seriesStrokeStyles) + } + } + + private var seriesStrokeStyles: [StrokeStyle] { + seriesNames.indices.map { index in + StrokeStyle( + lineWidth: 2, + lineCap: .round, + lineJoin: .round, + dash: Self.seriesDashPatterns[index % Self.seriesDashPatterns.count] + ) + } + } + + @ChartContentBuilder + private func mark(for point: ResultChartProjection.Point, x: X) -> some ChartContent { + let series = point.series?.displayName + switch chartType { + case .bar: barMark(for: point, x: x, series: series) + case .line: lineMark(for: point, x: x, series: series) + case .area: areaMark(for: point, x: x, series: series) + case .scatter: scatterMark(for: point, x: x, series: series) + } + } + + /// The grouping dimension is a slot, so its value has to be discrete. Swift Charts reads an + /// `Int` as a continuous offset, which drops a repeated category's second bar into the next + /// category's band; the scale orders discrete slots by first appearance, so the ordinal's + /// string keeps them in order. + private func slot(_ point: ResultChartProjection.Point) -> PlottableValue { + .value(String(localized: "Row"), String(point.barGroup)) + } + + private func seriesValue(_ series: String) -> PlottableValue { + .value(projection.seriesLabel ?? String(localized: "Series"), series) + } + + @ChartContentBuilder + private func barMark( + for point: ResultChartProjection.Point, + x: X, + series: String? + ) -> some ChartContent { + if let series { + BarMark( + x: .value(projection.xAxisLabel, x), + y: .value(projection.yAxisLabel, point.y), + stacking: .unstacked + ) + .position(by: slot(point)) + .foregroundStyle(by: seriesValue(series)) + .accessibilityLabel(pointAccessibilityLabel(point, series: series)) + .accessibilityValue(pointAccessibilityValue(point)) + PointMark( + x: .value(projection.xAxisLabel, x), + y: .value(projection.yAxisLabel, point.y) + ) + .position(by: slot(point)) + .foregroundStyle(by: seriesValue(series)) + .symbol(by: seriesValue(series)) + .symbolSize(28) + .accessibilityHidden(true) + } else { + BarMark( + x: .value(projection.xAxisLabel, x), + y: .value(projection.yAxisLabel, point.y), + stacking: .unstacked + ) + .position(by: slot(point)) + .foregroundStyle(Color.accentColor) + .accessibilityLabel(pointAccessibilityLabel(point, series: nil)) + .accessibilityValue(pointAccessibilityValue(point)) + } + } + + @ChartContentBuilder + private func lineMark( + for point: ResultChartProjection.Point, + x: X, + series: String? + ) -> some ChartContent { + if let series { + LineMark( + x: .value(projection.xAxisLabel, x), + y: .value(projection.yAxisLabel, point.y), + series: .value(String(localized: "Line"), point.lineGroup) + ) + .foregroundStyle(by: seriesValue(series)) + .lineStyle(by: seriesValue(series)) + .accessibilityLabel(pointAccessibilityLabel(point, series: series)) + .accessibilityValue(pointAccessibilityValue(point)) + PointMark( + x: .value(projection.xAxisLabel, x), + y: .value(projection.yAxisLabel, point.y) + ) + .foregroundStyle(by: seriesValue(series)) + .symbol(by: seriesValue(series)) + .symbolSize(42) + .accessibilityHidden(true) + } else { + LineMark( + x: .value(projection.xAxisLabel, x), + y: .value(projection.yAxisLabel, point.y), + series: .value(String(localized: "Line"), point.lineGroup) + ) + .foregroundStyle(Color.accentColor) + .lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + .accessibilityLabel(pointAccessibilityLabel(point, series: nil)) + .accessibilityValue(pointAccessibilityValue(point)) + PointMark( + x: .value(projection.xAxisLabel, x), + y: .value(projection.yAxisLabel, point.y) + ) + .foregroundStyle(Color.accentColor) + .symbolSize(42) + .accessibilityHidden(true) + } + } + + /// An area chart is a line chart with a fill under it, so the stroke, the points and the + /// series break all come from `lineMark`; only the fill is different. + @ChartContentBuilder + private func areaMark( + for point: ResultChartProjection.Point, + x: X, + series: String? + ) -> some ChartContent { + if let series { + AreaMark( + x: .value(projection.xAxisLabel, x), + y: .value(projection.yAxisLabel, point.y), + series: .value(String(localized: "Line"), point.lineGroup), + stacking: .unstacked + ) + .foregroundStyle(by: seriesValue(series)) + .opacity(0.18) + .accessibilityHidden(true) + } else { + AreaMark( + x: .value(projection.xAxisLabel, x), + y: .value(projection.yAxisLabel, point.y), + series: .value(String(localized: "Line"), point.lineGroup), + stacking: .unstacked + ) + .foregroundStyle(Color.accentColor.opacity(0.18)) + .accessibilityHidden(true) + } + lineMark(for: point, x: x, series: series) + } + + @ChartContentBuilder + private func scatterMark( + for point: ResultChartProjection.Point, + x: X, + series: String? + ) -> some ChartContent { + if let series { + PointMark( + x: .value(projection.xAxisLabel, x), + y: .value(projection.yAxisLabel, point.y) + ) + .foregroundStyle(by: seriesValue(series)) + .symbol(by: seriesValue(series)) + .symbolSize(48) + .accessibilityLabel(pointAccessibilityLabel(point, series: series)) + .accessibilityValue(pointAccessibilityValue(point)) + } else { + PointMark( + x: .value(projection.xAxisLabel, x), + y: .value(projection.yAxisLabel, point.y) + ) + .foregroundStyle(Color.accentColor) + .symbolSize(48) + .accessibilityLabel(pointAccessibilityLabel(point, series: nil)) + .accessibilityValue(pointAccessibilityValue(point)) + } + } + + @ChartContentBuilder + private func selectionMark(at x: X, selection: ResultChartSelection) -> some ChartContent { + RuleMark(x: .value(projection.xAxisLabel, x)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor).opacity(0.7)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4])) + .annotation( + position: .top, + alignment: .leading, + spacing: 8, + overflowResolution: AnnotationOverflowResolution(x: .fit(to: .chart), y: .fit(to: .chart)) + ) { + ResultChartSelectionCallout( + selection: selection, + xAxisLabel: projection.xAxisLabel, + yAxisLabel: projection.yAxisLabel, + seriesLabel: projection.seriesLabel + ) + } + .accessibilityHidden(true) + } + + static func orderedSeriesNames(in projection: ResultChartProjection) -> [String] { + var seen: Set = [] + return projection.points.compactMap { point in + guard let series = point.series, seen.insert(series).inserted else { return nil } + return series.displayName + } + } + + private func pointAccessibilityLabel(_ point: ResultChartProjection.Point, series: String?) -> String { + var components = ["\(projection.xAxisLabel): \(point.rawX)"] + if let seriesLabel = projection.seriesLabel, let series { + components.append("\(seriesLabel): \(series)") + } + return components.joined(separator: ", ") + } + + private func pointAccessibilityValue(_ point: ResultChartProjection.Point) -> String { + "\(projection.yAxisLabel): \(point.rawY)" + } + + private var accessibilitySummary: String { + String( + format: String(localized: "%1$@ chart of %2$@ by %3$@ with %4$d points"), + chartType.displayName, + projection.yAxisLabel, + projection.xAxisLabel, + projection.points.count + ) + } +} + +#Preview("Bar chart") { + ResultChartPreview(chartType: .bar) +} + +#Preview("Line chart") { + ResultChartPreview(chartType: .line) +} + +#Preview("Area chart") { + ResultChartPreview(chartType: .area) +} + +#Preview("Scatter chart") { + ResultChartPreview(chartType: .scatter) +} + +private struct ResultChartPreview: View { + let chartType: ResultChartType + + var body: some View { + ResultChartCanvas(projection: projection, chartType: chartType) + .frame(width: 900, height: 520) + .padding() + } + + private var projection: ResultChartProjection { + chartType == .scatter ? numericProjection : categoricalProjection + } + + private var categoricalProjection: ResultChartProjection { + let values = [ + ("Jan", 42, "Online"), ("Feb", 68, "Online"), + ("Mar", 57, "Online"), ("Apr", 84, "Online"), + ("Jan", 29, "Retail"), ("Feb", 51, "Retail"), + ("Mar", 46, "Retail"), ("Apr", 66, "Retail"), + ] + let points = values.enumerated().map { index, value in + ResultChartProjection.Point( + sourceIndex: index, + x: .category(value.0), + y: Double(value.1), + rawX: value.0, + rawY: String(value.1), + series: .value(value.2), + barGroup: value.2 == "Online" ? 0 : 1, + lineGroup: value.2 == "Online" ? 0 : 1 + ) + } + return ResultChartProjection( + points: points, + xAxisKind: .category, + limits: [], + loadedRowCount: points.count, + skippedRowCount: 0, + xAxisLabel: "Month", + yAxisLabel: "Revenue", + seriesLabel: "Channel" + ) + } + + private var numericProjection: ResultChartProjection { + let values = [ + (12, 38, "Online"), (18, 54, "Online"), + (26, 63, "Online"), (34, 82, "Online"), + (10, 26, "Retail"), (17, 40, "Retail"), + (24, 49, "Retail"), (31, 64, "Retail"), + ] + let points = values.enumerated().map { index, value in + ResultChartProjection.Point( + sourceIndex: index, + x: .number(Double(value.0)), + y: Double(value.1), + rawX: String(value.0), + rawY: String(value.1), + series: .value(value.2), + barGroup: value.2 == "Online" ? 0 : 1, + lineGroup: value.2 == "Online" ? 0 : 1 + ) + } + return ResultChartProjection( + points: points, + xAxisKind: .number, + limits: [], + loadedRowCount: points.count, + skippedRowCount: 0, + xAxisLabel: "Ad Spend", + yAxisLabel: "Revenue", + seriesLabel: "Channel" + ) + } +} diff --git a/TablePro/Views/Results/ResultChartSelection.swift b/TablePro/Views/Results/ResultChartSelection.swift new file mode 100644 index 000000000..9e4448537 --- /dev/null +++ b/TablePro/Views/Results/ResultChartSelection.swift @@ -0,0 +1,99 @@ +// +// ResultChartSelection.swift +// TablePro +// + +import Foundation + +struct ResultChartSelection: Equatable { + let x: ResultChartProjection.XValue + let rawX: String + let points: [ResultChartProjection.Point] +} + +/// Hover updates arrive continuously while the pointer moves, so the grouping is done once per +/// projection and every frame after that is a dictionary read or a binary search. +struct ResultChartSelectionIndex { + private let categories: [String: ResultChartSelection] + private let numbers: [(value: Double, selection: ResultChartSelection)] + private let dates: [(value: Date, selection: ResultChartSelection)] + + init(projection: ResultChartProjection) { + var order: [ResultChartProjection.XValue] = [] + var grouped: [ResultChartProjection.XValue: [ResultChartProjection.Point]] = [:] + var raw: [ResultChartProjection.XValue: String] = [:] + + for point in projection.points { + if grouped[point.x] == nil { + order.append(point.x) + raw[point.x] = point.rawX + } + grouped[point.x, default: []].append(point) + } + + var categories: [String: ResultChartSelection] = [:] + var numbers: [(value: Double, selection: ResultChartSelection)] = [] + var dates: [(value: Date, selection: ResultChartSelection)] = [] + + for value in order { + let selection = ResultChartSelection( + x: value, + rawX: raw[value] ?? "", + points: grouped[value] ?? [] + ) + switch value { + case .category(let key): categories[key] = selection + case .number(let key): numbers.append((key, selection)) + case .date(let key): dates.append((key, selection)) + } + } + + self.categories = categories + self.numbers = numbers.sorted { $0.value < $1.value } + self.dates = dates.sorted { $0.value < $1.value } + } + + func selection(forCategory category: String?) -> ResultChartSelection? { + guard let category else { return nil } + return categories[category] + } + + /// A continuous axis reports where the pointer is, not a value that exists, so the nearest + /// plotted value wins. + func selection(nearestNumber target: Double?) -> ResultChartSelection? { + guard let target, target.isFinite else { return nil } + return Self.nearest(to: target, in: numbers) { abs($0 - $1) } + } + + func selection(nearestDate target: Date?) -> ResultChartSelection? { + guard let target else { return nil } + return Self.nearest(to: target, in: dates) { abs($0.timeIntervalSince($1)) } + } + + private static func nearest( + to target: Value, + in entries: [(value: Value, selection: ResultChartSelection)], + distance: (Value, Value) -> Double + ) -> ResultChartSelection? { + guard !entries.isEmpty else { return nil } + + var low = entries.startIndex + var high = entries.endIndex + while low < high { + let middle = low + (high - low) / 2 + if entries[middle].value < target { + low = middle + 1 + } else { + high = middle + } + } + + guard low > entries.startIndex else { return entries[low].selection } + guard low < entries.endIndex else { return entries[low - 1].selection } + let before = entries[low - 1] + let after = entries[low] + return distance(after.value, target) < distance(before.value, target) + ? after.selection + : before.selection + } +} diff --git a/TablePro/Views/Results/ResultChartSelectionCallout.swift b/TablePro/Views/Results/ResultChartSelectionCallout.swift new file mode 100644 index 000000000..35c88e0e3 --- /dev/null +++ b/TablePro/Views/Results/ResultChartSelectionCallout.swift @@ -0,0 +1,74 @@ +// +// ResultChartSelectionCallout.swift +// TablePro +// + +import SwiftUI + +struct ResultChartSelectionCallout: View { + private static let maximumVisibleValues = 6 + + let selection: ResultChartSelection + let xAxisLabel: String + let yAxisLabel: String + let seriesLabel: String? + + private var visiblePoints: ArraySlice { + selection.points.prefix(Self.maximumVisibleValues) + } + + private var remainingCount: Int { + max(0, selection.points.count - visiblePoints.count) + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 2) { + Text(xAxisLabel) + .font(.caption) + .foregroundStyle(.secondary) + Text(selection.rawX) + .font(.callout) + .bold() + .lineLimit(2) + .truncationMode(.middle) + } + + Divider() + + ForEach(visiblePoints) { point in + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(valueLabel(for: point)) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer(minLength: 8) + Text(point.rawY) + .monospacedDigit() + .lineLimit(1) + } + .font(.caption) + } + + if remainingCount > 0 { + Text(String(format: String(localized: "%d more"), remainingCount)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: 260, alignment: .leading) + .background(.regularMaterial, in: .rect(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + } + .shadow(color: .black.opacity(0.12), radius: 6, y: 2) + .accessibilityElement(children: .combine) + } + + private func valueLabel(for point: ResultChartProjection.Point) -> String { + guard seriesLabel != nil, let series = point.series else { return yAxisLabel } + return series.displayName + } +} diff --git a/TablePro/Views/Results/ResultChartToolbar.swift b/TablePro/Views/Results/ResultChartToolbar.swift new file mode 100644 index 000000000..06ba0ff88 --- /dev/null +++ b/TablePro/Views/Results/ResultChartToolbar.swift @@ -0,0 +1,174 @@ +// +// ResultChartToolbar.swift +// TablePro +// + +import SwiftUI + +struct ResultChartToolbar: View { + @Binding var configuration: ResultChartConfiguration + let columns: [ResultChartColumn] + let resolved: ResultChartConfiguration.Resolved? + let projection: ResultChartProjection? + + private var xColumns: [ResultChartColumn] { + columns.filter { $0.xAxisKind != nil } + } + + private var yColumns: [ResultChartColumn] { + columns.filter(\.supportsY) + } + + private var seriesColumns: [ResultChartColumn] { + columns.filter(\.supportsSeries) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { + ResultChartTypePicker(selection: chartTypeBinding) + .fixedSize() + + Divider() + .frame(height: 22) + + axisPickers { $0.frame(width: 170) } + } + .fixedSize(horizontal: true, vertical: false) + + VStack(alignment: .leading, spacing: 8) { + ResultChartTypePicker(selection: chartTypeBinding) + .frame(maxWidth: 400, alignment: .leading) + + axisPickers { $0 } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + HStack(spacing: 6) { + ScrollView(.horizontal) { + HStack(spacing: 5) { + ForEach(Array(notices.enumerated()), id: \.offset) { index, notice in + if index > 0 { + Text("·") + .foregroundStyle(.quaternary) + } + Text(notice) + .foregroundStyle(.secondary) + } + } + .fixedSize(horizontal: true, vertical: false) + } + .scrollBounceBehavior(.basedOnSize, axes: .horizontal) + .accessibilityIdentifier("result-chart-data-scope") + + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + .help(String(localized: "Charts draw the rows the result pane has loaded. Grid selection, Find, hidden columns, and value filters do not change the chart.")) + .accessibilityLabel(String(localized: "Chart data scope")) + } + .font(.caption) + .lineLimit(1) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color(nsColor: .controlBackgroundColor)) + } + + @ViewBuilder + private func axisPickers(_ sized: (ResultChartAxisPicker) -> some View) -> some View { + sized(ResultChartAxisPicker( + title: String(localized: "X Axis"), + selection: xColumnBinding, + columns: xColumns, + noneLabel: String(localized: "Row Number"), + allowsNone: true, + accessibilityIdentifier: "result-chart-x-picker" + )) + sized(ResultChartAxisPicker( + title: String(localized: "Y Axis"), + selection: yColumnBinding, + columns: yColumns, + noneLabel: String(localized: "Choose Column"), + allowsNone: false, + accessibilityIdentifier: "result-chart-y-picker" + )) + sized(ResultChartAxisPicker( + title: String(localized: "Series"), + selection: seriesColumnBinding, + columns: seriesColumns, + noneLabel: String(localized: "None"), + allowsNone: true, + accessibilityIdentifier: "result-chart-series-picker" + )) + } + + /// What the chart itself has to say. The row count and the controls that load more rows belong + /// to the status bar, which shows them in chart mode too, so repeating them here would give the + /// same fact two different phrasings. + private var notices: [String] { + guard let projection else { return [] } + var notices = projection.limits.map { Self.noticeText(for: $0, loadedRowCount: projection.loadedRowCount) } + if projection.skippedRowCount > 0 { + notices.append(String(format: String(localized: "%d skipped"), projection.skippedRowCount)) + } + return notices + } + + static func noticeText(for limit: ResultChartProjection.Limit, loadedRowCount: Int) -> String { + switch limit { + case .points(let limit): + return String( + format: String(localized: "Showing the first %1$@ points of %2$@ loaded rows"), + grouped(limit), + grouped(loadedRowCount) + ) + case .series(let limit): + return String(format: String(localized: "Showing the first %@ series"), grouped(limit)) + case .inspectedRows(let limit): + return String( + format: String(localized: "Charting the first %1$@ of %2$@ loaded rows"), + grouped(limit), + grouped(loadedRowCount) + ) + } + } + + private static func grouped(_ value: Int) -> String { + value.formatted(.number.grouping(.automatic)) + } + + private var chartTypeBinding: Binding { + Binding( + get: { configuration.chartType }, + set: { configuration.chartType = $0 } + ) + } + + /// The pickers show what the chart is actually plotting, which is the resolved column rather + /// than the stored preference: a choice whose column is missing from this result is kept so it + /// comes back, but it is not what the reader is looking at. With no numeric column there is no + /// resolution to echo, so the stored preference is shown instead of snapping back to a + /// placeholder the user did not pick. + private var xColumnBinding: Binding { + Binding( + get: { resolved.map { $0.xColumn?.id } ?? configuration.xColumn }, + set: { configuration.xColumn = $0 } + ) + } + + private var yColumnBinding: Binding { + Binding( + get: { resolved?.yColumn.id ?? configuration.yColumn }, + set: { configuration.yColumn = $0 } + ) + } + + private var seriesColumnBinding: Binding { + Binding( + get: { resolved.map { $0.seriesColumn?.id } ?? configuration.seriesColumn }, + set: { configuration.seriesColumn = $0 } + ) + } +} diff --git a/TablePro/Views/Results/ResultChartTypePicker.swift b/TablePro/Views/Results/ResultChartTypePicker.swift new file mode 100644 index 000000000..c5a54179a --- /dev/null +++ b/TablePro/Views/Results/ResultChartTypePicker.swift @@ -0,0 +1,22 @@ +// +// ResultChartTypePicker.swift +// TablePro +// + +import SwiftUI + +struct ResultChartTypePicker: View { + @Binding var selection: ResultChartType + + var body: some View { + Picker(String(localized: "Chart Type"), selection: $selection) { + ForEach(ResultChartType.allCases) { type in + Label(type.displayName, systemImage: type.systemImage) + .tag(type) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .accessibilityIdentifier("result-chart-type-picker") + } +} diff --git a/TablePro/Views/Results/ResultChartView.swift b/TablePro/Views/Results/ResultChartView.swift new file mode 100644 index 000000000..9e6188e8a --- /dev/null +++ b/TablePro/Views/Results/ResultChartView.swift @@ -0,0 +1,148 @@ +// +// ResultChartView.swift +// TablePro +// + +import SwiftUI + +struct ResultChartProjectionKey: Hashable { + let tabId: UUID + let resultSetId: UUID + let dataRevision: Int + let xColumn: ResultChartColumnID? + let yColumn: ResultChartColumnID? + let seriesColumn: ResultChartColumnID? + let isUnlocked: Bool + + init( + tabId: UUID, + resultSetId: UUID, + dataRevision: Int, + resolved: ResultChartConfiguration.Resolved?, + isUnlocked: Bool + ) { + self.tabId = tabId + self.resultSetId = resultSetId + self.dataRevision = dataRevision + xColumn = resolved?.xColumn?.id + yColumn = resolved?.yColumn.id + seriesColumn = resolved?.seriesColumn?.id + self.isUnlocked = isUnlocked + } +} + +struct ResultChartView: View { + @Binding var configuration: ResultChartConfiguration + let tableRows: TableRows + let primaryKeyColumns: Set + let tabId: UUID + let resultSetId: UUID + let dataRevision: Int + let isUnlocked: Bool + + /// Every state the pane can be in has a branch below, so there is no combination that renders + /// nothing. Projection is cancellable but cannot otherwise fail, which its signature enforces. + private enum LoadState: Equatable { + case loading + case loaded(ResultChartProjection) + } + + @State private var state: LoadState = .loading + + private var columns: [ResultChartColumn] { + ResultChartColumn.columns(in: tableRows, primaryKeyColumns: primaryKeyColumns) + } + + private var resolved: ResultChartConfiguration.Resolved? { + configuration.resolved(in: columns) + } + + private var projectionKey: ResultChartProjectionKey { + ResultChartProjectionKey( + tabId: tabId, + resultSetId: resultSetId, + dataRevision: dataRevision, + resolved: resolved, + isUnlocked: isUnlocked + ) + } + + var body: some View { + Group { + if isUnlocked { + VStack(spacing: 0) { + ResultChartToolbar( + configuration: $configuration, + columns: columns, + resolved: resolved, + projection: loadedProjection + ) + Divider() + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } else { + Color.clear + } + } + .requiresPro(.resultCharts) + .task(id: projectionKey) { + await rebuild(for: projectionKey) + } + } + + private var loadedProjection: ResultChartProjection? { + guard case .loaded(let projection) = state else { return nil } + return projection + } + + @ViewBuilder + private var content: some View { + if tableRows.rows.isEmpty { + ContentUnavailableView( + String(localized: "No Data"), + systemImage: "chart.bar.xaxis", + description: Text(String(localized: "Execute a query to chart its loaded rows.")) + ) + } else if resolved == nil { + ContentUnavailableView( + String(localized: "No Numeric Column"), + systemImage: "slider.horizontal.3", + description: Text(String(localized: "Charts need a numeric column for the Y axis. This result has none.")) + ) + } else { + switch state { + case .loading: + ProgressView() + .controlSize(.small) + .accessibilityLabel(String(localized: "Building chart")) + case .loaded(let projection) where projection.points.isEmpty: + ContentUnavailableView( + String(localized: "No Chartable Rows"), + systemImage: "chart.bar.xaxis", + description: Text(String(localized: "The selected axes contain only null, binary, or invalid values.")) + ) + case .loaded(let projection): + ResultChartCanvas(projection: projection, chartType: configuration.chartType) + .id(projectionKey) + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + } + } + + /// A cancelled projection leaves the state alone: `task(id:)` cancels only to start a + /// replacement, and that replacement owns the state from its first line. + private func rebuild(for expectedKey: ResultChartProjectionKey) async { + guard expectedKey.isUnlocked, let configuration = resolved else { return } + state = .loading + guard let output = try? await ResultChartProjector.shared.project( + tableRows: tableRows, + configuration: configuration + ) else { + return + } + guard projectionKey == expectedKey else { return } + state = .loaded(output) + } +} diff --git a/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/TableProTests/Core/Menu/MainMenuBuilderTests.swift index da407b85d..dd723638b 100644 --- a/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -248,12 +248,12 @@ struct MainMenuValidationTests { #expect(enabled(#selector(MainSplitViewController.cancelQuery(_:)), context)) } - @Test("Filter bar is a table-tab command") - func filterBarNeedsTableTab() { + @Test("Filter bar needs an active table result grid") + func filterBarNeedsTableResultGrid() { var context = MenuValidationContext() context.isConnected = true #expect(!enabled(#selector(MainSplitViewController.toggleFilterBar(_:)), context)) - context.isTableTab = true + context.canUseTableResultCommands = true #expect(enabled(#selector(MainSplitViewController.toggleFilterBar(_:)), context)) } @@ -282,7 +282,7 @@ struct MainMenuValidationTests { private func capableContext() -> MenuValidationContext { var context = MenuValidationContext() - context.isTableTab = true + context.canUseTableResultCommands = true context.isQueryTab = true context.hasResultRows = true context.hasQueryText = true @@ -407,17 +407,17 @@ struct MainMenuValidationTests { )) } - @Test("Find needs somewhere to search: an editor, or a table tab to filter") + @Test("Find needs an editor or an active table result grid") func findNeedsAnEditor() { var context = MenuValidationContext() context.isConnected = true #expect(!enabled(#selector(MainSplitViewController.performFind(_:)), context)) #expect(!enabled(#selector(MainSplitViewController.findNext(_:)), context)) #expect(!enabled(#selector(MainSplitViewController.findPrevious(_:)), context)) - context.isTableTab = true + context.canUseTableResultCommands = true #expect(enabled(#selector(MainSplitViewController.performFind(_:)), context)) #expect(!enabled(#selector(MainSplitViewController.findNext(_:)), context)) - context.isTableTab = false + context.canUseTableResultCommands = false context.hasEditorForFind = true #expect(enabled(#selector(MainSplitViewController.performFind(_:)), context)) #expect(enabled(#selector(MainSplitViewController.findNext(_:)), context)) diff --git a/TableProTests/Core/Services/Formatting/DatabaseDateParserTests.swift b/TableProTests/Core/Services/Formatting/DatabaseDateParserTests.swift new file mode 100644 index 000000000..a41a3cd7d --- /dev/null +++ b/TableProTests/Core/Services/Formatting/DatabaseDateParserTests.swift @@ -0,0 +1,80 @@ +// +// DatabaseDateParserTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("DatabaseDateParser") +struct DatabaseDateParserTests { + private static let utc = TimeZone(secondsFromGMT: 0) + + @Test("Reads the spellings MySQL, PostgreSQL, SQLite and SQL Server put on the wire") + func readsEveryWireSpelling() throws { + let parser = DatabaseDateParser() + let spellings = [ + "2024-03-01 12:00:00", + "2024-03-01T12:00:00", + "2024-03-01T12:00:00Z", + "2024-03-01T12:00:00+0700", + "2024-03-01T12:00:00+07:00", + "2024-03-01T12:00:00.123Z", + "2024-03-01 12:00:00+07", + "2024-03-01 12:00:00+07:00", + "2024-03-01 12:00:00.123456", + "2024-03-01 12:00:00.123456+07", + "2024-03-01 12:00:00.5", + "2024-03-01", + "12:00:00", + ] + + for spelling in spellings { + #expect(parser.date(from: spelling) != nil, "\(spelling) should parse") + } + } + + @Test("A space-separated offset is read as the instant it names, like the ISO spelling") + func spaceSeparatedOffsetMatchesIsoSpelling() throws { + let parser = DatabaseDateParser() + let iso = try #require(parser.date(from: "2024-03-01T12:00:00+07:00")) + let spaced = try #require(parser.date(from: "2024-03-01 12:00:00+07:00")) + let shortOffset = try #require(parser.date(from: "2024-03-01 12:00:00+07")) + + #expect(iso == spaced) + #expect(iso == shortOffset) + } + + @Test("A value with no offset is read in the reader's own time zone") + func naiveValuesStayLocal() throws { + let parser = DatabaseDateParser() + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone.current + let parsed = try #require(parser.date(from: "2024-03-01 12:00:00")) + + #expect(calendar.component(.hour, from: parsed) == 12) + #expect(calendar.component(.day, from: parsed) == 1) + } + + @Test("Text that is not a date stays unparsed rather than becoming a plausible one") + func rejectsNonDates() { + let parser = DatabaseDateParser() + + #expect(parser.date(from: "not a date") == nil) + #expect(parser.date(from: "") == nil) + #expect(parser.date(from: "2024-03-01 12:00:00 trailing") == nil) + #expect(parser.date(from: "42") == nil) + } + + @Test("Reuses the last winning pattern without getting stuck on it") + func alternatingSpellingsBothParse() throws { + let parser = DatabaseDateParser() + + for _ in 0 ..< 3 { + #expect(parser.date(from: "2024-03-01 12:00:00") != nil) + #expect(parser.date(from: "2024-03-01") != nil) + #expect(parser.date(from: "2024-03-01T12:00:00Z") != nil) + } + } +} diff --git a/TableProTests/Core/Services/Query/ResultChartProjectorTests.swift b/TableProTests/Core/Services/Query/ResultChartProjectorTests.swift new file mode 100644 index 000000000..299b6ee6f --- /dev/null +++ b/TableProTests/Core/Services/Query/ResultChartProjectorTests.swift @@ -0,0 +1,394 @@ +// +// ResultChartProjectorTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("ResultChartProjector") +struct ResultChartProjectorTests { + @Test("Projects one point per valid row without aggregating duplicate categories") + func projectsRowsWithoutAggregation() async throws { + let rows = makeRows( + values: [ + [.text("A"), .text("10")], + [.text("A"), .text("20")], + [.text("B"), .text("30")], + ], + columns: ["category", "value"], + types: [.text(rawType: "TEXT"), .integer(rawType: "BIGINT")] + ) + let projection = try await project(rows, x: "category", y: "value") + + #expect(projection.limits.isEmpty) + #expect(projection.points.count == 3) + #expect(projection.points.map(\.rawX) == ["A", "A", "B"]) + #expect(projection.points.map(\.rawY) == ["10", "20", "30"]) + #expect(projection.points[0].barGroup != projection.points[1].barGroup) + } + + @Test("Ordinary two-decimal money values plot instead of being dropped as imprecise") + func moneyValuesPlot() async throws { + let prices = ["19.99", "0.07", "1.07", "0.11", "0.21", "5.00", "10.50", "3.25", "7.99", "2.50"] + let rows = makeRows( + values: prices.enumerated().map { [.text("p\($0.offset)"), .text($0.element)] }, + columns: ["product", "price"], + types: [.text(rawType: "TEXT"), .decimal(rawType: "DECIMAL(10,2)")] + ) + let projection = try await project(rows, x: "product", y: "price") + + #expect(projection.points.count == prices.count) + #expect(projection.skippedRowCount == 0) + #expect(projection.points.map(\.rawY) == prices) + } + + @Test("Accepts only values that Swift Charts can plot without precision loss") + func exactChartPrimitives() async throws { + let rows = makeRows( + values: [ + [.text("9007199254740992"), .text("9007199254740992")], + [.text("9007199254740993"), .text("1")], + [.text("2"), .text("9007199254740993")], + [.text("3"), .text("1234567890.125")], + [.text("4"), .text("1234567890.123456789")], + ], + columns: ["x", "y"], + types: [.decimal(rawType: "DECIMAL"), .decimal(rawType: "DECIMAL")] + ) + let projection = try await project(rows, x: "x", y: "y") + + #expect(projection.points.map(\.rawX) == ["9007199254740992", "3"]) + #expect(projection.points.map(\.rawY) == ["9007199254740992", "1234567890.125"]) + #expect(projection.skippedRowCount == 3) + } + + @Test("Numeric-equivalent X spellings receive distinct bar positions") + func equivalentNumericXValues() async throws { + let rows = makeRows( + values: [ + [.text("1"), .text("10")], + [.text("1.0"), .text("20")], + [.text("1e0"), .text("30")], + ], + columns: ["x", "y"], + types: [.integer(rawType: nil), .integer(rawType: nil)] + ) + let projection = try await project(rows, x: "x", y: "y") + + #expect(projection.points.map(\.x) == [.number(1), .number(1), .number(1)]) + #expect(Set(projection.points.map(\.barGroup)).count == 3) + } + + @Test("A date column plots on a temporal axis in chronological order") + func dateAxisIsTemporal() async throws { + let rows = makeRows( + values: [ + [.text("2024-9-1"), .text("10")], + [.text("2024-10-1"), .text("20")], + [.text("2024-03-01 08:30:00+07"), .text("30")], + ], + columns: ["when", "value"], + types: [.timestamp(rawType: "TIMESTAMPTZ"), .integer(rawType: nil)] + ) + let projection = try await project(rows, x: "when", y: "value") + + #expect(projection.xAxisKind == .date) + #expect(projection.points.count == 3) + let dates: [Date] = projection.points.compactMap { + guard case .date(let value) = $0.x else { return nil } + return value + } + #expect(dates.count == 3) + #expect(dates[2] < dates[0]) + #expect(dates[0] < dates[1]) + #expect(projection.points.map(\.rawX) == ["2024-9-1", "2024-10-1", "2024-03-01 08:30:00+07"]) + } + + @Test("An unparseable date is skipped rather than charted as a label") + func unparseableDateIsSkipped() async throws { + let rows = makeRows( + values: [ + [.text("2024-03-01"), .text("10")], + [.text("not a date"), .text("20")], + ], + columns: ["when", "value"], + types: [.date(rawType: "DATE"), .integer(rawType: nil)] + ) + let projection = try await project(rows, x: "when", y: "value") + + #expect(projection.points.map(\.rawX) == ["2024-03-01"]) + #expect(projection.skippedRowCount == 1) + } + + @Test("Bar series keep stable positions when row order changes between categories") + func stableBarSeriesPositions() async throws { + let rows = makeRows( + values: [ + [.text("A"), .text("10"), .text("first")], + [.text("A"), .text("20"), .text("second")], + [.text("B"), .text("30"), .text("second")], + [.text("B"), .text("40"), .text("first")], + ], + columns: ["x", "y", "series"], + types: [.text(rawType: nil), .integer(rawType: nil), .text(rawType: nil)] + ) + let projection = try await project(rows, x: "x", y: "y", series: "series") + + #expect(projection.points[0].barGroup == projection.points[3].barGroup) + #expect(projection.points[1].barGroup == projection.points[2].barGroup) + #expect(projection.points[0].barGroup != projection.points[1].barGroup) + } + + @Test("An invalid row breaks only its own line series") + func invalidRowBreaksItsOwnSeries() async throws { + let otherSeriesInvalid = makeRows( + values: [ + [.text("1"), .text("10"), .text("A")], + [.text("2"), .null, .text("B")], + [.text("3"), .text("30"), .text("A")], + ], + columns: ["x", "y", "series"], + types: [.integer(rawType: nil), .integer(rawType: nil), .text(rawType: nil)] + ) + let ownSeriesInvalid = makeRows( + values: [ + [.text("1"), .text("10"), .text("A")], + [.text("2"), .null, .text("A")], + [.text("3"), .text("30"), .text("A")], + ], + columns: ["x", "y", "series"], + types: [.integer(rawType: nil), .integer(rawType: nil), .text(rawType: nil)] + ) + + let unaffected = try await project(otherSeriesInvalid, x: "x", y: "y", series: "series") + let broken = try await project(ownSeriesInvalid, x: "x", y: "y", series: "series") + + #expect(unaffected.points.count == 2) + #expect(unaffected.points[0].lineGroup == unaffected.points[1].lineGroup) + #expect(broken.points.count == 2) + #expect(broken.points[0].lineGroup != broken.points[1].lineGroup) + } + + @Test("A row whose series cannot be read breaks every line, because it cannot be attributed") + func unreadableSeriesBreaksEveryLine() async throws { + let rows = makeRows( + values: [ + [.text("1"), .text("10"), .text("A")], + [.text("2"), .text("20"), .text("B")], + [.text("3"), .text("30"), .bytes(Data([0x01]))], + [.text("4"), .text("40"), .text("A")], + [.text("5"), .text("50"), .text("B")], + ], + columns: ["x", "y", "series"], + types: [.integer(rawType: nil), .integer(rawType: nil), .text(rawType: nil)] + ) + let projection = try await project(rows, x: "x", y: "y", series: "series") + + #expect(projection.points.count == 4) + #expect(projection.skippedRowCount == 1) + #expect(projection.points[0].lineGroup != projection.points[2].lineGroup) + #expect(projection.points[1].lineGroup != projection.points[3].lineGroup) + } + + @Test("Skips null, binary, invalid, and unrepresentable axis values") + func skipsInvalidAxisValues() async throws { + let rows = makeRows( + values: [ + [.text("A"), .text("1")], + [.null, .text("2")], + [.text("C"), .null], + [.text("D"), .bytes(Data([0x01]))], + [.text("E"), .text("NaN")], + [.text("F"), .text("1e1000")], + [.text("G"), .text("115792089237316195423570985008687907853269984665640564039457584007913129639935")], + [.text("H"), .text("1e3")], + ], + columns: ["category", "value"], + types: [.text(rawType: nil), .decimal(rawType: nil)] + ) + let projection = try await project(rows, x: "category", y: "value") + + #expect(projection.points.map(\.rawX) == ["A", "H"]) + #expect(projection.skippedRowCount == 6) + #expect(projection.points[0].lineGroup != projection.points[1].lineGroup) + } + + @Test("Row number is a one-based numeric X axis") + func rowNumberXAxis() async throws { + let rows = makeRows( + values: [[.text("4")], [.text("8")]], + columns: ["value"], + types: [.integer(rawType: nil)] + ) + let projection = try await project(rows, y: "value") + + #expect(projection.points.map(\.rawX) == ["1", "2"]) + #expect(projection.xAxisKind == .number) + #expect(projection.xAxisLabel == String(localized: "Row Number")) + } + + @Test("Null series values share one stable bucket") + func nullSeriesBucket() async throws { + let rows = makeRows( + values: [ + [.text("1"), .null], + [.text("2"), .null], + [.text("3"), .text("paid")], + ], + columns: ["value", "status"], + types: [.integer(rawType: nil), .text(rawType: nil)] + ) + let projection = try await project(rows, y: "value", series: "status") + + #expect(projection.points.map(\.series) == [.missing, .missing, .value("paid")]) + } + + @Test("Passing the point cap truncates and says so instead of discarding the chart") + func pointLimitTruncates() async throws { + let accepted = try await project( + makeNumericRows(count: ResultChartProjector.maximumPointCount), + y: "value" + ) + let truncated = try await project( + makeNumericRows(count: ResultChartProjector.maximumPointCount + 1), + y: "value" + ) + + #expect(accepted.points.count == ResultChartProjector.maximumPointCount) + #expect(accepted.limits.isEmpty) + #expect(truncated.points.count == ResultChartProjector.maximumPointCount) + #expect(truncated.limits == [.points(limit: ResultChartProjector.maximumPointCount)]) + } + + @Test("Passing the series cap keeps the series already plotted") + func seriesLimitKeepsPlottedSeries() async throws { + let accepted = try await project( + makeSeriesRows(count: ResultChartProjector.maximumSeriesCount), + y: "value", + series: "series" + ) + let truncated = try await project( + makeSeriesRows(count: ResultChartProjector.maximumSeriesCount + 3), + y: "value", + series: "series" + ) + + #expect(accepted.limits.isEmpty) + #expect(truncated.points.count == ResultChartProjector.maximumSeriesCount) + #expect(truncated.limits == [.series(limit: ResultChartProjector.maximumSeriesCount)]) + #expect(Set(truncated.points.compactMap(\.series)).count == ResultChartProjector.maximumSeriesCount) + } + + @Test("Passing the inspection cap charts the prefix it did inspect") + func inspectionLimitChartsThePrefix() async throws { + let rows = makeNumericRows(count: ResultChartProjector.maximumInspectedRowCount + 1) + let projection = try await project(rows, y: "value") + + #expect(projection.limits.contains(.points(limit: ResultChartProjector.maximumPointCount))) + #expect(projection.points.count == ResultChartProjector.maximumPointCount) + } + + @Test("Inspection stops after fifty thousand unusable rows") + func inspectionLimitBoundsUnusableRows() async throws { + let rows = makeRows( + values: Array( + repeating: [PluginCellValue.null], + count: ResultChartProjector.maximumInspectedRowCount + 1 + ), + columns: ["value"], + types: [.integer(rawType: nil)] + ) + let projection = try await project(rows, y: "value") + + #expect(projection.limits == [.inspectedRows(limit: ResultChartProjector.maximumInspectedRowCount)]) + #expect(projection.skippedRowCount == ResultChartProjector.maximumInspectedRowCount) + #expect(projection.points.isEmpty) + } + + @Test("Skips oversized values before building chart labels and group identities") + func oversizedValues() async throws { + let longLabel = String(repeating: "x", count: ResultChartProjector.maximumLabelLength + 1) + let longNumber = String(repeating: "9", count: ResultChartProjector.maximumNumericLength + 1) + let rows = makeRows( + values: [ + [.text("A"), .text("1"), .text("ok")], + [.text(longLabel), .text("2"), .text("ok")], + [.text("C"), .text(longNumber), .text("ok")], + [.text("D"), .text("4"), .text(longLabel)], + ], + columns: ["category", "value", "series"], + types: [.text(rawType: nil), .decimal(rawType: nil), .text(rawType: nil)] + ) + let projection = try await project(rows, x: "category", y: "value", series: "series") + + #expect(projection.points.map(\.rawX) == ["A"]) + #expect(projection.skippedRowCount == 3) + #expect(projection.points[0].barGroup == 0) + #expect(projection.points[0].lineGroup == 0) + } + + @Test("A cancelled projection stops before publishing points") + func cancellation() async throws { + let rows = makeNumericRows(count: 10_000) + let configuration = try resolve(rows, y: "value") + let task = Task { + try await ResultChartProjector.shared.project(tableRows: rows, configuration: configuration) + } + task.cancel() + + await #expect(throws: CancellationError.self) { + try await task.value + } + } + + private func resolve( + _ rows: TableRows, + x: String? = nil, + y: String, + series: String? = nil + ) throws -> ResultChartConfiguration.Resolved { + let configuration = ResultChartConfiguration( + xColumn: x.map { ResultChartColumnID(name: $0, occurrence: 1) }, + yColumn: ResultChartColumnID(name: y, occurrence: 1), + seriesColumn: series.map { ResultChartColumnID(name: $0, occurrence: 1) } + ) + return try #require(configuration.resolved(in: ResultChartColumn.columns(in: rows))) + } + + private func project( + _ rows: TableRows, + x: String? = nil, + y: String, + series: String? = nil + ) async throws -> ResultChartProjection { + let configuration = try resolve(rows, x: x, y: y, series: series) + return try await ResultChartProjector.shared.project(tableRows: rows, configuration: configuration) + } + + private func makeNumericRows(count: Int) -> TableRows { + makeRows( + values: (0.. TableRows { + makeRows( + values: (0.. TableRows { + TableRows.from(queryRows: values, columns: columns, columnTypes: types) + } +} diff --git a/TableProTests/Models/LicenseTierTests.swift b/TableProTests/Models/LicenseTierTests.swift index 343ef0b68..96ad9f984 100644 --- a/TableProTests/Models/LicenseTierTests.swift +++ b/TableProTests/Models/LicenseTierTests.swift @@ -132,6 +132,9 @@ struct LicenseTierTests { #expect(ProFeature.encryptedExport.requiredTier == .starter) #expect(ProFeature.envVarReferences.requiredTier == .starter) #expect(ProFeature.linkedFolders.requiredTier == .starter) + #expect(ProFeature.queryInsights.requiredTier == .starter) + #expect(ProFeature.resultCharts.requiredTier == .starter) #expect(ProFeature.teamCatalog.requiredTier == .team) + #expect(ProFeature.teamLibrary.requiredTier == .team) } } diff --git a/TableProTests/Models/Query/ResultChartConfigurationTests.swift b/TableProTests/Models/Query/ResultChartConfigurationTests.swift new file mode 100644 index 000000000..7935e0a62 --- /dev/null +++ b/TableProTests/Models/Query/ResultChartConfigurationTests.swift @@ -0,0 +1,190 @@ +// +// ResultChartConfigurationTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +@Suite("ResultChartConfiguration") +struct ResultChartConfigurationTests { + @Test("A result defaults to row number and its first typed numeric column") + func defaultConfiguration() throws { + let columns = ResultChartColumn.columns(in: makeRows( + columns: ["name", "amount"], + types: [.text(rawType: nil), .decimal(rawType: nil)] + )) + let resolved = try #require(ResultChartConfiguration().resolved(in: columns)) + + #expect(resolved.xColumn == nil) + #expect(resolved.yColumn.name == "amount") + #expect(resolved.chartType == .bar) + } + + @Test("The default Y axis skips the primary key so the first chart is not a row-id diagonal") + func defaultYSkipsPrimaryKey() throws { + let columns = ResultChartColumn.columns( + in: makeRows( + columns: ["TrackId", "Name", "Milliseconds"], + types: [.integer(rawType: nil), .text(rawType: nil), .integer(rawType: nil)] + ), + primaryKeyColumns: ["TrackId"] + ) + let resolved = try #require(ResultChartConfiguration().resolved(in: columns)) + + #expect(resolved.yColumn.name == "Milliseconds") + } + + @Test("A result whose only numeric column is the key still charts it") + func defaultYFallsBackToTheKey() throws { + let columns = ResultChartColumn.columns( + in: makeRows(columns: ["TrackId", "Name"], types: [.integer(rawType: nil), .text(rawType: nil)]), + primaryKeyColumns: ["TrackId"] + ) + let resolved = try #require(ResultChartConfiguration().resolved(in: columns)) + + #expect(resolved.yColumn.name == "TrackId") + } + + @Test("A tab keeps its chart choices when the result set is replaced") + func configurationSurvivesResultReplacement() { + var tab = QueryTab(title: "Query", tabType: .query) + tab.chartConfiguration.chartType = .scatter + tab.chartConfiguration.xColumn = ResultChartColumnID(name: "name", occurrence: 1) + + tab.display.replaceUnpinnedResults(with: [ResultSet(label: "Second", tableRows: TableRows())]) + + #expect(tab.chartConfiguration.chartType == .scatter) + #expect(tab.chartConfiguration.xColumn == ResultChartColumnID(name: "name", occurrence: 1)) + } + + @Test("Choices follow the column name, so a reordered SELECT cannot chart a different column") + func choicesFollowColumnNames() throws { + let configuration = ResultChartConfiguration( + xColumn: ResultChartColumnID(name: "name", occurrence: 1), + yColumn: ResultChartColumnID(name: "amount", occurrence: 1) + ) + let reordered = ResultChartColumn.columns(in: makeRows( + columns: ["amount", "name"], + types: [.decimal(rawType: nil), .text(rawType: nil)] + )) + let resolved = try #require(configuration.resolved(in: reordered)) + + #expect(resolved.xColumn?.name == "name") + #expect(resolved.yColumn.name == "amount") + } + + @Test("A choice whose column is missing falls back without being erased") + func missingChoiceFallsBackWithoutErasing() throws { + var configuration = ResultChartConfiguration( + xColumn: ResultChartColumnID(name: "month", occurrence: 1), + yColumn: ResultChartColumnID(name: "revenue", occurrence: 1) + ) + let other = ResultChartColumn.columns(in: makeRows( + columns: ["name", "amount"], + types: [.text(rawType: nil), .decimal(rawType: nil)] + )) + let resolved = try #require(configuration.resolved(in: other)) + + #expect(resolved.xColumn == nil) + #expect(resolved.yColumn.name == "amount") + #expect(configuration.yColumn == ResultChartColumnID(name: "revenue", occurrence: 1)) + + configuration.chartType = .line + #expect(configuration.xColumn == ResultChartColumnID(name: "month", occurrence: 1)) + } + + @Test("Changing chart type does not invalidate the data projection") + func chartTypeDoesNotChangeProjectionKey() throws { + let tabId = UUID() + let resultSetId = UUID() + let columns = ResultChartColumn.columns(in: makeRows( + columns: ["name", "amount", "channel"], + types: [.text(rawType: nil), .decimal(rawType: nil), .text(rawType: nil)] + )) + var configuration = ResultChartConfiguration( + chartType: .bar, + xColumn: columns[0].id, + yColumn: columns[1].id, + seriesColumn: columns[2].id + ) + func key() -> ResultChartProjectionKey { + ResultChartProjectionKey( + tabId: tabId, + resultSetId: resultSetId, + dataRevision: 7, + resolved: configuration.resolved(in: columns), + isUnlocked: true + ) + } + + let barKey = key() + configuration.chartType = .line + let lineKey = key() + #expect(barKey == lineKey) + + configuration.xColumn = nil + #expect(key() != lineKey) + } + + @Test("Duplicate column names have stable occurrence labels and identity") + func duplicateColumns() { + let columns = ResultChartColumn.columns(in: makeRows( + columns: ["value", "value", "group"], + types: [.integer(rawType: nil), .decimal(rawType: nil), .text(rawType: nil)] + )) + + #expect(columns.map(\.displayName) == ["value (1)", "value (2)", "group"]) + #expect(columns.map(\.id) == [ + ResultChartColumnID(name: "value", occurrence: 1), + ResultChartColumnID(name: "value", occurrence: 2), + ResultChartColumnID(name: "group", occurrence: 1), + ]) + } + + @Test("A result with no numeric column cannot be charted") + func noNumericColumn() { + let columns = ResultChartColumn.columns(in: makeRows( + columns: ["payload", "name"], + types: [.json(rawType: nil), .text(rawType: nil)] + )) + + #expect(ResultChartConfiguration().resolved(in: columns) == nil) + } + + @Test("Column eligibility follows typed metadata, not the cell spelling") + func columnEligibility() { + let columns = ResultChartColumn.columns(in: makeRows( + columns: ["text", "integer", "decimal", "date", "stamp", "bool", "blob", "json", "spatial", "array"], + types: [ + .text(rawType: nil), + .integer(rawType: nil), + .decimal(rawType: nil), + .date(rawType: nil), + .timestamp(rawType: nil), + .boolean(rawType: nil), + .blob(rawType: nil), + .json(rawType: nil), + .spatial(rawType: nil), + .array(rawType: nil, element: .integer(rawType: nil)), + ] + )) + + #expect(columns.map(\.xAxisKind) == [ + .category, .number, .number, .date, .date, .category, nil, nil, nil, nil, + ]) + #expect(columns.filter(\.supportsY).map(\.name) == ["integer", "decimal"]) + #expect(columns.filter(\.supportsSeries).map(\.name) == ["text", "bool"]) + } + + private func makeRows(columns: [String], types: [ColumnType]) -> TableRows { + TableRows.from( + queryRows: [Array(repeating: PluginCellValue.text("42"), count: columns.count)], + columns: columns, + columnTypes: types + ) + } +} diff --git a/TableProTests/Models/Query/ResultTabBarPolicyTests.swift b/TableProTests/Models/Query/ResultTabBarPolicyTests.swift index de0208c1c..13aa2b4dc 100644 --- a/TableProTests/Models/Query/ResultTabBarPolicyTests.swift +++ b/TableProTests/Models/Query/ResultTabBarPolicyTests.swift @@ -30,6 +30,15 @@ struct ResultTabBarPolicyTests { #expect(ResultTabBarPolicy.canPin(tabType: .query, display: display)) } + @Test("Chart view keeps the strip so each result keeps its own configuration") + func chartViewKeepsStrip() { + var display = Self.makeDisplay() + display.resultsViewMode = .chart + + #expect(ResultTabBarPolicy.showsTabBar(tabType: .query, display: display)) + #expect(ResultTabBarPolicy.canPin(tabType: .query, display: display)) + } + @Test("Structure view has no result strip and nothing to pin") func structureViewHasNoStrip() { var display = Self.makeDisplay() @@ -84,7 +93,7 @@ struct ResultTabBarPolicyTests { @Test("A result is never pinnable without a strip to pin it from") func pinningNeverOutrunsTheStrip() { var states: [TabDisplayState] = [TabDisplayState(), Self.makeDisplay()] - for mode in [ResultsViewMode.data, .structure, .json] { + for mode in [ResultsViewMode.data, .structure, .json, .chart] { var display = Self.makeDisplay() display.resultsViewMode = mode states.append(display) diff --git a/TableProTests/Models/UI/GridSelectionOwnerTests.swift b/TableProTests/Models/UI/GridSelectionOwnerTests.swift index ee5552f5b..1970cb12a 100644 --- a/TableProTests/Models/UI/GridSelectionOwnerTests.swift +++ b/TableProTests/Models/UI/GridSelectionOwnerTests.swift @@ -24,6 +24,23 @@ struct GridSelectionOwnerTests { #expect(GridSelectionOwner.resolve(tabType: .table, resultsViewMode: .json) == .dataGrid) } + @Test("Chart view does not inherit a stale grid selection") + func chartModeHasNoSelectionOwner() { + #expect(GridSelectionOwner.resolve(tabType: .table, resultsViewMode: .chart) == .none) + #expect(GridSelectionOwner.resolve(tabType: .query, resultsViewMode: .chart) == .none) + } + + /// Row editing follows the owner, so this is also the list of modes whose row commands stay + /// live. JSON shows the same rows the data grid owns and keeps them; Chart has no rows to edit. + @Test("Only a mode with an owning grid can edit rows") + func rowEditingFollowsTheOwningGrid() { + let owners = [ResultsViewMode.data, .structure, .json, .chart].map { + GridSelectionOwner.resolve(tabType: .table, resultsViewMode: $0) + } + + #expect(owners == [.dataGrid, .schemaGrid, .dataGrid, GridSelectionOwner.none]) + } + @Test("A query tab's results are data rows") func queryTab() { #expect(GridSelectionOwner.resolve(tabType: .query, resultsViewMode: .data) == .dataGrid) diff --git a/TableProTests/Views/Main/MainStatusBarLayoutTests.swift b/TableProTests/Views/Main/MainStatusBarLayoutTests.swift index c1dab3aa3..3c4fcb18e 100644 --- a/TableProTests/Views/Main/MainStatusBarLayoutTests.swift +++ b/TableProTests/Views/Main/MainStatusBarLayoutTests.swift @@ -55,6 +55,7 @@ struct MainStatusBarLayoutTests { #expect(MainStatusBarView.showsAddRow(viewMode: .data, canAddRow: true)) #expect(!MainStatusBarView.showsAddRow(viewMode: .structure, canAddRow: true)) #expect(!MainStatusBarView.showsAddRow(viewMode: .json, canAddRow: true)) + #expect(!MainStatusBarView.showsAddRow(viewMode: .chart, canAddRow: true)) } @Test("Add Row button is hidden when adding is not allowed") @@ -62,5 +63,27 @@ struct MainStatusBarLayoutTests { #expect(!MainStatusBarView.showsAddRow(viewMode: .data, canAddRow: false)) #expect(!MainStatusBarView.showsAddRow(viewMode: .structure, canAddRow: false)) #expect(!MainStatusBarView.showsAddRow(viewMode: .json, canAddRow: false)) + #expect(!MainStatusBarView.showsAddRow(viewMode: .chart, canAddRow: false)) + } + + @Test("Chart mode keeps the controls that decide which rows it is drawing") + func resultScopeVisibilityByMode() { + #expect(ResultsViewMode.data.showsResultScope) + #expect(ResultsViewMode.json.showsResultScope) + #expect(ResultsViewMode.chart.showsResultScope) + #expect(!ResultsViewMode.structure.showsResultScope) + } + + @Test("Grid-only controls stay with the grid") + func gridControlVisibilityByMode() { + #expect(ResultsViewMode.data.showsColumnControls) + #expect(ResultsViewMode.json.showsColumnControls) + #expect(!ResultsViewMode.chart.showsColumnControls) + #expect(!ResultsViewMode.structure.showsColumnControls) + + #expect(ResultsViewMode.data.showsRowFilters) + #expect(ResultsViewMode.json.showsRowFilters) + #expect(!ResultsViewMode.chart.showsRowFilters) + #expect(!ResultsViewMode.structure.showsRowFilters) } } diff --git a/TableProTests/Views/Main/ResultPinningTests.swift b/TableProTests/Views/Main/ResultPinningTests.swift index 8254ed2c8..7aaf5881c 100644 --- a/TableProTests/Views/Main/ResultPinningTests.swift +++ b/TableProTests/Views/Main/ResultPinningTests.swift @@ -225,7 +225,7 @@ struct ResultPinningTests { let index = try #require(coordinator.tabManager.selectedTabIndex) let result = Self.makeResultSet(label: "Result") - for mode in [ResultsViewMode.data, .json, .structure] { + for mode in [ResultsViewMode.data, .json, .chart, .structure] { coordinator.tabManager.mutate(at: index) { tab in tab.display.resultSets = [result] tab.display.activeResultSetId = result.id diff --git a/TableProTests/Views/Results/ResultChartCanvasTests.swift b/TableProTests/Views/Results/ResultChartCanvasTests.swift new file mode 100644 index 000000000..60da9bbfa --- /dev/null +++ b/TableProTests/Views/Results/ResultChartCanvasTests.swift @@ -0,0 +1,284 @@ +// +// ResultChartCanvasTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +@Suite("ResultChartCanvas") +struct ResultChartCanvasTests { + @Test("Every chart type renders in both appearances", arguments: ResultChartType.allCases, [ColorScheme.light, .dark]) + func renders(type: ResultChartType, colorScheme: ColorScheme) { + let view = ResultChartCanvas(projection: projection, chartType: type) + .frame(width: 640, height: 360) + .environment(\.colorScheme, colorScheme) + .accentColor(.red) + + guard let image = render(view, colorScheme: colorScheme) else { + Issue.record("The \(type.rawValue) chart did not render in \(colorScheme)") + return + } + #expect(image.width == 640) + #expect(image.height == 360) + #expect(chromaticPixelCount(in: image) > 20) + } + + @Test( + "A single point remains visible in line and area charts", + arguments: [ResultChartType.line, .area], [ColorScheme.light, .dark] + ) + func singlePointRemainsVisible(type: ResultChartType, colorScheme: ColorScheme) throws { + let point = ResultChartProjection.Point( + sourceIndex: 0, + x: .category("Only"), + y: 42, + rawX: "Only", + rawY: "42", + series: nil, + barGroup: 0, + lineGroup: 0 + ) + let projection = ResultChartProjection( + points: [point], + xAxisKind: .category, + limits: [], + loadedRowCount: 1, + skippedRowCount: 0, + xAxisLabel: "Category", + yAxisLabel: "Amount", + seriesLabel: nil + ) + let view = ResultChartCanvas(projection: projection, chartType: type) + .frame(width: 640, height: 360) + .environment(\.colorScheme, colorScheme) + .accentColor(.red) + + let image = try #require(render(view, colorScheme: colorScheme)) + + #expect(chromaticPixelCount(in: image) > 12) + } + + @Test( + "Numeric X renders every chart type in both appearances", + arguments: ResultChartType.allCases, + [ColorScheme.light, .dark] + ) + func numericXRendersEveryChartType(type: ResultChartType, colorScheme: ColorScheme) throws { + let projection = ResultChartProjection( + points: [ + numericPoint(index: 0, x: 1, y: 10), + numericPoint(index: 1, x: 2, y: 18), + numericPoint(index: 2, x: 3, y: 14), + ], + xAxisKind: .number, + limits: [], + loadedRowCount: 3, + skippedRowCount: 0, + xAxisLabel: "Distance", + yAxisLabel: "Amount", + seriesLabel: nil + ) + let view = ResultChartCanvas(projection: projection, chartType: type) + .frame(width: 640, height: 360) + .environment(\.colorScheme, colorScheme) + .accentColor(.red) + + let image = try #require(render(view, colorScheme: colorScheme)) + + #expect(image.width == 640) + #expect(image.height == 360) + #expect(chromaticPixelCount(in: image) > 20) + } + + @Test( + "A date axis renders every chart type in both appearances", + arguments: ResultChartType.allCases, + [ColorScheme.light, .dark] + ) + func dateXRendersEveryChartType(type: ResultChartType, colorScheme: ColorScheme) throws { + let start = Date(timeIntervalSince1970: 1_704_067_200) + let projection = ResultChartProjection( + points: (0 ..< 4).map { index in + ResultChartProjection.Point( + sourceIndex: index, + x: .date(start.addingTimeInterval(Double(index) * 86_400)), + y: Double(10 + index * 4), + rawX: "2024-01-0\(index + 1)", + rawY: String(10 + index * 4), + series: nil, + barGroup: 0, + lineGroup: 0 + ) + }, + xAxisKind: .date, + limits: [], + loadedRowCount: 4, + skippedRowCount: 0, + xAxisLabel: "Invoiced", + yAxisLabel: "Total", + seriesLabel: nil + ) + let view = ResultChartCanvas(projection: projection, chartType: type) + .frame(width: 640, height: 360) + .environment(\.colorScheme, colorScheme) + .accentColor(.red) + + let image = try #require(render(view, colorScheme: colorScheme)) + + #expect(image.width == 640) + #expect(image.height == 360) + #expect(chromaticPixelCount(in: image) > 20) + } + + @Test("A repeated category's second bar stays inside its own band") + func repeatedCategoryBarsStayBanded() throws { + let projection = ResultChartProjection( + points: [ + bandPoint(index: 0, x: "paid", y: 100, group: 0), + bandPoint(index: 1, x: "paid", y: 60, group: 1), + bandPoint(index: 2, x: "pending", y: 30, group: 0), + ], + xAxisKind: .category, + limits: [], + loadedRowCount: 3, + skippedRowCount: 0, + xAxisLabel: "Status", + yAxisLabel: "Amount", + seriesLabel: nil + ) + let view = ResultChartCanvas(projection: projection, chartType: .bar) + .frame(width: 700, height: 360) + .environment(\.colorScheme, ColorScheme.light) + .accentColor(.red) + let image = try #require(render(view, colorScheme: .light)) + let bitmap = NSBitmapImageRep(cgImage: image) + + var columns: [Int] = [] + for x in 0 ..< bitmap.pixelsWide { + for y in 0 ..< bitmap.pixelsHigh { + guard let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.sRGB), + color.saturationComponent > 0.35, color.brightnessComponent > 0.25, + color.alphaComponent > 0.5 + else { + continue + } + columns.append(x) + break + } + } + let bars = contiguousRuns(in: columns) + + #expect(bars.count == 3) + let widths = bars.map { $0.upperBound - $0.lowerBound } + let narrowest = try #require(widths.min()) + let widest = try #require(widths.max()) + #expect(widest - narrowest <= 4) + } + + private func bandPoint(index: Int, x: String, y: Int, group: Int) -> ResultChartProjection.Point { + ResultChartProjection.Point( + sourceIndex: index, + x: .category(x), + y: Double(y), + rawX: x, + rawY: String(y), + series: nil, + barGroup: group, + lineGroup: 0 + ) + } + + private func contiguousRuns(in columns: [Int]) -> [Range] { + var runs: [Range] = [] + var start: Int? + var previous: Int? + for column in columns { + if let last = previous, column > last + 1, let began = start { + runs.append(began ..< last + 1) + start = column + } + if start == nil { start = column } + previous = column + } + if let began = start, let last = previous { runs.append(began ..< last + 1) } + return runs + } + + private var projection: ResultChartProjection { + ResultChartProjection( + points: [ + point(index: 0, x: "A", y: 10, series: "First"), + point(index: 1, x: "B", y: 20, series: "First"), + point(index: 2, x: "A", y: 15, series: "Second"), + point(index: 3, x: "B", y: 25, series: "Second"), + ], + xAxisKind: .category, + limits: [], + loadedRowCount: 4, + skippedRowCount: 0, + xAxisLabel: "Category", + yAxisLabel: "Amount", + seriesLabel: "Group" + ) + } + + private func point(index: Int, x: String, y: Int, series: String) -> ResultChartProjection.Point { + ResultChartProjection.Point( + sourceIndex: index, + x: .category(x), + y: Double(y), + rawX: x, + rawY: String(y), + series: .value(series), + barGroup: index, + lineGroup: series == "First" ? 0 : 1 + ) + } + + private func numericPoint(index: Int, x: Int, y: Int) -> ResultChartProjection.Point { + ResultChartProjection.Point( + sourceIndex: index, + x: .number(Double(x)), + y: Double(y), + rawX: String(x), + rawY: String(y), + series: nil, + barGroup: 0, + lineGroup: 0 + ) + } + + private func render(_ content: Content, colorScheme: ColorScheme) -> CGImage? { + let appearanceName: NSAppearance.Name = colorScheme == .dark ? .darkAqua : .aqua + guard let appearance = NSAppearance(named: appearanceName) else { return nil } + var image: CGImage? + appearance.performAsCurrentDrawingAppearance { + let renderer = ImageRenderer(content: content) + renderer.scale = 1 + image = renderer.cgImage + } + return image + } + + private func chromaticPixelCount(in image: CGImage) -> Int { + matchingPixelCount(in: image) { color in + color.saturationComponent > 0.35 && color.brightnessComponent > 0.25 && color.alphaComponent > 0.5 + } + } + + private func matchingPixelCount(in image: CGImage, matches: (NSColor) -> Bool) -> Int { + let bitmap = NSBitmapImageRep(cgImage: image) + var count = 0 + for y in 0.. [String: Any] { + let url = try Self.repoRoot() + .appendingPathComponent("TablePro/Resources/Localizable.xcstrings") + let data = try Data(contentsOf: url) + let catalog = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + let strings = try #require(catalog["strings"] as? [String: Any]) + return try #require(strings[key] as? [String: Any]) + } + + private static func repoRoot() throws -> URL { + var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0 ..< 12 { + if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { + return directory + } + directory = directory.deletingLastPathComponent() + } + throw CatalogError.repoRootNotFound + } + + private enum CatalogError: Error { + case repoRootNotFound + } +} diff --git a/TableProTests/Views/Results/ResultChartSelectionTests.swift b/TableProTests/Views/Results/ResultChartSelectionTests.swift new file mode 100644 index 000000000..b83df5164 --- /dev/null +++ b/TableProTests/Views/Results/ResultChartSelectionTests.swift @@ -0,0 +1,130 @@ +// +// ResultChartSelectionTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Result chart selection") +struct ResultChartSelectionTests { + @Test("Categorical selection includes every series at the selected X value") + func categoricalSelectionIncludesEverySeries() throws { + let index = ResultChartSelectionIndex(projection: projection(points: [ + point(index: 0, x: .category("Jan"), rawX: "Jan", y: 10, series: "Online"), + point(index: 1, x: .category("Jan"), rawX: "Jan", y: 12, series: "Retail"), + point(index: 2, x: .category("Feb"), rawX: "Feb", y: 14, series: "Online"), + ])) + + let selection = try #require(index.selection(forCategory: "Jan")) + + #expect(selection.rawX == "Jan") + #expect(selection.points.map(\.sourceIndex) == [0, 1]) + } + + @Test("Unknown categorical selection produces no callout") + func unknownCategoricalSelectionProducesNothing() { + let index = ResultChartSelectionIndex(projection: projection(points: [ + point(index: 0, x: .category("Jan"), rawX: "Jan", y: 10), + ])) + + #expect(index.selection(forCategory: "Feb") == nil) + #expect(index.selection(forCategory: nil) == nil) + } + + @Test("Numeric selection resolves the nearest plotted X value") + func numericSelectionResolvesNearestValue() throws { + let index = ResultChartSelectionIndex(projection: projection(points: [ + point(index: 0, x: .number(10), rawX: "10", y: 1), + point(index: 1, x: .number(20), rawX: "20", y: 2), + point(index: 2, x: .number(20), rawX: "20.0", y: 3), + ])) + + let selection = try #require(index.selection(nearestNumber: 18)) + + #expect(selection.x == .number(20)) + #expect(selection.rawX == "20") + #expect(selection.points.map(\.sourceIndex) == [1, 2]) + } + + @Test("A pointer exactly between two values resolves to the lower one") + func numericTieResolvesToTheLowerValue() throws { + let index = ResultChartSelectionIndex(projection: projection(points: [ + point(index: 4, x: .number(20), rawX: "20", y: 2), + point(index: 2, x: .number(10), rawX: "10", y: 1), + ])) + + let selection = try #require(index.selection(nearestNumber: 15)) + + #expect(selection.x == .number(10)) + } + + @Test("A pointer beyond the plotted range clamps to the end value") + func numericSelectionClampsToRange() throws { + let index = ResultChartSelectionIndex(projection: projection(points: [ + point(index: 0, x: .number(10), rawX: "10", y: 1), + point(index: 1, x: .number(20), rawX: "20", y: 2), + ])) + + #expect(try #require(index.selection(nearestNumber: -500)).x == .number(10)) + #expect(try #require(index.selection(nearestNumber: 500)).x == .number(20)) + } + + @Test("Missing and nonfinite numeric selections produce no callout") + func missingAndNonfiniteNumericSelectionsProduceNothing() { + let index = ResultChartSelectionIndex(projection: projection(points: [ + point(index: 0, x: .number(10), rawX: "10", y: 1), + ])) + + #expect(index.selection(nearestNumber: nil) == nil) + #expect(index.selection(nearestNumber: .nan) == nil) + } + + @Test("Date selection resolves the nearest plotted instant") + func dateSelectionResolvesNearestInstant() throws { + let january = Date(timeIntervalSince1970: 1_704_067_200) + let march = Date(timeIntervalSince1970: 1_709_251_200) + let index = ResultChartSelectionIndex(projection: projection(points: [ + point(index: 0, x: .date(january), rawX: "2024-01-01", y: 1), + point(index: 1, x: .date(march), rawX: "2024-03-01", y: 2), + ])) + + let selection = try #require(index.selection(nearestDate: march.addingTimeInterval(-3_600))) + + #expect(selection.x == .date(march)) + #expect(index.selection(nearestDate: nil) == nil) + } + + private func projection(points: [ResultChartProjection.Point]) -> ResultChartProjection { + ResultChartProjection( + points: points, + xAxisKind: .category, + limits: [], + loadedRowCount: points.count, + skippedRowCount: 0, + xAxisLabel: "Month", + yAxisLabel: "Revenue", + seriesLabel: "Channel" + ) + } + + private func point( + index: Int, + x: ResultChartProjection.XValue, + rawX: String, + y: Int, + series: String? = nil + ) -> ResultChartProjection.Point { + ResultChartProjection.Point( + sourceIndex: index, + x: x, + y: Double(y), + rawX: rawX, + rawY: String(y), + series: series.map(ResultChartProjection.SeriesValue.value), + barGroup: 0, + lineGroup: 0 + ) + } +} diff --git a/TableProTests/Views/Results/ResultChartToolbarTests.swift b/TableProTests/Views/Results/ResultChartToolbarTests.swift new file mode 100644 index 000000000..1bca392ad --- /dev/null +++ b/TableProTests/Views/Results/ResultChartToolbarTests.swift @@ -0,0 +1,109 @@ +// +// ResultChartToolbarTests.swift +// TableProTests +// + +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +@Suite("Result chart toolbar") +struct ResultChartToolbarTests { + @Test("Controls stack when the result pane is narrow") + func controlsStackAtNarrowWidth() throws { + let wideHeight = try renderedHeight(width: 1_000) + let narrowHeight = try renderedHeight(width: 400) + + #expect(wideHeight > 0) + #expect(narrowHeight > wideHeight + 40) + #expect(narrowHeight < 220) + } + + /// The counts are grouped for readability, and the separator is the reader's, so the expectation + /// is built the same way rather than hard-coding a comma that only holds in some regions. + @Test("A cap is reported as what is shown, not as a failure") + func capsReadAsPartialResults() { + func grouped(_ value: Int) -> String { value.formatted(.number.grouping(.automatic)) } + + #expect( + ResultChartToolbar.noticeText(for: .points(limit: 2_000), loadedRowCount: 8_431) + == "Showing the first \(grouped(2_000)) points of \(grouped(8_431)) loaded rows" + ) + #expect( + ResultChartToolbar.noticeText(for: .series(limit: 20), loadedRowCount: 100) + == "Showing the first \(grouped(20)) series" + ) + #expect( + ResultChartToolbar.noticeText(for: .inspectedRows(limit: 50_000), loadedRowCount: 90_000) + == "Charting the first \(grouped(50_000)) of \(grouped(90_000)) loaded rows" + ) + } + + @Test("The point cap names the cap first and the loaded total second") + func pointCapArgumentOrder() throws { + let notice = ResultChartToolbar.noticeText(for: .points(limit: 2_000), loadedRowCount: 8_431) + let cap = try #require(notice.range(of: 2_000.formatted(.number.grouping(.automatic)))) + let total = try #require(notice.range(of: 8_431.formatted(.number.grouping(.automatic)))) + + #expect(cap.lowerBound < total.lowerBound) + } + + private func columns() -> [ResultChartColumn] { + [ + ResultChartColumn( + id: ResultChartColumnID(name: "category", occurrence: 1), + index: 0, + displayName: "A long category column name", + type: .text(rawType: nil), + isPrimaryKey: false + ), + ResultChartColumn( + id: ResultChartColumnID(name: "amount", occurrence: 1), + index: 1, + displayName: "A long numeric column name", + type: .decimal(rawType: nil), + isPrimaryKey: false + ), + ResultChartColumn( + id: ResultChartColumnID(name: "series", occurrence: 1), + index: 2, + displayName: "A long series column name", + type: .text(rawType: nil), + isPrimaryKey: false + ), + ] + } + + private func renderedHeight(width: CGFloat) throws -> Int { + let all = columns() + let configuration = ResultChartConfiguration( + chartType: .bar, + xColumn: all[0].id, + yColumn: all[1].id, + seriesColumn: all[2].id + ) + let toolbar = ResultChartToolbar( + configuration: .constant(configuration), + columns: all, + resolved: configuration.resolved(in: all), + projection: ResultChartProjection( + points: [], + xAxisKind: .category, + limits: [.points(limit: 2_000)], + loadedRowCount: 40, + skippedRowCount: 2, + xAxisLabel: "A long category column name", + yAxisLabel: "A long numeric column name", + seriesLabel: "A long series column name" + ) + ) + .frame(width: width) + .fixedSize(horizontal: false, vertical: true) + let renderer = ImageRenderer(content: toolbar) + renderer.scale = 1 + renderer.proposedSize = ProposedViewSize(width: width, height: nil) + + return try #require(renderer.cgImage).height + } +} diff --git a/TableProUITests/ResultChartUITests.swift b/TableProUITests/ResultChartUITests.swift new file mode 100644 index 000000000..ae8188ee8 --- /dev/null +++ b/TableProUITests/ResultChartUITests.swift @@ -0,0 +1,53 @@ +// +// ResultChartUITests.swift +// TableProUITests +// + +import XCTest + +final class ResultChartUITests: UITestCase { + func testAnUnlicensedChartBuildsNoResultData() throws { + let app = try launchWithSampleDatabase() + runQuery(in: app) + + let window = app.windows.firstMatch + let modePicker = window.radioGroups["results-view-mode-picker"].firstMatch + XCTAssertTrue(modePicker.waitForExistence(timeout: 10), "The result must expose its view modes") + + let chart = modePicker.radioButtons["Chart"].firstMatch + XCTAssertTrue( + waitUntilHittable(chart, timeout: 10), + "Column-bearing results must offer an interactive Chart mode" + ) + chart.click() + + let gate = window.staticTexts["pro-feature-gate-resultCharts"].firstMatch + XCTAssertTrue(gate.waitForExistence(timeout: 10), "An unlicensed chart must show its license gate") + XCTAssertFalse(window.descendants(matching: .any).matching(identifier: "result-chart").firstMatch.exists) + XCTAssertFalse( + window.descendants(matching: .any).matching(identifier: "result-chart-type-picker").firstMatch.exists, + "Locked chart controls must not expose result metadata behind the license gate" + ) + } + + private func runQuery(in app: XCUIApplication) { + app.typeKey("t", modifierFlags: .command) + let editor = editorTextView(in: app) + XCTAssertTrue(editor.waitForExistence(timeout: 10)) + editor.click() + app.typeText("SELECT Name, Milliseconds FROM Track ORDER BY TrackId LIMIT 12;") + app.typeKey(.return, modifierFlags: .command) + + let grid = app.windows.firstMatch.tables.matching(identifier: "data-grid").firstMatch + XCTAssertTrue(grid.waitForExistence(timeout: 15), "The query must produce a result grid") + } + + private func editorTextView(in app: XCUIApplication) -> XCUIElement { + let window = app.windows.firstMatch + let identified = window.textViews.matching(identifier: "sql-editor-textview").firstMatch + if identified.exists { + return identified + } + return window.textViews.firstMatch + } +} diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index f503688f4..0abd827f4 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -12,10 +12,18 @@ Query results and table data open in a spreadsheet-style grid. Sort and filter c ## View Modes -Switch between **Data**, **Structure**, and **JSON** in the status bar. Query tabs show Data and JSON only. The mode is remembered per tab. +Switch between **Data**, **Structure**, **JSON**, and **Chart** in the status bar. Query tabs show Data, JSON, and Chart only. The mode is remembered per tab. JSON mode shows the rows as a JSON array, with **Text** and **Tree** views. Select rows in Data mode first to limit what JSON shows, then use **Copy JSON**. +### Chart mode + +Chart mode is a Starter feature. Pick a bar, line, area, or scatter chart, then choose a numeric Y column. X can use row numbers, another numeric column, a date or timestamp column, or a categorical column; an optional text, boolean, enum, or set column splits the result into series. Date and timestamp columns plot on a real time axis, so points sit at their true spacing whatever order the rows arrive in. Hover the plot to inspect exact X, Y and series values. Controls stack vertically when the result pane is narrow. + +Your chart type and axis choices belong to the tab and follow the column names, so they survive a page turn, a sort, a refresh and a re-run. A column that is missing from the next result is remembered rather than cleared, and comes back when the column does. + +Charts draw the active result's loaded rows, up to 2,000 points, 20 series, and 50,000 inspected rows. Past a limit the chart still draws what fits and the toolbar says how much, for example "Showing the first 2,000 points of 8,431 loaded rows". Null, binary and unrepresentable axis values are skipped and counted. The status bar keeps the row count, the pagination controls and **Fetch All** in Chart mode, so you can load the rest without leaving the chart. Grid selection, Find, hidden columns and value filters do not narrow the chart. + ## Columns ### Sort diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index a6818aaa3..c0ed70288 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -16,6 +16,7 @@ There are two tiers, Starter and Team. Team includes everything in Starter. | [Environment variables in connection fields](/features/connection-sharing) | Starter | | [Linked Folders](/features/connection-sharing) | Starter | | [Query Insights](/features/query-insights) | Starter | +| [Result charts](/features/data-grid#chart-mode) | Starter | | [Team Catalog](/features/connection-sharing) | Team | | [Team Library](/features/team) | Team |