Skip to content

Commit 2241a6a

Browse files
committed
custom format redux
1 parent 4c1125a commit 2241a6a

File tree

2 files changed

+45
-102
lines changed

2 files changed

+45
-102
lines changed

src/marks/tip.d.ts

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type {ChannelName, ChannelValue, ChannelValueSpec} from "../channel.js";
1+
import type {ChannelName, ChannelValueSpec} from "../channel.js";
22
import type {Data, FrameAnchor, MarkOptions, RenderableMark} from "../mark.js";
33
import type {TextStyles} from "./text.js";
44

@@ -62,29 +62,8 @@ export interface TipOptions extends MarkOptions, TextStyles {
6262
*/
6363
anchor?: FrameAnchor;
6464

65-
/**
66-
* The format option controls what the tip shows; either a function which is
67-
* passed the datum d and zero-based index i and returns a string or an
68-
* iterable of formatted tip items, or an iterable of channel names or values
69-
* and optionally how to label and format them.
70-
*/
71-
format?: ((d: any, i: number) => string | Iterable<TipItem>) | Iterable<ChannelName | ChannelValue | TipFormatItem>;
72-
}
73-
74-
/** Shorthand for formatting channels and values. */
75-
export interface TipFormatItem {
76-
label?: string;
77-
value?: ChannelValue;
78-
channel?: ChannelName;
79-
format?: (d: any, i: number) => string;
80-
}
81-
82-
/** A formatted line item to show in a tip. */
83-
export interface TipItem {
84-
label?: string;
85-
value?: string;
86-
color?: string;
87-
opacity?: number;
65+
/** How channel values are formatted for display. */
66+
format?: {[name in ChannelName]?: string | ((d: any, i: number) => string)};
8867
}
8968

