Skip to content

Commit

Permalink
Merge pull request #65 from qmd-lab/revise-ojs-map
Browse files Browse the repository at this point in the history
Revise ojs map
andrewpbray authored Aug 5, 2024
2 parents 913c808 + f1382e8 commit a072936
Showing 6 changed files with 213 additions and 100 deletions.
24 changes: 20 additions & 4 deletions _extensions/closeread/closeread.js
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@

// set params
const triggerSelector = '.new-trigger'
const progressBlockSelector = '.progress-block'


//=================//
@@ -33,12 +34,14 @@ document.addEventListener("DOMContentLoaded", () => {
const ojsTriggerIndex = ojsModule?.variable()
const ojsTriggerProgress = ojsModule?.variable()
const ojsDirection = ojsModule?.variable()
const ojsProgressBlock = ojsModule?.variable()

let focusedSticky = "none";
ojsStickyName?.define("crStickyName", focusedSticky);
ojsStickyName?.define("crActiveSticky", focusedSticky);
ojsTriggerIndex?.define("crTriggerIndex", 0);
ojsTriggerProgress?.define("crTriggerProgress", 0);
ojsDirection?.define("crDirection", null);
ojsProgressBlock?.define("crProgressBlock", 0);

if (ojsModule === undefined) {
console.error("Warning: Quarto OJS module not found")
@@ -51,8 +54,8 @@ document.addEventListener("DOMContentLoaded", () => {
// === Set up scrolling event listeners === //
// scrollama() is accessible because scrollama.min.js is attached via closeread.lua

const scroller = scrollama();
scroller
const triggerScroller = scrollama();
triggerScroller
.setup({
step: triggerSelector,
offset: 0.5,
@@ -65,7 +68,7 @@ document.addEventListener("DOMContentLoaded", () => {

// update ojs variables
ojsTriggerIndex?.define("crTriggerIndex", trigger.index);
ojsStickyName?.define("crStickyName", focusedStickyName);
ojsStickyName?.define("crActiveSticky", focusedStickyName);

updateStickies(allStickies, focusedStickyName, trigger);

@@ -78,6 +81,19 @@ document.addEventListener("DOMContentLoaded", () => {

});

const progressBlockScroller = scrollama();
progressBlockScroller
.setup({
step: progressBlockSelector,
offset: 0.5,
progress: true,
debug: debugMode
})
.onStepProgress((progressBlock) => {
// update ojs variable
ojsProgressBlock?.define("crProgressBlock", progressBlock.progress);
});

// Add a listener for scrolling between new triggers
let currentIndex = -1; // Start before the first element

131 changes: 87 additions & 44 deletions _extensions/closeread/closeread.lua
Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@ local global_layout = "sidebar-left"
--======================--

function read_meta(m)

-- debug mode
if m["debug-mode"] ~= nil then
debug_mode = m["debug-mode"]
@@ -52,47 +52,14 @@ end
-- Form CR-Section AST --
--=====================--

-- Construct cr section AST
-- Construct cr-section AST
function make_section_layout(div)

if div.classes:includes("cr-section") then

-- make contents of stick-col
sticky_blocks = div.content:walk {
traverse = 'topdown',
Block = function(block)
if is_sticky(block) then
block = shift_id_to_block(block)
block.classes:insert("sticky")
return block, false -- if a sticky element is found, don't process child blocks
else
return {}
end
end
}

-- make contents of narrative-col
narrative_blocks = {}
for _,block in ipairs(div.content) do
if not is_sticky(block) then
if is_new_trigger(block) then
table.insert(block.attr.classes, "narrative")
local new_trigger_block = wrap_block(block, {"trigger", "new-trigger"})
table.insert(narrative_blocks, new_trigger_block)
else
-- if the block can hold attributes, make it a narrative block
if block.attr ~= nil then
table.insert(block.attr.classes, "narrative")
else
-- if it can't (like a Para), wrap it in a Div that can
block = wrap_block(block, {"narrative"})
end

local not_new_trigger_block = wrap_block(block, {"trigger"})
table.insert(narrative_blocks, not_new_trigger_block)
end
end
end
-- make key components of cr-section
narrative_col = make_narrative_col(div.content)
sticky_col = make_sticky_col(div.content)

-- identify section layout
local section_layout = global_layout -- inherit from doc yaml
@@ -103,12 +70,6 @@ function make_section_layout(div)
end

-- piece together the cr-section
narrative_col = pandoc.Div(pandoc.Blocks(narrative_blocks),
pandoc.Attr("", {"narrative-col"}, {}))
sticky_col_stack = pandoc.Div(sticky_blocks,
pandoc.Attr("", {"sticky-col-stack"}))
sticky_col = pandoc.Div(sticky_col_stack,
pandoc.Attr("", {"sticky-col"}, {}))
cr_section = pandoc.Div({narrative_col, sticky_col},
pandoc.Attr("", {"column-screen",table.unpack(div.classes), section_layout}, {}))

@@ -117,6 +78,88 @@ function make_section_layout(div)
end


function make_sticky_col(cr_section_blocks)

sticky_blocks = cr_section_blocks:walk {
traverse = 'topdown',
Block = function(block)
if is_sticky(block) then
block = shift_id_to_block(block)
block.classes:insert("sticky")
return block, false -- if a sticky element is found, don't process child blocks
else
return {}
end
end
}

sticky_col_stack = pandoc.Div(sticky_blocks,
pandoc.Attr("", {"sticky-col-stack"}))
sticky_col = pandoc.Div(sticky_col_stack,
pandoc.Attr("", {"sticky-col"}, {}))

return sticky_col
end


function make_narrative_col(cr_section_blocks)

narrative_blocks = make_narrative_blocks(cr_section_blocks)
narrative_col = pandoc.Div(pandoc.Blocks(narrative_blocks),
pandoc.Attr("", {"narrative-col"}, {}))

return narrative_col
end


function make_narrative_blocks(cr_section_blocks)

local narrative_blocks = {}
-- iterate over top-level blocks
for _,block in ipairs(cr_section_blocks) do
if not is_sticky(block) then
-- if it's progress-block...
if block.attr ~= nil then
if block.attr.classes ~= nil then
if block.classes:includes("progress-block") then
-- re-run this function on child blocks
nested_narr_blocks = make_narrative_blocks(block.content)
progress_blocks = pandoc.Div(nested_narr_blocks,
pandoc.Attr("", {"progress-block"}, {}))
table.insert(narrative_blocks, progress_blocks)
goto endofloop
end
end
end

-- if it's a new trigger
if is_new_trigger(block) then
table.insert(block.attr.classes, "narrative")
local new_trigger_block = wrap_block(block, {"trigger", "new-trigger"})
table.insert(narrative_blocks, new_trigger_block)

--if it's not a new trigger
else
-- if the block can hold attributes, make it a narrative block
if block.attr ~= nil then
table.insert(block.attr.classes, "narrative")
else
-- if it can't (like a Para), wrap it in a Div that can
block = wrap_block(block, {"narrative"})
end
local not_new_trigger_block = wrap_block(block, {"trigger"})
table.insert(narrative_blocks, not_new_trigger_block)
end
end

::endofloop::
end

return pandoc.Blocks(narrative_blocks)
end



function shift_id_to_block(block)

-- if block contains inlines...
2 changes: 1 addition & 1 deletion docs/_publish.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
- source: project
netlify:
- id: 2a052dac-9a3d-43c1-9f3d-e37e3cb55af2
url: 'https://friendly-horse-215d6e.netlify.app'
url: 'https://closeread.netlify.app'
2 changes: 1 addition & 1 deletion docs/gallery/demos/minard-zoom/cr-tufte.css
Original file line number Diff line number Diff line change
@@ -60,7 +60,7 @@

#quarto-content .cr-section .narrative-col .trigger {
padding-top: 0;
padding-bottom: 65svh;
padding-bottom: 85svh;
}

/* A small subset of the official Tufte CSS styles */
2 changes: 2 additions & 0 deletions docs/gallery/demos/minard-zoom/index.qmd
Original file line number Diff line number Diff line change
@@ -6,6 +6,8 @@ format:
remove-header-space: true
---

# Mindard's Map

:::{.epigraph}
> [The Visual Display of Quantitative Information](https://www.edwardtufte.com/tufte/books_vdqi) by Edward Tufte is a seminal text in the field of data visualization. On page 40, the text walks the reader through the complexity of a graphic by Charles Minard that depicts Napoleon's disastrous military campaign in Russia. An excerpt from that chapter is printed below, with permission.
:::
152 changes: 102 additions & 50 deletions docs/gallery/demos/ojs-map/index.qmd
Original file line number Diff line number Diff line change
@@ -5,25 +5,11 @@ description: "Smoothly transition interactive OJS graphics."
format: closeread-html
---

Close read makes scrolling progress available to users as [Observable JavasScript](https://quarto.org/docs/interactive/ojs) variables, so you can create Close Read sections with interactive graphics that change as you scroll.
Closeread makes scrolling progress available to users as [Observable JavasScript](https://quarto.org/docs/interactive/ojs) variables, so you can create closeread sections with interactive graphics that change as you scroll.

The four variables are:

- `crStickyName`: the name of the active element
- `crTriggerIndex`: the index of the active element
- `crTriggerProgress`: progress of the active element from 0 to 1
- `crDirection`: either `"down"` or `"up"`, depending on the direction a user is scrolling

Let's see what we can do with these variables.

I have a list of cities around the world. I'd like to show them off to everyone on a globe, but I'll need to rotate the globe in order to show parts of it.

If we make a globe using [Observable Plot's `geo` mark](https://observablehq.com/@observablehq/plot-projections?collection=@observablehq/plot), we can change its `rotation` option to turn it. That could be linked to the clock if we wanted it to animate on its own, but we can also link it to Close Read's variables to make it spin as we scroll.

Before we start, let's define some cities. Here I've done it in OJS, but you could easily make an R or Python data frame available using `ojs_define()` (or even load a CSV from elsewhere):
Let's use this functionality to make a visualization of a globe. Before we start, let's define some cities that we'll plot on that glove. Here I've done it in OJS, but you could easily make an R or Python data frame available using `ojs_define()` (or load a CSV from elsewhere):

```{ojs}
//| label: cities
//| echo: true
//| code-fold: false
cities = [
@@ -41,58 +27,136 @@ cities = [
]
```

Now let's load in some land, so we can distinguish it from ocean:
Now let's load data that describes the shape of the continents.

```{ojs}
//| label: download-land
//| echo: true
world = FileAttachment("naturalearth-land-110m.geojson").json()
```

The cities above wrap the entire globe, so to view them all we'll need to be give the user the ability to spin the globe. We'll map the progress of the user's scroll, stored in a variable called `crProgressBlock`, to a variable called `angle`. The `scale.Linear` function handles the linear mapping of `crProgressBlock` going from 0 to 1 to `angle` going from -180 to 0.

```{ojs}
//| echo: true
//| code-fold: false
angleScale1 = d3.scaleLinear()
.domain([0, 1])
.range([-180, 0])
.clamp(true)
angle1 = angleScale1(crProgressBlock)
```

To see the OJS code that actually creates the globe, look into the source of this document. Here is the result:

::::{.cr-section layout="overlay-center"}

:::{focus-on="cr-map"}
We want our globe to rotate with the scroll progress — between -180 and 180.
:::
:::{.progress-block}
This interactive globe visualization starts at an angle of 0 - the International Date Line. @cr-globe1

It ends at an angle of 0: the prime median. @cr-globe1

:::{focus-on="cr-map"}
Instead of trying to do the maths to scale it ourselves, we can make a scale with d3.
:::

:::{focus-on="cr-map"}
There are six narrative blocks that we want to scale over, but I'd like the scrolling to start a little late and end a little early — by the time the last block has just started.
:::{#cr-globe1}

```{ojs}
//| echo: false
Plot.plot({
marks: [
Plot.graticule(),
Plot.geo(world, {
fill: "#222222"
}),
Plot.sphere(),
Plot.dot(cities, {
x: "lon",
y: "lat",
fill: "#eb343d",
stroke: "white",
strokeWidth: 5,
paintOrder: "stroke",
size: 6
}),
Plot.text(cities, {
x: d => d.lon + 2,
y: d => d.lat + 2,
text: "name",
fill: "#eb343d",
stroke: "white",
strokeWidth: 5,
paintOrder: "stroke",
fontSize: 18,
textAnchor: "start"
}),
],
projection: {
type: "orthographic",
rotate: [angle1, -10]
}
})
```

:::

:::{focus-on="cr-map"}
So between 0.5 (because the scroll starts with the first narrative block of the document) and 5.1. If the numbers go outside this range, we'll _clamp_ them so that the scrolling doesn't continue.
::::

:::{.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) Angle 1: ${angle1.toFixed(1)}°`
md`(derived) Angle 2: ${angle2.toFixed(1)}°`
```
:::

:::{focus-on="cr-map"}
As you back and forth over this closeread section, note the values of the all six ojs variables that closeread makes available in ojs code cells:

1. `crActiveSticky`: name of the active sticky
2. `crTriggerIndex`: index of the active trigger
3. `crTriggerProgress`: progress of the active trigger block from 0 to 1
4. `crDirection`: either `"down"` or `"up"`, depending on the direction a user is scrolling
5. `crProgressBlock`: progress of the active spanning progress block from 0 to 1

To demonstrate the use of other ojs variables, we'll recreate the spinning behavior by a more creative mapping of `crTriggerIndex` and `crTriggerProgress` to form `angle2`. [This second globe demonstrates some interesting behavior: `angle2` was actually changing as a result of the two triggers used in making the first globe. ]


::::{.cr-section layout="overlay-center"}

We want our globe to rotate with the scroll progress — between -180 and 180. @cr-globe2

Instead of trying to do the maths to scale it ourselves, we can make a scale with d3. @cr-globe2

There are six narrative blocks that we want to scale over, but I'd like the scrolling to start a little late and end a little early — by the time the last block has just started. @cr-globe2

So between 2.5 (because the scroll starts with the third trigger of the document) and 7.1. If the numbers go outside this range, we'll _clamp_ them so that the scrolling doesn't continue. @cr-globe2

:::{focus-on="cr-globe2"}
Here's how we create that scale and then use it with Closeread's variables, `crTriggerIndex` and `crScrollProgress`:

```{ojs}
//| label: angle-scale
//| echo: true
//| code-fold: false
angleScale = d3.scaleLinear()
.domain([0.5, 5.1])
angleScale2 = d3.scaleLinear()
.domain([2.5, 7.1])
.range([-180, 180])
.clamp(true)
angle = angleScale(
angle2 = angleScale2(
(crTriggerIndex != null ? crTriggerIndex : -1)
+ crTriggerProgress)
```
:::

:::{focus-on="cr-map"}
With all that done, we can see our map!
:::
With all that done, we can see our map! @cr-globe2

:::{#cr-map}
:::{#cr-globe2}

```{ojs}
//| label: map
Plot.plot({
marks: [
Plot.graticule(),
@@ -123,7 +187,7 @@ Plot.plot({
],
projection: {
type: "orthographic",
rotate: [angle, -10]
rotate: [angle2, -10]
}
})
```
@@ -132,18 +196,6 @@ Plot.plot({

::::

:::{.counter style="position: fixed; top: 10px; right: 10px; background-color: skyblue; border-radius: 5px; padding: 18px 18px 0 18px;"}
```{ojs}
md`Active sticky: ${crStickyName}`
md`Active trigger: ${crTriggerIndex}`
md`Trigger progress: ${(crTriggerProgress * 100).toFixed(1)}%`
md`Scroll direction: ${crDirection}`
md`Angle: ${angle.toFixed(1)}°`
```
:::

And that's all! Let's put some lorem ipsum in so that it can scroll all the way to the end.

:::{style="color: slategrey; font-style: italic;"}
Eu in culpa officia cupidatat nostrud laborum do consequat officia Lorem tempor consectetur pariatur sunt. Veniam culpa dolore laborum nostrud ipsum pariatur ipsum dolore consectetur commodo ex. Non culpa deserunt voluptate. Amet excepteur incididunt deserunt pariatur velit labore do sunt occaecat eiusmod. Tempor proident sint exercitation culpa incididunt sunt proident sunt reprehenderit. Sint ipsum qui id nisi quis officia in. Anim velit minim fugiat qui dolor enim occaecat amet excepteur do aliqua ex adipisicing laboris labore.

0 comments on commit a072936

Please sign in to comment.