|
<!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 === */ |
|
/* Cards smoothly scale from 140px (desktop) to 80px (mobile) */ |
|
--card-height: clamp(80px, 12vw, 140px); |
|
--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; |
|
} |
|
|
|
.header-group { |
|
/* Header centered over visualization at all widths */ |
|
display: flex; |
|
flex-direction: column; |
|
align-items: stretch; /* Children stretch to container width */ |
|
width: fit-content; /* Shrink to fit widest child (title) */ |
|
max-width: 100%; /* Don't overflow viewport */ |
|
margin: 0 auto; /* Center the group */ |
|
} |
|
|
|
h1 { |
|
margin: 0 0 5px 0; /* Small gap below */ |
|
text-align: center; |
|
color: #333; |
|
font-size: clamp(1.3em, 1.8vw, 2em); /* Scales smoothly */ |
|
} |
|
|
|
.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; |
|
} |
|
|
|
.container { |
|
max-width: 1400px; |
|
margin: 0 auto; |
|
display: flex; |
|
flex-direction: column; |
|
align-items: center; |
|
} |
|
|
|
.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: clamp(50px, 8vw, 95px); |
|
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: 22px; /* 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: wrap; |
|
gap: clamp(6px, 0.8vw, 10px); |
|
margin-left: clamp(6px, 0.8vw, 10px); /* Gap between label and cards */ |
|
} |
|
|
|
/* Spacer between opus groups in combined rows */ |
|
.opus-spacer { |
|
width: clamp(120px, 15vw, 177px); /* Scales with viewport */ |
|
height: var(--card-height); |
|
flex-shrink: 0; |
|
margin: 0 clamp(6px, 0.8vw, 10px); /* Gap on both sides */ |
|
} |
|
|
|
.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 4px; /* 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: 22px; /* 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: 5px; |
|
} |
|
|
|
/* === 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; |
|
} |
|
|
|
.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) === */ |
|
/* Mini version of desktop - scaled down, no scrolling, pinch-to-zoom */ |
|
@media (max-width: 767px) { |
|
/* Override responsive sizing with fixed desktop values */ |
|
:root { |
|
--card-height: 140px; |
|
--top-section-height: 20px; |
|
--nickname-height: 15px; |
|
--bottom-section-height: 20px; |
|
} |
|
|
|
body { |
|
margin: 0; |
|
/* Scale to fit ~1000px content into 375px viewport */ |
|
/* Use transform instead of zoom for Safari compatibility */ |
|
transform: scale(0.37); |
|
transform-origin: 0 0; |
|
width: calc(100% / 0.37); /* Compensate: content laid out at ~270% then scaled down */ |
|
} |
|
|
|
h1 { |
|
font-size: 2em; /* Fixed desktop size */ |
|
} |
|
|
|
.opus-label { |
|
width: 95px; /* Fixed desktop width */ |
|
} |
|
|
|
.quartets-container { |
|
flex-wrap: nowrap; /* Prevent cards from wrapping */ |
|
gap: 10px; |
|
margin-left: 10px; |
|
} |
|
|
|
.opus-spacer { |
|
width: 177px; |
|
margin: 0 10px; |
|
} |
|
|
|
/* Prevent text wrapping in bottom sections */ |
|
.movements-count, |
|
.opus-label .category-badge { |
|
white-space: nowrap; |
|
overflow: hidden; |
|
} |
|
|
|
/* Tighten repeat sign spacing */ |
|
.movements-count .repeat-start { |
|
margin-left: -4px; |
|
} |
|
|
|
.movements-count .repeat-end { |
|
margin-right: -4px; |
|
} |
|
|
|
.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]) => { |
|
// 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 |
|
operaData.forEach(opus => { |
|
opus.quartets.forEach(quartet => { |
|
// 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); |
|
} |
|
}); |
|
}); |
|
|
|
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> |