// Copyright 2016 Christian d'Heureuse, Inventec Informatik AG, Zurich, Switzerland // www.source-code.biz, www.inventec.ch/chdh // // License: GPL, GNU General Public License, http://www.gnu.org/licenses/gpl.html // Home page: http://www.source-code.biz/snippets/typescript /// /// namespace FunctionCurveEditor { //--- Point and PointUtils ----------------------------------------------------- export class Point { public x: number; public y: number; constructor (x?: number, y?: number) { this.x = (x == undefined) ? 0 : x; this.y = (y == undefined) ? 0 : y; } public clone() : Point { return new Point(this.x, this.y); }} class PointUtils { // Returns the distance between two points. public static computeDistance (point1: Point, point2: Point) { let dx = point1.x - point2.x; let dy = point1.y - point2.y; return Math.sqrt(dx * dx + dy * dy); } public static computeCenter (point1: Point, point2: Point) { return new Point((point1.x + point2.x) / 2, (point1.y + point2.y) / 2); } // Returns the index of points1[pointIndex] in points2, or null. public static mapPointIndex (points1: Point[], points2: Point[], pointIndex: number | null) : number | null { if (pointIndex == null) { return null; } let point = points1[pointIndex]; return PointUtils.findPoint(points2, point); } // Returns the index of point in the points array or null. public static findPoint (points: Point[], point: Point) : number | null { if (point == null) { return null; } let i = points.indexOf(point); return (i >= 0) ? i : null; } public static makeXValsStrictMonotonic (points: Point[]) { 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; }}} public static dumpPoints (points: Point[]) { for (let i = 0; i < points.length; i++) { console.log("[" + i + "] = (" + points[i].x + ", " + points[i].y + ")"); }} public static encodeCoordinateList (points: Point[]) : string { let s: string = ""; for (let point of points) { if (s.length > 0) { s += ", "; } s += "[" + point.x + ", " + point.y + "]"; } return s; } public static decodeCoordinateList (s: string) : Point[] { let a = JSON.parse("[" + s + "]"); let points: Point[] = Array(a.length); for (let i = 0; i < a.length; i++) { let 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] = new Point(e[0], e[1]); } return points; }} //--- Plotter ------------------------------------------------------------------ class FunctionPlotter { private wctx: WidgetContext; private ctx: CanvasRenderingContext2D; constructor (wctx: WidgetContext) { this.wctx = wctx; let ctx = wctx.canvas.getContext("2d"); if (ctx == null) { throw new Error("Canvas 2D context not available."); } this.ctx = ctx; } private clearCanvas() { let wctx = this.wctx; let ctx = this.ctx; ctx.save(); let width = wctx.canvas.width; let height = wctx.canvas.height; let xMin = (wctx.cnf.relevantXMin != null) ? Math.max(0, Math.min(width, wctx.mapLogicalToCanvasXCoordinate(wctx.cnf.relevantXMin))) : 0; let xMax = (wctx.cnf.relevantXMax != null) ? Math.max(xMin, Math.min(width, wctx.mapLogicalToCanvasXCoordinate(wctx.cnf.relevantXMax))) : width; if (xMin > 0) { ctx.fillStyle = "#F8F8F8"; ctx.fillRect(0, 0, xMin, height); } if (xMax > xMin) { ctx.fillStyle = "#FFFFFF"; ctx.fillRect(xMin, 0, xMax - xMin, height); } if (xMax < width) { ctx.fillStyle = "#F8F8F8"; ctx.fillRect(xMax, 0, width - xMax, height); } ctx.restore(); } private drawKnot (knotNdx: number) { let wctx = this.wctx; let ctx = this.ctx; let point = wctx.mapLogicalToCanvasCoordinates(wctx.knots[knotNdx]); ctx.save(); ctx.beginPath(); let isDragging = knotNdx == wctx.selectedKnotNdx && wctx.knotDragging; let isSelected = knotNdx == wctx.selectedKnotNdx; let isPotential = knotNdx == wctx.potentialKnotNdx; let bold = isDragging || isSelected || isPotential; let r = bold ? 5 : 4; ctx.arc(point.x, point.y, r, 0, 2 * Math.PI); ctx.lineWidth = bold ? 3 : 1; ctx.strokeStyle = (isDragging || isPotential) ? "#EE5500" : isSelected ? "#0080FF" : "#CC4444"; ctx.stroke(); ctx.restore(); } private drawKnots() { let knots = this.wctx.knots; for (let knotNdx = 0; knotNdx < knots.length; knotNdx++) { this.drawKnot(knotNdx); }} private formatLabel (value: number, decPow: number) { let s = (decPow <= 7 && decPow >= -6) ? value.toFixed(Math.max(0, -decPow)) : value.toExponential(); if (s.length > 10) { s = value.toPrecision(6); } return s; } private drawLabel (cPos: number, value: number, decPow: number, xy: boolean) { let wctx = this.wctx; let ctx = this.ctx; ctx.save(); ctx.textBaseline = "bottom"; ctx.font = "12px"; ctx.fillStyle = "#707070"; let x = xy ? cPos + 5 : 5; let y = xy ? wctx.canvas.height - 2 : cPos - 2; let s = this.formatLabel(value, decPow); ctx.fillText(s, x, y); ctx.restore(); } private drawGridLine (p: number, cPos: number, lPos: number, xy: boolean) { let wctx = this.wctx; let ctx = this.ctx; ctx.save(); ctx.fillStyle = (p == 0) ? "#989898" : (p % 10 == 0) ? "#D4D4D4" : "#EEEEEE"; ctx.fillRect(xy ? cPos : 0, xy ? 0 : cPos, xy ? 1 : wctx.canvas.width, xy ? wctx.canvas.height : 1); ctx.restore(); } private drawXYGrid (xy: boolean) { let wctx = this.wctx; let gp = wctx.getGridParms(xy); if (gp == null) { return; } let p = gp.pos; let loopCtr = 0; while (true) { let lPos = p * gp.space; let cPos = xy ? wctx.mapLogicalToCanvasXCoordinate(lPos) : wctx.mapLogicalToCanvasYCoordinate(lPos); if (xy ? (cPos > wctx.canvas.width) : (cPos < 0)) { break; } this.drawGridLine(p, cPos, lPos, xy); this.drawLabel(cPos, lPos, gp.decPow, xy); p += gp.span; if (loopCtr++ > 100) { // to prevent endless loop on numerical instability break; }}} private drawGrid() { this.drawXYGrid(true); this.drawXYGrid(false); } private drawFunctionCurve (uniFunction: ApacheCommonsMathInterpolation.UnivariateFunction, lxMin: number, lxMax: number) { let wctx = this.wctx; let ctx = this.ctx; ctx.save(); ctx.beginPath(); let cxMin = Math.max(0, Math.ceil(wctx.mapLogicalToCanvasXCoordinate(lxMin))); let cxMax = Math.min(wctx.canvas.width, Math.floor(wctx.mapLogicalToCanvasXCoordinate(lxMax))); for (let cx = cxMin; cx <= cxMax; cx++) { let lx = wctx.mapCanvasToLogicalXCoordinate(cx); let ly = uniFunction.value(lx); let cy = Math.max(-1E6, Math.min(1E6, wctx.mapLogicalToCanvasYCoordinate(ly))); ctx.lineTo(cx, cy); } ctx.strokeStyle = "#44CC44"; ctx.stroke(); ctx.restore(); } private drawFunctionCurveFromKnots() { let wctx = this.wctx; let knots = wctx.knots; if (knots.length < 2 && !wctx.extendedDomain) { return; } let xMin = wctx.extendedDomain ? -1E99 : knots[0].x; let xMax = wctx.extendedDomain ? 1E99 : knots[knots.length - 1].x; let uniFunction = wctx.createInterpolationFunction(); this.drawFunctionCurve(uniFunction, xMin, xMax); } public paint() { let wctx = this.wctx; this.clearCanvas(); if (wctx.gridEnabled) { this.drawGrid(); } this.drawFunctionCurveFromKnots(); this.drawKnots(); }} //--- Pointer controller ------------------------------------------------------- // Common high-level routines for mouse and touch. class PointerController { private wctx: WidgetContext; private proximityRange: number; private dragStartPos: Point | null; // logical coordinates of starting point of drag action constructor (wctx: WidgetContext, proximityRange: number) { this.wctx = wctx; this.proximityRange = proximityRange; } public startDragging (cPoint: Point) { let wctx = this.wctx; let lPoint = wctx.mapCanvasToLogicalCoordinates(cPoint); let knotNdx = this.findNearKnot(cPoint); wctx.selectedKnotNdx = knotNdx; wctx.knotDragging = knotNdx != null; wctx.planeDragging = knotNdx == null; this.dragStartPos = lPoint; wctx.potentialKnotNdx = null; wctx.refresh(); } public processPointerMove (cPoint: Point) : boolean { let wctx = this.wctx; if (wctx.knotDragging && wctx.selectedKnotNdx != null) { let lPoint = wctx.mapCanvasToLogicalCoordinates(cPoint); let lPoint2 = this.snapToGrid(lPoint); wctx.moveKnot(wctx.selectedKnotNdx, lPoint2); wctx.refresh(); wctx.callOnAfterUpdate(); return true; } else if (wctx.planeDragging && this.dragStartPos != null) { wctx.adjustPlaneOrigin(cPoint, this.dragStartPos); wctx.refresh(); return true; } else { let knotNdx = this.findNearKnot(cPoint); if (wctx.potentialKnotNdx != knotNdx) { wctx.potentialKnotNdx = knotNdx; wctx.refresh(); }} return false; } public stopDragging() : boolean { let wctx = this.wctx; if (!wctx.knotDragging && !wctx.planeDragging) { return false; } wctx.knotDragging = false; wctx.planeDragging = false; this.dragStartPos = null; wctx.refresh(); return true; } public createKnot (cPoint: Point) { let wctx = this.wctx; let lPoint = wctx.mapCanvasToLogicalCoordinates(cPoint); let knotNdx = wctx.addKnot(lPoint); wctx.selectedKnotNdx = knotNdx; wctx.potentialKnotNdx = knotNdx; wctx.knotDragging = false; wctx.planeDragging = false; wctx.refresh(); wctx.callOnAfterUpdate(); } private findNearKnot (cPoint: Point) : number | null { let wctx = this.wctx; let r = wctx.findNearestKnot(cPoint) return (r.distance <= this.proximityRange) ? r.knotNdx : null; } private snapToGrid (lPoint: Point) : Point { let wctx = this.wctx; if (!wctx.gridEnabled || !wctx.snapToGridEnabled) { return lPoint; } return new Point(this.snapToGrid2(lPoint.x, true), this.snapToGrid2(lPoint.y, false)); } private snapToGrid2 (lPos: number, xy: boolean) { const maxDistance = 5; let wctx = this.wctx; let gp = wctx.getGridParms(xy); if (gp == null) { return lPos; } let gridSpace = gp.space * gp.span; let gridPos = Math.round(lPos / gridSpace) * gridSpace; let lDist = Math.abs(lPos - gridPos); let cDist = lDist * wctx.getZoomFactor(xy); if (cDist > maxDistance) { return lPos; } return gridPos; }} //--- Mouse controller --------------------------------------------------------- class MouseController { private wctx: WidgetContext; private pointerController: PointerController; constructor (wctx: WidgetContext) { this.wctx = wctx; this.pointerController = new PointerController(wctx, 15); wctx.$canvas.mousedown((event: JQueryMouseEventObject) => { if (event.which == 1) { let cPoint = this.getCanvasCoordinatesFromEvent(event); this.pointerController.startDragging(cPoint); event.preventDefault(); wctx.$canvas.focus(); }}); $(document).mouseup((event: JQueryMouseEventObject) => { if (this.pointerController.stopDragging()) { event.preventDefault(); }}); $(document).mousemove((event: JQueryMouseEventObject) => { let cPoint = this.getCanvasCoordinatesFromEvent(event); if (this.pointerController.processPointerMove(cPoint)) { event.preventDefault(); }}); wctx.$canvas.dblclick((event: JQueryMouseEventObject) => { if (event.which == 1) { let cPoint = this.getCanvasCoordinatesFromEvent(event); this.pointerController.createKnot(cPoint); event.preventDefault(); }}); wctx.$canvas.on("wheel", (event: JQueryMouseEventObject) => { let cPoint = this.getCanvasCoordinatesFromEvent(event); let oe: WheelEvent = event.originalEvent; if (oe.deltaY == 0) { return; } let f = (oe.deltaY > 0) ? Math.SQRT1_2 : Math.SQRT2; wctx.zoom(f, f, cPoint); wctx.refresh(); event.preventDefault(); }); } private getCanvasCoordinatesFromEvent (event: JQueryMouseEventObject) : Point { let wctx = this.wctx; let pPoint = new Point(event.pageX, event.pageY); return wctx.mapPageToCanvasCoordinates(pPoint); }} //--- Touch controller --------------------------------------------------------- class TouchController { private wctx: WidgetContext; private pointerController: PointerController; private lastTouchTime: number; private zooming: boolean; private zoomLCenter: Point; private zoomStartDist: number; private zoomStartFactorX: number; private zoomStartFactorY: number; constructor (wctx: WidgetContext) { this.wctx = wctx; this.pointerController = new PointerController(wctx, 30); wctx.$canvas.on("touchstart", (event: JQueryInputEventObject) => { let tEvent: TouchEvent = event.originalEvent; let touches = tEvent.touches; if (touches.length == 1) { let touch = touches[0]; let cPoint = this.getCanvasCoordinatesFromTouch(touch); if (this.lastTouchTime > 0 && performance.now() - this.lastTouchTime <= 300) { // double touch this.lastTouchTime = 0; this.pointerController.createKnot(cPoint); } else { // single touch this.lastTouchTime = performance.now(); this.pointerController.startDragging(cPoint); } event.preventDefault(); } else if (touches.length == 2) { // zoom gesture this.startZooming(touches); event.preventDefault(); }}); wctx.$canvas.on("touchmove", (event: JQueryInputEventObject) => { let tEvent: TouchEvent = event.originalEvent; let touches = tEvent.touches; if (touches.length == 1) { let touch = touches[0]; let cPoint = this.getCanvasCoordinatesFromTouch(touch); if (this.pointerController.processPointerMove(cPoint)) { event.preventDefault(); }} else if (touches.length == 2 && this.zooming) { this.zoom(touches); event.preventDefault(); }}); wctx.$canvas.on("touchend touchcancel", () => { if (this.pointerController.stopDragging() || this.zooming) { this.zooming = false; event.preventDefault(); }}); } private getCanvasCoordinatesFromTouch (touch: Touch) : Point { let wctx = this.wctx; let pPoint = new Point(touch.clientX, touch.clientY); return wctx.mapViewportToCanvasCoordinates(pPoint); } private startZooming (touches: TouchList) { let wctx = this.wctx; let touch1 = touches[0]; let touch2 = touches[1]; let cPoint1 = this.getCanvasCoordinatesFromTouch(touch1); let cPoint2 = this.getCanvasCoordinatesFromTouch(touch2); let cCenter = PointUtils.computeCenter(cPoint1, cPoint2); this.zoomLCenter = wctx.mapCanvasToLogicalCoordinates(cCenter); this.zoomStartDist = PointUtils.computeDistance(cPoint1, cPoint2); this.zoomStartFactorX = wctx.zoomFactorX; this.zoomStartFactorY = wctx.zoomFactorY; this.zooming = true; } private zoom (touches: TouchList) { // TODO: Implement X/Y only (asymetric) zoom. let wctx = this.wctx; let touch1 = touches[0]; let touch2 = touches[1]; let cPoint1 = this.getCanvasCoordinatesFromTouch(touch1); let cPoint2 = this.getCanvasCoordinatesFromTouch(touch2); let newCCenter = PointUtils.computeCenter(cPoint1, cPoint2); let newDist = PointUtils.computeDistance(cPoint1, cPoint2); let f = newDist / this.zoomStartDist; wctx.zoomFactorX = this.zoomStartFactorX * f; wctx.zoomFactorY = this.zoomStartFactorY * f; wctx.adjustPlaneOrigin(newCCenter, this.zoomLCenter); wctx.refresh(); }} //--- Keyboard controller ------------------------------------------------------ class KeyboardController { private wctx: WidgetContext; constructor (wctx: WidgetContext) { this.wctx = wctx; wctx.$canvas.keydown((event: JQueryKeyEventObject) => { if (this.processKeyDown(event.which)) { event.stopPropagation(); }}); wctx.$canvas.keypress((event: JQueryKeyEventObject) => { if (this.processKeyPress(event.which)) { event.stopPropagation(); }}); } private processKeyDown (keyCode: number) { let wctx = this.wctx; switch (keyCode) { case 8: case 46: { // backspace and delete keys if (wctx.selectedKnotNdx != null) { wctx.knotDragging = false; wctx.deleteKnot(wctx.selectedKnotNdx); wctx.refresh(); wctx.callOnAfterUpdate(); } return true; }} return false; } private processKeyPress (keyCharCode: number) { let wctx = this.wctx; let c = String.fromCharCode(keyCharCode); switch (c) { case "+": case "-": case "x": case "X": case "y": case "Y": { let fx = (c == '+' || c == 'X') ? Math.SQRT2 : (c == '-' || c == 'x') ? Math.SQRT1_2 : 1; let fy = (c == '+' || c == 'Y') ? Math.SQRT2 : (c == '-' || c == 'y') ? Math.SQRT1_2 : 1; wctx.zoom(fx, fy); wctx.refresh(); return true; } case "r": { wctx.reset(); wctx.refresh(); wctx.callOnAfterUpdate(); return true; } case "c": { wctx.clearKnots(); wctx.refresh(); wctx.callOnAfterUpdate(); return true; } case "e": { wctx.extendedDomain = !wctx.extendedDomain; wctx.refresh(); wctx.callOnAfterUpdate(); return true; } case "g": { wctx.gridEnabled = !wctx.gridEnabled; wctx.refresh(); return true; } case "s": { wctx.snapToGridEnabled = !wctx.snapToGridEnabled; return true; } case "l": { wctx.linearInterpolation = !wctx.linearInterpolation; wctx.refresh(); wctx.callOnAfterUpdate(); return true; } case "k": { let s1 = PointUtils.encodeCoordinateList(wctx.knots); let s2 = window.prompt("Knot coordinates:", s1); if (s2 == null || s2 == "" || s1 == s2) { return; } let newKnots: Point[]; try { newKnots = PointUtils.decodeCoordinateList(s2); } catch (e) { window.alert("Input could not be decoded. " + e); return; } wctx.replaceKnots(newKnots); wctx.refresh(); wctx.callOnAfterUpdate(); return true; } default: { return false; }}}} //--- Internal widget context -------------------------------------------------- class WidgetContext { public widget: Widget; public plotter: FunctionPlotter; public mouseController: MouseController; public touchController: TouchController; public keyboardController: KeyboardController; public cnf: Configuration; // initial widget configuration public onAfterUpdate: () => void; // event routine called each time the curve is updated by the user public $canvas: JQuery; // a JQuery reference to the canvas public canvas: HTMLCanvasElement; // the DOM canvas element // Curve editor state: public knots: Point[]; // knot points for the interpolation public planeOrigin: Point; // coordinate plane origin (logical coordinates of lower left canvas corner) public zoomFactorX: number; // zoom factor for x coordinate values public zoomFactorY: number; // zoom factor for y coordinate values public extendedDomain: boolean; // false = function domain is from first to last knot, true = function domain is extended public gridEnabled: boolean; // true to draw a coordinate grid public snapToGridEnabled: boolean; // true to enable snap to grid behavior public linearInterpolation: boolean; // true to only use linear interpolation, no Akima interpolation // Interaction state: public selectedKnotNdx: number | null; // index of currently selected knot or null public potentialKnotNdx: number | null; // index of potential target knot for mouse click (or null) public knotDragging: boolean; // true if the selected knot is beeing dragged public planeDragging: boolean; // true if the coordinate plane is beeing dragged constructor (widget: Widget, canvas: HTMLCanvasElement, cnf: Configuration) { this.widget = widget; this.canvas = canvas; this.$canvas = $(canvas); this.setConfiguration(cnf); this.resetInteractionState(); // this.plotter = new FunctionPlotter(this); this.mouseController = new MouseController(this); this.touchController = new TouchController(this); this.keyboardController = new KeyboardController(this); } public setConfiguration (cnf: Configuration) { this.cnf = cnf; this.knots = cnf.knots.slice(); this.planeOrigin = cnf.planeOrigin; this.zoomFactorX = cnf.zoomFactorX; this.zoomFactorY = cnf.zoomFactorY; this.extendedDomain = cnf.extendedDomain; this.gridEnabled = cnf.gridEnabled; this.snapToGridEnabled = cnf.snapToGridEnabled; this.linearInterpolation = cnf.linearInterpolation; } public getConfiguration() { let cnf = new Configuration(); cnf.knots = this.knots.slice(); cnf.planeOrigin = this.planeOrigin; cnf.zoomFactorX = this.zoomFactorX; cnf.zoomFactorY = this.zoomFactorY; cnf.extendedDomain = this.extendedDomain; cnf.relevantXMin = this.cnf.relevantXMin; cnf.relevantXMax = this.cnf.relevantXMax; cnf.gridEnabled = this.gridEnabled; cnf.snapToGridEnabled = this.snapToGridEnabled; cnf.linearInterpolation = this.linearInterpolation; return cnf; } private resetInteractionState() { this.selectedKnotNdx = null; this.potentialKnotNdx = null; this.knotDragging = false; this.planeDragging = false; } // Resets the context to the initial state. public reset() { this.setConfiguration(this.cnf); this.resetInteractionState(); } public clearKnots() { this.knots = Array(); this.resetInteractionState(); } public mapLogicalToCanvasXCoordinate (lx: number) : number { return (lx - this.planeOrigin.x) * this.zoomFactorX; } public mapLogicalToCanvasYCoordinate (ly: number) : number { return this.canvas.height - (ly - this.planeOrigin.y) * this.zoomFactorY; } public mapLogicalToCanvasCoordinates (lPoint: Point) : Point { return new Point(this.mapLogicalToCanvasXCoordinate(lPoint.x), this.mapLogicalToCanvasYCoordinate(lPoint.y)); } public mapCanvasToLogicalXCoordinate (cx: number) : number { return this.planeOrigin.x + cx / this.zoomFactorX; } public mapCanvasToLogicalYCoordinate (cy: number) : number { return this.planeOrigin.y + (this.canvas.height - cy) / this.zoomFactorY; } public mapCanvasToLogicalCoordinates (cPoint: Point) : Point { return new Point(this.mapCanvasToLogicalXCoordinate(cPoint.x), this.mapCanvasToLogicalYCoordinate(cPoint.y)); } public mapPageToCanvasCoordinates (pPoint: Point) : Point { let canvasPos = this.$canvas.offset(); let cPoint = new Point(); cPoint.x = pPoint.x - canvasPos.left - parseInt(this.$canvas.css("border-left-width")); cPoint.y = pPoint.y - canvasPos.top - parseInt(this.$canvas.css("border-top-width")); return cPoint; } public mapViewportToCanvasCoordinates (vPoint: Point) : Point { let canvasPos = this.canvas.getBoundingClientRect(); let cPoint = new Point(); cPoint.x = vPoint.x - canvasPos.left - parseInt(this.$canvas.css("border-left-width")); cPoint.y = vPoint.y - canvasPos.top - parseInt(this.$canvas.css("border-top-width")); return cPoint; } public adjustPlaneOrigin (cPoint: Point, lPoint: Point) { let x = lPoint.x - cPoint.x / this.zoomFactorX; let y = lPoint.y - (this.canvas.height - cPoint.y) / this.zoomFactorY; this.planeOrigin = new Point(x, y); } public getZoomFactor (xy: boolean) : number { return xy ? this.zoomFactorX : this.zoomFactorY; } public zoom (fx: number, fy?: number, cCenter?: Point) { if (fy == null) { fy = fx; } if (cCenter == null) { cCenter = new Point(this.canvas.width / 2, this.canvas.height / 2); } let lCenter = this.mapCanvasToLogicalCoordinates(cCenter); this.zoomFactorX *= fx; this.zoomFactorY *= fy; this.adjustPlaneOrigin(cCenter, lCenter); } public deleteKnot (knotNdx: number) { let oldKnots = this.knots.slice(); this.knots.splice(knotNdx, 1); this.fixUpKnotIndexes(oldKnots); } public moveKnot (knotNdx: number, newPosition: Point) { this.knots[knotNdx] = newPosition; this.revampKnots(); } // Returns the index of the newly inserted knot. public addKnot (newKnot: Point) : number { let knot = newKnot.clone(); this.knots.push(knot); this.revampKnots(); let knotNdx = PointUtils.findPoint(this.knots, knot); // (warning: This only works as long as makeXValsStrictMonotonic() modified the knots in-place and does not construct new knot point objects) if (knotNdx == null) { throw new Error("Program logic error."); } return knotNdx; } public replaceKnots (newKnots: Point[]) { this.knots = newKnots; this.resetInteractionState(); this.revampKnots(); } private revampKnots() { this.sortKnots(); PointUtils.makeXValsStrictMonotonic(this.knots); } private sortKnots() { let oldKnots = this.knots.slice(); this.knots.sort(function(p1: Point, p2: Point) { return (p1.x != p2.x) ? p1.x - p2.x : p1.y - p2.y; }); this.fixUpKnotIndexes(oldKnots); } private fixUpKnotIndexes (oldKnots: Point[]) { this.selectedKnotNdx = PointUtils.mapPointIndex(oldKnots, this.knots, this.selectedKnotNdx); this.potentialKnotNdx = PointUtils.mapPointIndex(oldKnots, this.knots, this.potentialKnotNdx); this.knotDragging = this.knotDragging && this.selectedKnotNdx != null; } // Returns the index and distance of the nearest knot. public findNearestKnot (cPoint: Point) : {knotNdx: number | null, distance: number | null} { let minDist: number | null = null; let nearestKnotNdx: number | null = null; for (let i = 0; i < this.knots.length; i++) { let lKnot = this.knots[i]; let cKnot = this.mapLogicalToCanvasCoordinates(lKnot); let d = PointUtils.computeDistance(cKnot, cPoint); if (minDist == null || d < minDist) { nearestKnotNdx = i; minDist = d; }} return {knotNdx: nearestKnotNdx, distance: minDist}; } public getGridParms (xy: boolean) : {space: number, span: number, pos: number, decPow: number} | null { const minSpaceC = xy ? 66 : 50; // minimum space between grid lines in pixel let edge = xy ? this.planeOrigin.x : this.planeOrigin.y; // canvas edge coordinate let minSpaceL = minSpaceC / this.getZoomFactor(xy); // minimum space between grid lines in logical coordinate units let decPow = Math.ceil(Math.log(minSpaceL / 5) / Math.LN10); // decimal power of grid line space let edgeDecPow = (edge == 0) ? -99 : Math.log(Math.abs(edge)) / Math.LN10; // decimal power of canvas coordinates if (edgeDecPow - decPow > 10) { return null; } // numerically instable let space = Math.pow(10, decPow); // grid line space (distance) in logical units let f = minSpaceL / space; // minimum for span factor let span = (f > 2.001) ? 5 : (f > 1.001) ? 2 : 1; // span factor for visible grid lines let p1 = Math.ceil(edge / space); let pos = span * Math.ceil(p1 / span); // position of first grid line in grid space units return {space: space, span: span, pos: pos, decPow: decPow}; } public createInterpolationFunction() : ApacheCommonsMathInterpolation.UnivariateFunction { let n = this.knots.length; let xVals: number[] = Array(n); let yVals: number[] = Array(n); for (let i = 0; i < n; i++) { xVals[i] = this.knots[i].x; yVals[i] = this.knots[i].y; } let interpolator: ApacheCommonsMathInterpolation.UnivariateInterpolator; if (n >= 5 && !this.linearInterpolation) { interpolator = new ApacheCommonsMathInterpolation.AkimaSplineInterpolator(); } else if (n >= 3 && !this.linearInterpolation) { interpolator = new ApacheCommonsMathInterpolation.SplineInterpolator(); } else if (n >= 2) { interpolator = new ApacheCommonsMathInterpolation.LinearInterpolator(); } else { let c = (n == 1) ? this.knots[0].y : 1; return new ApacheCommonsMathInterpolation.Constant(c); } return interpolator.interpolate(xVals, yVals); } // Re-paints the canvas and updates the cursor. public refresh() { this.plotter.paint(); this.updateCanvasCursorStyle(); } private updateCanvasCursorStyle() { let style = (this.knotDragging || this.planeDragging) ? "move" : "auto"; this.$canvas.css("cursor", style); } public callOnAfterUpdate() { if (this.onAfterUpdate != null) { this.onAfterUpdate.call(this.widget); }}} //--- Configuration ------------------------------------------------------------ export class Configuration { public knots: Point[]; // knot points for the interpolation public planeOrigin: Point; // coordinate plane origin (logical coordinates of lower left canvas corner) public zoomFactorX: number; // zoom factor for x coordinate values public zoomFactorY: number; // zoom factor for y coordinate values public extendedDomain: boolean; // false = function domain is from first to last knot, true = function domain is extended public relevantXMin: number | null; // lower edge of relevant X range or null public relevantXMax: number | null; // upper edge of relevant X range or null public gridEnabled: boolean; // true to draw a coordinate grid public snapToGridEnabled: boolean; // true to enable snap to grid behavior public linearInterpolation: boolean; // true to only use linear interpolation, no Akima interpolation // constructor() { this.knots = Array(); this.planeOrigin = new Point(0, 0); this.zoomFactorX = 1; this.zoomFactorY = 1; this.extendedDomain = true; this.relevantXMin = null; this.relevantXMax = null; this.gridEnabled = true; this.snapToGridEnabled = true; this.linearInterpolation = false; }} //--- Widget ------------------------------------------------------------------- export class Widget { private wctx: WidgetContext; constructor (canvas: HTMLCanvasElement, cnf: Configuration) { let wctx = new WidgetContext(this, canvas, cnf); this.wctx = wctx; wctx.refresh(); } // Sets an event routine that will be called each time the curve is updated by the user. public setOnAfterUpdate (onAfterUpdate: () => void) { this.wctx.onAfterUpdate = onAfterUpdate; } // Returns the current configuration (current state) of the function curve editor. public getConfiguration() : Configuration { return this.wctx.getConfiguration(); } // Updates the current configuration (current state) of the function curve editor. public setConfiguration (cnf: Configuration) { let wctx = this.wctx; wctx.setConfiguration(cnf); wctx.refresh(); } // Returns the current graph function. // The returned JavaScript function maps each x value to an y value. public getFunction() : (x: number) => number { let uniFunction = this.wctx.createInterpolationFunction(); return (x: number) => {return uniFunction.value(x); }} public getRawHelpText() : string[] { 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", "mouse wheel or touch gesture", "zoom in/out (both axis)", "+ / -", "zoom in/out (both axis)", "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)", "c", "clear the canvas", "r", "reset to the initial state" ]; } public getFormattedHelpText() : string { let t = this.getRawHelpText(); let a: string[] = []; a.push(""); a.push( ""); a.push( ""); a.push( ""); a.push( ""); a.push( ""); for (let i = 0; i < t.length; i += 2) { a.push(""); } a.push( ""); a.push("
"); a.push(t[i]); a.push(""); a.push(t[i + 1]); a.push("
"); return a.join(""); }} } // end namespace FunctionCurveEditor