managarten/packages/spiral-db/src/image.ts
Till-JS f1518e8c39 feat(spiral-db): add pixel-based spiral database package
Implement SpiralDB - a novel data storage format that encodes structured
data into PNG images using an 8-color palette. Data grows in a spiral
pattern from the center outward, enabling infinite expansion.

Features:
- 8-color palette (3-bit per pixel) for compression robustness
- Spiral coordinate system with ring-based organization
- Schema-based serialization (int, bool, string, timestamp, arrays)
- Record management with CRUD operations and status tracking
- PNG export/import with pako compression
- Browser support (Canvas, Blob, DataURL)
- Todo schema as reference implementation

Storage structure:
- Ring 0: Magic byte (validation)
- Ring 1: Database header (version, flags, counts)
- Ring 2: Schema definition
- Ring 3+: Master index (spans multiple rings if needed)
- Ring 4+: Record data
2026-02-17 10:42:09 +01:00

231 lines
6.1 KiB
TypeScript

/**
* Image handling for SpiralDB
* Converts between SpiralImage and raw pixel data
*/
import type { ColorIndex, SpiralImage } from './types.js';
import { colorToRGB, rgbToColor } from './encoding.js';
import { spiralToXY, xyToSpiral, getImageSizeForRing } from './spiral.js';
/**
* Create an empty spiral image
*/
export function createImage(size: number): SpiralImage {
if (size % 2 === 0) {
throw new Error('Image size must be odd');
}
const pixels = new Uint8Array(size * size * 3);
// Initialize all pixels to black (0, 0, 0)
return { width: size, height: size, pixels };
}
/**
* Create an image that can hold a specific ring
*/
export function createImageForRing(ring: number): SpiralImage {
const size = getImageSizeForRing(ring);
return createImage(size);
}
/**
* Get a pixel's color at a specific spiral index
*/
export function getPixelByIndex(image: SpiralImage, index: number): ColorIndex {
const point = spiralToXY(index, image.width);
return getPixelByXY(image, point.x, point.y);
}
/**
* Set a pixel's color at a specific spiral index
*/
export function setPixelByIndex(image: SpiralImage, index: number, color: ColorIndex): void {
const point = spiralToXY(index, image.width);
setPixelByXY(image, point.x, point.y, color);
}
/**
* Get a pixel's color at XY coordinates
*/
export function getPixelByXY(image: SpiralImage, x: number, y: number): ColorIndex {
if (x < 0 || x >= image.width || y < 0 || y >= image.height) {
throw new Error(`Coordinates out of bounds: (${x}, ${y})`);
}
const offset = (y * image.width + x) * 3;
const r = image.pixels[offset];
const g = image.pixels[offset + 1];
const b = image.pixels[offset + 2];
return rgbToColor(r, g, b);
}
/**
* Set a pixel's color at XY coordinates
*/
export function setPixelByXY(image: SpiralImage, x: number, y: number, color: ColorIndex): void {
if (x < 0 || x >= image.width || y < 0 || y >= image.height) {
throw new Error(`Coordinates out of bounds: (${x}, ${y})`);
}
const rgb = colorToRGB(color);
const offset = (y * image.width + x) * 3;
image.pixels[offset] = rgb.r;
image.pixels[offset + 1] = rgb.g;
image.pixels[offset + 2] = rgb.b;
}
/**
* Read a range of pixels starting at a spiral index
*/
export function readPixelRange(
image: SpiralImage,
startIndex: number,
length: number
): ColorIndex[] {
const colors: ColorIndex[] = [];
for (let i = 0; i < length; i++) {
colors.push(getPixelByIndex(image, startIndex + i));
}
return colors;
}
/**
* Write a range of pixels starting at a spiral index
*/
export function writePixelRange(
image: SpiralImage,
startIndex: number,
colors: ColorIndex[]
): void {
for (let i = 0; i < colors.length; i++) {
setPixelByIndex(image, startIndex + i, colors[i]);
}
}
/**
* Expand an image to accommodate more rings
* Centers the existing data in the new larger image
*/
export function expandImage(image: SpiralImage, newRing: number): SpiralImage {
const newSize = getImageSizeForRing(newRing);
if (newSize <= image.width) {
return image; // No expansion needed
}
const newImage = createImage(newSize);
// Copy existing pixels to center of new image
const offset = Math.floor((newSize - image.width) / 2);
for (let y = 0; y < image.height; y++) {
for (let x = 0; x < image.width; x++) {
const oldOffset = (y * image.width + x) * 3;
const newOffset = ((y + offset) * newSize + (x + offset)) * 3;
newImage.pixels[newOffset] = image.pixels[oldOffset];
newImage.pixels[newOffset + 1] = image.pixels[oldOffset + 1];
newImage.pixels[newOffset + 2] = image.pixels[oldOffset + 2];
}
}
return newImage;
}
/**
* Get the current ring count based on image size
*/
export function getMaxRingForImage(image: SpiralImage): number {
return Math.floor(image.width / 2);
}
/**
* Convert SpiralImage to raw RGBA buffer (for canvas/web)
*/
export function imageToRGBA(image: SpiralImage): Uint8Array {
const rgba = new Uint8Array(image.width * image.height * 4);
for (let i = 0; i < image.width * image.height; i++) {
rgba[i * 4] = image.pixels[i * 3]; // R
rgba[i * 4 + 1] = image.pixels[i * 3 + 1]; // G
rgba[i * 4 + 2] = image.pixels[i * 3 + 2]; // B
rgba[i * 4 + 3] = 255; // A (fully opaque)
}
return rgba;
}
/**
* Create SpiralImage from raw RGBA buffer
*/
export function rgbaToImage(rgba: Uint8Array, width: number, height: number): SpiralImage {
if (width !== height) {
throw new Error('Image must be square');
}
if (width % 2 === 0) {
throw new Error('Image size must be odd');
}
const pixels = new Uint8Array(width * height * 3);
for (let i = 0; i < width * height; i++) {
pixels[i * 3] = rgba[i * 4]; // R
pixels[i * 3 + 1] = rgba[i * 4 + 1]; // G
pixels[i * 3 + 2] = rgba[i * 4 + 2]; // B
// Ignore alpha
}
return { width, height, pixels };
}
/**
* Convert SpiralImage to a 2D array of color indices (for visualization)
*/
export function imageToColorGrid(image: SpiralImage): ColorIndex[][] {
const grid: ColorIndex[][] = [];
for (let y = 0; y < image.height; y++) {
const row: ColorIndex[] = [];
for (let x = 0; x < image.width; x++) {
row.push(getPixelByXY(image, x, y));
}
grid.push(row);
}
return grid;
}
/**
* Create a visual representation of the spiral order
* Returns a string showing the spiral indices
*/
export function visualizeSpiralOrder(size: number): string {
const grid: string[][] = [];
for (let y = 0; y < size; y++) {
const row: string[] = [];
for (let x = 0; x < size; x++) {
const index = xyToSpiral(x, y, size);
row.push(index.toString().padStart(3, ' '));
}
grid.push(row);
}
return grid.map((row) => row.join(' ')).join('\n');
}
/**
* Create a visual representation using emoji colors
*/
export function visualizeImageEmoji(image: SpiralImage): string {
const emoji: Record<ColorIndex, string> = {
0: '⬛',
1: '🟦',
2: '🟩',
3: '🔷',
4: '🟥',
5: '🟪',
6: '🟨',
7: '⬜',
};
const lines: string[] = [];
for (let y = 0; y < image.height; y++) {
let line = '';
for (let x = 0; x < image.width; x++) {
const color = getPixelByXY(image, x, y);
line += emoji[color];
}
lines.push(line);
}
return lines.join('\n');
}