(Old) hypertext in (new) hypertext

The Talmud is an early form of hypertext, with multiple texts and links between them. A daf (page) of Talmud has a unique layout. The Mishna and Gemara are in the center, with further discussion and commentary around it.

A daf of Talmud. Source: https://kottke.org/18/09/putting-the-talmud-online

A couple weeks ago, @emilyaviva@tech.lgbt posed the following challenge on Mastodon:

All right, tech folks, listen up. I want to make a web page that has a central div of text where two divs flow around it from top, sides, and bottom, just like @esther_alter@mastodon.social’s brilliant Talmudifier, but in CSS only. You’d think this would be possible, but after several hours of screwing around with floats and flexbox, I’m not sure it is. Any brilliant ideas?

Challenge accepted.

Challenge solved — almost. (It required a tiny bit of JavaScript.)

See the Pen Talmud daf layout by Noah (@noleli) on CodePen.

Let’s dig into how.

Semantics

First things first: semantics. The way I thought about it, the whole daf is an <article>. The Mishna/Gemara are the main body of the article (but gets wrapped in a <div> with a class to be unambiguous). Then, the commentary around it is an <aside>. Within the commentary, there are two distinct sections: the Rashi (set here in a sans serif font, following Talmudifier’s convention) is first. (Remember that while this text is faux Latin, Hebrew and Aramaic are written from right to left.) Then, the tosafot wrap around it. As they’re both part of the <aside>, the Rashi is also in a <div>.

So, the whole markup looks like this:

<article class="daf">
<div class="mishna"><p>The Mishna/Gemara text</p></div>
<aside>
<div class="rashi"><p>The Rashi text</p></div>
<p>The tosafot text</p>
</aside>
</article>

Since the original challenge referenced Talmudifier, I pasted in the lorem ipsum from there.

Layout

Now, let’s put things where they need to be. I used a simple grid layout with 3 rows and 3 columns. That might be a little counterintuitive because it looks at first like the commentary is in two columns, but it’s really not. We’ll handle that in a moment.

The center/Mishna text is in the center of the 3 × 3 grid, so the height of first row of the grid layout determines how high or low on the page it starts. Since the center text’s width is set by the width of the .mishna element and its height is intrinsic based on the length of the text, the grid looks like this:

.daf {
width: 80ch;
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-rows: 6rem auto 1fr;
}

Then the Mishna is placed in the center grid area, and the aside (commentary and Rashi) are put in the entire grid area (plus a bit of styling).

.mishna {
grid-area: 2 / 2 / 3 / 3;
font-weight: bold;
width: 25ch;
}

aside {
grid-area: 1 / 1 / -1 / -1;
}

.rashi {
font-family: sans-serif;
}

That leaves us with this delightful mess, in which the Rashi and tosafot are regular paragraphs and the Mishna is thrown on top:

Two paragraphs of text with a third paragraph stuck on top, narrow and in the center

Now let’s ignore the Mishna and shift gears to the <aside>. While it initially looks like a two-column layout at the top, if you look down below you can see that the tosafot wraps around the Rashi. So, this is really a straightforward floating layout in which the Rashi is floated to the right, with the tosafot wrapping around it. The main thing here is that the two “columns” are of equal width, and there’s a gap between the columns.

We’ll need to use the column gap value a bunch of times, so let’s add a custom property to the .daf:

.daf {
/* ... */
--column-gap: 1.125rem;
}

The Rashi needs to be approximately 50% of the width of its parent, but not exactly. To keep the two (apparent) columns the same width, the column gap needs to be split between the width of the tosafot and the width of the Rashi. Therefore, the width of the Rashi is 50% the width of its parent minus half of the column gap. The gap itself is applied as a left margin on the Rashi[1].

.rashi {
float: right;
font-family: sans-serif;
width: calc(50% - var(--column-gap)/2);
margin-left: var(--column-gap);
}

Hiding the Mishna/Gemara, we get this:

Rashi floated right in tosafot

And with the Mishna added back in,

layout without wrapping around mishna

Perfect! Except for that one big problem….

Wrapping around the Mishna

There’s no good way to wrap text around other things using CSS if you need anything more complicated than a simple float. This is a known shortcoming, and several attempts at solving it (and related problems) have been proposed over the years. Regions, Shapes Level 2, and — most promisingly here — Exclusions. Alas, we need to hack something now.

Floats are generally positioned inline with some text. That means that in order to have a float not be at the top of the text, but at a specific point along the block axis, you need to know exactly where to insert that float inline into your text’s markup. That’s not going to work here. You also can’t float something to the middle, or have multiple elements wrap around a single float.

So here’s what we’re going to do: we’re going to use JavaScript to insert two empty floating divs, size them appropriately, and “place” them by messing with their shape-outside. Why JavaScript? Two reasons, one required and one aesthetic: first, we need to measure the size of the Mishna container; and second, it lets us keep the input markup semantically clean.

Each of the two floated divs takes care of half of the mishna text. Let’s deal with the righthand side first. Here, the Rashi is our main block of text, so let’s add a div to the top of the Rashi element, give it a fixed size, and float it left.

const rashi = aside.querySelector(".rashi");
const rashiFloater = document.createElement("div");

rashiFloater.style = `
float: left;
width: 110px;
height: 130px;
`
;

rashi.prepend(rashiFloater);

At this arbitrary size, we get this (giving it a background color and again hiding the Mishna):

An empty div floated left in the Rashi

