1
0
mirror of https://github.com/Pomax/BezierInfo-2.git synced 2025-08-26 09:44:32 +02:00

renamed graphics-element dir

This commit is contained in:
Pomax
2020-11-06 11:32:44 -08:00
parent 3288732350
commit 77284e1051
34 changed files with 25 additions and 25 deletions

View File

@@ -0,0 +1,428 @@
# The Graphics API
Graphics code is bootstrapped and drawn uses two "master" functions:
```js
setup() {
// initialisation code goes here
}
draw() {
// drawing code goes here
}
```
All code starts at `setup()`, automatically calling `draw()` after setup has been completed. Standard JS scoping applies, so any variable declared outside of `setup`/`draw` will be a "global" variable.
Note that neither of these functions are _required_: without a `setup()` function the code will just jump straight to `draw()`, and without a `draw()` function the code will simply not draw anything beyond the initial empty canvas.
## User-initiated events
### Touch/mouse events
Graphics code can react to touch/mouse, which can be handled using:
- `onMouseDown()` triggered by mouse/touch start events.
- `onMouseUp()` triggered by mouse/touch end events.
- `onMouseMove()` triggered by moving the mouse/your finger.
Mouse event data can be accessed via the `this.cursor` property, which encodes:
```
{
x: current event's screen x coordinate
y: current event's screen x coordinate
down: boolean signifying whether the cursor is engaged or not
mark: {x,y} coordinate object representing where mousedown occurred
last: {x,y} coordinate object representing where the cursor was "one event ago"
diff: {x,y} coordinate object representing the x/y difference between "now" and "one event ago",
with an additional `total` propert that is an {x,y} coordinate object representing the x/y
difference between "now" and the original mousedown event.
}
```
#### Example
```js
setup() {
this.defaultBgColor = this.bgColor = `green`;
}
draw() {
clear(this.bgColor);
}
onMouseDown() {
this.bgColor = `blue`;
redraw();
}
onMouseMove() {
this.bgColor = `red`;
redraw();
}
onMouseUp() {
this.bgColor = this.defaultBgColor;
redraw();
}
```
### Keyboard events
Graphics code can also react to keyboard events, although this is a great way to make sure your code won't work for mobile devices, so it's better to use range sliders to keep things accessible. That said, they can be handled using:
- `onKeyDown()` triggered by pressing a key
- `onKeyUp()` triggered by releasing a key
Keyboard event data can be accessed via the `this.keyboard` property, which encodes:
```
{
currentKey: the name of the key associated with the current event
}
```
Additionally, the `this.keyboard` property can be consulted for named keys to see if they are currently down or not, e.g. to check whether the up arrow is down or not:
```js
draw() {
if (this.keyboard[`w`] && this.keyboard[`d`]) {
// move up-left
}
}
```
#### Example
```js
setup() {
this.y = this.height/2;
}
draw() {
clear();
setColor(`black`);
rect(0, this.y-1, this.width, 3);
}
onKeyDown() {
const key = this.keyboard.currentKey;
if (key === `ArrowUp`) {
y -= 5
}
if (key === `ArrowDown`) {
y += 5;
}
y = constrain(y, 0, this.height);
redraw();
}
```
## Controllable parameters
Graphics code can be provided with outside values in two different ways.
### Using fixed startup parameters
Graphics code can be passed fixed values from HTML using data attributes:
```html
<graphics-element src="..." data-propname="somevalue"></graphics-element>
```
which can be access on the code side using
```js
this.parameters.propname;
```
Note that `this.parameters` is a protected object. Properties of the parameters object can be updated by your code, but you cannot reassign `this.parameters` itself.
### Using dynamic value sliders
Graphics code has also be provided with dynamic values by using range sliders. There are two ways to do this: purely in code, or by tying the graphics code to HTML sliders.
#### In code
If sliders may be dynamically required, the `addSlider` function can be used:
```js
setup() {
addSlider(`rangeValue`, 0, 1, 0.001, 0.5);
}
draw() {
console.log(this.rangeValue);
}
```
Its function signature is `addSlider(property name, min, max, step, initial value)`, in which the arguments represent:
- `property name` a propertyname string that may start with `!`. If no `!` is used, the property name should follow the rules for variable names, as the property will be exposed as `this.propertyname` (e.g. is you use `rangeValue`, then `this.rangeValue` will exis and be kept up to date by the slider logic). If `!` is used, no `this.propertyname` will be be set up for use in your code. Regardless of whether `!` is used or not, the property name will also be displayed in the slider's UI.
- `min` the minimum numerical value this variable will be able to take on
- `max` the meximum numerical value this variable will be able to take on
- `step` the value increase/decrease per step of the slider.
- `initial value` the value that the associated variable will be assigned as part of the `addSlider` call.
#### From HTML
You can also "presupply" a graphic with sliders, if you know your graphic has a fixed number of dynamic variables. This uses the standard HTML `<input type="range">` element:
```html
<graphics-element src="..." data-propname="somevalue">
<input type="range" min="0" max="1" step="0.001" value="0.5" class="my-slider">
</graphics-element>
```
With the graphic code using `setSlider` with a query selector to find your slider element and tie it to a variable:
```js
setup() {
setSlider(`.my-slider`, `rangeValue`, 0.5);
}
draw() {
console.log(this.rangeValue);
}
```
Its function signature is `setSlider(query selector, property name, initial value)`, in which the arguments represent:
- `query select` a CSS query selector for finding the right slider in your `<graphics-element>` tree. If you only have one slider then this query selector can simply be `input[type=range]`, but if you have multiple sliders it's a better idea to give each slider a CSS class that can be used to indentify it.
- `property name` a propertyname string that may start with `!`. If no `!` is used, the property name should follow the rules for variable names, as the property will be exposed as `this.propertyname` (e.g. is you use `rangeValue`, then `this.rangeValue` will exis and be kept up to date by the slider logic). If `!` is used, no `this.propertyname` will be be set up for use in your code. Regardless of whether `!` is used or not, the property name will also be displayed in the slider's UI.
- `initial value` the value that the associated variable will be assigned as part of the `addSlider` call.
Note that while it might seem that `<input>` elements can be made fully self-descriptive for both the property name (using the `name` attribute) and initial value (using the `value` attribute), this code still needs to do the right thing even in the absence of an HTML page, and so the property name and initial value are explicitly required.
**warning:** if you try to set up a slider for a property name that you have already defined, the code will throw a runtime error.
## Movable points
An important part of the Graphics API is showing shapes that are controlled or defined by coordinates, and as there are special functions for marking points as "movable" - that is, these points can be click/touch-dragged around a graphic. To fascilitate this, the following functions can be used:
- `setMovable(points, ...)` takes one or more arrays of points, and marks all points as "being movable", such that if the cursor activates at an x/y coordinate near one of these, that point gets assigned to `this.currentPoint`, as well as being automatically moved around as you drag the cursor around on the sketch.
- `resetMovable()` will clear the list of movable points.
- `resetMovable(points, ...)` is the same as calling `resetMovable()` followed by `setMovable(points, ...)`.
## The API
The following is the list of API functions that can be used to draw... whatever you like, really.
### Global constants
- `PI` 3.14159265358979
- `TAU` 6.28318530717958
- `POINTER` "default"
- `HAND` "pointer"
- `CROSS` "crosshair"
- `POLYGON` Shape.POLYGON, "Polygon"
- `CURVE` Shape.CURVE, "CatmullRom"
- `BEZIER` Shape.BEZIER, "Bezier"
- `CENTER` "center"
- `LEFT` "left"
- `RIGHT` "right"
### Instance propeties
- `this.width` the width of the graphic
- `this.height` the height of the graphic
- `this.frame` the current frame (i.e. the number of times `draw()` has run)
- `this.panelWidth` the width of a single panel in the graphic, only meaningful in conjunction with `setPanelWidth` (see below)
- `this.parameters` the collection of externally passed parameters (via HTML: `data-...` attributes, via JS: a key/value object)
- `this.cursor` represents the current mouse/touch cursor state
- `this.currentPoint` whatever point thec cursor is currently close enough to to interact with
- `this.keyboard` the current keyboard state
- `this.currentShape` the currently active shape. **warning:** this value gets reset any time `start()` is used, so it is recommended to cache the current shape using `saveShape()` instead of directly referencing `this.currentShape`.
### General functions
- `setSize(width,height)` explicitly resizes the canvas. **warning:** this will reset all color, transform, etc. properties to their default values.
- `setPanelCount(int)` use this in `setup()` to let the API know that this graphic is technically a number of "separate" panels of content, setting `this.panelWidth` to `width`/`panelcount`.
- `toDataURL()` returns the graphic as PNG image, encoded as a data URL.
- `find(qs)` find an HTML elements in the `<graphics-element>` DOM tree, using a query selector
- `findAll(qs)` find all HTML elements that match the provided querySelector. **note:** unlike the DOM API, this function returns a plain array.
### Maths functions
- `abs(v)` get the absolute value
- `approx(v1, v2, epsilon = 0.001)` check whether v1 differs from v2 by no more than `epsilon`
- `atan2(dy, dx)` [atan2](https://en.wikipedia.org/wiki/Atan2)
- `binomial(n, k)` get the binomial coefficient, i.e. "n choose k"
- `ceil(v)` round any fractional number up to the next highest interger
- `constrain(v, lowest, highest)` restrict a value in its lowest and highest value.
- `cos(v)` cosine
- `dist(x1, y1, x2, y2)` the euclidean distance between (x1,y1) and (x2,y2)
- `floor(v)` round any fractional number to an integer by discarding its fractional part
- `map(v, fromStart, fromEnd, toStrart, toEnd, constrain = false)` compute a value on an interval [fromStart,fromEnd] to its corresponding value on the interval [toStart,toEnd], with optional constraining to that new interval.
- `max(...v)` find the highest number in two or more numbers
- `min(...v)` find the lowest number in two or more numbers
- `random()` generate a random value between 0 (inclusive) and 1 (exclusive)
- `random(v)` generate a random value between 0 (inclusive) and `v` (exclusive)
- `random(a,b)` generate a random value between `a` (inclusive) and `b` (exclusive)
- `round(v)` round any fractional number by applying `ceil` for any number with fractional part >= 0.5, and `floor` for any number with fractional part < 0.5.
- `sin(v)` sine
- `sqrt(v)` square root
- `tan(v)` tangent
### Property functions
- `setBorder(width = 1, color = "black")` set the canvas border width and color
- `setColor(color)` set the color for both shape stroke and fill
- `noColor()` set both stroke and fill color to "transparent"
- `setCursor(type)` set the CSS cursor type. `POINTER`, `HAND`, and `CROSS` constants are provided, other values must be supplied as string.
- `setFill(color)` set the fill color
- `noFill()` set the fill color to "transparent"
- `setFont(font)` set the text font, using [CSS font syntax](https://developer.mozilla.org/en-US/docs/Web/CSS/font)
- `setFontFamily(name)` set the font to be used, by name
- `setFontSize(px)` set the font size in pixels
- `setFontWeight(val)` set the font weight in CSS weight units
- `setGrid(size, color)` set the background grid's spacing and line coloring
- `noGrid()` do not draw a background grid
- `setLineDash(...values)` set the interval values for [dashed lines](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/setLineDash)
- `noLineDash()` do not use line dashing for strokes
- `setShadow(color, px)` set the color and blur distance for drawing shape shadows
- `noShadow()` do not use shape shadows
- `setStroke(color)` set the stroke color
- `noStroke()` set the stroke color to "transparent"
- `setTextStroke(color, weight)` set the text outline stroke color and width (in pixels)
- `noTextStroke()` disable text outline
- `setWidth()` reset the stroke width to be 1 pixel wide
- `setWidth(width)` set the stroke width to a custom value, in pixels
For coloring purposes, there is also the `randomColor` function:
- `randomColor()` returns a random, full opaque CSS color
- `randomColor(opacity)` returns a random CSS color with the indicated opacity (in interval [0,1])
- `randomColor(opacity, cycle=false)` if the second parameter is explicitly set to `false`, the random seed used to generate the random color will not be updated, and the resulting random color will be the same as when the function was previously called.
For temporary work, where you might want to change some properties and then revert to the previous state, there are two functions available:
- `save()` cache the current collection of properties. This uses a stack, with each call adding a new "snapshot" on the stack.
- `restore()` restore the most recently cached state from the stack.
### Coordinate transform function
- `rotate(angle)` rotate the coordinate system by `angle` (clockwise, in radians)
- `scale(x, y)` scale the coordinate system by a factor of `x` horizontally, and `y` vertically
- `translate(x, y)` move the coordinate system by `x` units horizontally, and `y` units vertically
- `resetTransform()` reset the coordinate system to its default values
- `transform(a,b,c,d,e,f)` transform the coordinate system by applying a transformation matrix to it. This matrix has the form:
```
| a b c |
m = | d e f |
| 0 0 1 |
```
**note:** all transforms are with respect to (0,0) irrespective of where (0,0) has been moved to through successive transforms.
In addition to transformations, there are also two functions to allow you to map screen coordinates (e.g. cursor position) to their corresponding transformed coordinate, and vice versa, to allow for drawing/computing points in either coordinate system
- `screenToWorld(x, y)` converts a screen coordinate (x,y) to its corresponding transformed coordinate system coordinate (x',y')
- `worldToScreen(x, y)` converts a transformed coordinate system coordinate (x,y) to its corresponding screen coordinate (x', y')
### Drawing functions
- `arc(x, y, r, s, e)` draw a section of outline for a circle with radius `r` centered on (x,y), starting at angle `s` and ending at angle `e`.
- `circle(x, y, r)` draw a circle at (x,y) with radius `r`
- `clear(color="white", preserveTransforms=false)` clears the graphics to a specific CSS background color, resetting the transform by default.
- `drawAxes(hlabel, hs, he, vlabel, vs, ve, w, h)` draw a set of labelled axes, using `{hlabel, hs, he}` as horizontal label, with start marker `hs` and end marker `he`, and using `{vlabel, vs, ve}` as vertical label, with start marker `vs` and end marker `ve`
- `drawGrid(division = 20)` draw a grid with the specified spacing, colored using the current stroke color
- `drawShape(...shapes)` draw one or more saved full shapes (see below)
- `image(img, x = 0, y = 0, w = auto, h = auto)` draw an image at some (x,y) coordinate, defaulting to (0,0), scaled to some width and height, defaulting to the native image dimensions
- `line(x1, y1, x2, y2)` draw a line from (x1,y1) to (x2,y2)
- `plot(fn, start = 0, end = 1, steps = 24, xscale = 1, yscale = 1)` plot a function defined by `fn` (which must take a single numerical input, and output an object of the form `{x:..., y:...}`) for inputs in the interval [start,end], at a resolution of `steps` steps, with the resulting values scaled by `xscale` horizontally, and `yscale` vertically. This function returns the plot as a `Shape` for later reuse.
- `point(x, y)` draw a single point at (x,y)
- `rect(x, y, w, h)` draw a rectangle from (x,y) with width `w` and height `h`
- `redraw()` triggers a new draw loop. Use this instead of calling `draw()` directly when you wish to draw a new frame as part of event handling.
- `text(str, x, y, alignment = LEFT)` place text, colored by the fill color, anchored to (x,y), with the type of anchoring determined by `alignemtn`. The alignment constants `LEFT`, `RIGHT`, and `CENTER` are available.
- `wedge(x, y, r, s, e)` similar to arc, but draw a full wedge
#### Shape drawing functions
- `start(type = POLYGON, factor)` set up a new `Shape` as `this.currentShape` in preparation for receiving data. Types can be `POLYGON` (default), `CURVE` (Catmull-Rom), or `BEZIER`. If `CURVE` is specified, the `factor` indicates how loose or tight the resulting Catmull-Rom curve will be.
- `segment(type, factor)` set up a new section of the shape. If left unspecified the `type` and `factor` are inherited from the current `Shape`.
- `vertex(x, y)` add a point to the current `Shape`'s current segment.
- `end(close = false)` draw the current `Shape`
- `saveShape()` returns the current shape for later reuse
## Built-in object types
### Shape
The `Shape` class reprents a drawable shape consisting of one or more polygonal, Catmull-Rom curve, or Bezier curve segments. It has a minimal API:
- **`new Shape(type, factor, points = [])`** construct a new `Shape` of the specified `type` (options are `Shape.POLYGON`, `Shape.CURVE` for Catmull-Rom curves, and `Shape.BEZIER` for Bezier curves), with the specified `factor` (used for Catmull-Rom curve tightness), and optional list of points to prepopulate the first shape segment.
- `merge(other)` merge another `Shape`'s segments into this `Shape`
- `copy()` returns a copy of this `Shape`
- `newSegment(type, factor)` start a new segment in this `Shape` of the indicated type, with the indicated tightness factor.
- `vertex(p)` add an on-shape coordinate (x,y) to this `Shape`. How this vertex contributes to the overall shape drawing depends on the current segment type.
### Vector
The `Vector` class represents a 2d/3d coordinate, with a minimal standard API:
- **`new Vector(x,y,z?) / new Vector({x:,y:,z:}`** construct a new `Vector`
- `vector.dist(other, y, z = 0)` calculate the distance to some other vector-as-coordinate
- `vector.normalize(f)` return a new `Vector` representing a scaled copy of this vector, with length 1.0
- `vector.getAngle()` get the angle between this vector and the x-axis.
- `vector.reflect(other)` reflect this vector-as-coordinate over the line that some other vector lies on
- `vector.add(other)` return a new `vector` representing the addition of some other vector to this vector
- `vector.subtract(other)` return a new `vector` representing the subtraction of some other vector to this vector
- `vector.scale(f = 1)` return a new `vector` representing the scaled version of this vector
### Matrix
The `Matrix` class represents an `N`x`M` matrix, with minimal standard API:
- **`new Matrix(n,m,data?)`** construct a new `Matrix`. If `data` is provided, the matrix will be filled with that. **warning:** `data` is assumed to be `n` x `m`, but is **not** validated.
- `setData(data)` **warning:** `data` is assumed to be `n` x `m`, but is **not** validated.
- `get(i, j)` get the value at row `i` and column `j`
- `set(i, j, value)` set the value at row `i` and column `j`
- `row(i)` get the entire `i`th row as an array of values
- `col(j)` get the entire `j`th column as a (flat) array of values
- `multiply(other)` return a new `Matrix` representing the right-multiplication of this matrix with some other matrix
- `invert()` return a new `Matrix` representing the inverse of this matrix, or `undefined` if no inverse exists
- `transpose()` return a new `Matrix` representing the transpose of this matrix
...
### Bezier
The `Bezier` class is an instance of [bezier.js](https://pomax.github.io/bezierjs/) with all its API functions, extended for use on the canvas:
- static `defaultQuadratic(apiInstance)` returns a new quadratic `Bezier` with preset coordinate values tailored to the Primer on Bezier Curves. The `apiInstance` must be a reference to a valid Graphics-API instance (typically thiat will simply be `this` in your code).
- static `defaultCubic(apiInstance)` returns a new cubic `Bezier` with preset coordinate values tailored to the Primer on Bezier Curves. The `apiInstance` must be a reference to a valid Graphics-API instance (typically thiat will simply be `this` in your code).
- static `fitCurveToPoints(apiInstance, points, tvalues)` returns a new `n`-dimensional `Bezier` that has been fit to the provided list of points, constrained to the provided `tvalues`, using MSE polygonal curve fitting. The `apiInstance` must be a reference to a valid Graphics-API instance (typically thiat will simply be `this` in your code).
The extended API in addition to these static functions are:
- **`new Bezier(apiInstance, ...coords)`** construct a new `Bezier` curve controlled by three or more points, either supplied as numerical arguments, or as point objects `{x:..., y:..., z?:...}` where `z` is optional. The `apiInstance` must be a reference to a valid Graphics-API instance (typically thiat will simply be `this` in your code).
- `project(x, y)` returns an `{x:..., y:...}` object representing the projection of some point (x,y) onto this curve.
- `getPointNear(x, y, d = 5)` returns either `undefined`, or one of the `Bezier` curve's control points if the specified (x,y) coordinate is `d` or fewer pixels from a control point.
- `drawCurve(color = #333)` draws this curve on the canvas, with optional custom color
- `drawPoints(labels = true)` draws the curve's control points, with optional coordinate labels (defaulting to true)
- `drawSkeleton(color = #555)` draws this curve's coordinate polygon, with optional custom color
- `getStrutPoints(t)` get the list of points obtained through "de Casteljau" interpolation, for a given `t` value
- `drawStruts(t, color = "black", showpoints = true)` draws this curve's "de Casteljau" points and lines, for a given `t` value, with optional custom color, and optional omission of the points if only the lines are required.
- `drawBoundingBox(color = "black")` draw the axis-aligned bounding box for this `Bezier` curve with optional custom color
### BSpline
The `BSpline` class represents a generaic [B-spline](https://en.wikipedia.org/wiki/B-spline) curve, with a minimal API:
- **`new BSpline(apiInstance, points)`** constructs a B-spline controlled by a points array in which each element may be either of the form `{x: ..., y: ...}` or a numerical tuple `[x,y]`. The `apiInstance` must be a reference to a valid Graphics-API instance (typically thiat will simply be `this` in your code).
- `getLUT(count)` returns an array of `count` coordinates of the form `{x:...,y:...}`, representing a polygonal approximation of this `BSpline`.
- `formKnots(open = false)` set-and-return the list of B-spline knots using either the standard uniform interval spacing, or if `open` is set to `true`, special spacing to effect a B-spline that start and ends at the actual start and end coordinates. The knot array returned in this fashion is a live array, and updating its values **will** change the B-spline's shape.
- `formUniformKnots()` set-and-return uniformaly spaced knot values. The knot array returned in this fashion is a live array, and updating its values **will** change the B-spline's shape.
- `formWeights()` set-and-return the array of weights. These will all be uniformly initialized to 1, with the weight array returned being a live array, so that updating its values will change the B-spline's shape.

View File

@@ -0,0 +1,263 @@
/**
* The base API class, responsible for such things as setting up canvas event
* handling, method accumulation, custom element binding, etc. etc.
*
* Basically if it's not part of the "public" API, it's in this class.
*/
class BaseAPI {
static get privateMethods() {
return [`constructor`, `createHatchPatterns`, `addListeners`, `getCursorCoords`].concat(this.superCallers).concat(this.eventHandlers);
}
static get superCallers() {
return [`setup`, `draw`];
}
static get eventHandlers() {
return [`onMouseDown`, `onMouseMove`, `onMouseUp`, `onKeyDown`, `onKeyUp`];
}
static get methods() {
const priv = this.privateMethods;
const names = Object.getOwnPropertyNames(this.prototype).concat([`setSize`, `redraw`]);
return names.filter((v) => priv.indexOf(v) < 0);
}
/**
*
*/
constructor(
uid,
width = 200,
height = 200,
canvasBuildFunction, // Only used during image generation, not used in the browser
customDataSet // " "
) {
if (uid) {
this.element = window[uid];
delete window[uid];
this.dataset = this.element.dataset;
}
if (canvasBuildFunction) {
const { canvas, ctx } = canvasBuildFunction(width, height);
this.canvas = canvas;
this.ctx = ctx;
this.preSized = true;
} else {
this.canvas = document.createElement(`canvas`);
}
if (!this.dataset) {
if (customDataSet) {
this.dataset = customDataSet;
} else {
this.dataset = {};
}
}
Object.defineProperty(this, `parameters`, {
writable: false,
configurable: false,
value: Object.fromEntries(
Object.entries(this.dataset)
.map((pair) => {
let name = pair[0];
let v = pair[1];
if (v === `null` || v === `undefined`) return [];
if (v === `true`) return [name, true];
else if (v === `false`) return [name, false];
else {
let d = parseFloat(v);
// Use == to evaluate "is this a string number"
if (v == d) return [name, d];
}
return [name, v];
})
.filter((v) => v.length)
),
});
this.addListeners();
this.setSize(width, height);
this.currentPoint = false;
this.frame = 0;
this.setup();
this.draw();
}
/**
*
*/
addListeners() {
const canvas = this.canvas;
if (this.element) {
this.element.setGraphic(this);
}
this.cursor = {};
const root = typeof document !== "undefined" ? document : canvas;
[`touchstart`, `mousedown`].forEach((evtName) => canvas.addEventListener(evtName, (evt) => this.onMouseDown(evt)));
[`touchmove`, `mousemove`].forEach((evtName) =>
canvas.addEventListener(evtName, (evt) => {
this.onMouseMove(evt);
// Force a redraw only if there are movable points,
// and there is a current point bound, but only if
// the subclass didn't already call redraw() as part
// of its own mouseMove handling.
if (this.movable.length && this.currentPoint && !this.redrawing) {
this.redraw();
}
})
);
[`touchend`, `mouseup`].forEach((evtName) => root.addEventListener(evtName, (evt) => this.onMouseUp(evt)));
this.keyboard = {};
[`keydown`].forEach((evtName) => canvas.addEventListener(evtName, (evt) => this.onKeyDown(evt)));
[`keyup`].forEach((evtName) => canvas.addEventListener(evtName, (evt) => this.onKeyUp(evt)));
}
stopEvent(evt) {
if (evt.target === this.canvas) {
evt.preventDefault();
evt.stopPropagation();
}
}
/**
*
*/
getCursorCoords(evt) {
const left = evt.target.offsetLeft,
top = evt.target.offsetTop;
let x, y;
if (evt.targetTouches) {
const touch = evt.targetTouches;
for (let i = 0; i < touch.length; i++) {
if (!touch[i] || !touch[i].pageX) continue;
x = touch[i].pageX - left;
y = touch[i].pageY - top;
}
} else {
x = evt.pageX - left;
y = evt.pageY - top;
}
this.cursor.x = x;
this.cursor.y = y;
}
/**
*
*/
onMouseDown(evt) {
this.stopEvent(evt);
this.cursor.down = true;
this.getCursorCoords(evt);
}
/**
*
*/
onMouseMove(evt) {
this.stopEvent(evt);
this.cursor.move = true;
this.getCursorCoords(evt);
}
/**
*
*/
onMouseUp(evt) {
this.stopEvent(evt);
this.cursor.down = false;
this.cursor.move = false;
}
/**
*
*/
safelyInterceptKey(evt) {
// We don't want to interfere with the browser, so we're only
// going to allow unmodified keys, or shift-modified keys,
// and tab has to always work. For obvious reasons.
const tab = evt.key !== "Tab";
const functionKey = evt.key.match(/F\d+/) === null;
const specificCheck = tab && functionKey;
if (!evt.altKey && !evt.ctrlKey && !evt.metaKey && specificCheck) {
this.stopEvent(evt);
}
}
/**
*
*/
onKeyDown(evt) {
this.safelyInterceptKey(evt);
// FIXME: Known bug: this approach means that "shift + r + !shift + !r" leaves "R" set to true
this.keyboard[evt.key] = true;
this.keyboard.currentKey = evt.key;
}
/**
*
*/
onKeyUp(evt) {
this.safelyInterceptKey(evt);
// FIXME: Known bug: this approach means that "shift + r + !shift + !r" leaves "R" set to true
this.keyboard[evt.key] = false;
this.keyboard.currentKey = evt.key;
}
/**
* This function is critical in correctly sizing the canvas.
*/
setSize(w, h) {
this.width = w || this.width;
this.height = h || this.height;
if (!this.preSized) {
this.canvas.width = this.width;
this.canvas.style.width = `${this.width}px`;
this.canvas.height = this.height;
this.ctx = this.canvas.getContext(`2d`);
}
}
/**
* This is the main entry point.
*/
setup() {
// console.log(`setup`);
this.movable = [];
this.font = {
size: 10,
weight: 400,
family: `arial`,
};
}
/**
* This is the draw (loop) function.
*/
draw() {
this.frame++;
// console.log(`draw`);
}
/**
* This is mostly a safety function, to
* prevent direct calls to draw().. it might
* disappear.
*/
redraw() {
this.redrawing = true;
this.draw();
this.redrawing = false;
}
}
export { BaseAPI };

View File

@@ -0,0 +1,816 @@
import { enrich } from "../lib/enrich.js";
import { Bezier } from "./types/bezier.js";
import { BSpline } from "./types/bspline.js";
import { Vector } from "./types/vector.js";
import { Matrix } from "./types/matrix.js";
import { Shape } from "./util/shape.js";
import binomial from "./util/binomial.js";
import { BaseAPI } from "./base-api.js";
import impartSliderLogic from "./impart-slider-logic.js";
const MOUSE_PRECISION_ZONE = 5;
const TOUCH_PRECISION_ZONE = 30;
let CURRENT_HUE = 0;
let CURRENT_CURSOR = `pointer`;
/**
* Our Graphics API, which is the "public" side of the API.
*/
class GraphicsAPI extends BaseAPI {
static get constants() {
return [`POINTER`, `HAND`, `PI`, `TAU`, `POLYGON`, `CURVE`, `BEZIER`, `CENTER`, `LEFT`, `RIGHT`];
}
draw() {
CURRENT_HUE = 0;
super.draw();
}
get PI() {
return 3.14159265358979;
}
get TAU() {
return 6.28318530717958;
}
get POINTER() {
return `default`;
}
get HAND() {
return `pointer`;
}
get CROSS() {
return `crosshair`;
}
get POLYGON() {
return Shape.POLYGON;
}
get CURVE() {
return Shape.CURVE;
}
get BEZIER() {
return Shape.BEZIER;
}
get CENTER() {
return `center`;
}
get LEFT() {
return `left`;
}
get RIGHT() {
return `right`;
}
onMouseDown(evt) {
super.onMouseDown(evt);
// mark this position as "when the cursor came down"
this.cursor.mark = { x: this.cursor.x, y: this.cursor.y };
// as well as for "what it was the previous cursor event"
this.cursor.last = { x: this.cursor.x, y: this.cursor.y };
const cdist = evt.targetTouches ? TOUCH_PRECISION_ZONE : MOUSE_PRECISION_ZONE;
for (let i = 0, e = this.movable.length, p, d; i < e; i++) {
p = this.movable[i];
d = new Vector(p).dist(this.cursor);
if (d <= cdist) {
this.currentPoint = p;
break;
}
}
}
onMouseMove(evt) {
super.onMouseMove(evt);
if (this.cursor.down) {
// If we're click-dragging, or touch-moving, update the
// "since last event" as well as "compared to initial event"
// cursor positional differences:
this.cursor.diff = {
x: this.cursor.x - this.cursor.last.x,
y: this.cursor.y - this.cursor.last.y,
total: {
x: this.cursor.x - this.cursor.mark.x,
y: this.cursor.y - this.cursor.mark.y,
},
};
this.cursor.last = { x: this.cursor.x, y: this.cursor.y };
}
// Are we dragging a movable point around?
if (this.currentPoint) {
this.currentPoint.x = this.cursor.x;
this.currentPoint.y = this.cursor.y;
} else {
for (let i = 0, e = this.movable.length, p; i < e; i++) {
p = this.movable[i];
if (new Vector(p).dist(this.cursor) <= 5) {
if (this.canvas.style.cursor !== `none`) {
this.setCursor(this.HAND);
}
return; // NOTE: this is a return, not a break!
}
}
if (this.canvas.style.cursor !== `none`) {
this.setCursor(this.POINTER);
}
}
}
onMouseUp(evt) {
super.onMouseUp(evt);
delete this.cursor.mark;
delete this.cursor.last;
delete this.cursor.diff;
this.currentPoint = false;
}
setup() {
super.setup();
this.setGrid(20, `#F0F0F0`);
}
resetMovable(...allpoints) {
this.movable.splice(0, this.movable.length);
if (allpoints) this.setMovable(...allpoints);
}
setMovable(...allpoints) {
allpoints.forEach((points) => points.forEach((p) => this.movable.push(p)));
}
/**
* Multi-panel graphics: set panel count
*/
setPanelCount(c) {
this.panelWidth = this.width / c;
}
/**
* Multi-panel graphics: set up (0,0) to the next panel's start
*/
nextPanel(color = `black`) {
this.translate(this.panelWidth, 0);
this.setStroke(color);
this.line(0, 0, 0, this.height);
}
/**
* Convert the canvas to an image
*/
toDataURL() {
return this.canvas.toDataURL();
}
/**
* Draw an image onto the canvas
*/
image(img, x = 0, y = 0, w, h) {
this.ctx.drawImage(img, x, y, w || img.width, h || img.height);
}
/**
* transforms: translate
*/
translate(x, y) {
this.ctx.translate(x, y);
}
/**
* transforms: rotate
*/
rotate(angle) {
this.ctx.rotate(angle);
}
/**
* transforms: scale
*/
scale(x, y) {
y = y ? y : x; // NOTE: this turns y=0 into y=x, which is fine. Scaling by 0 is really silly =)
this.ctx.scale(x, y);
}
/**
* transforms: universal free transform based on applying
*
* | a b c |
* m = | d e f |
* | 0 0 1 |
*/
transform(a, b, c, d, e, f) {
this.ctx.transform(a, b, c, d, e, f);
}
/**
* transforms: screen to world
*/
screenToWorld(x, y) {
if (y === undefined) {
y = x.y;
x = x.x;
}
let M = this.ctx.getTransform().invertSelf();
let ret = {
x: x * M.a + y * M.c + M.e,
y: x * M.b + y * M.d + M.f,
};
return ret;
}
/**
* transforms: world to screen
*/
worldToScreen(x, y) {
if (y === undefined) {
y = x.y;
x = x.x;
}
let M = this.ctx.getTransform();
let ret = {
x: x * M.a + y * M.c + M.e,
y: x * M.b + y * M.d + M.f,
};
return ret;
}
/**
* transforms: reset
*/
resetTransform() {
this.ctx.resetTransform();
}
/**
* custom element scoped querySelector
*/
find(qs) {
if (!this.element) return false;
return enrich(this.element.querySelector(qs));
}
/**
* custom element scoped querySelectorAll
*/
findAll(qs) {
if (!this.element) return false;
return Array.from(this.element.querySelectorAll(qs)).map((e) => enrich(e));
}
/**
* Set a (CSS) border on the canvas
*/
setBorder(width = 1, color = `black`) {
if (width === false) {
this.canvas.style.border = `none`;
} else {
this.canvas.style.border = `${width}px solid ${color}`;
}
}
/**
* Set a (CSS) margin around the canvas
*/
setMargin(width = 0) {
this.canvas.style.marginTop = `${width}px`;
this.canvas.style.marginBottom = `${width}px`;
}
/**
* Hide the cursor in a way that we can restore later.
*/
hideCursor() {
this.canvas.style.cursor = `none`;
}
/**
* Rebind the cursor to what it should be.
*/
showCursor() {
this.canvas.style.cursor = CURRENT_CURSOR;
}
/**
* Set the cursor type while the cursor is over the canvas
*/
setCursor(type) {
CURRENT_CURSOR = type;
this.showCursor();
}
/**
* Get a random color
*/
randomColor(a = 1.0, cycle = true) {
if (cycle) CURRENT_HUE = (CURRENT_HUE + 73) % 360;
return `hsla(${CURRENT_HUE},50%,50%,${a})`;
}
/**
* Set the context fillStyle
*/
setFill(color) {
if (color === false) color = `transparent`;
this.ctx.fillStyle = color;
}
/**
* Convenience alias
*/
noFill() {
this.setFill(false);
}
/**
* Set the context strokeStyle
*/
setStroke(color) {
if (color === false) color = `transparent`;
this.ctx.strokeStyle = color;
}
/**
* Convenience alias
*/
noStroke() {
this.setStroke(false);
}
/**
* stroke + fill
*/
setColor(color) {
this.setFill(color);
this.setStroke(color);
}
/**
* no stroke/fill
*/
noColor() {
this.noFill();
this.noStroke();
}
/**
* Set a text stroke/color
*/
setTextStroke(color, weight) {
this.textStroke = color;
this.strokeWeight = weight;
}
/**
* Do not use text stroking.
*/
noTextStroke() {
this.textStroke = false;
this.strokeWeight = false;
}
/**
* Set the line-dash pattern
*/
setLineDash(...values) {
this.ctx.setLineDash(values);
}
/**
* disable line-dash
*/
noLineDash() {
this.ctx.setLineDash([]);
}
/**
* Set the context lineWidth
*/
setWidth(width = 1) {
this.ctx.lineWidth = width;
}
/**
* Set the font size
*/
setFontSize(px) {
this.font.size = px;
this.setFont();
}
/**
* Set the font weight (CSS name/number)
*/
setFontWeight(val) {
this.font.weight = val;
this.setFont();
}
/**
* Set the font family by name
*/
setFontFamily(name) {
this.font.family = name;
this.setFont();
}
setFont(font) {
font = font || `${this.font.weight} ${this.font.size}px ${this.font.family}`;
this.ctx.font = font;
}
/**
* Set text shadow
*/
setShadow(color, px) {
this.ctx.shadowColor = color;
this.ctx.shadowBlur = px;
}
/**
* Disable text shadow
*/
noShadow() {
this.ctx.shadowColor = `transparent`;
this.ctx.shadowBlur = 0;
}
/**
* Save the current state/properties on a stack
*/
save() {
this.ctx.save();
}
/**
* Restore the most recently saved state/properties from the stack
*/
restore() {
this.ctx.restore();
}
/**
* set the default grid size and color
* @param {*} size
* @param {*} color
*/
setGrid(size, color) {
this._gridParams = { size, color };
}
/**
* disable drawing a grid when clearing.
*/
noGrid() {
this._gridParams = false;
}
/**
* Reset the canvas bitmap to a uniform color.
*/
clear(color = `white`, preserveTransforms = false) {
this.save();
this.resetTransform();
this.ctx.fillStyle = color;
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.restore();
if (!preserveTransforms) this.resetTransform();
if (this._gridParams) {
this.save();
this.setStroke(this._gridParams.color);
this.translate(0.5, 0.5);
this.drawGrid(this._gridParams.size);
this.translate(-0.5, -0.5);
this.restore();
}
}
/**
* Draw a Point (or {x,y,z?} conformant) object on the canvas
*/
point(x, y) {
this.circle(x, y, 5);
}
/**
* Draw a line between two Points
*/
line(x1, y1, x2, y2) {
this.ctx.beginPath();
this.ctx.moveTo(x1, y1);
this.ctx.lineTo(x2, y2);
this.ctx.stroke();
}
/**
* Draw a circle
*/
circle(x, y, r) {
this.arc(x, y, r, 0, this.TAU);
}
/**
* Draw a circular arc
*/
arc(x, y, r, s, e, wedge = false) {
this.ctx.beginPath();
if (wedge) this.ctx.moveTo(x, y);
this.ctx.arc(x, y, r, s, e);
if (wedge) this.ctx.moveTo(x, y);
this.ctx.fill();
this.ctx.stroke();
}
/**
* Draw a circular wedge
*/
wedge(x, y, r, s, e) {
this.arc(x, y, r, s, e, true);
}
/**
* Draw text on the canvas
*/
text(str, x, y, alignment) {
if (y === undefined) {
y = x.y;
x = x.x;
}
const ctx = this.ctx;
ctx.save();
if (alignment) {
ctx.textAlign = alignment;
}
if (this.textStroke) {
this.ctx.lineWidth = this.strokeWeight;
this.setStroke(this.textStroke);
this.ctx.strokeText(str, x, y);
}
this.ctx.fillText(str, x, y);
ctx.restore();
}
/**
* Draw a rectangle start with {p} in the upper left
*/
rect(x, y, w, h) {
this.ctx.fillRect(x, y, w, h);
this.ctx.strokeRect(x, y, w, h);
}
/**
* Draw a function plot from [start] to [end] in [steps] steps.
* Returns the plot shape so that it can be cached for redrawing.
*/
plot(generator, start = 0, end = 1, steps = 24, xscale = 1, yscale = 1) {
const interval = end - start;
this.start();
for (let i = 0, e = steps - 1, v; i < steps; i++) {
v = generator(start + (interval * i) / e);
this.vertex(v.x * xscale, v.y * yscale);
}
this.end();
return this.currentShape;
}
/**
* A signal for starting a complex shape
*/
start(type) {
this.currentShape = new Shape(type || this.POLYGON);
}
/**
* A signal for starting a new segment of a complex shape
*/
segment(type, factor) {
this.currentShape.addSegment(type, factor);
}
/**
* Add a plain point to the current shape
*/
vertex(x, y) {
this.currentShape.vertex({ x, y });
}
/**
* Draw a previously created shape
*/
drawShape(...shapes) {
shapes = shapes.map((s) => {
if (s instanceof Shape) return s;
return new Shape(this.POLYGON, undefined, s);
});
this.currentShape = shapes[0].copy();
for (let i = 1; i < shapes.length; i++) {
this.currentShape.merge(shapes[i]);
}
this.end();
}
/**
* A signal to draw the current complex shape
*/
end(close = false) {
this.ctx.beginPath();
let { x, y } = this.currentShape.first;
this.ctx.moveTo(x, y);
this.currentShape.segments.forEach((s) => this[`draw${s.type}`](s.points, s.factor));
if (close) this.ctx.closePath();
this.ctx.fill();
this.ctx.stroke();
}
/**
* Yield a snapshot of the current shape.
*/
saveShape() {
return this.currentShape;
}
/**
* Polygon draw function
*/
drawPolygon(points) {
points.forEach((p) => this.ctx.lineTo(p.x, p.y));
}
/**
* Curve draw function, which draws a CR curve as a series of Beziers
*/
drawCatmullRom(points, f) {
// invent a virtual first and last point
const f0 = points[0],
f1 = points[1],
fn = f0.reflect(f1),
l1 = points[points.length - 2],
l0 = points[points.length - 1],
ln = l0.reflect(l1),
cpoints = [fn, ...points, ln];
// four point sliding window over the segment
for (let i = 0, e = cpoints.length - 3; i < e; i++) {
let [c1, c2, c3, c4] = cpoints.slice(i, i + 4);
let p2 = {
x: c2.x + (c3.x - c1.x) / (6 * f),
y: c2.y + (c3.y - c1.y) / (6 * f),
};
let p3 = {
x: c3.x - (c4.x - c2.x) / (6 * f),
y: c3.y - (c4.y - c2.y) / (6 * f),
};
this.ctx.bezierCurveTo(p2.x, p2.y, p3.x, p3.y, c3.x, c3.y);
}
}
/**
* Curve draw function, which assumes Bezier coordinates
*/
drawBezier(points) {
for (let i = 0, e = points.length; i < e; i += 3) {
let [p1, p2, p3] = points.slice(i, i + 3);
this.ctx.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
}
}
/**
* convenient grid drawing function
*/
drawGrid(division = 20) {
let w = this.panelWidth ?? this.width;
for (let x = (division / 2) | 0; x < w; x += division) {
this.line(x, 0, x, this.height);
}
for (let y = (division / 2) | 0; y < this.height; y += division) {
this.line(0, y, w, y);
}
}
/**
* convenient axis drawing function
*
* api.drawAxes("t",0,1, "S","0%","100%");
*
*/
drawAxes(hlabel, hs, he, vlabel, vs, ve, w, h) {
h = h || this.height;
w = w || this.width;
this.line(0, 0, w, 0);
this.line(0, 0, 0, h);
const hpos = 0 - 5;
this.text(`${hlabel}`, w / 2, hpos, this.CENTER);
this.text(hs, 0, hpos, this.CENTER);
this.text(he, w, hpos, this.CENTER);
const vpos = -10;
this.text(`${vlabel}\n`, vpos, h / 2, this.RIGHT);
this.text(vs, vpos, 0 + 5, this.RIGHT);
this.text(ve, vpos, h, this.RIGHT);
}
/**
* math functions
*/
floor(v) {
return Math.floor(v);
}
ceil(v) {
return Math.ceil(v);
}
round(v) {
return Math.round(v);
}
random(a, b) {
if (a === undefined) {
a = 0;
b = 1;
} else if (b === undefined) {
b = a;
a = 0;
}
return a + Math.random() * (b - a);
}
abs(v) {
return Math.abs(v);
}
min(...v) {
return Math.min(...v);
}
max(...v) {
return Math.max(...v);
}
approx(v1, v2, epsilon = 0.001) {
return Math.abs(v1 - v2) < epsilon;
}
sin(v) {
return Math.sin(v);
}
cos(v) {
return Math.cos(v);
}
tan(v) {
return Math.tan(v);
}
sqrt(v) {
return Math.sqrt(v);
}
atan2(dy, dx) {
return Math.atan2(dy, dx);
}
binomial(n, k) {
return binomial(n, k);
}
map(v, s, e, ns, ne, constrain = false) {
const i1 = e - s,
i2 = ne - ns,
p = v - s;
let r = ns + (p * i2) / i1;
if (constrain) return this.constrain(r, ns, ne);
return r;
}
constrain(v, s, e) {
return v < s ? s : v > e ? e : v;
}
dist(x1, y1, x2, y2) {
let dx = x1 - x2;
let dy = y1 - y2;
return this.sqrt(dx * dx + dy * dy);
}
}
/**
* Multiple inheritance is probably a good thing to not have in JS,
* but traits would have been nice, because a 1000+ LoC class is kind
* of silly, so to keep things easy to maintain, you end up writing
* custom trait-adding functions like this...
*/
impartSliderLogic(GraphicsAPI);
export { GraphicsAPI, Bezier, BSpline, Vector, Matrix, Shape };

View File

@@ -0,0 +1,151 @@
import { create } from "../lib/create.js";
export default function impartSliderLogic(GraphicsAPI) {
/**
* Dynamically add a slider
*/
GraphicsAPI.prototype.addSlider = function addSlider(classes, propname, min, max, step, value, transform) {
if (this.element) {
let slider = create(`input`);
slider.type = `range`;
slider.min = min;
slider.max = max;
slider.step = step;
slider.setAttribute(`value`, value);
slider.setAttribute(`class`, classes);
this.element.append(slider);
return this.setSlider(slider, propname, value, transform);
}
};
/**
* Update a slider with new min/max/step parameters, and value.
*
* @param {*} propname
* @param {*} min
* @param {*} max
* @param {*} step
* @param {*} value
*/
GraphicsAPI.prototype.updateSlider = function updateSlider(propname, min, max, step, value) {
let slider = this._sliders[propname];
if (!slider) {
throw new Error(`this.${propname} has no associated slider.`);
}
slider.setAttribute(`min`, min);
slider.setAttribute(`max`, max);
slider.setAttribute(`step`, step);
slider.setAttribute(`value`, value);
slider.updateProperty(value);
};
/**
* Set up a slider to control a named, numerical property in the sketch.
*
* @param {String} local query selector for the type=range element.
* @param {String} propname the name of the property to control.
* @param {float} initial the initial value for this property.
* @param {boolean} redraw whether or not to redraw after updating the value from the slider.
*/
GraphicsAPI.prototype.setSlider = function setSlider(qs, propname, initial, transform) {
if (propname !== false && typeof this[propname] !== `undefined`) {
throw new Error(`this.${propname} already exists: cannot bind slider.`);
}
this._sliders = this._sliders || {};
let propLabel = propname.replace(`!`, ``);
propname = propLabel === propname ? propname : false;
let slider = typeof qs === `string` ? this.find(qs) : qs;
// create a slider row in the table of sliders
let ui = (() => {
if (!this.element) {
return { update: (v) => {} };
}
let table = find(`table.slider-wrapper`);
if (!table) {
table = slider.parentNode.querySelector(`table.slider-wrapper`);
if (!table) {
table = create(`table`);
table.classList.add(`slider-wrapper`);
slider.parentNode.replaceChild(table, slider);
}
}
let tr = create(`tr`);
let td = create(`td`);
let label = create(`label`);
label.classList.add(`slider-label`);
label.innerHTML = propLabel;
td.append(label);
tr.append(td);
td = create(`td`);
slider.classList.add(`slider`);
this._sliders[propname] = slider;
td.append(slider);
tr.append(td);
td = create(`td`);
let valueField = create(`label`);
valueField.classList.add(`slider-value`);
valueField.textContent;
td.append(valueField);
tr.append(td);
table.append(tr);
return { update: (v) => (valueField.textContent = v) };
})();
if (!slider) {
console.warn(`Warning: no slider found for query selector "${qs}"`);
if (propname) this[propname] = initial;
return undefined;
}
let step = slider.getAttribute(`step`) || "1";
let res = !step.includes(`.`) ? 0 : step.substring(step.indexOf(`.`) + 1).length;
slider.updateProperty = (evt) => {
let value = parseFloat(slider.value);
ui.update(value.toFixed(res));
try {
let checked = transform ? transform(value) ?? value : value;
if (propname) this[propname] = checked;
} catch (e) {
if (evt instanceof Event) {
evt.preventDefault();
evt.stopPropagation();
}
ui.update(e.value.toFixed(res));
slider.value = e.value;
slider.setAttribute(`value`, e.value);
}
if (!this.redrawing) this.redraw();
};
slider.value = initial;
slider.updateProperty({ target: { value: initial } });
slider.listen(`input`, (evt) => slider.updateProperty(evt));
return slider;
};
/**
* remove all sliders from this element
*/
GraphicsAPI.prototype.removeSliders = function removeSliders() {
this.findAll(`.slider-wrapper`).forEach((s) => {
s.parentNode.removeChild(s);
});
};
return GraphicsAPI;
}

View File

@@ -0,0 +1,178 @@
import { Vector } from "./vector.js";
import { Bezier as Original } from "../../lib/bezierjs/bezier.js";
import { fitCurveToPoints } from "../util/fit-curve-to-points.js";
/**
* A canvas-aware Bezier curve class
*/
class Bezier extends Original {
static defaultQuadratic(apiInstance) {
if (!apiInstance) {
throw new Error(`missing reference of API instance in Bezier.defaultQuadratic(instance)`);
}
return new Bezier(apiInstance, 70, 250, 20, 110, 220, 60);
}
static defaultCubic(apiInstance) {
if (!apiInstance) {
throw new Error(`missing reference of API instance in Bezier.defaultCubic(instance)`);
}
return new Bezier(apiInstance, 110, 150, 25, 190, 210, 250, 210, 30);
}
static fitCurveToPoints(apiInstance, points, tvalues) {
if (!tvalues) {
const D = [0];
for (let i = 1; i < n; i++) {
D[i] = D[i - 1] + dist(points[i - 1].x, points[i - 1].y, points[i].x, points[i].y);
}
const S = [],
len = D[n - 1];
D.forEach((v, i) => {
S[i] = v / len;
});
tvalues = S;
}
const bestFitData = fitCurveToPoints(points, tvalues),
x = bestFitData.x,
y = bestFitData.y,
bpoints = x.map((r, i) => ({ x: r[0], y: y[i][0] }));
return new Bezier(apiInstance, bpoints);
}
constructor(apiInstance, ...coords) {
if (!apiInstance || !apiInstance.setMovable) {
throw new Error(`missing reference of API instance in Bezier constructor`);
}
super(...coords);
this.api = apiInstance;
this.ctx = apiInstance.ctx;
}
project(x, y) {
return super.project({ x, y });
}
getPointNear(x, y, d = 5) {
const p = this.points;
for (let i = 0, e = p.length; i < e; i++) {
let dx = Math.abs(p[i].x - x);
let dy = Math.abs(p[i].y - y);
if ((dx * dx + dy * dy) ** 0.5 <= d) {
return p[i];
}
}
}
drawCurve(color = `#333`) {
const ctx = this.ctx;
ctx.save();
ctx.strokeStyle = color;
ctx.beginPath();
const lut = this.getLUT().slice();
let p = lut.shift();
ctx.moveTo(p.x, p.y);
while (lut.length) {
p = lut.shift();
ctx.lineTo(p.x, p.y);
}
ctx.stroke();
ctx.restore();
}
drawPoints(labels = true) {
const colors = [`red`, `green`, `blue`, `yellow`, `orange`, `cyan`, `magenta`];
const api = this.api;
const ctx = this.ctx;
ctx.save();
ctx.lineWidth = 2;
ctx.strokeStyle = `#999`;
this.points.forEach((p, i) => {
api.setFill(colors[i % colors.length]);
api.circle(p.x, p.y, 5);
if (labels) {
api.setFill(`black`);
let x = p.x | 0;
let y = p.y | 0;
api.text(`(${x},${y})`, x + 10, y + 10);
}
});
ctx.restore();
}
drawSkeleton(color = `#555`) {
const api = this.api;
const ctx = this.ctx;
ctx.save();
const p = this.points;
api.noFill();
api.setStroke(color);
api.start();
p.forEach((v) => api.vertex(v.x, v.y));
api.end();
ctx.restore();
}
getStrutPoints(t) {
const p = this.points.map((p) => new Vector(p));
const mt = 1 - t;
let s = 0;
let n = p.length + 1;
while (--n > 1) {
let list = p.slice(s, s + n);
for (let i = 0, e = list.length - 1; i < e; i++) {
let pt = list[i + 1].subtract(list[i + 1].subtract(list[i]).scale(mt));
p.push(pt);
}
s += n;
}
return p;
}
drawStruts(t, color = `black`, showpoints = true) {
const p = t.forEach ? t : this.getStrutPoints(t);
const api = this.api;
const ctx = api.ctx;
ctx.save();
api.noFill();
api.setStroke(color);
let s = this.points.length;
let n = this.points.length;
while (--n > 1) {
api.start();
for (let i = 0; i < n; i++) {
let pt = p[s + i];
api.vertex(pt.x, pt.y);
if (showpoints) api.circle(pt.x, pt.y, 5);
}
api.end();
s += n;
}
ctx.restore();
return p;
}
drawBoundingBox(color = `black`) {
let bbox = this.bbox(),
mx = bbox.x.min,
my = bbox.y.min,
MX = bbox.x.max,
MY = bbox.y.max,
api = this.api;
api.save();
api.noFill();
api.setStroke(color);
api.rect(mx, my, MX - mx, MY - my);
api.restore();
}
}
export { Bezier };

View File

@@ -0,0 +1,58 @@
import interpolate from "../util/interpolate-bspline.js";
// cubic B-Spline
const DEGREE = 3;
class BSpline {
constructor(apiInstance, points) {
this.api = apiInstance;
this.ctx = apiInstance.ctx;
// the spline library needs points in array format [x,y] rather than object format {x:..., y:...}
this.points = points.map((v) => {
if (v instanceof Array) return v;
return [v.x, v.y];
});
}
getLUT(count) {
let c = count - 1;
return [...new Array(count)].map((_, i) => {
let p = interpolate(i / c, DEGREE, this.points, this.knots, this.weights);
return { x: p[0], y: p[1] };
});
}
formKnots(open = false) {
if (!open) return this.formUniformKnots();
let knots = [],
l = this.points.length,
m = l - DEGREE;
// form the open-uniform knot vector
for (let i = 1; i < l - DEGREE; i++) {
knots.push(i + DEGREE);
}
// add [degree] zeroes at the front
for (let i = 0; i <= DEGREE; i++) {
knots = [DEGREE].concat(knots);
}
// add [degree] max-values to the back
for (let i = 0; i <= DEGREE; i++) {
knots.push(m + DEGREE);
}
return (this.knots = knots);
}
formUniformKnots() {
return (this.knots = [...new Array(this.points.length + DEGREE + 1)].map((_, i) => i));
}
formWeights() {
return (this.weights = this.points.map((p) => 1));
}
}
export { BSpline };

View File

@@ -0,0 +1,165 @@
function invert(M) {
// Copied from http://blog.acipo.com/matrix-inversion-in-javascript/
// With permission, http://blog.acipo.com/matrix-inversion-in-javascript/#comment-5057289889
// (1) 'augment' the matrix (left) by the identity (on the right)
// (2) Turn the matrix on the left into the identity by elemetry row ops
// (3) The matrix on the right is the inverse (was the identity matrix)
// There are 3 elemtary row ops:
// (a) Swap 2 rows
// (b) Multiply a row by a scalar
// (c) Add 2 rows
//if the matrix isn't square: exit (error)
if (M.length !== M[0].length) {
console.log("not square");
return;
}
//create the identity matrix (I), and a copy (C) of the original
var i = 0,
ii = 0,
j = 0,
dim = M.length,
e = 0,
t = 0;
var I = [],
C = [];
for (i = 0; i < dim; i += 1) {
// Create the row
I[I.length] = [];
C[C.length] = [];
for (j = 0; j < dim; j += 1) {
//if we're on the diagonal, put a 1 (for identity)
if (i == j) {
I[i][j] = 1;
} else {
I[i][j] = 0;
}
// Also, make the copy of the original
C[i][j] = M[i][j];
}
}
// Perform elementary row operations
for (i = 0; i < dim; i += 1) {
// get the element e on the diagonal
e = C[i][i];
// if we have a 0 on the diagonal (we'll need to swap with a lower row)
if (e == 0) {
//look through every row below the i'th row
for (ii = i + 1; ii < dim; ii += 1) {
//if the ii'th row has a non-0 in the i'th col
if (C[ii][i] != 0) {
//it would make the diagonal have a non-0 so swap it
for (j = 0; j < dim; j++) {
e = C[i][j]; //temp store i'th row
C[i][j] = C[ii][j]; //replace i'th row by ii'th
C[ii][j] = e; //repace ii'th by temp
e = I[i][j]; //temp store i'th row
I[i][j] = I[ii][j]; //replace i'th row by ii'th
I[ii][j] = e; //repace ii'th by temp
}
//don't bother checking other rows since we've swapped
break;
}
}
//get the new diagonal
e = C[i][i];
//if it's still 0, not invertable (error)
if (e == 0) {
return;
}
}
// Scale this row down by e (so we have a 1 on the diagonal)
for (j = 0; j < dim; j++) {
C[i][j] = C[i][j] / e; //apply to original matrix
I[i][j] = I[i][j] / e; //apply to identity
}
// Subtract this row (scaled appropriately for each row) from ALL of
// the other rows so that there will be 0's in this column in the
// rows above and below this one
for (ii = 0; ii < dim; ii++) {
// Only apply to other rows (we want a 1 on the diagonal)
if (ii == i) {
continue;
}
// We want to change this element to 0
e = C[ii][i];
// Subtract (the row above(or below) scaled by e) from (the
// current row) but start at the i'th column and assume all the
// stuff left of diagonal is 0 (which it should be if we made this
// algorithm correctly)
for (j = 0; j < dim; j++) {
C[ii][j] -= e * C[i][j]; //apply to original matrix
I[ii][j] -= e * I[i][j]; //apply to identity
}
}
}
//we've done all operations, C should be the identity
//matrix I should be the inverse:
return I;
}
function multiply(m1, m2) {
var M = [];
var m2t = transpose(m2);
m1.forEach((row, r) => {
M[r] = [];
m2t.forEach((col, c) => {
M[r][c] = row.map((v, i) => col[i] * v).reduce((a, v) => a + v, 0);
});
});
return M;
}
function transpose(M) {
return M[0].map((col, i) => M.map((row) => row[i]));
}
class Matrix {
constructor(n, m, data) {
data = n instanceof Array ? n : data;
this.data = data ?? [...new Array(n)].map((v) => [...new Array(m)].map((v) => 0));
this.rows = this.data.length;
this.cols = this.data[0].length;
}
setData(data) {
this.data = data;
}
get(i, j) {
return this.data[i][j];
}
set(i, j, value) {
this.data[i][j] = value;
}
row(i) {
return this.data[i];
}
col(j) {
var d = this.data,
col = [];
for (let r = 0, l = d.length; r < l; r++) {
col.push(d[r][j]);
}
return col;
}
multiply(other) {
return new Matrix(multiply(this.data, other.data));
}
invert() {
return new Matrix(invert(this.data));
}
transpose() {
return new Matrix(transpose(this.data));
}
}
export { Matrix };

View File

@@ -0,0 +1,73 @@
class Vector {
constructor(x, y, z) {
if (arguments.length === 1) {
z = x.z;
y = x.y;
x = x.x;
}
this.x = x;
this.y = y;
if (z !== undefined) {
this.z = z;
}
}
dist(other, y, z = 0) {
if (y !== undefined) other = { x: other, y, z };
let sum = 0;
sum += (this.x - other.x) ** 2;
sum += (this.y - other.y) ** 2;
let z1 = this.z ? this.z : 0;
let z2 = other.z ? other.z : 0;
sum += (z1 - z2) ** 2;
return sum ** 0.5;
}
normalize(f) {
let mag = this.dist(0, 0, 0);
return new Vector((f * this.x) / mag, (f * this.y) / mag, (f * this.z) / mag);
}
getAngle() {
return -Math.atan2(this.y, this.x);
}
reflect(other) {
let p = new Vector(other.x - this.x, other.y - this.y);
if (other.z !== undefined) {
p.z = other.z;
if (this.z !== undefined) {
p.z -= this.z;
}
}
return this.subtract(p);
}
add(other) {
let p = new Vector(this.x + other.x, this.y + other.y);
if (this.z !== undefined) {
p.z = this.z;
if (other.z !== undefined) {
p.z += other.z;
}
}
return p;
}
subtract(other) {
let p = new Vector(this.x - other.x, this.y - other.y);
if (this.z !== undefined) {
p.z = this.z;
if (other.z !== undefined) {
p.z -= other.z;
}
}
return p;
}
scale(f = 1) {
if (f === 0) {
return new Vector(0, 0, this.z === undefined ? undefined : 0);
}
let p = new Vector(this.x * f, this.y * f);
if (this.z !== undefined) {
p.z = this.z * f;
}
return p;
}
}
export { Vector };

View File

@@ -0,0 +1,21 @@
var binomialCoefficients = [[1], [1, 1]];
/**
* ... docs go here ...
*/
function binomial(n, k) {
if (n === 0) return 1;
var lut = binomialCoefficients;
while (n >= lut.length) {
var s = lut.length;
var nextRow = [1];
for (var i = 1, prev = s - 1; i < s; i++) {
nextRow[i] = lut[prev][i - 1] + lut[prev][i];
}
nextRow[s] = 1;
lut.push(nextRow);
}
return lut[n][k];
}
export default binomial;

View File

@@ -0,0 +1,86 @@
import { Matrix } from "../types/matrix.js";
import binomial from "./binomial.js";
/*
We can form any basis matrix using a generative approach:
- it's an M = (n x n) matrix
- it's a lower triangular matrix: all the entries above the main diagonal are zero
- the main diagonal consists of the binomial coefficients for n
- all entries are symmetric about the antidiagonal.
What's more, if we number rows and columns starting at 0, then
the value at position M[r,c], with row=r and column=c, can be
expressed as:
M[r,c] = (r choose c) * M[r,r] * S,
where S = 1 if r+c is even, or -1 otherwise
That is: the values in column c are directly computed off of the
binomial coefficients on the main diagonal, through multiplication
by a binomial based on matrix position, with the sign of the value
also determined by matrix position. This is actually very easy to
write out in code:
*/
function generateBasisMatrix(n) {
const M = new Matrix(n, n);
// populate the main diagonal
var k = n - 1;
for (let i = 0; i < n; i++) {
M.set(i, i, binomial(k, i));
}
// compute the remaining values
for (var c = 0, r; c < n; c++) {
for (r = c + 1; r < n; r++) {
var sign = (r + c) % 2 === 0 ? 1 : -1;
var value = binomial(r, c) * M.get(r, r);
M.set(r, c, sign * value);
}
}
return M;
}
/**
* ...docs go here...
*/
function formTMatrix(row, n) {
let data = [];
for (var i = 0; i < n; i++) {
data.push(row.map((v) => v ** i));
}
const Tt = new Matrix(n, n, data);
const T = Tt.transpose();
return { T, Tt };
}
/**
* ...docs go here...
*/
function computeBestFit(points, n, M, S) {
var tm = formTMatrix(S, n),
T = tm.T,
Tt = tm.Tt,
M1 = M.invert(),
TtT1 = Tt.multiply(T).invert(),
step1 = TtT1.multiply(Tt),
step2 = M1.multiply(step1),
X = new Matrix(points.map((v) => [v.x])),
Cx = step2.multiply(X),
Y = new Matrix(points.map((v) => [v.y])),
Cy = step2.multiply(Y);
return { x: Cx.data, y: Cy.data };
}
/**
* ...docs go here...
*/
function fitCurveToPoints(points, tvalues) {
const n = points.length;
return computeBestFit(points, n, generateBasisMatrix(n), tvalues);
}
export { fitCurveToPoints };

View File

@@ -0,0 +1,88 @@
// https://github.com/thibauts/b-spline
export default function interpolate(t, degree, points, knots, weights, result, scaled) {
var i, j, s, l; // function-scoped iteration variables
var n = points.length; // points count
var d = points[0].length; // point dimensionality
if (degree < 1) throw new Error("degree must be at least 1 (linear)");
if (degree > n - 1) throw new Error("degree must be less than or equal to point count - 1");
if (!weights) {
// build weight vector of length [n]
weights = [];
for (i = 0; i < n; i++) {
weights[i] = 1;
}
}
// closed curve?
if (weights.length < points.length) {
weights = weights.concat(weights.slice(0, degree));
}
if (!knots) {
// build knot vector of length [n + degree + 1]
var knots = [];
for (i = 0; i < n + degree + 1; i++) {
knots[i] = i;
}
} else {
if (knots.length !== n + degree + 1) throw new Error("bad knot vector length");
}
// closed curve?
if (knots.length === points.length) {
knots = knots.concat(knots.slice(0, degree));
}
var domain = [degree, knots.length - 1 - degree];
var low = knots[domain[0]];
var high = knots[domain[1]];
// remap t to the domain where the spline is defined
if (!scaled) {
t = t * (high - low) + low;
}
if (t < low || t > high) throw new Error("out of bounds");
// find s (the spline segment) for the [t] value provided
for (s = domain[0]; s < domain[1]; s++) {
if (t >= knots[s] && t <= knots[s + 1]) {
break;
}
}
// convert points to homogeneous coordinates
var v = [];
for (i = 0; i < n; i++) {
v[i] = [];
for (j = 0; j < d; j++) {
v[i][j] = points[i][j] * weights[i];
}
v[i][d] = weights[i];
}
// l (level) goes from 1 to the curve degree + 1
var alpha;
for (l = 1; l <= degree + 1; l++) {
// build level l of the pyramid
for (i = s; i > s - degree - 1 + l; i--) {
alpha = (t - knots[i]) / (knots[i + degree + 1 - l] - knots[i]);
// interpolate each component
for (j = 0; j < d + 1; j++) {
v[i][j] = (1 - alpha) * v[i - 1][j] + alpha * v[i][j];
}
}
}
// convert back to cartesian and return
var result = result || [];
for (i = 0; i < d; i++) {
result[i] = v[s][i] / v[s][d];
}
return result;
}

View File

@@ -0,0 +1,61 @@
/**
* A complex shape, represented as a collection of paths
* that can be either polygon, Catmull-Rom curves, or
* cubic Bezier curves.
*/
class Shape {
constructor(type, factor, points = []) {
this.first = false;
this.segments = [];
this.newSegment(type, factor);
points.forEach((p) => this.vertex(p));
}
merge(other) {
if (!other.segments) {
other = { segments: [new Segment(Shape.POLYGON, undefined, other)] };
}
other.segments.forEach((s) => this.segments.push(s));
}
copy() {
const copy = new Shape(this.type, this.factor);
copy.first = this.first;
copy.segments = this.segments.map((s) => s.copy());
return copy;
}
newSegment(type, factor) {
this.currentSegment = new Segment(type, factor);
this.segments.push(this.currentSegment);
}
vertex(p) {
if (!this.first) this.first = p;
else this.currentSegment.add(p);
}
}
/**
* Pathing type constants
*/
Shape.POLYGON = `Polygon`;
Shape.CURVE = `CatmullRom`;
Shape.BEZIER = `Bezier`;
/**
* A shape subpath
*/
class Segment {
constructor(type, factor, points = []) {
this.type = type;
this.factor = factor;
this.points = points;
}
copy() {
const copy = new Segment(this.type, this.factor);
copy.points = JSON.parse(JSON.stringify(this.points));
return copy;
}
add(p) {
this.points.push(p);
}
}
export { Shape, Segment };

View File

@@ -0,0 +1,120 @@
const REG_KEY = `registered as custom element`;
// helper function
function NotImplemented(instance, fname) {
console.warn(`missing implementation for ${fname}(...data) in ${instance.__proto__.constructor.name}`);
}
// helper function for turning "ClassName" into "class-name"
function getElementTagName(cls) {
return cls.prototype.constructor.name.replace(/([A-Z])([a-z])/g, (a, b, c, d) => {
const r = `${b.toLowerCase()}${c}`;
return d > 0 ? `-${r}` : r;
});
}
/**
* This is an enrichment class to make working with custom elements
* actually pleasant, rather than a ridiculous exercise in figuring
* out a low-level spec.
*/
class CustomElement extends HTMLElement {
static register(cls) {
if (!cls[REG_KEY]) {
const tagName = cls.tagName || getElementTagName(cls);
customElements.define(tagName, cls);
cls[REG_KEY] = true;
return customElements.whenDefined(tagName);
}
return Promise.resolve();
}
static get tagName() {
return getElementTagName(this);
}
constructor(options = {}) {
super();
if (!customElements.resolveScope) {
customElements.resolveScope = function (scope) {
try {
return scope.getRootNode().host;
} catch (e) {
console.warn(e);
}
return window;
};
}
this._options = options;
const route = {
childList: (record) => {
this.handleChildChanges(Array.from(record.addedNodes), Array.from(record.removedNodes));
this.render();
},
attributes: (record) => {
this.handleAttributeChange(record.attributeName, record.oldValue, this.getAttribute(record.attributeName));
this.render();
},
};
// Set up a mutation observer because there are no custom
// element lifecycle functions for changes to the childNodes
// nodelist.
this._observer = new MutationObserver((records) => {
if (this.isConnected) {
records.forEach((record) => {
// console.log(record);
route[record.type](record);
});
}
});
this._observer.observe(this, {
childList: true,
attributes: true,
});
// Set up an open shadow DOM, because the web is open,
// and hiding your internals is ridiculous.
const shadowProps = { mode: `open` };
this._shadow = this.attachShadow(shadowProps);
this._style = document.createElement(`style`);
this._style.textContent = this.getStyle();
if (this._options.header !== false) this._header = document.createElement(`header`);
if (this._options.slot !== false && this._options.void !== true) this._slot = document.createElement(`slot`);
if (this._options.footer !== false) this._footer = document.createElement(`footer`);
}
connectedCallback() {
this.render();
}
handleChildChanges(added, removed) {
if (!this._options.void) NotImplemented(this, `handleChildChanges`);
}
handleAttributeChange(name, oldValue, newValue) {
NotImplemented(this, `handleAttributeChange`);
}
getStyle() {
return ``;
}
render() {
this._shadow.innerHTML = ``;
this._shadow.append(this._style);
if (this._options.header !== false) this._shadow.append(this._header);
if (this._options.slot !== false) this._shadow.append(this._slot);
if (this._options.footer !== false) this._shadow.append(this._footer);
}
}
export { CustomElement };

View File

@@ -0,0 +1,89 @@
/*
Let's look at how we can give the <graphics-element> a decent look,
but also how we can make sure that the <fallback> content only shows
when the <graphics-element> isn't actually defined, because scripts
are disabled (or blocked!).
*/
graphics-element {
display: inline-block;
border: 1px solid grey;
}
/*
We can use the :defined pseudo-class to check whether a particular
element is considered a "real" element (either by being a built-in
standard HTML element, or a registered custom element) or whether it's
just "a tag name that isn't tied to anything".
If JS is disabled, as well as when the JS for registering our custom
element is still loading, our <graphics-element> will just be "a word"
and so we want to make sure to not show any content, except for the
<fallback-image>, if there is one.
So, first off: hide everything!
*/
graphics-element:not(:defined) > * {
display: none;
}
/*
And then we declare a more specific rule that does NOT hide the
<fallback-image> element and its content.
*/
graphics-element:not(:defined) fallback-image {
display: block;
text-align: center;
padding: 0.5em;
margin: auto;
}
/*
Normally, images are inline elements, but in our case we want it to be
treated as a full block, so that the caption text shows up underneath
it, rather than next to it:
*/
graphics-element:not(:defined) fallback-image > .view-source {
display: block;
position: relative;
top: -0.6em;
margin-bottom: -0.2em;
font-size: 60%;
color: #00000030;
}
graphics-element:not(:defined) fallback-image > label {
display: block;
font-style: italic;
font-size: 0.9em;
text-align: right;
}
graphics-element:not(:defined) fallback-image > img {
display: block;
border: 1px solid lightgrey;
}
/*
Then, we say what should happen once our <graphics-element> element
is considered a proper, registered HTML tag:
*/
graphics-element:defined {
display: inline-block;
padding: 0.5em;
justify-self: center;
font-size: revert;
text-align: revert;
}
/*
And of course, once that's the case we actually want to make sure that
the <fallback-image> does NOT show anymore!
*/
graphics-element:defined fallback-image.loaded {
display: none;
}

View File

@@ -0,0 +1,365 @@
import { CustomElement } from "./custom-element.js";
import splitCodeSections from "./lib/split-code-sections.js";
import performCodeSurgery from "./lib/perform-code-surgery.js";
const MODULE_URL = import.meta.url;
const MODULE_PATH = MODULE_URL.slice(0, MODULE_URL.lastIndexOf(`/`));
// Until global `await` gets added to JS, we need to declare this "constant"
// using the `let` keyword instead, and then boostrap its value during the
// `loadSource` call (using the standard if(undefined){assignvalue} pattern).
let IMPORT_GLOBALS_FROM_GRAPHICS_API = undefined;
// Really wish this was baked into the DOM API. Having to use an
// IntersectionObserver with bounding box fallback is super dumb
// from an authoring perspective.
function isInViewport(e) {
if (typeof window === `undefined`) return true;
if (typeof document === `undefined`) return true;
var b = e.getBoundingClientRect();
return (
b.top >= 0 &&
b.left >= 0 &&
b.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
b.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
/**
* A simple "for programming code" element, for holding entire
* programs, rather than code snippets.
*/
CustomElement.register(class ProgramCode extends HTMLElement {});
/**
* Our custom element
*/
class GraphicsElement extends CustomElement {
/**
* Create an instance of this element
*/
constructor() {
super({ header: false, footer: false });
this.originalHTML = this.outerHTML;
// Do we load immediately?
if (isInViewport(this)) {
this.loadSource();
}
// Or do we load later, once we've been scrolled into view?
else {
let fallback = this.querySelector(`img`);
new IntersectionObserver(
(entries, observer) =>
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.loadSource();
observer.disconnect();
}
}),
{ threshold: 0.1, rootMargin: `${window.innerHeight}px` }
).observe(fallback);
}
this.label = document.createElement(`label`);
if (!this.title) this.title = ``;
this.label.textContent = this.title;
}
/**
* part of the CustomElement API
*/
getStyle() {
return `
:host([hidden]) { display: none; }
:host { max-width: calc(2em + ${this.getAttribute(`width`)}px); }
:host style { display: none; }
:host .top-title { display: flex; flex-direction: row-reverse; justify-content: space-between; }
:host canvas { position: relative; z-index: 1; display: block; margin: auto; border-radius: 0; box-sizing: content-box!important; border: 1px solid lightgrey; }
:host canvas:focus { border: 1px solid red; }
:host a.view-source { font-size: 60%; text-decoration: none; }
:host button.reset { font-size: 0.5em; }
:host label { display: block; font-style:italic; font-size: 0.9em; text-align: right; }
`;
}
/**
* part of the CustomElement API
*/
handleChildChanges(added, removed) {
// debugLog(`child change:`, added, removed);
}
/**
* part of the CustomElement API
*/
handleAttributeChange(name, oldValue, newValue) {
if (name === `title`) {
this.label.textContent = this.getAttribute(`title`);
}
if (this.apiInstance) {
let instance = this.apiInstance;
if (name === `width`) {
instance.setSize(parseInt(newValue), false);
instance.redraw();
}
if (name === `height`) {
instance.setSize(false, parseInt(newValue));
instance.redraw();
}
}
}
/**
* Load the graphics code, either from a src URL, a <program-code> element, or .textContent
*/
async loadSource() {
debugLog(`loading ${this.getAttribute(`src`)}`);
if (!IMPORT_GLOBALS_FROM_GRAPHICS_API) {
const importStatement = (await fetch(`${MODULE_PATH}/api/graphics-api.js`).then((r) => r.text()))
.match(/(export { [^}]+ })/)[0]
.replace(`export`, `import`);
IMPORT_GLOBALS_FROM_GRAPHICS_API = `${importStatement} from "${MODULE_PATH}/api/graphics-api.js"`;
}
let src = false;
let codeElement = this.querySelector(`program-code`);
let code = ``;
if (codeElement) {
src = codeElement.getAttribute("src");
if (src) {
this.src = src;
code = await fetch(src).then((response) => response.text());
} else {
code = codeElement.textContent;
}
} else {
src = this.getAttribute("src");
if (src) {
this.src = src;
code = await fetch(src).then((response) => response.text());
} else {
code = this.textContent;
}
}
if (!codeElement) {
codeElement = document.createElement(`program-code`);
codeElement.textContent = code;
this.prepend(codeElement);
}
codeElement.setAttribute(`hidden`, `hidden`);
new MutationObserver((_records) => {
// nornmally we don't want to completely recreate the shadow DOM
this.processSource(src, codeElement.textContent);
}).observe(codeElement, {
characterData: true,
attributes: false,
childList: true,
subtree: true,
});
// But on the first pass, we do.
this.processSource(src, code, true);
}
/**
* Transform the graphics source code into global and class code.
*/
processSource(src, code, rerender = false) {
if (this.script) {
if (this.script.parentNode) {
this.script.parentNode.removeChild(this.script);
}
this.canvas.parentNode.removeChild(this.canvas);
rerender = true;
}
const uid = (this.uid = `bg-uid-${Date.now()}-${`${Math.random()}`.replace(`0.`, ``)}`);
window[uid] = this;
// Step 1: fix the imports. This is ... a bit of work.
let path;
let base = document.querySelector(`base`);
if (base) {
path = base.href;
} else {
let loc = window.location.toString();
path = loc.substring(0, loc.lastIndexOf(`/`) + 1);
}
let modulepath = `${path}${src}`;
let modulebase = modulepath.substring(0, modulepath.lastIndexOf(`/`) + 1);
// okay, I lied, it's actually quite a lot of work.
code = code.replace(/(import .+? from) "([^"]+)"/g, (_, main, group) => {
return `${main} "${modulebase}${group}"`;
});
this.linkableCode = code;
// Then, step 2: split up the code into "global" vs. "class" code.
const split = splitCodeSections(code);
const globalCode = split.quasiGlobal;
const classCode = performCodeSurgery(split.classCode);
this.setupCodeInjection(src, uid, globalCode, classCode, rerender);
}
/**
* Form the final, perfectly valid JS module code, and create the <script>
* element for it, to be inserted into the shadow DOM during render().
*/
setupCodeInjection(src, uid, globalCode, classCode, rerender) {
const width = this.getAttribute(`width`, 200);
const height = this.getAttribute(`height`, 200);
this.code = `
/**
* Program source: ${src}
* Data attributes: ${JSON.stringify(this.dataset)}
*/
${IMPORT_GLOBALS_FROM_GRAPHICS_API};
${globalCode}
class Example extends GraphicsAPI {
${classCode}
}
new Example('${uid}', ${width}, ${height});
`;
const script = (this.script = document.createElement(`script`));
script.type = "module";
script.src = `data:application/javascript;charset=utf-8,${encodeURIComponent(this.code)}`;
if (rerender) this.render();
}
/**
* Reload this graphics element the brute force way.
*/
async reset() {
const parent = this.parentNode;
const offDOM = document.createElement(`div`);
offDOM.style.display = `none`;
offDOM.innerHTML = this.originalHTML;
const newElement = offDOM.querySelector(`graphics-element`);
newElement.addEventListener(`load`, () => {
parent.replaceChild(newElement, this);
document.body.removeChild(offDOM);
});
document.body.appendChild(offDOM);
}
/**
* Hand the <graphics-element> a reference to the "Example" instance that it built.
*/
setGraphic(apiInstance) {
this.apiInstance = apiInstance;
this.setCanvas(apiInstance.canvas);
// at this point we can remove our placeholder image for this element, too.
let fallback = this.querySelector(`fallback-image`);
if (fallback) this.removeChild(fallback);
}
/**
* Locally bind the Example's canvas, since it needs to get added to the shadow DOM.
*/
setCanvas(canvas) {
this.canvas = canvas;
// Make sure focus works, Which is a CLUSTERFUCK OF BULLSHIT in the August, 2020,
// browser landscape, so this is the best I can give you right now. I am more
// disappointed about this than you will ever be.
this.canvas.setAttribute(`tabindex`, `0`);
this.canvas.addEventListener(`touchstart`, (evt) => this.canvas.focus());
this.canvas.addEventListener(`mousedown`, (evt) => this.canvas.focus());
// If we get here, there were no source code errors: undo the scheduled error print.
clearTimeout(this.errorPrintTimeout);
this.render();
// Once we've rendered, we can send the "ready for use" signal.
this.dispatchEvent(new CustomEvent(`load`));
if (this.onload) {
this.onload();
}
}
/**
* This is a helper to aid debugging, mostly because dev tools are not super
* great at pointing you to the right line for an injected script that it
* can't actually find anywhere in the document or shadow DOM...
*/
printCodeDueToError() {
debugLog(
this.code
.split(`\n`)
.map((l, pos) => `${pos + 1}: ${l}`)
.join(`\n`)
);
}
/**
* Regenerate the shadow DOM content.
*/
render() {
super.render();
if (this.script) {
if (!this.script.__inserted) {
// Schedule an error print, which will get cleared if there
// were no source code errors.
this.errorPrintTimeout = setTimeout(() => this.printCodeDueToError(), 1000);
this.script.__inserted = true;
this._shadow.appendChild(this.script);
}
}
const slotParent = this._slot.parentNode;
if (this.canvas) slotParent.insertBefore(this.canvas, this._slot);
if (this.label) slotParent.insertBefore(this.label, this._slot);
const toptitle = document.createElement(`div`);
toptitle.classList.add(`top-title`);
const r = document.createElement(`button`);
r.classList.add(`reset`);
r.textContent = this.getAttribute(`reset`) || `reset`;
r.addEventListener(`click`, () => this.reset());
toptitle.append(r);
if (this.src) {
const a = document.createElement(`a`);
a.classList.add(`view-source`);
a.textContent = this.getAttribute(`viewSource`) || `view source`;
a.href = this.src;
a.target = `_blank`;
toptitle.append(a);
}
if (this.label) slotParent.insertBefore(toptitle, this.canvas);
}
}
// Register our custom element
CustomElement.register(GraphicsElement);
// static property to regular debugging
GraphicsElement.DEBUG = false;
// debugging should be behind a flag
function debugLog(...data) {
if (GraphicsElement.DEBUG) {
console.log(...data);
}
}
export { GraphicsElement };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,226 @@
/**
* Normalise an SVG path to absolute coordinates
* and full commands, rather than relative coordinates
* and/or shortcut commands.
*/
export default function normalizePath(d) {
// preprocess "d" so that we have spaces between values
d = d
.replace(/,/g, " ") // replace commas with spaces
.replace(/-/g, " - ") // add spacing around minus signs
.replace(/-\s+/g, "-") // remove spacing to the right of minus signs.
.replace(/([a-zA-Z])/g, " $1 ");
// set up the variables used in this function
const instructions = d.replace(/([a-zA-Z])\s?/g, "|$1").split("|"),
instructionLength = instructions.length;
let i,
instruction,
op,
lop,
args = [],
alen,
a,
sx = 0,
sy = 0,
x = 0,
y = 0,
cx = 0,
cy = 0,
cx2 = 0,
cy2 = 0,
rx = 0,
ry = 0,
xrot = 0,
lflag = 0,
sweep = 0,
normalized = "";
// we run through the instruction list starting at 1, not 0,
// because we split up "|M x y ...." so the first element will
// always be an empty string. By design.
for (i = 1; i < instructionLength; i++) {
// which instruction is this?
instruction = instructions[i];
op = instruction.substring(0, 1);
lop = op.toLowerCase();
// what are the arguments? note that we need to convert
// all strings into numbers, or + will do silly things.
args = instruction.replace(op, "").trim().split(" ");
args = args
.filter(function (v) {
return v !== "";
})
.map(parseFloat);
alen = args.length;
// we could use a switch, but elaborate code in a "case" with
// fallthrough is just horrid to read. So let's use ifthen
// statements instead.
// moveto command (plus possible lineto)
if (lop === "m") {
normalized += "M ";
if (op === "m") {
x += args[0];
y += args[1];
} else {
x = args[0];
y = args[1];
}
// records start position, for dealing
// with the shape close operator ('Z')
sx = x;
sy = y;
normalized += x + " " + y + " ";
if (alen > 2) {
for (a = 0; a < alen; a += 2) {
if (op === "m") {
x += args[a];
y += args[a + 1];
} else {
x = args[a];
y = args[a + 1];
}
normalized += "L " + x + " " + y + " ";
}
}
}
// lineto commands
else if (lop === "l") {
for (a = 0; a < alen; a += 2) {
if (op === "l") {
x += args[a];
y += args[a + 1];
} else {
x = args[a];
y = args[a + 1];
}
normalized += "L " + x + " " + y + " ";
}
} else if (lop === "h") {
for (a = 0; a < alen; a++) {
if (op === "h") {
x += args[a];
} else {
x = args[a];
}
normalized += "L " + x + " " + y + " ";
}
} else if (lop === "v") {
for (a = 0; a < alen; a++) {
if (op === "v") {
y += args[a];
} else {
y = args[a];
}
normalized += "L " + x + " " + y + " ";
}
}
// quadratic curveto commands
else if (lop === "q") {
for (a = 0; a < alen; a += 4) {
if (op === "q") {
cx = x + args[a];
cy = y + args[a + 1];
x += args[a + 2];
y += args[a + 3];
} else {
cx = args[a];
cy = args[a + 1];
x = args[a + 2];
y = args[a + 3];
}
normalized += "Q " + cx + " " + cy + " " + x + " " + y + " ";
}
} else if (lop === "t") {
for (a = 0; a < alen; a += 2) {
// reflect previous cx/cy over x/y
cx = x + (x - cx);
cy = y + (y - cy);
// then get real end point
if (op === "t") {
x += args[a];
y += args[a + 1];
} else {
x = args[a];
y = args[a + 1];
}
normalized += "Q " + cx + " " + cy + " " + x + " " + y + " ";
}
}
// cubic curveto commands
else if (lop === "c") {
for (a = 0; a < alen; a += 6) {
if (op === "c") {
cx = x + args[a];
cy = y + args[a + 1];
cx2 = x + args[a + 2];
cy2 = y + args[a + 3];
x += args[a + 4];
y += args[a + 5];
} else {
cx = args[a];
cy = args[a + 1];
cx2 = args[a + 2];
cy2 = args[a + 3];
x = args[a + 4];
y = args[a + 5];
}
normalized += "C " + cx + " " + cy + " " + cx2 + " " + cy2 + " " + x + " " + y + " ";
}
} else if (lop === "s") {
for (a = 0; a < alen; a += 4) {
// reflect previous cx2/cy2 over x/y
cx = x + (x - cx2);
cy = y + (y - cy2);
// then get real control and end point
if (op === "s") {
cx2 = x + args[a];
cy2 = y + args[a + 1];
x += args[a + 2];
y += args[a + 3];
} else {
cx2 = args[a];
cy2 = args[a + 1];
x = args[a + 2];
y = args[a + 3];
}
normalized += "C " + cx + " " + cy + " " + cx2 + " " + cy2 + " " + x + " " + y + " ";
}
}
// rx ry x-axis-rotation large-arc-flag sweep-flag x y
// a 25,25 -30 0, 1 50,-25
// arc command
else if (lop === "a") {
for (a = 0; a < alen; a += 7) {
rx = args[a];
ry = args[a + 1];
xrot = args[a + 2];
lflag = args[a + 3];
sweep = args[a + 4];
if (op === "a") {
x += args[a + 5];
y += args[a + 6];
} else {
x = args[a + 5];
y = args[a + 6];
}
normalized += "A " + rx + " " + ry + " " + xrot + " " + lflag + " " + sweep + " " + x + " " + y + " ";
}
} else if (lop === "z") {
normalized += "Z ";
// not unimportant: path closing changes the current x/y coordinate
x = sx;
y = sy;
}
}
return normalized.trim();
}

