From fcd380d0a9f42e14bfc55d8ea92e93fe0b7b48b9 Mon Sep 17 00:00:00 2001 From: Norbert Preining Date: Sat, 23 Jul 2022 03:01:02 +0000 Subject: CTAN sync 202207230301 --- graphics/figput/javascript/development/layout.ts | 654 +++++ graphics/figput/javascript/development/main.ts | 1086 ++++++++ graphics/figput/javascript/development/tikz.ts | 1419 ++++++++++ graphics/figput/javascript/development/widgets.ts | 2908 +++++++++++++++++++++ graphics/figput/javascript/figput.html | 42 + graphics/figput/javascript/layout.js | 489 ++++ graphics/figput/javascript/main.js | 833 ++++++ graphics/figput/javascript/pdf.js | 1 + graphics/figput/javascript/pdf.worker.min.js | 1 + graphics/figput/javascript/pdfjs_license.txt | 180 ++ graphics/figput/javascript/release/figput.html | 38 + graphics/figput/javascript/release/figput.js | 457 ++++ graphics/figput/javascript/server.py | 209 ++ graphics/figput/javascript/tikz.js | 1040 ++++++++ graphics/figput/javascript/widgets.js | 2256 ++++++++++++++++ 15 files changed, 11613 insertions(+) create mode 100644 graphics/figput/javascript/development/layout.ts create mode 100644 graphics/figput/javascript/development/main.ts create mode 100644 graphics/figput/javascript/development/tikz.ts create mode 100644 graphics/figput/javascript/development/widgets.ts create mode 100644 graphics/figput/javascript/figput.html create mode 100644 graphics/figput/javascript/layout.js create mode 100644 graphics/figput/javascript/main.js create mode 100644 graphics/figput/javascript/pdf.js create mode 100644 graphics/figput/javascript/pdf.worker.min.js create mode 100644 graphics/figput/javascript/pdfjs_license.txt create mode 100644 graphics/figput/javascript/release/figput.html create mode 100644 graphics/figput/javascript/release/figput.js create mode 100644 graphics/figput/javascript/server.py create mode 100644 graphics/figput/javascript/tikz.js create mode 100644 graphics/figput/javascript/widgets.js (limited to 'graphics/figput/javascript') diff --git a/graphics/figput/javascript/development/layout.ts b/graphics/figput/javascript/development/layout.ts new file mode 100644 index 0000000000..e1e1c496f4 --- /dev/null +++ b/graphics/figput/javascript/development/layout.ts @@ -0,0 +1,654 @@ +/* +Code for page layout. The idea is a bit like Java Swing (or a zillion other +similar setups), but stripped down to only what I need. In particular, the +Panels are assumed to be stacked vertically. + +Everything is accessed through FullPanel, which consists of a list of +PagePanel objects. These are stacked vertically, and each one contains at +least one PDFPanel, and may contain FigurePanels. In the lingo of something +like Java, it would be better to think of PagePanel as a Container or a Box +since it's used for layout and doesn't directly do anything other than pass +things to its sub-parts. + +*/ + + +// This class is the only thing that should be touched by code outside +// this file. FullPanel.init() is called to set up page layout. Everything +// is static since there is only one window/canvas. + +class FullPanel { + + // The full document consists of a list of PagePanel objects. + // thePages[i] is the i-th page, counting from zero. + public static thePages : PagePanel[] = []; + + // The width of the widest page of the entire document. Don't access + // this directly; use getFullWidth(). + private static totalWidth : number = -1; + + + static init(specList : PageData[]) { + + // Add all the pages to this document. specList is PDFDocument.pageSpecs. + // Each element of specList corresponds to page. + // Call this once, when the program starts, to set up the page layout. + let cumV = 0; + for (let i = 0; i < specList.length; i++) + { + let pp = new PagePanel(i,specList[i],cumV); + this.thePages[i] = pp; + cumV += pp.h; + } + } + + static async renderAll( height : number ) { + + // Render every PagePanel in FullPanel.thePages. + + // Internal to this function, the height should be in "page pixels," + // meaning the number of pixels tall the destination canvas is at the + // current zoom ratio of the offsreen pages. + height = height / PDFDocument.getZoom(); + + // Because of the use of promises by pdf.js, rendering is broken into + // two steps: pre-render and the actual rendering. Pre-rendering + // generates a bunch of promises (the pages rendered offscreen) and + // rendering can't be done until those promises resolve. + let ps = []; + for (let i = 0; i < this.thePages.length; i++) + { + let p = this.thePages[i].preRender(height); + ps.push(p); + } + + // Don't return until this is done! + await Promise.all(ps); + + for (let i = 0; i < this.thePages.length; i++) + this.thePages[i].render(height); + + // Eliminate any excess pages from the buffer of offscreen pages. + // Do this after copying out, just in case there *is* some weird + // problem with race conditions. + PDFDocument.trimBuffer(); + } + + static totalHeight() : number { + + // Return the total height of all pages. This is used to set up + // the scroll bars. + let answer = 0; + for (let i = 0; i < this.thePages.length; i++) + answer += this.thePages[i].h; + + return answer; + } + + static getFullWidth() : number { + + // Return the width of the widest page. Most of the time, the pages of + // a document all have the same width, but they might not in some + // rare case. This is needed to center things left/right in the window. + if (this.totalWidth > 0) + return this.totalWidth; + + // Need to calculate it for the first time. + for (let i = 0; i < this.thePages.length; i++) + { + let pp = this.thePages[i]; + if (pp.w > this.totalWidth) + this.totalWidth = pp.w; + } + + return this.totalWidth; + } + + static mouseDown(x : number , y : number ) : void { + + // (x,y) is in pdf points, relative to the entire document. + // So y will be a huge number if it's on the umpteeth page. + + // To save CPU, I could start off by checking whether x is in + // [0,pageWidth] and return if it is not, but the user might want to + // place controls outside the page. This is unlikely, but possible. + + // Whoever had focus loses it. It may be taken up by some other widget + // (or the same widget again), but nobody has focus by default. + WidgetManager.focusOwner = null; + + // Figure out which page this is and hand it off. + let i = 0; + for ( ; i < this.thePages.length; i++) + { + if (this.thePages[i].v > y) + { + // Found the first and only page this could be. It was the + // page previous to this one. + i -= 1; + break; + } + } + + // If we got here then it had to be the very last page. + if (i === this.thePages.length) + i = this.thePages.length - 1; + + // Safety check: + if ((i == this.thePages.length) || (i < 0)) + return; + + this.thePages[i].mouseDown(x,y); + } + + static mouseMove(x : number , y : number ) : void { + + // As above. However, these are only of interest if some widget + // "owns" the mouse -- something was clicked on so that motion + // could mean something. + if (WidgetManager.mouseOwner === null) + return; + + // Although we know exactly which widget will ultimately get this + // event, it's easier to let this pass through the layout hierarchy, + // just as for mouseDown(), so that the coordinates are properly adjusted. + // BUG: Not DRY. + let i = 0; + for ( ; i < this.thePages.length; i++) + { + if (this.thePages[i].v > y) + { + i -= 1; + break; + } + } + + if (i === this.thePages.length) + i = this.thePages.length - 1; + + if ((i == this.thePages.length) || (i < 0)) + return; + + this.thePages[i].mouseMove(x,y); + } + + static mouseUp(x : number , y : number ) : void { + + // The big issue here is that the mouse was released so that ownership + // is once again up for grabs. In addition, certain widgets will want + // to know where the mouse was released. Buttons are a good example. + // You could have mouse-down on the button, then the user moves the + // mouse out of the button and releases it; the button should only be + // "clicked" if the mouse was released over the button. + // + // An annoying thing here is that the *only* widget that could care about + // this (at least, as designed) is the one that owns the mouse-down. + // As above, we want the event to pass through the layout hierarchy + // so that (x,y) is adjusted for the right frame, but the ultimate + // *consumer* of the event may not even be on the page where the + // mouse-up occured. + // BUG: Not DRY. + if (WidgetManager.mouseOwner === null) + return; + + let i = 0; + for ( ; i < this.thePages.length; i++) + { + if (this.thePages[i].v > y) + { + i -= 1; + break; + } + } + + if (i === this.thePages.length) + i = this.thePages.length - 1; + + if ((i == this.thePages.length) || (i < 0)) + return; + + this.thePages[i].mouseUp(x,y); + + // Whatever happened above, the mouse is now up for grabs. + WidgetManager.mouseOwner = null; + } + +} + + +// A PagePanel is the top-level thing, just under the canvas. Each +// PagePanel makes up a single page of the printed document. There's +// a list of them in FullPanel. It includes references to the PDFPanel +// and FigurePanel objects that it contains. + +class PagePanel { + + // Every panel has a vertical position within the entire document and height, + // in pdf pts. The vertical postion, v, is the top of the page, so the page + // extends from v to v+h. The caller must ensure that the Panels stack up + // correctly since there is no real page layout. + // + // In some ways the height, h, is redundant since it could be worked + // out from the heights of the individual sub-parts. In fact (see below) + // it *is* worked out by the constuctor, but it's easier to do it once + // and be done with it. This is the height of the page, as rendered, + // taking into account any mismatch due to extra "padding" in the figures + // (if there is any, and there often is not). + v = 0; + w = 0; + h = 0; + + // page number, counting from zero. + pageNum = 0; + + // The SubPanels that make up this PagePanel. + parts : SubPanel[] = []; + + + constructor(pageNum : number , pageSpec : PageData , v : number) { + + // This implicitly applies to the global (because everything is static) + // PDFDocument. pageNum is which page this is, counting from zero. + // pageSpec has the info about how the page breaks into pieces and the + // relevant figures. v is where this page lies in the entire vertical + // list of pages. + this.w = pageSpec.pageWidth; + this.v = v; + this.pageNum = pageNum; + + // Create the PDFPanels and FigurePanels in this PagePanel. + let s = pageSpec; + if (s.insertPoint.length == 0) + { + // There are no figures on this page. + let p = new PDFPanel(pageNum,this.v,0,0,this.v,this.w,s.pageHeight); + this.h = s.pageHeight; + this.parts = [p]; + } + else + { + // There are figures. + let srcV = 0; + let destV = 0; + let totalV = v; + for (let j = 0; j < s.insertPoint.length; j++) + { + // Bit of pdf above figure. + let p = new PDFPanel(pageNum,this.v,srcV,destV,totalV,this.w, + s.insertPoint[j]-srcV); + destV += s.insertPoint[j]-srcV; + totalV += s.insertPoint[j]-srcV; + + let f = new FigurePanel( this.v , destV , totalV , this.w , + s.deleteHeight[j] + s.aboveHeight[j] + s.belowHeight[j] , + s.aboveHeight[j] , s.belowHeight[j] , s.leftMargin , + s.textWidth , s.drawFcn[j] ); + + srcV = s.insertPoint[j] + s.deleteHeight[j]; + destV += s.deleteHeight[j] + s.aboveHeight[j] + s.belowHeight[j]; + totalV += s.deleteHeight[j] + s.aboveHeight[j] + s.belowHeight[j]; + + this.parts.push(p); + this.parts.push(f); + } + + // And the bit of pdf below the last figure on the page. + let p = new PDFPanel(pageNum,this.v,srcV,destV,totalV, + this.w,s.pageHeight - srcV); + this.parts.push(p); + + this.h = destV + s.pageHeight - srcV + } + } + + async preRender( height : number ) { + + // This renders the underlying page of pdf. It returns a promise + // so that the caller can wait for the promise to resolve before + // attempting to copy from the rendered page. + // + // The vpos is where the top of the ctx should be relative to the entire + // document, in pdf pts, and height is how much is visible, in rendered + // page pixels. + // The first question is whether any of this page is visible. + let vpos = window.scrollY; + if (this.v + this.h < vpos) + // Entire page is above the visible area. + return; + if (this.v > vpos + height) + // Entire page is below the visible area. + return; + + // Got here, so some portion of the page is visible. + await PDFDocument.render(this.pageNum); + } + + render( height : number ) : void { + + // Render every SubPanel of the current PagePanel. + + // BUG: Before returning, turn off any of these animations that are + // definitely not visible. + let vpos = window.scrollY; + if (this.v + this.h < vpos) + return; + if (this.v > vpos + height) + return; + + // Got here, so some portion of the page is visible. From here on, + // render everything, whether it's actually visible or not. + let ctx = ctxTopLevelAdjust(); + + // Call the parts of the page. These could be PDFPanel or FigurePanel + // objects. + for (let i = 0; i < this.parts.length; i++) + this.parts[i].render(); + + // BUG: I don't like this use of zoom here. Maybe no choice? + let z = PDFDocument.getZoom(); + + // Put a rectangle aroud the entire page. I'm not 100% convinced that + // I like this. + ctx.strokeStyle = "black"; + ctx.strokeRect(0,0,this.w*z,this.h*z); + } + + mouseDown(x : number , y : number ) : void { + + // Mouse clicked on this page. + + // y is given relative to the entire document; make it page-relative. + y -= this.v; + + // Only clicks on a figure could be of interest. + for (let i = 0; i < this.parts.length; i++) + { + // Either a PDFPanel or a FigurePanel. + let p = this.parts[i]; + if (p instanceof PDFPanel) + continue; + + // p must be a FigurePanel. + if ((p.destV <= y) && (y <= p.destV + p.h)) + return p.mouseDown(x,y); + } + } + + mouseMove(x : number , y : number ) : void { + + // As above. Note that this event could go to the "wrong" figure, + // but that's OK. Also, if the mouse is over a PDFPanel, and not + // a figure, then the event dies here, which is also OK. + y -= this.v; + + for (let i = 0; i < this.parts.length; i++) + { + let p = this.parts[i]; + if (p instanceof PDFPanel) + continue; + + if ((p.destV <= y) && (y <= p.destV + p.h)) + p.mouseMove(x,y); + } + } + + mouseUp(x : number , y : number ) : void { + + // As above, but the event can't be allowed to die. The owning widget + // must hear about the mouse up. At the same time, we can't just + // inform the widget directly of the mouse up since we also need to + // pass the correct coordinates. + y -= this.v; + + for (let i = 0; i < this.parts.length; i++) + { + let p = this.parts[i]; + + // This is different than above since *somebody* must take the event, + // and the relevant widget must hear about it. If the event is over + // a PDFPanel, then tell the widget using crazy coordinates. It + // doesn't matter exactly where the mouse was released; it only + // matters that it wasn't released anywhere near the widget. + if ((p.destV <= y) && (y <= p.destV + p.h)) + { + if (p instanceof PDFPanel) + WidgetManager.mouseOwner ! .mouseUp(10000000000000,10000000000000); + else + // Over a figure. + p.mouseUp(x,y); + } + } + } +} + + +// A PagePanel consists of one or more SubPanels. + +abstract class SubPanel { + + // The vertical position and height within a page, with zero being at the + // top, and measured in pdf points. This height, h, is the total height. + // There's no ambiguity to this height for PDFPanel subclasses, but for + // FigurePanel subclasses, it is the sum of the latex height + // (PageData.deleteHeight), plus any additional padding as given in + // PageData.aboveHeight/belowHeight. + destV = 0; + h = 0; + w = 0; + + // The position of this panel within the entire document. + // The only reason for this is the possible use of HTML DOM elements + // as widgets within a figure. I would prefer not to use those at all, + // but sometimes it's easier. See the NumberInputWidget for one example. + totalV = 0; + + + constructor(v : number , totalV : number , w : number , h : number ) { + this.destV = v; + this.totalV = totalV; + this.w = w; + this.h = h; + } + + // Will be filled in by sub-class. + public render( ) : void { + console.log("Error: called SubPanel.render()!"); + } + public mouseDown(x : number , y : number ) : void { + console.log("Error: called SubPanel.mouseDown()!"); + } + public mouseMove(x : number , y : number ) : void { + console.log("Error: called SubPanel.mouseMove()!"); + } + public mouseUp(x : number , y : number ) : void { + console.log("Error: called SubPanel.mouseUp()!"); + } + +} + + +// Used for portions of a page consisting of rendered pdf. + +class PDFPanel extends SubPanel { + + // The page numbers start at zero and positions are given in pdf points. + pageNum = 0; + srcV = 0; + + // BUG: I think I can fold this in elsewhere. It's the same as PagePanel.v. + offsetV = 0; + + constructor(pageNum : number , offsetV : number , srcV : number , destV : number , + totalV : number , w : number , h : number ) { + + // The pageNum is given relative to the global PDFDocument. The srcV + // and destV are locations relative to the page (in pdf points, with the + // top of the page at v=0) and h is the height of this piece, which + // is the same for src and dest. + super(destV,totalV,w,h); + this.pageNum = pageNum; + this.srcV = srcV; + this.offsetV = offsetV; + } + + render( ) : void { + + // Render a portion of the current page. + + let theCanvas = PDFDocument.getCanvas(this.pageNum) ; + if (theCanvas === null) + { + // I'm sure how this happens, but it does occasionally. + // It doesn't cause any noticable problems. It seems to happen + // if you move the scroll thumb too fast. + return; + } + + // BUG: I don't like this use of zoom here. Maybe no choice? + let z = PDFDocument.getZoom(); + + let ctx = ctxTopLevelAdjust(); + + // Adjust for scroll bar. + ctx.translate(0,(this.offsetV - window.scrollY) * z); + + // Arguments here: + // the source image (or canvas), + // the source (x,y), + // the source (width,height), + // the destination (x,y), + // the destination (width,height), + // It's confusing because optional things typically come after + // required things, but not here somehow. + ctx.drawImage(theCanvas, + 0,this.srcV * z,theCanvas.width,this.h * z, + 0,this.destV*z,theCanvas.width,this.h*z); + } +} + +class FigurePanel extends SubPanel { + + // Function name, as a string. + drawFcn : AugmentedDrawingFunction; + + // Page's v position. This is the position of the page on which + // this figure appears relative to the entire document. + pageV : number = 0; + + // This margin is the location of the left edge of the text, as + // reported by latex. + margin = 0; + + // Also from Latex (via figures.aux). This I have more confidence in. + textWidth = 0; + + // The height in SubPanel.h is the total height of the figure, including + // any padding above or below. The origin used for the figure occurs at + // a y-value that is PageData.belowHeight *above* the total height. + // lowerPadding is equal to PageData.belowHeight for this figure. + lowerPadding = 0; + upperPadding = 0; + + + constructor(pageV : number, destV : number , totalV : number , w : number , h : number , + upperPadding : number , lowerPadding : number , margin : number , + textWidth : number , drawFcn : AugmentedDrawingFunction) { + + // As for PDFPanel, plus the margin is the amount by which to shift the + // drawing to the right so that the origin is in line with the text.The + // drawFcn is the function provided through Latex. + // This is just the name of the function, as a string. + super(destV,totalV,w,h); + this.pageV = pageV; + this.upperPadding = upperPadding; + this.lowerPadding = lowerPadding; + this.margin = margin; + this.textWidth = textWidth; + this.drawFcn = drawFcn; + } + + render( ) : void { + + // Save this for widgets and animations to use. + let ctx = ctxTopLevelAdjust(); + + let z = PDFDocument.getZoom(); + ctx.translate(0,(this.pageV - window.scrollY) * z); + + // Erase the full width of the page. this.w is the correct width, + // but the origin will be shifted for drawing. So, erase, then + // return the origin to where it was and start over. + + // Shift to where the figure appears and erase. + ctx.translate(0,this.destV * z); + ctx.scale(z,z); + + // The small adjustment here is to prevent erasing the rectangle + // that encloses the entire page. + ctx.clearRect(1,0,this.w-2,this.h); + + // Return to the orginal t-matrix, then shift down and right before + // drawing the figure (and widgets). + // What we want is for the origin to be at the lower-right and + // right-handed, adjusted upwards by this.lowerPadding too. + ctx = ctxTopLevelAdjust(); + ctx.translate(0,(this.pageV - window.scrollY) * z); + ctx.translate(this.margin * z,(this.destV + this.h - this.lowerPadding) * z); + ctx.scale(1,-1); + ctx.scale(z,z); + + // Tack this FigurePanel onto the underlying figure-drawing code. + // The first time the figure is rendered, this is set, and it's re-set + // to the same value with every subsequent call. That seems like + // pointless extra work, but it gets the job done. + this.drawFcn.figurePanelClass = this; + this.drawFcn(ctx); + } + + mouseDown(x : number , y : number ) : void { + + // (x,y) is in pdf points, relative to the top-left of the page. + + // Adjust to be relative to the figure, but still LH, relative + // to the top of the figure. + y -= this.destV; + x -= this.margin; + + // y is now given relative to the top edge of the figure, getting + // larger as you go *down* the page. + + // Convert y to be RH relative to the correct origin, taking + // any padding into account. This is confusing. At this stage, + // y is the distance below the figure's top edge. Call that y0. + // We want the distance above the lower padding (if any); call + // that y1. The distance above the lower *edge* of the figure + // is this.h - y0, and from this we subtract the padding. + y = (this.h - y) - this.lowerPadding; + + // Pass to the relevant widget. + WidgetManager.mouseDown(this.drawFcn,x,y); + } + + mouseMove(x : number , y : number ) : void { + + // As above. + y -= this.destV; + x -= this.margin; + + y = (this.h - this.lowerPadding) - y; + + WidgetManager.mouseMove(this.drawFcn,x,y); + } + + mouseUp(x : number , y : number ) : void { + + // As above. + y -= this.destV; + x -= this.margin; + + y = (this.h - this.lowerPadding) - y; + + WidgetManager.mouseUp(this.drawFcn,x,y); + } +} + diff --git a/graphics/figput/javascript/development/main.ts b/graphics/figput/javascript/development/main.ts new file mode 100644 index 0000000000..ed7c179ed4 --- /dev/null +++ b/graphics/figput/javascript/development/main.ts @@ -0,0 +1,1086 @@ +/* + +Main entry point for all the js code behind the browser interface. + +This has the initialization code, to load the pdf and the data specifying +how figures are drawn and laid out, plus the top-level stuff to direct +events to the proper place for handling. + +The same code is used for developing the document, and for serving it +from a public-facing website, with a few changes. Look for "PUBLIC FACING" +annotations. Note also that it would be possible to strip considerably +more code away for the public-facing version, but there's no pressing +reason to do it -- the savings would be tiny -- and it could lead to +an error of some kind due to unforeseen dependencies on the deleted +code. + +*/ + + +// In rare cases, I need to know the browser. Note it first, before doing +// anything else. This userAgent is a longish bit of blather specifying +// the browser, and the easiest way to deal with it is to check whether +// certain strings appear. + +var theBrowser = function() { + + let blah = navigator.userAgent; + + if (blah.indexOf("Firefox") > -1) + return "Firefox"; + + // MS Edge is also "Chrome," at least for my purposes. + if (blah.indexOf("Chrome") > -1) + return "Chrome"; + + // Assume "Chrome" as the default. + console.log("MAKING DEFAULT 'CHROME' ASSUMPTION ABOUT THE BROWSWER!!!"); + return "Chrome"; +}(); + + +// JS has poor to non-existent facilities for dealing with thread scheduling. +// In a lot of JS code that doesn't matter, but the pdf.js library, on which +// this entire thing rests, makes heavy use of Promises and workers. Fortunately, +// there's a simple way of managing this. +// +// To use this, the caller says +// await sleep(100); +// and the "await" is crucial. This works because setTimeout() takes a +// function to run, and some amount of time to wait before running that +// function. +// +// This isn't a satisfying solution because it requires "await." +// For that reason it can only be used in async functions. + +function sleep(ms : number) : Promise { + + // ms is milliseconds, not microseconds. + return new Promise(resolve => setTimeout(resolve,ms)); +} + + +// Some semi-bogus stuff to quiet the ts compiler... + +// This makes the external library known to tsc. +declare var pdfjsLib : any; + +// And this provides the properties known to pdfjs that belong to a pdf. +// I had hoped to define a blank type, and have done with it, but we need +// access to certain fields. This provides the type information that +// pdf.js lacks -- or at least the minimal information that I need. +type LoadedPDF = { + numPages : number; + getPage : (n : number) => Promise; +}; + +// This is less bogus. It's the type of the functions called to draw the figures. +// These functions have an added property that points to the +// correct FigurePanel widget. See the layout code. +// Declaring these types serves to warn the programmer what's ahead. It also +// provides tsc with the information it needs to prevent certain mistakes. +type BaseDrawingFunction = (ctx : CanvasRenderingContext2D | CTX) => void; +type AugmentedDrawingFunction = BaseDrawingFunction & { figurePanelClass : FigurePanel | null; }; + + +function getAugmentedFunction(fcnName : string ) : AugmentedDrawingFunction { + + // This converts from a function name, as specified by the user in their latex + // document to the internally more useful AugmentedDrawingFunction. Doing this + // kind of conversion feels icky, but any alternative I've come up with requires + // that the person using FigPut know more than the absolute minimum about what's + // behind the curtain. + // + // References to these functions come into the program at two points. First, when + // the document is loaded, a list of the relevant drawing functions is provided + // by latex. Second, when the user creates widgets within his drawings, these + // widgets must belong to a particular drawing and the function (or its name, as + // a string) is used as the key under which the widget is filed. + + // BUG: This double-cast and use of 'any' is horrible, but is there + // anything better? + return window[ fcnName as any ]; +} + + +// Data related to the document pages and figures. Each page +// has one of these in PDFDocument.pageSpecs. This is all +// static data once the document has been loaded. + +// BUG: Should this be a type? An interface? + +class PageData { + + // Note that pageHeight is the height of a *printed* page. If the page + // has a figure whose height is different than the deleted height, then + // the page height, as rendered, will be different than this value. + // Also, the margins and textWidth are only valid when there is a + // figure to render since they come from figures.aux. + // For reference, 8.5 x 11 inch paper is 612 x 792 pts; + // A4 paper is 8.27 x 11.69 inch or 595.44 x 841.68 pts. + // However, it seems that latex sometimes produces slightly different + // dimensions of what must be A4 paper. + pageHeight : number = 0; + pageWidth : number = 0; + leftMargin : number = 0; + rightMargin : number = 0; + textWidth : number = 0; + + // These arrays have one entry for each interactive figure on the page. + // insertPoint is the position, from the top, at which the figure is + // inserted. deleteHeight is how much of the latex doc is omitted, and + // above/belowHeight is how much room to leave in the browser window for + // the figure. name is the name of the function to call to draw the + // figure, done is whether to generate new tikz + insertPoint : number[] = []; + deleteHeight : number[] = []; + aboveHeight : number[] = []; + belowHeight : number[] = []; + name : string[] = []; + done : boolean[] = []; + + // For each figure, there is a corresponding function (that will be + // augmented with a drawing target). These values make the name values + // given above conceptually redundant. + drawFcn : AugmentedDrawingFunction[] = []; +} + + +// Everything related to the pdf document is here. Its main purpose is +// to handle off-screen rendering. It's all static since there can only be +// one pdf open at a time. It does include data about how to lay out the +// figures, but none of the code that does it. +// +// I tried making this a module, but really no advantage over a class, +// and definitely some disadvantages. + +class PDFDocument { + + // The document, as returned by pdfjsLib.getDocument. + private static pdf : LoadedPDF; + + // Total number of pages in the above document. + public static numberOfPages = 0; + + // This is the scale at which to to render the pages offscreen. Setting + // this equal to 1 means that each px of the off-screen canvas is 1 pdf + // point. + private static zoom : number = 1; + + // Information about the layout, one for each page. + public static pageSpecs : PageData[] = []; + + // The fields below are a temporary buffer of pages rendered offscreen. + // This speeds up the process so that we aren't re-rendering pages. + // My original intent was for these arrays to have no more than + // pageBufSize entries, but the way async/promise works makes that + // difficult (impossible?). I want render() to render a given page + // to an offscreen canvas and store that canvas here, which means + // that all these calls to render() share the variables below. + // Each call to render() is, unavoidably, in a different thread, and + // that means that they are all trying to manipulate these varaibles + // in an order that is unpredictable. The only way that I can see to + // make this happen in a predicatable way is for each page to have + // its own canvas. It's OK for render() to put things into its designated + // spot in an array, but it can't touch anything that some other invocation + // of render might touch. + // + // This is wasteful, but I see no other way to do it without using + // a mutex. It's not that bad since we're talking about a few bytes for + // every page, but still annoying. + // + // Aside: JS does have the Atomics object, as of ECMA 2017, and it + // might (?) be possible to use that somehow as a way to synchronize + // threads, but I'd rather not mess with it. + + + // The total number of pages that may be held. This needs to be at least + // as large as the number of pages that may be visible at one time if you + // zoom all the way out, plus a couple extra due to possible problems + // resulting from race conditions that I can't (?) avoid. + private static pageBufSize : number = 6; + + // This holds a copy of page to be copied to the screen. Every page has + // its own entry, although all but (at most) pageBufSize will be null. + private static theCanvases : (HTMLCanvasElement | null)[] = []; + + // The renderCount is how many times we've rendered any page. It's used like + // a time since I have no confidence in window.performance.now, which is + // supposed to return the current time in nanoseconds. Every time a page + // is asked to be rendered, this value increments, and is stored in + // pageAge[i], where i is the index of the page rendered. It may not + // actually be rendered if it would be a re-render, but the time is updated. + // We need this to flush older pages from the buffer when there are too many. + // + // NOTE: This variable is shared by the various invocations of render() + // and something like + // ++renderCount + // is not an atomic operation. The way I am using this, the fact that + // the value of renderCount may be incorrect isn't a disaster. It can + // only be wrong by a few steps. This is why pageBufSize is bumped up to be a + // little larger than strictly necessary. Worst case, things are re-rendered + // and you waste some CPU. + private static renderCount : number = 0; + + // One of these for each page, indexed by page number. Holds the value + // or renderCount at the time when the page was rendered (or re-rendered). + private static pageAge : number[] = []; + + + public static async setPDF(thePDF : LoadedPDF) { + + // Take the newly loaded pdf and note some of it's stats. + // Once this is loaded, thePDF never needs to be accessed again + // from outside this class. + this.pdf = thePDF; + + this.numberOfPages = thePDF.numPages; + + // It seems that pdfjs counts pages from one, not zero. + for (let i = 1; i <= this.numberOfPages; i++) + { + await thePDF.getPage(i).then(function(page) { + + // Note the i-1 here since I want arrays to start at zero. + PDFDocument.pageSpecs[i-1] = new PageData(); + PDFDocument.pageSpecs[i-1].pageWidth = page.view[2]; + PDFDocument.pageSpecs[i-1].pageHeight = page.view[3]; + + // Make sure that the shared buffer arrays are all fully allocated. + PDFDocument.theCanvases[i-1] = null; + PDFDocument.pageAge[i-1] = 0; + }); + } + } + + public static isLoaded() : boolean { + if (this.pdf === null) + return false; + else + return true; + } + + public static getZoom() : number { + return this.zoom; + } + + public static setZoom(z : number) : void { + + this.zoom = z; + this.flushBuffer(); + } + + public static getCanvas(n : number) { + + // Return the canvas for page number n (counting from zero). + // This assumes that the canvas is available due to a previous + // call to render(n) -- which must have resolved by the time this + // method is called. + + // BUG: This shouldn't happen, but maybe I should create a fake + // blank canvas anyway. + if (this.theCanvases[n] === null) + console.log("ERROR! Canvas for page missing: " +n); + + return this.theCanvases[n]; + } + + public static async render(n : number ) { + + // Render page n offscreen, where the pages are counted from zero. + + // See if the page is already there. + if (PDFDocument.theCanvases[n] !== null) + return; + + // Note the +1 here since pdf.js counts pages from 1, not zero. + let thePage = await PDFDocument.pdf.getPage(n + 1); + + let newCanvas : HTMLCanvasElement = document.createElement('canvas'); + PDFDocument.theCanvases[n] = newCanvas; + PDFDocument.pageAge[n] = PDFDocument.renderCount; + ++PDFDocument.renderCount; + + let offctx = newCanvas.getContext('2d'); + let viewport = thePage.getViewport(PDFDocument.zoom); + + newCanvas.width = viewport.width; + newCanvas.height = viewport.height; + + await thePage.render({ + canvasContext: offctx, + viewport: viewport + }); + } + + public static trimBuffer() : void { + + // Call this periodically, like after making a series of calls to + // render(), to remove excess canvases from the buffer arrays. + // Due to the risk of race conditions -- which I think is minimal -- + // it's best not to call this until reaching a point at which it + // doesn't matter if *all* the offscreen canvases are deleted (although + // that outcome would be inefficient). The unlikely possiblitity of + // problems due to race conditions is why this is not part of render(). + let ctot = 0; + for (let i = 0; i < PDFDocument.numberOfPages; i++) + { + if (PDFDocument.theCanvases[i] !== null) + ++ctot; + } + + // Delete excess offscreen canvases. + while (ctot > PDFDocument.pageBufSize) + { + PDFDocument.removeOldestPage(); + --ctot; + } + } + + private static removeOldestPage() : void { + + // Remove the oldest single canvas from this.theCanvases. + let oldestIndex = -1; + let oldestAge = this.pageAge[0]; + for (let i = 0; i < this.numberOfPages; i++) + { + if (this.theCanvases[i] === null) + continue; + + if (this.pageAge[i] < oldestAge) + { + oldestAge= this.pageAge[i]; + oldestIndex = i; + } + } + this.theCanvases[oldestIndex] = null; + } + + public static flushBuffer() : void { + + // Removes all offscreen canvases in the buffer. This is needed, e.g., + // when resizing so that you don't use a canvas of the wrong scale. + for (let i = 0; i < this.numberOfPages; i++) + this.theCanvases[i] = null; + } +} + + +// A class for scheduling event handling. +// +// Everything is static because there is only one of these for all events. +// It is used to mediate the event loop so that everything happens +// syncrhonously, even when async functions are involved. It is used for +// things like scroll and mouse-down events; it is also used for animations, +// which are created by certain widgets. +// +// The idea is that you call getID() to get your ticket (like at the +// butcher's). Then call await waitMyTurn() until the butcher is ready. +// The 'await' is crucial! Then call allDone() to thank the butcher and +// leave so that he can take the next customer. +// +// For this to work properly, calls to getID() must be made outside +// of async functions. Otherwise the IDs could get out of order. +// +// BUG: I could use this in a DRY-er way. Define a method called +// getInLine() that takes the function to be invoked. Have getInLine() +// call getID(), waitMyTurn(), run the job, then call allDone(). +// +// BUG: It may be that someone who is more expert in JS could use +// Promises somehow to make this scheme unnecessary. OTOH, this may +// be easier to understand and use than something more clever. +class Events { + + // A count of events that have come in. Each event gets its own unique + // ID by calling getID(). + private static count : number = 0; + + // The ID number of the event whose handling was most recently completed. + private static complete : number = -1; + + public static getID() { + let answer = this.count; + this.count++; + return answer; + } + + public static async waitMyTurn(id : number) { + while (id !== Events.complete + 1) + await sleep(5); + } + + public static allDone(id : number) { + Events.complete = id; + } +} + + +async function doOpenDocument(latexName : string , scrollPos : number ) { + + // Called when the body of the page loads (onload). + // latexName is the base of the name of the pdf. Thus, latexName.pdf + // is what is to be opened. The scrollPos value is the position on the + // page. This is provided by doBeforeUnload(), noted by the server, and + // given back in the course of the reload. + + // The "Get TikZ" button is fixed, and it is always on top. + // PUBLIC FACING: Comment this block of code out when running serving from a + // public-facing website. There's no reason to provide this "Get TikZ" button + // and the server won't now how to handle the resulting messages. + + let fmenu = document.createElement("button"); + fmenu.style.position = "fixed"; + fmenu.style.display = "block"; + fmenu.style.left = "0px"; + fmenu.style.top = "0px"; + fmenu.style.width = "80px"; + fmenu.style.height = "18px"; + fmenu.style.fontSize = "10px"; + fmenu.textContent = "Get TikZ"; + fmenu.style.zIndex = "99"; + fmenu.onclick = doTikzClick; + document.body.appendChild(fmenu); + + + let theCanvas = document.getElementById("pdf_renderer")!; + theCanvas.style.position = "fixed"; + + // It might happen that the program starts with some level of zoom. + PDFDocument.setZoom(window.devicePixelRatio); + + // So that the canvas exactly fills the window. + adjustCanvas(); + + // Mouse events are registered on the canvas rather than the document + // (as is done for resize and scroll), but I'm not sure there's any + // practical difference since the canvas is the whole document. + // Note that this does *not* listen for onclick events. In a few cases, + // they might be a more natural choice (like for buttons), but they make + // things like coloring a "halfway clicked" widget difficult. + // Note that this does not distinguish between left, right and middle + // buttons; a click is a click. + // Also, the mouseup listener is registered on the window, not the canvas, + // so that we hear about mouse-ups even when they happen outside the + // browswer window entirely. + theCanvas.addEventListener("mousedown",doMouseDown); + theCanvas.addEventListener("mousemove",doMouseMove); + theCanvas.addEventListener("mouseup",doMouseUp); + + // I considered registering listeners for mouseenter/mouseleave, but + // they're not needed. + + /* + BUG: I have not tested this much on touch devices. Everything + works fine on some phones, but not on others. It's a hard to imagine + people trying to use this on a phone, but I should test on things + like iPads. + theCanvas.addEventListener("touchstart",doTouchDown); + theCanvas.addEventListener("touchend",doTouchUp); + theCanvas.addEventListener("touchmove",doTouchMove); + */ + + // Open the pdf and digest figures.aux. + await getDocumentData(latexName); + + // Create the various Panel objects that make up the document as a whole. + // This does the "page layout." + FullPanel.init(PDFDocument.pageSpecs); + + // Now that page layout is done, set the range for the scroll bars on + // the browswer window. + adjustScrollBars(); + + // When reloading, we don't want things to move back to the top of + // the first page. + window.scrollTo(0 , scrollPos); + + // Render for the very first time. + fullRender(); +} + +async function getDocumentData(latexName : string) { + + // This opens and digests the data files and the pdf itself. + let thePDF : LoadedPDF = await pdfjsLib.getDocument(latexName+ '.pdf'); + await PDFDocument.setPDF(thePDF); + + // Open and digest figures.aux. + let fname = latexName + ".fig.aux"; + + // fetch() is the new XMLHttpRequest(), which has been deprecated. + // Thanks to Dan Pratt for pointing that out. + let fetchPromise = await fetch(fname); + let allText = await fetchPromise.text(); + await getFigureInfo(allText); +} + +async function syncLoad(scriptName : string) { + + // Load the script with the given name and append to the DOM, and + // don't return until the load is complete. So this should typically + // be called with 'await'. + // + // Thanks to Dan Pratt for this tidy solution. This works because the + // promise can't resolve (either way) until appendChild() completes + // because you can't resolve or reject until the code is loaded + // or fails to load. Note that the 'once' argument means that the function + // is called once, then flushed. That's exactly what I want so that they + // don't hang around and consume resources. + let theCode = document.createElement("script"); + theCode.type = "application/javascript"; + theCode.src = scriptName; + + var p = new Promise((resolve, reject) => { + theCode.addEventListener("load", resolve, { once: true}); + theCode.addEventListener("error", reject, { once: true}); + }); + + document.body.appendChild(theCode); + + await p; +} + +async function getFigureInfo(textBody : string) { + + // textBody is the complete contents of the .aux file. Parse it and + // use the data to fill in PDFDocument.PageSpecs. + let lines = textBody.split('\n'); + + for (let i = 0; i < lines.length; i++) + { + if (lines[i].length < 2) + // Last line might be blank. + break; + + var parts = lines[i].split(' '); + + if (parts[0] === "load") + { + // Load a single .js file and move on. + await syncLoad(parts[1]); + continue; + } + + var pnum = parseInt(parts[0]); + var innerMargin = parseFloat(parts[1]); + var outerMargin = parseFloat(parts[2]); + var textWidth = parseFloat(parts[3]); + var vpos = parseFloat(parts[4]); + var hLatex = parseFloat(parts[5]); + var hAbove = parseFloat(parts[6]); + var hBelow = parseFloat(parts[7]); + var name : string = parts[8]; + + // Careful: JS weirdness means that Boolean(parts[9]) is ALWAYS true + // (unless parts[9] is empty). + var done : boolean = (parts[9] === 'true'); + var externLoad : boolean = (parts[10] === 'true'); + + // Page numbers should start at zero (not 1 as reported by latex). + pnum -= 1; + + // Load the JS now, if necessary. + // NOTE: In earlier versions, up to v23, I looked to either a + // .js file or an .fjs file. Individual files are now assumed + // to be .fjs files. This is much simpler. + if (externLoad === true) + await syncLoad(name + ".fjs"); + + // NOTE: This assumes that the relevant function has been loaded, + // perhaps as an external file from \LoadFigureCode. + let augFcn : AugmentedDrawingFunction = getAugmentedFunction( name ); + if (typeof augFcn === "undefined") + alert(name + " not found. Is the function name correct?"); + + // Note this for the future. + augFcn.figurePanelClass = null; + PDFDocument.pageSpecs[pnum].drawFcn.push(augFcn); + + // Copy the remaining fields over. + + // Adjust the vertical position to be given relative to the top + // of the page, rather than the bottom. + vpos = PDFDocument.pageSpecs[pnum].pageHeight - vpos; + + // If there are multiple figures on a particular page, then this + // is redundant, but harmless. + if (pnum % 2 === 0) + { + PDFDocument.pageSpecs[pnum].leftMargin = innerMargin; + PDFDocument.pageSpecs[pnum].rightMargin = outerMargin; + } + else + { + PDFDocument.pageSpecs[pnum].leftMargin = outerMargin; + PDFDocument.pageSpecs[pnum].rightMargin = innerMargin; + } + PDFDocument.pageSpecs[pnum].textWidth = textWidth; + + // The per-figure data. + PDFDocument.pageSpecs[pnum].insertPoint.push(vpos); + PDFDocument.pageSpecs[pnum].deleteHeight.push(hLatex); + PDFDocument.pageSpecs[pnum].aboveHeight.push(hAbove); + PDFDocument.pageSpecs[pnum].belowHeight.push(hBelow); + PDFDocument.pageSpecs[pnum].name.push(name); + PDFDocument.pageSpecs[pnum].done.push(done); + } + + // Make sure that all the figure names are distinct. + for (let i = 0; i < PDFDocument.numberOfPages; i++) + { + // Loop over each figure on the current page. + for (let fig = 0; fig < PDFDocument.pageSpecs[i].name.length; fig++) + { + let curName = PDFDocument.pageSpecs[i].name[fig]; + + // See if this name matches a name on any other page/figure. + // Consider the current page first. + for (let subfig = fig + 1; subfig < PDFDocument.pageSpecs[i].name.length; subfig++) + { + if (curName === PDFDocument.pageSpecs[i].name[subfig]) + { + alert("The figure name " +curName+ " is used more than once."); + throw new Error(); + } + } + + // Continue with all the remaining pages. + for (let j = i + 1; j < PDFDocument.numberOfPages; j++) + { + for (let subfig = 0; subfig < PDFDocument.pageSpecs[j].name.length; subfig++) + { + if (curName === PDFDocument.pageSpecs[j].name[subfig]) + { + alert("The figure name " +curName+ " is used more than once."); + throw new Error(); + } + } + } + } + } +} + +function adjustScrollBars() : void { + + // Based on the page size and document length, adjust the range of the + // browswer's scroll bars. + var body = document.getElementById("mainbody"); + + // I don't see any way to set the range of the scroll bar directly, so + // I'm fooling the browser to think that it has a body of a certain + // height, in px, when the body is really no bigger than the visible + // area of the window. What I want is a height such that, when the scroll + // bar is at the bottom, the lower edge of the last page is barely visible + // in the bottom of the window. + // + // There was some mental debate about whether it's better to let the + // bottom of the last page scroll up beyond the bottom of the window, but + // this is easier to program, and it's probably more natural for most people. + let totHeight = FullPanel.totalHeight(); + let visHeight = document.documentElement.clientHeight; + body.style.height = totHeight + "px"; + + // The horizontal scroll bar is expressed in terms of pdf pts. This is + // simpler than above since I don't want to be able to scroll so that the + // right edge of the document is barely off the window; I want to be able + // to scroll just far enough so that the right edge of the document meets + // the right edge of the window. The size of the window becomes irrelevant + // (i.e., the browser deals with it). + // However, we do need to know whether a scroll bar is needed at all, + // so we can't totally ignore the visible width. + let visWidth = document.documentElement.clientWidth; + let totWidth = FullPanel.getFullWidth(); + + if (visWidth > totWidth) + { + // No need for horizontal scroll. It seems like "0px" works, but "0 px" + // does not. Maybe the space causes a (silent) parse error? + body.style.width = "0px"; + return; + } + + body.style.width = totWidth + "px"; +} + +function ctxTopLevelAdjust( ) : CanvasRenderingContext2D { + + // Each function should call this before doing any drawing. + // Rendering is done relative to the ctx (obviously) with the t-matrix + // adjusted accordingly. This brings the t-matrix from being the + // identity to being relative to the entire document. + // + // In earlier versions, the ctx was being passed around and adjusted + // as the layout manager descended to a particular portion of the document. + // In some ways that is the cleaner and more modular way to do things, + // but it can be confusing because the adjustments to the t-matrix aren't + // done in a central place. + var canvas = document.getElementById("pdf_renderer"); + let ctx : CanvasRenderingContext2D = canvas.getContext('2d') ! ; + + ctx.resetTransform(); + + // Center the document. Rather than mess with CSS to center the canvas, + // leave the canvas at the size of the entire window, and shift the origin + // so the document is rendered in the center of the canvas. + // + // However, if the window is smaller than the document, then we do + // NOT want to center the document. If we did center it, then it would + // be impossible to scroll over to the left since we can't have "negative + // scroll;" window.scrollX is always non-negative (unfortunately). + let visWidth = document.documentElement.clientWidth; + let totWidth = FullPanel.getFullWidth(); + let z = PDFDocument.getZoom(); + if (visWidth > totWidth) + { + // No horizontal scroll bar. Center it. + let canvasCenter = canvas.width / 2; + let docCenter = FullPanel.getFullWidth() / 2; + docCenter *= PDFDocument.getZoom(); + + ctx.translate(canvasCenter - docCenter,0); + } + else + { + // Shift according to the horizontal scroll bar, leaving the document + // against the left edge. Note the scaling by the zoom factor so + // as to be consistent with the above. + ctx.translate(-window.scrollX * PDFDocument.getZoom(),0); + } + + return ctx; +} + +async function fullRender() { + + // Render all the pages of the pdf, together with the figures. + var canvas = document.getElementById("pdf_renderer"); + + // This shouldn't be necessary, but do it as a fail-safe. + var ctx = canvas.getContext('2d'); + ctx.resetTransform(); + + let visWidth = document.documentElement.clientWidth; + + let z = PDFDocument.getZoom(); + + // BUG: Could lead to flicker? It is necessary to clear everything + // because widgets could be drawn outside the page. I suppose that I could + // only erase the area outside the pages, which would reduce any flicker. + // I really don't want to double-buffer everything if it can be avoided. + // It may be the only solution. The problem is that, if a widget extends + // outside the page, and is animated (like LoopAnimWidget) so that it's + // drawn as part of an animation, then you must erase the entire page to + // avoid "leftovers," and *that* requires that you redraw the entire + // document (or what is visible) with every frame of an animation. + // + // BUG: So the correct solution is double-buffering everything, but I don't + // feel that it's pressing. For now, either restrict any widgets to + // draw within a page or accept that it might leave spoogey leftovers. + ctx.clearRect(0,0,visWidth*z,document.documentElement.clientHeight*z); + + await FullPanel.renderAll(canvas.height); +} + +function adjustCanvas() : void { + + // Call when the window has been zoomed or re-sized. + // + // canvas has two different dimensions: canvas.style.width and height, and + // canvas.width and height. The style values determine the size of the canvas + // on the screen -- so-called ""CSS styling." The straight values (no style) + // are the number of px in the canvas. By keeping the style values constant and + // varying the straight values, you can have more or less resolution for the + // canvas, EVEN WHEN ZOOMED IN. + // + // The bottom line is that if we adjust the straight pixel sizes, then + // we can always have one px for every physical pixel -- up to the accuracy + // of window.devicePixelRatio. + // + // BUG: Check for things with super high pixel density, like phones. I + // think the devicePixelRatio is somehow wrong for those. It may not matter. + var canvas = document.getElementById("pdf_renderer") ; + + // Adjust the visible width to match the window size. + // + // NOTE: For debugging, it can be helpful to subtract off a bit from these + // values, like 1, 10 or 50, depending. In earlier versions, there was + // also a bit of CSS in the html that put a visible rectangle around the canvas. + //canvas.style.width = (document.documentElement.clientWidth - 50) + "px"; + //canvas.style.height = (document.documentElement.clientHeight - 50) + "px"; + canvas.style.width = document.documentElement.clientWidth + "px"; + canvas.style.height = document.documentElement.clientHeight + "px"; + + // The visible width of the canvas has just been fixed. Now change the + // number of px so that the number of px per inch on the screen varies with + // the level of browser zoom. There seems to be no means of direct access + // to the phyical pixels of the monitor, but this is close, and may be + // exactly equivalent in many situations. + canvas.width = document.documentElement.clientWidth * window.devicePixelRatio; + canvas.height = document.documentElement.clientHeight * window.devicePixelRatio; +} + +async function doScrollGuts(id : number) { + + // Wait for any active event handler to finish and then wait until it is + // the turn of current id to run. + await Events.waitMyTurn(id); + + await fullRender(); + + // Give your ticket to the butcher so that he can take the next customer. + Events.allDone(id); +} + +function doScroll() : void { + + // Scroll events are registered below to call this function. + // It redraws the contents of the main canvas. + + // This appears to be called when the page is opened, before almost + // anything else, so return until page initialization is complete. + if (PDFDocument.isLoaded() == false) + return; + + // Get a unique ID for this event. + // This must be done outside any async function to ensure proper order. + let id = Events.getID(); + + doScrollGuts(id); +} + +async function doResizeGuts(id : number) { + + // As with scrolling, wait for any earlier event handler to complete. + await Events.waitMyTurn(id); + + // None of the buffered offscreen canvases are valid anymore. + // In theory, I could test for whether this was merely a window + // resize and not a zoom, but it's not worth fooling with. + PDFDocument.flushBuffer(); + + console.log("ratio: " + window.devicePixelRatio); + + // The other thing that needs to be adjusted is the zoom level + // used when rendering the pdfs. + adjustCanvas(); + + // Here is the magic. Effectively, this value is now the level of zoom. + // If you take out this line, and leave PDFDocument.zoom always equal to + // default value of 1, then zooming does nothing, although the mismatch + // between the resolution of the off-screen canvas and the resolution on + // the screen can make things blurry or blocky. + PDFDocument.setZoom(window.devicePixelRatio); + + // If the size of the window changed, then this needs to be adjusted too. + // This must be done after adjustCanvas(). + adjustScrollBars(); + + // Render the pages too. + await fullRender(); + + // Final bit of scheduling magic. + Events.allDone(id); +} + +function doResize() : void { + + // This is called whenever the user zooms and also if the + // entire broswer window is resized. + let id = Events.getID(); + doResizeGuts(id); +} + +async function mouseDownGuts(id : number , x : number , y : number ) { + + // (x,y) is the location of the mouse click, in pdf points, but + // relative to the window. + await Events.waitMyTurn(id); + + // Just as we have to tweak the t-matrix to determine where to draw things, + // we need to do something similar to figure out where (x,y) is on the page. + // The difference is that the ctx.translate() operations become adjustments + // to x and y. + let visWidth = document.documentElement.clientWidth; + let totWidth = FullPanel.getFullWidth(); + let canvas = document.getElementById("pdf_renderer"); + + if (visWidth > totWidth) + { + // No horizontal scroll bar. The document is centered. + let canvasCenter = canvas.width / 2; + let docCenter = FullPanel.getFullWidth() / 2; + canvasCenter = canvasCenter / PDFDocument.getZoom(); + + x = x - (canvasCenter - docCenter); + } + else + { + // Shift according to the horizontal scroll bar. + x = x + window.scrollX; + } + + // Fix up the y-coordinate too. + y = y + window.scrollY; + + FullPanel.mouseDown(x,y); + + // Don't forget! + Events.allDone(id); +} + +function doMouseDown(e : MouseEvent) : void { + + // This is an event like any other, so the usual scheduling rigmarole. + let id = Events.getID(); + mouseDownGuts(id,e.x,e.y); +} + +async function mouseMoveGuts(id : number , x : number , y : number ) { + + // As above. + // BUG: Common code. Not DRY. + await Events.waitMyTurn(id); + + // As for mouse-down. + let visWidth = document.documentElement.clientWidth; + let totWidth = FullPanel.getFullWidth(); + let canvas = document.getElementById("pdf_renderer"); + + if (visWidth > totWidth) + { + // No horizontal scroll bar. The document is centered. + let canvasCenter = canvas.width / 2; + let docCenter = FullPanel.getFullWidth() / 2; + canvasCenter = canvasCenter / PDFDocument.getZoom(); + + x = x - (canvasCenter - docCenter); + } + else + { + // Shift according to the horizontal scroll bar. + x = x + window.scrollX; + } + + // Fix up the y-coordinate too. + y = y + window.scrollY; + FullPanel.mouseMove(x,y); + + // Don't forget! + Events.allDone(id); +} + +function doMouseMove(e : MouseEvent) :void { + + // As above. + let id = Events.getID(); + mouseMoveGuts(id,e.x,e.y); +} + +async function mouseUpGuts(id : number , x : number , y : number ) { + + // As above. + // BUG: Common code. Not DRY. + await Events.waitMyTurn(id); + + let visWidth = document.documentElement.clientWidth; + let totWidth = FullPanel.getFullWidth(); + let canvas = document.getElementById("pdf_renderer"); + + if (visWidth > totWidth) + { + // No horizontal scroll bar. The document is centered. + let canvasCenter = canvas.width / 2; + let docCenter = FullPanel.getFullWidth() / 2; + canvasCenter = canvasCenter / PDFDocument.getZoom(); + + x = x - (canvasCenter - docCenter); + } + else + { + // Shift according to the horizontal scroll bar. + x = x + window.scrollX; + } + + // Fix up the y-coordinate too. + y = y + window.scrollY; + FullPanel.mouseUp(x,y); + + // Don't forget! + Events.allDone(id); +} + +function doMouseUp(e : MouseEvent ) : void { + + // As above. + let id = Events.getID(); + mouseUpGuts(id,e.x,e.y); +} + +function doTikzClick() : void { + + // Tikz button was clicked. Generate output file(s). + // + // Cycle over every figure and let it generate tikz data. + for (let pi = 0; pi < PDFDocument.pageSpecs.length; pi++) + { + let pd = PDFDocument.pageSpecs[pi] !; + if (pd.name.length === 0) + // No figures on this page. + continue; + + for (let fi = 0; fi < pd.name.length; fi++) + { + if (pd.done[fi] == true) + // User said to skip this one. + continue; + + let theFcn : AugmentedDrawingFunction = pd.drawFcn[fi]; + + if (theFcn.figurePanelClass === null) + // Hasn't been loaded, because was never visible. + continue; + + let ctx = new CTX(pd.name[fi]); + theFcn(ctx); + ctx.close(); + } + } +} + + +function doBeforeUnload(e : Event) { + + // This is *very* non-standard. Pass a made-up WHERE message with the position + // on the document. + // BUG: This only informs the server of the vertical scroll position. + // Dealing with horizontal scroll would be more painful. See fullRender() + // and adjustScrollBars(). + // If *everything* were double-buffered, then this would be a bit easier. + let req = new XMLHttpRequest(); + + // We don't care about the second argument. In a POST this would be + // something like a file name. The third argument, 'false', forces the request + // to be handled synchronously. If it's done async, then the unload proceeds + // and completes before this task finishes. + req.open("WHERE","bogus", false ); + + req.setRequestHeader("Content-Type","text/plain;charset=UTF-8"); + + // Send the postion as the message. It could probaby be sent instead of + // 'bogus' above, and this could be blank. + let msg = window.scrollY.toString(); + + // BUG: Firefox generates "Uncaught DOMException: A network error occurred." + // on this. It might be that XMLHttpRequest is deprecated? + req.send(msg); +} + +// Register additional event handlers. The main (and only) canvas had +// listeners registered for mouse events in doOpenDocument(). +document.addEventListener('scroll',doScroll); +window.addEventListener('resize', doResize); + +// PUBLIC FACING: Comment this out. It's doing something *very* non-standard, +// and any normal web-server will choke on it. +window.addEventListener('beforeunload',doBeforeUnload); + diff --git a/graphics/figput/javascript/development/tikz.ts b/graphics/figput/javascript/development/tikz.ts new file mode 100644 index 0000000000..f66395b70a --- /dev/null +++ b/graphics/figput/javascript/development/tikz.ts @@ -0,0 +1,1419 @@ +/* +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 { + + private _x : number; + private _y : number; + + constructor(x : number,y : number) { + this._x = x; + this._y = y; + } + + public toString() : string { + + // May be handy for debugging. + return "( " + this._x.toFixed(2) + "," + this._y.toFixed(2) + ")"; + } + + public get x() : number { + return this._x; + } + + public get y() : number { + return this._y; + } + + public copy() : Point2D { + return new Point2D( this._x , this._y ); + } + + public negate() : Point2D { + // return -this. + return new Point2D(-this._x,-this.y); + } + + public negateSelf() : void { + + this._x = -this.x; + this._y = -this.y; + } + + public minus(p : Point2D) : Point2D { + + // return this - p. Redunant since it's just a form of translation. + return new Point2D( this._x - p.x , this._y - p.y ); + } + + public minusSelf(p : Point2D) : void { + + this._x -= p._x; + this._y -= p._y; + } + + public translate2(u : number, v : number) : Point2D { + return new Point2D( u + this._x , v + this._y ); + } + + public translate(p : Point2D) : Point2D { + return new Point2D( p.x + this._x , p.y + this._y ); + } + + public translateSelf2(u : number, v : number) : void { + + this._x += u; + this._y += v; + } + + public translateSelf(p : Point2D) : void { + + this._x += p.x; + this._y += p.y; + } + + public scale(s : number) : Point2D { + return new Point2D( s * this._x , s * this._y ); + } + + public scaleSelf(s : number) : void { + + // As above, but it's done in-place rather than returning a copy. + this._x *= s + this._y *= s; + } + + public rotate(theta : number) : Point2D { + + // 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 + ); + } + + public rotateSelf(theta : number) : void { + + 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; + } + + public rotateAbout(c : Point2D , theta : number ) : Point2D { + + // 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; + } + + public dot(a : Point2D) : number { + + // Return this dot a. + return a.x * this._x + a.y * this._y; + } + + public length() : number { + return Math.sqrt(this._x**2 + this._y**2); + } + + public static dot(a: Point2D , b : Point2D) : number { + // This looks like Java-style overloading, but it's not. One dot() + // is static and the other is not. + return a.dot(b); + } + + public angleBetween( a : Point2D ) : number { + + // 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); + } + + public cliffordBetween( a : Point2D) : number { + + // 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 ); + } +} + +// Used for a function of the form f(t) = ( x(t) , y(t) ). +type Parametric2DFunction = (t : number) => Point2D; + +// BUG: Really, these should be based on Point2D. E.g., bezier should be three Point2D objects. +type CloseSegment = {}; + +type MoveToSegment = { + x : number; + y : number; +} + +type LineToSegment = { + x : number; + y : number; +} + +type BezierToSegment = { + cx1 : number; + cy1 : number; + cx2 : number; + cy2 : number; + x : number; + y : number; +} + +type QuadToSegment = { + cx : number; + cy : number; + x : number; + y : number; +} + +type ArcSegment = { + x : number; + y : number; + r : number; + a0 : number; + a1 : number; + ccw : boolean; +} + +type ArcToSegment = { + x1 : number; + y1 : number; + x2 : number; + y2 : number; + r : number; +} + +type EllipseSegment = { + x : number; + y : number; + rx : number; + ry : number; + rot : number; + a0 : number; + a1 : number; + ccw : boolean; +} + +type RectSegment = { + x : number; + y : number; + w : number; + h : number; +} + +type SegmentData = CloseSegment | MoveToSegment | LineToSegment | BezierToSegment | + ArcSegment | ArcToSegment | EllipseSegment | RectSegment; + + +// Almost everything here is static because this is essentially a factory for the +// various segment types. + +class PathSegment { + + // 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. + static readonly MOVE_TO = 1; + static readonly LINE_TO = 2; + static readonly BEZIER = 3; + static readonly QUADRATIC = 4; + static readonly ARC = 5; + static readonly ARC_TO = 6; + static readonly ELLIPSE = 7; + static readonly RECT = 8; + static readonly CLOSE = 9; + static readonly UNKNOWN = -1 + + // One of the values above. + public type = PathSegment.UNKNOWN; + + // The relevant data. + // BUG: change variable name to seg. + s : SegmentData; + + private constructor(kind : number , d : SegmentData) { + this.type = kind; + this.s = d; + } + + static getClose() : PathSegment { + let d : CloseSegment = {}; + return new PathSegment(PathSegment.CLOSE,d); + } + + static getMoveTo(x : number , y : number ) : PathSegment { + let d : MoveToSegment = { x : x , y : y }; + return new PathSegment(PathSegment.MOVE_TO,d); + } + + static getLineTo( x : number, y : number ) : PathSegment { + let d : LineToSegment = {x : x , y : y } + return new PathSegment(PathSegment.LINE_TO,d); + } + + static getBezier(cx1 : number , cy1 : number , cx2 : number , cy2 : number , + x : number , y : number ) : PathSegment { + let d : BezierToSegment = + {cx1 : cx1 , cy1 : cy1 , cx2 : cx2 , cy2 : cy2 , x : x , y : y}; + return new PathSegment(PathSegment.BEZIER, d ); + } + + static getQuadratic(cx : number , cy : number , x : number , y : number ) : PathSegment { + let d : QuadToSegment = { cx : cx , cy : cy , x : x , y : y }; + return new PathSegment(PathSegment.QUADRATIC , d ); + } + + static getArc(x : number , y : number , r : number , a0 : number , a1 : number , + ccw : boolean) : PathSegment { + let d : ArcSegment = { x : x , y : y , r : r , a0 : a0 , a1 : a1 , ccw : ccw }; + return new PathSegment(PathSegment.ARC , d ); + } + + static getArcTo(x1 : number , y1 : number , x2 : number , y2 : number , + r : number ) : PathSegment { + let d : ArcToSegment = { x1 : x1 , y1 : y1 , x2 : x2 , y2 : y2 , r : r }; + return new PathSegment(PathSegment.ARC_TO , d ); + } + + static getEllipse(x : number , y : number , rx : number , ry : number , rot : number , + a0 : number , a1 : number , ccw : boolean ) : PathSegment { + // 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 : EllipseSegment = { 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 : number , y : number , w : number , h : number ) : PathSegment { + let d : RectSegment = { x : x , y : y , w : w , h : h }; + return new PathSegment(PathSegment.RECT , d ); + } + +} + + +// 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 { + + // An array of PathSegments. + segs : PathSegment[] = []; + + + constructor() { + super(); + } + + addPath(p : FPath ) : void { + + // Append the elements of p to this. + for (let i = 0; i < p.segs.length; i++) + this.segs.push(p.segs[i]); + } + + closePath () : void { + super.closePath(); + this.segs.push(PathSegment.getClose()); + } + + moveTo(x : number , y : number) : void { + super.moveTo(x,y); + this.segs.push(PathSegment.getMoveTo(x,y)); + } + + frontLineTo(x : number , y : number ) : void { + + // 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 : number , y : number ) : void { + super.lineTo(x,y); + this.segs.push(PathSegment.getLineTo(x,y)); + } + + bezierCurveTo(cx1 : number , cy1 : number , cx2 : number , cy2 : number , + x : number , y : number ) : void { + super.bezierCurveTo(cx1,cy1,cx2,cy2,x,y); + this.segs.push(PathSegment.getBezier(cx1,cy1,cx2,cy2,x,y)); + } + + quadraticCurveTo(cx : number , cy : number , x : number , y : number ) : void { + super.quadraticCurveTo(cx,cy,x,y); + this.segs.push(PathSegment.getQuadratic(cx,cy,x,y)); + } + + translate(p : Point2D) : FPath { + + // 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 : number ) : FPath { + + // 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 : number ) : FPath { + + // 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() : FPath { + + // 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() : FPath { + + // 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 : number , p : Point2D ) : FPath { + + // 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 : number , a0 : number , a1 : number ) : FPath { + + // 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 : number , y : number , r : number , a0 : number , a1 : number , ccw : boolean) : void { + + // 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 : number , y : number , rx : number , ry : number , rot : number , + a0 : number , a1 : number , ccw : boolean) : void { + + // 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 : number , y : number , w : number , h : number ) : void { + + // 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 : number , a0 : number , a1 : number ) : FPath { + + // 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 : Parametric2DFunction , + t0 : number , t1 : number ,n : number) : FPath { + + // 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 : Point2D = f(t0); + p.moveTo(p1.x,p1.y); + + for (let i = 0; i < n; i++) + { + let p4 : Point2D = 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 : CanvasRenderingContext2D, txt : string, + x : number , y : number , dx = 0 , dy = 0) : void { + + 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 : TextMetrics = 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 : CanvasRenderingContext2D | CTX, txt : string, + x : number , y : number , dx = 0 , dy = 0) : void { + + if (ctx instanceof CTX) + // Skip it. + return; + + drawText(ctx,txt,x,y,dx,dy); +} + +function drawTextTikZOnly(ctx : CanvasRenderingContext2D | CTX, txt : string, + x : number , y : number , dx = 0 , dy = 0) : void { + + 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 { + + // 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. + private 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. + private 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. + lineWidth = 1.0; + + // File name (without the '.tizk') for the figure. + figureName = ""; + + // This holds the output as it is generated. + tikzstr = ""; + + + constructor(name : string) { + + // 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 : AugmentedDrawingFunction = getAugmentedFunction ( name ); + let fpc : FigurePanel = 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); + } + + private static clone3x3Matrix(m : number[][]) : number[][] { + + // 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 : number[][] = []; + 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; + } + + private getTransform() : number[][] { + return CTX.clone3x3Matrix(this.tmatrix); + } + + private setTransform(t : number[][]) : void { + this.tmatrix = CTX.clone3x3Matrix(t); + } + + private translate(tx : number , ty : number ) : void { + + // 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; + } + + private scale(sx : number , sy : number ) : void { + + // 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; + } + + private applyTMatrix(x : number , y : number ) : {x : number , y : number } { + + // 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 : FPath ) : void { + + // 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 : FPath ) : void { + + let segs = path.segs; + if (segs.length === 0) + return; + + this.tikzstr += "\\draw[line width=" +this.lineWidth.toFixed(2)+ "bp] "; + + this.handlePath(path); + } + + fill(path : FPath ) : void { + + let segs = path.segs; + if (segs.length === 0) + return; + + this.tikzstr += "\\fill "; + + this.handlePath(path); + } + + fillText(s : string , x : number , y : number ) : void { + + // 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"; + + + } + +} + + + +// A grab-bag of numerical techniques. +// BUG: This doesn't really belong in this file. + +type FunctionRealtoReal = ( x : number ) => number; + +class Numerical { + + static newton(f : FunctionRealtoReal , g : number, a : number , b : number , + y : number , e : number ) : number { + + // 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; + } + +} diff --git a/graphics/figput/javascript/development/widgets.ts b/graphics/figput/javascript/development/widgets.ts new file mode 100644 index 0000000000..6c371527ab --- /dev/null +++ b/graphics/figput/javascript/development/widgets.ts @@ -0,0 +1,2908 @@ +/* +Widget management code. + +Typically, widget management is done with a tightly siloed hierarchy, with +widgets in containers, which are in containers, etc. There is a hierarchy +like that here (though rather flat since I don't need much), but there's also +a global registry of widgets alongside the hierarchy. This makes widgets +easier to work with for the author of the latex document. + +I've also avoided any kind of clever abstraction around the widget concept. +This can mean that there's a certain amount of boilerplate (not DRY), but +it also means that things aren't tangled up. Widgets can be individually +modified without worries about side-effects. + +All widgets use a register() method rather than a constructor. This +is because the user should be able to specify a widget repeatedly +without actually creating a new one every time it's specified. + +So, the user should say something like +let w = RandomWidgetType.register(arguments); +to create a widget from the drawing code. If register() has never been +called for the particular widget, then a new widget *is* created and +a reference to it is placed in global storage. If this widget was created +earlier, then the reference to it is taken from global storage and returned. +So, register() is something like an object factory, but it won't make the +same object more than once. + +Every widget is distinguished by its type and the (name of the) figure it +belongs to. In addition, if a figure has several widgets of the same type, +then the user must provide an optional name for that particular widget. For +example, if there are three ButtonWidgets for a given figure, then they +might be created by +let b1 = ButtonWidget.register(whatever,"first"); +let b2 = ButtonWidget.register(whatever,"second"); +let b3 = ButtonWidget.register(whatever,"third"); + +------------------------- + +One of the ticklish issues is how to associate widgets with their +figures. In most languages, this problem is solved by explicitly using +"this" somehow. In Java, you might say something like +new Widget(this); +to indicate that the owner of the widget is the class from which the widget +was constructed. I would rather not do that because it's the kind of +boilerplate arcana that the user shouldn't have to think about. + +JS provides a couple of ways to determine who made a call to a particular +function. The easiest way is like this + +function example() { + let caller = example.caller.name; + console.log(caller); +} + +This should print the name of the function that invoked example(). + +Another way is very easy to do, but it's been deprecated. See +https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/caller +Even though it is deprecated, every browser supports it, according to the +above link. Also, my guess is that this is very commonly used since +it's so handy. They won't be getting rid of it any time soon. + +If they do get rid of caller.name, then another way to obtain this +information is by creating a bogus Error and examining the call stack: + +function example() { + + let stack = new Error().stack; + let caller = stack.split('\n')[1].trim(); + + // caller is now inforamtion about the function calling this one, + // including the file name and a bunch of other stuff I don't care about. + // I just want to know the name of the function. + // It *appears* (but not extensively tested) that the function name + // occurs before the first '@'.; + let callingFcn = caller.split('@')[0]; + console.log(callingFcn); +} + +I wanted to use caller.name, even though it is deprecated, because it is +so much easier. Unfortunately, it is not allowed in strict mode. +Since everything that appears within a class is strict, that makes its use +awkward. I tried to get around this by defining a top-level function (outside +any class) that does nothing but return the value I want, but that doesn't +work either. It seems like, as soon as you enter a class, the data you want +is stripped off (or something). Also, now that I've moved to TypeScript any +violation of "strict" is even harder. Since I'll have to define a seperate class +anyway, go ahead and use the Error-based approach. + +To top it off, the method via generating an Error is sensitive to the +broswer because different browsers format this information differently. + +------------------- + +BUG: Future Widgets...? + +Because LoopWidget takes such a crazy number of options, it would be nice +to have several widgets that merely call LoopWidget. This wouldn't really +be any different, but would simplify things for the user. Basically, these +would have a reduced set of arguments to register() and just hand everything +off to LoopWidget. They would be a mere glue skeleton. Same goes for +OpenAnimWidget. + +Image Building Widget + For something like (say) a Mandelbrot set. This takes time to generate + and you might want to draw it in an open-ended way. It's not reasonable + to allow the user to move through time and see the state of the image + at different time steps. The widget should store an off-screen image + and making the animation "run faster" would mean calling it more frequently + so that it can do more calculation. So it would use some of the same + animation infrastructure, but in a different way. The most that makes + sense here is a pause/run (to reduce computational load) and something + to increase or decrease the load. This would often be something for which + it does not make sense to generate tikz. Any JS rendering of a mandelbrot + set (say) would be terrible for inclusion in a book. You'd generate a + printed figure like that in some other way, even if an on-line animation + would be instructive. + +Various restrictions on how a DraggableDot can be moved would be handy. +Restricting to a particular line or arc wouldn't be hard, but restricting to +fall on a given Path2D would be hard. The JS implementation of Path2D is +poor, and I would need to reimplement the entire thing from the +ground up. I might need to do some of that anyway for the best tikz output +(I did some stuff along those lines already). + +Draggable Line + Similar to Draggable dot. In fact, it's not clear that this needs its own + widget. A line is determined by two points, so the user could use a + draggable dot and just draw the line himself. The only advantage I can + see to a draggable *line* is that the user could mouse-down on any + portion of the line. In fact, that could be done with DraggableDot since + the "dot" could be an entire line. + +Scroll bar for number selection. + Similar to a spinner, but the user drags a dot along a line. + Not clear whether a numerical value should appear. + +Checkboxes and Radio buttons + Gack + +Drop-down menu + Stuff like this gets more fiddly. If you have a drop-down menu and + it has a vertical scroll bar for multiple selections, then it's even worse. + +*/ + + +// There is only one of these, so everything is static. It manages all +// the widgets in the program. + +class WidgetManager { + + // All the widgets known to the program. + // Each of these is an instance of the Widget class. + // BUG: I could get rid of this and just use theWidgets. It's redundant, + // and theWidgets is actually easier. + // BUG: Yes, get rid of this. + static theList : Widget[] = []; + + // Also a complete list of all widgets, but indexed by the figure + // to which the widget belongs. This makes it easier to pass events + // to the proper recipent. So this is a hash map taking a figure name + // (as a string) to an array of Widget objects. + static theWidgets : Map = new Map(); + + // This is BOGUS. Certain path operations require a CanvasRenderingContext2D, + // even when the question is one of pure abstract geometry, like + // isPointInPath(), and the only way to get one of these is from a canvas. + // Using the visible canvas and ctx for this is prone to all kinds of misuse + // and mistakes, so create a bogus ctx here. + // BUG: The long-term solution is not to rely on js for this at all. Write + // my own code for Bezier curves and the like. I'm partway there already. + static bogusCanvas : HTMLCanvasElement = document.createElement('canvas'); + static bogusCtx : CanvasRenderingContext2D = WidgetManager.bogusCanvas.getContext('2d') !; + + // Whether a widget "owns" a recent mouse-down event. This is needed + // for things like dragging. If this is null, then the next mouse-down + // is up for grabs. Otherwise, it points to the relevant Widget object. + static mouseOwner : Widget | null = null; + + // This isn't being used yet, but it will be needed to handle keyboard + // events. The idea is that a widget ""takes ownership" of a mouse-down + // with mouseOwner, and that widget is the same one that has focus for + // any future keyboard events too. The only real difference is that + // focusOwner is "stickier." This goes to null whenever a user clicks + // on something other than a widget. + static focusOwner = null; + + + static register(w : Widget) : void { + + this.theList.push(w); + + if ( WidgetManager.theWidgets.has(w.betterOwner) ) + WidgetManager.theWidgets.get(w.betterOwner) ! .push(w); + else + WidgetManager.theWidgets.set(w.betterOwner,[w]); + } + + static knownWidget(o : string , t : string , n : string) : Widget | null { + + // If there is a widget with the given owner (o), type (t) and name(n) + // in theList, then return it; return null otherwise. + // BUG: Using a string as owner feels particularly bad. + for (let i = 0; i < this.theList.length; i++) + { + let curW = this.theList[i]; + + if (o !== curW.owner) + continue; + if (t !== curW.type) + continue; + if (n === curW.name) + return curW; + } + + return null; + } + + static mouseDown(theFig : AugmentedDrawingFunction , x : number , y : number ) : void { + + // theFig should match one of the Widget object's owner fields. + // (x,y) is given relative to he origin of the figure. + if (WidgetManager.theWidgets.has(theFig) === false) + return; + + // The only thing to do here is to pass the event to each widget and + // see if it wants it. I suppose that I *could* store the area for + // each widget, and do an initial test here, but this is easier. + let wlist : Widget[] = WidgetManager.theWidgets.get(theFig) !; + + for (let i = 0; i < wlist.length; i++) + { + if (wlist[i].mouseDown(x,y) === true) + // First come, first serve. + return; + } + } + + static mouseMove(theFig : AugmentedDrawingFunction , x : number , y : number ) : void { + + // As above. + if (WidgetManager.mouseOwner === null) + return; + + if (WidgetManager.theWidgets.has(theFig) === false) + return; + + // We only accept mouse moves if the mouse is on the figure that + // "owns" the mouse event. + if (WidgetManager.mouseOwner.betterOwner !== theFig) + return; + + WidgetManager.mouseOwner.mouseMove(x,y); + } + + static mouseUp(theFig : AugmentedDrawingFunction , x : number , y : number ) : void { + + // As above. + if (WidgetManager.mouseOwner === null) + return; + + if (WidgetManager.theWidgets.has(theFig) === false) + return; + + if (WidgetManager.mouseOwner.betterOwner !== theFig) + // Mouse was released over *a* figure, but not the figure with + // the owning widget. Tell the correct widget about the release, using + // bogus coordinates so that the mouse-up is sure to be off the widget. + WidgetManager.mouseOwner.mouseUp(10000000000000,10000000000000); + + //console.log("up up"); + WidgetManager.mouseOwner.mouseUp(x,y); + } +} + + +function getCaller() : string { + + // Irritating function to get around strict mode. See the comment at the + // top of the file. I want the caller of the thing that calls this. + // + // BUG: I was never entirely happy with this, and now that it depends + // on the particular browser, I am even less happy about it. It's a + // question of the lesser of two evils: this function or requiring + // the user to provide a boilerplate 'this' or the like. + + let stack : string = new Error().stack !; + + // The exact format of the information in stack depends on the particular + // browser. Firefox produces something like this: + // + // getCaller@http://localhost:8000/widgets.js:375:15 + // register@http://localhost:8000/widgets.js:1557:18 + // geartest01@http://localhost:8000/geartest01.js:142:26 + // render@http://localhost:8000/layout.js:628:11 + // renderFrame@http://localhost:8000/widgets.js:538:13 + // etc. + // + // While MS Edge/Chrome produces + // + // Error + // at getCaller (widgets.js:375:15) + // at Function.register (widgets.js:1557:18) + // at geartest01 (geartest01.js:142:26) + // at FigurePanel.render (layout.js:628:5) + // etc. + // + // Depending on the Browser, stack needs to be parsed differently. + // theBrowser was defined in main.js. + + // The digit value (e.g., '2') indicates how many steps back in the the + // stack to go. + if (theBrowser === "Firefox") + { + let caller = stack.split('\n')[2].trim(); + + // caller is now information about a function in the call stack, + // including the file name and a bunch of other stuff I don't care about. + // I just want to know the name of the function. + // The function name occurs before the first '@'. + let callingFcn = caller.split('@')[0]; + return callingFcn; + } + else if (theBrowser === "Chrome") + { + let caller = stack.split('\n')[3].trim(); + + // Here, space-deliminting works better. + let callingFcn = caller.split(' ')[1]; + + // But this may return something like 'FigurePanel.bezier'; what I want + // is just 'bezier'. + if (callingFcn.indexOf('.') > -1) + callingFcn = callingFcn.split('.')[1]; + + return callingFcn; + } + else + { + console.log("IMPOSSIBLE ERROR DUE TO UNKNOWN BROWSER TYPE!!!"); + return ""; + } +} + + + +// Base class for all widgets. No code outside this file should +// ever access this class directly. It's not abstract because there +// are certain actions common to every case in the constructor. + +class Widget { + + // Every widget is owned by a particular figure. The "owner" is + // the name of the figure function, just as in latex. + // BUG: Change this to the AugmentedDrawingFunction. Thus, get rid + // of this and use betterOwner. + owner : string = ""; + + betterOwner : AugmentedDrawingFunction; + + // JS isn't very good about types, so it's clearer to tag sub-classes with + // the name of that sub-class rather than mess with typeof or whatever. + // This should be "LoopWidget" or whatever the name is of the sub-class. + type : string = ""; + + // Name to distinguish this widget from all others of the same + // type that belong to the same figure. So, the triple (owner,type,name) + // fully distinguishes this Widget from all others. + // + // In principle, this variable could have been avoided as a user-provided + // value and he wouldn't have to come up with an 'extra' name for the widget, + // but it wouldn't be easy. The WidgetManager (or something) would have to + // come up with a unique ID and *that* would require that the user invoke + // something like a "starting to create widgets" and "done creating widgets" + // commands. Overall, this seems less fussy for him. + name : string = ""; + + // The (x,y) is where the widget should be drawn relative to the + // rectangle of the figure. Often, widgetX will be negative to put the + // widget in the margin of the page. + // It's tempting to call these fields x and y, but it would + // be easy to accidentally reuse those names. + widgetX = 0; + widgetY = 0; + + // To scale the drawing of a widget up or down. + scale = 1.0; + + // Occassionally, it may make sense to hide a widget. This is different + // than being non-visible because the widget is off-screen. If the + // widget has hide == true, then it is *never* shown. For example, the + // way animations work, you have to have an animation widget to run the + // animation, even if you don't want to see the widget. + hide : boolean = true; + + + constructor(owner : string , type : string , x : number , y : number , scale : number , + hide : boolean , name : string ) { + + // Due to the fact that this tracks the owner of the widget, and how + // it is done, it is IMPORTANT that no sub-class has its own constructor. + this.owner = owner; + this.type = type; + this.widgetX = x; + this.widgetY = y; + this.scale = scale; + this.hide = hide; + this.name = name; + this.betterOwner = getAugmentedFunction(owner); + + // When a widget is contructed it must be registered in a global list. + WidgetManager.register(this); + } + + draw(ctx : CanvasRenderingContext2D) : void { + // Every sub-class must implement this method. + console.log("Called Widget.draw()!!!"); + } + + // BUG: These methods that are "never" supposed to be called + // will be called for the widgets based on the DOM, like ButtonWidget. + // That's fine -- don't panic! I want to get rid of these DOM-based + // widgets anyway. DOM = cesspool. + mouseDown(x : number , y : number ) : boolean { + // Every sub-class must implement this method. + // Return true iff the widget wants to "take ownership" of this event. + console.log("Called Widget.mouseDown()!!! " +this.name); + return false; + } + + mouseMove(x : number , y : number ) : void { + // As above, but returns nothing. + console.log("Called Widget.mouseMove()!!!"); + } + + mouseUp(x : number , y : number ) : void { + // As above, but returns nothing. + console.log("Called Widget.mouseUp()!!!"); + } +} + + +// Animations require some infrastructure. +// BUG: Maybe this stuff should be in AnimationWidget. + +function doAnimation(theWidget : AnimationWidget) : void { + + // This is called to render a frame of an animation. It is generated via + // the usual event-loop, so we schedule it just as we do for things like + // mouse-downs and scroll events. + // theWidget is the one that "runs" the animation, like a LoopWidget. + let id = Events.getID(); + doAnimationGuts(id,theWidget); +} + +async function doAnimationGuts(id : number , theWidget : AnimationWidget ) { + + // Scheduling is handled in a way similar to doScroll() in main.ts. + await Events.waitMyTurn(id); + + await renderFrame(theWidget); + + // Advance to the next frame. + theWidget.curStep += theWidget.stepsPerFrame; + theWidget.advanceFrame(); + + // Don't forget this or the program is bricked! + Events.allDone(id); +} + +async function renderFrame(theWidget : Widget ) { + + // Calls the code to render the relevant figure. It renders the *entire* + // figure, widget and all. + // + // NOTE: This is used for animations, but it is also used to ensure + // that any change to a widget (and resulting changes to a figure) + // is shown. + // + // BUG: It's tempting to mention this in the user manual since users might + // find it useful. OTOH, that shouldn't be encouraged, and this is the kind + // of thing that might change in a later version. + + // The "owner" is the function (from the latex document) that created + // theWidget. + let myFunc : AugmentedDrawingFunction = getAugmentedFunction( theWidget.owner ); + let fpc : FigurePanel = myFunc.figurePanelClass !; + + // This is generally synchronous, but it doesn't hurt anything to tack an + // async on here. Maybe somebody will write one that *is* asynchronous. + await fpc.render(); +} + +function getFigureRect(theWidget : Widget ) : { w : number , ha : number , hb : number } { + +// Returns the width and height of the rectangle of the widget. +// The height is in two parts: the height above the x-axis, and the height +// below the x-axis. +// The units are pdf points, and the width is relative to the left +// margin. So the width matches the usual coordinate system for drawing +// the figure, and this width is equal to the text width, as reported +// by latex. So x in the range [0,width] should be limited to the area +// below the text. +let myFunc : AugmentedDrawingFunction = getAugmentedFunction( theWidget.owner ); +let fpc : FigurePanel = myFunc.figurePanelClass ! ; + +let answer = { w: fpc.textWidth, ha : fpc.h - fpc.lowerPadding , hb : fpc.lowerPadding }; +return answer; +} + +// Base class for widgets that run animations. See LoopAnimWidget and +// OpenAnimWidget. There is a fair amount of overlap between the two types +// of animation class, and it is tempting to pull more stuff up to this level for +// DRY reasons, but it seems cleaner and clearer to limit this to what's needed +// to run the animations with doAnimation() and related functions. + +abstract class AnimationWidget extends Widget { + + // Animations run as a series of frames, and this is the frame being displayed. + // This value may be open-ended or it may "loop back" so that animation repeats. + curStep = 0; + + // How much to advance the above with each frame -- an integer. Animations + // can be made to run faster by increasing this value, thereby skipping frames. + stepsPerFrame = 1; + + // The process id for the call to setInterval(). + animID = 0; + + advanceFrame() : void { + // This is why the class is abstract. It moves curStep to the next frame, + // however that should be done for the particular animation. + console.log("Calling abstract AnimationWidget.advanceFrame()!"); + } +} + +// A LoopWidget is to be used when an animiation runs in a repeating loop. +// You must have one of these for an animation to run, even if the widget +// itself is invisible. + +class LoopAnimWidget extends AnimationWidget { + + // These are useful to the user to help properly place things. + // These values are given with scale equal to 1, and are worked + // out from the actual drawing code. + // The TopHeight is the amount above the circle that is used for the + // time-step controls, and BottomHeight is the amount used for the + // faster/slower, pause/run contols. If you don't want the circle at + // all, then it's a little awkward to work out placement, but it works. + // There is some imprecision here due to line thickneses, but very close. + static Radius = 41.5; + static TopHeight = 24.5; + static BottomHeight = 21.0; + + // When things are "selected," draw them in this color. + static sColor = "blue"; + + // These are as passed to register(). See that method for a description. + // They really shouldn't be touched outside this class. The boolean values + // are whether certain elements of the widget are visible (and hence + // available for interaction). + steps = 100; + start = 0; + timeStep = 20; + visSteps = true; + visFastSlow = true; + visPauseRun = true; + visCircle = true; + triGrab = true; + + // This stuff is very much private. + + // These are stored when the figure is drawn so that mouse events can find + // what was clicked more easily. It's simpler than recalculating with + // every event. + pCircle : Path2D | null = null; + pUpStep : Path2D | null = null; + pDownStep : Path2D | null = null; + pFaster : Path2D | null = null; + pSlower : Path2D | null = null; + pPauseRun : Path2D | null = null; + + // The states of various parts of the widget; e.g., whether the pause + // or run icon is present, whether a part is "half-way clicked,"" etc. + // I'm using 'a' for 'active' and 's' for 'selected.' + // + // BUG: If I want to get *really* fancy, then I need another set of + // flags to indicate that the mouse *was* clicked on something, so it + // is "selected," but the user moved the mouse away from the item without + // a mouse-up, so that selected item should be drawn in normal color, not + // the highlighted color (sColor). If the mouse is moved back over the + // selected item, then the color can go back to being the selection color. + aRunning = true; + sCircle = false; + sPauseRun = false; + sFaster = false; + sSlower = false; + sUpStep = false; + sDownStep = false; + + + static register(ctx : CanvasRenderingContext2D , x : number , y : number , scale : number , + visWidget : boolean , steps : number , start : number , timeStep : number , + visSteps : boolean, visFastSlow : boolean , visPauseRun : boolean , visCircle : boolean , + triGrab : boolean , name : string ) : LoopAnimWidget { + + // BUG: Add an argument for the size of the steps so that + // curStep can be incremented by more than 1? + + // This is used something like a constructor. It either creates a new + // LoopWidget and returns it, or returns one that was created earlier. + + // Many of these arguments are the same as for Widget.constructor(). + // In addition, we have + // * ctx is assumed to have a t-matrix prepared to properly draw + // the widget. + // * steps is the number of steps required to form a loop -- when it + // "rolls over" or the "steps per revolution." + // * start is the starting step, which will usually be zero. + // * timeStep, in milliseconds, is the time from frame to frame. + // It seems like anything less than about 10ms is pointless. + // My guess (?) is that the rate of event generation is throttled + // somehow. It could be that my various layers of management are + // slowing things down, but I don't think so. 10ms is an eternity + // on modern hardware. The eye can only follow about 20 frames per + // second, at most, or 50ms per frame, so this is no big deal. + // * visWidget is whether the widget is visible at all -- same as the + // vis argument to Widget.construtor(). + // * visSteps is whether the time step controls (at the top) are visible. + // * visFastSlow is whether the faster/slower controls are visible (at + // the bottom) + // * visPauseRun is whether the pause/run controls are visible + // * visCircle is for the circle (with triangular indicator). + // * triGrab is whether the user is allowed to grab the indicator + // triangle and control the animation by dragging it. The indicator + // triangle is always there, but it might not be grabable. + // Note that if visCircle == false, then triGrab is implicitly false + // since the triangle isn't visible either. + // * name is as in Widget.constructor() + // + // There appears to be a tacit assumption that time is measured in + // integer steps, but fractional values are fine. So they aren't really + // time "steps," but more like time increments. + // + // BUG: Maybe I should have different classes for some of these choices. + // There are just too many. These could all use (internally) the + // same class, just not with such a crazy number of options. + + // Instead of messing with LoopWidget.name or something, be explicit. + let type = "LoopWidget"; + + // Something like this line must appear with every regester() method + // for each widget. + let caller : string = getCaller(); + + // I am forcing the type here, but if the wrong type is returned, + // then there are bigger problems. + let w : LoopAnimWidget = WidgetManager.knownWidget(caller,type,name); + + if (w != null) + { + // Widget is known, but it needs to be drawn too. + w.draw(ctx); + return w; + } + + // Got here, so this widget is not already known, and must be created. + // This class has NO construtor, by design, so this falls through to the + // super-class Widget constructor. + // Careful: Internally, I use an "is hidden" flag, but the user passes in + // an "is visible" flag. + // BUG: Change the names to be consistent. + w = new LoopAnimWidget(caller,type,x,y,scale,!visWidget,name); + + // Now the additional stuff. This is what would be in a constructor + // if this class had one. + w.steps = steps; + w.start = start; + w.timeStep = timeStep; + w.visSteps = visSteps; + w.visFastSlow = visFastSlow; + w.visPauseRun = visPauseRun; + w.visCircle = visCircle; + w.triGrab = triGrab; + + w.curStep = w.start; + + // Note the existence of this widget for the future. + WidgetManager.register(w); + + // This is a special case because it's an animiation. The animation + // needs to be scheduled. It may make sense not to call setInterval() + // immediately; rather, it may be better to call setTimeout() so that + // setInterval() is called after a brief pause. It depends on how these + // two work. Try an immediate call; it *should* be fine. + w.animID = setInterval(doAnimation,w.timeStep,w); + + // Before returning the widget, it must be drawn. + w.draw(ctx); + + return w; + } + + advanceFrame() : void { + + // This kind of animation repeats. + this.curStep += this.stepsPerFrame; + if (this.curStep >= this.steps) + this.curStep -= this.steps; + } + + draw(ctx : CanvasRenderingContext2D) : void { + + // This is drawn with (0,0) at the center of the circle. The ctx must be + // shifted and scaled based on where the user wants the widget relative + // to the larger drawing area. + if (this.hide === true) + return; + + // Don't attempt to draw to a tikz file. + if (ctx instanceof CTX) + return; + + let saveT = ctx.getTransform(); + ctx.translate(this.widgetX,this.widgetY); + ctx.scale(this.scale,this.scale); + + var p = new Path2D(); + + // These values determine where the entire drawing is. (cx,cy) is the + // center of the circular thing, with radius r. + // In unscaled terms, it's clear that cx should equal r to make the + // widget but up against x = 0. It's messier for the y-coordinate and you + // need to work backwards from what the values defined below. The total + // height is 2r (the circle), plus circWidth (circle line thickness), + // plus 2 * arrowHeight (the go fast/go slow things). Then we need to + // add the stuff on the top: upperGap, plus stepHeight, plus stepThick. + // This is silly and probably confusing to the user. Just place the + // widget relative to the center of the circle. In other words, set + // (cx,cy) = (0,0). The user just needs adjust accordingly, and + // exactly what he wants to do will be influenced by whether the upper + // and lower sub-controls are present. + var r = 40; + var cx = 0; + var cy = 0; + var circWidth = 3; + + if (this.visCircle == true) + { + // Draw the circle. (cx,cy) is center r and r are the two axes of the + // elipse. 0 is that the ellipse isn't rotated, and the last two are + // the start and end angle. + p.ellipse(cx,cy,r,r,0,0,2*Math.PI); + + // Note this circle for reference by mouse events. + this.pCircle = new Path2D(p); + + ctx.lineWidth = circWidth; + + if (this.sCircle == true) + ctx.strokeStyle = LoopAnimWidget.sColor; + else + ctx.strokeStyle = "black"; + + ctx.stroke(p); + + ctx.lineWidth = 1; + + // A little triangle to point to the location within the animation loop. + // This value is in radians, in [0,2pi). + + // Location of indicator triangle around the perimeter of the circle. + // Minus so that it travels clock-wise, which seems to be our natural expectation. + var loc = -2*Math.PI * this.curStep / this.steps; + + var triHeight = 10; + + // This is half the full angle at the outer point. + var triAngle = Math.PI/20; + + p = new Path2D(); + + var x = cx + (r-circWidth/2) * Math.cos(loc); + var y = cy + (r-circWidth/2) * Math.sin(loc); + p.moveTo(x,y); + + x = cx + (r-triHeight) * Math.cos(loc + triAngle); + y = cy + (r-triHeight) * Math.sin(loc + triAngle); + p.lineTo(x,y); + + x = cx + (r-triHeight) * Math.cos(loc - triAngle); + y = cy + (r-triHeight) * Math.sin(loc - triAngle); + p.lineTo(x,y); + + p.closePath(); + + ctx.strokeStyle = "red"; + ctx.stroke(p); + ctx.strokeStyle = "black"; + } + + // Next, some controls at the bottom for going faster/slower and + // pausing/running. + // First, a pair of '>' for going faster. + let arrowOffset = 18; + let lowerGap = 6; + let arrowHeight = 7; + let arrowDepth = 4; + let arrowPairSpace = 3; + let arrowThick = 1.25; + + if (this.visFastSlow === true) + { + ctx.lineWidth = arrowThick; + + if (this.sFaster == true) + ctx.strokeStyle = LoopAnimWidget.sColor; + else + ctx.strokeStyle = "black"; + + p = new Path2D(); + p.moveTo(cx + arrowOffset,cy - r - circWidth/2 - lowerGap); + p.lineTo(cx + arrowOffset + arrowDepth, + cy - r - circWidth/2 - lowerGap - arrowHeight); + p.lineTo(cx + arrowOffset, + cy - r - circWidth/2 - lowerGap - 2*arrowHeight); + ctx.stroke(p); + + // You can't just shift a path. Needs to be rebuilt. + // BUG: I need to add that ability to my FPath class. + // Maybe I have already? + p = new Path2D(); + p.moveTo(cx + arrowOffset + arrowPairSpace,cy - r - circWidth/2 - lowerGap); + p.lineTo(cx + arrowOffset + arrowDepth + arrowPairSpace, + cy - r - circWidth/2 - lowerGap - arrowHeight); + p.lineTo(cx + arrowOffset + arrowPairSpace, + cy - r - circWidth/2 - lowerGap - 2*arrowHeight); + ctx.stroke(p); + + // A rectangle for the clickable area. + this.pFaster = new Path2D(); + this.pFaster.rect(cx + arrowOffset - arrowThick,cy - r - circWidth/2 - lowerGap - 2*arrowHeight, + arrowPairSpace + arrowDepth + 2*arrowThick,2*arrowHeight); + + // ctx.strokeStyle = 'green'; + // ctx.stroke(this.pFaster); + + // Same idea: '<' to go slower. + if (this.sSlower == true) + ctx.strokeStyle = LoopAnimWidget.sColor; + else + ctx.strokeStyle = "black"; + + p = new Path2D(); + p.moveTo(cx - arrowOffset,cy - r - circWidth/2 - lowerGap); + p.lineTo(cx - arrowOffset - arrowDepth, + cy - r - circWidth/2 - lowerGap - arrowHeight); + p.lineTo(cx - arrowOffset, + cy - r - circWidth/2 - lowerGap - 2*arrowHeight); + ctx.stroke(p); + + p = new Path2D(); + p.moveTo(cx - arrowOffset - arrowPairSpace,cy - r - circWidth/2 - lowerGap); + p.lineTo(cx - arrowOffset - arrowDepth - arrowPairSpace, + cy - r - circWidth/2 - lowerGap - arrowHeight); + p.lineTo(cx - arrowOffset - arrowPairSpace, + cy - r - circWidth/2 - lowerGap - 2*arrowHeight); + ctx.stroke(p); + + // And the clickable area. + this.pSlower = new Path2D(); + this.pSlower.rect( + cx - arrowOffset - arrowPairSpace - arrowDepth - arrowThick, + cy - r - circWidth/2 - lowerGap - 2*arrowHeight, + arrowPairSpace + arrowDepth + 2*arrowThick,2*arrowHeight); + + // ctx.strokeStyle = 'green'; + // ctx.stroke(this.pSlower); + } + + ctx.lineWidth = 1; + + if (this.visPauseRun === true) + { + // A || or triangle for pause or run. + let pauseSpace = 3.25; + let pauseThick = 1.5; + let pauseHeight = 2 * arrowHeight; + + let runThick = 1.5; + let runLeftRight = 5; + let runHeight = 2 * arrowHeight; + + if (this.sPauseRun == true) + ctx.strokeStyle = LoopAnimWidget.sColor; + else + ctx.strokeStyle = "black"; + + if (this.aRunning === true) + { + // The animation is running, so show || to allow pausing. + ctx.lineWidth = pauseThick; + + p = new Path2D(); + p.moveTo(cx + pauseSpace,cy - r - circWidth/2 - lowerGap); + p.lineTo(cx + pauseSpace,cy - r - circWidth/2 - lowerGap - pauseHeight); + ctx.stroke(p); + + p = new Path2D(); + p.moveTo(cx - pauseSpace,cy - r - circWidth/2 - lowerGap); + p.lineTo(cx - pauseSpace,cy - r - circWidth/2 - lowerGap - pauseHeight); + ctx.stroke(p); + + ctx.lineWidth = 1; + } + else + { + // Animation is paused, so show triangle to run it again. + ctx.lineWidth = runThick; + + p = new Path2D(); + p.moveTo(cx - runLeftRight,cy - r - circWidth/2 - lowerGap); + p.lineTo(cx - runLeftRight,cy - r - circWidth/2 - lowerGap - runHeight); + p.lineTo(cx + runLeftRight,cy - r - circWidth/2 - lowerGap - runHeight/2); + p.closePath(); + ctx.stroke(p); + } + + // Either way (paused or running), we need the clickable area. + // This area is too generous for the "run" triangle, because + // I use the same rectangle for "pause" and "run," but no big deal. + this.pPauseRun = new Path2D(); + this.pPauseRun.rect(cx - runLeftRight - runThick, + cy - r - circWidth/2 - lowerGap - runHeight - runThick, + 2*runLeftRight + 2*runThick,runHeight + 2*runThick); + + // ctx.strokeStyle = "blue"; + // ctx.lineWidth = 0.5; + // ctx.stroke(this.pPauseRun); + // ctx.strokeStyle = "black"; + } + + ctx.lineWidth = 1; + + // Now some symbols above the circle for adjusting the step size. + + if (this.visSteps === true) + { + // Up and down arrows. + let stepSpace = 20; + let stepThick = 2.0; + let upperGap = 8; + let stepHeight = 15; + let stepArrowHeight = 8; + let stepArrowWidth = 5; + + ctx.lineWidth = stepThick; + + if (this.sDownStep == true) + { + ctx.strokeStyle = LoopAnimWidget.sColor; + ctx.fillStyle = LoopAnimWidget.sColor; + } + else + { + ctx.strokeStyle = "black"; + ctx.fillStyle = "black"; + } + + // Vertical line + p = new Path2D(); + p.moveTo(cx - stepSpace,cy + r + circWidth/2 + upperGap); + p.lineTo(cx - stepSpace,cy + r + circWidth/2 + upperGap + stepHeight); + ctx.stroke(p); + + // Arrow head + p = new Path2D(); + p.moveTo(cx - stepSpace, + cy + r + circWidth/2 + upperGap - stepThick); + p.lineTo(cx - stepSpace + stepArrowWidth, + cy + r + circWidth/2 + upperGap + stepArrowHeight - stepThick); + p.lineTo(cx - stepSpace - stepArrowWidth, + cy + r + circWidth/2 + upperGap + stepArrowHeight - stepThick); + p.closePath(); + ctx.fill(p); + + // Clickable area for down arrow. + this.pDownStep = new Path2D(); + this.pDownStep.rect(cx - stepSpace - stepArrowWidth, + cy + r + circWidth/2 + upperGap - stepThick, + 2*stepArrowWidth,stepHeight + stepThick); + + // ctx.strokeStyle = "blue"; + // ctx.lineWidth = 0.5; + // ctx.stroke(this.pDownStep); + // ctx.strokeStyle = "black"; + // ctx.lineWidth = stepThick; + + // Again, to the right, arrow head up. + if (this.sUpStep == true) + { + ctx.strokeStyle = LoopAnimWidget.sColor; + ctx.fillStyle = LoopAnimWidget.sColor; + } + else + { + ctx.strokeStyle = "black"; + ctx.fillStyle = "black"; + } + + p = new Path2D(); + p.moveTo(cx + stepSpace,cy + r + circWidth/2 + upperGap); + p.lineTo(cx + stepSpace,cy + r + circWidth/2 + upperGap + stepHeight); + ctx.stroke(p); + + p = new Path2D(); + p.moveTo(cx + stepSpace, + cy + r + circWidth/2 + upperGap + stepHeight + stepThick); + p.lineTo(cx + stepSpace + stepArrowWidth, + cy + r + circWidth/2 + upperGap + stepHeight - stepArrowHeight + stepThick); + p.lineTo(cx + stepSpace - stepArrowWidth, + cy + r + circWidth/2 + upperGap + stepHeight - stepArrowHeight + stepThick); + p.closePath(); + ctx.fill(p); + + // Clickable area for up arrow. + this.pUpStep = new Path2D(); + this.pUpStep.rect(cx + stepSpace - stepArrowWidth, + cy + r + circWidth/2 + upperGap, + 2*stepArrowWidth,stepHeight + stepThick); + + // ctx.strokeStyle = "blue"; + // ctx.lineWidth = 0.5; + // ctx.stroke(this.pUpStep); + // ctx.strokeStyle = "black"; + + ctx.lineWidth = 1; + + // A little step icon. + ctx.strokeStyle = "black"; + ctx.fillStyle = "black"; + stepThick = 1.5; + let stepSize = 6; + + ctx.lineWidth = stepThick; + + // Steps made as one path, starting at upper-left + p = new Path2D(); + p.moveTo(cx - stepSize,cy + r + circWidth/2 + upperGap + 2*stepSize); + p.lineTo(cx,cy + r + circWidth/2 + upperGap + 2*stepSize); + p.lineTo(cx,cy + r + circWidth/2 + upperGap + stepSize); + p.lineTo(cx + stepSize,cy + r + circWidth/2 + upperGap + stepSize); + p.lineTo(cx + stepSize,cy + r + circWidth/2 + upperGap); + ctx.stroke(p); + } + + ctx.lineWidth = 1; + + // Used this to verify the constants. The rectangle barely encloses + // the widget. + //ctx.strokeRect( + // -LoopWidget.Radius,-LoopWidget.Radius - LoopWidget.TopHeight, + // 2*LoopWidget.Radius, + // 2*LoopWidget.Radius + LoopWidget.TopHeight + LoopWidget.BottomHeight); + + ctx.setTransform(saveT); + } + + mouseDown(x : number , y : number ) : boolean { + + // (x,y) is given in coordinates relative to the owning figure. + // Return true iff these coordinates apply to this widget. + if (this.hide === true) + return false; + + // Adjust coordinates relative to what the draw() methods uses. + // This way we can compare (x,y) to what is on the screen. + x -= this.widgetX; + y -= this.widgetY; + x /= this.scale; + y /= this.scale; + + // The widget also has a scale, which must be taken into account. + WidgetManager.bogusCtx.resetTransform(); + + // There is isPointInPath() and isPointInStroke(). + // It seems that isPointInPath() works on an abstract geometric basis; + // the lineWidth of the ctx doesn't matter. OTOH, isPointInStroke() + // is affected by the lineWidth -- as it must be to work in any + // reasonable way. + // + // Note also that isPointInPath() defaults to the non-zero winding rule. + // Pass "evenodd" as the final argument for that winding rule. + + // Check the pause/run area first since it should be "on top of" + // the circle area. + if (this.pPauseRun !== null) + { + let isin = WidgetManager.bogusCtx.isPointInPath(this.pPauseRun,x,y); + if (isin === true) + { + this.sPauseRun = true; + WidgetManager.mouseOwner = this; + renderFrame(this); + return true; + } + } + + // And the run faster area. + if (this.pFaster !== null) + { + let isin = WidgetManager.bogusCtx.isPointInPath(this.pFaster,x,y); + if (isin === true) + { + this.sFaster = true; + WidgetManager.mouseOwner = this; + renderFrame(this); + return true; + } + } + + // The run slower area. + if (this.pSlower !== null) + { + let isin = WidgetManager.bogusCtx.isPointInPath(this.pSlower,x,y); + if (isin === true) + { + this.sSlower = true; + WidgetManager.mouseOwner = this; + renderFrame(this); + return true; + } + } + + // The longer step area. + if (this.pUpStep !== null) + { + let isin = WidgetManager.bogusCtx.isPointInPath(this.pUpStep,x,y); + if (isin === true) + { + this.sUpStep = true; + WidgetManager.mouseOwner = this; + renderFrame(this); + return true; + } + } + + // The shorter step area. + if (this.pDownStep !== null) + { + let isin = WidgetManager.bogusCtx.isPointInPath(this.pDownStep,x,y); + if (isin === true) + { + this.sDownStep = true; + WidgetManager.mouseOwner = this; + renderFrame(this); + return true; + } + } + + // Last thing to check since it should be "underneath" everything. + if (this.pCircle !== null) + { + // If the user clicked near the circle, then set the indicator and + // current step to that position. Be generous with the clickable area. + WidgetManager.bogusCtx.lineWidth = 15; + let isin = WidgetManager.bogusCtx.isPointInStroke(this.pCircle,x,y); + + if (isin === true) + { + // Act on this click and take ownership for future draggging. + // Want an angle in [0,2pi]. + this.sCircle = true; + + // Again, minus since clockwise. + let alpha = -Math.atan2(y,x); + if (alpha < 0) + alpha += 2*Math.PI; + + this.curStep = Math.floor(this.steps * alpha / (2*Math.PI)); + + WidgetManager.mouseOwner = this; + + // The appearance of the widget has changed. + renderFrame(this); + + return true; + } + } + + return false; + } + + mouseMove(x : number , y : number ) : void { + + // As above. + if (this.hide === true) + return; + + x -= this.widgetX; + y -= this.widgetY; + x /= this.scale; + y /= this.scale; + WidgetManager.bogusCtx.resetTransform(); + + if (this.sCircle === true) + { + // What I will do it check that the mouse is "close enough" to the + // circle, but it can be a *long* ways away. + WidgetManager.bogusCtx.lineWidth = 40; + let isin = WidgetManager.bogusCtx.isPointInStroke(this.pCircle !,x,y); + + if (isin == false) + return; + + // Minus to make clockwise. + let alpha = -Math.atan2(y,x); + if (alpha < 0) + alpha += 2*Math.PI; + + this.curStep = Math.floor(this.steps * alpha / (2*Math.PI)); + + // The appearance of the widget may have changed. + renderFrame(this); + } + + // BUG: I might (?) want colors to change based on what the mouse + // is over. See the BUG comment that goes with aRunning, sCircle, etc., + // at the top of the class. + } + + mouseUp(x : number , y : number ) : void { + + // As above. + if (this.hide === true) + return; + + x -= this.widgetX; + y -= this.widgetY; + x /= this.scale; + y /= this.scale; + WidgetManager.bogusCtx.resetTransform(); + + // The mouse is up, so nothing can remain selected. In most cases, + // releasing the mouse over the selected item means that something + // must be done since the "button" was properly pressed. + this.sCircle = false; + + if (this.sPauseRun) + { + // Did they *release* the mouse over the pause/run area? + let isin = WidgetManager.bogusCtx.isPointInPath(this.pPauseRun ! ,x,y); + if (isin === true) + { + // Start/stop the animation. + if (this.aRunning === true) + // Currently running. Pause it. + clearInterval(this.animID); + else + // Currently paused. Restart it. + this.animID = setInterval(doAnimation,this.timeStep,this); + + // Change the pause/run icon too. + if (this.aRunning === true) + this.aRunning = false; + else + this.aRunning = true; + } + + this.sPauseRun = false; + } + + if (this.sFaster === true) + { + let isin = WidgetManager.bogusCtx.isPointInPath(this.pFaster ! ,x,y); + if (isin === true) + { + // Make the animation run a bit faster by reducing the frame-to- + // frame time step -- the animation speed. This could be done in + // a lot of different ways, by using a factor of 1.4 seems about + // right. + this.timeStep /= 1.4; + if (this.timeStep < 1) + this.timeStep = 1; + + // Stop the animation and restart it at the new speed, + // but only if it is currently running. It restarts always, but + // don't try to halt it if it's not running. + if (this.aRunning === true) + clearInterval(this.animID); + + this.animID = setInterval(doAnimation,this.timeStep,this); + this.aRunning = true; + } + + this.sFaster = false; + } + + if (this.sSlower === true) + { + // Just as above, but make it go slower. + let isin = WidgetManager.bogusCtx.isPointInPath(this.pSlower ! ,x,y); + if (isin === true) + { + this.timeStep *= 1.4; + + // More than a second per frame is silly. + if (this.timeStep > 1000) + this.timeStep = 1000; + + if (this.aRunning === true) + clearInterval(this.animID); + + this.animID = setInterval(doAnimation,this.timeStep,this); + this.aRunning = true; + } + + this.sSlower = false; + } + + if (this.sUpStep === true) + { + // Make the number of time increments per frame larger. + let isin = WidgetManager.bogusCtx.isPointInPath(this.pUpStep ! ,x,y); + if (isin === true) + { + // Use a smaller ratio here. Conceptually, it seems like + // this should be an integer, but it really doesn't have to be. + this.stepsPerFrame *= 1.25; + + // Fewer than 3 frames per cycle seems silly. For most animations, + // you'd proably want at least 10 or 20, at a minimum. + if (this.stepsPerFrame > this.steps / 3) + this.stepsPerFrame = this.steps / 3; + } + + this.sUpStep = false; + } + + if (this.sDownStep === true) + { + // As above. + let isin = WidgetManager.bogusCtx.isPointInPath(this.pDownStep ! ,x,y); + if (isin === true) + { + this.stepsPerFrame /= 1.25; + + // I am tempted to put a lower bound on this, but it's 'not + // absolutely necessary. + } + + this.sDownStep = false; + } + + // The appearance of the widget may have changed. + renderFrame(this); + + } +} + + +// An OpenAnimWidget is for an open-ended animation that doesn't loop back +// on itself and repeat. In many respects, it's similar to a LoopWidget. +// It looks different because it's a long bar, sort of like a scroll bar. +// It's so similar that there are minimal (for me) comments. See +// LoopWidgets for certain details. + +class OpenAnimWidget extends AnimationWidget { + + // These are useful to the user to help properly place things. + // The widget is placed based on the lower-left corner, and the + // user can specify the width. The BarHeight is the height of the + // bar portion -- it's essentially the radius of the indicator dot -- + // and ControlsHeight is for the controls. + static BarHeight = 6.0; + static ControlsHeight = 20.0; + + // This is useful to help the user place stuff above the control. + static TotalHeight = 32.0; + + + // When things are "selected," draw them in this color. + static sColor = "blue"; + + // These things are important to the animation drawing code, and are + // meant to be public(ish). + + // These are as passed to register(). + barLength = 100; + timeStep = 25; + decay = 1.0001; + visSteps = true; + visFastSlow = true; + visPauseRun = true; + visBar = true; + barGrab = true; + + // As for LoopWidth, width modest changes: + + // Clickable areas: + pBar : Path2D | null = null; + pDot : Path2D | null = null; + pFaster : Path2D | null = null; + pSlower : Path2D | null = null; + pPauseRun : Path2D | null = null; + pUpStep : Path2D | null = null; + pDownStep : Path2D | null = null; + + // States of parts: + // + // BUG: If I want to get *really* fancy, then I need another set of + // flags to indicate that the mouse *was* clicked on something, so it + // is "selected," but the user moved the mouse away from the item without + // a mouse-up, so that selected item should be drawn in normal color, not + // the highlighted color (sColor). If the mouse is moved back over the + // selected item, then the color can go back to being the selection color. + aRunning = true; + sDot = false; + sFaster = false; + sSlower = false; + sPauseRun = false; + sUpStep = false; + sDownStep = false; + + + static register(ctx : CanvasRenderingContext2D , x : number , y : number ,scale : number , + width : number , visWidget : boolean , timeStep : number , decay : number , + visSteps : boolean , visFastSlow : boolean , visPauseRun : boolean ,visBar : boolean , + barGrab : boolean , name : string ) : OpenAnimWidget { + + // This is used something like a constructor. It either creates a new + // LoopWidget and returns it, or returns one that was created earlier. + + // Many of these arguments are the same as for Widget.constructor(). + // In addition, we have + // * ctx is assumed to have a t-matrix prepared to properly draw + // the widget. + // * width is the length of the indicator bar. If you want both the + // controls (time steps and fast/slow), then this should be at least + // 200 so that the controls don't stick out. Of course, this is the + // unscaled size, and you can make the entire thing smaller with the + // scale argument. + // * timeStep, in milliseconds, is the time from frame to frame. + // * decay is complicated. The value of this.curStep must be mapped to + // the linear bar, which is not infinite. + // We need a map from [0,infty) to [0,width). Define + // f(s) = 1 - 1/a^s + // This maps [0,infty) to [0,1), provided that a > 1. Then + // g(s) = w f(s) + // is the function we want. But what about a? The closer a is to 1, + // the faster g(s) will approach w. Typically, you'll want a to be + // something like 1.001 or 1.0001, depending on the number of steps + // in your animation. The decay argment determines a: + // a = 1 + 1/decay, so you'll usually want decay to be somewhere in the + // range from 100 to (maybe) 100,000. It depends how big the steps + // are and how long you want the animiation to run. For comparison, + // decay = 1,000 puts f(2000) = 0.86 and f(5000) = 0.99, while + // decay = 10,000 puts f(2000) = 0.18, f(5000) = 0.39, + // f(20,000) = 0.86. You can also work backwards. If you want + // f(n) = x, where x is in [0,1), like x = 85%, then you want + // 1 / (1 + 1/a)^n = x, or + // a = 1 / [ x^(1/n) - 1 ] + // That's not so informative, but you can write it as + // a = 1 / [ exp(-ln(x)/n) - 1 ] + // If we take x \approx 0.86 so that ln(x) = -0.15 (exactly), then + // a = 1 / [ exp(-0.15/n) - 1 ] + // Plug in the value for n at which you want to have reached the + // 86% level, and you get a. + // For brevity, in the code, I use this.decay as the value, a, + // discussed above. + // BUG: I feel like I made an algebra mistake, but that's the idea. + // * visWidget is whether the widget is visible at all -- same as the + // vis argument to Widget.construtor(). + // * visSteps is whether the time step controls (at the right) are visible. + // * visFastSlow is whether the faster/slower controls are visible (at + // the left) + // * visPauseRun is whether the pause/run controls are visible. + // * visBar is for the progress bar (with dot indicator). + // * barGrab is whether the user is allowed to grab the dot indicator + // triangle and control the animation by dragging it. + // * name is as in Widget.constructor() + // + // BUG: Maybe I should have different classes for some of these choices. + // There are just too many. These could all use (internally) the + // same class, just not with such a crazy number of options. + + + // As for LoopWidget. + let type = "OpenAnimWidget"; + let caller = getCaller(); + + let w : OpenAnimWidget = WidgetManager.knownWidget(caller,type,name); + + if (w != null) + { + w.draw(ctx); + return w; + } + + // Got here, so this widget is not already known, and must be created. + w = new OpenAnimWidget(caller,type,x,y,scale,!visWidget,name); + + // Adjust the length for the scale so that if the user asks for + // a bar that is X px long, he gets it. So, the scale adjusts the size + // of the "bits", not the total size. + w.barLength = width / scale; + w.timeStep = timeStep; + w.visSteps = visSteps; + w.visFastSlow = visFastSlow; + w.visPauseRun = visPauseRun; + w.visBar = visBar; + w.barGrab = barGrab; + + // For internal use, we convert the given decay to the value we use + // for exponentiation. + w.decay = 1 + 1/decay; + + // Note the existence of this widget for the future. + WidgetManager.register(w); + + // This is a special case because it's an animiation, as with LoopWidget. + w.animID = setInterval(doAnimation,w.timeStep,w); + + // Before returning the widget, it must be drawn. + w.draw(ctx); + + return w; + } + + advanceFrame() : void { + + // This animation is open-ended; curStep grows without limit (up to + // E500 or whatever it is). + this.curStep += this.stepsPerFrame; + } + + draw(ctx : CanvasRenderingContext2D ) : void { + + // This is drawn with (0,0) at the lower-right corner. + if (this.hide === true) + return; + + // Don't draw the widget to a tikz file. + if (ctx instanceof CTX) + return; + + let saveT = ctx.getTransform(); + ctx.translate(this.widgetX,this.widgetY); + ctx.scale(this.scale,this.scale); + + var p = new Path2D(); + + if (this.visBar == true) + { + // These *could* be made accessible to the user, but we already + // have a heap of arguments to create this thing. + let indDotRadius = 5.0; + let barThick = 3.0; + let indDotThick = 2.0; + + // Draw the indicator bar and dot. + // Bar first. + p.moveTo(0,indDotRadius + OpenAnimWidget.ControlsHeight); + p.lineTo(this.barLength,indDotRadius + OpenAnimWidget.ControlsHeight); + + // Note this circle for reference by mouse events. + if (this.barGrab === true) + this.pBar = new Path2D(p); + + ctx.lineWidth = barThick; + + ctx.stroke(p); + + // Now the dot. + p = new Path2D(); + + let dx = Math.pow(this.decay,this.curStep); + + dx = this.barLength * (1 - 1/dx); + p.ellipse(dx,indDotRadius + OpenAnimWidget.ControlsHeight, + indDotRadius,indDotRadius,0,0,2*Math.PI); + + if (this.barGrab === true) + this.pDot = new Path2D(p); + + if (this.sDot == true) + ctx.fillStyle = OpenAnimWidget.sColor; + else + ctx.fillStyle = "red"; + + ctx.fill(p); + + ctx.lineWidth = indDotThick; + ctx.strokeStyle = "black"; + ctx.stroke(p); + } + + // There may be controls under the bar for faster/slower, pause/run + // and larger/smaller steps. Whatever of these is present, they should be + // centered, which is a pain. The total width of the pause/run controls + // is 40.5, obtained by checking the size of the box necessary to + // exactly enclose the controls. Height of that box is 14. I've set + // things up so that the height of the "step size" controls is also 14, + // and the width of that part is 32. These *could* be expressed in + // terms of the various constants defined below, but hard-coding is + // easier. + // These controls are drawn relative to their individual centers, + // so the shifting is done relative to those centers and their widths. + let pauseRunWidth = 40; + let stepsWidth = 32; + let intraGap = 8; + + // Here (compared to LoopWidget), I use cx and cy to shift the parts + // of the control down and right. The right-shift is used for centering + // Changing the t-matrix of ctx would work too. + let cy = OpenAnimWidget.BarHeight - 4; + let cx = this.barLength / 2; + + // We position the right bit, based on whether the left bit is present. + if (this.visSteps === true) + cx += intraGap + (pauseRunWidth / 2); + + // Used for both fast/slow "chevrons" and for up/down arrows. + let arrowHeight = 7; + + if (this.visFastSlow === true) + { + + // I made this a little tighter than for LoopWidget. + let arrowOffset = 12;//18; + let arrowDepth = 4; + let arrowPairSpace = 3; + let arrowThick = 1.25; + + // First, a pair of '>' for going faster. + ctx.lineWidth = arrowThick; + + if (this.sFaster == true) + ctx.strokeStyle = OpenAnimWidget.sColor; + else + ctx.strokeStyle = "black"; + + p = new Path2D(); + p.moveTo(cx + arrowOffset, cy); + p.lineTo(cx + arrowOffset + arrowDepth,cy + arrowHeight); + p.lineTo(cx + arrowOffset,cy + 2*arrowHeight); + ctx.stroke(p); + + // You can't just shift a path. Needs to be rebuilt. + p = new Path2D(); + p.moveTo(cx + arrowOffset + arrowPairSpace,cy); + p.lineTo(cx + arrowOffset + arrowDepth + arrowPairSpace, + cy + arrowHeight); + p.lineTo(cx + arrowOffset + arrowPairSpace,cy + 2*arrowHeight); + ctx.stroke(p); + + // A rectangle for the clickable area. + this.pFaster = new Path2D(); + this.pFaster.rect(cx + arrowOffset - arrowThick,cy, + arrowPairSpace + arrowDepth + 2*arrowThick,2*arrowHeight); + + /* + // BUG: testing + ctx.strokeStyle = "green"; + ctx.lineWidth = 0.5; + ctx.stroke(this.pFaster); + ctx.strokeStyle = "black"; + ctx.lineWidth = arrowThick; + */ + + // Same idea: '<' to go slower. + if (this.sSlower == true) + ctx.strokeStyle = LoopAnimWidget.sColor; + else + ctx.strokeStyle = "black"; + + p = new Path2D(); + p.moveTo(cx - arrowOffset,cy); + p.lineTo(cx - arrowOffset - arrowDepth,cy + arrowHeight); + p.lineTo(cx - arrowOffset,cy + 2*arrowHeight); + ctx.stroke(p); + + p = new Path2D(); + p.moveTo(cx - arrowOffset - arrowPairSpace,cy); + p.lineTo(cx - arrowOffset - arrowDepth - arrowPairSpace,cy + arrowHeight); + p.lineTo(cx - arrowOffset - arrowPairSpace,cy + 2*arrowHeight); + ctx.stroke(p); + + // And the clickable area. + this.pSlower = new Path2D(); + this.pSlower.rect(cx - arrowOffset - arrowPairSpace - arrowDepth - arrowThick, + cy,arrowPairSpace + arrowDepth + 2*arrowThick,2*arrowHeight); + + /* + // BUG: testing + ctx.strokeStyle = "yellow"; + ctx.lineWidth = 0.5; + ctx.stroke(this.pSlower); + ctx.strokeStyle = "black"; + */ + + /* + // BUG: test box around entire thing. + p = new Path2D(); + p.rect(cx - arrowOffset - arrowPairSpace - arrowDepth - arrowThick,cy, + 2*(arrowOffset + arrowPairSpace + arrowDepth + arrowThick), + 2*arrowHeight); + + //let temp = 2*(arrowOffset + arrowPairSpace + arrowDepth + arrowThick); + //console.log("val: " +temp); + + ctx.strokeStyle = "red"; + ctx.stroke(p); + */ + + ctx.strokeStyle = "black"; + } + + ctx.lineWidth = 1; + + if (this.visPauseRun === true) + { + // A || or triangle for pause or run. + + let pauseSpace = 3.25; + let pauseThick = 1.5; + let pauseHeight = 2 * arrowHeight; + + let runThick = 1.5; + let runLeftRight = 5; + let runHeight = 2 * arrowHeight; + + if (this.sPauseRun == true) + ctx.strokeStyle = LoopAnimWidget.sColor; + else + ctx.strokeStyle = "black"; + + if (this.aRunning === true) + { + // The animation is running, so show || to allow pausing. + ctx.lineWidth = pauseThick; + + p = new Path2D(); + p.moveTo(cx + pauseSpace,cy); + p.lineTo(cx + pauseSpace,cy + pauseHeight); + ctx.stroke(p); + + p = new Path2D(); + p.moveTo(cx - pauseSpace,cy); + p.lineTo(cx - pauseSpace,cy + pauseHeight); + ctx.stroke(p); + + ctx.lineWidth = 1; + } + else + { + // Animation is paused, so show triangle to run it again. + ctx.lineWidth = runThick; + + p = new Path2D(); + p.moveTo(cx - runLeftRight,cy); + p.lineTo(cx - runLeftRight,cy + runHeight); + p.lineTo(cx + runLeftRight,cy + runHeight/2); + p.closePath(); + ctx.stroke(p); + } + + // Either way (paused or running), we need the clickable area. + // This area is too generous for the "run" triangle, because + // I use the same rectangle for "pause" and "run," but no big deal. + this.pPauseRun = new Path2D(); + this.pPauseRun.rect(cx - runLeftRight - runThick, + cy - runThick,2*runLeftRight + 2*runThick,runHeight + 2*runThick); + + /* + // BUG: testing + ctx.strokeStyle = "blue"; + ctx.lineWidth = 0.5; + ctx.stroke(this.pPauseRun); + ctx.strokeStyle = "black"; + */ + } + + // And position the left controls, based on whether the ones on + // the right are present. + cy += 12.5; + cx = this.barLength / 2; + if ((this.visPauseRun === true) || (this.visFastSlow === true)) + cx -= intraGap + stepsWidth/2; + + ctx.lineWidth = 1; + + // Symbols for adjusting the step size. + if (this.visSteps === true) + { + // Up and down arrows. This is a little smaller than for LoopWidget, + // so that the height matches the paure/run controls. I also + // tightened up the spacing a bit. + // Note that I am also using stepThick as a proxy for adjustment + // of the fact that the tip of the arrow head is a little tall. + let stepSpace = 12; + let stepThick = 2.0; + let stepHeight = 12.5; + let stepArrowHeight = 5.0; + let stepArrowWidth = 4.0; + + ctx.lineWidth = stepThick; + + if (this.sDownStep === true) + { + ctx.strokeStyle = OpenAnimWidget.sColor; + ctx.fillStyle = OpenAnimWidget.sColor; + } + else + { + ctx.strokeStyle = "black"; + ctx.fillStyle = "black"; + } + + // Vertical line + p = new Path2D(); + p.moveTo(cx - stepSpace,cy); + p.lineTo(cx - stepSpace,cy- stepHeight); + ctx.stroke(p); + + // Arrow head + p = new Path2D(); + p.moveTo(cx - stepSpace,cy + stepThick); + p.lineTo(cx - stepSpace + stepArrowWidth,cy - stepArrowHeight + stepThick); + p.lineTo(cx - stepSpace - stepArrowWidth,cy - stepArrowHeight + stepThick); + p.closePath(); + ctx.fill(p); + + // Clickable area for down arrow. + this.pDownStep = new Path2D(); + this.pDownStep.rect(cx - stepSpace - stepArrowWidth,cy - stepHeight, + 2*stepArrowWidth,stepHeight + stepThick); + + /* + // BUG: testing + ctx.strokeStyle = "blue"; + ctx.lineWidth = 0.5; + ctx.stroke(this.pDownStep); + ctx.strokeStyle = "black"; + ctx.lineWidth = stepThick; + */ + + // Again, to the right, arrow head up. + if (this.sUpStep === true) + { + ctx.strokeStyle = OpenAnimWidget.sColor; + ctx.fillStyle = OpenAnimWidget.sColor; + } + else + { + ctx.strokeStyle = "black"; + ctx.fillStyle = "black"; + } + + p = new Path2D(); + p.moveTo(cx + stepSpace,cy + stepThick); + p.lineTo(cx + stepSpace,cy + stepThick - stepHeight); + ctx.stroke(p); + + p = new Path2D(); + p.moveTo(cx + stepSpace,cy - stepHeight); + p.lineTo(cx + stepSpace + stepArrowWidth, + cy - stepHeight + stepArrowHeight); + p.lineTo(cx + stepSpace - stepArrowWidth, + cy - stepHeight + stepArrowHeight); + p.closePath(); + ctx.fill(p); + + // Clickable area for up arrow. + this.pUpStep = new Path2D(); + this.pUpStep.rect(cx + stepSpace - stepArrowWidth, + cy - stepHeight, + 2*stepArrowWidth,stepHeight + stepThick); + + /* + // BUG: testing + ctx.strokeStyle = "blue"; + ctx.lineWidth = 0.5; + ctx.stroke(this.pUpStep); + ctx.strokeStyle = "black"; + */ + + ctx.lineWidth = 1; + + // A little step icon. + ctx.strokeStyle = "black"; + ctx.fillStyle = "black"; + stepThick = 1.5; + let stepSize = 5; + + ctx.lineWidth = stepThick; + + // Steps made as one path, starting at upper-left + p = new Path2D(); + p.moveTo(cx - stepSize,cy - 2*stepSize); + p.lineTo(cx,cy - 2*stepSize); + p.lineTo(cx,cy - stepSize); + p.lineTo(cx + stepSize,cy - stepSize); + p.lineTo(cx + stepSize,cy); + ctx.stroke(p); + + /* + // BUG: Testing box around it all. + p = new Path2D(); + p.rect(cx - stepSpace - stepArrowWidth,cy - stepHeight, + 2*(stepSpace + stepArrowWidth), + stepHeight + stepThick); + + ctx.strokeStyle = "red"; + ctx.lineWidth = 0.5; + ctx.stroke(p); + + //let temp = 2*(stepSpace + stepArrowWidth); + //let temp = stepHeight + stepThick; + //console.log("val: " + temp); + */ + + ctx.strokeStyle = "black"; + + } + + ctx.lineWidth = 1; + + ctx.setTransform(saveT); + } + + mouseDown( x : number , y : number ) : boolean { + + // (x,y) is given in coordinates relative to the owning figure. + // Return true iff these coordinates apply to this widget. + // BUG: This is almost identical to LoopWidget. DRY? + if (this.hide === true) + return false; + + // Adjust coordinates relative to what the draw() methods uses. + x -= this.widgetX; + y -= this.widgetY; + x /= this.scale; + y /= this.scale; + + // The widget also has a scale, which must be taken into account. + WidgetManager.bogusCtx.resetTransform(); + + // The run faster area. + if (this.pFaster !== null) + { + let isin = WidgetManager.bogusCtx.isPointInPath(this.pFaster,x,y); + if (isin === true) + { + this.sFaster = true; + WidgetManager.mouseOwner = this; + renderFrame(this); + return true; + } + } + + // The run slower area. + if (this.pSlower !== null) + { + let isin = WidgetManager.bogusCtx.isPointInPath(this.pSlower,x,y); + if (isin === true) + { + this.sSlower = true; + WidgetManager.mouseOwner = this; + renderFrame(this); + return true; + } + } + + + // The pause/run area. + if (this.pPauseRun !== null) + { + let isin = WidgetManager.bogusCtx.isPointInPath(this.pPauseRun,x,y); + if (isin === true) + { + this.sPauseRun = true; + WidgetManager.mouseOwner = this; + renderFrame(this); + return true; + } + } + + // The longer step area. + if (this.pUpStep !== null) + { + let isin = WidgetManager.bogusCtx.isPointInPath(this.pUpStep,x,y); + if (isin === true) + { + this.sUpStep = true; + WidgetManager.mouseOwner = this; + renderFrame(this); + return true; + } + } + + // The shorter step area. + if (this.pDownStep !== null) + { + let isin = WidgetManager.bogusCtx.isPointInPath(this.pDownStep,x,y); + if (isin === true) + { + this.sDownStep = true; + WidgetManager.mouseOwner = this; + renderFrame(this); + return true; + } + } + + // Last thing to check since it should be "underneath" everything, + // although I don't think there's the same kind of overlap that + // there was for LoopWidget. + if (this.pDot !== null) + { + // See whether the user clicked on or near the dot. He must click + // on the dot, not at some random point along the bar. + WidgetManager.bogusCtx.lineWidth = 2; + let isin = WidgetManager.bogusCtx.isPointInStroke(this.pDot,x,y); + if (isin === false) + isin = WidgetManager.bogusCtx.isPointInPath(this.pDot,x,y); + + if (isin === true) + { + this.sDot = true; + + // Move the dot (slightly) so that it is centered at (x,y). + // We don't actually "move the dot;" instead we adjust + // this.curStep to put the dot where we want it. That is, we + // invert g(s) = w (1-1/a^s). See the discussion, in register(), + // of this function. We have + // x = w (1 - 1/a^s) + // a^s = w / (w - x) + // s = log_a [ w / (w - x) ] + // And recall that log_a (z) = ln(z) / ln(a). + let ratio = this.barLength / (this.barLength - x); + let s = Math.log(ratio) / Math.log(this.decay); + if (s < 0) + s = 0; + this.curStep = s; + + WidgetManager.mouseOwner = this; + + renderFrame(this); + + return true; + } + } + + return false; + } + + mouseMove( x : number , y : number ) : void { + + // As above. + if (this.hide === true) + return; + + x -= this.widgetX; + y -= this.widgetY; + x /= this.scale; + y /= this.scale; + + WidgetManager.bogusCtx.resetTransform(); + + if (this.sDot === true) + { + // What I will do it check that the mouse is "close enough" to the + // bar, but it can be a long ways away. + WidgetManager.bogusCtx.lineWidth = 20; + let isin = WidgetManager.bogusCtx.isPointInStroke(this.pBar !,x,y); + + if (isin == false) + return; + + let ratio = this.barLength / (this.barLength - x); + let s = Math.log(ratio) / Math.log(this.decay); + if (s < 0) + s = 0; + this.curStep = s; + + // The appearance of the widget may have changed. + renderFrame(this); + } + + // BUG: I might (?) want colors to change based on what the mouse + // is over. See the BUG comment that goes with aRunning, sCircle, etc., + // at the top of the class. + } + + mouseUp( x : number , y : number ) : void { + + // As above. + if (this.hide === true) + return; + + x -= this.widgetX; + y -= this.widgetY; + x /= this.scale; + y /= this.scale; + WidgetManager.bogusCtx.resetTransform(); + + // The mouse is up, so nothing can remain selected. In most cases, + // releasing the mouse over the selected item means that something + // must be done since the "button" was properly pressed. + this.sDot = false; + + if (this.sFaster === true) + { + let isin = WidgetManager.bogusCtx.isPointInPath(this.pFaster ! ,x,y); + if (isin === true) + { + this.timeStep /= 1.4; + if (this.timeStep < 1) + this.timeStep = 1; + + // Stop the animation and restart it at the new speed + if (this.aRunning === true) + clearInterval(this.animID); + + this.animID = setInterval(doAnimation,this.timeStep,this); + this.aRunning = true; + } + + this.sFaster = false; + } + + if (this.sSlower === true) + { + // Just as above, but make it go slower. + let isin = WidgetManager.bogusCtx.isPointInPath(this.pSlower ! ,x,y); + if (isin === true) + { + this.timeStep *= 1.4; + + // More than a second per frame is silly. + if (this.timeStep > 1000) + this.timeStep = 1000; + + if (this.aRunning === true) + clearInterval(this.animID); + + this.animID = setInterval(doAnimation,this.timeStep,this); + this.aRunning = true; + } + + this.sSlower = false; + } + + if (this.sPauseRun) + { + // Did they *release* the mouse over the pause/run area? + let isin = WidgetManager.bogusCtx.isPointInPath(this.pPauseRun ! ,x,y); + if (isin === true) + { + // Start/stop the animation. + if (this.aRunning === true) + // Currently running. Pause it. + clearInterval(this.animID); + else + // Currently paused. Restart it. + this.animID = setInterval(doAnimation,this.timeStep,this); + + // Change the pause/run icon too. + if (this.aRunning === true) + this.aRunning = false; + else + this.aRunning = true; + } + + this.sPauseRun = false; + } + + if (this.sUpStep === true) + { + // Make the number of time increments per frame larger. + let isin = WidgetManager.bogusCtx.isPointInPath(this.pUpStep ! ,x,y); + if (isin === true) + { + // Use a smaller ratio here. Conceptually, it seems like + // this should be an integer, but it really doesn't have to be. + // Unlike LoopWidget, there's no upper limit on the number + // of steps per frame. + this.stepsPerFrame *= 1.25; + } + + this.sUpStep = false; + } + + if (this.sDownStep === true) + { + // As above. + let isin = WidgetManager.bogusCtx.isPointInPath(this.pDownStep ! ,x,y); + if (isin === true) + this.stepsPerFrame /= 1.25; + + this.sDownStep = false; + } + + // The appearance of the widget may have changed. + renderFrame(this); + } +} + + +// This is to allow dragging points around. After the much more complicated +// animation widgets, this is a lot easier. One difference is that dots +// are not drawn automatically; the user must call the widget's draw() +// method. This is because the order of drawing may matter -- what should +// be on top? +// +// At one point, this was more flexible, but -- see DraggableDrawWidget below +// -- it seems better to keep this widget simple (just dots). + +class DraggableDotWidget extends Widget { + + // When things are "selected," draw them in this color. + static sColor = "blue"; + + // The clickable area for the dot, and whether it is selected. + // pDot : Path2D | null = null; + pDot : FPath | null = null; + selected = false; + + // The default radius of a dot. + dotRadius = 3.0; + + + static register(ctx : CanvasRenderingContext2D , x : number , y : number , + name : string ) : DraggableDotWidget { + + // As with other widgets. Note that there is no scale since it doesn't + // make sense here. + let type = "DraggableDotWidget"; + let caller = getCaller(); + let w = WidgetManager.knownWidget(caller,type,name); + + if (w != null) + { + return w; + } + + // Got here, so this widget is not already known, and must be created. + // The 'false' here means that we always assume the dot is visible. Leting + // this be invisible would be pointless, although it is hidable by + // directly changing the Widget.hide field. + w = new DraggableDotWidget(caller,type,x,y,1.0,false,name); + + // Note the existence of this widget for the future. + WidgetManager.register(w); + + // Note that we do *not* draw this widget here. + + return w; + } + + draw(ctx : CanvasRenderingContext2D ) : void { + + // Because these widgets might be drawn for tikz output, this uses + // FPath instead of Path2D. + // + // BUG: As a rule, I don't think people will want most widgets (like + // LoopWidget) to be drawn to the paper version, but I suppose it should + // be possible. *I* would like it for documentation purposes. + + if (this.hide === true) + return; + + // Adjusting the coordinates this way feels a little weird, but it's + // how the other widgets work, and it's actually easier. + let saveT = ctx.getTransform(); + ctx.translate(this.widgetX,this.widgetY); + ctx.scale(this.scale,this.scale); + + // BUG: Somehow, sometimes using FPath puts hair on these dots?? + // It looks like it happens where the segments meet. There's probably + // some algebra mistake. Either that or the JS implementation of + // bezier curves sucks. For now, I do not use bezier curves, and + // leave it as an ellipse internally. + let p = new FPath(); + //let p = new Path2D(); + + let r = this.dotRadius; + + p.ellipse(0,0,r,r,0,0,2*Math.PI,true); + + //this.pDot = new Path2D(p); + // this.pDot = new FPath(p); + this.pDot = p; + + if (this.selected === true) + ctx.fillStyle = DraggableDotWidget.sColor; + else + ctx.fillStyle = "red"; + + ctx.fill(p); + + ctx.setTransform(saveT); + } + + mouseDown(x: number , y : number ) : boolean { + + if (this.hide === true) + return false; + + // Adjust coordinates relative to what the draw() methods uses. + x -= this.widgetX; + y -= this.widgetY; + x /= this.scale; + y /= this.scale; + + // The widget also has a scale, which must be taken into account. + WidgetManager.bogusCtx.resetTransform(); + + if (this.pDot !== null) + { + let isin = WidgetManager.bogusCtx.isPointInPath(this.pDot,x,y); + if (isin === true) + { + this.selected = true; + WidgetManager.mouseOwner = this; + renderFrame(this); + return true; + } + } + + return false; + } + + mouseMove(x : number , y : number ) : void { + + // As above. + if (this.hide === true) + return; + + // Restrict to be within the figure area, at the least. + // By default (the code here, not this.drawSelFcn, whatever that might + // do), the dot doesn't get "lost," but it can be drawn partially outside + // the figure proper, leaving "crumbs" that aren't erased until you + // scroll the page. + let wh = getFigureRect(this); + + // Rrestrict to the entire figure rectangle, tightened up a bit. + wh.w -= 2 * this.dotRadius; + wh.ha -= 2 * this.dotRadius; + wh.hb -= 2 * this.dotRadius; + + // Note the minus with hb. The "height below" is a postive value, but + // we are comparing to a potentially negative y. + if (x <= this.dotRadius) return; + if (x >= wh.w) return; + if (y <= -wh.hb) return; + if (y >= wh.ha) return; + + if (this.selected === true) + { + this.widgetX = x; + this.widgetY = y; + + renderFrame(this); + } + } + + mouseUp( x : number , y : number ) : void { + + if (this.hide === true) + return; + + // This is more round-about because of the possibility that the + // mouse-up occured outside the figure area. We need to reach + // the renderFrame() line whatever happens. + if (this.selected === true) + { + this.selected = false; + + let wh = getFigureRect(this); + + wh.w -= 2 * this.dotRadius; + wh.ha -= 2 * this.dotRadius; + wh.hb -= 2 * this.dotRadius; + + // Note the minus with hb. The "height below" is a postive value, but + // we are comparing to a potentially negative y. + if ((x > this.dotRadius) && (x < wh.w) && + // (y > this.dotRadius) && (y < wh.h)) + (y > -wh.hb) && (y < wh.ha)) + { + this.widgetX = x; + this.widgetY = y; + } + + renderFrame(this); + } + } +} + + +// This is almost idential to DraggableDotWidget, except that +// there is no default drawing behavior. The user must provide it. +// +// Maybe... +// * The user might want dots to be drawn in different ways, like a solid +// circle, an open circle with or without a fill of some other color, or +// even a square "dot." +// * The motion of the point, when dragging, needs to be restricted to a +// limited path or region. By default, the point is limited to the entire +// figure area, but that may not suffice. +// * To address the two previous points, register() takes functions +// for drawing the "dot" (which need not be a dot at all), depending +// on whether the dot is to be drawn in selected form or unselected form. + +type SimpleDrawFunction = (ctx : CanvasRenderingContext2D) => FPath; +type AcceptedDrawLocationFunction = (x : number, y : number, w : number , + ha : number , hb : number) => boolean; + +class DraggableDrawWidget extends Widget { + + // When things are "selected," draw them in this color. + static sColor = "blue"; + + // The clickable area for the "dot" (which could have any shape) + // and whether it is selected. + pDot : FPath | null = null; + selected = false; + + // The user must provide these functions. + // Note the litle cheat to make the ts compiler shut its yapper. + drawFcn : SimpleDrawFunction = null as any; + drawSelFcn : SimpleDrawFunction = null as any; + testPosFcn : AcceptedDrawLocationFunction = null as any; + + + static register(ctx : CanvasRenderingContext2D , x : number , y : number , + drawFcn : SimpleDrawFunction, drawSelFcn : SimpleDrawFunction, + testPosFcn : AcceptedDrawLocationFunction , name : string ) : DraggableDrawWidget{ + + // The drawFcn should be defined to draw whatevever it wants. + // It should take the ctx as the sole argument, and return a path + // such that a click in the path (using ctx.isPointInPath()). + // + // The drawSelFcn is similar, but is used for drawing when the + // item is selected -- so that the user can change the color or + // whatever. + // + // The testPosFcn receives (x,y) as an argument, along with the (w,h) + // of the figure area (in pdf points) and should return + // true (point is acceptable) or false (not acceptable). + + let type = "DraggableDrawWidget"; + let caller = getCaller(); + let w = WidgetManager.knownWidget(caller,type,name); + + if (w != null) + { + return w; + } + + // Got here, so this widget is not already known, and must be created. + // The 'false' here means that we always assume the dot is visible. Leting + // this be invisible would be pointless, although it is hidable by + // directly changing the Widget.hide field. + w = new DraggableDrawWidget(caller,type,x,y,1.0,false,name); + + w.drawFcn = drawFcn; + w.drawSelFcn = drawSelFcn; + w.testPosFcn = testPosFcn; + + // Note the existence of this widget for the future. + WidgetManager.register(w); + + return w; + } + + draw(ctx : CanvasRenderingContext2D ) : void { + + // Because these widgets might be drawn for tikz output, this uses + // FPath instead of Path2D. + // + // BUG: As a rule, I don't think people will want most widgets (like + // LoopWidget) to be drawn to the paper version, but I suppose it should + // be possible. *I* would like it for documentation purposes. + if (this.hide === true) + return; + + // Adjusting the coordinates this way feels a little weird, but it's + // how the other widgets work, and it's actually easier. + let saveT = ctx.getTransform(); + ctx.translate(this.widgetX,this.widgetY); + ctx.scale(this.scale,this.scale); + + if (this.selected === true) + this.pDot = this.drawSelFcn(ctx); + else + this.pDot = this.drawFcn(ctx); + + ctx.setTransform(saveT); + } + + mouseDown(x: number , y : number ) : boolean { + + //console.log("dot down"); + + if (this.hide === true) + return false; + + // Adjust coordinates relative to what the draw() methods uses. + x -= this.widgetX; + y -= this.widgetY; + x /= this.scale; + y /= this.scale; + + WidgetManager.bogusCtx.resetTransform(); + + if (this.pDot !== null) + { + let isin = WidgetManager.bogusCtx.isPointInPath(this.pDot,x,y); + if (isin === true) + { + this.selected = true; + WidgetManager.mouseOwner = this; + renderFrame(this); + return true; + } + } + + return false; + } + + mouseMove(x : number , y : number ) : void { + + // As above. + if (this.hide === true) + return; + + // Restrict to be within the figure area, at the least. + // By default (the code here, not this.drawSelFcn, whatever that might + // do), the dot doesn't get "lost," but it can be drawn partially outside + // the figure proper, leaving "crumbs" that aren't erased until you + // scroll the page. + let wh = getFigureRect(this); + + if (this.testPosFcn(x,y,wh.w,wh.ha,wh.hb) === false) + return; + + if (this.selected === true) + { + this.widgetX = x; + this.widgetY = y; + + renderFrame(this); + } + } + + mouseUp( x : number , y : number ) : void { + + if (this.hide === true) + return; + + // This is more round-about because of the possibility that the + // mouse-up occured outside the figure area. We need to reach + // the renderFrame() line whatever happens. + if (this.selected === true) + { + this.selected = false; + + let wh = getFigureRect(this); + + if (this.testPosFcn(x,y,wh.w,wh.ha,wh.hb) === true) + { + this.widgetX = x; + this.widgetY = y; + } + + renderFrame(this); + } + } + +} + + +// A numerical value widget. This uses an HTML +// thing by putting it on top of the canvas. Using HTML this way is not the +// direction I want to go, but I do want to see if it's feasible and what's +// involved. Certain widgets that already exist in HTML are probably not +// worth building from scratch as "pure canvas" widgets. Also, this +// allows me to delay dealing with keyboard events. +// +// The idea is to put the HTML widget on top of the canvas, with absolute +// placement. +// +// NOTE: Firefox generates a warning about "ansynchronous panning." Somehow +// it sees that I'm doing something tricky and warns about it. As far as I +// can tell, I'm not doing anything likely to be deprecated or problematic +// in the future. Edge doesn't complain. + +class NumberInputWidget extends Widget { + + // An HTML thing. This is the HTML DOM element, + // like from document.getElementById() or createElement("input"). + theWidget : HTMLInputElement | null = null; + + + static register( ctx : CanvasRenderingContext2D , x : number , y : number , + v : number , name : string ) : NumberInputWidget { + + // Even fewer arguments than usual. + // v = initial value; + let type = "NumberInputWidget"; + let caller = getCaller(); + let w = WidgetManager.knownWidget(caller,type,name); + + if (w != null) + { + w.draw(ctx); + return w; + } + + // Got here, so this widget is not already known, and must be created. + w = new NumberInputWidget(caller,type,x,y,1.0,false,name); + + // Note the existence of this widget for the future. + WidgetManager.register(w); + + // The usual syntax for this within HTML is + // + // There are some other possible settings too. See + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/number + // + // BUG: There's some funny business here that I don't like at all. + // These DOM elements are placed relative to the body as a whole, + // not the canvas. So, I need to know where the figure containing + // this widget falls on the entire document. The body height is adjusted + // when I tweak the scroll bars (mainbody.style.height and width), + // and the widget needs to be placed relative to that. + // + // As a result, every time the user zooms (or resizes the window), + // every widget placed in the DOM this way needs to be repositioned. + // One reasonable way to deal with that is to recalculate the position + // every time draw() is called. + // + // So this isn't really a BUG; it works as intended. But using + // HTML this way is an entirely different (and unpleasant) approach. + + w.theWidget = document.createElement("input"); + w.theWidget.setAttribute("type","number"); + w.theWidget.value = v.toString(); + + w.theWidget.style.position = "absolute"; + w.theWidget.style.display = "block"; + w.theWidget.style.left = 400+"px"; + w.theWidget.style.top = 900+"px"; + w.theWidget.style.width = 50+"px"; + w.theWidget.style.height = 10+"px"; + w.theWidget.style.zIndex = "99"; + + document.body.appendChild(w.theWidget); + + // The owner will want to redraw it's figure when this changes. + w.theWidget.onchange = function() { + + // console.log("change"); + + let myFunc : AugmentedDrawingFunction = getAugmentedFunction( w.owner ); + let fpc : FigurePanel = myFunc.figurePanelClass !; + fpc.render( ); + }; + + // Before returning the widget, it must be drawn. + w.draw(ctx); + + return w; + } + + getValue() { + + // Return the numerical value. + // + // BUG: This seems to return a string -- whatever is in the field. + // The caller will typically need to do parseInt() or parseFloat() + // on the result. If this widget were better, then it would know + // what it's supposed to return and limit the possible things it can hold. + + // BUG: I should probably have getter functions for everything in + // all widgets instead of having the user access fields directly. + // So, define LoopWidget.getCurStep(), etc. + // It doesn't *really* matter, but it makes it clearer to the user + // what he's supposed to have access to. + return this.theWidget ! .value; + } + + draw(ctx : CanvasRenderingContext2D ) : void { + + // This widget is really an HTML DOM element, so this function doesn't + // actually draw the widget. It repositions the element in the DOM. + // See the discussion in register(). + + // BUG: make accessing this information a function, like I did + // for getFigureRect(). + + let myFunc : AugmentedDrawingFunction = getAugmentedFunction( this.owner ); + let fpc : FigurePanel = myFunc.figurePanelClass !; + let totalV = fpc.totalV; + + // The vertical position is relatively easy, but the horizontal position + // requires a calculation similar to fullRender() since the page + // is centered. Also, the vertical position must be given in LH coordinates + // relative to the entire document. + // BUG: Make this calculation a function used in main.js too. + // Can I combine with what's done for mouse events there too? + // In fact, this is more like the mouse calculation than like fullRender(). + let vpos = fpc.totalV + fpc.h - this.widgetY; + // let vpos = totalV + this.widgetY; + + + // BUG: I don't like reaching into the DOM this way to get the canvas, + // but what is the alternative? + let hpos = this.widgetX; + let canvas = document.getElementById("pdf_renderer"); + let visWidth = document.documentElement.clientWidth; + let totWidth = FullPanel.getFullWidth(); + + if (visWidth > totWidth) + { + // No horizontal scroll bar. Center it. + let canvasCenter = canvas.width / 2; + let docCenter = FullPanel.getFullWidth() / 2; + canvasCenter = canvasCenter / PDFDocument.getZoom(); + + hpos = hpos + (canvasCenter - docCenter); + } + else + { + // Shift according to the horizontal scroll bar. + hpos = hpos - window.scrollX; + } + + hpos += fpc.margin; + + //console.log("wid at " +hpos+ " " +vpos); + + // Don't forget the stupid "px"! + this.theWidget ! .style.top = vpos +"px"; + this.theWidget ! .style.left = hpos +"px"; + } +} + +// Another DOM-based widget. A simple button. + +class ButtonWidget extends Widget { + + // An HTML thing. + theWidget : HTMLButtonElement | null = null; + + // Sometimes you want to treat the button as a boolean + // Each time the button is clicked, this toggles. + clickState = false; + + // And this is set to true whenever the button is clicked. + resetState = false; + + static register(ctx : CanvasRenderingContext2D , x : number , y : number , + text : string , name : string ) : Widget { + + // Even fewer arguments than usual. + let type = "ButtonWidget"; + let caller = getCaller(); + let w = WidgetManager.knownWidget(caller,type,name); + + if (w != null) + { + w.draw(ctx); + return w; + } + + // Got here, so this widget is not already known, and must be created. + w = new ButtonWidget(caller,type,x,y,1.0,false,name); + + // Note the existence of this widget for the future. + WidgetManager.register(w); + + // The usual syntax for this within HTML is + //