jsbeeb 1.14.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.
- package/README.md +94 -0
- package/package.json +8 -6
- package/public/roms/tube/65C102Tube.rom +0 -0
- package/src/6502.js +22 -16
- package/src/6847.js +50 -8
- package/src/acia.js +31 -12
- package/src/canvas.js +77 -18
- package/src/config.js +16 -4
- package/src/disc-drive.js +9 -0
- package/src/disc-hfe.js +1 -2
- package/src/disc-surface.js +350 -0
- package/src/disc-visualiser.js +569 -0
- package/src/disc.js +143 -49
- package/src/dom-utils.js +16 -0
- package/src/econet.js +6 -2
- package/src/fake6502.js +2 -2
- package/src/gamepads.js +11 -4
- package/src/jsbeeb.css +126 -0
- package/src/main.js +112 -55
- package/src/models.js +47 -5
- package/src/serial.js +1 -0
- package/src/snapshot-helpers.js +1 -1
- package/src/sth.js +21 -18
- package/src/tapes.js +10 -12
- package/src/teletext_adaptor.js +38 -13
- package/src/touchscreen.js +6 -6
- package/src/tube.js +89 -37
- package/src/url-params.js +23 -19
- package/src/utils.js +10 -5
- package/src/utils_atom.js +9 -5
- 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 +17 -7
- package/tests/test-machine.js +7 -5
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { IbmDiscFormat } from "./disc.js";
|
|
4
|
+
import {
|
|
5
|
+
DensityPalette,
|
|
6
|
+
DensityRampHex,
|
|
7
|
+
DiscGeometry,
|
|
8
|
+
ErrorHex,
|
|
9
|
+
MaxDensity,
|
|
10
|
+
MinDensity,
|
|
11
|
+
RegionStyles,
|
|
12
|
+
RegionPalette,
|
|
13
|
+
UnformattedHex,
|
|
14
|
+
clampZoom,
|
|
15
|
+
renderTracks,
|
|
16
|
+
trackPulseDensity,
|
|
17
|
+
trackRegions,
|
|
18
|
+
} from "./disc-surface.js";
|
|
19
|
+
|
|
20
|
+
/** 300 rpm. */
|
|
21
|
+
const RevolutionMs = 200;
|
|
22
|
+
|
|
23
|
+
/** Not on either palette: the head and the index marker have to sit clear of the surface. */
|
|
24
|
+
const HeadColour = "#eb6834";
|
|
25
|
+
const IndexColour = "#c3c2b7";
|
|
26
|
+
const HoverColour = "#ffffff";
|
|
27
|
+
const MarkOutline = "#0d0d0d";
|
|
28
|
+
|
|
29
|
+
const ScanBudgetMs = 16;
|
|
30
|
+
|
|
31
|
+
/** A trackpad reports deltas in the tens, a mouse notch about 100. */
|
|
32
|
+
const WheelZoomRate = 0.003;
|
|
33
|
+
|
|
34
|
+
/** Firefox reports wheel deltas in lines, not pixels. */
|
|
35
|
+
const PixelsPerWheelLine = 16;
|
|
36
|
+
|
|
37
|
+
const Views = {
|
|
38
|
+
density: {
|
|
39
|
+
palette: DensityPalette,
|
|
40
|
+
analyse: (track) => ({ codes: trackPulseDensity(track) }),
|
|
41
|
+
describe: ({ codes }, word) => (codes[word] === 0 ? "no flux" : `${codes[word]} pulses`),
|
|
42
|
+
legend: () =>
|
|
43
|
+
swatch(UnformattedHex, "unformatted") +
|
|
44
|
+
`<span class="disc-legend-item disc-legend-grow">${MinDensity}` +
|
|
45
|
+
`<span class="disc-legend-ramp" style="background:linear-gradient(to right, ${DensityRampHex.join(", ")})"></span>` +
|
|
46
|
+
`${MaxDensity} pulses per 64µs</span>`,
|
|
47
|
+
},
|
|
48
|
+
format: {
|
|
49
|
+
palette: RegionPalette,
|
|
50
|
+
analyse: (track, warn) => trackRegions(track, warn),
|
|
51
|
+
describe: ({ codes, sectorNumbers, errors }, word) => {
|
|
52
|
+
const what = RegionStyles[codes[word]].name;
|
|
53
|
+
const named = sectorNumbers[word] < 0 ? what : `sector ${sectorNumbers[word]} ${what}`;
|
|
54
|
+
const error = errors.find(({ firstWord, lastWord }) =>
|
|
55
|
+
lastWord <= firstWord ? word >= firstWord || word < lastWord : word >= firstWord && word < lastWord,
|
|
56
|
+
);
|
|
57
|
+
return `${named}${error ? ` · ${error.kind} error` : ""}`;
|
|
58
|
+
},
|
|
59
|
+
legend: () => RegionStyles.map(({ hex, name }) => swatch(hex, name)).join("") + swatch(ErrorHex, "CRC error"),
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const ignoreWarning = () => {};
|
|
64
|
+
|
|
65
|
+
/** These lines are rewritten every frame, so skip the ones that have not changed. */
|
|
66
|
+
function setText(element, text) {
|
|
67
|
+
if (element.textContent !== text) element.textContent = text;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One atomic legend entry, so a swatch never gets orphaned from its label by a line break. */
|
|
71
|
+
function swatch(colour, label) {
|
|
72
|
+
return `<span class="disc-legend-item"><span class="disc-legend-swatch" style="background:${colour}"></span>${label}</span>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The disc surface panel: one side of one drive's disc drawn as the physical platter, coloured
|
|
77
|
+
* either by raw pulse density or by what the decoder makes of each word, with the head drawn
|
|
78
|
+
* where it sits.
|
|
79
|
+
*/
|
|
80
|
+
export class DiscVisualiser {
|
|
81
|
+
/**
|
|
82
|
+
* @param {object} options
|
|
83
|
+
* @param {object} options.fdc
|
|
84
|
+
*/
|
|
85
|
+
constructor({ fdc }) {
|
|
86
|
+
this._fdc = fdc;
|
|
87
|
+
this.panel = document.getElementById("disc-panel");
|
|
88
|
+
this.surfaceCanvas = document.getElementById("disc-surface");
|
|
89
|
+
this.overlayCanvas = document.getElementById("disc-overlay");
|
|
90
|
+
this.statusElem = document.getElementById("disc-status");
|
|
91
|
+
this.nameElem = document.getElementById("disc-name");
|
|
92
|
+
this.hoverWhereElem = document.getElementById("disc-hover-where");
|
|
93
|
+
this.hoverWhatElem = document.getElementById("disc-hover-what");
|
|
94
|
+
this.legendElem = document.getElementById("disc-legend");
|
|
95
|
+
this.sideControls = document.getElementById("disc-side-controls");
|
|
96
|
+
this.openBtn = document.getElementById("disc-visualiser-open");
|
|
97
|
+
|
|
98
|
+
this.isOpen = false;
|
|
99
|
+
this._view = "density";
|
|
100
|
+
this._driveIndex = 0;
|
|
101
|
+
this._isSideUpper = false;
|
|
102
|
+
this._disc = null;
|
|
103
|
+
this._geometry = null;
|
|
104
|
+
this._imageData = null;
|
|
105
|
+
this._pixels = null;
|
|
106
|
+
/** @type {(Uint8Array|null)[]} */
|
|
107
|
+
this._codes = [];
|
|
108
|
+
/** @type {(object|null)[]} */
|
|
109
|
+
this._info = [];
|
|
110
|
+
this._staleTracks = new Set();
|
|
111
|
+
this._needsFullRepaint = true;
|
|
112
|
+
this._scanCursor = 0;
|
|
113
|
+
this._scanHandle = null;
|
|
114
|
+
this._hover = null;
|
|
115
|
+
this._position = null;
|
|
116
|
+
this._pan = null;
|
|
117
|
+
this._drag = null;
|
|
118
|
+
this._surfaceStale = false;
|
|
119
|
+
this._frameHandle = null;
|
|
120
|
+
|
|
121
|
+
this._onTrackWrite = (isSideUpper, trackNum) => {
|
|
122
|
+
if (isSideUpper === this._isSideUpper) this._staleTracks.add(trackNum);
|
|
123
|
+
};
|
|
124
|
+
this._onResize = () => this._resize();
|
|
125
|
+
this.openBtn.addEventListener("click", (e) => {
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
this.toggle();
|
|
128
|
+
});
|
|
129
|
+
document.getElementById("disc-close").addEventListener("click", () => this.close());
|
|
130
|
+
this._bindChoice("[data-drive]", (button) => this._select(Number(button.dataset.drive), this._isSideUpper));
|
|
131
|
+
this._bindChoice("[data-side]", (button) => this._select(this._driveIndex, button.dataset.side === "1"));
|
|
132
|
+
this._bindChoice("[data-view]", (button) => this._setView(button.dataset.view));
|
|
133
|
+
this.overlayCanvas.addEventListener("mousemove", (e) => (this._hover = this._canvasPoint(e)));
|
|
134
|
+
this.overlayCanvas.addEventListener("mouseleave", () => (this._hover = null));
|
|
135
|
+
this._bindZoomAndPan();
|
|
136
|
+
this._bindDrag(this.panel.querySelector(".disc-header"));
|
|
137
|
+
this._buildLegend();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_bindZoomAndPan() {
|
|
141
|
+
const canvas = this.overlayCanvas;
|
|
142
|
+
canvas.addEventListener("wheel", (e) => {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
if (!this._geometry) return;
|
|
145
|
+
const { x, y } = this._canvasPoint(e);
|
|
146
|
+
const at = this._geometry.toDisc(x, y);
|
|
147
|
+
const lines = e.deltaMode === WheelEvent.DOM_DELTA_LINE ? PixelsPerWheelLine : 1;
|
|
148
|
+
const zoom = clampZoom(this._geometry.zoom * Math.exp(-e.deltaY * lines * WheelZoomRate));
|
|
149
|
+
// Keep whatever is under the pointer under the pointer.
|
|
150
|
+
this._setViewport(zoom, at.x - x / zoom, at.y - y / zoom);
|
|
151
|
+
});
|
|
152
|
+
canvas.addEventListener("pointerdown", (e) => {
|
|
153
|
+
if (e.button !== 0) return;
|
|
154
|
+
const { originX, originY } = this._geometry;
|
|
155
|
+
this._pan = { pointerId: e.pointerId, ...this._canvasPoint(e), originX, originY, moved: false };
|
|
156
|
+
canvas.setPointerCapture(e.pointerId);
|
|
157
|
+
});
|
|
158
|
+
canvas.addEventListener("pointermove", (e) => {
|
|
159
|
+
if (this._pan?.pointerId !== e.pointerId) return;
|
|
160
|
+
const { x, y } = this._canvasPoint(e);
|
|
161
|
+
const { zoom } = this._geometry;
|
|
162
|
+
this._pan.moved = true;
|
|
163
|
+
this._setViewport(
|
|
164
|
+
zoom,
|
|
165
|
+
this._pan.originX - (x - this._pan.x) / zoom,
|
|
166
|
+
this._pan.originY - (y - this._pan.y) / zoom,
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
for (const ending of ["pointerup", "pointercancel"])
|
|
170
|
+
canvas.addEventListener(ending, (e) => {
|
|
171
|
+
if (this._pan?.pointerId !== e.pointerId) return;
|
|
172
|
+
if (this._pan.moved) this._surfaceStale = true;
|
|
173
|
+
this._pan = null;
|
|
174
|
+
});
|
|
175
|
+
canvas.addEventListener("dblclick", () => this._setViewport(1, 0, 0));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** @returns {{x: number, y: number}} the event's position in canvas pixels */
|
|
179
|
+
_canvasPoint(e) {
|
|
180
|
+
const rect = this.overlayCanvas.getBoundingClientRect();
|
|
181
|
+
const scale = this.overlayCanvas.width / rect.width;
|
|
182
|
+
return { x: (e.clientX - rect.left) * scale, y: (e.clientY - rect.top) * scale };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
_setViewport(zoom, originX, originY) {
|
|
186
|
+
const was = { ...this._geometry };
|
|
187
|
+
this._geometry.setView(zoom, originX, originY);
|
|
188
|
+
if (
|
|
189
|
+
was.zoom !== this._geometry.zoom ||
|
|
190
|
+
was.originX !== this._geometry.originX ||
|
|
191
|
+
was.originY !== this._geometry.originY
|
|
192
|
+
)
|
|
193
|
+
this._surfaceStale = true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
_bindDrag(header) {
|
|
197
|
+
header.addEventListener("pointerdown", (e) => {
|
|
198
|
+
if (e.button !== 0 || e.target.closest("button")) return;
|
|
199
|
+
const { left, top } = this.panel.getBoundingClientRect();
|
|
200
|
+
this._drag = { pointerId: e.pointerId, grabX: e.clientX - left, grabY: e.clientY - top };
|
|
201
|
+
header.setPointerCapture(e.pointerId);
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
});
|
|
204
|
+
header.addEventListener("pointermove", (e) => {
|
|
205
|
+
if (this._drag?.pointerId !== e.pointerId) return;
|
|
206
|
+
this._moveTo(e.clientX - this._drag.grabX, e.clientY - this._drag.grabY);
|
|
207
|
+
});
|
|
208
|
+
for (const ending of ["pointerup", "pointercancel"])
|
|
209
|
+
header.addEventListener(ending, (e) => {
|
|
210
|
+
if (this._drag?.pointerId === e.pointerId) this._drag = null;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
_moveTo(left, top) {
|
|
215
|
+
const { width, height } = this.panel.getBoundingClientRect();
|
|
216
|
+
this._position = {
|
|
217
|
+
left: Math.min(Math.max(left, 0), Math.max(0, window.innerWidth - width)),
|
|
218
|
+
top: Math.min(Math.max(top, 0), Math.max(0, window.innerHeight - height)),
|
|
219
|
+
};
|
|
220
|
+
this.panel.style.left = `${this._position.left}px`;
|
|
221
|
+
this.panel.style.top = `${this._position.top}px`;
|
|
222
|
+
// Dragging trades the panel's right-hand anchor for an explicit position.
|
|
223
|
+
this.panel.style.right = "auto";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
_bindChoice(selector, onClick) {
|
|
227
|
+
for (const button of this.panel.querySelectorAll(selector))
|
|
228
|
+
button.addEventListener("click", () => {
|
|
229
|
+
onClick(button);
|
|
230
|
+
this.update();
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
toggle() {
|
|
235
|
+
if (this.isOpen) this.close();
|
|
236
|
+
else this.open();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
open() {
|
|
240
|
+
if (this.isOpen) return;
|
|
241
|
+
this.isOpen = true;
|
|
242
|
+
this.panel.hidden = false;
|
|
243
|
+
window.addEventListener("resize", this._onResize);
|
|
244
|
+
this._resize();
|
|
245
|
+
this._tick();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
close() {
|
|
249
|
+
if (!this.isOpen) return;
|
|
250
|
+
this.isOpen = false;
|
|
251
|
+
this.panel.hidden = true;
|
|
252
|
+
if (this._frameHandle !== null) cancelAnimationFrame(this._frameHandle);
|
|
253
|
+
this._frameHandle = null;
|
|
254
|
+
this._cancelScan();
|
|
255
|
+
this._detach();
|
|
256
|
+
this._hover = null;
|
|
257
|
+
this._needsFullRepaint = true;
|
|
258
|
+
window.removeEventListener("resize", this._onResize);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
_tick() {
|
|
262
|
+
this.update();
|
|
263
|
+
this._frameHandle = requestAnimationFrame(() => this._tick());
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Cheap enough to call every frame. */
|
|
267
|
+
update() {
|
|
268
|
+
if (!this.isOpen || !this._geometry) return;
|
|
269
|
+
const disc = this._drive?.disc ?? null;
|
|
270
|
+
if (this._isSideUpper && !disc?.isDoubleSided) this._select(this._driveIndex, false);
|
|
271
|
+
if (disc !== this._disc) this._attach(disc);
|
|
272
|
+
if (this._needsFullRepaint) this._beginFullRepaint();
|
|
273
|
+
else if (this._surfaceStale) this._repaintSurface();
|
|
274
|
+
else if (this._staleTracks.size && !this._scanning) this._repaintStaleTracks();
|
|
275
|
+
this._drawOverlay();
|
|
276
|
+
this._updateStatus();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
get _drive() {
|
|
280
|
+
return this._fdc.drives[this._driveIndex];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
get _showingHead() {
|
|
284
|
+
const drive = this._drive;
|
|
285
|
+
return !!drive?.disc && drive.isSideUpper === this._isSideUpper;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
get _scanning() {
|
|
289
|
+
return this._geometry !== null && this._scanCursor <= this._geometry.numTracks;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
_setView(view) {
|
|
293
|
+
if (view === this._view || !Views[view]) return;
|
|
294
|
+
this._view = view;
|
|
295
|
+
this._needsFullRepaint = true;
|
|
296
|
+
this._buildLegend();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
_select(driveIndex, isSideUpper) {
|
|
300
|
+
if (driveIndex === this._driveIndex && isSideUpper === this._isSideUpper) return;
|
|
301
|
+
this._driveIndex = driveIndex;
|
|
302
|
+
this._isSideUpper = isSideUpper;
|
|
303
|
+
this._detach();
|
|
304
|
+
this._needsFullRepaint = true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
_attach(disc) {
|
|
308
|
+
this._detach();
|
|
309
|
+
this._disc = disc;
|
|
310
|
+
disc?.addTrackWriteListener(this._onTrackWrite);
|
|
311
|
+
this._needsFullRepaint = true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
_detach() {
|
|
315
|
+
this._disc?.removeTrackWriteListener(this._onTrackWrite);
|
|
316
|
+
this._disc = null;
|
|
317
|
+
this._staleTracks.clear();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
_resize() {
|
|
321
|
+
if (this._position) this._moveTo(this._position.left, this._position.top);
|
|
322
|
+
const size = Math.round(this.surfaceCanvas.clientWidth * (window.devicePixelRatio || 1));
|
|
323
|
+
if (size <= 0 || size === this._geometry?.size) return;
|
|
324
|
+
for (const canvas of [this.surfaceCanvas, this.overlayCanvas]) {
|
|
325
|
+
canvas.width = size;
|
|
326
|
+
canvas.height = size;
|
|
327
|
+
}
|
|
328
|
+
const previous = this._geometry;
|
|
329
|
+
this._geometry = new DiscGeometry(size, IbmDiscFormat.tracksPerDisc);
|
|
330
|
+
if (previous) this._geometry.adoptView(previous);
|
|
331
|
+
this._imageData = this.surfaceCanvas.getContext("2d").createImageData(size, size);
|
|
332
|
+
this._pixels = new Uint32Array(this._imageData.data.buffer);
|
|
333
|
+
this._needsFullRepaint = true;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** @returns {{codes: Uint8Array, sectorNumbers: Int16Array|null, errors: object[]|null}|null} */
|
|
337
|
+
_analyse(trackNum) {
|
|
338
|
+
if (!this._disc) return null;
|
|
339
|
+
return Views[this._view].analyse(this._disc.getTrack(this._isSideUpper, trackNum), ignoreWarning);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
_blit() {
|
|
343
|
+
this.surfaceCanvas.getContext("2d").putImageData(this._imageData, 0, 0);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
_beginFullRepaint() {
|
|
347
|
+
this._needsFullRepaint = false;
|
|
348
|
+
this._cancelScan();
|
|
349
|
+
const count = this._geometry.numTracks;
|
|
350
|
+
this._codes = new Array(count).fill(null);
|
|
351
|
+
this._info = new Array(count).fill(null);
|
|
352
|
+
this._scanCursor = 0;
|
|
353
|
+
this._pixels.fill(0);
|
|
354
|
+
this._advanceScan();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
_cancelScan() {
|
|
358
|
+
if (this._scanHandle !== null) cancelAnimationFrame(this._scanHandle);
|
|
359
|
+
this._scanHandle = null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Driven by requestAnimationFrame rather than the emulator's loop, so it completes while paused. */
|
|
363
|
+
_advanceScan() {
|
|
364
|
+
const count = this._geometry.numTracks;
|
|
365
|
+
const deadline = performance.now() + ScanBudgetMs;
|
|
366
|
+
do {
|
|
367
|
+
if (this._scanCursor < count) this._analyseInto(this._scanCursor);
|
|
368
|
+
// A track's edge pixels sample its neighbours, so paint one behind the scan.
|
|
369
|
+
const paintTrack = this._scanCursor - 1;
|
|
370
|
+
if (paintTrack >= 0)
|
|
371
|
+
renderTracks(this._pixels, this._geometry, this._codes, this._palette, paintTrack, paintTrack);
|
|
372
|
+
this._scanCursor++;
|
|
373
|
+
} while (this._scanCursor <= count && performance.now() < deadline);
|
|
374
|
+
this._blit();
|
|
375
|
+
if (this._scanning) this._scanHandle = requestAnimationFrame(() => this._advanceScan());
|
|
376
|
+
else this._scanHandle = null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
get _palette() {
|
|
380
|
+
return Views[this._view].palette;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
_repaintStaleTracks() {
|
|
384
|
+
const deadline = performance.now() + ScanBudgetMs;
|
|
385
|
+
for (const trackNum of this._staleTracks) {
|
|
386
|
+
this._repaintTrack(trackNum);
|
|
387
|
+
this._staleTracks.delete(trackNum);
|
|
388
|
+
if (performance.now() >= deadline) break;
|
|
389
|
+
}
|
|
390
|
+
this._blit();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** For when the view moved rather than the disc. */
|
|
394
|
+
_repaintSurface() {
|
|
395
|
+
this._surfaceStale = false;
|
|
396
|
+
this._pixels.fill(0);
|
|
397
|
+
// Mid-drag the whole surface is redrawn every frame, so trade smoothed edges for the frame rate.
|
|
398
|
+
const supersample = this._pan ? 1 : undefined;
|
|
399
|
+
const last = this._geometry.numTracks - 1;
|
|
400
|
+
renderTracks(this._pixels, this._geometry, this._codes, this._palette, 0, last, supersample);
|
|
401
|
+
this._blit();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
_analyseInto(trackNum) {
|
|
405
|
+
const analysis = this._analyse(trackNum);
|
|
406
|
+
this._info[trackNum] = analysis;
|
|
407
|
+
this._codes[trackNum] = analysis?.codes ?? null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
_repaintTrack(trackNum) {
|
|
411
|
+
if (trackNum >= this._geometry.numTracks) return;
|
|
412
|
+
this._analyseInto(trackNum);
|
|
413
|
+
renderTracks(this._pixels, this._geometry, this._codes, this._palette, trackNum, trackNum);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
_drawOverlay() {
|
|
417
|
+
const geometry = this._geometry;
|
|
418
|
+
const ctx = this.overlayCanvas.getContext("2d");
|
|
419
|
+
const scale = window.devicePixelRatio || 1;
|
|
420
|
+
ctx.clearRect(0, 0, geometry.size, geometry.size);
|
|
421
|
+
|
|
422
|
+
const centreX = geometry.screenCentreX;
|
|
423
|
+
const centreY = geometry.screenCentreY;
|
|
424
|
+
ctx.lineWidth = scale;
|
|
425
|
+
ctx.strokeStyle = `${IndexColour}44`;
|
|
426
|
+
ctx.beginPath();
|
|
427
|
+
ctx.arc(centreX, centreY, geometry.outerRadius * geometry.zoom, 0, 2 * Math.PI);
|
|
428
|
+
ctx.moveTo(centreX + geometry.hubRadius * geometry.zoom, centreY);
|
|
429
|
+
ctx.arc(centreX, centreY, geometry.hubRadius * geometry.zoom, 0, 2 * Math.PI);
|
|
430
|
+
ctx.stroke();
|
|
431
|
+
this._strokeRadial(ctx, 0, `${IndexColour}66`, scale);
|
|
432
|
+
|
|
433
|
+
this._drawErrors(ctx, scale);
|
|
434
|
+
|
|
435
|
+
const drive = this._drive;
|
|
436
|
+
if (this._showingHead) {
|
|
437
|
+
const track = Math.min(drive.track, geometry.numTracks - 1);
|
|
438
|
+
const fraction = drive.positionFraction;
|
|
439
|
+
ctx.globalAlpha = drive.spinning ? 1 : 0.45;
|
|
440
|
+
ctx.strokeStyle = `${HeadColour}55`;
|
|
441
|
+
ctx.lineWidth = Math.max(geometry.trackPitch * geometry.zoom, 2 * scale);
|
|
442
|
+
ctx.beginPath();
|
|
443
|
+
ctx.arc(centreX, centreY, geometry.screenRadiusOf(track), 0, 2 * Math.PI);
|
|
444
|
+
ctx.stroke();
|
|
445
|
+
this._strokeRadial(ctx, fraction, `${HeadColour}66`, scale);
|
|
446
|
+
const { x, y } = geometry.pointAt(track, fraction);
|
|
447
|
+
ctx.fillStyle = HeadColour;
|
|
448
|
+
ctx.beginPath();
|
|
449
|
+
ctx.arc(x, y, 3.5 * scale, 0, 2 * Math.PI);
|
|
450
|
+
ctx.fill();
|
|
451
|
+
ctx.globalAlpha = 1;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const hover = this._hoverPosition();
|
|
455
|
+
if (hover) {
|
|
456
|
+
ctx.strokeStyle = `${HoverColour}55`;
|
|
457
|
+
ctx.lineWidth = scale;
|
|
458
|
+
ctx.beginPath();
|
|
459
|
+
ctx.arc(centreX, centreY, geometry.screenRadiusOf(hover.track), 0, 2 * Math.PI);
|
|
460
|
+
ctx.stroke();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
_drawErrors(ctx, scale) {
|
|
465
|
+
const geometry = this._geometry;
|
|
466
|
+
const width = Math.max(geometry.trackPitch * geometry.zoom, 2.5 * scale);
|
|
467
|
+
for (let trackNum = 0; trackNum < this._info.length; ++trackNum) {
|
|
468
|
+
const info = this._info[trackNum];
|
|
469
|
+
if (!info?.errors?.length) continue;
|
|
470
|
+
const radius = geometry.screenRadiusOf(trackNum);
|
|
471
|
+
for (const error of info.errors) {
|
|
472
|
+
const start = error.firstWord / info.codes.length;
|
|
473
|
+
let end = error.lastWord / info.codes.length;
|
|
474
|
+
if (end <= start) end += 1;
|
|
475
|
+
for (const [lineWidth, style] of [
|
|
476
|
+
[width + 3 * scale, MarkOutline],
|
|
477
|
+
[width, ErrorHex],
|
|
478
|
+
]) {
|
|
479
|
+
ctx.lineWidth = lineWidth;
|
|
480
|
+
ctx.strokeStyle = style;
|
|
481
|
+
ctx.beginPath();
|
|
482
|
+
ctx.arc(
|
|
483
|
+
geometry.screenCentreX,
|
|
484
|
+
geometry.screenCentreY,
|
|
485
|
+
radius,
|
|
486
|
+
geometry.angleOf(start),
|
|
487
|
+
geometry.angleOf(end),
|
|
488
|
+
);
|
|
489
|
+
ctx.stroke();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
_strokeRadial(ctx, fraction, style, scale) {
|
|
496
|
+
const geometry = this._geometry;
|
|
497
|
+
const angle = geometry.angleOf(fraction);
|
|
498
|
+
const cos = Math.cos(angle);
|
|
499
|
+
const sin = Math.sin(angle);
|
|
500
|
+
const inner = geometry.innerRadius * geometry.zoom;
|
|
501
|
+
const outer = geometry.outerRadius * geometry.zoom;
|
|
502
|
+
ctx.strokeStyle = style;
|
|
503
|
+
ctx.lineWidth = scale;
|
|
504
|
+
ctx.beginPath();
|
|
505
|
+
ctx.moveTo(geometry.screenCentreX + inner * cos, geometry.screenCentreY + inner * sin);
|
|
506
|
+
ctx.lineTo(geometry.screenCentreX + outer * cos, geometry.screenCentreY + outer * sin);
|
|
507
|
+
ctx.stroke();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** @returns {{track: number, fraction: number}|null} */
|
|
511
|
+
_hoverPosition() {
|
|
512
|
+
return this._hover ? this._geometry.positionAt(this._hover.x, this._hover.y) : null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
_updateStatus() {
|
|
516
|
+
const drive = this._drive;
|
|
517
|
+
const disc = drive?.disc;
|
|
518
|
+
this.sideControls.hidden = !disc?.isDoubleSided;
|
|
519
|
+
this._markActive("[data-drive]", (button) => Number(button.dataset.drive) === this._driveIndex);
|
|
520
|
+
this._markActive("[data-side]", (button) => (button.dataset.side === "1") === this._isSideUpper);
|
|
521
|
+
this._markActive("[data-view]", (button) => button.dataset.view === this._view);
|
|
522
|
+
|
|
523
|
+
const name = disc?.name ?? "";
|
|
524
|
+
setText(this.nameElem, name);
|
|
525
|
+
if (this.nameElem.title !== name) this.nameElem.title = name;
|
|
526
|
+
if (!disc) {
|
|
527
|
+
setText(this.statusElem, `Drive ${this._driveIndex}: no disc`);
|
|
528
|
+
} else {
|
|
529
|
+
const head = this._showingHead
|
|
530
|
+
? `head track ${drive.track} · ${(drive.positionFraction * RevolutionMs).toFixed(1)} ms`
|
|
531
|
+
: `head track ${drive.track}, other side`;
|
|
532
|
+
const spin = drive.spinning ? "spinning" : "stopped";
|
|
533
|
+
const zoom = this._geometry.zoom > 1 ? ` · ${this._geometry.zoom.toFixed(1)}x` : "";
|
|
534
|
+
setText(this.statusElem, `${spin} · ${head}${zoom}${this._scanNote()}`);
|
|
535
|
+
}
|
|
536
|
+
const { where, what } = this._describeHover();
|
|
537
|
+
setText(this.hoverWhereElem, where);
|
|
538
|
+
setText(this.hoverWhatElem, what);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
_markActive(selector, isActive) {
|
|
542
|
+
for (const button of this.panel.querySelectorAll(selector)) button.classList.toggle("active", isActive(button));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
_scanNote() {
|
|
546
|
+
if (this._scanning) return " · reading surface…";
|
|
547
|
+
const errors = this._info.reduce((count, info) => count + (info?.errors?.length ?? 0), 0);
|
|
548
|
+
if (!errors) return "";
|
|
549
|
+
return ` · ${errors} CRC error${errors === 1 ? "" : "s"}`;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** @returns {{where: string, what: string}} the pointer's position, and what the surface holds */
|
|
553
|
+
_describeHover() {
|
|
554
|
+
const hover = this._hoverPosition();
|
|
555
|
+
if (!hover) return { where: "Point at the surface to read it", what: "" };
|
|
556
|
+
const info = this._info[hover.track];
|
|
557
|
+
if (!info?.codes?.length) return { where: `Track ${hover.track}`, what: "not read yet" };
|
|
558
|
+
const word = Math.min((hover.fraction * info.codes.length) | 0, info.codes.length - 1);
|
|
559
|
+
const ms = (hover.fraction * RevolutionMs).toFixed(1);
|
|
560
|
+
return {
|
|
561
|
+
where: `Track ${hover.track} · word ${word} of ${info.codes.length} · ${ms} ms`,
|
|
562
|
+
what: Views[this._view].describe(info, word),
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
_buildLegend() {
|
|
567
|
+
this.legendElem.innerHTML = Views[this._view].legend();
|
|
568
|
+
}
|
|
569
|
+
}
|