jsundram.github.io/quartet-composers
Every composer on Wikipedia's List of String Quartet Composers — about 880 of them — plotted by how many quartets they wrote against how much their article is read — with three other views of the same roster (a birth-year timeline, a swarm, a fisheye lens) and a searchable, sortable table underneath.
A remake of a 2014 experiment that used a cartesian fisheye: both axes warped continuously under the cursor. It magnified beautifully and read terribly — with the axes always moving there was no stable picture, hovering was the only way to learn anything, and a screenshot of it was nonsense.
There is nothing to install — no build step, no package manager, no test framework. One command, once per clone:
scripts/setup.sh # point core.hooksPath at .githooks, so the pre-commit lints rungit will not let a repo enable its own hooks on clone, deliberately, so this is the one setup step
there is. What only the hook can do is the V bump and og-lint's card-size check, both of which
read the staged diff a CI checkout does not have; record-lint, volume and codehash all report
on a branch in CI as well, and og-lint's other checks fail the build there. Claude Code sessions get
the setup step from .claude/hooks/session-start.sh without asking.
| 2014 | now |
|---|---|
| One view: cartesian fisheye, always on | Three: fame (quartets written against monthly readers), scatter (fixed axes, ordinary pan/zoom), swarm (force-collided, nothing overlaps) — plus a lens you switch on over any of them: a circular fisheye over a picture that holds still |
| Linear y, 0–200 | Log y — most composers here wrote three quartets or fewer, and a linear axis crushed them into one line |
| Tap target = the dot (2.5px for most) | Voronoi hit-testing — a Delaunay over current screen positions, so the target is the dot's whole cell |
| Hover-only tooltip | A persistent detail panel. Hover previews it, click/tap pins it — which is also why there's no hover bubble to double-fire on touch |
| No labels | Collision-avoided labels, so the static view says something with no interaction at all |
| Fixed 960px, desktop only | Responsive, dark mode, print stylesheet, and a CSS-driven full-screen chart |
| Colour = lifespan on RdYlBu-9 | Sequential ramp (YlGnBu, stepped darker for contrast on this surface) over a fixed domain, with living composers off the ramp entirely — a diverging one needs a baseline, and pivoting on the dataset's own median moved the pivot whenever the data did |
| 477 composers, frozen 2014 scrape | the whole list, re-scraped, with a repeatable pipeline (below) |
| Dot size = one month of page views | Median of 12 months — a single month runs ~12% off typical, and far more than that for a small article |
| — | A readership sparkline in the detail panel — every month since 2015-07, hover or arrow-key any month to read it, and a caption that names the spike (Saariaho's obituary, 18× typical) or the trend (Haydn, down 42% since 2015) |
| — | Readership histogram with a drag-to-filter brush, to get the long tail out of the way |
| — | Gender filter from Wikidata P21 — a third of the roster, and the Fame view shows the band they occupy |
| — | Shareable URLs (#v=swarm&c=Joseph+Haydn&r=1500-200000), a share card generated from the real data, installable + offline |
Six cached stages. Nothing in the build touches the network, so the dataset is reproducible offline and the exact bytes behind a deploy stay in git.
python3 scripts/scrape_list.py # the wiki page -> data/list.json + data/list.wiki
python3 scripts/fetch_wikidata.py # canonical titles + P569/P570 + P21 -> data/people.json
python3 scripts/fetch_views.py # every month since 2015-07 -> data/pageviews.json
python3 scripts/fetch_imslp.py # IMSLP's quartet pages and composers -> data/imslp-scrape.json
python3 scripts/build_imslp.py # join them onto this roster -> data/imslp-join.json
python3 scripts/build_data.py # combine -> composers.json + readership.json + imslp-works.jsonbuild_data.py writes three files, because they are wanted at different moments.
composers.json is the roster and carries one view number and one IMSLP work count per composer —
the page cannot paint without it. readership.json is the monthly history behind the sparkline and
imslp-works.json is the per-page IMSLP detail behind the count: nothing waits for either, so they
are fetched after the first paint and the panel simply grows a line when they arrive. All three are
precached; only the first is a boot dependency.
The order is not arbitrary. build_imslp.py runs before build_data.py because
composers.json carries an IMSLP column, and it takes its roster by calling build_data.py's own
build_rows() rather than reading that file — one reduction of the caches, so the join and the app
cannot disagree about who is on this list.
Then run the data gate. V in sw.js has to move — all three are precached, so without a bump
the new numbers reach the repo and nobody's phone — and nothing about that needs a human:
refresh.py bumps it after the gate passes, and for a hand-edit the pre-commit hook does
(sw-lint.py --fix knows which files are precached and which of them you staged) — provided you
ran scripts/setup.sh.
Readership is the one input that goes stale purely because time passed, so it is the one on a schedule:
python3 scripts/refresh.py # top up if a month has completed since the last build
python3 scripts/refresh.py --check # is one due? exit 1 if so, touch nothingIt is a no-op unless a month has completed — the test is whether composers.json already
covers the last complete month, not a timestamp — and when one has, it runs the build stages,
fails the run if the data gate fails, and bumps V only after it passes. The IMSLP crawl rides
along on that trigger, because new scores arrive continuously and there is nothing to ask "is this
stale?" about; a failure there is reported and does not fail the run, since the cache is
committed and losing a month of readership to somebody else's bad afternoon is the worse trade.
.github/workflows/refresh.yml runs it on the 3rd of each month (the API needs a day or two to
settle a finished month) and opens a PR. A PR rather than a push because every dataset bug this
repo has had looked entirely plausible in the file and needed a human to read a two-line diff.
The roster and the Wikidata reads are deliberately not on the schedule: those change for editorial reasons, and a roster that grows by three composers overnight with nobody looking is how a bad parse ships.
Two review tools that are not part of the build:
python3 scripts/audit_counts.py # sample parsed counts beside their source sentence, to grade
python3 scripts/audit_redirects.py # price every redirect: what summing them would change
python3 scripts/compare_2014.py # diff against the archived 2014 snapshot, with reasons(a) The roster and (b) quartet counts come from the list page, which is prose, not a
table: *[[Joseph Haydn]] (1732–1809): Wrote sixty-eight string quartets…. A handful of rules read
a count for most of them; the rest return null and appear in the table but not the chart,
because a wrong count ships as a confident dot while a null is merely honest. Graded by hand on a random sample:
25 exactly right, 4 correctly null, 1 arguable. Wikidata is not an alternative here — Beethoven's
quartets are typed as generic "musical work/composition" with nothing linking them to the genre, so
a SPARQL count over the whole corpus returns four composers.
(c) Birth and death dates come from Wikidata (P569 born, P570 died), not the page prose, so a composer
who died last year isn't still shown as living. Rank matters: Wikidata marks known-wrong values
deprecated rather than deleting them, and reading claims without checking rank reported Tania
León — alive, Pulitzer 2021 — as having died in 1996.
(d) Page views are the noisiest input and the loudest channel, since they drive dot area. Three traps, all of which this repo fell into first:
- A redirect is its own title. Asking the API for "Bela Bartok" returns 41 views, not Béla Bartók's 14,330 — with a 200 and no error. Titles are resolved through the MediaWiki API before any view is requested.
- A disambiguator is load-bearing. A bare "John Adams" resolves correctly and unambiguously to the second President of the United States, whose 144,948 views briefly outranked Beethoven here.
- And a canonical title is only canonical today. The API counts the string that was requested,
so every month before a page move was counted under the name the article held then. Fanny
Hensel's article sat at "Fanny Mendelssohn" until March 2026 and shipped a median of 500
against a real 5,217 — and the sparkline caption, which names a spike when a month clears 3×
the composer's own 95th percentile, obligingly captioned the rename as an obituary. Some of the
roster's articles have moved.
scripts/pagemoves.pyfinds them (a level shift proposes, the MediaWiki move log decides, and a traffic-handover test throws out the moves that were reverted an hour later), and each month is counted under the title the article actually occupied. - One month is weather. Measured against a 12-month window, the typical month sits ~12% off a
composer's own median, and the worst months run several times that — a small article's peak can be
double its median by chance alone. August is a seasonal trough.
monthlygranularity returns the whole range in one request, so twelve months costs exactly what one did. The stored series makes the statistic recomputable offline.
The cache now holds every month the API has — 2015-07 onward — for the same one-request reason,
and the detail panel draws it as a sparkline. Each series is a flat array aligned to a shared
months axis, null where the API had nothing: the obvious {month: count} object repeats the key
once per composer per month, cost 1.9 MB against 0.5 MB, and had to be rewritten whole every month. The
three states are what make a top-up cheap — a null is asked, and there was nothing there, a
missing month is never asked, and a title that did not answer is dropped rather than
written, so the next run asks for it again in full. Without that distinction the articles created
after 2015 look permanently incomplete and are refetched every run. The corollary: a title is
fetched over the whole axis, never over --months, because a flat array has no third value and
a narrower fetch would record un-asked months as nulls that then read as complete forever. A month
in progress is refused outright — the API returns the days so far as though they were the month.
scripts/fetch_views.test.py holds all of that as stubbed, offline cases.
The headline number did not move when the window grew: the median is still over the last
twelve cached months, because "how much read is this composer" is a question about now. The rest
is history, a different question, and validate.py recomputes one from the other so the two files
cannot drift apart. What a decade buys is what twelve months structurally cannot show: Kaija
Saariaho runs at ~2,000 readers a month for eight years and touches 42,195 in June 2023, the month
she died. The articles that did not exist in 2015 start partway across the box and say so — a blank
stretch under a line chart otherwise reads as "nobody read this" rather than "not written yet".
(e) Sex or gender is Wikidata P21, and it is the one element that is not a measurement but
a statement about a person — so it is reported, never derived. Same rank discipline as the dates
(the two share one best_value()), Wikidata's own labels kept as the values, and no inference
from names or pronouns for the composers who have no claim: null is a fact here exactly as it
is for an unstated quartet count. A value outside the label map ships as its raw QID rather than
as a null — a stated fact filed under "not stated" is the one outcome that is wrong about someone
rather than merely incomplete — and validate.py fails on it, so the fix is a label, not a
mystery. A composer with no claim at all is in
neither filter, so the provenance line states how many there are rather than letting silence read
as none — with a branch for when there are none, which is where the roster stands today.
(f) Scores on IMSLP is the only element joined from a second site, and the problem is
identity: IMSLP files people as Surname, Forename and nothing guarantees it spells them the way
Wikipedia does. So the join matches identifiers, never names — the Wikidata item or Wikipedia
article an IMSLP composer page states, resolved back through en.wikipedia to a QID this roster
already holds — and the one rung that does start from a spelling is accepted only when IMSLP's
birth and death years agree with Wikidata's. About half the roster is placed; the rest ship
null, which is unknown, not empty: no P839 claim, no IMSLP page linking their article and no
Surname, Forename guess reached them, which is good evidence of absence and is not the same as
having asked. The UI says "no IMSLP page found" and never "not on IMSLP".
What is counted is works, not pages. IMSLP's unit is a publication entry, so Beethoven's
sixteen quartets occupy more pages than that — several of them complete-set editions that reprint
the others. Reading the Opus/Catalogue Number off each page, expanding a set by the designation
its members share and merging by id collapses the pages back to the works, which for him is the
sixteen plus the Grosse Fuge and the Hess 30 fugue. The UI calls them quartets, which is looser
than that parse and deliberately so — it is IMSLP's own category and what the reader came for. What
it is not is (b): the two columns routinely disagree, because the instrumentation category
legitimately holds fugues, fragments and single movements no numbered list counts. They sit side by
side answering different questions from different sources; they are never subtracted, and the
reader is expected to go and look — which is what the link on the number is for.
scripts/imslp-audit.py renders the parse against the source field for the composers the chart
highlights, because the measure of a parser is a human reading it against the page (the same rule
audit_counts.py exists for).
The honest name for (d) is English Wikipedia readership, not popularity — a Czech or Russian composer's readers are largely on their own language's Wikipedia, which this does not count. The UI says so in the legend ("EN Wikipedia readers / mo"), the lede, and the provenance line, rather than letting "views" imply importance. A per-language fan-out via Wikidata sitelinks would trade one bias for a messier one and is deliberately not attempted.
Readership spans four orders of magnitude, and the median composer is read a couple of hundred
times a month: half the roster is composers essentially nobody reads, and they are most of the ink.
Hence histogram.js — a log-scale histogram of the distribution with a drag-to-select brush, which
is the control and the context in one short strip. It intersects with the search box and the gender pills; none of the three knows the others exist —
each returns "a Set of row indices, or null for everything" and applyFilters() intersects them.
Archived in data/composers-2014.json and data/views-2014.json, not plotted. The pageviews
API has no per-article data before 2015-07, so the 2014 numbers came from a different measurement
system entirely and cannot be compared to a modern figure — "down 30% since 2014" is not a claim
this data can support. Counts and dates are comparable (same page, twelve years apart), which is
what compare_2014.py is for: birth years agree 98.2%, which is the check that proves rows are
matched to the same human.
python3 scripts/validate.py # THE DATA GATE — see below; run it after every rebuild
python3 scripts/validate.test.py # proves the gate still catches each bug it claims to
python3 scripts/fetch_views.test.py # the page-view cache's invariants, network stubbed
python3 scripts/pagemoves.test.py # the page-move rule, offline
node scripts/names.test.mjs # the display-name rules, offline against the real roster
python3 scripts/imslp.test.py # the IMSLP join's judgements — the wikitext readers, the
# catalogue parse, the work counting
python3 scripts/fetch_imslp.test.py # and the crawl's request sequence: that a warm run still asks
# the category, and still declines the rest
scripts/ui-test.sh # the behavioural suite in a real Chrome (lens, tap-to-pin, the three
# filters, theme repaint, 390/360px layout, offline, print) — no deps.
# It prints its own total; that is where the count lives.
# On Linux it wants xvfb-run: headless there reports no pointer at
# all, and a good few of these checks are about having one
node scripts/sw.test.mjs # the service worker's fetch handler
python3 scripts/sw-lint.py # precache contract: V bumped, SHELL paths exist, no cross-origin
python3 scripts/sw-lint.py --fix # ...and bump V yourself if a staged shell file needs one (the hook)
python3 scripts/og-lint.py # the link preview: card size, meta length, and the totals it states
python3 scripts/codehash.py # is this change comments-only, or did code go with them?
python3 scripts/ui-test.test.py # the runner's per-checkout ports, so two runs on one machine do not
# kill each other, and it stops rather than driving a stranger's
# server or browser — no browser needed
python3 scripts/fix-lint.test.py # the two branch gates below, on throwaway repos
python3 scripts/setup.test.py # setup.sh enables the hook, survives a second run, and never takes
# over a core.hooksPath somebody set deliberately
# The branch gates. They compare a branch against what it will merge into, so they need a base ref
# and run on pull requests in CI; by hand, point them at main.
python3 scripts/fix-lint.py --base main # did this branch change source and touch no test?
python3 scripts/ablate.py --base main # do the tests it changed actually CATCH the change?
python3 scripts/ablate.py --base main --with-ui # ...including the browser suite (slow)ablate.py is the one worth knowing about. It reverts the branch's source to the base, keeps the
branch's tests, and requires a named check to go red — a test that still passes without the code it
is meant to prove does not prove it. A No-test: <reason> trailer excuses a file when there is
genuinely nothing to assert — the FILES its own commit touched, so a docs-only commit cannot excuse
code, and a file edited again without one is back in the gate.
All of these run in CI, ui-test.sh included — ubuntu-latest ships a Chrome and an X server, and
the ui job in checks.yml gives the browser suite node 22 and xvfb-run. It still skips with
exit 0 rather than failing on a machine with no browser, which is right for a laptop and wrong for
a runner, so CI sets REQUIRE_BROWSER=1 and a lost browser goes red instead of quietly green. The
gates job runs ablate.py --with-ui for the same reason: a branch that changes what the page looks
like is where an ablation has the most to prove.
Every serious bug this dataset has had was a data bug, and not one was caught by a test — they
were caught by a human noticing a number looked off, twice only after it was already live. A
redirect returning 41 views instead of 14,330. The second President of the United States outranking
Beethoven. A living composer reported dead because Wikidata marks known-wrong values deprecated
rather than deleting them. A composer's decade of readership counted under a title her article had
not lived at since March 2026. Every one produced plausible-looking output, which is precisely
what unit tests and code review are worst at catching.
validate.py compares composers.json against three things — its schema, the other cached files,
and the previous commit — and fails the build. Drift against the last commit is the only check that
can see a wrong-article join, because 144,948 views is implausible only next to what the same row
said last time. validate.test.py reproduces each historical defect in a throwaway copy and asserts
the gate rejects it, so a weakened check goes red instead of quietly green.
index.html structure styles.css design system (light/dark/print)
app.js boot + selection chart.js the three views + the lens
table.js the data table histogram.js the readership filter (log histogram + brush)
names.js canonical Wikipedia title -> the short name the chart and the table print
theme.js three-state theme + the JS-baked-color contract
sw.js offline shell + the V cache-busting constant
composers.json the dataset (generated — edit data/ and rebuild)
d3.v7.min.js vendored, not a CDN
data/ cached pipeline inputs + the 2014 archive (not shipped)
scripts/ pipeline, review tools, lints, the data gate (never shipped)
Built on pwa-starter; vendored files carry a
pwa-starter: <file> @ <sha> stamp so a fix upstream can be traced downstream.
Based on fisheye.js by
Mike Bostock — the circular lens in chart.js is his, inlined
because the plugin is d3 v3-only.