Skip to content

Commit

Permalink
fix: improve brush selection (#217)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
flekschas authored Feb 13, 2025
1 parent 521f0e9 commit 0b3ce0b
Show file tree
Hide file tree
Showing 4 changed files with 97 additions and 32 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 1.13.2

- 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.
- Fix: Smooth the brush normal to avoid jitter

## 1.13.1

- Fix: an issue where new colors wouldn't be set properly ([#214](https://github.com/flekschas/regl-scatterplot/issues/214))
Expand Down
27 changes: 11 additions & 16 deletions src/lasso-manager/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
createLongPressOutAnimations,
} from './create-long-press-animations.js';
import createLongPressElements from './create-long-press-elements.js';
import { exponentialMovingAverage } from './utils.js';

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

Expand Down Expand Up @@ -496,9 +497,8 @@ export const createLasso = (
const N = lassoBrushCenterPos.length;

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

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

// Smoothing the current normal
const d = l2PointDist(point[0], point[1], prevPoint[0], prevPoint[1]);
const pd = l2PointDist(
prevPoint[0],
prevPoint[1],
prevPrevPoint[0],
prevPrevPoint[1],
);
const easing = Math.max(0, Math.min(1, 2 / 3 / (pd / d)));
nx = easing * nx + (1 - easing) * pnx;
ny = easing * ny + (1 - easing) * pny;
const nextRawBrushNormals = [...lassoBrushNormals, [nx, ny]];

// However, to avoid jittery lines we're smoothing the normal
[nx, ny] = exponentialMovingAverage(nextRawBrushNormals, 1, 10);

const [pnx, pny] = lassoBrushNormals.at(-1);

const pnx2 = (nx + pnx) / 2;
const pny2 = (ny + pny) / 2;
Expand Down
40 changes: 40 additions & 0 deletions src/lasso-manager/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Calculates exponential moving average of 2D points
* @param {[number, number][]} values - Array of numbers to average
* @param {number} halfLife - Number of steps after which weight becomes half
* @param {number} windowSize - Maximum number of previous values to consider
* @returns {number} The exponential moving average
*/
export const exponentialMovingAverage = (values, halfLife, windowSize) => {
if (values.length === 0) {
return 0;
}

if (values.length === 1) {
return values[0];
}

// Calculate decay factor from `halfLife` such that weight = 0.5 when the
// step is `halfLife`
const decayBase = 2 ** (-1 / halfLife);

// Limit to window size
const startIdx = Math.max(0, values.length - windowSize);
const relevantValues = values.slice(startIdx);

let weightedSumX = 0;
let weightedSumY = 0;
let weightSum = 0;

// Calculate weighted sum starting from most recent value
for (let i = relevantValues.length - 1; i >= 0; i--) {
const steps = relevantValues.length - 1 - i;
const weight = decayBase ** steps;

weightedSumX += relevantValues[i][0] * weight;
weightedSumY += relevantValues[i][1] * weight;
weightSum += weight;
}

return [weightedSumX / weightSum, weightedSumY / weightSum];
};
57 changes: 41 additions & 16 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,28 +238,53 @@ export const isNormFloat = (x) => x >= 0 && x <= 1;
export const isNormFloatArray = (a) => Array.isArray(a) && a.every(isNormFloat);

/**
* From: https://wrf.ecse.rpi.edu//Research/Short_Notes/pnpoly.html
* @param {Array} point Tuple of the form `[x,y]` to be tested.
* @param {Array} polygon 1D list of vertices defining the polygon.
* @return {boolean} If `true` point lies within the polygon.
* Computes the cross product to determine the orientation of three points
* @param {number} x1 X-coordinate of first point
* @param {number} y1 Y-coordinate of first point
* @param {number} x2 X-coordinate of second point
* @param {number} y2 Y-coordinate of second point
* @param {number} px X-coordinate of test point
* @param {number} py Y-coordinate of test point
* @return {number} Positive if counterclockwise, negative if clockwise
*/
function crossProduct(x1, y1, x2, y2, px, py) {
return (x2 - x1) * (py - y1) - (px - x1) * (y2 - y1);
}

/**
* Determines if a point lies within a polygon using the non-zero winding rule.
* This handles self-intersecting polygons and overlapping areas correctly.
* @param {Array} polygon 1D list of vertices defining the polygon [x1,y1,x2,y2,...]
* @param {Array} point Tuple of the form [x,y] to be tested
* @return {boolean} True if point lies within the polygon
*/
export const isPointInPolygon = (polygon, [px, py] = []) => {
let x1;
let y1;
let x2;
let y2;
let isWithin = false;
let winding = 0;

for (let i = 0, j = polygon.length - 2; i < polygon.length; i += 2) {
x1 = polygon[i];
y1 = polygon[i + 1];
x2 = polygon[j];
y2 = polygon[j + 1];
if (y1 > py !== y2 > py && px < ((x2 - x1) * (py - y1)) / (y2 - y1) + x1) {
isWithin = !isWithin;
const x1 = polygon[i];
const y1 = polygon[i + 1];
const x2 = polygon[j];
const y2 = polygon[j + 1];

if (y1 <= py) {
if (y2 > py) {
const orientation = crossProduct(x1, y1, x2, y2, px, py);
if (orientation > 0) {
winding++;
}
}
} else if (y2 <= py) {
const orientation = crossProduct(x1, y1, x2, y2, px, py);
if (orientation < 0) {
winding--;
}
}

j = i;
}
return isWithin;

return winding !== 0;
};

/**
Expand Down

0 comments on commit 0b3ce0b

Please sign in to comment.