From 99cafc0b5561bb6c8e4c376444a5009850ac88dc Mon Sep 17 00:00:00 2001 From: Bas Dado Date: Thu, 15 Oct 2026 14:45:38 +0200 Subject: [PATCH] feat: add view transform, photo loading and download helpers Co-Authored-By: Claude Opus 5.5 --- src/lib/download.ts | 8 ++++++++ src/lib/load-image.ts | 21 +++++++++++++++++++++ src/lib/view.ts | 25 +++++++++++++++++++++++++ tests/load-image.test.ts | 16 ++++++++++++++++ tests/view.test.ts | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+) create mode 100644 src/lib/download.ts create mode 100644 src/lib/load-image.ts create mode 100644 src/lib/view.ts create mode 100644 tests/load-image.test.ts create mode 100644 tests/view.test.ts diff --git a/src/lib/download.ts b/src/lib/download.ts new file mode 100644 index 0000000..b62f14a --- /dev/null +++ b/src/lib/download.ts @@ -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); +} diff --git a/src/lib/load-image.ts b/src/lib/load-image.ts new file mode 100644 index 0000000..fc9294b --- /dev/null +++ b/src/lib/load-image.ts @@ -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 { + 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(); + } +} diff --git a/src/lib/view.ts b/src/lib/view.ts new file mode 100644 index 0000000..a525eb1 --- /dev/null +++ b/src/lib/view.ts @@ -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, + }; +} diff --git a/tests/load-image.test.ts b/tests/load-image.test.ts new file mode 100644 index 0000000..f22ce87 --- /dev/null +++ b/tests/load-image.test.ts @@ -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 }); + }); +}); diff --git a/tests/view.test.ts b/tests/view.test.ts new file mode 100644 index 0000000..fbb0ecb --- /dev/null +++ b/tests/view.test.ts @@ -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); + }); +});