9069
/**

src/marks/tip.js

Lines changed: 42 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {select, format as numberFormat} from "d3";
1+
import {select, format as numberFormat, utcFormat} from "d3";
22
import {getSource} from "../channel.js";
33
import {create} from "../context.js";
44
import {defined} from "../defined.js";
@@ -7,7 +7,7 @@ import {anchorX, anchorY} from "../interactions/pointer.js";
77
import {Mark} from "../mark.js";
88
import {maybeAnchor, maybeFrameAnchor, maybeTuple, number, string} from "../options.js";
99
import {applyDirectStyles, applyFrameAnchor, applyIndirectStyles, applyTransform, impliedString} from "../style.js";
10-
import {identity, isIterable, isTextual, labelof, maybeValue} from "../options.js";
10+
import {identity, isIterable, isTemporal, isTextual} from "../options.js";
1111
import {inferTickFormat} from "./axis.js";
1212
import {applyIndirectTextStyles, defaultWidth, ellipsis, monospaceWidth} from "./text.js";
1313
import {cut, clipper, splitter, maybeTextOverflow} from "./text.js";
@@ -83,7 +83,7 @@ export class Tip extends Mark {
8383
for (const key in defaults) if (key in this.channels) this[key] = defaults[key]; // apply default even if channel
8484
this.splitLines = splitter(this);
8585
this.clipLine = clipper(this);
86-
this.format = maybeTipFormat(this.channels, format);
86+
this.format = {...format}; // defensive copy, and promote nullish to empty
8787
}
8888
render(index, scales, values, dimensions, context) {
8989
const mark = this;
@@ -116,11 +116,24 @@ export class Tip extends Mark {
116116
const widthof = monospace ? monospaceWidth : defaultWidth;
117117
const ee = widthof(ellipsis);
118118

119+
// Initialize shorthand formats. TODO Don’t mutate.
120+
for (const key in this.format) {
121+
const format = this.format[key];
122+
if (typeof format === "string" && key in sources) {
123+
this.format[key] = (isTemporal(sources[key].value) ? utcFormat : numberFormat)(format);
124+
}
125+
}
126+
127+
// Initialize default formats for the facet scales. TODO Don’t mutate.
128+
if (index.fi != null) {
129+
const {fx, fy} = scales;
130+
if (fx && this.format.fx === undefined) this.format.fx = inferTickFormat(fx, fx.domain());
131+
if (fy && this.format.fy === undefined) this.format.fy = inferTickFormat(fy, fy.domain());
132+
}
133+
119134
// Determine the appropriate formatter.
120135
const format =
121-
this.format !== undefined
122-
? this.format // use the custom format, if any
123-
: "title" in sources // if there is a title channel
136+
"title" in sources // if there is a title channel
124137
? formatTitle // display the title as-is
125138
: index.fi == null // if this mark is not faceted
126139
? formatChannels // display name-value pairs for channels
@@ -173,8 +186,7 @@ export class Tip extends Mark {
173186
// exact text metrics and translate the text as needed once we know the
174187
// tip’s orientation (anchor).
175188
function renderLine(selection, {label, value, color, opacity}) {
176-
label ??= ""; // TODO fix earlier?
177-
value ??= ""; // TODO fix earlier?
189+
(label ??= ""), (value ??= "");
178190
const swatch = color != null || opacity != null;
179191
let title;
180192
let w = lineWidth * 100;
@@ -318,60 +330,9 @@ function getSources({channels}) {
318330
return sources;
319331
}
320332

321-
// Note: mutates channels!
322-
function maybeTipFormat(channels, format) {
323-
if (format === undefined) return;
324-
if (typeof format === "function") {
325-
return function (i, index, channels, scales, {data}) {
326-
return format.call(this, data[i], i);
327-
};
328-
}
329-
format = Array.from(format, (f) => maybeTipFormatItem(f, channels));
330-
return function* (i, index, channels, scales, values) {
331-
for (let {label, channel: key, format: formatValue} of format) {
332-
if (label === undefined) label = formatLabel(scales, channels, key);
333-
if (key === "fx" || key === "fy") {
334-
if (formatValue === undefined) formatValue = inferTickFormat(scales[key]); // TODO optimize
335-
yield {
336-
label,
337-
value: formatValue(index[key])
338-
};
339-
} else {
340-
if (formatValue === undefined) formatValue = formatDefault;
341-
const channel = channels[key];
342-
const scale = channel.scale;
343-
yield {
344-
label,
345-
value: formatValue(channel.value[i]),
346-
color: scale === "color" ? values[key][i] : null,
347-
opacity: scale === "opacity" ? values[key][i] : null
348-
};
349-
}
350-
}
351-
};
352-
}
353-
354-
// Note: mutates channels!
355-
function maybeTipFormatItem(f, channels) {
356-
if (typeof f === "string") f = {channel: f}; // shorthand channel name
357-
f = maybeValue(f); // shorthand function, array, etc.
358-
if (typeof f.format === "string") f = {...f, format: numberFormat(f.format)}; // shorthand format; TODO dates
359-
if (f.value !== undefined) f = {...f, channel: deriveChannel(channels, f)}; // shorthand channel
360-
return f;
361-
}
362-
363-
let nextTipId = 0;
364-
365-
// Note: mutates channels!
366-
function deriveChannel(channels, f) {
367-
const key = `--tip-${++nextTipId}`; // TODO better anonymous channels
368-
const {value, label = labelof(value) ?? ""} = f;
369-
channels[key] = {label, value, filter: null};
370-
return key;
371-
}
372-
373333
function formatTitle(i, index, {title}) {
374-
return formatDefault(title.value[i]);
334+
const format = this.format?.title;
335+
return format === null ? [] : (format ?? formatDefault)(title.value[i]);
375336
}
376337

377338
function* formatChannels(i, index, channels, scales, values) {
@@ -380,22 +341,28 @@ function* formatChannels(i, index, channels, scales, values) {
380341
if (key === "y1" && "y2" in channels) continue;
381342
const channel = channels[key];
382343
if (key === "x2" && "x1" in channels) {
344+
const format = this.format?.x; // TODO x1, x2?
345+
if (format === null) continue;
383346
yield {
384347
label: formatPairLabel(scales, channels, "x"),
385-
value: formatPair(channels.x1, channel, i)
348+
value: formatPair(format ?? formatDefault, channels.x1, channel, i)
386349
};
387350
} else if (key === "y2" && "y1" in channels) {
351+
const format = this.format?.y; // TODO y1, y2?
352+
if (format === null) continue;
388353
yield {
389354
label: formatPairLabel(scales, channels, "y"),
390-
value: formatPair(channels.y1, channel, i)
355+
value: formatPair(format ?? formatDefault, channels.y1, channel, i)
391356
};
392357
} else {
358+
const format = this.format?.[key];
359+
if (format === null) continue;
393360
const value = channel.value[i];
394361
const scale = channel.scale;
395-
if (!defined(value) && scale == null) return;
362+
if (!defined(value) && scale == null) continue;
396363
yield {
397364
label: formatLabel(scales, channels, key),
398-
value: formatDefault(value),
365+
value: (format ?? formatDefault)(value),
399366
color: scale === "color" ? values[key][i] : null,
400367
opacity: scale === "opacity" ? values[key][i] : null
401368
};
@@ -404,25 +371,22 @@ function* formatChannels(i, index, channels, scales, values) {
404371
}
405372

406373
function* formatFacetedChannels(i, index, channels, scales, values) {
407-
yield* formatChannels(i, index, channels, scales, values);
408-
if (scales.fx) {
409-
yield {
410-
label: formatLabel(scales, channels, "fx"),
411-
value: inferTickFormat(scales.fx)(index.fx) // TODO optimize
412-
};
413-
}
414-
if (scales.fy) {
374+
yield* formatChannels.call(this, i, index, channels, scales, values);
375+
for (const key of ["fx", "fy"]) {
376+
if (!scales[key]) return;
377+
const format = this.format?.[key];
378+
if (format === null) continue;
415379
yield {
416-
label: formatLabel(scales, channels, "fy"),
417-
value: inferTickFormat(scales.fy)(index.fy) // TODO optimize
380+
label: formatLabel(scales, channels, key),
381+
value: (format ?? formatDefault)(index[key])
418382
};
419383
}
420384
}
421385

422-
function formatPair(c1, c2, i) {
386+
function formatPair(formatValue, c1, c2, i) {
423387
return c2.hint?.length // e.g., stackY’s y1 and y2
424-
? `${formatDefault(c2.value[i] - c1.value[i])}`
425-
: `${formatDefault(c1.value[i])}${formatDefault(c2.value[i])}`;
388+
? `${formatValue(c2.value[i] - c1.value[i])}`
389+
: `${formatValue(c1.value[i])}${formatValue(c2.value[i])}`;
426390
}
427391

428392
function formatPairLabel(scales, channels, key) {

0 commit comments

Comments
 (0)