Skip to content

Commit 0b3ce0b

Browse files
authored
fix: improve brush selection (#217)
* Fix: replace the even-odd rule based with the non-zero winding rule for `isPointInPolygon()` * feat: smooth normal to avoid jittery line * docs: Update changelog
1 parent 521f0e9 commit 0b3ce0b

File tree

4 files changed

+97
-32
lines changed

4 files changed

+97
-32
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 1.13.2
2+
3+
- Fix: replace the even-odd rule based with the non-zero winding rule for `isPointInPolygon()` to correctly handle overlapping/looping selections. Previosuly points that would fall within the overlapping area would falsely be excluded from the selection instead of being included.
4+
- Fix: Smooth the brush normal to avoid jitter
5+
16
## 1.13.1
27

38
- Fix: an issue where new colors wouldn't be set properly ([#214](https://github.com/flekschas/regl-scatterplot/issues/214))

src/lasso-manager/index.js

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
createLongPressOutAnimations,
3333
} from './create-long-press-animations.js';
3434
import createLongPressElements from './create-long-press-elements.js';
35+
import { exponentialMovingAverage } from './utils.js';
3536

3637
const ifNotNull = (v, alternative = null) => (v === null ? alternative : v);
3738

@@ -496,9 +497,8 @@ export const createLasso = (
496497
const N = lassoBrushCenterPos.length;
497498

498499
if (N === 1) {
499-
// In this special case, we have to add the initial two points upon and
500-
// addition of the second point because when the first brush point was set
501-
// the direction is undefined.
500+
// In this special case, we have to add the initial two points and normal
501+
// because when the first brush point was set the direction is undefined.
502502
const pl = [prevPoint[0] + nx, prevPoint[1] + ny];
503503
const pr = [prevPoint[0] - nx, prevPoint[1] - ny];
504504

@@ -508,20 +508,15 @@ export const createLasso = (
508508
} else {
509509
// In this case, we have to adjust the previous normal to create a proper
510510
// line join by taking the middle between the current and previous normal.
511-
const prevPrevPoint = lassoBrushCenterPos.at(-2);
512-
const [pnx, pny] = lassoBrushNormals.at(-1);
511+
// const prevPrevPoint = lassoBrushCenterPos.at(-2);
512+
[nx, ny] = getBrushNormal(point, prevPoint, width);
513513

514-
// Smoothing the current normal
515-
const d = l2PointDist(point[0], point[1], prevPoint[0], prevPoint[1]);
516-
const pd = l2PointDist(
517-
prevPoint[0],
518-
prevPoint[1],
519-
prevPrevPoint[0],
520-
prevPrevPoint[1],
521-
);
522-
const easing = Math.max(0, Math.min(1, 2 / 3 / (pd / d)));
523-
nx = easing * nx + (1 - easing) * pnx;
524-
ny = easing * ny + (1 - easing) * pny;
514+
const nextRawBrushNormals = [...lassoBrushNormals, [nx, ny]];
515+
516+
// However, to avoid jittery lines we're smoothing the normal
517+
[nx, ny] = exponentialMovingAverage(nextRawBrushNormals, 1, 10);
518+
519+
const [pnx, pny] = lassoBrushNormals.at(-1);
525520

526521
const pnx2 = (nx + pnx) / 2;
527522
const pny2 = (ny + pny) / 2;

src/lasso-manager/utils.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Calculates exponential moving average of 2D points
3+
* @param {[number, number][]} values - Array of numbers to average
4+
* @param {number} halfLife - Number of steps after which weight becomes half
5+
* @param {number} windowSize - Maximum number of previous values to consider
6+
* @returns {number} The exponential moving average
7+
*/
8+
export const exponentialMovingAverage = (values, halfLife, windowSize) => {
9+
if (values.length === 0) {
10+
return 0;
11+
}
12+
13+
if (values.length === 1) {
14+
return values[0];
15+
}
16+
17+
// Calculate decay factor from `halfLife` such that weight = 0.5 when the
18+
// step is `halfLife`
19+
const decayBase = 2 ** (-1 / halfLife);
20+
21+
// Limit to window size
22+
const startIdx = Math.max(0, values.length - windowSize);
23+
const relevantValues = values.slice(startIdx);
24+
25+
let weightedSumX = 0;
26+
let weightedSumY = 0;
27+
let weightSum = 0;
28+
29+
// Calculate weighted sum starting from most recent value
30+
for (let i = relevantValues.length - 1; i >= 0; i--) {
31+
const steps = relevantValues.length - 1 - i;
32+
const weight = decayBase ** steps;
33+
34+
weightedSumX += relevantValues[i][0] * weight;
35+
weightedSumY += relevantValues[i][1] * weight;
36+
weightSum += weight;
37+
}
38+
39+
return [weightedSumX / weightSum, weightedSumY / weightSum];
40+
};

src/utils.js

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -238,28 +238,53 @@ export const isNormFloat = (x) => x >= 0 && x <= 1;
238238
export const isNormFloatArray = (a) => Array.isArray(a) && a.every(isNormFloat);
239239

240240
/**
241-
* From: https://wrf.ecse.rpi.edu//Research/Short_Notes/pnpoly.html
242-
* @param {Array} point Tuple of the form `[x,y]` to be tested.
243-
* @param {Array} polygon 1D list of vertices defining the polygon.
244-
* @return {boolean} If `true` point lies within the polygon.
241+
* Computes the cross product to determine the orientation of three points
242+
* @param {number} x1 X-coordinate of first point
243+
* @param {number} y1 Y-coordinate of first point
244+
* @param {number} x2 X-coordinate of second point
245+
* @param {number} y2 Y-coordinate of second point
246+
* @param {number} px X-coordinate of test point
247+
* @param {number} py Y-coordinate of test point
248+
* @return {number} Positive if counterclockwise, negative if clockwise
249+
*/
250+
function crossProduct(x1, y1, x2, y2, px, py) {
251+
return (x2 - x1) * (py - y1) - (px - x1) * (y2 - y1);
252+
}
253+
254+
/**
255+
* Determines if a point lies within a polygon using the non-zero winding rule.
256+
* This handles self-intersecting polygons and overlapping areas correctly.
257+
* @param {Array} polygon 1D list of vertices defining the polygon [x1,y1,x2,y2,...]
258+
* @param {Array} point Tuple of the form [x,y] to be tested
259+
* @return {boolean} True if point lies within the polygon
245260
*/
246261
export const isPointInPolygon = (polygon, [px, py] = []) => {
247-
let x1;
248-
let y1;
249-
let x2;
250-
let y2;
251-
let isWithin = false;
262+
let winding = 0;
263+
252264
for (let i = 0, j = polygon.length - 2; i < polygon.length; i += 2) {
253-
x1 = polygon[i];
254-
y1 = polygon[i + 1];
255-
x2 = polygon[j];
256-
y2 = polygon[j + 1];
257-
if (y1 > py !== y2 > py && px < ((x2 - x1) * (py - y1)) / (y2 - y1) + x1) {
258-
isWithin = !isWithin;
265+
const x1 = polygon[i];
266+
const y1 = polygon[i + 1];
267+
const x2 = polygon[j];
268+
const y2 = polygon[j + 1];
269+
270+
if (y1 <= py) {
271+
if (y2 > py) {
272+
const orientation = crossProduct(x1, y1, x2, y2, px, py);
273+
if (orientation > 0) {
274+
winding++;
275+
}
276+
}
277+
} else if (y2 <= py) {
278+
const orientation = crossProduct(x1, y1, x2, y2, px, py);
279+
if (orientation < 0) {
280+
winding--;
281+
}
259282
}
283+
260284
j = i;
261285
}
262-
return isWithin;
286+
287+
return winding !== 0;
263288
};
264289

265290
/**

0 commit comments

Comments
 (0)