11import { extent , namespaces } from "d3" ;
2+ import { valueObject } from "../channel.js" ;
23import { create } from "../context.js" ;
34import { composeRender } from "../mark.js" ;
4- import { hasXY , identity , indexOf } from "../options.js" ;
5+ import { hasXY , identity , indexOf , isObject } from "../options.js" ;
56import { applyChannelStyles , applyDirectStyles , applyIndirectStyles , getPatternId } from "../style.js" ;
67import { template } from "../template.js" ;
8+ import { initializer } from "../transforms/basic.js" ;
79import { maybeIdentityX , maybeIdentityY } from "../transforms/identity.js" ;
810import { maybeIntervalX , maybeIntervalY } from "../transforms/interval.js" ;
911import { maybeStackX , maybeStackY } from "../transforms/stack.js" ;
@@ -14,8 +16,8 @@ const waffleDefaults = {
1416} ;
1517
1618export 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 ) ;
1921 this . unit = Math . max ( 0 , unit ) ;
2022 this . gap = + gap ;
2123 this . round = maybeRound ( round ) ;
@@ -24,26 +26,28 @@ export class WaffleX extends BarX {
2426}
2527
2628export 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 ) ;
2931 this . unit = Math . max ( 0 , unit ) ;
3032 this . gap = + gap ;
3133 this . round = maybeRound ( round ) ;
3234 this . multiple = maybeMultiple ( multiple ) ;
3335 }
3436}
3537
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 ;
4346
4447 // 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 ) ;
4751
4852 // The length of a unit along y in pixels.
4953 const scale = unit * scaleof ( scales . scales [ y ] ) ;
@@ -55,63 +59,98 @@ function waffleRender(y) {
5559 const cx = Math . min ( barwidth / multiple , scale * multiple ) ;
5660 const cy = scale * multiple ;
5761
58- // TODO insets?
59- const transform = y === "y" ? ( [ x , y ] ) => [ x * cx , - y * cy ] : ( [ x , y ] ) => [ y * cy , x * cx ] ;
62+ // The reference position.
6063 const tx = ( barwidth - multiple * cx ) / 2 ;
6164 const x0 = typeof barx === "function" ? ( i ) => barx ( i ) + tx : barx + tx ;
6265 const y0 = scales [ y ] ( 0 ) ;
6366
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+ } )
111150 } ;
112151}
113152
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
115154// cuts if the starting or ending value is not an even multiple of the number of
116155// columns (the width of the waffle in cells). We can represent any waffle by
117156// 8 points; below is a waffle of five columns representing the interval 2–11:
@@ -148,14 +187,11 @@ function waffleRender(y) {
148187// Waffles can also represent fractional intervals (e.g., 2.4–10.1). These
149188// require additional corner cuts, so the implementation below generates a few
150189// more points.
190+ //
191+ // The last point describes the centroid (used for pointing)
151192function 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
159195 const x1f = Math . floor ( i1 % columns ) ;
160196 const x1c = Math . ceil ( i1 % columns ) ;
161197 const x2f = Math . floor ( i2 % columns ) ;
@@ -177,9 +213,49 @@ function wafflePoints(i1, i2, columns) {
177213 points . push ( [ x2f , y2c ] ) ;
178214 if ( y2c > y1c ) points . push ( [ 0 , y2c ] ) ;
179215 }
216+ points . push ( waffleCentroid ( i1 , i2 , columns ) ) ;
180217 return points ;
181218}
182219
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+
183259function maybeRound ( round ) {
184260 if ( round === undefined || round === false ) return Number ;
185261 if ( round === true ) return Math . round ;
@@ -200,12 +276,28 @@ function spread(domain) {
200276 return max - min ;
201277}
202278
203- export function waffleX ( data , options = { } ) {
279+ export function waffleX ( data , { tip , ... options } = { } ) {
204280 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 ) ) ) } ) ;
206282}
207283
208- export function waffleY ( data , options = { } ) {
284+ export function waffleY ( data , { tip , ... options } = { } ) {
209285 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 ;
211303}
0 commit comments