Now we need to set it to the right size. The width needs to be the distance from the center of the column break to the edge of the Mishna. In other words, it’s half the width of the Mishna minus half the width of the column gap. The height needs to take our floater from where it starts at the top of the daf to the bottom of the Mishna.

// get the mishna measurements
const mishnaRect = document.querySelector(".mishna").getBoundingClientRect();
// but we'll need to shift it into local rather than viewport coordinates
const { top: dafTop } = document.querySelector(".daf").getBoundingClientRect();

rashiFloater.style = `
float: left;
width: calc((
${mishnaRect.width}px - var(--column-gap))/2);
height:
${mishnaRect.height + mishnaRect.y - dafTop}px;
margin-right: var(--column-gap);
`
;

Add in a right margin (column gap), and now you can really see where this is going.

An empty div floated left in the Rashi that's sized to start at the top and perfectly clear the Mishna

So close! But we still need to not have the float punching out the Rashi all the way to the top of the daf. For that, we’ll use shape-outside. shape-outside lets you give the browser an effective shape (for wrapping purposes) for a floating element. Using the inset() shape function, we can inset the top starting point of the wrapping to the distance between the top of the daf and the top of the Mishna.

rashiFloater.style = `
float: left;
width: calc((
${mishnaRect.width}px - var(--column-gap))/2);
height:
${mishnaRect.height + mishnaRect.y - dafTop}px;
shape-outside: inset(
${mishnaRect.y - dafTop}px 0 0 0);
margin-right: var(--column-gap);
`
;

And that’s it for the right side! Remember that shape-outside doesn’t actually move or clip the floated element, as you can see with the background color, but the wrapping works exactly the way we need it to.

An empty div floated left in the Rashi where the Rashi wraps around the Mishna, both above and below, but the color of the div goes all the way to the top

The left side is basically the same. The main differences are where to put the floater in the DOM, and which direction to float it. The <aside> already has something floated right (the Rashi itself), so the empty floater needs to go after the Rashi. Remember from the bad old days before flex layouts that if you have multiple items floated in the same direction, they “pile up” so that the first floated item goes the farthest, and the next floated item runs into that. By putting the floater right after the Rashi, the Rashi is farthest right, and the floater is to the left of that.

const aside = document.querySelector("aside");

const tosafotFloater = document.createElement("div");
tosafotFloater.style = `
float: right;
width: calc((
${mishnaRect.width}px - var(--column-gap))/2);
height:
${mishnaRect.height + mishnaRect.y - dafTop}px;
shape-outside: inset(
${mishnaRect.y - dafTop}px 0 0 0);
margin-left: var(--column-gap);
`
;

rashi.after(tosafotFloater);

Both left and right floats with the Rashi and Tosafot wrapping around them with background colors

That’s the whole thing! Get rid of the background colors from the floats and here’s what we’ve got. Success!

A completed page of lorem ipsum layed out like a daf of talmud


  1. You’d think this would be a perfect place to use logical properties, what with Hebrew and Aramaic being right-to-left languages. However, the reference image from Talmudifier has English text laid out in the same orientation as the original RTL pages, so using absolute directions seemed to be the way to go. ↩︎

A brief history of מה אשיב/Ma Ashiv

Over Pesach there was some debate during Hallel over the time signature of the beginning of a melody to מה אשיב/Ma Ashiv. Some people do the beginning in 3/4 time, and some do it in 4/4.

I had to find out who was right (though I was pretty sure it was 4/4 because that’s consistent with the rest of the song), so did a bit of digging. Here’s what I was able to find.

It was composed by Aviezer Wolfson and first recorded by Leibele Haschel. I couldn’t find the year or full recording, but FAU has a 45 second sample available. There seems to have been a rerelease of that album in 2007, so here are other 30 second samples (jump to 30 seconds in, because it’s track 2 in one concatenated file). In terms of the exact year, it must have been after 1978 because the liner notes say (if I’m correctly making out extremely blurry text) that the arranger, Moshe Laufer, was named composer of the year (by…someone) in 1978.

Album cover: Leibale Haschel, מה אשיב לה׳, Music by Freilach Orchestra, Songs composed by Aviezer Wolfson and Moshe Laufer

The songs seems to have had its breakout moment when Morechai Ben David recorded it in 1987 on a compilation album with Avraham Fried, The Piamentas, and Dov Levine. It’s not available on YouTube or Apple Music, but it is on Spotify.

Album cover: Suki & Ding Present: Morechai Ben David, Avraham Fried, The Piamentas, Dov Levine. Hallel, featuring מה אשיב. Original recordings musically and digitally enhanced.

