Skip to content

variable styles #909

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -708,15 +708,21 @@ All marks support the following optional channels:
* **stroke** - a stroke color; bound to the *color* scale
* **strokeOpacity** - a stroke opacity; bound to the *opacity* scale
* **strokeWidth** - a stroke width (in pixels)
* **strokeDasharray** - a comma-separated list of dash lengths (typically in pixels)
* **strokeDashoffset** - the [stroke dash offset](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dashoffset) (typically in pixels)
* **opacity** - an object opacity; bound to the *opacity* scale
* **title** - a tooltip (a string of text, possibly with newlines)
* **href** - a URL to link to
* **ariaLabel** - a short label representing the value in the accessibility tree
* **dx** - horizontal offset (in pixels; defaults to 0)
* **dy** - vertical offset (in pixels; defaults to 0)

The **fill**, **fillOpacity**, **stroke**, **strokeWidth**, **strokeOpacity**, and **opacity** options can be specified as either channels or constants. When the fill or stroke is specified as a function or array, it is interpreted as a channel; when the fill or stroke is specified as a string, it is interpreted as a constant if a valid CSS color and otherwise it is interpreted as a column name for a channel. Similarly when the fill opacity, stroke opacity, object opacity, stroke width, or radius is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel.
The **fill**, **fillOpacity**, **stroke**, **strokeWidth**, **strokeOpacity**, **strokeDasharray**, **strokeDashoffset**, and **opacity** options can be specified as either channels or constants. When the fill or stroke is specified as a function or array, it is interpreted as a channel; when the fill or stroke is specified as a string, it is interpreted as a constant if a valid CSS color and otherwise it is interpreted as a column name for a channel. Similarly when the fill opacity, stroke opacity, object opacity, stroke width, stroke-dashoffset or radius is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. When the stroke-dasharray is specified as a number, a string, or an array of numbers, is is interpreted as a constant; otherwise, it is interpreted as a channel.

The **title**, **href**, and **ariaLabel** options can *only* be specified as channels. When these options are specified as a string, the string refers to the name of a column in the mark’s associated data. If you’d like every instance of a particular mark to have the same value, specify the option as a function that returns the desired value, *e.g.* `() => "Hello, world!"`.

Currently, the **dx** option can be specified as a channel only when **x** is a channel; similarly, the **dy** option can be specified as a channel only when **y** is a channel.

