@mvarble/viewport.js
v1.3.9
Published
Canvas rendering in Cycle.js
Downloads
7
Readme
viewport.js
Declarative Cycle.js canvas rendering.
Introduction
It seems like Cycle.js does not have an API for declarative canvas rendering, so this package creates a little API for doing so. The package also provides utilities for detecting clicks on the canvas and feeding them back into the application. It does so by assuming that the canvas render is dependent on a frames.js state in which the world coordinates are those of the canvas.
Example
An example of a draggable box is done below.
Note that we simply create an initial frame initial
, the imperative rendering function render
, and a reducer update
;
from here, all of the intent of mouse clicks and rendering are handled with a concise API.
const { run } = require('@cycle/run');
const { h, makeDOMDriver } = require('@cycle/dom');
const { withState } = require('@cycle/state');
const {
Viewport,
ViewportDriver,
createDrag,
} = require('@mvarble/viewport.js');
const {
locsFrameTrans,
identityFrame,
translatedFrame,
} = require('@mvarble/frames.js');
const initial = {
type: 'draggable-box',
worldMatrix: [[10, 0, 250], [0, -10, 250], [0, 0, 1]],
data: { clickBox: [-1, -1, 1, 1] },
}
function render(canvas, state) {
const context = canvas.getContext('2d');
context.clearRect(0, 0, 500, 500);
const points = locsFrameTrans(
[[-1, -1], [1, -1], [1, 1], [-1, 1]],
state,
identityFrame
);
context.fillStyle = 'red';
context.beginPath();
context.moveTo(...points[0]);
[1, 2, 3, 0].forEach(i => { context.lineTo(...points[i]); });
context.fill();
context.stroke();
}
function update(state, event) {
return translatedFrame(
state,
[event.movementX, event.movementY],
identityFrame
);
}
function main({ DOM, viewport, state }) {
// intent
const onBox$ = viewport.mount(DOM.select('canvas'))
.select(frame => frame)
.events('mousedown');
const drag$ = createDrag(onBox$).flatten();
// model
const reducer$ = drag$
.map(event => (state => update(state, event)))
.startWith(() => initial);
// view
const viewportSink = Viewport({
state: state.stream,
canvas: xs.of(h('canvas', { attrs: { width: 500, height: 500 } })),
render: xs.of(render),
});
return {
DOM: viewportSink.DOM,
viewport: viewportSink.viewport,
state: reducer$,
};
}
run(withState(main), {
DOM: makeDOMDriver('#app'),
viewport: ViewportDriver,
});
API
Below we explain the API we use in examples like above. Throughout, we refer to the viewport as the interactive canvas.
Viewport
viewportSink = Viewport({ state, canvas, render, isDeep })
This is a Cycle.js component that takes a state stream state
, a snabbdom stream canvas
, and a stream of imperative render functions render
.
With these streams, it returns sink viewportSink
with streams viewportSink.viewport
and viewportSink.DOM
.
The viewportSink.viewport
stream exists to declare the render state for the ViewportDriver.
The viewportSink.DOM
stream is nothing but the original stream canvas
, but an insert hook is appended for each update of canvas
and render
so that the appropriate render will occur on insertion.
This stream is to be used to build the DOM (as opposed to the original canvas
stream) so that the render will still occur if the DOM element is not created by the time the viewportSink.viewport
stream hits the ViewportDriver
.
ViewportDriver
frameSource = ViewportDriver(viewport$)
This is a Cycle.js driver that takes a stream of the declared render data, performs the imperative render, and returns an object of queryable streams.
The input stream viewport$
above is a stream of objects with the declared state of the viewport, the appropriate imperative render function, and the DOM handle
You need not create these streams by hand; you should let the Viewport component handle generating these streams, with viewport$ = viewportSink.viewport
.
The output object frameSource
is an instance of the UnmountedFrameSource
class.
If you would like to parse clicks on the canvas, you must start with leveraging the event queries of the DOMDriver
by doing frameSource.mount(DOM.select(cssSelector))
, where cssSelector
is a tag suitable for identifying the canvas element on which you rendered, and DOM
is a reference to the MainDOMSource
exported by the DOMDriver
.
The UnmountedFrameSource.mount
method returns a (mounted) FrameSource, which can be used for parsing clicks on the canvas.
FrameSource
The FrameSource
has two methods of interest, select
and events
.
select, events
frameSource.select(selector).events(eventType)
By calling frameSource.select(selector)
for some function selector
of signature frame || undefined => bool
, you will get a new frameSource
with the selector for future calls to events
.
Once we have a FrameSource
with a bunch of selectors, calling the method frameSource.events(eventType)
will return a stream corresponding to domSource.events(eventType)
, along with a two of things:
- Each event will have the attribute
event.frame
appended to it, corresponding to the frame that was clicked on (as according to getOver). - The stream will be filtered to only include events that
event.frame
passes the selector built fromFrameSource.select
.
It is with these methods that you can parse the clicks on the canvas. For instance, the example below creates click streams dependent on the nature of the object that was clicked on.
const justCanvas$ = frameSource.select(f => !f).events('click');
const boxes$ = frameSource.select(f => f && f.type === 'box').events('click');
const disks$ = frameSource.select(f => f && f.type === 'disk').events('click');
isolation
Each FrameSource
instance has isolateSource/isolateSink
static functions which can be used in isolation in your apps.
It is intentionally set so that mounted DOMSource
will not be isolated.
For instance, the following app design would be such that SomeComponent
sees the clicks of the canvas element.
To not have this behavior, simply pass the UnmountedFrameSource
instance and mount the isolated DOM
in the component itself.
function app({ viewport, DOM }) {
const frame$ = xs.of(someFrame)
const frameSource = viewport.mount(DOM.select('canvas'));
const clicks$ = isolate(SomeComponent)({ frameSource, frame: state$ });
return {
DOM: xs.of(h('canvas')),
log: clicks$,
}
}
function SomeComponent({ frameSource, frame }) {
// this will still trigger
return frameSource.select(() => true).events('click');
}
Instead, isolation is done so that merged viewportSink.viewport
streams can later be parsed by the ViewportDriver and subsequent isolated streams may be returned.
Additionally, it is reasonable to design components that do not create their own canvas, but rather use the streams from a FrameSource
to declare new state updates / render functions.
We would like these objects to have their own isolation, despite having their own viewports.
To this end, we decided to have two types of isolation: (1) isolating separate viewports and (2) isolating components/frames within a viewport.
For (1), use strings as your isolation keys, while (2) should be an object of signature { key: anyComparableValue }
.
See the example here to see how to interface with the Cycle.js makeCollection API to have collections of component-declared frames for building big unist trees.
Utilities
Along with the component/driver pair that this package exports, we also have a couple of utilities that are repeatedly used in parsing clicks and resizes on the viewport.
isOver
bool = isOver(event, frame)
We consider frame
clickable if it has any of the following four attributes.
frame.data.clickBox
: Should be an array[mx, my, Mx, My]
corresponding to a rectangle with vertices[mx, my]
and[Mx, My]
inframe
coordinates.frame.data.clickBoxes
Should be an array of arrays of the type above.frame.data.clickDisk
: Should be an array[h, k, r]
corresponding to a center[h, k]
and a radiusr
of a disk inframe
coordinates.frame.data.clickDisks
: Should be an array of arrays of the type above.
This function checks each of these attributes of a clickable frame
and sees if the location of the mouse in the associated objects.
The function returns true if and only if the mouse pointer is in at least one object.
getOver
overObject = getOver(event, frame, deep)
This function will perform a breadth-first traversal through the inclusive descendants of frame
and find one which satisfies the isOver condition.
If no such descendant exists, it returns { frame: null, treeKeys: [] }
.
If deep
is false, the function will return { frame: someFrame, treeKeys: [k1, ..., kn] }
, where someFrame
is the first frame that passed the isOver
condition and [k1, ..., kn]
is a list of the ancestor.key
properties for inclusive ancestor of someFrame
.
If deep
is true, the function will return the same data type, but someFrame
represents the farthest sibling down the branch with which getOver
was repeatedly called for each successful frame in the deep=false
version.