MBD has rerecorded it at least twice since, once in 2002 (Kumzits), and one other time (on an album that contains such titles as “Beethoven In Birdland”, “The Malach Piano Concerto In C”, and “The Bach Suite #2 In B Minor”).

All of these versions were in 4/4.

I did find one recording that starts in 3/4, which is an instrumental/classical guitar arrangement played by a guy who also happens to be a small plane pilot.

Simplenote to Bear importer

With Bear 2 seemingly around the corner and the beta being great (though still very much a beta), I decided it was time to move from Simplenote.

Exporting from Simplenote gives you two things, neither of which is directly usable by Bear:

What I needed for Bear was a file for each note with correct creation and modification dates and tags at the end as hashtags. So I wrote a quick Python script that reads in the JSON file and — with some help from SetFile — outputs a file in the format I needed.

from subprocess import run
import os
import json
from datetime import datetime

# path to input JSON
in_path = os.path.join(os.environ['HOME'], 'Downloads/notes/source/notes.json')

# path to output directory. it must exist.
out_path = os.path.join(os.environ['HOME'], 'Downloads/for-bear')

# read the data
with open(in_path) as f:
note_data = json.load(f)

datetime_format = '%m/%d/%Y %H:%M:%S.%f'
def create_note_file(datum):
# the note text
content = datum['content']

# if there are tags, append them as space-separated hashtags at the end of the text
if 'tags' in datum:
tags_string = '\n\n'
tags_string += ' '.join(['#' + tag for tag in datum['tags']])
content += tags_string

# convert times to local timezone (Simplenote is in UTC) and format for SetFile
creation_date = datetime.fromisoformat(datum['creationDate']).astimezone()
creation_date_str = creation_date.strftime(datetime_format)

modification_date = datetime.fromisoformat(datum['lastModified']).astimezone()
modification_date_str = modification_date.strftime(datetime_format)

# write out the file
outfile_path = os.path.join(out_path, datum['id'] + '.md')
with open(outfile_path, 'w+') as outfile:
outfile.write(content)

# set the creation and modification dates
run(['SetFile', '-d', creation_date_str, outfile_path])
run(['SetFile', '-m', modification_date_str, outfile_path])

# iterate through the notes
for note in note_data['activeNotes']:
create_note_file(note)

Note: Using datetime.fromisoformat() with the date format Simplenote uses requires Python 3.11+.

After running the script, import them into Bear with these settings:

Possible improvements

It would be nice to \ escape all incidental hashtags, but it wouldn’t be trivial. For example, you shouldn’t escape # followed by a non-space character inside a Markdown `#code block`.

Here’s a Gist.

SVG-free graphs with D3 and CSS layouts

I love D3, but much of it was designed for SVG. When drawing a data visualization with SVG, you need to tell it exactly where and how big each item needs to be. That’s why D3 has layout utilities like scaleBand.bandwidth and stack(), and why you need to explicitly handle resizing. And when using D3 axis, it directly draws its own SVG.

But you don’t have to use SVG. You can take advantage of CSS’s grid and flex layouts to create many types of visualizations. I recently made an SVG-free bar chart that uses CSS to lay itself out as its container is resized and as data is added and removed — all without calculating any sizes using JavaScript.

See the Pen SVG-free bar graph with CSS layouts and D3 by Noah (@noleli) on CodePen.

So let’s make a flexible bar graph with axes, axis labels, bars, and bar labels without using SVG.

The markup and styling

Layout

Here’s the overall layout we’re going for.

Graph layout with an area for the y axis label, y axis, x axis label, x axis, and a space for the graph itself to go

We can achieve this with a basic grid layout.

<div class="graph" id="graphContainer">
<div class="axis-label x-axis-label">x axis label</div>
<div class="axis x-axis">x axis</div>
<div class="axis-label y-axis-label">y axis label</div>
<div class="axis y-axis">y axis</div>
<div class="graph-area">graph area</div>
</div>

The axes and axis labels should take up only as much space as they need, while the area for the graph itself takes up the rest of the place. That means giving axis and axis label rows and columns a dimension of auto, and letting the graph area take up the rest of the space with 1fr.

.graph {
display: grid;
grid-template-columns: auto auto 1fr;
grid-template-rows: 1fr auto auto;
grid-template-areas:
"y-axis-label y-axis graph-area"
". . x-axis"
". . x-axis-label";
}

(As an aside, I normally don’t reach for grid-template-areas, preferring to use grid-area: <start-row> / <start-col> / <end-row> / <end-col>, but in this case I think it’s nicely illustrative.)

We can now assign each of the graph elements to its respective grid area.

.graph .x-axis-label {
grid-area: x-axis-label;
}

.graph .x-axis {
grid-area: x-axis;
}

.graph .y-axis-label {
grid-area: y-axis-label;
}

.graph .y-axis {
grid-area: y-axis;
}

.graph .graph-area {
grid-area: graph-area;
aspect-ratio: 5 / 4;
}

Note that I gave the graph-area an aspect ratio of 5:4. Since, as basically a block element, the inline size (a.k.a. width in left-to-right and right-to-left languages) of the .graph element is determined by its container, we can set the aspect ratio to ensure that it has a nicely proportional block size (a.k.a. height). The exact value is a matter of taste, but 5:4 works for me. Depending on your needs, you might size the container differently.

Axes

Now that we have our skeleton, let’s quickly take care of the axis labels before moving on to the axes themselves. I’m going to make them bold and align them center, and also add a bit of margin between the labels and the axes.

.graph .axis-label {
--margin: 8px;
font-weight: bold;
text-align: center;
}

.graph .x-axis-label {
grid-area: x-axis-label;
margin-top: var(--margin);
}

Then, I’m going to rotate the y axis label so it’s vertical. Sadly, only Firefox supports writing-mode: sideways-lr, so we’re stuck with

.graph .y-axis-label {
grid-area: y-axis-label;
margin-block-end: var(--margin);
writing-mode: vertical-lr;
transform: rotate(180deg);
}

That gives us:

Graph skeleton with axis labels

Now for the axes themselves, I’m going to make an important assumption: all ticks are evenly spaced. That means that what I’m about to do won’t work if you want to show certain specific ticks or are using a nonlinear (e.g., log) axis scale.

With that stated, let’s add some ticks to our markup. Later on, we’ll have D3 take care of this, but let’s do it manually for now so we can get them laid out and styled.

<div class="axis x-axis">
<div class="tick">Category 1</div>
<div class="tick">Category 2</div>
<div class="tick">Category 3</div>
<div class="tick">Category 4</div>
</div>
<div class="axis y-axis">
<div class="tick">0</div>
<div class="tick">100</div>
<div class="tick">200</div>
<div class="tick">300</div>
<div class="tick">400</div>
<div class="tick">500</div>
<div class="tick">600</div>
<div class="tick">700</div>
<div class="tick">800</div>
</div>

Because this is a bar graph, meaning that the x values are categorical and the y values are continuous, we’re going to treat the x and y axes somewhat differently. But we know they’ll have some things in common, so we can set those things up once.

To begin with, each axis is going to be a flex layout, but they get different flex directions. Similarly, both will have the same border, but they’re oriented differently, so the border will be along different edges, so all we can do is define a custom property to be assigned later.

.graph .axis {
display: flex;
font-size: .8rem;
--border: 1px solid black;
}

The ticks themselves will also have many things in common, like font size, tick size, and tick color. We’ll also put a small gap between the tick label and the tick line.

.graph .tick {
--tick-lengeth: 10px;
--tick-thickness: 1px;
--tick-color: darkgray;

display: flex;
align-items: center;
gap: 2px;
white-space: nowrap;
}

Now, for the the y axis, let’s lay the ticks out vertically, starting at the bottom:

.graph .y-axis {
grid-area: y-axis;
flex-direction: column-reverse;
justify-content: space-between;
align-items: end;
border-inline-end: var(--border);
}

Let’s also add some tick marks to the ticks themselves:

.graph .y-axis .tick::after {
content: "";
height: var(--tick-thickness);
width: var(--tick-lengeth);
background-color: var(--tick-color);
}

That gives us this:

Work-in-progress screenshot where the y axis looks mostly correct, but the height of the tick text is affecting where the ticks themselves are.

But there’s a problem! The flex layout is taking into account the height of the tick labels themselves. To fix it, we have to give the tick a height of 0. That way, from a layout perspective a tick can be placed exactly where it’s supposed to be, but the tick content will happily overflow its 0-height container and look fine.

.graph .y-axis .tick {
height: 0;
}

The x axis is similar, but being categorical, it’s typical to not have the bars go edge to edge like space-between; rather, they get laid out with space-around.

Graph with x and y axes and labels but no actual graph content

.graph .x-axis {
grid-area: x-axis;
justify-content: space-around;
border-top: var(--border);
}

.graph .x-axis .tick {
width: 0;
flex-direction: column;
text-align: center;
}

.graph .x-axis .tick::before {
content: "";
width: var(--tick-thickness);
height: var(--tick-lengeth);
background-color: var(--tick-color);
}

One other thing with the x axis is that when there’s a lot of data (or the graph is small) the tick labels can overlap. That isn’t always the case, but we can add an option to let someone turn the labels vertical by setting data-orientation="vertical" on the axis element.

.graph .x-axis[data-orientation=vertical] .tick {
writing-mode: vertical-lr;
flex-direction: row-reverse;
transform: rotate(180deg);
}

Empty graph with axes like above, but with vertical x axis ticks

Neat.

Graph data

How about we add some data to our graph, eh?

Each data point is going to be an element inside the .graph-area with two children: a bar and a value label (optional):

<div class="datum">
<div class="value">336</div>
<div class="bar" style="height: 42%"></div>
</div>

Thinking a bit about the width and horizontal position of each data point, when there’s only a small number of data points, they should use the same space-around justification as the x axis labels (the data had better line up with the labels!), but should only grow to be so wide when there aren’t that many of them (we don’t want bars the width of the graph). But when there is a lot of data, the bars need to get narrower so more of them can fit.

To do this, we can have a grid create as many columns as there are data points, letting them get arbitrarily (well, 1 pixel) narrow, but only allowed to grow to 25% of the graph area. (Because there are no other columns, the remaining width and the total width are the same. That’s why .25fr is 25% of the total width.)

.graph .graph-area {
grid-area: graph-area;
aspect-ratio: 5 / 4;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(1px, .25fr));
justify-content: space-around;
}

