From cfd7f0b45861325e01cb4a738f4c504d5599f7b6 Mon Sep 17 00:00:00 2001 From: Venkatesh Sivaraman Date: Mon, 7 Oct 2024 18:31:46 -0400 Subject: [PATCH] populate docs --- docs/_config.yml | 2 +- docs/_pages/01-quickstart.md | 5 +- docs/_pages/02-marks-and-rendergroups.md | 210 ++++++++-- docs/_pages/03-animation-timing.md | 277 +++++++++++++- docs/_pages/04-staging.md | 183 ++++++++- docs/_pages/05-interaction.md | 468 +++++++++++++++++++++++ docs/_pages/06-accessible-navigation.md | 189 ++++++++- docs/_pages/07-optimizing-performance.md | 6 - docs/_pages/07-troubleshooting.md | 6 + docs/_posts/2024-04-30-citations.md | 2 + 10 files changed, 1305 insertions(+), 43 deletions(-) delete mode 100644 docs/_pages/07-optimizing-performance.md create mode 100644 docs/_pages/07-troubleshooting.md diff --git a/docs/_config.yml b/docs/_config.yml index f96d152..37640e1 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -18,7 +18,7 @@ # You can create any custom variable you would like, and they will be accessible # in the templates via {{ site.myvariable }}. -title: Counterpoint Documentation +title: Counterpoint description: >- # this means to ignore newlines until "baseurl:" Counterpoint is a JavaScript/TypeScript library to help people develop beautiful large-scale animated data visualizations using HTML5 Canvas and WebGL. diff --git a/docs/_pages/01-quickstart.md b/docs/_pages/01-quickstart.md index 0b53d7a..85dd4eb 100644 --- a/docs/_pages/01-quickstart.md +++ b/docs/_pages/01-quickstart.md @@ -243,13 +243,12 @@ interpolate to the new values. > Since the `draw()` function will get called about 60 times per second during animations, it's > important to make sure it runs fast and doesn't perform any unnecessary > calculations. Plus, you can configure Counterpoint to redraw only when -> needed, improving performance and saving energy. See [Optimizing Performance]({% link _pages/07-optimizing-performance.md %}) -> to learn more. +> needed, improving performance and saving energy. > > {: .block-tip } -To enable animations, we first have to create a **ticker** to keep track of our +To enable animations, we first have to create a **ticker** to keep track of our animations' timing. This `Ticker` instance will keep track of the render group(s) we give it, and we pass it a function to call when the state of the render group changes: diff --git a/docs/_pages/02-marks-and-rendergroups.md b/docs/_pages/02-marks-and-rendergroups.md index a5f6013..ff36d21 100644 --- a/docs/_pages/02-marks-and-rendergroups.md +++ b/docs/_pages/02-marks-and-rendergroups.md @@ -58,8 +58,7 @@ Change below.) You can also pass an object of options to the attribute constructor, including the attribute's value under either the `value` (for a static value) or `valueFn` -(for a function) keys. The options you can pass are listed in the -API Reference below. For example: +(for a function) keys. For example: ```javascript new Attribute({ @@ -69,6 +68,17 @@ new Attribute({ }) ``` +Below is the full list of options you can pass: + +| Option | Description | +|:-------|:-----------:| +| `value` | The value of the attribute, if it is not updated using a dynamic function. | +| `valueFn` | A function that takes the attribute's compute argument and returns the attribute's value. This overrides a static `value` property value. | +| `computeArg` | An argument to be passed to the attribute's `valueFn` and `transform` functions. If `undefined`, the attribute itself is passed as the argument. (This only applies to attributes *not* being used inside `Mark` instances. Marks pass themselves as their attributes' compute arguments.) | +| `transform` | A function that transforms the value of the attribute before being returned. It should take the raw attribute value and (optionally) the attribute's compute argument, and return a transformed value. | +| `recompute` | Defines the behavior of the attribute's computation when specified using a value function. The default value of `AttributeRecompute.DEFAULT` causes the value function to be called every time `get()`, `compute()`, or `animate()` is called. If set to `AttributeRecompute.ALWAYS`, the value function is called every time the `advance()` method is called (i.e. every tick). If set to `AttributeRecompute.WHEN_UPDATED`, it will only be called when `compute()` or `animate()` is called. See Controlling Attribute Computation for more details. | +| `cacheTransform` | If `true`, specifies that the transformed value should be cached and reused when the raw value does not change (suitable when the transform function is fixed). If `false`, specifies that the transform should be rerun every time the value is requested - suitable when the transform function's behavior may change from frame to frame. When the value is cached, the transform can be updated by calling `updateTransform()` on the attribute. | + ## Combining Marks in a Render Group The `MarkRenderGroup` class allows you to manage animations and updates across @@ -110,11 +120,9 @@ renderGroup.filter((mark) => mark.id % 2 == 0).animate('color'); ``` You can dynamically add and remove marks from a render group using the `addMark` -and `deleteMark` methods. However, using these methods directly typically only -works well when there is no risk of overwriting marks with the same ID. For use -cases in which the same mark might need to be reused (e.g. animating connection -lines between points when hovering), it's best to use a `StageManager` (see -[Staging Marks]({{ site.baseurl }}/pages/04-staging)). +and `deleteMark` methods (which take a Mark as argument), or the `add` and `delete` +methods (which take an ID as argument and assume all marks have a unique ID). +For more details, see [Animating Mark Entry and Exit]({{ site.baseurl }}/pages/04-staging). ## Controlling Attribute Computation @@ -123,7 +131,7 @@ expensive function to compute attribute values, Counterpoint allows you the flexibility to decide how and when attribute computation should be performed. Understanding when your value function will be run depends both on the options listed below and the behavior of the ticker instance you are using (see -[Configuring Ticker Behavior]({{ site.baseurl }}/pages/07-optimizing-performance#configuring-ticker-behavior)). +[Configuring Ticker Behavior]({{ site.baseurl }}/pages/03-animation-timing#tickers)). By default, all attributes with value functions are recomputed whenever their `get()` or `advance()` methods are called. This likely means that when the canvas @@ -149,26 +157,180 @@ of calls to potentially expensive functions: does change, you can call `updateTransform` on the render group, mark, or attribute. -## API Reference +## Representing Your Data -### Attribute +While Attributes represent potentially animatable properties of your marks, you +may also want to keep track of some fixed data for each mark or tie a mark to your +internal representation. -

