Reworked event streaming

This commit is contained in:
Peter Millauer
2026-05-10 12:45:22 +02:00
parent 03d681661a
commit d2de2452a8
8 changed files with 229 additions and 180 deletions
+1 -2
View File
@@ -3,5 +3,4 @@ import { ClientDrawService } from './services/draw/draw.client-service'
const stateService = new ClientStateService() const stateService = new ClientStateService()
const drawService = new ClientDrawService(stateService) const drawService = new ClientDrawService(stateService)
drawService.draw() stateService.connect(drawService)
stateService.connect()
+1
View File
@@ -5,6 +5,7 @@ export type CanvasState = {
export type Stroke = { export type Stroke = {
points: Point[], points: Point[],
color: string, color: string,
density: number,
width: number width: number
} }
+3
View File
@@ -0,0 +1,3 @@
import { Stroke } from "./canvas-state";
export type ListenCallbackParam = { strokeId: number, stroke: Stroke }
@@ -0,0 +1,124 @@
import { Point } from "../../model/canvas-state";
/**
* Returns a point on a Catmull-Rom spline segment at parameter t
*/
export function catmullRomPoint(
p0: Point,
p1: Point,
p2: Point,
p3: Point,
t: number
): Point {
const t2 = t * t;
const t3 = t2 * t;
const x =
0.5 *
(2 * p1.x +
(-p0.x + p2.x) * t +
(2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
(-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3);
const y =
0.5 *
(2 * p1.y +
(-p0.y + p2.y) * t +
(2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
(-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3);
return { x, y };
}
/**
* Converts a list of raw input points into a smooth Catmull-Rom spline
*/
export function getCatmullRomPath(
points: Point[],
density: number,
): Point[] {
const segmentsPerInterval = density;
if (points.length === 0) return [];
if (points.length === 1) return [{ ...points[0] }];
const smoothed: Point[] = [];
const n = points.length;
for (let i = 0; i < n - 1; i++) {
const p0 = points[Math.max(0, i - 1)];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[Math.min(n - 1, i + 2)];
const step = 1 / segmentsPerInterval;
for (let s = 0; s <= segmentsPerInterval; s++) {
const t = s * step;
const pt = catmullRomPoint(p0, p1, p2, p3, t);
smoothed.push(pt);
}
}
// Remove near-duplicates at segment boundaries
const result = smoothed.filter((pt, idx, arr) => {
if (idx === 0) return true;
const prev = arr[idx - 1];
return Math.hypot(pt.x - prev.x, pt.y - prev.y) > 0.001;
});
return result;
}
export function getBrushWidthAt(index: number, totalPoints: number, baseWidth: number = 12): number {
// Example: taper at start and end + speed-based variation
const t = index / (totalPoints - 1);
let width = baseWidth;
// Ease in / ease out
if (t < 0.1) width *= t * 10;
if (t > 0.9) width *= (1 - t) * 10;
return Math.max(1, width);
}
export function drawVariableWidthSegment(
ctx: CanvasRenderingContext2D,
p1: Point,
p2: Point,
width1: number,
width2: number
): void {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const len = Math.hypot(dx, dy); // More efficient than sqrt(dx*dx + dy*dy)
if (len < 0.001) return; // Points are too close
// Normalized perpendicular vector (rotated 90 degrees)
const nx = -dy / len;
const ny = dx / len;
const halfW1 = width1 / 2;
const halfW2 = width2 / 2;
// Four corners of the quadrilateral
const x1 = p1.x + nx * halfW1;
const y1 = p1.y + ny * halfW1;
const x2 = p1.x - nx * halfW1;
const y2 = p1.y - ny * halfW1;
const x3 = p2.x - nx * halfW2;
const y3 = p2.y - ny * halfW2;
const x4 = p2.x + nx * halfW2;
const y4 = p2.y + ny * halfW2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineTo(x3, y3);
ctx.lineTo(x4, y4);
ctx.closePath();
ctx.fill();
}
+37 -144
View File
@@ -1,23 +1,30 @@
import { Point, Stroke } from "../../model/canvas-state"; import { Point, Stroke } from "../../model/canvas-state";
import { ClientStateService } from "../state/state.client-service"; import { ClientStateService } from "../state/state.client-service";
import { getBrushWidthAt, drawVariableWidthSegment, getCatmullRomPath } from "./cat-mul-rom.util";
export class ClientDrawService { export class ClientDrawService {
private readonly canvasContext: CanvasRenderingContext2D private readonly cctx: CanvasRenderingContext2D
private currentColor private currentColor
private currentWidth = 12; private currentDensity
private currentStroke?: number private currentWidth
private currentStrokeId?: number
constructor( constructor(
private readonly stateService: ClientStateService, private readonly stateService: ClientStateService,
private readonly canvas: HTMLCanvasElement = document.getElementById("canvas") as HTMLCanvasElement, private readonly canvas: HTMLCanvasElement = document.getElementById("canvas") as HTMLCanvasElement,
private readonly colorPicker = document.getElementById('colorPicker')!, private readonly colorPicker = document.getElementById('colorPicker')!,
private readonly densitySlider = document.getElementById('densitySlider')!,
private readonly densityValue = document.getElementById('densityValue')!,
private readonly widthSlider = document.getElementById('widthSlider')!, private readonly widthSlider = document.getElementById('widthSlider')!,
private readonly widthValue = document.getElementById('widthValue')!, private readonly widthValue = document.getElementById('widthValue')!,
private readonly menuButton = document.getElementById('menuButton')!, private readonly menuButton = document.getElementById('menuButton')!,
private readonly drawer = document.getElementById('drawer')!, private readonly drawer = document.getElementById('drawer')!,
private readonly closeButton = document.getElementById('closeButton')!, private readonly closeButton = document.getElementById('closeButton')!,
@@ -25,33 +32,33 @@ export class ClientDrawService {
this.resizeCanvas() this.resizeCanvas()
this.setupCanvasEvents() this.setupCanvasEvents()
window.addEventListener("resize", () => this.resizeCanvas()); window.addEventListener("resize", () => this.resizeCanvas());
this.canvasContext = canvas.getContext("2d")! this.cctx = canvas.getContext("2d")!
this.currentColor = colorPicker?.getAttribute('value') ?? "#eaafff" this.currentColor = colorPicker!.getAttribute('value')!
this.currentDensity = Number(densitySlider!.getAttribute('value'))!
this.currentWidth = Number(widthSlider!.getAttribute('value'))!
} }
draw() { draw() {
console.log("drawing")
const strokes: Stroke[] = this.stateService.getStrokes() const strokes: Stroke[] = this.stateService.getStrokes()
this.canvasContext.globalAlpha = 0.05; this.cctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.canvasContext.fillStyle = "white";
this.canvasContext.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.canvasContext.globalAlpha = 1;
this.cctx.globalAlpha = 1;
strokes.forEach(stroke => { strokes.forEach(stroke => {
this.canvasContext.fillStyle = stroke.color; this.cctx.fillStyle = stroke.color;
this.drawCurve(stroke.points, stroke.width) this.drawCurve(stroke.points, stroke.density, stroke.width)
}) })
requestAnimationFrame(() => this.draw())
} }
private addPointToStroke = (e: any) => { private addPointToStroke = (e: any) => {
if (this.currentStroke === undefined) { if (this.currentStrokeId === undefined) {
return return
} }
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
this.stateService.addPoint(this.currentStroke, { this.stateService.addPoint(this.currentStrokeId, {
x: e.clientX - rect.left, x: e.clientX - rect.left,
y: e.clientY - rect.top, y: e.clientY - rect.top,
}) })
@@ -59,10 +66,10 @@ export class ClientDrawService {
private beginStroke = (e: any) => { private beginStroke = (e: any) => {
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
this.stateService.beginStroke({ this.stateService.beginStroke({
color: this.currentColor, color: this.currentColor,
width: this.currentWidth, width: this.currentWidth,
density: this.currentDensity,
points: [{ points: [{
x: e.clientX - rect.left, x: e.clientX - rect.left,
y: e.clientY - rect.top y: e.clientY - rect.top
@@ -70,7 +77,7 @@ export class ClientDrawService {
, ,
}).then((id: number) => { }).then((id: number) => {
console.log("begin stroke", id) console.log("begin stroke", id)
this.currentStroke = id; this.currentStrokeId = id;
this.canvas.addEventListener("mousemove", this.addPointToStroke) this.canvas.addEventListener("mousemove", this.addPointToStroke)
}) })
} }
@@ -99,6 +106,12 @@ export class ClientDrawService {
this.currentWidth = Number(target.value) this.currentWidth = Number(target.value)
}); });
this.densitySlider?.addEventListener('input', (e) => {
const target = e.target as HTMLTextAreaElement
this.densityValue!.innerHTML = target.value
this.currentDensity = Number(target.value)
});
this.menuButton.addEventListener('click', () => { this.menuButton.addEventListener('click', () => {
this.drawer.classList.toggle('open'); this.drawer.classList.toggle('open');
}); });
@@ -125,138 +138,18 @@ export class ClientDrawService {
this.canvas.height = size; this.canvas.height = size;
} }
private drawCurve(points: Point[], density: number) { private drawCurve(points: Point[], density: number, width: number) {
if (points.length < 2) return; if (points.length < 2) return;
points = getCatmullRomPath(points, density)
for (let i = 0; i < points.length - 1; i++) { for (let i = 0; i < points.length - 1; i++) {
const width1 = getBrushWidthAt(i, points.length); const width1 = getBrushWidthAt(i, points.length, width);
const width2 = getBrushWidthAt(i + 1, points.length); const width2 = getBrushWidthAt(i + 1, points.length, width);
const p1 = points[i]; const p1 = points[i];
const p2 = points[i + 1]; const p2 = points[i + 1];
drawVariableWidthSegment(this.canvasContext, p1, p2, width1, width2); drawVariableWidthSegment(this.cctx, p1, p2, width1, width2);
} }
} }
} }
/**
* Returns a point on a Catmull-Rom spline segment at parameter t
*/
export function catmullRomPoint(
p0: Point,
p1: Point,
p2: Point,
p3: Point,
t: number
): Point {
const t2 = t * t;
const t3 = t2 * t;
const x =
0.5 *
(2 * p1.x +
(-p0.x + p2.x) * t +
(2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
(-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3);
const y =
0.5 *
(2 * p1.y +
(-p0.y + p2.y) * t +
(2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
(-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3);
return { x, y };
}
/**
* Converts a list of raw input points into a smooth Catmull-Rom spline
*/
export function getCatmullRomPath(
points: Point[],
density: number,
): Point[] {
const segmentsPerInterval = density;
if (points.length === 0) return [];
if (points.length === 1) return [{ ...points[0] }];
const smoothed: Point[] = [];
const n = points.length;
for (let i = 0; i < n - 1; i++) {
const p0 = points[Math.max(0, i - 1)];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[Math.min(n - 1, i + 2)];
const step = 1 / segmentsPerInterval;
for (let s = 0; s <= segmentsPerInterval; s++) {
const t = s * step;
const pt = catmullRomPoint(p0, p1, p2, p3, t);
smoothed.push(pt);
}
}
// Remove near-duplicates at segment boundaries
const result = smoothed.filter((pt, idx, arr) => {
if (idx === 0) return true;
const prev = arr[idx - 1];
return Math.hypot(pt.x - prev.x, pt.y - prev.y) > 0.001;
});
return result;
}
function getBrushWidthAt(index: number, totalPoints: number, baseWidth: number = 12): number {
// Example: taper at start and end + speed-based variation
const t = index / (totalPoints - 1);
let width = baseWidth;
// Ease in / ease out
if (t < 0.1) width *= t * 10;
if (t > 0.9) width *= (1 - t) * 10;
return Math.max(1, width);
}
export function drawVariableWidthSegment(
ctx: CanvasRenderingContext2D,
p1: Point,
p2: Point,
width1: number,
width2: number
): void {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const len = Math.hypot(dx, dy); // More efficient than sqrt(dx*dx + dy*dy)
if (len < 0.001) return; // Points are too close
// Normalized perpendicular vector (rotated 90 degrees)
const nx = -dy / len;
const ny = dx / len;
const halfW1 = width1 / 2;
const halfW2 = width2 / 2;
// Four corners of the quadrilateral
const x1 = p1.x + nx * halfW1;
const y1 = p1.y + ny * halfW1;
const x2 = p1.x - nx * halfW1;
const y2 = p1.y - ny * halfW1;
const x3 = p2.x - nx * halfW2;
const y3 = p2.y - ny * halfW2;
const x4 = p2.x + nx * halfW2;
const y4 = p2.y + ny * halfW2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineTo(x3, y3);
ctx.lineTo(x4, y4);
ctx.closePath();
ctx.fill();
}
@@ -1,30 +1,45 @@
import { RPCSocket } from "../../../../node_modules/rpclibrary/js/Index"; import { RPCSocket } from "../../../../node_modules/rpclibrary/js/Index";
import { CanvasState, Point, Stroke } from "../../model/canvas-state"; import { CanvasState, Point, Stroke } from "../../model/canvas-state";
import { ListenCallbackParam } from "../../model/rpc-callbacks";
import { ClientDrawService } from "../draw/draw.client-service";
export class ClientStateService { export class ClientStateService {
private remoteService: any private remoteService: any
private canvasState: CanvasState = { strokes: [] } private canvasState: CanvasState = { strokes: [] }
getStrokes() { async connect(drawService: ClientDrawService) {
return this.canvasState?.strokes ?? []
}
async connect() {
const sock = await new RPCSocket(8080, 'localhost').connect(); const sock = await new RPCSocket(8080, 'localhost').connect();
this.remoteService = sock['StateService'] this.remoteService = sock['StateService']
this.canvasState = await this.remoteService.listen((state: CanvasState) => { this.canvasState = await this.getState()
this.canvasState = state await this.remoteService.listen((listenDto: ListenCallbackParam) => {
if(!this.canvasState.strokes[listenDto.strokeId]){
this.canvasState.strokes[listenDto.strokeId] = listenDto.stroke
}else{
this.canvasState.strokes[listenDto.strokeId].points = [...this.canvasState.strokes[listenDto.strokeId].points, ...listenDto.stroke.points]
}
drawService.draw()
}) })
drawService.draw()
} }
getState = async () => {
return await this.remoteService.getState()
}
beginStroke = async (stroke: Stroke) => { beginStroke = async (stroke: Stroke) => {
return await this.remoteService.beginStroke(stroke) return await this.remoteService.beginStroke(stroke)
} }
addPoint = async (strokeId: number, point: Point) =>{ addPoint = async (strokeId: number, point: Point) => {
return this.remoteService.addPoint(strokeId, point) return this.remoteService.addPoint(strokeId, point)
} }
getStrokes() {
return this.canvasState?.strokes ?? []
}
} }
+18 -7
View File
@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -55,7 +56,8 @@
.drawer { .drawer {
position: absolute; position: absolute;
top: 0; top: 0;
right: -500px; /* Fully hidden when closed */ right: -500px;
/* Fully hidden when closed */
width: 300px; width: 300px;
height: 100vh; height: 100vh;
background: white; background: white;
@@ -137,10 +139,11 @@
} }
</style> </style>
</head> </head>
<body> <body>
<!-- Canvas Area --> <!-- Canvas Area -->
<div class="canvas-container"> <div class="canvas-container">
<canvas id="canvas" width="1200" height="800"></canvas> <canvas id="canvas" width="450" height="450"></canvas>
</div> </div>
<!-- Burger Menu Button --> <!-- Burger Menu Button -->
@@ -156,17 +159,25 @@
<!-- Color Picker --> <!-- Color Picker -->
<div class="control"> <div class="control">
<label for="colorPicker">Color</label> <label for="colorPicker">Color</label>
<input type="color" id="colorPicker" value="#000000"> <input type="color" id="colorPicker" value="#eaafff">
</div> </div>
<!-- Brush Width --> <!-- density -->
<div class="control"> <div class="control">
<label for="widthSlider">Brush Size</label> <label for="densitySlider">Smoothness</label>
<input type="range" id="widthSlider" min="1" max="100" value="12"> <input type="range" id="densitySlider" min="1" max="50" value="35">
<span id="widthValue" class="value">12 px</span> <span id="densityValue" class="value">35</span>
</div>
<!-- width -->
<div class="control">
<label for="widthSlider">Width</label>
<input type="range" id="widthSlider" min="1" max="50" value="35">
<span id="widthValue" class="value">35</span>
</div> </div>
</div> </div>
<script type="module" src="main.js"></script> <script type="module" src="main.js"></script>
</body> </body>
</html> </html>
+11 -8
View File
@@ -1,13 +1,14 @@
import { Singleton, Initializable } from "depents"; import { Singleton, Initializable } from "depents";
import { RPCExporter } from "rpclibrary"; import { RPCExporter } from "rpclibrary";
import { CanvasState, Point, Stroke } from "../../../client/model/canvas-state"; import { CanvasState, Point, Stroke } from "../../../client/model/canvas-state";
import { ListenCallbackParam } from "../../../client/model/rpc-callbacks";
@Singleton() @Singleton()
export class StateService implements Initializable, RPCExporter { export class StateService implements Initializable, RPCExporter {
name = 'StateService' as const name = 'StateService' as const
private canvasState: CanvasState = { strokes: [] } private canvasState: CanvasState = { strokes: [] }
private clients: Array<(state: any) => void> = [] private clients: Array<(param: ListenCallbackParam) => void> = []
initialize() { initialize() {
this.canvasState = { strokes: [] } this.canvasState = { strokes: [] }
@@ -17,32 +18,34 @@ export class StateService implements Initializable, RPCExporter {
beginStroke = async (stroke: Stroke) => { beginStroke = async (stroke: Stroke) => {
const strokeId = this.canvasState.strokes.length const strokeId = this.canvasState.strokes.length
this.canvasState.strokes.push(stroke) this.canvasState.strokes.push(stroke)
console.log("beginStroke", strokeId, stroke) this.updateclients(strokeId, stroke)
this.updateclients()
return strokeId return strokeId
} }
addPoint = async (strokeId: number, point: Point) => { addPoint = async (strokeId: number, point: Point) => {
this.canvasState.strokes.length
this.canvasState.strokes[strokeId].points.push(point) this.canvasState.strokes[strokeId].points.push(point)
this.updateclients() this.updateclients(strokeId, { ...this.canvasState.strokes[strokeId], points: [point] })
} }
listen = async (callback: (state: any) => Promise<void>) => { listen = async (callback: (state: any) => Promise<void>) => {
await callback(this.canvasState)
this.clients = [...this.clients, callback] this.clients = [...this.clients, callback]
return this.canvasState return this.canvasState
} }
private updateclients = () => { getState = async (): Promise<CanvasState> => {
return this.canvasState
}
private updateclients = (strokeId: number, stroke: Stroke) => {
this.clients.forEach((client) => { this.clients.forEach((client) => {
client(this.canvasState) client({ strokeId, stroke })
}) })
} }
RPCs = [ RPCs = [
this.beginStroke, this.beginStroke,
this.addPoint, this.addPoint,
this.getState,
{ {
name: 'listen' as const, name: 'listen' as const,
hook: (cb: any) => { this.listen(cb) } hook: (cb: any) => { this.listen(cb) }