diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 351b8e372d..e72a077a46 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -70,6 +70,7 @@ export default defineConfig({
{text: "Formats", link: "/features/formats"},
{text: "Intervals", link: "/features/intervals"},
{text: "Markers", link: "/features/markers"},
+ {text: "Paints", link: "/features/paints"},
{text: "Shorthand", link: "/features/shorthand"},
{text: "Accessibility", link: "/features/accessibility"}
]
diff --git a/docs/data/api.data.ts b/docs/data/api.data.ts
index bbabdd8cce..7de8fbdde8 100644
--- a/docs/data/api.data.ts
+++ b/docs/data/api.data.ts
@@ -46,6 +46,7 @@ function getHref(name: string, path: string): string {
case "features/interval":
case "features/mark":
case "features/marker":
+ case "features/paint":
case "features/plot":
case "features/projection":
return `${path}s`;
diff --git a/docs/features/paints.md b/docs/features/paints.md
new file mode 100644
index 0000000000..312837afd8
--- /dev/null
+++ b/docs/features/paints.md
@@ -0,0 +1,7 @@
+# Paints
+
+A **paint** defines a fill or stroke implementation such as a pattern or gradient; it is an alternative to a constant color.
+
+## linearGradient() {#linearGradient}
+
+Returns a linear gradient.
diff --git a/src/index.d.ts b/src/index.d.ts
index dcaa949da8..f2d196a6cc 100644
--- a/src/index.d.ts
+++ b/src/index.d.ts
@@ -39,6 +39,7 @@ export * from "./marks/tip.js";
export * from "./marks/tree.js";
export * from "./marks/vector.js";
export * from "./options.js";
+export * from "./paint.js";
export * from "./plot.js";
export * from "./projection.js";
export * from "./reducer.js";
diff --git a/src/index.js b/src/index.js
index 4cca38b84f..76614175e4 100644
--- a/src/index.js
+++ b/src/index.js
@@ -56,5 +56,6 @@ export {pointer, pointerX, pointerY} from "./interactions/pointer.js";
export {formatIsoDate, formatNumber, formatWeekday, formatMonth} from "./format.js";
export {scale} from "./scales.js";
export {legend} from "./legends.js";
+export {linearGradient} from "./paint.js";
export {numberInterval} from "./options.js";
export {timeInterval, utcInterval} from "./time.js";
diff --git a/src/mark.d.ts b/src/mark.d.ts
index d244fdba76..5cfe92f3f7 100644
--- a/src/mark.d.ts
+++ b/src/mark.d.ts
@@ -2,6 +2,7 @@ import type {Channel, ChannelDomainSort, ChannelValue, ChannelValues, ChannelVal
import type {Context} from "./context.js";
import type {Dimensions} from "./dimensions.js";
import type {TipOptions} from "./marks/tip.js";
+import type {Paint} from "./paint.js";
import type {plot} from "./plot.js";
import type {ScaleFunctions} from "./scales.js";
import type {InitializerFunction, SortOrder, TransformFunction} from "./transforms/basic.js";
@@ -320,7 +321,7 @@ export interface MarkOptions {
*
* [1]: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/fill
*/
- fill?: ChannelValueSpec;
+ fill?: ChannelValueSpec | Paint;
/**
* The [fill-opacity][1]; a constant number between 0 and 1, or a channel
@@ -340,7 +341,7 @@ export interface MarkOptions {
*
* [1]: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke
*/
- stroke?: ChannelValueSpec;
+ stroke?: ChannelValueSpec | Paint;
/**
* The [stroke-dasharray][1]; a constant number indicating the length in
diff --git a/src/options.js b/src/options.js
index d7abbadf0e..05ec9f733f 100644
--- a/src/options.js
+++ b/src/options.js
@@ -478,6 +478,16 @@ export function isEvery(values, is) {
return every;
}
+// TODO Make isPaint the generic one (either a color or a custom paint
+// implementation), rather than making isColor the generic one?
+export function isPaint(value) {
+ return typeof value?.paint === "function";
+}
+
+// Mostly relies on d3-color, with a few extra color keywords. Currently this
+// strictly requires that the value be a string; we might want to apply string
+// coercion here, though note that d3-color instances would need to support
+// valueOf to work correctly with InternMap.
const namedColors = new Set("none,currentcolor,transparent,aliceblue,antiquewhite,aqua,aquamarine,azure,beige,bisque,black,blanchedalmond,blue,blueviolet,brown,burlywood,cadetblue,chartreuse,chocolate,coral,cornflowerblue,cornsilk,crimson,cyan,darkblue,darkcyan,darkgoldenrod,darkgray,darkgreen,darkgrey,darkkhaki,darkmagenta,darkolivegreen,darkorange,darkorchid,darkred,darksalmon,darkseagreen,darkslateblue,darkslategray,darkslategrey,darkturquoise,darkviolet,deeppink,deepskyblue,dimgray,dimgrey,dodgerblue,firebrick,floralwhite,forestgreen,fuchsia,gainsboro,ghostwhite,gold,goldenrod,gray,green,greenyellow,grey,honeydew,hotpink,indianred,indigo,ivory,khaki,lavender,lavenderblush,lawngreen,lemonchiffon,lightblue,lightcoral,lightcyan,lightgoldenrodyellow,lightgray,lightgreen,lightgrey,lightpink,lightsalmon,lightseagreen,lightskyblue,lightslategray,lightslategrey,lightsteelblue,lightyellow,lime,limegreen,linen,magenta,maroon,mediumaquamarine,mediumblue,mediumorchid,mediumpurple,mediumseagreen,mediumslateblue,mediumspringgreen,mediumturquoise,mediumvioletred,midnightblue,mintcream,mistyrose,moccasin,navajowhite,navy,oldlace,olive,olivedrab,orange,orangered,orchid,palegoldenrod,palegreen,paleturquoise,palevioletred,papayawhip,peachpuff,peru,pink,plum,powderblue,purple,rebeccapurple,red,rosybrown,royalblue,saddlebrown,salmon,sandybrown,seagreen,seashell,sienna,silver,skyblue,slateblue,slategray,slategrey,snow,springgreen,steelblue,tan,teal,thistle,tomato,turquoise,violet,wheat,white,whitesmoke,yellow".split(",")); // prettier-ignore
// Returns true if value is a valid CSS color string. This is intentionally lax
@@ -486,6 +496,7 @@ const namedColors = new Set("none,currentcolor,transparent,aliceblue,antiquewhit
// https://www.w3.org/TR/SVG11/painting.html#SpecifyingPaint
// https://www.w3.org/TR/css-color-5/
export function isColor(value) {
+ if (isPaint(value)) return true;
if (typeof value !== "string") return false;
value = value.toLowerCase().trim();
return (
diff --git a/src/paint.d.ts b/src/paint.d.ts
new file mode 100644
index 0000000000..a873f60fd4
--- /dev/null
+++ b/src/paint.d.ts
@@ -0,0 +1,10 @@
+import type {Context} from "./context.js";
+
+/** TODO */
+export interface Paint {
+ /** TODO */
+ paint(context: Context): string | null;
+}
+
+/** TODO */
+export function linearGradient(): Paint;
diff --git a/src/paint.js b/src/paint.js
new file mode 100644
index 0000000000..311c18ba88
--- /dev/null
+++ b/src/paint.js
@@ -0,0 +1,16 @@
+import {select} from "d3";
+
+export function linearGradient() {
+ return {
+ paint(context) {
+ select(context.ownerSVGElement)
+ .append("linearGradient")
+ .attr("gradientTransform", "rotate(90)")
+ .attr("id", "test-paint")
+ .call((gradient) => gradient.append("stop").attr("offset", "5%").attr("stop-color", "purple"))
+ .call((gradient) => gradient.append("stop").attr("offset", "75%").attr("stop-color", "red"))
+ .call((gradient) => gradient.append("stop").attr("offset", "100%").attr("stop-color", "gold"));
+ return "url(#test-paint)";
+ }
+ };
+}
diff --git a/src/style.js b/src/style.js
index 53601f3f62..31cd219dd9 100644
--- a/src/style.js
+++ b/src/style.js
@@ -2,7 +2,7 @@ import {geoPath, group, namespaces} from "d3";
import {create} from "./context.js";
import {defined, nonempty} from "./defined.js";
import {formatDefault} from "./format.js";
-import {isNone, isNoneish, isRound, maybeColorChannel, maybeNumberChannel} from "./options.js";
+import {isNone, isNoneish, isPaint, isRound, maybeColorChannel, maybeNumberChannel} from "./options.js";
import {keyof, number, string} from "./options.js";
import {warn} from "./warnings.js";
@@ -106,7 +106,7 @@ export function styles(
// Some marks don’t support fill (e.g., tick and rule).
if (defaultFill !== null) {
- mark.fill = impliedString(cfill, "currentColor");
+ mark.fill = isPaint(cfill) ? cfill : impliedString(cfill, "currentColor");
mark.fillOpacity = impliedNumber(cfillOpacity, 1);
}
@@ -349,7 +349,7 @@ function applyClip(selection, mark, dimensions, context) {
export function applyIndirectStyles(selection, mark, dimensions, context) {
applyClip(selection, mark, dimensions, context);
applyAttr(selection, "class", mark.className);
- applyAttr(selection, "fill", mark.fill);
+ applyAttr(selection, "fill", isPaint(mark.fill) ? mark.fill.paint(context) : mark.fill);
applyAttr(selection, "fill-opacity", mark.fillOpacity);
applyAttr(selection, "stroke", mark.stroke);
applyAttr(selection, "stroke-width", mark.strokeWidth);
diff --git a/test/output/penguinSpeciesPaint.svg b/test/output/penguinSpeciesPaint.svg
new file mode 100644
index 0000000000..9fd24c7ea0
--- /dev/null
+++ b/test/output/penguinSpeciesPaint.svg
@@ -0,0 +1,65 @@
+
\ No newline at end of file
diff --git a/test/plots/penguin-species.ts b/test/plots/penguin-species.ts
index c7eacdde31..993a5ece59 100644
--- a/test/plots/penguin-species.ts
+++ b/test/plots/penguin-species.ts
@@ -29,6 +29,16 @@ export async function penguinSpeciesCheysson() {
});
}
+export async function penguinSpeciesPaint() {
+ const penguins = await d3.csv("data/penguins.csv", d3.autoType);
+ return Plot.plot({
+ marks: [
+ Plot.barY(penguins, Plot.groupX({y: "count"}, {x: "species", fill: Plot.linearGradient()})),
+ Plot.ruleY([0])
+ ]
+ });
+}
+
export async function penguinSpeciesGradient() {
const penguins = await d3.csv("data/penguins.csv", d3.autoType);
return Plot.plot({