annotation tools for making the iterations publication (Manetta & Jara) - https://iterations.space/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

860 lines
22 KiB

// Copyright 2018 Raph Levien
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.
//! UI for drawing splines
/// Fancy name for something that just detects double clicks, but might expand.
class GestureDet {
constructor(ui) {
this.ui = ui;
this.lastEv = null;
this.lastPt = null;
this.clickCount = 0;
}
onPointerDown(ev) {
let dblClickThreshold = 550; // ms
let radiusThreshold = 5;
let pt = this.ui.getCoords(ev);
if (this.lastEv !== null) {
if (ev.timeStamp - this.lastEv.timeStamp > dblClickThreshold
|| Math.hypot(pt.x - this.lastPt.x, pt.y - this.lastPt.y) > radiusThreshold) {
this.clickCount = 0;
}
}
this.lastEv = ev;
this.lastPt = pt;
this.clickCount++;
}
}
// Dimensions of the tangent target
// Inside this radius, convert to corner point
let tanR1 = 5;
// Radius of drawn tangent marker
let tanR2 = 15;
// Outside this radius, remove explicit tangent
let tanR3 = 45;
/// State and UI for an editable spline
class SplineEdit {
constructor(ui) {
this.ui = ui;
this.knots = [];
this.isClosed = false;
this.bezpath = new BezPath;
this.selection = new Set();
this.mode = "start";
this.grid = 20;
this.shiftOnDrag = false;
}
renderGrid(visible) {
let grid = document.getElementById("grid");
this.ui.removeAllChildren(grid);
if (!visible) return;
let w = 640;
let h = 480;
for (let i = 0; i < w; i += this.grid) {
let line = this.ui.createSvgElement("line");
line.setAttribute("x1", i);
line.setAttribute("y1", 0);
line.setAttribute("x2", i);
line.setAttribute("y2", h);
grid.appendChild(line);
}
for (let i = 0; i < h; i += this.grid) {
let line = this.ui.createSvgElement("line");
line.setAttribute("x1", 0);
line.setAttribute("y1", i);
line.setAttribute("x2", w);
line.setAttribute("y2", i);
grid.appendChild(line);
}
}
setSelection(sel) {
this.selection = sel;
}
roundToGrid(pt) {
let g = this.grid;
return new Vec2(g * Math.round(pt.x / g), g * Math.round(pt.y / g));
}
onPointerDown(ev, obj) {
let pt = this.ui.getCoords(ev);
if (obj === null) {
let subdivideDist = 5;
if (ev.shiftKey) {
pt = this.roundToGrid(pt);
}
let ty = ev.altKey ? "smooth" : "corner";
let hit = this.bezpath.hitTest(pt.x, pt.y);
let insIx = this.knots.length;
if (hit.bestDist < subdivideDist) {
ty = "smooth";
insIx = hit.bestMark + 1;
}
let knot = new Knot(this, pt.x, pt.y, ty);
this.knots.splice(insIx, 0, knot);
this.ui.attachReceiver(knot.handleEl, this, knot);
// TODO: setter rather than state change?
this.ui.receiver = this;
this.setSelection(new Set([knot]));
if (ty === "corner") {
this.mode = "creating";
} else {
this.mode = "dragging";
}
this.initPt = pt;
} else if (obj instanceof TanHandle) {
this.mode = "tanhandle";
this.isRight = obj.isRight;
// This suggests maybe we should use selected point, not initPt
this.initPt = new Vec2(obj.knot.x, obj.knot.y);
this.updateTan(obj.knot, pt, ev);
this.setSelection(new Set([obj.knot]));
} else {
if (this.selection.size == 1
&& this.selection.has(this.knots[this.knots.length - 1])
&& this.knots.length >= 3
&& obj === this.knots[0])
{
this.isClosed = true;
}
this.mode = "dragging";
if (this.ui.gestureDet.clickCount > 1) {
if (obj.ty === "corner") {
obj.setTy("smooth");
} else {
obj.setTy("corner");
}
obj.setTan(null);
}
let sel;
this.shiftOnDrag = ev.shiftKey;
if (ev.shiftKey) {
// toggle point selection
sel = new Set(this.selection);
if (sel.has(obj)) {
sel.delete(obj);
} else {
sel.add(obj);
}
} else {
if (this.selection.has(obj)) {
sel = this.selection;
} else {
sel = new Set([obj]);
}
}
this.setSelection(sel);
}
this.render();
this.lastPt = pt;
}
onPointerMove(ev) {
let pt = this.ui.getCoords(ev);
let dx = pt.x - this.lastPt.x;
let dy = pt.y - this.lastPt.y;
if (this.mode === "dragging") {
for (let knot of this.selection) {
if (ev.shiftKey && !this.shiftOnDrag && this.selection.size == 1) {
pt = this.roundToGrid(pt);
knot.updatePos(pt.x, pt.y);
} else {
knot.updatePos(knot.x + dx, knot.y + dy);
}
}
} else if (this.mode === "creating") {
let r = Math.hypot(pt.x - this.initPt.x, pt.y - this.initPt.y);
let ty = r < tanR1 ? "corner" : "smooth";
for (let knot of this.selection) {
knot.setTy(ty);
}
} else if (this.mode === "tanhandle") {
let r = Math.hypot(pt.x - this.initPt.x, pt.y - this.initPt.y);
for (let knot of this.selection) {
this.updateTan(knot, pt, ev);
}
}
this.render();
this.lastPt = pt;
}
onPointerUp(ev) {
this.mode = "start";
this.render();
}
onPointerHover(ev) {
let pt = this.ui.getCoords(ev);
// TODO: hover the knots and the visible tangent handles
let hit = this.bezpath.hitTest(pt.x, pt.y);
// TODO: display proposed knot
}
onKeyDown(ev) {
if (ev.key === "Backspace" || ev.key === "Delete") {
this.delete();
return true;
} else if (ev.key === "ArrowLeft") {
this.nudge(-1, 0, ev);
return true;
} else if (ev.key === "ArrowRight") {
this.nudge(1, 0, ev);
return true;
} else if (ev.key === "ArrowUp") {
this.nudge(0, -1, ev);
return true;
} else if (ev.key === "ArrowDown") {
this.nudge(0, 1, ev);
return true;
}
return false;
}
delete() {
for (let i = 0; i < this.knots.length; i++) {
let knot = this.knots[i];
if (this.selection.has(knot)) {
this.knots.splice(i, 1);
knot.handleEl.remove();
i--;
}
}
if (this.knots.length < 3) {
this.isClosed = false;
}
this.selection = new Set();
this.render();
}
nudge(dx, dy, ev) {
if (ev && ev.shiftKey) {
dx *= 10;
dy *= 10;
}
for (let knot of this.selection) {
knot.updatePos(knot.x + dx, knot.y + dy);
}
this.render();
}
updateTan(knot, pt, ev) {
let dx = pt.x - this.initPt.x;
let dy = pt.y - this.initPt.y;
if (!this.isRight) {
dx = -dx;
dy = -dy;
}
let r = Math.hypot(dx, dy);
let th = null;
if (r < tanR3) {
th = Math.atan2(dy, dx);
if (ev.shiftKey) {
th = Math.PI / 2 * Math.round(th * (2 / Math.PI));
}
}
knot.setTan(th, this.isRight);
}
renderSpline() {
let ctrlPts = [];
for (let knot of this.knots) {
let pt = new ControlPoint(new Vec2(knot.x, knot.y), knot.ty, knot.lth, knot.rth);
ctrlPts.push(pt);
}
this.spline = new Spline(ctrlPts, this.isClosed);
this.spline.solve();
// Should this be bundled into solve?
this.spline.computeCurvatureBlending();
this.bezpath = this.spline.render();
let path = this.bezpath.renderSvg();
document.getElementById("spline").setAttribute("d", path);
}
renderSel() {
for (let i = 0; i < this.knots.length; i++) {
let knot = this.knots[i];
knot.computedLTh = this.spline.pt(i, 0).lTh;
knot.computedRTh = this.spline.pt(i, 0).rTh;
knot.updateSelDecoration(this.selection.has(knot), this.mode);
}
}
render() {
this.renderSpline();
this.renderSel();
}
/// Return a JSON object (suitable for stringify)
serialize() {
function r(x, adj) {
return Math.round(x * adj) / adj;
}
let pts = [];
for (let knot of this.knots) {
let pt = {"x": r(knot.x, 100), "y": r(knot.y, 100)};
if (knot.ty === "corner") {
pt["c"] = 0;
if (knot.lth !== null) {
pt["l"] = r(knot.lth, 1000);
}
if (knot.rth !== null) {
pt["r"] = r(knot.rth, 1000);
}
} else {
pt["c"] = 1;
if (knot.lth !== null) {
pt["t"] = r(knot.lth, 1000);
}
}
pts.push(pt);
}
let sp = {"closed": this.isClosed, "pts": pts};
let result = {"subpaths": [sp]};
return result;
}
/// Take either a JSON object or a string, and set UI state to it
deserialize(data) {
if (typeof data === 'string') {
data = JSON.parse(data);
}
let sp = data.subpaths[0];
let knots = [];
for (let pt of sp.pts) {
let ty = pt.c ? "smooth" : "corner";
let knot = new Knot(this, pt.x, pt.y, ty);
if (pt.c) {
if ("t" in pt) {
knot.lth = pt.t;
knot.rth = knot.lth;
}
} else {
if ("l" in pt) {
knot.lth = pt.l;
}
if ("r" in pt) {
knot.lth = pt.r;
}
}
knots.push(knot);
}
// Hopefully for invalid data an exception would have occurred by here.
for (let knot of this.knots) {
knot.handleEl.remove();
}
this.knots = knots;
for (let knot of knots) {
this.ui.attachReceiver(knot.handleEl, this, knot);
}
this.isClosed = sp.closed;
console.log(sp.closed);
this.selection = new Set();
this.render();
}
}
class Knot {
/// ty is one of 'corner', 'smooth'.
constructor(se, x, y, ty) {
this.se = se;
this.x = x;
this.y = y;
this.ty = ty;
this.lth = null;
this.rth = null;
this.selected = false;
this.lthLine = null;
this.rthLine = null;
this.lthCircle = null;
this.rthCircle = null;
this.handleEl = this.createHandleEl();
}
createHandleEl() {
let handle = this.se.ui.createSvgElement("g", true);
handle.setAttribute("class", "handle");
handle.setAttribute("transform", `translate(${this.x} ${this.y})`);
// TODO: handles group should probably be variable in ui
document.getElementById("handles").appendChild(handle);
let inner = this.renderHandleEl();
handle.appendChild(inner);
return handle;
}
renderHandleEl() {
let r = 4;
let inner;
if (this.ty === "corner") {
inner = this.se.ui.createSvgElement("rect", true);
inner.setAttribute("x", -r);
inner.setAttribute("y", -r);
inner.setAttribute("width", r * 2);
inner.setAttribute("height", r * 2);
} else {
inner = this.se.ui.createSvgElement("circle", true);
inner.setAttribute("cx", 0);
inner.setAttribute("cy", 0);
inner.setAttribute("r", r);
}
inner.setAttribute("class", "handle");
return inner;
}
setTan(th, isRight) {
if (this.ty === "smooth" || !isRight) {
this.lth = th;
}
if (this.ty === "smooth" || isRight) {
this.rth = th;
}
}
updateSelLine(th, el, r) {
if (th === null && el !== null) {
el.remove();
el = null;
} else if (th !== null && el === null) {
el = this.se.ui.createSvgElement("line");
el.setAttribute("x1", 0);
el.setAttribute("y1", 0);
el.setAttribute("class", "tan");
this.handleEl.appendChild(el);
}
if (el !== null) {
el.setAttribute("x2", r * Math.cos(th));
el.setAttribute("y2", r * Math.sin(th));
}
return el;
}
updateSelCircle(th, el, r, computed) {
if (th === null && el !== null) {
el.remove();
el = null;
} else if (th !== null && el === null) {
el = this.se.ui.createSvgElement("circle", true);
el.setAttribute("r", 3);
el.setAttribute("class", "tanhandle");
this.handleEl.appendChild(el);
let tanHandle = new TanHandle(this, r > 0);
// To be more object oriented, receiver might be the knot or tanHandle. Ah well.
this.se.ui.attachReceiver(el, this.se, tanHandle);
}
if (el !== null) {
if (computed) {
el.classList.add("computed");
} else {
el.classList.remove("computed");
}
el.setAttribute("cx", r * Math.cos(th));
el.setAttribute("cy", r * Math.sin(th));
}
return el;
}
updateSelDecoration(selected, mode) {
if (!selected && this.selected) {
this.handleEl.classList.remove("selected");
} else if (selected && !this.selected) {
this.handleEl.classList.add("selected");
}
let lComputed = this.lth === null;
let rComputed = this.rth === null;
let drawCirc = selected && (mode !== "creating" && mode !== "dragging");
let drawLTan = !lComputed || drawCirc;
let drawRTan = !rComputed || drawCirc;
let lth = null;
if (drawLTan && this.lth != null) {
lth = this.lth;
} else if (drawLTan && this.computedLTh !== undefined) {
lth = this.computedLTh;
}
let rth = null;
if (drawRTan && this.rth != null) {
rth = this.rth;
} else if (drawRTan && this.computedRTh !== undefined) {
rth = this.computedRTh;
}
this.rthLine = this.updateSelLine(rth, this.rthLine, tanR2 - 3);
this.lthLine = this.updateSelLine(lth, this.lthLine, -tanR2 + 3);
if (!drawCirc) {
lth = null;
rth = null;
}
this.lthCircle = this.updateSelCircle(lth, this.lthCircle, -tanR2, lComputed);
this.rthCircle = this.updateSelCircle(rth, this.rthCircle, tanR2, rComputed);
this.selected = selected;
}
setTy(ty) {
if (ty !== this.ty) {
this.ty = ty;
let oldHandle = this.handleEl.querySelector(".handle");
this.handleEl.replaceChild(this.renderHandleEl(), oldHandle);
}
}
updatePos(x, y) {
this.x = x;
this.y = y;
this.handleEl.setAttribute("transform", `translate(${x} ${y})`);
}
}
class TanHandle {
constructor(knot, isRight) {
this.knot = knot;
this.isRight = isRight;
}
}
// TODO: create UI base class rather than cutting and pasting.
class Ui {
constructor() {
this.svgNS = "http://www.w3.org/2000/svg";
this.setupHandlers();
this.controlPts = [];
this.se = new SplineEdit(this);
this.gestureDet = new GestureDet(this);
this.showGrid = true;
this.se.renderGrid(this.showGrid);
this.setupDialogs();
this.keyHandlerActive = true;
}
setupHandlers() {
let svg = document.getElementById("s");
if ("PointerEvent" in window) {
svg.addEventListener("pointermove", e => this.pointerMove(e));
svg.addEventListener("pointerup", e => this.pointerUp(e));
svg.addEventListener("pointerdown", e => this.pointerDown(e));
} else {
// Fallback for ancient browsers
svg.addEventListener("mousemove", e => this.mouseMove(e));
svg.addEventListener("mouseup", e => this.mouseUp(e));
svg.addEventListener("mousedown", e => this.mouseDown(e));
// TODO: add touch handlers
}
window.addEventListener("keydown", e => this.keyDown(e));
this.mousehandler = null;
this.receiver = null;
}
attachHandler(element, handler) {
let svg = document.getElementById("s");
if ("PointerEvent" in window) {
element.addEventListener("pointerdown", e => {
svg.setPointerCapture(e.pointerId);
this.mousehandler = handler;
e.preventDefault();
e.stopPropagation();
});
} else {
element.addEventListener("mousedown", e => {
this.mousehandler = handler;
e.preventDefault();
e.stopPropagation();
});
// TODO: add touch handlers
}
}
/// This is the pattern for the new object-y style.
// Maybe just rely on closures to capture obj?
attachReceiver(element, receiver, obj) {
let svg = document.getElementById("s");
if ("PointerEvent" in window) {
element.addEventListener("pointerdown", e => {
this.gestureDet.onPointerDown(e);
svg.setPointerCapture(e.pointerId);
this.receiver = receiver;
receiver.onPointerDown(e, obj);
e.preventDefault();
e.stopPropagation();
});
} else {
element.addEventListener("mousedown", e => {
this.gestureDet.onPointerDown(e);
this.receiver = receiver;
receiver.onPointerDown(e, obj);
e.preventDefault();
e.stopPropagation();
});
// TODO: add touch handlers
}
}
pointerDownCommon(e) {
this.gestureDet.onPointerDown(e);
this.se.onPointerDown(e, null);
e.preventDefault();
}
pointerDown(e) {
let svg = document.getElementById("s");
svg.setPointerCapture(e.pointerId);
this.pointerDownCommon(e);
}
mouseDown(e) {
this.pointerDownCommon(e);
}
pointerMove(e) {
if (this.receiver !== null) {
this.receiver.onPointerMove(e);
} else if (this.mousehandler !== null) {
this.mousehandler(e);
} else {
this.se.onPointerHover(e);
}
e.preventDefault();
}
mouseMove(e) {
this.pointerMove(e);
}
pointerUpCommon(e) {
if (this.receiver !== null) {
this.receiver.onPointerUp(e);
}
this.mousehandler = null;
this.receiver = null;
e.preventDefault();
}
pointerUp(e) {
e.target.releasePointerCapture(e.pointerId);
this.pointerUpCommon(e);
}
mouseUp(e) {
this.pointerUpCommon(e);
}
keyDown(e) {
// Maybe would be better to use focus instead of explicit logic...
if (!this.keyHandlerActive) { return; }
let handled = this.se.onKeyDown(e);
if (handled) {
e.preventDefault();
}
}
// On Chrome, just offsetX, offsetY work, but on FF it takes the group transforms
// into account. We always want coords relative to the SVG.
getCoords(e) {
let svg = document.getElementById("s");
let rect = svg.getBoundingClientRect();
let x = e.clientX - rect.left;
let y = e.clientY - rect.top;
return new Vec2(x, y);
}
createSvgElement(tagName, isRaw = false) {
let element = document.createElementNS(this.svgNS, tagName);
if (!isRaw) {
element.setAttribute("pointer-events", "none");
}
return element;
}
resetPlots() {
this.removeAllChildren(document.getElementById("plots"));
}
plotCircle(x, y, r = 2, color = "black", isRaw = false) {
let circle = this.createSvgElement("circle", isRaw);
circle.setAttribute("cx", x);
circle.setAttribute("cy", y);
circle.setAttribute("r", r);
if (color !== null) {
circle.setAttribute("fill", color);
}
document.getElementById("plots").appendChild(circle);
return circle;
}
tangentMarker(x, y, th) {
let len = 8;
let dx = len * Math.cos(th);
let dy = len * Math.sin(th);
let line = this.createSvgElement("line");
line.setAttribute("x1", x - dx);
line.setAttribute("y1", y + dy);
line.setAttribute("x2", x + dx);
line.setAttribute("y2", y - dy);
line.setAttribute("stroke", "green");
document.getElementById("plots").appendChild(line);
}
redraw() {
this.resetPlots();
let path = "";
let cmd = "M";
for (let pt of this.controlPts) {
path += `${cmd}${pt.x} ${pt.y}`;
cmd = " L";
}
document.getElementById("ctrlpoly").setAttribute("d", path);
let showMyCurve = true;
let showBiParabola = true;
let spline2Offset = 200;
let nIter = 10;
if (showMyCurve) {
let spline = new TwoParamSpline(new MyCurve, this.controlPts);
let ths = spline.initialThs();
for (let i = 0; i < nIter; i++) {
spline.iterDumb(i);
}
let splinePath = spline.renderSvg();
document.getElementById("spline").setAttribute("d", splinePath);
}
if (showBiParabola) {
let pts = [];
for (let pt of this.controlPts) {
pts.push(new Vec2(pt.x + spline2Offset, pt.y));
}
let spline2 = new TwoParamSpline(new BiParabola, pts);
spline2.initialThs();
for (let i = 0; i < nIter; i++) {
let absErr = spline2.iterDumb(i);
if (i == nIter - 1) {
console.log(`biparabola err: ${absErr}`);
}
}
let spline2Path = spline2.renderSvg();
document.getElementById("spline2").setAttribute("d", spline2Path);
}
/*
for (let i = 0; i < ths.length; i++) {
let pt = this.controlPts[i]
this.tangentMarker(pt.x, pt.y, -ths[i]);
}
*/
}
// TODO: extend so it can insert at an arbitrary location
addPoint(x, y) {
let ix = this.controlPts.length;
this.controlPts.push(new Vec2(x, y));
let handle = this.createSvgElement("circle", true);
handle.setAttribute("cx", x);
handle.setAttribute("cy", y);
handle.setAttribute("r", 4);
handle.setAttribute("class", "handle");
document.getElementById("handles").appendChild(handle);
this.attachHandler(handle, e => {
this.movePoint(handle, ix, e.offsetX, e.offsetY);
});
this.mousehandler = e => this.movePoint(handle, ix, e.offsetX, e.offsetY);
}
movePoint(handle, ix, x, y) {
handle.setAttribute("cx", x);
handle.setAttribute("cy", y);
this.controlPts[ix] = new Vec2(x, y);
this.redraw();
}
updateShowGrid(showGrid) {
if (showGrid) {
document.getElementById("show-grid-check").classList.remove("invisible");
} else {
document.getElementById("show-grid-check").classList.add("invisible");
}
this.se.renderGrid(showGrid);
this.showGrid = showGrid;
}
addMenuHandler(id, handler) {
document.getElementById(id).addEventListener("click", e => {
handler(e);
let el = e.target;
while (el.nodeName != "UL") {
el = el.parentNode;
}
// Hacks like this make me think we should just control the menu logic with
// JS instead of trying to leverage CSS. Oh well, it works...
el.classList.add("off");
window.setTimeout(() => el.classList.remove("off"), 50);
});
}
saveToJson() {
document.getElementById("save-json-modal").style.display = "block";
let jsonStr = JSON.stringify(this.se.serialize());
let el = document.getElementById("save-json-content");
this.removeAllChildren(el);
el.appendChild(document.createTextNode(jsonStr));
}
/// This displays the load dialog, doesn't do the load action.
loadFromJson() {
document.getElementById("load-json-modal").style.display = "block";
document.getElementById("load-text").focus();
this.keyHandlerActive = false;
}
doLoadAction(data) {
// TODO: error handling
this.se.deserialize(data);
document.getElementById("load-json-modal").style.display = "none";
this.keyHandlerActive = true;
}
setupDialogs() {
this.addMenuHandler("menu-save", e =>
this.saveToJson());
this.addMenuHandler("menu-load", e =>
this.loadFromJson());
this.addMenuHandler("menu-show-grid", e =>
this.updateShowGrid(!this.showGrid));
this.addMenuHandler("menu-delete", e =>
this.se.delete());
this.addMenuHandler("menu-help", e =>
document.getElementById("help-modal").style.display = "block");
document.getElementById("help-close").addEventListener("click", e =>
document.getElementById("help-modal").style.display = "none");
document.getElementById("save-json-close").addEventListener("click", e =>
document.getElementById("save-json-modal").style.display = "none");
document.getElementById("load-json-close").addEventListener("click", e => {
document.getElementById("load-json-modal").style.display = "none";
this.keyHandlerActive = true;
});
document.getElementById("load-button").addEventListener("click", e =>
this.doLoadAction(document.getElementById("load-text").value));
}
removeAllChildren(el) {
while (el.firstChild) {
el.removeChild(el.firstChild);
}
}
}
let ui = new Ui();