@@ -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