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
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