"use strict"; /* Code necessary to output tikz for figures. It spoofs the normal canvas drawing code so that the output comes here, and is then converted to tizk output. One problem with JS is that there is no path interator. You can't obtain the segments that make up an entire path. I don't see any way around this other than writing my own wrapper around Path2D. It might be possible to somehow hack the internals of Path2D, but that would be brittle, even if it works. BUG: The long-term solution is to eliminate use of the JS Path2D class, but it's not possible to entirely eliminate it since it's the only way to draw to the browser window. What I *could* do is sub-class CanvasRenderingContext2D so that this sub-class take my own path class objects and converts them to Path2D for drawing. Another hurdle is isPointInPath(), which is used in a few places. I could provide a separate implementation of that, but it's fiddly. isPointInStroke() is a bit harder. BUG: It's tempting to come up with a framework under which a "path" is closer to our intuition of something that can be drawn as a continuous thing, without lifting your pencil. Then, have a second-order thing that may hold several of these continuous paths. Intuitively one wants a "path" to have a clear start-point and end-point, but you also need to be able to handle things like winding number for multiple paths when filling. BUG: There are many cases where you might want the online version to be different from what is printed. I just gave the example of filling a path, and color is similar. There's lots of things that might make sense on a computer screen, but wouldn't work well on printed paper. */ // Turns out that Point used to be part of js, but was deprecated, and // seems not to exist any longer. class Point2D { constructor(x, y) { this._x = x; this._y = y; } toString() { // May be handy for debugging. return "( " + this._x.toFixed(2) + "," + this._y.toFixed(2) + ")"; } get x() { return this._x; } get y() { return this._y; } copy() { return new Point2D(this._x, this._y); } negate() { // return -this. return new Point2D(-this._x, -this.y); } negateSelf() { this._x = -this.x; this._y = -this.y; } minus(p) { // return this - p. Redunant since it's just a form of translation. return new Point2D(this._x - p.x, this._y - p.y); } minusSelf(p) { this._x -= p._x; this._y -= p._y; } translate2(u, v) { return new Point2D(u + this._x, v + this._y); } translate(p) { return new Point2D(p.x + this._x, p.y + this._y); } translateSelf2(u, v) { this._x += u; this._y += v; } translateSelf(p) { this._x += p.x; this._y += p.y; } scale(s) { return new Point2D(s * this._x, s * this._y); } scaleSelf(s) { // As above, but it's done in-place rather than returning a copy. this._x *= s; this._y *= s; } rotate(theta) { // Apply rotation matrix in the usual (RH) way. theta in radians. let c = Math.cos(theta); let s = Math.sin(theta); return new Point2D(c * this._x - s * this._y, s * this._x + c * this._y); } rotateSelf(theta) { let c = Math.cos(theta); let s = Math.sin(theta); let u = c * this._x - s * this._y; let v = s * this._x + c * this._y; this._x = u; this._y = v; } rotateAbout(c, theta) { // Rotate this about c by angle theta, returning the result. let answer = new Point2D(this.x - c.x, this.y - c.y); answer = answer.rotate(theta); answer._x += c.x; answer._y += c.y; return answer; } dot(a) { // Return this dot a. return a.x * this._x + a.y * this._y; } length() { return Math.sqrt(this._x ** 2 + this._y ** 2); } static dot(a, b) { // This looks like Java-style overloading, but it's not. One dot() // is static and the other is not. return a.dot(b); } angleBetween(a) { // Return angle between this and a, based on // this dot a = |this| |a| cos angle // This is the angle between the two, without any orientation. let cos = this.dot(a) / (this.length() * a.length()); return Math.acos(cos); } cliffordBetween(a) { // The "clifford angle," which is like angleBetween(), but it takes // orientation into account. The angle is given relative to ("from") this. return Math.atan2(this.x * a.y - a.x * this.y, this.x * a.x + this.y * a.y); } } // Almost everything here is static because this is essentially a factory for the // various segment types. class PathSegment { constructor(kind, d) { // One of the values above. this.type = PathSegment.UNKNOWN; this.type = kind; this.s = d; } static getClose() { let d = {}; return new PathSegment(PathSegment.CLOSE, d); } static getMoveTo(x, y) { let d = { x: x, y: y }; return new PathSegment(PathSegment.MOVE_TO, d); } static getLineTo(x, y) { let d = { x: x, y: y }; return new PathSegment(PathSegment.LINE_TO, d); } static getBezier(cx1, cy1, cx2, cy2, x, y) { let d = { cx1: cx1, cy1: cy1, cx2: cx2, cy2: cy2, x: x, y: y }; return new PathSegment(PathSegment.BEZIER, d); } static getQuadratic(cx, cy, x, y) { let d = { cx: cx, cy: cy, x: x, y: y }; return new PathSegment(PathSegment.QUADRATIC, d); } static getArc(x, y, r, a0, a1, ccw) { let d = { x: x, y: y, r: r, a0: a0, a1: a1, ccw: ccw }; return new PathSegment(PathSegment.ARC, d); } static getArcTo(x1, y1, x2, y2, r) { let d = { x1: x1, y1: y1, x2: x2, y2: y2, r: r }; return new PathSegment(PathSegment.ARC_TO, d); } static getEllipse(x, y, rx, ry, rot, a0, a1, ccw) { // BUG: If I convert everything to bezier, then many of these static // methods can be eliminated. // YES. THIS IS A BAD IDEA IN EVERY WAY. ONLY BEZIER CURVES SHOULD // BE ALLOWED INTERNALLY. // OTOH, there are certain shapes, like an ellipse or rectangle, that // should be treated as a single unitary thing. // What I should probably do is sub-class FPath for these. Interally, they // can be represented as a messy bezier thing, but that would be hidden from the user. // At the same time, one might want to add an ellipse or rect to an existing path to // obtain various fill effects. So, the ellipse sub-class will need something like // a toFPath() method so that it can be added to a normal FPath. let d = { x: x, y: y, rx: rx, ry: ry, rot: rot, a0: a0, a1: a1, ccw: ccw }; return new PathSegment(PathSegment.ELLIPSE, d); } static getRect(x, y, w, h) { let d = { x: x, y: y, w: w, h: h }; return new PathSegment(PathSegment.RECT, d); } } // The various kinds of segment. // I'm being sloppy about typing here since the use is uncomplicated and // private. Some kind of enumerated type would be better in the abstract. // BUG: I should get rid of anything after the QUADRATIC type. The // mathematically clean way to do this is to convert *everything* to beziers, // including ellipses. In fact (?) is a quadratic just a cubic where the // control points coincide? If so, I could get rid of QUADRATIC too. PathSegment.MOVE_TO = 1; PathSegment.LINE_TO = 2; PathSegment.BEZIER = 3; PathSegment.QUADRATIC = 4; PathSegment.ARC = 5; PathSegment.ARC_TO = 6; PathSegment.ELLIPSE = 7; PathSegment.RECT = 8; PathSegment.CLOSE = 9; PathSegment.UNKNOWN = -1; // BUG: Add some flags so that things could be drawn or not drawn based // on whether the output is going to tikz or to the screen. class FPath extends Path2D { constructor() { super(); // An array of PathSegments. this.segs = []; } addPath(p) { // Append the elements of p to this. for (let i = 0; i < p.segs.length; i++) this.segs.push(p.segs[i]); } closePath() { super.closePath(); this.segs.push(PathSegment.getClose()); } moveTo(x, y) { super.moveTo(x, y); this.segs.push(PathSegment.getMoveTo(x, y)); } frontLineTo(x, y) { // To tack a line segment to the *begining* of an existing path. // This assumes that segs[0] is a moveTo() -- as I think (?) it must be // in any reasonable case. // So, you start with a path that looks like // moveTo(a,b) ...whatever // and it becomes // moveTo(x,y) lineTo(a,b) ...whatever. // You're basically drawing as usual, but "from the wrong end." let s = this.segs[0]; if (s.type != PathSegment.MOVE_TO) console.log("ERROR: frontLineTo() doesn't start with moveTo(): " + s.type); let newfirst = PathSegment.getMoveTo(x, y); // Convert the initial moveto to a lineto. // In fact, this is sort of pointless, and is only done this way to respect // the type-checker. It's (x,y) whether it's a lineto or a moveto. let m = s.s; let newsecond = PathSegment.getLineTo(m.x, m.y); this.segs[0] = newsecond; this.segs.unshift(newfirst); } lineTo(x, y) { super.lineTo(x, y); this.segs.push(PathSegment.getLineTo(x, y)); } bezierCurveTo(cx1, cy1, cx2, cy2, x, y) { super.bezierCurveTo(cx1, cy1, cx2, cy2, x, y); this.segs.push(PathSegment.getBezier(cx1, cy1, cx2, cy2, x, y)); } quadraticCurveTo(cx, cy, x, y) { super.quadraticCurveTo(cx, cy, x, y); this.segs.push(PathSegment.getQuadratic(cx, cy, x, y)); } translate(p) { // Translate this entire path by the given point. // BUG: Not implemented for every possible type of segment. let answer = new FPath(); for (let i = 0; i < this.segs.length; i++) { let s = this.segs[i]; if (s.type == PathSegment.MOVE_TO) { let m = s.s; answer.moveTo(m.x + p.x, m.y + p.y); } else if (s.type == PathSegment.LINE_TO) { let m = s.s; answer.lineTo(m.x + p.x, m.y + p.y); } else if (s.type == PathSegment.BEZIER) { let m = s.s; answer.bezierCurveTo(m.cx1 + p.x, m.cy1 + p.y, m.cx2 + p.x, m.cy2 + p.y, m.x + p.x, m.y + p.y); } else if (s.type == PathSegment.ELLIPSE) { let m = s.s; answer.ellipse(m.x + p.x, m.y + p.y, m.rx, m.ry, m.rot, m.a0, m.a1, m.ccw); } else { console.log("whatever translattion you want, it's not done."); } } return answer; } rotate(a) { // Rotate this entire path about the origin and return the result. // BUG: I have only implemented this for bezier curves and lines. // Expanding this probably doesn't make sense until I settle on a // framework to more fully replace Path2D. let answer = new FPath(); for (let i = 0; i < this.segs.length; i++) { let s = this.segs[i]; if (s.type == PathSegment.MOVE_TO) { let m = s.s; let p = new Point2D(m.x, m.y).rotate(a); answer.moveTo(p.x, p.y); } else if (s.type == PathSegment.LINE_TO) { let m = s.s; let p = new Point2D(m.x, m.y).rotate(a); answer.lineTo(p.x, p.y); } else if (s.type == PathSegment.BEZIER) { let m = s.s; let c1 = new Point2D(m.cx1, m.cy1).rotate(a); let c2 = new Point2D(m.cx2, m.cy2).rotate(a); let e = new Point2D(m.x, m.y).rotate(a); answer.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, e.x, e.y); } else { console.log("whatever rotation you want, it's not done."); } } return answer; } scale(r) { // Scale this entire path about the origin and return the result. // BUG: I have only implemented this for bezier curves and lines. let answer = new FPath(); for (let i = 0; i < this.segs.length; i++) { let s = this.segs[i]; if (s.type == PathSegment.MOVE_TO) { let m = s.s; let p = new Point2D(r * m.x, r * m.y); answer.moveTo(p.x, p.y); } else if (s.type == PathSegment.LINE_TO) { let m = s.s; // BUG: console.log("scale not done for lines"); } else if (s.type == PathSegment.BEZIER) { let m = s.s; let c1 = new Point2D(r * m.cx1, r * m.cy1); let c2 = new Point2D(r * m.cx2, r * m.cy2); let p = new Point2D(r * m.x, r * m.y); answer.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, p.x, p.y); } else { console.log("whatever scale you want, it's not done."); } } return answer; } reflectX() { // Reflect this entire path about the x-axis and return the result. // BUG: not implemented for every case. let answer = new FPath(); for (let i = 0; i < this.segs.length; i++) { let s = this.segs[i]; if (s.type == PathSegment.MOVE_TO) { let m = s.s; let p = new Point2D(m.x, -m.y); answer.moveTo(p.x, p.y); } else if (s.type == PathSegment.LINE_TO) { let m = s.s; let p = new Point2D(m.x, -m.y); answer.lineTo(p.x, p.y); } else if (s.type == PathSegment.BEZIER) { let m = s.s; let c1 = new Point2D(m.cx1, -m.cy1); let c2 = new Point2D(m.cx2, -m.cy2); let p = new Point2D(m.x, -m.y); answer.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, p.x, p.y); } else if (s.type == PathSegment.ELLIPSE) { let m = s.s; answer.ellipse(m.x, -m.y, m.rx, m.ry, m.rot, m.a0, m.a1, m.ccw); } else { console.log("whatever reflect you want, it's not done."); } } return answer; } reflectXY() { // Reflect this entire path about the x-axis AND y-axis. // BUG: not implemented for every case. // BUG: Also, what about reflectY()? let answer = new FPath(); for (let i = 0; i < this.segs.length; i++) { let s = this.segs[i]; if (s.type == PathSegment.MOVE_TO) { let m = s.s; let p = new Point2D(-m.x, -m.y); answer.moveTo(p.x, p.y); } else if (s.type == PathSegment.LINE_TO) { let m = s.s; // BUG: console.log("reflect not done for lines"); } else if (s.type == PathSegment.BEZIER) { let m = s.s; let c1 = new Point2D(-m.cx1, -m.cy1); let c2 = new Point2D(-m.cx2, -m.cy2); let p = new Point2D(-m.x, -m.y); answer.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, p.x, p.y); } else { console.log("whatever reflect you want, it's not done."); } } return answer; } rotateAbout(a, p) { // Rotate this entire path about p and return the result. let t1 = this.translate(new Point2D(-p.x, -p.y)); let t2 = t1.rotate(a); return t2.translate(p); } static arcToBezierNEW(r, a0, a1) { // Generate a series of bezier curves to represent an arc. The result // represents an arc of a circle of radius r, centered // at (0,0), going from angle a0 to a1, in radians. // Each step should subtend no more than pi/4 radians. Most of the // time pi/2 would be accurate enough, but pi/4 is better, and not that // much extra work. let totalAngle = a1 - a0; if (totalAngle < 0) totalAngle += 2 * Math.PI; let numCurves = Math.ceil(4 * totalAngle / Math.PI); let subtend = totalAngle / numCurves; // See the manual for where this comes from. It's the crucial constant // for approximating arcs of circles by cubics. let k = (4 / 3) * Math.tan(subtend / 4); // Everything is built out of a single arc for a circle of radius r, // going cw, starting at (1,0) and angle subtend. let s = Math.sin(subtend); let c = Math.cos(subtend); let p1 = new Point2D(r, 0); let p2 = new Point2D(r, r * k); let p3 = new Point2D(r * (c + k * s), r * (s - k * c)); let p4 = new Point2D(r * c, r * s); // The arc determined by the p_i above must be rotated to create // a series of sub-arcs to get the total arc we want. let answer = new FPath(); answer.moveTo(p1.x, p1.y); for (let i = 0; i < numCurves; i++) { answer.bezierCurveTo(p2.x, p2.y, p3.x, p3.y, p4.x, p4.y); p2.rotateSelf(subtend); p3.rotateSelf(subtend); p4.rotateSelf(subtend); } // Rotate the entire thing so that it starts at a0. answer = answer.rotate(a0); return answer; } arc(x, y, r, a0, a1, ccw) { // BUG: I am pretty sure this isn't right. There are things about // being cw/cww and things like that. It needs to be tested. // A circular arc, centered at (x,y) and radius r, from angle // a0 to angle a1 (in radians), going cw or ccw. Like ellipse, this // is basically independent of the surrounding segments. Actually, // the documentation I've found is a little vague on this point, // but it looks like that is how it works. // // Another issue is the fact that these angles, a0 and a1, may // have "extra" multiples of 2pi in them and whether a1>a0. // The first thing this does is reduce the angles to be in [0,2pi). // // The whole cw versus ccw issue amounts to whether you're getting // the "large" arc or the "small" arc. If a1 > a0, then the ccw arc // is the small arc and the cw arc is the large arc. If a1 < a0, // then the ccw arc is the large arc and the cw arc is the small arc. // To untangle this, assume that the arc will be treated ccw, and // swap a1 and a0, if necessary, to make that the case. // // Finally, there's the issue of what cw and ccw mean when in // left or right handed coordinate systems. Ugh. // // See // https://pomax.github.io/bezierinfo/#circles_cubic // for an explanation of the math, which is just HS algrbra. // // // BUG: Seems like this is a special case of an arc of an ellipse. if (ccw === undefined) ccw = false; // Reduce angles to be in [0,2pi). while (a0 < 0) a0 += 2 * Math.PI; while (a1 < 0) a1 += 2 * Math.PI; while (a0 >= 2 * Math.PI) a0 -= 2 * Math.PI; while (a1 >= 2 * Math.PI) a1 -= 2 * Math.PI; // If the user asked for cw, then swap the angles so that we only // need to consider the ccw case below. if (ccw === true) { let temp = a1; a1 = a0; a0 = temp; } // Get the various arcs for a circle centered at zero. let arcs = FPath.arcToBezierNEW(r, a0, a1); // Translate them all by (x,y). arcs = arcs.translate(new Point2D(x, y)); this.addPath(arcs); } ellipse(x, y, rx, ry, rot, a0, a1, ccw) { // BUG: Long-term, the right thing to do here is convert it (internally) // to a series of bezier curves. if (ccw === undefined) ccw = false; super.ellipse(x, y, rx, ry, rot, a0, a1, ccw); this.segs.push(PathSegment.getEllipse(x, y, rx, ry, rot, a0, a1, ccw)); } rect(x, y, w, h) { // BUG: As above, make into a series of line segments. This one is easy. super.rect(x, y, w, h); this.segs.push(PathSegment.getRect(x, y, w, h)); } static circArcToBezier(r, a0, a1) { // Return a series of n bezier curves for a circle of radius r, extending // from r(cos a0,sin a0) to r (cos a1,sin a1). // // BUG: This should really be part of ellipse(). let answer = FPath.arcToBezierNEW(r, a0, a1); return answer; } static parametricToBezier(f, t0, t1, n) { // Given a 2D parametric curve, f(t) = x(t),y(t)), this returns a bezier // approximation from t=t0 to t=t1 by taking n time-steps. Obviously, // f must be a function returning f.x and f.y. // // This works by sampling f (n+1) times, plus n times at the in // between points, and fitting a Bezier to each trio of points. It // follows the notes in the "main" manual. This is a hard problem -- or // a messy one. There are various strategies. The one used here is to // choose the tangent at the intermediate point (which I call B) to be // parallel to the line between the two end-points of the Bezier segment. // This is relatively straightforward, but one problem with this is that // the slopes where these segments meet need not be the same -- the // resulting curve is not G_1. I'm pretty sure that I worked out a method // once that was based (somehow?) on the way MetaPost works, but it's // complicated and messy and uses complex numbers. let p = new FPath(); let p1 = f(t0); p.moveTo(p1.x, p1.y); for (let i = 0; i < n; i++) { let p4 = f(t0 + (i + 1) * (t1 - t0) / n); let B = f(t0 + (i + 0.5) * (t1 - t0) / n); // Work out an appropriate value for t based on relative distances. let d1 = Math.sqrt((B.x - p1.x) ** 2 + (B.y - p1.y) ** 2); let d2 = Math.sqrt((B.x - p4.x) ** 2 + (B.y - p4.y) ** 2); let t = d1 / (d1 + d2); // The p4 to p1 vector: let V = new Point2D(p4.x - p1.x, p4.y - p1.y); // e1 = B - (1-t)(p4-p1)/3 and e2 = B + t(p4-p1)/3. let e1 = new Point2D(B.x - (1 - t) * V.x / 3, B.y - (1 - t) * V.y / 3); let e2 = new Point2D(B.x + t * V.x / 3, B.y + t * V.y / 3); // Run de Casteljau's algorithm backwards. I call this alpha too, // but r is a better name since it's a ratio. let r = 1 - 1 / (t ** 3 + (1 - t) ** 3); let u = (1 - r) * (1 - t) ** 3; let C = new Point2D(p1.x * u + p4.x * (1 - u), p1.y * u + p4.y * (1 - u)); let A = new Point2D(B.x + (C.x - B.x) / r, B.y + (C.y - B.y) / r); let v1 = new Point2D((e1.x - A.x * t) / (1 - t), (e1.y - A.y * t) / (1 - t)); let v2 = new Point2D((e2.x - A.x * (1 - t)) / t, (e2.y - A.y * (1 - t)) / t); let p2 = new Point2D((v1.x - p1.x * (1 - t)) / t, (v1.y - p1.y * (1 - t)) / t); let p3 = new Point2D((v2.x - p4.x * t) / (1 - t), (v2.y - p4.y * t) / (1 - t)); p.bezierCurveTo(p2.x, p2.y, p3.x, p3.y, p4.x, p4.y); p1 = p4.copy(); } return p; } } // Text is a special case because it expects a LH coordinate system, but // everything else is set up for a RH coordinate system. The end-user // shouldn't make a direct call to ctx.fillText(). If he does, then the // the text will be upside-down. So, call this instead. // // Getting the placement of the js to match the placement of the tikz exactly // is difficult because they're using two different fonts. So the tikz // is drawn at (x+dx,y+dy). The dx and dy are optional and default to zero. // // BUG: I *could* create a class, something like FPath, to handle all drawing // of text, which may be more natural to the user. But I would probably have // to extend CanvasRenderingContext2D somehow and use that everywhere, not // just when creating TikZ. For now, this is a sufficient solution. // Another approach would be to overwrite the existing // CanvasRenderingContext2D.fillText method to call the function below. // In some ways, that's the "right" thing to do, but my gut is that // it could lead to various problems and make the code generally brittle. function drawText(ctx, txt, x, y, dx = 0, dy = 0) { let saveT = ctx.getTransform(); if (ctx instanceof CTX) { // Don't fool around. Just write it to the .tikz file. // BUG: The ts compiler complains about this, but it works fine. ctx.fillText(txt, x + dx, y + dy); ctx.setTransform(saveT); return; } // Get the measurements -- all we really care about is the baseline. // Transform the ctx so that the horizontal line at y becomes the // origin, flip the scale, and draw at (x,0). // // Recapitulating the info on MDN, we care about m.actualBoundingBoxAscent // and m.actualBoundBoxDescent, which give distances from ctx.textBaseline // to the relevant side of the bounding box of the text. The baseline // defaults to the 'alphabetic' setting, which puts the baseline just // under where you normally draw the letter -- B sits on the baseline, // while p hangs below it. let m = ctx.measureText(txt); ctx.translate(0, y); ctx.scale(1, -1); ctx.textBaseline = 'bottom'; ctx.fillText(txt, x, 0); ctx.setTransform(saveT); } // As above, but these to draw the text in only one scenario or // the other. This could be handled with boolean arguments to the // above, but this seems clearer for the user. function drawTextBrowserOnly(ctx, txt, x, y, dx = 0, dy = 0) { if (ctx instanceof CTX) // Skip it. return; drawText(ctx, txt, x, y, dx, dy); } function drawTextTikZOnly(ctx, txt, x, y, dx = 0, dy = 0) { if (ctx instanceof CTX) ctx.fillText(txt, x + dx, y + dy); } // This is to act much like the object returned from // canvas.getContext('2d'). // Only a few elements of the standard context class are needed. // I purposely did *not* make this extend CanvasRenderingContext2D. // By not extending, you can't accidentally make use of some feature // of the normal ctx framework and have it silently fail. // // This is one major difference. Each time you want to render a figure, // you need a new one of these since the tikz text goes out to a file // with a different name. In principle, it would be possible to allow // reusing these, but there's no value in allowing for that. // I had hoped not to need to deal with transformation matricies, and // just (implicitly) use the identity matrix. However, certain things // are easier for the user if they are permitted. See // www.alanzucconi.com/2016/02/10/tranfsormation-matrix // for a brief summary of how these work. // // Think of the matrix as R in the upper left, for rotation etc, and // (tx,ty,1) in the right column for translation, with M as the overall // matrix. The bottom row is always (0 0 1). If the user gives (x y) as // some position relative to M, then the "real" position is M(x y 1). // By "real" I mean that position relative to the identity matrix. // // BUG: I think I am doing this the wrong way. As things stand, I store // the thing the user does (the path and any points or whatever that // specify) in terms given by the user. Then I convert those values to // their "unadjusted" values when written to tikz output. Instead, I should // convert things as they come in. For one thing, as things stand, if // the user adjusts the t-matrix as things are drawn, it would mess up // everything. This would also side-step certain questions like what // a shear transformation should mean for something like an ellipse. If // we correct things as just described, then an ellipse is an ellipse, // and it is not shear-transformed, although the points where ellipse // is located would be shear-transformed. // // BUG: Add a flag, like CTX.paper, and set is to true here. // That way, the rendering process can output something different on paper. // This flag will be undefined when run in a browser. class CTX { constructor(name) { // Transformation matrix. // BUG: Try to get rid of this. I think that, now that all drawing is // done with a RH system, this is unnecessary. Everything related to // tmatrix is private and I think it's effectively unused. this.tmatrix = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]; // BUG: This is *not* the right way to do things, but it's easier. // The problem is in scaling lengths, which are not points. This is a // particular problem with radii. The proper solution is to work this value // out from the tmatrix, but that's messy. // This is why things like ellipses should be treated as beziers. this.netScale = 1.0; // To allow the user to set the linewidth. Otherwise, tikz uses a // default value of 0.4pt. Tikz has certained named line widths, like // 'semithick' and 'ultra thin' but I don't care about those. It's better // to stick with numerical values to be consistent with js. // This name matches what's used in a "normal" ctx. // The way to specify line width in tikz is as an option to \draw: // \draw[line width = 1mm] ...whatever... // for example. this.lineWidth = 1.0; // File name (without the '.tizk') for the figure. this.figureName = ""; // This holds the output as it is generated. this.tikzstr = ""; // Provide the name of the figure whose tikz is being generated. // This goes to a file, which is fiddly with js. The contents of the // file will be sent to the server, and it is assumed that the server // knows what to do. A normal HTTP server will choke on it (really, // it will just ignore it). // // As each call to stroke(), fill(), and so forth is made, the corresponding // tikz is noted. When all these are calls are done, call close() to write it out. this.figureName = name; this.tikzstr = ""; // The tikz file needs a bit of a heading. this.tikzstr += "\\begin{tikzpicture}\n"; // And everything is clipped to the permitted drawing area. To obtain // that area, we need to look at the figure specification. let myFunc = getAugmentedFunction(name); let fpc = myFunc.figurePanelClass; // Neither one really seems to give the right thing. // Maybe use \clip as an option to // \begin{tikzpicture}[\clip something?] //this.tikzstr += "\\clip (0bp,0bp) rectangle (" +fpc.textWidth+ // "bp," + fpc.h+ "bp);\n"; this.tikzstr += "\\useasboundingbox (0bp,0bp) rectangle (" + fpc.textWidth.toFixed(2) + "bp," + (fpc.h - fpc.lowerPadding - fpc.upperPadding).toFixed(2) + "bp);\n"; } close() { // Finalize the tikz specification, and write it out. // // Note that, under Firefox, this generates an error on the console: // // XML Parsing Error: no root element found // Location: http://localhost:8000/geartest01.tikz // Line Number 1, Column 1: // // or whatever the file name is that's saved. Apparently this is a // "known issue" (aka, a bug) with Firefox. No such message appears // with MS Edge. It works the same either way. this.tikzstr += "\\end{tikzpicture}\n"; // BUG: No doubt there is a more modern fetch() way to do this. let req = new XMLHttpRequest(); // This is *really* not the standard way to do things. // Pass the file name to save under, then the text to save. // I *should* be passing some cgi script that takes input, but // I've tweaked the http server so that it's non-standard, // and does what I want instead of what it is supposed to do. let fname = this.figureName + ".tikz"; req.open("POST", fname); // I have no idea whether this is really necessary. req.setRequestHeader("Content-Type", "text/plain;charset=UTF-8"); req.send(this.tikzstr); } static clone3x3Matrix(m) { // JS seems not to have a standard way of creating a copy of a matrix. // This does it for a 3x3 matrix and returns the result. let a = []; a[0] = []; a[0][0] = m[0][0]; a[0][1] = m[0][1]; a[0][2] = m[0][2]; a[1] = []; a[1][0] = m[1][0]; a[1][1] = m[1][1]; a[1][2] = m[1][2]; a[2] = []; a[2][0] = m[2][0]; a[2][1] = m[2][1]; a[2][2] = m[2][2]; return a; } getTransform() { return CTX.clone3x3Matrix(this.tmatrix); } setTransform(t) { this.tmatrix = CTX.clone3x3Matrix(t); } translate(tx, ty) { // Adjust the transformation matrix. Going forward, this will have the // effect of converting (x,y) to (tx + x,ty+y) whenever the user // refers to (x,y). this.tmatrix[0][2] += tx; this.tmatrix[1][2] += ty; } scale(sx, sy) { // Scale the transformation matrix. // Let S = diag(sx,sy,1). The new t-matrix is the old t-matrix times S. // // BUG: I am not sure. Maybe it should be S times old t-matrix, and // I have the order wrong. For the time being it doesn't matter since // every case I care about has sx=sy and the matrices commute in that // special case. this.tmatrix[0][0] *= sx; this.tmatrix[0][1] *= sy; this.tmatrix[1][0] *= sx; this.tmatrix[1][1] *= sy; // Track this here too. // BUG: This assumes that sx = xy. this.netScale *= sx; } applyTMatrix(x, y) { // Return tmatrix times (x,y). As a matrix operation, this is // tmatrix x (x y 1), but we only return the first two entries. let ax = this.tmatrix[0][0] * x + this.tmatrix[0][1] * y + this.tmatrix[0][2]; let ay = this.tmatrix[1][0] * x + this.tmatrix[1][1] * y + this.tmatrix[1][2]; return { x: ax, y: ay }; } handlePath(path) { // Called by either fill() or stroke(). var segs = path.segs; for (let i = 0; i < segs.length; i++) { // s is a PathSegment object. let s = segs[i]; if (s.type == PathSegment.MOVE_TO) { let m = s.s; let t = this.applyTMatrix(m.x, m.y); //this.tikzstr += "(" +s.x+ "pt, " + s.y+ "pt) "; this.tikzstr += "(" + t.x.toFixed(2) + "bp, " + t.y.toFixed(2) + "bp) "; } else if (s.type == PathSegment.LINE_TO) { // Lines are drawn with the tikz \draw command. It takes the form // \draw [options] (x1,y1) -- (x2,y2); // Note that I include "bp" for the dimensions. I think that tikz // defaults to cm if no dimension is given, so I should specify // something. Note also that I use bp, not pt. // // BUG: I am not sure whether the tikz point is 72ppi or 72.27 ppi // to match latex. // // BUG: For the time being, I will ignore these options, but they // can be things like fill or dashed, or to set the color or line // width, and probably a mess of other stuff. let m = s.s; let t = this.applyTMatrix(m.x, m.y); this.tikzstr += "-- (" + t.x.toFixed(2) + "bp, " + t.y.toFixed(2) + "bp) "; } else if (s.type == PathSegment.BEZIER) { // The sources I found aren't very explicit about exactly how // this is implemented. I assume it's done in the usual way. // We have // P(t) = B(3,0)*CP + B(3,1)*P1 + B(3,2)*P2 + B(3,3)*P3, // where t\in [0,1] and B are the usual Bernstein polynomials (the // functions of t): // B(n,m) = C(n,m) t^m (1-t)^(n-m), // and C is the choice function. // See also the tikz/pgf manual (v3.1.9a), p. 156, for the output. // However the manual is wrong, or not clear. Use 'and' between // the control points. // // The gist is that CP is fixed point where the curve starts; // it's implicit for both Java and tikz. P1 and P2 are the control // points and P3 is where the curve terminates. Fortunately this // matches up nicely with the tikz syntax. let m = s.s; let t1 = this.applyTMatrix(m.cx1, m.cy1); let t2 = this.applyTMatrix(m.cx2, m.cy2); let t3 = this.applyTMatrix(m.x, m.y); this.tikzstr += ".. controls (" + t1.x.toFixed(2) + "bp, " + t1.y.toFixed(2) + "bp) and (" + t2.x.toFixed(2) + "bp, " + t2.y.toFixed(2) + "bp) .. (" + t3.x.toFixed(2) + "bp, " + t3.y.toFixed(2) + "bp)"; } else if (s.type == PathSegment.QUADRATIC) { // BUG: Put this back. console.log("quadratic does not work"); // Tikz has this too (whew). Oddly, it's part of the pgf stuff. // Everything else is drawn with \draw (or \fill), but this // uses \pgfpathquadraticcurveto. I'm not sure if that matters, // and I hope that you can mix these freely in the middle of // a \draw command. The tikz/pgf manual isn't very clear on // mixing these. // BUG: I wonder if I should be using commands like \pgflineto, // \pgfcurveto, and so forth, throughout what I've done. // See the tikz/pgf manul (v3.1.9a), p. 1095. // // BUG: I'm going to code this hoping that it works, but I suspect // that it will not, and will need to go back and change to // something other than \draw or \fill to start with. // Maybe I need to define an entire path and then \draw or \fill // it? It looks like you define that path, then say // \pgfusepath{fill} or whatever. // // BUG: Maybe I could convert this to a cubic here and avoid this // entire messy issue? // // NOTE: pgf has some nice commands for drawing only *part* of // a Bezier curve. See p. 1097 for \pgfpathcurvebetweentime. // BUG: Maybe I'll be lucky and this is never called. // I think (?) it must be that the only time this type of // segment ever arises is if the user users a QuadCurve2D, which // seems (?) unlikely. /* let t1 = this.applyTMatrix(s.cx,s.cy); let t2 = this.applyTMatrix(s.x,s.y); //this.tikzstr += // "\\pgfpathquadraticcurveto {\\pgfpoint{" + // s.cx+ "pt}{" +x.cy+ "pt}}{\\pgfpoint{" + // s.x+ "pt}{" +s.y+ "pt}}"; this.tikzstr += "\\pgfpathquadraticcurveto {\\pgfpoint{" + t1.x+ "bp}{" +t1.y+ "bp}}{\\pgfpoint{" + t2.x+ "bp}{" +t2.y+ "bp}}"; */ } else if (s.type == PathSegment.ARC) { console.log("arc"); // This is a circular arc of a circle // centered at (x,y) over a given range of angles (cw or ccw). // // BUG: For the remaining cases, I may need to do something // special. It's not clear exactly what the browser is doing // with these. Are they converted, internally, to bezier // curves or are they somehow rendered more directly. this.tikzstr += "no arc implemented"; } else if (s.type == PathSegment.ARC_TO) { console.log("arc to not done"); // This is essentially a bezier curve. // You have two control points and a radius. It is not // clear exactly how it works. this.tikzstr += "no arcTo implemented"; } else if (s.type == PathSegment.ELLIPSE) { // BUG: This will only draw a complete ellipse, not an arc of // an ellipse. // BUG: The foolishness with netScale is another reason not // to allow an ellipse type. If an ellipse were really a series // of bezier curves, then this would be a non-issue. let m = s.s; let c = this.applyTMatrix(m.x, m.y); //console.log(s.x+ "," +s.y+ " becomes " +c.x+ " " +c.y); //this.tikzstr += "(" +c.x+ "pt," +c.y+ // "pt) ellipse [x radius=" +s.rx*this.netScale+ // "pt,y radius =" + s.ry*this.netScale+ "pt]"; this.tikzstr += "(" + c.x.toFixed(2) + "bp," + c.y.toFixed(2) + "bp) ellipse [x radius=" + (m.rx * this.netScale).toFixed(2) + "bp,y radius =" + (m.ry * this.netScale).toFixed(2) + "bp]"; } else if (s.type == PathSegment.RECT) { console.log("rect not done"); this.tikzstr += "no rect implemented"; } else if (s.type == PathSegment.CLOSE) { this.tikzstr += "-- cycle"; } else { console.log("unknown FPath: " + s.type); } } this.tikzstr += ";\n"; } stroke(path) { let segs = path.segs; if (segs.length === 0) return; this.tikzstr += "\\draw[line width=" + this.lineWidth.toFixed(2) + "bp] "; this.handlePath(path); } fill(path) { let segs = path.segs; if (segs.length === 0) return; this.tikzstr += "\\fill "; this.handlePath(path); } fillText(s, x, y) { // BUG: This is now done with top-level functions now and shouldn't // be called (or callable) by outside code. // // BUG: I have my doubts about including this one. It needs to be done // *somehow*, but I am concerned about a mismatch between the JS // font and the fonts used by latex. // // BUG: I am ignoring the ctx.font setting. It does seem that if you // set it to '10px san-serif' you get something reasonable for the // browswer that doesn't look too different than latex. // // BUG: This is so fussy that I suspect that any drawing that is at // all tricky will require that the user provide different placement for // text on the browser and text on the page. Getting things to match // up *exactly* may be impossible. let t = this.applyTMatrix(x, y); // I had this as 'anchor=south west', but 'base west' seems closer // to what latex does. // BUG: It's all a mystery. this.tikzstr += "\\node [anchor=base west] at (" + t.x.toFixed(2) + "pt, " + t.y.toFixed(2) + "pt) {" + s + "};\n"; } } class Numerical { static newton(f, g, a, b, y, e) { // Given a function, f, and an initial guess, g, bracketed between a and b, // for the argument to f, and a target value, y, this returns x such that // f(x) = y to within error, e. // // A crude off-the-cuff implementation of Newton-Raphson. // This will only work in the tamest situations. // // Recall that the idea is that // f(x0 + dx) ~ f(x0) + f'(x0) dx // We want y = f(x + dx) and that is approximately equivalent to // y = f(x0) + f'(x0) dx or dx = ( y - f(x0) ) / f'(x0) // so that x0 becomes x1 = x0 + dx = x0 + ( y - f(x0) ) / f'(x0) // // NOTE: I had hoped to avoid the need to bracket entirely, and for // some functions (and sufficiently good initial guesses), you could, // but it's too easy for the algorithm to get lost among local extrema // if there is no bracket. // // In fact, here is a good example of why bracketing is needed. // Let f = cos x + x sin x, which happens to be the x-coordinate for // the parameterization of the unit involute. Suppose that you want // to find x for which f(x) = 1.5, and you start off with a guess of // x = 0.5. The slope of f at 0.5 is small so that Newton-Raphson // sends x1 to a value that is beyond the inflection point near x = 3. // At that point things go haywire. let x0 = g; let y0 = f(x0); let i = 0; while (Math.abs(y - y0) > e) { let fplus = f(x0 + e); let fminus = f(x0 - e); let fprime = (fplus - fminus) / (2 * e); let dx = (y - y0) / fprime; let x1 = x0 + dx; // Make sure we haven't passed a bracket. Just subdivide if we have. if (x1 > b) x1 = (x1 - x0) / 2; if (x1 < a) x1 = (x0 - x1) / 2; x0 = x1; y0 = f(x0); // Don't allow an infinite loop ++i; if (i > 100) return x0; } return x0; } }