|
(function() { |
|
"use strict"; |
|
console.clear(); |
|
|
|
class Shape { |
|
constructor(corners) { |
|
this.corners = corners || []; |
|
} |
|
} |
|
|
|
class Corner { |
|
constructor(point, controlOffsets) { |
|
this.point = point; |
|
this.controlOffsets = controlOffsets; |
|
} |
|
|
|
absControls() { |
|
const center = this.point; |
|
return this.controlOffsets.map(x => [x[0] + center[0], x[1] + center[1]]); |
|
} |
|
} |
|
|
|
class BezierWrapper { |
|
constructor(controls, sampleCount, sampleByDist, classname) { |
|
this.controls = controls; |
|
this.classname = classname; |
|
|
|
if(sampleCount) { |
|
function point2obj(p) { |
|
return p; |
|
//return { x: p[0], y: p[1] }; |
|
} |
|
//https://gamedev.stackexchange.com/a/5427 |
|
const interpolator = new Bezier(point2obj(controls[0]), |
|
point2obj(controls[1]), |
|
point2obj(controls[2]), |
|
point2obj(controls[3])); |
|
const samples = this.samples = []; |
|
for(let i = 1; i <= sampleCount; i++) { |
|
const t = i / (sampleCount+1); |
|
samples.push(sampleByDist ? interpolator.pointAtDist(t) : interpolator.pointAtT(t)); |
|
} |
|
} |
|
} |
|
|
|
static lerpCurve(source, target, t) { |
|
|
|
function lerpCoord(from, to, t) { |
|
const diffX = to[0] - from[0], |
|
diffY = to[1] - from[1], |
|
lerpX = from[0] + (diffX * t), |
|
lerpY = from[1] + (diffY * t); |
|
return [lerpX, lerpY]; |
|
} |
|
|
|
const middle = source.map((c, i) => lerpCoord(c, target[i], t)); |
|
return middle; |
|
} |
|
|
|
static fitCurve(source, start, end) { |
|
|
|
function distance(p1, p2) { |
|
const dx = p2[0] - p1[0], |
|
dy = p2[1] - p1[1]; |
|
return Math.sqrt(dx*dx + dy*dy); |
|
} |
|
|
|
//https://gist.github.com/conorbuck/2606166 |
|
function angle(p1, p2) { |
|
const dx = p2[0] - p1[0], |
|
dy = p2[1] - p1[1], |
|
radians = Math.atan2(dy, dx); |
|
return radians; |
|
} |
|
|
|
//https://stackoverflow.com/questions/2259476/rotating-a-point-about-another-point-2d |
|
function rotate(p, radians) { |
|
const x = p[0], |
|
y = p[1], |
|
cos = Math.cos(radians), |
|
sin = Math.sin(radians); |
|
|
|
return [cos*x - sin*y, sin*x + cos*y]; |
|
} |
|
|
|
const sourceStart = source[0], |
|
sourceEnd = source[3], |
|
scale = distance(start, end)/distance(sourceStart, sourceEnd), |
|
rot = angle(start, end) - angle(sourceStart, sourceEnd); |
|
|
|
//Translate, scale and rotate the source control points to make them fit the start and end points: |
|
const sourceNorm = source.map(c => [c[0] - sourceStart[0], c[1] - sourceStart[1]]), |
|
fittedNorm = sourceNorm.map(c => rotate([c[0]*scale, c[1]*scale], rot)), |
|
fitted = fittedNorm.map(c => [c[0] + start[0], c[1] + start[1]]); |
|
|
|
return fitted; |
|
} |
|
} |
|
|
|
|
|
//Global state model. Can be changed from within Vue or from the outside. |
|
const _svgState = { |
|
size: [400, 400], |
|
shape: new Shape([ |
|
new Corner( |
|
[65, 78], |
|
[ [-38, 79], [63, -52] ] |
|
), |
|
new Corner( |
|
[336, 101], |
|
[ [-46, -57], [8, 76] ] |
|
), |
|
new Corner( |
|
[113, 356], |
|
[ [-83, -40], [56, -53] ] |
|
), |
|
new Corner( |
|
[282, 248], |
|
[ [-52, -37], [30, -59] ] |
|
) |
|
]), |
|
dividers: 3, |
|
divideByDist: true, |
|
}; |
|
|
|
|
|
Vue.component('drag-node', { |
|
template: '<circle data-draggable @dragging="onDragging" :cx="absCoord[0]" :cy="absCoord[1]" :r="r" />', |
|
props: { |
|
coord: Array, |
|
//If 'coord' is relative to some other point: |
|
offsetCenter: Array, |
|
|
|
r: { |
|
default: 16, |
|
} |
|
}, |
|
model: { |
|
prop: 'coord', |
|
event: 'do_it', |
|
}, |
|
computed: { |
|
absCoord() { |
|
const point = this.coord, |
|
center = this.offsetCenter, |
|
absCoord = center ? [ point[0] + center[0], point[1] + center[1] ] |
|
: point; |
|
return absCoord; |
|
}, |
|
}, |
|
methods: { |
|
onDragging(e) { |
|
const point = e.detail.pos, |
|
center = this.offsetCenter, |
|
relCoord = center ? [ point[0] - center[0], point[1] - center[1] ] |
|
: point; |
|
this.$emit('do_it', relCoord); |
|
}, |
|
}, |
|
}); |
|
|
|
Vue.component('connector', { |
|
template: '<line class="connector" :x1="start[0]" :y1="start[1]" :x2="end[0]" :y2="end[1]" />', |
|
props: ['start', 'end'], |
|
}); |
|
|
|
Vue.component('bezier', { |
|
template: |
|
`<g class="bezier" :class="wrapper.classname"> |
|
<path :d="pathData" /> |
|
<rect v-for="s in wrapper.samples" :x="s[0]-2" :y="s[1]-2" width="4" height="4" /> |
|
</g>`, |
|
props: ['wrapper'], |
|
computed: { |
|
pathData() { |
|
const cs = this.wrapper.controls; |
|
return `M${cs[0]} C${cs[1]} ${cs[2]} ${cs[3]}`; |
|
} |
|
}, |
|
}); |
|
|
|
Vue.component('corner', { |
|
template: |
|
`<g class="corner"> |
|
<connector :start="c.point" :end="absControls[0]" /> |
|
<connector :start="c.point" :end="absControls[1]" /> |
|
<drag-node class="corner-point" v-model="c.point" /> |
|
<drag-node class="corner-control" v-model="c.controlOffsets[0]" :offsetCenter="c.point" /> |
|
<drag-node class="corner-control" v-model="c.controlOffsets[1]" :offsetCenter="c.point" /> |
|
</g>`, |
|
props: { |
|
c: Object, |
|
}, |
|
computed: { |
|
absControls() { |
|
return this.c.absControls(); |
|
}, |
|
}, |
|
methods: { |
|
}, |
|
}); |
|
|
|
new Vue({ |
|
el: '#app', |
|
data: { |
|
svg: _svgState, |
|
debugSinglePath: false, |
|
}, |
|
computed: { |
|
}, |
|
methods: { |
|
getCorners() { |
|
const corners = this.svg.shape.corners; |
|
if(this.debugSinglePath) { |
|
return corners.slice(0, 2); |
|
} |
|
return corners; |
|
}, |
|
getBeziers() { |
|
const corners = this.svg.shape.corners, |
|
endpoints = corners.map(x => x.point), |
|
controls = corners.map(x => x.absControls()), |
|
dividers = this.svg.dividers; |
|
|
|
const ctlTop = [endpoints[0], controls[0][1], controls[1][0], endpoints[1]], |
|
ctlBottom = [endpoints[2], controls[2][1], controls[3][0], endpoints[3]], |
|
ctlLeft = [endpoints[0], controls[0][0], controls[2][0], endpoints[2]], |
|
ctlRight = [endpoints[1], controls[1][1], controls[3][1], endpoints[3]]; |
|
if(this.debugSinglePath) { |
|
return [new BezierWrapper(ctlTop, dividers, this.svg.divideByDist)]; |
|
} |
|
|
|
const ctlDividersHoriz = [], |
|
ctlDividersVert = []; |
|
for(let i = 1; i <= dividers; i++) { |
|
const t = i / (dividers+1), |
|
ctlHoriz = BezierWrapper.lerpCurve(ctlTop, ctlBottom, t), |
|
ctlVert = BezierWrapper.lerpCurve(ctlLeft, ctlRight, t); |
|
ctlDividersHoriz.push(ctlHoriz); |
|
ctlDividersVert.push(ctlVert); |
|
} |
|
|
|
const byDist = this.svg.divideByDist; |
|
const bezTop = new BezierWrapper(ctlTop, dividers, byDist), |
|
bezBottom = new BezierWrapper(ctlBottom, dividers, byDist), |
|
bezLeft = new BezierWrapper(ctlLeft, dividers, byDist), |
|
bezRight = new BezierWrapper(ctlRight, dividers, byDist); |
|
|
|
const bezDividersHoriz = ctlDividersHoriz.map((c, i) => { |
|
const fitted = BezierWrapper.fitCurve(c, bezLeft.samples[i], bezRight.samples[i]); |
|
return new BezierWrapper(fitted, 0, false, 'divider'); |
|
}); |
|
const bezDividersVert = ctlDividersVert.map((c, i) => { |
|
const fitted = BezierWrapper.fitCurve(c, bezTop.samples[i], bezBottom.samples[i]); |
|
return new BezierWrapper(fitted, 0, false, 'divider'); |
|
}); |
|
|
|
return [bezTop, bezBottom, bezLeft, bezRight].concat(bezDividersHoriz).concat(bezDividersVert); |
|
} |
|
}, |
|
filters: { |
|
prettyCompact: function(obj) { |
|
if(!obj) return ''; |
|
const pretty = JSON.stringify(obj, null, 2), |
|
//Collapse simple arrays (arrays without objects or nested arrays) to one line: |
|
compact = pretty.replace(/\[[^[{]*?]/g, (match => match.replace(/\s+/g, ' '))) |
|
|
|
return compact; |
|
} |
|
}, |
|
}); |
|
|
|
|
|
//Vue replaces the original <svg> element, so we must wait until now to enable dragging: |
|
dragTracker({ |
|
container: document.querySelector('#app svg'), |
|
selector: '[data-draggable]', |
|
callback: (node, pos) => { |
|
//var event = new CustomEvent('dragging', { detail: { pos } }); |
|
var event = document.createEvent('CustomEvent'); |
|
event.initCustomEvent('dragging', true, false, { pos }); |
|
|
|
node.dispatchEvent(event); |
|
}, |
|
}); |
|
|
|
})(); |