Improved UI

main
František Špaček 2 weeks ago
parent c49b69d9a7
commit 42d9505a6b

@ -1,21 +1,25 @@
Conway's Game of Life - Frontend only # Conway's Game of Life - Frontend only
Files: Files:
- index.html — main page - index.html — main page
- style.css — styles - style.css — styles
- script.js — game logic and UI - script.js — game logic and UI
How to run: How to run:
1. Open `index.html` in your browser (double-click or use a simple static file server). 1. Open `index.html` in your browser (double-click or use a simple static file server).
2. Use the controls to Start/Stop, Step, Randomize, or Clear. 2. Use the controls to Start/Stop, Step, Randomize, or Clear.
3. Click or drag on the canvas to toggle cells. Adjust speed and cell size with sliders. 3. Click or drag on the canvas to toggle cells. Adjust speed and cell size with sliders.
Notes: Notes:
- No backend required. - No backend required.
- For best results, open the page via a local server (e.g., `python3 -m http.server`) if your browser blocks local file scripts. - For best results, open the page via a local server (e.g., `python3 -m http.server`) if your browser blocks local file scripts.
New features: Features:
- Presets: select common patterns (Glider, Lightweight spaceship, Pulsar, Gosper glider gun) from the Presets dropdown. - Presets: select common patterns (Glider, Lightweight spaceship, Pulsar, Gosper glider gun) from the Presets dropdown.
- Wrap edges: enable "Wrap edges" to use toroidal neighbor rules (cells at edges consider opposite edges as neighbors). - Wrap edges: enable "Wrap edges" to use toroidal neighbor rules (cells at edges consider opposite edges as neighbors).
- Save / Load: Save the current grid to your browser's localStorage (client-side only) and load it back later. The save is stored per-browser and per-machine. - Save / Load: Save the current grid to your browser's localStorage (client-side only) and load it back later. The save is stored per-browser and per-machine.
- Speed slider: slider direction matches natural expectation: left = slower, right = faster. - Speed slider: slider direction matches natural expectation: left = slower, right = faster.

