@giro3d/function-curve-editor
Version:
A function curve editor widget / web component
1,238 lines • 46.6 kB
JavaScript
import { nextTick } from "./Utils.js";
import { createInterpolatorWithFallback } from "commons-math-interpolation";
import * as DialogManager from "dialog-manager";
class PointUtils {
static clone(p) {
return { x: p.x, y: p.y };
}
static computeDistance(point1, point2) {
const dx = point1.x - point2.x;
const dy = point1.y - point2.y;
return Math.sqrt(dx * dx + dy * dy);
}
static computeCenter(point1, point2) {
return { x: (point1.x + point2.x) / 2, y: (point1.y + point2.y) / 2 };
}
static mapPointIndex(points1, points2, pointIndex) {
if (pointIndex == undefined) {
return;
}
const point = points1[pointIndex];
return PointUtils.findPoint(points2, point);
}
static findPoint(points, point) {
if (!point) {
return;
}
const i = points.indexOf(point);
return (i >= 0) ? i : undefined;
}
static makeXValsStrictMonotonic(points) {
for (let i = 1; i < points.length; i++) {
if (points[i].x <= points[i - 1].x) {
points[i].x = points[i - 1].x + 1E-6;
}
}
}
static dumpPoints(points) {
for (let i = 0; i < points.length; i++) {
console.log("[" + i + "] = (" + points[i].x + ", " + points[i].y + ")");
}
}
static encodeCoordinateList(points) {
let s = "";
for (const point of points) {
if (s.length > 0) {
s += ", ";
}
s += "[" + formatCoordinateValue(point.x) + ", " + formatCoordinateValue(point.y) + "]";
}
return s;
}
static decodeCoordinateList(s) {
const a = JSON.parse("[" + s + "]");
const points = Array(a.length);
for (let i = 0; i < a.length; i++) {
const e = a[i];
if (!Array.isArray(e) || e.length != 2 || typeof e[0] != "number" || typeof e[1] != "number") {
throw new Error("Invalid syntax in element " + i + ".");
}
points[i] = { x: e[0], y: e[1] };
}
return points;
}
}
function formatCoordinateValue(v) {
let s = String(v);
if (s.length > 10) {
s = v.toPrecision(6);
}
return s;
}
class FunctionPlotter {
constructor(wctx) {
this.wctx = wctx;
const ctx = wctx.canvas.getContext("2d");
if (!ctx) {
throw new Error("Canvas 2D context not available.");
}
this.ctx = ctx;
}
clearCanvas() {
const wctx = this.wctx;
const ctx = this.ctx;
ctx.save();
const width = wctx.canvas.width;
const height = wctx.canvas.height;
const xMin = (wctx.eState.relevantXMin != undefined) ? Math.max(0, Math.min(width, wctx.mapLogicalToCanvasXCoordinate(wctx.eState.relevantXMin))) : 0;
const xMax = (wctx.eState.relevantXMax != undefined) ? Math.max(xMin, Math.min(width, wctx.mapLogicalToCanvasXCoordinate(wctx.eState.relevantXMax))) : width;
if (xMin > 0) {
ctx.fillStyle = wctx.eState.secondaryBackground;
ctx.fillRect(0, 0, xMin, height);
}
if (xMax > xMin) {
ctx.fillStyle = wctx.eState.background;
ctx.fillRect(xMin, 0, xMax - xMin, height);
}
if (xMax < width) {
ctx.fillStyle = wctx.eState.secondaryBackground;
ctx.fillRect(xMax, 0, width - xMax, height);
}
ctx.restore();
}
drawKnot(knotNdx) {
const wctx = this.wctx;
const ctx = this.ctx;
const knot = wctx.eState.knots[knotNdx];
const point = wctx.mapLogicalToCanvasCoordinates(knot);
ctx.save();
ctx.beginPath();
const isDragging = knotNdx == wctx.iState.selectedKnotNdx && wctx.iState.knotDragging;
const isSelected = knotNdx == wctx.iState.selectedKnotNdx;
const isPotential = knotNdx == wctx.iState.potentialKnotNdx;
const bold = isDragging || isSelected || isPotential;
const r = bold ? 5 : 4;
ctx.arc(point.x, point.y, r, 0, 2 * Math.PI);
ctx.lineWidth = bold ? 3 : 1;
ctx.strokeStyle = (isDragging || isPotential) ? wctx.eState.activeKnotColor : isSelected ? wctx.eState.selectedKnotColor : wctx.eState.defaultKnotColor;
ctx.stroke();
ctx.restore();
}
drawKnots() {
const knots = this.wctx.eState.knots;
for (let knotNdx = 0; knotNdx < knots.length; knotNdx++) {
this.drawKnot(knotNdx);
}
}
formatLabel(value, decPow) {
let s = (decPow <= 7 && decPow >= -6) ? value.toFixed(Math.max(0, -decPow)) : value.toExponential();
if (s.length > 10) {
s = value.toPrecision(6);
}
return s;
}
drawLabel(cPos, value, decPow, xy) {
const wctx = this.wctx;
const ctx = this.ctx;
ctx.save();
ctx.textBaseline = "bottom";
ctx.font = "12px";
ctx.fillStyle = wctx.eState.labelColor;
const x = xy ? cPos + 5 : 5;
const y = xy ? wctx.canvas.height - 2 : cPos - 2;
const s = this.formatLabel(value, decPow);
ctx.fillText(s, x, y);
ctx.restore();
}
drawGridLine(p, cPos, xy) {
const wctx = this.wctx;
const ctx = this.ctx;
ctx.save();
ctx.fillStyle = (p == 0) ? wctx.eState.axisColor : (p % 10 == 0) ? wctx.eState.secondaryLineColor : wctx.eState.primaryLineColor;
ctx.fillRect(xy ? cPos : 0, xy ? 0 : cPos, xy ? 1 : wctx.canvas.width, xy ? wctx.canvas.height : 1);
ctx.restore();
}
drawXYGrid(xy) {
const wctx = this.wctx;
const gp = wctx.getGridParms(xy);
if (!gp) {
return;
}
let p = gp.pos;
let loopCtr = 0;
while (true) {
const lPos = p * gp.space;
const cPos = xy ? wctx.mapLogicalToCanvasXCoordinate(lPos) : wctx.mapLogicalToCanvasYCoordinate(lPos);
if (xy ? (cPos > wctx.canvas.width) : (cPos < 0)) {
break;
}
this.drawGridLine(p, cPos, xy);
this.drawLabel(cPos, lPos, gp.decPow, xy);
p += gp.span;
if (loopCtr++ > 100) {
break;
}
}
}
drawGrid() {
this.drawXYGrid(true);
this.drawXYGrid(false);
}
drawFunctionCurve(uniFunction, lxMin, lxMax) {
const wctx = this.wctx;
const ctx = this.ctx;
ctx.save();
ctx.beginPath();
const cxMin = Math.max(0, Math.ceil(wctx.mapLogicalToCanvasXCoordinate(lxMin)));
const cxMax = Math.min(wctx.canvas.width, Math.floor(wctx.mapLogicalToCanvasXCoordinate(lxMax)));
for (let cx = cxMin; cx <= cxMax; cx++) {
const lx = wctx.mapCanvasToLogicalXCoordinate(cx);
const ly = uniFunction(lx);
const cy = Math.max(-1E6, Math.min(1E6, wctx.mapLogicalToCanvasYCoordinate(ly)));
ctx.lineTo(cx, cy);
}
ctx.strokeStyle = wctx.eState.curveColor;
ctx.stroke();
ctx.restore();
}
drawFunctionCurveFromKnots() {
const wctx = this.wctx;
const knots = wctx.eState.knots;
if (knots.length < 2 && !wctx.eState.extendedDomain) {
return;
}
const xMin = wctx.eState.extendedDomain ? -1E99 : knots[0].x;
const xMax = wctx.eState.extendedDomain ? 1E99 : knots[knots.length - 1].x;
const uniFunction = wctx.createInterpolationFunction();
this.drawFunctionCurve(uniFunction, xMin, xMax);
}
paint() {
const wctx = this.wctx;
if (!this.newCanvasWidth || !this.newCanvasHeight) {
return;
}
if (this.newCanvasWidth != wctx.canvas.width || this.newCanvasHeight != wctx.canvas.height) {
wctx.canvas.width = this.newCanvasWidth;
wctx.canvas.height = this.newCanvasHeight;
}
this.clearCanvas();
if (wctx.eState.gridEnabled) {
this.drawGrid();
}
this.drawFunctionCurveFromKnots();
this.drawKnots();
}
resize(width, height) {
const wctx = this.wctx;
if (this.newCanvasWidth == width && this.newCanvasHeight == height) {
return;
}
this.newCanvasWidth = width;
this.newCanvasHeight = height;
wctx.requestRefresh();
}
}
class PointerController {
constructor(wctx) {
this.zooming = false;
this.pointerDownEventListener = (event) => {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || (event.pointerType == "mouse" && event.button != 0)) {
return;
}
if (this.isPointerInResizeHandle(event)) {
return;
}
this.trackPointer(event);
if ((event.pointerType == "touch" || event.pointerType == "pen") && this.pointers.size == 1) {
if (this.lastTouchTime > 0 && performance.now() - this.lastTouchTime <= 300) {
this.lastTouchTime = 0;
this.processDoubleClickTouch();
event.preventDefault();
return;
}
this.lastTouchTime = performance.now();
}
this.switchMode();
event.preventDefault();
};
this.pointerUpEventListener = (event) => {
if (!this.pointers.has(event.pointerId)) {
return;
}
this.releasePointer(event.pointerId);
this.switchMode();
event.preventDefault();
};
this.pointerMoveEventListener = (event) => {
if (!this.pointers.has(event.pointerId)) {
this.updatePotentialKnot(event);
return;
}
this.trackPointer(event);
if (this.pointers.size == 1) {
this.drag();
}
else if (this.pointers.size == 2 && this.zooming) {
this.zoom();
}
event.preventDefault();
};
this.wheelEventListener = (event) => {
const wctx = this.wctx;
if (wctx.eState.focusShield && !wctx.hasFocus()) {
return;
}
event.preventDefault();
const isProbablyPad = event.deltaMode == 0 && Math.abs(event.deltaY) < 50 || event.deltaX != 0;
if (isProbablyPad && !event.ctrlKey) {
this.moveByWheel(event);
return;
}
if (event.deltaY == 0) {
return;
}
const f0 = isProbablyPad ? 1.05 : Math.SQRT2;
const f = (event.deltaY > 0) ? 1 / f0 : f0;
let zoomMode;
if (event.shiftKey) {
zoomMode = 1;
}
else if (event.altKey) {
zoomMode = 0;
}
else if (event.ctrlKey && !isProbablyPad) {
zoomMode = 2;
}
else {
zoomMode = wctx.eState.primaryZoomMode;
}
let fx;
let fy;
switch (zoomMode) {
case 0: {
fx = f;
fy = 1;
break;
}
case 1: {
fx = 1;
fy = f;
break;
}
default: {
fx = f;
fy = f;
}
}
const cPoint = this.getCanvasCoordinatesFromEvent(event);
wctx.zoom(fx, fy, cPoint);
wctx.requestRefresh();
wctx.fireViewportChangeEvent();
};
this.dblClickEventListener = (event) => {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.button != 0) {
return;
}
const cPoint = this.getCanvasCoordinatesFromEvent(event);
this.createKnot(cPoint);
event.preventDefault();
};
this.wctx = wctx;
this.pointers = new Map();
wctx.canvas.addEventListener("pointerdown", this.pointerDownEventListener);
wctx.canvas.addEventListener("pointerup", this.pointerUpEventListener);
wctx.canvas.addEventListener("pointercancel", this.pointerUpEventListener);
wctx.canvas.addEventListener("pointermove", this.pointerMoveEventListener);
wctx.canvas.addEventListener("dblclick", this.dblClickEventListener);
wctx.canvas.addEventListener("wheel", this.wheelEventListener);
}
dispose() {
const wctx = this.wctx;
wctx.canvas.removeEventListener("pointerdown", this.pointerDownEventListener);
wctx.canvas.removeEventListener("pointerup", this.pointerUpEventListener);
wctx.canvas.removeEventListener("pointercancel", this.pointerUpEventListener);
wctx.canvas.removeEventListener("pointermove", this.pointerMoveEventListener);
wctx.canvas.removeEventListener("dblclick", this.dblClickEventListener);
wctx.canvas.removeEventListener("wheel", this.wheelEventListener);
this.releaseAllPointers();
}
processEscKey() {
this.abortDragging();
}
switchMode() {
const wctx = this.wctx;
this.stopDragging();
this.stopZooming();
if (this.pointers.size == 1) {
this.startDragging();
wctx.canvas.focus();
}
else if (this.pointers.size == 2) {
this.startZooming();
}
}
trackPointer(event) {
const wctx = this.wctx;
const pointerId = event.pointerId;
if (!this.pointers.has(pointerId)) {
wctx.canvas.setPointerCapture(pointerId);
}
this.pointers.set(pointerId, event);
}
releasePointer(pointerId) {
const wctx = this.wctx;
this.pointers.delete(pointerId);
wctx.canvas.releasePointerCapture(pointerId);
}
releaseAllPointers() {
while (this.pointers.size > 0) {
const pointerId = this.pointers.keys().next().value;
this.releasePointer(pointerId);
}
}
startDragging() {
const wctx = this.wctx;
const cPoint = this.getCanvasCoordinates();
const lPoint = wctx.mapCanvasToLogicalCoordinates(cPoint);
const pointerType = this.pointers.values().next().value.pointerType;
const knotNdx = this.findNearKnot(cPoint, pointerType);
wctx.iState.selectedKnotNdx = knotNdx;
wctx.iState.knotDragging = knotNdx != undefined;
wctx.iState.planeDragging = knotNdx == undefined;
this.dragStartLPos = lPoint;
this.dragStartCPos = cPoint;
this.dragCount = 0;
wctx.iState.potentialKnotNdx = undefined;
wctx.requestRefresh();
}
abortDragging() {
const wctx = this.wctx;
if (wctx.iState.knotDragging && this.dragCount > 0) {
wctx.undo();
wctx.fireChangeEvent();
}
if (wctx.iState.planeDragging && this.dragStartCPos && this.dragStartLPos) {
wctx.moveCoordinatePlane(this.dragStartCPos, this.dragStartLPos);
wctx.fireViewportChangeEvent();
}
this.stopDragging();
wctx.requestRefresh();
}
stopDragging() {
const wctx = this.wctx;
if (wctx.iState.knotDragging || wctx.iState.planeDragging) {
wctx.requestRefresh();
}
this.dragStartLPos = undefined;
this.dragStartCPos = undefined;
wctx.iState.knotDragging = false;
wctx.iState.planeDragging = false;
}
drag() {
const wctx = this.wctx;
const cPoint = this.getCanvasCoordinates();
if (wctx.iState.knotDragging && wctx.iState.selectedKnotNdx != undefined) {
if (this.dragCount++ == 0) {
wctx.pushUndoHistoryState();
}
const lPoint = wctx.mapCanvasToLogicalCoordinates(cPoint);
const lPoint2 = this.snapToGrid(lPoint);
wctx.moveKnot(wctx.iState.selectedKnotNdx, lPoint2);
wctx.requestRefresh();
wctx.fireChangeEvent();
}
else if (wctx.iState.planeDragging && this.dragStartLPos) {
wctx.moveCoordinatePlane(cPoint, this.dragStartLPos);
wctx.requestRefresh();
wctx.fireViewportChangeEvent();
}
}
startZooming() {
const wctx = this.wctx;
const pointerValues = this.pointers.values();
const event1 = pointerValues.next().value;
const event2 = pointerValues.next().value;
const cPoint1 = this.getCanvasCoordinatesFromEvent(event1);
const cPoint2 = this.getCanvasCoordinatesFromEvent(event2);
const cCenter = PointUtils.computeCenter(cPoint1, cPoint2);
const xDist = Math.abs(cPoint1.x - cPoint2.x);
const yDist = Math.abs(cPoint1.y - cPoint2.y);
this.zoomLCenter = wctx.mapCanvasToLogicalCoordinates(cCenter);
this.zoomStartDist = PointUtils.computeDistance(cPoint1, cPoint2);
this.zoomStartFactorX = wctx.getZoomFactor(true);
this.zoomStartFactorY = wctx.getZoomFactor(false);
const t = Math.tan(Math.PI / 8);
this.zoomX = xDist > t * yDist;
this.zoomY = yDist > t * xDist;
this.zooming = true;
}
stopZooming() {
this.zooming = false;
}
zoom() {
const wctx = this.wctx;
const eState = wctx.eState;
const pointerValues = this.pointers.values();
const event1 = pointerValues.next().value;
const event2 = pointerValues.next().value;
const cPoint1 = this.getCanvasCoordinatesFromEvent(event1);
const cPoint2 = this.getCanvasCoordinatesFromEvent(event2);
const newCCenter = PointUtils.computeCenter(cPoint1, cPoint2);
const newDist = PointUtils.computeDistance(cPoint1, cPoint2);
const f = newDist / this.zoomStartDist;
if (this.zoomX) {
eState.xMax = eState.xMin + wctx.canvas.width / (this.zoomStartFactorX * f);
}
if (this.zoomY) {
eState.yMax = eState.yMin + wctx.canvas.height / (this.zoomStartFactorY * f);
}
wctx.moveCoordinatePlane(newCCenter, this.zoomLCenter);
wctx.requestRefresh();
wctx.fireViewportChangeEvent();
}
moveByWheel(event) {
const wctx = this.wctx;
const f = (event.deltaMode == 1) ? 15 : (event.deltaMode == 2) ? 100 : 1;
const dx = f * event.deltaX;
const dy = f * event.deltaY;
if (dx == 0 && dy == 0) {
return;
}
wctx.moveCoordinatePlaneRelPx(dx, -dy);
wctx.requestRefresh();
wctx.fireViewportChangeEvent();
}
processDoubleClickTouch() {
const cPoint = this.getCanvasCoordinates();
this.createKnot(cPoint);
}
createKnot(cPoint) {
const wctx = this.wctx;
wctx.pushUndoHistoryState();
const lPoint = wctx.mapCanvasToLogicalCoordinates(cPoint);
const knotNdx = wctx.addKnot(lPoint);
wctx.iState.selectedKnotNdx = knotNdx;
wctx.iState.potentialKnotNdx = knotNdx;
wctx.iState.knotDragging = false;
wctx.iState.planeDragging = false;
wctx.requestRefresh();
wctx.fireChangeEvent();
}
updatePotentialKnot(event) {
const wctx = this.wctx;
const cPoint = this.getCanvasCoordinatesFromEvent(event);
const knotNdx = this.findNearKnot(cPoint, event.pointerType);
if (wctx.iState.potentialKnotNdx != knotNdx) {
wctx.iState.potentialKnotNdx = knotNdx;
wctx.requestRefresh();
}
}
findNearKnot(cPoint, pointerType) {
const wctx = this.wctx;
const r = wctx.findNearestKnot(cPoint);
const proximityRange = (pointerType == "touch") ? 30 : 15;
return (r && r.distance <= proximityRange) ? r.knotNdx : undefined;
}
snapToGrid(lPoint) {
const wctx = this.wctx;
if (!wctx.eState.gridEnabled || !wctx.eState.snapToGridEnabled) {
return lPoint;
}
return { x: this.snapToGrid2(lPoint.x, true), y: this.snapToGrid2(lPoint.y, false) };
}
snapToGrid2(lPos, xy) {
const maxDistance = 5;
const wctx = this.wctx;
const gp = wctx.getGridParms(xy);
if (!gp) {
return lPos;
}
const gridSpace = gp.space * gp.span;
const gridPos = Math.round(lPos / gridSpace) * gridSpace;
const lDist = Math.abs(lPos - gridPos);
const cDist = lDist * wctx.getZoomFactor(xy);
if (cDist > maxDistance) {
return lPos;
}
return gridPos;
}
getCanvasCoordinates() {
if (this.pointers.size < 1) {
throw new Error("No active pointers.");
}
const event = this.pointers.values().next().value;
return this.getCanvasCoordinatesFromEvent(event);
}
getCanvasCoordinatesFromEvent(event) {
const wctx = this.wctx;
return wctx.mapViewportToCanvasCoordinates({ x: event.clientX, y: event.clientY });
}
isPointerInResizeHandle(event) {
const wctx = this.wctx;
const parentElement = wctx.canvas.parentNode;
if (!(parentElement instanceof HTMLElement)) {
return false;
}
if (getComputedStyle(parentElement).resize != "both") {
return false;
}
const rect = parentElement.getBoundingClientRect();
const dx = rect.right - event.clientX;
const dy = rect.bottom - event.clientY;
const handleSize = 18;
return dx >= 0 && dx < handleSize && dy >= 0 && dy < handleSize;
}
}
class KeyboardController {
constructor(wctx) {
this.keyDownEventListener = (event) => {
const keyName = genKeyName(event);
if (this.processKeyDown(keyName)) {
event.preventDefault();
}
};
this.keyPressEventListener = (event) => {
const keyName = genKeyName(event);
if (this.processKeyPress(keyName)) {
event.preventDefault();
}
};
this.wctx = wctx;
wctx.canvas.addEventListener("keydown", this.keyDownEventListener);
wctx.canvas.addEventListener("keypress", this.keyPressEventListener);
}
dispose() {
const wctx = this.wctx;
wctx.canvas.removeEventListener("keydown", this.keyDownEventListener);
wctx.canvas.removeEventListener("keypress", this.keyPressEventListener);
}
processKeyDown(keyName) {
const wctx = this.wctx;
switch (keyName) {
case "Backspace":
case "Delete": {
if (wctx.iState.selectedKnotNdx != undefined) {
wctx.iState.knotDragging = false;
wctx.pushUndoHistoryState();
wctx.deleteKnot(wctx.iState.selectedKnotNdx);
wctx.requestRefresh();
wctx.fireChangeEvent();
}
return true;
}
case "Ctrl+z":
case "Alt+Backspace": {
if (wctx.undo()) {
wctx.requestRefresh();
wctx.fireChangeEvent();
}
return true;
}
case "Ctrl+y":
case "Ctrl+Z": {
if (wctx.redo()) {
wctx.requestRefresh();
wctx.fireChangeEvent();
}
return true;
}
case "Escape": {
wctx.pointerController.processEscKey();
return true;
}
default: {
return false;
}
}
}
processKeyPress(keyName) {
const wctx = this.wctx;
const eState = wctx.eState;
switch (keyName) {
case "+":
case "-":
case "x":
case "X":
case "y":
case "Y": {
const fx = (keyName == '+' || keyName == 'X') ? Math.SQRT2 : (keyName == '-' || keyName == 'x') ? Math.SQRT1_2 : 1;
const fy = (keyName == '+' || keyName == 'Y') ? Math.SQRT2 : (keyName == '-' || keyName == 'y') ? Math.SQRT1_2 : 1;
wctx.zoom(fx, fy);
wctx.requestRefresh();
wctx.fireViewportChangeEvent();
return true;
}
case "i": {
wctx.reset();
wctx.requestRefresh();
wctx.fireChangeEvent();
return true;
}
case "c": {
wctx.pushUndoHistoryState();
wctx.clearKnots();
wctx.requestRefresh();
wctx.fireChangeEvent();
return true;
}
case "e": {
eState.extendedDomain = !eState.extendedDomain;
wctx.requestRefresh();
return true;
}
case "g": {
eState.gridEnabled = !eState.gridEnabled;
wctx.requestRefresh();
return true;
}
case "s": {
eState.snapToGridEnabled = !eState.snapToGridEnabled;
return true;
}
case "l": {
eState.interpolationMethod = (eState.interpolationMethod == "linear") ? "akima" : "linear";
wctx.requestRefresh();
wctx.fireChangeEvent();
return true;
}
case "k": {
void this.promptKnots();
return true;
}
case "r": {
void this.resample1();
return true;
}
default: {
return false;
}
}
}
async promptKnots() {
const wctx = this.wctx;
const s1 = wctx.getKnotCoordinateString();
const s2 = await DialogManager.promptInput({ promptText: "Knot coordinates:", defaultValue: s1, rows: 5 });
if (!s2 || s1 == s2) {
return;
}
await wctx.setKnotCoordinateString(s2);
}
async resample1() {
const n = await this.promptResampleCount();
if (!n) {
return;
}
this.resample2(n);
}
resample2(n) {
const wctx = this.wctx;
const oldKnots = wctx.eState.knots;
if (oldKnots.length < 1) {
void DialogManager.showMsg({ msgText: "No knots." });
return;
}
const xMin = oldKnots[0].x;
const xMax = oldKnots[oldKnots.length - 1].x;
const uniFunction = wctx.createInterpolationFunction();
const newKnots = Array(n);
for (let i = 0; i < n; i++) {
const x = xMin + (xMax - xMin) / (n - 1) * i;
const y = uniFunction(x);
newKnots[i] = { x, y };
}
wctx.pushUndoHistoryState();
wctx.replaceKnots(newKnots);
wctx.requestRefresh();
wctx.fireChangeEvent();
}
async promptResampleCount() {
const wctx = this.wctx;
const oldN = wctx.eState.knots.length;
const s = await DialogManager.promptInput({ titleText: "Re-sample", promptText: "Number of knots:", defaultValue: String(oldN) });
if (!s) {
return;
}
const n = Number(s);
if (!Number.isInteger(n) || n < 2 || n > 1E7) {
await DialogManager.showMsg({ titleText: "Error", msgText: "Invalid number: " + s });
return;
}
return n;
}
}
function genKeyName(event) {
const s = (event.altKey ? "Alt+" : "") +
(event.ctrlKey ? "Ctrl+" : "") +
(event.shiftKey && event.key.length > 1 ? "Shift+" : "") +
(event.metaKey ? "Meta+" : "") +
event.key;
return s;
}
class WidgetContext {
constructor(canvas, widget) {
this.animationFrameHandler = () => {
this.animationFramePending = false;
if (!this.isConnected) {
return;
}
this.refresh();
};
this.resizeObserverCallback = (entries) => {
const box = entries[0].contentBoxSize[0];
const width = box.inlineSize;
const height = box.blockSize;
this.plotter.resize(width, height);
};
globalInit();
this.widget = widget;
this.canvas = canvas;
this.canvasStyle = getComputedStyle(canvas);
this.eventTarget = new EventTarget();
this.isConnected = false;
this.animationFramePending = false;
this.resizeObserver = new ResizeObserver(this.resizeObserverCallback);
this.setEditorState({});
}
setConnected(connected) {
if (connected == this.isConnected) {
return;
}
if (connected) {
this.plotter = new FunctionPlotter(this);
this.pointerController = new PointerController(this);
this.kbController = new KeyboardController(this);
this.resizeObserver.observe(this.canvas);
canvasMap.set(this.canvas, this.widget);
}
else {
this.pointerController.dispose();
this.kbController.dispose();
this.resizeObserver.unobserve(this.canvas);
canvasMap.delete(this.canvas);
}
this.isConnected = connected;
this.requestRefresh();
}
setEditorState(eState) {
this.eState = cloneEditorState(eState);
this.initialEState = cloneEditorState(eState);
this.resetInteractionState();
this.resetHistoryState();
this.requestRefresh();
}
getEditorState() {
return cloneEditorState(this.eState);
}
resetInteractionState() {
this.iState = {
selectedKnotNdx: undefined,
potentialKnotNdx: undefined,
knotDragging: false,
planeDragging: false
};
}
reset() {
this.setEditorState(this.initialEState);
}
clearKnots() {
this.eState.knots = [];
this.resetInteractionState();
}
resetHistoryState() {
this.hState = {
undoStack: [],
undoStackPos: 0
};
}
pushUndoHistoryState() {
const hState = this.hState;
hState.undoStack.length = hState.undoStackPos;
hState.undoStack.push(this.eState.knots.slice());
hState.undoStackPos = hState.undoStack.length;
}
undo() {
const hState = this.hState;
if (hState.undoStackPos < 1) {
return false;
}
if (hState.undoStackPos == hState.undoStack.length) {
hState.undoStack.push(this.eState.knots.slice());
}
hState.undoStackPos--;
this.eState.knots = hState.undoStack[hState.undoStackPos].slice();
this.resetInteractionState();
return true;
}
redo() {
const hState = this.hState;
if (hState.undoStackPos >= hState.undoStack.length - 1) {
return false;
}
hState.undoStackPos++;
this.eState.knots = hState.undoStack[hState.undoStackPos].slice();
this.resetInteractionState();
return true;
}
mapLogicalToCanvasXCoordinate(lx) {
return (lx - this.eState.xMin) * this.canvas.width / (this.eState.xMax - this.eState.xMin);
}
mapLogicalToCanvasYCoordinate(ly) {
return this.canvas.height - (ly - this.eState.yMin) * this.canvas.height / (this.eState.yMax - this.eState.yMin);
}
mapLogicalToCanvasCoordinates(lPoint) {
return { x: this.mapLogicalToCanvasXCoordinate(lPoint.x), y: this.mapLogicalToCanvasYCoordinate(lPoint.y) };
}
mapCanvasToLogicalXCoordinate(cx) {
return this.eState.xMin + cx * (this.eState.xMax - this.eState.xMin) / this.canvas.width;
}
mapCanvasToLogicalYCoordinate(cy) {
return this.eState.yMin + (this.canvas.height - cy) * (this.eState.yMax - this.eState.yMin) / this.canvas.height;
}
mapCanvasToLogicalCoordinates(cPoint) {
return { x: this.mapCanvasToLogicalXCoordinate(cPoint.x), y: this.mapCanvasToLogicalYCoordinate(cPoint.y) };
}
mapViewportToCanvasCoordinates(vPoint) {
const canvasStyle = this.canvasStyle;
const rect = this.canvas.getBoundingClientRect();
const paddingLeft = getPx(canvasStyle.paddingLeft);
const paddingRight = getPx(canvasStyle.paddingRight);
const paddingTop = getPx(canvasStyle.paddingTop);
const paddingBottom = getPx(canvasStyle.paddingBottom);
const borderLeft = getPx(canvasStyle.borderLeftWidth);
const borderTop = getPx(canvasStyle.borderTopWidth);
const width = this.canvas.clientWidth - paddingLeft - paddingRight;
const height = this.canvas.clientHeight - paddingTop - paddingBottom;
const x1 = vPoint.x - rect.left - borderLeft - paddingLeft;
const y1 = vPoint.y - rect.top - borderTop - paddingTop;
const x = x1 / width * this.canvas.width;
const y = y1 / height * this.canvas.height;
return { x, y };
function getPx(s) {
return s ? parseFloat(s) : 0;
}
}
moveCoordinatePlane(cPoint, lPoint) {
const eState = this.eState;
const lWidth = eState.xMax - eState.xMin;
const lHeight = eState.yMax - eState.yMin;
const cWidth = this.canvas.width;
const cHeight = this.canvas.height;
eState.xMin = lPoint.x - cPoint.x * lWidth / cWidth;
eState.xMax = eState.xMin + lWidth;
eState.yMin = lPoint.y - (cHeight - cPoint.y) * lHeight / cHeight;
eState.yMax = eState.yMin + lHeight;
}
moveCoordinatePlaneRelPx(dx, dy) {
const eState = this.eState;
const lWidth = eState.xMax - eState.xMin;
const lHeight = eState.yMax - eState.yMin;
const cWidth = this.canvas.width;
const cHeight = this.canvas.height;
eState.xMin = eState.xMin + dx / cWidth * lWidth;
eState.xMax = eState.xMin + lWidth;
eState.yMin = eState.yMin + dy / cHeight * lHeight;
eState.yMax = eState.yMin + lHeight;
}
getZoomFactor(xy) {
const eState = this.eState;
return xy ? this.canvas.width / (eState.xMax - eState.xMin) : this.canvas.height / (eState.yMax - eState.yMin);
}
zoom(fx, fyOpt, cCenterOpt) {
const eState = this.eState;
const fy = (fyOpt != undefined) ? fyOpt : fx;
const cCenter = cCenterOpt ? cCenterOpt : { x: this.canvas.width / 2, y: this.canvas.height / 2 };
const lCenter = this.mapCanvasToLogicalCoordinates(cCenter);
eState.xMax = eState.xMin + (eState.xMax - eState.xMin) / fx;
eState.yMax = eState.yMin + (eState.yMax - eState.yMin) / fy;
this.moveCoordinatePlane(cCenter, lCenter);
}
deleteKnot(knotNdx) {
const knots = this.eState.knots;
const oldKnots = knots.slice();
knots.splice(knotNdx, 1);
this.fixUpKnotIndexes(oldKnots);
}
moveKnot(knotNdx, newPosition) {
this.eState.knots[knotNdx] = newPosition;
this.revampKnots();
}
addKnot(newKnot) {
const knot = PointUtils.clone(newKnot);
this.eState.knots.push(knot);
this.revampKnots();
const knotNdx = PointUtils.findPoint(this.eState.knots, knot);
if (knotNdx == undefined) {
throw new Error("Program logic error.");
}
return knotNdx;
}
replaceKnots(newKnots) {
this.eState.knots = newKnots;
this.resetInteractionState();
this.revampKnots();
}
revampKnots() {
this.sortKnots();
PointUtils.makeXValsStrictMonotonic(this.eState.knots);
}
sortKnots() {
const oldKnots = this.eState.knots.slice();
this.eState.knots.sort(function (p1, p2) {
return (p1.x != p2.x) ? p1.x - p2.x : p1.y - p2.y;
});
this.fixUpKnotIndexes(oldKnots);
}
fixUpKnotIndexes(oldKnots) {
this.iState.selectedKnotNdx = PointUtils.mapPointIndex(oldKnots, this.eState.knots, this.iState.selectedKnotNdx);
this.iState.potentialKnotNdx = PointUtils.mapPointIndex(oldKnots, this.eState.knots, this.iState.potentialKnotNdx);
this.iState.knotDragging = this.iState.knotDragging && this.iState.selectedKnotNdx != undefined;
}
findNearestKnot(cPoint) {
const knots = this.eState.knots;
let minDist = undefined;
let nearestKnotNdx = undefined;
for (let i = 0; i < knots.length; i++) {
const lKnot = knots[i];
const cKnot = this.mapLogicalToCanvasCoordinates(lKnot);
const d = PointUtils.computeDistance(cKnot, cPoint);
if (minDist == undefined || d < minDist) {
nearestKnotNdx = i;
minDist = d;
}
}
return (nearestKnotNdx != undefined) ? { knotNdx: nearestKnotNdx, distance: minDist } : undefined;
}
getKnotCoordinateString() {
return PointUtils.encodeCoordinateList(this.eState.knots);
}
async setKnotCoordinateString(s) {
let newKnots;
try {
newKnots = PointUtils.decodeCoordinateList(s);
}
catch (e) {
await DialogManager.showMsg({ titleText: "Error", msgText: "Knot coordinates could not be decoded. " + e });
return;
}
this.pushUndoHistoryState();
this.replaceKnots(newKnots);
this.requestRefresh();
this.fireChangeEvent();
}
getGridParms(xy) {
const minSpaceC = xy ? 66 : 50;
const edge = xy ? this.eState.xMin : this.eState.yMin;
const minSpaceL = minSpaceC / this.getZoomFactor(xy);
const decPow = Math.ceil(Math.log(minSpaceL / 5) / Math.LN10);
const edgeDecPow = (edge == 0) ? -99 : Math.log(Math.abs(edge)) / Math.LN10;
if (edgeDecPow - decPow > 10) {
return undefined;
}
const space = Math.pow(10, decPow);
const f = minSpaceL / space;
const span = (f > 2.001) ? 5 : (f > 1.001) ? 2 : 1;
const p1 = Math.ceil(edge / space);
const pos = span * Math.ceil(p1 / span);
return { space, span, pos, decPow };
}
createInterpolationFunction() {
const knots = this.eState.knots;
const n = knots.length;
const xVals = new Float64Array(n);
const yVals = new Float64Array(n);
for (let i = 0; i < n; i++) {
xVals[i] = knots[i].x;
yVals[i] = knots[i].y;
}
return createInterpolatorWithFallback(this.eState.interpolationMethod, xVals, yVals);
}
requestRefresh() {
if (this.animationFramePending || !this.isConnected) {
return;
}
requestAnimationFrame(this.animationFrameHandler);
this.animationFramePending = true;
}
refresh() {
this.plotter.paint();
this.updateCanvasCursorStyle();
}
updateCanvasCursorStyle() {
const style = (this.iState.knotDragging || this.iState.planeDragging) ? "move" : "auto";
this.canvas.style.cursor = style;
}
fireChangeEvent() {
this.fireEvent("change");
}
fireViewportChangeEvent() {
this.fireEvent("viewportchange");
}
fireEvent(eventName) {
const event = new CustomEvent(eventName);
nextTick(() => {
this.eventTarget.dispatchEvent(event);
});
}
hasFocus() {
return document.activeElement === this.canvas;
}
}
function cloneEditorState(eState) {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w;
return {
knots: ((_a = eState.knots) !== null && _a !== void 0 ? _a : []).slice(),
xMin: (_b = eState.xMin) !== null && _b !== void 0 ? _b : 0,
xMax: (_c = eState.xMax) !== null && _c !== void 0 ? _c : 1,
yMin: (_d = eState.yMin) !== null && _d !== void 0 ? _d : 0,
yMax: (_e = eState.yMax) !== null && _e !== void 0 ? _e : 1,
extendedDomain: (_f = eState.extendedDomain) !== null && _f !== void 0 ? _f : true,
relevantXMin: eState.relevantXMin,
relevantXMax: eState.relevantXMax,
gridEnabled: (_g = eState.gridEnabled) !== null && _g !== void 0 ? _g : true,
snapToGridEnabled: (_h = eState.snapToGridEnabled) !== null && _h !== void 0 ? _h : true,
interpolationMethod: (_j = eState.interpolationMethod) !== null && _j !== void 0 ? _j : "akima",
primaryZoomMode: (_k = eState.primaryZoomMode) !== null && _k !== void 0 ? _k : 2,
focusShield: (_l = eState.focusShield) !== null && _l !== void 0 ? _l : false,
curveColor: (_m = eState.curveColor) !== null && _m !== void 0 ? _m : "#44CC44",
defaultKnotColor: (_o = eState.defaultKnotColor) !== null && _o !== void 0 ? _o : "#CC4444",
selectedKnotColor: (_p = eState.selectedKnotColor) !== null && _p !== void 0 ? _p : "#0080FF",
activeKnotColor: (_q = eState.activeKnotColor) !== null && _q !== void 0 ? _q : "#EE5500",
labelColor: (_r = eState.labelColor) !== null && _r !== void 0 ? _r : "#707070",
axisColor: (_s = eState.axisColor) !== null && _s !== void 0 ? _s : "#989898",
secondaryLineColor: (_t = eState.secondaryLineColor) !== null && _t !== void 0 ? _t : "#D4D4D4",
primaryLineColor: (_u = eState.primaryLineColor) !== null && _u !== void 0 ? _u : "#EEEEEE",
secondaryBackground: (_v = eState.secondaryBackground) !== null && _v !== void 0 ? _v : "#F8F8F8",
background: (_w = eState.background) !== null && _w !== void 0 ? _w : "#FFFFFF"
};
}
export class Widget {
constructor(canvas, connected = true) {
this.wctx = new WidgetContext(canvas, this);
if (connected) {
this.setConnected(true);
}
}
setEventTarget(eventTarget) {
this.wctx.eventTarget = eventTarget;
}
setConnected(connected) {
this.wctx.setConnected(connected);
}
addEventListener(type, listener) {
this.wctx.eventTarget.addEventListener(type, listener);
}
removeEventListener(type, listener) {
this.wctx.eventTarget.removeEventListener(type, listener);
}
getEditorState() {
return this.wctx.getEditorState();
}
setEditorState(eState) {
const wctx = this.wctx;
wctx.setEditorState(eState);
}
getFunction() {
return this.wctx.createInterpolationFunction();
}
getRawHelpText() {
const pz = this.wctx.eState.primaryZoomMode;
const primaryZoomAxis = (pz == 0) ? "x-axis" : (pz == 1) ? "y-axis" : "both axes";
return [
"drag knot with mouse or touch", "move a knot",
"drag plane with mouse or touch", "move the coordinate space",
"click or tap on knot", "select a knot",
"Delete / Backspace", "delete the selected knot",
"double-click or double-tap", "create a new knot",
"Esc", "abort moving",
"Ctrl+Z / Alt+Backspace", "undo",
"Ctrl+Y / Ctrl+Shift+Z", "redo",
"mouse wheel", "zoom " + primaryZoomAxis,
"shift + mouse wheel", "zoom y-axis",
"ctrl + mouse wheel", "zoom both axes",
"alt + mouse wheel", "zoom x-axis",
"touch zoom gesture", "zoom in any direction",
"+ / -", "zoom both axes in/out",
"X / x", "zoom x-axis in/out",
"Y / y", "zoom y-axis in/out",
"e", "toggle extended function domain",
"g", "toggle coordinate grid",
"s", "toggle snap to grid",
"l", "toggle between linear interpolation and Akima",
"k", "knots (display prompt with coordinate values)",
"Clipboard copy / paste", "copy/paste knot coordinates",
"r", "re-sample knots",
"c", "clear the canvas",
"i", "reset to the initial state"
];
}
getFormattedHelpText() {
const t = this.getRawHelpText();
const a = [];
a.push("<table class='functionCurveEditorHelp'>");
a.push("<colgroup>");
a.push("<col class='functionCurveEditorHelpCol1'>");
a.push("<col class='functionCurveEditorHelpCol2'>");
a.push("</colgroup>");
a.push("<tbody>");
for (let i = 0; i < t.length; i += 2) {
a.push("<tr><td>");
a.push(t[i]);
a.push("</td><td>");
a.push(t[i + 1]);
a.push("</td>");
}
a.push("</tbody>");
a.push("</table>");
return a.join("");
}
clipboardCopyEventHandler(event) {
if (!event.clipboardData) {
return;
}
event.preventDefault();
const s = this.wctx.getKnotCoordinateString();
event.clipboardData.setData("text", s);
}
clipboardPasteEventHandler(event) {
var _a;
const s = (_a = event.clipboardData) === null || _a === void 0 ? void 0 : _a.getData("text");
if (!s) {
return;
}
event.preventDefault();
void this.wctx.setKnotCoordinateString(s);
}
}
var globalInitDone = false;
var canvasMap;
function getActiveWidget() {
var _a;
let e = document.activeElement;
while (true) {
if (!e) {
return;
}
if (e.tagName == "CANVAS") {
return canvasMap.get(e);
}
e = (_a = e.shadowRoot) === null || _a === void 0 ? void 0 : _a.activeElement;
}
}
function globalCopyEventListener(event) {
var _a;
(_a = getActiveWidget()) === null || _a === void 0 ? void 0 : _a.clipboardCopyEventHandler(event);
}
function globalPasteEventListener(event) {
var _a;
(_a = getActiveWidget()) === null || _a === void 0 ? void 0 : _a.clipboardPasteEventHandler(event);
}
function globalInit() {
if (globalInitDone) {
return;
}
canvasMap = new Map();
document.addEventListener("copy", globalCopyEventListener);
document.addEventListener("paste", globalPasteEventListener);
globalInitDone = true;
}
//# sourceMappingURL=FunctionCurveEditor.js.map