/** * Pure PNG compositing utilities — no I/O, no caching, no logging. * * Ports and generalizes the tile-stitching / alpha-blend logic proven working * offline in `scripts/capture-examples.mjs` (`fetchOsmBase` / `blendOnto`, * ~lines 314-358) for server-side use: stitching four 256px WMTS tiles into a * 512px image, blending a semi-transparent overlay onto a base, and drawing a * location marker. See `docs/plans/composited-imagery-plan.md` (D2, D3) and * `docs/plans/composited-imagery-implementation-plan.md` ("Findings that shape the * graph") for why this shape: the base map is two GIBS layers (an opaque * land/water layer and a transparent coastline/border outline layer) blended * together, then the radar overlay is blended on top, then a marker is drawn * — each step a small, independently testable pure function. * * `assembleTiles` intentionally does *not* force opacity the way the capture * script's single-purpose `fetchOsmBase` does: the outline layer * (`Reference_Features_15m`) is transparent by design, and that transparency * must survive stitching so `blendOnto` can composite it correctly. * Opacity-forcing is the separate, explicit `flattenOpaque`, applied only to * the opaque base layer. * * Composites are **centered on the requested coordinates**, not aligned to a * tile boundary. A tile-aligned composite puts the location wherever it * happens to fall inside its tile — live testing found a saved location * landing 36px from the edge of its own map. So the geometry helpers here * (`latLonToGlobalPixel`, `centeredWindowOrigin`, `planTileWindow`) work out * a window centered on the point, the covering tiles are assembled with * `assembleTiles`, and `cropTo` cuts the exact window out. Output size and * zoom are unchanged — only the framing moves. * * `pngjs` is a runtime dependency (pure JS, zero transitive deps — see D5 in * the design plan); its types come from the `@types/pngjs` devDependency, * since the package itself ships no declarations. `PNG` is re-exported here so * the rest of the composite path (`src/services/basemap.ts`, the imagery * handler) has a single import site for the image type. */ import { PNG } from 'pngjs'; export { PNG }; /** * Assemble a `cols`×`rows` grid of equally-sized square PNG tiles into one * image, preserving each source pixel's alpha unchanged. * * `tileBuffers` is in **row-major** order (left-to-right, then top-to-bottom). * Because every tile is placed at its native resolution there is no * resampling — the assembly is pixel-exact. */ export declare function assembleTiles(tileBuffers: Buffer[], cols: number, rows: number, tileSize: number): PNG; /** * Copy a `width`×`height` rectangle out of `src`, starting at `(x, y)`. * * This is what lets a composite be centered on a coordinate rather than * aligned to a tile boundary: the covering tiles are assembled into a grid * that is deliberately larger than the output, then the exact window around * the point of interest is cut out of it. */ export declare function cropTo(src: PNG, x: number, y: number, width: number, height: number): PNG; /** * Force every pixel's alpha channel to 255, in place. * * Applied only to the opaque land/water base layer after stitching (see * module doc comment) — never to the transparent features/outline layer, * whose alpha must survive to be blended. */ export declare function flattenOpaque(png: PNG): void; /** * Source-over alpha blend of `overlay` onto `base`, in place — verbatim * blending arithmetic from `scripts/capture-examples.mjs`'s `blendOnto` * (RGB channels only; `base`'s own alpha is never modified, matching the * capture script, since every caller blends onto an already-opaque base). * * Two short-circuits over the plain weighted-average formula, both producing * the identical numeric result: a fully transparent overlay pixel (`a === 0`, * the common case — most of a radar tile over dry sky, or most of the * features layer away from a coastline) is skipped entirely, and a fully * opaque overlay pixel (`a === 255`) replaces the base pixel outright. */ export declare function blendOnto(base: PNG, overlay: PNG): void; /** * Draw a small high-contrast crosshair — a dark plus-shaped core with a 1px * light outline — centered at pixel `(px, py)`, in place. * * The outline exists so the marker reads over both light base-map pixels and * dark radar-echo pixels; drawing the outline first and the core second means * the two never visually conflict at their shared boundary. Offsets that fall * outside `png`'s bounds are skipped individually (not the whole marker) — * clipped cleanly at an image edge, never wrapped onto the opposite edge. */ export declare function drawMarker(png: PNG, px: number, py: number): void; /** * Web Mercator pixel edge length of the world at zoom `z`, in the composite * path's shared pixel space. * * Every layer the composite touches lands in **one** pixel space, which is * what makes centering tractable: RainViewer serves 512px tiles addressed at * zoom `z`, and the GIBS layers serve 256px tiles addressed at zoom `z + 1`. * Those describe the identical grid — `2^z` tiles of 512px and `2^(z+1)` * tiles of 256px both come to `512 * 2^z` pixels across — so a single global * pixel coordinate indexes into both, and only the divisor differs. */ export declare function worldPixelSize(z: number): number; /** * Web Mercator position of `(lat, lon)` in the shared global pixel space at * zoom `z` (see `worldPixelSize`), as fractional pixels from the world's * top-left corner. * * Latitude is clamped to the Mercator-valid range, matching * `rainviewer.ts`'s own clamp — the projection is undefined at the poles. */ export declare function latLonToGlobalPixel(lat: number, lon: number, z: number): { gx: number; gy: number; }; /** A window of the world, in global pixels, and the tiles that cover it. */ export interface TileWindow { /** Tile column indices covering the window, west to east (wrapped at the antimeridian). */ tileXs: number[]; /** Tile row indices covering the window, north to south. */ tileYs: number[]; /** Where the window's top-left sits inside the assembled tile grid. */ offsetX: number; offsetY: number; } /** * Work out which tiles of edge length `tileSize` cover the `size`×`size` * window whose top-left corner is at global pixel `(gx0, gy0)`, and where the * window sits within that assembled grid. * * Columns **wrap** at the antimeridian (the world is cyclic in longitude), so * a window straddling ±180° still resolves to real tiles. Rows are not * wrapped — the caller is expected to have clamped `gy0` into range already, * since there is nothing beyond a pole to show. */ export declare function planTileWindow(gx0: number, gy0: number, size: number, tileSize: number, z: number): TileWindow; /** * Top-left corner of a `size`×`size` window centered on global pixel * `(gx, gy)`, with the vertical axis clamped so the window stays inside the * world. * * Horizontal position is left unclamped because longitude wraps * (`planTileWindow` handles it); vertical is clamped because a window * centered near a pole would otherwise run off the top or bottom of the * projection. Near a pole the point is therefore *not* perfectly centered — * which is correct: there is no imagery past the edge to center it against. */ export declare function centeredWindowOrigin(gx: number, gy: number, size: number, z: number): { gx0: number; gy0: number; }; /** * Parse the `z/x/y` web-mercator tile address out of a RainViewer frame tile * URL (`…/512/{z}/{x}/{y}/{color}/{options}.png`) — the same regex proven in * `scripts/capture-examples.mjs`. Returns `null` on any URL that doesn't * carry this shape (a malformed or unexpected upstream URL), so the caller * can degrade to text-only output instead of throwing. */ export declare function parseRadarTileUrl(url: string): { z: number; x: number; y: number; } | null; /** * Re-point a RainViewer frame URL at a different tile of the same frame. * * The frame's timestamp/hash path and its color/options suffix are preserved * verbatim — only the tile address is swapped — so a centered window can * gather the neighbouring tiles of the very same radar frame rather than * recomputing an upstream URL from scratch. */ export declare function buildRadarTileUrl(url: string, z: number, x: number, y: number): string; /** Synchronously encode a `PNG` image back to its binary representation. */ export declare function encodePng(png: PNG): Buffer; /** * Defense-in-depth cap on an encoded composite's size, in bytes. Live-measured * real-world composites (base + features + radar, 512×512) run 24-91 KB * (`docs/plans/composited-imagery-plan.md`); this cap sits roughly an order of * magnitude above that measured maximum, so it never constrains normal * output but still catches a pathological encode before it's base64'd and * returned as an MCP image content block. */ export declare const MAX_COMPOSITE_BYTES = 1000000; //# sourceMappingURL=composite.d.ts.map