Documentation menu
Point clouds
@mapmap/points draws LiDAR and photogrammetry point clouds inside a
MapMap map: in MapLibre's own WebGL context, in one draw call, so a cloud
composites with the basemap, the terrain and every other layer rather than
floating in a separate canvas on top. It reads two kinds of input: a small
baked payload you fetch whole, or a COPC file streamed
straight from storage over HTTP range requests.
Availability: live on npm, 0.6.0 (12 October 2026). That is the version this page documents and the version this site runs. 0.6.0 draws clouds on the globe as well as on mercator; 0.5.0 added MapLibre GL JS 6 support and published the clearance module; 0.3.0 added the streaming COPC path. Everything since 0.1.0 is additive, with the exceptions the changelog shipped in the package (
node_modules/@mapmap/points/CHANGELOG.md) lists under Changed and Fixed: chiefly the peer range (maplibre-gl^5.6.0 || ^6.9.0, so 4.x, 5.0 to 5.5 and 6.0 to 6.8 are out), WebGL 2 being required on MapLibre 6, andthreeat 0.186.
See it first: the live point-cloud scenes run on this package, the point clouds product page covers the managed viewer and the clearance service, and measured clearance is the routing answer built on the same surveys.
Install
npm install @mapmap/points three maplibre-gl
Add laz-perf as well if you will stream COPC files:
npm install @mapmap/points three maplibre-gl laz-perf
| Package | Peer requirement |
|---|---|
maplibre-gl | ^5.6.0 || ^6.9.0 (MapLibre 5.6 and later, or 6.9 and later) |
three | >=0.160.0 <1.0.0 |
laz-perf | ^0.0.7, optional: only the COPC path needs it, and it is loaded lazily inside a worker on the first decode |
three and maplibre-gl are peer dependencies, so provide a single shared
copy of each: two MapLibre instances on one page break the WebGL context,
and two copies of three give you two incompatible sets of classes. The
package is ESM only and needs Node 18 or later to build. On MapLibre 6,
import the module as a namespace (import * as maplibregl from "maplibre-gl"), because MapLibre 6 has no default export.
A minimal working example
A baked cloud is two files: a JSON sidecar and a binary payload. This draws one on a MapMap map, coloured by class:
import "maplibre-gl/dist/maplibre-gl.css";
import { createMap } from "@mapmap/maps";
import { classesFromMeta, createPointCloudLayer } from "@mapmap/points";
const [meta, raw] = await Promise.all([
fetch("/cloud.json").then((r) => r.json()),
fetch("/cloud.bin").then((r) => r.arrayBuffer()),
]);
const mm = createMap({
container: "map",
apiKey: "snk_...", // from the quickstart
center: meta.anchor, // [lng, lat], from the sidecar
zoom: 17,
});
await mm.whenReady();
const cloud = createPointCloudLayer(
{ meta, raw },
{ classes: classesFromMeta(meta.classes), colourMode: "class" },
);
mm.map.addLayer(cloud); // mm.map is the underlying MapLibre map
With plain MapLibre instead of @mapmap/maps, add the layer once the style
has loaded: map.on("style.load", () => map.addLayer(cloud)).
Then drive it from your own UI: setColourMode("rgb" | "class" | "height"),
setClassVisible(byte, on), setAllClassesVisible(on),
setPointSize(metres), setHeightRange(minM, maxM), resetHeightRange()
and getFps(). getClasses() returns the resolved class table, which is
what you build a legend from, and getHeightRange() returns the band the
height ramp currently spans, which is where a slider starts.
The table's visible array is live: it is the array the shader itself
reads, so it always reflects the last setClassVisible or
setAllClassesVisible call, and a legend reads its checked state straight
off the layer rather than shadowing the filter state alongside it.
getVisiblePointCount() is the matching number for a stat readout: how
many points are on screen right now. That is meta.count for a layer with
no class table, since nothing is being culled, and otherwise the sum of
meta.classCounts over the visible classes. It is null when the sidecar
carries no classCounts, because the only other way to answer is to walk
the class byte of every point in the payload on the main thread, which is
far too expensive to do on every toggle for a number the producer could
have baked in.
Supported formats
| Input | How it loads | Notes |
|---|---|---|
| Baked payload: a JSON sidecar plus a binary blob | createPointCloudLayer({ meta, raw }), fetched whole | 10 bytes per point, described below. Comfortable to a few million points; past that, use COPC. |
COPC (.copc.laz, LAS 1.4, point data record formats 6, 7 and 8) | openCopc(url), streamed over HTTP range requests | One file per layer. A plain LAZ or LAS that is not COPC is refused with a message naming pdal translate in.las out.copc.laz --writers.copc. |
| Your own range reader | openCopc(rangeGetter) | Any byte source: pass a RangeGetter and no fetch of ours runs. |
Not supported in 0.6.0: EPT sources, multi-file mosaics in one layer, and point data record formats 0 to 5 (refused by name). Rebake those to COPC first.
The format: 10 bytes per point
One JSON sidecar and one binary blob:
[ count * 3 * Uint16 ] positions, little-endian
[ count * 4 * Uint8 ] colour RGB + class in the alpha byte
The naive encoding (float64 ordinates, float colours) is about 48 bytes
per point, which puts a million-point corridor at 48 MB before compression.
More importantly, these two blocks reach the GPU untouched: decoding is two
typed-array views over the downloaded ArrayBuffer, with no copy and no
per-point loop, and the shader dequantises.
Positions are integers in units of quant metres from origin, in a local
east/up/south frame. At the default 2 cm a Uint16 spans 1.3 km per axis.
validateMeta fails a sidecar whose extent will not fit, because the
alternative is a cloud silently folded back on itself.
The class rides in the alpha byte rather than a fourth attribute buffer: a
Uint8x4 colour attribute is one interleaved upload, and alpha is dead
weight in an opaque cloud.
encodePointCloud exists for clouds built in the browser; production baking
usually happens offline.
The sidecar also carries two free-form provenance strings the SDK never
renders. source is attribution; sensor is what captured the cloud, such
as "Ouster OS-1-256-RGB @ 2048x10", so a viewer's info panel does not have
to parse it back out of source or duplicate it in app config. Both are
carried through encode, decode and validation untouched.
Classes are yours
Every point carries one class byte, and this SDK has no opinion about what those bytes mean. A survey vendor, a national mapping agency and an ASPRS LAS file all number their classes differently, and a renderer that assumes one of them silently mislabels the other two.
So the class table is an input:
createPointCloudLayer({ meta, raw }, {
classes: [
{ value: 2, label: "ground", colour: "#6b6f76" },
{ value: 6, label: "building", colour: "#dbd2c0" },
{ value: 40, label: "overhead wire" }, // default palette colour
],
});
Your byte values survive as your byte values. 40 stays 40, it is not
reindexed to 2. The shader is generated per class table, which is what
makes that possible. Duplicate bytes, values outside 0–255 and tables over
MAX_CLASSES (16, a bounded uniform scan with a compile-time trip count)
throw, because each of those otherwise renders as points quietly taking a
neighbour's colour with nothing on screen to explain it.
Omit classes and you get RGB and height colouring; setColourMode("class")
then throws rather than inventing a taxonomy.
classesFromMeta(meta.classes) builds that table from a sidecar, and reads
either shape a producer might have written: a map of class byte to label,
{ "2": "ground", "6": "building" }, or a list of entries,
[{ "byte": 2, "label": "ground", "colour": "#6b6f76" }], where color is
read as well as colour. The map is ordered by class byte, since a JSON
object has no order worth trusting; a list is kept in the producer's own
order, because they chose it and it drives the default palette. Anything
that is neither shape throws naming both, and validateMeta reports the
same thing at bake time rather than leaving it to surface as a broken class
table at first render. A table longer than MAX_CLASSES is truncated with a
warning naming the dropped count, so a viewer handed a 20-class sidecar
still draws something.
The height ramp
setColourMode("height") ramps colour over local height, and by default the
ramp spans the cloud's whole vertical extent. That is right for a clean
cloud and wrong for a real one: a scene with a few below-datum returns and
one tall roof, -6.4 m to 82.5 m on a real street, squeezes everything a
viewer came to look at into a slice of the ramp.
cloud.setHeightRange(0, 25); // local metres, live
cloud.resetHeightRange(); // back to bboxLocal.y
Or set the band up front with the heightRange option, so the first frame
is already right. Bounds are sorted if they arrive the wrong way round and a
collapsed range is widened rather than dividing by zero in the shader; a
non-finite bound throws, and a call on a layer that has been removed from
the map warns and does nothing.
Fog scales with the scene
Fog distances are in metres from the eye, so one pair of constants cannot
serve both a 4 km aerial capture and a 340 m street. Both bounds default
from the cloud's own horizontal extent: nearM is 0.6 of the horizontal
diagonal, floored at 120 m, and farM is three times nearM.
createPointCloudLayer({ meta, raw }, {
fog: { colour: "#dce7f2", nearM: 300, farM: 900 },
});
Anything you pass wins, and the two default independently, so { colour }
on its own still gets extent-scaled distances. Set colour to your
basemap's fog colour, or the cloud fades towards a grey the map never
reaches. If you would rather choose the distances by hand:
| Scene | Extent | nearM | farM |
|---|---|---|---|
| One junction, a yard | under 150 m | 120 | 360 |
| A street, a short corridor | 150 to 600 m | 200 to 350 | 600 to 1050 |
| A district | 600 m to 2 km | 400 to 1200 | 1200 to 3600 |
| Aerial, a whole town | over 2 km | 1500+ | 4500+ |
Fog set too near for the scene is the failure worth knowing about: the cloud
flattens to even grey as soon as the camera pulls back, which presents as a
broken renderer rather than as fog. autoFogRange(meta.bboxLocal) is
exported if you want the derived pair as a starting point.
Terrain
Bake absolute heights into your payload. Every point is drawn exactly where the payload puts it (nothing in the SDK displaces a cloud by terrain) and MapLibre displaces its own 3D ground by absolute metres. A cloud on any other datum is therefore off by the height of the hill the moment terrain is switched on: the street either floats above it or is buried inside it.
DEM-subtracted payloads are not supported. No option makes one render correctly. If your producer subtracted a DEM, put the heights back before encoding: estimate the smooth residual between the survey's datum and the DEM and subtract that, rather than resampling ground, which keeps the survey's own camber, kerb faces and gradient instead of replacing them with a resampled surface.
The one terrain field in the sidecar moves the camera, not the points.
absoluteHeights: true (the default when the field is absent)
reconstructs the eye at an altitude that includes the terrain elevation at
the map centre, which is where it belongs for a cloud whose heights are
absolute. Set it to false only for a cloud on a flat z = 0 datum with
terrain off.
That sounds like a detail and is not. MapLibre gives a custom layer a combined matrix and no world-space camera, so the eye position has to be derived, and with terrain on, the camera orbits the terrain surface, not the z = 0 plane. Get it wrong in hilly country and every point size and every fog distance is computed from an eye a hundred metres underground. The cloud still renders; it just renders wrong, and nothing in the picture says why.
terrainRelative is the deprecated 0.1.0 spelling of absoluteHeights,
read as an alias with the same polarity (absoluteHeights wins if both are
set) so existing sidecars render identically. Its old documentation
promised that the layer would add terrain elevation back onto
DEM-subtracted points. It never did.
Globe
From @mapmap/points 0.6.0 a point cloud draws on a globe map
(projection: { type: "globe" }) as well as on mercator, including the
blend between the two at zoom 11 to 12. There is nothing to configure.
- Mercator rendering is unchanged, to the bit.
- On the globe the cloud sits on a tangent plane at its anchor; the sphere curves away by about 2 m at 5 km, below a pixel at any zoom where a 5 km cloud fills the screen.
- A streamed cloud chooses its level of detail against the mercator view below zoom 12 on a globe map, where a street-scale cloud is a few pixels across.
- A cloud on the far side of the planet is hidden by it.
Streaming COPC
Added in 0.3.0. Absent from 0.1.0.
The payload path above is one buffer, fetched whole, and it has a ceiling at
a few million points. openCopc is the other path: a
COPC file read directly over HTTP range requests, its
own octree traversed each frame by a screen-space-error test, and only the
nodes this camera can resolve fetched, decompressed in a Web Worker pool and
drawn. One URL, no tiling server, no bake step, and the file may be
gigabytes. Our own Montreal scene streams 23.7 million points
out of a 233 MB file on somebody else's bucket.
npm install @mapmap/points three maplibre-gl laz-perf
laz-perf is an optional peer dependency, imported lazily inside the
worker on the first decode, so a page that never opens a COPC downloads no
wasm decoder.
import {
ASPRS_LAS14_CLASSES,
createPointCloudLayer,
openCopc,
} from "@mapmap/points";
// Two range requests and no points: the LAS public header with its VLR
// block, then the root hierarchy page. Under 64 KB, whatever the file size.
const source = await openCopc("https://example.org/city.copc.laz", {
source: "Contains information licensed under the Open Government Licence, Canada.",
});
map.fitBounds(source.meta.bboxWgs!);
const cloud = createPointCloudLayer(source, { fog: { colour: "#dce7f2" } });
map.on("style.load", () => map.addLayer(cloud));
// Poll, do not push: the counters change every frame and no readout needs
// them at 60 Hz.
setInterval(() => {
const s = cloud.getStats()!;
ui.textContent = `${fmt(s.residentPoints)} of ${fmt(s.totalPoints)} points`;
}, 500);
residentPoints / totalPoints never reaches 1 and should not pretend to.
The honest label is "2.1M of 23.7M points", not a progress bar to 100 %: a
streamed cloud is a window onto something larger, not a download. The rest
of StreamStats is what a diagnostics panel wants, and busy is the flag
that says whether anything is still outstanding.
Everything the layer already does still applies. setColourMode,
setClassVisible, setPointSize, setHeightRange and getFps behave
identically on a streamed source, and getVisiblePointCount() is exact and
live rather than read off classCounts. Three methods are new and are the
quality controls: setChannel("rgb" | "intensity") re-decodes the resident
set with zero new range requests while the compressed cache is warm,
setTargetSse(px) moves the screen-space-error target, and
setPointBudget(points) moves the resident-point ceiling.
Classes are discovered, not declared, and the numbers are estimates.
COPC declares no class taxonomy and LAS 1.4 defines no statistics VLR, so an
ASPRS-numbered file and a vendor-numbered one are indistinguishable to a
reader. meta.classes and meta.classCounts are therefore absent on a
streamed source. Ask the points that have arrived instead:
const seen = cloud.observedClasses(); // [{ value: 2, count: 3469091 }, …]
cloud.setClasses(ASPRS_LAS14_CLASSES); // a uniform write, not a recompile
observedClasses() is a sample: it describes the nodes decoded so far,
not the file. It is a good sample from the first request, because a COPC
root node is a spatially uniform subsample of the whole cloud by
construction, and it sharpens as levels arrive. Label it as an estimate in
your UI, the way the demo viewer does; a count that moves as the camera
moves is honest only if it says so. ASPRS_LAS14_CLASSES is exported and
never applied by default: it is a curation of the 14 classes that occur
in national LiDAR, not the standard's 23, and picking it is a claim about
the file that only you can make.
The CRS comes from the file, never from its filename. The
LASF_Projection WKT record is read and reprojected by a self-contained
kit: geographic, Web Mercator, Transverse Mercator (every UTM zone, BNG,
MTM) and Lambert Conformal Conic 2SP, agreeing with pyproj to well under a
millimetre across a zone. Anything else throws UnsupportedCrsError, naming
the CRS, with the two ways forward. One of them is to bring your own:
import proj4 from "proj4";
const from = proj4("EPSG:2056");
openCopc(url, { crs: { toWgs84: (x, y) => from.inverse([x, y]) } });
No datum shift is performed, by design. A datum outside a known-safe list warns once, naming it and the likely offset, and renders anyway: a viewer that is a metre out is more useful than one that refuses.
Failure modes
The appeal of COPC is pointing a viewer at somebody else's URL, which is also where it breaks. A generic "failed to load" is treated as a bug here: each condition gets its own message, and the recoverable ones do not stop the scene.
| Condition | What happens |
|---|---|
No CORS headers, or Range rejected on the preflight | CopcTransportError with code: "network", listing the three headers the server must return and how to tell CORS from an unreachable host in the Network tab |
Server answers 200 to a ranged GET | code: "range-unsupported": it cannot be streamed at all. Serve it from storage that does ranges (S3, GCS, Azure Blob, any CDN, nginx as shipped) or bake it to a payload |
403, 404, 416 | code: "http-status", not retried. A signed URL or an Authorization header goes through requestInit |
A request delivers nothing for requestStallMs (15 s by default) | code: "stalled", and the range is retried |
| Retries spent on one byte range | code: "retries-exhausted" |
laz-perf not installed | code: "laz-perf-missing", naming the install command |
The caller's AbortSignal fires | code: "aborted", with name kept as AbortError. isAbortError(err) is true; this is never a failure |
| CRS unsupported, or no WKT record at all | UnsupportedCrsError, naming the CRS and the two ways forward |
| PDRF outside 6, 7, 8, or the COPC info VLR missing | Throws: it is a LAZ, not a COPC. Points at pdal translate |
| An unknown datum in the WKT | Warns once, naming it and the likely offset, and renders |
| One node fails after retries | Warns once for that node, cools down 30 s, carries on. The scene is coarse there rather than holed |
| WebGL context lost | Geometries are disposed and rebuilt from the decoded cache. Nothing is re-downloaded |
The first two are the ones that will actually happen. Both are the publisher's server, not your code, which is why the messages name headers rather than apologise.
Bundlers
openCopc builds its decode workers from
new URL("./worker.js", import.meta.url), and the worker's lazy laz-perf
import only resolves if your bundler processes that worker's module graph.
Not every bundler does: Next copies the worker as an asset without
processing it, and Vite 8 bundles the worker chunk without the lazy
laz-perf import. Treat the three-part setup below as the default whatever
you bundle with: your own worker module that calls setLazPerfImporter,
createWorker pointing at that module, and lazPerfWasmUrl pointing at the
wasm you serve. You will want all three together:
// your own worker module, so your bundler owns the graph
import { setLazPerfImporter } from "@mapmap/points/worker";
setLazPerfImporter(() => import("laz-perf/lib/worker/index.js"));
The laz-perf typings reference Emscripten's global types, so add
@types/emscripten as a dev dependency if you do not use skipLibCheck.
openCopc(url, {
createWorker: () =>
new Worker(new URL("./my-worker.ts", import.meta.url), { type: "module" }),
// Emscripten looks for its wasm beside its glue, and after bundling its
// glue is in a hashed chunk. Serve laz-perf/lib/worker/laz-perf.wasm and
// say where.
lazPerfWasmUrl: "/vendor/laz-perf/laz-perf.wasm",
});
Without them the failure is silent rather than loud: nothing is logged, no request fails, and the scene stays empty while the network and the georeferencing both work.
Browser and device budgets
The layer needs WebGL 2 on MapLibre 6, which dropped the WebGL 1 fallback; in practice it has only ever run on a WebGL 2 context on MapLibre 5 too.
A baked payload is all resident. One buffer, one draw call, fetched whole: comfortable to a few million points on a modern GPU, and the file size is the point count times 10 bytes plus the sidecar. Past that, stream a COPC.
A streamed cloud runs to a resident point budget, and the default comes from the device:
navigator.deviceMemory | Default pointBudget |
|---|---|
| 8 GB or more | 12,000,000 points |
| 4 GB or more | 6,000,000 points |
| below 4 GB, or unknown | 3,000,000 points |
deviceMemory is Chromium-only, so Safari and Firefox take the
conservative tier unless you pass pointBudget yourself. The decoded-point
cache (cacheBytes) is sized from the budget and capped at 256 MB, and a
compressed-node cache (64 MB by default) is what lets a colour-channel
switch re-decode without a single new request. pointBudgetForDevice(),
cacheBytesForBudget() and deviceMemoryGb() return the same defaults, so
a quality slider can start where the SDK would have started.
Four layer options steer it, all for streamed sources only:
targetSsePx(default2): the screen-space-error target in device pixels. Higher is coarser and cheaper.setTargetSse(px)moves it live.pointBudget: the resident ceiling above.setPointBudget(points)moves it live.budgetRamp(off by default): a scene whose budget is large opens at a smaller one so the near field sharpens in a second or two, then grows to the full budget. Phones and mid-tier budgets ignore it.adaptiveBudget(off by default): samples the real frame rate and moves the budget to hold it.truemay only shrink the budget, to a floor of a quarter of where it started;{ min, max, targetFps }(default target about 50 fps) may also grow it up tomax.
On the network side, openCopc keeps at most 6 range requests in flight to
the file's origin (maxConcurrent), merges ranges closer than 32 KB into
one request up to 4 MB (coalesceGapBytes, coalesceMaxBytes), and treats
a request that stalls for 15 seconds as failed and retries it
(requestStallMs). Decoding runs in a pool of
clamp(hardwareConcurrency - 2, 2, 4) Web Workers (workers).
Clearance on route
Availability: published since 0.5.0, and in 0.6.0. The module is
points-sdk/src/clearance/: the types,formatClearance,formatLateralandreportToGeoJson, with their tests. This site's own clearance viewer imports it from the package, which is how the code below is exercised.
The gateway can answer whether a vehicle passes under what a survey actually
measured, rather than under what the map records
(POST /v1/clearance/along).
This module is the client half of that answer: the wire types, and two pure
functions for putting one in front of a person.
It does not fetch, it does not render, and it does not compute a clearance. The arithmetic lives once, server-side, so there is a single implementation of the safety-critical part rather than a second one drifting in a browser.
import { formatClearance, reportToGeoJson } from "@mapmap/points";
import type { RouteClearanceReport } from "@mapmap/points";
const report: RouteClearanceReport = await fetch(`${BASE}/v1/clearance/along`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
body: JSON.stringify({ geometry_polyline6: shape, vehicle_height_m: 4.2 }),
}).then((r) => r.json());
// The corridor, its coverage gaps and its measurement points, ready for
// map.addSource(). Pure: same report in, same features out.
map.getSource("clearance").setData(reportToGeoJson(report));
if (report.height.verdict === "fail") {
// "4.31 m measured (safe bound 4.12 m, surveyed Jan 2026)"
label.textContent = formatClearance(report.height.limiting.clearance);
}
Four rules are enforced by the type system rather than by this page, because a caveat in prose is a caveat somebody skips.
A pass cannot carry coverage gaps. Coverage is two distinct shapes
and the passing verdict admits only the complete one, which has no field for
gaps. A route that passes over ground nobody surveyed is a state the module
cannot represent. It is the typed version of the elevation API's rule that a
missing height is null and never a guess.
A measured clearance carries no signed or legal height. A survey measures
where a structure is; a sign states what you are permitted to do, with
somebody's margin already inside it. MeasuredClearance has no field for the
second, and a compile-time assertion keeps it that way. Signed heights appear
in TagComparison and nowhere else, where the verdict being reported is
about the tag.
A figure cannot be displayed without its bound. formatClearance takes
the whole measurement rather than a number, so you cannot format what you do
not hold, and it returns ClearanceText: a branded string a hand-built
"4.62 m" is not assignable to. Type a label as ClearanceText and it can
only be fed from here. Both figures round down to the centimetre, because
rounding a clearance up to make a label tidy is inventing headroom that was
never measured. Non-finite figures, negative bounds and a safe bound above
the measured value all throw rather than rendering as a confident label.
The two axes do not share a measurement type. A headroom metre and a
clear-width metre are different quantities, so MeasuredLateral has no
headroom field, MeasuredClearance has no clear width, and formatLateral
will not take the other's input. A lateral distance cannot be captioned as
overhead clearance by passing the wrong flag, because there is no flag.
reportToGeoJson(report) returns the corridor as a ClearanceGeoJson: the
route ribbon split by verdict, coverage gaps and advisory runs as their own
line features, and the limiting, contested and tightest points as point
features. Line features carry ClearanceLineProperties, points carry
ClearancePointProperties or LateralPointProperties, and every one of them
carries a ClearanceStatus to style on, so a map layer branches on a
property rather than re-deriving the verdict. The formatted text is already
in the properties, bound included.
Two groups of types here are contracts shipped ahead of the mechanism that
fills them, and are listed as such rather than left to look live.
TagAuditReport, TagComparison and OsmDraftEdit describe the comparison
between what the map claims and what a survey measured; no gateway route
serves one yet. EpochRegistration, ChangeReport, ChangeEvent and
ChangeKind describe change detection between survey epochs, which is the
same. Both are here so that a client written against them does not have to
be rewritten when they arrive.
Full export list, with every field, is in the API reference below. Everything the answer means, including why unsurveyed ground is never reported as clear, is on the clearance guide.
Limits
- The payload path is not an LOD system. One buffer, one draw call,
everything resident, payload fetched whole. Comfortable to a few million
points on a modern GPU; past that, bake to COPC and use
openCopc, or split the area into several clouds and add or remove layers by viewport. - One COPC per layer. Multi-file mosaics, and EPT sources, are not in 0.6.0.
- Vertical datums are the caller's problem. LAS
Zis used as-is;openCopc(url, { heightOffsetM })shifts the whole cloud if you know the offset. DEM-subtracted payloads remain unsupported. - Not a reprojection tool. The sidecar's
anchoris trusted as lng/lat. If you are unsure a dataset's declared CRS matches its coordinates, check it first. Thevalidate_geodataMCP tool andPOST /geodata/validateexist for exactly that. threeandmaplibre-glare peer dependencies. Provide a single shared copy of each: two MapLibre instances on one page break the WebGL context, and two copies of three give you two incompatible sets of classes.
Live demos and the sample data
Every scene at /demo/lidar is drawn by this package, and each one is a dated third-party survey published under its own terms. The credit is rendered on the canvas and no viewer control removes it.
| Scene | Source and licence |
|---|---|
| Vienna, Schillgasse | City of Vienna, Kappazunder mobile-mapping survey, CC BY 4.0 |
| Tower Bridge and Brighton seafront | Environment Agency National LIDAR Programme, Open Government Licence v3.0 |
| San Francisco, Chinatown and Nob Hill | USGS 3D Elevation Program, US public domain |
| Kawagoe, Saitama | Saitama Prefecture open road point cloud, CC BY 4.0 |
| Osaka, Kyobashi | Osaka City Construction Bureau, CC-BY |
| Montreal | Natural Resources Canada and the Communauté métropolitaine de Montréal (2023 acquisition), Open Government Licence, Canada. 23.7 million points streamed from a 233 MB COPC file read straight from NRCan's own bucket: the COPC path end to end, with no copy on our side |
| Balmy Alley, traffic cops and Chinatown intersection | Ouster sample library (studio.ouster.com), CC BY-NC-SA, published here with Ouster's written permission. That permission covers these three recordings on this site; the licence does not extend it to you |
/demo/vienna is the Vienna scene on its own route, kept as the SDK walkthrough. If you reuse any of these datasets in your own app, take the licence from the publisher, not from this table, and carry the credit with the data.
API reference
Every public export of @mapmap/points, grouped by the job it does.
The layer. createPointCloudLayer(source, options?) returns a
PointCloudLayer: a MapLibre CustomLayerInterface with the extra methods
listed above (setColourMode, setClassVisible, setAllClassesVisible,
setPointSize, setHeightRange, resetHeightRange, getHeightRange,
getFps, getClasses, getVisiblePointCount, and a readonly meta), plus
the streaming controls getStats, observedClasses, setClasses,
setChannel, setTargetSse and setPointBudget.
PointCloudSourceis whatsourcemay be: aDecodedPointCloud, a{ meta, raw }pair which is decoded and validated for you, or, from 0.3.0, aStreamingSourcesuch as theCopcSourcethatopenCopcreturns. The three are interchangeable at the call site, which is what lets a viewer carry baked and streamed scenes down one code path.PointCloudLayerOptionsconfigures a layer:id(default"mapmap-point-cloud"),classes(aPointClass[]),colourMode(default"rgb"),pointSizeM(world size of one point, default0.15),maxPointPx(screen-size cap in device pixels, default10, raise it for offline video bakes),heightRange({ minM, maxM }, default the cloud's own vertical extent) andfog({ colour?, nearM?, farM? }, where each distance defaults on its own from the cloud's horizontal extent). For a streamed source only:pointBudget,cacheBytes,targetSsePx(default2),budgetRampandadaptiveBudget(AdaptiveBudget,{ min, max, targetFps? }, ortrue), described under browser and device budgets.ColourModeis"rgb" | "class" | "height": the payload's own colours, flat per-class colours, or a ramp over local height.getClasses()returns the layer's liveResolvedClasses, ornullfor a layer created without a class table. Itsvisiblearray is the one the shader reads, so it never goes stale behindsetClassVisibleorsetAllClassesVisible.getVisiblePointCount()returns how many points are currently drawn:meta.countwhen there is no class table, otherwise the sum ofmeta.classCountsover the visible classes, ornullwhen the sidecar carries noclassCounts.localOffset(anchor, lngLat)returns the{ x, z }offset in metres (x east, z south) of a lng/lat from a cloud's anchor, using MapLibre's Mercator maths.
Streaming a COPC (added in 0.3.0; absent from 0.1.0).
openCopc(source, options?)reads a COPC's LAS public header and root hierarchy page and fetches no points, returning aCopcSource.sourceis a URL, or aRangeGetterif you want to read the bytes yourself.CopcOpenOptionsis every knob on the read:anchor,crs(a{ toWgs84(x, y) }hook, whichproj4satisfies as-is),heightOffsetM,channel,workers,createWorker,lazPerfWasmUrl,cacheBytes,compressedCacheBytes,coalesceGapBytes,coalesceMaxBytes,requestStallMs,maxConcurrent,requestInit(auth headers, credentials, a cache mode),getRangeandgetPageRange,sourceandsensor(whatmeta.sourceandmeta.sensorsay, which is where the credit goes),onWarnandsignal.CopcSourceis the opened file:kind: "copc",url,meta, thechannelsits PDRF allows and thechannelin the colour bytes now,setChannel(channel), astatsledger, the parsedheader,infoand projectiondescriptor, anddispose(). Calldispose(): a source owns a worker pool and its in-flight sockets, and a scene switch that walks away from one leaks both.CopcChannelis"rgb" | "intensity": which scalar lands in the three colour bytes. A channel is what is in the bytes and changing it re-decodes; a colour mode is how the shader paints them and changing it is a uniform write.RangeGetteris(begin, end, signal?) => Promise<Uint8Array>, withendexclusive. It is the whole I/O seam: pass one and nofetchof ours runs.CopcCloudMetais the metadata synthesised from the header and the COPC info VLR. It is aPointCloudMeta(soanchor,origin,quant,count,bboxLocal,bboxWgs,source,sensorall mean what they mean everywhere else) plusstreaming: true,totalPoints,maxLevel,spacingM,pdrf,hasRgb,hasNir, the file's ownwktand theprojectionfamily used to place it.classesandclassCountsare deliberately absent.quantis the root node's, descriptive only: every node carries its own, so code that multiplies by it is wrong for all the others.StreamCloudMetais the container-agnostic half of that (streaming,totalPoints,spacingM,maxLevel), andStreamingSourceis the container-agnostic sourcecreatePointCloudLayerconsumes. COPC is the only container in 0.6.0; the pair exists so a second one is not a second code path.isStreamingSource(source)is the type guard, for a viewer branching on which kind of cloud it was handed.getStats()returnsStreamStatsfor a streamed layer andnullfor a baked one:residentPoints,residentNodes,selectedNodes,totalPoints,pendingRequests,cacheBytes,cacheBudgetBytes,failedNodes,maxLevelResidentandbusy(true while anything is outstanding, which is the repaint condition).observedClasses()returnsObservedClass[], each{ value, count }: the class bytes present in the resident set right now, counted from points the workers decoded anyway. A sample, not a census.setClasses(table)names and colours them afterwards.CopcDataStats, onsource.stats, is the request ledger:requests,nodeRequests,pageRequests,bytes,wastedBytes(bytes inside a coalesced span nobody asked for),nodesDecoded,nodesFailed,cacheHits,decodedCacheBytesandcompressedCacheBytes. It is what a proof run measures and what tells you whether a channel switch really cost zero requests.ASPRS_LAS14_CLASSESis a ready-madePointClass[]for files that follow the standard,ASPRS_LAS14_CLASS_LABELSis the byte-to-label map behind it andasprsClassLabel(value)is the single lookup,undefinedfor a byte the curation does not name. Never applied by default, on either path.pointBudgetForDevice(deviceMemoryGb?, override?)andcacheBytesForBudget(pointBudget, override?)are the defaults behindpointBudgetandcacheBytes, anddeviceMemoryGb()readsnavigator.deviceMemorywithout assuming a DOM. It is Chromium-only, soundefinedtakes the conservative tier rather than the optimistic one. Together they are where a quality slider should start.
Streaming errors. Every one names what to do next, and all of them are
throwable from openCopc or reported through onWarn.
CopcTransportErrorcarries acode(CopcTransportErrorCode), plus thestatusandurlwhen there were any. The codes are"range-unsupported","network","stalled"(a request delivered nothing forrequestStallMsand was abandoned),"http-status","retries-exhausted","aborted","laz-perf-missing"and"invalid-request". Branch on the code: CORS, a server that ignoresRangeand a plain 404 need three different fixes.isAbortError(err)is true for our"aborted"code and for the DOMAbortErrorandTimeoutError, so one check covers a cancelled load however it was cancelled. An abort is never a failure and is never retried.UnsupportedCrsErrorcarriescrsName,epsgandfamily. Its message names the CRS rather than saying "unsupported projection", because the name is what sends a developer to the two fixes under it: supply acrshook, or reproject the file.
The wire format (pure, no WebGL or MapLibre).
decodePointCloud(meta, raw)points two zero-copy typed-array views at a payload after checking its byte length, returning aDecodedPointCloud({ positions: Uint16Array, colours: Uint8Array, meta }). It throws on a truncated or mismatched payload rather than rendering a partial cloud.PointCloudMetais the JSON sidecar:anchor([lng, lat]),quant(metres per position unit),origin,count,bboxLocal(aLocalBox), optionalbboxWgs,classes(aMetaClassTable, either shape),classCounts,absoluteHeights(defaulttrue;terrainRelativeis its deprecated alias),sourceandsensor.absoluteHeightsFromMeta(meta)resolves that pair to the single boolean the layer uses, preferringabsoluteHeightsand warning once if a sidecar still spells itterrainRelative.validateMeta(meta)returns the problems with a sidecar, an empty array meaning usable, and covers the class table's shape and the provenance strings' types as well as the geometry.classTableProblems(classes)is the class-table half on its own, for a baker checking one table.LocalBoxis the local bounding box in metres:{ x, y, z }, each a[min, max]pair, in the cloud's east/up/south frame.BYTES_PER_POINTis10(3 x Uint16 positions + 4 x Uint8 colour), andpayloadBytes(count)returns the byte length a payload ofcountpoints must have.
Classes.
PointClassis one row of a class table:{ value, label, colour?, visible? }, wherevalueis the class byte andcolouris#rrggbbor an[r, g, b]triple in 0 to 1.resolveClasses(classes)normalises aPointClass[]into the flat arrays the shader wants, returned asResolvedClasses({ values, labels, colours, visible, lookup }). It throws on duplicate bytes, a byte outside 0 to 255, or a table overMAX_CLASSES.classesFromMeta(meta.classes)builds aPointClass[]from either sidecar shape,MetaClassTable: aRecord<string, string>of class byte to label, or aMetaClassEntry[]of{ byte, label, colour? }(withcolorread too). It returns an empty table for an absent one, truncates pastMAX_CLASSESwith a warning, and throws on any third shape.visiblePointCount(classes, meta.classCounts)is the pure helper behindgetVisiblePointCount(), for code holding aResolvedClassesof its own. A visible class the producer never counted contributes nothing, so a partialclassCountsunder-reports rather than throwing.DEFAULT_PALETTEis the built-in, colour-blind-aware palette used when a class declares no explicit colour; its early entries stay separable under common colour-vision deficiencies.MAX_CLASSESis16: a bounded uniform scan with a compile-time trip count, which the shader is generated against. It is a product decision rather than a WebGL-version artefact, so MapLibre 6 and WebGL 2 do not widen it; a wider producer taxonomy needs a texture lookup and a different shader.
Scene ranges (pure, the defaults the layer applies).
autoFogRange(bboxLocal)returns theFogRange({ nearM, farM }) a cloud gets whenfog.nearMorfog.farMis unset:MIN_FOG_NEAR_M(120) orFOG_NEAR_DIAGONAL_FRACTION(0.6) of the horizontal diagonal, whichever is larger, andFOG_FAR_MULTIPLE(3) times that.defaultHeightRange(bboxLocal)returns theMetreRange({ minM, maxM }) the height ramp spans unsteered, widened toMIN_HEIGHT_SPAN_M(8) for a flat cloud, andclampHeightRange(minM, maxM)is whatsetHeightRangeputs a caller's bounds through: sorted, a collapsed range widened, a non-finite one thrown.
Camera reconstruction (used internally by the layer, exported for custom renderers).
CameraViewis the subset of a MapLibre map the maths reads:centre,zoom,bearing,pitch,fovDeg?,bufferHeightPx(device pixels, not CSS) andcentreElevationM?.eyePosition(view, centre)returns theEyePosition({ x, y, z, distanceM }) in the cloud's local frame, raising the eye by the terrain height at the centre when terrain is on.LocalCentreis where the map centre sits in the local frame ({ x, z }).stickyElevation(sampled, remembered)carries the last elevation the DEM actually answered with across the frames wherequeryTerrainElevationreturnsnull(before a tile has loaded), so the eye never drops to sea level and pops back up when it arrives.nullonly until the first real sample.metresPerPixel(lat, zoom)returns the ground metres one screen pixel covers, andpointSizeScale(worldSizeM, bufferHeightPx, fovDeg?)returns the device pixels a point of that world size subtends at one metre (the shader divides by distance so points shrink with depth).DEFAULT_FOV_DEG(36.87) is MapLibre's default vertical field of view, the fallback whenCameraView.fovDegis unset.
Clearance on route (types and pure functions; nothing here computes a clearance, and nothing here fetches or renders).
RouteClearanceReportis the answer for one route and one vehicle:basis, aClearanceEnforcementblock, the route, theVehicleQuery, aRouteVerdictfor height, aRouteVerdictornot_assessedfor width, the datasets consulted, advisories and anexplanation.RouteVerdictis the height answer:pass,fail,indeterminateorno_verdict. Apassaccepts only completeCoverage, so a route that passes over unsurveyed ground is a state the type cannot represent. AnindeterminatecarriesIndeterminateReasonvalues beside its sentence.WidthVerdictis the width answer, and a separate type carrying separate measurements.Coverageiscomplete(no field for gaps) orpartial(withnot_surveyedandinsufficient_dataruns, each aRouteSection).MeasuredClearanceis one overhead measurement and its bound:headroom_m,sigma_m,sampling_gap_m,safe_headroom_m, anOverheadClass,support_m2andsurveyed_on. It has no field for a signed or legal height.LimitingPointplaces one on the route with a viewer deep link.MeasuredLateralis one lateral measurement:clear_width_m,sigma_m,sampling_gap_m,safe_clear_width_m, anObstructionClassinlimited_by,support_m2andsurveyed_on. It has no headroom field, so a clear width cannot be captioned as overhead clearance.LateralPointplaces one on the route.formatClearance(clearance)andformatLateral(lateral)render a measurement as text, bound included, and returnClearanceText, a branded string a hand-built label cannot be substituted for. Each takes only its own measurement type.reportToGeoJson(report)returns aClearanceGeoJson: the route ribbon, coverage gaps, advisory runs and measurement points, asClearanceFeaturevalues carryingClearanceLineProperties,ClearancePointPropertiesorLateralPointProperties, and aClearanceStatusfor styling.TagAuditReportcompares what OpenStreetMap claims against what the survey measured:TagComparisonrecords andOsmDraftEditdrafts.ClearanceFieldSidecaris the baked artefact's sidecar, including its mandatoryDatumBlockandVerticalBasis.EpochRegistration,ChangeReport,ChangeEventandChangeKindare the change-detection contracts, shipped ahead of the mechanism.
Related
- Point clouds product page: the SDK, the managed viewer and the clearance service.
- Measured clearance on a route: the gateway half of the clearance types above.
- Truck restriction data, measured: what the map records about heights and widths, and where a survey adds to it.
- SDKs and installation: the other packages, and per-vehicle or per-MAU licensing.