View File

@@ -0,0 +1,70 @@
import { utils } from "./utils.js";
/**
* Poly Bezier
* @param {[type]} curves [description]
*/
class PolyBezier {
constructor(curves) {
this.curves = [];
this._3d = false;
if (!!curves) {
this.curves = curves;
this._3d = this.curves[0]._3d;
}
}
valueOf() {
return this.toString();
}
toString() {
return (
"[" +
this.curves
.map(function (curve) {
return utils.pointsToString(curve.points);
})
.join(", ") +
"]"
);
}
addCurve(curve) {
this.curves.push(curve);
this._3d = this._3d || curve._3d;
}
length() {
return this.curves
.map(function (v) {
return v.length();
})
.reduce(function (a, b) {
return a + b;
});
}
curve(idx) {
return this.curves[idx];
}
bbox() {
const c = this.curves;
var bbox = c[0].bbox();
for (var i = 1; i < c.length; i++) {
utils.expandbox(bbox, c[i].bbox());
}
return bbox;
}
offset(d) {
const offset = [];
this.curves.forEach(function (v) {
offset = offset.concat(v.offset(d));
});
return new PolyBezier(offset);
}
}
export { PolyBezier };

View File

@@ -0,0 +1,45 @@
import normalise from "./normalise-svg.js";
let M = { x: false, y: false };
/**
* ...
*/
function makeBezier(Bezier, term, values) {
if (term === "Z") return;
if (term === "M") {
M = { x: values[0], y: values[1] };
return;
}
const curve = new Bezier(M.x, M.y, ...values);
const last = values.slice(-2);
M = { x: last[0], y: last[1] };
return curve;
}
/**
* ...
*/
function convertPath(Bezier, d) {
const terms = normalise(d).split(" "),
matcher = new RegExp("[MLCQZ]", "");
let term,
segment,
values,
segments = [],
ARGS = { C: 6, Q: 4, L: 2, M: 2 };
while (terms.length) {
term = terms.splice(0, 1)[0];
if (matcher.test(term)) {
values = terms.splice(0, ARGS[term]).map(parseFloat);
segment = makeBezier(Bezier, term, values);
if (segment) segments.push(segment);
}
}
return new Bezier.PolyBezier(segments);
}
export { convertPath };

View File

@@ -0,0 +1,872 @@
import { Bezier } from "./bezier.js";
// math-inlining.
const { abs, cos, sin, acos, atan2, sqrt, pow } = Math;
// cube root function yielding real roots
function crt(v) {
return v < 0 ? -pow(-v, 1 / 3) : pow(v, 1 / 3);
}
// trig constants
const pi = Math.PI,
tau = 2 * pi,
quart = pi / 2,
// float precision significant decimal
epsilon = 0.000001,
// extremas used in bbox calculation and similar algorithms
nMax = Number.MAX_SAFE_INTEGER || 9007199254740991,
nMin = Number.MIN_SAFE_INTEGER || -9007199254740991,
// a zero coordinate, which is surprisingly useful
ZERO = { x: 0, y: 0, z: 0 };
// Bezier utility functions
const utils = {
// Legendre-Gauss abscissae with n=24 (x_i values, defined at i=n as the roots of the nth order Legendre polynomial Pn(x))
Tvalues: [
-0.0640568928626056260850430826247450385909,
0.0640568928626056260850430826247450385909,
-0.1911188674736163091586398207570696318404,
0.1911188674736163091586398207570696318404,
-0.3150426796961633743867932913198102407864,
0.3150426796961633743867932913198102407864,
-0.4337935076260451384870842319133497124524,
0.4337935076260451384870842319133497124524,
-0.5454214713888395356583756172183723700107,
0.5454214713888395356583756172183723700107,
-0.6480936519369755692524957869107476266696,
0.6480936519369755692524957869107476266696,
-0.7401241915785543642438281030999784255232,
0.7401241915785543642438281030999784255232,
-0.8200019859739029219539498726697452080761,
0.8200019859739029219539498726697452080761,
-0.8864155270044010342131543419821967550873,
0.8864155270044010342131543419821967550873,
-0.9382745520027327585236490017087214496548,
0.9382745520027327585236490017087214496548,
-0.9747285559713094981983919930081690617411,
0.9747285559713094981983919930081690617411,
-0.9951872199970213601799974097007368118745,
0.9951872199970213601799974097007368118745,
],
// Legendre-Gauss weights with n=24 (w_i values, defined by a function linked to in the Bezier primer article)
Cvalues: [
0.1279381953467521569740561652246953718517,
0.1279381953467521569740561652246953718517,
0.1258374563468282961213753825111836887264,
0.1258374563468282961213753825111836887264,
0.121670472927803391204463153476262425607,
0.121670472927803391204463153476262425607,
0.1155056680537256013533444839067835598622,
0.1155056680537256013533444839067835598622,
0.1074442701159656347825773424466062227946,
0.1074442701159656347825773424466062227946,
0.0976186521041138882698806644642471544279,
0.0976186521041138882698806644642471544279,
0.086190161531953275917185202983742667185,
0.086190161531953275917185202983742667185,
0.0733464814110803057340336152531165181193,
0.0733464814110803057340336152531165181193,
0.0592985849154367807463677585001085845412,
0.0592985849154367807463677585001085845412,
0.0442774388174198061686027482113382288593,
0.0442774388174198061686027482113382288593,
0.0285313886289336631813078159518782864491,
0.0285313886289336631813078159518782864491,
0.0123412297999871995468056670700372915759,
0.0123412297999871995468056670700372915759,
],
arcfn: function (t, derivativeFn) {
const d = derivativeFn(t);
let l = d.x * d.x + d.y * d.y;
if (typeof d.z !== "undefined") {
l += d.z * d.z;
}
return sqrt(l);
},
compute: function (t, points, _3d) {
// shortcuts
if (t === 0) {
points[0].t = 0;
return points[0];
}
const order = points.length - 1;
if (t === 1) {
points[order].t = 1;
return points[order];
}
const mt = 1 - t;
let p = points;
// constant?
if (order === 0) {
points[0].t = t;
return points[0];
}
// linear?
if (order === 1) {
const ret = {
x: mt * p[0].x + t * p[1].x,
y: mt * p[0].y + t * p[1].y,
t: t,
};
if (_3d) {
ret.z = mt * p[0].z + t * p[1].z;
}
return ret;
}
// quadratic/cubic curve?
if (order < 4) {
let mt2 = mt * mt,
t2 = t * t,
a,
b,
c,
d = 0;
if (order === 2) {
p = [p[0], p[1], p[2], ZERO];
a = mt2;
b = mt * t * 2;
c = t2;
} else if (order === 3) {
a = mt2 * mt;
b = mt2 * t * 3;
c = mt * t2 * 3;
d = t * t2;
}
const ret = {
x: a * p[0].x + b * p[1].x + c * p[2].x + d * p[3].x,
y: a * p[0].y + b * p[1].y + c * p[2].y + d * p[3].y,
t: t,
};
if (_3d) {
ret.z = a * p[0].z + b * p[1].z + c * p[2].z + d * p[3].z;
}
return ret;
}
// higher order curves: use de Casteljau's computation
const dCpts = JSON.parse(JSON.stringify(points));
while (dCpts.length > 1) {
for (let i = 0; i < dCpts.length - 1; i++) {
dCpts[i] = {
x: dCpts[i].x + (dCpts[i + 1].x - dCpts[i].x) * t,
y: dCpts[i].y + (dCpts[i + 1].y - dCpts[i].y) * t,
};
if (typeof dCpts[i].z !== "undefined") {
dCpts[i] = dCpts[i].z + (dCpts[i + 1].z - dCpts[i].z) * t;
}
}
dCpts.splice(dCpts.length - 1, 1);
}
dCpts[0].t = t;
return dCpts[0];
},
computeWithRatios: function (t, points, ratios, _3d) {
const mt = 1 - t,
r = ratios,
p = points;
let f1 = r[0],
f2 = r[1],
f3 = r[2],
f4 = r[3],
d;
// spec for linear
f1 *= mt;
f2 *= t;
if (p.length === 2) {
d = f1 + f2;
return {
x: (f1 * p[0].x + f2 * p[1].x) / d,
y: (f1 * p[0].y + f2 * p[1].y) / d,
z: !_3d ? false : (f1 * p[0].z + f2 * p[1].z) / d,
t: t,
};
}
// upgrade to quadratic
f1 *= mt;
f2 *= 2 * mt;
f3 *= t * t;
if (p.length === 3) {
d = f1 + f2 + f3;
return {
x: (f1 * p[0].x + f2 * p[1].x + f3 * p[2].x) / d,
y: (f1 * p[0].y + f2 * p[1].y + f3 * p[2].y) / d,
z: !_3d ? false : (f1 * p[0].z + f2 * p[1].z + f3 * p[2].z) / d,
t: t,
};
}
// upgrade to cubic
f1 *= mt;
f2 *= 1.5 * mt;
f3 *= 3 * mt;
f4 *= t * t * t;
if (p.length === 4) {
d = f1 + f2 + f3 + f4;
return {
x: (f1 * p[0].x + f2 * p[1].x + f3 * p[2].x + f4 * p[3].x) / d,
y: (f1 * p[0].y + f2 * p[1].y + f3 * p[2].y + f4 * p[3].y) / d,
z: !_3d ? false : (f1 * p[0].z + f2 * p[1].z + f3 * p[2].z + f4 * p[3].z) / d,
t: t,
};
}
},
derive: function (points, _3d) {
const dpoints = [];
for (let p = points, d = p.length, c = d - 1; d > 1; d--, c--) {
const list = [];
for (let j = 0, dpt; j < c; j++) {
dpt = {
x: c * (p[j + 1].x - p[j].x),
y: c * (p[j + 1].y - p[j].y),
};
if (_3d) {
dpt.z = c * (p[j + 1].z - p[j].z);
}
list.push(dpt);
}
dpoints.push(list);
p = list;
}
return dpoints;
},
between: function (v, m, M) {
return (m <= v && v <= M) || utils.approximately(v, m) || utils.approximately(v, M);
},
approximately: function (a, b, precision) {
return abs(a - b) <= (precision || epsilon);
},
length: function (derivativeFn) {
const z = 0.5,
len = utils.Tvalues.length;
let sum = 0;
for (let i = 0, t; i < len; i++) {
t = z * utils.Tvalues[i] + z;
sum += utils.Cvalues[i] * utils.arcfn(t, derivativeFn);
}
return z * sum;
},
map: function (v, ds, de, ts, te) {
const d1 = de - ds,
d2 = te - ts,
v2 = v - ds,
r = v2 / d1;
return ts + d2 * r;
},
lerp: function (r, v1, v2) {
const ret = {
x: v1.x + r * (v2.x - v1.x),
y: v1.y + r * (v2.y - v1.y),
};
if (!!v1.z && !!v2.z) {
ret.z = v1.z + r * (v2.z - v1.z);
}
return ret;
},
pointToString: function (p) {
let s = p.x + "/" + p.y;
if (typeof p.z !== "undefined") {
s += "/" + p.z;
}
return s;
},
pointsToString: function (points) {
return "[" + points.map(utils.pointToString).join(", ") + "]";
},
copy: function (obj) {
return JSON.parse(JSON.stringify(obj));
},
angle: function (o, v1, v2) {
const dx1 = v1.x - o.x,
dy1 = v1.y - o.y,
dx2 = v2.x - o.x,
dy2 = v2.y - o.y,
cross = dx1 * dy2 - dy1 * dx2,
dot = dx1 * dx2 + dy1 * dy2;
return atan2(cross, dot);
},
// round as string, to avoid rounding errors
round: function (v, d) {
const s = "" + v;
const pos = s.indexOf(".");
return parseFloat(s.substring(0, pos + 1 + d));
},
dist: function (p1, p2) {
const dx = p1.x - p2.x,
dy = p1.y - p2.y;
return sqrt(dx * dx + dy * dy);
},
closest: function (LUT, point) {
let mdist = pow(2, 63),
mpos,
d;
LUT.forEach(function (p, idx) {
d = utils.dist(point, p);
if (d < mdist) {
mdist = d;
mpos = idx;
}
});
return { mdist: mdist, mpos: mpos };
},
abcratio: function (t, n) {
// see ratio(t) note on http://pomax.github.io/bezierinfo/#abc
if (n !== 2 && n !== 3) {
return false;
}
if (typeof t === "undefined") {
t = 0.5;
} else if (t === 0 || t === 1) {
return t;
}
const bottom = pow(t, n) + pow(1 - t, n),
top = bottom - 1;
return abs(top / bottom);
},
projectionratio: function (t, n) {
// see u(t) note on http://pomax.github.io/bezierinfo/#abc
if (n !== 2 && n !== 3) {
return false;
}
if (typeof t === "undefined") {
t = 0.5;
} else if (t === 0 || t === 1) {
return t;
}
const top = pow(1 - t, n),
bottom = pow(t, n) + top;
return top / bottom;
},
lli8: function (x1, y1, x2, y2, x3, y3, x4, y4) {
const nx = (x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4),
ny = (x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4),
d = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
if (d == 0) {
return false;
}
return { x: nx / d, y: ny / d };
},
lli4: function (p1, p2, p3, p4) {
const x1 = p1.x,
y1 = p1.y,
x2 = p2.x,
y2 = p2.y,
x3 = p3.x,
y3 = p3.y,
x4 = p4.x,
y4 = p4.y;
return utils.lli8(x1, y1, x2, y2, x3, y3, x4, y4);
},
lli: function (v1, v2) {
return utils.lli4(v1, v1.c, v2, v2.c);
},
makeline: function (p1, p2) {
const x1 = p1.x,
y1 = p1.y,
x2 = p2.x,
y2 = p2.y,
dx = (x2 - x1) / 3,
dy = (y2 - y1) / 3;
return new Bezier(x1, y1, x1 + dx, y1 + dy, x1 + 2 * dx, y1 + 2 * dy, x2, y2);
},
findbbox: function (sections) {
let mx = nMax,
my = nMax,
MX = nMin,
MY = nMin;
sections.forEach(function (s) {
const bbox = s.bbox();
if (mx > bbox.x.min) mx = bbox.x.min;
if (my > bbox.y.min) my = bbox.y.min;
if (MX < bbox.x.max) MX = bbox.x.max;
if (MY < bbox.y.max) MY = bbox.y.max;
});
return {
x: { min: mx, mid: (mx + MX) / 2, max: MX, size: MX - mx },
y: { min: my, mid: (my + MY) / 2, max: MY, size: MY - my },
};
},
shapeintersections: function (s1, bbox1, s2, bbox2, curveIntersectionThreshold) {
if (!utils.bboxoverlap(bbox1, bbox2)) return [];
const intersections = [];
const a1 = [s1.startcap, s1.forward, s1.back, s1.endcap];
const a2 = [s2.startcap, s2.forward, s2.back, s2.endcap];
a1.forEach(function (l1) {
if (l1.virtual) return;
a2.forEach(function (l2) {
if (l2.virtual) return;
const iss = l1.intersects(l2, curveIntersectionThreshold);
if (iss.length > 0) {
iss.c1 = l1;
iss.c2 = l2;
iss.s1 = s1;
iss.s2 = s2;
intersections.push(iss);
}
});
});
return intersections;
},
makeshape: function (forward, back, curveIntersectionThreshold) {
const bpl = back.points.length;
const fpl = forward.points.length;
const start = utils.makeline(back.points[bpl - 1], forward.points[0]);
const end = utils.makeline(forward.points[fpl - 1], back.points[0]);
const shape = {
startcap: start,
forward: forward,
back: back,
endcap: end,
bbox: utils.findbbox([start, forward, back, end]),
};
shape.intersections = function (s2) {
return utils.shapeintersections(shape, shape.bbox, s2, s2.bbox, curveIntersectionThreshold);
};
return shape;
},
getminmax: function (curve, d, list) {
if (!list) return { min: 0, max: 0 };
let min = nMax,
max = nMin,
t,
c;
if (list.indexOf(0) === -1) {
list = [0].concat(list);
}
if (list.indexOf(1) === -1) {
list.push(1);
}
for (let i = 0, len = list.length; i < len; i++) {
t = list[i];
c = curve.get(t);
if (c[d] < min) {
min = c[d];
}
if (c[d] > max) {
max = c[d];
}
}
return { min: min, mid: (min + max) / 2, max: max, size: max - min };
},
align: function (points, line) {
const tx = line.p1.x,
ty = line.p1.y,
a = -atan2(line.p2.y - ty, line.p2.x - tx),
d = function (v) {
return {
x: (v.x - tx) * cos(a) - (v.y - ty) * sin(a),
y: (v.x - tx) * sin(a) + (v.y - ty) * cos(a),
};
};
return points.map(d);
},
roots: function (points, line) {
line = line || { p1: { x: 0, y: 0 }, p2: { x: 1, y: 0 } };
const order = points.length - 1;
const aligned = utils.align(points, line);
const reduce = function (t) {
return 0 <= t && t <= 1;
};
if (order === 2) {
const a = aligned[0].y,
b = aligned[1].y,
c = aligned[2].y,
d = a - 2 * b + c;
if (d !== 0) {
const m1 = -sqrt(b * b - a * c),
m2 = -a + b,
v1 = -(m1 + m2) / d,
v2 = -(-m1 + m2) / d;
return [v1, v2].filter(reduce);
} else if (b !== c && d === 0) {
return [(2 * b - c) / (2 * b - 2 * c)].filter(reduce);
}
return [];
}
// see http://www.trans4mind.com/personal_development/mathematics/polynomials/cubicAlgebra.htm
const pa = aligned[0].y,
pb = aligned[1].y,
pc = aligned[2].y,
pd = aligned[3].y;
let d = -pa + 3 * pb - 3 * pc + pd,
a = 3 * pa - 6 * pb + 3 * pc,
b = -3 * pa + 3 * pb,
c = pa;
if (utils.approximately(d, 0)) {
// this is not a cubic curve.
if (utils.approximately(a, 0)) {
// in fact, this is not a quadratic curve either.
if (utils.approximately(b, 0)) {
// in fact in fact, there are no solutions.
return [];
}
// linear solution:
return [-c / b].filter(reduce);
}
// quadratic solution:
const q = sqrt(b * b - 4 * a * c),
a2 = 2 * a;
return [(q - b) / a2, (-b - q) / a2].filter(reduce);
}
// at this point, we know we need a cubic solution:
a /= d;
b /= d;
c /= d;
const p = (3 * b - a * a) / 3,
p3 = p / 3,
q = (2 * a * a * a - 9 * a * b + 27 * c) / 27,
q2 = q / 2,
discriminant = q2 * q2 + p3 * p3 * p3;
let u1, v1, x1, x2, x3;
if (discriminant < 0) {
const mp3 = -p / 3,
mp33 = mp3 * mp3 * mp3,
r = sqrt(mp33),
t = -q / (2 * r),
cosphi = t < -1 ? -1 : t > 1 ? 1 : t,
phi = acos(cosphi),
crtr = crt(r),
t1 = 2 * crtr;
x1 = t1 * cos(phi / 3) - a / 3;
x2 = t1 * cos((phi + tau) / 3) - a / 3;
x3 = t1 * cos((phi + 2 * tau) / 3) - a / 3;
return [x1, x2, x3].filter(reduce);
} else if (discriminant === 0) {
u1 = q2 < 0 ? crt(-q2) : -crt(q2);
x1 = 2 * u1 - a / 3;
x2 = -u1 - a / 3;
return [x1, x2].filter(reduce);
} else {
const sd = sqrt(discriminant);
u1 = crt(-q2 + sd);
v1 = crt(q2 + sd);
return [u1 - v1 - a / 3].filter(reduce);
}
},
droots: function (p) {
// quadratic roots are easy
if (p.length === 3) {
const a = p[0],
b = p[1],
c = p[2],
d = a - 2 * b + c;
if (d !== 0) {
const m1 = -sqrt(b * b - a * c),
m2 = -a + b,
v1 = -(m1 + m2) / d,
v2 = -(-m1 + m2) / d;
return [v1, v2];
} else if (b !== c && d === 0) {
return [(2 * b - c) / (2 * (b - c))];
}
return [];
}
// linear roots are even easier
if (p.length === 2) {
const a = p[0],
b = p[1];
if (a !== b) {
return [a / (a - b)];
}
return [];
}
return [];
},
curvature: function (t, d1, d2, _3d, kOnly) {
let num,
dnm,
adk,
dk,
k = 0,
r = 0;
//
// We're using the following formula for curvature:
//
// x'y" - y'x"
// k(t) = ------------------
// (x'² + y'²)^(3/2)
//
// from https://en.wikipedia.org/wiki/Radius_of_curvature#Definition
//
// With it corresponding 3D counterpart:
//
// sqrt( (y'z" - y"z')² + (z'x" - z"x')² + (x'y" - x"y')²)
// k(t) = -------------------------------------------------------
// (x'² + y'² + z'²)^(3/2)
//
const d = utils.compute(t, d1);
const dd = utils.compute(t, d2);
const qdsum = d.x * d.x + d.y * d.y;
if (_3d) {
num = sqrt(pow(d.y * dd.z - dd.y * d.z, 2) + pow(d.z * dd.x - dd.z * d.x, 2) + pow(d.x * dd.y - dd.x * d.y, 2));
dnm = pow(qdsum + d.z * d.z, 3 / 2);
} else {
num = d.x * dd.y - d.y * dd.x;
dnm = pow(qdsum, 3 / 2);
}
if (num === 0 || dnm === 0) {
return { k: 0, r: 0 };
}
k = num / dnm;
r = dnm / num;
// We're also computing the derivative of kappa, because
// there is value in knowing the rate of change for the
// curvature along the curve. And we're just going to
// ballpark it based on an epsilon.
if (!kOnly) {
// compute k'(t) based on the interval before, and after it,
// to at least try to not introduce forward/backward pass bias.
const pk = utils.curvature(t - 0.001, d1, d2, _3d, true).k;
const nk = utils.curvature(t + 0.001, d1, d2, _3d, true).k;
dk = (nk - k + (k - pk)) / 2;
adk = (abs(nk - k) + abs(k - pk)) / 2;
}
return { k: k, r: r, dk: dk, adk: adk };
},
inflections: function (points) {
if (points.length < 4) return [];
// FIXME: TODO: add in inflection abstraction for quartic+ curves?
const p = utils.align(points, { p1: points[0], p2: points.slice(-1)[0] }),
a = p[2].x * p[1].y,
b = p[3].x * p[1].y,
c = p[1].x * p[2].y,
d = p[3].x * p[2].y,
v1 = 18 * (-3 * a + 2 * b + 3 * c - d),
v2 = 18 * (3 * a - b - 3 * c),
v3 = 18 * (c - a);
if (utils.approximately(v1, 0)) {
if (!utils.approximately(v2, 0)) {
let t = -v3 / v2;
if (0 <= t && t <= 1) return [t];
}
return [];
}
const trm = v2 * v2 - 4 * v1 * v3,
sq = Math.sqrt(trm),
d2 = 2 * v1;
if (utils.approximately(d2, 0)) return [];
return [(sq - v2) / d2, -(v2 + sq) / d2].filter(function (r) {
return 0 <= r && r <= 1;
});
},
bboxoverlap: function (b1, b2) {
const dims = ["x", "y"],
len = dims.length;
for (let i = 0, dim, l, t, d; i < len; i++) {
dim = dims[i];
l = b1[dim].mid;
t = b2[dim].mid;
d = (b1[dim].size + b2[dim].size) / 2;
if (abs(l - t) >= d) return false;
}
return true;
},
expandbox: function (bbox, _bbox) {
if (_bbox.x.min < bbox.x.min) {
bbox.x.min = _bbox.x.min;
}
if (_bbox.y.min < bbox.y.min) {
bbox.y.min = _bbox.y.min;
}
if (_bbox.z && _bbox.z.min < bbox.z.min) {
bbox.z.min = _bbox.z.min;
}
if (_bbox.x.max > bbox.x.max) {
bbox.x.max = _bbox.x.max;
}
if (_bbox.y.max > bbox.y.max) {
bbox.y.max = _bbox.y.max;
}
if (_bbox.z && _bbox.z.max > bbox.z.max) {
bbox.z.max = _bbox.z.max;
}
bbox.x.mid = (bbox.x.min + bbox.x.max) / 2;
bbox.y.mid = (bbox.y.min + bbox.y.max) / 2;
if (bbox.z) {
bbox.z.mid = (bbox.z.min + bbox.z.max) / 2;
}
bbox.x.size = bbox.x.max - bbox.x.min;
bbox.y.size = bbox.y.max - bbox.y.min;
if (bbox.z) {
bbox.z.size = bbox.z.max - bbox.z.min;
}
},
pairiteration: function (c1, c2, curveIntersectionThreshold) {
const c1b = c1.bbox(),
c2b = c2.bbox(),
r = 100000,
threshold = curveIntersectionThreshold || 0.5;
if (c1b.x.size + c1b.y.size < threshold && c2b.x.size + c2b.y.size < threshold) {
return [(((r * (c1._t1 + c1._t2)) / 2) | 0) / r + "/" + (((r * (c2._t1 + c2._t2)) / 2) | 0) / r];
}
let cc1 = c1.split(0.5),
cc2 = c2.split(0.5),
pairs = [
{ left: cc1.left, right: cc2.left },
{ left: cc1.left, right: cc2.right },
{ left: cc1.right, right: cc2.right },
{ left: cc1.right, right: cc2.left },
];
pairs = pairs.filter(function (pair) {
return utils.bboxoverlap(pair.left.bbox(), pair.right.bbox());
});
let results = [];
if (pairs.length === 0) return results;
pairs.forEach(function (pair) {
results = results.concat(utils.pairiteration(pair.left, pair.right, threshold));
});
results = results.filter(function (v, i) {
return results.indexOf(v) === i;
});
return results;
},
getccenter: function (p1, p2, p3) {
const dx1 = p2.x - p1.x,
dy1 = p2.y - p1.y,
dx2 = p3.x - p2.x,
dy2 = p3.y - p2.y,
dx1p = dx1 * cos(quart) - dy1 * sin(quart),
dy1p = dx1 * sin(quart) + dy1 * cos(quart),
dx2p = dx2 * cos(quart) - dy2 * sin(quart),
dy2p = dx2 * sin(quart) + dy2 * cos(quart),
// chord midpoints
mx1 = (p1.x + p2.x) / 2,
my1 = (p1.y + p2.y) / 2,
mx2 = (p2.x + p3.x) / 2,
my2 = (p2.y + p3.y) / 2,
// midpoint offsets
mx1n = mx1 + dx1p,
my1n = my1 + dy1p,
mx2n = mx2 + dx2p,
my2n = my2 + dy2p,
// intersection of these lines:
arc = utils.lli8(mx1, my1, mx1n, my1n, mx2, my2, mx2n, my2n),
r = utils.dist(arc, p1);
// arc start/end values, over mid point:
let s = atan2(p1.y - arc.y, p1.x - arc.x),
m = atan2(p2.y - arc.y, p2.x - arc.x),
e = atan2(p3.y - arc.y, p3.x - arc.x),
_;
// determine arc direction (cw/ccw correction)
if (s < e) {
// if s<m<e, arc(s, e)
// if m<s<e, arc(e, s + tau)
// if s<e<m, arc(e, s + tau)
if (s > m || m > e) {
s += tau;
}
if (s > e) {
_ = e;
e = s;
s = _;
}
} else {
// if e<m<s, arc(e, s)
// if m<e<s, arc(s, e + tau)
// if e<s<m, arc(s, e + tau)
if (e < m && m < s) {
_ = e;
e = s;
s = _;
} else {
e += tau;
}
}
// assign and done.
arc.s = s;
arc.e = e;
arc.r = r;
return arc;
},
numberSort: function (a, b) {
return a - b;
},
};
export { utils };

