feat: add view transform, photo loading and download helpers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0a97cb88af
commit
99cafc0b55
5 files changed
+102
No files matched your search
@@ -0,0 +1,8 @@
|
||||
export function downloadText(filename: string, text: string, mime: string): void {
|
||||
const url = URL.createObjectURL(new Blob([text], { type: mime }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { RasterImage } from './types';
|
||||
|
||||
export const MAX_SIDE = 2000;
|
||||
|
||||
export function fitSize(width: number, height: number, maxSide: number): { width: number; height: number } {
|
||||
const s = Math.min(1, maxSide / Math.max(width, height));
|
||||
return { width: Math.round(width * s), height: Math.round(height * s) };
|
||||
}
|
||||
|
||||
export async function loadPhoto(file: Blob): Promise<RasterImage> {
|
||||
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
|
||||
try {
|
||||
const { width, height } = fitSize(bitmap.width, bitmap.height, MAX_SIDE);
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(bitmap, 0, 0, width, height);
|
||||
return { width, height, data: ctx.getImageData(0, 0, width, height).data };
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Point } from './types';
|
||||
|
||||
export type View = { scale: number; offsetX: number; offsetY: number };
|
||||
|
||||
const MIN_SCALE = 0.05;
|
||||
const MAX_SCALE = 20;
|
||||
|
||||
export function screenToImage(p: Point, view: View): Point {
|
||||
return { x: (p.x - view.offsetX) / view.scale, y: (p.y - view.offsetY) / view.scale };
|
||||
}
|
||||
|
||||
export function zoomAt(view: View, screenPoint: Point, factor: number): View {
|
||||
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, view.scale * factor));
|
||||
const anchor = screenToImage(screenPoint, view);
|
||||
return { scale, offsetX: screenPoint.x - anchor.x * scale, offsetY: screenPoint.y - anchor.y * scale };
|
||||
}
|
||||
|
||||
export function fitView(imageWidth: number, imageHeight: number, viewWidth: number, viewHeight: number, padding = 16): View {
|
||||
const scale = Math.min((viewWidth - 2 * padding) / imageWidth, (viewHeight - 2 * padding) / imageHeight);
|
||||
return {
|
||||
scale,
|
||||
offsetX: (viewWidth - imageWidth * scale) / 2,
|
||||
offsetY: (viewHeight - imageHeight * scale) / 2,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fitSize, MAX_SIDE } from '../src/lib/load-image';
|
||||
|
||||
describe('fitSize', () => {
|
||||
it('downscales a 12 MP landscape photo to the max side', () => {
|
||||
expect(fitSize(4000, 3000, MAX_SIDE)).toEqual({ width: 2000, height: 1500 });
|
||||
});
|
||||
|
||||
it('downscales portrait photos by their height', () => {
|
||||
expect(fitSize(3024, 4032, 2000)).toEqual({ width: 1500, height: 2000 });
|
||||
});
|
||||
|
||||
it('never upscales small images', () => {
|
||||
expect(fitSize(800, 600, 2000)).toEqual({ width: 800, height: 600 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fitView, screenToImage, zoomAt, type View } from '../src/lib/view';
|
||||
|
||||
describe('view transform', () => {
|
||||
it('maps screen to image coordinates', () => {
|
||||
const view: View = { scale: 2, offsetX: 10, offsetY: 20 };
|
||||
expect(screenToImage({ x: 30, y: 60 }, view)).toEqual({ x: 10, y: 20 });
|
||||
});
|
||||
|
||||
it('keeps the point under the cursor fixed while zooming', () => {
|
||||
const view: View = { scale: 0.5, offsetX: 40, offsetY: -15 };
|
||||
const cursor = { x: 300, y: 200 };
|
||||
const before = screenToImage(cursor, view);
|
||||
const zoomed = zoomAt(view, cursor, 3);
|
||||
expect(zoomed.scale).toBeCloseTo(1.5);
|
||||
const after = screenToImage(cursor, zoomed);
|
||||
expect(after.x).toBeCloseTo(before.x);
|
||||
expect(after.y).toBeCloseTo(before.y);
|
||||
});
|
||||
|
||||
it('clamps zoom', () => {
|
||||
expect(zoomAt({ scale: 10, offsetX: 0, offsetY: 0 }, { x: 0, y: 0 }, 100).scale).toBe(20);
|
||||
expect(zoomAt({ scale: 0.1, offsetX: 0, offsetY: 0 }, { x: 0, y: 0 }, 0.001).scale).toBe(0.05);
|
||||
});
|
||||
|
||||
it('fits and centres the image in the viewport', () => {
|
||||
const view = fitView(2000, 1000, 1016, 1016, 8);
|
||||
expect(view.scale).toBeCloseTo(0.5);
|
||||
expect(view.offsetX).toBeCloseTo(8);
|
||||
expect(view.offsetY).toBeCloseTo(258);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user