npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@infrared-city/infrared-sdk

v1.0.1

Published

WASM-first Infrared City TypeScript SDK

Readme

Infrared City TypeScript SDK

This package contains the WASM-first Infrared City SDK implementation. It provides direct job submission, durable area schedules, area polling and merge operations, result routing, pricing helpers, and the buildings, vegetation, ground-materials, and weather clients used by the platform. The site-context and weather capabilities read the public data hosts and the SDK directly; the utilities service is not called at all.

What changed in 1.0.0

  • Values keep the type the server sent. A surface result (SurfaceColumns.values, valueDtype, valueDivisor, valid) and an area result (AreaResult.mergedGrid, valueDtype, valueDivisor, valid) hold "f16" (half-float bits in a Uint16Array), "f32", "i16" (with a divisor and a validity bitmap) or "f64". Read physical values with surfaceValuesF32 / areaGridValuesF32 and test a cell with surfaceHasValue / areaGridHasValue. UTCI read without the divisor is 10 times too large.
  • The SDK merges area grids. The merge keeps no canvas of every tile.
  • Render buffers. surfaceRenderBuffers(columns) gives the arrays a renderer needs, with no mesh for each cell.
  • emitCellTris is off by default in synthesizeFacadeLayout. A layout saved before 1.0.0 cannot be loaded (format version 3): build and save it again.
  • Save a result. surfaceColumnsToBytes and surfaceColumnsFromBytes (see Save and reload a facade result).
  • Year-wrap windows. A weather window from December to February is one window.

Details and the steps to change your code: UPGRADING.md.

Install

Upgrading from 0.12.12? Read UPGRADING.md first.

Registries. Stable versions (1.0.1 and later) go to the public npm registry (npmjs.org). A plain npm install gets them; you need no token and no .npmrc. Prereleases (for example 1.0.1-next.0) go to GitHub Packages with the next tag. To install a prerelease, see Release channels.

Two installs, because only ONE capability needs the parquet decoder.

npm install @infrared-city/infrared-sdk
# and, only to read Overture Maps:
npm install hyparquet hyparquet-compressors

| | base | + parquet stack | |---|---|---| | Extra packages | — | hyparquet, hyparquet-compressors (+ fzstd, hysnappy) | | node_modules (npm 10, Node 20) | 8.0 MB | 8.8 MB | | Payloads, direct jobs, area schedules, polling, merge | ✅ | ✅ | | Results (JSON and strict IRBF), pricing, images | ✅ | ✅ | | Static weather catalog + EPW (geo.infrared.city) | ✅ | ✅ | | Trees and city overlays (FlatGeobuf, read by the SDK) | ✅ | ✅ | | Overture buildings, land use, land cover and water (parquet) | ❌ | ✅ |

Without them an Overture read rejects with one GeodataDependencyError naming the exact command; it never resolves to an empty feature list.

import { GeodataDependencyError } from "@infrared-city/infrared-sdk";

try {
  await getArea(polygon);
} catch (error) {
  if (error instanceof GeodataDependencyError) {
    console.error(GeodataDependencyError.installCommand);
    // npm install hyparquet hyparquet-compressors
  }
}

The root entry does not load either package at import time. /geodata loads them with a dynamic import() at the moment of the read (tests/geodata/optional-parquet.test.ts asserts both halves).

The package contains the matching wasm-pack --target web JavaScript glue, declarations, and WASM binary. A browser must give initializeCore a URL, byte buffer, or compiled WebAssembly.Module. A Worker can inject its compiled module. Node loads the packaged WASM file when no source is supplied.

Each Web Worker has its own core: call await initializeCore() in the main thread and again in every Web Worker. A sync function that runs first throws CoreNotReadyError with realm and method, and a text that says what to do.

import { initializeCore, packMesh } from "@infrared-city/infrared-sdk";

await initializeCore({ bytes });
const result = packMesh(coordinates, indices);

JobsService submits direct jobs, polls job IDs, and downloads completed results. It resolves gateway authentication for every control request and downloads signed result URLs without gateway credentials. InfraredClient.analyses.execute accepts an already wire-keyed payload. The run, runAndWait, and area facades also accept declared legacy camel-case top-level fields. Opaque entity, mesh, and metadata maps keep their keys. Binary is the default transport: with no transport, every submission and area run uses the strict binary route. Pass transport: "json" for JSON. energy-balance has no binary route and uses JSON. A one-part daylight-factor request is one JSON job; a request of two or more parts uses the binary route (see "Daylight factor: large requests run as floor parts" below). Binary results carry the server's stored precision (F16 for SVF, solar radiation, thermal comfort statistics and wind; 0.1 steps for the thermal comfort index), so a value can differ from JSON by one step, and a binary area grid is a Float32Array. If the binary route refuses a request, the SDK raises that error; it does not resubmit the job as JSON. The production API serves the binary route. The old binaryResults option is rejected. On the binary route the two wind models carry a vegetation group like every other outdoor model: trees are never boxed client-side. The wind worker boxes a tree server-side when its model needs to. A malformed ground-geometry sheet refuses the submission by name before any HTTP; an omitted field is defaulted. Relevant outdoor requests with supplied terrain and solids default to terrain-alignment: "as-is". Callers can select another supported alignment mode. The two wind models spell the same option with their own two values — "as-is" (the default since 1.0.0) and "to-ground" — because they take no terrain and the worker reads a building's HEIGHT straight off the raw z of its mesh. runArea applies it once per plan: "to-ground" drops every supplied mesh so its own lowest point sits at z = 0, and a scene already at grade is submitted byte-for-byte unchanged. runArea is the one place this happens; a direct execute submission is sent as given. The option never reaches the wire, and ground-geometry is still refused on both models.

import { buildAuthResolver, JobsService } from "@infrared-city/infrared-sdk";

const jobs = new JobsService({
  baseUrl: "https://api.example.com",
  auth: buildAuthResolver({ apiKey: "your-api-key" }),
});
const completed = await jobs.waitForCompletion(jobId);
const download = await jobs.downloadResults(jobId, { job: completed });
const result = jobs.decompress(download.content);

JobsService.decompress returns a route-aware ParsedResult. Use decompressResultValue(jobs, bytes) when an older caller intentionally needs only the decoded payload. This adapter does not change the foundation method.

JobsService.decompress uses the shared result router. Binary grid and surface results use owned typed buffers and do not retain the compressed archive. Archive expansion defaults to 64 MiB compressed and 512 MiB expanded. Set archive.maxCompressedBytes or archive.maxExpandedBytes per call when a smaller positive safe-integer limit is required. These limits apply after the HTTP download has produced its byte buffer and before JSON parsing; they do not limit download buffering or the later JavaScript object graph.

If an ordinary inline submit outcome is unknown, do not automatically repeat the execute or submit call. Keep any accepted job IDs and poll those jobs. A geometry-reference error is different: its result is invalid and must use the separate handling described below. The server creates a new ID for each POST and has no client idempotency-key contract, so a manual resubmission can create another charge. This SDK does not claim exactly-once submission.

Waiting for jobs

