A23 ·
Draggable Elements with RxJS
<main class="overflow-hidden vh-100 flex items-stretch">
<svg
class="w-50 h-100"
viewBox="0 0 300 600"
preserveAspectRatio="xMidYMid slice"
>
<defs>
<pattern id="pyramids" width="60" height="60" patternUnits="userSpaceOnUse">
<path class="js-pyramid" fill="#6A9ED0" />
<path class="js-pyramid" fill="#fff" />
</pattern>
</defs>
<!-- pyramids -->
<rect x="0" y="0" width="300" height="100%" fill="#0259BF" />
<rect x="0" y="0" width="300" height="100%" fill="url('#pyramids')" />
</svg>
<svg
class="w-50 h-100"
viewBox="0 0 300 600"
preserveAspectRatio="xMidYMid slice"
>
<g stroke-width="3">
<!-- controls -->
<rect width="300" height="100%" fill="#231D1F" />
<!-- Grid -->
<g id="js-grid">
<!-- Horizontal -->
<line x1="100" x2="100" y1="0" y2="600" stroke="#fff" />
<line x1="200" x2="200" y1="0" y2="600" stroke="#fff" />
<!-- Vertical -->
<line x1="0" x2="300" y1="200" y2="200" stroke="#fff" />
<line x1="0" x2="300" y1="400" y2="400" stroke="#fff" />
</g>
<!-- Guides -->
<g id="js-guide" stroke-width="4" stroke="#fff" stroke-linecap="round" stroke-dasharray="0.25, 8">
<!-- Top -->
<line x1="125" x2="125" y1="0" y2="180" />
<line x1="175" x2="175" y1="0" y2="180" />
<!-- Bottom -->
<line x1="125" x2="125" y1="420" y2="600" />
<line x1="175" x2="175" y1="420" y2="600" />
</g>
<!-- curve -->
<path id="js-curve" d="M100,200 C150,200 150,400 200,400" fill="none" stroke="#1E5EB3" />
<!-- curve points -->
<g>
<!-- connector 2 -->
<line class="js-connector" x1="" x2="" y1="" y2="" stroke="#6A9ED0" />
<!-- stop -->
<circle class="move" id="js-stop" cx="" cy="" r="4" fill="#fff" stroke="#6A9ED0" />
<!-- cp2 -->
<circle class="ew-resize" id="js-cp2" cx="" cy="" r="4" fill="#6A9ED0" />
<!-- connector 1 -->
<line class="js-connector" x1="" x2="" y1="" y2="" stroke="#6A9ED0" />
<!-- cp1 -->
<circle class="ew-resize" id="js-cp1" cx="" cy="" r="4" fill="#6A9ED0" />
<!-- start -->
<circle class="move" id="js-start" cx="" cy="" r="4" fill="#fff" stroke="#6A9ED0" />
</g>
</g>
</svg>
</main> .move {
cursor: move;
}
.ew-resize {
cursor: ew-resize;
} // Dom Nodes
const pyramidEls = document.querySelectorAll('.js-pyramid');
const ptEls = document.querySelectorAll('.js-c');
const connectorEls = document.querySelectorAll('.js-connector');
const curveEl = document.querySelector('#js-curve');
const startEl = document.querySelector('#js-start');
const cp1El = document.querySelector('#js-cp1');
const cp2El = document.querySelector('#js-cp2');
const stopEl = document.querySelector('#js-stop');
const [y1El, y2El, x1El, x2El] = document.querySelectorAll('#js-grid line');
const [tlEl, trEl, blEl, brEl] = document.querySelectorAll('#js-guide line');
const animationFrame$ = Rx.Observable.interval(0, Rx.Scheduler.animationFrame);
// Drag Handler
const drag = (domNode, pan$) =>
pan$.filter(e => e.type === 'panstart').switchMap(pd => {
const start = {
x: +domNode.getAttribute('cx'),
y: +domNode.getAttribute('cy'),
};
const w = document.body.clientWidth;
const h = document.body.clientHeight;
domNode.setAttribute('r', 8);
const svgW = w > h ? 300 : 300 * w / h;
const svgH = w > h ? 600 * h / w : 600;
const move$ = pan$
.filter(e => e.type === 'panmove')
.map(pm => {
return {
x: start.x + linInterp(pm.deltaX, 0, w / 2, 0, svgW),
y: start.y + linInterp(pm.deltaY, 0, h, 0, svgH),
};
})
.takeUntil(pan$.filter(e => e.type === 'panend'));
move$.subscribe(null, null, () => domNode.setAttribute('r', 4));
return move$;
});
const handleDrag = domNode => {
const hammerPan = new Hammer(domNode, {
direction: Hammer.DIRECTION_ALL,
});
hammerPan.get('pan').set({ direction: Hammer.DIRECTION_ALL });
const pan$ = Rx.Observable.fromEventPattern(h =>
hammerPan.on('panstart panup pandown panmove panend', h),
);
const drag$ = drag(domNode, pan$);
return animationFrame$
.withLatestFrom(drag$, (_, p) => p)
.scan(RxCSS.lerp(0.1))
.map(p => [p.x, p.y]);
};
const points$ = Rx.Observable
.combineLatest(
handleDrag(startEl).startWith([200, 400]),
handleDrag(cp1El).startWith([150, 400]),
handleDrag(cp2El).startWith([150, 200]),
handleDrag(stopEl).startWith([100, 200]),
)
.map(([start, cp1, cp2, stop]) => [
start,
[cp1[0], start[1]],
[cp2[0], stop[1]],
stop,
]);
const svgGeometry$ = points$.map(([start, cp1, cp2, stop]) => {
const t = stop[1];
const r = start[0];
const b = start[1];
const l = stop[0];
const cpDelta = Math.abs(cp1[0] - cp2[0]);
return {
t,
r,
b,
l,
rot: linInterp(cpDelta, 0, 300, -12, 12),
pts: { start, cp1, cp2, stop },
connectors: [[stop, cp2], [start, cp1]],
curve: makeCurve(start, cp1, cp2, stop),
faces: makePyramid(Math.abs(t - b), Math.abs(l - r)),
};
});
svgGeometry$.subscribe(({ pts, curve, connectors, t, r, b, l, faces, rot }) => {
// Guides
tlEl.setAttribute('y2', t - 16);
trEl.setAttribute('y2', t - 16);
blEl.setAttribute('y1', b + 16);
brEl.setAttribute('y1', b + 16);
// Grid
moveY(t, x1El);
moveY(b, x2El);
moveX(l, y1El);
moveX(r, y2El);
// Curve
curveEl.setAttribute('d', curve);
// Connectors
line(connectors[0], connectorEls[0]);
line(connectors[1], connectorEls[1]);
// Points
moveTo(pts.start, startEl);
moveTo(pts.cp1, cp1El);
moveTo(pts.cp2, cp2El);
moveTo(pts.stop, stopEl);
// Pyramid
pyramidEls[0].setAttribute('d', faces[0]);
pyramidEls[1].setAttribute('d', faces[1]);
pyramidEls[0].setAttribute('transform', `rotate(${rot})`);
pyramidEls[1].setAttribute('transform', `rotate(${rot})`);
});
/**
* Geometry
*/
function makeCurve(start, cp1, cp2, stop) {
return ['M', start, 'C', cp1, cp2, stop].join(' ');
}
function line([[x1, y1], [x2, y2]], node) {
node.setAttribute('x1', x1);
node.setAttribute('x2', x2);
node.setAttribute('y1', y1);
node.setAttribute('y2', y2);
}
function moveTo([x, y], node) {
node.setAttribute('cx', x);
node.setAttribute('cy', y);
}
function moveX(x, node) {
node.setAttribute('x1', x);
node.setAttribute('x2', x);
}
function moveY(y, node) {
node.setAttribute('y1', y);
node.setAttribute('y2', y);
}
function pyramidPts(heightMult, spanMult) {
const cntr = [
linInterp(19.6875, 0, 56, 0, 60),
linInterp(10.5469, -32, 30, 0, 60),
];
const pts = [
// top
{
theta: -1.05165047,
r: 49.00343208245725 * heightMult,
},
// right
{
theta: 0.491808641,
r: 41.19491177147974 * spanMult,
},
// bottom
{
theta: 2.089943243589793,
r: 15.494629903937685 * spanMult,
},
// left
{
theta: -2.649782490589793,
r: 22.33460892561139 * spanMult,
},
];
return pts.map(pt => polarToCart(pt, cntr).map(round));
}
function makePyramid(deltaY, deltaX) {
const heightMult = linInterp(deltaY, 0, 600, 0.1, 1);
const spanMult = linInterp(deltaX, 0, 300, 0.1, 1);
const [c1, c2, c3, c4] = pyramidPts(heightMult, spanMult);
return [[c1, c4, c3], [c1, c2, c3]].map(makeFace);
}
function makeFace([p1, p2, p3]) {
return ['M', p1, 'L', p2, 'L', p3].join('');
}
function polarToCart({ r, theta }, [cx, cy]) {
return [cx + r * Math.cos(theta), cy + r * Math.sin(theta)];
}
/**
* Utils
*/
function round(x) {
return Math.round(x * 100) / 100;
}
function dist([ux, uy], [vx, vy]) {
return Math.sqrt((ux - vx) * (ux - vx) + (uy - vy) * (uy - vy));
}
function same(a, b) {
return Math.abs(a - b) <= 0.1;
}
function linInterp(x, x1, x2, y1, y2) {
return (x - x1) * ((y2 - y1) / (x2 - x1)) + y1;
} I’ve written previously about making DOM elements draggable using a combination of mouse and touch events. Recently I discovered a more elegant way to achieve this using RxJS and Hammer.
Tools
Hammer JS needs no introduction. It is the go to library for supporting touch gestures. Plus it provides an abstraction over the browser events allowing us to handle mouse and touch at the same time.
RxJS is a reactive programming library for JavaScript. We will use it to convert events into an observable stream and for animations.
I’ve been using RxJS for a couple of years now. Mostly for state management with Angular and with redux-observable. I love working with observables. It allows me to write succinct and declarative code.
A few months ago David Khourshid introduced me to his library RxCSS and to the idea of using observables to create reactive animations. Reactive programming makes it really easy to convert events into data and drive animations. This pushed me to learn more about observables and discover lots of new patterns. In this post I am going to share one of those patterns.
⚠️ I am going to assume a basic understanding of RxJS. If you are new to RxJS then I would highly recommend reading David’s animated intro to RxJS first.
Drag Gesture
The drag gesture can be broken down into three stages: start, move & end. On start we grab the current location of the element. The move event provides the delta which we can use to move the element. Lastly, the end event provides us with a hook to do any kind of cleanup once the gesture has ended. Hammer’s Pan Recognizer provides us with panstart, panmove & panend events which work perfectly for the drag gesture.
Events to Observable
We start by creating a Hammer manager and configure it to handle pan in all directions. Rx.Observable.fromEvent allows us to convert events into an observable sequence. This one observable stream – pan$ – will allow us to subscribe to events for pan-start, pan-move and pan-end.
// Create a new Hammer Manager
const hammerPan = new Hammer(element, {
direction: Hammer.DIRECTION_ALL,
});
hammerPan.get('pan').set({ direction: Hammer.DIRECTION_ALL });
// Convert hammer events to an observable
const pan$ = Rx.Observable.fromEvent(hammerPan, 'panstart panmove panend');
Composing the Drag Observable
For the drag gesture we want to create an observable stream such that it emits values from the pan-move event once the pan-start event has been triggered and then stops emitting those values once the pan-end event is triggered.
The filter operator allows us to filter values based on a provided condition. We can use this to target specific events. For example, pan$.filter(e => e.type === 'panstart') to subscribe only to pan-start events. Then to generate the drag$ observable we then need to combine panstart$, panmove$ & panend$ in the following pattern:
const drag$ = panstart$.switchMap(() =>
panmove$.map(calculateNewPosition).takeUntil(panend$)
);
Let’s break this down step by step. panstart$ is the observable that is driving the whole thing. When it emits the first value it switches to the panmove$ observable. This switching is done using the switchMap operator. The panmove$ observable then starts emitting the location values. We can tell it to stop when panend$ emits a value by chaining on the takeUntil operator. Therefore, all subscribers to drag$ only ever receive location values. You can see a simulated visualization of this setup here.
Now that we understand the basic structure we can flush out the details. The panmove event only provides delta values. To calculate the absolute position we need to start by getting the initial location. In this example I am getting that information from the element itself. To provide a cleanup hook we can subscribe to the move$ observable and handle it via the onComplete callback.
// Generates the drag$ observable
const drag = ({ element, pan$ }) => {
const panStart$ = pan$.filter(e => e.type === 'panstart');
const panMove$ = pan$.filter(e => e.type === 'panmove');
const panEnd$ = pan$.filter(e => e.type === 'panend');
panstart$.switchMap(() => {
// Get the starting point on pan-start
const start = {
x: +element.getAttribute('cx'),
y: +element.getAttribute('cy'),
};
// Create observable to handle pan-move and stop on pan-end
const move$ = panmove$
.map(pmEvent => ({
x: start.x + pmEvent.deltaX,
y: start.y + pmEvent.deltaY,
}))
.takeUntil(panend$);
// We can subscribe to move$ and handle cleanup in the onComplete callback
move$.subscribe(null, null, () => {
/* Handle cleanup when pan ends */
});
return move$;
});
};
The pattern I shared above is based on the dragndrop example from the RxJS documentation.
Scaling to Canvas
Quite often I end up having to limit the element to a parent container. For example, a <circle> that can only be dragged within the <svg> container where the cx and cy values need to be calculated in the viewBox coordinate system.
This is essentially a global to local coordinate transform. With SVG this can get slightly tricky depending on how you want the SVG to scale. I generally prefer preserveAspectRatio="xMidYMid slice". This makes the SVG grow until it entirely covers the container – very similar to background-size: cover.
Therefore, we can figure out how much the SVG has scaled using the aspect ratio of the container. Then use that value to map the viewport based coordinates to the SVG coordinate system.
function scaleToCanvas({ start: { x, y }, w, h }) {
const svgW = w > h ? VIEWBOX_SIZE.W : (VIEWBOX_SIZE.W * w) / h;
const svgH = w > h ? (VIEWBOX_SIZE.H * h) / w : VIEWBOX_SIZE.H;
return e => ({
x: x + mapFromToRange(e.deltaX, 0, w, 0, svgW),
y: y + mapFromToRange(e.deltaY, 0, h, 0, svgH),
});
}
function mapFromToRange(x, x1, x2, y1, y2) {
return (x - x1) * ((y2 - y1) / (x2 - x1)) + y1;
}
And here’s the complete example:
<main class="overflow-hidden vh-100 flex items-stretch">
<svg
class="w-100 h-100"
viewBox="0 0 600 600"
preserveAspectRatio="xMidYMid slice"
>
<g stroke-width="4">
<rect width="100%" height="100%" fill="#0259BF" />
<circle class="move" id="js-move" cx="" cy="" r="12" fill="#fff" stroke="#6A9ED0" />
</g>
</svg>
</main> .move { cursor: move; }
.ew-resize { cursor: ew-resize; } // Dom Nodes
const moveEl = document.querySelector("#js-move");
const VIEWBOX_SIZE = { W: 600, H: 600 };
/**
* Create an observable stream to handle drag gesture
*/
const drag = ({ element, pan$, onStart, onEnd }) => {
const panStart$ = pan$.filter(e => e.type === "panstart");
const panMove$ = pan$.filter(e => e.type === "panmove");
const panEnd$ = pan$.filter(e => e.type === "panend");
return panStart$.switchMap(() => {
// Get the starting point on panstart
const { start, w, h } = getStartInfo(element);
onStart();
// Create observable to handle pan-move
// and stop on pan-end
const move$ = panMove$
.map(scaleToCanvas({ start, w, h }))
.takeUntil(panEnd$);
// We can subscribe to move$ and
// handle cleanup in the onComplete callback
move$.subscribe(null, null, onEnd);
return move$;
});
};
/**
* Generate the drag handler for a DOM element
*/
function handleDrag(element) {
// Create a new Hammer Manager
const hammerPan = new Hammer(element, {
direction: Hammer.DIRECTION_ALL
});
hammerPan.get("pan").set({ direction: Hammer.DIRECTION_ALL });
// Convert hammer events to an observable
const pan$ = Rx.Observable.fromEvent(hammerPan, "panstart panmove panend");
const drag$ = drag({
element: element,
pan$,
onStart: () => element.setAttribute("r", 12 * 2),
onEnd: () => element.setAttribute("r", 12)
});
return drag$.map(({ x, y }) => [x, y]);
}
/**
* Utils
*/
function getStartInfo(element) {
const start = {
x: +element.getAttribute("cx"),
y: +element.getAttribute("cy")
};
const w = document.body.clientWidth;
const h = document.body.clientHeight;
return { start, w, h };
}
function scaleToCanvas({ start: { x, y }, w, h }) {
// Scale to account for SVG canvas with preserveAspectRatio="xMidYMid slice"
const svgW = w > h ? VIEWBOX_SIZE.W : VIEWBOX_SIZE.W * w / h;
const svgH = w > h ? VIEWBOX_SIZE.H * h / w : VIEWBOX_SIZE.H;
return e => ({
x: x + mapFromToRange(e.deltaX, 0, w, 0, svgW),
y: y + mapFromToRange(e.deltaY, 0, h, 0, svgH)
});
}
function mapFromToRange(x, x1, x2, y1, y2) {
return (x - x1) * ((y2 - y1) / (x2 - x1)) + y1;
}
/**
* Lerp
* based on @davidkpiano's RXCSS
* https://github.com/davidkpiano/RxCSS/blob/7817e419c98b1564195479f8b5e9c5dffb989f84/src/lerp.js
*/
function lerp(rate) {
return ({ x, y }, targetValue) => {
const mapValue = (value, tValue) => {
const delta = (tValue - value) * rate;
return value + delta;
};
return {
x: mapValue(x, targetValue.x),
y: mapValue(y, targetValue.y)
};
};
}
/**
* Make a DOM element move
*/
const location$ = handleDrag(moveEl).startWith([200, 400]);
location$
.map(([x, y]) => ({
moveElLocation: [x, y]
}))
.subscribe(({ moveElLocation }) => {
moveTo(moveElLocation, moveEl);
});
function moveTo([x, y], element) {
element.setAttribute("cx", x);
element.setAttribute("cy", y);
} Smooth Motion
In the example above you’ll notice that the motion is somewhat rigid. The circle is stuck to the pointer and instantly stops wherever the pointer stops. We can make this better by adding smooth motion. This will also provide a bit of momentum to the circle.
For smooth motion I am using the LERP-ing technique. It is described in detail by David Khourshid in the An Animated Intro to RxJS article I mentioned earlier. The gist of it is that instead of using the drag$ observable directly we combine it with an animation timer. This allows us to smooth out the motion by using linear interpolation. However, we still have the possibility to subscribe to drag$ if we want access to the raw location.
Here’s the final version with smooth motion.
<main class="overflow-hidden vh-100 flex items-stretch">
<svg
class="w-100 h-100"
viewBox="0 0 600 600"
preserveAspectRatio="xMidYMid slice"
>
<g stroke-width="4">
<rect width="100%" height="100%" fill="#0259BF" />
<circle class="move" id="js-move" cx="" cy="" r="12" fill="#fff" stroke="#6A9ED0" />
</g>
</svg>
</main> .move { cursor: move; }
.ew-resize { cursor: ew-resize; } /**
* For a more detailed explanation see:
* http://varun.ca/drag-with-rxjs/
*/
// Dom Nodes
const moveEl = document.querySelector("#js-move");
const VIEWBOX_SIZE = { W: 600, H: 600 };
const animationFrame$ = Rx.Observable.interval(0, Rx.Scheduler.animationFrame);
/**
* Create an observable stream to handle drag gesture
*/
const drag = ({ element, pan$, onStart, onEnd }) => {
const panStart$ = pan$.filter(e => e.type === "panstart");
const panMove$ = pan$.filter(e => e.type === "panmove");
const panEnd$ = pan$.filter(e => e.type === "panend");
return panStart$.switchMap(() => {
// Get the starting point on panstart
const { start, w, h } = getStartInfo(element);
onStart();
// Create observable to handle pan-move
// and stop on pan-end
const move$ = panMove$
.map(scaleToCanvas({ start, w, h }))
.takeUntil(panEnd$);
// We can subscribe to move$ and
// handle cleanup in the onComplete callback
move$.subscribe(null, null, onEnd);
return move$;
});
};
/**
* Generate the drag handler for a DOM element
*/
function handleDrag(element) {
// Create a new Hammer Manager
const hammerPan = new Hammer(element, {
direction: Hammer.DIRECTION_ALL
});
hammerPan.get("pan").set({ direction: Hammer.DIRECTION_ALL });
// Convert hammer events to an observable
const pan$ = Rx.Observable.fromEvent(hammerPan, "panstart panmove panend");
// alternatively you can use fromEventPattern:
// const pan$ = Rx.Observable.fromEventPattern(h =>
// hammerPan.on('panstart panmove panend', h),
// );
const drag$ = drag({
element: element,
pan$,
onStart: () => element.setAttribute("r", 12 * 2),
onEnd: () => element.setAttribute("r", 12)
});
// Smooth the drag location using lerp
return animationFrame$
.withLatestFrom(drag$, (_, p) => p)
.scan(lerp(0.05))
.map(p => [p.x, p.y]);
}
/**
* Utils
*/
function getStartInfo(element) {
const start = {
x: +element.getAttribute("cx"),
y: +element.getAttribute("cy")
};
const w = document.body.clientWidth;
const h = document.body.clientHeight;
return { start, w, h };
}
function scaleToCanvas({ start: { x, y }, w, h }) {
// Scale to account for SVG canvas with preserveAspectRatio="xMidYMid slice"
const svgW = w > h ? VIEWBOX_SIZE.W : VIEWBOX_SIZE.W * w / h;
const svgH = w > h ? VIEWBOX_SIZE.H * h / w : VIEWBOX_SIZE.H;
return e => ({
x: x + mapFromToRange(e.deltaX, 0, w, 0, svgW),
y: y + mapFromToRange(e.deltaY, 0, h, 0, svgH)
});
}
function mapFromToRange(x, x1, x2, y1, y2) {
return (x - x1) * ((y2 - y1) / (x2 - x1)) + y1;
}
/**
* Lerp
* based on @davidkpiano's RXCSS
* https://github.com/davidkpiano/RxCSS/blob/7817e419c98b1564195479f8b5e9c5dffb989f84/src/lerp.js
*/
function lerp(rate) {
return ({ x, y }, targetValue) => {
const mapValue = (value, tValue) => {
const delta = (tValue - value) * rate;
return value + delta;
};
return {
x: mapValue(x, targetValue.x),
y: mapValue(y, targetValue.y)
};
};
}
/**
* Make a DOM element move
*/
const location$ = handleDrag(moveEl).startWith([200, 300]);
location$
.map(([x, y]) => ({
moveElLocation: [x, y]
}))
.subscribe(({ moveElLocation }) => {
moveTo(moveElLocation, moveEl);
});
function moveTo([x, y], element) {
element.setAttribute("cx", x);
element.setAttribute("cy", y);
} Conclusion
You can see the power of RxJS here. We were able to convert events into observable streams. Then we composed those streams to create the drag$ observable. And finally we added the animation layer to smooth out the motion. The code is quite declarative. Each layer that we created is modular and can be easily composed to create complex scenarios – for example see the demo below and the one at the top of the page. Looking for more inspiration? Checkout CodePen for many more examples.
<div class="flex items-center vh-100">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 400 400"
preserveAspectRatio="xMidYMid"
class="w-100 h-100"
>
<g fill="none" stroke-width="2" stroke-linejoin="round">
<!-- <path id="js-debug" stroke="#F012BE" stroke-width="1" d="" />
<circle cx="" cy="" r="5" id="js-circum-center" fill="#F012BE" /> -->
<path id="js-triangle" stroke="#333" d="" />
<path id="js-centroid-triangle" stroke="#01FF70" d="" />
<circle cx="" cy="" r="10" id="js-handle-1" fill="#A463F2" opacity="0.55" />
<circle cx="" cy="" r="10" id="js-handle-2" fill="#A463F2" opacity="0.55" />
<circle cx="" cy="" r="10" id="js-handle-3" fill="#A463F2" opacity="0.55" />
</g>
</svg>
</div>
<a href="http://triangle.life" target="_blank" class="▲">
त्रिकोण
</a> body { overflow: hidden; }
.▲ {
position: fixed;
bottom: 1rem;
right: 1rem;
background-color: #0074d9;
color: #7fdbff;
width: 4rem;
display: block;
height: 2rem;
border-radius: 0.25rem;
width: 4rem;
height: 2rem;
font-size: 0.875rem;
line-height: 2rem;
text-align: center;
text-decoration: none;
}
.▲ {
-moz-osx-font-smoothing: grayscale;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
-webkit-transform: translateZ(0);
transform: translateZ(0);
-webkit-transition: -webkit-transform .25s ease-out;
transition: -webkit-transform .25s ease-out;
transition: transform .25s ease-out;
transition: transform .25s ease-out, -webkit-transform .25s ease-out;
}
.▲:hover,
.▲:focus {
-webkit-transform: scale(1.05);
transform: scale(1.05);
}
.▲:active {
-webkit-transform: scale(.90);
transform: scale(.90);
} // Dom Nodes
const triangle = document.querySelector('#js-triangle');
const centroidTriangle = document.querySelector('#js-centroid-triangle');
const handle1 = document.querySelector('#js-handle-1');
const handle2 = document.querySelector('#js-handle-2');
const handle3 = document.querySelector('#js-handle-3');
const showDebug = false;
if (showDebug) {
const debugCC = document.querySelector('#js-circum-center');
const debug = document.querySelector('#js-debug');
}
const animationFrame$ = Rx.Observable.interval(0, Rx.Scheduler.animationFrame);
// Drag Handler
const drag = (domNode, pan$) =>
pan$.filter(e => e.type === 'panstart').switchMap(pd => {
const start = {
x: +domNode.getAttribute('cx'),
y: +domNode.getAttribute('cy'),
};
const w = document.body.clientWidth;
const h = document.body.clientHeight;
domNode.setAttribute('r', 15);
const svgW = w > h ? 400 * w / h : 400;
const svgH = w > h ? 400 : 400 * h / w;
const move$ = pan$
.filter(e => e.type === 'panmove')
.map(pm => {
return {
x: start.x + linInterp(pm.deltaX, 0, w, 0, svgW),
y: start.y + linInterp(pm.deltaY, 0, h, 0, svgH),
};
})
.takeUntil(pan$.filter(e => e.type === 'panend'));
move$.subscribe(null, null, () => domNode.setAttribute('r', 10));
return move$;
});
const handleDrag = domNode => {
const hammerPan = new Hammer(domNode, {
direction: Hammer.DIRECTION_ALL,
});
hammerPan.get('pan').set({ direction: Hammer.DIRECTION_ALL });
const pan$ = Rx.Observable.fromEventPattern(h =>
hammerPan.on('panstart panup pandown panmove panend', h),
);
const drag$ = drag(domNode, pan$);
return animationFrame$
.withLatestFrom(drag$, (_, e) => e)
.scan(RxCSS.lerp(0.1))
.map(p => [p.x, p.y]);
};
const points$ = Rx.Observable.combineLatest(
handleDrag(handle1).startWith([100, 200]),
handleDrag(handle2).startWith([150, 100]),
handleDrag(handle3).startWith([300, 200]),
);
const triangle$ = points$
.distinctUntilChanged((p, n) => Math.abs(addAll(p) - addAll(n)) < 0.1)
.map(points => ({
points,
circumCenter: circumCenter(...points),
}));
// State: Main Triangle, Equilateral triangles and Centroid Triangle
const state$ = triangle$
.map(({ points: [u, v, w], circumCenter }) => ({
triangle: [u, v, w],
a: eqTriangle(u, v, circumCenter, w),
b: eqTriangle(v, w, circumCenter, u),
c: eqTriangle(w, u, circumCenter, v),
circumCenter,
}))
.map(({ triangle, a, b, c, circumCenter }) => ({
triangle,
a,
b,
c,
centroidTriangle: [centroid(a), centroid(b), centroid(c)],
cc: circumCenter,
}));
// Convert state to SVG geometry and render
const svgGeometry$ = state$.map(
({ triangle, a, b, c, centroidTriangle, cc }) => {
return {
triangleD: [
'M',
triangle.join('L'),
'L',
a.join('L'),
'L',
b.join('L'),
'L',
c.join('L'),
],
cc,
debugD: [
'M',
...triangle[0],
'L',
...cc,
'L',
...triangle[1],
'M',
...cc,
'L',
...triangle[2],
'M',
...a[1],
'L',
...cc,
'L',
...b[1],
'M',
...cc,
'L',
...c[1],
].join(' '),
centroidTriangleD: ['M', centroidTriangle.join('L'), 'Z'],
handles: [triangle[0], triangle[1], triangle[2]],
};
},
);
svgGeometry$.subscribe(
({ triangleD, centroidTriangleD, handles: [h1, h2, h3], cc, debugD }) => {
triangle.setAttribute('d', triangleD.join(''));
centroidTriangle.setAttribute('d', centroidTriangleD.join(''));
handle1.setAttribute('cx', h1[0]);
handle1.setAttribute('cy', h1[1]);
handle2.setAttribute('cx', h2[0]);
handle2.setAttribute('cy', h2[1]);
handle3.setAttribute('cx', h3[0]);
handle3.setAttribute('cy', h3[1]);
if (showDebug) {
debugCC.setAttribute('cx', cc[0]);
debugCC.setAttribute('cy', cc[1]);
debug.setAttribute('d', debugD);
}
},
);
/**
* Geometry
*/
function eqTriangle(u, v, cc) {
return [u, apex(u, v, cc), v];
}
function apex([ux, uy], [vx, vy], [ccx, ccy]) {
const [mpx, mpy] = [(vx + ux) / 2, (vy + uy) / 2];
const dir = sign([ccx, ccy], [ux, uy], [vx, vy]) > 0 ? 1 : -1;
const ccMp = [mpx - ccx, mpy - ccy].map(x => x * dir);
const h = triangleHeight([ux, uy], [vx, vy]);
const m = magnitude(ccMp);
const [nx, ny] = ccMp.map(s => s * (h + dir * m) / m);
return [nx + ccx, ny + ccy];
}
function circumCenter([ax, ay], [bx, by], [cx, cy]) {
const d = (ax - cx) * (by - cy) - (bx - cx) * (ay - cy);
const x =
(((ax - cx) * (ax + cx) + (ay - cy) * (ay + cy)) / 2 * (by - cy) -
((bx - cx) * (bx + cx) + (by - cy) * (by + cy)) / 2 * (ay - cy)) /
d;
const y =
(((bx - cx) * (bx + cx) + (by - cy) * (by + cy)) / 2 * (ax - cx) -
((ax - cx) * (ax + cx) + (ay - cy) * (ay + cy)) / 2 * (bx - cx)) /
d;
return [Math.round(x * 100) / 100, Math.round(y * 100) / 100];
}
function centroid([[ux, uy], [vx, vy], [wx, wy]]) {
return [avg(ux, vx, wx), avg(uy, vy, wy)];
}
/**
* Utils
*/
function magnitude([x, y]) {
return (x ** 2 + y ** 2) ** 0.5;
}
function triangleHeight([ux, uy], [vx, vy]) {
return 3 ** 0.5 * dist([ux, uy], [vx, vy]) / 2;
}
function dist([ux, uy], [vx, vy]) {
return Math.sqrt((ux - vx) * (ux - vx) + (uy - vy) * (uy - vy));
}
function avg(t0, t1, t2) {
return (t0 + t1 + t2) / 3;
}
function linInterp(x, x1, x2, y1, y2) {
return (x - x1) * ((y2 - y1) / (x2 - x1)) + y1;
}
function add(a, b) {
return a + b;
}
function addAll(list) {
return list.map(x => x.reduce(add, 0)).reduce(add, 0);
}
function sign([p1x, p1y], [p2x, p2y], [p3x, p3y]) {
return (p1x - p3x) * (p2y - p3y) - (p2x - p3x) * (p1y - p3y);
}