draw.mjsannotateddraw.mjssource241 lines · 8.4 KB · raw

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

86  Object.keys(animationFrames).forEach(
87    (id) => (!replID || id.startsWith(replID)) && !id.startsWith('_') && stopAnimationFrame(id),
88  );
89};
91Pattern.prototype.onPaint = function (painter) {
92  return this.withState((state) => {
93    if (!state.controls.painters) {
94      state.controls.painters = [];
95    }
96    state.controls.painters.push(painter);
97    return state;
98  });
99};

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 }); }); };

109Pattern.prototype.getPainters = function () {
110  let painters = [];
111  this.queryArc(0, 0, { painters });
112  return painters;
113};

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}