From a162e717eaf40b2c455d0039a3fd92c5734e2d49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 30 Sep 2024 09:28:37 +0200 Subject: [PATCH 1/7] document render (rebase) --- docs/features/marks.md | 139 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/docs/features/marks.md b/docs/features/marks.md index 4d9cb90cd4..0f6bb67b15 100644 --- a/docs/features/marks.md +++ b/docs/features/marks.md @@ -547,6 +547,8 @@ All marks support the following [transform](./transforms.md) options: The **sort** option, when not specified as a channel value (such as a field name or an accessor function), can also be used to [impute ordinal scale domains](./scales.md#sort-mark-option). +The **render** option allows to override or extend the default mark’s [rendering](#rendering) method. + ### Insets Rect-like marks support insets: a positive inset moves the respective side in (towards the opposing side), whereas a negative inset moves the respective side out (away from the opposing side). Insets are specified in pixels using the following options: @@ -588,3 +590,140 @@ Plot.marks( ``` A convenience method for composing a mark from a series of other marks. Returns an array of marks that implements the *mark*.plot function. See the [box mark](../marks/box.md) implementation for an example. + +## Rendering + +A mark’s render method is called for each facet, unless its data for that facet is empty. The render method is responsible for drawing the mark by producing an SVG element. + +:::warning +We do not recommend using this low-level interface when a more high-level option exists such as a [data transform](https://observablehq.com/plot/features/transforms). It is meant for use by extension developers more than by users. +::: + +The mark’s render function is called with the following five arguments: + +* *index*: the index of the facet +* *scales*: the scale functions and descriptors +* *values*: the scaled and raw channels +* *dimensions*: the dimensions of the facet +* *context*: the context + +The function is expected to return a single SVG node, or null or undefined if no output is desired for the current facet. Typically, it returns a [G element](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/g), with a child node (say, a [circle element](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/circle)) for each valid data point. + +You can extend or replace this method by specifying a render transform with the mark’s **render** option. The render transform will be called with the five arguments described above and a sixth argument: + +* *next*: the next render method in the chain + +The *index* is an array of indices in the channels, that represent the points to be drawn in the current facet. The *scales* object contains the scale functions, indexed by name, and an additional scales property with the scales descriptors, also indexed by name. + +For example, the following code will log the color associated with the Torgersen category ("#e15759") and the [instantiated color scale object](./plots.md#plot_scale), and will not render anything to the chart. + +```js +Plot.dot(penguins, { + x: "culmen_length_mm", + y: "culmen_depth_mm", + fill: "island", + render(index, scales) { + console.log(scales.color("Torgersen")); // "#e15759" + console.log(scales.scales.color); // {type: "ordinal", …} + } +}).plot() +``` + +The *values* object contains the scaled channels, indexed by name, and an additional channels property with the unscaled channels, also indexed by name. For example: + +```js +Plot.dot(penguins, { + x: "culmen_length_mm", + y: "culmen_depth_mm", + fx: "species", + fill: "island", + render(index, scales, values) { + const i = index[0]; + console.log(i, values.fill[i], values.channels.fill.value[i]); + } +}).plot() +``` + +will output the following three lines to the console, with each line containing the index of the first penguin of the current facet, its fill color, and the underlying (unscaled) category: + +```js +0 '#e15759' 'Torgersen' +152 '#f28e2c' 'Dream' +220 '#4e79a7' 'Biscoe' +``` + +The *dimensions* object contains the marginTop, marginRight, marginLeft,marginBottom, and width and height of the chart. For example, to draw an ellipse that extends to the edges: + +```js +Plot.plot({ + marks: [ + function (index, scales, values, dimensions, context) { + const e = context.document.createElementNS("http://www.w3.org/2000/svg", "ellipse"); + e.setAttribute("rx", (dimensions.width - dimensions.marginLeft - dimensions.marginRight) / 2); + e.setAttribute("ry", (dimensions.height - dimensions.marginTop - dimensions.marginBottom) / 2); + e.setAttribute("cx", (dimensions.width + dimensions.marginLeft - dimensions.marginRight) / 2); + e.setAttribute("cy", (dimensions.height + dimensions.marginTop - dimensions.marginBottom) / 2); + e.setAttribute("fill", "red"); + return e; + } + ] +}) +``` + +The *context* contains several useful globals: +* document - the [document object](https://developer.mozilla.org/en-US/docs/Web/API/Document) +* ownerSVGElement - the chart’s bare svg element +* className - the [class name](./plots.md#other-options) of the chart (*e.g.*, "plot-d6a7b5") +* projection - the [projection](./projections.md) stream, if any + +:::tip +When you write a plugin, using *context*.document allows your code to run in different contexts such as a server-side rendering environment. +::: + +Render transforms are called with a sixth argument, *next*, a function that can be called to continue the render chain. For example, if you wish to animate a mark to fade in, you can render it as usual, immediately set its opacity to 0, then bring it to life with D3: + +```js +Plot.dot(penguins, { + x: "culmen_length_mm", + y: "culmen_depth_mm", + fill: "island", + render(index, scales, values, dimensions, context, next) { + const g = next(index, scales, values, dimensions, context); + d3.select(g) + .selectAll("circle") + .style("opacity", 0) + .transition() + .delay(() => Math.random() * 5000) + .style("opacity", 1); + return g; + } +}).plot() +``` + +:::info +Note that Plot’s marks usually set the attributes of the nodes. As styles have precedence over attributes, the output can be customized with [CSS](https://developer.mozilla.org/en-US/docs/Web/CSS). +::: + +Here is another example, where we render the dots one by one: +```js +Plot.dot(penguins, { + x: "culmen_length_mm", + y: "culmen_depth_mm", + fill: "island", + render(index, scales, values, dimensions, context, next) { + let node = next(index, scales, values, dimensions, context); + let k = 0; + requestAnimationFrame(function draw() { + const newNode = next(index.slice(0, ++k), scales, values, dimensions, context); + node.replaceWith(newNode); + node = newNode; + if (node.isConnected && k < index.length) requestAnimationFrame(draw); + }); + return node; + } +}).plot() +``` + +:::info +A similar technique is used by Plot’s [pointer](../interactions/pointer.md) transform to render the point closest to the pointer. +::: From a74892a839e6ccd4a57e49d96e61387798790a31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 30 Sep 2024 10:39:12 +0200 Subject: [PATCH 2/7] revise prose --- docs/features/marks.md | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/docs/features/marks.md b/docs/features/marks.md index 0f6bb67b15..a9caed28ba 100644 --- a/docs/features/marks.md +++ b/docs/features/marks.md @@ -593,29 +593,31 @@ A convenience method for composing a mark from a series of other marks. Returns ## Rendering -A mark’s render method is called for each facet, unless its data for that facet is empty. The render method is responsible for drawing the mark by producing an SVG element. +To draw the visual representation of a mark, Plot calls its render function and inserts the returned SVG element (if any) in the chart. This function is called for each non-empty facet. It may also be called repeatedly by interactions, for example when the [pointer](../interactions/pointer.md) transform needs to draw the highlighted data point after a mouse move. :::warning -We do not recommend using this low-level interface when a more high-level option exists such as a [data transform](https://observablehq.com/plot/features/transforms). It is meant for use by extension developers more than by users. +This is a low-level interface. We recommend using higher-level options, such as [data transforms](./transforms.md), when possible. ::: -The mark’s render function is called with the following five arguments: +After all the marks have been initialized, the scales computed and applied to the channels, Plot calls the mark’s render function with the following five arguments, for each facet: -* *index*: the index of the facet +* *index*: the index of data to draw on this facet * *scales*: the scale functions and descriptors * *values*: the scaled and raw channels * *dimensions*: the dimensions of the facet * *context*: the context -The function is expected to return a single SVG node, or null or undefined if no output is desired for the current facet. Typically, it returns a [G element](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/g), with a child node (say, a [circle element](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/circle)) for each valid data point. +The render function must return a single SVG node—or a nullish value if there is no output. For a typical mark, like dot, it might return a [G element](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/g), with common properties reflecting, say, a constant stroke or fill color; this group will have children [circle](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/circle) elements for each data point, with individual properties reflecting, say, a variable radius. -You can extend or replace this method by specifying a render transform with the mark’s **render** option. The render transform will be called with the five arguments described above and a sixth argument: +You can override or extend this function by specifying a function as the mark’s **render** option. Plot calls that function with the mark as *this* and, in addition to the five arguments listed above, a sixth argument: * *next*: the next render method in the chain -The *index* is an array of indices in the channels, that represent the points to be drawn in the current facet. The *scales* object contains the scale functions, indexed by name, and an additional scales property with the scales descriptors, also indexed by name. +The first argument, *index*, is an array of indices representing the valid points to be drawn in the current facet. -For example, the following code will log the color associated with the Torgersen category ("#e15759") and the [instantiated color scale object](./plots.md#plot_scale), and will not render anything to the chart. +The *scales* object contains the scale functions, indexed by name, and an additional scales property with the scales descriptors, also indexed by name. + +For example, the following code will log the color associated with the Torgersen category ("#e15759") and the [instantiated color scale object](./plots.md#plot_scale); as it returns undefined, it will not render anything to the chart. ```js Plot.dot(penguins, { @@ -644,7 +646,7 @@ Plot.dot(penguins, { }).plot() ``` -will output the following three lines to the console, with each line containing the index of the first penguin of the current facet, its fill color, and the underlying (unscaled) category: +will output the following three lines to the console, with each line containing the index of the first penguin in the current facet, its fill color, and the underlying (unscaled) category: ```js 0 '#e15759' 'Torgersen' @@ -674,13 +676,16 @@ The *context* contains several useful globals: * document - the [document object](https://developer.mozilla.org/en-US/docs/Web/API/Document) * ownerSVGElement - the chart’s bare svg element * className - the [class name](./plots.md#other-options) of the chart (*e.g.*, "plot-d6a7b5") +* clip - the top-level [clip](./plots.md#other-options) option (to use when the mark’s clip option is undefined) * projection - the [projection](./projections.md) stream, if any +* dispatchValue - a function that sets the chart’s value and dispatches an input event if the value has changed; useful for interactive marks +* getMarkState - a function that returns a mark’s state :::tip -When you write a plugin, using *context*.document allows your code to run in different contexts such as a server-side rendering environment. +When you write a custom mark, use *context*.document to allow your code to run in different environments, such as a server-side rendering with jsdom. ::: -Render transforms are called with a sixth argument, *next*, a function that can be called to continue the render chain. For example, if you wish to animate a mark to fade in, you can render it as usual, immediately set its opacity to 0, then bring it to life with D3: +The sixth argument, *next*, is a function that can be called to continue the render chain. For example, if you wish to animate a mark to fade in, you can set the render option to a function that calls next to render the mark as usual, then immediately sets its opacity to 0, and brings it to life with a [D3 transition](https://d3js.org/d3-transition): ```js Plot.dot(penguins, { @@ -691,17 +696,17 @@ Plot.dot(penguins, { const g = next(index, scales, values, dimensions, context); d3.select(g) .selectAll("circle") - .style("opacity", 0) + .style("opacity", 0) .transition() .delay(() => Math.random() * 5000) - .style("opacity", 1); + .style("opacity", 1); return g; } }).plot() ``` :::info -Note that Plot’s marks usually set the attributes of the nodes. As styles have precedence over attributes, the output can be customized with [CSS](https://developer.mozilla.org/en-US/docs/Web/CSS). +Note that Plot’s marks usually set the attributes of the nodes. As styles have precedence over attributes, it is much simpler to customize the output with [CSS](https://developer.mozilla.org/en-US/docs/Web/CSS), when possible, than with a custom render function. ::: Here is another example, where we render the dots one by one: @@ -723,7 +728,3 @@ Plot.dot(penguins, { } }).plot() ``` - -:::info -A similar technique is used by Plot’s [pointer](../interactions/pointer.md) transform to render the point closest to the pointer. -::: From 18e0e0ac5584ade486402d431ca539424b2c965d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 1 Oct 2024 13:26:05 +0200 Subject: [PATCH 3/7] filterFacets --- docs/features/marks.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/features/marks.md b/docs/features/marks.md index a9caed28ba..e1b90f1928 100644 --- a/docs/features/marks.md +++ b/docs/features/marks.md @@ -678,8 +678,9 @@ The *context* contains several useful globals: * className - the [class name](./plots.md#other-options) of the chart (*e.g.*, "plot-d6a7b5") * clip - the top-level [clip](./plots.md#other-options) option (to use when the mark’s clip option is undefined) * projection - the [projection](./projections.md) stream, if any -* dispatchValue - a function that sets the chart’s value and dispatches an input event if the value has changed; useful for interactive marks -* getMarkState - a function that returns a mark’s state +* dispatchValue - sets the chart’s value and dispatches an input event if the value has changed; useful for interactive marks +* getMarkState - read a mark’s index and channels +* filterFacets - compute the facets for arbitrary data (for use in an [initializer](./transforms#initializer)) :::tip When you write a custom mark, use *context*.document to allow your code to run in different environments, such as a server-side rendering with jsdom. @@ -709,7 +710,7 @@ Plot.dot(penguins, { Note that Plot’s marks usually set the attributes of the nodes. As styles have precedence over attributes, it is much simpler to customize the output with [CSS](https://developer.mozilla.org/en-US/docs/Web/CSS), when possible, than with a custom render function. ::: -Here is another example, where we render the dots one by one: +In this chart, we render the dots one by one: ```js Plot.dot(penguins, { x: "culmen_length_mm", @@ -722,7 +723,9 @@ Plot.dot(penguins, { const newNode = next(index.slice(0, ++k), scales, values, dimensions, context); node.replaceWith(newNode); node = newNode; - if (node.isConnected && k < index.length) requestAnimationFrame(draw); + if (node.isConnected && k < index.length) { + requestAnimationFrame(draw); + } }); return node; } From 1fe26a99f9fb6fffd5ca77875f26975861f57045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 1 Oct 2024 16:34:21 +0200 Subject: [PATCH 4/7] lifecycle --- docs/.vitepress/config.ts | 3 +- docs/features/lifecycle.md | 198 +++++++++++++++++++++++++++++++++++++ docs/features/marks.md | 141 -------------------------- 3 files changed, 200 insertions(+), 142 deletions(-) create mode 100644 docs/features/lifecycle.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 7a8b9e91f8..a9eb25f290 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -75,7 +75,8 @@ export default defineConfig({ {text: "Intervals", link: "/features/intervals"}, {text: "Markers", link: "/features/markers"}, {text: "Shorthand", link: "/features/shorthand"}, - {text: "Accessibility", link: "/features/accessibility"} + {text: "Accessibility", link: "/features/accessibility"}, + {text: "Lifecycle", link: "/features/lifecycle"} ] }, { diff --git a/docs/features/lifecycle.md b/docs/features/lifecycle.md new file mode 100644 index 0000000000..83b09ba2c3 --- /dev/null +++ b/docs/features/lifecycle.md @@ -0,0 +1,198 @@ +# The lifecycle of a chart + +:::warning +This documents how Plot builds a chart from its marks option. It is intended to give a high-level view of the lifecycle of a chart to advanced users—in particular developers who want to write custom marks and transforms. Please refer to our TypeScript declarations for precise details. +::: + +## Gathering the marks + +The **marks** option is an explicit list of [marks](./marks.md). Marks can be nested, nullish, Mark objects or bare functions. Plot flattens this into an array of instances of the Mark class: it filters out nullish marks, promotes bare functions to marks, and throws an error if any mark does not have a render method. It then appends an interactive tip mark for any mark passed with the **tip** option. (After the scales are derived, any implicit axis mark will also be prepended to the array of marks.) + +The following methods of each Mark will be called as part of the lifecycle: + +* initialize - compute the channel values +* initializer - second pass, using scales +* filter - determine valid data points +* project - apply a (geographic) projection to pairs of ⟨x,y⟩ channels +* render - returns a SVG element to insert in the chart + +They are described below. + +## Faceting + +Facets are determined based on the top-level **facet**, **fx** and **fy** options and the mark’s **fx** and **fy** channels. Any mark explicitly faceted or using the same data as the top-level facet gets a faceted index. + +The facet scales (*fx* and *fy*) are then computed, subdividing the global frame into as many frames where marks will be rendered with a subset of the data. + +## Initializing marks + +The **initialize** method is called on each mark, which computes its channels as (unscaled) values, possibly after transforming the data and facets (_e.g._ by grouping). + +The initialize method materializes the marks’s data, calls the mark’s transform and sort functions, computes the mark’s channels and index, etc. + +## Setting scales + +Once all marks are initialized, all the scales associated to any of the marks’ channels are computed, based on the top-level options and the values in those channels. For example, the default domain of the *x* scale will include all the values from channels associated to *x* in any mark (typically *x*, *x1* and *x2*). + +This stage sets the geometries of the chart, including default height, margins and the dimensions of the frame. (It also calls the mark’s **project** method, if any—this is used by the line mark to skip the point-based projection and render the lines with a proper geographic interpolation algorithm.) + +The channels are scaled, meaning that any value *x* that was in data space is now also available as a scaled value in pixel space. Likewise, any *fill* or *stroke* value is now available as an actual color (like "red" or "#ff0000"). + +## Re-initializing marks + +In a second pass, any mark’s *initializer* method is called, giving it a chance to derive secondary channels from the current values *and scales*. For instance, this is where the voronoi mark derives its geometry, based on the scaled channels (in pixel coordinates) of the data. + +The initializer method is called with the following arguments: data, facets, channels, scales, dimensions, and context. + +For details on each of these arguments see [below](#rendering-marks). It is worth noting here that a mark (for instance, the grid and the axis marks) can call the *context*.filterFacets function in the initializer to derive its default data from the scales. + +Note that this order of operations implies that it is impossible to run a **transform** after an **initializer**—Plot will throw an error if you try. + +## Additional scales + +Marks are not allowed to mutate or reset existing scales through their initializer; however, those might return new channels that need additional scales—for instance, the hexbin transform which operates on scaled *x* and *y* values might generate bins with a varying radius or fill color. The corresponding *r* and *color* scales, if they were not already set, can be set from these new channels. + +## Rendering the chart + +Plot creates an SVG element with the proper dimensions, adds the style, then proceeds to draw the visual representation of each mark as described below. If the chart has additional elements such as a title, a subtitle, a caption or legends, Plot wraps the SVG with a figure element. The chart’s value (for interactions), scale and legend method are then added as properties. Plot then returns the chart. + +## Rendering marks + +To render a mark, Plot calls its **render** method, and inserts and positions the returned SVG element (if any) in the chart SVG. + +The render method receives five arguments: + +* *index*: the index of data to draw +* *scales*: the scale functions and descriptors +* *values*: the scaled and raw channels +* *dimensions*: the dimensions of the facet +* *context*: the context + +The render method must return a single SVG node—or a nullish value if there is no output. For a typical mark, like dot, it might return a [G element](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/g), with common properties reflecting, say, a constant stroke or fill color; this group will have children [circle](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/circle) elements for each data point, with individual properties reflecting, say, a variable radius. + +You can override or extend this method by specifying a function as the mark’s **render** option. In that case, Plot calls it with the mark as *this* and, in addition to the five arguments listed above, a sixth argument: + +* *next*: the next render method in the chain + +The first argument, *index*, is an array of indices representing the points to be drawn in the current facet (if the mark is faceted), with invalid values filtered out by the mark’s **filter** method. + +The *scales* object contains the scale functions, indexed by name, and an additional scales property with the scale descriptors, also indexed by name. + +For example, the following code will log the color associated with the Torgersen category ("#e15759") and the [instantiated color scale object](./plots.md#plot_scale); as it returns undefined, it will not render anything to the chart. + +```js +Plot.dot(penguins, { + x: "culmen_length_mm", + y: "culmen_depth_mm", + fill: "island", + render(index, scales) { + console.log(scales.color("Torgersen")); // "#e15759" + console.log(scales.scales.color); // {type: "ordinal", …} + } +}).plot() +``` + +The *values* object contains the scaled channels, indexed by name, and an additional channels property with the unscaled channels, also indexed by name. For example: + +```js +Plot.dot(penguins, { + x: "culmen_length_mm", + y: "culmen_depth_mm", + fx: "species", + fill: "island", + render(index, scales, values) { + const i = index[0]; + console.log(i, values.fill[i], values.channels.fill.value[i]); + } +}).plot() +``` + +will output the following three lines to the console, with each line containing the index of the first penguin in the current facet, its fill color, and the underlying (unscaled) category: + +```js +0 '#e15759' 'Torgersen' +152 '#f28e2c' 'Dream' +220 '#4e79a7' 'Biscoe' +``` + +The *dimensions* object contains the marginTop, marginRight, marginLeft,marginBottom, and width and height of the chart. For example, to draw an ellipse that extends to the edges: + +```js +Plot.plot({ + marks: [ + function (index, scales, values, dimensions, context) { + const e = context.document.createElementNS("http://www.w3.org/2000/svg", "ellipse"); + e.setAttribute("rx", (dimensions.width - dimensions.marginLeft - dimensions.marginRight) / 2); + e.setAttribute("ry", (dimensions.height - dimensions.marginTop - dimensions.marginBottom) / 2); + e.setAttribute("cx", (dimensions.width + dimensions.marginLeft - dimensions.marginRight) / 2); + e.setAttribute("cy", (dimensions.height + dimensions.marginTop - dimensions.marginBottom) / 2); + e.setAttribute("fill", "red"); + return e; + } + ] +}) +``` + +The *context* contains several useful globals: +* document - the [document object](https://developer.mozilla.org/en-US/docs/Web/API/Document) +* ownerSVGElement - the chart’s bare svg element +* className - the [class name](./plots.md#other-options) of the chart (*e.g.*, "plot-d6a7b5") +* clip - the top-level [clip](./plots.md#other-options) option (to use when the mark’s clip option is undefined) +* projection - the [projection](./projections.md) stream, if any +* dispatchValue - sets the chart’s value and dispatches an input event if the value has changed; useful for interactive marks +* getMarkState - read a mark’s index and channels +* filterFacets - compute the facets for arbitrary data (for use in an [initializer](#re-initializing-marks)) + +:::tip +When you write a custom mark, use *context*.document to allow your code to run in different environments, such as a server-side rendering with jsdom. +::: + +The sixth argument, *next*, is a function that can be called to continue the render chain. For example, if you wish to animate a mark to fade in, you can set the render option to a function that calls next to render the mark as usual, then immediately sets its opacity to 0, and brings it to life with a [D3 transition](https://d3js.org/d3-transition): + +```js +Plot.dot(penguins, { + x: "culmen_length_mm", + y: "culmen_depth_mm", + fill: "island", + render(index, scales, values, dimensions, context, next) { + const g = next(index, scales, values, dimensions, context); + d3.select(g) + .selectAll("circle") + .style("opacity", 0) + .transition() + .delay(() => Math.random() * 5000) + .style("opacity", 1); + return g; + } +}).plot() +``` + +:::info +Note that Plot’s marks usually set the attributes of the nodes. As styles have precedence over attributes, it is much simpler to customize the output with [CSS](https://developer.mozilla.org/en-US/docs/Web/CSS), when possible, than with a custom render function. +::: + +In this chart, we render the dots one by one: +```js +Plot.dot(penguins, { + x: "culmen_length_mm", + y: "culmen_depth_mm", + fill: "island", + render(index, scales, values, dimensions, context, next) { + let node = next(index, scales, values, dimensions, context); + let k = 0; + requestAnimationFrame(function draw() { + const newNode = next(index.slice(0, ++k), scales, values, dimensions, context); + node.replaceWith(newNode); + node = newNode; + if (node.isConnected && k < index.length) { + requestAnimationFrame(draw); + } + }); + return node; + } +}).plot() +``` + +:::tip +If you have any question about this documentation, please open a [GitHub discussion](https://github.com/observablehq/framework/discussions/categories/q-a). +::: \ No newline at end of file diff --git a/docs/features/marks.md b/docs/features/marks.md index e1b90f1928..897dce080e 100644 --- a/docs/features/marks.md +++ b/docs/features/marks.md @@ -590,144 +590,3 @@ Plot.marks( ``` A convenience method for composing a mark from a series of other marks. Returns an array of marks that implements the *mark*.plot function. See the [box mark](../marks/box.md) implementation for an example. - -## Rendering - -To draw the visual representation of a mark, Plot calls its render function and inserts the returned SVG element (if any) in the chart. This function is called for each non-empty facet. It may also be called repeatedly by interactions, for example when the [pointer](../interactions/pointer.md) transform needs to draw the highlighted data point after a mouse move. - -:::warning -This is a low-level interface. We recommend using higher-level options, such as [data transforms](./transforms.md), when possible. -::: - -After all the marks have been initialized, the scales computed and applied to the channels, Plot calls the mark’s render function with the following five arguments, for each facet: - -* *index*: the index of data to draw on this facet -* *scales*: the scale functions and descriptors -* *values*: the scaled and raw channels -* *dimensions*: the dimensions of the facet -* *context*: the context - -The render function must return a single SVG node—or a nullish value if there is no output. For a typical mark, like dot, it might return a [G element](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/g), with common properties reflecting, say, a constant stroke or fill color; this group will have children [circle](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/circle) elements for each data point, with individual properties reflecting, say, a variable radius. - -You can override or extend this function by specifying a function as the mark’s **render** option. Plot calls that function with the mark as *this* and, in addition to the five arguments listed above, a sixth argument: - -* *next*: the next render method in the chain - -The first argument, *index*, is an array of indices representing the valid points to be drawn in the current facet. - -The *scales* object contains the scale functions, indexed by name, and an additional scales property with the scales descriptors, also indexed by name. - -For example, the following code will log the color associated with the Torgersen category ("#e15759") and the [instantiated color scale object](./plots.md#plot_scale); as it returns undefined, it will not render anything to the chart. - -```js -Plot.dot(penguins, { - x: "culmen_length_mm", - y: "culmen_depth_mm", - fill: "island", - render(index, scales) { - console.log(scales.color("Torgersen")); // "#e15759" - console.log(scales.scales.color); // {type: "ordinal", …} - } -}).plot() -``` - -The *values* object contains the scaled channels, indexed by name, and an additional channels property with the unscaled channels, also indexed by name. For example: - -```js -Plot.dot(penguins, { - x: "culmen_length_mm", - y: "culmen_depth_mm", - fx: "species", - fill: "island", - render(index, scales, values) { - const i = index[0]; - console.log(i, values.fill[i], values.channels.fill.value[i]); - } -}).plot() -``` - -will output the following three lines to the console, with each line containing the index of the first penguin in the current facet, its fill color, and the underlying (unscaled) category: - -```js -0 '#e15759' 'Torgersen' -152 '#f28e2c' 'Dream' -220 '#4e79a7' 'Biscoe' -``` - -The *dimensions* object contains the marginTop, marginRight, marginLeft,marginBottom, and width and height of the chart. For example, to draw an ellipse that extends to the edges: - -```js -Plot.plot({ - marks: [ - function (index, scales, values, dimensions, context) { - const e = context.document.createElementNS("http://www.w3.org/2000/svg", "ellipse"); - e.setAttribute("rx", (dimensions.width - dimensions.marginLeft - dimensions.marginRight) / 2); - e.setAttribute("ry", (dimensions.height - dimensions.marginTop - dimensions.marginBottom) / 2); - e.setAttribute("cx", (dimensions.width + dimensions.marginLeft - dimensions.marginRight) / 2); - e.setAttribute("cy", (dimensions.height + dimensions.marginTop - dimensions.marginBottom) / 2); - e.setAttribute("fill", "red"); - return e; - } - ] -}) -``` - -The *context* contains several useful globals: -* document - the [document object](https://developer.mozilla.org/en-US/docs/Web/API/Document) -* ownerSVGElement - the chart’s bare svg element -* className - the [class name](./plots.md#other-options) of the chart (*e.g.*, "plot-d6a7b5") -* clip - the top-level [clip](./plots.md#other-options) option (to use when the mark’s clip option is undefined) -* projection - the [projection](./projections.md) stream, if any -* dispatchValue - sets the chart’s value and dispatches an input event if the value has changed; useful for interactive marks -* getMarkState - read a mark’s index and channels -* filterFacets - compute the facets for arbitrary data (for use in an [initializer](./transforms#initializer)) - -:::tip -When you write a custom mark, use *context*.document to allow your code to run in different environments, such as a server-side rendering with jsdom. -::: - -The sixth argument, *next*, is a function that can be called to continue the render chain. For example, if you wish to animate a mark to fade in, you can set the render option to a function that calls next to render the mark as usual, then immediately sets its opacity to 0, and brings it to life with a [D3 transition](https://d3js.org/d3-transition): - -```js -Plot.dot(penguins, { - x: "culmen_length_mm", - y: "culmen_depth_mm", - fill: "island", - render(index, scales, values, dimensions, context, next) { - const g = next(index, scales, values, dimensions, context); - d3.select(g) - .selectAll("circle") - .style("opacity", 0) - .transition() - .delay(() => Math.random() * 5000) - .style("opacity", 1); - return g; - } -}).plot() -``` - -:::info -Note that Plot’s marks usually set the attributes of the nodes. As styles have precedence over attributes, it is much simpler to customize the output with [CSS](https://developer.mozilla.org/en-US/docs/Web/CSS), when possible, than with a custom render function. -::: - -In this chart, we render the dots one by one: -```js -Plot.dot(penguins, { - x: "culmen_length_mm", - y: "culmen_depth_mm", - fill: "island", - render(index, scales, values, dimensions, context, next) { - let node = next(index, scales, values, dimensions, context); - let k = 0; - requestAnimationFrame(function draw() { - const newNode = next(index.slice(0, ++k), scales, values, dimensions, context); - node.replaceWith(newNode); - node = newNode; - if (node.isConnected && k < index.length) { - requestAnimationFrame(draw); - } - }); - return node; - } -}).plot() -``` From 9504bffd05a6cfe256420d7ce45260238d08d32f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 1 Oct 2024 16:35:53 +0200 Subject: [PATCH 5/7] link --- docs/features/marks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/marks.md b/docs/features/marks.md index 897dce080e..e0f2f6ab50 100644 --- a/docs/features/marks.md +++ b/docs/features/marks.md @@ -547,7 +547,7 @@ All marks support the following [transform](./transforms.md) options: The **sort** option, when not specified as a channel value (such as a field name or an accessor function), can also be used to [impute ordinal scale domains](./scales.md#sort-mark-option). -The **render** option allows to override or extend the default mark’s [rendering](#rendering) method. +The **render** option allows to override or extend the default mark’s [rendering](./lifecycle.md#rendering-marks) method. ### Insets From 8c6ee8a5a9c7b6445a667e69e30ff07c5c0e4048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 1 Oct 2024 18:10:29 +0200 Subject: [PATCH 6/7] live examples --- docs/features/lifecycle.md | 96 ++++++++++++++++++++++++++++++++++---- 1 file changed, 86 insertions(+), 10 deletions(-) diff --git a/docs/features/lifecycle.md b/docs/features/lifecycle.md index 83b09ba2c3..3f44aa2eed 100644 --- a/docs/features/lifecycle.md +++ b/docs/features/lifecycle.md @@ -1,3 +1,21 @@ + + # The lifecycle of a chart :::warning @@ -117,6 +135,7 @@ will output the following three lines to the console, with each line containing The *dimensions* object contains the marginTop, marginRight, marginLeft,marginBottom, and width and height of the chart. For example, to draw an ellipse that extends to the edges: +:::plot ```js Plot.plot({ marks: [ @@ -132,6 +151,7 @@ Plot.plot({ ] }) ``` +::: The *context* contains several useful globals: * document - the [document object](https://developer.mozilla.org/en-US/docs/Web/API/Document) @@ -147,51 +167,107 @@ The *context* contains several useful globals: When you write a custom mark, use *context*.document to allow your code to run in different environments, such as a server-side rendering with jsdom. ::: -The sixth argument, *next*, is a function that can be called to continue the render chain. For example, if you wish to animate a mark to fade in, you can set the render option to a function that calls next to render the mark as usual, then immediately sets its opacity to 0, and brings it to life with a [D3 transition](https://d3js.org/d3-transition): +The sixth argument, *next*, is a function that can be called to continue the render chain. For example, if you wish to animate a mark to fade in, you can set the render option to a function that calls next to render the mark, sets its opacity to 0.1, then brings it to life with a [D3 transition](https://d3js.org/d3-transition): -```js +

+ +

+ +:::plot defer +```js-vue Plot.dot(penguins, { - x: "culmen_length_mm", + x: "{{($replay1, "culmen_length_mm")}}", y: "culmen_depth_mm", fill: "island", render(index, scales, values, dimensions, context, next) { const g = next(index, scales, values, dimensions, context); d3.select(g) .selectAll("circle") - .style("opacity", 0) + .style("opacity", 0.1) .transition() .delay(() => Math.random() * 5000) + .duration(2000) .style("opacity", 1); return g; } }).plot() ``` +::: :::info Note that Plot’s marks usually set the attributes of the nodes. As styles have precedence over attributes, it is much simpler to customize the output with [CSS](https://developer.mozilla.org/en-US/docs/Web/CSS), when possible, than with a custom render function. ::: In this chart, we render the dots one by one: -```js +

+ +

+ +:::plot defer +```js-vue Plot.dot(penguins, { - x: "culmen_length_mm", + x: "{{($replay2, "culmen_length_mm")}}", y: "culmen_depth_mm", fill: "island", render(index, scales, values, dimensions, context, next) { let node = next(index, scales, values, dimensions, context); let k = 0; - requestAnimationFrame(function draw() { + const i = d3.interval(function draw() { const newNode = next(index.slice(0, ++k), scales, values, dimensions, context); node.replaceWith(newNode); node = newNode; - if (node.isConnected && k < index.length) { - requestAnimationFrame(draw); - } + if (!node.isConnected || k >= index.length) i.stop(); }); return node; } }).plot() ``` +::: + +In the following example, we tweak the **render** option of the implicit tip mark of a line chart to make it do much more than display a tip. Note that it takes advantage of the current implementation of the line mark: since it binds the data indices with the SVG elements, we can filter the paths we want to highlight. + +:::plot defer +```js-vue +Plot.plot({ + y: { + grid: true, + label: "↑ Unemployment (%)" + }, + marks: [ + Plot.ruleY([0]), + Plot.lineY(bls, { + x: "date", + y: "unemployment", + z: "division", + stroke: "steelblue", + mixBlendMode: "{{$dark ? "screen" : "multiply"}}", + tip: { + render(index, scales, values, dimensions, context, next) { + // Filter and highlight the paths with the same *z* as the hovered point. + const path = d3.select(context.ownerSVGElement).selectAll("[aria-label=line] path"); + if (index.length) { + const z = values.z[index[0]]; + path.style("stroke", "currentColor") + .style("stroke-opacity", 0.1) + .filter(([i]) => values.z[i] === z) + .style("stroke", null) + .style("stroke-opacity", null) + .raise(); + } + else path.style("stroke", null); + // Render the tip (optional). + return next(index, scales, values, dimensions, context); + } + } + }) + ] +}) +``` +::: :::tip If you have any question about this documentation, please open a [GitHub discussion](https://github.com/observablehq/framework/discussions/categories/q-a). From cf65384e5095fa355525d9bc0eaccce0325be029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 2 Oct 2024 17:28:01 +0200 Subject: [PATCH 7/7] simpler for now --- docs/features/lifecycle.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/features/lifecycle.md b/docs/features/lifecycle.md index 3f44aa2eed..5c235bad8b 100644 --- a/docs/features/lifecycle.md +++ b/docs/features/lifecycle.md @@ -178,7 +178,7 @@ The sixth argument, *next*, is a function that can be called to continue the ren :::plot defer ```js-vue Plot.dot(penguins, { - x: "{{($replay1, "culmen_length_mm")}}", + x: "culmen_length_mm", y: "culmen_depth_mm", fill: "island", render(index, scales, values, dimensions, context, next) { @@ -210,7 +210,7 @@ In this chart, we render the dots one by one: :::plot defer ```js-vue Plot.dot(penguins, { - x: "{{($replay2, "culmen_length_mm")}}", + x: "culmen_length_mm", y: "culmen_depth_mm", fill: "island", render(index, scales, values, dimensions, context, next) {