From aa34ac0048e588fd4d8bb7124ebc35cbe1329ac9 Mon Sep 17 00:00:00 2001 From: Fritz Lekschas Date: Fri, 8 May 2026 15:03:39 -0400 Subject: [PATCH 1/3] feat: allow controlling point order Fixes #162 --- src/constants.js | 1 + src/index.js | 124 ++++++++++++++++++++++++++++++-- src/types.d.ts | 1 + tests/get-set.test.js | 31 ++++++++ tests/methods.test.js | 163 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 316 insertions(+), 4 deletions(-) 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..8a4bd2d 100644 --- a/src/index.js +++ b/src/index.js @@ -85,6 +85,7 @@ import { DEFAULT_SHOW_POINT_CONNECTIONS, DEFAULT_SHOW_RETICLE, DEFAULT_SIZE_BY, + DEFAULT_POINT_ORDER, DEFAULT_SPATIAL_INDEX_USE_WORKER, DEFAULT_TARGET, DEFAULT_VIEW, @@ -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({ From 600a285abb9f1b33fa54fc19d6882dca7ec24c0f Mon Sep 17 00:00:00 2001 From: Fritz Lekschas Date: Fri, 8 May 2026 15:09:05 -0400 Subject: [PATCH 2/3] chore: sort --- src/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index 8a4bd2d..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, @@ -85,7 +86,6 @@ import { DEFAULT_SHOW_POINT_CONNECTIONS, DEFAULT_SHOW_RETICLE, DEFAULT_SIZE_BY, - DEFAULT_POINT_ORDER, DEFAULT_SPATIAL_INDEX_USE_WORKER, DEFAULT_TARGET, DEFAULT_VIEW, From 00c22bcc29f4fde6463a0bb59e18977f9ba928dc Mon Sep 17 00:00:00 2001 From: Fritz Lekschas Date: Fri, 8 May 2026 15:15:13 -0400 Subject: [PATCH 3/3] docs: document new `pointOrder` --- CHANGELOG.md | 4 ++++ README.md | 15 +++++++++++++++ 2 files changed, 19 insertions(+) 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),