/** * Generate Leaflet tile pyramid from a source image. * * Usage: npx tsx pipeline/generate-tiles.ts * * 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 '); process.exit(1); } generateTiles(source).catch(err => { console.error('Tile generation failed:', err); process.exit(1); });