Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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! 🙇‍♂️

<a name="property-point-order" href="#property-point-order">#</a> <b>pointOrder:</b>

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.

<a name="property-point-conntection-by" href="#property-point-conntection-by">#</a> <b>pointConnectionColorBy, pointConnectionOpacityBy, and pointConnectionSizeBy:</b>

In addition to the properties understood by [`colorBy`, etc.](#property-by),
Expand Down
1 change: 1 addition & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
124 changes: 120 additions & 4 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)));
Expand Down Expand Up @@ -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();
}
Expand All @@ -2097,10 +2193,11 @@ const createScatterplot = (
});

if (!preventFilterReset) {
computePointOrderIndex();
normalPointsIndexBuffer({
usage: 'static',
type: 'float',
data: createPointIndex(numPoints),
data: getEffectivePointIndex(numPoints),
});
}

Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -3522,6 +3630,10 @@ const createScatterplot = (
return sizeBy;
}

if (property === 'pointOrder') {
return pointOrder !== null ? [...pointOrder] : null;
}

if (property === 'deselectOnDblClick') {
return deselectOnDblClick;
}
Expand Down Expand Up @@ -3908,6 +4020,10 @@ const createScatterplot = (
setSizeBy(properties.sizeBy);
}

if (properties.pointOrder !== undefined) {
setPointOrder(properties.pointOrder);
}

if (properties.opacity !== undefined) {
setOpacity(properties.opacity);
}
Expand Down
1 change: 1 addition & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions tests/get-set.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Loading