This page contains several advanced exercises that cover D3's brushing, dragging, and zooming functionality. These exercises will not be graded and no points will be awarded for them. But, if you are up for it, they are a fun challenge!
We have prepared a force-directed network visualization that shows the
interactions of characters in Star Wars. As in previous exercises, the data is
loaded in ex_1.svelte
which then constructs a _ex_1_network.svelte
component that contains the actual visualization. Aside from the forceSimulation
and the way images are used as fill for some of the circles, most things should
look familiar at this point. The forceSimulation
, however,
deservese an explanation:
const simulation = forceSimulation(nodes)
.force('link', forceLink(links).id((d) => d.label))
.force('charge', forceManyBody())
.force('collide', forceCollide((d) => rScale(d.value)))
.force('center', forceCenter(width / 2, height / 2))
.on('tick', () => {
// Needed so that svelte detects the values have changed...
nodes = nodes;
links = links;
});
We use d3.js' d3-force module for running force simulations, which compute how a network should be layed out so that it is most legible. Essentially, the simulation tries to minimize the forces we introduce. Several forces are provided in the module, we used the:
forceLink
which introduces a force that pulls nodes that are connected
together.
forceManyBody
which introduces a force that pushes all nodes away
from each other.
forceCollide
which introduces a force that pushes nodes that overlap
away from each other.
forceCenter
which introduces a force that pulls all nodes to the
center.
To minimize the forces in the layout, the simulation uses a temperature
schedule that is controlled throught the .alpha()
helper function.
Initially the temperature is high, allowing the nodes to move faster. As the simulation
runs, the temperature decreases, until it hits zero. Then the simulation stops.
While the simulation is running, it will continually release a tick
event. We can monitor that event to re-draw the nodes and edges while the simulation
is running. In Svelte, our tick
callback actually only has to tell
Svelte that the values within the nodes
and links
arrays
have changed. We do that by assigning both arrays to themselves, as Svelte uses
assignment to detect changing variables. The simulation will have added and update
several fields on the nodes and links:
node.x
the x-coordinate of the node.node.y
the y-coordinate of the node.node.vx
the x-velocity of the node.node.vy
the y-velocity of the node.node.fx
and node.fy
. When set to a non-null
value, the node will be forced to stay at these coordinates and drags all
nodes connected to it towards that location.
link.source
and link.target
point to the nodes that
this link connects, not just their index or id, but the actual variables! So,
you can get the coordinates to draw the link using link.source.x
, ... .
In the following exercises you will extend the visualization with three interactions:
You will develop these interactions in separate JavaScript files (
_ex_1_drag.js
, _ex_2_zoom.js
, and _ex_3_hover.js
). This allows us to re-use some of the functionality later on. However, we
will have to do a bit more work to use Svelte's reactivity. But that is worth
the trouble!
For this exercise, you have to edit the files src/routes/extra_challenges/exercises/_ex_1_network.svelte
and src/routes/extra_challenges/exercises/_ex_1_drag.js
. Any
changes you make to those file should show up below.
d3.js provides the d3-drag
module to add dragging functionality to DOM-elements. As you probably expect by
now, to use the module, you have to import, call, and configure the drag()
function. This gives back a function that takes a
selected DOM-handle and makes that element draggable. Like with the brush
in exercise 3 of week 7, you can configure what has to happen when the drag starts,
moves, and ends.
For a nice dragging behaviour on force-directed networks, you have to increase
the temperature of the simulation to 0.3
when a drag starts and keep
it at that level while the drag moves. This ensures the layout gets a chance to
adapt. To actually move a node, you have to force it to the position of the mouse
while the drag moves.
Hint: check the argument your callbacks are given when called!
Finally, when the drag ends, you have to release the node. The simulation will
continue for a short while settling in to the new layout, you do not have to stop
it when the drag ends.
With this information, implement a re-usable dragging function in _ex_1_drag.js
. The file should export a function that takes a force-simulation as
argument and gives you a function to use as an
action
on the circle elements. That action-function should take each circle's node
variable as second argument. Configure the dragging behaviour within the action-function
as described and use it in _ex_1_network.svelte
. Remember that
an action-function is a function that takes a handle to an element on the DOM
as argument. For example, when you specify <circle use:func />
Svelte will call func(circleHandle)
when
the circle is constructed. To pass more arguments to an action-function, you can
use <circle use:func={value} />
, which
will call func(circleHandle, value)
.
Loading the data, please wait...
For this exercise, you have to edit the files src/routes/extra_challenges/exercises/_ex_2_network.svelte
and src/routes/extra_challenges/exercises/_ex_2_zoom.js
/>. Any changes you make to those file should show up below.
For zooming and panning, we will use d3.js' d3-zoom
module. Using this module is similar to the brush and drag modules. To configure
it, you have to specify the size of the SVG element using the .extent()
function and register a callback for the zoom
event. This event
is released when the user zooms or drags on the element you applied the zoom-function
to. The
transform
property of the argument that is passed to your callback
specifies the SVG transform that corresponds to the user's movement. We want to
capture that transform
and have Svelte automatically use new values
when they occur.
If we were to implement this functionality directly within _ex_2_network.svelte
, you could use the same approach as with the brush in Exercise 3 (where we
updated the range
variable and had svelte react to the changes).
For this exercise, however, you have to implement it in _ex_2_zoom.js
. Svelte cannot monitor if variables in other files change, except when you
use a
store. For this exercise, you will use a readable
store:
const myStore = readable(new Date(), function start(set) {
const handle = setInterval(set(new Date()), 1000);
return function stop() {
clearInterval(handle);
}
});
Readable stores are used to give acces to values that change, but cannot be
changed by the components that use them. For example, they can store the
current time, as in the example above. Here, we created readable store with
current time as initial value. The second argument is a function that is
called the moment a first component starts using the value of the store. In
that function, we update the value of the store every second by using the set
function that is given as argument to the start function. Finally, the start
function returns a function that performs any cleanup that has to happen when the
last component stops using the store. Here, we cleared the interval so that the
store's value is not updated every second anymore.
Using this information, implement zooming and panning functionality in _ex_2_zoom.js
. The file should export a function that takes the width and height of the
element it will be applied to and return an action-function that configures
the zoom-module. Define a readable store that monitors the transform
value given in the zoom
event callback.
Hint: you need to use the store's set function in the zoom
callback!
Assign the store as a property of the action-function, so that it can be accessed
later on.
Hint: Functions in JavaScript are really just objects that you can call, so
you can give properties using:
function func() {
return 'func called!';
}
func.myProperty = 'func property accessed!';
console.log(func());
console.log(func.myProperty);
Use the zoom-action you have created on the SVG-element in _ex_2_network.svelte
. Apply the transform to the SVG group tag that contains the nodes and links
and not on the SVG element. This makes sure that the Star Wars logo does not
move when you zoom or pan!
Loading the data, please wait...
For this exercise, you have to edit the files src/routes/extra_challenges/exercises/_ex_3_network.svelte
and src/routes/extra_challenges/exercises/_ex_3_hover.js
/>. Any changes you make to those file should show up below.
The hover interaction is a bit different from the other two, as we do not need
to use any d3.js functionality. Instead we can just use Svelte's on:mouseover
and
on:mouseout
directives to capture the user's interaction. The complexity
of this interaction is in determining which nodes and links should be highlighted
and which ones should fade.
Because you have to implement the hover functionality in _ex_3_hover.js
, you will have to use
stores
again to make Svelte detect when values change. In this case, you will need both
writable
and derived
stores. writable
stores are the easiest type of store in Svelte, you can just initialize them
with a value and then assign to them using the $
notation:
// Create the writable store
const myStore = writable('initial value');
// Update the value
$myStore = 'new value!';
// Print the value when the value changes
$: console.log($myStore);
derived
stores can be used to compute values that depend on the value
of other stores. For instance, if we have a store containing the grades of a student,
then a derived store can be used to automatically update that student's average
grade:
import { mean } from "d3-array";
const average_grade = derived(grades, ($grades) => {
return mean($grades);
});
Here, a derived store is constructed by giving it the store that it depends on
and a callback function that uses that store's value to computed the derived
store's value. Specifically, grades
is a store containing the grades
of a student as an array of numerical values and the callback computes the mean
grade for that student. It is a convention to name the variable within the callback
as the name of the store with a $
prefixed (i.e., $grades
), but the $
does not have any special meaning here. It just indicates
that you are working with the value of a store and not the store object itself!
Aside from .filter()
, which we showed
before, two additional array manipulation functions are often useful in
derived stores:
.map()
and .reduce()
. They are used to replace typical for-loop patterns. The .map()
replaces a loop that creates a single value for each item in an array. For example,
const xs = nodes.map(d => d.x);
creates an array containing the x-coordinates of the nodes.
.reduce()
is more flexible, as it does not
have to return a value for every item in the array. Instead, you get control over
what to add to the output and what the output should be. For example, a .reduce()
can be used to sum all elements in an array:
const sum = array.reduce((output, current_value) => {
return output += current_value;
}, 0);
Here, the first argument is a callback that is called on every item in the
array. This callback takes as arguments the variable that will be the output
and the current value in the array. Within this callback you can update the
output variable, here we simply add the current value to it. The callback has
to return the updated output variable, as it will be used in the next
iteration. The second argument is the output's initial value. In this example
we used zero as initial value. A .reduce()
can also be used to convert an array into an object, which is sometimes useful:
const object = array.reduce((output, current_item) => {
output[current_item.id] = current_item;
return output;
}, {});
Note, objects cannot be used as keys in normal JavaScript objects, that is
why we used the id
field in this example!
Using this information, complete the hover functionality in _ex_3_hover.js
. The file already exports a function that takes the nodes
and
links
arrays, computes each node's neighbors, and contains a writable
store to track the currently hovered node. Define two derived stores that contain
a Set
indicating which nodes and links should
be highlighted. Hint: look up how sets work in JavaScript! These sets
should contain all nodes and all links when there is no currently hovered
node. Otherwise, they should contain only the hovered node and its neighbors
and the links between them. Add these derived stores to the object that is
returned from the function. Finally, use the hovering functionality in _ex_3_network.js
, add mouse event listeners to the circles that update the hover state, and
set the node's and link's opacity to 0.3
when they are not in the
sets.
Loading the data, please wait...
The way that data was loaded in these exercises is not optimal. You have probably noticed that the page jumps around when it is refreshed. This happens for two reasons: (1) the loading-messages do not have the same size as the visualizations, and (2) the visualisations are not pre-rendered by our server.
There are two ways we could solve this issue. The first approach removes the if-block we have used so far. As a result, components that depend on the data are shown regardless of whether the data is available yet. This means that we have deal with the case that our data is not available within those components. They have to update themselves when the data becomes available. You have enough Svelte experience by now to figure out how to do this. However, this approach complicates the code in your components. In addition, it breaks the spirit of the RAII principle: objects should acquire their resources during their initialization and release them when they get destroyed.
The second approach relies on SvelteKit rather than pure Svelte. Unlike Svelte, SvelteKit is able initialize components with pre-loaded values. We only have to tell it how it should load our data! Unfortunately, this only works when a component is shown as a page and not when it is imported and used on a page. So, to see this approach in action, you have to look at the bonus exercise directly. You can also compare it to exercise 1. The difference is most obvious when you refresh both pages a couple of times!
To tell SvelteKit how to load our data, we have to define a module-script in the component:
<script context="module">
export async function load({ fetch }) {
const response = await fetch("/data/starwars.json");
return {
status: response.status,
props: {
data: response.ok && (await response.json())
}
};
}
</h5script>
This should be an additional script-tag within the component, separate from
the ones we have used in components so far! Within that module-script, you can
export an asynchronous function called load()
. This function is given an object as input, but we only need the fetch
property, which we extract using object destructuring. Within the load function,
we can use fetch()
to load our file. You should
not use the d3.js loading functions here because they use the native fetch funcion
and not the one given by SvelteKit. Finally, we have to return an object with two
values: status
and props
. The status value comes
directly from the response
we got from fetch. Within props
we can specify values for the component properties that our component should
be initialized with. Here, we only need a value for the data
array.
To get that value, we interpret the text contained in response
as
json by calling the .json()
function on it.
Look at the
d3-dsv
module if you need to interpret text as csv!
Now, SvelteKit will instantiate our component with the data
property
already filled in!
There are no exercises about this section. I just really wanted to show how you can avoid the page-jumping! Perhaps you can use this approach for your projects later in the course :)