regl-scatterplot
Version:
A WebGL-Powered Scalable Interactive Scatter Plot Library
782 lines (657 loc) • 24 kB
JavaScript
import {
assign,
identity,
l2Norm,
l2PointDist,
nextAnimationFrame,
pipe,
throttleAndDebounce,
withConstructor,
withStaticProperty,
} from '@flekschas/utils';
import {
DEFAULT_BRUSH_SIZE,
DEFAULT_LASSO_MIN_DELAY,
DEFAULT_LASSO_MIN_DIST,
DEFAULT_LASSO_START_INITIATOR_SHOW,
DEFAULT_LASSO_TYPE,
LASSO_HIDE_START_INITIATOR_TIME,
LASSO_SHOW_START_INITIATOR_TIME,
} from './constants.js';
import {
DEFAULT_LASSO_LONG_PRESS_AFTER_EFFECT_TIME,
DEFAULT_LASSO_LONG_PRESS_EFFECT_DELAY,
DEFAULT_LASSO_LONG_PRESS_REVERT_EFFECT_TIME,
DEFAULT_LASSO_LONG_PRESS_TIME,
} from '../constants.js';
import {
createLongPressInAnimations,
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);
let cachedLassoStylesheets;
const getLassoStylesheets = () => {
if (!cachedLassoStylesheets) {
const lassoStyleEl = document.createElement('style');
document.head.appendChild(lassoStyleEl);
cachedLassoStylesheets = lassoStyleEl.sheet;
}
return cachedLassoStylesheets;
};
const addRule = (rule) => {
const lassoStylesheets = getLassoStylesheets();
const currentNumRules = lassoStylesheets.rules.length;
lassoStylesheets.insertRule(rule, currentNumRules);
return currentNumRules;
};
const removeRule = (index) => {
getLassoStylesheets().deleteRule(index);
};
const inAnimation = `${LASSO_SHOW_START_INITIATOR_TIME}ms ease scaleInFadeOut 0s 1 normal backwards`;
const createInAnimationRule = (opacity, scale, rotate) => `
@keyframes scaleInFadeOut {
0% {
opacity: ${opacity};
transform: translate(-50%,-50%) scale(${scale}) rotate(${rotate}deg);
}
10% {
opacity: 1;
transform: translate(-50%,-50%) scale(1) rotate(${rotate + 20}deg);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(0.9) rotate(${rotate + 60}deg);
}
}
`;
let inAnimationRuleIndex = null;
const outAnimation = `${LASSO_HIDE_START_INITIATOR_TIME}ms ease fadeScaleOut 0s 1 normal backwards`;
const createOutAnimationRule = (opacity, scale, rotate) => `
@keyframes fadeScaleOut {
0% {
opacity: ${opacity};
transform: translate(-50%,-50%) scale(${scale}) rotate(${rotate}deg);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(0) rotate(${rotate}deg);
}
}
`;
let outAnimationRuleIndex = null;
export const createLasso = (
element,
{
onDraw: initialOnDraw = identity,
onStart: initialOnStart = identity,
onEnd: initialOnEnd = identity,
enableInitiator:
initialenableInitiator = DEFAULT_LASSO_START_INITIATOR_SHOW,
initiatorParentElement: initialInitiatorParentElement = document.body,
longPressIndicatorParentElement:
initialLongPressIndicatorParentElement = document.body,
minDelay: initialMinDelay = DEFAULT_LASSO_MIN_DELAY,
minDist: initialMinDist = DEFAULT_LASSO_MIN_DIST,
pointNorm: initialPointNorm = identity,
type: initialType = DEFAULT_LASSO_TYPE,
brushSize: initialBrushSize = DEFAULT_BRUSH_SIZE,
} = {},
) => {
let enableInitiator = initialenableInitiator;
let initiatorParentElement = initialInitiatorParentElement;
let longPressIndicatorParentElement = initialLongPressIndicatorParentElement;
let onDraw = initialOnDraw;
let onStart = initialOnStart;
let onEnd = initialOnEnd;
let minDelay = initialMinDelay;
let minDist = initialMinDist;
let pointNorm = initialPointNorm;
let type = initialType;
let brushSize = initialBrushSize;
const initiator = document.createElement('div');
const initiatorId =
Math.random().toString(36).substring(2, 5) +
Math.random().toString(36).substring(2, 5);
initiator.id = `lasso-initiator-${initiatorId}`;
initiator.style.position = 'fixed';
initiator.style.display = 'flex';
initiator.style.justifyContent = 'center';
initiator.style.alignItems = 'center';
initiator.style.zIndex = 99;
initiator.style.width = '4rem';
initiator.style.height = '4rem';
initiator.style.borderRadius = '4rem';
initiator.style.opacity = 0.5;
initiator.style.transform = 'translate(-50%,-50%) scale(0) rotate(0deg)';
const {
longPress,
longPressCircle,
longPressCircleLeft,
longPressCircleRight,
longPressEffect,
} = createLongPressElements();
let isMouseDown = false;
let isLasso = false;
let lassoPos = [];
let lassoPosFlat = [];
let lassoBrushCenterPos = [];
let lassoBrushNormals = [];
let prevMousePos;
let longPressIsStarting = false;
let longPressMainInAnimationRuleIndex = null;
let longPressEffectInAnimationRuleIndex = null;
let longPressCircleLeftInAnimationRuleIndex = null;
let longPressCircleRightInAnimationRuleIndex = null;
let longPressCircleInAnimationRuleIndex = null;
let longPressMainOutAnimationRuleIndex = null;
let longPressEffectOutAnimationRuleIndex = null;
let longPressCircleLeftOutAnimationRuleIndex = null;
let longPressCircleRightOutAnimationRuleIndex = null;
let longPressCircleOutAnimationRuleIndex = null;
const mouseUpHandler = () => {
isMouseDown = false;
};
const getMousePosition = (event) => {
const { left, top } = element.getBoundingClientRect();
return [event.clientX - left, event.clientY - top];
};
window.addEventListener('mouseup', mouseUpHandler);
const resetInitiatorStyle = () => {
initiator.style.opacity = 0.5;
initiator.style.transform = 'translate(-50%,-50%) scale(0) rotate(0deg)';
};
const getCurrentTransformStyle = (node, hasRotated) => {
const computedStyle = getComputedStyle(node);
const opacity = +computedStyle.opacity;
// The css rule `transform: translate(-1, -1) scale(0.5);` is represented as
// `matrix(0.5, 0, 0, 0.5, -1, -1)`
const m = computedStyle.transform.match(/([0-9.-]+)+/g);
const a = +m[0];
const b = +m[1];
const scale = Math.sqrt(a * a + b * b);
let rotate = Math.atan2(b, a) * (180 / Math.PI);
rotate = hasRotated && rotate <= 0 ? 360 + rotate : rotate;
return { opacity, scale, rotate };
};
const showInitiator = (event) => {
if (!enableInitiator || isMouseDown) {
return;
}
const x = event.clientX;
const y = event.clientY;
initiator.style.top = `${y}px`;
initiator.style.left = `${x}px`;
const style = getCurrentTransformStyle(initiator);
const opacity = style.opacity;
const scale = style.scale;
const rotate = style.rotate;
initiator.style.opacity = opacity;
initiator.style.transform = `translate(-50%,-50%) scale(${scale}) rotate(${rotate}deg)`;
initiator.style.animation = 'none';
// See https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Animations/Tips
// why we need to wait for two animation frames
nextAnimationFrame().then(() => {
if (inAnimationRuleIndex !== null) {
removeRule(inAnimationRuleIndex);
}
inAnimationRuleIndex = addRule(
createInAnimationRule(opacity, scale, rotate),
);
initiator.style.animation = inAnimation;
nextAnimationFrame().then(() => {
resetInitiatorStyle();
});
});
};
const hideInitiator = () => {
const { opacity, scale, rotate } = getCurrentTransformStyle(initiator);
initiator.style.opacity = opacity;
initiator.style.transform = `translate(-50%,-50%) scale(${scale}) rotate(${rotate}deg)`;
initiator.style.animation = 'none';
nextAnimationFrame(2).then(() => {
if (outAnimationRuleIndex !== null) {
removeRule(outAnimationRuleIndex);
}
outAnimationRuleIndex = addRule(
createOutAnimationRule(opacity, scale, rotate),
);
initiator.style.animation = outAnimation;
nextAnimationFrame().then(() => {
resetInitiatorStyle();
});
});
};
const showLongPressIndicator = (
x,
y,
{
time = DEFAULT_LASSO_LONG_PRESS_TIME,
extraTime = DEFAULT_LASSO_LONG_PRESS_AFTER_EFFECT_TIME,
delay = DEFAULT_LASSO_LONG_PRESS_EFFECT_DELAY,
} = {
time: DEFAULT_LASSO_LONG_PRESS_TIME,
extraTime: DEFAULT_LASSO_LONG_PRESS_AFTER_EFFECT_TIME,
delay: DEFAULT_LASSO_LONG_PRESS_EFFECT_DELAY,
},
) => {
longPressIsStarting = true;
const mainStyle = getComputedStyle(longPress);
longPress.style.color = mainStyle.color;
longPress.style.top = `${y}px`;
longPress.style.left = `${x}px`;
longPress.style.animation = 'none';
const circleStyle = getComputedStyle(longPressCircle);
longPressCircle.style.clipPath = circleStyle.clipPath;
longPressCircle.style.opacity = circleStyle.opacity;
longPressCircle.style.animation = 'none';
const effectStyle = getCurrentTransformStyle(longPressEffect);
longPressEffect.style.opacity = effectStyle.opacity;
longPressEffect.style.transform = `scale(${effectStyle.scale})`;
longPressEffect.style.animation = 'none';
const circleLeftStyle = getCurrentTransformStyle(longPressCircleLeft);
longPressCircleLeft.style.transform = `rotate(${circleLeftStyle.rotate}deg)`;
longPressCircleLeft.style.animation = 'none';
const circleRightStyle = getCurrentTransformStyle(longPressCircleRight);
longPressCircleRight.style.transform = `rotate(${circleRightStyle.rotate}deg)`;
longPressCircleRight.style.animation = 'none';
nextAnimationFrame().then(() => {
if (!longPressIsStarting) {
return;
}
if (longPressCircleInAnimationRuleIndex !== null) {
removeRule(longPressCircleInAnimationRuleIndex);
}
if (longPressCircleRightInAnimationRuleIndex !== null) {
removeRule(longPressCircleRightInAnimationRuleIndex);
}
if (longPressCircleLeftInAnimationRuleIndex !== null) {
removeRule(longPressCircleLeftInAnimationRuleIndex);
}
if (longPressEffectInAnimationRuleIndex !== null) {
removeRule(longPressEffectInAnimationRuleIndex);
}
if (longPressMainInAnimationRuleIndex !== null) {
removeRule(longPressMainInAnimationRuleIndex);
}
const { rules, names } = createLongPressInAnimations({
time,
extraTime,
delay,
currentColor: mainStyle.color || 'currentcolor',
targetColor: longPress.dataset.activeColor,
effectOpacity: effectStyle.opacity || 0,
effectScale: effectStyle.scale || 0,
circleLeftRotation: circleLeftStyle.rotate || 0,
circleRightRotation: circleRightStyle.rotate || 0,
circleClipPath: circleStyle.clipPath || 'inset(0 0 0 50%)',
circleOpacity: circleStyle.opacity || 0,
});
longPressMainInAnimationRuleIndex = addRule(rules.main);
longPressEffectInAnimationRuleIndex = addRule(rules.effect);
longPressCircleLeftInAnimationRuleIndex = addRule(rules.circleLeft);
longPressCircleRightInAnimationRuleIndex = addRule(rules.circleRight);
longPressCircleInAnimationRuleIndex = addRule(rules.circle);
longPress.style.animation = names.main;
longPressEffect.style.animation = names.effect;
longPressCircleLeft.style.animation = names.circleLeft;
longPressCircleRight.style.animation = names.circleRight;
longPressCircle.style.animation = names.circle;
});
};
const hideLongPressIndicator = (
{ time = DEFAULT_LASSO_LONG_PRESS_REVERT_EFFECT_TIME } = {
time: DEFAULT_LASSO_LONG_PRESS_REVERT_EFFECT_TIME,
},
) => {
if (!longPressIsStarting) {
return;
}
longPressIsStarting = false;
const mainStyle = getComputedStyle(longPress);
longPress.style.color = mainStyle.color;
longPress.style.animation = 'none';
const circleStyle = getComputedStyle(longPressCircle);
longPressCircle.style.clipPath = circleStyle.clipPath;
longPressCircle.style.opacity = circleStyle.opacity;
longPressCircle.style.animation = 'none';
const effectStyle = getCurrentTransformStyle(longPressEffect);
longPressEffect.style.opacity = effectStyle.opacity;
longPressEffect.style.transform = `scale(${effectStyle.scale})`;
longPressEffect.style.animation = 'none';
// The first half of the circle animation, the clip-path is set to `inset(0px 0px 0px 50%)`.
// In the second half it's set to `inset(0px)`. Hence we can look at the second to last
// character to determine if the animatation has progressed passed half time.
const isAnimatedMoreThan50Percent =
circleStyle.clipPath.slice(-2, -1) === 'x';
const circleLeftStyle = getCurrentTransformStyle(
longPressCircleLeft,
isAnimatedMoreThan50Percent,
);
longPressCircleLeft.style.transform = `rotate(${circleLeftStyle.rotate}deg)`;
longPressCircleLeft.style.animation = 'none';
const circleRightStyle = getCurrentTransformStyle(longPressCircleRight);
longPressCircleRight.style.transform = `rotate(${circleRightStyle.rotate}deg)`;
longPressCircleRight.style.animation = 'none';
nextAnimationFrame().then(() => {
if (longPressCircleOutAnimationRuleIndex !== null) {
removeRule(longPressCircleOutAnimationRuleIndex);
}
if (longPressCircleRightOutAnimationRuleIndex !== null) {
removeRule(longPressCircleRightOutAnimationRuleIndex);
}
if (longPressCircleLeftOutAnimationRuleIndex !== null) {
removeRule(longPressCircleLeftOutAnimationRuleIndex);
}
if (longPressEffectOutAnimationRuleIndex !== null) {
removeRule(longPressEffectOutAnimationRuleIndex);
}
if (longPressMainOutAnimationRuleIndex !== null) {
removeRule(longPressMainOutAnimationRuleIndex);
}
const { rules, names } = createLongPressOutAnimations({
time,
currentColor: mainStyle.color || 'currentcolor',
targetColor: longPress.dataset.color,
effectOpacity: effectStyle.opacity || 0,
effectScale: effectStyle.scale || 0,
circleLeftRotation: circleLeftStyle.rotate || 0,
circleRightRotation: circleRightStyle.rotate || 0,
circleClipPath: circleStyle.clipPath || 'inset(0px)',
circleOpacity: circleStyle.opacity || 1,
});
longPressMainOutAnimationRuleIndex = addRule(rules.main);
longPressEffectOutAnimationRuleIndex = addRule(rules.effect);
longPressCircleLeftOutAnimationRuleIndex = addRule(rules.circleLeft);
longPressCircleRightOutAnimationRuleIndex = addRule(rules.circleRight);
longPressCircleOutAnimationRuleIndex = addRule(rules.circle);
longPress.style.animation = names.main;
longPressEffect.style.animation = names.effect;
longPressCircleLeft.style.animation = names.circleLeft;
longPressCircleRight.style.animation = names.circleRight;
longPressCircle.style.animation = names.circle;
});
};
const draw = () => {
onDraw(lassoPos, lassoPosFlat);
};
const extendFreeform = (point) => {
lassoPos.push(point);
lassoPosFlat.push(point[0], point[1]);
};
const extendRectangle = (point) => {
const [x, y] = point;
const [startX, startY] = lassoPos[0];
lassoPos[1] = [x, startY];
lassoPos[2] = [x, y];
lassoPos[3] = [startX, y];
lassoPos[4] = [startX, startY];
lassoPosFlat[2] = x;
lassoPosFlat[3] = startY;
lassoPosFlat[4] = x;
lassoPosFlat[5] = y;
lassoPosFlat[6] = startX;
lassoPosFlat[7] = y;
lassoPosFlat[8] = startX;
lassoPosFlat[9] = startY;
};
const startBrush = (point) => {
lassoBrushCenterPos.push(point);
};
const getNormalizedBrushSize = () =>
Math.abs(pointNorm([0, 0])[0] - pointNorm([brushSize / 2, 0])[0]);
const getBrushNormal = (point1, point2, w) => {
const [x1, y1] = point1;
const [x2, y2] = point2;
const dx = x1 - x2;
const dy = y1 - y2;
const dn = l2Norm([dx, dy]);
return [(+dy / dn) * w, (-dx / dn) * w];
};
const extendBrush = (point) => {
const prevPoint = lassoBrushCenterPos.at(-1);
const width = getNormalizedBrushSize();
let [nx, ny] = getBrushNormal(point, prevPoint, width);
const N = lassoBrushCenterPos.length;
if (N === 1) {
// 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];
lassoPos.push(pl, pr);
lassoPosFlat.push(pl[0], pl[1], pr[0], pr[1]);
lassoBrushNormals.push([nx, ny]);
} 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);
[nx, ny] = getBrushNormal(point, prevPoint, width);
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;
const pl = [prevPoint[0] + pnx2, prevPoint[1] + pny2];
const pr = [prevPoint[0] - pnx2, prevPoint[1] - pny2];
// We're going to replace the previous left and right points
lassoPos.splice(N - 1, 2, pl, pr);
lassoPosFlat.splice(2 * (N - 1), 4, pl[0], pl[1], pr[0], pr[1]);
lassoBrushNormals.splice(N, 1, [pnx2, pny2]);
}
const pl = [point[0] + nx, point[1] + ny];
const pr = [point[0] - nx, point[1] - ny];
lassoPos.splice(N, 0, pl, pr);
lassoPosFlat.splice(2 * N, 0, pl[0], pl[1], pr[0], pr[1]);
lassoBrushCenterPos.push(point);
lassoBrushNormals.push([nx, ny]);
};
let extendLasso = extendFreeform;
let startLasso = extendFreeform;
const extend = (currMousePos) => {
if (prevMousePos) {
const d = l2PointDist(
currMousePos[0],
currMousePos[1],
prevMousePos[0],
prevMousePos[1],
);
if (d > minDist) {
prevMousePos = currMousePos;
extendLasso(pointNorm(currMousePos));
if (lassoPos.length > 1) {
draw();
}
}
} else {
if (!isLasso) {
isLasso = true;
onStart();
}
prevMousePos = currMousePos;
const point = pointNorm(currMousePos);
startLasso(point);
}
};
const extendDb = throttleAndDebounce(extend, minDelay, minDelay);
const extendPublic = (event, debounced) => {
const mousePosition = getMousePosition(event);
if (debounced) {
return extendDb(mousePosition);
}
return extend(mousePosition);
};
const clear = () => {
lassoPos = [];
lassoPosFlat = [];
lassoBrushCenterPos = [];
lassoBrushNormals = [];
prevMousePos = undefined;
draw();
};
const initiatorClickHandler = (event) => {
showInitiator(event);
};
const initiatorMouseDownHandler = () => {
isMouseDown = true;
isLasso = true;
clear();
onStart();
};
const initiatorMouseLeaveHandler = () => {
hideInitiator();
};
const end = ({ merge = false, remove = false } = {}) => {
isLasso = false;
const currLassoPos = [...lassoPos];
const currLassoPosFlat = [...lassoPosFlat];
extendDb.cancel();
clear();
// When `currLassoPos` is empty the user didn't actually lasso
if (currLassoPos.length > 0) {
onEnd(currLassoPos, currLassoPosFlat, { merge, remove });
}
return currLassoPos;
};
const setExtendLasso = (newType) => {
switch (newType) {
case 'rectangle': {
type = newType;
extendLasso = extendRectangle;
// This is on purpose. The start of a rectangle & freeform are the same
startLasso = extendFreeform;
break;
}
case 'brush': {
type = newType;
extendLasso = extendBrush;
startLasso = startBrush;
break;
}
default: {
type = 'freeform';
extendLasso = extendFreeform;
startLasso = extendFreeform;
break;
}
}
};
const get = (property) => {
if (property === 'onDraw') {
return onDraw;
}
if (property === 'onStart') {
return onStart;
}
if (property === 'onEnd') {
return onEnd;
}
if (property === 'enableInitiator') {
return enableInitiator;
}
if (property === 'minDelay') {
return minDelay;
}
if (property === 'minDist') {
return minDist;
}
if (property === 'pointNorm') {
return pointNorm;
}
if (property === 'type') {
return type;
}
if (property === 'brushSize') {
return brushSize;
}
};
const set = ({
onDraw: newOnDraw = null,
onStart: newOnStart = null,
onEnd: newOnEnd = null,
enableInitiator: newEnableInitiator = null,
initiatorParentElement: newInitiatorParentElement = null,
longPressIndicatorParentElement: newLongPressIndicatorParentElement = null,
minDelay: newMinDelay = null,
minDist: newMinDist = null,
pointNorm: newPointNorm = null,
type: newType = null,
brushSize: newBrushSize = null,
} = {}) => {
onDraw = ifNotNull(newOnDraw, onDraw);
onStart = ifNotNull(newOnStart, onStart);
onEnd = ifNotNull(newOnEnd, onEnd);
enableInitiator = ifNotNull(newEnableInitiator, enableInitiator);
minDelay = ifNotNull(newMinDelay, minDelay);
minDist = ifNotNull(newMinDist, minDist);
pointNorm = ifNotNull(newPointNorm, pointNorm);
brushSize = ifNotNull(newBrushSize, brushSize);
if (
newInitiatorParentElement !== null &&
newInitiatorParentElement !== initiatorParentElement
) {
initiatorParentElement.removeChild(initiator);
newInitiatorParentElement.appendChild(initiator);
initiatorParentElement = newInitiatorParentElement;
}
if (
newLongPressIndicatorParentElement !== null &&
newLongPressIndicatorParentElement !== longPressIndicatorParentElement
) {
longPressIndicatorParentElement.removeChild(longPress);
newLongPressIndicatorParentElement.appendChild(longPress);
longPressIndicatorParentElement = newLongPressIndicatorParentElement;
}
if (enableInitiator) {
initiator.addEventListener('click', initiatorClickHandler);
initiator.addEventListener('mousedown', initiatorMouseDownHandler);
initiator.addEventListener('mouseleave', initiatorMouseLeaveHandler);
} else {
initiator.removeEventListener('mousedown', initiatorMouseDownHandler);
initiator.removeEventListener('mouseleave', initiatorMouseLeaveHandler);
}
if (newType !== null) {
setExtendLasso(newType);
}
};
const destroy = () => {
initiatorParentElement.removeChild(initiator);
longPressIndicatorParentElement.removeChild(longPress);
window.removeEventListener('mouseup', mouseUpHandler);
initiator.removeEventListener('click', initiatorClickHandler);
initiator.removeEventListener('mousedown', initiatorMouseDownHandler);
initiator.removeEventListener('mouseleave', initiatorMouseLeaveHandler);
};
const withPublicMethods = () => (self) =>
assign(self, {
clear,
destroy,
end,
extend: extendPublic,
get,
set,
showInitiator,
hideInitiator,
showLongPressIndicator,
hideLongPressIndicator,
});
initiatorParentElement.appendChild(initiator);
longPressIndicatorParentElement.appendChild(longPress);
set({
onDraw,
onStart,
onEnd,
enableInitiator,
initiatorParentElement,
type,
brushSize,
});
return pipe(
withStaticProperty('initiator', initiator),
withStaticProperty('longPressIndicator', longPress),
withPublicMethods(),
withConstructor(createLasso),
)({});
};
export default createLasso;