Skip to content

Commit ff3c49f

Browse files
committed
checkpoint format shorthand
1 parent a285359 commit ff3c49f

File tree

5 files changed

+122
-64
lines changed

5 files changed

+122
-64
lines changed

src/channel.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ export type ChannelValue =
145145
* object to override the scale that would normally be associated with the
146146
* channel.
147147
*/
148-
export type ChannelValueSpec = ChannelValue | {value: ChannelValue; scale?: Channel["scale"]}; // TODO label
148+
export type ChannelValueSpec = ChannelValue | {value: ChannelValue; label?: string; scale?: Channel["scale"]};
149149

150150
/**
151151
* In some contexts, when specifying a mark channel’s value, you can provide a

src/channel.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ import {registry} from "./scales/index.js";
55
import {isSymbol, maybeSymbol} from "./symbol.js";
66
import {maybeReduce} from "./transforms/group.js";
77

8-
export function createChannel(data, {scale, type, value, filter, hint}, name) {
8+
export function createChannel(data, {scale, type, value, filter, hint, label = labelof(value)}, name) {
99
if (hint === undefined && typeof value?.transform === "function") hint = value.hint;
1010
return inferChannelScale(name, {
1111
scale,
1212
type,
1313
value: valueof(data, value),
14-
label: labelof(value),
14+
label,
1515
filter,
1616
hint
1717
});

src/marks/tip.d.ts

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

@@ -63,17 +63,25 @@ export interface TipOptions extends MarkOptions, TextStyles {
6363
anchor?: FrameAnchor;
6464

6565
/**
66-
* A custom format function specifying what the tip shows. This function is
67-
* passed the datum d and zero-based index i for each tip. It may return
68-
* either a string, an object of name-value pairs, or an iterable of {name,
69-
* value, color, opacity} objects.
66+
* The format option controls what the tip shows. It may be specified as a
67+
* function which is passed the datum d and zero-based index i and returns a
68+
* string or an iterable of already-formatted tip items, or an iterable of
69+
* channels or values and how to format them.
7070
*/
71-
format?: (d: any, i: number) => string | {[name: string]: string} | Iterable<TipItem>;
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;
7280
}
7381

7482
/** A formatted line item to show in a tip. */
7583
export interface TipItem {
76-
name?: string;
84+
label?: string;
7785
value?: string;
7886
color?: string;
7987
opacity?: number;

src/marks/tip.js

Lines changed: 104 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import {select} from "d3";
1+
import {select, format as numberFormat} from "d3";
22
import {getSource} from "../channel.js";
33
import {create} from "../context.js";
44
import {defined} from "../defined.js";
55
import {formatDefault} from "../format.js";
66
import {anchorX, anchorY} from "../interactions/pointer.js";
77
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";
99
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";
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 = maybeFunction(format);
86+
this.format = maybeTipFormat(this.channels, format);
8787
}
8888
render(index, scales, values, dimensions, context) {
8989
const mark = this;
@@ -119,12 +119,12 @@ export class Tip extends Mark {
119119
// Determine the appropriate formatter.
120120
const format =
121121
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
123123
: "title" in sources // if there is a title channel
124124
? formatTitle // display the title as-is
125125
: index.fi == null // if this mark is not faceted
126126
? formatChannels // display name-value pairs for channels
127-
: formatFacetedChannels(index, scales); // same, plus facets
127+
: formatFacetedChannels(scales); // same, plus facets
128128

129129
// We don’t call applyChannelStyles because we only use the channels to
130130
// derive the content of the tip, not its aesthetics.
@@ -149,17 +149,17 @@ export class Tip extends Mark {
149149
this.setAttribute("fill-opacity", 1);
150150
this.setAttribute("stroke", "none");
151151
// 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);
154154
if (typeof lines === "string") {
155155
for (const line of mark.splitLines(lines)) {
156156
renderLine(that, {value: mark.clipLine(line)});
157157
}
158158
} else {
159159
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);
163163
renderLine(that, line);
164164
}
165165
}
@@ -172,27 +172,29 @@ export class Tip extends Mark {
172172
// just the initial layout of the text; in postrender we will compute the
173173
// exact text metrics and translate the text as needed once we know the
174174
// 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?
176178
const swatch = color != null || opacity != null;
177179
let title;
178180
let w = lineWidth * 100;
179-
const [j] = cut(name, w, widthof, ee);
181+
const [j] = cut(label, w, widthof, ee);
180182
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;
183185
title = value.trim();
184186
value = "";
185187
} 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);
188190
if (k >= 0) {
189191
// value is truncated
190192
value = value.slice(0, k).trimEnd() + ellipsis;
191193
title = value.trim();
192194
}
193195
}
194196
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);
196198
if (value) line.append(() => document.createTextNode(value));
197199
if (swatch) line.append("tspan").text(" ■").attr("fill", color).attr("fill-opacity", opacity).style("user-select", "none"); // prettier-ignore
198200
if (title) line.append("title").text(title);
@@ -319,53 +321,108 @@ function getSources({channels}) {
319321
function formatData(format, data) {
320322
return function (i) {
321323
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}));
323325
return result;
324326
};
325327
}
326328

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}) {
328368
return formatDefault(title.value[i]);
329369
}
330370

331-
function* formatChannels(i, channels, scales, values) {
371+
function* formatChannels(i, index, channels, scales, values) {
332372
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);
348375
}
349376
}
350377

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,
353385
// We borrow the scale’s tick format for facet channels; this is safe for
354386
// ordinal scales (but not continuous scales where the display value may need
355387
// 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);
362419
};
363420
}
364421

365-
function formatPair(c1, c2, i) {
422+
function formatPair(formatValue, c1, c2, i) {
366423
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])}`;
369426
}
370427

371428
function formatPairLabel(scales, c1, c2, defaultLabel) {

src/options.js

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -130,13 +130,6 @@ export function keyword(input, name, allowed) {
130130
return i;
131131
}
132132

133-
// Validates the specified optional function.
134-
export function maybeFunction(input, name) {
135-
if (input == null) return;
136-
if (typeof input !== "function") throw new Error(`invalid ${name}: ${input}`);
137-
return input;
138-
}
139-
140133
// Promotes the specified data to an array as needed.
141134
export function arrayify(data) {
142135
return data == null || data instanceof Array || data instanceof TypedArray ? data : Array.from(data);

0 commit comments

Comments
 (0)