All waits use one poll engine: waitForCompletion, runAndWait, checkAreaState loops and runAreaAndWait. The SDK holds the numbers, and the Python SDK uses the same ones.

  • The first status check is 0.5 s after the wait starts, not at once.
  • Then the SDK checks every 1 s for the first 10 s of the wait, and every 2 s after that.
  • A single job uses GET /async/jobs/{id}. An area run asks for up to 50 jobs in one GET /async/jobs?ids= where the gateway has that route, and otherwise one GET for each job. A sweep of n requests waits at least n / 2 s.
  • HTTP 429, any 5xx or a network error does not end the wait. The engine backs off (at most 30 s, never shorter than Retry-After) and tries again. The first good answer resets the backoff. A 401 or 404 ends the wait at once.
  • The default wait timeout is 900 s. At the deadline the wait rejects with JobTimeoutError. The area timeout default (3600 s) does not change.

pollIntervalMs (a fixed healthy interval) and backoffCapSeconds still work.

Daylight factor: large requests run as floor parts

runAndWait splits a daylight-factor request into parts of whole floors, sends the parts as separate jobs in parallel, and joins the part results into the result the single request gives, byte for byte. The SDK does all of it: it counts each floor's sensors exactly as the worker grids them, packs whole floors into parts of at most about 300 000 sensors, writes each part body (the request with a shorter floors list) and joins the results. A floor above the part size is split inside the floor and joined exactly. Only the floors tier splits; sensor-points, sensor-surfaces, buildings and a single floor-index are one job.

A request of one part is one job, and its POST carries the same bytes as analyses.execute. maxParts: 1 turns splitting off. A larger maxParts is a limit: a plan with more parts is refused (RangeError) before any request; a maxParts that is not a positive integer is a TypeError, as for maxWorkers.

Each part is one job and is billed as one job. previewParts tells what a run submits and bills, without a request:

const request = {
  "analysis-type": "daylight-factor",
  latitude: 48.2, longitude: 16.37,
  floors: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
  "grid-size": 0.5,
  barriers, openings,             // wire-keyed interior entity maps
  spatialVolumes: rooms,          // the rooms: per-room statistics and warnings
  "glazing-transmittance": 0.7,   // a window with no openingFactor
};
const preview = client.previewParts(request);
console.log(preview.partCount, preview.totalSensors, preview.estimatedCostTokens);
const result = await client.runAndWait(request) as DaylightFactorResult;
for (const group of result.groups) {           // one group per floor
  const df = result.values.subarray(group.start, group.end);   // Float32Array view
  console.log(group.key, group.meanDf, df.length);
}
const json = result.toJson();                  // the JSON result, on demand

The result is binary by default. runAndWait and mergeParts return a DaylightFactorResult (kind: "daylight-points"): x, y, z (Float64Array), values (the daylight factor, Float32Array), validity and room (Uint16Array, an index into rooms; NO_ROOM = no room) are views over ONE IRBF frame, with no object per sensor, as values / validity of a grid or facade result. groups, rooms, buildings, warnings are small arrays; minLegend / maxLegend as for the other results. values holds the f32 of the worker's 4-digit value; toJson() (or toJsonBytes()) gives the worker's JSON result, byte for byte. The parts ask for binary results when the server's capability lists daylight-points for daylight-factor; otherwise they come back as JSON and the SDK turns them into the same frame. Pass resultFormat: "json" to get the JSON value as before. The default is DEFAULT_DAYLIGHT_RESULT_FORMAT.

The request stays wire-keyed (this SDK has no daylight-factor type). Every key goes to every part unchanged, so spatialVolumes and glazing-transmittance reach the worker as sent.

  • A part that fails stops the join: runAndWait rejects with AnalysisPartsError, whose failedParts names the parts and whose schedule holds the accepted jobs. runAndWait(request, { retryFrom: error.schedule }) sends only the failed parts again and joins. A part whose POST outcome is unknown is never sent again automatically. A retry needs a SHA-256 digest of the request (WebCrypto or the Node entry); without one the run works, but its retryFrom is refused before any request.
  • Once the parts are submitted, every error of runAndWait carries the schedule (an abort, a timeout, a part still running): it is an AnalysisPartsError or PartsTimeoutError, with the original error as cause where there is one. Merge the schedule again, or pass it as retryFrom; nothing is paid twice.
  • A part result that does not download also rejects with AnalysisPartsError, with downloadFailedParts set and failedParts empty. mergeParts(error.schedule) downloads again; nothing is resent.
  • The wait uses timeout (default 900 s, as for one job). At the deadline a multi-part run rejects with PartsTimeoutError, which carries the schedule, not JobTimeoutError. onPoll reports one job, so a multi-part run does not call it; use onProgress.
  • runParts, checkPartsState and mergeParts are the same steps one by one, as runArea, checkAreaState and the merges are for an area.
  • The SDK's notes on the request (for example a grid-size of 0) and the result's warnings go to the client logger as warn events. The SDK does not refuse a request for its content: a request the SDK cannot plan is sent as one job, and the server answers it.
  • A daylight-factor run of two or more parts uses the binary route when the server supports it: the scene is uploaded once and each part sends only its control. Otherwise, and for a one-part request, every part carries the whole scene inline on the JSON route; the result is still a DaylightFactorResult.

Uploads

Each request that sends a body follows one rule. The request stops if no body byte moves for 10 s (the stall guard), or if the body is not sent in min(1800 s, max(60 s, bytes / 64 KiB/s)). A slow upload that still moves is not stopped before that budget. When the last byte is sent, both limits end, and timeoutMs applies only to the answer.

  • In real Node, the SDK sends the body as a stream in 64 KiB blocks, with an explicit Content-Length. Bun, Deno, Electron and Workers send the body as bytes.
  • In a browser or web worker with the default fetch, a cross-origin presigned S3 PUT uses XMLHttpRequest, which reports upload progress. Gateway requests use fetch. A proxy rewrite must therefore be in the fetch option of the client, not only in a window.fetch patch (see Calling the API from a browser page).
  • A custom fetch (the client fetch option) shows no progress. It gets one window for body and answer: the budget plus timeoutMs, with no stall guard.

The SDK does not send a paid POST again after the guard stops it. The outcome is uncertain; poll the job IDs that you know.

Area preparation validates polygons, generates deterministic tiles, reads the core-owned tiling preset, and composes supported geometry groups:

InfraredClient.runArea returns a durable AreaSchedule. checkAreaState updates that schedule from job status responses. mergeAreaJobs downloads and merges grid results, and mergeSurfaceAreaJobs downloads and unions surface results. Both THROW when a tile did not contribute rather than returning a grid with holes in it: a result that could not be fetched is still on the server, so calling the merge again with the same schedule completes the run. Failed tiles are in schedule.failedSubmissions for runArea({ retryFrom }).

