|
"use strict"; |
|
console.clear(); |
|
|
|
//`${x} ${x} ${x} ${x}` |
|
(function gradGen() { |
|
|
|
/* Utils */ |
|
|
|
function getLength(start, end) { |
|
const dx = end ? (end[0] - start[0]) : start[0], |
|
dy = end ? (end[1] - start[1]) : start[1]; |
|
|
|
return Math.sqrt(dx*dx + dy*dy); |
|
} |
|
|
|
function getVector(start, end) { |
|
const dx = end ? (end[0] - start[0]) : start[0], |
|
dy = end ? (end[1] - start[1]) : start[1]; |
|
|
|
let length = Math.sqrt(dx*dx + dy*dy), |
|
//https://gamedev.stackexchange.com/questions/33709/get-angle-in-radians-given-a-point-on-a-circle |
|
radians = Math.atan2(dy, dx); |
|
if(radians < 0) { |
|
radians += Math.PI * 2; |
|
} |
|
|
|
return { length, radians }; |
|
} |
|
|
|
// p1 & p2: Two points which the line passes through |
|
// p0: A point outside the line, for which we'll find the closest point on the line |
|
//https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line |
|
function pointOnLine(p1, p2, p0) { |
|
const x1 = p1[0], y1 = p1[1], |
|
x2 = p2[0], y2 = p2[1], |
|
x0 = p0[0], y0 = p0[1], |
|
a = y2 - y1, |
|
b = x1 - x2, |
|
c = x2*y1 - y2*x1; |
|
|
|
const x = (b*(b*x0 - a*y0) - a*c) / |
|
(a*a + b*b), |
|
y = (a*(-b*x0 + a*y0) - b*c) / |
|
(a*a + b*b); |
|
|
|
return [x, y]; |
|
} |
|
|
|
/* /Utils */ |
|
|
|
|
|
//Global state model. Can be changed from within Vue or from the outside. |
|
const __w = Math.min(window.innerWidth * .9, 500), |
|
__h = __w * .7; |
|
const _state = |
|
{ |
|
size: [Math.round(__w), Math.round(__h)], |
|
gradient: { |
|
shape: 'ellipse', |
|
start: [.19, .19], |
|
extent: 'closest-corner', |
|
//extent: [.23, .20], |
|
stops: [ |
|
{ |
|
color1: 'rgba(255,241,97, 0)', |
|
dist: .08, |
|
smooth: false, |
|
color2: 'rgb(255,253,20)', |
|
//}, { |
|
// color1: 'rgb(255,241,97)', |
|
// dist: .16, |
|
// smooth: false, |
|
// color2: 'rgb(206,214,49)', |
|
}, { |
|
color1: 'rgb(206,214,49)', |
|
dist: .37, |
|
smooth: false, |
|
color2: 'rgb(131,194,43)', |
|
//}, { |
|
// color1: 'rgb(131,194,43)', |
|
// dist: .69, |
|
// smooth: false, |
|
// color2: 'rgba(80,141,133, 0.67)', |
|
//}, { |
|
// color1: 'rgba(80,141,133, 0.67)', |
|
// dist: .75, |
|
// smooth: true, |
|
// color2: null, |
|
}, { |
|
color1: 'rgba(80,141,133, 0)', |
|
dist: 1, |
|
smooth: true, |
|
color2: null, |
|
}, |
|
], |
|
currStop: null, |
|
repeating: true, |
|
}, |
|
}; |
|
|
|
function isLinear() { |
|
return (_state.gradient.shape === 'line'); |
|
} |
|
|
|
function setSize(size) { |
|
const w = Math.max(0, size[0]), |
|
h = Math.max(0, size[1]); |
|
_state.size = [w, h]; |
|
} |
|
|
|
function setStart(pixelPos) { |
|
const size = _state.size; |
|
_state.gradient.start = [pixelPos[0]/size[0], pixelPos[1]/size[1]]; |
|
}; |
|
|
|
function setExtent(pixelPos) { |
|
const s = _state, |
|
size = s.size, |
|
g = s.gradient, |
|
start = g.start; |
|
|
|
const relPos = [pixelPos[0]/size[0], pixelPos[1]/size[1]]; |
|
g.extent = [relPos[0] - start[0], relPos[1] - start[1]]; |
|
} |
|
|
|
function getExtentPos() { |
|
const s = _state, |
|
g = s.gradient, |
|
start = g.start, |
|
ext = g.extent; |
|
|
|
let extPos; |
|
if(Array.isArray(ext)) { |
|
extPos = [ext[0] + start[0], ext[1] + start[1]]; |
|
} |
|
else { |
|
extPos = parseExtent(); |
|
} |
|
return extPos; |
|
} |
|
|
|
function getExtentVector() { |
|
const s = _state, |
|
w = s.size[0], |
|
h = s.size[1], |
|
g = s.gradient, |
|
start = g.start, |
|
ext = getExtentPos(); |
|
|
|
const pxStart = [start[0] * w, start[1] * h], |
|
pxExt = [ext[0] * w, ext[1] * h], |
|
vector = getVector(pxStart, pxExt); |
|
|
|
return vector; |
|
} |
|
|
|
function getRelExtentCorner() { |
|
const s = _state, |
|
g = s.gradient, |
|
ext = g.extent; |
|
|
|
let corner; |
|
if(Array.isArray(ext)) { |
|
if(g.shape === 'circle') { |
|
//Circle: Largest bounding square |
|
const w = s.size[0], |
|
h = s.size[1], |
|
extW = Math.abs(ext[0] * w), |
|
extH = Math.abs(ext[1] * h); |
|
corner = (extW > extH) |
|
? [ext[0], extW/h * (ext[1]<0 ? -1 : 1)] |
|
: [extH/w * (ext[0]<0 ? -1 : 1), ext[1]]; |
|
} |
|
else { |
|
corner = ext.slice(); |
|
} |
|
} |
|
return corner; |
|
} |
|
|
|
function getExtentTangents() { |
|
const s = _state, |
|
g = s.gradient, |
|
tangents = []; |
|
|
|
function extend(tang, factor) { |
|
const dx = tang.end[0] - tang.start[0], |
|
dy = tang.end[1] - tang.start[1]; |
|
tang.end = [ |
|
tang.end[0] + dx * factor, |
|
tang.end[1] + dy * factor, |
|
]; |
|
return tang; |
|
} |
|
|
|
if(isLinear()) { |
|
const extVec = getExtentVector(), |
|
extRads = extVec.radians; |
|
|
|
let diagFrom = [0, 0], |
|
diagTo = [1, 1]; |
|
|
|
const quad = Math.floor(extRads/(Math.PI/2)); |
|
switch(quad) { |
|
case 1: |
|
diagFrom = [1, 0], diagTo = [0, 1]; |
|
break; |
|
case 2: |
|
diagFrom = [1, 1], diagTo = [0, 0]; |
|
break; |
|
case 3: |
|
diagFrom = [0, 1], diagTo = [1, 0]; |
|
break; |
|
} |
|
|
|
const w = s.size[0] * (diagTo[0] - diagFrom[0]), |
|
h = s.size[1] * (diagTo[1] - diagFrom[1]), |
|
contVec = getVector([w, h]); |
|
|
|
|
|
const hyp = contVec.length, |
|
a = extRads - contVec.radians, |
|
gradLine = Math.abs(Math.cos(a)) * hyp; |
|
|
|
const cornerX = Math.cos(extRads) * gradLine, |
|
cornerY = Math.sin(extRads) * gradLine, |
|
corner = [cornerX/w, cornerY/h]; |
|
|
|
|
|
switch(quad) { |
|
case 1: |
|
corner[0] = 1 - corner[0]; |
|
break; |
|
case 2: |
|
corner[0] = 1 - corner[0]; |
|
corner[1] = 1 - corner[1]; |
|
break; |
|
case 3: |
|
corner[1] = 1 - corner[1]; |
|
break; |
|
} |
|
|
|
tangents.push({ |
|
start: diagFrom, |
|
end: corner, |
|
}); |
|
tangents.push(extend({ |
|
start: diagTo, |
|
end: corner, |
|
}, 1)); |
|
} |
|
//Radial (circle or ellipse) |
|
else { |
|
const corner = getRelExtentCorner(); |
|
if(corner) { |
|
const start = g.start; |
|
corner[0] += start[0]; |
|
corner[1] += start[1]; |
|
|
|
//Vertical border: |
|
tangents.push(extend({ |
|
start: corner, |
|
end: [corner[0], start[1]], |
|
}, .5)); |
|
//Horizontal border: |
|
tangents.push(extend({ |
|
start: corner, |
|
end: [start[0], corner[1]], |
|
}, .5)); |
|
} |
|
} |
|
|
|
return tangents; |
|
} |
|
|
|
|
|
function normLen(len, parse = false) { |
|
let result; |
|
if(Array.isArray(len)) { |
|
result = len.map(normLen); |
|
} |
|
else { |
|
const num = Number(len); |
|
if(num || (num === 0)) { |
|
result = printLen(num * 100) + '%'; |
|
} |
|
else { |
|
//Probably an extent keyword |
|
result = parse ? normLen(parseExtent(len)) : len; |
|
} |
|
} |
|
//console.log('norm', parse, len, result); |
|
return result; |
|
} |
|
function printLen(len) { |
|
return len.toFixed(1).replace(/\.0+$/, ''); |
|
} |
|
|
|
function parseExtent() { |
|
const s = _state, |
|
ext = s.gradient.extent; |
|
if(typeof(ext) !== 'string') { return ext; } |
|
|
|
const w = s.size[0], |
|
h = s.size[1], |
|
sX = s.gradient.start[0], |
|
sY = s.gradient.start[1]; |
|
|
|
let x, y, distX, distY; |
|
switch(ext) { |
|
case 'closest-corner': |
|
x = (sX < .5) ? 0 : 1; |
|
y = (sY < .5) ? 0 : 1; |
|
break; |
|
|
|
case 'farthest-corner': |
|
x = (sX > .5) ? 0 : 1; |
|
y = (sY > .5) ? 0 : 1; |
|
break; |
|
|
|
case 'closest-side': |
|
distX = (sX < .5) ? w * sX : w * (1-sX); |
|
distY = (sY < .5) ? h * sY : h * (1-sY); |
|
if(distX < distY) { |
|
x = (sX < .5) ? 0 : 1; |
|
y = sY; |
|
} |
|
else { |
|
x = sX; |
|
y = (sY < .5) ? 0 : 1; |
|
} |
|
break; |
|
|
|
case 'farthest-side': |
|
distX = (sX > .5) ? w * sX : w * (1-sX); |
|
distY = (sY > .5) ? h * sY : h * (1-sY); |
|
if(distX > distY) { |
|
x = (sX > .5) ? 0 : 1; |
|
y = sY; |
|
} |
|
else { |
|
x = sX; |
|
y = (sY > .5) ? 0 : 1; |
|
} |
|
break; |
|
|
|
default: |
|
throw 'Unknown extent: ' + ext; |
|
} |
|
|
|
return [x, y]; |
|
} |
|
|
|
function printGradient() { |
|
/* |
|
radial-gradient( |
|
[ [ circle || <length> ] [ at <position> ]? , | |
|
[ ellipse || [ <length> | <percentage> ]{2} ] [ at <position> ]? , | |
|
[ [ circle | ellipse ] || <extent-keyword> ] [ at <position> ]? , | |
|
at <position> , |
|
]? |
|
<color-stop> [ , <color-stop> ]+ |
|
) |
|
where <extent-keyword> = closest-corner | closest-side | farthest-corner | farthest-side |
|
and <color-stop> = <color> [ <percentage> | <length> ]? |
|
*/ |
|
|
|
//SHAPE EXTENT at START, COLOR, COLOR, COLOR, ... |
|
|
|
const s = _state, |
|
g = s.gradient; |
|
|
|
function printExtent() { |
|
const ext = g.extent; |
|
let normExt; |
|
|
|
if(Array.isArray(ext)) { |
|
normExt = getRelExtentCorner().map(Math.abs); |
|
|
|
if(g.shape === 'circle') { |
|
//circle: Single value, and not percentage: |
|
const w = s.size[0]; |
|
normExt = printLen(normExt[0] * w) + 'px'; |
|
} |
|
else { |
|
//ellipse: Two values, any unit: |
|
normExt = normLen(normExt).join(' '); |
|
} |
|
} |
|
else { |
|
//Extent keyword: |
|
normExt = ext; |
|
} |
|
return normExt; |
|
} |
|
|
|
let grad, |
|
stopTransform = (x) => x; |
|
|
|
if(isLinear()) { |
|
const vector = getExtentVector(), |
|
degrees = vector.radians * 180/Math.PI; |
|
grad = g.repeating ? 'repeating-linear-gradient(' : 'linear-gradient('; |
|
grad += printLen((degrees + 90) % 360) + 'deg, '; |
|
|
|
|
|
const [glFrom, glTo] = (function gradLineRange(gradLine) { |
|
const ww = s.size[0], |
|
hh = s.size[1], |
|
glFrom = gradLine.start, |
|
glTo = gradLine.end; |
|
function abs(coord) { |
|
return [coord[0]*ww, coord[1]*hh]; |
|
} |
|
|
|
//Find where the start and extent points fall on the gradient line (p1 -> p2), |
|
//and thus which range we need to squeeze our color stops inside. |
|
const p1 = abs(glFrom), |
|
p2 = abs(glTo), |
|
gradDirX = p2[0] > p1[0], |
|
gradDirY = p2[1] > p1[1], |
|
gradLineLen = getLength(p1, p2); |
|
|
|
const range = [g.start, getExtentPos()].map(p0 => { |
|
p0 = abs(p0); |
|
|
|
const p = pointOnLine(p1, p2, p0), |
|
pDirX = p[0] > p1[0], |
|
pDirY = p[1] > p1[1], |
|
pLen = ((pDirX === gradDirX) && (pDirY === gradDirY)) |
|
? getLength(p1, p) |
|
: -getLength(p1, p); |
|
|
|
return (pLen/gradLineLen); |
|
}); |
|
|
|
return range; |
|
})(getExtentTangents()[0]); |
|
|
|
|
|
stopTransform = (cs) => { |
|
//https://stackoverflow.com/questions/122102/what-is-the-most-efficient-way-to-deep-clone-an-object-in-javascript |
|
const clone = JSON.parse(JSON.stringify(cs)); |
|
|
|
clone.dist = (glTo - glFrom)*cs.dist + glFrom; |
|
return clone; |
|
}; |
|
} |
|
|
|
//Radial (circle or ellipse) |
|
else { |
|
grad = g.repeating ? 'repeating-radial-gradient(' : 'radial-gradient('; |
|
grad += `${g.shape} ${printExtent()} at ${normLen(g.start).join(' ')}, `; |
|
} |
|
|
|
grad += printColorStops(g.stops.map(stopTransform)); |
|
grad += ')'; |
|
|
|
//console.log('grad', grad); |
|
return grad; |
|
} |
|
|
|
function printColorStops(stops) { |
|
stops = stops.slice() |
|
.sort((a, b) => a.dist - b.dist); |
|
|
|
const parts = stops.map(cs => { |
|
//Truncate alpha values and such with a lot of decimals: |
|
const col1 = cs.color1.replace(/(\.\d\d)\d+/g, '$1'), |
|
doBreak = cs.color2 && !cs.smooth; |
|
|
|
//On a non-repeating radial gradient, you can't finish with a color break at 100% (color2 won't be used). |
|
//The max stop distance for that effect is 99.5% (at least in Chrome..) |
|
const dist = doBreak ? Math.min(cs.dist, .995) : cs.dist; |
|
|
|
let part = `${col1} ${normLen(dist)}`; |
|
if(doBreak) { |
|
const col2 = cs.color2.replace(/(\.\d\d)\d+/g, '$1'); |
|
part += `, ${col2} 0`; |
|
} |
|
return part; |
|
}); |
|
|
|
return parts.join(', '); |
|
} |
|
|
|
function printAbsCoord(relCoord, isPct) { |
|
return isPct ? relCoord.map(a => a*100 + '%') : relCoord.map(a => a + 'px'); |
|
} |
|
|
|
|
|
/* UI created with Vue.js */ |
|
|
|
Vue.component('drag-point', { |
|
props: ['p', 'isPct'], |
|
template: '<div data-draggable @dragging="onDragging" :class="classObj" :style="styleObj"></div>', |
|
computed: { |
|
absCoord() { |
|
return printAbsCoord(this.p, this.isPct); |
|
}, |
|
classObj() { |
|
return { |
|
//selected: (this.p === _svgState.selectedPoint), |
|
}; |
|
}, |
|
styleObj() { |
|
return { |
|
position: 'absolute', |
|
left: this.absCoord[0], |
|
top: this.absCoord[1], |
|
}; |
|
}, |
|
}, |
|
methods: { |
|
//Just relay the event up to the parent. |
|
//Looks like the parent can't set an event handler for the original event on a component, |
|
//e.g. <svg-point class="start" @dragging="myParentFunction" ... |
|
onDragging(e) { this.$emit('drag_relay', e); }, |
|
} |
|
}); |
|
|
|
Vue.component('connector', { |
|
props: ['start', 'end'], |
|
template: '<div class="line" :style="styleObj"></div>', |
|
computed: { |
|
styleObj() { |
|
const w = _state.size[0], |
|
h = _state.size[1], |
|
dx = (this.end[0] - this.start[0]) * w, |
|
dy = (this.end[1] - this.start[1]) * h, |
|
vector = getVector([dx, dy]); |
|
return { |
|
position: 'absolute', |
|
left: this.start[0]*w + 'px', |
|
top: this.start[1]*h + 'px', |
|
width: vector.length + 'px', |
|
transformOrigin: 'left center', |
|
transform: `translateY(-50%) rotate(${vector.radians}rad)`, |
|
}; |
|
}, |
|
}, |
|
methods: { |
|
} |
|
}); |
|
|
|
Vue.component('color-stop', { |
|
props: ['cs'], |
|
template: |
|
`<div class="stop-marker" :class="classObj" :style="styleObj" |
|
@dragging="onDragging" @mousedown="onClick" @touchstart="onClick" @dblclick="onRemove"> |
|
<div class="marker-color-bg"> |
|
<div class="marker-color" :style="colorStyleObj"></div> |
|
</div> |
|
</div>`, |
|
data: function () { |
|
return { |
|
//expanded: false, |
|
} |
|
}, |
|
computed: { |
|
styleObj() { |
|
const cs = this.cs; |
|
return { |
|
position: 'absolute', |
|
left: cs.dist * 100 + '%', |
|
//color: cs.color1, |
|
}; |
|
}, |
|
classObj() { |
|
return { |
|
active: (this.cs === _state.gradient.currStop), |
|
}; |
|
}, |
|
colorStyleObj() { |
|
const cs = this.cs, |
|
doBreak = cs.color2 && !cs.smooth; |
|
return { |
|
background: doBreak |
|
? `linear-gradient(90deg, ${cs.color1} 50%, ${cs.color2} 0)` |
|
: cs.color1, |
|
}; |
|
}, |
|
}, |
|
methods: { |
|
onDragging(e) { |
|
const x = e.detail.pos[0], |
|
slider = e.target.closest('#stops-slider'); |
|
this.cs.dist = x/slider.clientWidth; |
|
}, |
|
onClick(e) { |
|
const stop = _state.gradient.currStop = this.cs; |
|
cp1.setColor(stop.color1); |
|
cp2.setColor(stop.color2 || stop.color1); |
|
}, |
|
onRemove(e) { |
|
e.preventDefault(); |
|
|
|
const stops = _state.gradient.stops, |
|
i = stops.indexOf(this.cs); |
|
stops.splice(i, 1); |
|
}, |
|
} |
|
}); |
|
|
|
new Vue({ |
|
el: '#app', |
|
data: { |
|
state: _state, |
|
}, |
|
computed: { |
|
g() { |
|
return this.state.gradient; |
|
}, |
|
extentPoint: getExtentPos, |
|
extentTangents: getExtentTangents, |
|
containerStyle: function gradStyle() { |
|
return { |
|
width: this.state.size[0] + 'px', |
|
height: this.state.size[1] + 'px', |
|
}; |
|
}, |
|
gradStyle: function gradStyle() { |
|
//Hack to refresh Chrome if only the gradient's extent has changed: |
|
//https://bugs.chromium.org/p/chromium/issues/detail?id=775201 |
|
if(!isLinear()) { |
|
document.querySelector('#gradient').style.backgroundImage = ''; |
|
document.querySelector('#gradient').style.backgroundImage = this.printGradient(); |
|
} |
|
|
|
return { |
|
backgroundImage: this.printGradient() |
|
}; |
|
}, |
|
sliderStyle() { |
|
var g = this.state.gradient; |
|
return { |
|
backgroundImage: `linear-gradient(90deg, ${printColorStops(g.stops)})`, |
|
}; |
|
}, |
|
cssBackground() { |
|
const bg = `background-image: ${this.printGradient()};`; |
|
//Homebrew pretty-print: |
|
return bg.replace('(', '(\n ') |
|
.replace(',', ',\n ') |
|
.replace(');', '\n);'); |
|
}, |
|
}, |
|
methods: { |
|
printGradient, |
|
setStart(e) { |
|
setStart(e.detail.pos); |
|
}, |
|
setExtent(e) { |
|
setExtent(e.detail.pos); |
|
}, |
|
setSize(e) { |
|
setSize(e.detail.pos); |
|
}, |
|
}, |
|
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; |
|
} |
|
}, |
|
}); |
|
|
|
|
|
/* |
|
User input. |
|
Vue replaces the original elements, so we must wait until now to enable dragging |
|
*/ |
|
|
|
function raiseDragEvent(dragged, pos) { |
|
//Doesn't look like this binding is two-way, |
|
//so we must dispatch a custom event which is handled by the point's Vue component... |
|
// dragged.setAttribute('cx', pos[0]); |
|
// dragged.setAttribute('cy', pos[1]); |
|
|
|
var event = document.createEvent('CustomEvent'); |
|
event.initCustomEvent('dragging', true, false, { pos } ); |
|
//var event = new CustomEvent('dragging', { detail: { pos } }); |
|
dragged.dispatchEvent(event); |
|
} |
|
|
|
dragTracker({ |
|
container: document.querySelector('#grad-container'), |
|
selector: '[data-draggable]', |
|
handleOffset: 'center', |
|
callback: raiseDragEvent, |
|
}); |
|
|
|
|
|
const colorStops = document.querySelector('#stops-slider'), |
|
cp1 = new Picker({ |
|
parent: document.querySelector('#color-picker1'), |
|
popup: false, |
|
onChange: function(color) { |
|
const stop = _state.gradient.currStop; |
|
if(stop) { stop.color1 = color.rgbaString; } |
|
}, |
|
}), |
|
cp2 = new Picker({ |
|
parent: document.querySelector('#color-picker2'), |
|
popup: false, |
|
onChange: function(color) { |
|
const stop = _state.gradient.currStop; |
|
if(stop) { stop.color2 = color.rgbaString; } |
|
}, |
|
}); |
|
|
|
dragTracker({ |
|
container: colorStops, |
|
selector: '.stop-marker', |
|
handleOffset: 'center', |
|
dragOutside: false, |
|
callback: raiseDragEvent, |
|
}); |
|
|
|
//Add color stops: |
|
//Use a different child element to collect clicks, |
|
//to avoid all problems with event bubbling and "drag vs click" conflicts: |
|
colorStops.querySelector('#stops-preview').onclick = function(e) { |
|
const stopsBounds = colorStops.getBoundingClientRect(), |
|
x = (e.clientX - stopsBounds.left)/stopsBounds.width, |
|
newStop = { |
|
color1: cp1.color.rgbaString, |
|
dist: x, |
|
smooth: true, |
|
color2: null, |
|
}; |
|
|
|
_state.gradient.stops.push(newStop); |
|
_state.gradient.currStop = newStop; |
|
}; |
|
|
|
})(); |