Remember from the markup above that each .datum has two children: a .value, where the text value goes (though this is optional), and a .bar. The value sits on top of the bar, so let’s use a column flex layout for the .datums (data?). Notice that each grid column in .graph-area is the full height of the graph area. That means that each .datum will also be the full height of the graph area. So, we can use justify-content: end to send the bar and value label to the bottom.

I also added a bit of inline margin here. This is what makes sure there’s space between the bars even when there are a lot of them. I used margin instead of a grid gap for a couple reasons. First, by keeping the spacing as part of each .datum rather than part of the layout we don’t have to compensate for it in the axis; everything stays nicely lined up. Second, we can use a percent value, which means that as you add more bars, the space gets narrower. This uses space more efficiently and feels more natural to me.

.graph .datum {
margin-inline: 5%;
display: flex;
flex-direction: column;
justify-content: end;
}

For the bar itself, you probably noticed in the .datum markup that its height is set on each bar. Beyond that, the bars get two additional styles. A color, of course (although that could also be set based on the data), and, importantly, flex-shrink: 0. We need that because if a bar gets tall enough that the value text hits the top of the graph area, by default the browser will squish the bar so the text fits. We don’t want that because the height represents a specific numeric value.

.graph .datum .bar {
flex-shrink: 0;
background-color: rebeccapurple;
}