A tile that fails does not stop the run: runArea sends every tile. The tiles that went out before the failure may be billed. A retry with retryFrom sends only the failed tiles. runArea has up to maxWorkers requests in flight at once. When the API answers with HTTP 429, the SDK sends that tile again. A paid retry needs the same immutable geometry and layer inputs, plus a saved siteIdentity. An input map changed in place is new content: the next run sends it, and a paid retry of the old schedule is refused. Older schedules without this identity can be polled and merged, but failed tiles cannot be resubmitted. runAreaAndWait performs these steps and selects the merge operation from the saved schedule intent. previewArea and generateTiles do not submit jobs. Schedules can be saved and restored with areaScheduleToJSON and areaScheduleFromJSON from the root package or the /tiling entry point.

Keep uploaded geometry for a new worker or a reload

The SDK uploads the geometry of each tile once and keeps its signed URL in memory. A second run in the same JavaScript realm sends no geometry again. A new worker or a page reload has empty memory, so it uploads all tiles again. On a slow line the upload is most of the run time.

Give the client a geometryUrlStore to keep the URLs longer:

const client = new InfraredClient({
  apiKey,
  geometryUrlStore: {
    get: (key) => JSON.parse(sessionStorage.getItem(`ir-geom:${key}`) ?? "null") ?? undefined,
    set: (key, url, expiresAt) =>
      sessionStorage.setItem(`ir-geom:${key}`, JSON.stringify({ url, expiresAt })),
  },
});

sessionStorage does not exist in a Worker. There, wrap an IndexedDB object store (workers of the same origin share it) with the same get and set, and pass it to serveSdkWorker({ geometryUrlStore }).

Rules:

  • An entry is a read link to your geometry for its lifetime; treat the store like a cache of secrets.
  • The SDK makes the keys. A key holds the gateway URLs, a SHA-256 digest of the credentials (never the credentials) and the digest of the geometry.
  • Before an upload the SDK looks in its memory, then calls get once per geometry (runs that wait for the same upload do not call it again). After an upload it calls set and does not wait for it.
  • expiresAt is 23 hours after the upload (the server keeps geometry for 24 hours), or the end of the signed URL when that is earlier. The SDK ignores an expired or malformed entry. You can delete entries at any time.
  • When the server refuses a stored URL (gone, expired, unreadable), the SDK uploads again, overwrites the entry and sends that job once more.
  • A get that throws or does not answer in 2 s, or a set that fails, gives one warning; the SDK then uploads. A store never fails a run.
  • Two clients that miss the same key at the same time may both upload.
  • Without geometryUrlStore, nothing changes: the URLs stay in memory only.

Robust area retry

Every tile submit of an area run carries an Idempotency-Key header, so a retried submit is never billed twice. runArea({ retryFrom }) asks the core for the retry plan instead of resubmitting every failedSubmissions key: it also resends a compute-failed job (one whose status ended Failed) and an uncertain submission with its SAME key, up to one core constant's worth of attempts per key (3). A key past the cap is held back, never sent again.

runAreaAndWait({ retries }) retries automatically: after the run completes, if the SDK plan finds anything left to fix, it calls the retry path and waits again, up to retries rounds (default 1; 0 turns this off). It stops early once a round's plan resubmits nothing, and never retries after a 402 (schedule.submissionAbortStatus).

A schedule saved by an older SDK has no run id: it still reads and still retries, but its uncertain keys are held (never resent) because its first submit carried no key to repeat.

import {
  composeTilePayloads,
  generateTilesForPolygon,
  getTilingConfig,
  validatePolygon,
} from "@infrared-city/infrared-sdk";

const analysisType = "sky-view-factors";
const checked = validatePolygon(polygon);
const grid = generateTilesForPolygon(checked, { analysisType });
const nonEmptyTiles = grid.flat()
  .filter((tile) => !tile.empty)
  .map((tile) => ({ row: tile.row, col: tile.col, tileId: tile.tileId }));
const config = getTilingConfig(analysisType);
// The terrain arm `runArea` submits: the caller's extra reach, floored at the
// preset's own context margin. Terrain always reaches the solids the tile
// carries, whatever this says.
const marginM = Math.max((config.contextSizeM - config.inferenceSizeM) / 2, 128);
const payloads = composeTilePayloads(groups, nonEmptyTiles, checked, {
  analysisType,
  terrainContext: { margin_m: marginM },
});

Every group comes back finished. composeTilePayloads assigns the buildings and occluders, decides facade ownership, slices the terrain, trees and materials, and re-anchors every group into each tile's own frame in ONE core pass over the site. The bodies it returns are the bodies runArea submits, in the canonical form the wire carries: mesh keys sorted, numbers as JavaScript writes them. There is no finishing step.

How big a site can be

A run is capped at 100 non-empty tiles. The cap is a cost guard, not a platform limit: one tile is one separately billed job. Over it, nothing is submitted and the refusal prices the run you asked for.

Polygon produces 196 non-empty tiles on the wind grid (256 m step), over the
limit of 100. Each tile is one billed job at about 10 tokens, so this run would
cost about 1,960 tokens. To run it, pass maxTilesOverride: 196; to spend less,
shrink the polygon or size it first with previewArea().

The grid decides the count, not the polygon alone. wind-speed and pedestrian-wind-comfort step at 256 m with 50 % overlap; solar, daylight and thermal-comfort types step at 512 m with none, so the same 3.5 km square is 49 tiles on solar and 196 on wind. The number the refusal names is the number to pass — maxTilesOverride: 196 runs this polygon, and so does anything larger.

previewArea submits nothing, takes the same override, and returns the same numbers the refusal quotes. previewAreaWithPricing replaces the SDK's default of 10 tokens a job with the gateway's live per-analysis price.

const preview = client.previewArea(polygon, {
  analysisType: "wind-speed",
  maxTilesOverride: 400,
});
console.log(preview.tileCount, preview.estimatedCostTokens); // 196 1960

const schedule = await client.runArea(input, polygon, { maxTilesOverride: 400 });

The same refusal text reaches the Python SDK, which spells the knob max_tiles_override.

A site is prepared once per tiling family and reused by every runArea in the process. The site stage of a plan — assigning the buildings to tiles, slicing the terrain, re-anchoring every tile's occluders — depends only on the geometry and the tiling preset, not on the analysis, so six analyses of one site prepare it twice (the solar family and the wind family), not six times. The prepared site is keyed by the content of the inputs as you pass them and by the polygon, so a changed building is a new site and nothing stale is ever served; concurrent runs of one family wait for the one build instead of repeating it; the process holds a few sites and evicts the least recently used. A long-lived process that has finished with a site can release the memory early with freePreparedSites() (root package or the /tiling entry point); the next runArea on that site prepares it again.

A facade run bills per building batch, not per tile — use previewAreaBatches to price it. previewArea above returns tileCount * 10 tokens, one job per tile — right for a grid analysis, wrong for a facade (analysisSurfaces) one: the server splits a dense tile's target buildings into several separately billed sub-batches, and a tile with no target building submits no job at all. previewAreaBatches runs the SAME offline plan runArea submits — no HTTP — and reports the real planned job count and the total synthesized sensor count:

const preview = await client.previewAreaBatches(input, polygon);
console.log(preview.tileCount, preview.plannedJobCount, preview.sensorCount);

