Skip to content

Repository files navigation

String Quartet Composers

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.

Working on it

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 run

git 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.

What changed

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

The pipeline

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.json

build_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.

Keeping it current

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 nothing

It 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

Six data elements, six different problems

(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.py finds 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. monthly granularity 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.

The 2014 data

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.

Checks

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.

Why there's a data gate

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.

Layout

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.

Credit

Based on fisheye.js by Mike Bostock — the circular lens in chart.js is his, inlined because the plugin is d3 v3-only.

About

String Quartet Composers -- an interactive explorer

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages