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

@mapmap/points

v0.5.0

Published

MapMap Points SDK: render LiDAR and photogrammetry point clouds inside a MapLibre GL JS map, as a single draw call that composites with the basemap and terrain.

Downloads

820

Readme

@mapmap/points

Render LiDAR and photogrammetry point clouds inside a MapLibre GL JS map.

A million-point street-level cloud drawn in one THREE.Points call inside MapLibre's own WebGL context — so it composites with the basemap, the terrain and every other layer, rather than floating in a separate canvas on top of them.

npm install @mapmap/points three maplibre-gl

three and maplibre-gl are peer dependencies. The consuming app must 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 mutually incompatible sets of classes.

MapLibre GL JS 5 and 6

The peer range is ^5.6.0 || ^6.9.0: MapLibre 5.6 and later, and MapLibre 6.9 and later. One build serves both majors, so npm install @mapmap/points is right whichever you are on.

MapLibre 6 requires WebGL 2, having dropped the WebGL 1 fallback, and it removed its default export, so import the module as a namespace (import * as maplibregl from "maplibre-gl") on either major.

Bundlers that cannot rewrite MapLibre 6's worker URL (Turbopack among them) additionally need maplibregl.setWorkerUrl(...), called before the first map is constructed, with maplibre-gl-worker.mjs and maplibre-gl-shared.mjs served by your app. Without it the map shows no tiles and logs nothing at all. The Next.js recipe and the 5.x feature-detection note are in the 0.5.0 changelog entry.

Usage

import * as maplibregl from "maplibre-gl";
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 cloud = createPointCloudLayer(
  { meta, raw },
  { classes: classesFromMeta(meta.classes) },
);

map.on("style.load", () => map.addLayer(cloud));

Then drive it from your own UI:

cloud.setColourMode("class");        // "rgb" | "class" | "height"
cloud.setClassVisible(2, false);     // hide class byte 2
cloud.setPointSize(0.2);             // metres, before perspective scaling
cloud.setHeightRange(0, 25);         // steer the height ramp, in local metres
cloud.resetHeightRange();            // back to the cloud's own extent
cloud.getFps();                      // rolling 1s average

cloud.getClasses() returns the resolved table (values, labels, colours and visibility), which is what you build a legend from. Its 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 can read its checked state straight off the layer rather than shadowing the filter state alongside it. cloud.getHeightRange() returns the band the ramp currently spans, which is where a slider starts.

cloud.getVisiblePointCount() is the number a stat readout wants: how many points are on screen right now.

cloud.setClassVisible(6, false);
cloud.getClasses()!.visible;         // [1, 0, 1], current
cloud.getVisiblePointCount();        // 450_000, or null (see below)

It returns meta.count for a layer with no class table, since nothing is being culled, and otherwise sums meta.classCounts over the visible classes. It is null when the sidecar carries no classCounts: 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. Show nothing rather than stall the viewer or report a figure that is quietly wrong.

The wire format

One JSON sidecar plus one binary blob:

[ count * 3 * Uint16 ]  positions, little-endian
[ count * 4 * Uint8  ]  colour RGB + class in the alpha byte

10 bytes per point. The naive encoding (float64 ordinates, float colours) is around 48, which puts a million-point corridor at 48 MB before compression. More importantly, these two blocks go to 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.

import { encodePointCloud, decodePointCloud, validateMeta } from "@mapmap/points";

Production baking usually happens offline in Python or Rust; encodePointCloud exists for small clouds built in the browser.

The sidecar also carries two free-form provenance strings the SDK never renders: source for attribution, and sensor for what captured the cloud, such as "Ouster OS-1-256-RGB @ 2048x10". sensor exists 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 does not get reindexed to 2. Duplicate bytes, values outside 0–255 and tables over MAX_CLASSES throw, because each of those otherwise renders as points quietly taking a neighbour's colour with nothing on screen to explain it.

Omit classes entirely 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:

// map of class byte to label
"classes": { "2": "ground", "6": "building" }

// or a list of entries; `color` is read as well as `colour`
"classes": [
  { "byte": 2, "label": "ground", "colour": "#6b6f76" },
  { "byte": 6, "label": "building", "color": "#dbd2c0" }
]

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. 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.

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 is 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.

Fog

Fog distances are in metres from the eye, and they have to suit the scene: a pair of constants cannot serve both a 4 km aerial capture and a 340 m street. So both default from the cloud's own horizontal extent, nearM at 0.6 of the diagonal floored at 120 m and farM at 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 to a grey the map never reaches.

As a starting point, if you are choosing by hand rather than letting the extent choose:

| 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 too near for the scene is the failure worth knowing about: the cloud flattens to even grey the moment the camera pulls back, which reads as a broken renderer rather than as fog. autoFogRange(meta.bboxLocal) is exported if you want the derived pair to start from.

Terrain

Bake absolute heights into your payload. Every point is drawn exactly where the payload puts it — nothing here displaces a cloud by terrain — and MapLibre displaces its own 3D ground by absolute metres. A cloud on some other datum is therefore off by the height of the hill the moment terrain is switched on: the street either floats or is buried inside it.

DEM-subtracted payloads are not supported. There is no option that 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 every centimetre of the survey's own camber, kerb faces and gradient instead of replacing them with a resampled surface.

The one terrain knob in the sidecar moves the camera, not the points:

{ "absoluteHeights": true }  // the default; omit it and you get this

absoluteHeights: true 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 present) so existing sidecars render identically. Its old docstring promised that the layer would add terrain elevation back onto DEM-subtracted points. It never did.

Streaming a COPC

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. The file can be gigabytes.

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 — a page that never opens a COPC downloads no wasm decoder.

import { openCopc, createPointCloudLayer, ASPRS_LAS14_CLASSES } from "@mapmap/points";

const source = await openCopc("https://example.org/city.copc.laz");
map.fitBounds(source.meta.bboxWgs!);

const cloud = createPointCloudLayer(source, { fog: { colour: "#dce7f2" } });
map.addLayer(cloud);

setInterval(() => {
  const s = cloud.getStats()!;
  ui.textContent = `${fmt(s.residentPoints)} of ${fmt(s.totalPoints)} points`;
}, 250);

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.

Classes are discovered, not declared

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 data 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 one from the first request — 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. 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 your decision to make.

Coordinate reference systems

The CRS comes from the file's LASF_Projection WKT record, never from its filename. Built in: 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 is refused by name, with the two ways forward, and 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.

Bundlers

openCopc builds its decode workers from new URL("./worker.js", import.meta.url), which most bundlers understand. Some — Next among them — copy the worker as an asset without processing its module graph, and its lazy laz-perf import then cannot resolve. Three options exist for exactly that, and 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"));
openCopc(url, {
  createWorker: () => new Worker(new URL("./my-worker.ts", import.meta.url), { type: "module" }),
  // emscripten looks for its wasm beside its glue; after bundling, its glue
  // is in a hashed chunk. Serve laz-perf/lib/worker/laz-perf.wasm and say so.
  lazPerfWasmUrl: "/vendor/laz-perf/laz-perf.wasm",
});

Without them the failure is silent rather than loud: the decode promise never settles, nothing is logged, and the scene stays empty while the network and the georeferencing both work.

Limits

  • The payload path is not an LOD system. One buffer, one draw call, everything resident. Comfortable to a few million points on a modern GPU; past that, bake to COPC and use openCopc instead.
  • One COPC per layer. Multi-file mosaics, and EPT sources, are not in 0.3.0.
  • Vertical datums are the caller's problem. LAS Z is 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 anchor is trusted as lng/lat. If you are unsure a dataset's declared CRS matches its coordinates, check it first — MapMap's validate_geodata MCP tool and POST /geodata/validate exist for exactly that.

Licence

Proprietary — see LICENSE. Published to npm with public access (the Mapbox GL JS v2+ model): public package, commercial terms.