Pass the SAME input (and options.buildings, if the geometry is not embedded on it) a runArea call would; plannedJobCount is what that call would actually submit.

options.maxSensorsPerJob makes facade jobs smaller: it is the most retained sensors one job may carry, a whole number from 1 to 250 000 (the default target). Each batch is still an exact, verified count, so a preview with the same cap reports the same jobs and sensors as the run. A value out of range throws a RangeError before any request; a building above the cap throws while planning. A retry replays the saved batches: it may omit the cap or repeat the saved value, and any other cap is refused before a request. The Python SDK's max_sensors_per_job has the same meaning. Running the identical facade request twice bills every job again, in full — there is no result cache. Geometry-reference reuse (INFRARED_GEOMETRY_REF_ENABLED, above) saves an upload — the same geometry bytes are not sent twice — never the simulation result; see docs/sdk-data-flow.md for why, and why that is a platform feature to ask for, not an SDK one.

Mesh cleaning on a facade run (meshCleaning). Each mesh is cleaned before it is gridded ("auto", the default); pass meshCleaning: "off" to use meshes as drawn. See Bring your own geometry.

Draw facades and roofs with render buffers (surfaceRenderBuffers). surfaceRenderBuffers(columns) gives the flat arrays a renderer needs: the frames, the outline triangles of each frame, one value for each cell (in the type the server sent) and one validity bit for each cell. No mesh for each cell exists, so the buffers are small and every array can be transferred to a worker. A facade or roof run of this client has the outline. surfaceRenderBuffers(columns, { layout }) takes it from a saved FacadeLayout instead. The guide "Draw facade and roof results fast" on the SDK docs site shows the format and a drawing recipe. To serve many users, read "Serve many users".

const columns = await client.runAreaAndWait(
  { ...input, analysisSurfaces: "facades" }, polygon, { buildings });
const buffers = surfaceRenderBuffers(columns);   // frames, outline, values, validity

The client keeps the capture of each facade or roof job until you merge the run. Merge in the client that submitted it. Call client.jobs.captures.forgetSchedule(schedule) for a schedule you will not merge, and client.jobs.captures.free() when you finish with the client.

Per-cell triangles are optional (emitCellTris: true). cell-tris is the exact clipped per-cell render geometry. It is 12.6x the rest of a facade response body (+214 MB gzip on a 66-job city scene), so this SDK never asks the server for it. emitCellTris is false by default. Pass emitCellTris: true only if you need the old per-cell mesh. Then mergeSurfaceAreaJobs synthesizes the identical shapes with the SDK it already bundles, from the geometry this client submitted, and hands them back in result.triangles:

const schedule = await client.runArea(
  { ...input, analysisSurfaces: "facades", emitCellTris: true }, polygon, { buildings });
const result = await client.mergeSurfaceAreaJobs(schedule);
// One mesh per job (tile or batch): tile-local f32 positions plus an f64 anchor.
const table = result.triangles!;
for (let group = 0; group < table.positions.length; group += 1) {
  const positions = table.positions[group];           // 9 floats per triangle
  const values = vertexValues(result, group);          // one value per vertex
  const [x, y] = [table.anchors[2 * group], table.anchors[2 * group + 1]];
  // e.g. three.js: new BufferGeometry() + position/value attributes, mesh.position.set(x, y, 0)
}

Show facade results after a reload, offline (synthesizeFacadeLayout, #707). The outline and the triangles above come from the live client's in-memory capture; after a page reload there is nothing to merge. synthesizeFacadeLayout({ buildings, analysisSurfaces, surfaceGridSize, ... }) rebuilds the identical frames, cell order and outline straight from the buildings you already have, offline, no HTTP, no billing, with surfgridVersion pinned to the value the original result was built at (never a silent "latest"). layout.toBytes() / FacadeLayout.fromBytes(bytes) save and reload a layout WITHOUT the geometry, for when the model may have changed since the run. The saved format (version 3) holds the frames and the outline of each frame; it holds no per-cell triangles unless you pass emitCellTris: true. A layout saved by an earlier version is refused: build it and save it again. Then surfaceRenderBuffers(columns, { layout }) builds the render buffers. Store layout.layoutKey alongside every result: attachValues(layout, columns, { expectedLayoutKey }) requires it and refuses a mismatch -- including a geometry change too small to move a frame's header, since layoutKey hashes the geometry itself -- instead of draping values onto plausible-looking wrong cells. toBytes() exports the layout you already have, never re-synthesizing (a large dense-city fixture, 817 buildings: 38.98 MB in 1.9 s); pass per-job activeIds (and, tiled, siteOrigin/tileOrigin) so a large site's export stays job-sized. See UPGRADING.md § Show facade results after a reload.

The merged surface result is columns (SurfaceColumns, since 1.0.0). One typed array per surface field (origin, uAxis, vAxis, gridSize, nu, nv, area, mean, peak), cellOffsets cutting the per-cell values and cellArea, the surface ids in one string cut by idOffsets (surfaceId, surfaceIndex), and the aggregates and legends as before. There is no object per surface and no array per cell, and every array owns its buffer, so the whole result can be transferred to a worker. The SDK joins the run in one call: the 4 km Vienna facade case (397 k surfaces, 39.3 M triangles) merges in about 10 s single-threaded, down from 32 s, at 3 GB peak, down from 7.3 GB. The triangles cost zero extra bytes.

values keeps the server's type (since 1.0.0). It is a Uint16Array of half-float bits (valueDtype "f16"), a Float32Array, an Int16Array with valueDivisor and the valid bitmap ("i16"), or a Float64Array (JSON results): 2 or 4 bytes per cell, not 8. Test a cell with surfaceHasValue(result, cell) and get physical numbers with surfaceValuesF32(result, first, last). See UPGRADING.md.

The row order is fixed. The SDK sorts the jobs by their schedule entry id before it joins them, so two identical runs give the same rows in the same order, whatever order the server accepted the jobs in. To join two results, use the surface id and the cell index, not the row number of an earlier SDK version. The SDK reads a binary surface result in schema 1 and in schema 2 (per-surface columns, 2026-09-29); the merged result is the same for both.

Wall grids are level (SURFGRID_VERSION 4). A wall grid has u horizontal along the wall and v up the wall; a roof keeps the grid of its minimum-area rectangle. Together with mesh cleaning, this changes facade values from earlier versions and test builds. The largest change is on south- and west-facing walls: before the cleaning, many of those walls pointed into their own building. Compare new runs with new runs only.

Every attach is checked against the response's sensor-layout-hash; if the local layout does not match, or the geometry is no longer held (a schedule resumed in another process), nothing is attached, one warn line is logged, the result carries cellTrisFallback (the sorted fallback reasons), and the result is the blocky one. On terrain the SDK first seats the buildings the way the server does for terrainAlignment: "auto-align".

Area composition currently accepts uncompressed JSON mesh coordinates. It rejects packed or mixed binary mesh arms because the current tiling core shifts the JSON coordinate arm. Compact vegetation instances also have no area assignment policy yet and fail explicitly.

Synchronous operations throw CoreNotReadyError until initialization finishes. Malformed geometry throws. Valid geometry that cannot use the portable binary format returns { not_representable: reason }; callers can retain its JSON arm. The wrapper checks primitive types before it makes typed buffers. It does not change or detach caller arrays.

The browser build does not initialize WASM as an import side effect. Call initializeCore({url}), initializeCore({bytes}), or initializeCore({module}) before geometry, tiling, cleaning, merge, prepareAreaPayload with column-form weatherData (the SDK filters it), or the geodata reads (groundMaterials.getArea, groundMaterials.getRaw, vegetation.getArea, buildings.getBuildingsInArea). A Web Worker has a separate module realm and must initialize its own SDK instance. Node can load packaged WASM when initializeCore() has no argument, and the four geodata reads above, runArea and runAreaAndWait do this for you in Node — call initializeCore() first only to pick a non-default source or to pay the load cost earlier. A second initializeCore call in the same realm is safe: after the core is loaded, it resolves for a URL or byte source without loading it again, and for a WebAssembly.Module that exports every function this SDK calls (a module without them is refused). A worker that gets a new posted WebAssembly.Module for each request can call it every time.

Vite applications can resolve the public WASM asset at application boot:

import coreUrl from "@infrared-city/infrared-sdk/core.wasm?url";

await initializeCore({ url: new URL(coreUrl, location.href) });

Repeat this initialization inside each existing Worker realm before it calls an SDK operation. This follows Vite's explicit URL import and WebAssembly asset pattern; the SDK does not load WASM as an import side effect.

Threads in Node (opt-in)

In Node, initializeCore({ threads: 4 }) loads the threaded core that the package carries in generated-threads/ and starts a pool of four worker threads. Nothing loads it unless threads is above 1. The SDK then runs its parallel steps on the pool; the results are byte-identical to the serial core. A 4 km facade merge with triangles takes 5.5 s instead of 10.0 s, and about 10 % more memory. Four threads is the best value measured; eight is slower.

  • Call it once, before any other SDK call. A realm holds one core, so a later call with another thread count is refused.
  • It needs Node 22 or later; on Node 20 the pool does not start, so the call is refused there.
  • It takes no url, bytes or module. The browser and worker entries refuse threads above 1.
  • A failure on a pool thread ends the process. The threaded build aborts on a core panic, and the main thread is blocked in the SDK call where no error can reach it. So the worker writes one JSON line to stderr ("event":"infrared_core_worker_failed") and ends the process (SIGKILL, which no handler can catch) instead of hanging. That includes running out of WebAssembly memory near the 4 GiB limit, on a pool thread or on the main thread in a core call. On the serial core the same failure is a thrown error. Run a threaded merge where a process restart is acceptable, and keep inputs well below the limit.
  • A pool that fails to start does not block the serial core: a later initializeCore() loads it.

Calling the API from a browser page

A browser app must call the Infrared API through a same-origin proxy. Today the API does not send CORS headers on the 202 that accepts an analysis job, so the browser blocks that response when the page calls the API directly. A server fix is in progress; until it is deployed, set baseUrl to a path on your own origin and forward that path to the API:

const client = new InfraredClient({
  baseUrl: `${location.origin}/infrared-api`, // proxied to https://api.infrared.city/v2
});

The proxy forwards the method, body and Authorization header unchanged. For a Cloudflare Pages app, add a _worker.js that relays the API calls and the result downloads described below. Node and Cloudflare Workers do not apply CORS and can call the API directly.

The proxy must also relay S3 traffic. A run's presigned upload and download URLs point at S3, a different origin, so the browser blocks them the same way. The async-results bucket sends no CORS headers, so a result download must go through the proxy too — not only the /infrared-api calls above.

A worker can relay this at /s3-proxy/<host>/<key>: it allowlists the results-bucket host, strips the Authorization and cookie headers, then forwards the request to S3. Rewrite a presigned URL to that path before you fetch it:

const S3_HOST = /^(staging-)?infrared-async-inference-jobs-outputs\.s3\.[a-z0-9-]+\.amazonaws\.com$/;
const toProxyUrl = (url: string, origin: string) => {
  const u = new URL(url);
  return S3_HOST.test(u.host) ? `${origin}/s3-proxy/${u.host}${u.pathname}${u.search}` : url;
};

Apply this rewrite inside a custom fetch and pass that fetch to InfraredClient (on the page and in a Worker realm). Do not only patch window.fetch: with the default fetch, the SDK sends a presigned S3 PUT through XMLHttpRequest (see Uploads), so a window.fetch patch does not see that URL and the browser blocks the upload. A page fetch patch also does not reach a Worker. Rewrite the presigned URL to the relay path yourself, as shown above, and have the Worker allowlist the results-bucket host.

One SDK worker

@infrared-city/infrared-sdk/worker (browser, ESM only) runs one SDK client in one module Worker, so site preparation, encode, decode and merge run off the page:

// sdk.worker.ts — the whole worker file
import { serveSdkWorker } from "@infrared-city/infrared-sdk/worker";
serveSdkWorker(); // or serveSdkWorker({ fetch }); a custom fetch must forward init.signal

// page
import { createWorkerClient } from "@infrared-city/infrared-sdk/worker";

const sdk = createWorkerClient({
  worker: new Worker(new URL("./sdk.worker.ts", import.meta.url), { type: "module" }),
  module,              // WebAssembly.Module, compiled once on the page
  config: { baseUrl }, // cloneable InfraredClientConfig fields only
  getToken,            // runs on the page
});
const schedule = await sdk.runArea(input, polygon, {
  signal, onProgress, onAccepted: (jobId, tileKey) => saveJobId(jobId, tileKey),
});
const result = await sdk.mergeAreaJobs(schedule, { signal }); // grid buffer transferred
sdk.dispose();

Store each job id when onAccepted reports it (an observer: the run does not wait for it, so keep the ids in memory until the store succeeds), and store the returned schedule with areaScheduleToJSON. One worker serves one client; a second createWorkerClient on the same worker throws. A worker failure rejects open calls with WorkerLostError: reason: "load" (never ready, nothing sent) or "lost" (acceptedJobIds are accepted, every other tile is uncertain; do not send the run again). After lost in runArea, the helper cannot merge the accepted jobs: there is no public API that builds an AreaSchedule from job ids. Only a schedule you saved earlier can be merged. getToken is called per request with no session binding: use one worker per signed-in session and dispose the client on sign-out. Errors cross as a plain Error: check name, not instanceof. Poll with a plain InfraredClient on the page. The recipe, with the no-Worker fallback, is examples/dedicated-worker.

Release channels

Builds require Rust 1.94.0 and wasm-pack 0.13.1. Normal CI only builds and tests the package, through typescript-sdk.yml. Each publish channel has its own dispatch-only file. CI does not call these files, and their publish input defaults to none (build only).

  • Prereleases (typescript-sdk-internal.yml): prerelease versions from staging, to restricted GitHub Packages with the next tag.
  • Stable releases (typescript-sdk-release.yml): stable versions from main, to npmjs.org, authenticated by OIDC (no stored token, after a one-time manual first publish). The upload becomes latest at once. This is the stable channel from 1.0.0 (owner decision, 2026-09-29).

typescript-sdk-stable-packages.yml (stable versions to GitHub Packages) stays in the repository, but it is not the stable channel. Each publish needs the exact version typed as confirmation. No channel rewrites the checked-in version, and no channel can publish the SDK packages. The full procedure, the settings each channel needs, and what you cannot undo are in docs/RELEASING.md §"TypeScript SDK release channels".

Installing a prerelease from GitHub Packages needs an .npmrc that maps the scope, plus a token with read:packages and read access on the package:

@infrared-city:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}