View File

@@ -0,0 +1,27 @@
import { enrich } from "./enrich.js";
const noop = () => {};
function create(tag) {
if (typeof document !== `undefined`) {
return enrich(document.createElement(tag));
}
const element = {
name: tag,
tag: tag.toUpperCase(),
append: noop,
appendChild: noop,
replaceChild: noop,
removeChild: noop,
classList: {
add: noop,
remove: noop,
},
children: [],
};
return element;
}
export { create };

View File

@@ -0,0 +1,30 @@
function enrich(element) {
if (!element) return element;
element.__listeners = {};
element.listen = function (evtNames, handler) {
if (!evtNames.map) evtNames = [evtNames];
evtNames.forEach((evtName) => {
element.addEventListener(evtName, handler);
if (!element.__listeners[evtName]) {
element.__listeners[evtName] = [];
}
element.__listeners[evtName].push(handler);
});
}.bind(element);
element.ignore = function (evtNames, handler) {
if (!evtNames.map) evtNames = [evtNames];
evtNames.forEach((evtName) => {
if (!handler) {
return element.__listeners[evtName].forEach((h) => element.removeEventListener(evtName, h));
}
element.removeEventListener(evtName, handler);
});
}.bind(element);
return element;
}
export { enrich };

View File

@@ -0,0 +1,108 @@
// TODO: FIXME: finish writing out this functionality
/**
Scope 0:
check for variable declaration/assignment, as well as function
declarations _without_ the `function` keyword
Scope 1+:
check any not-namespaced function calls to see whether they map
to any API functions. If they do, they should be prefixed with
`this.`
check any not-namespaced var references to see whether they map
to any predefined API vars. If they do, they should be prefixed
with `this.`
**/
function splitSymbols(v) {
if (v.match(/\w/)) return v;
return v.split(``);
}
class Lexer {
constructor(code) {
this.scope = 0;
this.pos = 0;
this.tokens = code.split(/\b/).map(splitSymbols).flat();
this.scopes = [];
console.log(this.tokens);
}
parse() {
while (this.pos < this.tokens.length) {
let token = this.tokens[this.pos++];
if ([`const`, `let`, "var"].includes(token)) {
this.parseVariable(token);
}
// skip over strings so we don't treat them as active content
else if ([`'`, `"`, "`"].includes(token)) {
this.parseString(token);
}
// figure out if
else if (token === `(`) {
let functor,
i = 2;
do {
functor = this.tokens[this.pos - i++];
} while (functor.match(/\s+/));
// TODO: maths is fun?
console.log(`[${this.scope}]: ${functor}(...`);
} else if (token === `)`) {
}
// ...
else if (token === `{`) {
this.scopes[this.pos] = ++this.scope;
}
// ...
else if (token === `}`) {
this.scopes[this.pos] = --this.scope;
}
}
console.log(this.scopes);
}
parseVariable(type) {
let name;
do {
name = this.tokens[this.pos++];
} while (name.match(/\s+/));
console.log(`[${this.scope}]: ${type} ${name}`);
}
parseString(symbol) {
// we technically don't really care about the contents
// of strings, as they don't introduce new variables
// or functions that we need to care about.
let token;
let buffer = [symbol];
let blen = 1;
do {
token = this.tokens[this.pos++];
buffer.push(token);
blen++;
} while (token !== symbol && buffer[blen - 2] !== `\\` && this.pos < this.tokens.length);
// buffer = buffer.join(``);
// if (symbol === "`") {
// this.parseTemplateString(buffer);
// }
}
// parseTemplateString(buffer) {
// // console.log(buffer);
// }
}
export { Lexer };

