Skip to content

Instantly share code, notes, and snippets.

@Sphinxxxx
Created February 23, 2026 02:21
Show Gist options
  • Select an option

  • Save Sphinxxxx/770d042dfda4c6bc3cdea3de8d7a7947 to your computer and use it in GitHub Desktop.

Select an option

Save Sphinxxxx/770d042dfda4c6bc3cdea3de8d7a7947 to your computer and use it in GitHub Desktop.
Polyline direction
<script>console.clear();</script>
<script src="https://unpkg.com/vue@2"></script>
<script src="https://unpkg.com/drag-tracker@1"></script>
<h2>Polyline direction and point distance</h2>
<ul>
<li>Red: Turns right</li>
<li>Green: Turns left</li>
</ul>
<section id="app">
<b>Endpoint projection:</b>
<label>
<input type="checkbox" v-model="clampDist"/>
Clamp distance to blue segment
</label>
<b>Offset:</b>
<label>
<span>Distance</span>
<input type="range" min="-50" max="50" v-model.number="offsetDist" />
<output>{{ offsetDist }}</output>
</label>
<svg :width="size[0]" :height="size[1]">
<g class="projection">
<connector class="extension" :start="points[0]" :end="projection"></connector>
<path id="distance" :d="'M' + [points[2], projection]"></path>
<circle :cx="projection[0]" :cy="projection[1]" r="14" />
<text dy="-8">
<textPath href="#distance" startOffset="50%" text-anchor="middle">
{{ projDist.toFixed(1) }}
</textPath>
</text>
</g>
<path :d="'M' + offset" stroke="silver" fill="none" />
<connector :start="points[0]" :end="points[1]" class="line line1"></connector>
<connector :start="points[1]" :end="points[2]" class="line line2" :style="line2Style"></connector>
<drag-node v-for="(p, i) in points" v-model="points[i]"></drag-node>
</svg>
<pre>{{ JSON.stringify(points) }}</pre>
</section>
/*
* https://algorithmtutor.com/Computational-Geometry/Determining-if-two-consecutive-segments-turn-left-or-right/
*/
function direction(p1, p2, p3) {
function vector(from, to) {
return [to[0] - from[0], to[1] - from[1]];
}
// Calculates the cross product of vectors v1 and v2:
// - If v2 is clockwise from v1 wrt origin then it returns +ve value.
// - If v2 is anti-clockwise from v1 wrt origin then it returns -ve value.
// - If v2 and v1 are collinear then it returns 0.
function cross_product(v1, v2) {
return v1[0] * v2[1] - v2[0] * v1[1];
}
const v1 = vector(p1, p2),
v2 = vector(p1, p3),
cross = cross_product(v1, v2);
if(cross) { return (cross > 0) ? 1 : -1; }
//Collinear:
return (Math.abs(v1[0]) < Math.abs(v2[0])) || (Math.abs(v1[1]) < Math.abs(v2[1]))
//Straight ahead
? 0
//Turn back
: -0;
}
/*
* https://stackoverflow.com/questions/3120357/get-closest-point-to-a-line
* https://stackoverflow.com/a/3121418/1869660
* https://stackoverflow.com/a/3122532/1869660
*/
function closestPoint(a, b, p, clamp) {
function vector(from, to) {
return [to[0] - from[0], to[1] - from[1]];
}
function dot(v1, v2) {
return (v1[0] * v2[0]) + (v1[1] * v2[1]);
}
const ap = vector(a, p),
ab = vector(a, b);
//The dot product of AP and AB, over the squared magnitude of AB
//gives us the normalized "distance" from A to the closest point:
let k = dot(ap, ab) / dot(ab, ab);
if(clamp) {
if(k < 0) { k = 0; }
else if(k > 1) { k = 1; }
}
//Add the distance to A, moving towards B:
const x = [
a[0] + k*ab[0],
a[1] + k*ab[1],
];
//console.log(x);
return x;
}
function offset(poly, dist) {
function offsetSeg(p1, p2) {
const [x1, y1] = p1,
[x2, y2] = p2,
dx = x2 - x1,
dy = y2 - y1,
len = Math.hypot(dx, dy);
//https://math.stackexchange.com/questions/1966404/find-a-2d-unit-vector-perpendicular-to-a-given-vector
//`multiplier` is already signed and will flip dx and dy as needed
const multiplier = dist / len,
xPerp = -dy * multiplier, //((dist > 0) ? -dy : dy) * multiplier,
yPerp = dx * multiplier; //((dist > 0) ? dx : -dx) * multiplier;
//Return the offset segment on the form [start point, dx and dy],
//which are the values we'll need in `intersect()` later:
return [x1 + xPerp, y1 + yPerp, dx, dy];
}
function intersect(seg1, seg2) {
const [x1, y1, dx1, dy1] = seg1,
[x2, y2, dx2, dy2] = seg2;
/*
https://math.stackexchange.com/questions/89769/intersection-point-of-line-segments
x1 + t*dx1 = x2 + u*dx2 and y1 + t*dy1 = y2 + u*dy2
u = (y1 - y2 + t*dy1) / dy2
t = (x2 - x1 + u*dx2) / dx1
t = (x2 - x1 + ((y1 - y2 + t*dy1) / dy2)*dx2) / dx1
...
t = (dy2 * (x2 - x1) + dx2 * (y1 - y2)) / (dx1 * dy2 - dx2 * dy1)
*/
const t = (dy2 * (x2 - x1) + dx2 * (y1 - y2)) / (dx1 * dy2 - dx2 * dy1),
p = [x1 + t * dx1, y1 + t * dy1];
return p;
}
const offSegs = [];
for (let i = 1; i < poly.length; i++) {
const p1 = poly[i - 1],
p2 = poly[i];
offSegs.push(offsetSeg(p1, p2));
}
const points = [offSegs[0].slice(0, 2)];
for (let i = 1; i < offSegs.length; i++) {
const s1 = offSegs[i - 1],
s2 = offSegs[i];
points.push(intersect(s1, s2))
}
const [xLast, yLast, dxLast, dyLast] = offSegs.at(-1);
points.push([xLast + dxLast, yLast + dyLast]);
return points;
}
(function() {
"use strict";
const SIZE = 400;
function rnd(max) {
return Math.round(Math.random()*max);
}
//Global state model. Can be changed from within Vue or from the outside.
const _state = {
size: [SIZE, SIZE],
points: [
[rnd(SIZE), rnd(SIZE)],
[rnd(SIZE), rnd(SIZE)],
[rnd(SIZE), rnd(SIZE)],
],
clampDist: false,
offsetDist: 30,
};
Vue.component('drag-node', {
template: '<circle data-draggable @dragging="onDragging" :cx="coord[0]" :cy="coord[1]" r="1em" />',
props: {
coord: Array,
},
model: {
prop: 'coord',
event: 'do_it',
},
methods: {
onDragging(e) {
const point = e.detail.pos;
this.$emit('do_it', point);
},
},
});
Vue.component('connector', {
template: '<line class="connector" :x1="start[0]" :y1="start[1]" :x2="end[0]" :y2="end[1]" />',
props: ['start', 'end'],
});
new Vue({
el: '#app',
data: _state,
computed: {
line2Style() {
let color;
const dir = direction(...this.points);
switch(dir) {
case 1: color = 'red'; break;
case -1: color = 'lime'; break;
case 0: color = Object.is(dir, -0) ? 'gold' : 'black';
}
return 'stroke:' + color;
},
projection() {
const [p1, p2, p3] = this.points;
return closestPoint(p1, p2, p3, this.clampDist);
},
projDist() {
const [x1, y1] = this.points[2],
[x2, y2] = this.projection,
dx = x2 - x1,
dy = y2 - y1,
dist = Math.sqrt(dx*dx + dy*dy);
return dist;
},
offset() {
return offset(this.points, this.offsetDist);
}
},
mounted() {
dragTracker({
container: document.querySelector('#app svg'),
selector: '[data-draggable]',
dragOutside: false,
callback: (node, pos) => {
//Doesn't look like this binding is two-way,
//so we must dispatch a custom event which is handled by the node's Vue component...
// node.setAttribute('cx', pos[0]);
// node.setAttribute('cy', pos[1]);
var event = document.createEvent('CustomEvent');
event.initCustomEvent('dragging', true, false, { pos } );
//var event = new CustomEvent('dragging', { detail: { pos } });
node.dispatchEvent(event);
},
});
}
});
})();
html, body { height: 100%; }
body {
display: flex;
flex-flow: column nowrap;
margin: 0;
justify-content: center;
align-items: center;
font-family: Georgia, sans-serif;
h1, h2 {
margin: 0;
}
ul {
padding: 0;
}
}
#app {
label {
display: table;
cursor: pointer;
}
svg {
background: white;
box-shadow: 0 0 100px 0 gainsboro;
.line {
stroke: dodgerblue;
stroke-width: 20;
}
.line2 {
stroke-width: 10;
}
[data-draggable] {
stroke: black;
stroke-width: 2;
fill: rgba(black, .2);
cursor: move;
}
.projection {
circle, .extension, #distance {
fill: transparent;
stroke: gainsboro;
stroke-width: 2;
stroke-dasharray: 6;
}
}
}
pre {
flex: 1 1 auto;
background: white;
color: #888;
border: 1px solid gainsboro;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment