ts-hashlife
Version:
Efficient TypeScript implementation of HashLife, an optimized algorithm for simulating Conway's Game of Life with memoization and quadtree-based compression.
426 lines (348 loc) • 12 kB
text/typescript
import { TreeNode } from "./life-universe";
import EventBus from "./event-bus";
export const clamp = (value: number, min: number, max: number) => {
return Math.min(max, Math.max(min, value));
};
export class LifeCanvasDrawer {
private canvas!: HTMLCanvasElement;
private context!: CanvasRenderingContext2D;
private image_data!: ImageData;
private image_data_data!: Int32Array;
private pixel_ratio: number = 1;
private cell_color_rgb!: { r: number; g: number; b: number };
private canvas_width: number = 0;
private canvas_height: number = 0;
private _canvas_offset_x = 0;
private _canvas_offset_y = 0;
private _cell_width = 0;
private _default_cell_width = 0;
public border_width: number = 0;
public background_color: string | null = null;
public cell_color: string | null = null;
constructor() {}
// ----------------------------------------
// Getters and setters
// ----------------------------------------
get default_cell_width(): number {
return this._default_cell_width;
}
set default_cell_width(value: number) {
this._default_cell_width = value;
}
private get canvas_offset_x(): number {
return this._canvas_offset_x;
}
private set canvas_offset_x(value: number) {
this._canvas_offset_x = value;
EventBus.emit("pan:x", value.toFixed(0));
}
private get canvas_offset_y(): number {
return this._canvas_offset_y;
}
private set canvas_offset_y(value: number) {
this._canvas_offset_y = value;
EventBus.emit("pan:y", value.toFixed(0));
}
get cell_width(): number {
return this._cell_width;
}
set cell_width(value: number) {
this._cell_width = value;
this.border_width = Math.floor((value - 5) / 5) + 1;
const ratio = value / this._default_cell_width;
EventBus.emit(
"zoom",
ratio >= 1 ? `1:${Math.round(ratio)}` : `${Math.round(1 / ratio)}:1`
);
}
private set_cell_width = (cell_width: number): boolean => {
const clampedWidth = clamp(cell_width, 0.01, 500);
if (clampedWidth === this.cell_width) return false;
this.cell_width = clampedWidth;
return true;
};
// ----------------------------------------
//
// ----------------------------------------
init(canvas: HTMLCanvasElement): boolean {
this.canvas = canvas;
if (!canvas?.getContext) return false;
this.context = this.canvas.getContext("2d") as CanvasRenderingContext2D;
document.body.style.overscrollBehavior = "none";
return true;
}
set_size(width: number, height: number): void {
if (width !== this.canvas_width || height !== this.canvas_height) {
const factor = window.devicePixelRatio || 1;
this.pixel_ratio = factor;
this.canvas.width = Math.round(width * factor);
this.canvas.height = Math.round(height * factor);
this.canvas.style.width = `${width}px`;
this.canvas.style.height = `${height}px`;
this.canvas_width = this.canvas.width;
this.canvas_height = this.canvas.height;
this.image_data = this.context.createImageData(
this.canvas_width,
this.canvas_height
);
this.image_data_data = new Int32Array(this.image_data.data.buffer);
for (let i = 0; i < width * height; i++) {
this.image_data_data[i] = 0xff << 24;
}
}
}
private draw_node(
node: TreeNode,
size: number,
left: number,
top: number
): void {
if (node.population === 0) {
return;
}
const adjustedLeft = left + this.canvas_offset_x;
const adjustedTop = top + this.canvas_offset_y;
const adjustedSize = size;
if (
adjustedLeft + adjustedSize < 0 ||
adjustedTop + adjustedSize < 0 ||
adjustedLeft >= this.canvas_width ||
adjustedTop >= this.canvas_height
) {
return;
}
if (node.level === 0 || adjustedSize <= 1) {
if (node.population) {
this.fill_square(adjustedLeft, adjustedTop, adjustedSize);
}
} else {
size /= 2;
this.draw_node((node as TreeNode).nw!, size, left, top);
this.draw_node((node as TreeNode).ne!, size, left + size, top);
this.draw_node((node as TreeNode).sw!, size, left, top + size);
this.draw_node((node as TreeNode).se!, size, left + size, top + size);
}
}
private fill_square(x: number, y: number, size: number): void {
x = Math.round(x);
y = Math.round(y);
size = Math.round(size);
let width = size - this.border_width;
let height = width;
if (x < 0) {
width += x;
x = 0;
}
if (x + width > this.canvas_width) {
width = this.canvas_width - x;
}
if (y < 0) {
height += y;
y = 0;
}
if (y + height > this.canvas_height) {
height = this.canvas_height - y;
}
if (width <= 0 || height <= 0) {
return;
}
let pointer = x + y * this.canvas_width;
const row_width = this.canvas_width - width;
const color =
this.cell_color_rgb.r |
(this.cell_color_rgb.g << 8) |
(this.cell_color_rgb.b << 16) |
(0xff << 24);
for (let i = 0; i < height; i++) {
for (let j = 0; j < width; j++) {
this.image_data_data[pointer] = color;
pointer++;
}
pointer += row_width;
}
}
redraw(node: any): void {
const bg_color_rgb = this.color2rgb(this.background_color || "#000");
const bg_color_int =
bg_color_rgb.r |
(bg_color_rgb.g << 8) |
(bg_color_rgb.b << 16) |
(0xff << 24);
this.cell_color_rgb = this.color2rgb(this.cell_color || "#000");
const count = this.canvas_width * this.canvas_height;
for (let i = 0; i < count; i++) {
this.image_data_data[i] = bg_color_int;
}
const size = Math.pow(2, node.level - 1) * this.cell_width;
this.draw_node(node, 2 * size, -size, -size);
this.context.putImageData(this.image_data, 0, 0);
if (this.cell_width > 10) this.draw_grid_lines();
}
private draw_grid_lines(): void {
const ctx = this.context;
ctx.strokeStyle = `rgba(180, 180, 180, ${
this.cell_width > 15 ? 0.2 : 0.4
})`;
ctx.lineWidth = Math.min(
(Math.floor((this.cell_width - 10) / 10) + 1) / 4,
1
);
const startX = this.canvas_offset_x % this.cell_width;
const startY = this.canvas_offset_y % this.cell_width;
// Vertical lines
for (let x = startX; x < this.canvas_width; x += this.cell_width) {
ctx.beginPath();
ctx.moveTo(x - this.border_width / 2, 0);
ctx.lineTo(x - this.border_width / 2, this.canvas_height);
ctx.stroke();
}
// Horizontal lines
for (let y = startY; y < this.canvas_height; y += this.cell_width) {
ctx.beginPath();
ctx.moveTo(0, y - this.border_width / 2);
ctx.lineTo(this.canvas_width, y - this.border_width / 2);
ctx.stroke();
}
}
pan(dx: number, dy: number): void {
this.canvas_offset_x -= dx * this.pixel_ratio;
this.canvas_offset_y -= dy * this.pixel_ratio;
}
center_view(): void {
this.canvas_offset_x = this.canvas_width >> 1;
this.canvas_offset_y = this.canvas_height >> 1;
}
private zoom(out: boolean, center_x: number, center_y: number): void {
if (out) {
const didUpdate = this.set_cell_width(this.cell_width / 2);
if (!didUpdate) return;
this.canvas_offset_x -= Math.round((this.canvas_offset_x - center_x) / 2);
this.canvas_offset_y -= Math.round((this.canvas_offset_y - center_y) / 2);
} else {
const didUpdate = this.set_cell_width(this.cell_width * 2);
if (!didUpdate) return;
this.canvas_offset_x += Math.round(this.canvas_offset_x - center_x);
this.canvas_offset_y += Math.round(this.canvas_offset_y - center_y);
}
}
zoom_at(
zoom_factor: number,
pinch_origin_x: number,
pinch_origin_y: number
): void {
// Store the old cell width before updating
const old_cell_width = this.cell_width;
// Update the cell width with the new zoom factor
const didUpdate = this.set_cell_width(this.cell_width * zoom_factor);
if (!didUpdate) return;
// Calculate the new scale factor
const new_cell_width = this.cell_width;
const scale_factor = new_cell_width / old_cell_width;
// Adjust the canvas offsets to zoom relative to the pinch origin
this.canvas_offset_x +=
(1 - scale_factor) *
(pinch_origin_x * this.pixel_ratio - this.canvas_offset_x);
this.canvas_offset_y +=
(1 - scale_factor) *
(pinch_origin_y * this.pixel_ratio - this.canvas_offset_y);
}
zoom_centered(out: boolean): void {
this.zoom(out, this.canvas_width >> 1, this.canvas_height >> 1);
}
zoom_to(level: number): void {
while (this.cell_width > level) {
this.zoom_centered(true);
}
while (this.cell_width * 2 < level) {
this.zoom_centered(false);
}
}
fit_bounds(
bounds: {
right: number;
left: number;
bottom: number;
top: number;
},
padding?: {
right?: number;
left?: number;
bottom?: number;
top?: number;
}
): void {
const width = bounds.right - bounds.left;
const height = bounds.bottom - bounds.top;
const pad = {
right: (padding?.right || 0) * this.pixel_ratio,
left: (padding?.left || 0) * this.pixel_ratio,
bottom: (padding?.bottom || 0) * this.pixel_ratio,
top: (padding?.top || 0) * this.pixel_ratio,
};
if (isFinite(width) && isFinite(height)) {
const availableWidth = this.canvas_width - (pad.left + pad.right);
const availableHeight = this.canvas_height - (pad.top + pad.bottom);
const relative_size = Math.min(
16, // maximum cell size
availableWidth / width,
availableHeight / height
);
this.zoom_to(relative_size);
// Calculate center position accounting for padding
// This shifts the pattern to account for the padding on all sides
const horizontalCenter = pad.left + availableWidth / 2;
const verticalCenter = pad.top + availableHeight / 2;
this.canvas_offset_x = Math.round(
horizontalCenter - (bounds.left + width / 2) * this.cell_width
);
this.canvas_offset_y = Math.round(
verticalCenter - (bounds.top + height / 2) * this.cell_width
);
} else {
this.zoom_to(16);
// Center within available space
const availableWidth = this.canvas_width - (pad.left + pad.right);
const availableHeight = this.canvas_height - (pad.top + pad.bottom);
this.canvas_offset_x = pad.left + (availableWidth >> 1);
this.canvas_offset_y = pad.top + (availableHeight >> 1);
}
}
// draw_cell(x: number, y: number, set: boolean): void {
// const size = this.cell_width + this.cell_width * this.zoom_factor;
// const cell_x = x * size + this.canvas_offset_x;
// const cell_y = y * size + this.canvas_offset_y;
// const width = Math.ceil(size) - size * this.border_width;
// console.log(size, cell_x, cell_y, width)
// this.context.fillStyle = set
// ? this.cell_color || "#000"
// : this.background_color || "#000";
// this.context.fillRect(cell_x, cell_y, width, width);
// }
pixel2cell(x: number, y: number): { x: number; y: number } {
return {
x: Math.floor(
(x * this.pixel_ratio - this.canvas_offset_x + this.border_width / 2) /
this.cell_width
),
y: Math.floor(
(y * this.pixel_ratio - this.canvas_offset_y + this.border_width / 2) /
this.cell_width
),
};
}
private color2rgb(color: string): { r: number; g: number; b: number } {
if (color.length === 4) {
return {
r: parseInt(color[1] + color[1], 16),
g: parseInt(color[2] + color[2], 16),
b: parseInt(color[3] + color[3], 16),
};
} else {
return {
r: parseInt(color.slice(1, 3), 16),
g: parseInt(color.slice(3, 5), 16),
b: parseInt(color.slice(5, 7), 16),
};
}
}
}