Skip to content

Commit

Permalink
checkpoint format shorthand
Browse files Browse the repository at this point in the history
  • Loading branch information
mbostock committed Aug 22, 2023
1 parent 2ce6ac0 commit 5388248
Show file tree
Hide file tree
Showing 5 changed files with 122 additions and 64 deletions.
2 changes: 1 addition & 1 deletion src/channel.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export type ChannelValue =
* object to override the scale that would normally be associated with the
* channel.
*/
export type ChannelValueSpec = ChannelValue | {value: ChannelValue; scale?: Channel["scale"]}; // TODO label
export type ChannelValueSpec = ChannelValue | {value: ChannelValue; label?: string; scale?: Channel["scale"]};

/**
* In some contexts, when specifying a mark channel’s value, you can provide a
Expand Down
4 changes: 2 additions & 2 deletions src/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import {registry} from "./scales/index.js";
import {isSymbol, maybeSymbol} from "./symbol.js";
import {maybeReduce} from "./transforms/group.js";

export function createChannel(data, {scale, type, value, filter, hint}, name) {
export function createChannel(data, {scale, type, value, filter, hint, label = labelof(value)}, name) {
if (hint === undefined && typeof value?.transform === "function") hint = value.hint;
return inferChannelScale(name, {
scale,
type,
value: valueof(data, value),
label: labelof(value),
label,
filter,
hint
});
Expand Down
22 changes: 15 additions & 7 deletions src/marks/tip.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type {ChannelValueSpec} from "../channel.js";
import type {ChannelName, ChannelValue, ChannelValueSpec} from "../channel.js";
import type {Data, FrameAnchor, MarkOptions, RenderableMark} from "../mark.js";
import type {TextStyles} from "./text.js";

Expand Down Expand Up @@ -63,17 +63,25 @@ export interface TipOptions extends MarkOptions, TextStyles {
anchor?: FrameAnchor;

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

/** Shorthand for formatting channels and values. */
export interface TipFormatItem {
label?: string;
value?: ChannelValue;
channel?: ChannelName;
format?: (d: any, i: number) => string;
}

