Lab 9: Animation & Scrollytelling
In this lab, we will learn:
- What different methods do we have to animate elements and when to use each?
- What considerations should we take into account for using animations effectively?
Table of contents
- Check-off
- Lab 9 Rubric
- Slides
- What will we make?
- Step 1: Evolution visualization
- Step 2: The race for the biggest file!
- Step 2.1: Creating a component for the unit visualization
- Step 2.2: Making it look like an actual unit visualization
- Step 2.3: Varying the color of the dots by technology
- Step 2.4: Consistent colors across visualizations
- Step 2.5: Sorting files by number of lines
- Step 2.6: Dot transitions
- Step 2.7: Animated race
- Step 3: Transforming the bar chart into a stacked bar chart
- Step 4: Scrollytelling Part 1 (commits over time)
- Step 5: Scrollytelling Part 2 (file sizes)
- Resources
Check-off
You need to come to TA Office Hours to get checked off for this lab (any of them, no appointment needed), OR submit your work asynchronously by filling out this form.
If you choose to submit your work asynchronously and have an incorrect or incomplete part of the lab, you will not receive any credit for the lab (we do not offer partial credit on labs). You may not resubmit this form nor ask for a synchronous check off for the same lab.
Lab 9 Rubric
To successfully complete this lab check-off, ensure your work meets all of the following requirements:
General
- Succesfully deployed to GitHub Pages.
Scatterplot
- Commits update (filtered by time) while scrolling (in a stable way).
Unit visualization
- File lengths are represented by dots and colored accordingly.
- Visualization updates while scrolling.
- Consistent colors across visualizations (referring to bar chart).
Bar chart
- Bar chart was converted to stacked bar chart with adapted width.
- Bar chart updates while scrolling.
- Legend is consistent with content.
- Labels are not displayed if bar is too slim.
Scrollytelling
- Narrative about commit history was added.
- Consistent scrollytelling functionality across full page containing all the different visualization components.
Slides
What will we make?
In this lab, we will go back to the Meta page of our portfolio, and convert it to an interactive narrative visualization that shows the progress of our codebase over time.
Step 1: Evolution visualization
src/meta/+page.svelte
In this step, we will create an interactive timeline visualization that shows the evolution of our repo by allowing us to move a slider to change the date range of the commits we are looking at.
Step 1.1: Creating the filtering UI
In this step we will create a slider, bind its value to a variable, and display the date and time it corresponds to. It’s very familiar to what we did in the previous lab, except we don’t need to worry about a “no filter” state.
First, let’s create a new variable, commitProgress
, that will represent the maximum time we want to show as a percentage of the total time:
let commitProgress = 100;
To map this percentage to a date, we will need a new time scale, just like we did in Lab 7 that will map commit.datetime
values to the [0, 100]
range.
Once we have our scale, we can easily get from the 0-100 number to a date:
$: commitMaxTime = timeScale.invert(commitProgress);
We are now ready to add our filtering UI. We will use the slider to filter our scatterplot so we place it above the plot.
- Wrap all of the following in a
<div>
and assign a class to it for styling. I chose “slider-container”. (You might need another<div>
container around the label and the slider to be able to place them both in the same row.) - Create a new
<label>
element for our time slider, which contains a short description. Remember, we want to show the commits until a specified time. - Define the
<input>
and assign values to its properties type (we want to display a range of numbers), id, class (we are in the process of creating a slider), and min and max. Don’t forget to bind the slider value tocommitProgress
. - We also need a
<time>
element that will display the date and time corresponding to the slider value. To get thisdatetime
value, we can make use of the variable we just created, that maps the date to a 0-100 number (converted to a string by usingcommitMaxTime.toLocaleString()
, similarly to how we did in Lab 7 and Lab 8).This time we need to display both the date and the time. - Add some CSS to make the slider maximum width and to place the time element underneath the slider (otherwise differences in output value length will move the slider, which is very jarring). You might want to create a grid with two rows for the overall container and use
display: flex
for the inner container together withflex:1
for the slider. (Hint: If the text does not get displayed in one line, trywhite-space: nowrap
.)
Feel free to use any settings you like. In the screencasts below, I use dateStyle: "long"
and timeStyle: "short"
.
If everything went well, your slider should now be working!
Step 1.2: Filtering by commitMaxTime
Let’s now ceate a new filteredCommits
variable that will reactively filter commits
by comparing commit.datetime
with commitMaxTime
. It will be updated as we interact with our website, so don’t forget to make it reactive. Also, make sure to define it above xScale
in order to be able to access it later in the code.
The final syntax should look like this.
$: filteredCommits = commits.filter(commit => commit.datetime <= commitMaxTime)
Similarly, create a filteredLines
variable that filters data
in the same way.
We can now replace commits
with filteredCommits
and data
with filteredLines
in several places:
- The
xScale
domain - The
brushed()
function that updates thebrushedCommits
variable - The
{#each}
block that draws the circles - The
hoveredCommit
variable - Your summary stats
Try moving the slider and see what happens!
Step 1.3: Making the circles stable
CSS transitions are already applied to our circles since Lab 7. However, notice that when we move the slider, the circles are jumping around a lot.
This is because Svelte doesn’t know which data items correspond to which previous data items, so it does not necessarily reuse the right <circle>
element for the same commit. To tell Svelte which data items correspond to which previous data items, we can use a keyed each
block, with a value that uniquely identifies the data item. A good candidate for that in this case would be the commit id:
{#each filteredCommits as commit, index (commit.id) }
Just this small addition fixes the issue completely!
Step 1.4: Entry transitions with CSS
Notice that even though we are now getting a nice transition when an existing commit changes radius, there is no transition when a new commit appears.
This is because CSS transitions fire for state changes where both the start and end changes are described by CSS. A new element being added does not have a start state, so it doesn’t transition. We could use Svelte transitions for this, but we don’t need to. We can actually use CSS transitions, we just need to explicitly tell the browser what the start state should be. That’s what the @starting-style
rule is for!
Inside the circle
CSS rule, add a @starting-style
rule (and ignore Svelte’s “Unknown at rule” warning):
@starting-style {
r: 0;
}
If you preview again, you should notice that that’s all it took, new circles are now being animated as well!
You might notice that the largest circles and the smallest circles are both transitioning with the same duration, which means dramatically different speeds. We may decide that this is desirable: it means all circles are appearing at once. However, if you want to instead keep speed constant, you can set an
--r
CSS variable on eachcircle
element with its radius, and then set the transition duration to e.g.calc(var(--r) / 100ms)
. You can do that only forr
transitions like so:transition: all 200ms, r calc(var(--r) * 100ms);
Step 1.5: Moving the scatterplot into a separate component (optional, but recommended)
src/routes/meta/+page.svelte
, src/routes/meta/Scatterplot.svelte
src/routes/meta/+page.svelte
has begun to grow quite a lot, and it’s only about to get bigger. It will really help make your code more manageable to start moving reusable functionality to components. One good candidate is the commit scatterplot.
Create a new file, src/routes/meta/Scatterplot.svelte
, and move the scatterplot code there. This includes:
- The
<svg>
element - The commit tooltip
- Any CSS styling elements in those
- The JS dealing with dimensions, margins, scales, axes, brushing, user interaction with dots, commit selection, etc.
It should have two props (that we want to export):
commits
(an array of commits)selectedCommits
(mostly used as output, but could be used as input as well)
We name the variable commits
in order to make the scatterplot a bit more generalizable. As such, make sure to change any mentions of filteredCommits
in your new file back to commits
. VS Code allows you to do that safely in one go, by placing the text caret on the variable name, then pressing F2 (or right clicking and selecting “Rename Symbol”)
The final result should allow us to replace our entire <svg>
and commit tooltip in src/routes/meta/+page.svelte
with just:
<CommitScatterplot commits={filteredCommits} bind:selectedCommits={selectedCommits} />
Don’t forget to also move the necessary imports (and to import your new Scatterplot component as CommitScatterplot
)! I find it helpful to just copy all of them, then remove the ones VS Code highlights as unused.
Step 2: The race for the biggest file!
In this step we will create a unit visualization that shows the relative size of each file in the codebase in lines of code, as well as the type and age of each line.
Step 2.1: Creating a component for the unit visualization
src/lib/FileLines.svelte
, src/routes/meta/+page.svelte
To avoid bloating src/routes/meta/+page.svelte
even more, let’s create a new component, src/lib/FileLines.svelte
, that will contain the unit visualization.
The component should take a lines
and a width
prop, and will group lines of code into files internally within the specified width. To make this work, make sure to export those variables on top of your FileLines.svelte
file. This means that from the outside, we’d just use it like this:
<FileLines lines={filteredLines} width={width}/>
Eventually we want this to go after the scatterplot & bar chart, but for now let’s add it right after our filtering UI as that makes development faster.
Then, within the component we will group the lines by file, convert the groups to an array of {name, lines}
objects and sort them like this:
let files = [];
$: files = d3.groups(lines, d => d.file)
.map(([name, lines]) => ({ name, lines }));
Now that we have our files, let’s output them.
We are using D3 here, as this is what we mainly want to teach in this class. However, you could also use HTML and CSS. In the case of unit visualizations, it can sometimes actually be easier, as there is a lot less work necessary to manage the position of each element.
This is how we would start in the HTML/CSS case.
There are many ways to implement this logic, but here’s one:
<dl class="files"> {#each files as file (file.name) } <div> <dt> <code>{file.name}</code> </dt> <dd>{file.lines.length} lines</dd> </div> {/each} </dl>
We use an <svg>
element to display our visualization, which we need to bind to a variable (which we can define with let svg
). We define a fixed width and compute the height based on the number of files. One way to set this up is:
$: if (svg) {
const rowHeight = 30;
const width = 400;
const height = files.length * rowHeight;
d3.select(svg)
.attr('width', width)
.attr('height', height);
}
Note that we use a reactive block here, such that the dimensions get updated accordingly when something changes.
Next, we use D3’s data join to create a group <g>
for each file. Note that all the group logic is placed within the if(svg)
block.
const groups = d3.select(svg)
.selectAll('g.file')
.data(files, d => d.name);
To make the filtering work, we need to remove groups that no longer exist and add groups for new data.
groups.exit().remove();
const enterGroups = groups.enter()
.append('g')
.attr('class', 'file')
.attr('transform', (d, i) => `translate(0, ${i * rowHeight})`);
Within each group, we have two <text>
elements, one for the file name and one for the line count.
enterGroups.append('text')
.attr('class', 'filename')
.attr('x', 10)
.attr('y', rowHeight / 2)
.attr('dominant-baseline', 'hanging')
.text(d => d.name);
enterGroups.append('text')
.attr('class', 'linecount')
.attr('x', 250)
.attr('y', rowHeight / 2)
.attr('dominant-baseline', 'hanging')
.text(d => `${d.lines.length} lines`);
To finally make the filtering work and update our output accordingly, we add the following syntax.
groups.attr('transform', (d, i) => `translate(0, ${i * rowHeight})`)
.select('text.filename')
.text(d => d.name);
groups.select('text.linecount')
.text(d => `${d.lines.length} lines`)
.attr('x', 250);
Great, now the functionality should already be in place! Let’s work on the styling. You can apply styles to both of your groups (text.filename
and text.linecount
), such as adapting the font-size or choosing a different font-family for the filenames to stand out.
In case you encounter the issue that the styling is not applied correctly, try the syntax :global(text.filename)
, which ensures that styles are applied even if the elements are created dynamically by D3.
At this point, our “visualization” is rather spartan, but if you move the slider, you should already see the number of lines changing!
Step 2.2: Making it look like an actual unit visualization
For a unit visualization, we want to draw an element per data point (in this case, per line), so let’s do that. We start by defining a function that generates our dots. After computing the number of dots we need for a specific file (totalDots
), we compute the maximum dots we can place in each row (maxDotsPerRow
) to determine necessary line breaks and the number of rows we have for each file (dotRows
). Then, we loop over all of the rows and for each of them, we create the corresponding dots.
function generateDots(file, svgWidth) {
const totalDots = Math.ceil(file.lines.length / linesPerDot);
const availableWidth = svgWidth - dotsColumnX;
const maxDotsPerRow = Math.floor(availableWidth / approxDotWidth) || totalDots;
let tspans = "";
const dotRows = Math.ceil(totalDots / maxDotsPerRow);
for (let r = 0; r < dotRows; r++) {
const count = Math.min(maxDotsPerRow, totalDots - r * maxDotsPerRow);
const rowDots = Array(count).fill('•').join('');
tspans += `<tspan x="${dotsColumnX}" dy="${r === 0 ? 0 : dotRowHeight + 'px'}">${rowDots}</tspan>`;
}
return tspans;
}
In our previous version, we were just defining a fixed width and height for each row, but now, this might change, depending on the number of dots we have per entry. This requires us to change the y
attribute in the filename group to baseY
. Seeing the total number of lines per file is still useful but we don’t want to have an overlap with the dots. Therefore, we place it as a <small>
element below the filename by changing the y
attribute in the filename group to baseY + totalLinesOffset
(to make this work, we should also remove .attr('x', 250)
in its select definition). Additionally, we define the variable filesWithHeights
to store, as the name suggests, the required height for each of the files, which depends on the necessary number of dots and rows.
$: filesWithHeights = files.map(file => {
const totalDots = Math.ceil(file.lines.length / linesPerDot);
const availableWidth = width - dotsColumnX;
const maxDotsPerRow = Math.floor(availableWidth / approxDotWidth) || totalDots;
const dotRows = Math.ceil(totalDots / maxDotsPerRow);
return { ...file, groupHeight: fileInfoHeight + dotRows * dotRowHeight };
});
Because each file might require a different height, we calculate the y-position for each group cumulatively. This means we determine the position of a certain entry based on all the entries before.
$: positions = (() => {
let pos = [], y = 0;
for (const f of filesWithHeights) {
pos.push(y);
y += f.groupHeight;
}
return pos;
})();
Then we can use the i-th position (${positions[i]}
) to define the translation in the transform attribute of the filename (we want to replace i * rowHeight
). Both of our new variables can also be used to update our svg
element accordingly and dynamically. We also add an overflow
parameter here to ensure a proper display of the dots.
$: if (svg) {
const svgWidth = 1200;
const totalHeight = positions.length
? positions[positions.length - 1] + filesWithHeights[filesWithHeights.length - 1].groupHeight
: 0;
d3.select(svg)
.attr('width', svgWidth)
.attr('height', totalHeight)
.style('overflow', 'hidden');
}
Now we want to add the dots to our visualization, exactly as we did for the filenames, and add a select action for it.
enterGroups.append('text')
.attr('class', 'unit-dots')
.attr('x', dotsColumnX)
.attr('y', baseY - 2)
.attr('dominant-baseline', 'mathematical')
.attr('fill', "#1f77b4")
.html(d => generateDots(d, svgWidth));
groups.select('text.unit-dots')
.html(d => generateDots(d, svgWidth))
.attr('x', dotsColumnX)
.attr('fill', "#1f77b4");
Finally, you can add some styling in CSS. You should assign a font-size to the unit dots (I used 2.2rem
) and you can change the font-size and color of the total line number. Also make sure that the text of the filenames does not impact the dots and that the spacing between the files is reasonable.
When running the code, you might notice some missing variable definitions that determine the layout of the page. You need to specify for example the column width (dotsColumnX
) and height (fileInfoHeight
, dotRowHeight
) and how many lines one dots represents (linesPerDot
). Try to set up a suitable layout for the visualization yourself.
Here are the values I've used.
const firstColumnWidth = 150;
const fileInfoMargin = 100;
const dotsColumnX = firstColumnWidth + fileInfoMargin;
const approxDotWidth = 20;
const linesPerDot = 1;
const baseY = 10;
const totalLinesOffset = 20;
const fileInfoHeight = baseY + totalLinesOffset;
const dotRowHeight = 20;
At this point, we should have an actual unit visualization!
It should look something like this:
Step 2.3: Varying the color of the dots by technology
Our visualization shows us the size of the files, but not all files are created equal. We can use color to differentiate the lines within each file by technology.
Let’s create an ordinal scale that maps technology ids to colors:
let colorScale = d3.scaleOrdinal(d3.schemeTableau10);
Then, we can use this scale to color the dots by modifying our generateDots
function by replacing
const count = Math.min(maxDotsPerRow, totalDots - r * maxDotsPerRow);
const rowDots = Array(count).fill('•').join('');
with
const rowLines = file.lines.slice(r * maxDotsPerRow, (r + 1) * maxDotsPerRow);
const rowDots = rowLines
.map(line => `<tspan class="dot" style="fill:${colorScale(line.type)}">•</tspan>`)
.join('');
We are not solely interested in the count anymore, but this change allows us to extract line objects, which allows us to color them by type
, which corresponds to the programming language.
If you’re interested, you can log the lines
data in the console and inspect the different parameters in detail.
Much better now!
Step 2.4: Consistent colors across visualizations
Notice that we have two visualizations that use colors to represent technologies, but they use different colors for the same technologies!
To fix this, we need to allow our components (Bar.svelte
and FileLines.svelte
) to accept a color scale as a prop, by prepending their colors
declarations with export
:
export let colorScale = d3.scaleOrdinal(d3.schemeTableau10);
Then we create the color scale on the parent page and pass it to each of them. For example, <FileLines />
would become <FileLines lines={filteredLines} width={width} colorScale={colorScale} />
.
Our visualization is now way more informative! Make sure that both in File Lines and in the Bar chart, the same colors represent the same technologies!
Step 2.5: Sorting files by number of lines
Now let’s come back to FileLines.svelte
again. Our visualization is not really much of a race right now, since the order of files seems random. We need to sort the files by the number of lines they contain in descending order. We can do that in the same reactive block where we calculate files
by adding the following expression to our definition of the reactive variable.
.sort((a, b) => b.lines.length - a.lines.length);
Step 2.6: Dot transitions
Notice that it’s a little hard to compare which lines of each file have been added as we move the slider. If we make new elements appear with a transition, it will be much easier to see what is happening. Note that we can not use the Svelte predefined transitions in this case, as the dots are created by D3. Therefore, as mentioned above, they are outside of Svelte’s reactivity system, which means we need to use d3-transition instead.
First of all, we only want to animate new dots that appear when we move the slider. Let’s create a variable that remembers how many dots were rendered for each file during the last update.
let previousDotCounts = new Map();
Now, we want to create a function that loops over each file group to check for new dots. We select the current group groupSel
and within that group, we select the element unitDotsSel
that contains our dots. We retrieve the previous dot count oldCount
from our new variable previousDotCounts
and compare it with the new dot count newCount
. Then we update the html display of our dots and add an animation only if there are more new dots than before. Finally, we update our variable with the current dot count to be used in the next update.
groups.each(function(d) {
const groupSel = d3.select(this);
const unitDotsSel = groupSel.select('text.unit-dots');
const newCount = d.lines.length;
const oldCount = previousDotCounts.get(d.name) || 0;
unitDotsSel.html(generateDots(d, svgWidth));
if(newCount > oldCount) {
// ... the animation syntax will go here
}
previousDotCounts.set(d.name, newCount);
});
To prepare for the animation, we select all the dots (which are of class tspan.dot
) and filter for new ones (that have a data-index
attribute greater or equal to oldCount
, this was assigned in the generateDots
function).
if(newCount > oldCount) {
unitDotsSel.selectAll('tspan.dot')
.filter(function() {
return +this.getAttribute('data-index') >= oldCount;
})
// TODO: add the code for the transition here
}
Now, d3-transition
finally comes in to animate our dots! We start by setting the opacity
to 0
(fully transparent) and then create a transition with a duration of e.g. 1000 ms
and uses a d3.easeCubicOut
function. During this transition, we want to set the opacity
to 1
, such that the dots will be fully visible in the end. If everything works out, you should see your dots gradually appearing when sliding!
Step 2.7: Animated race
We can now animate files when they move to a new position. Remember, that we store the vertical positions of each file in our reactive variable positions
, which we can make use of again now. We can use a new instance of groups
and add a .transition()
to it with a .duration()
of e.g. 3000 ms
. Then, we need to define the transition. We want that the transition interpolates between the current and the new position value and therefore enables a smooth transition. This can be done by adding the following syntax to groups
.
.attr('transform', (d, i) => `translate(0, ${positions[i]})`)
Now we can already see what happens, but this does not yet look like a race (a very slow one, in the best case).
One option to improve this is, instead of setting a fixed duration, specify duration as a function, which depends on the distance travelled. We need to compute the difference between the current and the new position of each element, which corresponds to the distance. I chose to multiply the computed distance by 2
(which corresponds to 2 ms per pixel) to get the final value for the duration of the transition but you can experiment here.
.duration(function(d, i) {
const currentTransform = this.getAttribute("transform") || "translate(0,0)";
const match = currentTransform.match(/translate\(\s*0\s*,\s*([0-9.]+)\s*\)/);
const oldY = match ? +match[1] : 0;
const newY = positions[i];
const distance = Math.abs(newY - oldY);
return distance * 2;
})
If you try it now, you will see that the animation is correct!
Step 3: Transforming the bar chart into a stacked bar chart
lib/StackedBar.svelte
In lab 7, we created a bar chart that shows the distribution of edited lines per technology, and responds to filtering. However, we want to emphasize the composition of our project, which means it would be helpful to highlight how different technologies contribute to our whole project. Therefore, we’re gonna transform our bar chart to a stacked bar chart instead.
As a first step, create a StackedBar.svelte
file and copy the content of Bar.svelte
. You can also directly replace the content in the old file but it might be helpful to keep it as a resource. Don’t forget to adapt the input statement in meta/+pages.svelte
.
Step 3.1: Data Transformation
To be able to use d3.stack
, which helps us to compute the stacked layout, we need to slightly restructure our data. Instead of having one object per bar we need one object that contains all the labels (keys) with their counts. To extract the keys, we need to get an array of the labels from our data. You can create a reactive variable keys
by using the map
function and access the first element in the data (remember the zero-indexing here).
Now, as mentioned before, we want all of our data in one single object. d3.stack
expects an array of objects, therefore, we need to additionally wrap it into array using []
.
$: dataForStack = [Object.fromEntries(data)];
Now we can finally stack our data! Create a reactive variable stackedData
by using the d3.stack()
function together with keys
and dataForStack
. The result is an array of series, one per key.
Because the stacked bar chart displays percentages, we need to calculate the maximum value from the stacked data set, which we can then use to set the domain for our x-scale.
$: total = d3.max(stackedData, series => d3.max(series, d => d[1])) || 1;
Update your xScale
variable accordingly by changing the domain to [0,total]
.
Step 3.2: Rendering the Chart
In our standard bar chart, we were iterating over the data and then rendering one <rect>
and one <text>
element per bar. Now, we want to show a single bar that is made out of multiple segments. This means we loop over each series (label) (that was produced by d3.stack
) and then we loop over each segment in that series. This means we need to replace our single loop with a nested loop and make some more modifications.
- We need to update the overall height of the
<svg>
element, which now corresponds to only thebarHeight
. - Before, all the bars were starting at
x=0
. Now, each segment has a different starting position (depending on all the other segments before) that can be expressed byxScale(d[0])
. Accordingly, we need to subtract this from the width of the segment to get the total width of the current bar. - Because we changed our data structure, we need to assign the color based on
series.key
, which is technically the same value as in our standard bar chart, our data is just organized differently. - We need to make some slight modifications on the label positions.
- To make our labels nice, let’s make sure they are only rendered, when the segment has a certain width, to avoid overlapping with other segments. We can do this using the following expression
{#if (xScale(d[1]) - xScale(d[0]) > MIN_LABEL_WIDTH)}
.
This leads to the following syntax:
<svg {width} height={barHeight}>
{#each stackedData as series, i (series.key)}
{#each series as d, j}
<rect
class:selected={selectedIndex === i}
class:hovered={hoveredIndex === i}
x={xScale(d[0])}
y="0"
width={xScale(d[1]) - xScale(d[0])}
height={barHeight - 5}
fill={colorScale(series.key)}
on:click={() => selectedIndex = selectedIndex === i ? -1 : i}
on:mouseenter={() => hoveredIndex = i}
on:mouseleave={() => hoveredIndex = -1}
/>
{#if (xScale(d[1]) - xScale(d[0]) > MIN_LABEL_WIDTH)}
<text
class="label"
x={(xScale(d[0]) + xScale(d[1])) / 2}
y={barHeight / 2}
text-anchor="middle"
fill="white"
dominant-baseline="middle"
>
{series.key}: {d[1] - d[0]}
</text>
{/if}
{/each}
{/each}
</svg>
To make sure that also the stacked bar chart updates correctly, you need to update the reactive statement for selectedLines
(in +page.svelte
) such that it uses filteredCommits
.
Step 3.3: Updating the legend
The legend now loops over the stackedData
instead of barData
, again, because we updated our data structure. The color, as above, is based on series.key
instead of d.label
and the count is not anymore determined by d.count
but series[0][1] - series[0][0]
instead.
Step 3.4: See it in Action!
If everything worked, you should be able to see the stacked bar chart now, as shown below! It has the same selection functionalities as the standard bar chart before and is integrated with the Scatterplot. With this, we can now see the composition of each commit more easily.
Step 4: Scrollytelling Part 1 (commits over time)
So far, we have been progressing through these visualizations by moving a slider. However, these visualizations both tell a story, the story of how our repo evolved. Wouldn’t it be cool if we could actually tell that story in our own words, and have the viewer progress through the visualizations as they progress through the narrative?
Let’s do that!
Step 4.0: Making our page a bit wider, if there is space
Because our scrolly will involve a story next to a visualization, we want to be able to use up more space, at least in large viewports.
We can do this only for the Meta page, by adding a CSS rule where the selector is :global(body)
. :global()
tells Svelte not to rewrite that part of the selector, and that we will handle any scoping conflicts ourselves. In general, the :global
selector ensures that its content is applied to the full body
element on the Meta page, which additionally contains all the different svelte
components we defined in various files.
Then, within the rule, we want to set the max-width
to 120ch
(instead of 100ch
), but only as long as that doesn’t exceed 80% of the viewport width. We can do that like this:
max-width: min(120ch, 80vw);
Because all our components (Scatterplot, StackedBar, FileLines) have a fixed width assigned, this doesn’t change anything at the first glance.
Step 4.1: Using a scrolly
I prepared a <Scrolly>
component for you to use in this step, you will find the documentation here: svelte-scrolly. If you find any bugs, please file bug reports directly at its bug tracker.
There is an official Svelte package for this purpose: @sveltejs/svelte-scroller
but it seems to only cater to scrollytelling cases where the narrative is overlaid on top of the visualization, which is not what we want here.
To use <Scrolly>
, you first need to install it (via npm install svelte-scrolly
), then import it at the top of your component:
import Scrolly from "svelte-scrolly";
Then you can use it like this:
<Scrolly bind:progress={ myProgressVariable }>
<!-- Story here -->
<svelte:fragment slot="viz">
<!-- Visualizations here -->
</svelte:fragment>
</Scrolly>
<svelte:fragment>
is a special element that we can use when no suitable element exists, to avoid bloating our DOM with pointless wrapper elements. If there is an actual element that makes sense to use, you should use that instead!
Step 4.2: Creating a dummy narrative
Before you finish the lab, you should have something meaningful for the narrative. Don’t spend long on it; you can even generate it with ChatGPT as long as you check that the result is coherent, relevant, and tells a story that complements to the visualization next to it without simply repeating information in a less digestible format.
For now, let’s just create some dummy text that we can use to test our scrollytelling so that writing the narrative is not a blocker:
{#each commits as commit, index }
<p>
On {commit.datetime.toLocaleString("en", {dateStyle: "full", timeStyle: "short"})},
{index === 0
? "I set forth on my very first commit, beginning a magical journey of code. You can view it "
: "I added another enchanted commit, each line sparkling with a touch of wonder. See it "}
<a href="{commit.url}" target="_blank">
{index === 0 ? "here" : "here"}
</a>.
This update transformed {commit.totalLines} lines across { d3.rollups(commit.lines, D => D.length, d => d.file).length } files.
With every commit, our project grows into a kingdom of dreams.
</p>
{/each}
Step 4.3: Creating a scroller for our commits over time
Move the story you just generated into the <Scrolly>
component, and the scatterplot and bar chart into the <svelte:fragment slot="viz">
.
Then, bind the existing commitProgress
variable to the progress
variable of the <Scrolly>
component (<Scrolly bind:progress={commitProgress}>
).
If you try it now, you should already see that the scroller is advancing the slider as you scroll! Don’t worry if the racing bar chart is behaving strangely with scrolling … that’s what we will fix next! Note that I changed the input width of the bar chart to 0.4
to avoid a cut-off.
Now that everything works, you should remove the slider as it conflicts with the scrolly, and it’s largely repeating information that the scrollbar already provides.
One thing you could do is show a date next to the actual browser scrollbar thumb, so that users have a sense of where they are in the timeline.
Step 5: Scrollytelling Part 2 (file sizes)
Step 5.1: Adding another scrolly
Create another <Scrolly>
instance for the file sizes visualization, after the commit visualization. You can copy and paste the same narrative as a temporary placeholder, but as with the one about commits, you should replace it with something meaningful before you finish the lab.
The progress of this component is independent, so you will want to use a different variable for it (and similarly the corresponding maxTime
variable and filtered data). Aside from that, the rest is very similar to Step 4.
To make it more visually interesting, we can place that story on the right, and the unit visualization on the left. To do that, you add --scrolly-layout="viz-first"
to the component, which passes that CSS variable to it:
<Scrolly bind:progress={raceProgress} --scrolly-layout="viz-first">
You can also specify --scrolly-viz-width="3fr"
to change the proportion of viz to story to give it more space.
Step 5.2: Update our unit visualization for a clean display
Before, we hardcoded svgWidth = 1200
but now we want to adapt it based on our two-column layout. We want to use an export property instead, which also needs to be defined in the <FileLines>
component (I’ve set it to svgWidth={0.8*width}
).
Your result should look similar to this.
Step 5.3: Recovering the appearance transition (optional)
If you observe in detail, you may notice that the new dots do not appear gradually while scrolling. In the filtering case before, we were re-rendering the complete data at each filter step, comparing the number of dots and then animating only the new ones. We want to do the same in scrolling, but updates happen faster and new transitions may interrupt ones that are still running. Therefore, we introduce a transition flag (data-transition-running
), which delays a new update if another transition is still running. We add this flag to our update block (within groups.each
). If the flag is set to true
(which corresponds to a running transition), the update step is skipped. Once the transition is finished, the flag is cleared and the next update can run.
groups.each(function(d) {
const groupSel = d3.select(this);
// We check here if a transition is running.
if (groupSel.attr('data-transition-running') === 'true') {
return; // If a transition is running, we skip this group for now.
}
const unitDotsSel = groupSel.select('text.unit-dots');
const oldCount = previousDotCounts.get(d.name) || 0;
unitDotsSel.html(generateDots(d, svgWidth));
const newCount = Math.ceil(d.lines.length / linesPerDot);
if (newCount > oldCount) {
// Here we set flag to prevent overlapping transitions.
groupSel.attr('data-transition-running', 'true');
unitDotsSel.selectAll('tspan.dot')
.filter(function() {
return +this.getAttribute('data-index') >= oldCount;
})
.style('opacity', 0)
.transition()
.duration(1000)
.ease(d3.easeCubicOut)
.style('opacity', 1)
.on('end', function() {
// When the transition is finished, we clear the flag.
groupSel.attr('data-transition-running', 'false');
});
} else {
groupSel.attr('data-transition-running', 'false');
}
previousDotCounts.set(d.name, newCount);
});
The final result looks similar to this:
Step 5.4: Limit number of updates (optional)
Our scrolly currently looks smooth if we scroll relatively slowly, but might break down if we scroll fast:
This is where throttling and debouncing come in. They are both methods of achieving the same goal: limiting the number of times a function is called.
- Throttling enforces a minimum interval between subsequent calls to the same action.
- Debouncing defers an action until a certain period of time has passed since the last user action.
The <Scrolly>
component we are using supports both, via throttle
and debounce
props. Experiment with different values for these props (you don’t need to use both) to see what works best for your scrolly.
Resources
Transitions & Animations
Tech:
Scrollytelling
Cool examples: