Skip to content

Commit ef91bed

Browse files
ten9876claude
andcommitted
Experience: the digital-mode operator's four-app screen
No headphones. One big screen split into quadrants, each drawn after the real app's layout (from the GridTracker2 docs screenshot, the WSJT-X manual and the OpsLog wiki), with placeholder calls: - top left: GridTracker2 — map with worked grid squares, control panel - top right: WSJT-X — Wide Graph, Band Activity / Rx Frequency, controls - bottom left: AetherSDR — the real screenshot - bottom right: OpsLog — entry strip, worked-before matrix, great-circle map, cluster, recent QSOs Light themes are toned down so they don't bloom. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0cff61b commit ef91bed

3 files changed

Lines changed: 188 additions & 2 deletions

File tree

‎experience/src/data/personas.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const personas = [
2828
{
2929
id: 'digital', name: 'The Digital-Mode Operator', color: '#8ef7a8',
3030
tagline: 'FT8, FT4, JS8, packet, RADE — often at once',
31-
look: { desk: 'shack', shots: ['multislice'], antenna: 'hex' },
31+
look: { desk: 'digital', shots: ['multislice'], antenna: 'hex', phones: false },
3232
who: 'FT8 by breakfast, packet by lunch, a RADE QSO in the evening. FT8 passed 60% of Club Log activity by 2021,[[clublog-ft8]] and digital was the only mode that grew at Field Day 2025.[[fd-2025]] Every mode means another program — and another virtual audio cable and CAT port.[[flex-wsjtx-multi]]',
3333
needs: [
3434
'Audio and rig control for decoders without a stack of virtual cables and ports.',

‎experience/src/world/stations.js‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import * as THREE from 'three';
1010
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
11-
import { labelSprite, shotTexture, glowTexture, loggerTexture, notepadTexture } from './textures.js';
11+
import { labelSprite, shotTexture, glowTexture, loggerTexture, notepadTexture, digitalDeskTexture } from './textures.js';
1212

1313
export const RING = 300;
1414

@@ -321,6 +321,16 @@ function dress(grp, look, color) {
321321
palm.position.set(-25, base - 0.5, -9); palm.scale.setScalar(0.85); grp.add(palm);
322322

323323
pileup(grp, anims, { dx: 'XX9ZZ QRZ?', color: look.color || '#5de3ff', y: 37, z: -12 });
324+
} else if (look.desk === 'digital') {
325+
// One big screen, four apps: GridTracker2, WSJT-X, AetherSDR, OpsLog.
326+
desk(40, 14);
327+
const quad = digitalDeskTexture(look.shots[0]);
328+
const big = screen(quad, 34, color);
329+
big.traverse((o) => { if (o.material && o.material.map === quad) o.material.color.setScalar(0.82); }); // keep the light apps out of the bloom
330+
big.position.set(0, 11.6 + 2.5 + 34 * 0.29 + 2.6, -6.8); big.rotation.x = -0.05; grp.add(big);
331+
rig(15, 8);
332+
const kb = box(11, 0.6, 3.6, color); kb.position.set(-2, 11.9, 0.8); grp.add(kb);
333+
const mouse = box(1.4, 0.6, 2.2, color); mouse.position.set(6, 11.9, 1); grp.add(mouse);
324334
} else if (look.desk === 'picnic') {
325335
const top = box(30, 1, 13, color); top.position.set(0, 10, -2); grp.add(top);
326336
[-1, 1].forEach((s) => { const bench = box(30, 0.8, 4, color, { opacity: 0.6 }); bench.position.set(0, 6, -2 + s * 10); grp.add(bench); const a = box(1, 10, 12, color, { opacity: 0.5 }); a.position.set(s * 12, 5, -2); grp.add(a); });

‎experience/src/world/textures.js‎

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,182 @@ export function loggerTexture({ title, band, mode, accent = '#ffb547', seed = 1
188188
return canvasTexture(c);
189189
}
190190

191+
// The digital-mode operator's big screen, in four quadrants laid out like
192+
// the real apps: GridTracker2 (top left), WSJT-X (top right), AetherSDR
193+
// (bottom left, the real screenshot) and OpsLog (bottom right). Schematic,
194+
// with placeholder calls only.
195+
export function digitalDeskTexture(shotKey) {
196+
const W = 2048, H = 1188, QW = W / 2, QH = H / 2;
197+
const c = document.createElement('canvas');
198+
c.width = W; c.height = H;
199+
const g = c.getContext('2d');
200+
const rnd = mulberry(4242);
201+
const L = 'ABCDEFGHJKLMNOPRSTUVWYZ';
202+
const call = () => 'XX' + Math.floor(rnd() * 10) + L[Math.floor(rnd() * L.length)] + L[Math.floor(rnd() * L.length)] + (rnd() > 0.5 ? L[Math.floor(rnd() * L.length)] : '');
203+
const grid4 = () => 'ABCDEFGHIJKLMNOPQR'[Math.floor(rnd() * 18)] + 'ABCDEFGHIJKLMNOPQR'[Math.floor(rnd() * 18)] + Math.floor(rnd() * 10) + Math.floor(rnd() * 10);
204+
const titleBar = (x, y, w, text, dark = true) => {
205+
g.fillStyle = dark ? '#1f1f1f' : '#a8a8a8'; g.fillRect(x, y, w, 30);
206+
g.fillStyle = dark ? '#d8d8d8' : '#333'; g.font = `500 17px ${FONT}`; g.textBaseline = 'middle'; g.textAlign = 'left';
207+
g.fillText(text, x + 12, y + 16, w - 24);
208+
};
209+
const T = (txt, x, y, font, col, align = 'left') => { g.font = font; g.fillStyle = col; g.textAlign = align; g.fillText(txt, x, y); g.textAlign = 'left'; };
210+
211+
// ---------------- top left: GridTracker2
212+
{
213+
const x0 = 0, y0 = 0;
214+
titleBar(x0, y0, QW, 'GridTracker2 [Band: 20m Mode: FT8 Layer: Grids] Worked 180 Confirmed 164');
215+
const mx = x0, my = y0 + 30, mw = QW * 0.72, mh = QH - 30;
216+
g.fillStyle = '#4f7f99'; g.fillRect(mx, my, mw, mh); // ocean
217+
const land = (pts, col) => { g.fillStyle = col; g.beginPath(); pts.forEach(([u, v], i) => (i ? g.lineTo : g.moveTo).call(g, mx + u * mw, my + v * mh)); g.closePath(); g.fill(); };
218+
land([[0.04, 0.2], [0.16, 0.13], [0.32, 0.11], [0.46, 0.07], [0.6, 0.09], [0.71, 0.16], [0.8, 0.27], [0.77, 0.36], [0.72, 0.45], [0.69, 0.54], [0.64, 0.6], [0.66, 0.7], [0.61, 0.64], [0.53, 0.63], [0.47, 0.71], [0.43, 0.8], [0.37, 0.78], [0.31, 0.67], [0.23, 0.56], [0.17, 0.43], [0.12, 0.31], [0.05, 0.27]], '#8f8a70');
219+
land([[0.53, 0.84], [0.64, 0.82], [0.73, 0.9], [0.71, 1], [0.57, 1]], '#8f8a70');
220+
land([[0.7, 0.04], [0.85, 0.02], [0.89, 0.12], [0.78, 0.19]], '#a9adb2');
221+
g.strokeStyle = 'rgba(40,40,40,0.5)'; g.lineWidth = 2; g.beginPath(); g.arc(mx + mw * 0.38, my + mh * 0.48, mh * 0.38, 0, 7); g.stroke();
222+
g.beginPath(); g.arc(mx + mw * 0.38, my + mh * 0.48, mh * 0.7, 0, 7); g.stroke();
223+
for (let i = 0; i < 120; i++) { // worked grid squares
224+
const u = 0.24 + rnd() * 0.44, v = 0.3 + rnd() * 0.32;
225+
if (u > 0.62 && v > 0.55) continue;
226+
const k = rnd();
227+
g.fillStyle = k > 0.93 ? 'rgba(80,200,80,0.85)' : k > 0.86 ? 'rgba(240,230,60,0.85)' : k > 0.8 ? 'rgba(60,80,230,0.8)' : 'rgba(225,50,50,0.8)';
228+
g.fillRect(mx + u * mw, my + v * mh, 16, 14);
229+
g.strokeStyle = 'rgba(60,0,0,0.6)'; g.lineWidth = 1; g.strokeRect(mx + u * mw, my + v * mh, 16, 14);
230+
}
231+
g.fillStyle = 'rgba(0,0,0,0.75)'; g.fillRect(mx + 30, my + 10, 300, 28);
232+
T('3.499, -78.662 3897mi 123° FJ03QL', mx + 40, my + 25, `600 16px ${FONT}`, '#fff');
233+
// control panel
234+
const px = mx + mw, pw = QW - mw;
235+
g.fillStyle = '#000'; g.fillRect(px, my, pw, mh);
236+
g.strokeStyle = '#4a4a4a'; g.strokeRect(px + 8, my + 8, pw - 16, 78);
237+
T('GridTracker2', px + pw / 2, my + 28, `700 18px ${FONT}`, '#fff', 'center');
238+
T('14.074.000 Hz (20m) FT8', px + pw / 2, my + 52, `600 15px ${FONT}`, '#ffd23f', 'center');
239+
T('16:30:32 UTC', px + pw / 2, my + 74, `500 14px ${FONT}`, '#ddd', 'center');
240+
g.fillStyle = '#1fa31f'; g.fillRect(px + 8, my + 96, pw - 16, 26); T('RECEIVE', px + pw / 2, my + 110, `700 16px ${FONT}`, '#fff', 'center');
241+
for (let i = 0; i < 12; i++) { const h = 6 + rnd() * 40; g.fillStyle = i === 9 ? '#4f6dff' : '#e03030'; g.fillRect(px + 22 + i * 18, my + 180 - h, 10, h); }
242+
[['Rx Calls', '338'], ['QSO', '2831'], ['Rx DXCC', '58'], ['QSL', '2506']].forEach(([k, v], i) => {
243+
T(k, px + 20 + (i % 2) * 130, my + 210 + Math.floor(i / 2) * 28, `500 15px ${FONT}`, '#fff');
244+
g.fillStyle = '#1730c8'; g.fillRect(px + 92 + (i % 2) * 130, my + 197 + Math.floor(i / 2) * 28, 44, 22);
245+
T(v, px + 114 + (i % 2) * 130, my + 213 + Math.floor(i / 2) * 28, `600 14px ${FONT}`, '#fff', 'center');
246+
});
247+
T('Map View Filters', px + pw / 2, my + 286, `700 15px ${FONT}`, '#fff', 'center');
248+
[['Band', 'Auto'], ['Mode', 'Auto'], ['Prop', 'Mixed'], ['Data', 'Logbook & Live']].forEach(([k, v], i) => {
249+
T(k, px + 24, my + 316 + i * 28, `500 15px ${FONT}`, '#ffd23f');
250+
g.fillStyle = '#1730c8'; g.fillRect(px + 92, my + 302 + i * 28, pw - 112, 22); T(v, px + 100, my + 318 + i * 28, `500 14px ${FONT}`, '#fff');
251+
});
252+
const icons = ['#1e88e5', '#fbc02d', '#8e24aa', '#43a047', '#9e9e9e', '#fb8c00', '#00acc1', '#e53935', '#5e35b1', '#3949ab'];
253+
for (let i = 0; i < 10; i++) { g.fillStyle = icons[i]; g.beginPath(); g.arc(px + 36 + (i % 5) * 46, my + 450 + Math.floor(i / 5) * 46, 17, 0, 7); g.fill(); }
254+
}
255+
256+
// ---------------- top right: WSJT-X (light theme, as in the manual)
257+
{
258+
const x0 = QW, y0 = 0;
259+
g.fillStyle = '#838383'; g.fillRect(x0, y0, QW, QH);
260+
titleBar(x0, y0, QW, 'WSJT-X v2.7.0 by K1JT, G4WJS, K9AN, and IV3NWV', false);
261+
// Wide Graph
262+
const wy = y0 + 34, wh = 120;
263+
g.fillStyle = '#979797'; g.fillRect(x0 + 8, wy, QW - 16, 22);
264+
for (let f = 500; f <= 3000; f += 500) { const x = x0 + 8 + (f / 3200) * (QW - 16); T(String(f), x, wy + 15, `500 14px ${FONT}`, '#222', 'center'); }
265+
for (let yy = 0; yy < 56; yy += 2) for (let xx = 0; xx < QW - 16; xx += 4) {
266+
const v = rnd(); g.fillStyle = v > 0.985 ? '#e7e047' : v > 0.6 ? '#1a3bb0' : '#0d1f7a'; g.fillRect(x0 + 8 + xx, wy + 22 + yy, 4, 2);
267+
}
268+
for (let i = 0; i < 14; i++) { // FT8 signals in the waterfall
269+
const x = x0 + 30 + rnd() * (QW - 70), hot = rnd() > 0.75;
270+
g.fillStyle = hot ? '#e8251f' : '#3fbf6a'; g.fillRect(x, wy + 22 + rnd() * 20, 12, 30);
271+
if (hot) { g.fillStyle = '#f5d33a'; g.fillRect(x + 3, wy + 34, 6, 10); }
272+
}
273+
g.fillStyle = '#05054a'; g.fillRect(x0 + 8, wy + 78, QW - 16, 42);
274+
g.strokeStyle = '#3cff5a'; g.lineWidth = 2; g.beginPath();
275+
for (let xx = 0; xx < QW - 16; xx += 4) { const yv = wy + 112 - Math.pow(rnd(), 5) * 30; xx ? g.lineTo(x0 + 8 + xx, yv) : g.moveTo(x0 + 8, yv); }
276+
g.stroke();
277+
// Band Activity / Rx Frequency
278+
const ty = wy + wh + 10, half = (QW - 24) / 2;
279+
[['Band Activity', 0], ['Rx Frequency', 1]].forEach(([name, k]) => {
280+
const bx = x0 + 8 + k * (half + 8);
281+
T(name, bx + half / 2, ty + 16, `500 18px ${FONT}`, '#111', 'center');
282+
g.fillStyle = '#7d7d7d'; g.fillRect(bx, ty + 28, half, 26);
283+
T('UTC dB DT Freq Message', bx + 10, ty + 42, `500 15px ${MONO}`, '#111');
284+
g.fillStyle = '#979797'; g.fillRect(bx, ty + 58, half, 270);
285+
});
286+
for (let i = 0; i < 13; i++) {
287+
const y = ty + 72 + i * 20, cq = rnd() > 0.72, me = !cq && rnd() > 0.86;
288+
if (cq || me) { g.fillStyle = cq ? '#3fa34a' : '#b8454a'; g.fillRect(x0 + 9, y - 10, half - 2, 19); }
289+
const db = String(Math.round(-20 + rnd() * 30)).padStart(3), dt = (rnd() * 0.8 - 0.2).toFixed(1).padStart(4), fq = String(Math.floor(300 + rnd() * 2600)).padStart(4);
290+
const msg = cq ? `CQ ${call()} ${grid4()}` : me ? `XX0AA ${call()} -${String(Math.floor(rnd() * 20)).padStart(2, '0')}` : `${call()} ${call()} ${rnd() > 0.5 ? 'RR73' : grid4()}`;
291+
T(`163030 ${db} ${dt} ${fq} ~ ${msg}`, x0 + 14, y + 1, `500 14px ${MONO}`, '#111');
292+
}
293+
[0, 1].forEach((i) => { const y = ty + 72 + i * 20; g.fillStyle = '#b8454a'; g.fillRect(x0 + 16 + half + 1, y - 10, half - 2, 19); T(`163030 -1${i + 2} 0.1 1${i}48 ~ XX0AA ${call()} R-1${i}`, x0 + 22 + half, y + 1, `500 14px ${MONO}`, '#111'); });
294+
// controls
295+
const cy = ty + 338;
296+
['Log QSO', 'Stop', 'Monitor', 'Erase', 'Decode', 'Enable Tx', 'Halt Tx', 'Tune'].forEach((b, i) => {
297+
const bx = x0 + 10 + i * 124; g.fillStyle = b === 'Monitor' ? '#3fa34a' : b === 'Enable Tx' ? '#b8454a' : '#b8b8b8';
298+
g.fillRect(bx, cy, 116, 26); g.strokeStyle = '#999'; g.lineWidth = 1; g.strokeRect(bx, cy, 116, 26);
299+
T(b, bx + 58, cy + 14, `500 14px ${FONT}`, '#111', 'center');
300+
});
301+
g.fillStyle = '#000'; g.fillRect(x0 + 10, cy + 36, 300, 46);
302+
T('14.074 000', x0 + 160, cy + 60, `700 30px ${MONO}`, '#6af06a', 'center');
303+
T('20m', x0 + 340, cy + 60, `600 22px ${FONT}`, '#111');
304+
}
305+
306+
// ---------------- bottom left: AetherSDR (the real screenshot, drawn when it loads)
307+
const tex = canvasTexture(c);
308+
{
309+
const x0 = 0, y0 = QH;
310+
g.fillStyle = '#0b111b'; g.fillRect(x0, y0, QW, QH);
311+
titleBar(x0, y0, QW, 'AetherSDR');
312+
const img = new Image();
313+
img.onload = () => {
314+
const s2 = Math.min(QW / img.width, (QH - 30) / img.height), w = img.width * s2, h = img.height * s2;
315+
g.drawImage(img, x0 + (QW - w) / 2, y0 + 30 + (QH - 30 - h) / 2, w, h);
316+
tex.needsUpdate = true;
317+
};
318+
img.src = SHOTS[shotKey].src;
319+
}
320+
321+
// ---------------- bottom right: OpsLog (dark, Log4OM-style entry strip)
322+
{
323+
const x0 = QW, y0 = QH;
324+
g.fillStyle = '#14171c'; g.fillRect(x0, y0, QW, QH);
325+
titleBar(x0, y0, QW, 'OpsLog — XX0AA');
326+
const ey = y0 + 40;
327+
g.fillStyle = '#1d222a'; g.fillRect(x0 + 8, ey, QW - 16, 92);
328+
[['Callsign', 'XX9ZZ', 150], ['RST S', '-12', 70], ['RST R', '-08', 70], ['Name', 'Pat', 110], ['Grid', 'FN31', 90], ['Band', '20m', 70], ['Mode', 'FT8', 70], ['Freq', '14.074', 110]].reduce((x, [k, v, w]) => {
329+
T(k, x, ey + 18, `500 13px ${FONT}`, '#8fa1b8');
330+
g.fillStyle = k === 'Callsign' ? '#2a3d5c' : '#262c36'; g.fillRect(x, ey + 26, w, 34);
331+
T(v, x + 8, ey + 44, k === 'Callsign' ? `700 20px ${MONO}` : `500 17px ${MONO}`, k === 'Callsign' ? '#ffd23f' : '#e6edf5');
332+
return x + w + 12;
333+
}, x0 + 18);
334+
T('Pat · Example, ST · CQ 5 · ITU 8 · NA', x0 + 18, ey + 80, `500 14px ${FONT}`, '#8fa1b8');
335+
// worked-before matrix
336+
const bands = ['160', '80', '40', '30', '20', '17', '15', '12', '10', '6'], modes = ['CW', 'SSB', 'DIG'];
337+
const wx = x0 + 18, wy = ey + 108;
338+
T('Worked before', wx, wy, `600 14px ${FONT}`, '#cfd8e3');
339+
bands.forEach((b, i) => T(b, wx + 60 + i * 34 + 15, wy + 22, `500 12px ${FONT}`, '#8fa1b8', 'center'));
340+
modes.forEach((m, r) => { T(m, wx, wy + 44 + r * 26, `500 13px ${FONT}`, '#8fa1b8'); bands.forEach((b, i) => { const k = rnd(); g.fillStyle = k > 0.6 ? '#2e7d32' : k > 0.35 ? '#c79100' : '#2a3038'; g.fillRect(wx + 60 + i * 34, wy + 32 + r * 26, 30, 20); }); });
341+
// great-circle map + cluster
342+
const gx = x0 + 440, gy = ey + 104, gr = 92;
343+
g.fillStyle = '#0e2238'; g.beginPath(); g.arc(gx + gr, gy + gr, gr, 0, 7); g.fill();
344+
g.strokeStyle = 'rgba(120,170,220,0.35)'; g.lineWidth = 1;
345+
for (let k = 1; k < 4; k++) { g.beginPath(); g.arc(gx + gr, gy + gr, gr * k / 4, 0, 7); g.stroke(); }
346+
for (let k = 0; k < 12; k++) { const a = k * Math.PI / 6; g.beginPath(); g.moveTo(gx + gr, gy + gr); g.lineTo(gx + gr + Math.cos(a) * gr, gy + gr + Math.sin(a) * gr); g.stroke(); }
347+
g.strokeStyle = '#ffd23f'; g.lineWidth = 3; g.beginPath(); g.moveTo(gx + gr, gy + gr); g.lineTo(gx + gr + 70, gy + gr - 50); g.stroke();
348+
const cx = x0 + 650;
349+
T('DX Cluster', cx, gy + 4, `600 14px ${FONT}`, '#cfd8e3');
350+
for (let i = 0; i < 8; i++) { const y = gy + 26 + i * 21; T(`${(14074 + rnd() * 3).toFixed(1)} ${call().padEnd(7)} FT8`, cx, y, `500 13px ${MONO}`, i === 2 ? '#ff6a6a' : '#cfd8e3'); }
351+
// recent QSOs
352+
const ry = y0 + 330;
353+
g.fillStyle = '#262c36'; g.fillRect(x0 + 8, ry, QW - 16, 26);
354+
T('Date Time Call Band Mode RST S RST R Grid Country', x0 + 16, ry + 14, `600 14px ${MONO}`, '#cfd8e3');
355+
for (let i = 0; i < 9; i++) {
356+
const y = ry + 44 + i * 24;
357+
g.fillStyle = i % 2 ? '#181c22' : '#1c2128'; g.fillRect(x0 + 8, y - 12, QW - 16, 24);
358+
T(`2026-10-23 16${String(29 - i * 3).padStart(2, '0')} ${call().padEnd(9)} 20m FT8 -${String(5 + Math.floor(rnd() * 15)).padStart(2, '0')} -${String(5 + Math.floor(rnd() * 15)).padStart(2, '0')} ${grid4()}`, x0 + 16, y + 1, `500 14px ${MONO}`, '#e6edf5');
359+
}
360+
}
361+
// quadrant dividers
362+
g.fillStyle = '#05080d'; g.fillRect(QW - 3, 0, 6, H); g.fillRect(0, QH - 3, W, 6);
363+
tex.needsUpdate = true;
364+
return tex;
365+
}
366+
191367
// A spiral notepad page: ruled lines and scribbled calls.
192368
export function notepadTexture() {
193369
const c = document.createElement('canvas');

0 commit comments

Comments
 (0)