Show a million points on Cesium

recipe
spatial
cesium

This page shows a cesium view with 1,000,000 randomly generated points displayed. Mousing over a point will show a tag with the point ID. The point size is adjusted by a NearFarScalar responsive to the camera distance from the points.

Although terrain is shown, the points are not adjusted with terrain height. There is an option to compute the height of points using sampleTerrainMostDetailed, however that should only be used when the results can be preserved since it is fairly slow (generally a couple seconds per batch of 1000).

The points use the PointPrimitive class and so have limited properties available. With such a large number of points, the properties per point is very limited. Operations for aggregation, selection, grouping, etc should be based on the id orposition attributes and access the records separately (e.g. using duckdb).

function randomCoordinateJitter(degree, margin) {
    return degree + margin * (Math.random() - 0.5) / 0.5;
}

async function sleep(ms=500) {
        return new Promise((resolve) => setTimeout(resolve, ms));
}


function createShowPrimitive(viewer) {
    return function(movement) {
        // Get the point at the mouse end position
        const selectPoint = viewer.viewer.scene.pick(movement.endPosition);
        let is_a_point = false;
        if (Cesium.defined(selectPoint)) {
            is_a_point = (selectPoint.primitive instanceof Cesium.PointPrimitive);            
        }
        // if it is a point, then we may need to do something
        if (is_a_point) {
            // if the selected point is the same as current selection, then do nothing
            if (selectPoint !== viewer.currentSelection) {
                // If the selected is different to the previous then clear the previous
                if (viewer.currentSelection !== null) {
                    viewer.pointLabel.label.show = false;
                    viewer.currentSelection.primitive.pixelSize = 4;
                    viewer.currentSelection.primitive.outlineColor = Cesium.Color.TRANSPARENT;
                    viewer.currentSelection.outlineWidth = 0;
                    viewer.currentSelection = null;
                }
                // show the selection
                const carto = Cesium.Cartographic.fromCartesian(selectPoint.primitive.position)
                viewer.pointLabel.position = selectPoint.primitive.position;
                viewer.pointLabel.label.text = `id:${selectPoint.id}, ${carto}`;
                selectPoint.primitive.pixelSize = 20;
                selectPoint.primitive.outlineColor = Cesium.Color.YELLOW;
                selectPoint.primitive.outlineWidth = 3;
                viewer.currentSelection = selectPoint;
                viewer.pointLabel.label.show = true;
            }
        } else {
            // if no point was selected, then clear the previous
            if (viewer.currentSelection !== null) {
                viewer.pointLabel.label.show = false;
                viewer.currentSelection.primitive.pixelSize = 4;
                viewer.currentSelection.primitive.outlineColor = Cesium.Color.TRANSPARENT;
                viewer.currentSelection.outlineWidth = 0;
                viewer.currentSelection = null;
            }
        }
    }
}


class CView {
    constructor(target) {
        this.viewer = new Cesium.Viewer(
            target, {
                timeline: false,
                animation: false,
                baseLayerPicker: false,
                fullscreenElement: target,
                terrain: Cesium.Terrain.fromWorldTerrain()
            });
        this.currentSelection = null;
        this.point_size = 1;
        this.n_points = 0;
        // https://cesium.com/learn/cesiumjs/ref-doc/PointPrimitiveCollection.html
        this.points = new Cesium.PointPrimitiveCollection();
        this.viewer.scene.primitives.add(this.points);
     
        //this.viewer.scene.globe.depthTestAgainstTerrain = false;
        //this.viewer.scene.logarithmicDepthBuffer = false;

        this.pointLabel = this.viewer.entities.add({
            label: {
            show: false,
            showBackground: true,
            font: "14px monospace",
            horizontalOrigin: Cesium.HorizontalOrigin.LEFT,
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
            pixelOffset: new Cesium.Cartesian2(15, 0),
            // this attribute will prevent this entity clipped by the terrain
            disableDepthTestDistance: Number.POSITIVE_INFINITY,
            text:"",
            },
        });

        this.pickHandler = new Cesium.ScreenSpaceEventHandler(this.viewer.scene.canvas);
        // Can also do this rather than wait for the points to be generated
        //this.pickHandler.setInputAction(createShowPrimitive(this), Cesium.ScreenSpaceEventType.MOUSE_MOVE);
    }

    enableTracking() {
        this.pickHandler.setInputAction(createShowPrimitive(this), Cesium.ScreenSpaceEventType.MOUSE_MOVE);
    }