The value itself is pretty simple: center it with the datum. I also gave it a z index so that if the bars get close enough, and the value gets wide enough, the value will be on top of the adjacent bars. That said, if you find yourself in this situation, I’d recommend hiding the value label except when a bar is hovered over.

.graph .datum .value {
align-self: center;
z-index: 1;
}

Value text overlapping adjacent bars

That’s it for the HTML and CSS. This markup:

<div class="graph" id="graphContainer">
<div class="axis-label x-axis-label">x axis label</div>
<div class="axis x-axis">
<div class="tick">Category 1</div>
<div class="tick">Category 2</div>
<div class="tick">Category 3</div>
<div class="tick">Category 4</div>
</div>
<div class="axis-label y-axis-label">y axis label</div>
<div class="axis y-axis">
<div class="tick">0</div>
<div class="tick">100</div>
<div class="tick">200</div>
<div class="tick">300</div>
<div class="tick">400</div>
<div class="tick">500</div>
<div class="tick">600</div>
<div class="tick">700</div>
<div class="tick">800</div>
</div>
<div class="graph-area">
<div class="datum">
<div class="value">200</div>
<div class="bar" style="height: 25%"></div>
</div>
<div class="datum">
<div class="value">400</div>
<div class="bar" style="height: 50%"></div>
</div>
<div class="datum">
<div class="value">600</div>
<div class="bar" style="height: 75%"></div>
</div>
<div class="datum">
<div class="value">800</div>
<div class="bar" style="height: 100%"></div>
</div>
</div>
</div>

will produce this graph:

A bar graph with 4 bars at 200, 400, 600, and 800, with values on top, axes with labels, and ticks

🎉

The JavaScript

Did someone say D3? Ah yes, that was me a long time ago.

People think of D3 as a data visualization library, but that’s not quite right. It’s a library for binding data to DOM elements and manipulating those elements based on the data — and it happens to have a ton of utilities to help with visualization.

The class

So let’s build a JavaScript class that will build the DOM structure described above based on data.

To do this, we need a bit of D3. Not all of it, but we can import just the bits we need at the top. You’ll see how these all get used soon.

import { select, selectAll } from "d3-selection";
import { scaleLinear } from "d3-scale";
import { max } from "d3-array";

I like the pattern of passing a container element and an object of optional options into a class constructor, then doing everything else from inside the class. Here’s our basic class definition:

class BarGraph {
constructor(container, options) {
this.container = select(container);
}
}

Continuing in the constructor, the options object will handle axis labels and orientation, with some logical defaults. If an axis label is omitted, it’s set to an empty string. This is great, because if you don’t want a label, there simply won’t be one. This is a nice advantage over SVG: since the browser automatically sizes the axis label containers based on their content, if that content is "" it essentially collapses out of the layout. Compare this to SVG, where the idiomatic D3 way of leaving room for things like axis labels is manually adding some space to a margin = { top, right, bottom, left } object.

this.xAxisLabel = options.xAxisLabel ?? "";
this.yAxisLabel = options.yAxisLabel ?? "";
this.xAxisOrientation = options.xAxisOrientation ?? "horizontal";

Then, our constructor builds the DOM. It gives the container a class of graph, creates the DOM tree, and applies the three options we just set up. It also assigns the three elements whose content will be dynamically built — xAxis, yAxis, and graphArea — to instance fields.

this.container.classed("graph", true);
this.container.html(`
<div class="axis-label x-axis-label">
${this.xAxisLabel}</div>
<div class="axis x-axis" data-orientation=
${this.xAxisOrientation}></div>
<div class="axis-label y-axis-label">
${this.yAxisLabel}</div>
<div class="axis y-axis"></div>
<div class="graph-area"></div>
`
);
this.xAxis = this.container.select(".x-axis");
this.yAxis = this.container.select(".y-axis");
this.graphArea = this.container.select(".graph-area");

Note that with this approach, you can’t change the options later. We could easily change this by applying the options in the update() method I’ll describe below instead of here in the constructor.

Remember that the height of each bar is determined by a percent. The last thing the constructor does is create the only D3 scale we need: a scale that will map the values of our data to 0–100%.

this.y = scaleLinear().range([0, 100]);

Next, let’s get some data. Our class will provide a getter and setter for the data.

get data() {
return this._data;
}
set data(d) {
this._data = d;
this.y.domain([0, max(this.data, d => d.value)]).nice();
this.update();
}

The getter is simple enough: just return the data itself. The setter sets the data, of course, then does a couple more things. It sets the domain of our y scale, and it calls the (yet-to-be-defined) update() method.

There are two important things about setting the scale domain that I want to call out. First, rather than using d3-array’s extent(), it goes from 0 to max because you should make good bar charts. Second, notice the call to nice() at the end. This is because we don’t necessarily want the top of the y axis to be the exact maximum value of our data; we want it to be a nice round number. That’s exactly what nice() does for us.

Finally, we get to the heart of any D3 application: the update() method. But first, let’s state what the data has to look like. It’ll be an array of objects, and each object has (at least) a category and value field.

const data = [
{ category: "Category 1", value: 200 },
{ category: "Category 2", value: 400 },
{ category: "Category 3", value: 600 },
{ category: "Category 4", value: 800 }
];

Now that we’ve clarified that, let’s get into update(). It needs to do three things: set up the x axis, set up the y axis, and draw the bars.

For the x axis, we know we need a .tick for each value in our data array.