constructor(value | valueFn | options)

+Let's say we are creating a network visualization. We may have an array of nodes +that we want to represent with marks. We can associate each mark with its corresponding +node using the `represented` property and the `representing` method: +```javascript +let marks = new MarkRenderGroup( + nodes.map((node) => new Mark(node.id, { + // populate attributes... + }).representing(node)) +); +// later, get the represented node for a given mark: +let node = mark.represented; +``` -Constructs an `Attribute` using either a single value, value function, or a -dictionary of options. If using an options dictionary, the available options are -listed below: +## Event Listeners + +To help you organize your code, render groups and marks provide a lightweight +event dispatching and handling mechanism. This allows you to specify how marks +should visually respond to state changes independently of the state changes +themselves. + +For example, let's say we want to implement different types of animations when +a user clicks each of the marks below. + +
+ + +
+ +We can register each mark to listen for the 'click' event using the `onEvent` +method: -| Option | Description | -|:-------|:-----------:| -| `value` | The value of the attribute, if it is not updated using a dynamic function. | -| `valueFn` | A function that takes the attribute's compute argument and returns the attribute's value. This overrides a static `value` property value. | -| `computeArg` | An argument to be passed to the attribute's `valueFn` and `transform` functions. If `undefined`, the attribute itself is passed as the argument. (This only applies to attributes *not* being used inside `Mark` instances. Marks pass themselves as their attributes' compute arguments.) | -| `transform` | A function that transforms the value of the attribute before being returned. It should take the raw attribute value and (optionally) the attribute's compute argument, and return a transformed value. | -| `recompute` | Defines the behavior of the attribute's computation when specified using a value function. The default value of `AttributeRecompute.DEFAULT` causes the value function to be called every time `get()`, `compute()`, or `animate()` is called. If set to `AttributeRecompute.ALWAYS`, the value function is called every time the `advance()` method is called (i.e. every tick). If set to `AttributeRecompute.WHEN_UPDATED`, it will only be called when `compute()` or `animate()` is called. See Controlling Attribute Computation for more details. | -| `cacheTransform` | If `true`, specifies that the transformed value should be cached and reused when the raw value does not change (suitable when the transform function is fixed). If `false`, specifies that the transform should be rerun every time the value is requested - suitable when the transform function's behavior may change from frame to frame. When the value is cached, the transform can be updated by calling `updateTransform()` on the attribute. | +```javascript +let scaleMark = new Mark('scale', { + x: 50, y: 150, w: 40, h: 40, + rotation: 0, + alpha: 1.0, + color: 'salmon' +}).onEvent('click', async (mark, details) => { + // the details object is unused in this example, but could represent any information + await mark.animateTo('w', 60).animateTo('h', 60).wait(['w', 'h']); + mark.animateTo('w', 40).animateTo('h', 40); +}); +let fadeMark = new Mark('fade', { + x: 150, y: 150, w: 40, h: 40, + rotation: 0, + alpha: 1.0, + color: 'salmon' +}).onEvent('click', async (mark, details) => { + await mark.animateTo('alpha', 0.3).wait('alpha'); + mark.animateTo('alpha', 1.0); +}); +// ... similar for the other marks +let rotateMark = ... +let colorMark = ... +let marks = new MarkRenderGroup([scaleMark, fadeMark, rotateMark, colorMark]); +``` -### Mark +Then, in a click event handler, we can use the `PositionMap` class to identify +which mark was clicked (see [Interaction and Pan/Zoom]({{ site.baseurl }}/pages/05-interaction#retrieving-marks-by-position) for more details). We dispatch the +click event to the clicked mark: -### MarkRenderGroup +```javascript +clickedMark.dispatch('click'); +``` +To pass detail information to the mark in the event handler, we can simply add +an arbitrary details object as a second argument to the `dispatch` method. + +> **TIP: Render Group-Level Events** +> You can define similar event handlers on an entire render group at once, using +> the `onEvent` method of `MarkRenderGroup`. The listener function takes the same +> arguments, a `Mark` object and an arbitrary details object. +> +{: .block-tip } diff --git a/docs/_pages/03-animation-timing.md b/docs/_pages/03-animation-timing.md index 6605185..1cc18a4 100644 --- a/docs/_pages/03-animation-timing.md +++ b/docs/_pages/03-animation-timing.md @@ -15,10 +15,9 @@ making this an animation is as easy as changing `setAttr` to `animateTo`. The rest is customization and control to fit animation within the needs of your web app, as we describe below. -## Triggering Render Updates with a Ticker +## Tickers -Before we describe Counterpoint's animation options, it's important to note that -all updates and animations of attributes require a ticker. You can set up a +All updates and animations of attributes require a ticker to be loaded somewhere in your code. You can set up a `Ticker` with a single line of code somewhere in your script such as: ```javascript @@ -32,6 +31,14 @@ of them returns `true` (which happens when an attribute was updated or is being animated), the render state is considered to have changed, and the `onChange` callback is called. +> **WARNING: Ticker Inputs** +> +> It's important to make sure that the argument to the `Ticker` contains *all* +> potentially changing attributes. If not, your draw function might not always get +> called when your attributes change. +> +{: .block-warning } + Note that while the `advance` method is called on every element every frame, conditioning on the `advance` methods' return values allows us to reduce the number of times your drawing code is called. @@ -47,14 +54,270 @@ animation you need to run, and it will automatically stop when no longer needed. This can be useful when you have a lot of advanceable objects that may be expensive to iterate through when not necessary. +## Specified and Momentary States + +Counterpoint maintains a conceptual separation between the final value of an +attribute (its *specified* state) and its intermediate value during an animation +(the *momentary* state). This allows you to read attribute values with or without +assuming an animation is ongoing. + +When drawing marks, for instance, you'll probably want to get the momentary state +of each attribute. To do so, simply use the `Mark.attr` method (which wraps +`Attribute.get`). +```javascript +let currentX = mark.attr('x'); +// OR +let currentX = attribute.get(); +``` +If no animation is present, this momentary state is *equal* to the specified state. + +In other cases in your code, you may want to read the final value of an attribute. +For example, let's say we have a `color` attribute that animates between red and +blue when the user clicks a button. In our click handler, we may want to check +whether the attribute is currently set to red or blue. To do so we can use the +`Mark.data` or `Attribute.data` method: +```javascript +if (mark.data('color') == 'red') mark.animateTo('color', 'blue'); +else mark.animateTo('color', 'red'); +``` + +
+ +
+ +
+ +### Choosing the Specified State Value + +Each mark and render group provides two animation functions: `animateTo` +and `animate`. + +To specify the final state value directly, use `animateTo`, as follows: +```javascript +mark.animateTo('x', 100); +``` +Using the same function, we can also specify that the mark or attribute should +be assigned a new value function and its value animated to the function's result: +```javascript +mark.animateTo('x', getX); +``` + +Now, if the final state value will be computed implicitly by the +attribute's *existing* value function, we can use `animate` without a value +argument: +```javascript +mark.animate('x'); +``` + +When running animations on an entire render group, we may want to specify a +different final value per mark. To do so, we can use `MarkRenderGroup.animateTo` +and pass a function as argument: +```javascript +renderGroup.animateTo('x', (mark) => mark.attr('x') + 100); +``` + +> **TIP: Chaining Animations** +> +> Animation calls on the same object can be chained together. For example, +> `mark.animate('x').animate('y')` animates both the `x` and `y` attributes +> simultaneously. +> +{: .block-tip } + + ## Animation Timing Options -TBD +We can add arguments to the call to `animate` or `animateTo` to specify the duration, +delay, curve, or custom interpolation behavior. + +For example, to animate a mark slowly with a delay and an cubic ease-in-out +easing function: +```javascript +mark.animate('color', { + duration: 2000, // 2 seconds + delay: 500, // 0.5 seconds + curve: curveEaseInOut +}); +``` + +The animation curve can be any function that takes a linear animation progress value +between 0 and 1, and returns a new animation progress value. For example, +you can use [D3's ease functions](https://d3js.org/d3-ease) directly. + +### Custom Interpolators + +*Interpolators* in Counterpoint are objects that have a single function, +`interpolate`. This function should take an initial value and +a progress value (between 0 and 1), and produce the momentary value for the +attribute. + +By default, Counterpoint animates numerical attributes using a +continuous numerical interpolator, and string attributes using a color interpolator. +However, you can also define a custom interpolator to support animations on +different data types. + +For example, let's say we want to create a typewriter-style animation for text, +in which the existing characters will be deleted one-by-one, followed by adding +the new characters one-by-one: + +
+ +
+ +
+ +We will store the text value in an attribute: +```javascript +let textMark = new Mark('mark-id', { + text: 'I like apples' +}); +``` +Then, we define an interpolator object by creating a factory function that creates +an interpolator given a final value: +```javascript +function makeTypewriterInterpolator(finalValue: string): Interpolator { + return { + finalValue, // store the final value in the returned object for internal bookkeeping + interpolate (initialValue, t) { + let initialLength = initialValue.length; + let finalLength = finalValue.length; + let total = initialLength + finalLength; + if (t < initialLength / total) + return initialValue.slice(0, initialLength - Math.floor(t * total)); + else + return finalValue.slice(0, Math.floor((t - initialLength / total) * total)); + } + }; +} +``` + +Finally, we pass the interpolator to the mark in our call to `animate` (note we +do not use `animateTo` because we are using a custom interpolator): +```javascript +textMark.animate('text', { + interpolator: makeTypewriterInterpolator('I like oranges'), + duration: 1000 +}); +``` ## Waiting for an Animation to Complete -TBD +Animations in Counterpoint run asynchronously, so a call to `animate` or +`animateTo` simply *starts* the animation. To wait until an animation is +completed and then run some code, use the aptly-named `wait` method. This method +returns a `Promise` that resolves when the animation completes, and rejects if +the animation is canceled by the start of another animation or attribute update. +(If no animation is currently running, the Promise immediately resolves.) -## Animation API Reference +For example, to create a pulse animation: + +```javascript +async function pulse() { + mark.animateTo('radius', 1.2) + await mark.wait('radius'); + mark.animateTo('radius', 1.0); +} +``` -TBD \ No newline at end of file +Note that you must specify which attribute name(s) to wait on (either a string +or an array of strings). If you specify multiple attributes, the Promise resolves +only when all attributes' animations have completed, and rejects if any of them +are canceled. diff --git a/docs/_pages/04-staging.md b/docs/_pages/04-staging.md index c6d820b..a067d68 100644 --- a/docs/_pages/04-staging.md +++ b/docs/_pages/04-staging.md @@ -1,4 +1,185 @@ --- layout: post -title: 'Staging Marks' +title: 'Animating Mark Entry and Exit' --- + +Counterpoint's **stage** concept allows you to choreograph the animated entry and +exit of marks from a render group. + +For example, in the demo below, click around the canvas to create points, then +click some existing points to remove them. You should see that the number of points +in the render group changes instantly when you remove a point. However, the number of points +in the *stage* (i.e., points that are being rendered) changes only after the fade-out animation completes. That's +because the stage keeps track of marks that are being animated in and out *separately* +from those that are part of the render group. This allows you to reuse marks that +have are exiting, and to separately manage the *specified* and *momentary* states +of the visualization. + +
+