The rectangular marks ([bar](#bar), [cell](#cell), and [rect](#rect)) support insets and rounded corner constant options:

* **insetTop** - inset the top edge
Expand Down Expand Up @@ -1398,7 +1404,7 @@ The following text-specific constant options are also supported:

If a **lineWidth** is specified, input text values will be wrapped as needed to fit while preserving existing newlines. The line wrapping implementation is rudimentary; for non-ASCII, non-U.S. English text, or for when a different font is used, you may get better results by hard-wrapping the text yourself (by supplying newlines in the input). If the **monospace** option is truthy, the default **fontFamily** changes to “ui-monospace, monospace”, and the **lineWidth** option is interpreted as characters (ch) rather than ems.

The **fontSize** and **rotate** options can be specified as either channels or constants. When fontSize or rotate is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel.
The **fontSize**, **fontFamily**, **fontStyle**, **fontVariant**, **fontWeight**, **textAnchor** and **rotate** options can be specified as either channels or constants. When fontSize or rotate is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. When fontFamily, fontStyle, or fontVariant is specified as a string, it is interpreted as a constant, otherwise as a channel. When fontWeight is specified as a string or a number, it is interpreted as a constant, otherwise as a channel. When textAnchor is specified as valid keyword, it is interpreted as a constant, otherwise as a channel.

If the **frameAnchor** option is not specified, then **textAnchor** and **lineAnchor** default to middle. Otherwise, **textAnchor** defaults to start if **frameAnchor** is on the left, end if **frameAnchor** is on the right, and otherwise middle. Similarly, **lineAnchor** defaults to top if **frameAnchor** is on the top, bottom if **frameAnchor** is on the bottom, and otherwise middle.

Expand Down
7 changes: 6 additions & 1 deletion src/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ export function valueObject(channels, scales) {
return Object.fromEntries(
Object.entries(channels).map(([name, {scale: scaleName, value}]) => {
const scale = scales[scaleName];
return [name, scale === undefined ? value : map(value, scale)];
value = scale === undefined ? value : map(value, scale);
let k = channels.dx;
if ((k && ["x", "x1", "x2"].includes(name)) || ((k = channels.dy) && ["y", "y1", "y2"].includes(name))) {
value = map(value, (d, i) => d + (+k.value[i] || 0));
}
return [name, value];
})
);
}
Expand Down
67 changes: 59 additions & 8 deletions src/marks/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import {
keyword,
maybeFrameAnchor,
isTextual,
isIterable
isIterable,
labelof,
valueof
} from "../options.js";
import {Mark} from "../plot.js";
import {
Expand Down Expand Up @@ -55,34 +57,55 @@ export class Text extends Mark {
rotate
} = options;
const [vrotate, crotate] = maybeNumberChannel(rotate, 0);
const [vtextAnchor, ctextAnchor] = maybeTextAnchorChannel(textAnchor);
const [vfontSize, cfontSize] = maybeFontSizeChannel(fontSize);
const [vfontFamily, cfontFamily] = maybeFontChannel(fontFamily);
const [vfontStyle, cfontStyle] = maybeFontChannel(fontStyle);
const [vfontVariant, cfontVariant] = maybeFontChannel(fontVariant);
const [vfontWeight, cfontWeight] = maybeFontChannel(fontWeight);
super(
data,
{
x: {value: x, scale: "x", optional: true},
y: {value: y, scale: "y", optional: true},
fontSize: {value: vfontSize, optional: true},
rotate: {value: numberChannel(vrotate), optional: true},
text: {value: text, filter: nonempty}
text: {value: text, filter: nonempty},
textAnchor: {value: keywordChannel(vtextAnchor, ["start", "end"]), optional: true, filter: null},
fontFamily: {value: vfontFamily, optional: true, filter: null},
fontStyle: {value: vfontStyle, optional: true, filter: null},
fontVariant: {value: vfontVariant, optional: true, filter: null},
fontWeight: {value: vfontWeight, optional: true, filter: null}
},
options,
defaults
);
this.rotate = crotate;
this.textAnchor = impliedString(textAnchor, "middle");
this.textAnchor = ctextAnchor;
this.lineAnchor = keyword(lineAnchor, "lineAnchor", ["top", "middle", "bottom"]);
this.lineHeight = +lineHeight;
this.lineWidth = +lineWidth;
this.monospace = !!monospace;
this.fontFamily = string(fontFamily);
this.fontSize = cfontSize;
this.fontStyle = string(fontStyle);
this.fontVariant = string(fontVariant);
this.fontWeight = string(fontWeight);
this.fontFamily = cfontFamily;
this.fontStyle = cfontStyle;
this.fontVariant = cfontVariant;
this.fontWeight = cfontWeight;
this.frameAnchor = maybeFrameAnchor(frameAnchor);
}
render(index, scales, channels, dimensions, context) {
const {x: X, y: Y, rotate: R, text: T, fontSize: FS} = channels;
const {
x: X,
y: Y,
rotate: R,
text: T,
fontSize: FS,
textAnchor: TA,
fontFamily: FF,
fontStyle: FT,
fontVariant: FV,
fontWeight: FW
} = channels;
const {rotate} = this;
const [cx, cy] = applyFrameAnchor(this, dimensions);
return create("svg:g", context)
Expand Down Expand Up @@ -124,6 +147,11 @@ export class Text extends Mark {
: `translate(${cx},${cy})`
)
.call(applyAttr, "font-size", FS && ((i) => FS[i]))
.call(applyAttr, "text-anchor", TA && ((i) => TA[i]))
.call(applyAttr, "font-family", FF && ((i) => FF[i]))
.call(applyAttr, "font-style", FT && ((i) => FT[i]))
.call(applyAttr, "font-variant", FV && ((i) => FV[i]))
.call(applyAttr, "font-weight", FW && ((i) => FW[i]))
.call(applyChannelStyles, this, channels)
)
.node();
Expand Down Expand Up @@ -219,6 +247,29 @@ function maybeFontSizeChannel(fontSize) {
: [fontSize, undefined];
}

// The text-anchor can be a constant "middle" | "start" | "end"
// Any other value is a channel definition.
function maybeTextAnchorChannel(textAnchor) {
if (textAnchor == null || ["middle", "start", "end"].includes(textAnchor))
return [undefined, impliedString(textAnchor, "middle")];
return [textAnchor, undefined];
}

// Other font properties can only be set as channels if they are defined as functions
// and a constant strings otherwise.
function maybeFontChannel(font) {
return ["string", "number"].includes(typeof font) ? [undefined, string(font)] : [font, undefined];
}

function keywordChannel(value, words) {
return value == null
? null
: {
transform: (data) => valueof(data, value).map((d) => (words.includes((d = `${d}`)) ? d : null)),
label: labelof(value)
};
}

// This is a greedy algorithm for line wrapping. It would be better to use the
// Knuth–Plass line breaking algorithm (but that would be much more complex).
// https://en.wikipedia.org/wiki/Line_wrap_and_word_wrap
Expand Down
4 changes: 1 addition & 3 deletions src/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ export function plot(options = {}) {

export class Mark {
constructor(data, channels = {}, options = {}, defaults) {
const {facet = "auto", sort, dx, dy, clip, channels: extraChannels} = options;
const {facet = "auto", sort, clip, channels: extraChannels} = options;
this.data = data;
this.sort = isDomainSort(sort) ? sort : null;
this.initializer = initializer(options).initializer;
Expand All @@ -322,8 +322,6 @@ export class Mark {
throw new Error(`missing channel value: ${name}`);
})
);
this.dx = +dx || 0;
this.dy = +dy || 0;
this.clip = maybeClip(clip);
}
initialize(facets, facetChannels) {
Expand Down
45 changes: 39 additions & 6 deletions src/style.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ export function styles(
mixBlendMode,
paintOrder,
pointerEvents,
shapeRendering
shapeRendering,
dx,
dy
},
{
ariaLabel: cariaLabel,
Expand Down Expand Up @@ -75,6 +77,8 @@ export function styles(
const [vstroke, cstroke] = maybeColorChannel(stroke, defaultStroke);
const [vstrokeOpacity, cstrokeOpacity] = maybeNumberChannel(strokeOpacity, defaultStrokeOpacity);
const [vopacity, copacity] = maybeNumberChannel(opacity);
const [vstrokeDasharray, cstrokeDasharray] = maybeDasharrayChannel(strokeDasharray);
const [vstrokeDashoffset, cstrokeDashoffset] = maybeNumberChannel(strokeDashoffset);

// For styles that have no effect if there is no stroke, only apply the
// defaults if the stroke is not the constant none. (If stroke is a channel,
Expand Down Expand Up @@ -110,8 +114,8 @@ export function styles(
mark.strokeLinejoin = impliedString(strokeLinejoin, "miter");
mark.strokeLinecap = impliedString(strokeLinecap, "butt");
mark.strokeMiterlimit = impliedNumber(strokeMiterlimit, 4);
mark.strokeDasharray = impliedString(strokeDasharray, "none");
mark.strokeDashoffset = impliedString(strokeDashoffset, "0");
mark.strokeDasharray = impliedString(cstrokeDasharray, "none");
mark.strokeDashoffset = impliedString(cstrokeDashoffset, "0");
}

mark.target = string(target);
Expand All @@ -124,16 +128,25 @@ export function styles(
mark.pointerEvents = impliedString(pointerEvents, "auto");
mark.shapeRendering = impliedString(shapeRendering, "auto");

const [vdx, cdx] = maybeNumberChannel(dx);
const [vdy, cdy] = maybeNumberChannel(dy);
mark.dx = cdx || 0;
mark.dy = cdy || 0;

return {
title: {value: title, optional: true},
href: {value: href, optional: true},
ariaLabel: {value: variaLabel, optional: true},
fill: {value: vfill, scale: "color", optional: true},
fillOpacity: {value: vfillOpacity, scale: "opacity", optional: true},
stroke: {value: vstroke, scale: "color", optional: true},
strokeDasharray: {value: vstrokeDasharray, optional: true},
strokeDashoffset: {value: vstrokeDashoffset, optional: true},
strokeOpacity: {value: vstrokeOpacity, scale: "opacity", optional: true},
strokeWidth: {value: vstrokeWidth, optional: true},
opacity: {value: vopacity, scale: "opacity", optional: true}
opacity: {value: vopacity, scale: "opacity", optional: true},
dx: {value: vdx, optional: true, filter: null},
dy: {value: vdy, optional: true, filter: null}
};
}

Expand Down Expand Up @@ -175,7 +188,9 @@ export function applyChannelStyles(
strokeOpacity: SO,
strokeWidth: SW,
opacity: O,
href: H
href: H,
strokeDasharray: SDA,
strokeDashoffset: SDO
}
) {
if (AL) applyAttr(selection, "aria-label", (i) => AL[i]);
Expand All @@ -184,6 +199,8 @@ export function applyChannelStyles(
if (S) applyAttr(selection, "stroke", (i) => S[i]);
if (SO) applyAttr(selection, "stroke-opacity", (i) => SO[i]);
if (SW) applyAttr(selection, "stroke-width", (i) => SW[i]);
if (SDA) applyAttr(selection, "stroke-dasharray", (i) => SDA[i]);
if (SDO) applyAttr(selection, "stroke-dashoffset", (i) => SDO[i]);
if (O) applyAttr(selection, "opacity", (i) => O[i]);
if (H) applyHref(selection, (i) => H[i], target);
applyTitle(selection, T);
Expand All @@ -201,7 +218,9 @@ export function applyGroupedChannelStyles(
strokeOpacity: SO,
strokeWidth: SW,
opacity: O,
href: H
href: H,
strokeDasharray: SDA,
strokeDashoffset: SDO
}
) {
if (AL) applyAttr(selection, "aria-label", ([i]) => AL[i]);
Expand All @@ -210,6 +229,8 @@ export function applyGroupedChannelStyles(
if (S) applyAttr(selection, "stroke", ([i]) => S[i]);
if (SO) applyAttr(selection, "stroke-opacity", ([i]) => SO[i]);
if (SW) applyAttr(selection, "stroke-width", ([i]) => SW[i]);
if (SDA) applyAttr(selection, "stroke-dasharray", ([i]) => SDA[i]);
if (SDO) applyAttr(selection, "stroke-dashoffset", ([i]) => SDO[i]);
if (O) applyAttr(selection, "opacity", ([i]) => O[i]);
if (H) applyHref(selection, ([i]) => H[i], target);
applyTitleGroup(selection, T);
Expand Down Expand Up @@ -402,3 +423,15 @@ export function applyFrameAnchor({frameAnchor}, {width, height, marginTop, margi
: (marginTop + height - marginBottom) / 2
];
}

// A constant stroke-dasharray can be specified as a number, an array of numbers
// or as a string joining numbers with white space or comma; any other non-null
// value is considered as a channel.
function maybeDasharrayChannel(value) {
if (value == null) return [undefined, "none"];
return (typeof value === "string" && value.match(/^([\d.]+(\s+|,)){1,}[\d.]+$/)) ||
typeof value === "number" ||
(Array.isArray(value) && typeof value[0] === "number")
? [undefined, `${value}`]
: [value, undefined];
}
Loading