this.xAxis.selectAll(".tick").data(this.data, d => d.category)
.join("div")
.attr("class", "tick")
.text(d => d.category);

If you’re not used to thinking with joins that might be a little confusing. Basically, this selects all elements matching .tick in .x-axis. (To start, there should be none.) It then binds the data to them with the data() method. The second parameter to data() is a key so that it knows which data point is which should the data update later on. It then uses the join() method, which is a handy D3 shorthand for treating the enter and update selections the same. What that means is that for each row of data we’ll end up with a <div> with the class tick and text equal to the category — exactly what we want for the x axis.

The y axis is similar, but the data isn’t the data we’re trying to graph; we want a list of ticks. For that, we’ll rely on the D3 scale’s ticks() method. It takes an optional argument for the number of ticks, so we could be clever and set it based on the height of the container, but we can just stick with the default, which is 10. So this

this.y.ticks()

returns a list of 10 nice, evenly spaced ticks across the y scale’s domain. From there, we can have it add those ticks to the DOM the same way we did for the x axis:

this.yAxis.selectAll(".tick").data(this.y.ticks())
.join("div")
.attr("class", "tick")
.text(d => d);

Finally — finally! — we can actually graph our data. Because each .datum element has two children, it’s going to be a little different than how we did the x ticks. Rather than using the join() shorthand, we need to handle the enter and update selections separately. Specifically, when a new data point shows up (the enter selection), we need to create the .datum and its two children, .value and .bar. Then, for all data points, whether new or not (a merged enter and update selection), we want to update the value and bar height.

let datums = this.graphArea.selectAll(".datum").data(this.data, d => d.category);
const datumsEnter = datums.enter().append("div").attr("class", "datum");
datumsEnter.append("div").attr("class", "value");
datumsEnter.append("div").attr("class", "bar");
datums = datums.merge(datumsEnter);
datums.select(".value").text(d => d.value);
datums.select(".bar").style("height", d => `${this.y(d.value)}%`);

Using it

Now we can use our new class with some data. Let’s say we want to graph the heights of the world’s tallest buildings:

const data = [
{ building: "Burj Khalifa", height: 828 },
{ building: "Merdeka 118", height: 678.9 },
{ building: "Shanghai Tower",height: 632 },
{ building: "Abraj Al-Bait Clock Tower", height: 601 },
{ building: "Ping An International Finance Centre", height: 599.1 }
];

In order to use it, we need to map it to the right format, where the keys are category and value.

const formattedData = data.map(d => ({ category: d.building, value: d.height }));

Now all we need to to is instantiate a BarGraph object with the options we want and set the data.

const myGraph = new BarGraph("#buildings", {
xAxisLabel: "Building",
yAxisLabel: "Height (m)",
xAxisOrientation: "vertical"
});
myGraph.data = formattedData;

And that’s it! <div id="buildings"></div> now looks like this.

The finished graph of building heights

As always, the full demo is on CodePen.

🚇 CTA ‘L’ sign CSS

CSS has gotten to the point where things that, a few years ago, I would have used SVG and JavaScript for I can now do in just HTML and CSS. Being able to rely on the browser for layout, plus :has(), make things so much more flexible and responsive.

Last night I put together a quick demo of a Chicago Transit Authority ‘L’ sign generator.

See the Pen CTA ‘L’ sign CSS by Noah (@noleli) on CodePen.

There are a few things I like about this:

  1. The entire sign is sized based on the font size. aspect-ratio, combined with fr and em units, means that you set the font size, and the whole rest of the sign — its overall aspect ratio and the ratio between the colored bars and the rest of the sign — is proportionally correct.
    .sign {
    font-size: clamp(1.30rem, calc(-0.05rem + 6.73vw), 5.00rem);
    height: 1.8em;
    aspect-ratio: 8;
    grid-template-columns: 1fr 4fr 1fr;
    }
  2. The train line color stripes are automatically laid out by a grid layout. Being able to add and remove stripes and have the rest of them adjust is very handy.
  3. As many others have noted, the :has() selector lets you easily hook up checkboxes and radio buttons to other parts of the DOM. It’s new, so it’s neat :)

Most of this isn’t exactly new, but when it all comes together it can be quite powerful.

Reminiscing about audio formats

The sound podcast Twenty Thousand Hertz asked people to share their memories about old audio formats. I doubt they’ll use mine on the show, but here’s what I shared.

Growing up in my dad’s recording studio in the 1990s I was exposed to a variety of audio formats. There was 24-track 2-inch tape, and 1/4” reel-to-reel tape that radio stations wanted commercials on until nearly the turn of the century[1] when they switched to DAT.

Via YouTube from user @FabricioBizu

But the most unique format was the DTRS format used by the Tascam DA-88. It used Hi8 video cassettes to record 8 tracks of digital audio. My dad linked 3 of them together for 24 tracks. As a kid, those machines were cool because when you turned them on they were programmed to marquee the word TASCAM across the 8 LED meters, and that was just fun to watch.


  1. I remember my dad using a razor blade to splice in leader even after he was editing and mixing in Pro Tools. ↩︎

Call audio update: Audio Hijack

With Audio Hijack 4, Rouge Amoeba added the ability to manually edit connections. I’ve been an occasional Audio Hijack user since 2004[1], but I knew right away that this had the potential to bring it into daily use.

As I wrote a couple years ago, I had been using a DAW, Reaper, to route and process my everyday call audio, but that seemed heavy, and definitely not what it was designed for.

