1
- import { select } from "d3" ;
1
+ import { select , format as numberFormat , utcFormat } from "d3" ;
2
2
import { getSource } from "../channel.js" ;
3
3
import { create } from "../context.js" ;
4
4
import { defined } from "../defined.js" ;
@@ -7,7 +7,7 @@ import {anchorX, anchorY} from "../interactions/pointer.js";
7
7
import { Mark } from "../mark.js" ;
8
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 } from "../options.js" ;
10
+ import { identity , isIterable , isTemporal , isTextual } 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" ;
@@ -18,8 +18,8 @@ const defaults = {
18
18
stroke : "currentColor"
19
19
} ;
20
20
21
- // These channels are not displayed in the tip; TODO allow customization .
22
- const ignoreChannels = new Set ( [ "geometry" , "href" , "src" , "ariaLabel" ] ) ;
21
+ // These channels are not displayed in the default tip; see formatChannels .
22
+ const ignoreChannels = new Set ( [ "geometry" , "href" , "src" , "ariaLabel" , "scales" ] ) ;
23
23
24
24
export class Tip extends Mark {
25
25
constructor ( data , options = { } ) {
@@ -42,6 +42,7 @@ export class Tip extends Mark {
42
42
lineHeight = 1 ,
43
43
lineWidth = 20 ,
44
44
frameAnchor,
45
+ format,
45
46
textAnchor = "start" ,
46
47
textOverflow,
47
48
textPadding = 8 ,
@@ -82,6 +83,7 @@ export class Tip extends Mark {
82
83
for ( const key in defaults ) if ( key in this . channels ) this [ key ] = defaults [ key ] ; // apply default even if channel
83
84
this . splitLines = splitter ( this ) ;
84
85
this . clipLine = clipper ( this ) ;
86
+ this . format = { ...format } ; // defensive copy before mutate; also promote nullish to empty
85
87
}
86
88
render ( index , scales , values , dimensions , context ) {
87
89
const mark = this ;
@@ -114,41 +116,33 @@ export class Tip extends Mark {
114
116
const widthof = monospace ? monospaceWidth : defaultWidth ;
115
117
const ee = widthof ( ellipsis ) ;
116
118
117
- // We borrow the scale’s tick format for facet channels; this is safe for
118
- // ordinal scales (but not continuous scales where the display value may
119
- // need higher precision), and generally better than the default format.
120
- const formatFx = fx && inferTickFormat ( fx ) ;
121
- const formatFy = fy && inferTickFormat ( fy ) ;
122
-
123
- function * format ( sources , i ) {
124
- if ( "title" in sources ) {
125
- const text = sources . title . value [ i ] ;
126
- for ( const line of mark . splitLines ( formatDefault ( text ) ) ) {
127
- yield { name : "" , value : mark . clipLine ( line ) } ;
128
- }
129
- return ;
119
+ // Promote shorthand string formats to functions. Note: mutates this.format,
120
+ // but that should be safe since we made a defensive copy.
121
+ for ( const key in this . format ) {
122
+ const format = this . format [ key ] ;
123
+ if ( typeof format === "string" ) {
124
+ const value = key in sources ? sources [ key ] . value : key in scales ? scales [ key ] . domain ( ) : [ ] ;
125
+ this . format [ key ] = ( isTemporal ( value ) ? utcFormat : numberFormat ) ( format ) ;
130
126
}
131
- for ( const key in sources ) {
132
- if ( key === "x1" && "x2" in sources ) continue ;
133
- if ( key === "y1" && "y2" in sources ) continue ;
134
- const channel = sources [ key ] ;
135
- const value = channel . value [ i ] ;
136
- if ( ! defined ( value ) && channel . scale == null ) continue ;
137
- if ( key === "x2" && "x1" in sources ) {
138
- yield { name : formatPairLabel ( scales , sources . x1 , channel , "x" ) , value : formatPair ( sources . x1 , channel , i ) } ;
139
- } else if ( key === "y2" && "y1" in sources ) {
140
- yield { name : formatPairLabel ( scales , sources . y1 , channel , "y" ) , value : formatPair ( sources . y1 , channel , i ) } ;
141
- } else {
142
- const scale = channel . scale ;
143
- const line = { name : formatLabel ( scales , channel , key ) , value : formatDefault ( value ) } ;
144
- if ( scale === "color" || scale === "opacity" ) line [ scale ] = values [ key ] [ i ] ;
145
- yield line ;
146
- }
147
- }
148
- if ( index . fi != null && fx ) yield { name : String ( fx . label ?? "fx" ) , value : formatFx ( index . fx ) } ;
149
- if ( index . fi != null && fy ) yield { name : String ( fy . label ?? "fy" ) , value : formatFy ( index . fy ) } ;
150
127
}
151
128
129
+ // Borrow the scale tick format for facet channels; this is generally better
130
+ // than the default format (and safe for ordinal scales). Note: mutates
131
+ // this.format, but that should be safe since we made a defensive copy.
132
+ if ( index . fi != null ) {
133
+ const { fx, fy} = scales ;
134
+ if ( fx && this . format . fx === undefined ) this . format . fx = inferTickFormat ( fx , fx . domain ( ) ) ;
135
+ if ( fy && this . format . fy === undefined ) this . format . fy = inferTickFormat ( fy , fy . domain ( ) ) ;
136
+ }
137
+
138
+ // Determine the appropriate formatter.
139
+ const format =
140
+ "title" in sources // if there is a title channel
141
+ ? formatTitle // display the title as-is
142
+ : index . fi == null // if this mark is not faceted
143
+ ? formatChannels // display name-value pairs for channels
144
+ : formatFacetedChannels ; // same, plus facets
145
+
152
146
// We don’t call applyChannelStyles because we only use the channels to
153
147
// derive the content of the tip, not its aesthetics.
154
148
const g = create ( "svg:g" , context )
@@ -172,12 +166,19 @@ export class Tip extends Mark {
172
166
this . setAttribute ( "fill-opacity" , 1 ) ;
173
167
this . setAttribute ( "stroke" , "none" ) ;
174
168
// iteratively render each channel value
175
- const names = new Set ( ) ;
176
- for ( const line of format ( sources , i ) ) {
177
- const name = line . name ;
178
- if ( name && names . has ( name ) ) continue ;
179
- else names . add ( name ) ;
180
- renderLine ( that , line ) ;
169
+ const lines = format . call ( mark , i , index , sources , scales , values ) ;
170
+ if ( typeof lines === "string" ) {
171
+ for ( const line of mark . splitLines ( lines ) ) {
172
+ renderLine ( that , { value : mark . clipLine ( line ) } ) ;
173
+ }
174
+ } else {
175
+ const labels = new Set ( ) ;
176
+ for ( const line of lines ) {
177
+ const { label = "" } = line ;
178
+ if ( label && labels . has ( label ) ) continue ;
179
+ else labels . add ( label ) ;
180
+ renderLine ( that , line ) ;
181
+ }
181
182
}
182
183
} )
183
184
)
@@ -188,27 +189,28 @@ export class Tip extends Mark {
188
189
// just the initial layout of the text; in postrender we will compute the
189
190
// exact text metrics and translate the text as needed once we know the
190
191
// tip’s orientation (anchor).
191
- function renderLine ( selection , { name, value, color, opacity} ) {
192
+ function renderLine ( selection , { label, value, color, opacity} ) {
193
+ ( label ??= "" ) , ( value ??= "" ) ;
192
194
const swatch = color != null || opacity != null ;
193
195
let title ;
194
196
let w = lineWidth * 100 ;
195
- const [ j ] = cut ( name , w , widthof , ee ) ;
197
+ const [ j ] = cut ( label , w , widthof , ee ) ;
196
198
if ( j >= 0 ) {
197
- // name is truncated
198
- name = name . slice ( 0 , j ) . trimEnd ( ) + ellipsis ;
199
+ // label is truncated
200
+ label = label . slice ( 0 , j ) . trimEnd ( ) + ellipsis ;
199
201
title = value . trim ( ) ;
200
202
value = "" ;
201
203
} else {
202
- if ( name || ( ! value && ! swatch ) ) value = " " + value ;
203
- const [ k ] = cut ( value , w - widthof ( name ) , widthof , ee ) ;
204
+ if ( label || ( ! value && ! swatch ) ) value = " " + value ;
205
+ const [ k ] = cut ( value , w - widthof ( label ) , widthof , ee ) ;
204
206
if ( k >= 0 ) {
205
207
// value is truncated
206
208
value = value . slice ( 0 , k ) . trimEnd ( ) + ellipsis ;
207
209
title = value . trim ( ) ;
208
210
}
209
211
}
210
212
const line = selection . append ( "tspan" ) . attr ( "x" , 0 ) . attr ( "dy" , `${ lineHeight } em` ) . text ( "\u200b" ) ; // zwsp for double-click
211
- if ( name ) line . append ( "tspan" ) . attr ( "font-weight" , "bold" ) . text ( name ) ;
213
+ if ( label ) line . append ( "tspan" ) . attr ( "font-weight" , "bold" ) . text ( label ) ;
212
214
if ( value ) line . append ( ( ) => document . createTextNode ( value ) ) ;
213
215
if ( swatch ) line . append ( "tspan" ) . text ( " ■" ) . attr ( "fill" , color ) . attr ( "fill-opacity" , opacity ) . style ( "user-select" , "none" ) ; // prettier-ignore
214
216
if ( title ) line . append ( "title" ) . text ( title ) ;
@@ -332,18 +334,73 @@ function getSources({channels}) {
332
334
return sources ;
333
335
}
334
336
335
- function formatPair ( c1 , c2 , i ) {
337
+ function formatTitle ( i , index , { title} ) {
338
+ const format = this . format ?. title ;
339
+ return format === null ? [ ] : ( format ?? formatDefault ) ( title . value [ i ] , i ) ;
340
+ }
341
+
342
+ function * formatChannels ( i , index , channels , scales , values ) {
343
+ for ( const key in channels ) {
344
+ if ( key === "x1" && "x2" in channels ) continue ;
345
+ if ( key === "y1" && "y2" in channels ) continue ;
346
+ const channel = channels [ key ] ;
347
+ if ( key === "x2" && "x1" in channels ) {
348
+ const format = this . format ?. x ; // TODO x1, x2?
349
+ if ( format === null ) continue ;
350
+ yield {
351
+ label : formatPairLabel ( scales , channels , "x" ) ,
352
+ value : formatPair ( format ?? formatDefault , channels . x1 , channel , i )
353
+ } ;
354
+ } else if ( key === "y2" && "y1" in channels ) {
355
+ const format = this . format ?. y ; // TODO y1, y2?
356
+ if ( format === null ) continue ;
357
+ yield {
358
+ label : formatPairLabel ( scales , channels , "y" ) ,
359
+ value : formatPair ( format ?? formatDefault , channels . y1 , channel , i )
360
+ } ;
361
+ } else {
362
+ const format = this . format ?. [ key ] ;
363
+ if ( format === null ) continue ;
364
+ const value = channel . value [ i ] ;
365
+ const scale = channel . scale ;
366
+ if ( ! defined ( value ) && scale == null ) continue ;
367
+ yield {
368
+ label : formatLabel ( scales , channels , key ) ,
369
+ value : ( format ?? formatDefault ) ( value , i ) ,
370
+ color : scale === "color" ? values [ key ] [ i ] : null ,
371
+ opacity : scale === "opacity" ? values [ key ] [ i ] : null
372
+ } ;
373
+ }
374
+ }
375
+ }
376
+
377
+ function * formatFacetedChannels ( i , index , channels , scales , values ) {
378
+ yield * formatChannels . call ( this , i , index , channels , scales , values ) ;
379
+ for ( const key of [ "fx" , "fy" ] ) {
380
+ if ( ! scales [ key ] ) return ;
381
+ const format = this . format ?. [ key ] ;
382
+ if ( format === null ) continue ;
383
+ yield {
384
+ label : formatLabel ( scales , channels , key ) ,
385
+ value : ( format ?? formatDefault ) ( index [ key ] , i )
386
+ } ;
387
+ }
388
+ }
389
+
390
+ function formatPair ( formatValue , c1 , c2 , i ) {
336
391
return c2 . hint ?. length // e.g., stackY’s y1 and y2
337
- ? `${ formatDefault ( c2 . value [ i ] - c1 . value [ i ] ) } `
338
- : `${ formatDefault ( c1 . value [ i ] ) } –${ formatDefault ( c2 . value [ i ] ) } ` ;
392
+ ? `${ formatValue ( c2 . value [ i ] - c1 . value [ i ] , i ) } `
393
+ : `${ formatValue ( c1 . value [ i ] , i ) } –${ formatValue ( c2 . value [ i ] , i ) } ` ;
339
394
}
340
395
341
- function formatPairLabel ( scales , c1 , c2 , defaultLabel ) {
342
- const l1 = formatLabel ( scales , c1 , defaultLabel ) ;
343
- const l2 = formatLabel ( scales , c2 , defaultLabel ) ;
396
+ function formatPairLabel ( scales , channels , key ) {
397
+ const l1 = formatLabel ( scales , channels , ` ${ key } 1` , key ) ;
398
+ const l2 = formatLabel ( scales , channels , ` ${ key } 2` , key ) ;
344
399
return l1 === l2 ? l1 : `${ l1 } –${ l2 } ` ;
345
400
}
346
401
347
- function formatLabel ( scales , c , defaultLabel ) {
348
- return String ( scales [ c . scale ] ?. label ?? c ?. label ?? defaultLabel ) ;
402
+ function formatLabel ( scales , channels , key , defaultLabel = key ) {
403
+ const channel = channels [ key ] ;
404
+ const scale = scales [ channel ?. scale ?? key ] ;
405
+ return String ( scale ?. label ?? channel ?. label ?? defaultLabel ) ;
349
406
}
0 commit comments