jsbeeb 1.15.0 → 1.17.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.
- package/README.md +8 -2
- package/package.json +15 -6
- package/src/6502.js +14 -7
- package/src/6847.js +50 -8
- package/src/acia.js +2 -1
- package/src/app/app.js +4 -0
- package/src/bbcdiscs.js +84 -0
- package/src/canvas.js +85 -19
- package/src/disc-drive.js +54 -12
- package/src/disc-hfe.js +19 -3
- package/src/disc-surface.js +350 -0
- package/src/disc-visualiser.js +569 -0
- package/src/disc.js +226 -62
- package/src/econet.js +6 -2
- package/src/fdc.js +83 -11
- package/src/google-drive.js +4 -4
- package/src/intel-fdc.js +8 -1
- package/src/jsbeeb.css +191 -0
- package/src/keyboard.js +14 -15
- package/src/machine-session.js +2 -1
- package/src/main.js +389 -152
- package/src/models.js +15 -3
- package/src/sth.js +25 -22
- package/src/tapes.js +10 -12
- package/src/teletext_adaptor.js +6 -4
- package/src/touchscreen.js +6 -6
- package/src/tube.js +89 -37
- package/src/url-params.js +28 -0
- package/src/utils.js +3 -0
- package/src/video-filters/pal-composite.js +14 -48
- package/src/video-filters/passthrough-filter.js +11 -39
- package/src/video-filters/pixel-grid.js +55 -0
- package/src/video-filters/shader-program.js +53 -0
- package/src/video-filters/shaders/xbr.frag.glsl +278 -0
- package/src/video-filters/shaders/xbr.vert.glsl +7 -0
- package/src/video-filters/xbr-filter.js +107 -0
- package/src/video.js +56 -16
- package/src/wd-fdc.js +25 -8
- package/src/web/audio-handler.js +4 -2
- package/src/web/toast.js +79 -0
- package/tests/test-machine.js +8 -6
package/src/disc-drive.js
CHANGED
|
@@ -33,6 +33,21 @@ export class BaseDiscDrive extends EventTarget {
|
|
|
33
33
|
throw new Error("Not implemented: headPosition getter");
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** @returns {boolean} */
|
|
37
|
+
get isSideUpper() {
|
|
38
|
+
throw new Error("Not implemented: isSideUpper getter");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @returns {Number} */
|
|
42
|
+
get tracksPerStep() {
|
|
43
|
+
throw new Error("Not implemented: tracksPerStep getter");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @param {Number} _tracksPerStep */
|
|
47
|
+
set tracksPerStep(_tracksPerStep) {
|
|
48
|
+
throw new Error("Not implemented: tracksPerStep setter");
|
|
49
|
+
}
|
|
50
|
+
|
|
36
51
|
/** @returns {boolean} */
|
|
37
52
|
get indexPulse() {
|
|
38
53
|
throw new Error("Not implemented: indexPulse getter");
|
|
@@ -155,8 +170,10 @@ export class DiscDrive extends BaseDiscDrive {
|
|
|
155
170
|
this._scheduler = scheduler;
|
|
156
171
|
/** @type {Disc|undefined} */
|
|
157
172
|
this._disc = undefined;
|
|
158
|
-
|
|
159
|
-
//
|
|
173
|
+
// Two for a drive whose 40/80 switch is set to 40, which reaches a 48 tpi format by
|
|
174
|
+
// stepping twice for each track the controller counts.
|
|
175
|
+
this._tracksPerStep = 1;
|
|
176
|
+
// Where the head is over the 96 tpi surface, whatever the controller believes.
|
|
160
177
|
this._track = 0;
|
|
161
178
|
this._isSideUpper = false;
|
|
162
179
|
// In units where 3125 is a normal track length.
|
|
@@ -240,6 +257,10 @@ export class DiscDrive extends BaseDiscDrive {
|
|
|
240
257
|
return this._track;
|
|
241
258
|
}
|
|
242
259
|
|
|
260
|
+
get isSideUpper() {
|
|
261
|
+
return this._isSideUpper;
|
|
262
|
+
}
|
|
263
|
+
|
|
243
264
|
get positionFraction() {
|
|
244
265
|
return (this._headPosition + this._pulsePosition / 32) / this.trackLength;
|
|
245
266
|
}
|
|
@@ -296,6 +317,22 @@ export class DiscDrive extends BaseDiscDrive {
|
|
|
296
317
|
this._disc = disc;
|
|
297
318
|
}
|
|
298
319
|
|
|
320
|
+
/** @returns {Number} how many of the surface's tracks the head crosses for one of the format's */
|
|
321
|
+
get tracksPerStep() {
|
|
322
|
+
return this._tracksPerStep;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
set tracksPerStep(tracksPerStep) {
|
|
326
|
+
if (tracksPerStep !== 1 && tracksPerStep !== 2)
|
|
327
|
+
throw new Error(`Drives step over one or two tracks at a time, not ${tracksPerStep}`);
|
|
328
|
+
this._tracksPerStep = tracksPerStep;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** @returns {Number} the track the controller believes the head is on */
|
|
332
|
+
get logicalTrack() {
|
|
333
|
+
return (this._track / this._tracksPerStep) | 0;
|
|
334
|
+
}
|
|
335
|
+
|
|
299
336
|
get indexPulse() {
|
|
300
337
|
// With no disc loaded the drive asserts the index all the time.
|
|
301
338
|
if (!this.disc) return true;
|
|
@@ -330,22 +367,22 @@ export class DiscDrive extends BaseDiscDrive {
|
|
|
330
367
|
* @param {Number} delta track step delta, either 1 or -1
|
|
331
368
|
*/
|
|
332
369
|
seekOneTrack(delta) {
|
|
333
|
-
|
|
334
|
-
this._selectTrack(this._track + delta);
|
|
370
|
+
this._selectTrack(this._track + delta * this._tracksPerStep);
|
|
335
371
|
}
|
|
336
372
|
|
|
337
373
|
/**
|
|
338
374
|
* Notify that an overall seek is happening to a particular track. Purely informational.
|
|
339
375
|
*/
|
|
340
376
|
notifySeek(newTrack) {
|
|
341
|
-
this.notifySeekAmount(newTrack - this.
|
|
377
|
+
this.notifySeekAmount(newTrack - this.logicalTrack);
|
|
342
378
|
}
|
|
343
379
|
|
|
344
380
|
/**
|
|
345
|
-
* Notify that an overall seek is happening by some delta
|
|
381
|
+
* Notify that an overall seek is happening by some delta amount. Purely informational.
|
|
346
382
|
*/
|
|
347
383
|
notifySeekAmount(delta) {
|
|
348
|
-
|
|
384
|
+
// The step drives the seek noise, so it counts the tracks the head crosses.
|
|
385
|
+
this.dispatchEvent(new StepEvent(delta * this._tracksPerStep));
|
|
349
386
|
}
|
|
350
387
|
|
|
351
388
|
/**
|
|
@@ -353,11 +390,12 @@ export class DiscDrive extends BaseDiscDrive {
|
|
|
353
390
|
*/
|
|
354
391
|
_selectTrack(track) {
|
|
355
392
|
this._checkTrackNeedsWrite();
|
|
393
|
+
const lastTrack = IbmDiscFormat.tracksPerDisc - this._tracksPerStep;
|
|
356
394
|
if (track < 0) {
|
|
357
395
|
track = 0;
|
|
358
396
|
console.log("Clang! disc head stopped at track 0");
|
|
359
|
-
} else if (track
|
|
360
|
-
track =
|
|
397
|
+
} else if (track > lastTrack) {
|
|
398
|
+
track = lastTrack;
|
|
361
399
|
console.log("Clang! disc head stopper at track max");
|
|
362
400
|
}
|
|
363
401
|
const fraction = this.positionFraction;
|
|
@@ -366,7 +404,11 @@ export class DiscDrive extends BaseDiscDrive {
|
|
|
366
404
|
}
|
|
367
405
|
|
|
368
406
|
_checkTrackNeedsWrite() {
|
|
369
|
-
if (this.disc)
|
|
407
|
+
if (!this.disc) return;
|
|
408
|
+
const written = this.disc.flushWrites();
|
|
409
|
+
// A 48 tpi head writes across most of its band but not as far as the neighbouring 96 tpi
|
|
410
|
+
// track, which is left in the guard band with nothing readable on it.
|
|
411
|
+
if (written && this._tracksPerStep === 2) this.disc.eraseTrack(written.isSideUpper, written.trackNum ^ 1);
|
|
370
412
|
}
|
|
371
413
|
|
|
372
414
|
snapshotState() {
|
|
@@ -377,7 +419,7 @@ export class DiscDrive extends BaseDiscDrive {
|
|
|
377
419
|
pulsePosition: this._pulsePosition,
|
|
378
420
|
in32usMode: this._in32usMode,
|
|
379
421
|
spinning: this._spinning,
|
|
380
|
-
is40Track: this.
|
|
422
|
+
is40Track: this._tracksPerStep === 2,
|
|
381
423
|
timerTaskOffset: this._timer.scheduled() ? this._timer.expireEpoch - this._scheduler.epoch : null,
|
|
382
424
|
disc: this._disc ? this._disc.snapshotState() : null,
|
|
383
425
|
};
|
|
@@ -389,7 +431,7 @@ export class DiscDrive extends BaseDiscDrive {
|
|
|
389
431
|
this._headPosition = state.headPosition;
|
|
390
432
|
this._pulsePosition = state.pulsePosition;
|
|
391
433
|
this._in32usMode = state.in32usMode;
|
|
392
|
-
this.
|
|
434
|
+
this._tracksPerStep = state.is40Track ? 2 : 1;
|
|
393
435
|
|
|
394
436
|
// Restore spinning state and timer
|
|
395
437
|
this._timer.cancel();
|
package/src/disc-hfe.js
CHANGED
|
@@ -12,6 +12,7 @@ const HfeV3OpcodeSetIndex = 0xf1;
|
|
|
12
12
|
const HfeV3OpcodeSetBitrate = 0xf2;
|
|
13
13
|
const HfeV3OpcodeSkipBits = 0xf3;
|
|
14
14
|
const HfeV3OpcodeRand = 0xf4;
|
|
15
|
+
const HfeTrackCountOffset = 9;
|
|
15
16
|
const HfeBlockSideSize = 256;
|
|
16
17
|
const HfeBlockSize = HfeBlockSideSize * 2;
|
|
17
18
|
const HfeShugartDdFloppyMode = 7;
|
|
@@ -52,6 +53,21 @@ function hfeGetTrackOffsetAndLength(metadata, track) {
|
|
|
52
53
|
return { offset, length };
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Whether an HFE image holds a 40 track format. A capture with no more tracks than half a surface
|
|
58
|
+
* cannot be one of an 80 track disc, whatever drive read it.
|
|
59
|
+
*
|
|
60
|
+
* @param {Uint8Array} data
|
|
61
|
+
* @returns {{is40Track: boolean, reason: string}}
|
|
62
|
+
*/
|
|
63
|
+
export function sniffHfeLayout(data) {
|
|
64
|
+
const numTracks = data.length > HfeTrackCountOffset ? data[HfeTrackCountOffset] : 0;
|
|
65
|
+
return {
|
|
66
|
+
is40Track: numTracks > 0 && numTracks * 2 <= IbmDiscFormat.tracksPerDisc,
|
|
67
|
+
reason: `its header declares ${numTracks} tracks`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
55
71
|
/**
|
|
56
72
|
* Load a disc image in HFE format (v1 or v3)
|
|
57
73
|
* @param {import("./disc.js").Disc} disc - The disc object to load into
|
|
@@ -84,11 +100,12 @@ export function loadHfe(disc, data, onChange) {
|
|
|
84
100
|
const numSides = data[10];
|
|
85
101
|
if (numSides < 1 || numSides > 2) throw new Error(`Invalid number of sides: ${numSides}`);
|
|
86
102
|
|
|
87
|
-
const numTracks = data[
|
|
103
|
+
const numTracks = data[HfeTrackCountOffset];
|
|
88
104
|
if (numTracks > IbmDiscFormat.tracksPerDisc) throw new Error(`Too many tracks: ${numTracks}`);
|
|
89
105
|
let expandShift = 0;
|
|
90
106
|
if (disc.config.expandTo80 && numTracks * 2 <= IbmDiscFormat.tracksPerDisc) {
|
|
91
107
|
expandShift = 1;
|
|
108
|
+
disc.is40Track = true;
|
|
92
109
|
console.log("Expanding 40 tracks to 80");
|
|
93
110
|
}
|
|
94
111
|
|
|
@@ -190,9 +207,8 @@ export function loadHfe(disc, data, onChange) {
|
|
|
190
207
|
}
|
|
191
208
|
}
|
|
192
209
|
|
|
193
|
-
// Set up write track callback if onChange is provided
|
|
194
210
|
if (onChange) {
|
|
195
|
-
disc.
|
|
211
|
+
disc.addTrackWriteListener((_side, _trackNum, _trackObj) => {
|
|
196
212
|
// Generate a complete HFE image from the current disc state
|
|
197
213
|
const hfeData = toHfe(disc);
|
|
198
214
|
// Call the onChange handler with the updated HFE data
|
|
@@ -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
|
+
}
|