Points in render group: 0. Points in stage: 0.

+ + +
+ +## Configuring Staging Behavior + +You define how the stage should animate the entry and exit of marks using a simple +configuration method. For example, to animate the alpha and radius of a point +when it is added to the render group, we might configure our render group as follows: + +```javascript +renderGroup.configureStaging({ + initialize: (mark) => mark.setAttr('alpha', 0.0).setAttr('radius', 0.0), + enter: async (mark) => await (mark + .animate('alpha', 1.0) + .animate('radius', 20.0) + .wait(['alpha', 'radius'])), + exit: async (mark) => await (mark + .animate('alpha', 0.0) + .animate('radius', 0.0) + .wait(['alpha', 'radius'])), +}); +``` + +Here, we used three callback functions to specify the animation behavior: + +- **`initialize`**: Static changes to the mark before it enters, i.e. setting it + up for an entrance animation. +- **`enter`**: Animations to the mark to enter it onscreen. Note that this function + should return a Promise that resolves when the animation completes, which we + can easily provide using the wait + function. +- **`exit`**: Animations to the mark to remove it from the screen. Similar to `enter`, + this function should return a Promise that resolves when the animation completes. + +> **WARNING** +> +> If your `enter` and `exit` functions don't return Promises, the stage can exhibit +> unintended behavior. Be sure to call `wait()` after any animation calls. +> +{: .block-warning } + +## Adding and Removing Marks + +Once your staging behavior is configured, you can simply add and remove marks from +the render group. The entry and exit animations will automatically be applied. + +There are two ways to add and delete marks. The first way requires +that you have a reference to the Mark you wish to add or remove: +```javascript +let mark = ... +// Add the mark +renderGroup.addMark(mark); +// Delete the mark +renderGroup.deleteMark(mark); +``` +This method makes no assumptions about mark IDs and is purely imperative, so +you can freely add or remove marks with the same ID. + +In cases where you know that the mark IDs will be unique, you can use convenience +methods to add and remove marks just based on their IDs. To delete a mark by its +ID, simply call: +```javascript +renderGroup.delete(id); +``` +To add a mark by its ID, the render group needs to know how to construct a new +mark based on the ID you give it. To do so, we can initialize the render group +using a factory function: +```javascript +renderGroup = new MarkRenderGroup((id) => new Mark(id, { + x: ..., + y: ..., + color: 'green', + alpha: 0 +})); +``` +Now, when we want to add a mark later, we can simply use the `add` method: +```javascript +renderGroup.add(id); +``` + +What if we want to customize the mark after adding it? We can just get the mark +and update it: +```javascript +(renderGroup.add(id).get(id) + .setAttr('x', 200) + .setAttr('y', 100)); +``` diff --git a/docs/_pages/05-interaction.md b/docs/_pages/05-interaction.md index 74e4aeb..5f0f759 100644 --- a/docs/_pages/05-interaction.md +++ b/docs/_pages/05-interaction.md @@ -2,3 +2,471 @@ layout: post title: 'Interaction and Pan/Zoom' --- + +While Counterpoint does not provide built-in rendering capabilities, it does +provide helper classes to make it easier to use Canvas and WebGL for rendering +while still supporting interaction. + +## Retrieving Marks by Position + +For many interactive applications, you'll need to detect what mark is under the +user's cursor based on the mark coordinates. You may also +need to enumerate the set of marks that are within a certain distance of the +user's cursor. Counterpoint supports both tasks using an efficient hashing-based +algorithm implemented in the `PositionMap` class. + +In the example below, as you hover your mouse, you can see that the `hitTest` +returns at most one mark that contains the mouse location. The `marksNear` function +can return multiple marks that are within 50 pixels of the mouse. + +
+

