+page.svelte (8325B)
1 <script lang="ts"> 2 import { unitDragging, unitStore, unitToConnect, sinksStore } from '$lib/stores'; 3 import { onMount } from 'svelte'; 4 import { debounce } from 'lodash'; 5 import Sink from '$lib/Sink.svelte'; 6 import { 7 COLS, 8 ROWS, 9 inp, 10 rescale, 11 RANGE, 12 is, 13 getUnit as _getUnit, 14 unitKinds, 15 UNIT_NAMES 16 } from '$lib/types'; 17 import type { 18 Output, 19 Input, 20 Unit, 21 UnitId, 22 NumberUnit, 23 Units, 24 Sinks, 25 UnitKind, 26 Pos 27 } from '$lib/types'; 28 import OscUnitComponent from '$lib/OscUnit.svelte'; 29 import NoiseUnitComponent from '$lib/NoiseUnit.svelte'; 30 import NumberUnitComponent from '$lib/NumberUnit.svelte'; 31 import SmoothUnitComponent from '$lib/SmoothUnit.svelte'; 32 import MathUnitComponent from '$lib/MathUnit.svelte'; 33 import LagUnitComponent from '$lib/LagUnit.svelte'; 34 import ImgUnitComponent from '$lib/ImgUnit.svelte'; 35 import UnitDrag from '$lib/UnitDrag.svelte'; 36 import Wires from '$lib/Wires.svelte'; 37 38 const debug = false; 39 40 let cvs: HTMLCanvasElement | undefined; 41 42 // initialize worker 43 let engineWorker: Worker | undefined = undefined; 44 const loadWorker = async () => { 45 const EngineWorker = await import('$lib/engine.worker?worker'); 46 engineWorker = new EngineWorker.default(); 47 engineWorker.postMessage({ kind: 'config', content: { units, sinks: $sinksStore } }); 48 }; 49 50 onMount(loadWorker); 51 52 $: { 53 if (engineWorker) { 54 engineWorker.onmessage = (msg) => { 55 requestAnimationFrame(() => { 56 if (cvs) { 57 const ctx = cvs.getContext('2d'); 58 if (!ctx) return; 59 ctx.imageSmoothingEnabled = false; 60 61 const offscreen = new OffscreenCanvas(COLS, ROWS); 62 const offscreenCtx = offscreen.getContext('2d'); 63 if (!offscreenCtx) throw new Error('cant get 2d context for offscreen canvas'); 64 65 const arr = new Uint8ClampedArray(msg.data.content); 66 const img = new ImageData(arr, COLS); 67 offscreenCtx.putImageData(img, 0, 0); 68 ctx.drawImage(offscreen, 0, 0, cvs.width, cvs.height); 69 } 70 }); 71 }; 72 } 73 } 74 75 onMount(() => { 76 const resizeObserver = new ResizeObserver((entries) => { 77 const entry = entries.find((entry) => entry.target === cvs); 78 if (entry && cvs) { 79 const width = entry.devicePixelContentBoxSize[0].inlineSize; 80 const height = entry.devicePixelContentBoxSize[0].blockSize; 81 cvs.width = width; 82 cvs.height = height; 83 engineWorker && engineWorker.postMessage({ kind: 'window', content: { width, height } }); 84 } 85 }); 86 87 if (cvs) resizeObserver.observe(cvs, { box: 'device-pixel-content-box' }); 88 89 // This callback cleans up the observer 90 return () => { 91 if (cvs) resizeObserver.unobserve(cvs); 92 }; 93 }); 94 95 const getUnit = (id: UnitId): Unit => _getUnit(null, $unitStore, id); 96 let uid = 0; 97 $: units = $unitStore; 98 99 // let's update the engine whenever the config changes for now, even though we could ignore pos at least. 100 $: { 101 if (engineWorker) { 102 engineWorker.postMessage({ 103 kind: 'config', 104 content: { units: $unitStore, sinks: $sinksStore } 105 }); 106 } 107 } 108 onMount(() => { 109 const doc: SerializedState = fromUrl(); 110 if (doc) { 111 $unitStore = doc.units; 112 uid = Object.keys(doc.units).length; 113 $sinksStore = doc.sinks; 114 } 115 }); 116 117 type SerializedState = { 118 version: 1; 119 units: Units; 120 sinks: Sinks; 121 }; 122 123 function toUrl() { 124 return btoa(JSON.stringify({ version: 1, sinks: $sinksStore, units })); 125 } 126 127 function fromUrl() { 128 const doc = atob(new URL(document.location.toString()).searchParams.get('z') || ''); 129 if (doc) { 130 return JSON.parse(doc); 131 } 132 } 133 134 function _updateUrl() { 135 const url = new URL(String(location)); 136 url.searchParams.set('z', toUrl()); 137 history.pushState({}, '', url); 138 } 139 const updateUrl = debounce(_updateUrl, 250); 140 141 function addUnit(unit: Unit): UnitId { 142 const id = String(uid++); 143 unitStore.setUnit(id, unit); 144 return id; 145 } 146 147 const handleSignalStart = (o: Output) => { 148 $unitToConnect = o; 149 const h = () => { 150 $unitToConnect = false; 151 document.removeEventListener('mouseup', h); 152 }; 153 setTimeout(() => { 154 document.addEventListener('mouseup', h); 155 }, 0); 156 }; 157 158 const _onSinkConnect = (ch: 'l' | 'c' | 'h'): void => { 159 if (!$unitToConnect) { 160 throw new Error('cant connect sink to nonexistant signal'); 161 } 162 $sinksStore[ch] = inp.toggle($sinksStore[ch], $unitToConnect); 163 engineWorker && 164 engineWorker.postMessage({ kind: 'config', content: { sinks: $sinksStore, units } }); 165 $unitToConnect = false; 166 }; 167 168 const setSinkInput = (ch: 'l' | 'c' | 'h', input: Input) => { 169 $sinksStore[ch] = input; 170 engineWorker && 171 engineWorker.postMessage({ kind: 'config', content: { sinks: $sinksStore, units } }); 172 }; 173 174 $: onSinkConnect = $unitToConnect ? _onSinkConnect : null; 175 176 type SinkEntries = [keyof Sinks, Input][]; 177 // Object.entries COULD have extra stuff, so it doesn't assume keys are keyof Sinks exactly. 178 // see https://stackoverflow.com/questions/60141960/typescript-key-value-relation-preserving-object-entries-type 179 $: sinkEntries = [...Object.entries($sinksStore)] as SinkEntries; 180 181 const getConnectHandler = (sdrag: false | Output, input: UnitId) => { 182 if (!sdrag) return null; 183 if (sdrag.id === input) return null; 184 return connectHandler.bind(null, sdrag.id, input); 185 }; 186 187 const connectHandler = (output: UnitId, input: UnitId, k: string) => { 188 const unit = getUnit(input); 189 // @ts-ignore unit[k] is a channel but i don't wanna prove it. 190 unitStore.setUnit(input, { ...unit, [k]: inp.toggle(unit[k], { id: output }) }); 191 engineWorker && 192 engineWorker.postMessage({ kind: 'config', content: { sinks: $sinksStore, units } }); 193 $unitToConnect = false; 194 }; 195 </script> 196 197 <canvas bind:this={cvs} /> 198 <Wires /> 199 200 <div id="buttons"> 201 {#each unitKinds as kind} 202 {#if kind !== 'lag'} 203 <button on:click={() => unitStore.addUnit(kind)}>add {UNIT_NAMES[kind]}</button> 204 {/if} 205 {/each} 206 <button on:click={updateUrl}>save</button> 207 </div> 208 209 <div id="sinks"> 210 {#each sinkEntries as [channel, input]} 211 <Sink 212 setInput={setSinkInput.bind(null, channel)} 213 {input} 214 {channel} 215 {onSinkConnect} 216 connected={$unitToConnect && input ? inp.connected(input, $unitToConnect) : false} 217 /> 218 {/each} 219 </div> 220 <div id="units"> 221 {#each [...Object.entries(units)] as [id, unit]} 222 {@const pos = $unitDragging?.id === id ? $unitDragging.pos : unit.pos} 223 <div class="unit" style={`top: ${pos.y}px; left: ${pos.x}px`}> 224 <div data-unit-id={id}> 225 <UnitDrag {id}> 226 <h2> 227 {#if debug}{id.substring(0, 5)}-{/if}{UNIT_NAMES[unit.kind]} 228 </h2> 229 </UnitDrag> 230 <div 231 class="output-dragger" 232 on:mousedown={(e) => { 233 e.preventDefault(); 234 handleSignalStart({ id }); 235 }} 236 /> 237 {#if unit.kind === 'number'} 238 <NumberUnitComponent {id} /> 239 {:else if unit.kind === 'osc'} 240 <OscUnitComponent {id} /> 241 {:else if unit.kind === 'noise'} 242 <NoiseUnitComponent {id} /> 243 {:else if unit.kind === 'smooth'} 244 <SmoothUnitComponent {id} /> 245 {:else if unit.kind === 'math'} 246 <MathUnitComponent {id} /> 247 {:else if unit.kind === 'lag'} 248 <LagUnitComponent {id} /> 249 {:else if unit.kind === 'img'} 250 <ImgUnitComponent {id} /> 251 {/if} 252 </div> 253 </div> 254 {/each} 255 </div> 256 257 <style> 258 .output-dragger { 259 position: absolute; 260 top: 0; 261 right: 0; 262 width: 16px; 263 height: 16px; 264 border-radius: 8px; 265 background: purple; 266 margin: 5px; 267 } 268 #sinks { 269 position: absolute; 270 top: 0; 271 right: 0; 272 display: flex; 273 flex-direction: column; 274 } 275 .unit { 276 width: 300px; 277 position: absolute; 278 z-index: 1; 279 } 280 #buttons { 281 z-index: 1; 282 position: fixed; 283 bottom: 5px; 284 right: 5px; 285 } 286 h2 { 287 margin: 0; 288 padding: 0 5px; 289 background: white; 290 } 291 canvas { 292 width: 100vw; 293 height: 100vh; 294 background: salmon; 295 } 296 #units { 297 position: absolute; 298 left: 10px; 299 top: 10px; 300 display: flex; 301 } 302 .unit { 303 --main-color: #eee; 304 --r: 6px; 305 --shadow: rgba(200, 200, 200, 255); 306 --s-g: linear-gradient(45deg, #eee, #dedede); 307 --angle: 45deg; 308 background: var(--s-g); 309 padding: 2px; 310 border-radius: var(--r); 311 } 312 .unit::before, 313 .unit::after { 314 content: ''; 315 position: absolute; 316 width: 100%; 317 height: 100%; 318 background: var(--s-g); 319 border-radius: var(--r); 320 z-index: -1; 321 } 322 .unit::before { 323 top: 2px; 324 left: 2px; 325 } 326 .unit::after { 327 top: 4px; 328 left: 4px; 329 } 330 .unit > div { 331 overflow: hidden; 332 background: var(--main-color); 333 border-radius: var(--r); 334 } 335 </style>