@ -5,104 +5,127 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Music of Life</title> <title>Music of Life</title>
<link rel="icon" href="src/musical_note.png">
<link rel="icon" href="musical_note.png"> <link rel="stylesheet" href="src/style.css">
<link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
<main> <main>
<header> <header>
<h1>Music of Life</h1> <h1>Music of Life</h1>
<div id="status"> <div id="status">
<span id="runState">stopped</span>
<span id="generation">Generation: 0</span> <span id="generation">Generation: 0</span>
<span id="runState">stopped</span>
</div> </div>
</header> </header>
<section class="controls"> <div class="app-grid">
<div class="panel primary"> <aside class="sidebar">
<button id="startBtn">Start</button> <section class="panel secondary collapsible">
<button id="stepBtn">Step</button> <button class="collapse-toggle" type="button">Controls</button>
<button id="randomBtn">Random</button>
<button id="clearBtn">Clear</button> <div class="collapsible-content">
<section class="panel primary">
<select id="presetSelect"> <div class="gap-row">
<option value="">Presets</option> <button id="startBtn">Start</button>
<option value="glider">Glider</option> <button id="stepBtn">Step</button>
<option value="lwss">Lightweight spaceship</option> <button id="randomBtn">Random</button>
<option value="gosper">Gosper glider gun</option> <button id="clearBtn">Clear</button>
</select> </div>
<button id="saveBtn">Save</button> <div class="right-controls">
<button id="loadBtn">Load</button> <select id="presetSelect">
<option value="">Presets</option>
<div class="record-controls"> <option value="glider">Glider</option>
<button id="recordBtn">Start Recording</button> <option value="lwss">Lightweight spaceship</option>
<button id="stopRecordBtn">Stop Recording</button> <option value="gosper">Gosper glider gun</option>
</div> </select>
<button id="saveBtn">Save</button>
</div> <button id="loadBtn">Load</button>
</div>
<div class="panel secondary"> </section>
<label>
Speed <section class="panel secondary controls">
<input id="speedRange" type="range" min="50" max="2000" value="800"> <label class="slider-row">Speed
<output id="speedValue"></output> <div class="slider-control">
</label> <input id="speedRange" type="range" min="0.5" max="50" value="5" step="1">
<output id="speedValue"></output>
<label> </div>
Cell size </label>
<input id="cellSizeRange" type="range" min="4" max="64" value="24">
<output id="cellSizeValue"></output> <label class="slider-row">Cell size
</label> <div class="slider-control">
<input id="cellSizeRange" type="range" min="4" max="64" value="24">
<label> <output id="cellSizeValue"></output>
Base frequency </div>
<input id="baseFrequencyRange" type="range" min="20" max="600" value="100"> </label>
<output id="baseFrequencyValue"></output>
</label> <label class="slider-row">Base frequency
<div class="slider-control">
<label> <input id="baseFrequencyRange" type="range" min="20" max="600" value="100">
Volume <output id="baseFrequencyValue"></output>
<input id="volumeRange" type="range" min="0" max="100" value="50"> </div>
<output id="volumeValue"></output> </label>
</label>
<label class="slider-row">Volume
<label class="inline"> <div class="slider-control">
<input type="checkbox" id="wrapCheckbox" checked> <input id="volumeRange" type="range" min="0" max="100" value="50">
Wrap edges <output id="volumeValue"></output>
</label> </div>
</label>
<label>
Music mode <label class="inline">
<select id="musicModeSelect"> <input type="checkbox" id="wrapCheckbox" checked>
<option value="additive">Additive</option> <span>Wrap edges</span>
<option value="frequencyGrid">Frequency Grid</option> </label>
<option value="granular">Granular</option>
<option value="polyrhythm">Polyrhythm</option> <label>Music mode
</select> <select id="musicModeSelect">
</label> <option value="additive">Additive</option>
<option value="frequencyGrid">Frequency Grid</option>
<small id="modeHint"></small> <option value="granular">Granular</option>
</div> <option value="polyrhythm">Polyrhythm</option>
</section> </select>
</label>
<canvas id="lifeCanvas"></canvas>
<small id="modeHint" class="muted"></small>
<footer> </section>
<small> </div>
Click or drag to draw · Right click to erase · Space = start/stop </section>
</small>
</footer> <section class="panel secondary collapsible collapsed">
<button class="collapse-toggle" type="button">Recording</button>
<div class="collapsible-content">
<section class="panel secondary">
<div class="gap-row">
<button id="recordBtn">Start Recording</button>
<button id="stopRecordBtn">Stop Recording</button>
<span id="recordDot" class="recording-indicator hidden">Recording</span>
</div>
<canvas id="waveformCanvas" width="400" height="80"></canvas>
<div id="timerDisplay">00:00</div>
</section>
</div>
</section>
</aside>
<section class="canvas-area">
<canvas id="lifeCanvas"></canvas>
<button id="fullscreenToggle" class="fullscreen-toggle" type="button">Full screen ⛶</button>
<footer>
<small>Click or drag to draw · Right click to erase · Space = start/stop</small>
</footer>
</section>
</div>
</main> </main>
<!-- order matters --> <script src="src/scripts/recording.js"></script>
<script src="recording.js"></script> <script src="src/scripts/music.js"></script>
<script src="music.js"></script> <script src="src/scripts/script.js"></script>
<script src="script.js"></script> <script src="src/scripts/ui.js"></script>
<script src="ui.js"></script>
</body> </body>
</html> </html>

