tapspace
Version:
A zoomable user interface lib for web apps
264 lines (248 loc) • 8.92 kB
JavaScript
const emitter = require('component-emitter')
const TreeLoader = function (config) {
// @TreeLoader(config)
//
// An asynchronous and recursive loader for your tree-structured content.
// Use this to build infinite or very deep, zoomable web applications
// to overcome limits of floating point arithmetics.
//
// To setup the loader, you need to implement a few functions:
// mapper, backmapper, tracker, and backtracker.
// The mappers define the relative positions of content and
// the trackers define the order of content.
// The mappers are optional in a sense that you need to implement only
// one of the two for the loader to work.
// Pick the one that best suits your data: if your datum stores
// its location relative to the parent, implement a backmapper;
// if your datum stores the locations of its children, implement a mapper.
//
// Parameters:
// config
// an object with properties:
// viewport
// a tapspace.components.Viewport
// mapper
// optional function (parentId, parent, childId), synchronous, where
// .. parent is a Component. Determine the placement of the child
// .. by returning a Basis relative to the parent.
// backmapper
// optional function (childId, child, parentId), synchronous, where
// .. child is a Component. Determine the placement of the parent
// .. by returning a Basis relative to the child.
// tracker
// a function (parentId, parent), synchronous.
// .. The parent is a Component.
// .. Return a list of ID strings
// .. where each ID represents a child of the parent.
// backtracker
// a function (childId, child), synchronous.
// .. The child is a Component.
// .. Return the parent ID string.
// .. Return null if the child does not have a parent.
//
// Emits:
// open
// when you should build a space and call loader.open().
// Called with `{ id, data }`.
// opened
// when a space has been added to the loader successfully.
// Called with `{ id, space }`.
// close
// when a space is about to be closed.
// Called with `{ id, space, data }`.
// closed
// when a space has been closed successfully.
// Called with `{ id }`.
//
// By calling `loader.init(id, basis)` the loader is initiated and
// the tree-building starts.
// However, the loader does not yet know your content.
// To provide the content, and also means to render and retrieve the content,
// you need to set an 'open' event handler:
//
// ```
// loader.on('open', (ev) => {
// ...
// })
// ```
//
// Inside the handler, you can create your content for your tree node,
// like text, images, html, loading animations.
// Place the content into a tapspace item or plane,
// and call `loader.open(id, content)`.
// You can also call `loader.placeholder(id, content)`
// to place a placeholder that will be replaced when the actual content
// is retrieved and opened.
//
// Here is a minimal example:
//
// ```
// loader.on('open', (ev) => {
// loader.addPlaceholder(ev.id, '...')
// fetch('https://api.example.com/data')
// .then(response => {
// if (!response.ok) throw new Error('Network response was not OK')
// return response.json()
// })
// .then(data => {
// loader.addSpace(ev.id, data.html)
// callback(null)
// })
// .catch(error => {
// loader.addSpace(ev.id, error.message)
// console.error('Error:', error)
// callback(error)
// })
// })
// ```
//
// The loader also emits 'close' event.
// While the loader closes content automatically,
// if your code needs some deconstruction behavior,
// place a listener. For example:
//
// ```
// loader.on('close', (ev) => {
// streams[ev.id].close()
// })
// ```
//
// Here is a complete configuration example:
//
// ```
// const loader = new tapspace.loaders.TreeLoader({
// viewport: viewport,
//
// mapper: function (parentId, parent, childId) {
// // Find the location for the child, relative to the parent.
// // The return value must be a Basis.
// // Get the parent basis, and transform it as you like.
// // Return null if there is no place for the child.
// const data = store[parentId]
// const dataPoint = data.points[childId]
// if (dataPoint) {
// return parent.getBasis().offset(dataPoint.x, dataPoint.y)
// }
// return null
// },
//
// backmapper: function (childId, child, parentId) {
// // Find the location for the parent, relative to the child.
// // The mapper and backmapper are optional, but you need to implement
// // at least one of the two.
// const data = store[childId]
// const dataPoint = data.parent.point
// if (dataPoint) {
// return child.getBasis().offset(dataPoint.x, dataPoint.y)
// }
// return null
// },
//
// tracker: function (parentId, parent) {
// // Find IDs of the child nodes, given the parent node.
// // Return an empty array if there are no childred.
// const dataPoints = store[parentId].points
// const idArray = dataPoints.map(p => p.id)
// return idArray
// },
//
// backtracker: function (childId, child) {
// // Find ID of the parent node, given the child node.
// // Return null if there are no parents. Such child is the root node.
// const data = store[childId]
// if (data.parent.id) {
// return data.parent.id
// }
// return null
// }
// })
// ```
//
// Now you have the TreeLoader constructed.
// You still need to build a driver for it.
// The driver is a function ran at each viewport idle event
// or similar, non-realtime schedule.
// Its purpose is to find our current location in the tree
// and open/close the TreeLoader nodes accordingly.
// See examples.
//
if (!config.viewport || !config.viewport.isViewport) {
throw new Error('Invalid loader viewport.')
}
if (typeof config.mapper !== 'function') {
throw new Error('Invalid loader mapper.')
}
// TODO
// if (typeof config.backmapper !== 'function') {
// throw new Error('Invalid loader backmapper.')
// }
if (typeof config.tracker !== 'function') {
throw new Error('Invalid loader tracker.')
}
if (typeof config.backtracker !== 'function') {
throw new Error('Invalid loader backtracker.')
}
this.viewport = config.viewport
// TODO this.driver = config.driver
// Wrap mapper.
// Detect invalid response to help the app developer.
const nakedMapper = config.mapper
this.mapper = (parentId, parentSpace, childId) => {
const basis = nakedMapper(parentId, parentSpace, childId)
if (typeof basis !== 'object' || (basis !== null && !basis.isBasis)) {
throw new Error('Invalid mapper. Should return Basis or null.')
}
return basis
}
this.backmapper = config.backmapper || null
// Wrap tracker.
const nakedTracker = config.tracker
this.tracker = (id, space) => {
const tracks = nakedTracker(id, space)
if (!Array.isArray(tracks)) {
throw new Error('Invalid tracker. Should return an array.')
}
return tracks
}
// Wrap backtracker.
const nakedBacktracker = config.backtracker
this.backtracker = (id, space) => {
const parentId = nakedBacktracker(id, space)
if (typeof parentId !== 'string' && parentId !== null) {
throw new Error('Invalid backtracker. Should return string or null.')
}
if (parentId === '') {
throw new Error('Invalid backtracker. ID cannot be an empty string.')
}
return parentId
}
// Track created spaces.
// id -> Component
this.spaces = {}
// Cached bases, at least for the init basis.
this.bases = {}
// Track which spaces are still loading.
this.loading = {}
// Track to which depth the node should open.
this.demand = {}
}
module.exports = TreeLoader
const proto = TreeLoader.prototype
proto.isTreeLoader = true
// Inherit
emitter(proto)
// Methods
proto.addPlaceholder = require('./addPlaceholder')
proto.addSpace = require('./addSpace')
proto.closeChild = require('./closeChild')
proto.closeChildren = require('./closeChildren')
proto.closeNeighbors = require('./closeNeighbors')
proto.closeParent = require('./closeParent')
proto.countSpaces = require('./countSpaces')
proto.init = require('./init')
proto.openChild = require('./openChild')
proto.openChildren = require('./openChildren')
proto.openNeighbors = require('./openNeighbors')
proto.openParent = require('./openParent')
proto.remapChildren = require('./remapChildren')
proto.removeSpace = require('./removeSpace')