/** A formatted line item to show in a tip. */
export interface TipItem {
name?: string;
label?: string;
value?: string;
color?: string;
opacity?: number;
Expand Down
151 changes: 104 additions & 47 deletions src/marks/tip.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import {select} from "d3";
import {select, format as numberFormat} from "d3";
import {getSource} from "../channel.js";
import {create} from "../context.js";
import {defined} from "../defined.js";
import {formatDefault} from "../format.js";
import {anchorX, anchorY} from "../interactions/pointer.js";
import {Mark} from "../mark.js";
import {maybeAnchor, maybeFrameAnchor, maybeFunction, maybeTuple, number, string} from "../options.js";
import {maybeAnchor, maybeFrameAnchor, maybeTuple, number, string} from "../options.js";
import {applyDirectStyles, applyFrameAnchor, applyIndirectStyles, applyTransform, impliedString} from "../style.js";
import {identity, isIterable, isTextual, isObject} from "../options.js";
import {identity, isIterable, isTextual, isObject, labelof, maybeValue} from "../options.js";
import {inferTickFormat} from "./axis.js";
import {applyIndirectTextStyles, defaultWidth, ellipsis, monospaceWidth} from "./text.js";
import {cut, clipper, splitter, maybeTextOverflow} from "./text.js";
Expand Down Expand Up @@ -83,7 +83,7 @@ export class Tip extends Mark {
for (const key in defaults) if (key in this.channels) this[key] = defaults[key]; // apply default even if channel
this.splitLines = splitter(this);
this.clipLine = clipper(this);
this.format = maybeFunction(format);
this.format = maybeTipFormat(this.channels, format);
}
render(index, scales, values, dimensions, context) {
const mark = this;
Expand Down Expand Up @@ -119,12 +119,12 @@ export class Tip extends Mark {
// Determine the appropriate formatter.
const format =
this.format !== undefined
? formatData(this.format, values.data) // use the custom format, if any
? this.format(values) // use the custom format, if any
: "title" in sources // if there is a title channel
? formatTitle // display the title as-is
: index.fi == null // if this mark is not faceted
? formatChannels // display name-value pairs for channels
: formatFacetedChannels(index, scales); // same, plus facets
: formatFacetedChannels(scales); // same, plus facets

// We don’t call applyChannelStyles because we only use the channels to
// derive the content of the tip, not its aesthetics.
Expand All @@ -149,17 +149,17 @@ export class Tip extends Mark {
this.setAttribute("fill-opacity", 1);
this.setAttribute("stroke", "none");
// iteratively render each channel value
const names = new Set();
const lines = format.call(mark, i, sources, scales, values);
const labels = new Set();
const lines = format.call(mark, i, index, sources, scales, values);
if (typeof lines === "string") {
for (const line of mark.splitLines(lines)) {
renderLine(that, {value: mark.clipLine(line)});
}
} else {
for (const line of lines) {
const {name = ""} = line;
if (name && names.has(name)) continue;
else names.add(name);
const {label = ""} = line;
if (label && labels.has(label)) continue;
else labels.add(label);
renderLine(that, line);
}
}
Expand All @@ -172,27 +172,29 @@ export class Tip extends Mark {
// just the initial layout of the text; in postrender we will compute the
// exact text metrics and translate the text as needed once we know the
// tip’s orientation (anchor).
function renderLine(selection, {name = "", value = "", color, opacity}) {
function renderLine(selection, {label, value, color, opacity}) {
label ??= ""; // TODO fix earlier?
value ??= ""; // TODO fix earlier?
const swatch = color != null || opacity != null;
let title;
let w = lineWidth * 100;
const [j] = cut(name, w, widthof, ee);
const [j] = cut(label, w, widthof, ee);
if (j >= 0) {
// name is truncated
name = name.slice(0, j).trimEnd() + ellipsis;
// label is truncated
label = label.slice(0, j).trimEnd() + ellipsis;
title = value.trim();
value = "";
} else {
if (name || (!value && !swatch)) value = " " + value;
const [k] = cut(value, w - widthof(name), widthof, ee);
if (label || (!value && !swatch)) value = " " + value;
const [k] = cut(value, w - widthof(label), widthof, ee);
if (k >= 0) {
// value is truncated
value = value.slice(0, k).trimEnd() + ellipsis;
title = value.trim();
}
}
const line = selection.append("tspan").attr("x", 0).attr("dy", `${lineHeight}em`).text("\u200b"); // zwsp for double-click
if (name) line.append("tspan").attr("font-weight", "bold").text(name);
if (label) line.append("tspan").attr("font-weight", "bold").text(label);
if (value) line.append(() => document.createTextNode(value));
if (swatch) line.append("tspan").text(" ■").attr("fill", color).attr("fill-opacity", opacity).style("user-select", "none"); // prettier-ignore
if (title) line.append("title").text(title);
Expand Down Expand Up @@ -315,53 +317,108 @@ function getSources({channels}) {
function formatData(format, data) {
return function (i) {
let result = format.call(this, data[i], i);
if (isObject(result)) result = Object.entries(result).map(([name, value]) => ({name, value}));
if (isObject(result)) result = Object.entries(result).map(([label, value]) => ({label, value}));
return result;
};
}

function formatTitle(i, {title}) {
// Requirements
// - To add a channel to the tip (e.g., to add the “name” field)
// - To control how a channel value is formatted (e.g., ".2f" for x)
// - To remove a channel from the tip (e.g., to suppress x) [optional]
// - To change how a channel is labeled (alternative to label scale option?) [optional]
// Note: mutates channels!
function maybeTipFormat(channels, format) {
if (format === undefined) return;
if (typeof format === "function") return ({data}) => formatData(format, data);
format = Array.from(format, (f) => {
if (typeof f === "string") f = channels[f] ? {channel: f} : {value: f}; // shorthand string
f = maybeValue(f); // shorthand function, array, etc.
if (typeof f.format === "string") f = {...f, format: numberFormat(f.format)}; // shorthand format; TODO dates
if (f.value !== undefined) f = {...f, channel: deriveChannel(channels, f)}; // shorthand channel
return f;
});
return () => {
return function* (i, index, channels, scales, values) {
for (const {label, channel: key, format: formatValue} of format) {
for (const l of formatChannel(key, i, index, channels, scales, values, formatValue)) {
if (label !== undefined) l.label = label; // TODO clean this up
yield l;
}
}
};
};
}

let nextTipId = 0;

// Note: mutates channels!
function deriveChannel(channels, f) {
const key = `--tip-${++nextTipId}`; // TODO better anonymous channels
const {value, label = labelof(value) ?? ""} = f;
channels[key] = {label, value, filter: null};
return key;
}

function formatTitle(i, index, {title}) {
return formatDefault(title.value[i]);
}

function* formatChannels(i, channels, scales, values) {
function* formatChannels(i, index, channels, scales, values) {
for (const key in channels) {
if (key === "x1" && "x2" in channels) continue;
if (key === "y1" && "y2" in channels) continue;
const channel = channels[key];
const value = channel.value[i];
if (!defined(value) && channel.scale == null) continue;
if (key === "x2" && "x1" in channels) {
yield {name: formatPairLabel(scales, channels.x1, channel, "x"), value: formatPair(channels.x1, channel, i)};
} else if (key === "y2" && "y1" in channels) {
yield {name: formatPairLabel(scales, channels.y1, channel, "y"), value: formatPair(channels.y1, channel, i)};
} else {
const scale = channel.scale;
const line = {name: formatLabel(scales, channel, key), value: formatDefault(value)};
if (scale === "color" || scale === "opacity") line[scale] = values[key][i];
yield line;
}
if (key === "scales") continue; // not really a channel… TODO make this non-enumerable?
yield* formatChannel(key, i, index, channels, scales, values);
}
}

function formatFacetedChannels(index, scales) {
const {fx, fy} = scales;
function* formatChannel(
key,
i,
index,
channels,
scales,
values,
// We borrow the scale’s tick format for facet channels; this is safe for
// ordinal scales (but not continuous scales where the display value may need
// higher precision), and generally better than the default format.
const formatFx = fx && inferTickFormat(fx);
const formatFy = fy && inferTickFormat(fy);
return function* (i, channels, scales, values) {
yield* formatChannels(i, channels, scales, values);
if (fx) yield {name: String(fx.label ?? "fx"), value: formatFx(index.fx)};
if (fy) yield {name: String(fy.label ?? "fy"), value: formatFy(index.fy)};
// TODO inferring the tick format each time we format is too slow!
formatValue = key === "fx" ? inferTickFormat(scales.fx) : key === "fy" ? inferTickFormat(scales.fy) : formatDefault
) {
if (key === "x1" && "x2" in channels) return;
if (key === "y1" && "y2" in channels) return;
const channel = key === "fx" ? {scale: "fx"} : key === "fy" ? {scale: "fy"} : channels[key];
let value = key === "fx" ? index.fx : key === "fy" ? index.fy : channel.value[i];
if (!defined(value) && channel.scale == null) return;
let label, color, opacity;
if (key === "x2" && "x1" in channels) {
label = formatPairLabel(scales, channels.x1, channel, "x");
value = formatPair(formatValue, channels.x1, channel, i);
} else if (key === "y2" && "y1" in channels) {
label = formatPairLabel(scales, channels.y1, channel, "y");
value = formatPair(formatValue, channels.y1, channel, i);
} else {
const scale = channel.scale;
label = formatLabel(scales, channel, key);
value = formatValue(value);
if (scale === "color") color = values[key][i];
else if (scale === "opacity") opacity = values[key][i];
}
yield {label, value, color, opacity};
}

function formatFacetedChannels(scales) {
const {fx, fy} = scales;
return function* (i, index, channels, scales, values) {
yield* formatChannels(i, index, channels, scales, values);
if (fx) yield* formatChannel("fx", i, index, channels, scales, values);
if (fy) yield* formatChannel("fy", i, index, channels, scales, values);
};
}

function formatPair(c1, c2, i) {
function formatPair(formatValue, c1, c2, i) {
return c2.hint?.length // e.g., stackY’s y1 and y2
? `${formatDefault(c2.value[i] - c1.value[i])}`
: `${formatDefault(c1.value[i])}${formatDefault(c2.value[i])}`;
? `${formatValue(c2.value[i] - c1.value[i])}`
: `${formatValue(c1.value[i])}${formatValue(c2.value[i])}`;
}

function formatPairLabel(scales, c1, c2, defaultLabel) {
Expand Down
7 changes: 0 additions & 7 deletions src/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,6 @@ export function keyword(input, name, allowed) {
return i;
}

// Validates the specified optional function.
export function maybeFunction(input, name) {
if (input == null) return;
if (typeof input !== "function") throw new Error(`invalid ${name}: ${input}`);
return input;
}

// Promotes the specified data to an array as needed.
export function arrayify(data) {
return data == null || data instanceof Array || data instanceof TypedArray ? data : Array.from(data);
Expand Down

0 comments on commit 5388248

Please sign in to comment.