@ -1,496 +0,0 @@
// Music synthesis from Conway's Game of Life
// Base class and multiple synthesis approaches
/**
* Base class for all music synthesis approaches
*/
class MusicSynthesizer {
constructor() {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.isPlaying = false;
this.masterGain = this.audioContext.createGain();
this.masterGain.connect(this.audioContext.destination);
this.masterGain.gain.value = 0.3; // Prevent clipping
this.baseFrequency = 100; // Hz
}
/**
* Generate music based on grid state - override in subclasses
*/
generateFromGrid(grid, cols, rows) {
// To be implemented by subclasses
}
play() {
if (this.isPlaying) return;
this.isPlaying = true;
this.masterGain.gain.setValueAtTime(0.3, this.audioContext.currentTime);
}
stop() {
this.isPlaying = false;
this.masterGain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
this.cleanup();
}
setBaseFrequency(frequency) {
this.baseFrequency = frequency;
}
cleanup() {
// To be implemented by subclasses
}
}
/**
* Additive Synthesis: Each live cell generates a sine oscillator
* Frequency based on cell position, creating harmonic textures
*/
class AdditiveSynthesizer extends MusicSynthesizer {
constructor(superCellThreshold = 100, superCellMaxOscillators = 100) {
super();
this.oscillators = new Map(); // Map of "key" -> { osc, gain, frequency }
this.lastUpdate = 0;
this.liveCells = new Set(); // Tracks currently alive cells as "x,y"
this.superCellThreshold = superCellThreshold;
this.superCellMaxOscillators = superCellMaxOscillators;
}
calculateFrequency(x, y, cols, rows) {
const normalizedX = 1 - x / (cols - 1 || 1);
const normalizedY = 1 - y / (rows - 1 || 1);
const octaveOffset = normalizedY * 3;
const noteOffset = normalizedX * 12;
return this.baseFrequency * Math.pow(2, octaveOffset + noteOffset / 12);
}
generateFromGrid(grid, cols, rows) {
if (!this.isPlaying) return;
// Throttle updates ~40Hz
const now = performance.now();
if (now - this.lastUpdate < 25) return;
this.lastUpdate = now;
const newLiveCells = new Set();
// Only iterate the sparse live cells
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (grid[y][x]) newLiveCells.add(`${x},${y}`);
}
}
const activeCellCount = newLiveCells.size;
const useSuperCells = activeCellCount > this.superCellThreshold;
let superCellSize = 1;
const superCells = new Map();
if (useSuperCells) {
// Determine super-cell size to limit total oscillators
superCellSize = Math.ceil(Math.sqrt((cols * rows) / this.superCellMaxOscillators));
for (const cellKey of newLiveCells) {
const [x, y] = cellKey.split(',').map(Number);
const sx = Math.floor(x / superCellSize);
const sy = Math.floor(y / superCellSize);
const sKey = `${sx},${sy}`;
superCells.set(sKey, (superCells.get(sKey) || 0) + 1);
}
} else {
// In sparse mode, each live cell becomes its own "super cell"
for (const cellKey of newLiveCells) {
superCells.set(cellKey, 1);
}
}
// Stop oscillators for empty keys
const toDelete = [];
for (const [key, { osc, gain }] of this.oscillators) {
if (!superCells.has(key)) {
gain.gain.cancelScheduledValues(this.audioContext.currentTime);
gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.05);
osc.stop(this.audioContext.currentTime + 0.05);
toDelete.push(key);
}
}
for (const key of toDelete) this.oscillators.delete(key);
// Create/update oscillators
for (const [key, count] of superCells) {
const [sx, sy] = key.split(',').map(Number);
const cx = useSuperCells
? sx * superCellSize + superCellSize / 2
: sx;
const cy = useSuperCells
? sy * superCellSize + superCellSize / 2
: sy;
const frequency = this.calculateFrequency(cx, cy, cols, rows);
const gainValue = useSuperCells
? Math.min(0.3, Math.sqrt(count / (superCellSize * superCellSize)) * 0.3)
: 0.3 / Math.sqrt(activeCellCount);
if (!this.oscillators.has(key)) {
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
osc.type = 'sine';
osc.frequency.value = frequency;
gain.gain.setValueAtTime(0, this.audioContext.currentTime);
gain.gain.setTargetAtTime(gainValue, this.audioContext.currentTime, 0.05);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
this.oscillators.set(key, { osc, gain, frequency });
} else {
const { osc, gain } = this.oscillators.get(key);
if (Math.abs(osc.frequency.value - frequency) > 0.1) {
osc.frequency.setTargetAtTime(frequency, this.audioContext.currentTime, 0.05);
this.oscillators.get(key).frequency = frequency;
}
if (Math.abs(gain.gain.value - gainValue) > 0.01) {
gain.gain.setTargetAtTime(gainValue, this.audioContext.currentTime, 0.05);
}
}
}
// Update liveCells set
this.liveCells = newLiveCells;
}
cleanup() {
for (const [, { osc, gain }] of this.oscillators) {
gain.gain.cancelScheduledValues(this.audioContext.currentTime);
gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
osc.stop(this.audioContext.currentTime + 0.1);
}
this.oscillators.clear();
this.liveCells.clear();
}
}
/**
* Frequency Grid: Map rows to frequencies, columns to amplitude modulation
* Creates a more melodic, wavetable-like sound
*/
class FrequencyGridSynthesizer extends MusicSynthesizer {
constructor() {
super();
this.oscillators = new Map(); // Map of row index -> { osc, gain }
}
generateFromGrid(grid, cols, rows) {
if (!this.isPlaying) return;
const activeCells = new Set();
// Find which rows have active cells
for (let y = 0; y < rows; y++) {
let rowHasLife = false;
for (let x = 0; x < cols; x++) {
if (grid[y][x]) {
rowHasLife = true;
activeCells.add(y);
break;
}
}
}
// Stop oscillators for rows that are now empty
for (const [rowKey, { osc, gain }] of this.oscillators) {
if (!activeCells.has(parseInt(rowKey))) {
gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.05);
setTimeout(() => {
osc.stop();
}, 100);
this.oscillators.delete(rowKey);
}
}
// global grid density
const density = activeCells.size / (rows * cols);
// compensation: louder when overall density is low, neutral when medium
const globalBoost = Math.min(1 / Math.sqrt(density), 10);
// Create or update oscillators for rows with life
for (const row of activeCells) {
const frequency = this.baseFrequency * Math.pow(2, (1 - row / (rows - 1 || 1)) * 3);
// Count density in this row for amplitude
let cellCount = 0;
for (let x = 0; x < cols; x++) {
if (grid[row][x]) cellCount++;
}
const amplitude = Math.min((cellCount / cols) * globalBoost, 0.1);
if (!this.oscillators.has(row)) {
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
osc.type = 'triangle';
osc.frequency.value = frequency;
gain.gain.setValueAtTime(0, this.audioContext.currentTime);
gain.gain.setTargetAtTime(amplitude, this.audioContext.currentTime, 0.1);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
this.oscillators.set(row, { osc, gain });
} else {
const { osc, gain } = this.oscillators.get(row);
osc.frequency.setTargetAtTime(frequency, this.audioContext.currentTime, 0.1);
gain.gain.setTargetAtTime(amplitude, this.audioContext.currentTime, 0.1);
}
}
}
cleanup() {
for (const [, { osc, gain }] of this.oscillators) {
gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
setTimeout(() => {
osc.stop();
}, 150);
}
this.oscillators.clear();
}
}
/**
* Granular Synthesis: Each cell generates a brief burst/grain
* Creates glitchy, textured, evolving sounds
*/
class GranularSynthesizer extends MusicSynthesizer {
constructor() {
super();
this.liveCells = new Set(); // currently alive cells
}
generateFromGrid(grid, cols, rows) {
if (!this.isPlaying) return;
const newLiveCells = new Set();
const triggeredCells = [];
// Only loop over the entire grid to detect transitions
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const key = `${x},${y}`;
const isAlive = grid[y][x] === 1;
if (isAlive) newLiveCells.add(key);
const wasAlive = this.liveCells.has(key);
if (isAlive && !wasAlive) triggeredCells.push([x, y]);
}
}
// Calculate per-grain volume based on number of grains this update
const grainCount = triggeredCells.length || 1; // avoid division by zero
const gainPerGrain = Math.min(0.2, 0.5 / Math.sqrt(grainCount));
for (const [x, y] of triggeredCells) {
this.triggerGrain(x, y, cols, rows, gainPerGrain);
}
// Update live cells set
this.liveCells = newLiveCells;
}
triggerGrain(x, y, cols, rows, gainValue) {
const frequency = this.baseFrequency * Math.pow(
2,
(1 - y / (rows - 1 || 1)) * 3 + (1 - x / (cols - 1 || 1))
);
const grainDuration = 0.1; // 100ms
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
const now = this.audioContext.currentTime;
osc.frequency.value = frequency;
osc.type = 'sine';
// Envelope: fade in, hold, fade out
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(gainValue, now + 0.01);
gain.gain.linearRampToValueAtTime(gainValue, now + grainDuration - 0.02);
gain.gain.linearRampToValueAtTime(0, now + grainDuration);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start(now);
osc.stop(now + grainDuration);
}
cleanup() {
this.liveCells.clear();
}
}
class PolyrhythmSynthesizer extends MusicSynthesizer {
constructor() {
super();
this.oscillatorPool = [];
this.activeOscillators = [];
this.maxOscillators = 50;
this._initOscillatorPool();
// Simple delay for echo effect
this.delayNode = this.audioContext.createDelay();
this.delayNode.delayTime.value = 0.2; // 200ms echo
this.delayGain = this.audioContext.createGain();
this.delayGain.gain.value = 0.25; // echo volume
this.delayNode.connect(this.delayGain);
this.delayGain.connect(this.masterGain);
}
_initOscillatorPool() {
for (let i = 0; i < this.maxOscillators; i++) {
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
osc.type = 'sine';
gain.gain.setValueAtTime(0, this.audioContext.currentTime);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
this.oscillatorPool.push({ osc, gain, inUse: false });
}
}
generateFromGrid(grid, cols, rows) {
if (!this.isPlaying) return;
for (let y = 0; y < rows; y++) {
const rowCells = [];
for (let x = 0; x < cols; x++) if (grid[y][x]) rowCells.push(x);
if (!rowCells.length) continue;
const rowLength = (rows - y) + 2;
for (const x of rowCells) {
const triggerProb = Math.min(1, 0.3 * rowLength / rowCells.length);
if (Math.random() < triggerProb) this.triggerNote(y, rowCells.length);
}
}
}
triggerNote(row, activeCellsInRow) {
const freeOsc = this.oscillatorPool.find(o => !o.inUse);
if (!freeOsc) return;
freeOsc.inUse = true;
const now = this.audioContext.currentTime;
const freq = this.baseFrequency * Math.pow(2, (1 - row / 8) * 4); // wider pitch spread
freeOsc.osc.frequency.setValueAtTime(freq, now);
const gainValue = Math.min(0.3, 0.5 / Math.sqrt(activeCellsInRow));
// Calculate attack and release times based on frequency
const noteDuration = 1 + (6 - row) * 0.2; // Longer duration for lower notes
const attackTime = 0.05;
const releaseTime = noteDuration - attackTime;
// Smoother fade-in/out with dynamic times
freeOsc.gain.gain.cancelScheduledValues(now);
freeOsc.gain.gain.setValueAtTime(0, now);
freeOsc.gain.gain.linearRampToValueAtTime(gainValue, now + attackTime);
freeOsc.gain.gain.exponentialRampToValueAtTime(0.01, now + releaseTime);
// Optional echo: duplicate into delay node
const delayGain = this.audioContext.createGain();
delayGain.gain.value = 0.2;
freeOsc.osc.connect(delayGain);
delayGain.connect(this.delayNode);
// Free oscillator after envelope finishes
setTimeout(() => {
freeOsc.inUse = false;
delayGain.disconnect();
}, noteDuration * 1000); // Adjusted to match the new decay time
}
/*triggerNote(row, activeCellsInRow) {
const freeOsc = this.oscillatorPool.find(o => !o.inUse);
if (!freeOsc) return;
freeOsc.inUse = true;
const now = this.audioContext.currentTime;
const freq = this.baseFrequency * Math.pow(2, (1 - row / 8) * 4); // wider pitch spread
freeOsc.osc.frequency.setValueAtTime(freq, now);
const gainValue = Math.min(0.3, 0.5 / Math.sqrt(activeCellsInRow));
// Long envelope with smoother fade in/out
freeOsc.gain.gain.cancelScheduledValues(now);
freeOsc.gain.gain.setValueAtTime(0, now);
freeOsc.gain.gain.linearRampToValueAtTime(gainValue, now + 0.05);
freeOsc.gain.gain.linearRampToValueAtTime(0.01, now + 0.4); // ~400ms note duration
// Optional echo: duplicate into delay node
const delayGain = this.audioContext.createGain();
delayGain.gain.value = 0.2;
freeOsc.osc.connect(delayGain);
delayGain.connect(this.delayNode);
// Free oscillator after envelope finishes
setTimeout(() => {
freeOsc.inUse = false;
delayGain.disconnect();
}, 450);
}*/
cleanup() {
for (const oscObj of this.oscillatorPool) {
oscObj.gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.05);
oscObj.inUse = false;
}
}
}
// Create global instance using Additive Synthesis by default
let lifeMusic = new AdditiveSynthesizer();
// Function to switch synthesis approaches
function setSynthesisMode(mode) {
const wasPlaying = lifeMusic.isPlaying;
if (wasPlaying) {
lifeMusic.stop();
}
switch (mode) {
case 'additive':
lifeMusic = new AdditiveSynthesizer();
break;
case 'frequencyGrid':
lifeMusic = new FrequencyGridSynthesizer();
break;
case 'granular':
lifeMusic = new GranularSynthesizer();
break;
case 'polyrhythm':
lifeMusic = new PolyrhythmSynthesizer();
break;
default:
lifeMusic = new AdditiveSynthesizer();
}
if (wasPlaying) {
lifeMusic.play();
}
}

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

@ -317,20 +317,10 @@
let elapsedTime = 0; // total elapsed seconds let elapsedTime = 0; // total elapsed seconds
let lastUpdate = 0; // for pausing/resuming let lastUpdate = 0; // for pausing/resuming
const waveformCanvas = document.createElement('canvas'); const waveformCanvas = document.getElementById('waveformCanvas');
waveformCanvas.width = 400;
waveformCanvas.height = 80;
waveformCanvas.style.border = '1px solid #ccc';
waveformCanvas.style.display = 'block';
waveformCanvas.style.marginTop = '6px';
document.body.appendChild(waveformCanvas);
const ctx = waveformCanvas.getContext('2d'); const ctx = waveformCanvas.getContext('2d');
const timerDisplay = document.createElement('div'); const timerDisplay = document.getElementById('timerDisplay');
timerDisplay.style.color = '#cfe7ff';
timerDisplay.style.margin = '4px 0';
timerDisplay.textContent = '00:00';
document.body.appendChild(timerDisplay);
function ensureAnalyser(audioContext) { function ensureAnalyser(audioContext) {
if (analyser && analyser.source) { if (analyser && analyser.source) {

@ -128,13 +128,17 @@ function runInterval() {
const loop = () => { const loop = () => {
if (!running) return; if (!running) return;
step(); step();
const v = parseInt(speedRange.value, 10); const stepsPerSecond = parseFloat(speedRange.value, 10);
const ms = Math.max(10, Math.round(1550 - v)); const ms = Math.max(10, Math.round(1000 / stepsPerSecond));
//const v = parseInt(speedRange.value, 10);
//const ms = Math.max(10, Math.round(1550 - v));
timer = setTimeout(loop, ms); timer = setTimeout(loop, ms);
}; };
const v = parseInt(speedRange.value, 10); const stepsPerSecond = parseFloat(speedRange.value, 10);
const ms = Math.max(10, Math.round(1550 - v)); const ms = Math.max(10, Math.round(1000 / stepsPerSecond));
//const v = parseInt(speedRange.value, 10);
//const ms = Math.max(10, Math.round(1550 - v));
timer = setTimeout(loop, ms); timer = setTimeout(loop, ms);
} }

@ -73,11 +73,19 @@ window.addEventListener("keydown", e => {
startBtn.click(); startBtn.click();
break; break;
case "KeyR": case "KeyR":
document.getElementById("randomBtn").click(); //document.getElementById("randomBtn").click();
break; break;
case "KeyC": case "KeyC":
document.getElementById("clearBtn").click(); document.getElementById("clearBtn").click();
break; break;
case "Escape":
if (document.body.classList.contains('canvas-only')) {
document.body.classList.toggle('canvas-only');
fsBtn.textContent = 'Full screen ⛶';
var resizeEvent = new Event('resize');
window.dispatchEvent(resizeEvent);
}
break;
} }
}); });
@ -101,3 +109,25 @@ document.getElementById("stopRecordBtn").addEventListener("click", async () => {
await window.recording.stopAndMerge("music-of-life.wav"); await window.recording.stopAndMerge("music-of-life.wav");
window.recording.visualize.stop(); window.recording.visualize.stop();
}); });
// Collapsible panels
document.querySelectorAll('.collapsible').forEach(section => {
const toggle = section.querySelector('.collapse-toggle');
toggle.addEventListener('click', () => {
section.classList.toggle('collapsed');
});
});
// Full canvas mode
const fsBtn = document.getElementById('fullscreenToggle');
fsBtn.addEventListener('click', () => {
document.body.classList.toggle('canvas-only');
fsBtn.textContent = document.body.classList.contains('canvas-only')
? 'Exit full screen ↻'
: 'Full screen ⛶';
// keep rendering sharp
//window.resizeCanvas?.();
var resizeEvent = new Event('resize');
window.dispatchEvent(resizeEvent);
});

@ -0,0 +1,244 @@
:root {
--bg: #0f172a;
--canvas-bg: #071020;
--muted: rgba(255, 255, 255, 0.06);
--text: #e6eef8;
--accent: #7cb7ff;
--panel: rgba(255, 255, 255, 0.02);
--radius: 12px;
--gap: 14px;
--max-width: 1200px;
}
* {
box-sizing: border-box
}
html,
body {
height: 100%
}
body {
margin: 0;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial;
background: var(--bg);
color: var(--text);
display: flex;
justify-content: center;
padding: 22px;
}
main {
width: 100%;
max-width: var(--max-width);
display: flex;
flex-direction: column;
gap: var(--gap)
}
header {
display: flex;
justify-content: space-between;
align-items: baseline
}
h1 {
margin: 0;
font-size: 20px
}
.app-grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 20px
}
.sidebar {
display: flex;
flex-direction: column;
gap: var(--gap)
}
@media(min-width:980px) {
.sidebar {
position: sticky;
top: 22px;
height: calc(100vh - 44px);
overflow: auto
}
}
.canvas-area {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0
}
#runState::before, .recording-indicator::before {
content: "● ";
}
#runState.running {
color: #7cb7ff;
}
.panel {
background: var(--panel);
border: 1px solid var(--muted);
border-radius: var(--radius);
padding: 12px;
}
.panel.primary {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center
}
.panel.secondary {
display: flex;
flex-direction: column;
gap: 10px
}
button {
background: transparent;
color: var(--text);
border: 1px solid var(--muted);
padding: 8px 12px;
border-radius: 999px;
cursor: pointer;
}
label {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
font-size: 13px
}
label.inline {
display: flex;
gap: 8px
}
#lifeCanvas {
width: 100%;
height: min(72vh, 900px);
background: var(--canvas-bg);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.04);
}
footer {
text-align: center;
font-size: 12px;
opacity: .8
}
.recording-indicator {
margin-left: 8px;
font-size: 13px;
color: #ff5f5f;
animation: pulse 1.2s infinite;
}
.hidden {
display: none;
}
@keyframes pulse {
0% { opacity: 1 }
50% { opacity: 0.4 }
100% { opacity: 1 }
}
@media(max-width:980px) {
.app-grid {
grid-template-columns: 1fr
}
#lifeCanvas {
height: 60vh
}
}
/* ---------- Collapsible panels ---------- */
.collapsible .collapse-toggle {
background: none;
border: none;
color: var(--accent);
font-weight: 600;
text-align: left;
padding: 0;
cursor: pointer;
}
.collapsible .collapsible-content {
margin-top: 10px;
}
.collapsible.collapsed .collapsible-content {
display: none;
}
.collapse-toggle::before {
content: "↑ ";
}
.collapsible.collapsed .collapse-toggle::before {
content: "↓ ";
}
/* ---------- Full canvas mode ---------- */
.fullscreen-toggle {
align-self: flex-end;
margin-bottom: 8px;
}
body.canvas-only .sidebar,
body.canvas-only header,
body.canvas-only footer {
display: none;
}
body.canvas-only .canvas-area {
width: 100%;
}
body.canvas-only #lifeCanvas {
height: calc(100vh - 80px);
}
body.canvas-only .app-grid {
display: block;
}
/* Slider alignment */
.slider-row {
display: grid;
grid-template-columns: 6em 1fr;
align-items: center;
gap: 5px;
}
.slider-control {
display: grid;
grid-template-columns: 1fr 3ch;
align-items: center;
gap: 8px;
}
/* Prevent output from resizing */
.slider-control output {
display: inline-block;
width: 2ch;
text-align: right;
font-variant-numeric: tabular-nums;
}

