1
0
basics/stfx/ui/html/generic.html

384 lines
11 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<title>Generic STFX UI</title>
<style>
:root {
font-size:12pt;
}
* {
box-sizing: border-box;
}
body {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
width: 100vw;
max-width: 100vw;
height: 100vh;
min-height: 100vh;
max-height: 100vh;
overflow: hidden;
background: linear-gradient(#FFF, #EEE, #CCC);
color: #000;
text-align: center;
word-wrap: break-word;
font-family: Bahnschrift, 'DIN Alternate', 'Alte DIN 1451 Mittelschrift', 'D-DIN', 'OpenDin', 'Clear Sans', 'Barlow', 'Abel', 'Franklin Gothic Medium', system-ui, sans-serif;
/* no text selectable by default */
user-select: none;
}
output {
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
font-weight: normal;
color: #000;
}
#header {
grid-area: header;
display: flex;
justify-content: center;
margin: 0.5rem;
font-weight: normal;
letter-spacing: 0.1ex;
transform: scale(0.95, 1);
}
#params {
flex-grow: 1;
display: flex;
align-content: space-around;
justify-content: space-evenly;
flex-wrap: wrap;
padding: 1rem;
gap: 1rem;
}
.param-range {
display: grid;
width: 3.5rem;
grid-template-areas: "dial" "name";
grid-template-rows: 3.5rem 1fr;
}
.param-range-value {
position: relative;
grid-area: dial;
width: 3.5rem;
height: 3.5rem;
}
.param-range-dial {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
}
.param-range-text {
position: absolute;
top: 30%;
left: 0;
width: 100%;
letter-spacing: -0.1ex;
}
.param-range-units {
position: absolute;
top: 60%;
left: 0;
width: 100%;
color: #666;
font-size: 0.85rem;
}
.param-range-name {
grid-area: name;
}
#plots {
display: flex;
flex-grow: 10;
}
.plot {
display: block;
width: 100%;
flex-grow: 100;
position: relative;
background: linear-gradient(#222, #000 2rem);
}
.plot canvas {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
/* Alternative layouts */
/* compact: more compact, */
:root.compact {
font-size: 9pt;
}
:root.compact #params {
padding: 0.5rem;
}
/* columns: horizontal layout, not vertical */
:root.columns body {
flex-direction: row;
}
:root.columns #params {
flex-direction: column;
}
:root.columns #header {
display: none;
}
</style>
</head>
<body>
<h1 id="header">{name}</h1>
<section id="params">
<template @foreach>
<label class="param-range" @if="${d => d.$type == 'ParamRange'}">
<div class="param-range-name">{name}</div>
<script>
let gestureUnit = Symbol(), scrollTimeout = Symbol();
function move(data, dx, dy, element) {
let prev = data.gesture ? data[gestureUnit] : data.rangeUnit;
data[gestureUnit] = data.rangeUnit = Math.min(1, Math.max(0, prev - dy/250));
}
function scroll(data, dx, dy, element) {
let prev = data.gesture ? data[gestureUnit] : data.rangeUnit;
clearTimeout(element[scrollTimeout]);
data.gesture = true;
element[scrollTimeout] = setTimeout(_ => data.gesture = false, 500);
data[gestureUnit] = data.rangeUnit = Math.min(1, Math.max(0, prev + dy/250));
}
function press(data, count, event, element) {
clearTimeout(element[scrollTimeout]);
data.gesture = true;
data[gestureUnit] = data.rangeUnit;
if (count == 2) {
data.value = data.defaultValue;
data.gesture = false;
}
}
function unpress(data) {
data.gesture = false;
}
function drawDial(data, canvas) {
let scale = window.devicePixelRatio;
let pixels = canvas.offsetWidth*scale;
if (canvas.width != pixels) canvas.width = pixels;
if (canvas.height != pixels) canvas.height = pixels;
let context = canvas.getContext('2d');
context.resetTransform();
context.clearRect(0, 0, pixels, pixels);
context.scale(pixels/2, pixels/2);
context.translate(1, 1);
context.beginPath();
context.ellipse(0, 0, 0.8, 0.8, 0, 0, Math.PI*2);
context.fillStyle = '#FFF';
context.fill();
context.lineWidth = 0.2;
context.lineCap = 'round';
context.beginPath();
context.ellipse(0, 0, 0.9, 0.9, 0, Math.PI*-1.25, Math.PI*0.25);
context.strokeStyle = '#0001';
context.stroke();
let rangeUnit = data.rangeUnit;
if (data.gesture) rangeUnit = data[gestureUnit];
context.beginPath();
context.ellipse(0, 0, 0.9, 0.9, 0, Math.PI*-1.25, Math.PI*(-1.249 + rangeUnit*1.499));
context.strokeStyle = '#000';
context.stroke();
}
</script>
<div class="param-range-value" $move="${move}" $scroll="${scroll}" $press="${press}" $unpress="${unpress}">
<canvas class="param-range-dial" $update="${drawDial}"></canvas>
<output class="param-range-text">{text}</output><br>
<div class="param-range-units">{textUnits}</div>
</div>
</label>
</template>
</section>
<template @foreach>
<div class="plot" @if="${d => d.$type == 'Spectrum'}" $fullscreentoggle='foo'>
<canvas class="plot-grid" $update="${drawSpectrum}" $resize="${drawSpectrum}"></canvas>
<canvas class="plot-data" $update="${drawSpectrum}" $resize="${drawSpectrum}"></canvas>
<canvas class="plot-labels" $update="${drawSpectrum}" $resize="${drawSpectrum}"></canvas>
</div>
</template>
<script>
// Query keys set CSS class on the root
new URL(location).searchParams.forEach((value, key) => {
document.body.parentNode.classList.add(key);
});
let plotColours = ['#8CF', '#FC8', '#8D8', '#F9B'];
function freqScale(width, lowHz, highHz) {
// let a = -289.614, b = 1176.76, c = 15.3385, d = -0.552833; // Bark
let low = lowHz/(1500 + lowHz);
let high = highHz/(1500 + highHz);
let scale = width/(high - low);
return hz => {
let v = hz/(1500 + hz);
return scale*(v - low);
};
}
function drawSpectrum(data, canvas) {
let context = canvas.getContext('2d');
let width = canvas.offsetWidth, height = canvas.offsetHeight;
{
let pixelWidth = Math.round(width*window.devicePixelRatio);
let pixelHeight = Math.round(height*devicePixelRatio);
if (canvas.width != pixelWidth) canvas.width = pixelWidth;
if (canvas.height != pixelHeight) canvas.height = pixelHeight;
context.resetTransform();
context.clearRect(0, 0, pixelWidth, pixelHeight);
context.scale(window.devicePixelRatio, window.devicePixelRatio);
}
let baseHz = 100, maxHz = data.hz[data.hz.length - 1];
let xScale = freqScale(width, data.hz[0], maxHz);
let yScale = e => height*Math.log10(e + 1e-30)*10/-105;
yScale = e => {
e = Math.pow(e, 0.12);
e = e*0.6/(1 - 0.6 - e + 2*e*0.6);
return height*(1 - e);
};
if (canvas.classList.contains('plot-grid')) {
context.beginPath();
context.lineWidth = 1;
context.strokeStyle = '#555';
for (let baseHz = 100; baseHz < maxHz; baseHz *= 10) {
for (let i = 1; i < 10; ++i) {
let hz = baseHz*i;
let x = xScale(hz);
context.moveTo(x, 0);
context.lineTo(x, height);
}
}
for (let db = 0; db >= -240; db -= 12) {
let e = Math.pow(10, db/10);
let y = yScale(e);
context.moveTo(0, y);
context.lineTo(width, y);
}
context.stroke();
} else if (canvas.classList.contains('plot-data')) {
context.lineWidth = 1.5;
context.lineCap = 'round';
context.lineJoin = 'round';
let xMapped = Matsui.getRaw(data.hz).map(xScale);
data.energy.forEach((line, index) => {
context.strokeStyle = plotColours[index%plotColours.length];
context.globalAlpha = 6/(6 + index);
context.beginPath();
for (let i = 0; i < xMapped.length; ++i) {
let e = line[i];
let y = yScale(e);
context.lineTo(xMapped[i], y);
}
context.stroke();
if (1) {
context.fillStyle = plotColours[index%plotColours.length];
context.lineTo(width, height);
context.lineTo(0, height);
context.globalAlpha = 2/(10 + index);
context.fill();
}
});
} else {
context.globalAlpha = 1;
context.lineWidth = 2;
context.strokeStyle = '#0006';
context.fillStyle = '#DDD';
for (let baseHz = 100; baseHz < maxHz; baseHz *= 10) {
let text = (baseHz < 1000) ? baseHz + "Hz" : baseHz/1000 + "kHz";
context.strokeText(text, xScale(baseHz) + 2, height - 2);
context.fillText(text, xScale(baseHz) + 2, height - 2);
}
for (let db = 0; db > -105; db -= (db <= -48 ? 24 : 12)) {
let e = Math.pow(10, db/10);
let y = yScale(e);
context.strokeText(db + "dB", 2, y + 4 + 4*(db == 0));
context.fillText(db + "dB", 2, y + 4 + 4*(db == 0));
}
}
}
</script>
<script src="cbor.min.js"></script>
<script src="matsui-bundle.min.js"></script>
<script>
Matsui.global.attributes.fullscreentoggle = (element) => {
Matsui.global.attributes.press(element, count => {
if (count == 2) {
if (document.fullscreenElement == element) {
document.exitFullscreen();
} else {
element.requestFullscreen();
}
}
});
};
let state = Matsui.replace(document.body, {name: "..."});
state.trackMerges(merge => {
console.log(JSON.stringify(merge));
window.parent.postMessage(CBOR.encode(merge), '*');
});
let pendingMerge = null;
addEventListener('message', e => {
let merge = CBOR.decode(e.data);
if (pendingMerge !== null) {
Matsui.merge.apply(pendingMerge, merge);
} else {
pendingMerge = merge;
requestAnimationFrame(_ => {
pendingMerge = null;
state.merge(merge);
});
}
});
if (window.parent !== window) window.parent.postMessage(CBOR.encode("ready"), '*');
// Monitor framerate and send every 200ms
let frameMs = 100, prevFrame = Date.now(), prevSent = 100;
requestAnimationFrame(function nextFrame() {
let now = Date.now(), delta = now - prevFrame;
frameMs += (delta - frameMs)*frameMs/1000;
prevFrame = now;
prevSent += delta;
if (prevSent > 200) {
window.parent.postMessage(CBOR.encode(["meters", frameMs/1000, 0.3]));
prevSent = 0;
}
requestAnimationFrame(nextFrame);
});
</script>
</body>
</html>