Visual and sonic adding of two simple sine waves.
## Using Domeleon Domeleon is a compact abstraction over any virtual DOM, including Preact (the default), React, and Vue. A Domeleon Component is a class representing application state rather than a wrapper around a DOM element. Components may define a pure view function of their state, and compose into hierarchies that can be updated, serialized, validated, or routed. ### Example **HTML** ```html <div id="app"></div> ``` **Code** ```ts import { App, Component, div, button, formField, inputRange, canvas } from "domeleon" import { inputNumber } from "domeleon/maskito" class Counter extends Component { count: number role: string #canvasEl: HTMLCanvasElement #canvasCtx: CanvasRenderingContext2D constructor(initial: number, role: string) { super() this.count = initial this.role = role } view() { return div({ class: "counter" }, formField ({ // databound input target: this, inputFn: inputRange, // inputSelect, inputText, inputTextArea, inputCheckbox etc. prop: () => this.count, label: this.role, fieldAttrs: { class: "field-class"}, labelAttrs: { class: "label-class"}, inputProps: { attrs: { min: 100, max: 250, step: 1 } } }), button({ onClick: () => this.inc(+1) }, "+"), // always use closure to bind to preserve "this" button({ onClick: () => this.inc(-1) }, "-"), this.count, canvas({ width: 400, height: 20, onMounted: el => { this.#canvasEl = el as HTMLCanvasElement this.#canvasCtx = this.#canvasEl.getContext("2d") return () => { // dispose this.#canvasEl = undefined this.#canvasCtx = undefined } } }) ) } inc(x: number) { this.count+=x this.update() // triggers render on app } // `onRendered` called after VDOM has created/updated DOM elements // useful for operations that require raw DOM access, such as canvas operations override onRendered () { this.drawOnCanvas() } drawOnCanvas() { const canvas = this.#canvasEl const cc = this.#canvasCtx cc.clearRect(0, 0, canvas.width, canvas.height) const barWidth = (this.count / 250) * canvas.width cc.fillStyle = "#4a90e2" cc.fillRect(0, 0, barWidth, canvas.height) } } class Game extends Component { wins = new Counter(10, "wins") losses = new Counter(5, "losses") view() { return div( this.wins.view(), this.losses.view() ) } } new App({ root: new Game(), id: "app"}) ```
import { App, Component, canvas, div, formField, inputRange } from "domeleon"
function getNoteName(semitones: number): string {
const noteNames = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
const octave = 4 + Math.floor(semitones / 12);
return `${noteNames[semitones % 12]}${octave}`;
}
class Wave extends Component {
amplitude: number;
semitones: number; // 0-24 (C4 to C6, 2 octaves)
color: string;
waveNumber: number;
constructor(amplitude: number, semitones: number, color: string, waveNumber: number) {
super();
this.amplitude = amplitude;
this.semitones = semitones;
this.color = color;
this.waveNumber = waveNumber;
}
get className() { return `wave${this.waveNumber}`; }
get audioFrequency(): number {
return 261.63 * Math.pow(2, this.semitones / 12); // C4 = 261.63 Hz
}
get visualFrequency(): number {
return this.audioFrequency / 261.63;
}
get noteName(): string {
return getNoteName(this.semitones);
}
// Calculate y-value at given x position and time offset
valueAt(x: number, offset: number): number {
return this.amplitude * Math.sin((x / 50) * this.visualFrequency + offset);
}
view() {
return div({ className: this.className },
div({ className: "wave-label" }, `Wave ${this.waveNumber}`),
formField({
prop: () => this.amplitude,
target: this,
inputFn: inputRange,
inputProps: { attrs: { min: 0, max: 100, step: 1 } },
label: "Amplitude:"
}),
formField({
prop: () => this.semitones,
target: this,
inputFn: inputRange,
inputProps: { attrs: { min: 0, max: 24, step: 1 } },
label: `Note: ${this.noteName} (${Math.round(this.audioFrequency)} Hz)`
})
)
}
}
class Waves extends Component {
wave1 = new Wave(40, 0, "#ff4444", 1); // C4
wave2 = new Wave(30, 7, "#4444ff", 2); // G4 (perfect fifth)
speed = 0.05;
offset = 0;
private canvasEl!: HTMLCanvasElement;
private canvasCtx!: CanvasRenderingContext2D;
private animId: number | null = null;
private audioCtx!: AudioContext;
private sources: Array<{ osc: OscillatorNode; gain: GainNode }> = [];
private resizeObserver!: ResizeObserver;
private drawWave(
color: string,
lineWidth: number,
valueFunction: (x: number, offset: number) => number
)
{
const { canvasCtx: ctx, canvasEl, offset } = this;
const centerY = canvasEl.height / 2;
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
for (let x = 0; x < canvasEl.width; x++) {
const y = centerY + valueFunction(x, offset);
x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
}
private drawGrid() {
const { canvasCtx: ctx, canvasEl } = this;
const gridColor = getComputedStyle(document.documentElement)
.getPropertyValue('--grid-color').trim();
ctx.strokeStyle = gridColor || '#888';
ctx.lineWidth = 0.5;
for (let x = 50; x < canvasEl.width; x += 50) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvasEl.height);
ctx.stroke();
}
for (let y = 50; y < canvasEl.height; y += 50) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvasEl.width, y);
ctx.stroke();
}
}
private animate = () => {
const { canvasCtx, canvasEl } = this;
canvasCtx.clearRect(0, 0, canvasEl.width, canvasEl.height);
this.drawGrid();
this.drawWave(this.wave1.color, 2, (x, off) => this.wave1.valueAt(x, off));
this.drawWave(this.wave2.color, 2, (x, off) => this.wave2.valueAt(x, off));
this.drawWave("#00dd00", 3, (x, off) =>
this.wave1.valueAt(x, off) + this.wave2.valueAt(x, off)
);
this.offset += this.speed;
this.animId = requestAnimationFrame(this.animate);
};
private resizeCanvas() {
if (!this.canvasEl) return;
const container = this.canvasEl.parentElement;
if (!container) return;
const rect = container.getBoundingClientRect();
const width = Math.floor(rect.width - 20); // Account for container padding
const height = 400;
this.canvasEl.width = width;
this.canvasEl.height = height;
}
private initAudio() {
this.audioCtx = new AudioContext();
// Create oscillators for both waves
[this.wave1, this.wave2].forEach(() => {
const osc = this.audioCtx.createOscillator();
const gain = this.audioCtx.createGain();
osc.type = 'sine';
osc.connect(gain);
gain.connect(this.audioCtx.destination);
osc.start();
this.sources.push({ osc, gain });
});
this.updateAudio();
}
updateAudio() {
if (!this.audioCtx || this.sources.length === 0) return;
const waves = [this.wave1, this.wave2];
const t = this.audioCtx.currentTime;
this.sources.forEach(({ osc, gain }, i) => {
const wave = waves[i];
osc.frequency.setValueAtTime(wave.audioFrequency, t);
gain.gain.setValueAtTime((wave.amplitude / 100) * 0.15, t);
});
}
onUpdated() {
this.updateAudio();
}
private startAnimation(el: HTMLCanvasElement) {
this.canvasEl = el;
this.canvasCtx = el.getContext('2d')!;
this.offset = 0;
// Set initial size and observe container for changes
this.resizeCanvas();
this.resizeObserver = new ResizeObserver(() => this.resizeCanvas());
if (el.parentElement) {
this.resizeObserver.observe(el.parentElement);
}
this.animate();
}
private stopAnimation() {
if (this.animId) {
cancelAnimationFrame(this.animId);
}
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
this.sources.forEach(({ osc }) => {
osc.stop();
osc.disconnect();
});
this.sources = [];
if (this.audioCtx) {
this.audioCtx.close();
}
}
view() {
return div({ className: "container" },
canvas({
className: "viz-canvas",
onMounted: el => {
const initAudioOnce = () => {
if (!this.audioCtx) this.initAudio();
document.removeEventListener('click', initAudioOnce);
};
document.addEventListener('click', initAudioOnce);
this.startAnimation(el as HTMLCanvasElement);
return () => this.stopAnimation();
}
}),
div({ className: "controls" },
div({ className: "waves-grid" },
this.wave1.view(),
this.wave2.view()
),
div({ className: "wave3" },
div({ className: "wave-label wave3" }, "Wave = Sum of Wave 1 + Wave 2"),
formField({
prop: () => this.speed,
target: this,
inputFn: inputRange,
labelAttrs: { class: "wave3" },
inputProps: { attrs: { min: 0.01, max: 0.2, step: 0.01 } },
label: "Speed:"
})
)
)
)
}
}
new App({root: new Waves(), id: "app"})