Skip to content

Instantly share code, notes, and snippets.

@jsundram
Last active January 16, 2026 04:15
Show Gist options
  • Select an option

  • Save jsundram/a187ff2619b90eaf68d449462b1f9795 to your computer and use it in GitHub Desktop.

Select an option

Save jsundram/a187ff2619b90eaf68d449462b1f9795 to your computer and use it in GitHub Desktop.
Periodic Table of Boccherini String Quartets

Periodic Table of Boccherini String Quartets

An interactive visualization of Luigi Boccherini's complete string quartet output (1761-1804), displayed as a periodic table-inspired grid.

View it (via gisthack, see below for details):

Updating the Gist

Quick Start (First Time Setup)

# 1. Configure git to use GitHub CLI credentials (do this once)
gh auth setup-git

# 2. Save your gist ID (do this once)
gh gist list | head -1 | awk '{print $1}' > .gist_id

# 3. Create a sync script (do this once)
cat > sync-gist.sh << 'EOF'
#!/bin/bash

# Usage: ./sync-gist.sh "commit message" file1 file2 ...
# Or: ./sync-gist.sh file1 file2 ... (uses default message)

# If first arg looks like a commit message (has spaces or quotes), use it
if [[ "$1" == *" "* ]] || [[ "$1" == \"*\" ]]; then
    MESSAGE="$1"
    shift
    FILES="$@"
else
    MESSAGE="Update gist"
    FILES="${@:-index.html}"
fi

# Add, commit, and push with message
git add $FILES
git commit -m "$MESSAGE"
git push
EOF
chmod +x sync-gist.sh

Daily Workflow

# 1. Make your changes to index.html, opera.json, or README.md

# 2. Check what changed
git diff                    # See all changes
git diff index.html         # See specific file changes

# 3. Update the gist with a commit message
./sync-gist.sh "Fix title alignment" index.html

# Or update multiple files
./sync-gist.sh "Update visualization and docs" index.html README.md

# Or use default message "Update gist"
./sync-gist.sh index.html

How it works

The script uses standard git commands to update the gist:

  1. git add - stages your files
  2. git commit -m "message" - commits with your message
  3. git push - pushes to the gist

Your commit messages appear in the gist history instead of just "revised".

Manual method (if you prefer)

# Standard git workflow
git add index.html
git commit -m "Your commit message"
git push

# Check status
git status
git log --oneline

Viewing Online

Get the raw file URL and use githack.com to serve it:

  1. Get the raw URL from your gist:

    https://gist.githubusercontent.com/USERNAME/GIST_ID/raw/index.html
    
  2. Replace gist.githubusercontent.com with gist.githack.com:

    https://gist.githack.com/USERNAME/GIST_ID/raw/index.html
    

Example for this gist:

https://gist.githack.com/jsundram/a187ff2619b90eaf68d449462b1f9795/raw/index.html

Why githack.com?

  • Properly serves files with correct MIME types
  • Loads external scripts (like D3.js) without CORS issues
  • No rate limiting for development use
  • Updates automatically when you update your gist

Note: htmlpreview.github.io doesn't work because it can't load external scripts like D3.js.

Data Overview (opera.json)

Structure

The data is organized as an array of opus groups, where each opus contains:

{
  "opus": 2,                    // Opus number
  "year": 1761,                 // Year of composition
  "dedication": "...",          // Optional: dedicatee
  "imslp": "...",              // Optional: IMSLP link for the opus
  "quartets": [...]            // Array of quartets in this opus
}

Each quartet contains:

{
  "number": 1,                 // Quartet number within opus
  "gerard": 159,               // Gerard catalog number
  "key": "C",                  // Key (e.g., "C", "E-flat")
  "major": false,              // true = major, false = minor
  "nickname": "...",           // Optional: nickname
  "imslp": "...",             // Optional: IMSLP link
  "mvmts": [...],             // Array of movement names
  "category": "opera grande"   // "opera grande" or "opera piccola" variants
}

Data Usage

Displayed in visualization:

  • Opus number (row header)
  • Year and Boccherini's age (row header, top)
  • Category: Opera Grande/Piccola (row header, bottom)
  • Dedication (row header, when present)
  • Gerard catalog number (card header)
  • Quartet number within opus (card header)
  • Key signature (card center, with ♭ symbol)
  • Major/minor mode (card center)
  • Movement count with color coding (card bottom)
  • Nickname (card center, when present)

Used in interactions:

  • Individual movement names (shown in hover tooltip)
  • IMSLP links (quartet and opus level, opened on click)

Notable transformations:

  • "-flat" in key names is replaced with the Unicode flat symbol (♭)
  • Boccherini's age calculated from birth year (1743)
  • Category determines row background gradient color

Unused/Metadata

All data fields are currently utilized either in the display, tooltips, or interactions. The data is comprehensive and fully integrated.

Technical Overview (index.html)

Architecture

Single-file design: All HTML, CSS, and JavaScript in one file for easy gist hosting and sharing.

Technology stack:

  • D3.js v7 for data loading and DOM manipulation
  • Pure CSS for styling (no CSS frameworks)
  • Vanilla JavaScript (no additional frameworks)

Design Decisions

1. Periodic Table Metaphor

  • Square cards (140×140px) arranged in rows by opus
  • Each opus group forms a "period" (row)
  • Inspired by chemical periodic table organization

2. Visual Alignment Strategy The design emphasizes vertical alignment across three levels:

Row Header          ↔  Quartet Cards
─────────────────────────────────────
Year (age)          ↔  Mode bar (G# / quartet #)
Opus number         ↔  Key signature
Category badge      ↔  Movement count

This creates strong visual relationships between semantically related information.

3. Color Encoding System

Mode bar (top of each card):

  • Blue (#2196F3): Major keys
  • Pink (#E91E63): Minor keys

Movement count (diverging purple-green palette):

  • 1 movement: Deep purple (#9C27B0) - rare/incomplete
  • 2 movements: Light purple (#CE93D8) - opera piccola
  • 3 movements: Blue-gray (#B0BEC5) - standard
  • 4 movements: Light green (#81C784) - substantial
  • 5 movements: Deep green (#2E7D32) - rare/complete

Category badges use the same palette:

  • Opera Piccola: Light purple (matches 2-movement works)
  • Opera Grande: Light green (matches 4-movement works)

Row background gradients:

  • Subtle gradient (12% opacity) extends from row header toward cards
  • Uses category color to create visual flow across the row

4. Layout System

Flexbox throughout:

  • Rows: display: flex with flex-wrap for cards
  • Opus labels: justify-content: space-between for vertical distribution
  • Cards: flex-direction: column for stacking elements

Height constraints:

  • Opus label and cards both fixed at 140px for alignment
  • flex-grow: 1 on middle sections (opus number, key signature) for vertical centering
  • Movement count uses margin-top: auto to anchor to bottom

5. Typography Hierarchy

Dramatic size contrast in row headers:

  • Opus number: 2.8em, weight 900 (ultra bold)
  • Year: 0.8em, weight 600
  • Age: 0.65em (parenthetical)
  • Category badge: 0.65em
  • Dedication: 0.6em, italic

Code Intricacies

1. Commented sections for easy tweaking The opus label CSS and JavaScript are heavily commented with clear section markers (e.g., === OPUS LABEL SECTION ===) to facilitate experimentation with layout and sizing.

2. Dynamic class application Category background gradients are applied dynamically:

const categoryClass = opus.quartets[0].category.includes('grande')
  ? 'grande-bg' : 'piccola-bg';

3. Unicode transformation Flat symbols are rendered using Unicode replacement:

const keyDisplay = quartet.key.replace('-flat', '♭');

4. Nested container pattern Cards use multiple nested containers for precise alignment:

  • quartet-cardmode-bar + card-content
  • card-contentkey-section + nickname + movements-count

This allows independent control of each vertical section.

5. Age calculation Boccherini's age is calculated inline from a constant:

const BOCCHERINI_BIRTH_YEAR = 1743;
const age = opus.year - BOCCHERINI_BIRTH_YEAR;

Browser Compatibility

Requires modern browser support for:

  • CSS Flexbox
  • Unicode symbols (♭)
  • ES6 JavaScript (const, arrow functions, template literals)
  • D3.js v7

Tested in Chrome, Firefox, Safari, and Edge (2023+).


Created: December 2025 Data source: Luigi Boccherini quartet catalog (G.159-249)

#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "playwright>=1.40.0",
# ]
# ///
"""
Generate PDF of Boccherini String Quartets visualization
Usage:
uv run generate-pdf.py
First-time setup (install Playwright browsers):
uvx playwright install chromium
Requirements:
- uv installed (https://docs.astral.sh/uv/)
- Local server running at http://localhost:8000
- qpdf installed (for linearization/optimization)
"""
import sys
import subprocess
import os
from playwright.sync_api import sync_playwright
def generate_pdf():
"""Generate the PDF"""
print("📄 Generating Boccherini Quartets PDF...")
print(" Loading http://localhost:8000/index.html")
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
# Set viewport size for proper layout (desktop view)
# Below 1200px width, responsive layout causes wrapping
page.set_viewport_size({'width': 1400, 'height': 2000})
# Navigate to the page
page.goto('http://localhost:8000/index.html', wait_until='networkidle')
margin = {
'top': '0in',
'bottom': '0in',
'left': '0in',
'right': '0in'
}
outfile = 'boccherini-quartets-temp.pdf'
# Generate PDF with exact settings
page.pdf(
path=outfile,
format='Letter',
print_background=True, # Enable background graphics
tagged=False, # Generate tagged PDF for accessibility?
# margin=margin, # Ignored since we are using css below.
prefer_css_page_size=True # Use CSS @page size settings
)
browser.close()
print(f"✓ Temporary PDF generated: {outfile}")
# Linearize the PDF using qpdf to avoid AirPrint issues.
final_pdf = 'boccherini-quartets.pdf'
print(f"🔄 Linearizing PDF with qpdf...")
try:
subprocess.run(
['qpdf', '--linearize', outfile, final_pdf],
check=True,
capture_output=True,
text=True
)
print(f"✓ Linearized PDF saved to: {final_pdf}")
# Clean up temporary file
os.remove(outfile)
print(f"✓ Cleaned up temporary file: {outfile}")
except FileNotFoundError:
print("\n⚠️ qpdf not found.")
print("\nPlease install qpdf:")
print(" brew install qpdf (macOS)")
print(f"\nTemporary PDF available at: {outfile}")
print(f"Run manually: qpdf --linearize {outfile} {final_pdf}")
sys.exit(1)
except subprocess.CalledProcessError as e:
print(f"\n⚠️ qpdf failed: {e.stderr}")
print(f"Temporary PDF available at: {outfile}")
sys.exit(1)
if __name__ == "__main__":
try:
generate_pdf()
except Exception as e:
if "Executable doesn't exist" in str(e) or "browserType.launch" in str(e):
print("\n⚠️ Playwright browsers not installed.")
print("\nPlease run this command first:")
print(" uvx playwright install chromium")
sys.exit(1)
else:
raise
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Periodic Table of Boccherini String Quartets</title>
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<style>
:root {
/* === DYNAMIC SIZING SYSTEM === */
/* Scale Card Size based on browser window width */
--card-height: clamp(100px, 12vw, 140px);
--card-gap: clamp(6px, 0.8vw, 10px);
--opus-label-width: clamp(85px, 8vw, 95px);
--top-section-height: clamp(14px, 1.7vw, 20px);
--nickname-height: clamp(10px, 1.3vw, 15px);
--bottom-section-height: clamp(14px, 1.7vw, 20px);
/* Calculated middle section height - same for both sides */
--middle-section-height: calc(
var(--card-height)
- var(--top-section-height)
- var(--nickname-height)
- var(--bottom-section-height)
);
/* Font sizes for aligned elements */
--top-font-size: 0.8em;
--middle-font-size: 2.8em;
--bottom-font-size: 0.7em;
/* Header widths */
--header-width: 900px;
/* === DEBUG MODE === */
--debug-mode: 0; /* Set to 1 to show bounding boxes, 0 to hide */
/* === OWNERSHIP INDICATOR === */
--show-ownership: 1; /* Set to 1 to show repeat signs on owned parts, 0 to hide */
}
/* Debug bounding boxes - controlled by --debug-mode */
.opus-label .year-age {
outline: calc(var(--debug-mode) * 1px) solid rgba(255, 0, 0, 0.3);
}
.opus-label .opus-number {
outline: calc(var(--debug-mode) * 1px) solid rgba(0, 255, 0, 0.3);
}
.opus-label .bottom-section {
outline: calc(var(--debug-mode) * 1px) solid rgba(0, 0, 255, 0.3);
}
.mode-bar {
outline: calc(var(--debug-mode) * 1px) solid rgba(255, 0, 0, 0.3);
}
.key-section {
outline: calc(var(--debug-mode) * 1px) solid rgba(0, 255, 0, 0.3);
}
.movements-count {
outline: calc(var(--debug-mode) * 1px) solid rgba(0, 0, 255, 0.3);
}
.nickname {
outline: calc(var(--debug-mode) * 1px) solid rgba(255, 165, 0, 0.3);
}
.opus-label .dedication {
outline: calc(var(--debug-mode) * 1px) solid rgba(128, 0, 128, 0.3);
}
body {
font-family: 'Helvetica Neue', Arial, sans-serif;
background-color: #f5f5f5;
margin: 20px;
padding: 0;
}
.container {
width: fit-content; /* Shrink to visualization width */
margin: 0 auto; /* Center the container */
display: flex;
flex-direction: column;
align-items: stretch; /* Children match container width */
}
.header-group {
/* Header spans full width of visualization */
display: flex;
flex-direction: column;
align-items: stretch; /* Children stretch to container width */
container-type: inline-size; /* Enable container queries for children */
}
h1 {
margin: 0 0 5px 0; /* Small gap below */
text-align: center;
color: #333;
font-size: 4.3cqw; /* Scale to span container width */
white-space: nowrap; /* Keep on one line */
}
.subtitle {
margin: 0 0 25px 0; /* Larger gap below */
text-align: right; /* Right-align to match title's right edge */
color: #666;
font-size: clamp(0.75em, 0.85vw, 0.9em); /* Scales smoothly */
font-style: italic;
}
.subtitle a {
color: #2196F3;
text-decoration: none;
}
.subtitle a:hover {
text-decoration: underline;
}
.opus-row {
display: flex;
align-items: flex-start;
margin-bottom: 10px;
}
/* Combined rows - no special CSS needed, just flex layout */
.opus-row.combined-pair {
/* Flexbox will lay out multiple label+card groups horizontally */
}
/* === OPUS LABEL SECTION (easy to tweak) === */
.opus-label {
/* Container sizing - MATCHES CARD HEIGHT, scales proportionally */
width: var(--opus-label-width);
height: var(--card-height);
padding: clamp(1px, 0.2vw, 2px) clamp(3px, 0.5vw, 6px) 0 clamp(5px, 0.8vw, 10px);
/* Layout - vertical alignment structure */
display: flex;
flex-direction: column;
justify-content: flex-start; /* Stack from top, no auto-spacing */
text-align: right;
position: relative;
}
/* Background gradient based on category - applied via JS */
.opus-label.grande-bg {
background: linear-gradient(to right,
transparent 0%,
rgba(129, 199, 132, 0.12) 100%); /* Light green fade */
}
.opus-label.piccola-bg {
background: linear-gradient(to right,
transparent 0%,
rgba(206, 147, 216, 0.12) 100%); /* Light purple fade */
}
/* TOP SECTION: Year and Age - ALIGNS WITH MODE BAR */
.opus-label .year-age {
display: flex;
justify-content: space-between; /* Year left, age right - periodic table aesthetic */
align-items: center;
height: var(--top-section-height);
flex-shrink: 0;
line-height: 1; /* Match mode-bar line-height */
}
.opus-label .year {
font-size: var(--top-font-size);
font-weight: 700; /* Slightly bolder for clarity */
color: #444; /* Slightly darker */
line-height: 1; /* Exact alignment */
}
.opus-label .age {
font-size: calc(var(--top-font-size) * 0.85); /* Slightly larger */
font-weight: 400; /* Regular weight */
color: #777; /* Slightly darker */
line-height: 1; /* Exact alignment */
}
/* MIDDLE SECTION: Opus number - ALIGNS WITH KEY/MODE */
.opus-label .opus-number {
font-size: var(--middle-font-size);
font-weight: 900;
color: #222;
line-height: 1;
letter-spacing: -0.03em;
height: var(--middle-section-height); /* Fixed height to match key-section */
margin-bottom: var(--nickname-height); /* Spacer to match nickname section */
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
}
/* BOTTOM SECTION: Dedication and category badge */
.opus-label .bottom-section {
display: flex;
flex-direction: column;
align-items: flex-end; /* Right align */
justify-content: center; /* Vertically center - matches movements-count */
flex-shrink: 0;
height: var(--bottom-section-height); /* Fixed height to match movements-count */
position: relative; /* For dedication positioning */
}
/* Dedication - absolutely positioned above opus number to preserve alignment */
.opus-label .dedication {
position: absolute; /* Remove from layout flow */
top: calc(var(--top-section-height) + 2px); /* Position below year/age */
left: 0;
right: 0;
font-size: 0.6em; /* Size: smallest */
font-style: italic; /* Style: italic */
color: #999; /* Color: lighter gray */
text-align: center; /* Center align */
line-height: 1.2; /* Tighter line height for wrapping */
z-index: 1; /* Above background */
}
/* Category badge - aligns with movement count */
.opus-label .category-badge {
font-size: var(--bottom-font-size); /* Aligns with movement count */
width: 100%; /* Full width like movement count */
height: 100%; /* Full height like movement count */
display: flex; /* Use flexbox for centering */
align-items: center; /* Vertically center text */
justify-content: center; /* Horizontally center text */
font-weight: 500; /* Match movement count weight */
line-height: 1; /* Match movement count */
}
/* === END OPUS LABEL SECTION === */
.quartets-container {
display: flex;
flex-wrap: nowrap;
gap: var(--card-gap);
margin-left: var(--card-gap); /* Gap between label and cards */
}
/* Spacer between opus groups in combined rows */
.opus-spacer {
/* Responsive scaling for desktop/iPad; print/mobile override with calc() */
width: clamp(120px, 15vw, 177px);
height: var(--card-height);
flex-shrink: 0;
margin: 0 var(--card-gap);
}
.quartet-card {
width: var(--card-height); /* Square cards - width matches height */
height: var(--card-height);
background: white;
border: 2px solid #ddd;
border-radius: 4px;
padding: 0;
box-shadow: 2px 2px 5px rgba(0,0,0,0.1);
cursor: pointer;
transition: all 0.2s;
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
}
.quartet-card:hover {
transform: translateY(-3px);
box-shadow: 3px 3px 10px rgba(0,0,0,0.2);
border-color: #888;
}
/* Subtle gray wash for quartets without minuets */
.quartet-card.no-minuet {
background: rgba(0, 0, 0, 0.03);
}
.mode-bar {
height: var(--top-section-height); /* Aligns with year-age section */
width: 100%;
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 8px;
box-sizing: border-box;
}
.mode-bar.major {
background: transparent;
}
.mode-bar.minor {
background: #E91E63;
}
.card-content {
padding: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
/* No position: relative - nickname positions relative to .quartet-card instead */
}
/* Text colors for major keys (on transparent background) */
.mode-bar.major .quartet-number {
font-size: var(--top-font-size); /* Aligns with year */
color: #888; /* Lighter gray */
font-weight: 600; /* Semi-bold */
line-height: 1; /* Exact alignment */
}
.mode-bar.major .gerard-number {
font-size: var(--top-font-size); /* Aligns with year */
font-weight: 700; /* Bold for emphasis */
color: #444; /* Darker for readability */
line-height: 1; /* Exact alignment */
}
/* Text colors for minor keys (on colored background) */
.mode-bar.minor .quartet-number {
font-size: var(--top-font-size); /* Aligns with year */
color: rgba(255, 255, 255, 0.85); /* Slightly less opaque */
font-weight: 600; /* Semi-bold */
line-height: 1; /* Exact alignment */
}
.mode-bar.minor .gerard-number {
font-size: var(--top-font-size); /* Aligns with year */
font-weight: 700; /* Bold for emphasis */
color: white;
line-height: 1; /* Exact alignment */
}
/* Key section - aligns with opus number */
.key-section {
height: var(--middle-section-height); /* Fixed height to match opus-number */
margin-bottom: var(--nickname-height); /* Space for nickname below (matches opus-number margin-bottom) */
flex-shrink: 0;
display: flex;
flex-direction: column;
justify-content: center; /* Vertically center */
align-items: center; /* Horizontally center */
padding: 0 8px; /* Horizontal padding only */
}
.key-signature {
text-align: center;
font-size: calc(var(--middle-font-size) * 0.64); /* Proportional to opus number */
font-weight: bold;
color: #222;
line-height: 1;
margin: 0; /* Remove margin for proper centering */
}
.key-mode {
text-align: center;
font-size: 0.75em;
color: #666;
margin: 0; /* Remove margin for proper centering */
line-height: 1;
margin-top: 2px; /* Small space above, not below key-signature */
}
.category-badge.grande {
background-color: #81C784;
color: #333;
}
.category-badge.piccola {
background-color: #CE93D8;
color: #333;
}
/* Links container for quartet cards - holds IMSLP, QR, and Recording indicator */
.card-links {
display: grid;
grid-template-columns: 1fr 1fr 1fr; /* 3 equal columns: IMSLP (left), QR (center), Recording (right) */
align-items: center;
padding: 2px 4px;
font-size: 0.65em;
font-weight: 400;
}
.imslp-link,
.qr-link,
.recording-indicator {
color: #2196F3;
text-decoration: none;
}
.imslp-link {
justify-self: start; /* Left-aligned in its grid cell */
}
.qr-link {
justify-self: center; /* Center-aligned in its grid cell */
}
.recording-indicator {
justify-self: end; /* Right-aligned in its grid cell */
cursor: default; /* Not clickable */
}
.imslp-link:hover,
.qr-link:hover {
text-decoration: underline;
color: #1976D2;
}
/* IMSLP link in opus label - positioned absolutely to not affect layout */
.opus-label .imslp-link {
position: absolute;
bottom: 100%; /* Position above bottom-section */
left: 0; /* Left align to match quartet cells */
margin-bottom: 1px; /* Minimal space below link */
padding: 2px 4px; /* Match quartet cell link padding */
font-size: 0.65em; /* Match quartet cell link size */
font-weight: 400; /* Lighter weight */
}
.movements-count {
text-align: center;
font-size: var(--bottom-font-size); /* Aligns with category badge */
height: var(--bottom-section-height); /* Fixed height to match bottom-section */
flex-shrink: 0;
display: flex;
align-items: center; /* Vertically center text */
justify-content: center; /* Horizontally center text */
margin: 0;
padding: 0 0px; /* Small padding for repeat glyphs */
border-radius: 0; /* Square edges like top bar */
font-weight: 500;
line-height: 1; /* Match category badge */
}
/* Repeat glyphs for parts.json quartets */
.movements-count .repeat-start,
.movements-count .repeat-end {
font-size: 1.75em; /* Make glyphs taller */
line-height: 1;
opacity: var(--show-ownership); /* Controlled by CSS variable */
}
.movements-count .repeat-start {
margin-right: auto;
}
.movements-count .repeat-end {
margin-left: auto;
}
/* Movement text container - stacks text and lines */
.movements-count .mvmt-text {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
}
/* SVG movement lines */
.movements-count .mvmt-lines-svg {
display: block;
}
/* Diverging color scheme: Purple (short) -> Gray (standard) -> Green (long) */
.movements-count.mvmt-1 {
background-color: #9C27B0;
color: white;
}
.movements-count.mvmt-2 {
background-color: #CE93D8;
color: #333;
}
.movements-count.mvmt-3 {
background-color: #B0BEC5;
color: #333;
}
.movements-count.mvmt-4 {
background-color: #81C784;
color: #333;
}
.movements-count.mvmt-5 {
background-color: #2E7D32;
color: white;
}
.nickname {
position: absolute; /* Remove from layout flow to preserve alignment */
top: calc(var(--top-section-height) + 2px); /* Match dedication positioning */
left: 0;
right: 0;
max-height: 13px; /* Constrain to allocated space (22px to 35px) */
overflow: hidden; /* Clip overflow to prevent overlapping key section */
font-size: 0.55em; /* Slightly smaller for tighter fit in limited space */
font-style: italic; /* Same as dedication */
color: #999; /* Same as dedication */
text-align: center; /* Center align */
line-height: 1; /* Tight line-height to maximize space */
z-index: 2; /* Above mode bar */
pointer-events: none; /* Don't block clicks on mode bar */
}
.tooltip {
position: absolute;
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 10px;
border-radius: 5px;
font-size: 0.85em;
pointer-events: none;
z-index: 1000;
max-width: 300px;
line-height: 1.4;
}
.dedication {
font-size: 0.7em;
color: #666;
font-style: italic;
margin-left: 2px;
}
/* === PRINT STYLES === */
/*
* PRINTING INSTRUCTIONS:
* For best results when printing or saving as PDF:
* 1. Open print dialog (Cmd+P / Ctrl+P)
* 2. Enable "Background graphics"
* 3. Set margins to "Custom" with:
* - Top: 0.25 inches
* - Bottom: 0 inches (minimum)
* - Left: 0 inches (minimum)
* - Right: 0 inches (minimum)
* Note: Browser print dialogs override CSS @page margins,
* so custom margins must be set manually.
*/
@media print {
@page {
margin: 0; /* Reset all margins to 0 first */
margin-top: 0.25in;
margin-left: 0in;
margin-right: 0in;
margin-bottom: 0in;
size: letter portrait;
}
/* Override responsive sizing with fixed desktop values for print */
:root {
--card-height: 140px;
--top-section-height: 20px;
--nickname-height: 15px;
--bottom-section-height: 20px;
}
body {
font-size: 9pt;
background: white !important;
zoom: 0.73; /* Scale to 73% to fit on page */
}
/* Override responsive font sizes with fixed desktop values for print */
h1 {
font-size: 3.5em !important;
max-width: 100%; /* Responsive width */
}
.subtitle {
font-size: 1.5em !important;
}
/* Page break controls */
.opus-row {
page-break-inside: avoid;
break-inside: avoid;
}
/* Clean aesthetics for print */
.opus-label {
width: 95px; /* Fixed width for print (not responsive) */
box-shadow: none !important; /* Remove sticky shadow */
/* position: static; /* Remove sticky positioning */*/
}
.opus-spacer {
width: 179px; /* Fixed width for print */
}
.quartet-card {
box-shadow: none !important;
border: 1px solid #999;
}
/* Hide interactive elements */
.tooltip {
display: none !important;
}
/* Ensure links and recording indicator are visible */
.imslp-link,
.qr-link,
.recording-indicator {
color: #2196F3;
}
/*
ChatGPT suggests that if your table has very thin rules (≤0.25pt),
this reduces Quartz/CUPS line thinning on some printers.
*/
* {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
/* === MOBILE RESPONSIVE (iPhone) === */
/* Small touch devices */
/* hover:none = no mouse, pointer:coarse = touch screen */
@media (hover: none) and (pointer: coarse) and (max-width: 800px) {
/* Override responsive sizing with fixed desktop values */
:root {
--card-height: 140px;
--card-gap: 10px;
--opus-label-width: 95px;
--top-section-height: 20px;
--nickname-height: 15px;
--bottom-section-height: 20px;
}
body {
margin: 0;
/* Scale to fit ~1000px content into 375px viewport with margins */
/* Use transform instead of zoom for Safari compatibility */
transform: scale(0.35);
transform-origin: 0 0;
width: calc(100% / 0.35); /* Compensate: content laid out at ~286% then scaled down */
}
.container {
width: auto; /* Override fit-content for scaled view */
align-items: center; /* Keep centered */
}
.header-group {
container-type: normal;
}
h1 {
font-size: 2.7em;
}
.opus-spacer {
/* Width = 2 cards - opus label + 2 gaps (fixed values on mobile) */
width: calc(2 * var(--card-height) - var(--opus-label-width) + 2 * var(--card-gap));
margin: 0; /* Gap handled by container */
}
/* Prevent text wrapping in bottom sections */
.movements-count,
.opus-label .category-badge {
white-space: nowrap;
overflow: hidden;
}
.card-links {
padding: 0px 2px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header-group">
<h1>Luigi Boccherini (1743–1805) – 91 String Quartets</h1>
<p class="subtitle">see also <a href="https://quartetroulette.com/Boccherini/" target="_blank">Quartet Roulette</a></p>
</div>
<div id="visualization"></div>
</div>
<script>
// Load all JSON files: peters.json, parts.json, then opera.json
Promise.all([
d3.json('peters.json'),
d3.json('parts.json'),
d3.json('opera.json')
]).then(([petersData, partsData, operaData]) => {
// Counter in Javascript
let count = l => l.reduce((d, k) => {d[k] = (d[k] || 0) + 1; return d;}, {})
// hand-coded from https://en.wikipedia.org/wiki/Circle_of_fifths#/media/File:Circle_of_fifths_deluxe_4.svg
const c5 = new Map(Object.entries({
"C major": {count: 0, sign: ""},
"A minor": {count: 0, sign: ""},
"G major": {count: 1, sign: "♯"},
"E minor": {count: 1, sign: "♯"},
"D major": {count: 2, sign: "♯"},
"B minor": {count: 2, sign: "♯"},
"A major": {count: 3, sign: "♯"},
"F-sharp minor": {count: 3, sign: "♯"},
"E major": {count: 4, sign: "♯"},
"C-sharp minor": {count: 4, sign: "♯"},
"B major": {count: 5, sign: "♯"},
"G-sharp minor": {count: 5, sign: "♯"},
"F-sharp major": {count: 6, sign: "♯"},
"D-sharp minor": {count: 6, sign: "♯"},
"C-sharp major": {count: 7, sign: "♯"},
"F major": {count: 1, sign: "♭"},
"D minor": {count: 1, sign: "♭"},
"B-flat major": {count: 2, sign: "♭"},
"G minor": {count: 2, sign: "♭"},
"E-flat major": {count: 3, sign: "♭"},
"C minor": {count: 3, sign: "♭"},
"A-flat major": {count: 4, sign: "♭"},
"F minor": {count: 4, sign: "♭"},
"D-flat major": {count: 5, sign: "♭"},
"B-flat minor": {count: 5, sign: "♭"},
"G-flat major": {count: 6, sign: "♭"},
"E-flat minor": {count: 6, sign: "♭"},
"C-flat major": {count: 7, sign: "♭"},
}));
window.data = operaData;
console.log(operaData);
// Create lookup maps keyed by Gerard number
const petersMap = new Map(petersData.map(p => [p.Gerard, { number: p.number, label: p.label }]));
const glabelMap = new Map(petersData.filter(p => p.glabel != null).map(p => [p.glabel, p.actual]));
const partsMap = new Map(partsData.map(p => [p.Gerard, p.edition]));
// Enrich opera data with peters nicknames and parts editions
let quartets = [];
operaData.forEach(opus => {
opus.quartets.forEach(quartet => {
quartet.opus = opus.opus;
// Add nickname from peters.json if available
if (petersMap.has(quartet.gerard)) {
const peters = petersMap.get(quartet.gerard);
quartet.nickname = `Peters ${peters.number}: ${peters.label}`;
}
// Add "see also" nickname for glabel references
else if (glabelMap.has(quartet.gerard)) {
const actual = glabelMap.get(quartet.gerard);
quartet.nickname = `Peters: see ${actual}`;
}
// Add edition info from parts.json for styling
if (partsMap.has(quartet.gerard)) {
quartet.edition = partsMap.get(quartet.gerard);
}
quartets.push(quartet);
});
});
window.quartets = quartets;
const key = q => (q.key + " " + (q.major ? "major" : "minor"));
const key_counts = count(quartets.map(key))
window.key_counts = key_counts;
// can use for UI as follows:
console.log(Object.entries(key_counts).map(([k, v]) =>
({key: k, count: v, sign: c5.get(k).sign.repeat(c5.get(k).count)})
));
const data = operaData; // Use enriched data
const container = d3.select('#visualization');
// Create tooltip
const tooltip = d3.select('body')
.append('div')
.attr('class', 'tooltip')
.style('opacity', 0);
// Detect touch device
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
// Track currently active tooltip card (for touch devices)
let activeCard = null;
// On touch devices, enable pointer events on tooltip and prevent click passthrough
if (isTouchDevice) {
tooltip.style('pointer-events', 'auto');
tooltip.on('click', function(event) {
// Prevent clicks on tooltip from passing through
event.stopPropagation();
});
// Close tooltip when clicking outside on touch devices
d3.select('body').on('click', function(event) {
// Only close if clicking outside tooltip and cards
if (!event.target.closest('.quartet-card') && !event.target.closest('.tooltip')) {
tooltip.style('opacity', 0);
d3.selectAll('.quartet-card').style('border-color', '#ddd');
activeCard = null;
}
});
}
// === HELPER FUNCTIONS ===
// Render opus label (left side of row)
function renderOpusLabel(parent, opus) {
// Determine category for background gradient
const categoryClass = opus.quartets.length > 0 &&
opus.quartets[0].category.includes('grande') ? 'grande-bg' : 'piccola-bg';
const opusLabel = parent.append('div')
.attr('class', `opus-label ${categoryClass}`);
// === TOP: Year + Age (aligns with mode bar) ===
const BOCCHERINI_BIRTH_YEAR = 1743;
const age = opus.year - BOCCHERINI_BIRTH_YEAR;
const yearAge = opusLabel.append('div')
.attr('class', 'year-age');
yearAge.append('span')
.attr('class', 'year')
.text(opus.year);
yearAge.append('span')
.attr('class', 'age')
.text(`(${age})`);
// === Dedication (if present) - appears above opus number ===
if (opus.dedication) {
// Update dedications for display
let dedicationDisplay = opus.dedication;
if (opus.dedication === 'Monsieur le Baron du Beine de Malchamps') {
dedicationDisplay = 'Baron de Malchamps';
} else if (opus.dedication === 'Alli Signori Diletanti di Madrid') {
dedicationDisplay = 'Diletanti di Madrid';
} else if (opus.dedication === 'Infante Luis of Spain') {
dedicationDisplay = 'Infante Luigi di Spagna';
}
opusLabel.append('div')
.attr('class', 'dedication')
.text(dedicationDisplay);
}
// === MIDDLE: Opus number (NO "Op." prefix) ===
opusLabel.append('div')
.attr('class', 'opus-number')
.text(opus.opus);
// === BOTTOM: Bottom section (IMSLP link + category badge) ===
const bottomSection = opusLabel.append('div')
.attr('class', 'bottom-section');
// IMSLP link (if present at opus level)
if (opus.imslp) {
bottomSection.append('a')
.attr('class', 'imslp-link')
.attr('href', opus.imslp)
.attr('target', '_blank')
.attr('rel', 'noopener noreferrer')
.text('imslp')
.on('click', function(event) {
event.stopPropagation();
});
}
// Category badge below dedication/link
if (opus.quartets.length > 0) {
const category = opus.quartets[0].category;
const catClass = category.includes('grande') ? 'grande' : 'piccola';
const categoryText = category.includes('grande') ? 'Grande' : 'Piccola';
bottomSection.append('div')
.attr('class', `category-badge ${catClass}`)
.text(categoryText);
}
return opusLabel;
}
// Render quartet card
function renderQuartetCard(parent, opus, quartet, tooltip, isTouchDevice) {
const showTooltip = function(event) {
// Close any previously active tooltip
if (activeCard && activeCard !== this) {
d3.select(activeCard).style('border-color', '#ddd');
}
activeCard = this;
d3.select(this).style('border-color', '#333');
const movements = quartet.mvmts.map((m, i) => `${i + 1}. ${m}`).join('<br>');
const keyFull = `${quartet.key} ${quartet.major ? 'major' : 'minor'}`;
let tooltipText = `<strong>Opus ${opus.opus} #${quartet.number || '—'} \
in ${keyFull}, G. ${quartet.gerard}</strong><br>`;
if (quartet.nickname) {
tooltipText += `<em>"${quartet.nickname}"</em><br>`;
}
tooltipText += `${quartet.category}<br><br>`;
tooltipText += `<strong>Movements:</strong><br>${movements}`;
const x = event.pageX || (event.touches && event.touches[0].pageX) || 0;
const y = event.pageY || (event.touches && event.touches[0].pageY) || 0;
// Account for body transform scale on mobile
const bodyTransform = window.getComputedStyle(document.body).transform;
let scale = 1;
if (bodyTransform && bodyTransform !== 'none') {
// transform matrix(a, b, c, d, tx, ty) - 'a' is the scale factor
const match = bodyTransform.match(/matrix\(([^,]+)/);
if (match) scale = parseFloat(match[1]);
}
tooltip.html(tooltipText)
.style('left', (x / scale + 10) + 'px')
.style('top', (y / scale - 10) + 'px')
.style('opacity', 1);
};
const hideTooltip = function() {
d3.select(this).style('border-color', '#ddd');
tooltip.style('opacity', 0);
activeCard = null;
};
// Check if quartet has any minuets
const hasMinuet = quartet.mvmts.some(m => m.includes('Minuetto'));
// TODO: remove this once the data is updated
quartet.hasRecording = d3.randomUniform()(1) < .5;
const card = parent.append('div')
.attr('class', hasMinuet ? 'quartet-card' : 'quartet-card no-minuet');
if (isTouchDevice) {
// Touch device: use click to toggle, ignore mouse events
card.on('click', function(event) {
event.stopPropagation();
if (activeCard === this) {
// Clicking same card: close tooltip
hideTooltip.call(this);
} else {
// Clicking different card: show its tooltip
showTooltip.call(this, event);
}
});
} else {
// Desktop: use hover
card.on('mouseover', showTooltip)
.on('mouseout', hideTooltip);
}
// Add colored top bar for major/minor with numbers
const modeBar = card.append('div')
.attr('class', `mode-bar ${quartet.major ? 'major' : 'minor'}`);
// Gerard catalog number in the mode bar (left)
modeBar.append('div')
.attr('class', 'gerard-number')
.text(quartet.gerard);
// Quartet number (within opus) in the mode bar (right)
modeBar.append('div')
.attr('class', 'quartet-number')
.text(quartet.number ? `#${quartet.number}` : '');
// Create content container
const content = card.append('div')
.attr('class', 'card-content');
// Nickname if exists - appears above key section
if (quartet.nickname) {
content.append('div')
.attr('class', 'nickname')
.text(`"${quartet.nickname}"`);
}
// Key section (aligns with opus number in row header)
const keySection = content.append('div')
.attr('class', 'key-section');
// Key signature (replace -flat with ♭ symbol)
const keyDisplay = quartet.key.replace('-flat', '♭');
keySection.append('div')
.attr('class', 'key-signature')
.text(keyDisplay);
// Major/minor mode
keySection.append('div')
.attr('class', 'key-mode')
.text(quartet.major ? 'major' : 'minor');
// Links container (IMSLP, QR, and Recording) - appended to card, positioned above movement bar
const linksContainer = card.append('div')
.attr('class', 'card-links');
// IMSLP link (left-aligned in grid column 1)
const imslpLink = quartet.imslp || opus.imslp;
if (imslpLink) {
linksContainer.append('a')
.attr('class', 'imslp-link')
.attr('href', imslpLink)
.attr('target', '_blank')
.attr('rel', 'noopener noreferrer')
.text('imslp')
.on('click', function(event) {
event.stopPropagation(); // Prevent card click
});
} else {
// Empty span to maintain grid structure when no IMSLP link
linksContainer.append('span');
}
// QR link (center-aligned in grid column 2) - always present
linksContainer.append('a')
.attr('class', 'qr-link')
.attr('href', `https://quartetroulette.com/boccherini-g${quartet.gerard}/`)
.attr('target', '_blank')
.attr('rel', 'noopener noreferrer')
.text('QR')
.on('click', function(event) {
event.stopPropagation(); // Prevent card click
});
// Recording indicator (right-aligned in grid column 3)
if (quartet.hasRecording) {
linksContainer.append('span')
.attr('class', 'recording-indicator')
.text('♩');
} else {
// Empty span to maintain grid structure when no recording
linksContainer.append('span');
}
// Movement count (appended to card, not content, so it's at the bottom)
const mvmtCount = quartet.mvmts.length;
const movementsDiv = card.append('div')
.attr('class', `movements-count mvmt-${mvmtCount}`);
// Add repeat glyphs for quartets in parts.json
if (quartet.edition) {
movementsDiv.append('span')
.attr('class', 'repeat-start')
.text('𝄆');//.text('x');
}
// Create movement text container
const mvmtText = movementsDiv.append('div')
.attr('class', 'mvmt-text');
// Add movement count text
mvmtText.append('span')
.attr('class', 'mvmt-count-text')
.text(`${mvmtCount} movement${mvmtCount === 1 ? '' : 's'}`);
// Create SVG for movement lines
const lineWidth = 12;
const lineSpacing = 4;
const lineGap = lineWidth + lineSpacing;
// Find all minuet positions (handles multiple minuets like G.202)
const minuetIndices = new Set(
quartet.mvmts.map((m, i) => m.includes('Minuetto') ? i : -1)
.filter(i => i >= 0)
);
const svg = mvmtText.append('svg')
.attr('class', 'mvmt-lines-svg')
.attr('width', mvmtCount * lineGap - lineSpacing)
.attr('height', 3);
// Draw lines: white for minuets, black for others
svg.selectAll('line')
.data(d3.range(mvmtCount))
.enter()
.append('line')
.attr('x1', d => d * lineGap)
.attr('y1', 1.5)
.attr('x2', d => d * lineGap + lineWidth)
.attr('y2', 1.5)
.attr('stroke', d => minuetIndices.has(d) ? 'white' : 'black')
.attr('stroke-width', 1);
if (quartet.edition) {
movementsDiv.append('span')
.attr('class', 'repeat-end')
.text('𝄇');//.text('x');
}
return card;
}
// === AUTO-FLOW CONFIGURATION ===
const excludeFromCombining = new Set([64]); // Historical significance (Boccherini's final opus)
const maxQuartetsPerRow = 4; // Limit combined rows to max 4 quartets (e.g., 1+2 or 2+2)
const maxQuartetsToConsiderForCombining = 2; // Only combine opuses with ≤2 quartets
let currentRowOpuses = [];
let currentRowQuartetCount = 0;
// === RENDERING FUNCTIONS ===
// Render a single opus on its own row
function renderSingleRow(container, opus) {
const row = container.append('div')
.attr('class', 'opus-row');
// Render opus label
renderOpusLabel(row, opus);
// Quartets container
const quartetContainer = row.append('div')
.attr('class', 'quartets-container');
// Render quartet cards
opus.quartets.forEach(quartet => {
renderQuartetCard(quartetContainer, opus, quartet, tooltip, isTouchDevice);
});
}
// Render multiple opuses on a combined row with spacers
function renderCombinedRow(container, ...opuses) {
const row = container.append('div')
.attr('class', 'opus-row combined-pair');
opuses.forEach((opus, index) => {
// Render opus label
renderOpusLabel(row, opus);
// Quartets container for this opus
const quartetContainer = row.append('div')
.attr('class', 'quartets-container');
// Render quartet cards
opus.quartets.forEach(quartet => {
renderQuartetCard(quartetContainer, opus, quartet, tooltip, isTouchDevice);
});
// Add spacer between opus groups (but not after the last one)
if (index < opuses.length - 1) {
row.append('div')
.attr('class', 'opus-spacer');
}
});
}
// === MAIN RENDERING LOOP WITH AUTO-FLOW ===
data.forEach((opus, index) => {
const quartetCount = opus.quartets.length;
if (excludeFromCombining.has(opus.opus) || quartetCount > maxQuartetsToConsiderForCombining) {
// Render accumulated row if any, then render this opus alone
if (currentRowOpuses.length > 0) {
renderCombinedRow(container, ...currentRowOpuses);
currentRowOpuses = [];
currentRowQuartetCount = 0;
}
renderSingleRow(container, opus);
} else {
// Try to add to current row
if (currentRowQuartetCount + quartetCount <= maxQuartetsPerRow) {
currentRowOpuses.push(opus);
currentRowQuartetCount += quartetCount;
} else {
// Current row full, render it and start new row
if (currentRowOpuses.length > 0) {
renderCombinedRow(container, ...currentRowOpuses);
}
currentRowOpuses = [opus];
currentRowQuartetCount = quartetCount;
}
}
});
// Render any remaining accumulated row
if (currentRowOpuses.length > 0) {
renderCombinedRow(container, ...currentRowOpuses);
}
}).catch(error => {
console.error('Error loading JSON data:', error);
d3.select('#visualization')
.append('p')
.style('color', 'red')
.text('Error loading data. Please ensure peters.json, parts.json, and opera.json are in the same directory as this HTML file.');
});
</script>
</body>
</html>
[
{
"opus": 2,
"year": 1761,
"imslp": "https://imslp.org/wiki/6_String_Quartets,_Op.2_(Boccherini,_Luigi)",
"quartets": [
{
"number": 1,
"gerard": 159,
"key": "C",
"major": false,
"imslp": "https://imslp.org/wiki/String_Quartet_in_C_minor%2C_G.159_(Boccherini%2C_Luigi)",
"mvmts": [
"Allegro comodo",
"Largo",
"Allegro"
],
"category": "opera grande"
},
{
"number": 2,
"gerard": 160,
"key": "B-flat",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_B-flat_major%2C_G.160_(Boccherini%2C_Luigi)",
"mvmts": [
"Allegro non tanto",
"Largo",
"Fuga con spirito"
],
"category": "opera grande"
},
{
"number": 3,
"gerard": 161,
"key": "D",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_D_major%2C_G.161_(Boccherini%2C_Luigi)",
"mvmts": [
"Allegro moderato",
"Largo",
"Minuetto"
],
"category": "opera grande"
},
{
"number": 4,
"gerard": 162,
"key": "E-flat",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_E-flat_major%2C_G.162_(Boccherini%2C_Luigi)",
"mvmts": [
"Allegro spiritoso",
"Adagio",
"Minuetto"
],
"category": "opera grande"
},
{
"number": 5,
"gerard": 163,
"key": "E",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_E_major%2C_G.163_(Boccherini%2C_Luigi)",
"mvmts": [
"Allegro moderato",
"Adagio",
"Allegro assai"
],
"category": "opera grande"
},
{
"number": 6,
"gerard": 164,
"key": "C",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_C_major%2C_G.164_(Boccherini%2C_Luigi)",
"mvmts": [
"Allegro con spirito",
"Largo",
"Minuetto"
],
"category": "opera grande"
}
]
},
{
"opus": 8,
"year": 1768,
"dedication": "Infante Luis of Spain",
"quartets": [
{
"number": 1,
"gerard": 165,
"key": "D",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_D_major%2C_G.165_(Boccherini%2C_Luigi)",
"mvmts": [
"Allegro assai",
"Adagio",
"Allegro Rondeau"
],
"category": "opera grande"
},
{
"number": 2,
"gerard": 166,
"key": "C",
"major": false,
"imslp": "https://imslp.org/wiki/String_Quartet_in_C_minor%2C_G.166_(Boccherini%2C_Luigi)",
"mvmts": [
"Moderato",
"Largo",
"Allegro"
],
"category": "opera grande"
},
{
"number": 3,
"gerard": 167,
"key": "E-flat",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_E-flat_major%2C_G.167_(Boccherini%2C_Luigi)",
"mvmts": [
"Largo",
"Allegro",
"Tempo di Minuetto"
],
"category": "opera grande"
},
{
"number": 4,
"gerard": 168,
"key": "G",
"major": false,
"imslp": "https://imslp.org/wiki/String_Quartet_in_G_minor%2C_G.168_(Boccherini%2C_Luigi)",
"mvmts": [
"Allegro",
"Grave",
"Allegro"
],
"category": "opera grande"
},
{
"number": 5,
"gerard": 169,
"key": "F",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_F_major%2C_G.169_(Boccherini%2C_Luigi)",
"mvmts": [
"Andantino",
"Allegro",
"Tempo di Minuetto"
],
"category": "opera grande"
},
{
"number": 6,
"gerard": 170,
"key": "A",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_A_major%2C_G.170_(Boccherini%2C_Luigi)",
"mvmts": [
"Allegro brillante",
"Amoroso",
"Allegro"
],
"category": "opera grande"
}
]
},
{
"opus": 9,
"year": 1770,
"dedication": "Alli Signori Diletanti di Madrid",
"quartets": [
{
"number": 1,
"gerard": 171,
"key": "C",
"major": false,
"imslp": "https://imslp.org/wiki/String_Quartet_in_C_minor%2C_G.171_(Boccherini%2C_Luigi)",
"mvmts": [
"Allegro",
"Larghetto",
"Minuetto",
"Presto"
],
"category": "opera grande"
},
{
"number": 2,
"gerard": 172,
"key": "D",
"major": false,
"imslp": "https://imslp.org/wiki/String_Quartet_in_D_minor%2C_G.172_(Boccherini%2C_Luigi)",
"mvmts": [
"Grave - Allegro",
"Larghetto",
"Allegretto con moto"
],
"category": "opera grande"
},
{
"number": 3,
"gerard": 173,
"key": "F",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_F_major%2C_G.173_(Boccherini%2C_Luigi)",
"mvmts": [
"Largo - Allegro",
"Largo cantabile",
"Minuetto"
],
"category": "opera grande"
},
{
"number": 4,
"gerard": 174,
"key": "E-flat",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_E-flat_major%2C_G.174_(Boccherini%2C_Luigi)",
"mvmts": [
"Adagio",
"Allegro",
"Minuetto"
],
"category": "opera grande"
},
{
"number": 5,
"gerard": 175,
"key": "D",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_D_major%2C_G.175_(Boccherini%2C_Luigi)",
"mvmts": [
"Andante con moto",
"Allegro assai",
"Rondo Allegro"
],
"category": "opera grande"
},
{
"number": 6,
"gerard": 176,
"key": "E",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_E_major%2C_G.176_(Boccherini%2C_Luigi)",
"mvmts": [
"Andante grazioso",
"Allegretto",
"Minuetto",
"Allegro assai"
],
"category": "opera grande"
}
]
},
{
"opus": 15,
"year": 1772,
"quartets": [
{
"number": 1,
"gerard": 177,
"key": "D",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_D_major%2C_G.177_(Boccherini%2C_Luigi)",
"mvmts": [
"Presto",
"Allegro rondeau"
],
"category": "opera piccola"
},
{
"number": 2,
"gerard": 178,
"key": "F",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_F_major%2C_G.178_(Boccherini%2C_Luigi)",
"mvmts": [
"Allegretto",
"Allegro Minuetto"
],
"category": "opera piccola"
},
{
"number": 3,
"gerard": 179,
"key": "E",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_E_major%2C_G.179_(Boccherini%2C_Luigi)",
"mvmts": [
"Andantino",
"Prestissimo"
],
"category": "opera piccola"
},
{
"number": 4,
"gerard": 180,
"key": "F",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_F_major%2C_G.180_(Boccherini%2C_Luigi)",
"mvmts": [
"Prestissimo",
"Minuetto"
],
"category": "opera piccola"
},
{
"number": 5,
"gerard": 181,
"key": "E-flat",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_E-flat_major%2C_G.181_(Boccherini%2C_Luigi)",
"mvmts": [
"Adagio con sordina",
"Minuetto"
],
"category": "opera piccola"
},
{
"number": 6,
"gerard": 182,
"key": "C",
"major": false,
"imslp": "https://imslp.org/wiki/String_Quartet_in_C_minor%2C_G.182_(Boccherini%2C_Luigi)",
"mvmts": [
"Larghetto",
"Minuetto. Allegro moderato"
],
"category": "opera piccola"
}
]
},
{
"opus": 22,
"year": 1775,
"imslp": "https://imslp.org/wiki/6_String_Quartets,_G.183-188_(Op.22)_(Boccherini,_Luigi)",
"quartets": [
{
"number": 1,
"gerard": 183,
"key": "C",
"major": true,
"mvmts": [
"Allegro molto",
"Tempo di Minuetto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 2,
"gerard": 184,
"key": "D",
"major": true,
"mvmts": [
"Moderato",
"Minuetto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 3,
"gerard": 185,
"key": "E-flat",
"major": true,
"mvmts": [
"Adagio",
"Rondeau Allegro"
],
"category": "quartettino ... opera piccola"
},
{
"number": 4,
"gerard": 186,
"key": "B-flat",
"major": true,
"mvmts": [
"Allegretto moderato",
"Allegro vivace"
],
"category": "quartettino ... opera piccola"
},
{
"number": 5,
"gerard": 187,
"key": "A",
"major": false,
"mvmts": [
"Allegro con molto",
"Minuetto Amoroso"
],
"category": "quartettino ... opera piccola"
},
{
"number": 6,
"gerard": 188,
"key": "C",
"major": true,
"mvmts": [
"Andantino",
"Non troppo presto"
],
"category": "quartettino ... opera piccola"
}
]
},
{
"opus": 24,
"year": 1777,
"imslp": "https://imslp.org/wiki/6_String_Quartets,_G.189-194_(Op.24)_(Boccherini,_Luigi)",
"quartets": [
{
"number": 1,
"gerard": 189,
"key": "D",
"major": true,
"mvmts": [
"Moderato",
"Grave",
"Allegro assai"
],
"category": "opera grande"
},
{
"number": 2,
"gerard": 190,
"key": "A",
"major": true,
"mvmts": [
"Larghetto",
"Allegro spiritoso",
"Minuetto Amoroso"
],
"category": "opera grande"
},
{
"number": 3,
"gerard": 191,
"key": "E-flat",
"major": true,
"mvmts": [
"Allegro moderato",
"Adagio non tanto",
"Minuetto"
],
"category": "opera grande"
},
{
"number": 4,
"gerard": 192,
"key": "C",
"major": true,
"mvmts": [
"Moderato",
"Larghetto",
"Minuetto"
],
"category": "opera grande"
},
{
"number": 5,
"gerard": 193,
"key": "C",
"major": false,
"mvmts": [
"Allegro moderato",
"Larghetto",
"Allegro molto"
],
"category": "opera grande"
},
{
"number": 6,
"gerard": 194,
"key": "G",
"major": false,
"mvmts": [
"Allegro vivo assai",
"Adagio",
"Minuetto"
],
"category": "opera grande"
}
]
},
{
"opus": 26,
"year": 1778,
"imslp": "https://imslp.org/wiki/6_String_Quartets,_G.195-200_(Op.26)_(Boccherini,_Luigi)",
"dedication": "Monsieur le Baron du Beine de Malchamps",
"quartets": [
{
"number": 1,
"gerard": 195,
"key": "B-flat",
"major": true,
"mvmts": [
"Allegro moderato",
"Minuetto con moto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 2,
"gerard": 196,
"key": "G",
"major": false,
"mvmts": [
"Larghetto",
"Minuetto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 3,
"gerard": 197,
"key": "E-flat",
"major": true,
"mvmts": [
"Allegro vivace",
"Minuetto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 4,
"gerard": 198,
"key": "A",
"major": true,
"mvmts": [
"Larghetto",
"Minuetto con moto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 5,
"gerard": 199,
"key": "F",
"major": true,
"mvmts": [
"Allegretto",
"Minuetto Allegro"
],
"category": "quartettino ... opera piccola"
},
{
"number": 6,
"gerard": 200,
"key": "F",
"major": false,
"mvmts": [
"Andante appassionato ma con lento",
"Minuetto"
],
"category": "quartettino ... opera piccola"
}
]
},
{
"opus": 32,
"year": 1780,
"imslp": "https://imslp.org/wiki/6_String_Quartets,_G.201-206_(Op.32)_(Boccherini,_Luigi)",
"quartets": [
{
"number": 1,
"gerard": 201,
"key": "E-flat",
"major": true,
"mvmts": [
"Allegretto lentarello e affettuoso",
"Minuetto",
"Grave",
"Allegro vivace assai"
],
"category": "opera grande"
},
{
"number": 2,
"gerard": 202,
"key": "E",
"major": false,
"mvmts": [
"Largo sostenuto",
"Minuetto",
"Larghetto",
"Minuetto (same!)",
"Rondeau comodo assai"
],
"category": "opera grande"
},
{
"number": 3,
"gerard": 203,
"key": "D",
"major": true,
"mvmts": [
"Allegro vivo",
"Adagio",
"Allegro vivo ma non presto"
],
"category": "opera grande"
},
{
"number": 4,
"gerard": 204,
"key": "C",
"major": true,
"mvmts": [
"Allegro bizarro",
"Larghetto",
"Allegro con brio"
],
"category": "opera grande"
},
{
"number": 5,
"gerard": 205,
"key": "G",
"major": false,
"mvmts": [
"Allegro comodo",
"Andantino",
"Minuetto con moto",
"Allegro giusto"
],
"category": "opera grande"
},
{
"number": 6,
"gerard": 206,
"key": "A",
"major": true,
"mvmts": [
"Allegro",
"Andante lentarello",
"Minuetto con moto",
"Presto assai"
],
"category": "opera grande"
}
]
},
{
"opus": 33,
"year": 1781,
"quartets": [
{
"number": 1,
"gerard": 207,
"key": "E",
"major": true,
"mvmts": [
"Allegro spiritoso",
"Rondeau. Allegretto ma con moto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 2,
"gerard": 208,
"key": "C",
"major": true,
"mvmts": [
"Allegretto",
"Minuetto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 3,
"gerard": 209,
"key": "G",
"major": true,
"mvmts": [
"Andante con moto",
"Presto assai"
],
"category": "quartettino ... opera piccola"
},
{
"number": 4,
"gerard": 210,
"key": "B-flat",
"major": true,
"mvmts": [
"Andante lentarello",
"Minuetto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 5,
"gerard": 211,
"key": "E",
"major": false,
"mvmts": [
"Allegro brillante",
"Allegro vivo assai"
],
"category": "quartettino ... opera piccola"
},
{
"number": 6,
"gerard": 212,
"key": "E-flat",
"major": true,
"mvmts": [
"Adagio",
"Minuetto. Affetuoso"
],
"category": "quartettino ... opera piccola"
}
]
},
{
"opus": 39,
"year": 1787,
"quartets": [
{
"number": null,
"gerard": 213,
"key": "A",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_A_major%2C_G.213_(Boccherini%2C_Luigi)",
"mvmts": [
"Allegro moderato",
"Minuetto",
"Grave",
"Allegro giusto"
],
"category": "opera grande"
}
]
},
{
"opus": 41,
"year": 1788,
"imslp": "https://imslp.org/wiki/2_String_Quartets,_G.214-215_(Op.41)_(Boccherini,_Luigi)",
"quartets": [
{
"number": 1,
"gerard": 214,
"key": "C",
"major": false,
"mvmts": [
"Prestissimo",
"Tempo di Minuetto",
"Andante Flebile",
"Prestissimo (repeat of second half of 1)"
],
"category": "opera grande"
},
{
"number": 2,
"gerard": 215,
"key": "C",
"major": true,
"mvmts": [
"Allegretto moderato assai",
"Minuetto. Allegro",
"Larghetto affettuoso",
"Rondeau. Allegro moderato"
],
"category": "opera grande"
}
]
},
{
"opus": 42,
"year": 1789,
"quartets": [
{
"number": 1,
"gerard": 216,
"key": "A",
"major": true,
"mvmts": [
"Allegretto moderato",
"Minuetto. Allegro"
],
"category": "quartettino ... opera piccola"
},
{
"number": 2,
"gerard": 217,
"key": "C",
"major": true,
"mvmts": [
"Andante",
"Minuetto"
],
"category": "quartettino ... opera piccola"
}
]
},
{
"opus": 43,
"year": 1790,
"quartets": [
{
"number": 1,
"gerard": 218,
"key": "A",
"major": true,
"mvmts": [
"Allegretto moderato",
"Tempo di Minuetto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 2,
"gerard": 219,
"key": "A",
"major": true,
"mvmts": [
"Allegretto con moto",
"Minuetto"
],
"category": "quartettino ... opera piccola"
}
]
},
{
"opus": 44,
"year": 1792,
"quartets": [
{
"number": 1,
"gerard": 220,
"key": "B-flat",
"major": true,
"mvmts": [
"Maestoso assai",
"Tempo di Minuetto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 2,
"gerard": 221,
"key": "E",
"major": false,
"mvmts": [
"Andante larghetto",
"Minuetto amoroso",
"Andante allegretto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 3,
"gerard": 222,
"key": "F",
"major": true,
"mvmts": [
"Lento assai",
"Allegretto con moto",
"Tempo di Minuetto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 4,
"gerard": 223,
"key": "G",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_G_major%2C_G.223_'La_Tiranna'_(Boccherini%2C_Luigi)",
"nickname": "la tiranna",
"mvmts": [
"Presto",
"Tempo di Minuetto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 5,
"gerard": 224,
"key": "D",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_D_major%2C_G.224_(Boccherini%2C_Luigi)",
"mvmts": [
"Andantino lento",
"Allegro non tanto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 6,
"gerard": 225,
"key": "E-flat",
"major": true,
"mvmts": [
"Andantino",
"Minuetto on moto"
],
"category": "quartettino ... opera piccola"
}
]
},
{
"opus": 48,
"year": 1794,
"quartets": [
{
"number": 1,
"gerard": 226,
"key": "F",
"major": true,
"mvmts": [
"Andante moderato",
"Moderato con moto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 2,
"gerard": 227,
"key": "A",
"major": true,
"mvmts": [
"Andante lento",
"Tempo di Minuetto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 3,
"gerard": 228,
"key": "B",
"major": false,
"mvmts": [
"Allegretto moderato",
"Minuetto con moto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 4,
"gerard": 229,
"key": "E-flat",
"major": true,
"mvmts": [
"Andantino lento",
"Minuetto con un poco di moto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 5,
"gerard": 230,
"key": "G",
"major": true,
"mvmts": [
"Larghetto",
"Minuetto con un poco di moto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 6,
"gerard": 231,
"key": "C",
"major": true,
"mvmts": [
"Allegro vivace",
"Tempo di Minuetto affettuoso"
],
"category": "quartettino ... opera piccola"
}
]
},
{
"opus": 52,
"year": 1795,
"imslp": "https://imslp.org/wiki/4_String_Quartets,_G.232-235_(Op.52)_(Boccherini,_Luigi)",
"quartets": [
{
"number": 1,
"gerard": 232,
"key": "C",
"major": true,
"mvmts": [
"Allegro con moto",
"Minuetto",
"Adagio",
"Finale. Allegro giusto"
],
"category": "opera grande"
},
{
"number": 2,
"gerard": 233,
"key": "D",
"major": true,
"mvmts": [
"Allegro vivace assai",
"Andantino Patetico",
"Minuetto",
"Rondeau. Allegretto"
],
"category": "opera grande"
},
{
"number": 3,
"gerard": 234,
"key": "G",
"major": true,
"mvmts": [
"Allegretto con moto",
"Minuetto",
"Adagio",
"Rondeau. Allegro giusto"
],
"category": "opera grande"
},
{
"number": 4,
"gerard": 235,
"key": "F",
"major": false,
"mvmts": [
"Allegretto appassionato",
"Minuetto con modo",
"Adagio non tanto",
"Finale. Allegro assai"
],
"category": "opera grande"
}
]
},
{
"opus": 53,
"year": 1796,
"quartets": [
{
"number": 1,
"gerard": 236,
"key": "E-flat",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_E-flat_major%2C_G.236_(Boccherini%2C_Luigi)",
"mvmts": [
"Allegro (che appena si senta)",
"Tempo di Minuetto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 2,
"gerard": 237,
"key": "D",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_D_major%2C_G.237_(Boccherini%2C_Luigi)",
"mvmts": [
"Andantino Pausato",
"Minuetto. Allegro"
],
"category": "quartettino ... opera piccola"
},
{
"number": 3,
"gerard": 238,
"key": "C",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_C_major%2C_G.238_(Boccherini%2C_Luigi)",
"mvmts": [
"Allegro vivace",
"Minuetto. Allegro"
],
"category": "quartettino ... opera piccola"
},
{
"number": 4,
"gerard": 239,
"key": "A",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_A_major%2C_G.239_(Boccherini%2C_Luigi)",
"mvmts": [
"Allegro vivace",
"Rondeau. Allegro"
],
"category": "quartettino ... opera piccola"
},
{
"number": 5,
"gerard": 240,
"key": "C",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_C_major%2C_G.240_(Boccherini%2C_Luigi)",
"mvmts": [
"Andantino lento",
"Tempo di Minuetto"
],
"category": "quartettino ... opera piccola"
},
{
"number": 6,
"gerard": 241,
"key": "E-flat",
"major": true,
"imslp": "https://imslp.org/wiki/String_Quartet_in_E-flat_major%2C_G.241_(Boccherini%2C_Luigi)",
"mvmts": [
"Andantino amoroso",
"Minuetto. Allegro"
],
"category": "quartettino ... opera piccola"
}
]
},
{
"opus": 58,
"year": 1799,
"imslp": "https://imslp.org/wiki/6_String_Quartets,_G.242-247_(Op.58)_(Boccherini,_Luigi)",
"quartets": [
{
"number": 1,
"gerard": 242,
"key": "C",
"major": true,
"mvmts": [
"Allegro",
"Larghetto",
"Allegro vivo assai"
],
"category": "opera grande"
},
{
"number": 2,
"gerard": 243,
"key": "E-flat",
"major": true,
"mvmts": [
"Allegretto lento",
"Minuetto. Allegro",
"Larghetto Malincolico",
"Finale. Allegro vivo assai"
],
"category": "opera grande"
},
{
"number": 3,
"gerard": 244,
"key": "B-flat",
"major": true,
"mvmts": [
"Larghetto",
"Allegro vivo assai",
"Rondeau. Allegro giusto"
],
"category": "opera grande"
},
{
"number": 4,
"gerard": 245,
"key": "B",
"major": false,
"mvmts": [
"Allegro molto",
"Andantino lento",
"Rondeau. Allegro"
],
"category": "opera grande"
},
{
"number": 5,
"gerard": 246,
"key": "D",
"major": true,
"nickname": "Le Cornamuse",
"mvmts": [
"Andante sostenuto",
"Allegretto Gajo",
"Andante sostenuto como primo",
"Presto"
],
"category": "opera grande"
},
{
"number": 6,
"gerard": 247,
"key": "E-flat",
"major": true,
"mvmts": [
"Allegretto moderato",
"Larghetto",
"Allegro giusto e sostenuto"
],
"category": "opera grande"
}
]
},
{
"opus": 64,
"year": 1804,
"dedication": "Luciano Bonaparte",
"quartets": [
{
"number": 1,
"gerard": 248,
"key": "F",
"major": true,
"mvmts": [
"Allegro molto",
"Adagio non tanto",
"Allegro vivo ma non presto"
],
"category": "opera grande"
},
{
"number": 2,
"gerard": 249,
"key": "D",
"major": true,
"mvmts": [
"Allegro con brio"
],
"category": "opera grande"
}
]
}
]
[
{"Gerard": 159, "edition": "Edition Moeck", "opus": "2#1"},
{"Gerard": 160, "edition": "Edition Moeck", "opus": "2#2"},
{"Gerard": 161, "edition": "Edition Moeck", "opus": "2#3"},
{"Gerard": 162, "edition": "Edition Moeck", "opus": "2#4"},
{"Gerard": 163, "edition": "Edition Moeck", "opus": "2#5"},
{"Gerard": 164, "edition": "Edition Moeck", "opus": "2#6"},
{"Gerard": 170, "edition": "Peters", "opus": "8#6"},
{"Gerard": 172, "edition": "Peters", "opus": "9#2"},
{"Gerard": 176, "edition": "Peters", "opus": "9#6"},
{"Gerard": 177, "edition": "Peters", "opus": "15#1"},
{"Gerard": 194, "edition": "Peters", "opus": "24#6"},
{"Gerard": 198, "edition": "Peters", "opus": "26#4"},
{"Gerard": 201, "edition": "Edition Steglein", "opus": "32#1"},
{"Gerard": 202, "edition": "Edition Steglein", "opus": "32#2"},
{"Gerard": 203, "edition": "Edition Steglein", "opus": "32#3"},
{"Gerard": 204, "edition": "Edition Steglein", "opus": "32#4"},
{"Gerard": 205, "edition": "Edition Steglein", "opus": "32#5"},
{"Gerard": 206, "edition": "Edition Steglein", "opus": "32#6"},
{"Gerard": 207, "edition": "Simrock", "opus": "33#1"},
{"Gerard": 208, "edition": "Simrock", "opus": "33#2"},
{"Gerard": 209, "edition": "Simrock", "opus": "33#3"},
{"Gerard": 237, "edition": "Heinrichshofen", "opus": "53#2"},
{"Gerard": 243, "edition": "Ricordi", "opus": "58#2"},
{"Gerard": 245, "edition": "Ricordi", "opus": "58#4"},
{"Gerard": 247, "edition": "Ricordi", "opus": "58#6"},
{"Gerard": 232, "edition": "Peters", "opus": "52#1"}
]
[
{"number": 1, "label": "8#5", "actual": "15#1", "Gerard": 177, "glabel": 169},
{"number": 2, "label": "32#4", "actual": "26#4", "Gerard": 198, "glabel": 204},
{"number": 3, "label": "6#6", "actual": "8#6", "Gerard": 170, "glabel": null},
{"number": 4, "label": "10#2", "actual": "9#2", "Gerard": 172, "glabel": null},
{"number": 5, "label": "27#2", "actual": "24#6", "Gerard": 194, "glabel": null},
{"number": 6, "label": "10#6", "actual": "9#6", "Gerard": 176, "glabel": null},
{"number": 7, "label": "33#5", "actual": "32#5", "Gerard": 205, "glabel": 211},
{"number": 8, "label": "33#6", "actual": "32#6", "Gerard": 206, "glabel": 212},
{"number": 9, "label": "39#1", "actual": "52#1", "Gerard": 232, "glabel": 213}
]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment