582 lines
17 KiB
TypeScript
582 lines
17 KiB
TypeScript
class Vec2 {
|
|
constructor(public x: number, public y: number) {}
|
|
|
|
static zero(): Vec2 {
|
|
return new Vec2(0, 0);
|
|
}
|
|
static one(): Vec2 {
|
|
return new Vec2(1, 1);
|
|
}
|
|
|
|
public clone(): Vec2 {
|
|
return new Vec2(this.x, this.y);
|
|
}
|
|
|
|
public add(other: Vec2): Vec2 {
|
|
return new Vec2(this.x + other.x, this.y + other.y);
|
|
}
|
|
public sub(other: Vec2): Vec2 {
|
|
return new Vec2(this.x - other.x, this.y - other.y);
|
|
}
|
|
|
|
public mul(s: number): Vec2 {
|
|
return new Vec2(this.x * s, this.y * s);
|
|
}
|
|
public div(s: number): Vec2 {
|
|
return new Vec2(this.x / s, this.y / s);
|
|
}
|
|
|
|
public cross(other: Vec2): number {
|
|
return this.x * other.y - this.y * other.x;
|
|
}
|
|
public dot(other: Vec2): number {
|
|
return this.x * other.y + this.y * other.y;
|
|
}
|
|
|
|
public magnitude2(): number {
|
|
return this.x * this.x + this.y * this.y;
|
|
}
|
|
public magnitude(): number {
|
|
return Math.sqrt(this.magnitude2());
|
|
}
|
|
|
|
public asUnit(): Vec2 {
|
|
return this.div(this.magnitude());
|
|
}
|
|
|
|
static numberAsString(n: number): string {
|
|
if (n != Math.floor(n)) {
|
|
n.toFixed(2);
|
|
}
|
|
return n.toString();
|
|
}
|
|
public asString(): string {
|
|
return `${Vec2.numberAsString(this.x)},${Vec2.numberAsString(this.y)} `;
|
|
}
|
|
public asAbsolute(): string {
|
|
return `M${this.asString()}`;
|
|
}
|
|
public asLine(): string {
|
|
return `L${this.asString()}`;
|
|
}
|
|
}
|
|
class Line {
|
|
constructor(public p1: Vec2, public p2: Vec2) {}
|
|
public lineLineIntersect(l2: Line): null | Vec2 {
|
|
const p = this.p1;
|
|
const r = this.p2.sub(p);
|
|
|
|
const q = l2.p1;
|
|
const s = l2.p2.sub(q);
|
|
|
|
const r_cross_s = r.cross(s);
|
|
const q_minus_p = q.sub(p);
|
|
|
|
if (r_cross_s === 0) {
|
|
return null;
|
|
// let q_minus_p_cross_r = q_minus_p.cross(r);
|
|
// if (q_minus_p_cross_r === 0)
|
|
// return null; // collinear
|
|
// else
|
|
// return null; // parallel
|
|
} else {
|
|
const t = q_minus_p.cross(s.div(r_cross_s));
|
|
const u = q_minus_p.cross(r.div(r_cross_s));
|
|
if (0 <= t && t <= 1 && 0 <= u && u <= 1) {
|
|
if (t === 0)
|
|
return q.add(s.mul(u));
|
|
else
|
|
return p.add(r.mul(t));
|
|
}
|
|
else {
|
|
return null; // divergent
|
|
}
|
|
}
|
|
}
|
|
}
|
|
class PongElements {
|
|
public playerPaddle: SVGRectElement;
|
|
public aiPaddle: SVGRectElement;
|
|
|
|
public playerScoreElement: SVGTextElement;
|
|
public aiScoreElement: SVGTextElement;
|
|
|
|
public ball: SVGCircleElement;
|
|
public ballAnimation: SVGAnimateMotionElement;
|
|
public ballPath: SVGPathElement;
|
|
public collisionPath: SVGPathElement;
|
|
|
|
public resetButton: HTMLInputElement;
|
|
public serveButton: HTMLInputElement;
|
|
public upButton: HTMLInputElement;
|
|
public stopButton: HTMLInputElement;
|
|
public downButton: HTMLInputElement;
|
|
|
|
public fps: HTMLSpanElement;
|
|
|
|
constructor(svgContent: Document) {
|
|
this.playerPaddle = getSVGRectElement(svgContent, 'pong-player-paddle');
|
|
this.aiPaddle = getSVGRectElement(svgContent, 'pong-ai-paddle');
|
|
|
|
this.playerScoreElement = getSVGTextElement(svgContent, 'pong-player-score');
|
|
this.aiScoreElement = getSVGTextElement(svgContent, 'pong-ai-score');
|
|
|
|
this.ball = getSVGCircleElement(svgContent, 'pong-ball');
|
|
this.ballAnimation = getSVGAnimateMotionElement(svgContent, 'pong-ball-animation');
|
|
this.ballPath = getSVGPathElement(svgContent, 'pong-ball-path');
|
|
this.collisionPath = getSVGPathElement(svgContent, 'pong-collision-path');
|
|
|
|
this.resetButton = getHTMLInput('pong-reset');
|
|
this.serveButton = getHTMLInput('pong-serve');
|
|
this.upButton = getHTMLInput('pong-paddle-up');
|
|
this.stopButton = getHTMLInput('pong-paddle-stop');
|
|
this.downButton = getHTMLInput('pong-paddle-down');
|
|
|
|
this.fps = getHTMLSpan('fps');
|
|
|
|
const WIDTH_STRING = Pong.PADDLE_WIDTH.toString();
|
|
this.aiPaddle.setAttribute('width', WIDTH_STRING);
|
|
this.playerPaddle.setAttribute('width', WIDTH_STRING);
|
|
|
|
const BALL_RADIUS_STRING = Pong.BALL_RADIUS.toString();
|
|
this.ball.setAttribute('r', BALL_RADIUS_STRING);
|
|
|
|
this.resetPaddles();
|
|
this.resetBall();
|
|
}
|
|
public resetBall() {
|
|
this.ballPath.setAttribute('d', Pong.ABSOLUTE_CENTER);
|
|
this.collisionPath.setAttribute('d', Pong.ABSOLUTE_CENTER);
|
|
this.ballAnimation.beginElement();
|
|
}
|
|
public resetPaddles() {
|
|
translateToPosition(this.playerPaddle, Pong.PLAYER_STARTING_POSITION);
|
|
translateToPosition(this.aiPaddle, Pong.AI_STARTING_POSITION);
|
|
}
|
|
public resetScores() {
|
|
this.playerScoreElement.innerHTML = '0';
|
|
this.aiScoreElement.innerHTML = '0';
|
|
}
|
|
}
|
|
class PongState {
|
|
public lastAnimationFrame = 0;
|
|
|
|
public playerPosition = Pong.PLAYER_STARTING_POSITION.clone();
|
|
public aiPosition = Pong.AI_STARTING_POSITION.clone();
|
|
|
|
public ballSpeed = Pong.BALL_BASE_SPEED;
|
|
public ballVelocity = Vec2.zero();
|
|
|
|
public running = false;
|
|
public shouldServe = false;
|
|
public playerServe = false;
|
|
|
|
public time = 0;
|
|
public lastTime = 0;
|
|
public serveTime = 0;
|
|
public dt_ms = 0;
|
|
public dt = 0;
|
|
|
|
public playerScore = 0;
|
|
public aiScore = 0;
|
|
|
|
public scoreDuration = 0;
|
|
public collisionDuration = 0;
|
|
public collisionPoint = Vec2.zero();
|
|
|
|
public moveUp = false;
|
|
public moveDown = false;
|
|
|
|
get timeSinceServe(): number { return (this.time - this.serveTime) / 1000; }
|
|
|
|
public update_dt(time: number) {
|
|
this.lastTime = this.time;
|
|
this.time = time;
|
|
this.dt_ms = this.time - this.lastTime;
|
|
this.dt = this.dt_ms / 1000;
|
|
}
|
|
public resetBall() {
|
|
this.ballSpeed = Pong.BALL_BASE_SPEED;
|
|
this.ballVelocity = Vec2.zero();
|
|
}
|
|
public resetPaddles() {
|
|
this.playerPosition = Pong.PLAYER_STARTING_POSITION.clone();
|
|
this.aiPosition = Pong.AI_STARTING_POSITION.clone();
|
|
this.moveUp = false;
|
|
this.moveDown = false;
|
|
}
|
|
}
|
|
class Pong {
|
|
/*
|
|
x w
|
|
y +---------------------+
|
|
| | |
|
|
h | 0 |
|
|
| | |
|
|
+---------------------+
|
|
*/
|
|
static readonly WIDTH = 512;
|
|
static readonly HEIGHT = 256;
|
|
static readonly HALF_WIDTH = Pong.WIDTH / 2;
|
|
static readonly HALF_HEIGHT = Pong.HEIGHT / 2;
|
|
static readonly CENTER = new Vec2(Pong.HALF_WIDTH, Pong.HALF_HEIGHT);
|
|
static readonly ABSOLUTE_CENTER = Pong.CENTER.asAbsolute();
|
|
|
|
static readonly PADDLE_WIDTH = 4;
|
|
static readonly PADDLE_HEIGHT = 28;
|
|
static readonly PADDLE_HALF_WIDTH = Pong.PADDLE_WIDTH / 2;
|
|
static readonly PADDLE_HALF_HEIGHT = Pong.PADDLE_HEIGHT / 2;
|
|
static readonly PADDLE_MAX_POSITION = Pong.HEIGHT - Pong.PADDLE_HEIGHT;
|
|
static readonly PADDLE_VELOCITY = 150;
|
|
|
|
static readonly BALL_RADIUS = 5;
|
|
static readonly BALL_SPEED_INCREASE = 10;
|
|
static readonly BALL_BASE_SPEED = 200;
|
|
|
|
static readonly PADDLE_STARTING_POSITION_Y = Pong.CENTER.y - Pong.PADDLE_HALF_HEIGHT;
|
|
static readonly PLAYER_STARTING_POSITION = new Vec2(0, Pong.PADDLE_STARTING_POSITION_Y);
|
|
static readonly AI_STARTING_POSITION = new Vec2(Pong.WIDTH - Pong.PADDLE_WIDTH, Pong.PADDLE_STARTING_POSITION_Y);
|
|
|
|
static readonly RAW_CORNERS: Array<Vec2> = [
|
|
// top left
|
|
new Vec2(0, 0+Pong.BALL_RADIUS),
|
|
// top right
|
|
new Vec2(Pong.WIDTH, 0+Pong.BALL_RADIUS),
|
|
// bottom right
|
|
new Vec2(Pong.WIDTH, Pong.HEIGHT-Pong.BALL_RADIUS),
|
|
// bottom left
|
|
new Vec2(0, Pong.HEIGHT-Pong.BALL_RADIUS)
|
|
];
|
|
static readonly BOARD_CORNERS: Array<Vec2> = [
|
|
// top left
|
|
new Vec2(
|
|
Pong.RAW_CORNERS[0].x-Pong.BALL_RADIUS,
|
|
Pong.RAW_CORNERS[0].y),
|
|
// top right
|
|
new Vec2(
|
|
Pong.RAW_CORNERS[1].x+Pong.BALL_RADIUS,
|
|
Pong.RAW_CORNERS[1].y),
|
|
// bottom right
|
|
new Vec2(
|
|
Pong.RAW_CORNERS[2].x+Pong.BALL_RADIUS,
|
|
Pong.RAW_CORNERS[2].y),
|
|
// bottom left
|
|
new Vec2(
|
|
Pong.RAW_CORNERS[3].x-Pong.BALL_RADIUS,
|
|
Pong.RAW_CORNERS[3].y)
|
|
];
|
|
static readonly PADDLE_COLLISION_CORNERS: Array<Vec2> = [
|
|
// top left
|
|
new Vec2(
|
|
Pong.RAW_CORNERS[0].x+Pong.PADDLE_WIDTH+Pong.BALL_RADIUS,
|
|
Pong.RAW_CORNERS[0].y),
|
|
// top right
|
|
new Vec2(
|
|
(Pong.RAW_CORNERS[1].x-Pong.PADDLE_WIDTH)-Pong.BALL_RADIUS,
|
|
Pong.RAW_CORNERS[1].y),
|
|
// bottom right
|
|
new Vec2(
|
|
(Pong.RAW_CORNERS[2].x-Pong.PADDLE_WIDTH)-Pong.BALL_RADIUS,
|
|
Pong.RAW_CORNERS[2].y),
|
|
// bottom left
|
|
new Vec2(
|
|
Pong.RAW_CORNERS[3].x+Pong.PADDLE_WIDTH+Pong.BALL_RADIUS,
|
|
Pong.RAW_CORNERS[3].y)
|
|
];
|
|
|
|
static readonly BOARD_BOUNDS: Array<[Line, boolean]> = [
|
|
// top
|
|
[new Line(Pong.BOARD_CORNERS[0], Pong.BOARD_CORNERS[1]), false],
|
|
// left
|
|
[new Line(Pong.BOARD_CORNERS[0], Pong.BOARD_CORNERS[3]), true],
|
|
// bottom
|
|
[new Line(Pong.BOARD_CORNERS[3], Pong.BOARD_CORNERS[2]), false],
|
|
// right
|
|
[new Line(Pong.BOARD_CORNERS[1], Pong.BOARD_CORNERS[2]), true]
|
|
];
|
|
static readonly PADDLE_COLLISION_BOUNDS: Array<[Line, boolean]> = [
|
|
// top
|
|
[new Line(Pong.PADDLE_COLLISION_CORNERS[0], Pong.PADDLE_COLLISION_CORNERS[1]), false],
|
|
// left
|
|
[new Line(Pong.PADDLE_COLLISION_CORNERS[0], Pong.PADDLE_COLLISION_CORNERS[3]), true],
|
|
// bottom
|
|
[new Line(Pong.PADDLE_COLLISION_CORNERS[3], Pong.PADDLE_COLLISION_CORNERS[2]), false],
|
|
// right
|
|
[new Line(Pong.PADDLE_COLLISION_CORNERS[1], Pong.PADDLE_COLLISION_CORNERS[2]), true]
|
|
];
|
|
|
|
private elements: PongElements;
|
|
private state = new PongState();
|
|
|
|
constructor(svgContent: Document) {
|
|
this.elements = new PongElements(svgContent);
|
|
this.elements.resetButton.addEventListener('click', () => { this.reset(); });
|
|
this.elements.serveButton.addEventListener('click', () => {
|
|
if (!this.state.running) {
|
|
this.state.running = true;
|
|
this.state.shouldServe = true;
|
|
}
|
|
});
|
|
this.elements.upButton.addEventListener('click', () => {
|
|
this.state.moveUp = true;
|
|
this.state.moveDown = false;
|
|
});
|
|
this.elements.downButton.addEventListener('click', () => {
|
|
this.state.moveUp = false;
|
|
this.state.moveDown = true;
|
|
});
|
|
this.elements.stopButton.addEventListener('click', () => {
|
|
this.state.moveUp = false;
|
|
this.state.moveDown = false;
|
|
});
|
|
document.addEventListener('keydown', (event) => { this.handleKeydown(event); });
|
|
document.addEventListener('keyup', (event) => { this.handleKeyup(event); });
|
|
|
|
this.state.lastAnimationFrame = window.requestAnimationFrame((timestamp) => {
|
|
this.update(timestamp);
|
|
});
|
|
}
|
|
|
|
private serve() {
|
|
this.state.shouldServe = false;
|
|
this.state.serveTime = this.state.time;
|
|
this.state.ballVelocity.x = genRandom();
|
|
this.state.ballVelocity.y = genRandom();
|
|
this.state.ballVelocity = this.state.ballVelocity.asUnit().mul(this.state.ballSpeed);
|
|
if (this.state.ballVelocity.x > 0) {
|
|
this.state.playerServe = true;
|
|
}
|
|
this.buildBallPaths(Pong.CENTER);
|
|
}
|
|
public reset() {
|
|
this.state = new PongState();
|
|
|
|
window.cancelAnimationFrame(this.state.lastAnimationFrame);
|
|
|
|
this.updatePaddles();
|
|
this.elements.resetScores();
|
|
this.elements.resetBall();
|
|
}
|
|
private resetBall() {
|
|
this.state.resetBall();
|
|
this.elements.resetBall();
|
|
}
|
|
private resetPaddles() {
|
|
this.state.resetPaddles();
|
|
this.updatePaddles();
|
|
}
|
|
private updatePaddles() {
|
|
translateToPosition(this.elements.playerPaddle, this.state.playerPosition);
|
|
translateToPosition(this.elements.aiPaddle, this.state.aiPosition);
|
|
}
|
|
|
|
private update(timestamp: number) {
|
|
this.state.update_dt(timestamp);
|
|
|
|
const fps = 1 / this.state.dt;
|
|
this.elements.fps.innerHTML = fps.toString();
|
|
|
|
if (this.state.shouldServe) {
|
|
this.serve();
|
|
}
|
|
|
|
this.updatePlayer();
|
|
if (this.state.running) {
|
|
this.updateAI(this.elements.aiPaddle, this.state.aiPosition);
|
|
this.processScore();
|
|
if (this.state.running) {
|
|
this.processHit();
|
|
}
|
|
}
|
|
|
|
this.state.lastAnimationFrame = window.requestAnimationFrame((timestamp) => {
|
|
this.update(timestamp);
|
|
});
|
|
}
|
|
private isColliding(paddle: Vec2): boolean {
|
|
return paddle.y + Pong.PADDLE_HEIGHT > this.state.collisionPoint.y &&
|
|
paddle.y < this.state.collisionPoint.y;
|
|
}
|
|
private processScore() {
|
|
if (this.state.timeSinceServe > this.state.scoreDuration) {
|
|
if (this.state.playerServe) {
|
|
this.state.playerScore++;
|
|
this.elements.playerScoreElement.innerHTML = this.state.playerScore.toString();
|
|
} else {
|
|
this.state.aiScore++;
|
|
this.elements.aiScoreElement.innerHTML = this.state.aiScore.toString();
|
|
}
|
|
this.state.running = false;
|
|
this.resetBall();
|
|
this.resetPaddles();
|
|
}
|
|
}
|
|
private processHit() {
|
|
if (this.state.timeSinceServe > this.state.collisionDuration) {
|
|
let position: Vec2 = this.state.playerPosition;
|
|
let collisionOffset: number = 0.1;
|
|
if (this.state.playerServe) {
|
|
position = this.state.aiPosition;
|
|
collisionOffset = -collisionOffset;
|
|
}
|
|
if (this.isColliding(position)) {
|
|
this.state.ballVelocity.x = -this.state.ballVelocity.x;
|
|
this.state.ballSpeed += Pong.BALL_SPEED_INCREASE;
|
|
|
|
this.state.serveTime = this.state.time;
|
|
this.state.playerServe = !this.state.playerServe;
|
|
|
|
this.state.collisionPoint.x += collisionOffset;
|
|
this.buildBallPaths(this.state.collisionPoint);
|
|
}
|
|
}
|
|
}
|
|
private clampPaddlePosition(paddlePosition: Vec2) {
|
|
paddlePosition.y = Math.max(Math.min(paddlePosition.y, Pong.PADDLE_MAX_POSITION), 0);
|
|
}
|
|
private updatePlayer() {
|
|
if (this.state.moveUp && this.state.moveDown
|
|
|| !(this.state.moveUp || this.state.moveDown))
|
|
{
|
|
return;
|
|
}
|
|
|
|
const dy = this.state.dt * Pong.PADDLE_VELOCITY;
|
|
if (this.state.moveDown) {
|
|
this.state.playerPosition.y += dy;
|
|
} else {
|
|
this.state.playerPosition.y -= dy;
|
|
}
|
|
this.clampPaddlePosition(this.state.playerPosition);
|
|
translateToPosition(this.elements.playerPaddle, this.state.playerPosition);
|
|
}
|
|
private updateAI(paddle: SVGRectElement, position: Vec2) {
|
|
let targetPosition: number;
|
|
if (this.state.playerServe) {
|
|
targetPosition = this.state.collisionPoint.y;
|
|
} else {
|
|
targetPosition = Pong.HALF_HEIGHT;
|
|
}
|
|
targetPosition -= Pong.PADDLE_HALF_HEIGHT;
|
|
|
|
const targetRange = Pong.PADDLE_HALF_HEIGHT / 2;
|
|
const dy = this.state.dt * Pong.PADDLE_VELOCITY;
|
|
|
|
let shouldMove = false;
|
|
if (position.y > targetPosition + targetRange) {
|
|
shouldMove = true;
|
|
position.y -= dy;
|
|
} else if (position.y < targetPosition - targetRange) {
|
|
shouldMove = true;
|
|
position.y += dy;
|
|
}
|
|
if (shouldMove) {
|
|
this.clampPaddlePosition(position);
|
|
translateToPosition(paddle, position);
|
|
}
|
|
}
|
|
private buildBallPath(startingPosition: Vec2, dir: Vec2, bounds: [Line, boolean][]): Vec2[] {
|
|
const path = new Line(startingPosition, startingPosition.add(dir));
|
|
const points: Array<Vec2> = [];
|
|
while (true) {
|
|
const maybeIntersect = this.findNextCollision(path, bounds);
|
|
if (maybeIntersect !== null) {
|
|
const [point, isEnd] = maybeIntersect;
|
|
if (isEnd) {
|
|
if (point.x > Pong.HALF_WIDTH)
|
|
point.x += 0.01;
|
|
else
|
|
point.x -= 0.01;
|
|
} else {
|
|
if (point.y > Pong.HALF_HEIGHT)
|
|
point.y -= 0.01;
|
|
else
|
|
point.y += 0.01;
|
|
}
|
|
points.push(point);
|
|
|
|
if (isEnd) {
|
|
break;
|
|
}
|
|
|
|
dir.y = -dir.y;
|
|
path.p1 = point;
|
|
path.p2 = point.add(dir);
|
|
} else {
|
|
console.error("intersect is null")
|
|
break;
|
|
}
|
|
}
|
|
return points;
|
|
}
|
|
private buildBallPaths(startingPosition: Vec2) {
|
|
const dir = this.state.ballVelocity.mul(1000);
|
|
const animationPoints = this.buildBallPath(startingPosition, dir.clone(), Pong.BOARD_BOUNDS);
|
|
const collisionPoints = this.buildBallPath(startingPosition, dir, Pong.PADDLE_COLLISION_BOUNDS);
|
|
this.state.collisionPoint = collisionPoints[collisionPoints.length - 1].clone();
|
|
this.state.ballVelocity = dir.div(1000);
|
|
|
|
const animationPath = this.buildSVGPathFromPoints(startingPosition, animationPoints);
|
|
this.elements.ballPath.setAttribute('d', animationPath);
|
|
this.state.scoreDuration = this.elements.ballPath.getTotalLength() / this.state.ballSpeed;
|
|
this.elements.ballAnimation.setAttribute('dur', this.state.scoreDuration.toString());
|
|
|
|
const collisionPath = this.buildSVGPathFromPoints(startingPosition, collisionPoints);
|
|
this.elements.collisionPath.setAttribute('d', collisionPath);
|
|
this.state.collisionDuration = this.elements.collisionPath.getTotalLength() / this.state.ballSpeed;
|
|
|
|
this.elements.ballAnimation.beginElement();
|
|
}
|
|
private buildSVGPathFromPoints(start: Vec2, points: Array<Vec2>): string {
|
|
let directions = start.asAbsolute();
|
|
for (const point of points) {
|
|
directions = directions.concat(point.asLine());
|
|
}
|
|
return directions;
|
|
}
|
|
private findNextCollision(path: Line, bounds: [Line, boolean][]): null | [Vec2, boolean] {
|
|
for (const [line, isEnd] of bounds) {
|
|
const maybeIntersect = path.lineLineIntersect(line);
|
|
if (maybeIntersect !== null) {
|
|
return [maybeIntersect, isEnd];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
private handleKeydown(event: KeyboardEvent) {
|
|
const key = event.key;
|
|
if (key === ',') {
|
|
this.state.moveUp = true;
|
|
} else if (key === '.') {
|
|
this.state.moveDown = true;
|
|
} else if (key === 's') {
|
|
if (!this.state.running) {
|
|
this.state.running = true;
|
|
this.state.shouldServe = true;
|
|
}
|
|
} else if (key === 'n') {
|
|
this.reset();
|
|
}
|
|
}
|
|
private handleKeyup(event: KeyboardEvent) {
|
|
const key = event.key;
|
|
if (key === ',') {
|
|
this.state.moveUp = false;
|
|
}
|
|
else if (key === '.') {
|
|
this.state.moveDown = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// generate random number in range(-1, 1)
|
|
function genRandom() {
|
|
return (Math.random() - 0.5) * 2;
|
|
}
|
|
function translateToPosition(element: SVGElement, position: Vec2) {
|
|
translateTo(element, position.x, position.y);
|
|
}
|
|
function translateTo(element: SVGElement, x: number, y: number) {
|
|
element.setAttribute('transform', `translate(${x}, ${y})`);
|
|
}
|