hitTest: none. marksNear: none.

+ + +
+ +To use hit-testing, first define a hit-test function for each mark. This determines +whether a location is contained within the mark. For example, if we have circular marks, +we could use a Euclidean distance function: +```javascript +renderGroup.configure({ + ..., + hitTest: (mark, location) => { + let x = mark.attr('x'); + let y = mark.attr('y'); + let r = mark.attr('radius'); + return Math.sqrt(Math.pow(x - location[0], 2.0) + Math.pow(y - location[1], 2.0)) <= r; + } +}); +``` + +Then, we create a position map and add the render group to it: +```javascript +let positionMap = new PositionMap().add(renderGroup); +``` + +When we want to query the position map, we can call the `hitTest` function, with +an array of coordinates, e.g.: +```javascript +function handleMouseMove(event) { + let location = [ + event.pageX - canvas.getBoundingClientRect().left, + event.pageY - canvas.getBoundingClientRect().top, + ]; + let hit = positionMap.hitTest(location); + if (hit) { + // do something... + } +} +``` + +To simply get the marks within a given radius of a location, the logic is very +similar, and a hit-test function is not required: +```javascript +let marksInRadius = positionMap.marksNear(location, radius); +``` + +**IMPORTANT:** When using a Position Map, the internal memory of where points are +located is *not* automatically updated. Call the `invalidate` method to notify +the position map that it needs to be recomputed: +```javascript +// some code to modify point locations... +positionMap.invalidate(); +``` + +### `PositionMap` Options + +| Option | Description | +|:-------|:-----------:| +| `coordinateAttributes: string[]` | The names of the attributes to use for coordinates from each mark | +| `marksPerBin: number` | The approximate average number of marks to place in each bin. This is used to determine the bin size. If the number of marks will be very large, it is recommended to set this to a higher number to prevent a very sparse hash map.| +| `transformCoordinates: boolean` | Whether or not to run the transform function on each coordinate. If set to `false`, this can allow the position map to run in untransformed coordinates and thus be invariant to pan and zoom interactions, if the transform function performs pan/zoom scaling. | +| `maximumHitTestDistance: number` | The default maximum distance to search when hit-testing. Set this to the largest distance from mark coordinates that would be expected to result in a match. | + +## Pan and Zoom + +Counterpoint offers pan and zoom transforms in a `Scales` class, which uses +Counterpoint attributes under the hood so it can respond reactively to other +attributes. These scales are interoperable with [D3's zoom framework](https://d3js.org/d3-zoom). + +Try zooming and panning the scatterplot below to see how it works. You can also +click the buttons to zoom to or follow specific points as you animate the plot. +([See the code on GitHub.](https://github.com/cmudig/counterpoint/tree/main/examples/zoomable_scatter)) + +
+ +
+ + + + + +
+

