Skip to content

Commit 5b6f25f

Browse files
committed
fix(demystify): draw the spectrum as SVG, default to the light theme
The block-character chart could not tile in a browser. A font's FULL BLOCK glyph fills the em box, which is not the height of a `line-height: 1` line box, so every bar showed horizontal seams and ragged tops, with subpixel colour fringing on the edges. Draw it as SVG rects instead; the hand-drawn banners stay as characters, since they do not depend on glyphs lining up. Rename the geometry module to spectrumScale.js. As spectrum.js it collided by case with the new Spectrum.jsx, and on a case-insensitive filesystem the component import resolved to the geometry module — React rendered a plain function as a component and silently produced nothing. Make light the default theme, and move its palette onto bare :root so the prerendered first paint matches instead of flashing dark before hydration. Remove Ripperland, The Uninvited, Discombobulated, Portals Of The Flying Dutchman and They Call This (Hip Hop). Pin preview clips for Live On Live Long, Real Ni***s and Rollin' Down The Freeway.
1 parent 86184ee commit 5b6f25f

9 files changed

Lines changed: 264 additions & 171 deletions

File tree

public/demystify/genre/boom-bap.txt

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ prod:
2929
tracks:
3030
- Still Dreaming | Nas, Kanye West, Chrisette Michele
3131
- Love is... | Common
32-
- The Uninvited | Breeze Brewin, Juggaknots, Black Milk
3332
- Scottie Pippens | Curren$y, The Alchemist, Freddie Gibbs
3433
- Battle Cry | Joell Ortiz, Just Blaze
3534
- Fist Full of Dollars | Nature, Dr. Dre
@@ -41,7 +40,7 @@ tracks:
4140
- Above The Clouds | Gang Starr, Inspectah Deck
4241
- Meet Joe Black | Nas
4342
- What It's Worth | Black Milk
44-
- Rollin' Down The Freeway | Statik Selektah, Glasses Malone, Kali, Termanology
43+
- Rollin' Down The Freeway | Statik Selektah, Glasses Malone, Kali, Termanology | https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview115/v4/7c/25/d3/7c25d3cf-16b2-cff4-142b-8fa04e35d25d/mzaf_8807513309246383750.plus.aac.ep.m4a
4544
- Trouble in the Water | dj honda, De La Soul
4645
- Welcome To New York City | Cam'ron, JAY-Z, Juelz Santana
4746
- TEMPTATION | Joey Bada$$
@@ -60,15 +59,14 @@ tracks:
6059
- You're da Man | Nas
6160
- Trouble Man | Juggaknots
6261
- Belize (feat. MF DOOM) | Danger Mouse, Black Thought, MF DOOM
63-
- Ripperland | Canibus, The Goddess Psalm One
6462
- Sniper Elite | J Dilla, MF DOOM
6563
- Survival Test | Jaylib, Madlib, J Dilla
6664
- Book Of Rhymes | Nas
6765
- Chef Dreds | Griselda
6866
- Publicity | GZA
69-
- Real Ni***s | The Diplomats
67+
- Real Ni***s | The Diplomats | https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview221/v4/c4/69/c6/c469c6d3-944a-e472-7beb-432e2c057b25/mzaf_7043771456860144142.plus.aac.ep.m4a
7068
- Exhibit C | JAYELECTRONICA
71-
- Live On Live Long | Capone-N-Noreaga
69+
- Live On Live Long | Capone-N-Noreaga | https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview116/v4/8e/3a/92/8e3a92fb-34c6-a6fd-628b-81cf37966b19/mzaf_165728014347235677.plus.aac.ep.m4a
7270
- Plane Gang | Reks, Akrobatik, Edo. G, Termanology, DJ Deadeye
7371
- Bad Name - Remix | Gang Starr, Redman, Method Man
7472
- The Ghetto | Beanie Sigel
@@ -92,7 +90,6 @@ tracks:
9290
- Watch How It Go Down | Termanology
9391
- Hustle | M.O.P., Mr. Probz
9492
- Losing Out (feat. Royce Da 5'9) | Black Milk, Royce Da 5'9"
95-
- Discombobulated | Eminem
9693
- Bullet (feat. Citizen Cope) | Rhymefest, Citizen Cope
9794
- We As Americans | Eminem
9895
- Better Future | Mr. Green, DJ Kool Herc
@@ -134,9 +131,7 @@ tracks:
134131
- The Spark | GZA
135132
- That'll Work | The Alchemist
136133
- New Thangs | Curren$y, Freddie Gibbs, The Alchemist
137-
- Portals Of The Flying Dutchman | The White Shadow
138134
- Assassination Day | DJ Muggs, MF DOOM, Kool G Rap
139135
- Fine And Free (feat. Vivian Green) | Guru, Solar, Vivian Green
140136
- Moment Of Truth | Gang Starr
141137
- Feel It In The Air | Beanie Sigel, Melissa Jay
142-
- They Call This (Hip Hop) - Feat. Royce Da 5'9 and B.O.B | Classified

public/demystify/genre/index.txt

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,6 @@ freqs: 68.94, 86.41, 335.14
338338
tracks:
339339
- Still Dreaming | Nas, Kanye West, Chrisette Michele
340340
- Love is... | Common
341-
- The Uninvited | Breeze Brewin, Juggaknots, Black Milk
342341
- Scottie Pippens | Curren$y, The Alchemist, Freddie Gibbs
343342
- Battle Cry | Joell Ortiz, Just Blaze
344343
- Fist Full of Dollars | Nature, Dr. Dre
@@ -350,7 +349,7 @@ tracks:
350349
- Above The Clouds | Gang Starr, Inspectah Deck
351350
- Meet Joe Black | Nas
352351
- What It's Worth | Black Milk
353-
- Rollin' Down The Freeway | Statik Selektah, Glasses Malone, Kali, Termanology
352+
- Rollin' Down The Freeway | Statik Selektah, Glasses Malone, Kali, Termanology | https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview115/v4/7c/25/d3/7c25d3cf-16b2-cff4-142b-8fa04e35d25d/mzaf_8807513309246383750.plus.aac.ep.m4a
354353
- Trouble in the Water | dj honda, De La Soul
355354
- Welcome To New York City | Cam'ron, JAY-Z, Juelz Santana
356355
- TEMPTATION | Joey Bada$$
@@ -369,15 +368,14 @@ tracks:
369368
- You're da Man | Nas
370369
- Trouble Man | Juggaknots
371370
- Belize (feat. MF DOOM) | Danger Mouse, Black Thought, MF DOOM
372-
- Ripperland | Canibus, The Goddess Psalm One
373371
- Sniper Elite | J Dilla, MF DOOM
374372
- Survival Test | Jaylib, Madlib, J Dilla
375373
- Book Of Rhymes | Nas
376374
- Chef Dreds | Griselda
377375
- Publicity | GZA
378-
- Real Ni***s | The Diplomats
376+
- Real Ni***s | The Diplomats | https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview221/v4/c4/69/c6/c469c6d3-944a-e472-7beb-432e2c057b25/mzaf_7043771456860144142.plus.aac.ep.m4a
379377
- Exhibit C | JAYELECTRONICA
380-
- Live On Live Long | Capone-N-Noreaga
378+
- Live On Live Long | Capone-N-Noreaga | https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview116/v4/8e/3a/92/8e3a92fb-34c6-a6fd-628b-81cf37966b19/mzaf_165728014347235677.plus.aac.ep.m4a
381379
- Plane Gang | Reks, Akrobatik, Edo. G, Termanology, DJ Deadeye
382380
- Bad Name - Remix | Gang Starr, Redman, Method Man
383381
- The Ghetto | Beanie Sigel
@@ -401,7 +399,6 @@ tracks:
401399
- Watch How It Go Down | Termanology
402400
- Hustle | M.O.P., Mr. Probz
403401
- Losing Out (feat. Royce Da 5'9) | Black Milk, Royce Da 5'9"
404-
- Discombobulated | Eminem
405402
- Bullet (feat. Citizen Cope) | Rhymefest, Citizen Cope
406403
- We As Americans | Eminem
407404
- Better Future | Mr. Green, DJ Kool Herc
@@ -443,12 +440,10 @@ tracks:
443440
- The Spark | GZA
444441
- That'll Work | The Alchemist
445442
- New Thangs | Curren$y, Freddie Gibbs, The Alchemist
446-
- Portals Of The Flying Dutchman | The White Shadow
447443
- Assassination Day | DJ Muggs, MF DOOM, Kool G Rap
448444
- Fine And Free (feat. Vivian Green) | Guru, Solar, Vivian Green
449445
- Moment Of Truth | Gang Starr
450446
- Feel It In The Air | Beanie Sigel, Melissa Jay
451-
- They Call This (Hip Hop) - Feat. Royce Da 5'9 and B.O.B | Classified
452447

453448
===
454449
id: confessional-rap

src/pages/demystify/EntryDetail.jsx

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import React, { useMemo } from 'react';
1+
import React from 'react';
22
import { Link } from 'react-router-dom';
33
import AuditionButton from './AuditionButton';
44
import RichText from './RichText';
5-
import { renderSpectrum } from './spectrum';
5+
import Spectrum from './Spectrum';
66

77
const MetaRow = ({ label, children }) =>
88
children ? (
@@ -26,8 +26,6 @@ const EntryDetail = ({
2626
audioEnabled,
2727
onPlay,
2828
}) => {
29-
const spectrum = useMemo(() => renderSpectrum(entry.spec), [entry.spec]);
30-
3129
return (
3230
<article className="dm-entry">
3331
<header className="dm-entry-head">
@@ -49,14 +47,7 @@ const EntryDetail = ({
4947
<pre className="dm-ascii" aria-hidden="true">{entry.ascii}</pre>
5048
)}
5149

52-
{spectrum && (
53-
<figure className="dm-figure">
54-
<pre className="dm-ascii is-spectrum" aria-hidden="true">{spectrum}</pre>
55-
<figcaption className="dm-figcaption">
56-
Characteristic frequency-energy profile
57-
</figcaption>
58-
</figure>
59-
)}
50+
<Spectrum spec={entry.spec} />
6051

6152
{(entry.signature || entry.keyGear || entry.origin) && (
6253
<dl className="dm-meta">

src/pages/demystify/Spectrum.jsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import React, { useMemo } from 'react';
2+
import { spectrumBars, SPECTRUM_AXIS } from './spectrumScale';
3+
4+
// Chart geometry in user units; the SVG scales to its container.
5+
const BAR = 10;
6+
const GAP = 2;
7+
const PLOT = 90;
8+
const AXIS = 18;
9+
const PAD = 1;
10+
11+
/**
12+
* A genre's frequency-energy profile, drawn as SVG so the bars tile exactly.
13+
* Colours come from the demystify palette via currentColor and CSS variables,
14+
* so it follows the theme without any per-theme markup.
15+
*/
16+
const Spectrum = ({ spec }) => {
17+
const bars = useMemo(() => spectrumBars(spec), [spec]);
18+
if (!bars.length) return null;
19+
20+
const width = bars.length * BAR + (bars.length - 1) * GAP;
21+
const height = PLOT + AXIS;
22+
23+
return (
24+
<figure className="dm-figure">
25+
<svg
26+
className="dm-spectrum"
27+
viewBox={`${-PAD} 0 ${width + PAD * 2} ${height}`}
28+
role="img"
29+
aria-label="Characteristic frequency-energy profile"
30+
preserveAspectRatio="xMidYMid meet"
31+
>
32+
<g className="dm-spectrum-bars">
33+
{bars.map((value, i) => {
34+
const barHeight = Math.max(1, value * PLOT);
35+
return (
36+
<rect
37+
key={i}
38+
x={i * (BAR + GAP)}
39+
y={PLOT - barHeight}
40+
width={BAR}
41+
height={barHeight}
42+
/>
43+
);
44+
})}
45+
</g>
46+
47+
<line
48+
className="dm-spectrum-axis"
49+
x1={-PAD}
50+
y1={PLOT + 0.5}
51+
x2={width + PAD}
52+
y2={PLOT + 0.5}
53+
/>
54+
55+
{SPECTRUM_AXIS.filter((mark) => mark.at < bars.length).map((mark) => {
56+
const x = mark.at * (BAR + GAP) + BAR / 2;
57+
return (
58+
<g key={mark.label}>
59+
<line
60+
className="dm-spectrum-axis"
61+
x1={x}
62+
y1={PLOT}
63+
x2={x}
64+
y2={PLOT + 4}
65+
/>
66+
<text
67+
className="dm-spectrum-label"
68+
x={x}
69+
y={PLOT + 14}
70+
textAnchor={
71+
mark.at === 0
72+
? 'start'
73+
: mark.at === bars.length - 1
74+
? 'end'
75+
: 'middle'
76+
}
77+
>
78+
{mark.label}
79+
</text>
80+
</g>
81+
);
82+
})}
83+
</svg>
84+
<figcaption className="dm-figcaption">
85+
Characteristic frequency-energy profile
86+
</figcaption>
87+
</figure>
88+
);
89+
};
90+
91+
export default Spectrum;

src/pages/demystify/demystifyPages.test.jsx

Lines changed: 67 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
66
import DemystifyHubPage from './DemystifyHubPage';
77
import GenreCollectionPage from './GenreCollectionPage';
88
import { clearDemystifyCache } from './demystifyData';
9-
import { renderSpectrum } from './spectrum';
9+
import { spectrumBars } from './spectrumScale';
10+
import Spectrum from './Spectrum';
1011
import {
1112
previewQuery,
1213
isMatch,
@@ -135,66 +136,88 @@ afterEach(() => {
135136
clearDemystifyCache();
136137
});
137138

138-
describe('renderSpectrum', () => {
139-
it('draws a rectangular chart plus an axis ruler and labels', () => {
140-
const lines = renderSpectrum([100, 50, 0], { rows: 4, cell: 2 }).split('\n');
141-
// 4 chart rows + ruler + labels
142-
expect(lines).toHaveLength(6);
143-
expect(lines[0].startsWith('██')).toBe(true); // tallest band reaches the top
144-
expect(lines[4]).toMatch(/^/); // ruler tick under band 0
145-
expect(lines[5]).toMatch(/^SUB/);
146-
});
147-
139+
describe('spectrumBars', () => {
148140
// These profiles sit in a narrow band, so an absolute 0–100 scale renders the
149141
// bottom two thirds as a featureless slab. Bars are scaled to the genre's own
150142
// range instead, which is the difference between a chart and a rectangle.
151143
it('scales to the range present, not to an absolute 0–100', () => {
152-
const narrow = renderSpectrum([44, 84, 60], { rows: 6, cell: 1 }).split(
153-
'\n',
154-
);
155-
// The top row carries ink — nothing has to hit 100 for the chart to fill.
156-
expect(narrow[0].trim()).not.toBe('');
157-
// …and the quietest band still shows a sliver rather than vanishing.
158-
expect(narrow[5][0]).not.toBe(' ');
144+
const bars = spectrumBars([44, 84, 60]);
145+
expect(bars[1]).toBe(1); // the loudest band fills the chart…
146+
expect(bars[0]).toBeGreaterThan(0); // …and the quietest still shows
147+
expect(bars[0]).toBeLessThan(0.2);
148+
expect(bars[2]).toBeGreaterThan(bars[0]);
149+
expect(bars[2]).toBeLessThan(bars[1]);
159150
});
160151

161-
it('never leaves an empty row at the top of the chart', () => {
162-
// No genre in the collection reaches 100, which used to waste the top row.
163-
const lines = renderSpectrum([68, 74, 72, 62, 84], { rows: 7 }).split('\n');
164-
expect(lines[0].trim()).not.toBe('');
165-
// A leading newline would shift the whole chart down a row inside <pre>.
166-
expect(renderSpectrum([68, 74, 72, 62, 84]).startsWith('\n')).toBe(false);
152+
it('fills the chart even though no genre reaches 100', () => {
153+
const bars = spectrumBars([68, 74, 72, 62, 84]);
154+
expect(Math.max(...bars)).toBe(1);
167155
});
168156

169-
it('leaves no featureless slab across the bottom rows', () => {
170-
// The old absolute scale rendered every row below the minimum as solid
171-
// blocks, so most of the chart carried no information at all.
172-
const rows = 9;
173-
const lines = renderSpectrum(
174-
[68, 74, 72, 62, 58, 62, 68, 74, 80, 82, 80, 78, 80, 84, 82, 76, 68, 60, 52, 44],
175-
{ rows },
176-
).split('\n');
177-
const chart = lines.slice(0, rows);
178-
const solid = chart.filter((l) => /^+$/.test(l)).length;
179-
expect(solid).toBeLessThan(3);
157+
it('renders a flat profile without dividing by zero', () => {
158+
const bars = spectrumBars([50, 50, 50]);
159+
expect(bars.every(Number.isFinite)).toBe(true);
160+
expect(new Set(bars).size).toBe(1);
180161
});
181162

182-
it('renders a flat profile without dividing by zero', () => {
183-
const lines = renderSpectrum([50, 50, 50], { rows: 4, cell: 1 }).split('\n');
184-
expect(lines.every((l) => !l.includes('NaN'))).toBe(true);
185-
expect(lines[3]).toBe('███');
163+
it('clamps out-of-range values instead of overflowing the chart', () => {
164+
const bars = spectrumBars([500, -20]);
165+
expect(Math.max(...bars)).toBe(1);
166+
expect(Math.min(...bars)).toBeGreaterThan(0);
167+
expect(bars.every((b) => b <= 1)).toBe(true);
186168
});
187169

188170
it('returns nothing when a genre has no spectrum data', () => {
189-
expect(renderSpectrum([])).toBe('');
190-
expect(renderSpectrum(undefined)).toBe('');
171+
expect(spectrumBars([])).toEqual([]);
172+
expect(spectrumBars(undefined)).toEqual([]);
191173
});
174+
});
192175

193-
it('clamps out-of-range values instead of overflowing the chart', () => {
194-
const lines = renderSpectrum([500, -20], { rows: 3, cell: 1 }).split('\n');
195-
expect(lines[0]).toBe('█');
176+
/* The assertions below are about SVG shape geometry, which has no accessible
177+
query — the chart exposes one role, `img`, and the bars inside it are not
178+
individually addressable. Querying the container is the only way to check
179+
that the rects actually line up. */
180+
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
181+
describe('Spectrum', () => {
182+
// The block-character version could not tile exactly in a browser: a font's
183+
// FULL BLOCK glyph is not the height of a `line-height: 1` line box, so bars
184+
// showed seams and ragged edges. SVG rects have no such dependency.
185+
it('draws one rect per band plus the axis, with no gaps between rows', () => {
186+
const { container } = render(<Spectrum spec={[44, 84, 60]} />);
187+
const rects = container.querySelectorAll('.dm-spectrum-bars rect');
188+
expect(rects).toHaveLength(3);
189+
190+
// Every bar sits on the same baseline: y + height is identical throughout.
191+
const baselines = [...rects].map(
192+
(r) => Number(r.getAttribute('y')) + Number(r.getAttribute('height')),
193+
);
194+
expect(new Set(baselines.map((b) => b.toFixed(3))).size).toBe(1);
195+
196+
// The tallest band reaches the top of the plot.
197+
expect(Math.min(...[...rects].map((r) => Number(r.getAttribute('y'))))).toBe(
198+
0,
199+
);
200+
expect(container.querySelector('.dm-spectrum-axis')).toBeTruthy();
201+
});
202+
203+
it('labels the axis and carries an accessible name', () => {
204+
const { container } = render(<Spectrum spec={new Array(20).fill(60)} />);
205+
const labels = [...container.querySelectorAll('.dm-spectrum-label')].map(
206+
(n) => n.textContent,
207+
);
208+
expect(labels).toEqual(['SUB', 'LOW', 'MID', 'UPPER', 'AIR']);
209+
expect(screen.getByRole('img')).toHaveAttribute(
210+
'aria-label',
211+
expect.stringContaining('frequency-energy'),
212+
);
213+
});
214+
215+
it('renders nothing when a genre has no spectrum data', () => {
216+
const { container } = render(<Spectrum spec={[]} />);
217+
expect(container.querySelector('svg')).toBeNull();
196218
});
197219
});
220+
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
198221

199222
describe('previewQuery', () => {
200223
// The catalogue does not know our display strings, so the search term has to

0 commit comments

Comments
 (0)