diff --git a/CHANGELOG.md b/CHANGELOG.md
index 799864e..f2b18d9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 1.16.0
+
+- Feat: Added a `pointOrder` property that controls point draw order so users can ensure specific points (e.g., outliers) render on top without pre-sorting their data.
+
## 1.15.0
- Feat: add programmatic lasso selection API via new `lassoSelect()` method that accept a polygon in either data or GL space. This enables automated point selection without manual interaction. Supports `merge` and `remove` options. Note, vertices in data space requires `xScale` and `yScale` to be defined.
diff --git a/README.md b/README.md
index 2b467f1..c90ad8e 100644
--- a/README.md
+++ b/README.md
@@ -843,6 +843,7 @@ can be read and written via [`scatterplot.get()`](#scatterplot.get) and [`scatte
| colorBy | string | `null` | See [data encoding](#property-by) | `true` | `true` |
| sizeBy | string | `null` | See [data encoding](#property-by) | `true` | `true` |
| opacityBy | string | `null` | See [data encoding](#property-by) | `true` | `true` |
+| pointOrder | int[] | `null` | Array of point indices defining draw order | `true` | `true` |
| deselectOnDblClick | boolean | `true` | | `true` | `false` |
| deselectOnEscape | boolean | `true` | | `true` | `false` |
| opacity | float | `1` | Must be in ]0, 1] | `true` | `false` |
@@ -968,6 +969,20 @@ example go to [dynamic-opacity.html](https://flekschas.github.io/regl-scatterplo
The implementation is an extension of [Ricky Reusser's awesome notebook](https://observablehq.com/@rreusser/selecting-the-right-opacity-for-2d-point-clouds).
Huuuge kudos Ricky! 🙇♂️
+# pointOrder:
+
+By default, points are drawn in the order they appear in the input data. Since depth testing is disabled, later-drawn points render on top of earlier ones. To control the draw order, set `pointOrder` to an array of point indices. Points are drawn in array order, so the last index renders on top. Any indices not included in the array are appended in sequential order.
+
+```javascript
+// Ensure point 3 is drawn on top
+scatterplot.set({ pointOrder: [0, 1, 2, 4, 3] });
+
+// Reset to default input order
+scatterplot.set({ pointOrder: null });
+```
+
+The point order is respected when [filtering](#scatterplot.filter), and resets automatically when `draw()` is called with a different number of points.
+
# pointConnectionColorBy, pointConnectionOpacityBy, and pointConnectionSizeBy:
In addition to the properties understood by [`colorBy`, etc.](#property-by),
diff --git a/src/constants.js b/src/constants.js
index bed43e5..32122e9 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -109,6 +109,7 @@ export const DEFAULT_POINT_SIZE = 6;
export const DEFAULT_POINT_SIZE_SELECTED = 2;
export const DEFAULT_POINT_OUTLINE_WIDTH = 2;
export const DEFAULT_SIZE_BY = null;
+export const DEFAULT_POINT_ORDER = null;
export const DEFAULT_POINT_CONNECTION_SIZE = 2;
export const DEFAULT_POINT_CONNECTION_SIZE_ACTIVE = 2;
export const DEFAULT_POINT_CONNECTION_SIZE_BY = null;
diff --git a/src/index.js b/src/index.js
index 0bf4ff5..d0c07e0 100644
--- a/src/index.js
+++ b/src/index.js
@@ -75,6 +75,7 @@ import {
DEFAULT_POINT_CONNECTION_SIZE,
DEFAULT_POINT_CONNECTION_SIZE_ACTIVE,
DEFAULT_POINT_CONNECTION_SIZE_BY,
+ DEFAULT_POINT_ORDER,
DEFAULT_POINT_OUTLINE_WIDTH,
DEFAULT_POINT_SCALE_MODE,
DEFAULT_POINT_SIZE,
@@ -313,6 +314,7 @@ const createScatterplot = (
opacityInactiveMax = DEFAULT_OPACITY_INACTIVE_MAX,
opacityInactiveScale = DEFAULT_OPACITY_INACTIVE_SCALE,
sizeBy = DEFAULT_SIZE_BY,
+ pointOrder = DEFAULT_POINT_ORDER,
pointScaleMode = DEFAULT_POINT_SCALE_MODE,
height = DEFAULT_HEIGHT,
width = DEFAULT_WIDTH,
@@ -521,6 +523,8 @@ const createScatterplot = (
let normalPointsIndexBuffer; // Buffer holding the indices pointing to the correct texel
let selectedPointsIndexBuffer; // Used for pointing to the selected texels
let hoveredPointIndexBuffer; // Used for pointing to the hovered texels
+ let pointOrderIndices = null; // Validated ordered point indices (Int32Array or null)
+ let pointOrderIndex = null; // Float32Array of tex coords in pointOrder sequence
let cameraZoomTargetStart; // Stores the start (i.e., current) camera target for zooming
let cameraZoomTargetEnd; // Stores the end camera target for zooming
@@ -1600,6 +1604,44 @@ const createScatterplot = (
const setSizeBy = (type) => {
sizeBy = getEncodingType(type, DEFAULT_SIZE_BY);
};
+ const setPointOrder = (newPointOrder) => {
+ if (newPointOrder === null || newPointOrder === undefined) {
+ pointOrder = null;
+ } else if (Array.isArray(newPointOrder)) {
+ pointOrder = newPointOrder;
+ } else {
+ return;
+ }
+
+ if (isPointsDrawn) {
+ computePointOrderIndex();
+ if (isPointsFiltered) {
+ // Re-apply filter respecting the new point order
+ const filteredPointsBuffer = [];
+ if (pointOrderIndices !== null) {
+ for (let i = 0; i < pointOrderIndices.length; i++) {
+ if (filteredPointsSet.has(pointOrderIndices[i])) {
+ filteredPointsBuffer.push.apply(
+ filteredPointsBuffer,
+ indexToStateTexCoord(pointOrderIndices[i]),
+ );
+ }
+ }
+ } else {
+ const sortedFiltered = insertionSort([...filteredPointsSet]);
+ for (const idx of sortedFiltered) {
+ filteredPointsBuffer.push.apply(
+ filteredPointsBuffer,
+ indexToStateTexCoord(idx),
+ );
+ }
+ }
+ normalPointsIndexBuffer.subdata(filteredPointsBuffer);
+ } else {
+ normalPointsIndexBuffer.subdata(getEffectivePointIndex(numPoints));
+ }
+ }
+ };
const setPointConnectionColorBy = (type) => {
pointConnectionColorBy = getEncodingType(
type,
@@ -1994,6 +2036,51 @@ const createScatterplot = (
return index;
};
+ const computePointOrderIndex = () => {
+ if (pointOrder === null) {
+ pointOrderIndices = null;
+ pointOrderIndex = null;
+ return;
+ }
+
+ const includedSet = new Set();
+ const orderedIndices = [];
+
+ for (let i = 0; i < pointOrder.length; i++) {
+ const idx = pointOrder[i];
+ if (
+ Number.isFinite(idx) &&
+ idx >= 0 &&
+ idx < numPoints &&
+ !includedSet.has(idx)
+ ) {
+ orderedIndices.push(idx);
+ includedSet.add(idx);
+ }
+ }
+
+ // Append any missing indices in sequential order
+ for (let i = 0; i < numPoints; i++) {
+ if (!includedSet.has(i)) {
+ orderedIndices.push(i);
+ }
+ }
+
+ pointOrderIndices = orderedIndices;
+
+ pointOrderIndex = new Float32Array(orderedIndices.length * 2);
+ let j = 0;
+ for (let i = 0; i < orderedIndices.length; i++) {
+ const texCoord = indexToStateTexCoord(orderedIndices[i]);
+ pointOrderIndex[j] = texCoord[0];
+ pointOrderIndex[j + 1] = texCoord[1];
+ j += 2;
+ }
+ };
+
+ const getEffectivePointIndex = (count) =>
+ pointOrderIndex !== null ? pointOrderIndex : createPointIndex(count);
+
const createStateTexture = (newPoints, dataTypes = {}) => {
const numNewPoints = newPoints.length;
stateTexRes = Math.max(2, Math.ceil(Math.sqrt(numNewPoints)));
@@ -2085,9 +2172,18 @@ const createScatterplot = (
const preventFilterReset =
options?.preventFilterReset && newPoints.length === numPoints;
+ const prevNumPoints = numPoints;
numPoints = newPoints.length;
numPointsInView = numPoints;
+ // Reset pointOrder when re-drawing with different-length data
+ // (ordering becomes meaningless). Skip on first draw (prevNumPoints === 0).
+ if (prevNumPoints > 0 && numPoints !== prevNumPoints) {
+ pointOrder = null;
+ pointOrderIndices = null;
+ pointOrderIndex = null;
+ }
+
if (stateTex) {
stateTex.destroy();
}
@@ -2097,10 +2193,11 @@ const createScatterplot = (
});
if (!preventFilterReset) {
+ computePointOrderIndex();
normalPointsIndexBuffer({
usage: 'static',
type: 'float',
- data: createPointIndex(numPoints),
+ data: getEffectivePointIndex(numPoints),
});
}
@@ -2341,7 +2438,7 @@ const createScatterplot = (
const unfilter = ({ preventEvent = false } = {}) => {
isPointsFiltered = false;
filteredPointsSet.clear();
- normalPointsIndexBuffer.subdata(createPointIndex(numPoints));
+ normalPointsIndexBuffer.subdata(getEffectivePointIndex(numPoints));
return new Promise((resolve) => {
const finish = () => {
@@ -2400,9 +2497,20 @@ const createScatterplot = (
}
}
- const sortedFilteredPoints = insertionSort([...filteredPoints]);
+ let orderedFilteredPoints;
+ if (pointOrderIndices !== null) {
+ // Maintain the custom point order within the filtered set
+ orderedFilteredPoints = [];
+ for (let i = 0; i < pointOrderIndices.length; i++) {
+ if (filteredPointsSet.has(pointOrderIndices[i])) {
+ orderedFilteredPoints.push(pointOrderIndices[i]);
+ }
+ }
+ } else {
+ orderedFilteredPoints = insertionSort([...filteredPoints]);
+ }
- for (const pointIdx of sortedFilteredPoints) {
+ for (const pointIdx of orderedFilteredPoints) {
filteredPointsBuffer.push.apply(
filteredPointsBuffer,
indexToStateTexCoord(pointIdx),
@@ -3522,6 +3630,10 @@ const createScatterplot = (
return sizeBy;
}
+ if (property === 'pointOrder') {
+ return pointOrder !== null ? [...pointOrder] : null;
+ }
+
if (property === 'deselectOnDblClick') {
return deselectOnDblClick;
}
@@ -3908,6 +4020,10 @@ const createScatterplot = (
setSizeBy(properties.sizeBy);
}
+ if (properties.pointOrder !== undefined) {
+ setPointOrder(properties.pointOrder);
+ }
+
if (properties.opacity !== undefined) {
setOpacity(properties.opacity);
}
diff --git a/src/types.d.ts b/src/types.d.ts
index 4f08cb5..87b04d5 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -157,6 +157,7 @@ interface BaseOptions {
colorBy: null | DataEncoding;
sizeBy: null | DataEncoding;
opacityBy: null | DataEncoding;
+ pointOrder: null | number[];
xScale: null | Scale;
yScale: null | Scale;
pointScaleMode: PointScaleMode;
diff --git a/tests/get-set.test.js b/tests/get-set.test.js
index 47d8079..8cf8589 100644
--- a/tests/get-set.test.js
+++ b/tests/get-set.test.js
@@ -830,3 +830,34 @@ test('get() and set() lasso types', async () => {
expect(scatterplot.get('lassoType')).toBe('freeform');
});
+
+test('set({ pointOrder }) and get("pointOrder")', async () => {
+ const scatterplot = createScatterplot({ canvas: createCanvas() });
+
+ expect(scatterplot.get('pointOrder')).toBe(null);
+
+ await scatterplot.draw([
+ [0, 0],
+ [1, 1],
+ [2, 2],
+ [3, 3],
+ ]);
+
+ scatterplot.set({ pointOrder: [3, 1, 0, 2] });
+ expect(scatterplot.get('pointOrder')).toEqual([3, 1, 0, 2]);
+
+ // Returns a copy, not a reference
+ const order = scatterplot.get('pointOrder');
+ order[0] = 999;
+ expect(scatterplot.get('pointOrder')).toEqual([3, 1, 0, 2]);
+
+ // Reset to null
+ scatterplot.set({ pointOrder: null });
+ expect(scatterplot.get('pointOrder')).toBe(null);
+
+ // Invalid values are ignored
+ scatterplot.set({ pointOrder: 'invalid' });
+ expect(scatterplot.get('pointOrder')).toBe(null);
+
+ scatterplot.destroy();
+});
diff --git a/tests/methods.test.js b/tests/methods.test.js
index dbb006c..2351cbf 100644
--- a/tests/methods.test.js
+++ b/tests/methods.test.js
@@ -1222,6 +1222,169 @@ test('pointScaleMode', async () => {
scatterplot.destroy();
});
+test('pointOrder controls draw order', async () => {
+ const dim = 64;
+ const scatterplot = createScatterplot({
+ canvas: createCanvas(dim, dim),
+ width: dim,
+ height: dim,
+ pointSize: dim,
+ opacity: 1,
+ pointColor: ['#ff0000', '#0000ff'],
+ colorBy: 'valueA',
+ });
+
+ // Two overlapping points at center: point 0 = red, point 1 = blue
+ await scatterplot.draw([
+ [0, 0, 0],
+ [0, 0, 1],
+ ]);
+ await nextAnimationFrame();
+
+ // Default order: point 1 (blue) drawn last, so it is on top
+ let image = scatterplot.export();
+ const cx = Math.floor(dim / 2);
+ const cy = Math.floor(dim / 2);
+ let pixelIdx = (cy * dim + cx) * 4;
+ let r = image.data[pixelIdx];
+ let b = image.data[pixelIdx + 2];
+ expect(b).toBeGreaterThan(r); // Blue on top
+
+ // Reverse order: point 0 (red) drawn last, so it is on top
+ await scatterplot.set({ pointOrder: [1, 0] });
+ await nextAnimationFrame();
+
+ image = scatterplot.export();
+ pixelIdx = (cy * dim + cx) * 4;
+ r = image.data[pixelIdx];
+ b = image.data[pixelIdx + 2];
+ expect(r).toBeGreaterThan(b); // Red on top
+
+ scatterplot.destroy();
+});
+
+test('pointOrder with filter preserves order within filtered set', async () => {
+ const scatterplot = createScatterplot({ canvas: createCanvas() });
+
+ const points = [
+ [0, 0],
+ [1, 1],
+ [2, 2],
+ [3, 3],
+ [4, 4],
+ ];
+
+ await scatterplot.draw(points);
+
+ // Set a custom order
+ scatterplot.set({ pointOrder: [4, 2, 0, 1, 3] });
+
+ // Filter to a subset
+ await scatterplot.filter([0, 2, 4]);
+ await wait(0);
+
+ expect(scatterplot.get('isPointsFiltered')).toBe(true);
+ expect(
+ hasSameElements(scatterplot.get('filteredPoints'), [0, 2, 4])
+ ).toBe(true);
+
+ // Unfilter restores full set
+ await scatterplot.unfilter();
+ await wait(0);
+
+ expect(scatterplot.get('isPointsFiltered')).toBe(false);
+ expect(scatterplot.get('filteredPoints').length).toBe(5);
+
+ scatterplot.destroy();
+});
+
+test('pointOrder resets when draw() is called with different-length data', async () => {
+ const scatterplot = createScatterplot({ canvas: createCanvas() });
+
+ await scatterplot.draw([
+ [0, 0],
+ [1, 1],
+ [2, 2],
+ ]);
+
+ scatterplot.set({ pointOrder: [2, 0, 1] });
+ expect(scatterplot.get('pointOrder')).toEqual([2, 0, 1]);
+
+ // Draw with different number of points
+ await scatterplot.draw([
+ [0, 0],
+ [1, 1],
+ ]);
+
+ expect(scatterplot.get('pointOrder')).toBe(null);
+
+ scatterplot.destroy();
+});
+
+test('pointOrder is preserved when draw() is called with same-length data', async () => {
+ const scatterplot = createScatterplot({ canvas: createCanvas() });
+
+ await scatterplot.draw([
+ [0, 0],
+ [1, 1],
+ [2, 2],
+ ]);
+
+ scatterplot.set({ pointOrder: [2, 0, 1] });
+ expect(scatterplot.get('pointOrder')).toEqual([2, 0, 1]);
+
+ // Draw with same number of points
+ await scatterplot.draw([
+ [3, 3],
+ [4, 4],
+ [5, 5],
+ ]);
+
+ expect(scatterplot.get('pointOrder')).toEqual([2, 0, 1]);
+
+ scatterplot.destroy();
+});
+
+test('pointOrder via constructor', async () => {
+ const scatterplot = createScatterplot({
+ canvas: createCanvas(),
+ pointOrder: [2, 0, 1],
+ });
+
+ expect(scatterplot.get('pointOrder')).toEqual([2, 0, 1]);
+
+ await scatterplot.draw([
+ [0, 0],
+ [1, 1],
+ [2, 2],
+ ]);
+
+ // pointOrder should still be set after draw
+ expect(scatterplot.get('pointOrder')).toEqual([2, 0, 1]);
+
+ scatterplot.destroy();
+});
+
+test('pointOrder with partial array appends missing indices', async () => {
+ const scatterplot = createScatterplot({ canvas: createCanvas() });
+
+ await scatterplot.draw([
+ [0, 0],
+ [1, 1],
+ [2, 2],
+ [3, 3],
+ ]);
+
+ // Only specify 2 of 4 points
+ scatterplot.set({ pointOrder: [3, 1] });
+ expect(scatterplot.get('pointOrder')).toEqual([3, 1]);
+
+ // All 4 points should still be drawn (missing indices appended)
+ expect(scatterplot.get('filteredPoints').length).toBe(4);
+
+ scatterplot.destroy();
+});
+
test('export()', async () => {
const dim = 10;
const scatterplot = createScatterplot({