|
<!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 { |
|
/* === ALIGNMENT SYSTEM === */ |
|
--card-height: 140px; |
|
--top-section-height: 20px; |
|
--nickname-height: 15px; /* Fixed height for nickname section */ |
|
--bottom-section-height: 20px; /* Fixed height for badge/movement count */ |
|
|
|
/* 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) |
|
); |
|
/* = 140px - 20px - 15px - 20px = 85px */ |
|
|
|
/* 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 */ |
|
} |
|
|
|
/* 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 { |
|
margin-left: 95px; /* Shift right by opus-label width */ |
|
} |
|
|
|
h1 { |
|
max-width: var(--header-width); |
|
margin: 0 auto 5px auto; /* Center horizontally, small gap below */ |
|
text-align: center; |
|
color: #333; |
|
font-size: 2em; |
|
} |
|
|
|
.subtitle { |
|
max-width: var(--header-width); /* Same width as title for alignment */ |
|
margin: 0 auto 25px auto; /* Center horizontally, larger gap below */ |
|
text-align: right; /* Right-align text within container */ |
|
color: #666; |
|
font-size: 0.9em; |
|
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: 15px; |
|
} |
|
|
|
/* 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 */ |
|
width: 95px; |
|
height: var(--card-height); |
|
padding: 2px 6px 0 10px; /* 2px top, 6px right, 0 bottom, 10px left */ |
|
|
|
/* 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: 10px; |
|
margin-left: 10px; /* Gap between label and cards */ |
|
} |
|
|
|
/* Spacer between opus groups in combined rows */ |
|
.opus-spacer { |
|
width: 177px; /* Calculated for pixel-perfect vertical alignment */ |
|
height: var(--card-height); |
|
flex-shrink: 0; |
|
margin: 0 10px; /* Gap on both sides */ |
|
} |
|
|
|
.quartet-card { |
|
width: 140px; |
|
height: var(--card-height); /* Matches opus-label 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; |
|
} |
|
|
|
.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 and QR links */ |
|
.card-links { |
|
display: flex; |
|
justify-content: space-between; /* IMSLP left, QR right */ |
|
align-items: center; |
|
padding: 2px 4px; |
|
font-size: 0.65em; |
|
font-weight: 400; |
|
} |
|
|
|
.imslp-link, |
|
.qr-link { |
|
color: #2196F3; |
|
text-decoration: none; |
|
} |
|
|
|
.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; /* No padding - height is explicit */ |
|
border-radius: 0; /* Square edges like top bar */ |
|
font-weight: 500; |
|
line-height: 1; /* Match category badge */ |
|
} |
|
|
|
/* 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; /* Only top margin */ |
|
size: letter portrait; |
|
} |
|
|
|
body { |
|
font-size: 9pt; |
|
background: white !important; |
|
zoom: 0.73; /* Scale to 73% to fit on page */ |
|
} |
|
|
|
/* Page break controls */ |
|
h1, .subtitle { |
|
page-break-after: avoid; |
|
} |
|
|
|
.opus-row { |
|
page-break-inside: avoid; |
|
break-inside: avoid; |
|
} |
|
|
|
/* Clean aesthetics for print */ |
|
.opus-label { |
|
background: none !important; |
|
} |
|
|
|
.opus-label.grande-bg, |
|
.opus-label.piccola-bg { |
|
background: none !important; |
|
} |
|
|
|
.quartet-card { |
|
background: none !important; |
|
box-shadow: none !important; |
|
border: 1px solid #999; |
|
} |
|
|
|
.mode-bar.major { |
|
background: transparent !important; |
|
} |
|
|
|
/* Hide interactive elements */ |
|
.tooltip { |
|
display: none !important; |
|
} |
|
|
|
/* Ensure links are visible */ |
|
.imslp-link, |
|
.qr-link { |
|
color: #2196F3; |
|
} |
|
} |
|
|
|
/* === MOBILE RESPONSIVE === */ |
|
@media (max-width: 768px) { |
|
body { |
|
margin: 10px; |
|
} |
|
|
|
.header-group { |
|
margin-left: 0; |
|
text-align: center; |
|
} |
|
|
|
h1 { |
|
font-size: 1.5em; |
|
margin-bottom: 10px; |
|
} |
|
|
|
.subtitle { |
|
font-size: 0.8em; |
|
text-align: center; |
|
margin-bottom: 15px; |
|
} |
|
|
|
.container { |
|
max-width: 100%; |
|
align-items: flex-start; |
|
} |
|
|
|
.opus-row { |
|
flex-wrap: wrap; |
|
margin-bottom: 10px; |
|
width: 100%; |
|
} |
|
|
|
/* On mobile, opus label takes full width, then cards wrap below */ |
|
.opus-label { |
|
width: 100%; |
|
text-align: left; |
|
height: auto; |
|
min-height: var(--card-height); |
|
margin-bottom: 5px; |
|
} |
|
|
|
.quartets-container { |
|
margin-left: 0; |
|
width: 100%; |
|
justify-content: center; |
|
} |
|
|
|
/* Hide spacers on mobile - not needed when rows wrap */ |
|
.opus-spacer { |
|
display: none; |
|
} |
|
} |
|
</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 the opera.json data |
|
d3.json('opera.json').then(data => { |
|
const container = d3.select('#visualization'); |
|
|
|
// Create tooltip |
|
const tooltip = d3.select('body') |
|
.append('div') |
|
.attr('class', 'tooltip') |
|
.style('opacity', 0); |
|
|
|
// === 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) { |
|
let activeTooltip = false; |
|
|
|
const showTooltip = function(event) { |
|
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; |
|
|
|
tooltip.html(tooltipText) |
|
.style('left', (x + 10) + 'px') |
|
.style('top', (y - 10) + 'px') |
|
.style('opacity', 1); |
|
|
|
activeTooltip = true; |
|
}; |
|
|
|
const hideTooltip = function() { |
|
d3.select(this).style('border-color', '#ddd'); |
|
tooltip.style('opacity', 0); |
|
activeTooltip = false; |
|
}; |
|
|
|
const card = parent.append('div') |
|
.attr('class', 'quartet-card') |
|
.on('mouseover', showTooltip) |
|
.on('mouseout', hideTooltip) |
|
.on('click', function(event) { |
|
// Toggle tooltip on mobile tap |
|
if ('ontouchstart' in window) { |
|
event.preventDefault(); |
|
if (activeTooltip) { |
|
hideTooltip.call(this); |
|
} else { |
|
showTooltip.call(this, event); |
|
} |
|
} |
|
}); |
|
|
|
// 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 and QR) - appended to card, positioned above movement bar |
|
const linksContainer = card.append('div') |
|
.attr('class', 'card-links'); |
|
|
|
// IMSLP link (left-aligned) |
|
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 spacing when no IMSLP link |
|
linksContainer.append('span'); |
|
} |
|
|
|
// QR link (right-aligned) - 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 |
|
}); |
|
|
|
// Movement count (appended to card, not content, so it's at the bottom) |
|
const mvmtCount = quartet.mvmts.length; |
|
card.append('div') |
|
.attr('class', `movements-count mvmt-${mvmtCount}`) |
|
.text(`${mvmtCount} movement${mvmtCount === 1 ? '' : 's'}`); |
|
|
|
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); |
|
}); |
|
} |
|
|
|
// 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); |
|
}); |
|
|
|
// 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 opera.json:', error); |
|
d3.select('#visualization') |
|
.append('p') |
|
.style('color', 'red') |
|
.text('Error loading data. Please ensure opera.json is in the same directory as this HTML file.'); |
|
}); |
|
</script> |
|
</body> |
|
</html> |