UNPKG

@mvarble/viewport.js

Version:
215 lines (164 loc) 9.64 kB
# viewport.js Declarative Cycle.js canvas rendering. ## Introduction It seems like [Cycle.js](https://cycle.js.org/) 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](https://github.com/mvarble/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. ```js 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 ```js 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](#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 ```js 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](#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](#framesource), which can be used for parsing clicks on the canvas. ### FrameSource The `FrameSource` has two methods of interest, `select` and `events`. #### select, events ```js 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: 1. Each event will have the attribute `event.frame` appended to it, corresponding to the frame that was clicked on (as according to [getOver](#getover)). 2. The stream will be filtered to only include events that `event.frame` passes the selector built from `FrameSource.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. ```js 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. ```js 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](https://github.com/mvarble/viewport.js/blob/master/example/app3.js) to see how to interface with the [Cycle.js makeCollection API](https://cycle.js.org/api/state.html#cycle-state-source-usage-how-to-handle-a-dynamic-list-of-nested-components) 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 ```js 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]` in `frame` 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 radius `r` of a disk in `frame` 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 ```js 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](#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.