Instead of Reaper, I set up Audio Hijack. As with before, I’m using BlackHole to create two virtual audio devices: a 2-channel device I use as the call app’s mic, and a 16-channel device I use as the call app’s speaker. Then, Audio Hijack looks like this:

An Audio Hijack 4 session that routes and processes my call audio. It's a series of blocks and connecting lines that I walk through in detail in the rest of this post.

It looks like a lot, but talking through it from top to bottom, left to right:

That’s it for mic routing, but there’s one more thing in the Audio Hijack session. Sharing application audio with a call can be tricky and unreliable (“Can y’all hear this?”), so I keep a block around that I can turn on to capture application audio and send it to both my ears and the call. If I can hear it, they can hear it. Plus, I didn’t have to un-share and re-share my screen to make it happen.

One other thing I’ve started doing is using a global shortcut to mute myself. Usually when I’m talking in a meeting, the call app isn’t in the foreground, and I like to be able to quickly toggle it because (let’s be honest), with a mic in my face, I worry about mouth sounds. (Unless you enable Original Sound in Zoom or a similar feature in other apps, this probably isn’t a huge concern, but still….)

Screenshot of MuteKey in my menubar with its settings open, showing that it's unmuted and other settings I'm about to describe

There are a gazillion of these little apps out there. They all seem just a little scammy. I’d like to use Mic Drop, but it doesn’t support muting only a single device at a time. I ultimately settled on MuteKey. It’s not perfect: I need to quit and relaunch it after the computer sleeps and/or it’s unplugged from the selected device, and sometimes after quitting while muted it turns down the input level, so I need to use SoundSource or Audio MIDI Setup to turn it back up. But it gets the job done. I use the shortcut Space because I can easily use it one handed with either hand — very handy for pointing with a mouse while talking on a call.

One of the reasons I like being able to hear myself in my headphones is that when the USB interface is muted with this app, I can’t hear myself either. So if I can’t hear myself, neither can the call, and if I can hear myself, the call can hear me, too.

So there’s the latest on my call audio setup. Let me know if you have any questions or suggestions on how to sound even better!


  1. In college I set Audio Hijack to automatically start and record the Real Audio (remember that‽) stream of Morning Edition on Michigan Radio from 05:00–07:00, then put it on my iPod to listen to throughout the day. Yep, I invented podcasting. ↩︎

Capitalism and the Big Bang

I finally read that Why the Super Rich are Inevitable piece that’s been going around. It reminds me a lot of something I’ve thought about for years:

The economy is like the early universe shortly after the Big Bang. You might think of matter as being uniformly distributed, but in reality small, random perturbations meant that some regions of space ended up with more matter than others. Over billions of years, the effect of gravity is that some places will have collected all the matter from surrounding areas.

Map of Cosmic Microwave Background radiation

The detailed, all-sky picture of the infant universe created from nine years of WMAP data. The image reveals 13.77 billion year old temperature fluctuations (shown as color differences) that correspond to the seeds that grew to become the galaxies. The signal from our galaxy was subtracted using the multi-frequency data. This image shows a temperature range of ± 200 microKelvin.

NASA / WMAP Science Team

The natural result of the Big Bang is that a relatively few areas of space will be extremely densely filled with matter (e.g., stars), while most of the universe will be almost entirely empty (e.g., intergalactic space).

As the yard sale model demonstrates, wealth distribution in a market economy is similar, with the rich getting richer by drawing capital from the less wealthy people around them until there are just a handful of oligarchs and a lot of poor people.

∿ Waves! Part 2: plucky underline

A few weeks ago I talked about the bottom of the header that gets wavy when you scroll. Now I’m going to talk about the main navigation links that “pluck” when you hover over them.

These underlines are examples of standing waves, waves with nodes and antinodes (peaks) that are fixed in space. For example, here is a standing wave with a period of 2 seconds.

Animation of standing wave in the stationary medium with marked wave nodes
Lucas Vieira, Public domain, via Wikimedia Commons

The only thing that changes is the amplitude of the wave, so I started out by creating a JavaScript object that generates an SVG path of a sine wave. You initially configure it with the number of half-periods you want, then, using a simple setter, it updates the amplitude every time you change the amp property.

set amp(newAmp) {
this._amp = newAmp;
this.path.setAttribute("d", this.generateWavePath());
}
get amp() {
return this._amp;
}

I implemented the the Bézier approximation of a sine wave used in the Wikimedia Harmonic partials on strings file[1].

One of the great things about GSAP is that it can tween any property of an object. In this case, I needed to tween amplitude values. The initial “pulling” of the line (when hovered) was easy: I picked an easing that looked natural and used GSAP to tween to the maximum amplitude value.

pull() {
gsap.killTweensOf(this);

// gsap is going to tween the amplitude of this Plucky object
gsap.to(this, { amp: this.maxAmp, ease: "power2.out", duration: this.pullDuration });
}

Now when you pull() the underline, it animates from whatever the current amplitude is to the configured maximum amplitude (I’m using 10px).

The release and subsequent decay was trickier because I wanted it to dampen and decay (what’s technically known as “jiggle-jiggle”) in a physically natural way. To do this, I needed an easing curve based on a damped sinusoid, as described in the Wikipedia Damping article:

Exponentially decaying cosine function
y(t)=etcos(2πt) y(t) = e^{-t}\cos(2\pi t) Nicoguaro, CC BY 4.0, via Wikimedia Commons

