Top Language | Count | Percent of Total |
---|---|---|
R | 149 |
46.7%
|
Python | 73 |
22.9%
|
null | 42 |
13.2%
|
Jupyter Notebook | 18 |
5.6%
|
SAS | 8 |
2.5%
|
TSQL | 7 |
2.2%
|
Batchfile | 6 |
1.9%
|
TeX | 5 |
1.6%
|
HTML | 4 |
1.3%
|
Dockerfile | 1 |
0.3%
|
Java | 1 |
0.3%
|
JavaScript | 1 |
0.3%
|
Rebol | 1 |
0.3%
|
Roff | 1 |
0.3%
|
Rust | 1 |
0.3%
|
Shell | 1 |
0.3%
|
How it works
The closeread docs for ojs are great - I highly recommend reading through that document.
They use crProgressBlock
as a variable to indicate how far along the page a user has scrolled.
They then take that variable and apply some basic math to it. For example, when the user starts scrolling at 0%, they set the variable angle1
to be -180
. And angle1
will change as the user scrolls down until it gets to angle = 0
. Like this:
angleScale1 = d3.scaleLinear()[0, 1])
.domain([-180, 0])
.range(
.clamp(true)
angle1 = angleScale1(crProgressBlock)
What you need to do - user scroll locations
- put this chunk in your quarto document and run
quarto preview
. Note that the last variable is a variable you can derive
:::{.counter style="position: fixed; top: 10px; right: 10px; background-color: skyblue; border-radius: 5px; padding: 18px 18px 0 18px; line-height: .8em;"}
```{ojs}
md`Active sticky: ${crActiveSticky}`
md`Active trigger: ${crTriggerIndex}`
md`Trigger progress: ${(crTriggerProgress * 100).toFixed(1)}%`
md`Scroll direction: ${crDirection}`
md`Progress Block progress: ${(crProgressBlock * 100).toFixed(1)}%`
md`-----`
md`(derived) derived var1: ${bf1.toFixed(1)}°`
```
:::
scroll on the script and the blue tab on the upper right will tell you what those variables equal when the user scrolls through the site
now you can see how variables change
assign that variable to something in ojs, such as
scan = crTriggerIndex
What you need to do - learn some D3
Now you have scan = crTriggerIndex
, a variable in ojs that gets updated when a user scrolls. We can use this variable in our plot.
I used D3.js to make the beeswarm plot
use Observable to design the plot (and chatGPT to guide you with the code)
for a D3.js introduction, you NEED to read this document
- especially this part: it shows how D3 visuals are broken down into parts
Structure of d3-force simulations Before we dive into the specific forces that we’ll use, let’s briefly discuss the general structure used to create a force-directed graph.
// 1. create a copy of the node data
nodes = node_data.map(d =\> Object.create(d))
// 2. create new force simulation specifying the forces to use // and, in our case, how many “ticks” or frames we’ll want to simulate
sim = d3.forceSimulation(nodes) .force("force_name", ...) // ... chain together as many forces as we want .stop() .tick(n_frames_to_simulate)
// 3. bind data and draw nodes
node = svg.selectAll(".node") .data(nodes).enter() // ... specify node position, radius, etc. as we normally would
// 4. indicate how we should update the graph for each tick
sim.on("tick", () =\> { // ... specify how we should move nodes/edges given new positional data })
Step one is to create a copy of our initial node data (position, radius, etc.) so that we can pass this copy rather than our original data to
d3.forceSimulation()
. This is because as we simulate forces, thed3.forceSimulation()
function will update whatever array of data we pass it to reflect how forces are influencing our nodes so if we have any intention of re-using our initial node data after we start simulating, we’ll want to copy it first. However, if you are only doing one simulation and do not need preserve the initial data, you don’t necessarily need to worry about copying your data.Step two is to actually make our simulation by first passing our (copied) array of data to
d3.forceSimulation()
. We can then add/chain whatever forces we would like to use withforce(...)
(more on this below). In our case, once we have defined the behavior of the simulation, we will then explictly stop the simulation before it has a chance to start so that we can specify how many frames or ticks we would like to run of our simulation. We do this below so that we can control where we are in the simulation with scrubbers, but without doing this, the simulation will simply start and continue to run on it’s own.Step three is to simply bind our data and draw our nodes as we would for any static graph. Because we are binding our nodes to the same data that is passed to
d3.forceSimulation()
, as our simulation runs, we can simply rely on the next and final step to update our nodes with new positioning.Step four is to end by indicating how we should update our nodes at each step as the simulation applies forces. The simulation will be updating the underlying positional data array (
nodes
) that is already bound to our drawn elements so we just have specify moving each node to its new position.
What you need to do - put it together
I went back and forth with chatGPT a lot, and I read a lot of documentation to make the visual. But it was worth it. I feel like I have a better understanding of D3.
- For my beeswarm plot, I make a base plot that has minimal D3 forces applied to it, it looks like this:
// Initialize simulation with the base forces
const sim = d3
.forceSimulation(node_data)
.force("x", d3.forceX(d => xScale(new Date(d.create_date)))) // Position along the X-axis based on create_date .force("collide", d3.forceCollide().radius(d => radiusScale(d.commits) + 1).strength(0.5)); // Default collision force
- Then I used the
scan
variable I created above that is linked to closeread’s scroll trigger to dynamically add new D3 forces to the plot:
`scan > 1`, apply additional forces for language grouping
// If
if (scan > 1) {
// Apply additional y-force to divide nodes by language
sim.force("y", d3.forceY(d => yScale(d.language) + 70)) // Position nodes along y-axis based on language
.force("collide", d3.forceCollide().radius(d => radiusScale(d.commits) + 1).strength(0.8)); // Adjust collision force
// Create x-axis for years
const xAxis = d3.axisBottom(xScale).tickFormat(d3.timeFormat("%Y"));
const xAxisGroup = svg.append("g")`translate(0, ${chart_param.height - chart_param.margin.bottom})`)
.attr("transform",
.call(xAxis);
// Style x-axis labels (make them bold and larger)
xAxisGroup.selectAll("text")
.attr("font-size", "16px") // Set font size to 16px or any value you prefer
.attr("font-weight", "bold"); // Make the labels bold
// Create y-axis for language groups
const yAxis = d3.axisLeft(yScale);
const yAxisGroup = svg.append("g")
.attr("transform", `translate(${chart_param.margin.left}, 0)`)
.call(yAxis);
// Style y-axis labels (make them bold and larger)
yAxisGroup.selectAll("text")
.attr("font-size", "15px") // Set font size to 16px or any value you prefer
.attr("font-weight", "bold"); // Make the labels bold
} else {`scan === 1`, apply the default force with no language division
// For
sim.force("y", d3.forceY(chart_param.height / 2)) // All nodes at the center of Y-axis
.force("collide", d3.forceCollide().radius(d => radiusScale(d.commits) + 1).strength(0.5)); // Default collision force
// Create x-axis for years
const xAxis = d3.axisBottom(xScale).tickFormat(d3.timeFormat("%Y"));
const xAxisGroup = svg.append("g")`translate(0, ${chart_param.height - chart_param.margin.bottom})`)
.attr("transform",
.call(xAxis);
// Style x-axis labels (make them bold and larger)
xAxisGroup.selectAll("text")
.attr("font-size", "16px") // Set font size to 16px or any value you prefer
.attr("font-weight", "bold"); // Make the labels bold
}
- You can see a conditional statement: when
scan >1
then the plot will get split into a plot by thelanguage
variable, and a new y axis will be added to the plot. Else, whenscan === 1
then the plot has a base force with the regular x axis only