Then install with the tag: npm install @infrared-city/infrared-sdk@next. Remove this .npmrc mapping to install stable versions from npmjs.org again.

CI builds the locked WASM package, runs unit and real-WASM tests, and installs the packed tarball outside the source tree. Installed checks cover root ESM and CommonJS and the ESM-only /tiling, /utilities, and /billing entry points. tests/installed-dual-entry.mjs loads the root with require and the subpaths with import in one process and checks that one initializeCore() prepares all of them (infrared-core#683). Browser and Worker acceptance uses the installed package, initializes each realm explicitly, and runs the /worker helper in a real module Worker. Release, publishing, and production cutover are separate.

Supported tsconfig

The package ships .d.ts files that compile under a plain tsc --strict project with no skipLibCheck.

TypeScript 5.8 or newer. That floor is not a guess: npm ci pins it as an alias devDependency and CI compiles a consumer with it on every run, next to the TypeScript this package builds with. ESM-only consumers in fact compile from 5.4 (measured); 5.8 is where CommonJS joins them, because that is the release where TypeScript accepts require() of an ES module — see the CommonJS note below.

The minimum config is:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true
  }
}

| Setting | Supported | Note | |---|---|---| | target: ES2022, lib unset (the default library) | ✅ | Checked in CI | | lib: ["ESNext", ...] | ✅ | Checked in CI; also what using needs | | skipLibCheck: false | ✅ | Never required to make our types compile | | module/moduleResolution: NodeNext or Bundler | ✅ | The exports map needs one of these | | module/moduleResolution: Node10 ("node") | ❌ | Cannot read exports, and TypeScript 6 deprecates it | | CommonJS (require, .cts), TypeScript ≥ 5.8 | ✅ | Checked in CI; root entry only | | CommonJS, TypeScript 5.4 – 5.7 | ❌ | TS1479 — see below |

lib: ["ES2022"] written out explicitly drops DOM, and with it URL, Response and AbortSignal — which this SDK's own types name. Either keep the default library, or add "DOM" (or @types/node with "lib": ["ES2022"] plus "types": ["node"]) yourself.

CommonJS below TypeScript 5.8. At RUNTIME a require() caller is fine on any supported Node: the node+require condition resolves to dist/index.cjs, real CommonJS. It is the TYPES that stop early — the root entry's "types" key sits above the node/require branch, so a CommonJS consumer's compiler reads the ESM dist/index.d.ts and, before 5.8, refuses it with TS1479: ... cannot be imported with 'require'. TypeScript 5.8 accepts require() of an ES module and the error goes away. Consumers below 5.8 can use a dynamic import(), or upgrade. Shipping a dist/index.d.cts would lift the floor back to 5.4 for them; that is a change to the published export map and is tracked separately, not folded into this one.

