1/* Picaso's bounded Canvas 2D circuit sculpture. The page's WebGPU world stays independent. */ 2(() => { 3 'use strict'; 4 const card = document.querySelector('[data-skill-id="picaso"]'); 5 const canvas = card?.querySelector('.picaso-energy'); 6 const stage = card?.querySelector('.picaso-reactor'); 7 const button = card?.querySelector('.picaso-charge'); 8 const context = canvas?.getContext('2d'); 9 if (!card || !canvas || !stage || !button || !context) return; 10 11 const listeners = new AbortController(); 12 const { signal } = listeners; 13 const reduced = matchMedia('(prefers-reduced-motion: reduce)'); 14 let frame = 0, last = 0, clock = 0, frames = 0; 15 let width = 1, height = 1, dpr = 1, visible = false, suspended = false; 16 let charge = 0, engaged = false, pointerX = 0, pointerY = 0, offsetX = 0, offsetY = 0; 17 const colors = ['#9dffed', '#8a8dff', '#80dbff']; 18 const permitted = () => !reduced.matches && !document.documentElement.classList.contains('motion-paused'); 19 const running = () => permitted() && visible && !document.hidden && !suspended && card.style.display !== 'none'; 20 21 function path(points, color, alpha, thickness, glow = 0) { 22 context.beginPath(); 23 context.moveTo(points[0][0], points[0][1]); 24 for (let i = 1; i < points.length; i++) context.lineTo(points[i][0], points[i][1]); 25 context.strokeStyle = color; 26 context.globalAlpha = alpha; 27 context.lineWidth = thickness; 28 context.shadowColor = color; 29 context.shadowBlur = glow; 30 context.stroke(); 31 context.shadowBlur = 0; 32 context.globalAlpha = 1; 33 } 34 35 function draw() { 36 context.setTransform(dpr, 0, 0, dpr, 0, 0); 37 context.clearRect(0, 0, width, height); 38 const radius = Math.min(width, height) * .405; 39 const cx = width / 2 + offsetX, cy = height / 2 + offsetY; 40 const energy = 1 + charge * .6 + (engaged ? .12 : 0); 41 const halo = context.createRadialGradient(cx, cy, radius * .12, cx, cy, radius * 1.3); 42 halo.addColorStop(0, '#85eedd04'); 43 halo.addColorStop(.58, '#9181ff15'); 44 halo.addColorStop(.77, '#75ead724'); 45 halo.addColorStop(1, '#75ead700'); 46 context.fillStyle = halo; 47 context.fillRect(0, 0, width, height); 48 49 // Continuous braided conduits, deliberately smooth in time: no random frame flicker. 50 for (let strand = 0; strand < 3; strand++) { 51 const points = []; 52 for (let j = 0; j <= 160; j++) { 53 const angle = j / 160 * Math.PI * 2; 54 const wave = Math.sin(angle * 11 + clock * .8 + strand * 1.7) * 4 55 + Math.sin(angle * 23 - clock * .6 + strand) * 2.5; 56 const r = radius * (.74 + strand * .095) + wave * energy; 57 points.push([cx + Math.cos(angle) * r, cy + Math.sin(angle) * r]); 58 } 59 path(points, colors[strand], .5 + charge * .15, strand === 0 ? 1.8 : 1, 10); 60 } 61 62 // Eight live circuit branches connect the central processor to the outer field. 63 for (let branch = 0; branch < 8; branch++) { 64 const angle = branch * Math.PI / 4 + .18; 65 const points = []; 66 for (let step = 0; step <= 20; step++) { 67 const along = step / 20; 68 const r = radius * (.35 + along * .7); 69 const bend = Math.sin(along * Math.PI) * Math.sin(along * 15 + branch * 1.8 + clock * .75) * 9 * energy; 70 points.push([cx + Math.cos(angle) * r - Math.sin(angle) * bend, cy + Math.sin(angle) * r + Math.cos(angle) * bend]); 71 } 72 path(points, colors[branch % 3], .25 + charge * .15, 1, 5); 73 const endpoint = points[20]; 74 context.strokeStyle = colors[branch % 3]; 75 context.globalAlpha = .65; 76 context.beginPath(); context.arc(endpoint[0], endpoint[1], 3, 0, Math.PI * 2); context.stroke(); 77 context.globalAlpha = 1; 78 } 79 80 // Sixty-four bounded motes drift along orbital paths; charge expands their orbit. 81 for (let i = 0; i < 64; i++) { 82 const angle = i * 2.399963 + clock * (.1 + (i % 5) * .014) * (i % 2 ? 1 : -1); 83 const r = radius * (.58 + (i % 13) / 30 + charge * .055); 84 const x = cx + Math.cos(angle) * r; 85 const y = cy + Math.sin(angle) * r * (.89 + (i % 3) * .055); 86 context.fillStyle = colors[i % 3]; 87 context.globalAlpha = .3 + (Math.sin(clock * .6 + i) + 1) * .23; 88 context.beginPath(); context.arc(x, y, i % 7 === 0 ? 2.4 : 1.25, 0, Math.PI * 2); context.fill(); 89 } 90 context.globalAlpha = 1; 91 canvas.dataset.frames = String(++frames); 92 } 93 94 function tick(now) { 95 frame = 0; 96 if (!running()) { sync(); return; }
97 const elapsed = last ? now - last : 34; 98 if (elapsed >= 32) { 99 const dt = Math.min(elapsed / 1000, .07); 100 last = now; 101 clock += dt * (1 + charge * .8); 102 charge = Math.max(0, charge - dt * .24); 103 offsetX += (pointerX * 12 - offsetX) * .075; 104 offsetY += (pointerY * 12 - offsetY) * .075; 105 card.dataset.charged = charge > .05 ? 'true' : 'false'; 106 draw(); 107 } 108 frame = requestAnimationFrame(tick); 109 } 110 111 function sync() { 112 cancelAnimationFrame(frame); 113 frame = 0; last = 0; 114 const state = running() ? 'running' : reduced.matches ? 'reduced' : 'paused'; 115 canvas.dataset.motion = state; 116 if (state === 'running') frame = requestAnimationFrame(tick); 117 else { 118 offsetX = 0; offsetY = 0; 119 draw(); 120 } 121 } 122 123 function resize() { 124 const bounds = stage.getBoundingClientRect(); 125 if (!bounds.width || !bounds.height) return; 126 width = bounds.width; height = bounds.height; 127 dpr = Math.min(devicePixelRatio || 1, 1.5); 128 canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); 129 draw(); 130 } 131 132 card.addEventListener('pointermove', event => { 133 const bounds = card.getBoundingClientRect(); 134 pointerX = (event.clientX - bounds.left) / bounds.width * 2 - 1; 135 pointerY = (event.clientY - bounds.top) / bounds.height * 2 - 1; 136 card.style.setProperty('--picaso-x', `${(pointerX + 1) * 50}%`); 137 card.style.setProperty('--picaso-y', `${(pointerY + 1) * 50}%`); 138 engaged = true; 139 }, { passive: true, signal }); 140 card.addEventListener('pointerleave', () => { engaged = false; pointerX = 0; pointerY = 0; }, { signal }); 141 card.addEventListener('focusin', () => { engaged = true; }, { signal }); 142 card.addEventListener('focusout', event => { if (!card.contains(event.relatedTarget)) engaged = false; }, { signal }); 143 button.addEventListener('click', () => { 144 charge = 1; card.dataset.charged = 'true'; 145 // In reduced/paused mode, the same action creates a deliberate static charged composition. 146 if (!running()) draw(); 147 }, { signal }); 148 149 const intersection = new IntersectionObserver(entries => { 150 visible = entries[0].isIntersecting; sync(); 151 }, { threshold: 0 }); 152 const size = new ResizeObserver(resize); 153 const motion = new MutationObserver(sync); 154 const display = new MutationObserver(records => { 155 if (records.some(record => record.oldValue?.includes('display: none') !== (card.style.display === 'none'))) sync(); 156 }); 157 intersection.observe(card); 158 size.observe(stage); 159 motion.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); 160 display.observe(card, { attributes: true, attributeFilter: ['style'], attributeOldValue: true }); 161 reduced.addEventListener('change', sync, { signal }); 162 document.addEventListener('visibilitychange', sync, { signal }); 163 window.addEventListener('pagehide', event => { 164 suspended = true; sync(); 165 if (!event.persisted) { 166 listeners.abort(); intersection.disconnect(); size.disconnect(); motion.disconnect(); display.disconnect(); 167 } 168 }, { signal }); 169 window.addEventListener('pageshow', () => { suspended = false; sync(); }, { signal }); 170 card.classList.add('picaso-ready'); 171 resize(); sync(); 172})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.