Selected indexes: none

+ +
+ +To initialize the `Scales`, you provide the X and Y domains and ranges. Domains +represent the extent of values in your mark coordiantes, while ranges represent +the extent of values they should be mapped to on the screen. For example, to +display values ranging from -1 to 1 in a plot that is 600 x 600 pixels, we can use: +```javascript +let scales = (new Scales() + .xDomain([-1, 1]) + .yDomain([-1, 1]) + .xRange([0, 600]) + .yRange([0, 600]) +); +``` + +Then, you can pass the `scales.xScale` and `scales.yScale` properties as-is to +the `transform` options of your marks' coordinate attributes: +```javascript +let mark = new Mark(id, { + x: { + value: ..., + transform: scales.xScale + }, + y: { + value: ..., + transform: scales.yScale + } +}); +``` + +Importantly, you must add the `scales` to your Ticker so that the view will be +redrawn when the scales change. + +Now, you can simply implement zooming and panning using gestures (or use D3-zoom), +and update the `scales` object as needed. Below we provide example code for +implementing basic zoom and pan with native JavaScript event handlers and with +D3-zoom: + +**Native JavaScript** (supports scroll wheel and click+drag only) + +```javascript +let lastMousePos: [number, number] | null = null; + +function onMousedown(e: MouseEvent) { + lastMousePos = [ + e.clientX - canvas.getBoundingClientRect().left, + e.clientY - canvas.getBoundingClientRect().top, + ]; +} + +function onMousemove(e: MouseEvent) { + if (lastMousePos != null) { + let newMousePos: [number, number] = [ + e.clientX - canvas.getBoundingClientRect().left, + e.clientY - canvas.getBoundingClientRect().top, + ]; + scales.translateBy( + newMousePos[0] - lastMousePos[0], + newMousePos[1] - lastMousePos[1] + ); + lastMousePos = newMousePos; + e.preventDefault(); + } +} + +function onMouseup(e: MouseEvent) { + lastMousePos = null; +} + +function onMouseWheel(e: WheelEvent) { + let ds = -0.01 * e.deltaY; + + let rect = canvas.getBoundingClientRect(); + let mouseX = e.clientX - rect.left; + let mouseY = e.clientY - rect.top; + + scales.scaleBy(ds, [mouseX, mouseY]); + + e.preventDefault(); +} +``` + +**D3 Zoom** + +```javascript +let zoom = d3 + .zoom() + .scaleExtent([0.1, 10]) + .on('zoom', (e) => { + // important to make sure the source event exists, filtering out our + // programmatic changes + if (e.sourceEvent != null) { + // tell the scales the zoom transform has changed + scales.transform(e.transform); + } + }); +d3.select(canvas).call(zoom); + +scales.onUpdate(() => { + // When the scales update, we also need to let the d3 zoom object know that + // the zoom transform has changed + let sel = d3.select(canvas); + let currentT = d3.zoomTransform(canvas); + let t = scales.transform(); + if (t.k != currentT.k || t.x != currentT.x || t.y != currentT.y) { + sel.call(zoom.transform, new d3.ZoomTransform(t.k, t.x, t.y)); + } +}); +``` + +### Reactive Zoom Behavior + +As shown in the demo above, the `Scales` class can update dynamically in response +to marks that you provide. There are two main types of updates you can perform: + +1. **Zoom Once** (`Scales.zoomTo`): Update the transform to focus on a given set + of marks in their current locations. If these marks move later, the scales + will not change. +2. **Follow** (`Scales.follow`/`Scales.unfollow`): Update the transform to remain + focused on a given set of marks, even if they change locations. + +With either of these updates, you can specify how to compute the desired zoom +transform by passing an instance of `MarkFollower` to the `zoomTo` or `follow` +methods. There are two easy ways to define a `MarkFollower`: + +1. **Center on a Point** (`centerOn`): This global helper function takes a single + mark as input, and it computes a box in which the given mark is centered. It + also provides options to set the padding, etc. + For example: + ```javascript + scales.follow(centerOn(myMark, { padding: 50 })); + ``` + +2. **Contain a set of Marks** (`markBox`): This function takes an array of marks + as input, and computes a box that contains all of the marks. For example: + ```javascript + scales.zoomTo(markBox(myFavoriteMarks, { padding: 50 })); + ``` + +> **TIP: Custom Coordinate Names** +> +> If your marks use names other than 'x' and 'y' to represent their coordinates, +> you can specify which attributes should be used to compute the mark box using +> the `xAttr` and `yAttr` options. +> +{: .block-tip } + diff --git a/docs/_pages/06-accessible-navigation.md b/docs/_pages/06-accessible-navigation.md index 0bec55b..15a4a69 100644 --- a/docs/_pages/06-accessible-navigation.md +++ b/docs/_pages/06-accessible-navigation.md @@ -1,4 +1,191 @@ --- layout: post -title: 'Accessible Navigation' +title: 'Accessible Rendering and Navigation' --- + +Counterpoint's state management functionality helps you accommodate users with +different preferences about the appearance, animation, and navigation style of +your interfaces. Check out the [Accessible Gapminder plot]({{ site.baseurl }}/2024/04/30/gapminder-accessible) to see some examples. + +## Responding to Global User Preferences + +It's important to respect users' accessibility preferences regarding graphics and +animation. This can help users who have vestibular or photosensitive disabilities +use your visualizations. + +Ordinarily, these preferences are exposed to your application as [CSS media queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries). Counterpoint *wraps* +these media queries in a reactive type called the `RenderContext`. + +To respond to `RenderContext` changes in your rendering, add it to your ticker: +```javascript +const renderContext = getRenderContext(); +let ticker = new Ticker([ + // other elements... + renderContext +]); +``` + +Then, in your drawing function, you can use the following properties of the +render context (which are exposed as simple JavaScript types, not Counterpoint +Attributes): + +- **Reduced Motion** (`renderContext.prefersReducedMotion`, boolean): wraps the CSS + `prefers-reduced-motion` media query +- **Reduced Transparency** (`renderContext.prefersReducedTransparency`, boolean): + wraps the CSS `prefers-reduced-transparency` media query +- **Contrast** (`renderContext.contrastPreference`, enum): wraps the CSS + `prefers-contrast` media query. Possible values are `ContrastPreference.none`, + `more`, or `less`. +- **Color Scheme** (`renderContext.colorSchemePreference`, enum): wraps the CSS + `prefers-color-scheme` media query. Possible values are `ColorSchemePreference.light` + or `dark`. + +## Implementing Alternate Animation Styles with Events + +As described in [Event Listeners]({{ site.baseurl }}/pages/02-marks-and-rendergroups#event-listeners), +render groups provide an event dispatching mechanism that allows you to separate +the *triggering* of animation events with how marks *execute* them. This can +allow you to implement alternative animation styles, such as versions with and +without motion or transparency. + +In the below example, you can click the button to change between motion animations +and fade animations. + +
+ +
+ + +
+ +
+ +To implement this, let's say we have a variable called `reduceMotion` +representing the value of the checkbox above (or we can get the value from `RenderContext.prefersReducedMotion`). +We configure the render group to listen for an event called 'animate' and perform +the appropriate animation depending on the `reduceMotion` setting. To implement +the fade animations, we simply create a clone of each mark, animate its alpha +using the stage manager, then remove the original. + +```javascript +renderGroup.configureStaging({ + initialize: (mark) => mark.setAttr('alpha', 0.0), + enter: (mark) => mark.animateTo('alpha', 1.0).wait('alpha'), + exit: (mark) => mark.animateTo('alpha', 0.0).wait('alpha') +}).onEvent('animate', (mark, locationFn) => { + let newCoords = locationFn(mark); + if (reduceMotion) { + // fade animation + let clone = mark.copy(mark.id, { x: newCoords.x, y: newCoords.y }); + markSet.addMark(clone); + markSet.deleteMark(mark); + } else { + // motion animation + mark.animateTo('x', newCoords.x, { duration: 1000 }); + mark.animateTo('y', newCoords.y, { duration: 1000 }); + } +}); +``` + +Here, `locationFn` is a function specifying where each point should be moved to. +When the points need to be animated, we dispatch an 'animate' event and provide +that location function: + +```javascript +markSet.dispatch('animate', (mark) => ({ + x: mark.attr('x') + Math.random() * 50 - 25, + y: mark.attr('y') + Math.random() * 50 - 25 +})); +``` + +## Navigation with Data Navigator + +[Data Navigator](https://www.npmjs.com/package/data-navigator) +is a library to help make data visualizations navigable by interfacing with a +variety of input formats, such as key presses, gestures, and voice commands. +While Counterpoint is a state management library, Data Navigator is stateless. +So the two libraries complement each other to help you develop performant +animated visualizations that are also accessible to those who need alternate +ways of navigating charts. + +To see an example of how Data Navigator can be used with Counterpoint, see the +[running demo]({{ site.baseurl}}/2024/04/30/gapminder-accessible) and +[source code](https://github.com/cmudig/counterpoint/blob/main/docs/assets/gapminder/gapminder_accessible.js) +of an accessible Gapminder chart. \ No newline at end of file diff --git a/docs/_pages/07-optimizing-performance.md b/docs/_pages/07-optimizing-performance.md deleted file mode 100644 index 1563661..0000000 --- a/docs/_pages/07-optimizing-performance.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -layout: post -title: 'Optimizing Performance' ---- - -## Configuring Ticker Behavior \ No newline at end of file diff --git a/docs/_pages/07-troubleshooting.md b/docs/_pages/07-troubleshooting.md new file mode 100644 index 0000000..d33c2db --- /dev/null +++ b/docs/_pages/07-troubleshooting.md @@ -0,0 +1,6 @@ +--- +layout: post +title: 'Troubleshooting' +--- + +TBD. If you run into problems, please file a GitHub issue [here](https://github.com/cmudig/counterpoint/issues). \ No newline at end of file diff --git a/docs/_posts/2024-04-30-citations.md b/docs/_posts/2024-04-30-citations.md index c834f54..fa15e3c 100644 --- a/docs/_posts/2024-04-30-citations.md +++ b/docs/_posts/2024-04-30-citations.md @@ -5,6 +5,8 @@ title: 'Example: VIS Citations' In the below chart, each point represents an IEEE VIS paper published between 1990 and 2022. Lighter colors represent more recent publications, and larger bubbles indicate more highly cited papers. Select a point to connect it to its immediate references and highlight papers that have been "influenced" by that paper (e.g. papers for whom the selected paper is in their citation ancestry). +If the chart doesn't load at first, try reloading the page. +