1
- import { select } from "d3" ;
1
+ import { select , format as numberFormat } from "d3" ;
2
2
import { getSource } from "../channel.js" ;
3
3
import { create } from "../context.js" ;
4
4
import { defined } from "../defined.js" ;
5
5
import { formatDefault } from "../format.js" ;
6
6
import { anchorX , anchorY } from "../interactions/pointer.js" ;
7
7
import { Mark } from "../mark.js" ;
8
- import { maybeAnchor , maybeFrameAnchor , maybeFunction , maybeTuple , number , string } from "../options.js" ;
8
+ import { maybeAnchor , maybeFrameAnchor , maybeTuple , number , string } from "../options.js" ;
9
9
import { applyDirectStyles , applyFrameAnchor , applyIndirectStyles , applyTransform , impliedString } from "../style.js" ;
10
- import { identity , isIterable , isTextual , isObject } from "../options.js" ;
10
+ import { identity , isIterable , isTextual , isObject , labelof , maybeValue } from "../options.js" ;
11
11
import { inferTickFormat } from "./axis.js" ;
12
12
import { applyIndirectTextStyles , defaultWidth , ellipsis , monospaceWidth } from "./text.js" ;
13
13
import { cut , clipper , splitter , maybeTextOverflow } from "./text.js" ;
@@ -83,7 +83,7 @@ export class Tip extends Mark {
83
83
for ( const key in defaults ) if ( key in this . channels ) this [ key ] = defaults [ key ] ; // apply default even if channel
84
84
this . splitLines = splitter ( this ) ;
85
85
this . clipLine = clipper ( this ) ;
86
- this . format = maybeFunction ( format ) ;
86
+ this . format = maybeTipFormat ( this . channels , format ) ;
87
87
}
88
88
render ( index , scales , values , dimensions , context ) {
89
89
const mark = this ;
@@ -119,12 +119,12 @@ export class Tip extends Mark {
119
119
// Determine the appropriate formatter.
120
120
const format =
121
121
this . format !== undefined
122
- ? formatData ( this . format , values . data ) // use the custom format, if any
122
+ ? this . format ( values ) // use the custom format, if any
123
123
: "title" in sources // if there is a title channel
124
124
? formatTitle // display the title as-is
125
125
: index . fi == null // if this mark is not faceted
126
126
? formatChannels // display name-value pairs for channels
127
- : formatFacetedChannels ( index , scales ) ; // same, plus facets
127
+ : formatFacetedChannels ( scales ) ; // same, plus facets
128
128
129
129
// We don’t call applyChannelStyles because we only use the channels to
130
130
// derive the content of the tip, not its aesthetics.
@@ -149,17 +149,17 @@ export class Tip extends Mark {
149
149
this . setAttribute ( "fill-opacity" , 1 ) ;
150
150
this . setAttribute ( "stroke" , "none" ) ;
151
151
// iteratively render each channel value
152
- const names = new Set ( ) ;
153
- const lines = format . call ( mark , i , sources , scales , values ) ;
152
+ const labels = new Set ( ) ;
153
+ const lines = format . call ( mark , i , index , sources , scales , values ) ;
154
154
if ( typeof lines === "string" ) {
155
155
for ( const line of mark . splitLines ( lines ) ) {
156
156
renderLine ( that , { value : mark . clipLine ( line ) } ) ;
157
157
}
158
158
} else {
159
159
for ( const line of lines ) {
160
- const { name = "" } = line ;
161
- if ( name && names . has ( name ) ) continue ;
162
- else names . add ( name ) ;
160
+ const { label = "" } = line ;
161
+ if ( label && labels . has ( label ) ) continue ;
162
+ else labels . add ( label ) ;
163
163
renderLine ( that , line ) ;
164
164
}
165
165
}
@@ -172,27 +172,29 @@ export class Tip extends Mark {
172
172
// just the initial layout of the text; in postrender we will compute the
173
173
// exact text metrics and translate the text as needed once we know the
174
174
// tip’s orientation (anchor).
175
- function renderLine ( selection , { name = "" , value = "" , color, opacity} ) {
175
+ function renderLine ( selection , { label, value, color, opacity} ) {
176
+ label ??= "" ; // TODO fix earlier?
177
+ value ??= "" ; // TODO fix earlier?
176
178
const swatch = color != null || opacity != null ;
177
179
let title ;
178
180
let w = lineWidth * 100 ;
179
- const [ j ] = cut ( name , w , widthof , ee ) ;
181
+ const [ j ] = cut ( label , w , widthof , ee ) ;
180
182
if ( j >= 0 ) {
181
- // name is truncated
182
- name = name . slice ( 0 , j ) . trimEnd ( ) + ellipsis ;
183
+ // label is truncated
184
+ label = label . slice ( 0 , j ) . trimEnd ( ) + ellipsis ;
183
185
title = value . trim ( ) ;
184
186
value = "" ;
185
187
} else {
186
- if ( name || ( ! value && ! swatch ) ) value = " " + value ;
187
- const [ k ] = cut ( value , w - widthof ( name ) , widthof , ee ) ;
188
+ if ( label || ( ! value && ! swatch ) ) value = " " + value ;
189
+ const [ k ] = cut ( value , w - widthof ( label ) , widthof , ee ) ;
188
190
if ( k >= 0 ) {
189
191
// value is truncated
190
192
value = value . slice ( 0 , k ) . trimEnd ( ) + ellipsis ;
191
193
title = value . trim ( ) ;
192
194
}
193
195
}
194
196
const line = selection . append ( "tspan" ) . attr ( "x" , 0 ) . attr ( "dy" , `${ lineHeight } em` ) . text ( "\u200b" ) ; // zwsp for double-click
195
- if ( name ) line . append ( "tspan" ) . attr ( "font-weight" , "bold" ) . text ( name ) ;
197
+ if ( label ) line . append ( "tspan" ) . attr ( "font-weight" , "bold" ) . text ( label ) ;
196
198
if ( value ) line . append ( ( ) => document . createTextNode ( value ) ) ;
197
199
if ( swatch ) line . append ( "tspan" ) . text ( " ■" ) . attr ( "fill" , color ) . attr ( "fill-opacity" , opacity ) . style ( "user-select" , "none" ) ; // prettier-ignore
198
200
if ( title ) line . append ( "title" ) . text ( title ) ;
@@ -319,53 +321,108 @@ function getSources({channels}) {
319
321
function formatData ( format , data ) {
320
322
return function ( i ) {
321
323
let result = format . call ( this , data [ i ] , i ) ;
322
- if ( isObject ( result ) ) result = Object . entries ( result ) . map ( ( [ name , value ] ) => ( { name , value} ) ) ;
324
+ if ( isObject ( result ) ) result = Object . entries ( result ) . map ( ( [ label , value ] ) => ( { label , value} ) ) ;
323
325
return result ;
324
326
} ;
325
327
}
326
328
327
- function formatTitle ( i , { title} ) {
329
+ // Requirements
330
+ // - To add a channel to the tip (e.g., to add the “name” field)
331
+ // - To control how a channel value is formatted (e.g., ".2f" for x)
332
+ // - To remove a channel from the tip (e.g., to suppress x) [optional]
333
+ // - To change how a channel is labeled (alternative to label scale option?) [optional]
334
+ // Note: mutates channels!
335
+ function maybeTipFormat ( channels , format ) {
336
+ if ( format === undefined ) return ;
337
+ if ( typeof format === "function" ) return ( { data} ) => formatData ( format , data ) ;
338
+ format = Array . from ( format , ( f ) => {
339
+ if ( typeof f === "string" ) f = channels [ f ] ? { channel : f } : { value : f } ; // shorthand string
340
+ f = maybeValue ( f ) ; // shorthand function, array, etc.
341
+ if ( typeof f . format === "string" ) f = { ...f , format : numberFormat ( f . format ) } ; // shorthand format; TODO dates
342
+ if ( f . value !== undefined ) f = { ...f , channel : deriveChannel ( channels , f ) } ; // shorthand channel
343
+ return f ;
344
+ } ) ;
345
+ return ( ) => {
346
+ return function * ( i , index , channels , scales , values ) {
347
+ for ( const { label, channel : key , format : formatValue } of format ) {
348
+ for ( const l of formatChannel ( key , i , index , channels , scales , values , formatValue ) ) {
349
+ if ( label !== undefined ) l . label = label ; // TODO clean this up
350
+ yield l ;
351
+ }
352
+ }
353
+ } ;
354
+ } ;
355
+ }
356
+
357
+ let nextTipId = 0 ;
358
+
359
+ // Note: mutates channels!
360
+ function deriveChannel ( channels , f ) {
361
+ const key = `--tip-${ ++ nextTipId } ` ; // TODO better anonymous channels
362
+ const { value, label = labelof ( value ) ?? "" } = f ;
363
+ channels [ key ] = { label, value, filter : null } ;
364
+ return key ;
365
+ }
366
+
367
+ function formatTitle ( i , index , { title} ) {
328
368
return formatDefault ( title . value [ i ] ) ;
329
369
}
330
370
331
- function * formatChannels ( i , channels , scales , values ) {
371
+ function * formatChannels ( i , index , channels , scales , values ) {
332
372
for ( const key in channels ) {
333
- if ( key === "x1" && "x2" in channels ) continue ;
334
- if ( key === "y1" && "y2" in channels ) continue ;
335
- const channel = channels [ key ] ;
336
- const value = channel . value [ i ] ;
337
- if ( ! defined ( value ) && channel . scale == null ) continue ;
338
- if ( key === "x2" && "x1" in channels ) {
339
- yield { name : formatPairLabel ( scales , channels . x1 , channel , "x" ) , value : formatPair ( channels . x1 , channel , i ) } ;
340
- } else if ( key === "y2" && "y1" in channels ) {
341
- yield { name : formatPairLabel ( scales , channels . y1 , channel , "y" ) , value : formatPair ( channels . y1 , channel , i ) } ;
342
- } else {
343
- const scale = channel . scale ;
344
- const line = { name : formatLabel ( scales , channel , key ) , value : formatDefault ( value ) } ;
345
- if ( scale === "color" || scale === "opacity" ) line [ scale ] = values [ key ] [ i ] ;
346
- yield line ;
347
- }
373
+ if ( key === "scales" ) continue ; // not really a channel… TODO make this non-enumerable?
374
+ yield * formatChannel ( key , i , index , channels , scales , values ) ;
348
375
}
349
376
}
350
377
351
- function formatFacetedChannels ( index , scales ) {
352
- const { fx, fy} = scales ;
378
+ function * formatChannel (
379
+ key ,
380
+ i ,
381
+ index ,
382
+ channels ,
383
+ scales ,
384
+ values ,
353
385
// We borrow the scale’s tick format for facet channels; this is safe for
354
386
// ordinal scales (but not continuous scales where the display value may need
355
387
// higher precision), and generally better than the default format.
356
- const formatFx = fx && inferTickFormat ( fx ) ;
357
- const formatFy = fy && inferTickFormat ( fy ) ;
358
- return function * ( i , channels , scales , values ) {
359
- yield * formatChannels ( i , channels , scales , values ) ;
360
- if ( fx ) yield { name : String ( fx . label ?? "fx" ) , value : formatFx ( index . fx ) } ;
361
- if ( fy ) yield { name : String ( fy . label ?? "fy" ) , value : formatFy ( index . fy ) } ;
388
+ // TODO inferring the tick format each time we format is too slow!
389
+ formatValue = key === "fx" ? inferTickFormat ( scales . fx ) : key === "fy" ? inferTickFormat ( scales . fy ) : formatDefault
390
+ ) {
391
+ if ( key === "x1" && "x2" in channels ) return ;
392
+ if ( key === "y1" && "y2" in channels ) return ;
393
+ const channel = key === "fx" ? { scale : "fx" } : key === "fy" ? { scale : "fy" } : channels [ key ] ;
394
+ let value = key === "fx" ? index . fx : key === "fy" ? index . fy : channel . value [ i ] ;
395
+ if ( ! defined ( value ) && channel . scale == null ) return ;
396
+ let label , color , opacity ;
397
+ if ( key === "x2" && "x1" in channels ) {
398
+ label = formatPairLabel ( scales , channels . x1 , channel , "x" ) ;
399
+ value = formatPair ( formatValue , channels . x1 , channel , i ) ;
400
+ } else if ( key === "y2" && "y1" in channels ) {
401
+ label = formatPairLabel ( scales , channels . y1 , channel , "y" ) ;
402
+ value = formatPair ( formatValue , channels . y1 , channel , i ) ;
403
+ } else {
404
+ const scale = channel . scale ;
405
+ label = formatLabel ( scales , channel , key ) ;
406
+ value = formatValue ( value ) ;
407
+ if ( scale === "color" ) color = values [ key ] [ i ] ;
408
+ else if ( scale === "opacity" ) opacity = values [ key ] [ i ] ;
409
+ }
410
+ yield { label, value, color, opacity} ;
411
+ }
412
+
413
+ function formatFacetedChannels ( scales ) {
414
+ const { fx, fy} = scales ;
415
+ return function * ( i , index , channels , scales , values ) {
416
+ yield * formatChannels ( i , index , channels , scales , values ) ;
417
+ if ( fx ) yield * formatChannel ( "fx" , i , index , channels , scales , values ) ;
418
+ if ( fy ) yield * formatChannel ( "fy" , i , index , channels , scales , values ) ;
362
419
} ;
363
420
}
364
421
365
- function formatPair ( c1 , c2 , i ) {
422
+ function formatPair ( formatValue , c1 , c2 , i ) {
366
423
return c2 . hint ?. length // e.g., stackY’s y1 and y2
367
- ? `${ formatDefault ( c2 . value [ i ] - c1 . value [ i ] ) } `
368
- : `${ formatDefault ( c1 . value [ i ] ) } –${ formatDefault ( c2 . value [ i ] ) } ` ;
424
+ ? `${ formatValue ( c2 . value [ i ] - c1 . value [ i ] ) } `
425
+ : `${ formatValue ( c1 . value [ i ] ) } –${ formatValue ( c2 . value [ i ] ) } ` ;
369
426
}
370
427
371
428
function formatPairLabel ( scales , c1 , c2 , defaultLabel ) {
0 commit comments