Four of these rows are a CI gate, not a claim: tests/consumer-typecheck.mjs compiles a consumer against the packed tarball on every run — three shapes (ESM default library, ESM esnext, CommonJS) against two compilers (the supported floor and the one this package builds with), six clean compilations (tests/README.md). The gate exists because the [Symbol.dispose] members wasm-bindgen writes into generated/infrared-core.d.ts used to demand lib: esnext from every consumer, including ones that never write using (infrared-core#262).

Conversion and data acquisition in the SDK

The site-context capabilities no longer call the utilities service at all. Trees, ground materials and buildings are read from the public data hosts in this process and computed in the SDK, and there is no option that selects a service path. Callers upgrading from an earlier version: see MIGRATION.md.

vegetation.toMeshes(featureCollection) converts tree Point features to dotbim meshes in the SDK, the same computation as the Python SDK's convert_to_mesh(converter="local"). The collection carries referencePoint: [lon, lat] and features. converter accepts only "local": this package has never had a remote convert route, so a remote value is a typed VegetationMeshError rather than a silent local run.

const meshes = client.vegetation.toMeshes(collection);

There is nothing to select:

// Node: loads the packaged core if no earlier call already did.
await initializeCore();
// Browser: name the core explicitly instead (see "Core initialization" above),
// e.g. with Vite: await initializeCore({ url: new URL(coreUrl, location.href) });

const client = new InfraredClient({ apiKey });
const trees = await client.vegetation.getArea(polygon); // { signal } cancels the read
const ground = await client.groundMaterials.getArea(polygon);
const buildings = await client.buildings.getBuildingsInArea(polygon);

In Node, each of these three calls (plus groundMaterials.getRaw) loads the core itself when nothing initialized it yet, so the explicit call above is optional there — call it first only to pick a non-default source or to pay the load cost earlier. A browser or Worker realm has no packaged core to load, so there this call stays required; calling one of these methods first throws a CoreInitializationError naming the method.

Read only the layers the analysis uses. runArea does not read a layer by itself; the caller reads it. Ground materials are the slowest read, and only thermal-comfort-index and thermal-comfort-statistics use them. The other models ignore them; the binary transport refuses them. Trees are used by those two and by sky-view-factors, solar-radiation, daylight-availability and direct-sun-hours; the wind models use buildings only. On a 2 km solar-radiation area, not reading ground materials removed about 7 s of a 15 s run and about 330 MB of peak memory.

An area is acquired at AREA level: one Overture read per data type for the whole polygon, the types read together, and the features then assigned to the tiles whose query rectangle their bbox meets — the same features each tile's own read returned, so the documents are identical and only the number of reads changes. Roads and trees stay per tile.

The read margin follows the analysis. All three take an optional analysisType, and it sizes the read: a solar, daylight or thermal model needs the buildings and trees that cast a shadow into a tile, and they stand further away than the obstacles a wind model needs. The half extent is the SDK preset's contextSizeM / 2 — 256 m for wind-speed and pedestrian-wind-comfort, 384 m for every other analysis — and ground materials and trees read the tile half diagonal of that, 363 m and 544 m.

Pass nothing and the read uses the WIDEST margin. That is correct for every analysis and costs a wind caller extra reading; the reverse default would drop the outer band of context with no error and no other symptom.

await client.buildings.getBuildingsInArea(polygon, { analysisType: "wind-speed" }); // 256 m
await client.buildings.getBuildingsInArea(polygon);                                 // 384 m

Each result records readMarginM and analysisType. runArea compares them with what its own analysis needs and throws ReadMarginError, naming both margins, rather than running a scene with a band of context missing. Equal or wider passes, so one default acquisition serves every analysis. Pass the whole object — runArea(input, polygon, { buildings, vegetation, groundMaterials }) — for that check to happen; a bare map records nothing and is never refused.

cleaner left unset cleans in the SDK, on data that never leaves the process. It still accepts a caller's own GroundMaterialCleaner object, which is an extension seam rather than a service selector; the old string values "remote" and "local" raise a TypeError naming the replacement.

The five ground materials. A ground-materials layer key must be one of asphalt, concrete, soil, vegetation or water. Those are the only names the simulation resolves to a surface; anything else — a typo, a registry UUID, or the land-cover class building — reaches the model as an unknown material and produces a wrong thermal result, so the SDK throws before the job is submitted. Case and surrounding spaces do not matter. Layers from groundMaterials.getArea() always pass.

Direct buildings follow the prod-BUILDINGS route's precedence: a city buildings overlay from sources.json when the query rectangle intersects that city, otherwise Overture with a 0.002 degree candidate buffer. A missing height extrudes 9 m from z = 0 and min_height is carried but unused. The whole site is extruded ONCE, in one frame, and AreaBuildings.origin names that frame — the polygon's south-west corner. buildingIds is ALWAYS empty: the deleted POST /buildings route returned Mapbox ids only, and the public sources read here carry none.

Pass the acquired object to runArea, not just its map.

const buildings = await client.buildings.getBuildingsInArea(polygon);
const schedule = await client.runArea(input, polygon, { buildings });

runArea reads buildings.origin and re-anchors the whole map into the run polygon's frame, so buildings acquired once for a large area can be run as several sub-areas. Passing the bare buildings.buildings map still works and is unchanged, but a bare map carries no frame: acquire with the same polygon you run, or the city is submitted translated by the distance between the two south-west corners.

Overlay-first matters for the counts. On one Vienna tile the deleted POST /buildings, served from the Stadt Wien overlay, returned 815 meshes, where the public Overture buildings index holds 312 footprints for the same box — an Overture-only read under-counts Vienna by roughly 60 %, which is why the overlay is read first. Separately, that route grouped a courtyard parent with its children into ONE building and this SDK does not, so a per-building count can differ from the route's on the same data.

Footprints the SDK extruder cannot represent are dropped inside the core and are simply absent from the map; skippedFootprints therefore stays empty on this path.

Results carry overtureRelease, the Overture release the base themes came from. The index pointer moves daily, so a result without it is not reproducible; pass it back as the overtureRelease option to pin the read to that release's immutable manifest.

These paths reach exactly three hosts, always over https and never with an API key: geo.infrared.city (the world FlatGeobufs and the city-overlay registry), registry.infrared.city, and overturemaps-us-west-2.s3.us-west-2.amazonaws.com (anonymous Overture parquet). Every URL is checked against that allow-list, including the ones a fetched manifest names. The lower-level readers are exported from @infrared-city/infrared-sdk/geodata.

A site is acquired as a SITE, not as a pile of simulation tiles: the Overture themes and the building footprints are read ONCE for the whole site, the road FlatGeobuf once per read chunk, and the SDK composes, merges and cleans the ground in one call and normalises and extrudes every footprint in one frame. Read chunking is deliberately decoupled from simulation tiling — a 5 km2 site is 81 overlapping 512 m simulation tiles but ONE parquet read — and the chunk grid (about 2 x 2 km above 4 km2) bounds the COMPOSE, not the read: a row group is the smallest unit the parquet reader can fetch and it spans far more ground than a chunk, so chunking the read only re-fetches the same bytes. runArea then re-anchors each tile's building bodies into that tile's own frame with the exact affine the server projects it at.

Save and reload a facade result

Save the values of a facade or roof result as one blob. Save the layout of the RUN, as a second file. Both files together draw the result in a new process, with no geometry and no network.

Build the layout of the run with the schedule that the run returned. The schedule holds the job split and the tile frames of the run, so the layout has the same surfaces and cells as the result. A layout that you build from the buildings alone does not match a server result: the run analyses only the buildings of the polygon, and attachValues refuses the pair.

This example runs (Node 20 or later, ES modules). It bills one run (one job for each tile or batch).

// save.mjs
import { writeFileSync } from "node:fs";
import {
  InfraredClient, initializeCore, surfaceColumnsToBytes, synthesizeFacadeLayout,
} from "@infrared-city/infrared-sdk";

const lon = 16.371, lat = 48.208, d = 0.0005;          // a small site in Vienna
const polygon = { type: "Polygon", coordinates: [[
  [lon - d, lat - d * 0.67], [lon + d, lat - d * 0.67], [lon + d, lat + d * 0.67],
  [lon - d, lat + d * 0.67], [lon - d, lat - d * 0.67]]] };
await initializeCore();
const client = new InfraredClient({ apiKey: process.env.INFRARED_API_KEY });
const buildings = await client.buildings.getBuildingsInArea(polygon);
const input = { analysisType: "sky-view-factors", analysisSurfaces: "facades", surfaceGridSize: 2 };

const schedule = await client.runArea(input, polygon, { buildings });
while (!(await client.checkAreaState(schedule)).isComplete) {
  await new Promise((resolve) => setTimeout(resolve, 5000));
}
const columns = await client.mergeSurfaceAreaJobs(schedule);

// The layout of THIS run: same buildings, same sensor settings, the run's schedule.
const layout = synthesizeFacadeLayout({
  buildings: buildings.buildings, analysisSurfaces: "facades", surfaceGridSize: 2, schedule,
});
writeFileSync("layout.bin", layout.toBytes());
writeFileSync("values.bin", surfaceColumnsToBytes(columns, { layoutKey: layout.layoutKey }));
layout.free();
client.jobs.captures.free();
// load.mjs -- a new process: no buildings, no client, no network.
import { readFileSync } from "node:fs";
import {
  FacadeLayout, attachValues, initializeCore, surfaceColumnsFromBytes, surfaceRenderBuffers,
} from "@infrared-city/infrared-sdk";

await initializeCore();
const layout = FacadeLayout.fromBytes(readFileSync("layout.bin"));
const { columns, layoutKey } = surfaceColumnsFromBytes(readFileSync("values.bin"));
attachValues(layout, columns, { expectedLayoutKey: layoutKey }); // throws on a mismatch
const buffers = surfaceRenderBuffers(columns, { layout });
layout.free();

Install the parquet stack for getBuildingsInArea (see Install). Checked on 1.0.0-next.0 against the production API (one small Vienna site, 9,117 surfaces, 387,167 cells): the values of load.mjs are bit-identical to the live values.

Both SDKs use the same blob format; a blob saved by one loads in the other. For the same schedule the layout bytes are identical. The TypeScript values blob also stores the legend and aggregates; the Python one leaves them empty.

  • JSON.stringify, structuredClone and a copy of the object do not keep the compact columns in a reliable way. The blob is the way to save a result.
  • The blob holds the values in the server's type, the surface ids, the cell placement and the legend and aggregates. It holds no outline: render buffers after a reload need the saved layout.

Grid images

renderGridPng(grid, options) colours a result grid in the SDK, with the registry visualConfigurations the retired image route used. The image is 1:1 — one pixel per grid cell up to a 960 px long axis — twice the route's ~480 px long axis (four times the pixels).

// `renderGridPng` takes rows of numbers. An `AreaResult` keeps its values in one
// flat array in the server's type, so take the physical values first.
const [rows, cols] = result.gridShape;
const flat = areaGridValuesF32(result);          // Float32Array, NaN = no value
const grid = Array.from({ length: rows }, (_, r) =>
  Array.from(flat.subarray(r * cols, (r + 1) * cols)));
const png = await renderGridPng(grid, {
  analysisType: "pedestrian-wind-comfort",
  criteria: "lawson-lddc",
  maxLongAxisPx: 960,        // the default; 0 renders every cell
});
const size = gridImageSize(png, cols, rows);

Above the cap the grid is sampled nearest-neighbour on the VALUES before it is coloured, so the aspect ratio holds and a no-data cell stays no-data — no blended value appears between two classes. gridImageSize reads the size back out of the PNG and gives the scale factor, for aligning an overlay: output pixel (x, y) is grid cell (floor((x + 0.5) * gridWidth / size.width), floor((y + 0.5) * gridHeight / size.height)). It returns a GridImageSize, and the default is DEFAULT_MAX_LONG_AXIS_PX; both are on the package root. 0 is the opt-out rather than undefined, because JavaScript cannot tell an omitted option from an explicit undefined.

Legend range

A grid AreaResult carries minLegend and maxLegend: the exact min and max of the finite cells of the merged, clipped grid, measured in the SDK (#390). Both are absent when no cell is finite, and for a categorical result, which carries its class list in legend. The backend's own estimate is not used. For another scale, pick a mode at display time:

legendRange(result, "trimmed");            // 2nd..98th percentile, robust to outliers
sharedLegendRange([before, after]);        // one scale for several results
const fixed = await registryFixedRange("wind-speed");
if (fixed) legendRange(result, "fixed", { fixed }); // the registry's full scale

registryFixedRange is undefined when the registry publishes no numeric range for the analysis.

Long or narrow sites. A site is read as its bounding box, so a long, narrow or diagonal polygon has a box much bigger than the ground its simulation tiles cover. Three things follow, and the SDK does all three for you. The COMPOSE is given chunk ∩ union(tile rectangles) — the read chunk cut down to the pieces a simulation will actually read — so the merged ground document carries no surplus from the empty corners of the box; the READ itself is unchanged, for the row group reason above, and the read chunk stays the same 2 km grid whatever the shape. And the one-frame warning has a second rule: the flat-frame error grows with EXTENT, not with area, so a 10 km x 300 m strip is only 3 km2 but carries the far-end error of a 10 km site (about 17 m at 48 deg N); above 5 km on the longest side siteChunks warns through your logger and names that error. A compact site is unaffected in every respect: its answer, byte for byte, is the one it gave before.

What a public read is allowed to do

Every read of a public host obeys four rules. They also hold for the colour registry, and the Python SDK holds the same ones — the shared statement of them is scripts/ci/transport_contract_cases.json, which both test suites walk.

One deadline covers the answer AND its body. A timer that stops when the headers arrive is no timer: a server can answer and then stall. Each read takes timeoutMs, or DEFAULT_PUBLIC_TIMEOUT_MS (180 000 ms, the same default the authenticated transport uses) when the caller names none. The deadline also carries the caller's own signal, and it removes the listener it puts on that signal when the read ends.

Every body is read under a cap, while it arrives. The reader counts the bytes and stops the download the moment they pass the cap; a header is never the only limit. The caps are exported: MAX_HTTP_200_BYTES (8 MiB, for a server that ignores a Range request and sends the whole object), MAX_RANGE_BYTES (64 MiB for one range answer), and MAX_JSON_BYTES (32 MiB for an index document). A body that disagrees with its own Content-Length is an error.

A range answer must prove its window. One Content-Range only, the requested start, and the requested end or the last byte of the object when the window runs past the end of the file. multipart/byteranges is refused.

Range reads ask for Accept-Encoding: identity. Lengths are checked on decoded bytes, so an encoded range answer cannot be checked against its own Content-Range. Node sends that header; a browser is not allowed to set it, so a 206 that comes back under a content coding other than identity is a typed error. Index documents are not affected — they may arrive compressed, and only the length comparison is skipped for them.

fetchVisualConfigurations() takes the same timeoutMs and signal options. It had no timeout before; its default is 30 000 ms.

The parquet reader dependency

hyparquet and hyparquet-compressors are OPTIONAL PEER dependencies. npm does not install an optional peer, so a caller who brings their own data gets the smaller install and no warning. They are loaded with a dynamic import() in one module (src/geodata/overture-parquet.ts), only when ground materials or buildings are read from Overture, and kept external in the CommonJS bundle so the root entry never carries them at require() time. They are dropped when a core parquet reader or an Overture FlatGeobuf republish lands, whichever comes first.

Memory of an Overture read: the reader decodes one row group at a time, keeps the geometry as WKB bytes until a row is kept, and does not decode the bbox column twice. For the WKB step it also imports hyparquet/src/convert.js and hyparquet/src/wkb.js (the package exports ./src/*.js); where they cannot be imported, the read decodes GeoJSON as before. With hyparquet 1.31.1 or later the peak is lower again, because that version assembles nested columns (sources, names) only for the rows asked. 1.30.0 stays supported.

flatgeobuf is gone. It backed a second FlatGeobuf reader until the SDK two-phase reader shipped in both bindings; the SDK reader is now the only one, so selectFgbBackend, libraryFgbAvailable, setFgbLibraryModule and the FgbBackend type are removed with it.

Why parquet is read in the host: a core parquet reader has not been tried in this repo — the Rust stack's zstd codec is C, so it needs a C toolchain in the wasm build job, and the size cost is unmeasured. That is a build question to settle with numbers, not a proven impossibility. Everything AFTER the columnar read (the bbox clip, the zoom-band drop) is the SDK's, whichever reader wins.

Bundle cost, measured with esbuild (minified, browser, ESM, WASM and the parquet readers external): the ./geodata subpath is 17.9 KiB / 7.0 KiB gzipped on its own, and adds 13.9 KiB / 5.0 KiB gzipped to the package root. Bundling hyparquet with it instead of leaving it external took the subpath to 193.3 KiB / 100.1 KiB gzipped, which is why it stays a dynamic import.

readFgbBbox has one seam and one backend. The host does only fetch with Range headers: it asks the SDK for the file layout from a small prefix, then loops — hand the SDK the index nodes fetched so far, fetch the ranges it asks for next — and finally reads the feature ranges it plans. The packed index of a world file is over a gigabyte, so it is never downloaded whole. The reader's three exports are on the core's required-export list, so a build without them fails at initializeCore() rather than falling back.

Three rules protect a multi-request read of a public object, and each one is a typed GeodataRangeError:

  • the object size comes fr