    /**
     * Wait for the terrain provider to be available. This is created asynchronously during the
     * view initialization which often returns before the terrain provider is complete. Here we 
     * wait around for up to 3 seconds (10x300ms) until the terrain provider is ready.
     */
    async waitForTerrainProvider() {
        let n = 0;
        while (n < 10 && !Cesium.defined(this.viewer.terrainProvider)) {
            await sleep(300);
            n += 1;
        }
    }

    /**
     * Add points at terrain elevation.
     * 
     * This uses sampleTerrainMostDetailed to compute the terrain elevations for batches
     * of 100 points at a time. It typically takes a couple seconds for a batch of 1000 to complete
     * so using 100 provides a more interesting visual as the points are added.
     * 
     * The actual time seems to be dependent on exogenous factors.
     */
    async generatePointsAtTerrain(n_points=1000, point_size=4) {
        this.n_points = n_points;
        this.point_size = point_size;
        // change point size with camera distance
        const scalar = new Cesium.NearFarScalar(1.5e2, 2, 8.0e6, 0.2);
        let total = 0;
        await this.waitForTerrainProvider();
        let batch_size = 1000;
        while (total < this.n_points) {
            const batch = [];
            if (n_points - total < batch_size) {
                batch_size = n_points - total;
            }
            // create a batch of coordinates
            for (let i=0; i<batch_size; i++) {
                batch.push(Cesium.Cartographic.fromDegrees(
                    randomCoordinateJitter(0.0, 180.0),  //longitude
                    randomCoordinateJitter(0.0, 90.0),   //latitude
                ));
            }
            // compute the terrain (ellipsoid) elevations for the coordinates
            // since this is async, it has the convenient result of letting the display update 
            // as more points are added.
            const updatedPositions = await Cesium.sampleTerrainMostDetailed(this.viewer.terrainProvider, batch, true);

            // create points from the updated coordinates that have the terrain elevation added.
            for (let i=0; i<batch.length; i++) {
                let color = Cesium.Color.PINK;
                if ((i % 2) === 0) {
                    color = Cesium.Color.CYAN;
                }
                let pt = updatedPositions[i];
                this.points.add({
                    id: total,
                    position: Cesium.Cartesian3.fromRadians(pt.longitude, pt.latitude, pt.height),
                    pixelSize: this.point_size,
                    color: color,
                    scaleByDistance: scalar,
                });
                total += 1;
            }
        }
    }

    async generatePoints(n_points=1000, point_size=4) {

        async function generatePointsBatch(container, n=1000) {
            for (let i = 0; i < n; i++) {
                let color = Cesium.Color.PINK;
                if ((i % 2) === 0) {
                    color = Cesium.Color.CYAN;
                }
                container.points.add({
                    id: i,
                    // https://cesium.com/learn/cesiumjs/ref-doc/Cartesian3.html#.fromDegrees
                    position: Cesium.Cartesian3.fromDegrees(
                        randomCoordinateJitter(0.0, 180.0),  //longitude
                        randomCoordinateJitter(0.0, 90.0),   //latitude
                        0,//randomCoordinateJitter(10.0, 10.0), //elevation, m
                    ),
                    pixelSize: container.point_size,
                    color: color,
                    scaleByDistance: scalar,
                    heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
                });
            }
        }

        this.n_points = n_points;
        this.point_size = point_size;
        // https://cesium.com/learn/ion-sdk/ref-doc/NearFarScalar.html
        const scalar = new Cesium.NearFarScalar(1.5e2, 2, 8.0e6, 0.2);
        let i = 0;
        let batch_size = 50000;
        if (this.n_points < batch_size) {
            batch_size = this.n_points;
        }
        while (i < this.n_points) {
            if (this.n_points - i < batch_size) {
                batch_size = this.n_points - i;
            }
            generatePointsBatch(this, batch_size);
            i += batch_size;
            //await sleep(1);
        }
    }

    async doCreatePoints() {
        const t0 = new Date().getTime();
        console.log(`start generating`);
        // Generating points is fast, but computing their elevation is fairly slow at a couple seconds per batch of 1000.
        //await this.generatePointsAtTerrain(10000);
        await this.generatePoints(1000000);
        const t1 = new Date().getTime();
        console.log(`done generating. Took ${(t1-t0)/1000} seconds`);
    }
}


content = new CView("cesiumContainer");
setTimeout(function() {
    content.doCreatePoints().then(content.enableTracking());
}, 1000);