From 644b4b4335e226a961ef1d7e4905dfde28a5d8c2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 19 Oct 2026 21:09:42 +0700 Subject: [PATCH 1/2] feat(datagrid): split result charts into one panel per column value --- .../Query/ResultChartProjection.swift | 185 ++++++++++++- .../Services/Query/ResultChartProjector.swift | 252 ++++++++++++------ .../Models/Query/ResultChartColumn.swift | 7 +- .../Query/ResultChartConfiguration.swift | 23 +- .../Models/Query/ResultChartLabels.swift | 28 ++ .../TablePro/Resources/Localizable.xcstrings | 120 +++++++++ .../Views/Results/ResultChartAxisPicker.swift | 20 +- .../Views/Results/ResultChartCanvas.swift | 165 +++++++++--- .../Views/Results/ResultChartFacetGrid.swift | 112 ++++++++ .../Views/Results/ResultChartLegend.swift | 93 +++++++ .../Views/Results/ResultChartSelection.swift | 12 +- .../Results/ResultChartSelectionCallout.swift | 4 +- .../Results/ResultChartSeriesStyle.swift | 77 ++++++ .../Views/Results/ResultChartToolbar.swift | 25 +- .../Views/Results/ResultChartView.swift | 90 ++++--- .../Views/Results/ResultMapView.swift | 78 +++--- .../Results/ResultPaneProjectionState.swift | 39 +++ .../Query/ResultChartProjectorTests.swift | 154 ++++++++++- .../Query/ResultChartConfigurationTests.swift | 26 ++ .../Models/Query/ResultChartLabelsTests.swift | 22 ++ .../Results/ResultChartCanvasTests.swift | 177 +++++++++++- .../Results/ResultChartFacetLayoutTests.swift | 42 +++ .../Results/ResultChartSelectionTests.swift | 47 ++-- .../Results/ResultChartSeriesStyleTests.swift | 32 +++ .../Results/ResultChartToolbarTests.swift | 18 ++ .../ResultPaneProjectionStateTests.swift | 62 +++++ 26 files changed, 1652 insertions(+), 258 deletions(-) create mode 100644 apple/TablePro/Models/Query/ResultChartLabels.swift create mode 100644 apple/TablePro/Views/Results/ResultChartFacetGrid.swift create mode 100644 apple/TablePro/Views/Results/ResultChartLegend.swift create mode 100644 apple/TablePro/Views/Results/ResultChartSeriesStyle.swift create mode 100644 apple/TablePro/Views/Results/ResultPaneProjectionState.swift create mode 100644 apple/TableProTests/Models/Query/ResultChartLabelsTests.swift create mode 100644 apple/TableProTests/Views/Results/ResultChartFacetLayoutTests.swift create mode 100644 apple/TableProTests/Views/Results/ResultChartSeriesStyleTests.swift create mode 100644 apple/TableProTests/Views/Results/ResultPaneProjectionStateTests.swift diff --git a/apple/TablePro/Core/Services/Query/ResultChartProjection.swift b/apple/TablePro/Core/Services/Query/ResultChartProjection.swift index 7461d4e8c4..0786ebbd13 100644 --- a/apple/TablePro/Core/Services/Query/ResultChartProjection.swift +++ b/apple/TablePro/Core/Services/Query/ResultChartProjection.swift @@ -12,17 +12,60 @@ struct ResultChartProjection: Equatable, Sendable { case date(Date) } - enum SeriesValue: Equatable, Hashable, Sendable { + /// A series or facet cell. It stays apart from its label, so a NULL and a literal "No value" are + /// two groups even though both would read the same. + enum GroupValue: 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 { + /// The data grid's words for the two cells that have no text of their own. + var baseLabel: String { switch self { + case .value(let raw) where raw.isEmpty: return String(localized: "Empty") case .value(let raw): return raw case .missing: return String(localized: "No value") } } + + /// The order that does not depend on row order. Series colours and duplicate-label numbers + /// follow it, so a re-sort or a page turn that brings another value first changes neither. + static func areInIncreasingOrder(_ lhs: GroupValue, _ rhs: GroupValue) -> Bool { + switch (lhs, rhs) { + case (.value(let left), .value(let right)): + switch left.localizedStandardCompare(right) { + case .orderedAscending: return true + case .orderedDescending: return false + case .orderedSame: return left < right + } + case (.value, .missing): + return true + case (.missing, _): + return false + } + } + } + + /// What the legend, the panel title, the callout and VoiceOver show for a group, and the value + /// the chart plots it under. Labels are unique within a projection: two groups that would read + /// the same are numbered the way duplicate column names are. + struct Group: Hashable, Sendable { + let value: GroupValue + let label: String + + init(_ value: GroupValue, label: String? = nil) { + self.value = value + self.label = label ?? value.baseLabel + } + + /// Labels distinct values, in the order given, numbering duplicates in the row-order-free + /// order so "No value (1)" names the same group after a re-sort. + static func labeled(_ values: [GroupValue]) -> [Group] { + let sorted = values.sorted(by: GroupValue.areInIncreasingOrder) + let labels = Dictionary( + uniqueKeysWithValues: zip(sorted, ResultChartLabels.unique(sorted.map(\.baseLabel))) + ) + return values.map { Group($0, label: labels[$0]) } + } } /// A bound the projection ran into. The points already gathered are kept and drawn: a result one @@ -30,6 +73,7 @@ struct ResultChartProjection: Equatable, Sendable { enum Limit: Equatable, Sendable { case points(limit: Int) case series(limit: Int) + case facets(limit: Int) case inspectedRows(limit: Int) } @@ -39,11 +83,87 @@ struct ResultChartProjection: Equatable, Sendable { let y: Double let rawX: String let rawY: String - let series: SeriesValue? + let series: Group? + let facet: Group? let barGroup: Int let lineGroup: Int + init( + sourceIndex: Int, + x: XValue, + y: Double, + rawX: String, + rawY: String, + series: Group?, + facet: Group? = nil, + barGroup: Int, + lineGroup: Int + ) { + self.sourceIndex = sourceIndex + self.x = x + self.y = y + self.rawX = rawX + self.rawY = rawY + self.series = series + self.facet = facet + self.barGroup = barGroup + self.lineGroup = lineGroup + } + var id: Int { sourceIndex } + + /// A category plots under its label, which names an empty string; numbers and dates read as + /// the database wrote them. + var xLabel: String { + if case .category(let label) = x { return label } + return rawX + } + } + + /// One chart of the grid. An unfaceted projection is a single panel with no facet. + struct Panel: Identifiable, Equatable, Sendable { + let facet: Group? + let points: [Point] + + var id: String { facet?.label ?? "" } + } + + /// The extent of every panel's points, so panels drawn as separate charts share their axes. + struct Domain: Equatable, Sendable { + let categories: [String] + let numbers: ClosedRange? + let dates: ClosedRange? + let y: ClosedRange? + + init(points: [Point]) { + var categories: [String] = [] + var seenCategories: Set = [] + var numbers: ClosedRange? + var dates: ClosedRange? + var y: ClosedRange? + + for point in points { + switch point.x { + case .category(let label): + if seenCategories.insert(label).inserted { categories.append(label) } + case .number(let value): + numbers = Self.extend(numbers, with: value) + case .date(let value): + dates = Self.extend(dates, with: value) + } + y = Self.extend(y, with: point.y) + } + + self.categories = categories + self.numbers = numbers + self.dates = dates + self.y = y + } + + private static func extend(_ range: ClosedRange?, with value: Bound) -> ClosedRange { + guard let range else { return value...value } + return min(range.lowerBound, value)...max(range.upperBound, value) + } } let points: [Point] @@ -54,4 +174,61 @@ struct ResultChartProjection: Equatable, Sendable { let xAxisLabel: String let yAxisLabel: String let seriesLabel: String? + let facetLabel: String? + /// False for row numbers: each panel numbers only its own rows, so a shared axis would squeeze + /// every panel into its slice of the whole result. + let sharesXAxis: Bool + + /// Every series, in the order the rows first name them, which is the legend's order. + let series: [Group] + let panels: [Panel] + let domain: Domain + + init( + points: [Point], + xAxisKind: ResultChartColumn.AxisKind, + limits: [Limit], + loadedRowCount: Int, + skippedRowCount: Int, + xAxisLabel: String, + yAxisLabel: String, + seriesLabel: String?, + facetLabel: String? = nil, + sharesXAxis: Bool = true + ) { + self.points = points + self.xAxisKind = xAxisKind + self.limits = limits + self.loadedRowCount = loadedRowCount + self.skippedRowCount = skippedRowCount + self.xAxisLabel = xAxisLabel + self.yAxisLabel = yAxisLabel + self.seriesLabel = seriesLabel + self.facetLabel = facetLabel + self.sharesXAxis = sharesXAxis + series = Self.orderedGroups(points.compactMap(\.series)) + panels = Self.panels(points: points, isFaceted: facetLabel != nil) + domain = Domain(points: points) + } + + var isFaceted: Bool { facetLabel != nil } + + /// First appearance, so the query's ORDER BY orders the legend and the panels, and the caps + /// keep the first values a reader sees. + private static func orderedGroups(_ groups: [Group]) -> [Group] { + var seen: Set = [] + return groups.filter { seen.insert($0.value).inserted } + } + + private static func panels(points: [Point], isFaceted: Bool) -> [Panel] { + guard isFaceted else { return [Panel(facet: nil, points: points)] } + var grouped: [GroupValue: [Point]] = [:] + for point in points { + guard let facet = point.facet else { continue } + grouped[facet.value, default: []].append(point) + } + return orderedGroups(points.compactMap(\.facet)).map { facet in + Panel(facet: facet, points: grouped[facet.value] ?? []) + } + } } diff --git a/apple/TablePro/Core/Services/Query/ResultChartProjector.swift b/apple/TablePro/Core/Services/Query/ResultChartProjector.swift index 8c61a198b1..dddc6d14df 100644 --- a/apple/TablePro/Core/Services/Query/ResultChartProjector.swift +++ b/apple/TablePro/Core/Services/Query/ResultChartProjector.swift @@ -10,30 +10,63 @@ actor ResultChartProjector { static let shared = ResultChartProjector() static let maximumPointCount = 2_000 static let maximumSeriesCount = 20 + static let maximumFacetCount = 12 static let maximumInspectedRowCount = 50_000 static let maximumLabelLength = 512 static let maximumNumericLength = 256 - private enum XOccurrenceKey: Hashable { case category(String) case number(String) case date(Date) } - private struct GroupKey: Hashable { + private struct BarOccurrenceKey: Hashable { + let facet: Int + let x: XOccurrenceKey + let series: Int + } + + /// Not keyed by facet: a series keeps the same slot in every panel, so its bars sit in the same + /// place wherever it appears. + private struct BarGroupKey: Hashable { let series: Int let occurrence: Int } - private struct BarOccurrenceKey: Hashable { - let x: XOccurrenceKey + private struct LineKey: Hashable { + let facet: ResultChartProjection.GroupValue? + let series: ResultChartProjection.GroupValue? + } + + private struct LineGroupKey: Hashable { + let facet: Int let series: Int + let generation: Int } - private enum LineSeriesKey: Hashable { + /// Which group of one column a row belongs to, if it can be told. + private enum Attribution { case ungrouped - case series(ResultChartProjection.SeriesValue) + case group(ResultChartProjection.GroupValue) + case unreadable + + /// Nil when the cell cannot be read; `.some(nil)` when the chart does not group by it. + var groupValue: ResultChartProjection.GroupValue?? { + switch self { + case .ungrouped: return .some(nil) + case .group(let value): return value + case .unreadable: return nil + } + } + + func couldOwn(_ line: ResultChartProjection.GroupValue?) -> Bool { + switch self { + case .ungrouped: return line == nil + case .group(let value): return line == value + case .unreadable: return true + } + } } private struct RowValues { @@ -43,19 +76,48 @@ actor ResultChartProjector { let rawY: String } + /// A point before labels exist. Labels have to be unique across the whole projection, so they + /// are assigned once every group is known. + private struct PendingPoint { + let sourceIndex: Int + let values: RowValues + let series: ResultChartProjection.GroupValue? + let facet: ResultChartProjection.GroupValue? + let barGroup: Int + let lineGroup: Int + } + + private struct Ordinals { + private(set) var values: [ResultChartProjection.GroupValue: Int] = [:] + let limit: Int + + /// Nil when the value is new and the cap is reached. Nothing is recorded until `commit`, so + /// a row dropped by another cap does not use up a slot here. + func ordinal(for value: ResultChartProjection.GroupValue) -> Int? { + if let ordinal = values[value] { return ordinal } + return values.count < limit ? values.count : nil + } + + mutating func commit(_ value: ResultChartProjection.GroupValue, as ordinal: Int) { + values[value] = ordinal + } + } + /// 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 pending: [PendingPoint] = [] + pending.reserveCapacity(min(tableRows.rows.count, Self.maximumPointCount)) + var seriesOrdinals = Ordinals(limit: Self.maximumSeriesCount) + var facetOrdinals = Ordinals(limit: Self.maximumFacetCount) var xOccurrences: [BarOccurrenceKey: Int] = [:] - var barGroupOrdinals: [GroupKey: Int] = [:] - var lineGroupOrdinals: [GroupKey: Int] = [:] - var lineBreakGenerations: [LineSeriesKey: Int] = [:] + var barGroupOrdinals: [BarGroupKey: Int] = [:] + var lineGroupOrdinals: [LineGroupKey: Int] = [:] + var lineBreakGenerations: [LineKey: Int] = [:] + var drawnLines: Set = [] var limits: [ResultChartProjection.Limit] = [] var skippedRowCount = 0 @@ -68,87 +130,117 @@ actor ResultChartProjector { throw CancellationError() } - guard let values = rowValues(in: row, sourceIndex: sourceIndex, configuration: configuration) else { + let facet = attribution(of: row, column: configuration.facetColumn) + let series = attribution(of: row, column: configuration.seriesColumn) + guard let values = rowValues(in: row, sourceIndex: sourceIndex, configuration: configuration), + let facetValue = facet.groupValue, + let seriesValue = series.groupValue + else { skippedRowCount += 1 - recordLineBreak( - for: row, - seriesColumn: configuration.seriesColumn, - seriesOrdinals: seriesOrdinals, - generations: &lineBreakGenerations - ) + recordLineBreak(facet: facet, series: series, drawnLines: drawnLines, 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 - ) + var facetOrdinal = 0 + if let facetValue { + guard let ordinal = facetOrdinals.ordinal(for: facetValue) else { + appendOnce(.facets(limit: Self.maximumFacetCount), to: &limits) 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 + facetOrdinal = ordinal + } + var seriesOrdinal = 0 + if let seriesValue { + guard let ordinal = seriesOrdinals.ordinal(for: seriesValue) else { + appendOnce(.series(limit: Self.maximumSeriesCount), to: &limits) + continue } - } else { - series = nil - seriesOrdinal = 0 + seriesOrdinal = ordinal } - if points.count == Self.maximumPointCount { + if pending.count == Self.maximumPointCount { limits.append(.points(limit: Self.maximumPointCount)) break rows } + if let facetValue { facetOrdinals.commit(facetValue, as: facetOrdinal) } + if let seriesValue { seriesOrdinals.commit(seriesValue, as: seriesOrdinal) } - let xKey = stableXKey(values.x, raw: values.rawX) - let occurrenceKey = BarOccurrenceKey(x: xKey, series: seriesOrdinal) + let occurrenceKey = BarOccurrenceKey( + facet: facetOrdinal, + x: stableXKey(values.x, raw: values.rawX), + 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( + let lineKey = LineKey(facet: facetValue, series: seriesValue) + drawnLines.insert(lineKey) + let lineGroupKey = LineGroupKey( + facet: facetOrdinal, series: seriesOrdinal, - occurrence: lineBreakGenerations[lineSeriesKey, default: 0] + generation: lineBreakGenerations[lineKey, default: 0] ) - points.append(ResultChartProjection.Point( + pending.append(PendingPoint( sourceIndex: sourceIndex, - x: values.x, - y: values.y, - rawX: values.rawX, - rawY: values.rawY, - series: series, - barGroup: ordinal(for: barGroupKey, in: &barGroupOrdinals), + values: values, + series: seriesValue, + facet: facetValue, + barGroup: ordinal(for: BarGroupKey(series: seriesOrdinal, occurrence: occurrence), in: &barGroupOrdinals), lineGroup: ordinal(for: lineGroupKey, in: &lineGroupOrdinals) )) } return ResultChartProjection( - points: points, + points: labeledPoints(pending), 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 + seriesLabel: configuration.seriesColumn?.displayName, + facetLabel: configuration.facetColumn?.displayName, + sharesXAxis: configuration.xColumn != nil ) } + private func labeledPoints(_ pending: [PendingPoint]) -> [ResultChartProjection.Point] { + let series = groups(pending.compactMap(\.series)) + let facets = groups(pending.compactMap(\.facet)) + var categories: [String] = [] + var seenCategories: Set = [] + for point in pending { + if case .category(let raw) = point.values.x, seenCategories.insert(raw).inserted { + categories.append(raw) + } + } + let categoryLabels = Dictionary(uniqueKeysWithValues: zip( + categories, + ResultChartProjection.Group.labeled(categories.map { .value($0) }).map(\.label) + )) + + return pending.map { point in + var x = point.values.x + if case .category(let raw) = x { x = .category(categoryLabels[raw] ?? raw) } + return ResultChartProjection.Point( + sourceIndex: point.sourceIndex, + x: x, + y: point.values.y, + rawX: point.values.rawX, + rawY: point.values.rawY, + series: point.series.flatMap { series[$0] }, + facet: point.facet.flatMap { facets[$0] }, + barGroup: point.barGroup, + lineGroup: point.lineGroup + ) + } + } + + private func groups( + _ values: [ResultChartProjection.GroupValue] + ) -> [ResultChartProjection.GroupValue: ResultChartProjection.Group] { + Dictionary(uniqueKeysWithValues: ResultChartProjection.Group.labeled(Array(Set(values))).map { ($0.value, $0) }) + } + private func appendOnce( _ limit: ResultChartProjection.Limit, to limits: inout [ResultChartProjection.Limit] @@ -246,7 +338,15 @@ actor ResultChartProjector { return "\(isNegative ? "-" : "")\(String(digits))e\(exponent)" } - private func seriesValue(from cell: PluginCellValue) -> ResultChartProjection.SeriesValue? { + private func attribution(of row: Row, column: ResultChartColumn?) -> Attribution { + guard let column else { return .ungrouped } + guard let cell = cell(in: row, at: column.index), let value = groupValue(from: cell) else { + return .unreadable + } + return .group(value) + } + + private func groupValue(from cell: PluginCellValue) -> ResultChartProjection.GroupValue? { switch cell { case .null: return .missing @@ -259,28 +359,22 @@ actor ResultChartProjector { } /// 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 + /// line being drawn straight through it. When a group cell is the unreadable one the row cannot + /// be attributed, so every line it could belong to 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] + facet: Attribution, + series: Attribution, + drawnLines: Set, + generations: inout [LineKey: Int] ) { - guard let seriesColumn else { - generations[.ungrouped, default: 0] += 1 + if let facetValue = facet.groupValue, let seriesValue = series.groupValue { + generations[LineKey(facet: facetValue, series: seriesValue), 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 + for line in drawnLines where facet.couldOwn(line.facet) && series.couldOwn(line.series) { + generations[line, default: 0] += 1 } - generations[.series(series), default: 0] += 1 } private func cell(in row: Row, at index: Int) -> PluginCellValue? { @@ -296,7 +390,7 @@ actor ResultChartProjector { } } - private func ordinal(for key: GroupKey, in ordinals: inout [GroupKey: Int]) -> Int { + private func ordinal(for key: Key, in ordinals: inout [Key: Int]) -> Int { if let ordinal = ordinals[key] { return ordinal } let ordinal = ordinals.count ordinals[key] = ordinal diff --git a/apple/TablePro/Models/Query/ResultChartColumn.swift b/apple/TablePro/Models/Query/ResultChartColumn.swift index e04c3a74d8..945efb4f34 100644 --- a/apple/TablePro/Models/Query/ResultChartColumn.swift +++ b/apple/TablePro/Models/Query/ResultChartColumn.swift @@ -61,21 +61,18 @@ struct ResultChartColumn: Identifiable, Equatable, Sendable { 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 - } + let displayNames = ResultChartLabels.unique(tableRows.columns) 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, + displayName: displayNames[index], type: type, isPrimaryKey: primaryKeyColumns.contains(name) ) diff --git a/apple/TablePro/Models/Query/ResultChartConfiguration.swift b/apple/TablePro/Models/Query/ResultChartConfiguration.swift index 6849222c78..e1b4b638ba 100644 --- a/apple/TablePro/Models/Query/ResultChartConfiguration.swift +++ b/apple/TablePro/Models/Query/ResultChartConfiguration.swift @@ -14,17 +14,20 @@ struct ResultChartConfiguration: Equatable, Hashable, Sendable { var xColumn: ResultChartColumnID? var yColumn: ResultChartColumnID? var seriesColumn: ResultChartColumnID? + var facetColumn: ResultChartColumnID? init( chartType: ResultChartType = .bar, xColumn: ResultChartColumnID? = nil, yColumn: ResultChartColumnID? = nil, - seriesColumn: ResultChartColumnID? = nil + seriesColumn: ResultChartColumnID? = nil, + facetColumn: ResultChartColumnID? = nil ) { self.chartType = chartType self.xColumn = xColumn self.yColumn = yColumn self.seriesColumn = seriesColumn + self.facetColumn = facetColumn } /// The first numeric column that is not part of the primary key. A leading auto-increment key @@ -42,7 +45,8 @@ struct ResultChartConfiguration: Equatable, Hashable, Sendable { chartType: chartType, xColumn: columns.first { $0.id == xColumn && $0.xAxisKind != nil }, yColumn: resolvedY, - seriesColumn: columns.first { $0.id == seriesColumn && $0.supportsSeries } + seriesColumn: columns.first { $0.id == seriesColumn && $0.supportsSeries }, + facetColumn: columns.first { $0.id == facetColumn && $0.supportsSeries } ) } @@ -51,10 +55,25 @@ struct ResultChartConfiguration: Equatable, Hashable, Sendable { let xColumn: ResultChartColumn? let yColumn: ResultChartColumn let seriesColumn: ResultChartColumn? + let facetColumn: ResultChartColumn? /// No X column means the row number, which is a numeric axis. var xAxisKind: ResultChartColumn.AxisKind { xColumn?.xAxisKind ?? .number } + + /// Every column the projection reads, and nothing else. The projection key holds this + /// rather than its own list, so a new axis cannot be left out of the key and leave a stale + /// projection on screen. + var columnIDs: ColumnIDs { + ColumnIDs(x: xColumn?.id, y: yColumn.id, series: seriesColumn?.id, facet: facetColumn?.id) + } + } + + struct ColumnIDs: Hashable, Sendable { + let x: ResultChartColumnID? + let y: ResultChartColumnID + let series: ResultChartColumnID? + let facet: ResultChartColumnID? } } diff --git a/apple/TablePro/Models/Query/ResultChartLabels.swift b/apple/TablePro/Models/Query/ResultChartLabels.swift new file mode 100644 index 0000000000..98927acf12 --- /dev/null +++ b/apple/TablePro/Models/Query/ResultChartLabels.swift @@ -0,0 +1,28 @@ +// +// ResultChartLabels.swift +// TablePro +// + +import Foundation + +internal enum ResultChartLabels { + /// Numbers every label that occurs more than once, "name (1)", "name (2)", in the order given, + /// and skips a number whose label is already taken, so a real value spelled "name (1)" cannot + /// merge with a numbered duplicate. + static func unique(_ bases: [String]) -> [String] { + let totals = bases.reduce(into: [String: Int]()) { totals, base in + totals[base, default: 0] += 1 + } + var taken = Set(bases) + var next: [String: Int] = [:] + return bases.map { base in + guard totals[base, default: 0] > 1 else { return base } + var number = next[base, default: 1] + while taken.contains("\(base) (\(number))") { number += 1 } + next[base] = number + 1 + let label = "\(base) (\(number))" + taken.insert(label) + return label + } + } +} diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 34af60495b..a31291cea9 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -65123,6 +65123,46 @@ } } }, + "Draws one chart for each value of this column." : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dessine un graphique pour chaque valeur de cette colonne." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 열의 값마다 차트를 하나씩 그립니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu sütunun her değeri için bir grafik çizer." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vẽ một biểu đồ cho mỗi giá trị của cột này." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "为此列的每个值绘制一个图表。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "為此欄的每個值繪製一個圖表。" + } + } + } + }, "Driver Options" : { "comment" : "A section for driver-specific options.", "isCommentAutoGenerated" : true, @@ -83417,6 +83457,46 @@ } } }, + "Facet" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Facette" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "패싯" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Faset" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tách biểu đồ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分面" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "分面" + } + } + } + }, "Full Access" : { "localizations" : { "fr" : { @@ -163190,6 +163270,46 @@ } } }, + "Showing the first %@ facets" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Affichage des %@ premières facettes" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "처음 %@개 패싯을 표시 중" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İlk %@ faset gösteriliyor" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang hiển thị %@ biểu đồ đầu tiên" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示前 %@ 个分面" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "顯示前 %@ 個分面" + } + } + } + }, "Showing the first of %d result sets. End each statement with a semicolon to see every result." : { "extractionState" : "manual", "generatesSymbol" : false, diff --git a/apple/TablePro/Views/Results/ResultChartAxisPicker.swift b/apple/TablePro/Views/Results/ResultChartAxisPicker.swift index b55b5d46bb..bac547da07 100644 --- a/apple/TablePro/Views/Results/ResultChartAxisPicker.swift +++ b/apple/TablePro/Views/Results/ResultChartAxisPicker.swift @@ -17,7 +17,7 @@ struct ResultChartAxisPicker: View { let accessibilityIdentifier: String var body: some View { - Picker(title, selection: $selection) { + Picker(title, selection: shownSelection) { if allowsNone || columns.isEmpty { Text(noneLabel).tag(ResultChartColumnID?.none) } @@ -27,4 +27,22 @@ struct ResultChartAxisPicker: View { } .accessibilityIdentifier(accessibilityIdentifier) } + + /// A pop-up shows nothing at all for a selection none of its items carries. A stored column + /// missing from this result is kept for when it comes back, but the pop-up reads as the none + /// item meanwhile, which is also what the chart is plotting. + static func shownSelection( + _ selection: ResultChartColumnID?, + in columns: [ResultChartColumn] + ) -> ResultChartColumnID? { + guard let selection, columns.contains(where: { $0.id == selection }) else { return nil } + return selection + } + + private var shownSelection: Binding { + Binding( + get: { Self.shownSelection(selection, in: columns) }, + set: { selection = $0 } + ) + } } diff --git a/apple/TablePro/Views/Results/ResultChartCanvas.swift b/apple/TablePro/Views/Results/ResultChartCanvas.swift index ea30385ed6..3d341d16fa 100644 --- a/apple/TablePro/Views/Results/ResultChartCanvas.swift +++ b/apple/TablePro/Views/Results/ResultChartCanvas.swift @@ -8,17 +8,15 @@ import SwiftUI import TableProTheme 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 panel: ResultChartProjection.Panel + private let slotSeeds: [Int] + private let showsLegend: Bool + private let accessibilityIdentifier: String private let selectionIndex: ResultChartSelectionIndex - private let seriesNames: [String] + private let seriesStyle: ResultChartSeriesStyle @State private var selectedCategory: String? @State private var selectedNumber: Double? @@ -26,11 +24,26 @@ struct ResultChartCanvas: View { @ObservedObject private var themeEngine = ThemeEngine.shared @Environment(\.colorSchemeContrast) private var contrast - init(projection: ResultChartProjection, chartType: ResultChartType) { + /// Draws one panel of the projection, every panel when it is not faceted. The axes, colours, + /// symbols and dashes come from the whole projection, so the panels of one grid line up. + init( + projection: ResultChartProjection, + panel: ResultChartProjection.Panel? = nil, + chartType: ResultChartType, + showsLegend: Bool = true, + accessibilityIdentifier: String = "result-chart" + ) { self.projection = projection self.chartType = chartType - selectionIndex = ResultChartSelectionIndex(projection: projection) - seriesNames = Self.orderedSeriesNames(in: projection) + let panel = panel ?? ResultChartProjection.Panel(facet: nil, points: projection.points) + self.panel = panel + slotSeeds = chartType == .bar && projection.isFaceted + ? Array(Set(projection.points.map(\.barGroup))).sorted() + : [] + self.showsLegend = showsLegend + self.accessibilityIdentifier = accessibilityIdentifier + selectionIndex = ResultChartSelectionIndex(points: panel.points) + seriesStyle = ResultChartSeriesStyle(series: projection.series) } var body: some View { @@ -61,6 +74,11 @@ struct ResultChartCanvas: View { .foregroundStyle(Color(nsColor: .secondaryLabelColor)) } } + .modifier(SharedAxes( + domain: projection.domain, + xAxisKind: projection.xAxisKind, + sharesXAxis: projection.sharesXAxis + )) .chartPlotStyle { plotArea in plotArea .themeBackground( @@ -71,7 +89,7 @@ struct ResultChartCanvas: View { } .accessibilityElement(children: .contain) .accessibilityLabel(accessibilitySummary) - .accessibilityIdentifier("result-chart") + .accessibilityIdentifier(accessibilityIdentifier) } /// The chart stands where the grid would, so its plot takes the grid's background and lines. @@ -91,7 +109,10 @@ struct ResultChartCanvas: View { private var categoricalChart: some View { let selection = selectionIndex.selection(forCategory: selectedCategory) return legend(Chart { - ForEach(projection.points) { point in + if case .category(let x)? = panel.points.first?.x { + ForEach(slotSeeds, id: \.self) { slotSeed($0, x: x) } + } + ForEach(panel.points) { point in if case .category(let x) = point.x { mark(for: point, x: x) } @@ -106,7 +127,10 @@ struct ResultChartCanvas: View { private var numericChart: some View { let selection = selectionIndex.selection(nearestNumber: selectedNumber) return legend(Chart { - ForEach(projection.points) { point in + if case .number(let x)? = panel.points.first?.x { + ForEach(slotSeeds, id: \.self) { slotSeed($0, x: x) } + } + ForEach(panel.points) { point in if case .number(let x) = point.x { mark(for: point, x: x) } @@ -121,7 +145,10 @@ struct ResultChartCanvas: View { private var dateChart: some View { let selection = selectionIndex.selection(nearestDate: selectedDate) return legend(Chart { - ForEach(projection.points) { point in + if case .date(let x)? = panel.points.first?.x { + ForEach(slotSeeds, id: \.self) { slotSeed($0, x: x) } + } + ForEach(panel.points) { point in if case .date(let x) = point.x { mark(for: point, x: x) } @@ -133,35 +160,29 @@ struct ResultChartCanvas: View { .chartXSelectionCompat(value: $selectedDate) } - /// The per-series stroke lives on the chart's line-style scale, not on the mark: a constant + /// Every scale takes its domain from the whole projection: a panel infers its own otherwise, so + /// one series would change colour between panels, and every series whenever the rows are + /// re-sorted. The per-series stroke lives on the 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(showsLegend && projection.seriesLabel != nil ? .visible : .hidden) .chartLegend(position: .bottom, alignment: .leading, spacing: 10) - if seriesNames.isEmpty { + if seriesStyle.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] - ) + styled + .chartForegroundStyleScale(domain: seriesStyle.names, range: seriesStyle.colors) + .chartSymbolScale(domain: seriesStyle.names, range: seriesStyle.symbols) + .chartLineStyleScale(domain: seriesStyle.names, range: seriesStyle.strokes) } } @ChartContentBuilder private func mark(for point: ResultChartProjection.Point, x: X) -> some ChartContent { - let series = point.series?.displayName + let series = point.series?.label switch chartType { case .bar: barMark(for: point, x: x, series: series) case .line: lineMark(for: point, x: x, series: series) @@ -172,8 +193,10 @@ struct ResultChartCanvas: View { /// 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. + /// category's band. The scale orders discrete slots by first appearance in the chart's marks, + /// which the projection numbers in that order; a faceted panel names every slot up front + /// (`slotSeed`), so a panel whose rows name a later series first still puts each where the + /// other panels do. private func slot(_ point: ResultChartProjection.Point) -> PlottableValue { .value(String(localized: "Row"), String(point.barGroup)) } @@ -182,6 +205,23 @@ struct ResultChartCanvas: View { .value(projection.seriesLabel ?? String(localized: "Series"), series) } + /// `position(by:)` divides each band among the slots this chart's marks name, in the order they + /// first appear, and has no domain to pass. A panel missing a series, or naming a later one + /// first, would draw its bars wider or swapped, so every slot of the whole projection is named + /// once, in order, by an invisible zero-height bar ahead of the real ones. Measured: a sparse + /// panel's bars then land at the full panel's positions and widths. + @ChartContentBuilder + private func slotSeed(_ slot: Int, x: X) -> some ChartContent { + BarMark( + x: .value(projection.xAxisLabel, x), + y: .value(projection.yAxisLabel, 0), + stacking: .unstacked + ) + .position(by: .value(String(localized: "Row"), String(slot))) + .opacity(0) + .accessibilityHidden(true) + } + @ChartContentBuilder private func barMark( for point: ResultChartProjection.Point, @@ -360,16 +400,8 @@ struct ResultChartCanvas: View { ) } - 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)"] + var components = ["\(projection.xAxisLabel): \(point.xLabel)"] if let seriesLabel = projection.seriesLabel, let series { components.append("\(seriesLabel): \(series)") } @@ -386,11 +418,56 @@ struct ResultChartCanvas: View { chartType.displayName, projection.yAxisLabel, projection.xAxisLabel, - projection.points.count + panel.points.count ) } } +/// The panels of a faceted chart are separate charts, so each is given the extent of every panel's +/// data instead of inferring its own. Numbers keep Swift Charts' own rounding and zero baseline: +/// the shared extent is appended to what it infers, which a single chart already contains. +private struct SharedAxes: ViewModifier { + let domain: ResultChartProjection.Domain + let xAxisKind: ResultChartColumn.AxisKind + let sharesXAxis: Bool + + func body(content: Content) -> some View { + sharedX(content).chartYScale(domain: Self.including(domain.y)) + } + + @ViewBuilder + private func sharedX(_ content: Content) -> some View { + if !sharesXAxis { + content + } else { + sharedDomainX(content) + } + } + + @ViewBuilder + private func sharedDomainX(_ content: Content) -> some View { + switch xAxisKind { + case .category: + content.chartXScale(domain: domain.categories) + case .number: + content.chartXScale(domain: Self.including(domain.numbers)) + case .date: + if let dates = domain.dates, dates.lowerBound < dates.upperBound { + content.chartXScale(domain: dates) + } else { + content + } + } + } + + private static func including(_ extent: ClosedRange?) -> AutomaticScaleDomain { + guard let extent else { return .automatic } + return .automatic(dataType: Double.self) { inferred in + inferred.append(contentsOf: [extent.lowerBound, extent.upperBound]) + } + } +} + #Preview("Bar chart") { ResultChartPreview(chartType: .bar) } @@ -434,7 +511,7 @@ private struct ResultChartPreview: View { y: Double(value.1), rawX: value.0, rawY: String(value.1), - series: .value(value.2), + series: .init(.value(value.2)), barGroup: value.2 == "Online" ? 0 : 1, lineGroup: value.2 == "Online" ? 0 : 1 ) @@ -465,7 +542,7 @@ private struct ResultChartPreview: View { y: Double(value.1), rawX: String(value.0), rawY: String(value.1), - series: .value(value.2), + series: .init(.value(value.2)), barGroup: value.2 == "Online" ? 0 : 1, lineGroup: value.2 == "Online" ? 0 : 1 ) diff --git a/apple/TablePro/Views/Results/ResultChartFacetGrid.swift b/apple/TablePro/Views/Results/ResultChartFacetGrid.swift new file mode 100644 index 0000000000..86a2452dc4 --- /dev/null +++ b/apple/TablePro/Views/Results/ResultChartFacetGrid.swift @@ -0,0 +1,112 @@ +// +// ResultChartFacetGrid.swift +// TablePro +// + +import SwiftUI + +/// How many panels sit side by side and how tall each one is. A few panels fill the pane; past +/// what fits at the minimum size the grid scrolls instead of shrinking the charts further. Rows are +/// balanced, so four panels in a pane that fits three across read as two by two, not three and one. +struct ResultChartFacetLayout: Equatable { + static let minimumPanelWidth: CGFloat = 280 + static let minimumPanelHeight: CGFloat = 200 + static let spacing: CGFloat = 12 + + let columns: Int + let panelHeight: CGFloat + + init(panelCount: Int, size: CGSize) { + let count = max(panelCount, 1) + let fitting = min(count, max(Int((size.width + Self.spacing) / (Self.minimumPanelWidth + Self.spacing)), 1)) + let rows = (count + fitting - 1) / fitting + columns = (count + rows - 1) / rows + let available = (size.height - Self.spacing * CGFloat(rows - 1)) / CGFloat(rows) + panelHeight = max(Self.minimumPanelHeight, available.rounded(.down)) + } +} + +/// Swift Charts has no facet mark, so small multiples are one chart per facet value. Each panel is +/// drawn from the whole projection's scales and the legend is drawn once under the grid, since a +/// legend in every panel would repeat it and take a different height from each plot. +struct ResultChartFacetGrid: View { + let projection: ResultChartProjection + let chartType: ResultChartType + /// Changes when a new projection lands. It resets each panel's hover, and is not applied to the + /// scroll view, which would jump back to the top on every refresh. + let revision: AnyHashable + + private var seriesStyle: ResultChartSeriesStyle { + ResultChartSeriesStyle(series: projection.series) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + GeometryReader { proxy in + ScrollView(.vertical) { + ResultChartFacetPanels( + projection: projection, + chartType: chartType, + layout: ResultChartFacetLayout(panelCount: projection.panels.count, size: proxy.size), + revision: revision + ) + } + } + + if projection.seriesLabel != nil, !seriesStyle.isEmpty { + ResultChartLegend( + title: projection.seriesLabel, + style: seriesStyle, + showsStroke: chartType == .line || chartType == .area + ) + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("result-chart-facets") + } +} + +struct ResultChartFacetPanels: View { + let projection: ResultChartProjection + let chartType: ResultChartType + let layout: ResultChartFacetLayout + let revision: AnyHashable + + var body: some View { + LazyVGrid( + columns: Array( + repeating: GridItem(.flexible(), spacing: ResultChartFacetLayout.spacing, alignment: .top), + count: layout.columns + ), + alignment: .leading, + spacing: ResultChartFacetLayout.spacing + ) { + ForEach(Array(projection.panels.enumerated()), id: \.element.id) { index, panel in + panelView(panel, index: index) + .frame(height: layout.panelHeight) + } + } + } + + private func panelView(_ panel: ResultChartProjection.Panel, index: Int) -> some View { + let title = panel.facet?.label ?? "" + return VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + .truncationMode(.middle) + .help(title) + ResultChartCanvas( + projection: projection, + panel: panel, + chartType: chartType, + showsLegend: false, + accessibilityIdentifier: "result-chart-panel-chart" + ) + .id(revision) + } + .accessibilityElement(children: .contain) + .accessibilityLabel("\(projection.facetLabel ?? ""): \(title)") + .accessibilityIdentifier("result-chart-panel-\(index)") + } +} diff --git a/apple/TablePro/Views/Results/ResultChartLegend.swift b/apple/TablePro/Views/Results/ResultChartLegend.swift new file mode 100644 index 0000000000..5483e2da42 --- /dev/null +++ b/apple/TablePro/Views/Results/ResultChartLegend.swift @@ -0,0 +1,93 @@ +// +// ResultChartLegend.swift +// TablePro +// + +import Charts +import SwiftUI + +/// The legend of a faceted chart. Swift Charts draws a legend only inside one chart, so this one +/// draws the same symbol, colour and dash each panel takes from `ResultChartSeriesStyle`, because +/// colour alone does not tell two series apart. +struct ResultChartLegend: View { + let title: String? + let style: ResultChartSeriesStyle + let showsStroke: Bool + + var body: some View { + LegendFlowLayout(spacing: 10, lineSpacing: 6) { + ForEach(style.entries) { entry in + HStack(spacing: 6) { + swatch(for: entry) + Text(entry.name) + .lineLimit(1) + .truncationMode(.middle) + } + .help(entry.name) + .accessibilityElement(children: .ignore) + .accessibilityLabel(entry.name) + } + } + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityElement(children: .contain) + .accessibilityLabel(title ?? String(localized: "Series")) + } + + private func swatch(for entry: ResultChartSeriesStyle.Entry) -> some View { + ZStack { + if showsStroke { + Path { path in + path.move(to: CGPoint(x: 0, y: 5)) + path.addLine(to: CGPoint(x: 20, y: 5)) + } + .stroke(entry.color, style: entry.stroke) + } + entry.symbol + .fill(entry.color) + .frame(width: 8, height: 8) + } + .frame(width: showsStroke ? 20 : 8, height: 10) + } +} + +/// Entries side by side at their own width, wrapping to a new line, the way the chart's own legend +/// lays them out. +private struct LegendFlowLayout: Layout { + let spacing: CGFloat + let lineSpacing: CGFloat + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let frames = arrange(subviews, width: proposal.width ?? .infinity) + let width = frames.map(\.maxX).max() ?? 0 + let height = frames.map(\.maxY).max() ?? 0 + return CGSize(width: width, height: height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let frames = arrange(subviews, width: bounds.width) + for (subview, frame) in zip(subviews, frames) { + subview.place( + at: CGPoint(x: bounds.minX + frame.minX, y: bounds.minY + frame.minY), + proposal: ProposedViewSize(frame.size) + ) + } + } + + private func arrange(_ subviews: Subviews, width: CGFloat) -> [CGRect] { + var frames: [CGRect] = [] + var origin = CGPoint.zero + var lineHeight: CGFloat = 0 + for subview in subviews { + let size = subview.sizeThatFits(ProposedViewSize(width: width, height: nil)) + if origin.x > 0, origin.x + size.width > width { + origin = CGPoint(x: 0, y: origin.y + lineHeight + lineSpacing) + lineHeight = 0 + } + frames.append(CGRect(origin: origin, size: size)) + origin.x += size.width + spacing + lineHeight = max(lineHeight, size.height) + } + return frames + } +} diff --git a/apple/TablePro/Views/Results/ResultChartSelection.swift b/apple/TablePro/Views/Results/ResultChartSelection.swift index 9e4448537b..57e523703a 100644 --- a/apple/TablePro/Views/Results/ResultChartSelection.swift +++ b/apple/TablePro/Views/Results/ResultChartSelection.swift @@ -7,7 +7,7 @@ import Foundation struct ResultChartSelection: Equatable { let x: ResultChartProjection.XValue - let rawX: String + let xLabel: String let points: [ResultChartProjection.Point] } @@ -18,15 +18,15 @@ struct ResultChartSelectionIndex { private let numbers: [(value: Double, selection: ResultChartSelection)] private let dates: [(value: Date, selection: ResultChartSelection)] - init(projection: ResultChartProjection) { + init(points: [ResultChartProjection.Point]) { var order: [ResultChartProjection.XValue] = [] var grouped: [ResultChartProjection.XValue: [ResultChartProjection.Point]] = [:] - var raw: [ResultChartProjection.XValue: String] = [:] + var labels: [ResultChartProjection.XValue: String] = [:] - for point in projection.points { + for point in points { if grouped[point.x] == nil { order.append(point.x) - raw[point.x] = point.rawX + labels[point.x] = point.xLabel } grouped[point.x, default: []].append(point) } @@ -38,7 +38,7 @@ struct ResultChartSelectionIndex { for value in order { let selection = ResultChartSelection( x: value, - rawX: raw[value] ?? "", + xLabel: labels[value] ?? "", points: grouped[value] ?? [] ) switch value { diff --git a/apple/TablePro/Views/Results/ResultChartSelectionCallout.swift b/apple/TablePro/Views/Results/ResultChartSelectionCallout.swift index da85d1a3cd..a672d0aa0e 100644 --- a/apple/TablePro/Views/Results/ResultChartSelectionCallout.swift +++ b/apple/TablePro/Views/Results/ResultChartSelectionCallout.swift @@ -27,7 +27,7 @@ struct ResultChartSelectionCallout: View { Text(xAxisLabel) .font(.caption) .foregroundStyle(.secondary) - Text(selection.rawX) + Text(selection.xLabel) .font(.callout) .bold() .lineLimit(2) @@ -69,6 +69,6 @@ struct ResultChartSelectionCallout: View { private func valueLabel(for point: ResultChartProjection.Point) -> String { guard seriesLabel != nil, let series = point.series else { return yAxisLabel } - return series.displayName + return series.label } } diff --git a/apple/TablePro/Views/Results/ResultChartSeriesStyle.swift b/apple/TablePro/Views/Results/ResultChartSeriesStyle.swift new file mode 100644 index 0000000000..3b32bcbfc2 --- /dev/null +++ b/apple/TablePro/Views/Results/ResultChartSeriesStyle.swift @@ -0,0 +1,77 @@ +// +// ResultChartSeriesStyle.swift +// TablePro +// + +import Charts +import SwiftUI + +/// The colour, symbol and dash each series takes. Every panel of a faceted chart and the legend +/// under them read this one table, so a series looks the same wherever it appears. A series takes +/// the style of its place in sorted order, not in row order, so re-sorting the rows or turning the +/// page does not repaint every series. +struct ResultChartSeriesStyle { + /// Swift Charts' own categorical palette, in its order: measured identical to the default in + /// light, dark and both high-contrast appearances. + private static let palette: [Color] = [.blue, .green, .orange, .purple, .red, .teal, .yellow] + + /// The symbols Swift Charts gives a `PointMark` by default, in its order, measured the same way. + private static let symbolShapes: [BasicChartSymbolShape] = [ + .circle, .cross, .triangle, .plus, .square, .asterisk, .diamond, + ] + + /// 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 dashPatterns: [[CGFloat]] = [ + [], [6, 3], [2, 3], [8, 3, 2, 3], [1, 3], [10, 4], + ] + + struct Entry: Identifiable { + let name: String + let color: Color + let symbol: BasicChartSymbolShape + let stroke: StrokeStyle + + var id: String { name } + } + + /// In the legend's order, which is the order the rows first name them. + let names: [String] + private let ranks: [Int] + + init(series: [ResultChartProjection.Group]) { + names = series.map(\.label) + let sorted = series.indices.sorted { + ResultChartProjection.GroupValue.areInIncreasingOrder(series[$0].value, series[$1].value) + } + var ranks = Array(repeating: 0, count: series.count) + for (rank, index) in sorted.enumerated() { ranks[index] = rank } + self.ranks = ranks + } + + var isEmpty: Bool { names.isEmpty } + + var colors: [Color] { ranks.map(Self.color(at:)) } + + var symbols: [BasicChartSymbolShape] { ranks.map(Self.symbol(at:)) } + + var strokes: [StrokeStyle] { ranks.map(Self.stroke(at:)) } + + var entries: [Entry] { + zip(names, ranks).map { name, rank in + Entry(name: name, color: Self.color(at: rank), symbol: Self.symbol(at: rank), stroke: Self.stroke(at: rank)) + } + } + + private static func color(at rank: Int) -> Color { + palette[rank % palette.count] + } + + private static func symbol(at rank: Int) -> BasicChartSymbolShape { + symbolShapes[rank % symbolShapes.count] + } + + private static func stroke(at rank: Int) -> StrokeStyle { + StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round, dash: dashPatterns[rank % dashPatterns.count]) + } +} diff --git a/apple/TablePro/Views/Results/ResultChartToolbar.swift b/apple/TablePro/Views/Results/ResultChartToolbar.swift index 6537582068..3dc2cd48d1 100644 --- a/apple/TablePro/Views/Results/ResultChartToolbar.swift +++ b/apple/TablePro/Views/Results/ResultChartToolbar.swift @@ -28,7 +28,7 @@ struct ResultChartToolbar: View { Divider() .frame(height: 22) - axisPickers { $0.frame(width: 170) } + axisPickers { $0.frame(width: 150) } } .fixedSize(horizontal: true, vertical: false) @@ -52,6 +52,7 @@ struct ResultChartToolbar: View { .fixedSize(horizontal: true, vertical: false) } .scrollBounceBasedOnSize(axes: .horizontal) + .accessibilityElement(children: .contain) .accessibilityIdentifier("result-chart-data-scope") Image(systemName: "info.circle") @@ -93,6 +94,15 @@ struct ResultChartToolbar: View { allowsNone: true, accessibilityIdentifier: "result-chart-series-picker" )) + sized(ResultChartAxisPicker( + title: String(localized: "Facet"), + selection: facetColumnBinding, + columns: seriesColumns, + noneLabel: String(localized: "None"), + allowsNone: true, + accessibilityIdentifier: "result-chart-facet-picker" + )) + .help(String(localized: "Draws one chart for each value of this column.")) } /// What the chart itself has to say. The row count and the controls that load more rows belong @@ -117,6 +127,8 @@ struct ResultChartToolbar: View { ) case .series(let limit): return String(format: String(localized: "Showing the first %@ series"), grouped(limit)) + case .facets(let limit): + return String(format: String(localized: "Showing the first %@ facets"), grouped(limit)) case .inspectedRows(let limit): return String( format: String(localized: "Charting the first %1$@ of %2$@ loaded rows"), @@ -140,8 +152,8 @@ struct ResultChartToolbar: View { /// 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. + /// resolution to echo, so the stored preference is passed on, and a pop-up that does not list + /// it shows its none item (`ResultChartAxisPicker.shownSelection`). private var xColumnBinding: Binding { Binding( get: { resolved.map { $0.xColumn?.id } ?? configuration.xColumn }, @@ -162,4 +174,11 @@ struct ResultChartToolbar: View { set: { configuration.seriesColumn = $0 } ) } + + private var facetColumnBinding: Binding { + Binding( + get: { resolved.map { $0.facetColumn?.id } ?? configuration.facetColumn }, + set: { configuration.facetColumn = $0 } + ) + } } diff --git a/apple/TablePro/Views/Results/ResultChartView.swift b/apple/TablePro/Views/Results/ResultChartView.swift index 73218d19e0..28fa6e3f2b 100644 --- a/apple/TablePro/Views/Results/ResultChartView.swift +++ b/apple/TablePro/Views/Results/ResultChartView.swift @@ -5,13 +5,11 @@ import SwiftUI -struct ResultChartProjectionKey: Hashable { +struct ResultChartProjectionKey: ResultPaneProjectionKey { let tabId: UUID let resultSetId: UUID let dataRevision: Int - let xColumn: ResultChartColumnID? - let yColumn: ResultChartColumnID? - let seriesColumn: ResultChartColumnID? + let columns: ResultChartConfiguration.ColumnIDs? let isUnlocked: Bool init( @@ -24,11 +22,19 @@ struct ResultChartProjectionKey: Hashable { self.tabId = tabId self.resultSetId = resultSetId self.dataRevision = dataRevision - xColumn = resolved?.xColumn?.id - yColumn = resolved?.yColumn.id - seriesColumn = resolved?.seriesColumn?.id + columns = resolved?.columnIDs self.isUnlocked = isUnlocked } + + struct Subject: Hashable { + let tabId: UUID + let columns: ResultChartConfiguration.ColumnIDs? + let isUnlocked: Bool + } + + var subject: Subject { + Subject(tabId: tabId, columns: columns, isUnlocked: isUnlocked) + } } struct ResultChartView: View { @@ -40,15 +46,7 @@ struct ResultChartView: View { 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. - /// A loaded projection carries the key it was built for, so the two cannot disagree. - private enum LoadState: Equatable { - case loading - case loaded(ResultChartProjection, key: ResultChartProjectionKey) - } - - @State private var state: LoadState = .loading + @State private var state = ResultPaneProjectionState() private var columns: [ResultChartColumn] { ResultChartColumn.columns(in: tableRows, primaryKeyColumns: primaryKeyColumns) @@ -68,6 +66,13 @@ struct ResultChartView: View { ) } + /// Nothing is shown for a result with no numeric column, so its notices cannot come from the + /// last result that had one. + private var shownProjection: ResultChartProjection? { + guard resolved != nil else { return nil } + return state.projection(for: projectionKey) + } + var body: some View { Group { if isUnlocked { @@ -76,7 +81,7 @@ struct ResultChartView: View { configuration: $configuration, columns: columns, resolved: resolved, - projection: loadedProjection + projection: shownProjection ) Divider() content @@ -92,11 +97,6 @@ struct ResultChartView: View { } } - 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 { @@ -111,42 +111,54 @@ struct ResultChartView: View { 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: + } else if let projection = shownProjection { + if projection.points.isEmpty { UnavailableStateView( 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) + } else { + chart(projection) .padding(.horizontal, 14) .padding(.vertical, 8) } + } else { + ProgressView() + .controlSize(.small) + .accessibilityLabel(String(localized: "Building chart")) + } + } + + /// A new projection resets the charts' hover through `.id`, keyed by the key it was built for, + /// so the reset lands with the new drawing rather than while the old one is still up. + @ViewBuilder + private func chart(_ projection: ResultChartProjection) -> some View { + if projection.isFaceted { + ResultChartFacetGrid( + projection: projection, + chartType: configuration.chartType, + revision: AnyHashable(state.key) + ) + } else { + ResultChartCanvas(projection: projection, chartType: configuration.chartType) + .id(state.key) } } /// A connection switch re-parents the pane and SwiftUI re-runs `task(id:)` with an unchanged - /// key, so a projection already built for that key is kept rather than blanked and rebuilt. - /// A cancelled projection commits nothing; the replacement task, or the next appearance, owns - /// the state from its first line. + /// key, so a projection already built for that key is kept rather than rebuilt. `task(id:)` + /// cancels the previous task when the key changes, and a cancelled projection commits nothing. private func rebuild(for expectedKey: ResultChartProjectionKey) async { - if case .loaded(_, let key) = state, key == expectedKey { return } + if state.isCurrent(for: expectedKey) { return } 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 !Task.isCancelled, projectionKey == expectedKey else { return } - state = .loaded(output, key: expectedKey) + guard !Task.isCancelled else { return } + state.commit(output, for: expectedKey) } } diff --git a/apple/TablePro/Views/Results/ResultMapView.swift b/apple/TablePro/Views/Results/ResultMapView.swift index 6bee6f459b..427d082e7a 100644 --- a/apple/TablePro/Views/Results/ResultMapView.swift +++ b/apple/TablePro/Views/Results/ResultMapView.swift @@ -7,12 +7,21 @@ import SwiftUI import TableProGeometry import TableProPluginKit -struct ResultMapProjectionKey: Hashable { +struct ResultMapProjectionKey: ResultPaneProjectionKey { let tabId: UUID let resultSetId: UUID let dataRevision: Int let displayRevision: Int let geometryColumn: SpatialColumnID? + + struct Subject: Hashable { + let tabId: UUID + let geometryColumn: SpatialColumnID? + } + + var subject: Subject { + Subject(tabId: tabId, geometryColumn: geometryColumn) + } } struct ResultMapView: View { @@ -27,15 +36,7 @@ struct ResultMapView: View { let displayRevision: Int let onSelectRow: (Int?) -> Void - /// 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. - /// A loaded projection carries the key it was built for, so the two cannot disagree. - private enum LoadState: Equatable { - case loading - case loaded(ResultMapProjection, key: ResultMapProjectionKey) - } - - @State private var state: LoadState = .loading + @State private var state = ResultPaneProjectionState() @State private var fitToken = 0 private var resolved: SpatialColumn? { @@ -52,9 +53,9 @@ struct ResultMapView: View { ) } - private var loadedProjection: ResultMapProjection? { - guard case .loaded(let projection, _) = state else { return nil } - return projection + private var shownProjection: ResultMapProjection? { + guard resolved != nil else { return nil } + return state.projection(for: projectionKey) } var body: some View { @@ -63,8 +64,8 @@ struct ResultMapView: View { configuration: $configuration, columns: columns, resolved: resolved, - status: loadedProjection?.diagnostics.statusText ?? "", - canFit: !(loadedProjection?.isEmpty ?? true), + status: shownProjection?.diagnostics.statusText ?? "", + canFit: !(shownProjection?.isEmpty ?? true), onFit: { fitToken &+= 1 } ) Divider() @@ -74,9 +75,6 @@ struct ResultMapView: View { .task(id: projectionKey) { await rebuild(for: projectionKey) } - .onChange(of: projectionKey) { _ in - fitToken &+= 1 - } } @ViewBuilder @@ -93,29 +91,35 @@ struct ResultMapView: View { systemImage: "map", description: Text(String(localized: "A map needs a geometry or geography column. This result has none.")) ) - } else { - switch state { - case .loading: - ProgressView() - .controlSize(.small) - .accessibilityLabel(String(localized: "Building map")) - case .loaded(let projection, _) where projection.isEmpty: + } else if let projection = shownProjection { + if projection.isEmpty { UnavailableStateView { Label(String(localized: "Nothing to Draw"), systemImage: "map") } description: { Text(projection.diagnostics.emptyReason) } - case .loaded(let projection, _): + } else { + /// While a rebuild runs the old shapes stay up. The token names the key they were + /// built for, so the new shapes still read as new, and their row ids index the old + /// rows, so they take no click and show no selection until the new shapes land. + let isCurrent = state.isCurrent(for: projectionKey) ResultMapCanvas( projection: projection, - projectionToken: projectionKey.hashValue, - selectedRowIDs: selectedRowIDs(in: projection), + projectionToken: state.key.hashValue, + selectedRowIDs: isCurrent ? selectedRowIDs(in: projection) : [], fitToken: fitToken, - onSelect: select(rowID:) + onSelect: { rowID in + guard isCurrent else { return } + select(rowID: rowID) + } ) .accessibilityIdentifier("result-map") .accessibilityLabel(accessibilityLabel(for: projection)) } + } else { + ProgressView() + .controlSize(.small) + .accessibilityLabel(String(localized: "Building map")) } } @@ -171,20 +175,20 @@ struct ResultMapView: View { // MARK: - Projection /// A connection switch re-parents the pane and SwiftUI re-runs `task(id:)` with an unchanged - /// key. Rebuilding then would enter `.loading`, which unmounts the canvas and loses the user's - /// zoom and pan, so a projection already built for that key is kept. A cancelled projection - /// commits nothing; the replacement task, or the next appearance, owns the state from its - /// first line. + /// key; rebuilding then would replace the drawing and lose the user's zoom and pan, so a + /// projection already built for that key is kept. `task(id:)` cancels the previous task when + /// the key changes, and a cancelled projection commits nothing. New shapes are fitted once they + /// are drawn, not when the key changes, so the fit frames them rather than the old ones. private func rebuild(for expectedKey: ResultMapProjectionKey) async { - if case .loaded(_, let key) = state, key == expectedKey { return } + if state.isCurrent(for: expectedKey) { return } guard let column = resolved else { return } - state = .loading let output = await SpatialResultProjector.shared.project( tableRows: tableRows, displayIDs: displayIDs, column: column ) - guard !Task.isCancelled, projectionKey == expectedKey else { return } - state = .loaded(output, key: expectedKey) + guard !Task.isCancelled else { return } + state.commit(output, for: expectedKey) + fitToken &+= 1 } } diff --git a/apple/TablePro/Views/Results/ResultPaneProjectionState.swift b/apple/TablePro/Views/Results/ResultPaneProjectionState.swift new file mode 100644 index 0000000000..68a8f5a2ce --- /dev/null +++ b/apple/TablePro/Views/Results/ResultPaneProjectionState.swift @@ -0,0 +1,39 @@ +// +// ResultPaneProjectionState.swift +// TablePro +// + +import Foundation + +/// What a result pane's projection was built from. The subject is the part that decides whether an +/// older projection may stand in while a new one builds: the tab and the columns drawn. The result +/// set is not part of it, because every refresh, page turn and sort installs a new result set. +protocol ResultPaneProjectionKey: Hashable { + associatedtype Subject: Hashable + var subject: Subject { get } +} + +/// The projection a chart or map pane draws, and the key it was built for. +/// +/// The pane's view survives a tab switch and a result-set switch (its hosting split view reassigns +/// `rootView`), so a projection is shown only for its own subject. Within it, the old drawing stays +/// up while its replacement builds: a refresh, a re-sort or a page turn never blanks the pane to a +/// spinner. +struct ResultPaneProjectionState { + private(set) var projection: Projection? + private(set) var key: Key? + + func isCurrent(for key: Key) -> Bool { + self.key == key + } + + func projection(for key: Key) -> Projection? { + guard let loaded = self.key, loaded.subject == key.subject else { return nil } + return projection + } + + mutating func commit(_ projection: Projection, for key: Key) { + self.projection = projection + self.key = key + } +} diff --git a/apple/TableProTests/Core/Services/Query/ResultChartProjectorTests.swift b/apple/TableProTests/Core/Services/Query/ResultChartProjectorTests.swift index 595f88bb81..1d033046fc 100644 --- a/apple/TableProTests/Core/Services/Query/ResultChartProjectorTests.swift +++ b/apple/TableProTests/Core/Services/Query/ResultChartProjectorTests.swift @@ -242,7 +242,7 @@ struct ResultChartProjectorTests { ) let projection = try await project(rows, y: "value", series: "status") - #expect(projection.points.map(\.series) == [.missing, .missing, .value("paid")]) + #expect(projection.points.map(\.series?.value) == [.missing, .missing, .value("paid")]) } @Test("Passing the point cap truncates and says so instead of discarding the chart") @@ -329,6 +329,138 @@ struct ResultChartProjectorTests { #expect(projection.points[0].lineGroup == 0) } + @Test("A facet draws one panel per value, in the order the rows name them") + func facetPanels() async throws { + let rows = makeSalesRows([ + ("Jan", "West", "Online", "10"), + ("Jan", nil, "Online", "4"), + ("Jan", "East", "Online", "12"), + ("Feb", "West", "Online", "14"), + ]) + let projection = try await project(rows, x: "month", y: "revenue", facet: "region") + + #expect(projection.isFaceted) + #expect(projection.facetLabel == "region") + #expect(projection.panels.map(\.id) == ["West", "No value", "East"]) + #expect(projection.panels.map { $0.points.map(\.rawY) } == [["10", "14"], ["4"], ["12"]]) + #expect(projection.skippedRowCount == 0) + #expect(projection.sharesXAxis) + } + + @Test("Past twelve facets the later rows are dropped and noted, not counted as skipped") + func facetCap() async throws { + let rows = makeSalesRows((0..<14).map { ("Jan", "region-\($0)", "Online", String($0)) }) + let projection = try await project(rows, x: "month", y: "revenue", facet: "region") + + #expect(projection.panels.count == ResultChartProjector.maximumFacetCount) + #expect(projection.limits == [.facets(limit: ResultChartProjector.maximumFacetCount)]) + #expect(projection.skippedRowCount == 0) + #expect(!projection.points.contains { $0.facet?.label == "region-12" }) + } + + @Test("A row a capped series drops does not take a facet slot") + func seriesCapLeavesFacetSlotsAlone() async throws { + var values = (0.. 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) } + seriesColumn: series.map { ResultChartColumnID(name: $0, occurrence: 1) }, + facetColumn: facet.map { ResultChartColumnID(name: $0, occurrence: 1) } ) return try #require(configuration.resolved(in: ResultChartColumn.columns(in: rows))) } @@ -361,12 +495,22 @@ struct ResultChartProjectorTests { _ rows: TableRows, x: String? = nil, y: String, - series: String? = nil + series: String? = nil, + facet: String? = nil ) async throws -> ResultChartProjection { - let configuration = try resolve(rows, x: x, y: y, series: series) + let configuration = try resolve(rows, x: x, y: y, series: series, facet: facet) return try await ResultChartProjector.shared.project(tableRows: rows, configuration: configuration) } + private func makeSalesRows(_ values: [(String, String?, String?, String?)]) -> TableRows { + func cell(_ value: String?) -> PluginCellValue { value.map(PluginCellValue.text) ?? .null } + return makeRows( + values: values.map { [.text($0.0), cell($0.1), cell($0.2), cell($0.3)] }, + columns: ["month", "region", "product", "revenue"], + types: [.text(rawType: nil), .text(rawType: nil), .text(rawType: nil), .integer(rawType: nil)] + ) + } + private func makeNumericRows(count: Int) -> TableRows { makeRows( values: (0.. 200) + #expect(blue == 0) + } + + /// Swift Charts orders bar slots by first appearance in each chart, so a panel whose rows name + /// the later series first used to draw the two bars the other way round from its neighbour. + @Test("A panel puts each series' bar where the other panels do") + func barOrderMatchesAcrossPanels() throws { + let projection = ResultChartProjection( + points: [ + facetPoint(index: 0, x: "Jan", y: 10, series: "Online", facet: "East"), + facetPoint(index: 1, x: "Jan", y: 12, series: "Retail", facet: "East"), + facetPoint(index: 2, x: "Jan", y: 14, series: "Retail", facet: "West"), + facetPoint(index: 3, x: "Jan", y: 16, series: "Online", facet: "West"), + ], + xAxisKind: .category, + limits: [], + loadedRowCount: 4, + skippedRowCount: 0, + xAxisLabel: "Month", + yAxisLabel: "Revenue", + seriesLabel: "Channel", + facetLabel: "Region" + ) + let west = try #require(projection.panels.first { $0.facet?.label == "West" }) + let view = ResultChartCanvas(projection: projection, panel: west, chartType: .bar, showsLegend: false) + .frame(width: 640, height: 360) + let image = try #require(render(view, colorScheme: .light)) + + let online = try #require(firstColumn(in: image) { Self.hue(of: $0, isWithin: 195...225) }) + let retail = try #require(firstColumn(in: image) { Self.hue(of: $0, isWithin: 120...160) }) + #expect(online < retail) + } + + @Test("A panel missing a series keeps its other bars at the width and place they have elsewhere") + func sparsePanelBarsKeepTheirSlot() throws { + let projection = ResultChartProjection( + points: [ + facetPoint(index: 0, x: "Jan", y: 10, series: "Online", facet: "East"), + facetPoint(index: 1, x: "Jan", y: 12, series: "Retail", facet: "East"), + facetPoint(index: 2, x: "Jan", y: 14, series: "Retail", facet: "West"), + ], + xAxisKind: .category, + limits: [], + loadedRowCount: 3, + skippedRowCount: 0, + xAxisLabel: "Month", + yAxisLabel: "Revenue", + seriesLabel: "Channel", + facetLabel: "Region" + ) + func retailColumns(in facet: String) throws -> ClosedRange { + let panel = try #require(projection.panels.first { $0.facet?.label == facet }) + let view = ResultChartCanvas(projection: projection, panel: panel, chartType: .bar, showsLegend: false) + .frame(width: 640, height: 360) + let image = try #require(render(view, colorScheme: .light)) + let first = try #require(firstColumn(in: image) { Self.hue(of: $0, isWithin: 120...160) }) + let last = try #require(lastColumn(in: image) { Self.hue(of: $0, isWithin: 120...160) }) + return first...last + } + + let east = try retailColumns(in: "East") + let west = try retailColumns(in: "West") + #expect(abs(east.lowerBound - west.lowerBound) <= 1) + #expect(abs(east.upperBound - west.upperBound) <= 1) + } + + private func lastColumn(in image: CGImage, where matches: (NSColor) -> Bool) -> Int? { + let bitmap = NSBitmapImageRep(cgImage: image) + for x in (0 ..< bitmap.pixelsWide).reversed() { + for y in 0 ..< bitmap.pixelsHigh { + guard let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) else { continue } + if matches(color) { return x } + } + } + return nil + } + + private func firstColumn(in image: CGImage, where matches: (NSColor) -> Bool) -> Int? { + let bitmap = NSBitmapImageRep(cgImage: image) + for x in 0 ..< bitmap.pixelsWide { + for y in 0 ..< bitmap.pixelsHigh { + guard let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) else { continue } + if matches(color) { return x } + } + } + return nil + } + + @Test("A faceted grid draws every panel in both appearances", arguments: [ColorScheme.light, .dark]) + func facetGridRendersEveryPanel(colorScheme: ColorScheme) throws { + let projection = ResultChartProjection( + points: ["East", "North", "West"].enumerated().flatMap { offset, region in + [ + facetPoint(index: offset * 2, x: "Jan", y: 10 + offset, series: "Online", facet: region), + facetPoint(index: offset * 2 + 1, x: "Feb", y: 20 + offset, series: "Online", facet: region), + ] + }, + xAxisKind: .category, + limits: [], + loadedRowCount: 6, + skippedRowCount: 0, + xAxisLabel: "Month", + yAxisLabel: "Revenue", + seriesLabel: "Channel", + facetLabel: "Region" + ) + let layout = ResultChartFacetLayout(panelCount: projection.panels.count, size: CGSize(width: 960, height: 400)) + let view = ResultChartFacetPanels(projection: projection, chartType: .bar, layout: layout, revision: 0) + .frame(width: 960, height: 400) + .environment(\.colorScheme, colorScheme) + let image = try #require(render(view, colorScheme: colorScheme)) + 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), + Self.hue(of: color, isWithin: 195...225) + else { + continue + } + columns.append(x) + break + } + } + #expect(projection.panels.count == 3) + #expect(contiguousRuns(in: columns).count >= 6) + } + + private static func hue(of color: NSColor, isWithin degrees: ClosedRange) -> Bool { + color.saturationComponent > 0.35 && color.brightnessComponent > 0.25 && color.alphaComponent > 0.5 + && degrees.contains(color.hueComponent * 360) + } + + private func facetPoint(index: Int, x: String, y: Int, series: String, facet: String) -> ResultChartProjection.Point { + ResultChartProjection.Point( + sourceIndex: index, + x: .category(x), + y: Double(y), + rawX: x, + rawY: String(y), + series: .init(.value(series)), + facet: .init(.value(facet)), + barGroup: series == "Online" ? 0 : 1, + lineGroup: 0 + ) + } + @Test("The plot stands in for the grid: its background under a theme, its own wash by default") func plotTakesTheGridBackground() throws { try ThemeTestFixture.preservingEngine { @@ -302,7 +477,7 @@ struct ResultChartCanvasTests { y: Double(y), rawX: x, rawY: String(y), - series: .value(series), + series: .init(.value(series)), barGroup: index, lineGroup: series == "First" ? 0 : 1 ) diff --git a/apple/TableProTests/Views/Results/ResultChartFacetLayoutTests.swift b/apple/TableProTests/Views/Results/ResultChartFacetLayoutTests.swift new file mode 100644 index 0000000000..cb6a97f114 --- /dev/null +++ b/apple/TableProTests/Views/Results/ResultChartFacetLayoutTests.swift @@ -0,0 +1,42 @@ +// +// ResultChartFacetLayoutTests.swift +// TableProTests +// + +import CoreGraphics +@testable import TablePro +import Testing + +struct ResultChartFacetLayoutTests { + @Test("Two panels in a wide pane sit side by side at full height") + func fewPanelsFillThePane() { + let layout = ResultChartFacetLayout(panelCount: 2, size: CGSize(width: 1_200, height: 600)) + + #expect(layout.columns == 2) + #expect(layout.panelHeight == 600) + } + + @Test("Past what fits at the minimum size the grid scrolls instead of shrinking") + func manyPanelsKeepTheMinimumHeight() { + let layout = ResultChartFacetLayout(panelCount: 12, size: CGSize(width: 1_200, height: 600)) + + #expect(layout.columns == 4) + #expect(layout.panelHeight == ResultChartFacetLayout.minimumPanelHeight) + } + + @Test("Rows are balanced rather than leaving one panel alone on the last row") + func rowsAreBalanced() { + let layout = ResultChartFacetLayout(panelCount: 4, size: CGSize(width: 1_000, height: 600)) + + #expect(layout.columns == 2) + #expect(layout.panelHeight == 294) + } + + @Test("A narrow pane stacks the panels in one column") + func narrowPaneStacks() { + let layout = ResultChartFacetLayout(panelCount: 3, size: CGSize(width: 300, height: 900)) + + #expect(layout.columns == 1) + #expect(layout.panelHeight == 292) + } +} diff --git a/apple/TableProTests/Views/Results/ResultChartSelectionTests.swift b/apple/TableProTests/Views/Results/ResultChartSelectionTests.swift index 05d072668b..2d938583e2 100644 --- a/apple/TableProTests/Views/Results/ResultChartSelectionTests.swift +++ b/apple/TableProTests/Views/Results/ResultChartSelectionTests.swift @@ -10,23 +10,23 @@ import Testing struct ResultChartSelectionTests { @Test("Categorical selection includes every series at the selected X value") func categoricalSelectionIncludesEverySeries() throws { - let index = ResultChartSelectionIndex(projection: projection(points: [ + let index = ResultChartSelectionIndex(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.xLabel == "Jan") #expect(selection.points.map(\.sourceIndex) == [0, 1]) } @Test("Unknown categorical selection produces no callout") func unknownCategoricalSelectionProducesNothing() { - let index = ResultChartSelectionIndex(projection: projection(points: [ + let index = ResultChartSelectionIndex(points: [ point(index: 0, x: .category("Jan"), rawX: "Jan", y: 10), - ])) + ]) #expect(index.selection(forCategory: "Feb") == nil) #expect(index.selection(forCategory: nil) == nil) @@ -34,25 +34,25 @@ struct ResultChartSelectionTests { @Test("Numeric selection resolves the nearest plotted X value") func numericSelectionResolvesNearestValue() throws { - let index = ResultChartSelectionIndex(projection: projection(points: [ + let index = ResultChartSelectionIndex(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.xLabel == "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: [ + let index = ResultChartSelectionIndex(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)) @@ -61,10 +61,10 @@ struct ResultChartSelectionTests { @Test("A pointer beyond the plotted range clamps to the end value") func numericSelectionClampsToRange() throws { - let index = ResultChartSelectionIndex(projection: projection(points: [ + let index = ResultChartSelectionIndex(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)) @@ -72,9 +72,9 @@ struct ResultChartSelectionTests { @Test("Missing and nonfinite numeric selections produce no callout") func missingAndNonfiniteNumericSelectionsProduceNothing() { - let index = ResultChartSelectionIndex(projection: projection(points: [ + let index = ResultChartSelectionIndex(points: [ point(index: 0, x: .number(10), rawX: "10", y: 1), - ])) + ]) #expect(index.selection(nearestNumber: nil) == nil) #expect(index.selection(nearestNumber: .nan) == nil) @@ -84,10 +84,10 @@ struct ResultChartSelectionTests { 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: [ + let index = ResultChartSelectionIndex(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))) @@ -95,19 +95,6 @@ struct ResultChartSelectionTests { #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, @@ -121,7 +108,7 @@ struct ResultChartSelectionTests { y: Double(y), rawX: rawX, rawY: String(y), - series: series.map(ResultChartProjection.SeriesValue.value), + series: series.map { .init(.value($0)) }, barGroup: 0, lineGroup: 0 ) diff --git a/apple/TableProTests/Views/Results/ResultChartSeriesStyleTests.swift b/apple/TableProTests/Views/Results/ResultChartSeriesStyleTests.swift new file mode 100644 index 0000000000..1438361669 --- /dev/null +++ b/apple/TableProTests/Views/Results/ResultChartSeriesStyleTests.swift @@ -0,0 +1,32 @@ +// +// ResultChartSeriesStyleTests.swift +// TableProTests +// + +import SwiftUI +@testable import TablePro +import Testing + +struct ResultChartSeriesStyleTests { + @Test("A series keeps its colour whatever order the rows name it in") + func colourIgnoresRowOrder() { + let names = ["Widgets", "Bolts", "Nuts"] + let forward = ResultChartSeriesStyle(series: names.map { .init(.value($0)) }) + let reversed = ResultChartSeriesStyle(series: names.reversed().map { .init(.value($0)) }) + + #expect(forward.names == names) + #expect(colours(of: forward) == colours(of: reversed)) + #expect(Set(colours(of: forward).values).count == names.count) + } + + @Test("The legend lists series in the order the rows name them") + func legendFollowsRowOrder() { + let style = ResultChartSeriesStyle(series: ["Widgets", "Bolts"].map { .init(.value($0)) }) + + #expect(style.entries.map(\.name) == ["Widgets", "Bolts"]) + } + + private func colours(of style: ResultChartSeriesStyle) -> [String: Color] { + Dictionary(uniqueKeysWithValues: style.entries.map { ($0.name, $0.color) }) + } +} diff --git a/apple/TableProTests/Views/Results/ResultChartToolbarTests.swift b/apple/TableProTests/Views/Results/ResultChartToolbarTests.swift index 82e0325ea9..01ff3855ee 100644 --- a/apple/TableProTests/Views/Results/ResultChartToolbarTests.swift +++ b/apple/TableProTests/Views/Results/ResultChartToolbarTests.swift @@ -35,12 +35,30 @@ struct ResultChartToolbarTests { ResultChartToolbar.noticeText(for: .series(limit: 20), loadedRowCount: 100) == "Showing the first \(grouped(20)) series" ) + #expect( + ResultChartToolbar.noticeText(for: .facets(limit: 12), loadedRowCount: 100) + == "Showing the first \(grouped(12)) facets" + ) #expect( ResultChartToolbar.noticeText(for: .inspectedRows(limit: 50_000), loadedRowCount: 90_000) == "Charting the first \(grouped(50_000)) of \(grouped(90_000)) loaded rows" ) } + @Test("A stored column this result lacks shows as the pop-up's none item") + func missingSelectionShowsNone() { + let columns = ResultChartColumn.columns(in: TableRows.from( + queryRows: [], + columns: ["name"], + columnTypes: [.text(rawType: nil)] + )) + let stored = ResultChartColumnID(name: "revenue", occurrence: 1) + + #expect(ResultChartAxisPicker.shownSelection(stored, in: columns) == nil) + #expect(ResultChartAxisPicker.shownSelection(stored, in: []) == nil) + #expect(ResultChartAxisPicker.shownSelection(columns[0].id, in: columns) == columns[0].id) + } + @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) diff --git a/apple/TableProTests/Views/Results/ResultPaneProjectionStateTests.swift b/apple/TableProTests/Views/Results/ResultPaneProjectionStateTests.swift new file mode 100644 index 0000000000..52ec04790f --- /dev/null +++ b/apple/TableProTests/Views/Results/ResultPaneProjectionStateTests.swift @@ -0,0 +1,62 @@ +// +// ResultPaneProjectionStateTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +struct ResultPaneProjectionStateTests { + private struct Key: ResultPaneProjectionKey { + let tabId: UUID + let resultSetId: UUID + let column: String + let revision: Int + + var subject: [AnyHashable] { [tabId, column] } + } + + private let tab = UUID() + + @Test("A refresh, sort or page turn keeps the drawing up while its replacement builds") + func refreshKeepsTheProjection() { + var state = ResultPaneProjectionState() + state.commit("first", for: Key(tabId: tab, resultSetId: UUID(), column: "revenue", revision: 1)) + let refreshed = Key(tabId: tab, resultSetId: UUID(), column: "revenue", revision: 2) + + #expect(state.projection(for: refreshed) == "first") + #expect(!state.isCurrent(for: refreshed)) + } + + @Test("Another tab or other columns never show this projection") + func otherSubjectsShowNothing() { + var state = ResultPaneProjectionState() + let result = UUID() + state.commit("first", for: Key(tabId: tab, resultSetId: result, column: "revenue", revision: 1)) + + #expect(state.projection(for: Key(tabId: UUID(), resultSetId: result, column: "revenue", revision: 1)) == nil) + #expect(state.projection(for: Key(tabId: tab, resultSetId: result, column: "cost", revision: 1)) == nil) + } + + @Test("The key it was built for needs no rebuild") + func exactKeyIsCurrent() { + var state = ResultPaneProjectionState() + let key = Key(tabId: tab, resultSetId: UUID(), column: "revenue", revision: 1) + + #expect(state.projection(for: key) == nil) + state.commit("first", for: key) + #expect(state.isCurrent(for: key)) + #expect(state.projection(for: key) == "first") + } + + @Test("A chart keeps its drawing across a new result set with the same columns") + func chartSubjectIgnoresTheResultSet() { + let tab = UUID() + let first = ResultChartProjectionKey(tabId: tab, resultSetId: UUID(), dataRevision: 1, resolved: nil, isUnlocked: true) + let second = ResultChartProjectionKey(tabId: tab, resultSetId: UUID(), dataRevision: 2, resolved: nil, isUnlocked: true) + + #expect(first.subject == second.subject) + #expect(first != second) + } +} From 0ba74abb183ae753a792f5f0e9fceb80bd233cde Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 19 Oct 2026 21:10:32 +0700 Subject: [PATCH 2/2] docs(changelog): add fragments for result chart facets --- apple/changelog.d/3593.added.md | 1 + apple/changelog.d/3593.fixed.1.md | 1 + apple/changelog.d/3593.fixed.2.md | 1 + apple/changelog.d/3593.fixed.3.md | 1 + apple/changelog.d/3593.fixed.4.md | 1 + apple/changelog.d/3593.fixed.md | 1 + 6 files changed, 6 insertions(+) create mode 100644 apple/changelog.d/3593.added.md create mode 100644 apple/changelog.d/3593.fixed.1.md create mode 100644 apple/changelog.d/3593.fixed.2.md create mode 100644 apple/changelog.d/3593.fixed.3.md create mode 100644 apple/changelog.d/3593.fixed.4.md create mode 100644 apple/changelog.d/3593.fixed.md diff --git a/apple/changelog.d/3593.added.md b/apple/changelog.d/3593.added.md new file mode 100644 index 0000000000..ea30a879c9 --- /dev/null +++ b/apple/changelog.d/3593.added.md @@ -0,0 +1 @@ +Facet picker in result charts that draws one chart per value of a column, with shared axes and one legend. diff --git a/apple/changelog.d/3593.fixed.1.md b/apple/changelog.d/3593.fixed.1.md new file mode 100644 index 0000000000..851a941151 --- /dev/null +++ b/apple/changelog.d/3593.fixed.1.md @@ -0,0 +1 @@ +Result charts and maps blanked to a spinner on every refresh, sort or page turn. diff --git a/apple/changelog.d/3593.fixed.2.md b/apple/changelog.d/3593.fixed.2.md new file mode 100644 index 0000000000..57ed293c28 --- /dev/null +++ b/apple/changelog.d/3593.fixed.2.md @@ -0,0 +1 @@ +Result chart toolbar kept the previous result's notices after switching to a result with no numeric column. diff --git a/apple/changelog.d/3593.fixed.3.md b/apple/changelog.d/3593.fixed.3.md new file mode 100644 index 0000000000..009cd2a1c3 --- /dev/null +++ b/apple/changelog.d/3593.fixed.3.md @@ -0,0 +1 @@ +Result chart labels were blank for empty text, and a NULL series merged with a literal "No value" series. diff --git a/apple/changelog.d/3593.fixed.4.md b/apple/changelog.d/3593.fixed.4.md new file mode 100644 index 0000000000..4cb84b1340 --- /dev/null +++ b/apple/changelog.d/3593.fixed.4.md @@ -0,0 +1 @@ +Result chart pickers went blank when their column was missing from the new result. diff --git a/apple/changelog.d/3593.fixed.md b/apple/changelog.d/3593.fixed.md new file mode 100644 index 0000000000..04a20e4be9 --- /dev/null +++ b/apple/changelog.d/3593.fixed.md @@ -0,0 +1 @@ +Result chart series changed color, symbol and dash when the rows were sorted or paged.