Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"dependencies": {
"zustand": "^4.5.2"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^25.0.0",
"@rollup/plugin-node-resolve": "^15.2.3",
Expand Down Expand Up @@ -57,7 +60,8 @@
"storybook": "^8.2.0",
"@storybook/react-vite": "^8.2.0",
"@storybook/addon-essentials": "^8.2.0",
"vite": "^5.4.0"
"vite": "^5.4.0",
"vitest": "^2.0.5"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
Expand All @@ -67,7 +71,8 @@
"lint:fix": "eslint . --ext .ts,.tsx --fix",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"test": "echo 'No tests yet — placeholder for CI'",
"test": "vitest run",
"test:watch": "vitest",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build",
"changeset": "changeset",
Expand Down
43 changes: 43 additions & 0 deletions src/core/coords.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import {
worldToScreen,
screenToWorld,
clampZoom,
zoomAtPoint,
type Camera,
type Point,
} from './coords';

describe('coords', () => {
it('worldToScreen and screenToWorld roundtrip', () => {
const camera: Camera = { zoom: 2, offsetX: 5, offsetY: -5 };
const world: Point = { x: 10, y: 20 };

const screen = worldToScreen(world, camera);
expect(screen).toEqual({ x: (10 - 5) * 2, y: (20 - -5) * 2 });

const back = screenToWorld(screen, camera);
expect(back).toEqual(world);
});

it('clampZoom keeps zoom within bounds', () => {
expect(clampZoom(0.05)).toBeCloseTo(0.1);
expect(clampZoom(10)).toBeCloseTo(5);
expect(clampZoom(1)).toBeCloseTo(1);
});

it('zoomAtPoint keeps the target screen point stationary', () => {
const camera: Camera = { zoom: 1, offsetX: 0, offsetY: 0 };
const p: Point = { x: 100, y: 50 }; // screen point in px

const next = zoomAtPoint(camera, p, 2);

// The world point under p before zoom
const worldBefore = screenToWorld(p, camera);
// After zoom, that same world point should map to the same screen point
const screenAfter = worldToScreen(worldBefore, next);

expect(screenAfter.x).toBeCloseTo(p.x, 6);
expect(screenAfter.y).toBeCloseTo(p.y, 6);
});
});
44 changes: 44 additions & 0 deletions src/core/coords.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,47 @@ export function screenToWorld(p: Point, camera: Camera): Point {
y: p.y / camera.zoom + camera.offsetY,
};
}

/**
* Clamp a zoom value to sane defaults (10%..500%).
* You can override min/max if needed.
*/
export function clampZoom(zoom: number, min = 0.1, max = 5): number {
if (!Number.isFinite(zoom)) return min;
if (zoom < min) return min;
if (zoom > max) return max;
return zoom;
}

/**
* Apply pan in WORLD units. Positive dx moves the camera right (content appears to move left).
*/
export function applyPan(camera: Camera, dx: number, dy: number): Camera {
return {
zoom: camera.zoom,
offsetX: camera.offsetX + dx,
offsetY: camera.offsetY + dy,
};
}

/**
* Zoom at a specific SCREEN point, keeping that point visually stationary.
* factor > 1 zooms in; factor < 1 zooms out.
*/
export function zoomAtPoint(camera: Camera, screenPoint: Point, factor: number, min = 0.1, max = 5): Camera {
const targetWorld = screenToWorld(screenPoint, camera);
const nextZoom = clampZoom(camera.zoom * factor, min, max);
// Solve for offsets so that worldToScreen(targetWorld, nextCamera) == screenPoint
const nextOffsetX = targetWorld.x - screenPoint.x / nextZoom;
const nextOffsetY = targetWorld.y - screenPoint.y / nextZoom;
return { zoom: nextZoom, offsetX: nextOffsetX, offsetY: nextOffsetY };
}

/**
* Compute a CSS transform for a content layer representing WORLD space.
* Usage example: style={{ transform: cameraToCssTransform(camera) }}
*/
export function cameraToCssTransform(camera: Camera): string {
// translate in world units, then scale; keeps worldToScreen mapping: (x - offset) * zoom
return `translate(${-camera.offsetX}px, ${-camera.offsetY}px) scale(${camera.zoom})`;
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './react/Canvas';
export * from './core/coords';
export * from './types';
export * from './state/store';
71 changes: 71 additions & 0 deletions src/react/Camera.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { Meta, StoryObj } from '@storybook/react';
import React from 'react';
import { Canvas } from './Canvas';
import { useCamera, useCanvasActions } from '../state/store';
import { cameraToCssTransform } from '../core/coords';

const meta: Meta<typeof Canvas> = {
title: 'Core/Camera',
component: Canvas,
tags: ['dev'],
};

export default meta;

type Story = StoryObj<typeof Canvas>;

function Controls() {
const camera = useCamera();
const { panBy, zoomTo, zoomByAt } = useCanvasActions();

const anchor = { x: 400, y: 300 }; // center of 800x600 viewport

return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8, flexWrap: 'wrap' }}>
<button type="button" onClick={() => zoomByAt(anchor, 1.25)}>Zoom In</button>
<button type="button" onClick={() => zoomByAt(anchor, 0.8)}>Zoom Out</button>
<button type="button" onClick={() => panBy(-50, 0)}>←</button>
<button type="button" onClick={() => panBy(50, 0)}>→</button>
<button type="button" onClick={() => panBy(0, -50)}>↑</button>
<button type="button" onClick={() => panBy(0, 50)}>↓</button>
<button type="button" onClick={() => zoomTo(1)}>Reset Zoom</button>
<span style={{ marginLeft: 8, color: '#555' }}>
zoom: {camera.zoom.toFixed(2)} | offset: ({camera.offsetX.toFixed(0)}, {camera.offsetY.toFixed(0)})
</span>
</div>
);
}

function WorldLayer() {
const camera = useCamera();
return (
<div style={{ position: 'absolute', inset: 0, transform: cameraToCssTransform(camera), transformOrigin: '0 0' }}>
{/* Big world area with a light grid */}
<div
style={{
position: 'relative',
width: 2000,
height: 2000,
backgroundImage:
'repeating-linear-gradient(0deg, #f8f8f8 0 19px, #eaeaea 19px 20px), repeating-linear-gradient(90deg, #f8f8f8 0 19px, #eaeaea 19px 20px)',
}}
>
{/* Markers at known world coords */}
<div style={{ position: 'absolute', left: 0, top: 0, width: 12, height: 12, background: '#e11', borderRadius: 2 }} />
<div style={{ position: 'absolute', left: 100, top: 100, width: 12, height: 12, background: '#16f', borderRadius: 2 }} />
<div style={{ position: 'absolute', left: 400, top: 300, width: 12, height: 12, background: '#1a1', borderRadius: 2 }} />
</div>
</div>
);
}

export const Playground: Story = {
render: () => (
<div>
<Controls />
<Canvas style={{ width: 800, height: 600, border: '1px solid #ddd', position: 'relative', overflow: 'hidden' }}>
<WorldLayer />
</Canvas>
</div>
),
};
56 changes: 56 additions & 0 deletions src/state/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { create } from 'zustand';
import type { Camera, Point } from '../core/coords';
import { applyPan, clampZoom, zoomAtPoint } from '../core/coords';

export const MIN_ZOOM = 0.1;
export const MAX_ZOOM = 5;

export type CanvasState = {
readonly camera: Camera;
};

export type CanvasActions = {
setCamera: (camera: Camera) => void;
panBy: (dx: number, dy: number) => void;
zoomTo: (zoom: number) => void;
/** Zoom by factor centered at screenPoint (screen coords in px). */
zoomByAt: (screenPoint: Point, factor: number) => void;
};

export type CanvasStore = CanvasState & CanvasActions;

const initialCamera: Camera = { zoom: 1, offsetX: 0, offsetY: 0 };

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const useCanvasStore = create<CanvasStore>()((set, get) => ({
camera: initialCamera,

setCamera: (camera) => set({ camera }),

panBy: (dx, dy) => set((s) => ({ camera: applyPan(s.camera, dx, dy) })),

zoomTo: (zoom) =>
set((s) => ({
camera: { ...s.camera, zoom: clampZoom(zoom, MIN_ZOOM, MAX_ZOOM) },
})),

zoomByAt: (screenPoint, factor) =>
set((s) => ({ camera: zoomAtPoint(s.camera, screenPoint, factor, MIN_ZOOM, MAX_ZOOM) })),
}));

// Convenience hooks
export function useCamera(): Camera {
return useCanvasStore((s) => s.camera);
}

export function useCanvasActions(): Pick<
CanvasActions,
'setCamera' | 'panBy' | 'zoomTo' | 'zoomByAt'
> {
return useCanvasStore((s) => ({
setCamera: s.setCamera,
panBy: s.panBy,
zoomTo: s.zoomTo,
zoomByAt: s.zoomByAt,
}));
}