home to your local SPACEGIRL 💫 arimelody.space
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

hell yeah i love canvas rendering

+188 -200
+20 -8
cursor/cursor.go
··· 17 17 ID int32 18 18 Conn *websocket.Conn 19 19 Route string 20 - X int16 21 - Y int16 20 + X float32 21 + Y float32 22 + Click bool 22 23 Disconnected bool 23 24 } 24 25 ··· 91 92 if otherClient.ID == client.ID { continue } 92 93 if otherClient.Route != client.Route { continue } 93 94 client.Send([]byte(fmt.Sprintf("join:%d", otherClient.ID))) 94 - client.Send([]byte(fmt.Sprintf("pos:%d:%d:%d", otherClient.ID, otherClient.X, otherClient.Y))) 95 + client.Send([]byte(fmt.Sprintf("pos:%d:%f:%f", otherClient.ID, otherClient.X, otherClient.Y))) 95 96 } 96 97 mutex.Unlock() 97 98 broadcast <- CursorMessage{ ··· 118 119 client.Route, 119 120 []*CursorClient{ client }, 120 121 } 122 + case "click": 123 + if len(args) < 2 { return } 124 + click := 0 125 + if args[1][0] == '1' { 126 + click = 1 127 + } 128 + broadcast <- CursorMessage{ 129 + []byte(fmt.Sprintf("click:%d:%d", client.ID, click)), 130 + client.Route, 131 + []*CursorClient{ client }, 132 + } 121 133 case "pos": 122 134 if len(args) < 3 { return } 123 - x, err := strconv.ParseInt(args[1], 10, 32) 124 - y, err := strconv.ParseInt(args[2], 10, 32) 135 + x, err := strconv.ParseFloat(args[1], 32) 136 + y, err := strconv.ParseFloat(args[2], 32) 125 137 if err != nil { return } 126 - client.X = int16(x) 127 - client.Y = int16(y) 138 + client.X = float32(x) 139 + client.Y = float32(y) 128 140 broadcast <- CursorMessage{ 129 - []byte(fmt.Sprintf("pos:%d:%d:%d", client.ID, client.X, client.Y)), 141 + []byte(fmt.Sprintf("pos:%d:%f:%f", client.ID, client.X, client.Y)), 130 142 client.Route, 131 143 []*CursorClient{ client }, 132 144 }
+164 -133
public/script/cursor.js
··· 1 1 import config from './config.js'; 2 2 3 - const CURSOR_TICK_RATE = 1000/30; 4 3 const CURSOR_LERP_RATE = 1/100; 5 4 const CURSOR_FUNCHAR_RATE = 20; 6 5 const CURSOR_CHAR_MAX_LIFE = 5000; 7 6 const CURSOR_MAX_CHARS = 64; 8 7 9 - /** @type HTMLElement */ 10 - let cursorContainer; 8 + /** @type HTMLCanvasElement */ 9 + let canvas; 10 + /** @type CanvasRenderingContext2D */ 11 + let ctx; 11 12 /** @type Cursor */ 12 13 let myCursor; 13 14 /** @type Map<number, Cursor> */ ··· 19 20 let running = false; 20 21 let lastUpdate = 0; 21 22 23 + let cursorBoxHeight = 0; 24 + let cursorBoxRadius = 0; 25 + let cursorIDFontSize = 0; 26 + let cursorCharFontSize = 0; 27 + 22 28 class Cursor { 23 - /** @type HTMLElement */ 24 - #element; 25 - /** @type HTMLElement */ 26 - #charElement; 27 29 #funCharCooldown = CURSOR_FUNCHAR_RATE; 28 30 29 31 /** ··· 32 34 * @param {number} y 33 35 */ 34 36 constructor(id, x, y) { 37 + this.id = id; 38 + 35 39 // real coordinates (canonical) 36 40 this.x = x; 37 41 this.y = y; 38 42 // render coordinates (interpolated) 39 43 this.rx = x; 40 44 this.ry = y; 45 + 41 46 this.msg = ''; 47 + /** @type Array<FunChar> */ 42 48 this.funChars = new Array(); 43 - 44 - const element = document.createElement('i'); 45 - element.classList.add('cursor'); 46 - const colour = randomColour(); 47 - element.style.borderColor = colour; 48 - element.style.color = colour; 49 - cursorContainer.appendChild(element); 50 - this.#element = element; 51 - 52 - const char = document.createElement('i'); 53 - char.className = 'char'; 54 - cursorContainer.appendChild(char); 55 - this.#charElement = char; 56 - 57 - this.setID(id); 58 - } 59 - 60 - destroy() { 61 - this.#element.remove(); 62 - this.#charElement.remove(); 63 - this.funChars.forEach(char => { 64 - char.destroy(); 65 - }); 66 - } 67 - 68 - /** 69 - * @param {number} x 70 - * @param {number} y 71 - */ 72 - move(x, y) { 73 - this.x = x; 74 - this.y = y; 49 + this.colour = randomColour(); 50 + this.click = false; 75 51 } 76 52 77 53 /** ··· 80 56 update(deltaTime) { 81 57 this.rx += (this.x - this.rx) * CURSOR_LERP_RATE * deltaTime; 82 58 this.ry += (this.y - this.ry) * CURSOR_LERP_RATE * deltaTime; 83 - this.#element.style.left = this.rx + 'px'; 84 - this.#element.style.top = this.ry + 'px'; 85 - this.#charElement.style.left = this.rx + 'px'; 86 - this.#charElement.style.top = (this.ry - 24) + 'px'; 87 59 88 60 if (this.#funCharCooldown > 0) 89 61 this.#funCharCooldown -= deltaTime; 90 62 91 - if (this.msg.length > 0) { 92 - if (config.cursorFunMode === true) { 63 + const x = this.rx * innerWidth - scrollX; 64 + const y = this.ry * innerHeight - scrollY; 65 + const onBackground = ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--on-background'); 66 + 67 + if (config.cursorFunMode === true) { 68 + if (this.msg.length > 0) { 93 69 if (this.#funCharCooldown <= 0) { 94 70 this.#funCharCooldown = CURSOR_FUNCHAR_RATE; 95 71 if (this.funChars.length >= CURSOR_MAX_CHARS) { 96 - const char = this.funChars.shift(); 97 - char.destroy(); 72 + this.funChars.shift(); 98 73 } 99 - const yOffset = -20; 74 + const yOffset = -10 / innerHeight; 100 75 const accelMultiplier = 0.002; 101 76 this.funChars.push(new FunChar( 102 - this.x + window.scrollX, this.y + window.scrollY + yOffset, 77 + this.x, this.y + yOffset, 103 78 (this.x - this.rx) * accelMultiplier, (this.y - this.ry) * accelMultiplier, 104 79 this.msg)); 105 80 } 106 - } else { 107 - this.#charElement.innerText = this.msg; 108 81 } 109 - } else { 110 - this.#charElement.innerText = ''; 82 + 83 + this.funChars.forEach(char => { 84 + if (char.life > CURSOR_CHAR_MAX_LIFE || 85 + char.y - scrollY > innerHeight || 86 + char.x < 0 || 87 + char.x * innerWidth - scrollX > innerWidth 88 + ) { 89 + this.funChars = this.funChars.filter(c => c !== this); 90 + return; 91 + } 92 + char.update(deltaTime); 93 + }); 94 + } else if (this.msg.length > 0) { 95 + ctx.font = 'normal bold ' + cursorCharFontSize + 'px monospace'; 96 + ctx.fillStyle = onBackground; 97 + ctx.fillText( 98 + this.msg, 99 + (x + 6) * devicePixelRatio, 100 + (y + -8) * devicePixelRatio); 111 101 } 112 102 113 - this.funChars.forEach(char => { 114 - if (char.life > CURSOR_CHAR_MAX_LIFE || 115 - char.y > document.body.clientHeight || 116 - char.x < 0 || 117 - char.x > document.body.clientWidth 118 - ) { 119 - this.funChars = this.funChars.filter(c => c !== this); 120 - char.destroy(); 121 - return; 122 - } 123 - char.update(deltaTime); 124 - }); 125 - } 103 + const lightTheme = matchMedia && matchMedia('(prefers-color-scheme: light)').matches; 126 104 127 - setID(id) { 128 - this.id = id; 129 - this.#element.id = 'cursor' + id; 130 - this.#element.innerText = '0x' + id.toString(16).slice(0, 8).padStart(8, '0'); 131 - } 105 + if (lightTheme) 106 + ctx.filter = 'saturate(5) brightness(0.8)'; 107 + 108 + const idText = '0x' + this.id.toString(16).padStart(8, '0'); 109 + const colour = this.click ? onBackground : this.colour; 110 + 111 + ctx.beginPath(); 112 + ctx.roundRect( 113 + (x) * devicePixelRatio, 114 + (y) * devicePixelRatio, 115 + (12 + 7.2 * idText.length) * devicePixelRatio, 116 + cursorBoxHeight, 117 + cursorBoxRadius); 118 + ctx.closePath(); 119 + ctx.fillStyle = lightTheme ? '#fff8' : '#0008'; 120 + ctx.fill(); 121 + ctx.strokeStyle = colour; 122 + ctx.lineWidth = devicePixelRatio; 123 + ctx.stroke(); 132 124 133 - /** 134 - * @param {boolean} active 135 - */ 136 - click(active) { 137 - if (active) 138 - this.#element.classList.add('click'); 139 - else 140 - this.#element.classList.remove('click'); 125 + ctx.font = cursorIDFontSize + 'px monospace'; 126 + ctx.fillStyle = colour; 127 + ctx.fillText( 128 + idText, 129 + (x + 6) * devicePixelRatio, 130 + (y + 14) * devicePixelRatio); 131 + 132 + ctx.filter = ''; 141 133 } 142 134 } 143 135 ··· 152 144 constructor(x, y, xa, ya, text) { 153 145 this.x = x; 154 146 this.y = y; 155 - this.xa = xa + Math.random() * .2 - .1; 156 - this.ya = ya + Math.random() * -.25; 157 - this.r = this.xa * 100; 147 + this.xa = xa + Math.random() * .0005 - .00025; 148 + this.ya = ya + Math.random() * -.00025; 149 + this.r = this.xa * 1000; 158 150 this.ra = this.r * 0.01; 151 + this.text = text; 159 152 this.life = 0; 160 - 161 - const char = document.createElement('i'); 162 - char.className = 'funchar'; 163 - char.innerText = text; 164 - char.style.left = x + 'px'; 165 - char.style.top = y + 'px'; 166 - char.style.transform = `rotate(${this.r}deg)`; 167 - this.element = char; 168 - cursorContainer.appendChild(this.element); 169 153 } 170 154 171 155 /** ··· 177 161 this.x += this.xa * deltaTime; 178 162 this.y += this.ya * deltaTime; 179 163 this.r += this.ra * deltaTime; 180 - this.ya = Math.min(this.ya + 0.0005 * deltaTime, 10); 164 + this.ya = Math.min(this.ya + 0.000001 * deltaTime, 10); 165 + 166 + const x = this.x * innerWidth - scrollX; 167 + const y = this.y * innerHeight - scrollY; 181 168 182 - this.element.style.left = (this.x - window.scrollX) + 'px'; 183 - this.element.style.top = (this.y - window.scrollY) + 'px'; 184 - this.element.style.transform = `rotate(${this.r}deg)`; 185 - } 169 + const translateOffset = { 170 + x: (x + 7.2) * devicePixelRatio, 171 + y: (y - 7.2) * devicePixelRatio, 172 + }; 173 + ctx.translate(translateOffset.x, translateOffset.y); 174 + ctx.rotate(this.r); 175 + ctx.translate(-translateOffset.x, -translateOffset.y); 186 176 187 - destroy() { 188 - this.element.remove(); 177 + ctx.font = 'normal bold ' + cursorCharFontSize + 'px monospace'; 178 + ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--on-background'); 179 + ctx.fillText( 180 + this.text, 181 + x * devicePixelRatio, 182 + y * devicePixelRatio); 183 + 184 + ctx.resetTransform(); 189 185 } 190 186 } 191 187 ··· 205 201 /** 206 202 * @param {MouseEvent} event 207 203 */ 204 + let mouseMoveLock = false; 205 + const mouseMoveCooldown = 1000/30; 208 206 function handleMouseMove(event) { 209 207 if (!myCursor) return; 210 - if (ws && ws.readyState == WebSocket.OPEN) 211 - ws.send(`pos:${event.x}:${event.y}`); 212 - myCursor.move(event.x, event.y); 208 + 209 + const x = event.pageX / innerWidth; 210 + const y = event.pageY / innerHeight; 211 + const f = 10000; // four digit floating-point precision 212 + 213 + if (!mouseMoveLock) { 214 + mouseMoveLock = true; 215 + if (ws && ws.readyState == WebSocket.OPEN) 216 + ws.send(`pos:${Math.round(x * f) / f}:${Math.round(y * f) / f}`); 217 + setTimeout(() => { 218 + mouseMoveLock = false; 219 + }, mouseMoveCooldown); 220 + } 221 + 222 + myCursor.x = x; 223 + myCursor.y = y; 213 224 } 214 225 215 226 function handleMouseDown() { 216 - myCursor.click(true); 227 + myCursor.click = true; 228 + if (ws && ws.readyState == WebSocket.OPEN) 229 + ws.send('click:1'); 217 230 } 218 231 function handleMouseUp() { 219 - myCursor.click(false); 232 + myCursor.click = false; 233 + if (ws && ws.readyState == WebSocket.OPEN) 234 + ws.send('click:0'); 220 235 } 221 236 222 237 /** ··· 238 253 } 239 254 240 255 /** 241 - * @param {number} time 256 + * @param {number} timestamp 242 257 */ 243 - function updateCursors(time) { 258 + function update(timestamp) { 244 259 if (!running) return; 245 260 246 - const deltaTime = time - lastUpdate; 261 + const deltaTime = timestamp - lastUpdate; 262 + lastUpdate = timestamp; 263 + 264 + ctx.clearRect(0, 0, canvas.width, canvas.height); 247 265 248 266 cursors.forEach(cursor => { 249 267 cursor.update(deltaTime); 250 268 }); 251 269 252 - lastUpdate = time; 253 - requestAnimationFrame(updateCursors); 270 + requestAnimationFrame(update); 271 + } 272 + 273 + function handleWindowResize() { 274 + canvas.width = innerWidth * devicePixelRatio; 275 + canvas.height = innerHeight * devicePixelRatio; 276 + cursorBoxHeight = 20 * devicePixelRatio; 277 + cursorBoxRadius = 4 * devicePixelRatio; 278 + cursorIDFontSize = 12 * devicePixelRatio; 279 + cursorCharFontSize = 20 * devicePixelRatio; 254 280 } 255 281 256 282 function cursorSetup() { 257 283 if (running) throw new Error('Only one instance of Cursor can run at a time.'); 258 284 running = true; 259 285 260 - cursorContainer = document.createElement('div'); 261 - cursorContainer.id = 'cursors'; 262 - document.body.appendChild(cursorContainer); 286 + canvas = document.createElement('canvas'); 287 + canvas.id = 'cursors'; 288 + handleWindowResize(); 289 + document.body.appendChild(canvas); 263 290 264 - myCursor = new Cursor("You!", window.innerWidth / 2, window.innerHeight / 2); 291 + ctx = canvas.getContext('2d'); 292 + 293 + myCursor = new Cursor('You!', innerWidth / 2, innerHeight / 2); 265 294 cursors.set(0, myCursor); 266 295 296 + addEventListener('resize', handleWindowResize); 267 297 document.addEventListener('mousemove', handleMouseMove); 268 298 document.addEventListener('mousedown', handleMouseDown); 269 299 document.addEventListener('mouseup', handleMouseUp); 270 300 document.addEventListener('keypress', handleKeyPress); 271 301 document.addEventListener('keyup', handleKeyUp); 272 302 273 - requestAnimationFrame(updateCursors); 303 + requestAnimationFrame(update); 274 304 275 - ws = new WebSocket("/cursor-ws"); 276 - 277 - ws.addEventListener("open", () => { 278 - console.log("Cursor connected to server successfully."); 305 + ws = new WebSocket('/cursor-ws'); 306 + ws.addEventListener('open', () => { 307 + console.log('Cursor connected to server successfully.'); 279 308 280 - ws.send(`loc:${window.location.pathname}`); 309 + ws.send(`loc:${location.pathname}`); 281 310 }); 282 - ws.addEventListener("error", error => { 283 - console.error("Cursor WebSocket error:", error); 311 + ws.addEventListener('error', error => { 312 + console.error('Cursor WebSocket error:', error); 284 313 }); 285 - ws.addEventListener("close", () => { 286 - console.log("Cursor connection closed."); 314 + ws.addEventListener('close', () => { 315 + console.log('Cursor connection closed.'); 287 316 }); 288 - 289 - ws.addEventListener("message", event => { 290 - const args = String(event.data).split(":"); 317 + ws.addEventListener('message', event => { 318 + const args = String(event.data).split(':'); 291 319 if (args.length == 0) return; 292 320 293 321 let id = 0; ··· 300 328 301 329 switch (args[0]) { 302 330 case 'id': { 303 - myCursor.setID(Number(args[1])); 331 + myCursor.id = Number(args[1]); 304 332 break; 305 333 } 306 334 case 'join': { ··· 310 338 } 311 339 case 'leave': { 312 340 if (!cursor || cursor === myCursor) break; 313 - cursors.get(id).destroy(); 314 341 cursors.delete(id); 315 342 break; 316 343 } ··· 324 351 cursor.msg = ''; 325 352 break; 326 353 } 354 + case 'click': { 355 + if (!cursor || cursor === myCursor) break; 356 + cursor.click = args[2] == '1'; 357 + break; 358 + } 327 359 case 'pos': { 328 360 if (!cursor || cursor === myCursor) break; 329 - cursor.move(Number(args[2]), Number(args[3])); 361 + cursor.x = Number(args[2]); 362 + cursor.y = Number(args[3]); 330 363 break; 331 364 } 332 365 default: { 333 - console.warn("Cursor: Unknown command received from server:", args[0]); 366 + console.warn('Cursor: Unknown command received from server:', args[0]); 334 367 break; 335 368 } 336 369 } 337 370 }); 338 371 339 - console.log(`Cursor tracking @ ${window.location.pathname}`); 372 + console.log(`Cursor tracking @ ${location.pathname}`); 340 373 } 341 374 342 375 function cursorDestroy() { 343 376 if (!running) return; 344 377 378 + removeEventListener('resize', handleWindowResize); 345 379 document.removeEventListener('mousemove', handleMouseMove); 346 380 document.removeEventListener('mousedown', handleMouseDown); 347 381 document.removeEventListener('mouseup', handleMouseUp); 348 382 document.removeEventListener('keypress', handleKeyPress); 349 383 document.removeEventListener('keyup', handleKeyUp); 350 384 351 - cursors.forEach(cursor => { 352 - cursor.destroy(); 353 - }); 354 385 cursors.clear(); 355 386 myCursor = null; 356 387
+4 -59
public/style/cursor.css
··· 1 - #cursors i.cursor { 1 + canvas#cursors { 2 2 position: fixed; 3 - padding: 4px; 4 - 5 - display: block; 6 - z-index: 1000; 7 - 8 - background: #0008; 9 - border: 2px solid #808080; 10 - border-radius: 2px; 11 - 12 - font-style: normal; 13 - font-size: 10px; 14 - font-weight: bold; 15 - white-space: nowrap; 16 - } 17 - 18 - #cursors i.cursor.click { 19 - color: var(--on-background) !important; 20 - border-color: var(--on-background) !important; 21 - } 22 - 23 - #cursors i.char { 24 - position: absolute; 25 - margin: 0; 26 - font-style: normal; 27 - font-size: 20px; 28 - font-weight: bold; 29 - color: var(--on-background); 30 - } 31 - 32 - #cursors i.funchar { 33 - position: absolute; 34 - margin: 0; 35 - 36 - display: block; 37 - z-index: 1000; 38 - 39 - font-style: normal; 40 - font-size: 20px; 41 - font-weight: bold; 42 - color: var(--on-background); 43 - } 44 - 45 - #cursors { 3 + top: 0; 4 + left: 0; 46 5 width: 100vw; 47 6 height: 100vh; 48 - position: fixed; 49 - top: 0; 50 - left: 0; 51 - 52 - z-index: 1000; 53 - overflow: clip; 54 - 55 - user-select: none; 56 7 pointer-events: none; 57 - } 58 - 59 - @media (prefers-color-scheme: light) { 60 - #cursors i.cursor { 61 - filter: saturate(5) brightness(0.8); 62 - background: #fff8; 63 - } 8 + z-index: 100; 64 9 }