@ -1,179 +0,0 @@
:root {
--bg: #0f172a;
--canvas-bg: #071020;
--text: #e6eef8;
--label: #cfe7ff;
--button-bg: #0b1220;
--footer: #9bb8d9;
--border: rgba(255, 255, 255, 0.06);
}
* {
box-sizing: border-box;
font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
display: flex;
justify-content: center;
align-items: center;
}
main {
width: 100%;
max-width: 1100px;
padding: 22px;
}
/* ---------- Header ---------- */
header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
}
h1 {
margin: 0;
font-size: 24px;
}
#status {
display: flex;
gap: 14px;
font-size: 13px;
opacity: 0.85;
}
#runState::before {
content: "● ";
}
#runState.running {
color: #7cb7ff;
}
/* ---------- Controls ---------- */
.controls {
display: flex;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.panel {
border-radius: 10px;
padding: 10px;
border: 1px solid var(--border);
}
.panel.primary {
display: flex;
gap: 6px;
flex-wrap: wrap;
background: rgba(255,255,255,0.02);
}
.panel.secondary {
display: flex;
flex-direction: column;
gap: 8px;
background: rgba(255,255,255,0.015);
min-width: 260px;
}
/* ---------- Inputs ---------- */
button {
background: var(--button-bg);
color: var(--text);
border: 1px solid var(--border);
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
}
button:hover {
filter: brightness(1.05);
}
button:disabled {
opacity: 0.4;
cursor: default;
}
select,
input[type="range"] {
background: var(--button-bg);
color: var(--text);
}
label {
display: grid;
grid-template-columns: 1fr auto;
gap: 6px;
font-size: 14px;
color: var(--label);
}
label.inline {
display: flex;
gap: 6px;
align-items: center;
}
output {
font-size: 12px;
opacity: 0.7;
align-self: center;
}
#modeHint {
font-size: 12px;
opacity: 0.7;
line-height: 1.3;
}
/* ---------- Canvas ---------- */
#lifeCanvas {
width: 100%;
height: 70vh;
display: block;
background: var(--canvas-bg);
border: 1px solid var(--border);
cursor: crosshair;
}
/* ---------- Footer ---------- */
footer {
margin-top: 8px;
text-align: center;
color: var(--footer);
font-size: 12px;
}
/* ---------- Recording -------- */
.recording-indicator {
margin-left: 8px;
font-size: 13px;
color: #ff5f5f;
animation: pulse 1.2s infinite;
}
.hidden {
display: none;
}
@keyframes pulse {
0% { opacity: 1 }
50% { opacity: 0.4 }
100% { opacity: 1 }
}
Loading…
Cancel
Save

Powered by TurnKey Linux.