/** * The Tremolo, the Vibrato, the Phaser and the Flanger as devices (#398), what their nodes show in * the graph: a plate of their own, a screen, a lamp beside the name that swings with them, the * buttons of a shape or of stages, and their knobs. A Tremolo's screen draws a wave its volume * shapes over the first seconds of a sound, a Vibrato's the pitch; a Phaser's and a Flanger's what * they do to each frequency, and in a lane under it where that is going. A light plays it all in * real time, over and over. */ import { Graphics, type Text, type TextStyle } from 'pixi.js'; import { mixColor } from '../../core/util/color'; import { crispText, Knob, textStyle, Widget } from '../../ui'; import { secondsText } from './chipView'; import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel'; import { TEXT_ROW } from './delayView'; import { type Box, KNOB_CAPTION } from './hardwareView'; import { heightOf, lampOf, MOD_LAYOUTS, type ModDevice, type ModKind, type ModTexts, type ModValues, notchesOf, RESPONSE_FLOOR, RESPONSE_TOP, responseFrame, shareOfHz, type SwingPicture, swingValueAt, wetKey, } from './modulationView'; import { PickButton, type PickLook } from './ReverbPanel'; /** What the panel asks of the node it sits in. */ export interface ModHost { preview(key: string, value: number): void; commit(key: string, value: number): void; pick(value: string): void; } /** What the panel shows of a node. */ export interface ModShown { values: ModValues; wired: (key: string) => boolean; /** The settings that do nothing as things stand. */ idle: ReadonlySet; picture: SwingPicture; texts: ModTexts; } /** A device's colours: its plate, its buttons and knobs, its screen, and its lamp dark and lit. */ interface ModLook { plate: PlateLook; buttons: PickLook; arc: number; glass: number; grid: number; text: number; dim: number; trace: number; /** A Vibrato's pitch goes from this below to the trace above. */ under: number; lamp: [off: number, on: number]; } /** * A Tremolo in the cream and red of an old amp's front, a Vibrato in plum, a Phaser in mustard as * the pedals of the 70s were, a Flanger in steel blue with a violet jet on its screen. */ const LOOKS: Record = { tremolo: { plate: { stops: [ [0, 0xf4ecd6], [0.5, 0xe6d9b8], [1, 0xcdbb92], ], rim: 0x7a6740, grain: 0x5c4a28, }, buttons: { face: 0x4a3f2e, hover: 0x5c5040, picked: 0xc0392b, shadow: 0x241d14 }, arc: 0x8a2416, glass: 0x110d08, grid: 0x2e2618, text: 0xffd28a, dim: 0x7a6a50, trace: 0xffb347, under: 0xffb347, lamp: [0x5a1a14, 0xff3b2a], }, vibrato: { plate: { stops: [ [0, 0xf2c6dc], [0.5, 0xe0a6c4], [1, 0xc283a6], ], rim: 0x6e2f50, grain: 0x4f1d38, }, buttons: { face: 0x4a2a3c, hover: 0x5c3a4e, picked: 0x8e3fc8, shadow: 0x24121c }, arc: 0x4a0f30, glass: 0x0e0912, grid: 0x2a1f30, text: 0xf2c6ff, dim: 0x7a6080, trace: 0xffa8d8, under: 0x8fb4ff, lamp: [0x3e1f4a, 0xc56cff], }, phaser: { plate: { stops: [ [0, 0xf6dc7a], [0.5, 0xe9c552], [1, 0xcfa632], ], rim: 0x7a5a10, grain: 0x5c420a, }, buttons: { face: 0x3e3520, hover: 0x504530, picked: 0xd9531e, shadow: 0x1e190c }, arc: 0x5a3a00, glass: 0x07100f, grid: 0x18302c, text: 0x9ff5e8, dim: 0x4f7a72, trace: 0x5ef0d8, under: 0x5ef0d8, lamp: [0x5a2a10, 0xff6a2a], }, flanger: { plate: { stops: [ [0, 0xc9d4e2], [0.5, 0xaebdd0], [1, 0x8c9fb8], ], rim: 0x3c4a60, grain: 0x2a3648, }, buttons: { face: 0x2a3446, hover: 0x3a4456, picked: 0x6a4fd0, shadow: 0x141a24 }, arc: 0x1c2a44, glass: 0x0a0a14, grid: 0x22223a, text: 0xd8c8ff, dim: 0x6a6a90, trace: 0xb48cff, under: 0xb48cff, lamp: [0x1f3550, 0x5ab4ff], }, }; const BEZEL = 0x2a2624; const BEZEL_TEXT = 0xe4dccf; const IDLE_ALPHA = 0.4; /** Cycles of the wave a Tremolo's volume shapes, across its screen. */ const CARRIER = 48; /** How finely a Vibrato's pitch is coloured: by chunks of so many points. */ const PITCH_CHUNK = 20; function scopeStyle(size: number, fill: number): TextStyle { const style = textStyle('small').clone(); style.fill = fill; style.fontFamily = 'monospace'; style.fontSize = size; style.fontWeight = 'bold'; return style; } function bezelStyle(): TextStyle { const style = textStyle('small').clone(); style.fill = BEZEL_TEXT; style.fontSize = 9; style.fontWeight = 'bold'; style.letterSpacing = 1; return style; } /** What both screens share: the glass, what they write and where the light is. */ abstract class ModScope extends Widget { protected readonly g = new Graphics(); protected readonly light = new Graphics(); protected readonly under: Record<'left' | 'right', Text>; picture: SwingPicture | null = null; values: ModValues | null = null; texts: ModTexts = { side: '', left: '', right: '' }; /** Where the light is, in seconds after the sound, and what it reads there (checks). */ at = 0; reading = 0; constructor( protected readonly device: ModDevice, protected readonly look: ModLook, ) { super(); this.addChild(this.g, this.light); this.under = { left: this.made(9, look.text, 0, 0), right: this.made(9, look.text, 1, 0) }; } protected made(size: number, fill: number, ax: number, ay: number, text = ''): Text { const t = crispText({ text, style: scopeStyle(size, fill) }); t.anchor.set(ax, ay); t.eventMode = 'none'; this.addChild(t); return t; } show(picture: SwingPicture, values: ModValues, texts: ModTexts): void { this.picture = picture; this.values = values; this.texts = texts; if (!picture.moving) this.at = 0; this.reading = swingValueAt(this.device, values, this.at); this.invalidate(); } protected get glassH(): number { return this.h - TEXT_ROW; } /** The glass and what is written under it; the rest is each screen's own. */ protected drawGlass(): void { this.g.roundRect(0, 0, this.w, this.glassH, 4).fill(this.look.glass); const y = this.glassH + 3; this.under.left.text = this.texts.left; this.under.left.position.set(0, y); this.under.right.text = this.texts.right; this.under.right.position.set(this.w, y); } /** `t` seconds after the sound: the light moves there. */ step(t: number): void { if (!this.picture || !this.values) return; this.at = t; this.reading = swingValueAt(this.device, this.values, t); this.drawLight(); } /** A faint line where every swing of the LFO starts, as long as they are not too close. */ protected drawSwings(x0: number, w: number, top: number, bottom: number): void { const rate = this.values?.numbers.rate ?? 0; const span = this.picture?.span ?? 1; if (!(rate > 0) || span * rate > 40) return; for (let k = 1; k / rate < span - 1e-9; k++) { const x = x0 + (k / rate / span) * w; this.g.moveTo(x, top).lineTo(x, bottom); } this.g.stroke({ color: this.look.grid, width: 1 }); } protected abstract drawLight(): void; } /** The Tremolo's and the Vibrato's screen: the volume, or the pitch, over the first seconds of a sound. */ class SwingScope extends ModScope { private readonly marks: Record<'top' | 'bottom' | 'span', Text>; constructor(device: ModDevice, look: ModLook) { super(device, look); this.marks = { top: this.made(8, look.dim, 0, 0), bottom: this.made(8, look.dim, 0, 1), span: this.made(8, look.dim, 1, 1), }; } private get plot(): Box { return { x: 8, y: 12, w: this.w - 16, h: this.glassH - 28 }; } private xOf(t: number): number { const p = this.plot; return p.x + (t / (this.picture?.span ?? 1)) * p.w; } /** Where a Vibrato's pitch sits up the screen. */ private yOf(value: number): number { const p = this.plot; return p.y + (1 - (this.picture ? heightOf(this.picture, value) : 0.5)) * p.h; } override redraw(): void { const g = this.g.clear(); this.drawGlass(); const picture = this.picture; const p = this.plot; const mid = p.y + p.h / 2; this.marks.top.text = ''; this.marks.bottom.text = ''; if (picture) { this.drawSwings(p.x, p.w, p.y - 4, p.y + p.h + 4); const trace = picture.trace; const xAt = (i: number) => p.x + (i / (trace.length - 1)) * p.w; if (this.device.kind === 'tremolo') { // Full volume up and down, and the middle the wave swings round. g.moveTo(p.x, p.y) .lineTo(p.x + p.w, p.y) .moveTo(p.x, p.y + p.h) .lineTo(p.x + p.w, p.y + p.h) .stroke({ color: this.look.grid, width: 1 }); g.moveTo(p.x, mid) .lineTo(p.x + p.w, mid) .stroke({ color: this.look.dim, width: 1, alpha: 0.5 }); // The sound as a wave its volume shapes, and the volume as the wave's outline. trace.forEach((level, i) => { const y = mid - level * (p.h / 2) * Math.sin((2 * Math.PI * CARRIER * i) / (trace.length - 1)); if (i === 0) g.moveTo(xAt(i), y); else g.lineTo(xAt(i), y); }); g.stroke({ color: this.look.trace, width: 1, alpha: 0.35 }); for (const side of [-1, 1]) { trace.forEach((level, i) => { const y = mid + side * level * (p.h / 2); if (i === 0) g.moveTo(xAt(i), y); else g.lineTo(xAt(i), y); }); g.stroke({ color: this.look.trace, width: 2, join: 'round' }); } } else { const reach = picture.high; for (const share of [-1, -0.5, 0.5, 1]) g.moveTo(p.x, this.yOf(share * reach)).lineTo(p.x + p.w, this.yOf(share * reach)); g.stroke({ color: this.look.grid, width: 1 }); this.marks.top.text = `+${reach} st`; this.marks.top.position.set(p.x + 2, p.y - 10); this.marks.bottom.text = `-${reach} st`; this.marks.bottom.position.set(p.x + 2, p.y + p.h + 10); // The pitch the sound came at; under a wet below 1 it is heard beside the swing. const wet = Math.max(0, Math.min(1, this.values?.numbers[wetKey(this.device)] ?? 1)); g.moveTo(p.x, mid) .lineTo(p.x + p.w, mid) .stroke({ color: this.look.dim, width: 1 + (1 - wet) * 2, alpha: 0.5 + 0.5 * (1 - wet) }); // The pitch it swings to: cool below, warm above, in chunks so a few strokes do. for (let from = 0; from < trace.length - 1; from += PITCH_CHUNK) { const to = Math.min(trace.length - 1, from + PITCH_CHUNK); const middle = trace[Math.round((from + to) / 2)]!; g.moveTo(xAt(from), this.yOf(trace[from]!)); for (let i = from + 1; i <= to; i++) g.lineTo(xAt(i), this.yOf(trace[i]!)); g.stroke({ color: mixColor(this.look.under, this.look.trace, heightOf(picture, middle)), width: 2, alpha: 0.35 + 0.65 * wet, join: 'round', }); } } this.marks.span.text = secondsText(picture.span); this.marks.span.position.set(p.x + p.w, this.glassH - 2); } this.drawLight(); } protected drawLight(): void { const s = this.light.clear(); const picture = this.picture; if (!picture?.moving || this.w <= 0) return; const p = this.plot; const x = this.xOf(this.at); s.rect(x - 0.5, p.y - 6, 1, p.h + 12).fill({ color: 0xffffff, alpha: 0.35 }); const dots = this.device.kind === 'tremolo' ? [-1, 1].map((side) => p.y + p.h / 2 + side * this.reading * (p.h / 2)) : [this.yOf(this.reading)]; for (const y of dots) { s.circle(x, y, 6).fill({ color: this.look.trace, alpha: 0.3 }); s.circle(x, y, 3).fill(0xffffff); } } } /** The Hz written under a Phaser's or a Flanger's screen, and the faint ones between them. */ const DECADES: readonly [hz: number, text: string][] = [ [100, '100'], [1000, '1k'], [10000, '10k'], ]; const BETWEEN = [50, 200, 500, 2000, 5000]; /** The dB written left of the response, 0 first. */ const DB_MARKS = [0, -12, -24]; /** * The Phaser's and the Flanger's screen: what they do to each frequency where the light is – the * notches they cut, and how they ring – the range they sweep shaded behind it, and under it a lane * of where that goes over the first seconds of a sound. */ class ResponseScope extends ModScope { private readonly curve = new Graphics(); private readonly hzMarks: Text[]; private readonly dbMarks: Text[]; private readonly marks: Record<'lane' | 'span', Text>; /** The notches drawn now, by Hz (checks). */ notches: number[] = []; constructor(device: ModDevice, look: ModLook) { super(device, look); this.addChildAt(this.curve, 1); this.hzMarks = DECADES.map(([, text]) => this.made(8, look.dim, 0.5, 0, text)); this.dbMarks = DB_MARKS.map((db) => this.made(8, look.dim, 1, 0.5, String(db))); this.marks = { lane: this.made(8, look.dim, 0, 0), span: this.made(8, look.dim, 1, 0) }; } /** The response, with room on its left for its dB. */ private get response(): Box { return { x: 28, y: 10, w: this.w - 36, h: 100 }; } private get lane(): Box { return { x: 8, y: 130, w: this.w - 16, h: this.glassH - 136 }; } private xOfHz(hz: number): number { const r = this.response; return r.x + Math.max(0, Math.min(1, shareOfHz(hz))) * r.w; } private yOfDb(db: number): number { const r = this.response; const clamped = Math.max(RESPONSE_FLOOR, Math.min(RESPONSE_TOP, db)); return r.y + ((RESPONSE_TOP - clamped) / (RESPONSE_TOP - RESPONSE_FLOOR)) * r.h; } /** Where the light's reading is marked on the frequencies: the Phaser's turn, the Flanger's first notch. */ private markHz(reading: number): number | null { if (this.device.kind === 'phaser') return reading; return reading > 0 ? 500 / reading : null; } override redraw(): void { const g = this.g.clear(); this.drawGlass(); const r = this.response; const picture = this.picture; for (const hz of BETWEEN) g.moveTo(this.xOfHz(hz), r.y).lineTo(this.xOfHz(hz), r.y + r.h); g.stroke({ color: this.look.grid, width: 1, alpha: 0.5 }); for (const [hz] of DECADES) g.moveTo(this.xOfHz(hz), r.y).lineTo(this.xOfHz(hz), r.y + r.h); for (const db of DB_MARKS.slice(1)) g.moveTo(r.x, this.yOfDb(db)).lineTo(r.x + r.w, this.yOfDb(db)); g.stroke({ color: this.look.grid, width: 1 }); g.moveTo(r.x, this.yOfDb(0)) .lineTo(r.x + r.w, this.yOfDb(0)) .stroke({ color: this.look.dim, width: 1, alpha: 0.6 }); this.hzMarks.forEach((t, i) => t.position.set(this.xOfHz(DECADES[i]![0]), r.y + r.h + 2)); this.dbMarks.forEach((t, i) => t.position.set(r.x - 4, this.yOfDb(DB_MARKS[i]!))); const lane = this.lane; g.moveTo(lane.x, lane.y - 4) .lineTo(lane.x + lane.w, lane.y - 4) .stroke({ color: this.look.grid, width: 1 }); this.marks.lane.position.set(lane.x + 2, lane.y); this.marks.span.position.set(lane.x + lane.w, lane.y); this.marks.lane.text = ''; this.marks.span.text = ''; if (picture) { // The range the light sweeps the mark through, shaded behind the response. const ends = [this.markHz(picture.lowest), this.markHz(picture.highest)].filter( (hz): hz is number => hz !== null, ); if (ends.length === 2 && picture.moving) { const a = this.xOfHz(Math.min(...ends)); const b = this.xOfHz(Math.max(...ends)); g.rect(a, r.y, Math.max(1, b - a), r.h).fill({ color: this.look.trace, alpha: 0.08 }); } this.drawSwings(lane.x, lane.w, lane.y, lane.y + lane.h); const trace = picture.trace; trace.forEach((value, i) => { const x = lane.x + (i / (trace.length - 1)) * lane.w; const y = lane.y + lane.h - heightOf(picture, value) * lane.h; if (i === 0) g.moveTo(x, y); else g.lineTo(x, y); }); g.stroke({ color: this.look.trace, width: 1.5, alpha: 0.8, join: 'round' }); this.marks.lane.text = this.device.kind === 'phaser' ? 'TURN' : `DELAY UP TO ${picture.high} ms`; this.marks.span.text = secondsText(picture.span); } this.drawLight(); } protected drawLight(): void { const c = this.curve.clear(); const s = this.light.clear(); const picture = this.picture; const values = this.values; if (!picture || !values || this.w <= 0) return; const r = this.response; const frame = responseFrame(this.device, values, this.at, Math.max(1, Math.round(r.w))); this.notches = notchesOf(frame.columns); const bottom = r.y + r.h; const step = r.w / frame.columns.length; const xs = frame.columns.map((_, i) => r.x + (i + 0.5) * step); // Under the response a faint fill, up to the longest trail where there is one, so it stays calm. const under = frame.trail[0] ?? frame.columns; c.poly([r.x, bottom, ...xs.flatMap((x, i) => [x, this.yOfDb(under[i]!.high)]), r.x + r.w, bottom]).fill({ color: this.look.trace, alpha: 0.1, }); // Behind the line the trail of what it swept just before, fading with age: each band reaches // from longer ago to now, so they stack up towards the line. for (const columns of frame.trail) { const band = xs.flatMap((x, i) => [x, this.yOfDb(columns[i]!.high)]); for (let i = xs.length - 1; i >= 0; i--) band.push(xs[i]!, this.yOfDb(columns[i]!.low)); c.poly(band).fill({ color: this.look.trace, alpha: 0.16 }); } // The line itself, thin, fading out where its teeth pass faster than an eye follows: in runs // of the same few steps of alpha, each starting where the last ended. const { line, clear } = frame; const steps = 8; const at = (i: number) => [r.x + line[i]!.share * r.w, this.yOfDb(line[i]!.db)] as const; for (let i = 0; i < line.length - 1;) { const alpha = Math.round(clear[i]! * steps) / steps; let j = i + 1; while (j < line.length - 1 && Math.round(clear[j]! * steps) / steps === alpha) j++; if (alpha > 0) { c.moveTo(...at(i)); for (let k = i + 1; k <= j; k++) c.lineTo(...at(k)); c.stroke({ color: this.look.trace, width: 1, alpha, join: 'round' }); } i = j; } const hz = this.markHz(this.reading); if (hz !== null && shareOfHz(hz) >= 0 && shareOfHz(hz) <= 1) s.rect(this.xOfHz(hz) - 0.5, r.y, 1, r.h).fill({ color: 0xffffff, alpha: 0.3 }); if (!picture.moving) return; const lane = this.lane; const x = lane.x + (this.at / picture.span) * lane.w; const y = lane.y + lane.h - heightOf(picture, this.reading) * lane.h; s.rect(x - 0.5, lane.y - 2, 1, lane.h + 4).fill({ color: 0xffffff, alpha: 0.35 }); s.circle(x, y, 5).fill({ color: this.look.trace, alpha: 0.3 }); s.circle(x, y, 2.5).fill(0xffffff); } } export class ModulationPanel extends Widget { private readonly plate = new Graphics(); private readonly bezel = new Graphics(); private readonly rules = new Graphics(); private readonly lampLight = new Graphics(); private readonly look: ModLook; readonly scope: SwingScope | ResponseScope; readonly pickButtons: PickButton[]; readonly knobs: Record = {}; private readonly captions: Record = {}; private readonly groupNames: { ink: Text; edge: Text }[]; private readonly pickName: { ink: Text; edge: Text } | null; private readonly title: { ink: Text; edge: Text }; private readonly lampCaption: { ink: Text; edge: Text }; private readonly screenName: Text; /** What the screen sums up, above it on the right. */ readonly sideText: Text; /** The settings shown idle now, and how lit the lamp is, 0 to 1 (checks). */ idle: ReadonlySet = new Set(); lamp = 0; constructor( readonly device: ModDevice, host: ModHost, ) { super(); const L = MOD_LAYOUTS[device.kind]; this.look = LOOKS[device.kind]; this.minWidth = L.panel.w; this.minHeight = L.panel.h; this.scope = device.kind === 'tremolo' || device.kind === 'vibrato' ? new SwingScope(device, this.look) : new ResponseScope(device, this.look); this.addChild(this.plate, this.rules, this.bezel, this.scope, this.lampLight); this.title = engrave(this, device.title, 14); this.lampCaption = engrave(this, 'RATE', 9); this.screenName = crispText({ text: device.screenName, style: bezelStyle() }); this.screenName.eventMode = 'none'; this.sideText = crispText({ text: '', style: bezelStyle() }); this.sideText.anchor.set(1, 0); this.sideText.eventMode = 'none'; this.addChild(this.screenName, this.sideText); this.pickName = device.pick ? engrave(this, device.pick.label, 9) : null; this.pickButtons = (device.pick?.options ?? []).map((option) => { const button = new PickButton(option.value, option.caption, this.look.buttons, (picked) => host.pick(picked), ); this.addChild(button); return button; }); this.groupNames = L.groups.map((group) => engrave(this, group.label, 9)); for (const look of device.knobs) { const row = look.row; const knob = new Knob('', row.default, { min: row.min ?? 0, max: row.max ?? 1, step: row.step ?? 0.01, taper: look.taper, bipolar: look.bipolar, default: row.default, decimals: look.decimals, size: L.knobs[look.key]!.size, look: 'chrome', typed: false, format: look.format, ink: INK, arc: this.look.arc, onInput: (value) => host.preview(look.key, value), onChange: (value) => host.commit(look.key, value), }); this.knobs[look.key] = knob; this.captions[look.key] = engrave(this, look.label.toUpperCase(), 9); this.addChild(knob); } } override measure(): { w: number; h: number } { return { ...MOD_LAYOUTS[this.device.kind].panel }; } sync(shown: ModShown): void { for (const look of this.device.knobs) { const knob = this.knobs[look.key]!; knob.setValue(shown.values.numbers[look.key]!); knob.setEnabled(!shown.wired(look.key)); knob.alpha = shown.idle.has(look.key) ? IDLE_ALPHA : 1; this.captions[look.key]!.ink.alpha = knob.alpha; } const pickIdle = this.device.pick !== null && shown.idle.has(this.device.pick.key); for (const button of this.pickButtons) { button.pick(button.value === shown.values.picked); button.alpha = pickIdle ? IDLE_ALPHA : 1; } if (this.pickName) this.pickName.ink.alpha = pickIdle ? IDLE_ALPHA : 1; this.idle = shown.idle; this.scope.show(shown.picture, shown.values, shown.texts); this.sideText.text = shown.texts.side; this.lamp = lampOf(shown.picture, this.scope.reading); this.drawLamp(); } /** Whether anything moves: there is a swing, and it is heard. */ get moving(): boolean { return this.scope.picture?.moving === true; } /** `t` seconds after the sound: the screen's light goes there, the lamp swings with it. */ step(t: number): void { this.scope.step(t); if (this.scope.picture) this.lamp = lampOf(this.scope.picture, this.scope.reading); this.drawLamp(); } private drawLamp(): void { const { x, y, r } = MOD_LAYOUTS[this.device.kind].lamp; const [off, on] = this.look.lamp; const lit = this.lamp; const g = this.lampLight.clear(); if (lit > 0) g.circle(x, y, r + 4).fill({ color: on, alpha: 0.3 * lit }); g.circle(x, y, r) .fill(mixColor(off, on, lit)) .stroke({ color: 0x2a2c30, width: 1.5 }); g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: 0.2 + 0.5 * lit }); } override layout(): void { const L = MOD_LAYOUTS[this.device.kind]; const put = (widget: Widget, box: Box) => { widget.position.set(Math.round(box.x), Math.round(box.y)); widget.resize(box.w, box.h); }; put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW }); this.screenName.position.set(L.screen.x, L.bezel.y + 3); this.sideText.position.set(L.screen.x + L.screen.w, L.bezel.y + 3); this.pickButtons.forEach((button, i) => put(button, L.picks[i]!)); if (this.pickName && L.pickGroup) placeEngraving(this.pickName, L.pickGroup.x + L.pickGroup.w / 2, L.pickGroup.y); placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y); for (const look of this.device.knobs) { const spot = L.knobs[look.key]!; const knob = this.knobs[look.key]!; const size = knob.measure(); knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2)); knob.resize(size.w, size.h); placeEngraving(this.captions[look.key]!, spot.x, spot.y - spot.size / 2 - KNOB_CAPTION); } L.groups.forEach((group, i) => placeEngraving(this.groupNames[i]!, group.box.x + group.box.w / 2, group.box.y), ); placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4); } override redraw(): void { const L = MOD_LAYOUTS[this.device.kind]; drawPlate(this.plate.clear(), L.panel, this.look.plate, L.screws); const b = L.bezel; this.bezel .clear() .roundRect(b.x, b.y, b.w, b.h, 8) .fill(BEZEL) .roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, 8) .stroke({ color: 0x161412, width: 1 }); // A rule each side of a group's name, engraved as the names are. const r = this.rules.clear(); const named: [Box, Text][] = L.groups.map((group, i) => [group.box, this.groupNames[i]!.ink]); if (L.pickGroup && this.pickName) named.push([L.pickGroup, this.pickName.ink]); for (const [box, name] of named) { const mid = box.y + 6; const left = box.x + 6; const right = box.x + box.w - 6; const gap = name.width / 2 + 6; const centre = box.x + box.w / 2; if (centre - gap > left) r.moveTo(left, mid).lineTo(centre - gap, mid); if (centre + gap < right) r.moveTo(centre + gap, mid).lineTo(right, mid); } r.stroke({ color: INK, width: 1, alpha: 0.5 }); this.drawLamp(); } }