1
1
import { extent , namespaces } from "d3" ;
2
+ import { valueObject } from "../channel.js" ;
2
3
import { create } from "../context.js" ;
3
4
import { composeRender } from "../mark.js" ;
4
- import { hasXY , identity , indexOf } from "../options.js" ;
5
+ import { hasXY , identity , indexOf , isObject } from "../options.js" ;
5
6
import { applyChannelStyles , applyDirectStyles , applyIndirectStyles , getPatternId } from "../style.js" ;
6
7
import { template } from "../template.js" ;
8
+ import { initializer } from "../transforms/basic.js" ;
7
9
import { maybeIdentityX , maybeIdentityY } from "../transforms/identity.js" ;
8
10
import { maybeIntervalX , maybeIntervalY } from "../transforms/interval.js" ;
9
11
import { maybeStackX , maybeStackY } from "../transforms/stack.js" ;
@@ -14,8 +16,8 @@ const waffleDefaults = {
14
16
} ;
15
17
16
18
export class WaffleX extends BarX {
17
- constructor ( data , { unit = 1 , gap = 1 , round, render , multiple, ...options } = { } ) {
18
- super ( data , { ... options , render : composeRender ( render , waffleRender ( "x" ) ) } , waffleDefaults ) ;
19
+ constructor ( data , { unit = 1 , gap = 1 , round, multiple, ...options } = { } ) {
20
+ super ( data , wafflePolygon ( "x" , options ) , waffleDefaults ) ;
19
21
this . unit = Math . max ( 0 , unit ) ;
20
22
this . gap = + gap ;
21
23
this . round = maybeRound ( round ) ;
@@ -24,26 +26,28 @@ export class WaffleX extends BarX {
24
26
}
25
27
26
28
export class WaffleY extends BarY {
27
- constructor ( data , { unit = 1 , gap = 1 , round, render , multiple, ...options } = { } ) {
28
- super ( data , { ... options , render : composeRender ( render , waffleRender ( "y" ) ) } , waffleDefaults ) ;
29
+ constructor ( data , { unit = 1 , gap = 1 , round, multiple, ...options } = { } ) {
30
+ super ( data , wafflePolygon ( "y" , options ) , waffleDefaults ) ;
29
31
this . unit = Math . max ( 0 , unit ) ;
30
32
this . gap = + gap ;
31
33
this . round = maybeRound ( round ) ;
32
34
this . multiple = maybeMultiple ( multiple ) ;
33
35
}
34
36
}
35
37
36
- function waffleRender ( y ) {
37
- return function ( index , scales , values , dimensions , context ) {
38
- const { ariaLabel, href, title, ...visualValues } = values ;
39
- const { unit, gap, rx, ry, round} = this ;
40
- const { document} = context ;
41
- const Y1 = values . channels [ `${ y } 1` ] . value ;
42
- const Y2 = values . channels [ `${ y } 2` ] . value ;
38
+ function wafflePolygon ( y , options ) {
39
+ const x = y === "y" ? "x" : "y" ;
40
+ const y1 = `${ y } 1` ;
41
+ const y2 = `${ y } 2` ;
42
+ return initializer ( waffleRender ( options ) , function ( data , facets , channels , scales , dimensions ) {
43
+ const { round, unit} = this ;
44
+ const Y1 = channels [ y1 ] . value ;
45
+ const Y2 = channels [ y2 ] . value ;
43
46
44
47
// We might not use all the available bandwidth if the cells don’t fit evenly.
45
- const barwidth = this [ y === "y" ? "_width" : "_height" ] ( scales , values , dimensions ) ;
46
- const barx = this [ y === "y" ? "_x" : "_y" ] ( scales , values , dimensions ) ;
48
+ const xy = valueObject ( { ...( x in channels && { [ x ] : channels [ x ] } ) , [ y1 ] : channels [ y1 ] , [ y2 ] : channels [ y2 ] } , scales ) ;
49
+ const barwidth = this [ y === "y" ? "_width" : "_height" ] ( scales , xy , dimensions ) ;
50
+ const barx = this [ y === "y" ? "_x" : "_y" ] ( scales , xy , dimensions ) ;
47
51
48
52
// The length of a unit along y in pixels.
49
53
const scale = unit * scaleof ( scales . scales [ y ] ) ;
@@ -55,63 +59,98 @@ function waffleRender(y) {
55
59
const cx = Math . min ( barwidth / multiple , scale * multiple ) ;
56
60
const cy = scale * multiple ;
57
61
58
- // TODO insets?
59
- const transform = y === "y" ? ( [ x , y ] ) => [ x * cx , - y * cy ] : ( [ x , y ] ) => [ y * cy , x * cx ] ;
62
+ // The reference position.
60
63
const tx = ( barwidth - multiple * cx ) / 2 ;
61
64
const x0 = typeof barx === "function" ? ( i ) => barx ( i ) + tx : barx + tx ;
62
65
const y0 = scales [ y ] ( 0 ) ;
63
66
64
- // Create a base pattern with shared attributes for cloning.
65
- const patternId = getPatternId ( ) ;
66
- const basePattern = document . createElementNS ( namespaces . svg , "pattern" ) ;
67
- basePattern . setAttribute ( "width" , y === "y" ? cx : cy ) ;
68
- basePattern . setAttribute ( "height" , y === "y" ? cy : cx ) ;
69
- basePattern . setAttribute ( "patternUnits" , "userSpaceOnUse" ) ;
70
- const basePatternRect = basePattern . appendChild ( document . createElementNS ( namespaces . svg , "rect" ) ) ;
71
- basePatternRect . setAttribute ( "x" , gap / 2 ) ;
72
- basePatternRect . setAttribute ( "y" , gap / 2 ) ;
73
- basePatternRect . setAttribute ( "width" , ( y === "y" ? cx : cy ) - gap ) ;
74
- basePatternRect . setAttribute ( "height" , ( y === "y" ? cy : cx ) - gap ) ;
75
- if ( rx != null ) basePatternRect . setAttribute ( "rx" , rx ) ;
76
- if ( ry != null ) basePatternRect . setAttribute ( "ry" , ry ) ;
77
-
78
- return create ( "svg:g" , context )
79
- . call ( applyIndirectStyles , this , dimensions , context )
80
- . call ( this . _transform , this , scales )
81
- . call ( ( g ) =>
82
- g
83
- . selectAll ( )
84
- . data ( index )
85
- . enter ( )
86
- . append ( ( ) => basePattern . cloneNode ( true ) )
87
- . attr ( "id" , ( i ) => `${ patternId } -${ i } ` )
88
- . select ( "rect" )
89
- . call ( applyDirectStyles , this )
90
- . call ( applyChannelStyles , this , visualValues )
91
- )
92
- . call ( ( g ) =>
93
- g
94
- . selectAll ( )
95
- . data ( index )
96
- . enter ( )
97
- . append ( "path" )
98
- . attr ( "transform" , y === "y" ? template `translate(${ x0 } ,${ y0 } )` : template `translate(${ y0 } ,${ x0 } )` )
99
- . attr (
100
- "d" ,
101
- ( i ) =>
102
- `M${ wafflePoints ( round ( Y1 [ i ] / unit ) , round ( Y2 [ i ] / unit ) , multiple )
103
- . map ( transform )
104
- . join ( "L" ) } Z`
105
- )
106
- . attr ( "fill" , ( i ) => `url(#${ patternId } -${ i } )` )
107
- . attr ( "stroke" , this . stroke == null ? null : ( i ) => `url(#${ patternId } -${ i } )` )
108
- . call ( applyChannelStyles , this , { ariaLabel, href, title} )
109
- )
110
- . node ( ) ;
67
+ // TODO insets?
68
+ const transform = y === "y" ? ( [ x , y ] ) => [ x * cx , - y * cy ] : ( [ x , y ] ) => [ y * cy , x * cx ] ;
69
+ const mx = typeof x0 === "function" ? ( i ) => x0 ( i ) - barwidth / 2 : ( ) => x0 ;
70
+ const [ ix , iy ] = y === "y" ? [ 0 , 1 ] : [ 1 , 0 ] ;
71
+
72
+ const n = Y2 . length ;
73
+ const P = new Array ( n ) ;
74
+ const X = new Float64Array ( n ) ;
75
+ const Y = new Float64Array ( n ) ;
76
+
77
+ for ( let i = 0 ; i < n ; ++ i ) {
78
+ P [ i ] = wafflePoints ( round ( Y1 [ i ] / unit ) , round ( Y2 [ i ] / unit ) , multiple ) . map ( transform ) ;
79
+ const c = P [ i ] . pop ( ) ; // extract the transformed centroid
80
+ X [ i ] = c [ ix ] + mx ( i ) ;
81
+ Y [ i ] = c [ iy ] + y0 ;
82
+ }
83
+
84
+ return {
85
+ channels : {
86
+ polygon : { value : P , source : null , filter : null } ,
87
+ [ `c${ x } ` ] : { value : [ cx , x0 ] , source : null , filter : null } ,
88
+ [ `c${ y } ` ] : { value : [ cy , y0 ] , source : null , filter : null } ,
89
+ [ x ] : { value : X , scale : null , source : null } ,
90
+ [ y1 ] : { value : Y , scale : null , source : channels [ y1 ] } ,
91
+ [ y2 ] : { value : Y , scale : null , source : channels [ y2 ] }
92
+ }
93
+ } ;
94
+ } ) ;
95
+ }
96
+
97
+ function waffleRender ( { render, ...options } ) {
98
+ return {
99
+ ...options ,
100
+ render : composeRender ( render , function ( index , scales , values , dimensions , context ) {
101
+ const { gap, rx, ry} = this ;
102
+ const { channels, ariaLabel, href, title, ...visualValues } = values ;
103
+ const { document} = context ;
104
+ const polygon = channels . polygon . value ;
105
+ const [ cx , x0 ] = channels . cx . value ;
106
+ const [ cy , y0 ] = channels . cy . value ;
107
+
108
+ // Create a base pattern with shared attributes for cloning.
109
+ const patternId = getPatternId ( ) ;
110
+ const basePattern = document . createElementNS ( namespaces . svg , "pattern" ) ;
111
+ basePattern . setAttribute ( "width" , cx ) ;
112
+ basePattern . setAttribute ( "height" , cy ) ;
113
+ basePattern . setAttribute ( "patternUnits" , "userSpaceOnUse" ) ;
114
+ const basePatternRect = basePattern . appendChild ( document . createElementNS ( namespaces . svg , "rect" ) ) ;
115
+ basePatternRect . setAttribute ( "x" , gap / 2 ) ;
116
+ basePatternRect . setAttribute ( "y" , gap / 2 ) ;
117
+ basePatternRect . setAttribute ( "width" , cx - gap ) ;
118
+ basePatternRect . setAttribute ( "height" , cy - gap ) ;
119
+ if ( rx != null ) basePatternRect . setAttribute ( "rx" , rx ) ;
120
+ if ( ry != null ) basePatternRect . setAttribute ( "ry" , ry ) ;
121
+
122
+ return create ( "svg:g" , context )
123
+ . call ( applyIndirectStyles , this , dimensions , context )
124
+ . call ( this . _transform , this , scales )
125
+ . call ( ( g ) =>
126
+ g
127
+ . selectAll ( )
128
+ . data ( index )
129
+ . enter ( )
130
+ . append ( ( ) => basePattern . cloneNode ( true ) )
131
+ . attr ( "id" , ( i ) => `${ patternId } -${ i } ` )
132
+ . select ( "rect" )
133
+ . call ( applyDirectStyles , this )
134
+ . call ( applyChannelStyles , this , visualValues )
135
+ )
136
+ . call ( ( g ) =>
137
+ g
138
+ . selectAll ( )
139
+ . data ( index )
140
+ . enter ( )
141
+ . append ( "path" )
142
+ . attr ( "transform" , template `translate(${ x0 } ,${ y0 } )` )
143
+ . attr ( "d" , ( i ) => `M${ polygon [ i ] . join ( "L" ) } Z` )
144
+ . attr ( "fill" , ( i ) => `url(#${ patternId } -${ i } )` )
145
+ . attr ( "stroke" , this . stroke == null ? null : ( i ) => `url(#${ patternId } -${ i } )` )
146
+ . call ( applyChannelStyles , this , { ariaLabel, href, title} )
147
+ )
148
+ . node ( ) ;
149
+ } )
111
150
} ;
112
151
}
113
152
114
- // A waffle is a approximately rectangular shape, but may have one or two corner
153
+ // A waffle is approximately a rectangular shape, but may have one or two corner
115
154
// cuts if the starting or ending value is not an even multiple of the number of
116
155
// columns (the width of the waffle in cells). We can represent any waffle by
117
156
// 8 points; below is a waffle of five columns representing the interval 2–11:
@@ -148,14 +187,11 @@ function waffleRender(y) {
148
187
// Waffles can also represent fractional intervals (e.g., 2.4–10.1). These
149
188
// require additional corner cuts, so the implementation below generates a few
150
189
// more points.
190
+ //
191
+ // The last point describes the centroid (used for pointing)
151
192
function wafflePoints ( i1 , i2 , columns ) {
152
- if ( i1 < 0 || i2 < 0 ) {
153
- const k = Math . ceil ( - Math . min ( i1 , i2 ) / columns ) ; // shift negative to positive
154
- return wafflePoints ( i1 + k * columns , i2 + k * columns , columns ) . map ( ( [ x , y ] ) => [ x , y - k ] ) ;
155
- }
156
- if ( i2 < i1 ) {
157
- return wafflePoints ( i2 , i1 , columns ) ;
158
- }
193
+ if ( i2 < i1 ) return wafflePoints ( i2 , i1 , columns ) ; // ensure i1 <= i2
194
+ if ( i1 < 0 ) return wafflePointsOffset ( i1 , i2 , columns , Math . ceil ( - Math . min ( i1 , i2 ) / columns ) ) ; // ensure i1 >= 0
159
195
const x1f = Math . floor ( i1 % columns ) ;
160
196
const x1c = Math . ceil ( i1 % columns ) ;
161
197
const x2f = Math . floor ( i2 % columns ) ;
@@ -177,9 +213,49 @@ function wafflePoints(i1, i2, columns) {
177
213
points . push ( [ x2f , y2c ] ) ;
178
214
if ( y2c > y1c ) points . push ( [ 0 , y2c ] ) ;
179
215
}
216
+ points . push ( waffleCentroid ( i1 , i2 , columns ) ) ;
180
217
return points ;
181
218
}
182
219
220
+ function wafflePointsOffset ( i1 , i2 , columns , k ) {
221
+ return wafflePoints ( i1 + k * columns , i2 + k * columns , columns ) . map ( ( [ x , y ] ) => [ x , y - k ] ) ;
222
+ }
223
+
224
+ function waffleCentroid ( i1 , i2 , columns ) {
225
+ const r = Math . floor ( i2 / columns ) - Math . floor ( i1 / columns ) ;
226
+ return r === 0
227
+ ? // Single row
228
+ waffleRowCentroid ( i1 , i2 , columns )
229
+ : r === 1
230
+ ? // Two incomplete rows; use the midpoint of their overlap if any, otherwise the larger row
231
+ Math . floor ( i2 % columns ) > Math . ceil ( i1 % columns )
232
+ ? [ ( Math . floor ( i2 % columns ) + Math . ceil ( i1 % columns ) ) / 2 , Math . floor ( i2 / columns ) ]
233
+ : i2 % columns > columns - ( i1 % columns )
234
+ ? waffleRowCentroid ( i2 - ( i2 % columns ) , i2 , columns )
235
+ : waffleRowCentroid ( i1 , columns * Math . ceil ( i1 / columns ) , columns )
236
+ : // At least one full row; take the midpoint of all the rows that include the middle
237
+ [ columns / 2 , ( Math . round ( i1 / columns ) + Math . round ( i2 / columns ) ) / 2 ] ;
238
+ }
239
+
240
+ function waffleRowCentroid ( i1 , i2 , columns ) {
241
+ const c = Math . floor ( i2 ) - Math . floor ( i1 ) ;
242
+ return c === 0
243
+ ? // Single cell
244
+ [ Math . floor ( i1 % columns ) + 0.5 , Math . floor ( i1 / columns ) + ( ( ( i1 + i2 ) / 2 ) % 1 ) ]
245
+ : c === 1
246
+ ? // Two incomplete cells; use the overlap if large enough, otherwise use the largest
247
+ ( i2 % 1 ) - ( i1 % 1 ) > 0.5
248
+ ? [ Math . ceil ( i1 % columns ) , Math . floor ( i2 / columns ) + ( ( i1 % 1 ) + ( i2 % 1 ) ) / 2 ]
249
+ : i2 % 1 > 1 - ( i1 % 1 )
250
+ ? [ Math . floor ( i2 % columns ) + 0.5 , Math . floor ( i2 / columns ) + ( i2 % 1 ) / 2 ]
251
+ : [ Math . floor ( i1 % columns ) + 0.5 , Math . floor ( i1 / columns ) + ( 1 + ( i1 % 1 ) ) / 2 ]
252
+ : // At least one full cell; take the midpoint
253
+ [
254
+ Math . ceil ( i1 % columns ) + Math . ceil ( Math . floor ( i2 ) - Math . ceil ( i1 ) ) / 2 ,
255
+ Math . floor ( i1 / columns ) + ( i2 >= 1 + i1 ? 0.5 : ( ( i1 + i2 ) / 2 ) % 1 )
256
+ ] ;
257
+ }
258
+
183
259
function maybeRound ( round ) {
184
260
if ( round === undefined || round === false ) return Number ;
185
261
if ( round === true ) return Math . round ;
@@ -200,12 +276,28 @@ function spread(domain) {
200
276
return max - min ;
201
277
}
202
278
203
- export function waffleX ( data , options = { } ) {
279
+ export function waffleX ( data , { tip , ... options } = { } ) {
204
280
if ( ! hasXY ( options ) ) options = { ...options , y : indexOf , x2 : identity } ;
205
- return new WaffleX ( data , maybeStackX ( maybeIntervalX ( maybeIdentityX ( options ) ) ) ) ;
281
+ return new WaffleX ( data , { tip : waffleTip ( tip ) , ... maybeStackX ( maybeIntervalX ( maybeIdentityX ( options ) ) ) } ) ;
206
282
}
207
283
208
- export function waffleY ( data , options = { } ) {
284
+ export function waffleY ( data , { tip , ... options } = { } ) {
209
285
if ( ! hasXY ( options ) ) options = { ...options , x : indexOf , y2 : identity } ;
210
- return new WaffleY ( data , maybeStackY ( maybeIntervalY ( maybeIdentityY ( options ) ) ) ) ;
286
+ return new WaffleY ( data , { tip : waffleTip ( tip ) , ...maybeStackY ( maybeIntervalY ( maybeIdentityY ( options ) ) ) } ) ;
287
+ }
288
+
289
+ /**
290
+ * Waffle tips behave a bit unpredictably because we they are driven by the
291
+ * waffle centroid; you could be hovering over a waffle segment, but more than
292
+ * 40px away from its centroid, or closer to the centroid of another segment.
293
+ * We’d rather show a tip, even if it’s the “wrong” one, so we increase the
294
+ * default maxRadius to Infinity. The “right” way to fix this would be to use
295
+ * signed distance to the waffle geometry rather than the centroid.
296
+ */
297
+ function waffleTip ( tip ) {
298
+ return tip === true
299
+ ? { maxRadius : Infinity }
300
+ : isObject ( tip ) && tip . maxRadius === undefined
301
+ ? { ...tip , maxRadius : Infinity }
302
+ : undefined ;
211
303
}
0 commit comments