View File

@@ -0,0 +1,37 @@
import { GraphicsAPI } from "../api/graphics-api.js";
export default function performCodeSurgery(code) {
// 0. strip out superfluous whitespace
code = code.replace(/\r?\n(\r?\n)+/, `\n`);
// 1. ensure that anything that needs to run by first calling its super function, does so.
GraphicsAPI.superCallers.forEach((name) => {
const re = new RegExp(`${name}\\(([^)]*)\\)[\\s\\r\\n]*{[\\s\\r\\n]*`, `g`);
code = code.replace(re, `${name}($1) { super.${name}($1);\n`);
});
// 2. rewrite event handlers so that they capture the event and forward it to the super function.
GraphicsAPI.eventHandlers.forEach((name) => {
const re = new RegExp(`\\b${name}\\(\\)[\\s\\r\\n]*{[\\s\\r\\n]*`, `g`);
code = code.replace(re, `${name}(evt) { super.${name}(evt);\n`);
});
// 3. rewrite all public GraphicsAPI functions to have the required `this.` prefix
GraphicsAPI.methods.forEach((fn) => {
const re = new RegExp(`([!({\\s\\r\\n])${fn}\\(`, `g`);
code = code.replace(re, `$1this.${fn}(`);
});
// 4. do the same for all GraphicsAPI constants.
GraphicsAPI.constants.forEach((name) => {
const re = new RegExp(`(\\b)${name}(\\b)`, `g`);
code = code.replace(re, `$1this.${name}$2`);
});
return code;
}

View File

@@ -0,0 +1,43 @@
/**
* Get all code that isn't contained in functions.
* We're going to regexp our way to flawed victory here.
*/
export default function splitCodeSections(code) {
// removs comments and superfluous white space.
code = code.replace(/\\\*[\w\s\r\n]+?\*\\/, ``);
code = code.replace(/\r?\n(\r?\n)+/, `\n`);
const re = /\b[\w\W][^\s]*?\([^)]*\)[\r\n\s]*{/;
const cuts = [];
for (let result = code.match(re); result; result = code.match(re)) {
result = result[0];
let start = code.indexOf(result);
let end = start + result.length;
let depth = 0;
let slice = Array.from(code).slice(start + result.length);
slice.some((c, pos) => {
if (c === `{`) {
depth++;
return false;
}
if (c === `}`) {
if (depth > 0) {
depth--;
return false;
}
end += pos + 1;
return true;
}
});
let cut = code.slice(start, end);
cuts.push(cut);
code = code.replace(cut, ``);
}
return {
quasiGlobal: code,
classCode: cuts.join(`\n`),
};
}