- core/: Pure TS hex engine (axial coords, hex grid, terrain types, edge connectivity with constraint solver, HexMap state model) - src/map/: Leaflet L.CRS.Simple map init, Canvas-based hex overlay layer (L.GridLayer), click/edge interaction detection - src/ui/: Sidebar with toolbar (Select/Paint/Feature modes), terrain picker, hex inspector, map settings (hex size, grid, opacity) - pipeline/: Tile pyramid generator (sharp, from source image) - tests/: 32 passing tests for coords, hex-grid, edge-connectivity - Uses Kiepenkerl tiles (symlinked) for development Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
103 lines
3.2 KiB
TypeScript
103 lines
3.2 KiB
TypeScript
/**
|
|
* Generate Leaflet tile pyramid from a source image.
|
|
*
|
|
* Usage: npx tsx pipeline/generate-tiles.ts <path-to-image>
|
|
*
|
|
* Output: tiles/{z}/{x}/{y}.jpg (also copied to public/tiles/ for dev server)
|
|
*/
|
|
|
|
import sharp from 'sharp';
|
|
import { mkdirSync, existsSync, cpSync } from 'fs';
|
|
import { join, resolve } from 'path';
|
|
|
|
const TILE_SIZE = 256;
|
|
const ROOT = resolve(import.meta.dirname, '..');
|
|
|
|
async function generateTiles(sourcePath: string) {
|
|
if (!existsSync(sourcePath)) {
|
|
console.error(`Source image not found: ${sourcePath}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const tilesDir = join(ROOT, 'tiles');
|
|
const publicTilesDir = join(ROOT, 'public', 'tiles');
|
|
|
|
console.log(`Reading source image: ${sourcePath}`);
|
|
const metadata = await sharp(sourcePath).metadata();
|
|
const width = metadata.width!;
|
|
const height = metadata.height!;
|
|
console.log(`Image size: ${width}x${height}`);
|
|
|
|
const maxDim = Math.max(width, height);
|
|
const maxZoom = Math.ceil(Math.log2(maxDim / TILE_SIZE));
|
|
console.log(`Max zoom: ${maxZoom} (grid: ${Math.pow(2, maxZoom) * TILE_SIZE}px)`);
|
|
|
|
let totalTiles = 0;
|
|
const startTime = Date.now();
|
|
|
|
for (let z = 0; z <= maxZoom; z++) {
|
|
const scale = Math.pow(2, z) / Math.pow(2, maxZoom);
|
|
const scaledW = Math.ceil(width * scale);
|
|
const scaledH = Math.ceil(height * scale);
|
|
const tilesX = Math.ceil(scaledW / TILE_SIZE);
|
|
const tilesY = Math.ceil(scaledH / TILE_SIZE);
|
|
const levelTiles = tilesX * tilesY;
|
|
|
|
console.log(`Zoom ${z}: ${scaledW}x${scaledH} -> ${tilesX}x${tilesY} = ${levelTiles} tiles`);
|
|
|
|
const buffer = await sharp(sourcePath)
|
|
.resize(scaledW, scaledH, { fit: 'fill', kernel: 'lanczos3' })
|
|
.raw()
|
|
.toBuffer();
|
|
|
|
let done = 0;
|
|
for (let x = 0; x < tilesX; x++) {
|
|
for (let y = 0; y < tilesY; y++) {
|
|
const tileDir = join(tilesDir, `${z}`, `${x}`);
|
|
mkdirSync(tileDir, { recursive: true });
|
|
|
|
const left = x * TILE_SIZE;
|
|
const top = y * TILE_SIZE;
|
|
const tileW = Math.min(TILE_SIZE, scaledW - left);
|
|
const tileH = Math.min(TILE_SIZE, scaledH - top);
|
|
|
|
await sharp(buffer, {
|
|
raw: { width: scaledW, height: scaledH, channels: 3 },
|
|
})
|
|
.extract({ left, top, width: tileW, height: tileH })
|
|
.extend({
|
|
right: TILE_SIZE - tileW,
|
|
bottom: TILE_SIZE - tileH,
|
|
background: { r: 42, g: 85, b: 116, alpha: 1 },
|
|
})
|
|
.jpeg({ quality: 85 })
|
|
.toFile(join(tileDir, `${y}.jpg`));
|
|
|
|
totalTiles++;
|
|
done++;
|
|
}
|
|
}
|
|
process.stdout.write(` -> ${done} tiles written\n`);
|
|
}
|
|
|
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
console.log(`\nGenerated ${totalTiles} tiles in ${tilesDir} (${elapsed}s)`);
|
|
|
|
mkdirSync(join(ROOT, 'public'), { recursive: true });
|
|
cpSync(tilesDir, publicTilesDir, { recursive: true });
|
|
console.log(`Copied to ${publicTilesDir}`);
|
|
|
|
console.log(`\nMap config: imageSize: [${width}, ${height}], maxZoom: ${maxZoom}`);
|
|
}
|
|
|
|
const source = process.argv[2];
|
|
if (!source) {
|
|
console.error('Usage: npx tsx pipeline/generate-tiles.ts <path-to-image>');
|
|
process.exit(1);
|
|
}
|
|
|
|
generateTiles(source).catch(err => {
|
|
console.error('Tile generation failed:', err);
|
|
process.exit(1);
|
|
});
|