jsbeeb 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,350 @@
1
+ "use strict";
2
+
3
+ // Pure geometry and colour mapping, free of the DOM; disc-visualiser.js owns the canvases.
4
+
5
+ import { IbmDiscFormat } from "./disc.js";
6
+
7
+ /** One FM byte, or two MFM bytes; 2us a slot. */
8
+ export const PulsesPerWord = 32;
9
+
10
+ /**
11
+ * Both FM and MFM put between eight and sixteen flux transitions in a formatted word, so the ramp
12
+ * spans exactly that and gets one step per count.
13
+ */
14
+ export const MinDensity = 8;
15
+ export const MaxDensity = 16;
16
+
17
+ /** Dark to light, for a dark surface. */
18
+ export const DensityRampHex = [
19
+ "#1c5cab",
20
+ "#256abf",
21
+ "#2a78d6",
22
+ "#3987e5",
23
+ "#5598e7",
24
+ "#6da7ec",
25
+ "#86b6ef",
26
+ "#9ec5f4",
27
+ "#cde2fb",
28
+ ];
29
+
30
+ export const UnformattedHex = "#2a2a28";
31
+
32
+ export const Region = {
33
+ Unformatted: 0,
34
+ Gap: 1,
35
+ Header: 2,
36
+ Data: 3,
37
+ Deleted: 4,
38
+ };
39
+
40
+ /**
41
+ * Indexed by {@link Region}. Gap and unformatted are recessive neutrals; header, data and deleted
42
+ * carry identity.
43
+ */
44
+ export const RegionStyles = [
45
+ { name: "unformatted", hex: UnformattedHex },
46
+ { name: "gap", hex: "#3f3f3c" },
47
+ { name: "header", hex: "#d95926" },
48
+ { name: "data", hex: "#3987e5" },
49
+ { name: "deleted data", hex: "#199e70" },
50
+ ];
51
+
52
+ /** Reserved status colour, never used as a fill. */
53
+ export const ErrorHex = "#d03b3b";
54
+
55
+ /** Canvas pixel buffers are little-endian ABGR, as in bbc-palette.js. */
56
+ function hexToAbgr(hex) {
57
+ const value = parseInt(hex.slice(1), 16);
58
+ const r = (value >>> 16) & 0xff;
59
+ const g = (value >>> 8) & 0xff;
60
+ const b = value & 0xff;
61
+ return ((0xff << 24) | (b << 16) | (g << 8) | r) >>> 0;
62
+ }
63
+
64
+ const UnformattedColour = hexToAbgr(UnformattedHex);
65
+
66
+ /** Indexed by pulse count. */
67
+ export const DensityPalette = Uint32Array.from({ length: PulsesPerWord + 1 }, (_, density) => {
68
+ if (density === 0) return UnformattedColour;
69
+ const step = Math.min(Math.max(density - MinDensity, 0), DensityRampHex.length - 1);
70
+ return hexToAbgr(DensityRampHex[step]);
71
+ });
72
+
73
+ export const RegionPalette = Uint32Array.from(RegionStyles, ({ hex }) => hexToAbgr(hex));
74
+
75
+ /**
76
+ * @param {number} pulses one word of surface data
77
+ * @returns {number} flux transitions it holds
78
+ */
79
+ export function pulseDensity(pulses) {
80
+ let bits = pulses - ((pulses >>> 1) & 0x55555555);
81
+ bits = (bits & 0x33333333) + ((bits >>> 2) & 0x33333333);
82
+ return (((bits + (bits >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24;
83
+ }
84
+
85
+ /**
86
+ * @param {Track} track
87
+ * @returns {Uint8Array} flux transitions in each word of the track
88
+ */
89
+ export function trackPulseDensity(track) {
90
+ const density = new Uint8Array(track.length);
91
+ for (let word = 0; word < track.length; ++word) density[word] = pulseDensity(track.pulses2Us[word]);
92
+ return density;
93
+ }
94
+
95
+ /** Address mark aside, a sector header is four bytes of identity and two of CRC. */
96
+ const HeaderBytes = 6;
97
+ const CrcBytes = 2;
98
+
99
+ /**
100
+ * Pick the sectors out of a track and say what each word of it holds.
101
+ *
102
+ * @param {Track} track
103
+ * @param {function(string): void} [warn] where to send the decoder's complaints
104
+ * @returns {{codes: Uint8Array, sectorNumbers: Int16Array, errors: {firstWord: number, lastWord: number, kind: string, sectorNumber: number}[]}}
105
+ */
106
+ export function trackRegions(track, warn) {
107
+ const codes = new Uint8Array(track.length);
108
+ const sectorNumbers = new Int16Array(track.length).fill(-1);
109
+ const errors = [];
110
+ for (let word = 0; word < track.length; ++word)
111
+ codes[word] = track.pulses2Us[word] === 0 ? Region.Unformatted : Region.Gap;
112
+
113
+ /** Marks the words a bit range covers, wrapping at the index. */
114
+ const fill = (startBit, endBit, code, sectorNumber) => {
115
+ for (let word = Math.floor(startBit / PulsesPerWord); word < Math.ceil(endBit / PulsesPerWord); ++word) {
116
+ const index = ((word % track.length) + track.length) % track.length;
117
+ codes[index] = code;
118
+ sectorNumbers[index] = sectorNumber;
119
+ }
120
+ };
121
+
122
+ for (const sector of track.findSectors(warn)) {
123
+ const pulsesPerByte = sector.isMfm ? PulsesPerWord / 2 : PulsesPerWord;
124
+ const noteError = (kind, startBit, endBit) =>
125
+ errors.push({
126
+ firstWord: Math.floor(startBit / PulsesPerWord) % track.length,
127
+ lastWord: Math.ceil(endBit / PulsesPerWord) % track.length,
128
+ kind,
129
+ sectorNumber: sector.sectorNumber,
130
+ });
131
+
132
+ // A region starts at its address mark, which sits one byte before the offset the reader
133
+ // was handed.
134
+ const idStart = sector.idPosBitOffset - pulsesPerByte;
135
+ const idEnd = sector.idPosBitOffset + HeaderBytes * pulsesPerByte;
136
+ fill(idStart, idEnd, Region.Header, sector.sectorNumber);
137
+ if (sector.hasHeaderCrcError) noteError("header CRC", idStart, idEnd);
138
+
139
+ if (sector.dataPosBitOffset === null) continue;
140
+ // A failed CRC leaves no confirmed length, so fall back to what the header claimed.
141
+ const bytes = sector.byteLength ?? 128 << Math.min(sector.header[3], 4);
142
+ const dataStart = sector.dataPosBitOffset - pulsesPerByte;
143
+ const dataEnd = sector.dataPosBitOffset + (bytes + CrcBytes) * pulsesPerByte;
144
+ const code = sector.isDeleted ? Region.Deleted : Region.Data;
145
+ fill(dataStart, dataEnd, code, sector.sectorNumber);
146
+ if (sector.hasDataCrcError) noteError("data CRC", dataStart, dataEnd);
147
+ }
148
+ return { codes, sectorNumbers, errors };
149
+ }
150
+
151
+ /** Enough to fill the view with a couple of tracks. */
152
+ export const MaxZoom = 32;
153
+
154
+ export const clampZoom = (zoom) => Math.min(Math.max(zoom, 1), MaxZoom);
155
+
156
+ const OuterRadiusFraction = 0.97;
157
+ const InnerRadiusFraction = 0.36;
158
+ const HubRadiusFraction = 0.26;
159
+
160
+ /**
161
+ * Maps between a square canvas and the surface of a disc drawn on it. Disc space is the canvas at
162
+ * rest, with the platter filling a `size` square; zoom and pan move the canvas over it.
163
+ */
164
+ export class DiscGeometry {
165
+ /**
166
+ * @param {number} size canvas edge in device pixels
167
+ * @param {number} numTracks
168
+ */
169
+ constructor(size, numTracks = IbmDiscFormat.tracksPerDisc) {
170
+ this.size = size;
171
+ this.numTracks = numTracks;
172
+ this.centre = size / 2;
173
+ this.outerRadius = this.centre * OuterRadiusFraction;
174
+ this.innerRadius = this.centre * InnerRadiusFraction;
175
+ this.hubRadius = this.centre * HubRadiusFraction;
176
+ this.trackPitch = (this.outerRadius - this.innerRadius) / numTracks;
177
+ this.zoom = 1;
178
+ this.originX = 0;
179
+ this.originY = 0;
180
+ }
181
+
182
+ /**
183
+ * @param {number} zoom canvas pixels per disc pixel, at least 1
184
+ * @param {number} originX disc-space coordinate shown at the canvas's left edge
185
+ * @param {number} originY disc-space coordinate shown at the canvas's top edge
186
+ */
187
+ setView(zoom, originX, originY) {
188
+ this.zoom = clampZoom(zoom);
189
+ // Holding the window inside the platter's square keeps the disc from being panned away.
190
+ const slack = this.size - this.size / this.zoom;
191
+ this.originX = Math.min(Math.max(originX, 0), slack);
192
+ this.originY = Math.min(Math.max(originY, 0), slack);
193
+ return this;
194
+ }
195
+
196
+ /** The origin is in disc pixels, which scale with the canvas. */
197
+ adoptView({ zoom, originX, originY, size }) {
198
+ return this.setView(zoom, (originX * this.size) / size, (originY * this.size) / size);
199
+ }
200
+
201
+ toDisc(x, y) {
202
+ return { x: x / this.zoom + this.originX, y: y / this.zoom + this.originY };
203
+ }
204
+
205
+ get screenCentreX() {
206
+ return (this.centre - this.originX) * this.zoom;
207
+ }
208
+
209
+ get screenCentreY() {
210
+ return (this.centre - this.originY) * this.zoom;
211
+ }
212
+
213
+ screenRadiusOf(track) {
214
+ return this.radiusOf(track) * this.zoom;
215
+ }
216
+
217
+ /** Track 0 is the outermost, as on the real thing. */
218
+ trackAt(radius) {
219
+ const track = Math.floor((this.outerRadius - radius) / this.trackPitch);
220
+ return track >= 0 && track < this.numTracks ? track : null;
221
+ }
222
+
223
+ radiusOf(track) {
224
+ return this.outerRadius - (track + 0.5) * this.trackPitch;
225
+ }
226
+
227
+ /** Index sits at twelve o'clock, with the surface running clockwise from there. */
228
+ angleOf(fraction) {
229
+ return fraction * 2 * Math.PI - Math.PI / 2;
230
+ }
231
+
232
+ /**
233
+ * @param {number} dx offset from the centre
234
+ * @param {number} dy offset from the centre
235
+ * @returns {number} how far round from the index, in [0, 1)
236
+ */
237
+ fractionAt(dx, dy) {
238
+ const fraction = (Math.atan2(dy, dx) + Math.PI / 2) / (2 * Math.PI);
239
+ return fraction - Math.floor(fraction);
240
+ }
241
+
242
+ /** @returns {{x: number, y: number}} where on the canvas that point of the surface is drawn */
243
+ pointAt(track, fraction) {
244
+ const angle = this.angleOf(fraction);
245
+ const radius = this.screenRadiusOf(track);
246
+ return { x: this.screenCentreX + radius * Math.cos(angle), y: this.screenCentreY + radius * Math.sin(angle) };
247
+ }
248
+
249
+ /**
250
+ * @returns {{track: number, fraction: number}|null} what the surface holds under a canvas point
251
+ */
252
+ positionAt(x, y) {
253
+ const disc = this.toDisc(x, y);
254
+ const dx = disc.x - this.centre;
255
+ const dy = disc.y - this.centre;
256
+ const track = this.trackAt(Math.hypot(dx, dy));
257
+ return track === null ? null : { track, fraction: this.fractionAt(dx, dy) };
258
+ }
259
+ }
260
+
261
+ const Supersample = 2;
262
+ const InvTwoPi = 1 / (2 * Math.PI);
263
+
264
+ /** Math.hypot's overflow scaling is pure cost at canvas coordinates, and this is the hot path. */
265
+ function distance(dx, dy) {
266
+ return Math.sqrt(dx * dx + dy * dy);
267
+ }
268
+
269
+ /**
270
+ * Paint a range of tracks into a square ABGR pixel buffer. Only pixels whose centres fall in the
271
+ * band those tracks occupy are written.
272
+ *
273
+ * @param {Uint32Array} pixels size * size ABGR pixels
274
+ * @param {DiscGeometry} geometry
275
+ * @param {(Uint8Array|null)[]} codes one value per word, indexed by track
276
+ * @param {Uint32Array} palette ABGR for each code
277
+ * @param {number} firstTrack
278
+ * @param {number} lastTrack inclusive
279
+ * @param {number} [supersample] samples per pixel edge; 1 trades the smoothed edges for speed
280
+ */
281
+ export function renderTracks(pixels, geometry, codes, palette, firstTrack, lastTrack, supersample = Supersample) {
282
+ const { size, outerRadius, trackPitch, numTracks, zoom } = geometry;
283
+ // Work in canvas pixels throughout: zooming and panning scale and shift the radius, and leave
284
+ // the angle alone.
285
+ const centreX = geometry.screenCentreX;
286
+ const centreY = geometry.screenCentreY;
287
+ const bandOuter = (outerRadius - firstTrack * trackPitch) * zoom;
288
+ const bandInner = (outerRadius - (lastTrack + 1) * trackPitch) * zoom;
289
+ const bandOuterSquared = bandOuter * bandOuter;
290
+ const bandInnerSquared = bandInner * bandInner;
291
+ const samplesPerPixel = supersample * supersample;
292
+ const subStep = 1 / supersample;
293
+
294
+ /** @returns {number} the pixel's ABGR, averaged over its samples, or zero if no track covers it */
295
+ const samplePixel = (x, y) => {
296
+ let covered = 0;
297
+ let red = 0;
298
+ let green = 0;
299
+ let blue = 0;
300
+ for (let subY = 0; subY < supersample; ++subY) {
301
+ const sampleY = y + (subY + 0.5) * subStep - centreY;
302
+ for (let subX = 0; subX < supersample; ++subX) {
303
+ const sampleX = x + (subX + 0.5) * subStep - centreX;
304
+ const at = (outerRadius - distance(sampleX, sampleY) / zoom) / trackPitch;
305
+ if (at < 0 || at >= numTracks) continue;
306
+ const trackCodes = codes[at | 0];
307
+ if (!trackCodes || trackCodes.length === 0) continue;
308
+ let fraction = Math.atan2(sampleY, sampleX) * InvTwoPi + 0.25;
309
+ if (fraction < 0) fraction += 1;
310
+ const word = Math.min((fraction * trackCodes.length) | 0, trackCodes.length - 1);
311
+ const colour = palette[trackCodes[word]];
312
+ red += colour & 0xff;
313
+ green += (colour >>> 8) & 0xff;
314
+ blue += (colour >>> 16) & 0xff;
315
+ covered++;
316
+ }
317
+ }
318
+ if (covered === 0) return 0;
319
+ const alpha = ((255 * covered) / samplesPerPixel) | 0;
320
+ return (
321
+ ((alpha << 24) |
322
+ (((blue / covered) | 0) << 16) |
323
+ (((green / covered) | 0) << 8) |
324
+ ((red / covered) | 0)) >>>
325
+ 0
326
+ );
327
+ };
328
+
329
+ const firstRow = Math.max(0, Math.floor(centreY - bandOuter));
330
+ const lastRow = Math.min(size, Math.ceil(centreY + bandOuter) + 1);
331
+ for (let y = firstRow; y < lastRow; ++y) {
332
+ const dy = y + 0.5 - centreY;
333
+ const halfWidth = Math.sqrt(Math.max(0, bandOuterSquared - dy * dy));
334
+ const low = Math.max(0, Math.floor(centreX - halfWidth));
335
+ const high = Math.min(size, Math.ceil(centreX + halfWidth) + 1);
336
+ const holeHalfWidth = Math.abs(dy) < bandInner ? Math.sqrt(bandInnerSquared - dy * dy) : 0;
337
+ const pastHole = Math.ceil(centreX + holeHalfWidth - 0.5);
338
+ for (let x = low; x < high; ++x) {
339
+ const dx = x + 0.5 - centreX;
340
+ if (Math.abs(dx) < holeHalfWidth) {
341
+ x = Math.max(x, pastHole - 1);
342
+ continue;
343
+ }
344
+ const radiusSquared = dx * dx + dy * dy;
345
+ if (radiusSquared < bandInnerSquared || radiusSquared >= bandOuterSquared) continue;
346
+ const colour = samplePixel(x, y);
347
+ if (colour !== 0) pixels[y * size + x] = colour;
348
+ }
349
+ }
350
+ }