draw.mjs - <short description TODO> Copyright (C) 2022 Strudel contributors - see https://codeberg.org/uzu/strudel/src/branch/main/packages/canvas/draw.mjs This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see https://www.gnu.org/licenses/.
7import { Pattern, getTime, State, TimeSpan } from '@strudel/core';
9export const getDrawContext = (id = 'test-canvas', options) => { 10 let { contextType = '2d', pixelated = false, pixelRatio = window.devicePixelRatio } = options || {}; 11 let canvas = document.querySelector('#' + id); 12 if (!canvas) { 13 canvas = document.createElement('canvas'); 14 canvas.id = id; 15 canvas.width = window.innerWidth * pixelRatio; 16 canvas.height = window.innerHeight * pixelRatio; 17 canvas.style = 'pointer-events:none;width:100%;height:100%;position:fixed;top:0;left:0'; 18 pixelated && (canvas.style.imageRendering = 'pixelated'); 19 document.body.prepend(canvas); 20 let timeout; 21 window.addEventListener('resize', () => { 22 timeout && clearTimeout(timeout); 23 timeout = setTimeout(() => { 24 canvas.width = window.innerWidth * pixelRatio; 25 canvas.height = window.innerHeight * pixelRatio; 26 }, 200); 27 }); 28 } 29 return canvas.getContext(contextType, { willReadFrequently: true }); 30}; 31 32let animationFrames = {}; 33function stopAnimationFrame(id) { 34 if (animationFrames[id] !== undefined) { 35 cancelAnimationFrame(animationFrames[id]); 36 delete animationFrames[id]; 37 } 38} 39function stopAllAnimations(replID) { 40 Object.keys(animationFrames).forEach((id) => (!replID || id.startsWith(replID)) && stopAnimationFrame(id)); 41} 42 43let memory = {}; 44Pattern.prototype.draw = function (fn, options) { 45 if (typeof window === 'undefined') { 46 return this; 47 } 48 let { id = 1, lookbehind = 0, lookahead = 0 } = options; 49 let __t = Math.max(getTime(), 0); 50 stopAnimationFrame(id); 51 lookbehind = Math.abs(lookbehind); 52 // init memory, clear future haps of old pattern 53 memory[id] = (memory[id] || []).filter((h) => !h.isInFuture(__t)); 54 let newFuture = this.queryArc(__t, __t + lookahead).filter((h) => h.hasOnset()); 55 memory[id] = memory[id].concat(newFuture); 56 57 let last; 58 const animate = () => { 59 const _t = getTime(); 60 const t = _t + lookahead; 61 // filter out haps that are too far in the past 62 memory[id] = memory[id].filter((h) => h.isInNearPast(lookbehind, _t)); 63 // begin where we left off in last frame, but max -0.1s (inactive tab throttles to 1fps) 64 let begin = Math.max(last || t, t - 1 / 10); 65 const haps = this.queryArc(begin, t).filter((h) => h.hasOnset()); 66 memory[id] = memory[id].concat(haps); 67 last = t; // makes sure no haps are missed 68 fn(memory[id], _t, t, this); 69 animationFrames[id] = requestAnimationFrame(animate); 70 }; 71 animationFrames[id] = requestAnimationFrame(animate); 72 return this; 73}; 74 75export const cleanupDraw = (clearScreen = true, id) => { 76 const ctx = getDrawContext(); 77 clearScreen && ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 78 stopAllAnimations(id); 79}; 80 81export const cleanupDrawContext = (replID) => { 82 const ctx = getDrawContext(); 83 ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
clear the big canvas context, ignore inline widgets
TODO - Why isn't this pure deep copy not working? Pattern.prototype.onPaint = function (painter) { return this.withState((state) => { const painters = state.controls.painters ? [...state.controls.painters, painter] : [painter]; return new State(state.span, { ...state.controls, painters }); }); };
const round = (x) => Math.round(x * 1000) / 1000;
encapsulates starting and stopping animation frames
118export class Framer { 119 constructor(onFrame, onError) { 120 this.onFrame = onFrame; 121 this.onError = onError; 122 } 123 start() { 124 const self = this; 125 let frame = requestAnimationFrame(function updateHighlights(time) { 126 try { 127 self.onFrame(time); 128 } catch (err) { 129 self.onError(err); 130 } 131 frame = requestAnimationFrame(updateHighlights); 132 }); 133 self.cancel = () => { 134 cancelAnimationFrame(frame); 135 }; 136 } 137 stop() { 138 if (this.cancel) { 139 this.cancel(); 140 } 141 } 142}
syncs animation frames to a cyclist scheduler see vite-vanilla-repl-cm6 for an example
146export class Drawer { 147 constructor(onDraw, drawTime) { 148 this.visibleHaps = []; 149 this.lastFrame = null; 150 this.drawTime = drawTime; 151 this.painters = []; 152 this.framer = new Framer( 153 () => { 154 if (!this.scheduler) { 155 console.warn('Drawer: no scheduler'); 156 return; 157 } 158 const lookbehind = Math.abs(this.drawTime[0]); 159 const lookahead = this.drawTime[1]; 160 // calculate current frame time (think right side of screen for pianoroll) 161 const phase = this.scheduler.now() + lookahead; 162 // first frame just captures the phase; so does time going back (a 163 // restart counts from 0 again): a frame never queries backwards from 164 // where the last song was 165 if (this.lastFrame === null || phase < this.lastFrame) { 166 this.lastFrame = phase; 167 return; 168 } 169 // query haps from last frame till now. take last 100ms max 170 const haps = this.scheduler.pattern.queryArc(Math.max(this.lastFrame, phase - 1 / 10), phase); 171 this.lastFrame = phase; 172 this.visibleHaps = (this.visibleHaps || []) 173 // filter out haps that are too far in the past (think left edge of screen for pianoroll) 174 .filter((h) => h.whole && h.endClipped >= phase - lookbehind - lookahead) 175 // add new haps with onset (think right edge bars scrolling in) 176 .concat(haps.filter((h) => h.hasOnset())); 177 const time = phase - lookahead; 178 onDraw(this.visibleHaps, time, this, this.painters); 179 }, 180 (err) => { 181 console.warn('draw error', err); 182 }, 183 ); 184 } 185 setDrawTime(drawTime) { 186 this.drawTime = drawTime; 187 } 188 invalidate(scheduler = this.scheduler, t) { 189 if (!scheduler) { 190 return; 191 } 192 // TODO: scheduler.now() seems to move even when it's stopped, this hints at a bug... 193 t = t ?? scheduler.now(); 194 this.scheduler = scheduler; 195 let [_, lookahead] = this.drawTime; 196 // +0.1 = workaround for weird holes in query.. 197 const [begin, end] = [Math.max(t, 0), t + lookahead + 0.1]; 198 // remove all future haps 199 this.visibleHaps = this.visibleHaps.filter((h) => h.whole?.begin < t); 200 this.painters = []; // will get populated by .onPaint calls attached to the pattern 201 // query future haps 202 const futureHaps = scheduler.pattern.queryArc(begin, end, { painters: this.painters }); 203 // append future haps 204 this.visibleHaps = this.visibleHaps.concat(futureHaps); 205 } 206 start(scheduler) { 207 this.scheduler = scheduler; 208 this.lastFrame = null; 209 this.invalidate(); 210 this.framer.start(); 211 } 212 stop() { 213 if (this.framer) { 214 this.framer.stop(); 215 } 216 } 217}
219export function getComputedPropertyValue(name) { 220 if (typeof window === 'undefined') { 221 return '#fff'; 222 } 223 return getComputedStyle(document.documentElement).getPropertyValue(name); 224} 225 226let theme = { 227 background: '#222', 228 foreground: '#75baff', 229 caret: '#ffcc00', 230 selection: 'rgba(128, 203, 196, 0.5)', 231 selectionMatch: '#036dd626', 232 lineHighlight: '#00000050', 233 gutterBackground: 'transparent', 234 gutterForeground: '#8a919966', 235}; 236export function getTheme() { 237 return theme; 238} 239export function setTheme(_theme) { 240 theme = _theme; 241}