Skip to content

Commit 6a104a9

Browse files
authored
feat(datagrid): split result charts into one panel per column value (#3593)
1 parent 6467a90 commit 6a104a9

32 files changed

Lines changed: 1658 additions & 258 deletions

‎apple/TablePro/Core/Services/Query/ResultChartProjection.swift‎

Lines changed: 181 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,68 @@ struct ResultChartProjection: Equatable, Sendable {
1212
case date(Date)
1313
}
1414

15-
enum SeriesValue: Equatable, Hashable, Sendable {
15+
/// A series or facet cell. It stays apart from its label, so a NULL and a literal "No value" are
16+
/// two groups even though both would read the same.
17+
enum GroupValue: Hashable, Sendable {
1618
case value(String)
1719
case missing
1820

19-
/// The legend entry and the hover callout name the same series, so they read it from here.
20-
var displayName: String {
21+
/// The data grid's words for the two cells that have no text of their own.
22+
var baseLabel: String {
2123
switch self {
24+
case .value(let raw) where raw.isEmpty: return String(localized: "Empty")
2225
case .value(let raw): return raw
2326
case .missing: return String(localized: "No value")
2427
}
2528
}
29+
30+
/// The order that does not depend on row order. Series colours and duplicate-label numbers
31+
/// follow it, so a re-sort or a page turn that brings another value first changes neither.
32+
static func areInIncreasingOrder(_ lhs: GroupValue, _ rhs: GroupValue) -> Bool {
33+
switch (lhs, rhs) {
34+
case (.value(let left), .value(let right)):
35+
switch left.localizedStandardCompare(right) {
36+
case .orderedAscending: return true
37+
case .orderedDescending: return false
38+
case .orderedSame: return left < right
39+
}
40+
case (.value, .missing):
41+
return true
42+
case (.missing, _):
43+
return false
44+
}
45+
}
46+
}
47+
48+
/// What the legend, the panel title, the callout and VoiceOver show for a group, and the value
49+
/// the chart plots it under. Labels are unique within a projection: two groups that would read
50+
/// the same are numbered the way duplicate column names are.
51+
struct Group: Hashable, Sendable {
52+
let value: GroupValue
53+
let label: String
54+
55+
init(_ value: GroupValue, label: String? = nil) {
56+
self.value = value
57+
self.label = label ?? value.baseLabel
58+
}
59+
60+
/// Labels distinct values, in the order given, numbering duplicates in the row-order-free
61+
/// order so "No value (1)" names the same group after a re-sort.
62+
static func labeled(_ values: [GroupValue]) -> [Group] {
63+
let sorted = values.sorted(by: GroupValue.areInIncreasingOrder)
64+
let labels = Dictionary(
65+
uniqueKeysWithValues: zip(sorted, ResultChartLabels.unique(sorted.map(\.baseLabel)))
66+
)
67+
return values.map { Group($0, label: labels[$0]) }
68+
}
2669
}
2770

2871
/// A bound the projection ran into. The points already gathered are kept and drawn: a result one
2972
/// row over a cap is a chart with a note, not an error card.
3073
enum Limit: Equatable, Sendable {
3174
case points(limit: Int)
3275
case series(limit: Int)
76+
case facets(limit: Int)
3377
case inspectedRows(limit: Int)
3478
}
3579

@@ -39,11 +83,87 @@ struct ResultChartProjection: Equatable, Sendable {
3983
let y: Double
4084
let rawX: String
4185
let rawY: String
42-
let series: SeriesValue?
86+
let series: Group?
87+
let facet: Group?
4388
let barGroup: Int
4489
let lineGroup: Int
4590

91+
init(
92+
sourceIndex: Int,
93+
x: XValue,
94+
y: Double,
95+
rawX: String,
96+
rawY: String,
97+
series: Group?,
98+
facet: Group? = nil,
99+
barGroup: Int,
100+
lineGroup: Int
101+
) {
102+
self.sourceIndex = sourceIndex
103+
self.x = x
104+
self.y = y
105+
self.rawX = rawX
106+
self.rawY = rawY
107+
self.series = series
108+
self.facet = facet
109+
self.barGroup = barGroup
110+
self.lineGroup = lineGroup
111+
}
112+
46113
var id: Int { sourceIndex }
114+
115+
/// A category plots under its label, which names an empty string; numbers and dates read as
116+
/// the database wrote them.
117+
var xLabel: String {
118+
if case .category(let label) = x { return label }
119+
return rawX
120+
}
121+
}
122+
123+
/// One chart of the grid. An unfaceted projection is a single panel with no facet.
124+
struct Panel: Identifiable, Equatable, Sendable {
125+
let facet: Group?
126+
let points: [Point]
127+
128+
var id: String { facet?.label ?? "" }
129+
}
130+
131+
/// The extent of every panel's points, so panels drawn as separate charts share their axes.
132+
struct Domain: Equatable, Sendable {
133+
let categories: [String]
134+
let numbers: ClosedRange<Double>?
135+
let dates: ClosedRange<Date>?
136+
let y: ClosedRange<Double>?
137+
138+
init(points: [Point]) {
139+
var categories: [String] = []
140+
var seenCategories: Set<String> = []
141+
var numbers: ClosedRange<Double>?
142+
var dates: ClosedRange<Date>?
143+
var y: ClosedRange<Double>?
144+
145+
for point in points {
146+
switch point.x {
147+
case .category(let label):
148+
if seenCategories.insert(label).inserted { categories.append(label) }
149+
case .number(let value):
150+
numbers = Self.extend(numbers, with: value)
151+
case .date(let value):
152+
dates = Self.extend(dates, with: value)
153+
}
154+
y = Self.extend(y, with: point.y)
155+
}
156+
157+
self.categories = categories
158+
self.numbers = numbers
159+
self.dates = dates
160+
self.y = y
161+
}
162+
163+
private static func extend<Bound: Comparable>(_ range: ClosedRange<Bound>?, with value: Bound) -> ClosedRange<Bound> {
164+
guard let range else { return value...value }
165+
return min(range.lowerBound, value)...max(range.upperBound, value)
166+
}
47167
}
48168

49169
let points: [Point]
@@ -54,4 +174,61 @@ struct ResultChartProjection: Equatable, Sendable {
54174
let xAxisLabel: String
55175
let yAxisLabel: String
56176
let seriesLabel: String?
177+
let facetLabel: String?
178+
/// False for row numbers: each panel numbers only its own rows, so a shared axis would squeeze
179+
/// every panel into its slice of the whole result.
180+
let sharesXAxis: Bool
181+
182+
/// Every series, in the order the rows first name them, which is the legend's order.
183+
let series: [Group]
184+
let panels: [Panel]
185+
let domain: Domain
186+
187+
init(
188+
points: [Point],
189+
xAxisKind: ResultChartColumn.AxisKind,
190+
limits: [Limit],
191+
loadedRowCount: Int,
192+
skippedRowCount: Int,
193+
xAxisLabel: String,
194+
yAxisLabel: String,
195+
seriesLabel: String?,
196+
facetLabel: String? = nil,
197+
sharesXAxis: Bool = true
198+
) {
199+
self.points = points
200+
self.xAxisKind = xAxisKind
201+
self.limits = limits
202+
self.loadedRowCount = loadedRowCount
203+
self.skippedRowCount = skippedRowCount
204+
self.xAxisLabel = xAxisLabel
205+
self.yAxisLabel = yAxisLabel
206+
self.seriesLabel = seriesLabel
207+
self.facetLabel = facetLabel
208+
self.sharesXAxis = sharesXAxis
209+
series = Self.orderedGroups(points.compactMap(\.series))
210+
panels = Self.panels(points: points, isFaceted: facetLabel != nil)
211+
domain = Domain(points: points)
212+
}
213+
214+
var isFaceted: Bool { facetLabel != nil }
215+
216+
/// First appearance, so the query's ORDER BY orders the legend and the panels, and the caps
217+
/// keep the first values a reader sees.
218+
private static func orderedGroups(_ groups: [Group]) -> [Group] {
219+
var seen: Set<GroupValue> = []
220+
return groups.filter { seen.insert($0.value).inserted }
221+
}
222+
223+
private static func panels(points: [Point], isFaceted: Bool) -> [Panel] {
224+
guard isFaceted else { return [Panel(facet: nil, points: points)] }
225+
var grouped: [GroupValue: [Point]] = [:]
226+
for point in points {
227+
guard let facet = point.facet else { continue }
228+
grouped[facet.value, default: []].append(point)
229+
}
230+
return orderedGroups(points.compactMap(\.facet)).map { facet in
231+
Panel(facet: facet, points: grouped[facet.value] ?? [])
232+
}
233+
}
57234
}

0 commit comments

Comments
 (0)