So let’s talk about easing functions. An easing function, like those from cubic-bezier.com or the GSAP ease visualizer, describes how a value changes over time, with both inputs and outputs to that function normalized to the range 0–1. As time progresses from 0 to 1, the value progresses from its start value (denoted 0) to a target value (denoted 1).

In this case, the goal is to tween from an amplitude of maxAmp (say, 10) to an amplitude of 0 (a flat line). The trick here is that overshooting the “end” of the ease (at which the easing function outputs 1 and our amplitude is 0) will result in a value that’s past the target of the ease. For us, that’s a negative amplitude. Negative amplitude means amplitude in the other direction, so what had been above the 0 line is now below. That’s exactly what we want to happen, as you can see in the standing wave animation at the top of this post.

I wanted to use the damped cosine above — or something like it — as a GSAP easing function. At this point, a normal person would have splurged on CustomWiggle, but that’s not what I did. Instead, it meant doing a few things: altering the function so its output value starts at 0 and ends at 1, setting a parameter that makes it settle down at 1 at time 1, setting a parameter for how many times it should jiggle before settling down, and transforming it into an SVG path string so GSAP can accept it as a custom easing function.

Looking at the plot above, we can see that it starts at y=1y = 1 and settles at y=0y = 0. Multiplying by -1 will flip it so it starts at y=1y = -1 and ends at y=0y = 0. Adding 1 to the whole thing will make it start at y=0y = 0 and end at y=1y = 1. So now our function is

y(t)=etcos(2πt)+1. y(t) = -e^{-t}\cos(2\pi t) + 1.

Next we need to parameterize it. There are two parameters we care about: bb, the damping coefficient, which determines how quickly the envelope approaches its asymptote[2]; and ff, the frequency, which determines the number of jiggles before it settles down[3].

y(t)=ebtcos(2πft)+1 y(t) = -e^{-bt}\cos(2\pi ft) + 1

For us, how to set ff is a matter of aesthetics, but for simplicity it’s nice for bb to be set so that when time t=1t = 1, it has just about settled down. (GSAP’s CustomEase will normalize any input to 0–1, but it was easier for me to think about if I did it this way.) Playing around with it in a graphing calculator a bit told me that b=5b = 5 gives the desired result. I then picked f=8f = 8 because I liked how the result looked when used as an easing function.

So our final easing function becomes:

y(t)=e5tcos(2π8t)+1 y(t) = -e^{-5t}\cos(2\pi 8t) + 1

The last step in turning this function into a custom easing curve was converting it into an SVG path string. I used more or less the same approach as I did for the wavy header: I sampled the function and ran the resulting values through d3-shape’s line() generator.

I probably over-engineered how I sampled the function, especially in how I came up with the stopping condition. I tried to be clever and only sample at/near[4] peaks and zero-crossings and to stop when peaks were no higher than 0.01. It works, though it makes it a bit hard to read.

calcDampCurve() {
// b: dampening coefficient. higher numbers dampen more quickly.
// fixing it at 5 has it reaching ~.01 by t = 1
// adjusting the decayFreq and releaseDuration allows for full flexibility
const b = 5;
const decayFn = t => -1 * (Math.E ** (-b * t)) * Math.cos(2 * Math.PI * this.decayFreq * t) + 1;

const samplesPerWavelength = 8;
const sampleRate = samplesPerWavelength * this.decayFreq;

let sampling = true;
let stopNextZero = false;
const samples = [];
let T = 0;
while(sampling) {
const t = T/sampleRate;
const y = decayFn(t);
samples.push([t, -y]);

if(T % samplesPerWavelength/2 === 0) { // near a local extreme
if(Math.abs(y - 1) < .01) {
stopNextZero = true;
}
}
if(stopNextZero && (T % samplesPerWavelength === 2 || T % samplesPerWavelength === 6)) { // at a zero crossing
sampling = false;
}
else {
T += 1;
}
}
// use d3-shape to turn the points into a path string for gsap
return line().curve(curveNatural)(samples);
}

We can drop the resulting SVG string into the GSAP Custom Ease Visualizer to check it, and it works!

Screenshot of the custom ease visualizer showing the desired damped cosine curve

Finally, we can apply this easing function to a GSAP tween when “releasing” the underline:

this.dampCurve = this.calcDampCurve();
if(!CustomEase.get("damped")) CustomEase.create("damped", this.dampCurve);

release() {
gsap.to(this, { amp: 0, ease: "damped", duration: this.releaseDuration });
}

Then hook up the pulling and releasing functions to the appropriate mouse events and that’s just about it.

this.container.addEventListener("mouseover", this.pull);
this.container.addEventListener("mouseout", this.release);

As with before, there were a few other things to take care of before calling it done, like respecting someone’s prefers-reduced-motion setting. In terms of progressive enhancement, I should point out that I made sure that the default link underline isn’t removed until the relevant JavaScript loads so that there’s always an appropriate affordance.

So there you go! That’s how I did my plucky nav links.

And as with before, be sure to check out the whole thing on CodePen.


  1. I later found that the d3-shape curveNatural generates an essentially identical curve. ↩︎

  2. This kind of corresponds to CustomWiggle’s amplitudeEase. ↩︎

  3. This kind of corresponds to CustomWiggle’s timingEase. ↩︎

  4. The peaks and 0 crossings aren’t exactly where they’d be if it were a pure (co)sine wave because changing the amplitude envelope creates higher harmonics. See Fourier for more details 😅 ↩︎