Skip to content

Commit a3334cd

Browse files
committed
fix(demystify): scale the spectrum to its own range, bump 0.31.1
The frequency profiles cluster between roughly 40 and 90, but the chart was drawn against an absolute 0-100. The bottom two thirds came out as a solid slab of full blocks carrying no information, every difference was squeezed into the top row or two, and the topmost row was always empty because no genre reaches 100. - scale bars to each genre's own min and max, so the curve uses the full chart - keep a floor under the quietest band so it shows a sliver rather than nothing - raise the chart to 9 rows for more resolution - centre the axis labels on their ticks instead of left-aligning them - render the <pre> content with no leading newline: the HTML parser strips one after <pre> on first paint but React keeps it after hydration, which shifted the art down a row
1 parent 9bb9041 commit a3334cd

4 files changed

Lines changed: 79 additions & 20 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "fezcodex",
3-
"version": "0.31.0",
3+
"version": "0.31.1",
44
"private": true,
55
"homepage": "https://fezcode.com",
66
"dependencies": {

src/pages/demystify/EntryDetail.jsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,16 @@ const EntryDetail = ({
4242
{entry.sub && <p className="dm-entry-sub">{entry.sub}</p>}
4343
</header>
4444

45+
{/* No newline between the tag and the expression: a leading line break
46+
inside <pre> is stripped by the HTML parser on first paint but kept by
47+
React after hydration, which shifts the art by one row. */}
4548
{entry.ascii && (
46-
<pre className="dm-ascii" aria-hidden="true">
47-
{entry.ascii}
48-
</pre>
49+
<pre className="dm-ascii" aria-hidden="true">{entry.ascii}</pre>
4950
)}
5051

5152
{spectrum && (
5253
<figure className="dm-figure">
53-
<pre className="dm-ascii is-spectrum" aria-hidden="true">
54-
{spectrum}
55-
</pre>
54+
<pre className="dm-ascii is-spectrum" aria-hidden="true">{spectrum}</pre>
5655
<figcaption className="dm-figcaption">
5756
Characteristic frequency-energy profile
5857
</figcaption>

src/pages/demystify/demystifyPages.test.jsx

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,51 @@ describe('renderSpectrum', () => {
135135
const lines = renderSpectrum([100, 50, 0], { rows: 4, cell: 2 }).split('\n');
136136
// 4 chart rows + ruler + labels
137137
expect(lines).toHaveLength(6);
138-
expect(lines[0].startsWith('██')).toBe(true); // full-height first band
139-
expect(lines[3].slice(4)).toBe(''); // empty band leaves no ink
138+
expect(lines[0].startsWith('██')).toBe(true); // tallest band reaches the top
140139
expect(lines[4]).toMatch(/^/); // ruler tick under band 0
141140
expect(lines[5]).toMatch(/^SUB/);
142141
});
143142

143+
// These profiles sit in a narrow band, so an absolute 0–100 scale renders the
144+
// bottom two thirds as a featureless slab. Bars are scaled to the genre's own
145+
// range instead, which is the difference between a chart and a rectangle.
146+
it('scales to the range present, not to an absolute 0–100', () => {
147+
const narrow = renderSpectrum([44, 84, 60], { rows: 6, cell: 1 }).split(
148+
'\n',
149+
);
150+
// The top row carries ink — nothing has to hit 100 for the chart to fill.
151+
expect(narrow[0].trim()).not.toBe('');
152+
// …and the quietest band still shows a sliver rather than vanishing.
153+
expect(narrow[5][0]).not.toBe(' ');
154+
});
155+
156+
it('never leaves an empty row at the top of the chart', () => {
157+
// No genre in the collection reaches 100, which used to waste the top row.
158+
const lines = renderSpectrum([68, 74, 72, 62, 84], { rows: 7 }).split('\n');
159+
expect(lines[0].trim()).not.toBe('');
160+
// A leading newline would shift the whole chart down a row inside <pre>.
161+
expect(renderSpectrum([68, 74, 72, 62, 84]).startsWith('\n')).toBe(false);
162+
});
163+
164+
it('leaves no featureless slab across the bottom rows', () => {
165+
// The old absolute scale rendered every row below the minimum as solid
166+
// blocks, so most of the chart carried no information at all.
167+
const rows = 9;
168+
const lines = renderSpectrum(
169+
[68, 74, 72, 62, 58, 62, 68, 74, 80, 82, 80, 78, 80, 84, 82, 76, 68, 60, 52, 44],
170+
{ rows },
171+
).split('\n');
172+
const chart = lines.slice(0, rows);
173+
const solid = chart.filter((l) => /^+$/.test(l)).length;
174+
expect(solid).toBeLessThan(3);
175+
});
176+
177+
it('renders a flat profile without dividing by zero', () => {
178+
const lines = renderSpectrum([50, 50, 50], { rows: 4, cell: 1 }).split('\n');
179+
expect(lines.every((l) => !l.includes('NaN'))).toBe(true);
180+
expect(lines[3]).toBe('███');
181+
});
182+
144183
it('returns nothing when a genre has no spectrum data', () => {
145184
expect(renderSpectrum([])).toBe('');
146185
expect(renderSpectrum(undefined)).toBe('');

src/pages/demystify/spectrum.js

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,22 @@
44
* The atlas draws this as an SVG bar chart; here it becomes the same chart in
55
* the character grid the rest of /demystify lives in, so it sits beside the
66
* hand-drawn ASCII banners without looking imported.
7+
*
8+
* Bars are scaled to each genre's own range rather than to an absolute 0–100.
9+
* These profiles cluster in a narrow band — most sit between 40 and 90 — so an
10+
* absolute scale renders the lower two thirds as a featureless slab and squeezes
11+
* every difference into the top row. What matters here is the shape of the
12+
* curve, not its absolute height, and the shape is only legible if it uses the
13+
* full chart.
714
*/
815

916
// U+2581..U+2588 climb in eighths, so a column's partial top cell can land on
1017
// any of eight sub-row heights.
1118
const EIGHTHS = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
1219

20+
/** The quietest band still gets a visible sliver rather than nothing. */
21+
const FLOOR = 0.7;
22+
1323
const AXIS = [
1424
{ at: 0, label: 'SUB' },
1525
{ at: 4, label: 'LOW' },
@@ -18,38 +28,49 @@ const AXIS = [
1828
{ at: 19, label: 'AIR' },
1929
];
2030

31+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
32+
2133
/**
2234
* @param {number[]} spec energy per band, 0–100
2335
* @param {{rows?: number, cell?: number}} options
2436
* @returns {string} newline-joined block art, or '' when there is no data
2537
*/
26-
export const renderSpectrum = (spec, { rows = 7, cell = 2 } = {}) => {
38+
export const renderSpectrum = (spec, { rows = 9, cell = 2 } = {}) => {
2739
if (!Array.isArray(spec) || spec.length === 0) return '';
2840

29-
const columns = spec.map((value) => {
30-
const clamped = Math.min(100, Math.max(0, Number(value) || 0));
31-
return (clamped / 100) * rows;
41+
const values = spec.map((value) => clamp(Number(value) || 0, 0, 100));
42+
const lo = Math.min(...values);
43+
const hi = Math.max(...values);
44+
45+
// A flat profile would divide by zero; render it as a uniform mid-height bar.
46+
const heights = values.map((value) => {
47+
const t = hi === lo ? 0.5 : (value - lo) / (hi - lo);
48+
return FLOOR + t * (rows - FLOOR);
3249
});
3350

3451
const lines = [];
3552
for (let row = rows - 1; row >= 0; row -= 1) {
3653
let line = '';
37-
columns.forEach((height) => {
38-
const fill = Math.min(1, Math.max(0, height - row));
54+
heights.forEach((height) => {
55+
const fill = clamp(height - row, 0, 1);
3956
line += EIGHTHS[Math.round(fill * 8)].repeat(cell);
4057
});
4158
lines.push(line.replace(/\s+$/, ''));
4259
}
4360

44-
// Axis ruler: a tick under each labelled band, then the labels themselves.
61+
// Axis ruler: a tick under each labelled band, then the labels, centred on
62+
// their tick and nudged inward so nothing overflows the chart.
4563
const width = spec.length * cell;
4664
const ticks = Array.from({ length: width }, () => '─');
4765
const labels = Array.from({ length: width }, () => ' ');
4866
AXIS.filter((mark) => mark.at < spec.length).forEach((mark) => {
49-
const centre = mark.at * cell;
50-
ticks[Math.min(centre, width - 1)] = '┴';
51-
// Nudge the last label left so it cannot overflow the chart width.
52-
const start = Math.min(centre, Math.max(0, width - mark.label.length));
67+
const centre = clamp(mark.at * cell, 0, width - 1);
68+
ticks[centre] = '┴';
69+
const start = clamp(
70+
centre - Math.floor(mark.label.length / 2),
71+
0,
72+
Math.max(0, width - mark.label.length),
73+
);
5374
[...mark.label].forEach((ch, i) => {
5475
if (start + i < width) labels[start + i] = ch;
5576
});

0 commit comments

Comments
 (0)