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 @@ + + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + + + ↑ Frequency + + + + Adelie + Chinstrap + Gentoo + + + species + + + + + + + + + + + + + + + \ 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({