A Pen by Andreas Borgen on CodePen.
Created
February 23, 2026 02:21
-
-
Save Sphinxxxx/770d042dfda4c6bc3cdea3de8d7a7947 to your computer and use it in GitHub Desktop.
Polyline direction
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * 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); | |
| }, | |
| }); | |
| } | |
| }); | |
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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