diff --git a/src/d3/3d/D33dAxis.js b/src/d3/3d/D33dAxis.js new file mode 100644 index 0000000000000000000000000000000000000000..96c89b7d17a8aa25904924ce76c5d6687f4ac55b --- /dev/null +++ b/src/d3/3d/D33dAxis.js @@ -0,0 +1,240 @@ +'use strict'; + +var D33dGroup = require('./D33dGroup'), + D33dPath = require('./D33dPath'), + D33dText = require('./D33dText'), + Util = require('../../util/Util'), + Vector = require('../../math/Vector'); + +var _DEFAULTS = { + className: 'D33dAxis', + extent: [ + [0, 0, 0], + [1, 0, 0], + ], + format: function (coord) { + return '' + coord; + }, + labelAnchor: 'middle', + labelDirection: null, + labelVector: null, + padding: 0.05, + scale: null, + tickVector: [0, 0, 1], + ticks: 10, + title: 'Axis', + titleAnchor: 'middle', + titleDirection: null, + titleVector: null, +}; + +/** + * A 3d axis. + * + * @param options {Object} + * @param options.extent {Array<Array<Number>>} + * default [[0, 0, 0], [1, 0, 0]]. + * axis extent. + * @param options.format {Function(Array<Number>)} + * format one coordinate (for ticks). + * @param options.labelAnchor {'start'|'middle'|'end'} + * default 'middle'. + * svg text-anchor property for tick labels. + * @param options.labelDirection {Array<Number>} + * default null. + * rotate label text to be parallel to this vector. + * @param options.labelVector {Array<Number>} + * default tickVector.multiply(2). + * placement of tick labels + * @param options.padding {Number} + * default 0.05 + * padding outside extent. 0.05 would be 5% padding based on extent. + * @param options.tickVector {Array<Number>} + * direction and length of tick marks. + * @param options.ticks {Number} + * number of ticks to create. + * @param options.title {String} + * axis title. + * @param options.titleAnchor {'start'|'middle'|'end'} + * default 'middle'. + * svg text-anchor property for title. + * @param options.titleDirection {Array<Number>} + * default null. + * rotate title to be parallel to this vector. + * @param options.titleVector {Array<Number>} + * default Vector(tickVector).multiply(5). + * direction and distance to title from center of axis extent. + */ +var D3Axis = function (options) { + var _this, + _initialize, + // variables + _extent, + _ticks, + _title, + // methods + _createTicks; + + _this = D33dGroup(options); + + _initialize = function (options) { + options = Util.extend({}, _DEFAULTS, options); + + _this.model.set( + { + extent: options.extent, + format: options.format, + labelAnchor: options.labelAnchor, + labelDirection: options.labelDirection, + labelVector: options.labelVector, + padding: options.padding, + tickVector: options.tickVector, + ticks: options.ticks, + title: options.title, + titleAnchor: options.titleAnchor, + titleDirection: options.titleDirection, + titleVector: options.titleVector, + }, + { silent: true } + ); + + _extent = D33dPath({ + className: 'axis-extent', + }); + _title = D33dText({ + className: 'axis-title', + }); + _ticks = []; + + _createTicks(); + _this.model.on('change', _createTicks); + }; + + /** + * Update the _ticks array and model "items" property. + */ + _createTicks = function () { + var extent, + format, + i, + labelAnchor, + labelDirection, + labelVector, + padding, + tickEnd, + tick, + tickStart, + tickUnit, + tickVector, + ticks, + title, + titleAnchor, + titleDirection, + titleVector; + + _ticks.forEach(function (tick) { + tick.destroy(); + }); + _ticks = []; + + extent = _this.model.get('extent'); + format = _this.model.get('format'); + labelAnchor = _this.model.get('labelAnchor'); + labelDirection = _this.model.get('labelDirection'); + labelVector = _this.model.get('labelVector'); + padding = _this.model.get('padding'); + tickVector = _this.model.get('tickVector'); + ticks = _this.model.get('ticks'); + title = _this.model.get('title'); + titleAnchor = _this.model.get('titleAnchor'); + titleDirection = _this.model.get('titleDirection'); + titleVector = _this.model.get('titleVector'); + + tickEnd = Vector(extent[1]); + tickStart = Vector(extent[0]); + tickVector = Vector(tickVector); + titleVector = titleVector ? Vector(titleVector) : tickVector.multiply(5); + + // update extent + if (padding !== 0) { + padding = tickEnd.subtract(tickStart).multiply(padding); + extent[0] = Vector(extent[0]).subtract(padding).data(); + extent[1] = Vector(extent[1]).add(padding).data(); + } + _extent.model.set({ + coords: extent, + }); + _title.model.set({ + coords: Vector(extent[0]) + // center within axis + .add(tickEnd.subtract(tickStart).multiply(0.5)) + // offset + .add(titleVector) + .data(), + direction: titleDirection, + text: title, + textAnchor: titleAnchor, + }); + + // create ticks + tickUnit = tickEnd.subtract(tickStart).multiply(1 / ticks); + labelVector = labelVector ? Vector(labelVector) : tickVector.multiply(2); + for (i = 0; i <= ticks; i++) { + tick = tickStart.add(tickUnit.multiply(i)); + + _ticks.push( + D33dGroup({ + className: 'axis-tick', + items: [ + D33dPath({ + coords: [tick.data(), tick.add(tickVector).data()], + }), + D33dText({ + coords: tick.add(labelVector).data(), + direction: labelDirection, + text: format(tick.data()), + textAnchor: labelAnchor, + }), + ], + }) + ); + } + + _this.model.set( + { + items: [_extent, _title].concat(_ticks), + }, + { silent: true } + ); + }; + + /** + * Free references. + */ + _this.destroy = Util.compose(function () { + if (_this === null) { + return; + } + + _this.model.off('change', _createTicks); + + _extent.destroy(); + _title.destroy(); + _ticks.forEach(function (tick) { + tick.destroy(); + }); + + _createTicks = null; + _extent = null; + _initialize = null; + _this = null; + _ticks = null; + _title = null; + }, _this.destroy); + + _initialize(options); + options = null; + return _this; +}; + +module.exports = D3Axis; diff --git a/src/d3/3d/D33dCuboid.js b/src/d3/3d/D33dCuboid.js new file mode 100644 index 0000000000000000000000000000000000000000..88f2e17821959cdd4a31c969a34e5a5ddafd6443 --- /dev/null +++ b/src/d3/3d/D33dCuboid.js @@ -0,0 +1,156 @@ +'use strict'; + +var D33dGroup = require('./D33dGroup'), + D33dPath = require('./D33dPath'), + Util = require('../../util/Util'); + +var _DEFAULTS = { + className: 'D33dCuboid', + x0: 0, + x1: 1, + y0: 0, + y1: 1, + z0: 0, + z1: 1, +}; + +/** + * A 3d cuboid. + */ +var D33dCuboid = function (options) { + var _this, + _initialize, + // variables + _x0, + _x1, + _y0, + _y1, + _z0, + _z1, + // methods + _updateCoordinates; + + _this = D33dGroup(options); + + _initialize = function (options) { + options = Util.extend({}, _DEFAULTS, options); + + // create sides as paths + _x0 = D33dPath({ + className: 'side-x0', + }); + _x1 = D33dPath({ + className: 'side-x1', + }); + _y0 = D33dPath({ + className: 'side-y0', + }); + _y1 = D33dPath({ + className: 'side-y1', + }); + _z0 = D33dPath({ + className: 'side-z0', + }); + _z1 = D33dPath({ + className: 'side-z1', + }); + + // update model for D33dGroup + _this.model.set( + { + className: options.className, + items: [_x0, _x1, _y0, _y1, _z0, _z1], + x0: options.x0, + x1: options.x1, + y0: options.y0, + y1: options.y1, + z0: options.z0, + z1: options.z1, + }, + { silent: true } + ); + + // set coordinates + _updateCoordinates(); + _this.model.on('change', _updateCoordinates); + }; + + /** + * Update the coordinates of each side of the cuboid. + */ + _updateCoordinates = function () { + var p000, p001, p010, p011, p100, p101, p110, p111, x0, x1, y0, y1, z0, z1; + + // raw coordinates + x0 = _this.model.get('x0'); + x1 = _this.model.get('x1'); + y0 = _this.model.get('y0'); + y1 = _this.model.get('y1'); + z0 = _this.model.get('z0'); + z1 = _this.model.get('z1'); + + // points on cuboid + p000 = [x0, y0, z0]; + p001 = [x0, y0, z1]; + p010 = [x0, y1, z0]; + p011 = [x0, y1, z1]; + p100 = [x1, y0, z0]; + p101 = [x1, y0, z1]; + p110 = [x1, y1, z0]; + p111 = [x1, y1, z1]; + + // update sides + _x0.model.set({ + coords: [p000, p010, p011, p001], + }); + _x1.model.set({ + coords: [p100, p101, p111, p110], + }); + _y0.model.set({ + coords: [p000, p001, p101, p100], + }); + _y1.model.set({ + coords: [p010, p011, p111, p110], + }); + _z0.model.set({ + coords: [p000, p010, p110, p100], + }); + _z1.model.set({ + coords: [p001, p011, p111, p101], + }); + }; + + /** + * Unbind listeners, free references. + */ + _this.destroy = Util.compose(function () { + if (_this === null) { + return _this; + } + + _this.model.off('change', _updateCoordinates); + + _x0.destroy(); + _x1.destroy(); + _y0.destroy(); + _y1.destroy(); + _z0.destroy(); + _z1.destroy(); + + _initialize = null; + _this = null; + _updateCoordinates = null; + _x0 = null; + _x1 = null; + _y0 = null; + _y1 = null; + _z0 = null; + _z1 = null; + }, _this.destroy); + + _initialize(options); + options = null; + return _this; +}; + +module.exports = D33dCuboid; diff --git a/src/d3/3d/D33dGroup.js b/src/d3/3d/D33dGroup.js new file mode 100644 index 0000000000000000000000000000000000000000..001d6d766f90a5fea183df3584945b1925af83bd --- /dev/null +++ b/src/d3/3d/D33dGroup.js @@ -0,0 +1,70 @@ +'use strict'; + +var D33dSubView = require('../view/D33dSubView'), + Util = require('../../util/Util'); + +var _DEFAULTS = { + className: 'D33dGroup', + coords: [0, 0, 0], + items: [], +}; + +/** + * Text to be plotted at a point in the view. + * + * @param options {Object} + * @param options.className {String} + * default 'D33dGroup'. + * element class attribute value (may include spaces). + * @param options.coords {Array<Number>} + * 3d coordinates where text should be positioned. + * @param options.items {Array<Object>} + * nested items. + */ +var D33dGroup = function (options) { + var _this, _initialize; + + _this = D33dSubView(options); + + _initialize = function (options) { + options = Util.extend({}, _DEFAULTS, options); + + _this.model.set( + { + className: options.className, + coords: options.coords, + items: options.items, + }, + { silent: true } + ); + }; + + /** + * @return data for D33dView plotting. + */ + _this.getData = function () { + return { + el: _this.el, + items: _this.model.get('items').map(function (item) { + return item.getData(); + }), + }; + }; + + /** + * Render everything except position. + */ + _this.render = function (view) { + _this.el.setAttribute('class', _this.model.get('className')); + // render nested views + _this.model.get('items').forEach(function (item) { + item.render(view); + }); + }; + + _initialize(options); + options = null; + return _this; +}; + +module.exports = D33dGroup; diff --git a/src/d3/3d/D33dPath.js b/src/d3/3d/D33dPath.js new file mode 100644 index 0000000000000000000000000000000000000000..f1fdbbae369525be29b9c8eaf8dbba8e19045912 --- /dev/null +++ b/src/d3/3d/D33dPath.js @@ -0,0 +1,71 @@ +'use strict'; + +var D33dSubView = require('../view/D33dSubView'), + Util = require('../../util/Util'); + +var _DEFAULTS = { + className: 'D33dPath', + close: true, + coords: [], +}; + +/** + * Class info and constructor parameters. + */ +var D33dPath = function (options) { + var _this, _initialize; + + _this = D33dSubView( + Util.extend( + { + element: 'path', + }, + options + ) + ); + + _initialize = function (options) { + options = Util.extend({}, _DEFAULTS, options); + + _this.model.set( + { + className: options.className, + close: options.close, + coords: options.coords, + }, + { silent: true } + ); + }; + + /** + * Free references. + */ + _this.destroy = Util.compose(function () { + _initialize = null; + _this = null; + }, _this.destroy); + + /** + * @return data for D33dView plotting. + */ + _this.getData = function () { + return { + close: _this.model.get('close'), + coords: _this.model.get('coords'), + el: _this.el, + }; + }; + + /** + * Render everything except coordinates. + */ + _this.render = function (/*view*/) { + _this.el.setAttribute('class', _this.model.get('className')); + }; + + _initialize(options); + options = null; + return _this; +}; + +module.exports = D33dPath; diff --git a/src/d3/3d/D33dText.js b/src/d3/3d/D33dText.js new file mode 100644 index 0000000000000000000000000000000000000000..5b46bf89ac59f9e06b42903685139f2afe7b2899 --- /dev/null +++ b/src/d3/3d/D33dText.js @@ -0,0 +1,89 @@ +'use strict'; + +var D33dSubView = require('../view/D33dSubView'), + Util = require('../../util/Util'); + +var _DEFAULTS = { + className: 'D33dText', + coords: [0, 0, 0], + direction: null, + text: '', + textAnchor: 'start', +}; + +/** + * Text to be plotted at a point in the view. + * + * @param options {Object} + * @param options.className {String} + * default 'D33dText'. + * element class attribute value (may include spaces). + * @param options.coords {Array<Number>} + * 3d coordinates where text should be positioned. + * @param options.text {String} + * text content. + * @param options.textAnchor {String} + * default 'start'. + * svg text anchor. + */ +var D33dText = function (options) { + var _this, _initialize; + + _this = D33dSubView( + Util.extend( + { + element: 'text', + }, + options + ) + ); + + _initialize = function (options) { + options = Util.extend({}, _DEFAULTS, options); + + _this.model.set( + { + className: options.className, + coords: options.coords, + direction: options.direction, + text: options.text, + textAnchor: options.textAnchor, + }, + { silent: true } + ); + }; + + /** + * Free references. + */ + _this.destroy = Util.compose(function () { + _initialize = null; + _this = null; + }, _this.destroy); + + /** + * @return data for D33dView plotting. + */ + _this.getData = function () { + return { + coords: _this.model.get('coords'), + direction: _this.model.get('direction'), + el: _this.el, + }; + }; + + /** + * Render everything except position. + */ + _this.render = function (/*view*/) { + _this.el.textContent = _this.model.get('text'); + _this.el.setAttribute('class', _this.model.get('className')); + _this.el.setAttribute('text-anchor', _this.model.get('textAnchor')); + }; + + _initialize(options); + options = null; + return _this; +}; + +module.exports = D33dText; diff --git a/src/d3/index.d.ts b/src/d3/index.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e0e908cc9c8e69217b2d3f65aa75b6bf1edff7c --- /dev/null +++ b/src/d3/index.d.ts @@ -0,0 +1 @@ +export * from '../../types/d3'; diff --git a/src/d3/index.js b/src/d3/index.js new file mode 100644 index 0000000000000000000000000000000000000000..c74ac0c40fc5ef1a753bcfcedbc542ee73c326a7 --- /dev/null +++ b/src/d3/index.js @@ -0,0 +1,16 @@ +module.exports = { + ClassList: require('./util/ClassList'), + D3Util: require('./util/D3Util'), + + D3BaseView: require('./view/D3BaseView'), + D3SubView: require('./view/D3SubView'), + D3View: require('./view/D3View'), + D33dSubView: require('./view/D33dSubView'), + D33dView: require('./view/D33dView'), + + D33dAxis: require('./3d/D33dAxis'), + D33dCuboid: require('./3d/D33dCuboid'), + D33dGroup: require('./3d/D33dGroup'), + D33dPath: require('./3d/D33dPath'), + D33dText: require('./3d/D33dText'), +}; diff --git a/src/d3/util/ClassList.js b/src/d3/util/ClassList.js new file mode 100644 index 0000000000000000000000000000000000000000..710f4bc2e5f7fef85fd0860db59cb515127feba9 --- /dev/null +++ b/src/d3/util/ClassList.js @@ -0,0 +1,163 @@ +'use strict'; + +/** + * Simulates a class list. + * + * If changes are made outside this object, resync using synchronize(). + */ +var ClassList = function (el) { + var _this, + _initialize, + // variables + _classList, + _syncValue, + // methods + _sync; + + _this = {}; + + /** + * Initialize ClassList. + */ + _initialize = function () { + _syncValue = null; + _classList = []; + _this.length = 0; + + _sync(true); + }; + + /** + * Synchronize with element state. + * + * @param load {Boolean} + * when true, read state from element. + * otherwise, set element state. + */ + _sync = function (load) { + var value; + + if (load) { + // read from element + value = el.getAttribute('class'); + if (value === null) { + _classList = []; + _this.length = 0; + } else { + value = '' + value; + _classList = value.split(' '); + _this.length = _classList.length; + } + } else { + // update element + value = _classList.join(' '); + el.setAttribute('class', value); + } + _syncValue = value; + }; + + /** + * Add a class. + * + * @param className {String} + * class to add. + */ + _this.add = function (className) { + var pos; + // load from element + _sync(true); + pos = _classList.indexOf(className); + if (pos === -1) { + _classList.push(className); + _this.length++; + // update element + _sync(false); + } + }; + + /** + * Check if element has a class. + * + * @param className {String} + * class to add. + * @return {Boolean} + * true if element list includes class. + */ + _this.contains = function (className) { + var pos; + // load from element + _sync(true); + pos = _classList.indexOf(className); + return pos !== -1; + }; + + /** + * Access a class. + * + * @param pos {String} + * index in list [0,ClassList.length-1]. + * @return className in list, or null if out of range. + */ + _this.item = function (pos) { + // load from element + _sync(true); + if (pos < 0 || pos >= _classList.length) { + return null; + } + return _classList[pos]; + }; + + /** + * Remove a class. + * + * @param className {String} + * class to remove. + */ + _this.remove = function (className) { + var pos; + // load from element + _sync(true); + pos = _classList.indexOf(className); + if (pos !== -1) { + _classList.splice(pos, 1); + _this.length--; + // update element + _sync(false); + } + }; + + /** + * Toggle a class. + * + * Add is not in list, otherwise remove. + * + * @param className {String} + * class to add. + */ + _this.toggle = function (className) { + if (_this.has(className)) { + _this.remove(className); + } else { + _this.add(className); + } + }; + + _initialize(); + return _this; +}; + +/** + * Add classList if element doesn't natively support classList. + * + * Some SVG implementations do not support classList. + * + * @param el {Element} + * element to polyfill. + */ +ClassList.polyfill = function (el) { + if (!el.classList) { + el.classList = ClassList(el); + } +}; + +module.exports = ClassList; diff --git a/src/d3/util/D3Util.js b/src/d3/util/D3Util.js new file mode 100644 index 0000000000000000000000000000000000000000..73665da0d2b489bb8f35cd6871ab84820007320b --- /dev/null +++ b/src/d3/util/D3Util.js @@ -0,0 +1,133 @@ +'use strict'; + +/** + * Format text content. + * + * @param el {D3Element} + * tooltip container element. + * @param data {Array<Object|Array>} + * data passed to showTooltip. + * this implementation expects objects (or arrays of objects): + * obj.class {String} class attribute for text|tspan. + * obj.text {String} content for text|tspan. + */ +var _formatText = function (el, data) { + var y; + + // add content to tooltip + data = data.map(function (line) { + var text = el.append('text'); + if (typeof line.forEach === 'function') { + // array of components: + line.forEach(function (l) { + text + .append('tspan') + .attr('class', l.class || '') + .text(l.text); + }); + } else { + text.attr('class', line.class || '').text(line.text); + } + return text; + }); + // position lines in tooltip + y = 0; + data.forEach(function (line) { + var bbox = line.node().getBBox(); + y += bbox.height; + line.attr('y', y); + }); +}; + +/** + * Persistently tries to get the bounding box for the given element. + * + * @param element {SVGText} + * The element for which to get the bounding box. + * @return {Object} + * A bounding box object with x, y, width, height attributes + */ +var _getBBox = function (element) { + var bbox; + + try { + bbox = element.getBBox(); + } catch (e) { + // Ignore + } + + if (!bbox) { + try { + bbox = element.getBoundingClientRect(); + } catch (e) { + // Ignore + } + } + + if (!bbox) { + bbox = { x: 0, y: 0, width: 0, height: 0 }; + } + + return bbox; +}; + +/** + * Pad an extent. + * + * @param extent {Array<Number>} + * first entry should be minimum. + * last entry should be maximum. + * @param amount {Number} + * percentage of range to pad. + * For example: 0.05 = +/- 5% of range. + * @return {Array<Number>} + * padded extent. + */ +var _padExtent = function (extent, amount) { + var start = extent[0], + end = extent[extent.length - 1], + range = end - start, + pad = range * amount; + + // Deal with case where there is only one value for the extents. + if (pad === 0) { + pad = amount; + } + + return [start - pad, end + pad]; +}; + +/** + * Pad a log based extent. + * + * Similar to _padExtent(), but padding occurs in log space. + * + * @param extent {Array<Number>} + * first entry should be minimum. + * last entry should be maximum. + * @param amount {Number} + * percentage of range to pad. + * For example: 0.05 = +/- 5% of range. + * @return {Array<Number>} + * padded extent. + */ +var _padLogExtent = function (extent, amount) { + var base, baseLog, end, start; + + // convert min/max to base 10 + base = 10; + baseLog = Math.log(base); + start = Math.log(extent[0]) / baseLog; + end = Math.log(extent[extent.length - 1]) / baseLog; + extent = _padExtent([start, end], amount); + return [Math.pow(base, extent[0]), Math.pow(base, extent[extent.length - 1])]; +}; + +var D3Util = { + formatText: _formatText, + getBBox: _getBBox, + padExtent: _padExtent, + padLogExtent: _padLogExtent, +}; + +module.exports = D3Util; diff --git a/src/d3/view/D33dSubView.js b/src/d3/view/D33dSubView.js new file mode 100644 index 0000000000000000000000000000000000000000..436a82f76af386d23b83114b0edc9855c4d32980 --- /dev/null +++ b/src/d3/view/D33dSubView.js @@ -0,0 +1,157 @@ +'use strict'; + +var d3 = require('d3'), + ClassList = require('../util/ClassList'), + Util = require('../../util/Util'), + View = require('../../mvc/View'); + +var _DEFAULTS = { + element: 'g', + elementNamespace: 'http://www.w3.org/2000/svg', + id: null, +}; + +var _ID_SEQUENCE = 0; + +/** + * Sub view for a D3 plot. + * + * Manages mouseover, mouseout, click events for view. + * mouseover and mouseout toggle a "mouseover" class on view. + * click triggers "click" event. + * + * When added to a D3BaseView, "click" event triggers "select" in collection. + * D3BaseView calls onSelect, onDeselect methods when collection selection + * changes. + * + * @param options {Object} + * all options are passed to View. + */ +var D33dSubView = function (options) { + var _this, + _initialize, + // variables + _el; + + _this = View( + Util.extend( + { + // View will not create an element if any "el" property is specified. + // Do this so an element is only created if not configured. + el: null, + }, + options + ) + ); + + /** + * Initialize view. + */ + _initialize = function (options) { + options = Util.extend({}, _DEFAULTS, options); + + // ensure views have a unique id + _this.id = options.id || _ID_SEQUENCE++; + + if (_this.el === null) { + _this.el = document.createElementNS(options.elementNamespace, options.element); + } + + // reference to view from element + _this.el.view = _this; + + ClassList.polyfill(_this.el); + _el = d3.select(_this.el); + _el.on('click', _this.onClick); + _el.on('mouseout', _this.onMouseOut); + _el.on('mouseover', _this.onMouseOver); + }; + + /** + * Destroy view. + */ + _this.destroy = Util.compose(function () { + if (_this === null) { + // already destroyed + return; + } + + if (_el) { + _el.on('click', null); + _el.on('mouseout', null); + _el.on('mouseover', null); + _el = null; + } + + _this.el.view = null; + _this = null; + }, _this.destroy); + + /** + * Click event handler. + */ + _this.onClick = function () { + _this.trigger('click'); + }; + + /** + * Deselect event handler. + */ + _this.onDeselect = function () { + _this.el.classList.remove('selected'); + }; + + /** + * Mouseout event handler. + */ + _this.onMouseOut = function () { + _this.el.classList.remove('mouseover'); + }; + + /** + * Mouseover event handler. + */ + _this.onMouseOver = function () { + _this.el.classList.add('mouseover'); + }; + + /** + * Select event handler. + */ + _this.onSelect = function () { + _this.el.classList.add('selected'); + }; + + /** + * Get plotting data for D33dView. + * + * @return {Object} + * el - view element + * when el is a SVG "g" element + * items {Array<Object>} + * items within group + * when el is a SVG "text" element + * coords {Array<Number>} + * coordinates for text anchor point. + * when el is a SVG "path" element + * close {Boolean} + * default true + * coords {Array<Array<Number>>} + * array of path coordinates. + */ + _this.getData = function () { + throw new Error('D33dSubView.getData not implemented'); + }; + + /** + * Render sub view. + * Update all view attributes except coordinates (updated by D33dView). + */ + _this.render = function () {}; + + _initialize(options); + options = null; + return _this; +}; + +module.exports = D33dSubView; diff --git a/src/d3/view/D33dView.js b/src/d3/view/D33dView.js new file mode 100644 index 0000000000000000000000000000000000000000..15279a450f799135bb88fcd84777cea7ef01281a --- /dev/null +++ b/src/d3/view/D33dView.js @@ -0,0 +1,283 @@ +'use strict'; + +var Camera = require('../../math/Camera'), + D3BaseView = require('./D3BaseView'), + Util = require('../../util/Util'), + Vector = require('../../math/Vector'); + +var _DEFAULTS = { + lookAt: [0, 0, 0], + origin: [100, 100, 100], + up: [0, 0, 1], + zoom: 10, +}; + +/** + * Simulate a 3D view. + * + * Add objects to the D33dView.views collection to make them plot: + * - D33dGroup + * - D33dAxis + * - D33dCuboid + * - D33dPath + * - D33dText + * + * The properties lookAt, origin, up, and zoom, can be modified by updating the + * corresponding D33dView.model properties. + * + * @param options {Object} + * @param options.lookAt {Array<x, y, z>} + * default [0, 0, 0]. + * where view looks at. + * @param options.origin {Array<x, y, z>} + * default [100, 100, 100]. + * where view looks from. + * @param options.up {Array<x, y, z>} + * default [0, 0, 1] (z-axis up). + * up vector. + * @param options.zoom {Number} + * default 10. + * scaling factor for plot. + */ +var D33dView = function (options) { + var _this, + _initialize, + // variables + _camera, + _centerX, + _centerY, + _zoom, + // methods + _createCamera, + _projectObject, + _renderObject; + + _this = D3BaseView(options); + + _initialize = function (options) { + options = Util.extend({}, _DEFAULTS, options); + + _centerX = 0; + _centerY = 0; + _zoom = options.zoom; + + _createCamera(); + + _this.model.on('change:lookAt', _createCamera); + _this.model.on('change:origin', _createCamera); + _this.model.on('change:up', _createCamera); + }; + + /** + * Create the camera object used for projection. + */ + _createCamera = function () { + var lookAt, origin, up; + + lookAt = _this.model.get('lookAt'); + origin = _this.model.get('origin'); + up = _this.model.get('up'); + _zoom = _this.model.get('zoom'); + + _camera = Camera({ + lookAt: lookAt, + origin: origin, + up: up, + }); + }; + + /** + * Project object coordinates. + * + * Sets the object properties `_projected` and `_z`. + */ + _projectObject = function (obj) { + var type; + + type = obj.el.nodeName; + if (type === 'g') { + obj.items.forEach(function (obj) { + _projectObject(obj); + }); + obj._z = Math.max.apply( + Math, + obj.items.map(function (p) { + return p._z; + }) + ); + } else if (type === 'text') { + obj._projected = _this.project(obj.coords); + obj._z = obj._projected[2]; + if (obj.direction) { + obj._direction = obj.direction.map(_this.project); + } + } else if (type === 'path') { + obj._projected = obj.coords.map(_this.project); + obj._z = Math.max.apply( + Math, + obj._projected.map(function (c) { + return c[2]; + }) + ); + } else { + throw new Error('unexpected element type "' + type + '"'); + } + }; + + /** + * Render object coordinates. + * + * Ignores objects that are behind the camera (z' <= 0). + * + * @param obj {Object} + * object to render. + * @param el {SVGElement} + * Element where object should be appended. + */ + _renderObject = function (obj, el) { + var angle, direction, end, start, type, x, y; + + if (obj._z <= 0) { + // behind camera + return; + } + + // attach element + el.appendChild(obj.el); + el = obj.el; + type = el.nodeName; + if (type === 'g') { + // empty group element + Util.empty(el); + // sort to plot further objects first + obj.items.sort(function (a, b) { + return b._z - a._z; + }); + // plot nested objects + obj.items.forEach(function (item) { + _renderObject(item, obj.el); + }); + } else if (type === 'text') { + x = obj._projected[0]; + y = obj._projected[1]; + el.setAttribute('x', x); + el.setAttribute('y', y); + if (obj._direction) { + // direction representa a vector that should be parallel to the text + end = Vector(obj._direction[1]); + start = Vector(obj._direction[0]); + direction = end.subtract(start); + // check angle in camera plane + direction.z(0); + angle = (direction.azimuth() * 180) / Math.PI; + // subtract azimuth from 90, screen y axis is in opposite direction + angle = 90 - angle; + el.setAttribute('transform', 'rotate(' + [angle, x, y].join(' ') + ')'); + } + } else if (type === 'path') { + el.setAttribute( + 'd', + 'M' + + obj._projected + .map(function (c) { + return c[0] + ',' + c[1]; + }) + .join('L') + + (obj.close !== false ? 'Z' : '') + ); + } else { + throw new Error('unexpected element type "' + type + '"'); + } + }; + + /** + * Free references. + */ + _this.destroy = Util.compose(function () { + if (_this === null) { + return; + } + + _this.model.off('change:lookAt', _createCamera); + _this.model.off('change:origin', _createCamera); + _this.model.off('change:up', _createCamera); + + _centerX = null; + _centerY = null; + _createCamera = null; + _initialize = null; + _projectObject = null; + _renderObject = null; + _this = null; + _zoom = null; + }, _this.destroy); + + /** + * Project a single coordinate into the view x/y plane. + * + * @param coords {Array<x, y, z>} + * @return {Array<x', y', z'>} projected coordinates. + * z' represents the distance from the camera plane to the object. + */ + _this.project = function (coords) { + var projected, x, y, z; + + projected = _camera.project(coords); + x = projected[0]; + y = projected[1]; + z = projected[2]; + + // zoom and center + x = x * _zoom + _centerX; + y = -y * _zoom + _centerY; + + return [x, y, z]; + }; + + /** + * Render all views. + * + * @param info {Object} + * plot information. + */ + _this.renderViews = function (info) { + var plotArea, torender; + + // update projection + _centerX = info.innerHeight / 2; + _centerY = info.innerWidth / 2; + + // clear plot + plotArea = info.el; + Util.empty(plotArea); + + // get raw coordinates + torender = []; + _this.views.data().forEach(function (view) { + torender.push(view.getData()); + }); + // compute position + torender.forEach(function (r) { + _projectObject(r); + }); + // stack to plot closest elements last + torender.sort(function (a, b) { + return b._z - a._z; + }); + // position elements + torender.forEach(function (r) { + _renderObject(r, plotArea); + }); + + // now that elements are positioned, have views update rendering + _this.views.data().forEach(function (view) { + view.render(_this); + }); + }; + + _initialize(options); + options = null; + return _this; +}; + +module.exports = D33dView; diff --git a/src/d3/view/D3BaseView.js b/src/d3/view/D3BaseView.js new file mode 100644 index 0000000000000000000000000000000000000000..d4f925b0c2c582a338cec3eb1a31606c9e042048 --- /dev/null +++ b/src/d3/view/D3BaseView.js @@ -0,0 +1,536 @@ +'use strict'; + +var d3 = require('d3'), + Collection = require('../../mvc/Collection'), + D3Util = require('../util/D3Util'), + Util = require('../../util/Util'), + View = require('../../mvc/View'); + +var _DEFAULTS = { + clickToSelect: true, + height: 480, + legendPosition: 'topright', + legendOffset: 20, + marginBottom: 0, + marginLeft: 0, + marginRight: 0, + marginTop: 0, + paddingBottom: 80, + paddingLeft: 80, + paddingRight: 20, + paddingTop: 50, + pointRadius: 3, + title: '', + tooltipOffset: 10, + tooltipPadding: 5, + width: 640, +}; + +/** + * View for a D3 plot. + * + * @param options {Object} + * options are passed to View. + * @param options.clickToSelect {Boolean} + * default true. + * when true, clicking a view causes it to be selected in the + * views collection. + * @param options.height {Number} + * default 480. + * overall (viewbox) height of svg element. + * @param options.legendPosition {String} + * default 'topleft'. + * one of (topright|topleft|bottomright|bottomleft). + * position of legend element. + * @param options.marginBottom {Number} + * default 0. + * @param options.marginLeft {Number} + * default 0. + * @param options.marginRight {Number} + * default 0. + * @param options.marginTop {Number} + * default 0. + * @param options.paddingBottom {Number} + * default 80. + * @param options.paddingLeft {Number} + * default 80. + * @param options.paddingRight {Number} + * default 20. + * @param options.paddingTop {Number} + * default 50. + * @param options.title {String} + * title for plot. + * @param options.tooltipOffset {Number} + * default 10. + * x/y distance from tooltip coordinate. + * @param options.tooltipPadding {Number} + * default 5. + * padding around tooltip content. + * @param options.width {Number} + * default 640. + * width of svg viewBox. + */ +var D3BaseView = function (options) { + var _this, + _initialize, + // variables + _axes, + _firstRender, + _innerFrame, + _legend, + _margin, + _outerFrame, + _padding, + _plotArea, + _plotAreaClip, + _plotTitle, + _svg, + _tooltip; + + _this = View(options); + + /** + * Initialize view. + */ + _initialize = function (options) { + var el; + + options = options || {}; + _firstRender = true; + _this.model.set(Util.extend({}, _DEFAULTS, options), { silent: true }); + + el = _this.el; + el.classList.add('D3BaseView'); + el.innerHTML = + '<svg xmlns="http://www.w3.org/2000/svg">' + + '<defs>' + + '<clipPath id="plotAreaClip">' + + '<rect x="0" y="0"></rect>' + + '</clipPath>' + + '</defs>' + + '<g class="margin">' + + '<rect class="outer-frame"></rect>' + + '<text class="plot-title" text-anchor="middle"></text>' + + '<g class="padding">' + + '<rect class="inner-frame"></rect>' + + '<g class="legend"></g>' + + '<g class="plot"></g>' + + '<g class="tooltip"></g>' + + '</g>' + + '</g>' + + '</svg>'; + + _svg = el.querySelector('svg'); + _plotAreaClip = _svg.querySelector('#plotAreaClip > rect'); + _outerFrame = _svg.querySelector('.outer-frame'); + _innerFrame = _svg.querySelector('.inner-frame'); + _margin = _svg.querySelector('.margin'); + _plotTitle = _margin.querySelector('.plot-title'); + _padding = _margin.querySelector('.padding'); + _legend = _padding.querySelector('.legend'); + _axes = _padding.querySelector('.axes'); + _plotArea = _padding.querySelector('.plot'); + _tooltip = _padding.querySelector('.tooltip'); + + _this.views = Collection([]); + _this.views.on('add', _this.onAdd); + _this.views.on('deselect', _this.onDeselect); + _this.views.on('remove', _this.onRemove); + _this.views.on('reset', _this.onReset); + _this.views.on('select', _this.onSelect); + }; + + /** + * Destroy view. + */ + _this.destroy = Util.compose(function () { + if (_this === null) { + // already destroyed + return; + } + + _this.views.off(); + _this.views.destroy(); + + _this.views = null; + _innerFrame = null; + _legend = null; + _margin = null; + _outerFrame = null; + _padding = null; + _plotArea = null; + _plotAreaClip = null; + _plotTitle = null; + _svg = null; + _tooltip = null; + _this = null; + }, _this.destroy); + + /** + * Views collection add handler. + * + * @param views {Array<D3SubView>} + * views that were added. + */ + _this.onAdd = function (views, dontrender) { + views.forEach(function (view) { + view._d3view_onclick = function () { + _this.onClick(view); + }; + view.on('click', view._d3view_onclick); + }); + if (!dontrender) { + _this.render(); + } + }; + + /** + * Called when a view is clicked. + * + * @param view {D3SubView} + * view that was clicked. + */ + _this.onClick = function (view) { + if (_this.model.get('clickToSelect')) { + _this.views.select(view); + } + }; + + /** + * Views collection select handler. + * + * @param view {D3SubView} + * view that was selected. + */ + _this.onDeselect = function (view) { + view.onDeselect(); + }; + + /** + * Views collection remove handler. + * + * @param views {Array<D3SubView>} + * views that were removed. + */ + _this.onRemove = function (views, dontrender) { + views.forEach(function (view) { + view.off('click', view._d3view_onclick); + view._d3view_onclick = null; + }); + if (!dontrender) { + _this.render(); + } + }; + + /** + * Views collection reset handler. + */ + _this.onReset = function () { + var el, + toRemove = []; + // call onRemove for all existing views. + while (_plotArea.firstChild) { + // detach view + el = _plotArea.firstChild; + _plotArea.removeChild(el); + // call remove to clean up + toRemove.push(el.view); + } + _this.onRemove(toRemove); + // call onAdd for all views + _this.onAdd(_this.views.data()); + }; + + /** + * Views collection select handler. + * + * @param view {D3SubView} + * view that was selected. + */ + _this.onSelect = function (view) { + view.onSelect(); + }; + + /** + * Project data coordinates to plot coordinates. + * + * @param coords {Array<Number>} + * data coordinate. + * @return {Array<Number>} + * plot coordinate. + */ + _this.project = function (coords) { + return coords; + }; + + /** + * Render view. + * + * @param changed {Object} + * default is _this.model.get. + * list of properties that have changed. + */ + _this.render = function (changed) { + var height, + innerWidth, + innerHeight, + legendPosition, + legendX, + legendY, + marginBottom, + marginLeft, + marginRight, + marginTop, + options, + outerHeight, + outerWidth, + paddingBottom, + paddingLeft, + paddingRight, + paddingTop, + width; + + options = _this.model.get(); + if (_firstRender || !changed) { + changed = options; + _firstRender = false; + } + + // these are used for label positioning + paddingBottom = options.paddingBottom; + paddingLeft = options.paddingLeft; + width = options.width; + height = options.height; + marginBottom = options.marginBottom; + marginLeft = options.marginLeft; + marginRight = options.marginRight; + marginTop = options.marginTop; + paddingRight = options.paddingRight; + paddingTop = options.paddingTop; + // adjust based on margin/padding + outerWidth = width - marginLeft - marginRight; + outerHeight = height - marginTop - marginBottom; + innerWidth = outerWidth - paddingLeft - paddingRight; + innerHeight = outerHeight - paddingTop - paddingBottom; + + if (changed.hasOwnProperty('title')) { + _plotTitle.textContent = options.title; + _plotTitle.setAttribute('y', D3Util.getBBox(_plotTitle).height); + } + + if ( + changed.hasOwnProperty('width') || + changed.hasOwnProperty('height') || + changed.hasOwnProperty('legendPosition') || + changed.hasOwnProperty('marginBottom') || + changed.hasOwnProperty('marginLeft') || + changed.hasOwnProperty('marginRight') || + changed.hasOwnProperty('marginTop') || + changed.hasOwnProperty('paddingBottom') || + changed.hasOwnProperty('paddingLeft') || + changed.hasOwnProperty('paddingRight') || + changed.hasOwnProperty('paddingTop') + ) { + // update elements + _this.el.style.paddingBottom = (100 * height) / width + '%'; + _svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height); + _svg.setAttribute('preserveAspectRatio', 'xMinYMin meet'); + _plotAreaClip.setAttribute('width', innerWidth); + _plotAreaClip.setAttribute('height', innerHeight); + _margin.setAttribute('transform', 'translate(' + marginLeft + ',' + marginTop + ')'); + _outerFrame.setAttribute('height', outerHeight); + _outerFrame.setAttribute('width', outerWidth); + _plotTitle.setAttribute('x', outerWidth / 2); + _padding.setAttribute('transform', 'translate(' + paddingLeft + ',' + paddingTop + ')'); + _innerFrame.setAttribute('width', innerWidth); + _innerFrame.setAttribute('height', innerHeight); + + legendPosition = options.legendPosition; + legendX = 0; + legendY = 0; + if (legendPosition === 'topright') { + legendX = innerWidth; + } else if (legendPosition === 'bottomleft') { + legendY = innerHeight; + } else if (legendPosition === 'bottomright') { + legendX = innerWidth; + legendY = innerHeight; + } // else 'topleft' + _legend.setAttribute('transform', 'translate(' + legendX + ',' + legendY + ')'); + } + + _this.renderViews({ + el: _plotArea, + height: height, + innerHeight: innerHeight, + innerWidth: innerWidth, + marginBottom: marginBottom, + marginLeft: marginLeft, + marginRight: marginRight, + marginTop: marginTop, + outerWidth: outerWidth, + outerHeight: outerHeight, + }); + + _this.renderLegend({ + el: _legend, + }); + + /** + work around suggested by + https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/3723601/ + */ + _this.el.classList.add('ms-render-fix'); + setTimeout(function () { + if (_this) { + _this.el.classList.remove('ms-render-fix'); + } + }, 50); + }; + + /** + * Re-render sub-views. + * + * @param info {Object} + * plot information. + * @param info.el {SVGElement} + * plot area element. + * @param info.height {Number} + * total plot height. + * @param info.innerHeight {Number} + * plot area height (height - margin/padding). + * @param info.innerWidth {Number} + * plot area width (width - margin/padding). + * @param info.marginBottom {Number} + * bottom margin. + * @param info.marginLeft {Number} + * left margin. + * @param info.marginRight {Number} + * right margin. + * @param info.marginTop {Number} + * top margin. + * @param info.outerHeight {Number} + * height outside padding (height - margin). + * @param info.outerWidth {Number} + * width outside padding (width - margin). + */ + _this.renderViews = function (info) { + var plotArea = info.el; + + _this.views.data().forEach(function (view, index) { + // add elements + plotArea.appendChild(view.el); + view.el.setAttribute('data-index', index); + view.render(_this); + }); + }; + + /** + * Re-render legend. + * + * @param info {Object} + * plot information. + * @param info.el {SVGElement} + * legend area element. + */ + _this.renderLegend = function (info) { + var bbox, legend, legendContent, legendOffset, legendPosition, legendX, legendY; + + legend = info.el; + // clear legend area + Util.empty(legend); + legendContent = d3.select(legend).append('g').attr('class', 'legend-content').node(); + + // add views to plot area + legendY = 0; + _this.views.data().forEach(function (view, index) { + if (view.legend) { + legendContent.appendChild(view.legend); + view.legend.setAttribute('data-index', index); + if (typeof view.renderLegend === 'function') { + // render legend after attached + view.renderLegend(); + } + // position legend + bbox = D3Util.getBBox(view.legend); + legendY += bbox.height; + view.legend.setAttribute('transform', 'translate(0,' + legendY + ')'); + } + }); + + // position legend content. + bbox = D3Util.getBBox(legendContent); + legendOffset = _this.model.get('legendOffset'); + legendPosition = _this.model.get('legendPosition'); + legendX = legendOffset; + legendY = legendOffset; + if (legendPosition === 'topright') { + legendX = -(legendOffset + bbox.width); + } else if (legendPosition === 'bottomleft') { + legendY = -(legendOffset + bbox.height); + } else if (legendPosition === 'bottomright') { + legendX = -(legendOffset + bbox.width); + legendY = -(legendOffset + bbox.height); + } // else 'topleft' + legendContent.setAttribute('transform', 'translate(' + legendX + ',' + legendY + ')'); + }; + + /** + * Show a tooltip on the graph. + * + * @param coords {Array<x, y>} + * plot coordinates for origin of tooltip, should be pre-projected. + * @param data {Array<Object|Array>} + * tooltip content, passed to formatTooltip. + */ + _this.showTooltip = function (coords, data) { + var bbox, content, offset, options, outline, padding, tooltip, tooltipBbox, x, y; + + tooltip = d3.select(_tooltip); + // clear tooltip + tooltip.selectAll('*').remove(); + if (!coords || !data) { + return; + } + + options = _this.model.get(); + offset = options.tooltipOffset; + padding = options.tooltipPadding; + // create tooltip content + outline = tooltip.append('rect').attr('class', 'tooltip-outline'); + content = tooltip.append('g').attr('class', 'tooltip-content'); + D3Util.formatText(content, data); + // position tooltip outline + bbox = D3Util.getBBox(tooltip.node()); + outline.attr('width', bbox.width + 2 * padding).attr('height', bbox.height + 2 * padding); + content.attr('transform', 'translate(' + padding + ',0)'); + + // center of point + x = coords[0]; + y = coords[1]; + + // box rendering inside + bbox = D3Util.getBBox(_innerFrame); + // box being rendered + tooltipBbox = D3Util.getBBox(_tooltip); + // keep tooltip in graph area + if (x + tooltipBbox.width > bbox.width) { + x = x - tooltipBbox.width - offset; + } else { + x = x + offset; + } + if (y + tooltipBbox.height > bbox.height) { + y = y - tooltipBbox.height - offset; + } else { + y = y + offset; + } + // set position + _tooltip.setAttribute('transform', 'translate(' + x + ',' + y + ')'); + }; + + _initialize(options); + options = null; + return _this; +}; + +module.exports = D3BaseView; diff --git a/src/d3/view/D3SubView.js b/src/d3/view/D3SubView.js new file mode 100644 index 0000000000000000000000000000000000000000..599f04037f18abd0205e9bad43031cad59375b7c --- /dev/null +++ b/src/d3/view/D3SubView.js @@ -0,0 +1,195 @@ +'use strict'; + +var d3 = require('d3'), + ClassList = require('../util/ClassList'), + Util = require('../../util/Util'), + View = require('../../mvc/View'); + +var ID_SEQUENCE = 0; + +/** + * Sub view for a D3 plot. + * + * Manages mouseover, mouseout, click events for view. + * mouseover and mouseout toggle a "mouseover" class on view. + * click triggers "click" event. + * + * When added to a D3View, "click" event triggers "select" in collection. + * D3View calls onSelect, onDeselect methods when collection selection changes. + * + * Subclasses should override at least getXExtent(), getYExtent(), render(view). + * + * @param options {Object} + * all options are passed to View. + * @param options.el {SVGElement} + * default svg:g. + * @param options.legend {SVGElement} + * default svg:g. + * set to null for no legend. + * @param options.className {String} + * default null. + * class added to el and legend. + */ +var D3SubView = function (options) { + var _this, + _initialize, + // variables + _el, + _legend; + + _this = View( + Util.extend( + { + el: document.createElementNS('http://www.w3.org/2000/svg', 'g'), + }, + options + ) + ); + + /** + * Initialize view. + */ + _initialize = function (options) { + options = Util.extend( + { + className: null, + legend: document.createElementNS('http://www.w3.org/2000/svg', 'g'), + }, + options + ); + + // ensure views have a unique id + _this.id = options.id || ID_SEQUENCE++; + _this.view = options.view; + + // reference to view from element + _this.el.view = _this; + + ClassList.polyfill(_this.el); + _el = d3.select(_this.el); + _el.on('click', _this.onClick); + _el.on('mouseout', _this.onMouseOut); + _el.on('mouseover', _this.onMouseOver); + + _this.legend = options.legend; + if (_this.legend) { + ClassList.polyfill(_this.legend); + _legend = d3.select(_this.legend); + _legend.on('click', _this.onClick); + _legend.on('mouseout', _this.onMouseOut); + _legend.on('mouseover', _this.onMouseOver); + } + + if (options.className) { + _this.el.classList.add(options.className); + if (_this.legend) { + _this.legend.classList.add(options.className); + } + } + }; + + /** + * Destroy view. + */ + _this.destroy = Util.compose(function () { + if (_this === null) { + // already destroyed + return; + } + + if (_el) { + _el.on('click', null); + _el.on('mouseout', null); + _el.on('mouseover', null); + _el = null; + } + if (_this.legend) { + _legend.on('click', null); + _legend.on('mouseout', null); + _legend.on('mouseover', null); + _legend = null; + } + + _this.el.view = null; + _this = null; + }, _this.destroy); + + /** + * X extent for view. + * + * @return {Array<Number>} + * x extent for view. + */ + _this.getXExtent = function () { + return []; + }; + + /** + * Y extent for view. + * + * @return {Array<Number>} + * y extent for view. + */ + _this.getYExtent = function () { + return []; + }; + + /** + * Click event handler. + */ + _this.onClick = function () { + _this.trigger('click'); + }; + + /** + * Deselect event handler. + */ + _this.onDeselect = function () { + _this.el.classList.remove('selected'); + if (_this.legend) { + _this.legend.classList.remove('selected'); + } + }; + + /** + * Mouseout event handler. + */ + _this.onMouseOut = function () { + _this.el.classList.remove('mouseover'); + if (_this.legend) { + _this.legend.classList.remove('mouseover'); + } + }; + + /** + * Mouseover event handler. + */ + _this.onMouseOver = function () { + _this.el.classList.add('mouseover'); + if (_this.legend) { + _this.legend.classList.add('mouseover'); + } + }; + + /** + * Select event handler. + */ + _this.onSelect = function () { + _this.el.classList.add('selected'); + if (_this.legend) { + _this.legend.classList.add('selected'); + } + }; + + /** + * Render sub view. + * Element has already been attached to view. + */ + _this.render = function () {}; + + _initialize(options); + options = null; + return _this; +}; + +module.exports = D3SubView; diff --git a/src/d3/view/D3View.js b/src/d3/view/D3View.js new file mode 100644 index 0000000000000000000000000000000000000000..22ba5a2b352bb9fe05c39b1b0499e9151f79c06d --- /dev/null +++ b/src/d3/view/D3View.js @@ -0,0 +1,696 @@ +'use strict'; + +var d3 = require('d3'), + Collection = require('../../mvc/Collection'), + D3Util = require('../util/D3Util'), + Util = require('../../util/Util'), + View = require('../../mvc/View'); + +/** + * View for a D3 plot. + * + * @param options {Object} + * options are passed to View. + * @param options.clickToSelect {Boolean} + * default true. + * when true, clicking a view causes it to be selected in the + * views collection. + * @param options.height {Number} + * default 480. + * overall (viewbox) height of svg element. + * @param options.legendPosition {String} + * default 'topleft'. + * one of (topright|topleft|bottomright|bottomleft). + * position of legend element. + * @param options.marginBottom {Number} + * default 0. + * @param options.marginLeft {Number} + * default 0. + * @param options.marginRight {Number} + * default 0. + * @param options.marginTop {Number} + * default 0. + * @param options.paddingBottom {Number} + * default 80. + * @param options.paddingLeft {Number} + * default 80. + * @param options.paddingRight {Number} + * default 20. + * @param options.paddingTop {Number} + * default 50. + * @param options.title {String} + * title for plot. + * @param options.tooltipOffset {Number} + * default 10. + * x/y distance from tooltip coordinate. + * @param options.tooltipPadding {Number} + * default 5. + * padding around tooltip content. + * @param options.width {Number} + * default 640. + * width of svg viewBox. + * @param options.xAxisFormat {Function|String} + * default null. + * x axis tickFormat. + * @param options.xAxisPadding {Number} + * default 0.05. + * pad extents by this ratio. + * For example: 0.05 pads the x axis extent by 5% of the range. + * @param options.xAxisScale {d3.scale} + * default d3.scale.linear(). + * @param options.xAxisTicks {Function(extent)|Array<Number>} + * default null. + * x axis tick values. + * @param optoins.xExtent {Array<Number>} + * default null. + * explicit x extent for graph, default is auto. + * @param options.xLabel {String} + * label for x axis. + * @param options.yAxisFormat {Function|String} + * default null. + * y axis tickFormat. + * @param options.yAxisPadding {Number} + * default 0.05. + * pad extents by this ratio. + * For example: 0.05 pads the y axis extent by 5% of the range. + * @param options.yAxisScale {d3.scale} + * default d3.scale.linear(). + * @param options.yAxisTicks {Function(extent)|Array<Number>} + * default null. + * y axis tick values. + * @param optoins.yExtent {Array<Number>} + * default null. + * explicit y extent for graph, default is auto. + * @param options.yLabel {String} + * label for y axis. + */ +var D3View = function (options) { + var _this, + _initialize, + // variables + _firstRender, + _innerFrame, + _legend, + _margin, + _outerFrame, + _padding, + _plotArea, + _plotAreaClip, + _plotTitle, + _svg, + _tooltip, + _xAxis, + _xAxisEl, + _xAxisLabel, + _xEl, + _yAxis, + _yAxisEl, + _yAxisLabel, + _yEl; + + _this = View(options); + + /** + * Initialize view. + */ + _initialize = function (options) { + var el; + + options = options || {}; + _firstRender = true; + + _this.model.set( + Util.extend( + { + clickToSelect: true, + height: 480, + legendPosition: 'topright', + legendOffset: 20, + marginBottom: 0, + marginLeft: 0, + marginRight: 0, + marginTop: 0, + paddingBottom: 80, + paddingLeft: 80, + paddingRight: 20, + paddingTop: 50, + pointRadius: 3, + title: '', + tooltipOffset: 10, + tooltipPadding: 5, + width: 640, + xAxisFormat: null, + xAxisPadding: 0.05, + xAxisScale: d3.scale.linear(), + xAxisTicks: null, + xExtent: null, + xLabel: '', + yAxisFormat: null, + yAxisPadding: 0.05, + yAxisScale: d3.scale.linear(), + yAxisTicks: null, + yExtent: null, + yLabel: '', + }, + options + ), + { silent: true } + ); + + el = _this.el; + el.classList.add('D3View'); + el.innerHTML = + '<svg xmlns="http://www.w3.org/2000/svg">' + + '<defs>' + + '<clipPath id="plotAreaClip">' + + '<rect x="0" y="0"></rect>' + + '</clipPath>' + + '</defs>' + + '<g class="margin">' + + '<rect class="outer-frame"></rect>' + + '<text class="plot-title" text-anchor="middle"></text>' + + '<g class="padding">' + + '<rect class="inner-frame"></rect>' + + '<g class="legend"></g>' + + '<g class="x">' + + '<g class="axis"></g>' + + '<text class="label" text-anchor="middle"></text>' + + '</g>' + + '<g class="y">' + + '<g class="axis"></g>' + + '<text class="label" text-anchor="middle"' + + ' transform="rotate(-90)"></text>' + + '</g>' + + '<g class="plot"></g>' + + '<g class="tooltip"></g>' + + '</g>' + + '</g>' + + '</svg>'; + + _svg = el.querySelector('svg'); + _plotAreaClip = _svg.querySelector('#plotAreaClip > rect'); + _outerFrame = _svg.querySelector('.outer-frame'); + _innerFrame = _svg.querySelector('.inner-frame'); + _margin = _svg.querySelector('.margin'); + _plotTitle = _margin.querySelector('.plot-title'); + _padding = _margin.querySelector('.padding'); + _legend = _padding.querySelector('.legend'); + _xEl = _padding.querySelector('.x'); + _xAxisEl = _xEl.querySelector('.axis'); + _xAxisLabel = _xEl.querySelector('.label'); + _yEl = _padding.querySelector('.y'); + _yAxisEl = _yEl.querySelector('.axis'); + _yAxisLabel = _yEl.querySelector('.label'); + _plotArea = _padding.querySelector('.plot'); + _tooltip = _padding.querySelector('.tooltip'); + + _this.views = Collection([]); + _this.views.on('add', _this.onAdd); + _this.views.on('deselect', _this.onDeselect); + _this.views.on('remove', _this.onRemove); + _this.views.on('reset', _this.onReset); + _this.views.on('select', _this.onSelect); + + _xAxis = d3.svg.axis().orient('bottom').outerTickSize(0); + _yAxis = d3.svg.axis().orient('left').outerTickSize(0); + }; + + /** + * Destroy view. + */ + _this.destroy = Util.compose(function () { + if (_this === null) { + // already destroyed + return; + } + + _this.views.off(); + _this.views.destroy(); + + _this.views = null; + _innerFrame = null; + _legend = null; + _margin = null; + _outerFrame = null; + _padding = null; + _plotArea = null; + _plotAreaClip = null; + _plotTitle = null; + _svg = null; + _tooltip = null; + _xAxis = null; + _xAxisEl = null; + _xAxisLabel = null; + _xEl = null; + _yAxis = null; + _yAxisEl = null; + _yAxisLabel = null; + _yEl = null; + _this = null; + }, _this.destroy); + + _this.getLegendClass = function (/*data, index, scope*/) { + return 'legend-content'; + }; + + /** + * Views collection add handler. + * + * @param views {Array<D3SubView>} + * views that were added. + */ + _this.onAdd = function (views, dontrender) { + views.forEach(function (view) { + view._d3view_onclick = function () { + _this.onClick(view); + }; + view.on('click', view._d3view_onclick); + }); + if (!dontrender) { + _this.render(); + } + }; + + /** + * Called when a view is clicked. + * + * @param view {D3SubView} + * view that was clicked. + */ + _this.onClick = function (view) { + if (_this.model.get('clickToSelect')) { + _this.views.select(view); + } + }; + + /** + * Views collection select handler. + * + * @param view {D3SubView} + * view that was selected. + */ + _this.onDeselect = function (view) { + view.onDeselect(); + }; + + /** + * Views collection remove handler. + * + * @param views {Array<D3SubView>} + * views that were removed. + */ + _this.onRemove = function (views, dontrender) { + views.forEach(function (view) { + view.off('click', view._d3view_onclick); + view._d3view_onclick = null; + }); + if (!dontrender) { + _this.render(); + } + }; + + /** + * Views collection reset handler. + */ + _this.onReset = function () { + var el, + toRemove = []; + // call onRemove for all existing views. + while (_plotArea.firstChild) { + // detach view + el = _plotArea.firstChild; + _plotArea.removeChild(el); + // call remove to clean up + toRemove.push(el.view); + } + _this.onRemove(toRemove); + // call onAdd for all views + _this.onAdd(_this.views.data()); + }; + + /** + * Views collection select handler. + * + * @param view {D3SubView} + * view that was selected. + */ + _this.onSelect = function (view) { + view.onSelect(); + }; + + /** + * Render view. + * + * @param changed {Object} + * default is _this.model.get. + * list of properties that have changed. + */ + _this.render = function (changed) { + var height, + innerWidth, + innerHeight, + legendPosition, + legendX, + legendY, + marginBottom, + marginLeft, + marginRight, + marginTop, + options, + outerHeight, + outerWidth, + paddingBottom, + paddingLeft, + paddingRight, + paddingTop, + width, + xAxisScale, + xAxisTicks, + xExtent, + xPlotExtent, + yAxisScale, + yAxisTicks, + yPlotExtent; + + options = _this.model.get(); + if (_firstRender || !changed) { + changed = options; + _firstRender = false; + } + + // all options + xAxisScale = options.xAxisScale; + yAxisScale = options.yAxisScale; + // these are used for label positioning + paddingBottom = options.paddingBottom; + paddingLeft = options.paddingLeft; + + if (changed.hasOwnProperty('title')) { + _plotTitle.textContent = options.title; + _plotTitle.setAttribute('y', D3Util.getBBox(_plotTitle).height); + } + if (changed.hasOwnProperty('xLabel')) { + _xAxisLabel.textContent = options.xLabel; + } + if (changed.hasOwnProperty('yLabel')) { + _yAxisLabel.textContent = options.yLabel; + } + + if ( + changed.hasOwnProperty('width') || + changed.hasOwnProperty('height') || + changed.hasOwnProperty('legendPosition') || + changed.hasOwnProperty('marginBottom') || + changed.hasOwnProperty('marginLeft') || + changed.hasOwnProperty('marginRight') || + changed.hasOwnProperty('marginTop') || + changed.hasOwnProperty('paddingBottom') || + changed.hasOwnProperty('paddingLeft') || + changed.hasOwnProperty('paddingRight') || + changed.hasOwnProperty('paddingTop') + ) { + width = options.width; + height = options.height; + marginBottom = options.marginBottom; + marginLeft = options.marginLeft; + marginRight = options.marginRight; + marginTop = options.marginTop; + paddingRight = options.paddingRight; + paddingTop = options.paddingTop; + // adjust based on margin/padding + outerWidth = width - marginLeft - marginRight; + outerHeight = height - marginTop - marginBottom; + innerWidth = outerWidth - paddingLeft - paddingRight; + innerHeight = outerHeight - paddingTop - paddingBottom; + // update elements + _this.el.style.paddingBottom = (100 * height) / width + '%'; + _svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height); + _svg.setAttribute('preserveAspectRatio', 'xMinYMin meet'); + _plotAreaClip.setAttribute('width', innerWidth); + _plotAreaClip.setAttribute('height', innerHeight); + _margin.setAttribute('transform', 'translate(' + marginLeft + ',' + marginTop + ')'); + _outerFrame.setAttribute('height', outerHeight); + _outerFrame.setAttribute('width', outerWidth); + _plotTitle.setAttribute('x', outerWidth / 2); + _padding.setAttribute('transform', 'translate(' + paddingLeft + ',' + paddingTop + ')'); + _innerFrame.setAttribute('width', innerWidth); + _innerFrame.setAttribute('height', innerHeight); + _xEl.setAttribute('transform', 'translate(0,' + innerHeight + ')'); + // update axes range and position + xAxisScale.range([0, innerWidth]); + yAxisScale.range([innerHeight, 0]); + _xAxisLabel.setAttribute('x', innerWidth / 2); + _yAxisLabel.setAttribute('x', -innerHeight / 2); + + legendPosition = options.legendPosition; + legendX = 0; + legendY = 0; + if (legendPosition === 'topright') { + legendX = innerWidth; + } else if (legendPosition === 'bottomleft') { + legendY = innerHeight; + } else if (legendPosition === 'bottomright') { + legendX = innerWidth; + legendY = innerHeight; + } // else 'topleft' + _legend.setAttribute('transform', 'translate(' + legendX + ',' + legendY + ')'); + } + + // update axes extent + xExtent = _this.getXExtent(); + xPlotExtent = _this.getPlotXExtent(xExtent); + xAxisScale.domain(xPlotExtent); + yPlotExtent = _this.getPlotYExtent(xPlotExtent); + yAxisScale.domain(yPlotExtent); + + // redraw axes + _xAxis.scale(xAxisScale); + _xAxis.tickFormat(options.xAxisFormat); + xAxisTicks = options.xAxisTicks; + if (typeof xAxisTicks === 'function') { + xAxisTicks = xAxisTicks(xExtent); + } + _xAxis.tickValues(xAxisTicks); + + _yAxis.scale(yAxisScale); + _yAxis.tickFormat(options.yAxisFormat); + yAxisTicks = options.yAxisTicks; + if (typeof yAxisTicks === 'function') { + yAxisTicks = yAxisTicks(yPlotExtent); + } + _yAxis.tickValues(yAxisTicks); + + d3.select(_xAxisEl).call(_xAxis); + d3.select(_yAxisEl).call(_yAxis); + + // update label positions based on axes size + _xAxisLabel.setAttribute('y', paddingBottom - D3Util.getBBox(_xAxisLabel).height); + _yAxisLabel.setAttribute('y', D3Util.getBBox(_yAxisLabel).height - paddingLeft); + + // now render views + _this.renderViews(); + + /** + work around suggested by + https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/3723601/ + */ + _this.el.classList.add('ms-render-fix'); + setTimeout(function () { + if (_this) { + _this.el.classList.remove('ms-render-fix'); + } + }, 50); + }; + + /** + * Re-render sub-views. + */ + _this.renderViews = function () { + var bbox, legendContent, legendOffset, legendPosition, legendX, legendY; + + // clear plot area + Util.empty(_plotArea); + Util.empty(_legend); + legendContent = d3.select(_legend).append('g').attr('class', _this.getLegendClass).node(); + + // add views to plot area + legendY = 0; + _this.views.data().forEach(function (view, index) { + // add elements + _plotArea.appendChild(view.el); + view.el.setAttribute('data-index', index); + if (view.legend) { + legendContent.appendChild(view.legend); + view.legend.setAttribute('data-index', index); + } + // render elements + view.render(_this); + // position legend + if (view.legend) { + bbox = D3Util.getBBox(view.legend); + legendY += bbox.height; + view.legend.setAttribute('transform', 'translate(0,' + legendY + ')'); + } + }); + + // position legend content. + bbox = D3Util.getBBox(legendContent); + legendOffset = _this.model.get('legendOffset'); + legendPosition = _this.model.get('legendPosition'); + legendX = legendOffset; + legendY = legendOffset; + if (legendPosition === 'topright') { + legendX = -(legendOffset + bbox.width); + } else if (legendPosition === 'bottomleft') { + legendY = -(legendOffset + bbox.height); + } else if (legendPosition === 'bottomright') { + legendX = -(legendOffset + bbox.width); + legendY = -(legendOffset + bbox.height); + } // else 'topleft' + legendContent.setAttribute('transform', 'translate(' + legendX + ',' + legendY + ')'); + }; + + /** + * Get the plot x extent, including padding. + * + * @return {Array<Number>} x extents. + */ + _this.getPlotXExtent = function (xExtent) { + var xAxisPadding, xAxisScale; + + xAxisPadding = _this.model.get('xAxisPadding'); + if (xAxisPadding) { + xAxisScale = _this.model.get('xAxisScale'); + xExtent = (typeof xAxisScale.base === 'function' ? D3Util.padLogExtent : D3Util.padExtent)( + xExtent, + xAxisPadding + ); + } + + return xExtent; + }; + + /** + * Get the plot y extent, including padding. + * + * @param xExtent {Array<Number>} + * xExtent is passed to _this.getYExtent(). + * @return {Array<Number>} y extents. + */ + _this.getPlotYExtent = function (xExtent) { + var yAxisPadding, yAxisScale, yExtent; + + yExtent = _this.getYExtent(xExtent); + yAxisPadding = _this.model.get('yAxisPadding'); + if (yAxisPadding) { + yAxisScale = _this.model.get('yAxisScale'); + yExtent = (typeof yAxisScale.base === 'function' ? D3Util.padLogExtent : D3Util.padExtent)( + yExtent, + yAxisPadding + ); + } + + return yExtent; + }; + + /** + * Get the data x extent. + * + * @return {Array<Number>} x extents. + */ + _this.getXExtent = function () { + var xExtent; + + xExtent = _this.model.get('xExtent'); + if (xExtent === null) { + xExtent = []; + _this.views.data().forEach(function (view) { + xExtent = xExtent.concat(view.getXExtent()); + }); + xExtent = d3.extent(xExtent); + } + + return xExtent; + }; + + /** + * Get the data y extent, including padding. + * + * @param xExtent {Array<Number>} + * x extent, in case y extent is filtered based on x extent. + * @return {Array<Number>} x extents. + */ + _this.getYExtent = function (/* xExtent */) { + var yExtent; + + yExtent = _this.model.get('yExtent'); + if (yExtent === null) { + yExtent = []; + _this.views.data().forEach(function (view) { + yExtent = yExtent.concat(view.getYExtent()); + }); + yExtent = d3.extent(yExtent); + } + + return yExtent; + }; + + /** + * Show a tooltip on the graph. + * + * @param coords {Array<x, y>} + * coordinate for origin of tooltip. + * @param data {Array<Object|Array>} + * tooltip content, passed to formatTooltip. + */ + _this.showTooltip = function (coords, data) { + var bbox, content, offset, options, outline, padding, tooltip, tooltipBbox, x, y; + + tooltip = d3.select(_tooltip); + // clear tooltip + tooltip.selectAll('*').remove(); + if (!coords || !data) { + return; + } + + options = _this.model.get(); + offset = options.tooltipOffset; + padding = options.tooltipPadding; + // create tooltip content + outline = tooltip.append('rect').attr('class', 'tooltip-outline'); + content = tooltip.append('g').attr('class', 'tooltip-content'); + D3Util.formatText(content, data); + // position tooltip outline + bbox = D3Util.getBBox(tooltip.node()); + outline.attr('width', bbox.width + 2 * padding).attr('height', bbox.height + 2 * padding); + content.attr('transform', 'translate(' + padding + ',0)'); + + // position tooltip on graph + // center of point + x = options.xAxisScale(coords[0]); + y = options.yAxisScale(coords[1]); + // box rendering inside + bbox = D3Util.getBBox(_innerFrame); + // box being rendered + tooltipBbox = D3Util.getBBox(_tooltip); + // keep tooltip in graph area + if (x + tooltipBbox.width > bbox.width) { + x = x - tooltipBbox.width - offset; + } else { + x = x + offset; + } + if (y + tooltipBbox.height > bbox.height) { + y = y - tooltipBbox.height - offset; + } else { + y = y + offset; + } + // set position + _tooltip.setAttribute('transform', 'translate(' + x + ',' + y + ')'); + }; + + _initialize(options); + options = null; + return _this; +}; + +module.exports = D3View; diff --git a/src/disagg/D33dDeaggregationBin.js b/src/disagg/D33dDeaggregationBin.js new file mode 100644 index 0000000000000000000000000000000000000000..d61416974c6ec4a4f85e66f2bae2588275c293c1 --- /dev/null +++ b/src/disagg/D33dDeaggregationBin.js @@ -0,0 +1,113 @@ +'use strict'; + +var D33dCuboid = require('../d3/3d/D33dCuboid'), + D33dGroup = require('../d3/3d/D33dGroup'), + Util = require('../util/Util'); + +var _DEFAULTS; + +_DEFAULTS = { + size: 1, + xScale: 1, + yScale: 1, + zScale: 1, +}; + +/** + * Represents one vertical bar on a deaggregation plot. + * + * @param options {Object} + * @param options.bin {Object} + * one magnitude/distance bin object. + * @param options.bin.m {Number} + * magnitude. + * @param options.bin.r {Number} + * distance. + * @param options.bin.εdata {Array<Object>} + * epsilon bin percent contributions. + * @param options.size {Number} + * default 1. + * size of bin around distance/magnitude point on graph. + * @param options.xScale {Number} + * default 1. + * multiplier for raw x values. + * @param options.yScale {Number} + * default 1. + * multiplier for raw y values. + * @param options.zScale {Number} + * default 1. + * multiplier for raw z values. + */ +var D33dDeaggregationBin = function (options) { + var _this, _initialize; + + _this = D33dGroup({ + className: 'D33dDeaggregationBin', + }); + + _initialize = function (options) { + options = Util.extend({}, _DEFAULTS, options); + + _this.createEpsilonCuboids(options); + }; + + /** + * Create all epsilon bins within this magnitude/distance bin. + */ + _this.createEpsilonCuboids = function (options) { + var bin, items, m, r, size, total, x, xScale, y, yScale, z, zScale, εbinIds, εbins, εdata; + + bin = options.bin; + εbins = options.εbins; + size = options.size / 2; + xScale = options.xScale; + yScale = options.yScale; + zScale = options.zScale; + m = bin.m; + r = bin.r; + εdata = bin.εdata; + εbinIds = εbins.getIds(); + + items = []; + // represents total contribution after all bins are processed. + total = 0; + εdata.forEach(function (bin) { + var item, value, εbin; + + value = bin.value; + εbin = bin.εbin; + + x = r * xScale; + y = m * yScale; + z = total * zScale; + + // cuboid for this epsilon bin contribution + item = D33dCuboid({ + x0: x - size, + x1: x + size, + y0: y - size, + y1: y + size, + z0: z, + z1: z + value * zScale, + }); + item.el.setAttribute('data-bin-index', εbinIds[εbin]); + items.push(item); + + // increase with each epsilon bin contribution + total += value; + }); + + _this.model.set( + { + items: items, + }, + { silent: true } + ); + }; + + _initialize(options); + options = null; + return _this; +}; + +module.exports = D33dDeaggregationBin; diff --git a/src/disagg/DeaggResponse.js b/src/disagg/DeaggResponse.js new file mode 100644 index 0000000000000000000000000000000000000000..0e5145e56a9881cba6fb0b0d3d00486627c7f62f --- /dev/null +++ b/src/disagg/DeaggResponse.js @@ -0,0 +1,83 @@ +'use strict'; + +var Deaggregation = require('./Deaggregation'), + Collection = require('../mvc/Collection'), + Model = require('../mvc/Model'), + Util = require('../util/Util'); + +// Default values to be used by constructor +var _DEFAULTS = { + metadata: { + imt: { value: 'Unknown' }, + rlabel: 'Distance', + mlabel: 'Magnitude', + εlabel: 'Contribution', + εbins: [], + }, + data: [], +}; + +var _DEAGG_ID = 0; + +/** + * Class: DeaggResponse + * + * @param params {Object} + * Configuration options. See _DEFAULTS for more details. + */ +var DeaggResponse = function (params) { + var _this, _initialize; + + // Inherit from parent class + _this = Model(); + + /** + * @constructor + * + */ + _initialize = function (params) { + var attributes, deaggs, metadata; + + params = Util.extend({}, _DEFAULTS, { id: 'deagg-response-' + _DEAGG_ID++ }, params); + + metadata = params.metadata; + deaggs = params.data.map(function (deagg) { + return Deaggregation( + Util.extend( + { + metadata: metadata, + }, + deagg + ) + ); + }); + + attributes = { + imt: metadata.imt.value, + rlabel: metadata.rlabel, + mlabel: metadata.mlabel, + εlabel: metadata.εlabel, + εbins: metadata.εbins, + deaggregations: Collection(deaggs), + }; + + // Should not have listeners yet, but silent anyway to short-circuit check + _this.set(attributes, { silent: true }); + }; + + _this.destroy = Util.compose(function () { + var deaggs; + + deaggs = _this.get('deaggregations'); + deaggs.destroy(); + + _initialize = null; + _this = null; + }, _this.destroy); + + _initialize(params); + params = null; + return _this; +}; + +module.exports = DeaggResponse; diff --git a/src/disagg/Deaggregation.js b/src/disagg/Deaggregation.js new file mode 100644 index 0000000000000000000000000000000000000000..3868ce7f0da7e03a74333322daf4e608b18b44f9 --- /dev/null +++ b/src/disagg/Deaggregation.js @@ -0,0 +1,31 @@ +'use strict'; + +var Model = require('../mvc/Model'), + Util = require('../util/Util'); + +// Default values to be used by constructor +var _DEFAULTS = { + component: '', // String - GMPE Name or "Total" + data: [], // Array - Hazard contirbution bins + // Each bin has "m", "r", and "εdata" attributes +}; + +var _DEAGG_ID = 0; + +/** + * Class: Deaggregation + * + * @param params {Object} + * Configuration options. See _DEFAULTS for more details. + */ +var Deaggregation = function (params) { + var _this; + + params = Util.extend({}, _DEFAULTS, { id: 'deagg-' + _DEAGG_ID++ }, params); + _this = Model(params); + + params = null; + return _this; +}; + +module.exports = Deaggregation; diff --git a/src/disagg/DeaggregationGraphView.js b/src/disagg/DeaggregationGraphView.js new file mode 100644 index 0000000000000000000000000000000000000000..2ed45902e4bae0dc575d4dbaad0c779034ec99fc --- /dev/null +++ b/src/disagg/DeaggregationGraphView.js @@ -0,0 +1,656 @@ +'use strict'; + +var Collection = require('../mvc/Collection'), + d3 = require('d3'), + D33dAxis = require('../d3/3d/D33dAxis'), + D33dDeaggregationBin = require('./D33dDeaggregationBin'), + D33dPath = require('../d3/3d/D33dPath'), + D33dView = require('../d3/view/D33dView'), + D3Util = require('../d3/util/D3Util'), + SelectedCollectionView = require('../mvc/SelectedCollectionView'), + Util = require('../util/Util'); + +var _DEFAULTS; + +_DEFAULTS = {}; + +var __calculateBounds; + +/** + * Calculate deaggregation data bounds. + * + * @param bindata {Array<Bin>} + * array of deaggregation bins + * where Bin is an Object: + * bin.r {Number} + * distance to bin (x-axis) + * bin.m {Number} + * magnitude of bin (y-axis) + * bin.εdata {Array<Object>} + * array of epsilon data for bin (z-axis) + * Each Object has properties: + * εdata.value {Number} + * % contribution to hazard + * εdata.εbin {Number} + * id of epsilon bin. + * + * @return {Array<Array<x0,y0,z0>, Array<x1,y1,z1>} + * bounds of deaggregation data. + * Array contains two sub arrays, + * containing minimum and maximum values for each axis. + */ +__calculateBounds = function (bindata) { + var x0, x1, y0, y1, z0, z1; + + // start with values that will always be smaller/larger than actual values + x0 = y0 = z0 = Number.POSITIVE_INFINITY; + x1 = y1 = z1 = Number.NEGATIVE_INFINITY; + + bindata.forEach(function (bin) { + var binx, biny, binz; + + binx = bin.r; + biny = bin.m; + // sum values for z + binz = 0; + bin.εdata.forEach(function (εval) { + binz = binz + εval.value; + }); + + // track min/max + if (binx < x0) { + x0 = binx; + } + if (binx > x1) { + x1 = binx; + } + if (biny < y0) { + y0 = biny; + } + if (biny > y1) { + y1 = biny; + } + if (binz < z0) { + z0 = binz; + } + if (binz > z1) { + z1 = binz; + } + }); + + // return bounds + return [ + [x0, y0, z0], + [x1, y1, z1], + ]; +}; + +/** + * A deaggregation distance/magnitude plot. + * + * @param options {Object} + * options are passed to SelectedCollectionView. + * @param options.bounds {Array<Array>} + * default null. + * plotting bounds. + * can be changed later by setting VIEW.bounds and calling VIEW.render + * @param options.collection {Collection<Deaggregation>} + * collection with data to display. + * selected Deaggregation object is displayed. + */ +var DeaggregationGraphView = function (options) { + var _this, _initialize; + + _this = SelectedCollectionView(options); + + _initialize = function (options) { + options = Util.extend({}, _DEFAULTS, options); + + _this.el.innerHTML = '<div class="DeaggregationGraphView"></div>'; + + _this.bounds = options.bounds || null; + + _this.axes = []; + _this.bins = []; + + _this.xScale = 2; + _this.yScale = 25; + _this.zScale = 2; + + _this.xTickSpacing = 5; + _this.yTickSpacing = 0.5; + _this.zTickSpacing = 5; + + _this.d33d = D33dView({ + el: _this.el.querySelector('.DeaggregationGraphView'), + lookAt: [60, 125, 10], + origin: [280, -150, 180], + up: [0, 0, 1], + zoom: 3.5, + }); + _this.d33d.renderLegend = _this.renderLegend; + + _this.render(); + }; + + /** + * Use data extents to set view lookAt, origin, and zoom so plot is + * roughly centered within view plot area. + */ + _this.centerView = function () { + var bounds, + lookAt, + origin, + projectedBounds, + projectedWidth, + projectedHeight, + x0, + x1, + y0, + y1, + z0, + z1, + zoom; + + // get current view + lookAt = _this.d33d.model.get('lookAt'); + origin = _this.d33d.model.get('origin'); + zoom = _this.d33d.model.get('zoom'); + + // compute projected bounds + bounds = _this.getBounds(); + _this.xScale = 100 / (bounds[1][0] - bounds[0][0]); + _this.yScale = 70 / (bounds[1][1] - bounds[0][1]); + _this.zScale = 50 / (bounds[1][2] - bounds[0][2]); + x0 = bounds[0][0] * _this.xScale; + x1 = bounds[1][0] * _this.xScale; + y0 = bounds[0][1] * _this.yScale; + y1 = bounds[1][1] * _this.yScale; + z0 = bounds[0][2] * _this.zScale; + z1 = bounds[1][2] * _this.zScale; + + projectedBounds = [ + [x0, y0, z0], + [x0, y1, z0], + [x1, y1, z0], + [x1, y0, z0], + [x0, y0, z1], + [x0, y1, z1], + [x1, y1, z1], + [x1, y0, z1], + ] + .map(_this.d33d.project) + .reduce( + function (bounds, point) { + var x, y, z; + + x = point[0]; + y = point[1]; + z = point[2]; + + if (x < bounds[0][0]) { + bounds[0][0] = x; + } + if (x > bounds[1][0]) { + bounds[1][0] = x; + } + if (y < bounds[0][1]) { + bounds[0][1] = y; + } + if (y > bounds[1][1]) { + bounds[1][1] = y; + } + if (z < bounds[0][2]) { + bounds[0][2] = z; + } + if (z > bounds[1][2]) { + bounds[1][2] = z; + } + + return bounds; + }, + [ + [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY], + [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY], + ] + ); + + projectedWidth = projectedBounds[1][0] - projectedBounds[0][0]; + projectedHeight = projectedBounds[1][1] - projectedBounds[0][1]; + + // look at x/y center at z=z0 + lookAt = [(0.95 * (x1 + x0)) / 2, (0.95 * (y1 + y0)) / 2, -5 * _this.zScale]; + origin = [lookAt[0] + 1000, lookAt[1] - 1000, lookAt[2] + 1000]; + zoom = Math.min((480 * zoom) / projectedWidth, (480 * zoom) / projectedHeight); + + _this.d33d.model.set({ + lookAt: lookAt, + origin: origin, + zoom: zoom, + }); + }; + + /** + * Create axes to be plotted. + * + * TODO: eliminate "magic" numbers. + * Extents and tick, label, and title, vectors arre hard coded and work for + * the current lookAt, origin, and zoom, combination. + */ + _this.createAxes = function () { + var bounds, + extent, + gridSpacing, + metadata, + x, + x0, + x1, + xLabel, + xTicks, + y, + y0, + y1, + yLabel, + yTicks, + z0, + z1, + zLabel, + zTicks; + + bounds = _this.getBounds(); + x0 = bounds[0][0]; + x1 = bounds[1][0]; + y0 = bounds[0][1]; + y1 = bounds[1][1]; + z0 = bounds[0][2]; + z1 = bounds[1][2]; + + if (_this.model) { + metadata = _this.model.get('metadata'); + } else { + metadata = {}; + } + + //_this.yScale = 100 / (y1 - y0); + //_this.zScale = 100 / (z1 - z0); + + xLabel = metadata.rlabel; + xTicks = (x1 - x0) / _this.xTickSpacing; + while (xTicks > 10) { + xTicks = xTicks / 2; + } + + yLabel = metadata.mlabel; + yTicks = (y1 - y0) / _this.yTickSpacing; + zLabel = metadata.εlabel; + zTicks = (z1 - z0) / _this.zTickSpacing; + + x0 = x0 * _this.xScale; + x1 = x1 * _this.xScale; + y0 = y0 * _this.yScale; + y1 = y1 * _this.yScale; + z0 = z0 * _this.zScale; + z1 = z1 * _this.zScale; + + // x axis at y0 + extent = [ + [x0, y0, z0], + [x1, y0, z0], + ]; + _this.axes.push( + D33dAxis({ + className: 'axis x-axis', + extent: extent, + format: _this.formatX, + labelAnchor: 'middle', + labelDirection: extent, + labelVector: [1, -5, 0], + padding: 0, + tickVector: [0, -1, 0], + ticks: xTicks, + title: xLabel, + titleAnchor: 'middle', + titleDirection: extent, + titleVector: [2, -10, 0], + }) + ); + + // x axis at y1 + extent = [ + [x0, y1, z0], + [x1, y1, z0], + ]; + _this.axes.push( + D33dAxis({ + className: 'axis x-axis', + extent: extent, + format: _this.formatX, + labelAnchor: 'middle', + labelDirection: extent, + labelVector: [0, 2, 0], + padding: 0, + tickVector: [0, 1, 0], + ticks: xTicks, + title: xLabel, + titleAnchor: 'middle', + titleDirection: extent, + titleVector: [0, 8, 0], + }) + ); + + // y axis at x0 + extent = [ + [x0, y0, z0], + [x0, y1, z0], + ]; + _this.axes.push( + D33dAxis({ + className: 'axis y-axis', + extent: extent, + format: _this.formatY, + labelAnchor: 'middle', + labelDirection: extent, + labelVector: [-2, 0, 0], + padding: 0, + tickVector: [-1, 0, 0], + ticks: yTicks, + title: yLabel, + titleAnchor: 'middle', + titleDirection: extent, + titleVector: [-7, 2, 0], + }) + ); + + // y axis at x1 + extent = [ + [x1, y0, z0], + [x1, y1, z0], + ]; + _this.axes.push( + D33dAxis({ + className: 'axis y-axis', + extent: extent, + format: _this.formatY, + labelAnchor: 'middle', + labelDirection: extent, + labelVector: [5, -2, 0], + padding: 0, + tickVector: [1, 0, 0], + ticks: yTicks, + title: yLabel, + titleAnchor: 'middle', + titleDirection: extent, + titleVector: [9, -2, 0], + }) + ); + + // z axis + extent = [ + [x0, y0, z0], + [x0, y0, z1], + ]; + _this.axes.push( + D33dAxis({ + className: 'axis z-axis', + extent: extent, + format: _this.formatZ, + labelAnchor: 'middle', + labelDirection: extent, + labelVector: [-1.5, 0, 0], + padding: 0, + tickVector: [-1, 0, 0], + ticks: zTicks, + title: zLabel, + titleAnchor: 'middle', + titleDirection: extent, + titleVector: [-5, 0, 0], + }) + ); + + // grid lines + gridSpacing = _this.xTickSpacing * _this.xScale; + while ((x1 - x0) / gridSpacing > 10) { + gridSpacing = gridSpacing * 2; + } + + for (x = x0 + gridSpacing; x < x1; x += gridSpacing) { + _this.axes.push( + D33dPath({ + className: 'grid-line', + close: false, + coords: [ + [x, y0, z0], + [x, y1, z0], + ], + }) + ); + } + + gridSpacing = _this.yTickSpacing * _this.yScale; + for (y = y0 + gridSpacing; y < y1; y += gridSpacing) { + _this.axes.push( + D33dPath({ + className: 'grid-line', + close: false, + coords: [ + [x0, y, z0], + [x1, y, z0], + ], + }) + ); + } + }; + + /** + * Format x-axis {distance} labels. + * + * @param c {Array<Number>} + * tick coordinate. + * @return {String} + * formatted coordinate. + */ + _this.formatX = function (c) { + var x; + x = c[0] / _this.xScale; + if (x === 0) { + return ''; + } + return '' + Number(x.toPrecision(3)); + }; + + /** + * Format y-axis (magnitude) labels. + * + * @param c {Array<Number>} + * tick coordinate. + * @return {String} + * formatted coordinate. + */ + _this.formatY = function (c) { + var y; + y = c[1] / _this.yScale; + if (y === 0) { + return ''; + } + return '' + Number(y.toPrecision(3)); + }; + + /** + * Format z-axis (percent contribution) labels. + * + * @param c {Array<Number>} + * tick coordinate. + * @return {String} + * formatted coordinate. + */ + _this.formatZ = function (c) { + var z; + z = c[2] / _this.zScale; + if (z === 0) { + return ''; + } + return '' + Number(z.toPrecision(3)); + }; + + /** + * Get data bounds for plotting. + * + * @return {Array<Array<x0,y0,z0>, Array<x1,y1,z1>} + * bounds of deaggregation data. + * Array contains two sub arrays, + * containing minimum and maximum values for each axis. + */ + _this.getBounds = function () { + var bounds, x0, x1, y0, y1, z0, z1; + + // default bounds + x0 = 0; + x1 = 100; + y0 = 5; + y1 = 8; + z0 = 0; + z1 = 35; + + if (_this.model) { + bounds = _this.bounds; + if (!bounds) { + bounds = __calculateBounds(_this.model.get('data')); + } + + x0 = bounds[0][0]; + x1 = bounds[1][0]; + y0 = bounds[0][1]; + y1 = bounds[1][1]; + z0 = bounds[0][2]; + z1 = bounds[1][2]; + } + + // round min/max down/up to an increment of 10 + x0 = _this.xTickSpacing * Math.floor(x0 / _this.xTickSpacing) - _this.xTickSpacing; + x1 = _this.xTickSpacing * Math.ceil(x1 / _this.xTickSpacing) + _this.xTickSpacing; + + // round min/max down/up to next whole unit + y0 = _this.yTickSpacing * Math.floor(y0 / _this.yTickSpacing) - _this.yTickSpacing; + y1 = _this.yTickSpacing * Math.ceil(y1 / _this.yTickSpacing) + _this.yTickSpacing; + + // always use 0 + z0 = 0; + // round up to increment of 10 + z1 = _this.zTickSpacing * Math.ceil(z1 / _this.zTickSpacing) + _this.zTickSpacing; + + return [ + [x0, y0, z0], + [x1, y1, z1], + ]; + }; + + /** + * Update displayed data. + * + * When a model is selected, bins are plotted on the graph. + * Otherwise, any existing bins are destroyed and removed from the graph. + */ + _this.render = function () { + var oldAxes, oldBins, εbins; + + oldBins = _this.bins; + oldAxes = _this.axes; + + _this.centerView(); + + // create new views + _this.axes = []; + _this.createAxes(); + + _this.bins = []; + if (_this.model) { + εbins = Collection(_this.model.get('metadata').εbins); + _this.model.get('data').forEach(function (bin) { + var view; + view = D33dDeaggregationBin({ + bin: bin, + xScale: _this.xScale, + yScale: _this.yScale, + zScale: _this.zScale, + εbins: εbins, + }); + _this.bins.push(view); + }); + } + + // update plot + _this.d33d.views.reset([].concat(_this.axes).concat(_this.bins)); + + // clean up old views + oldAxes.forEach(function (a) { + a.destroy(); + }); + + oldBins.forEach(function (v) { + v.destroy(); + }); + + oldAxes = null; + oldBins = null; + }; + + /** + * Custom legend rendering function for wrapped D33dView. + * + * @param info {Object} + * rendering information. + * @param info.el {SVGElement} + * element where legend should be rendered. + */ + _this.renderLegend = function (info) { + var bbox, el, legendEl, metadata, z, εbins; + + legendEl = info.el; + + // clear legend + Util.empty(legendEl); + if (!_this.model) { + return; + } + + // plot epsilon bins + z = 0; + el = d3 + .select(legendEl) + .append('g') + .attr('class', 'legend-content') + .attr('class', 'D33dDeaggregationBin'); + + metadata = _this.model.get('metadata'); + εbins = metadata.εbins; + εbins.forEach(function (bin, index) { + var binEl, text, textEl; + + text = + 'ε = ' + + (bin.min === null ? '(-∞' : '[' + bin.min) + + ' .. ' + + (bin.max === null ? '+∞' : bin.max) + + ')'; + + binEl = el + .append('g') + .attr('data-bin-index', index) + .attr('transform', 'translate(0 ' + z + ')'); + binEl.append('path').attr('d', 'M0,2' + 'L10,2' + 'L10,-8' + 'L0,-8' + 'Z'); + textEl = binEl.append('text').attr('x', 15).text(text); + z += D3Util.getBBox(binEl.node()).height; + }); + + bbox = D3Util.getBBox(el.node()); + el.attr('transform', 'translate(' + -bbox.width + ' 0)'); + }; + + _initialize(options); + options = null; + return _this; +}; + +DeaggregationGraphView.calculateBounds = __calculateBounds; + +module.exports = DeaggregationGraphView; diff --git a/src/disagg/index.d.ts b/src/disagg/index.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb52eedb022f26073b7ff476466ee59e16eab5e0 --- /dev/null +++ b/src/disagg/index.d.ts @@ -0,0 +1 @@ +export * from '../../types/disagg'; diff --git a/src/disagg/index.js b/src/disagg/index.js new file mode 100644 index 0000000000000000000000000000000000000000..4f9fbbc8526762e3cd0585e0912c9d35ad81a50f --- /dev/null +++ b/src/disagg/index.js @@ -0,0 +1,6 @@ +module.exports = { + D33dDeaggregationBin: require('./D33dDeaggregationBin'), + Deaggregation: require('./Deaggregation'), + DeaggregationGraphView: require('./DeaggregationGraphView'), + DeaggResponse: require('./DeaggResponse'), +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000000000000000000000000000000000000..158be18e7c1476f68822eb81b6e0e5096af844ea --- /dev/null +++ b/src/index.js @@ -0,0 +1,7 @@ +module.exports = { + d3: require('./d3'), + disagg: require('./disagg'), + math: require('./math'), + mvc: require('./mvc'), + util: require('./util'), +}; diff --git a/src/math/Camera.js b/src/math/Camera.js new file mode 100644 index 0000000000000000000000000000000000000000..b82d8069ff3035c93a8d70397f83946fd74f3a4f --- /dev/null +++ b/src/math/Camera.js @@ -0,0 +1,103 @@ +'use strict'; + +var Matrix = require('./Matrix'), + Vector = require('./Vector'), + Util = require('../util/Util'); + +var _DEFAULTS = { + lookAt: [0, 0, 0], + origin: [100, 100, 100], + up: [0, 0, 1], +}; + +/** + * Camera defines a coordinate translation from World coordinates (X, Y, Z) + * to Camera coordinates (x, y, z). + * + * After projection: + * +z is to lookAt from camera + * +x is right from camera + * +y is up from camera + * + * @param options {Object} + * @param options.origin {Array<Number>} + * default [100, 100, 100]. + * position of camera in world coordinates. + * @param options.lookAt {Array<Number>} + * default [0, 0, 0]. + * position for camera to look at in world coordinates. + * @param options.up {Array<Number>} + * default [0, 0, 1]. + * vector pointing up in world coordinates. + */ +var Camera = function (options) { + var _this, + _initialize, + // variables + _lookAt, + _origin, + _up, + _worldToCamera; + + _this = {}; + + _initialize = function (options) { + var rotate, translate, x, y, z; + + options = Util.extend({}, _DEFAULTS, options); + + _lookAt = Vector(options.lookAt); + _origin = Vector(options.origin); + _up = Vector(options.up); + + // camera axes using world coordinates + // +z is from origin through look at + z = _lookAt.subtract(_origin).unit(); + // +x is right + x = z.cross(_up).unit(); + // +y is up + y = x.cross(z).unit(); + + rotate = Matrix( + [x.x(), x.y(), x.z(), 0, y.x(), y.y(), y.z(), 0, z.x(), z.y(), z.z(), 0, 0, 0, 0, 1], + 4, + 4 + ); + + translate = Matrix( + [1, 0, 0, -_origin.x(), 0, 1, 0, -_origin.y(), 0, 0, 1, -_origin.z(), 0, 0, 0, 1], + 4, + 4 + ); + + _worldToCamera = rotate.multiply(translate).data(); + }; + + /** + * Project a point from world coordinates to camera coordinates. + * + * @param world {Array<Number>} + * x, y, z world coordinates. + * @return {Array<Number>} + * x, y, z, camera coordinates. + */ + _this.project = function (world) { + var projected, x, xp, y, yp, z, zp; + + x = world[0]; + y = world[1]; + z = world[2]; + projected = Matrix.multiply(_worldToCamera, 4, 4, [x, y, z, 1], 4, 1); + + xp = projected[0]; + yp = projected[1]; + zp = projected[2]; + return [xp, yp, zp]; + }; + + _initialize(options); + options = null; + return _this; +}; + +module.exports = Camera; diff --git a/src/math/Matrix.js b/src/math/Matrix.js new file mode 100644 index 0000000000000000000000000000000000000000..b5b3e9372e5bc0c9e705925f302278fb8e775b3b --- /dev/null +++ b/src/math/Matrix.js @@ -0,0 +1,655 @@ +'use strict'; + +var Vector = require('./Vector'); + +// static methods that operate on arrays +var __col, + __diagonal, + __get, + __identity, + __index, + __jacobi, + __multiply, + __row, + __set, + __stringify, + __transpose; + +/** + * Extract a column from this matrix. + * + * @param data {Array<Number>} + * matrix data. + * @param m {Number} + * number of rows. + * @param n {Number} + * number of columns. + * @param col {Number} + * index of column, in range [0,n) + * @throws Error if column out of range. + * @return {Array<Number>} column elements. + */ +__col = function (data, m, n, col) { + var row, + values = []; + if (col < 0 || col >= n) { + throw new Error('column ' + col + ' out of range [0,' + n + ')'); + } + if (n === 1) { + // only one column in matrix + return data; + } + values = []; + for (row = 0; row < m; row++) { + values.push(data[__index(m, n, row, col)]); + } + return values; +}; + +/** + * Get array of elements on the diagonal. + * + * @param data {Array<Number>} + * matrix data. + * @param m {Number} + * number of rows. + * @param n {Number} + * number of columns. + * @return {Array<Number>} elements on the diagonal. + */ +__diagonal = function (data, m, n) { + var len = Math.min(m, n), + diag = [], + i; + for (i = 0; i < len; i++) { + diag.push(data[__index(m, n, i, i)]); + } + return diag; +}; + +/** + * Get the value of an element of this matrix. + * + * @param data {Array<Number>} + * matrix data. + * @param m {Number} + * number of rows. + * @param n {Number} + * number of columns. + * @param row {Number} + * row of element, in range [0,m) + * @param col {Number} + * column of element, in range [0,n) + * @throws Error if row or col are out of range. + * @return {Number} value. + */ +__get = function (data, m, n, row, col) { + return data[__index(m, n, row, col)]; +}; + +/** + * Create an identity Matrix. + * + * @param n {Number} + * number of rows and columns. + * @return identity matrix of size n. + */ +__identity = function (n) { + var values = [], + row, + col; + for (row = 0; row < n; row++) { + for (col = 0; col < n; col++) { + values.push(row === col ? 1 : 0); + } + } + return values; +}; + +/** + * Get the index of an element of this matrix. + * + * @param data {Array<Number>} + * matrix data. + * @param m {Number} + * number of rows. + * @param n {Number} + * number of columns. + * @param row {Number} + * row of element, in range [0,m) + * @param col {Number} + * column of element, in range [0,n) + * @return {Number} index. + */ +__index = function (m, n, row, col) { + return n * row + col; +}; + +/** + * Jacobi eigenvalue algorithm. + * + * Ported from: + * http://users-phys.au.dk/fedorov/nucltheo/Numeric/now/eigen.pdf + * + * An iterative method for eigenvalues and eigenvectors, + * only works on symmetric matrices. + * + * @param data {Array<Number>} + * matrix data. + * @param m {Number} + * number of rows. + * @param n {Number} + * number of columns. + * @param maxRotations {Number} + * maximum number of rotations. + * Optional, default 100. + * @return {Array<Vector>} array of eigenvectors, magnitude is eigenvalue. + */ +__jacobi = function (data, m, n, maxRotations) { + var a, + aip, + aiq, + api, + app, + app1, + apq, + aqi, + aqq, + aqq1, + c, + changed, + e, + i, + ip, + iq, + p, + phi, + pi, + q, + qi, + rotations, + s, + v, + vector, + vectors, + vip, + viq; + + if (m !== n) { + throw new Error('Jacobi only works on symmetric, square matrices'); + } + + // set a default max + maxRotations = maxRotations || 100; + a = data.slice(0); + e = __diagonal(data, m, n); + v = __identity(n); + rotations = 0; + + do { + changed = false; + + for (p = 0; p < n; p++) { + for (q = p + 1; q < n; q++) { + app = e[p]; + aqq = e[q]; + apq = a[n * p + q]; + phi = 0.5 * Math.atan2(2 * apq, aqq - app); + c = Math.cos(phi); + s = Math.sin(phi); + app1 = c * c * app - 2 * s * c * apq + s * s * aqq; + aqq1 = s * s * app + 2 * s * c * apq + c * c * aqq; + + if (app1 !== app || aqq1 !== aqq) { + changed = true; + rotations++; + + e[p] = app1; + e[q] = aqq1; + a[n * p + q] = 0; + + for (i = 0; i < p; i++) { + ip = n * i + p; + iq = n * i + q; + aip = a[ip]; + aiq = a[iq]; + a[ip] = c * aip - s * aiq; + a[iq] = c * aiq + s * aip; + } + for (i = p + 1; i < q; i++) { + pi = n * p + i; + iq = n * i + q; + api = a[pi]; + aiq = a[iq]; + a[pi] = c * api - s * aiq; + a[iq] = c * aiq + s * api; + } + for (i = q + 1; i < n; i++) { + pi = n * p + i; + qi = n * q + i; + api = a[pi]; + aqi = a[qi]; + a[pi] = c * api - s * aqi; + a[qi] = c * aqi + s * api; + } + for (i = 0; i < n; i++) { + ip = n * i + p; + iq = n * i + q; + vip = v[ip]; + viq = v[iq]; + v[ip] = c * vip - s * viq; + v[iq] = c * viq + s * vip; + } + } + } + } + } while (changed && rotations < maxRotations); + + if (changed) { + throw new Error('failed to converge'); + } + + vectors = []; + for (i = 0; i < n; i++) { + // i-th vector is i-th column + vector = Vector(__col(v, m, n, i)); + vector.eigenvalue = e[i]; + vectors.push(vector); + } + + return vectors; +}; + +/** + * Multiply this matrix by another matrix. + * + * @param data1 {Array<Number>} + * first matrix data. + * @param m1 {Number} + * number of rows in first matrix. + * @param n1 {Number} + * number of columns in first matrix. + * @param data2 {Array<Number>} + * second matrix data. + * @param m2 {Number} + * number of rows in second matrix. + * @param n2 {Number} + * number of columns in second matrix. + * @throws Error if n1 !== m2 + * @return result of multiplication (original matrix is unchanged). + */ +__multiply = function (data1, m1, n1, data2, m2, n2) { + var col, col2, row, row1, values; + + if (n1 !== m2) { + throw new Error('wrong combination of rows and cols'); + } + values = []; + for (row = 0; row < m1; row++) { + row1 = __row(data1, m1, n1, row); + for (col = 0; col < n2; col++) { + col2 = __col(data2, m2, n2, col); + // result is dot product + values.push(Vector.dot(row1, col2)); + } + } + return values; +}; + +/** + * Extract a row from this matrix. + * + * @param data {Array<Number>} + * matrix data. + * @param m {Number} + * number of rows. + * @param n {Number} + * number of columns. + * @param row {Number} + * index of row, in range [0,m) + * @throws Error if row out of range. + * @return {Array<Number>} row elements. + */ +__row = function (data, m, n, row) { + var col, values; + if (row < 0 || row >= m) { + throw new Error('row ' + row + ' out of range [0,' + m + ')'); + } + values = []; + for (col = 0; col < n; col++) { + values.push(data[__index(m, n, row, col)]); + } + return values; +}; + +/** + * Set the value of an element of this matrix. + * + * NOTE: this method modifies the contents of this matrix. + * + * @param data {Array<Number>} + * matrix data. + * @param m {Number} + * number of rows. + * @param n {Number} + * number of columns. + * @param row {Number} + * row of element, in range [0,m) + * @param col {Number} + * column of element, in range [0,n) + * @param value {Number} + * value to set. + * @throws Error if row or col are out of range. + */ +__set = function (data, m, n, row, col, value) { + data[__index(m, n, row, col)] = value; +}; + +/** + * Display matrix as a string. + * + * @param data {Array<Number>} + * matrix data. + * @param m {Number} + * number of rows. + * @param n {Number} + * number of columns. + * @return {String} formatted matrix. + */ +__stringify = function (data, m, n) { + var lastRow = m - 1, + lastCol = n - 1, + buf = [], + row, + col; + + buf.push('['); + for (row = 0; row < m; row++) { + for (col = 0; col < n; col++) { + buf.push(data[n * row + col], col !== lastCol || row !== lastRow ? ', ' : ''); + } + if (row !== lastRow) { + buf.push('\n '); + } + } + buf.push(']'); + return buf.join(''); +}; + +/** + * Transpose this matrix. + * + * @param data {Array<Number>} + * matrix data. + * @param m {Number} + * number of rows. + * @param n {Number} + * number of columns. + * @return transposed matrix (original matrix is unchanged). + */ +__transpose = function (data, m, n) { + var values = [], + row, + col; + for (col = 0; col < n; col++) { + for (row = 0; row < m; row++) { + values.push(data[__index(m, n, row, col)]); + } + } + return values; +}; + +/** + * Construct a new Matrix object. + * + * If m and n are omitted, Matrix is assumed to be square and + * data length is used to compute size. + * + * If m or n are omitted, data length is used to compute omitted value. + * + * @param data {Array} + * matrix data. + * @param m {Number} + * number of rows. + * @param n {Number} + * number of columns. + */ +var Matrix = function (data, m, n) { + var _this, + _initialize, + // variables + _data, + _m, + _n; + + _this = {}; + + _initialize = function (data, m, n) { + _data = data; + _m = m; + _n = n; + + if (m && n) { + // done + return; + } + + // try to compute size based on data + if (!m && !n) { + var side = Math.sqrt(data.length); + if (side !== parseInt(side, 10)) { + throw new Error('matrix m,n unspecified, and matrix not square'); + } + _m = side; + _n = side; + } else if (!m) { + _m = data.length / n; + if (_m !== parseInt(_m, 10)) { + throw new Error('wrong number of data elements'); + } + } else if (!n) { + _n = data.length / m; + if (_n !== parseInt(_n, 10)) { + throw new Error('wrong number of data elements'); + } + } + }; + + /** + * Add matrices. + * + * @param that {Matrix} + * matrix to add. + * @throws Error if dimensions do not match. + * @return result of addition (original matrix is unchanged). + */ + _this.add = function (that) { + if (_m !== that.m() || n !== that.n()) { + throw new Error('matrices must be same size'); + } + return Matrix(Vector.add(_data, that.data()), _m, _n); + }; + + /** + * Get a column from this matrix. + * + * @param col {Number} + * zero-based column index. + * @return {Array<Number>} array containing elements from column. + */ + _this.col = function (col) { + return __col(_data, _m, _n, col); + }; + + /** + * Access the wrapped array. + */ + _this.data = function () { + return _data; + }; + + /** + * Get the diagonal from this matrix. + * + * @return {Array<Number>} array containing elements from diagonal. + */ + _this.diagonal = function () { + return __diagonal(_data, _m, _n); + }; + + /** + * Get a value from this matrix. + * + * @param row {Number} + * zero-based index of row. + * @param col {Number} + * zero-based index of column. + * @return {Number} value at (row, col). + */ + _this.get = function (row, col) { + return __get(_data, _m, _n, row, col); + }; + + /** + * Compute the eigenvectors of this matrix. + * + * NOTE: Matrix should be 3x3 and symmetric. + * + * @param maxRotations {Number} + * default 100. + * maximum number of iterations. + * @return {Array<Vector>} eigenvectors. + * Magnitude of each vector is eigenvalue. + */ + _this.jacobi = function (maxRotations) { + return __jacobi(_data, _m, _n, maxRotations); + }; + + /** + * Get the number of rows in matrix. + * + * @return {Number} + * number of rows. + */ + _this.m = function () { + return _m; + }; + + /** + * Multiply matrices. + * + * @param that {Matrix} + * matrix to multiply. + * @return {Matrix} result of multiplication. + */ + _this.multiply = function (that) { + return Matrix( + __multiply(_data, _m, _n, that.data(), that.m(), that.n()), + // use that.N + _m, + that.n() + ); + }; + + /** + * Get number of columns in matrix. + * + * @return {Number} number of columns. + */ + _this.n = function () { + return _n; + }; + + /** + * Multiply each element by -1. + * + * @return {Matrix} result of negation. + */ + _this.negative = function () { + return Matrix(Vector.multiply(_data, -1), _m, _n); + }; + + /** + * Get a row from this matrix. + * + * @param row {Number} + * zero-based index of row. + * @return {Array<Number>} elements from row. + */ + _this.row = function (row) { + return __row(_data, _m, _n, row); + }; + + /** + * Set a value in this matrix. + * + * @param row {Number} + * zero-based row index. + * @param col {Number} + * zero-based column index. + * @param value {Number} + * value to set. + */ + _this.set = function (row, col, value) { + __set(_data, _m, _n, row, col, value); + }; + + /** + * Subtract another matrix from this matrix. + * + * @param that {Matrix} + * matrix to subtract. + * @throws Error if dimensions do not match. + * @return result of subtraction (original matrix is unchanged). + */ + _this.subtract = function (that) { + if (_m !== that.m() || n !== that.n()) { + throw new Error('matrices must be same size'); + } + return Matrix(Vector.subtract(_data, that.data()), _m, _n); + }; + + /** + * Display matrix as a string. + * + * @return {String} formatted matrix. + */ + _this.toString = function () { + return __stringify(_data, _m, _n); + }; + + /** + * Transpose matrix. + * + * Columns become rows, and rows become columns. + * + * @return {Matrix} result of transpose. + */ + _this.transpose = function () { + return Matrix( + __transpose(_data, _m, _n), + // swap M and N + _n, + _m + ); + }; + + _initialize(data, m, n); + data = null; + return _this; +}; + +// expose static methods. +Matrix.col = __col; +Matrix.diagonal = __diagonal; +Matrix.get = __get; +Matrix.identity = __identity; +Matrix.index = __index; +Matrix.jacobi = __jacobi; +Matrix.multiply = __multiply; +Matrix.row = __row; +Matrix.set = __set; +Matrix.stringify = __stringify; +Matrix.transpose = __transpose; + +module.exports = Matrix; diff --git a/src/math/Vector.js b/src/math/Vector.js new file mode 100644 index 0000000000000000000000000000000000000000..edda42b171ff9253a5dd5e2e4cb9ed730feea63d --- /dev/null +++ b/src/math/Vector.js @@ -0,0 +1,644 @@ +'use strict'; + +// static methods that operate on arrays +var __add, + __angle, + __azimuth, + __cross, + __dot, + __equals, + __magnitude, + __multiply, + __plunge, + __unit, + __rotate, + __subtract, + __x, + __y, + __z; + +/** + * Add two vectors. + * + * @param v1 {Array<Number>} + * the first vector. + * @param v2 {Array<Number>} + * the second vector. + * @return {Array<Number>} + * result of addition. + * @throws {Error} when vectors are different lengths. + */ +__add = function (v1, v2) { + var i, v; + if (v1.length !== v2.length) { + throw new Error('vectors must be same length'); + } + v = []; + for (i = 0; i < v1.length; i++) { + v.push(v1[i] + v2[i]); + } + return v; +}; + +/** + * Compute the angle between two vectors. + * + * @param v1 {Array<Number>} + * the first vector. + * @param v2 {Array<Number>} + * the second vector. + * @return {Number} + * angle between vectors in radians. + */ +__angle = function (v1, v2) { + return Math.acos(__dot(v1, v2) / (__magnitude(v1) * __magnitude(v2))); +}; + +/** + * Compute the azimuth of a vector. + * + * @param v1 {Array<Number>} + * the first vector. + * @param v2 {Array<Number>} + * the second vector. + * @return {Number} + * angle between vectors in radians. + */ +__azimuth = function (v1) { + if (v1.length < 2) { + throw new Error('azimuth requires at least 2 dimensions'); + } + if (v1[0] === 0 && v1[1] === 0) { + // if vector is zero, or vertical, azimuth is zero. + return 0; + } + return Math.PI / 2 - Math.atan2(v1[1], v1[0]); +}; + +/** + * Compute vector cross product. + * + * Note: only computes cross product in 3 dimensions. + * + * @param v1 {Array<Number>} + * the first vector. + * @param v2 {Array<Number>} + * the second vector. + * @return {Array<Number>} + * the 3 dimensional cross product. + * the resulting vector follows the right-hand rule: if the fingers on + * your right hand point to v1, and you close your hand to get to v2, + * the resulting vector points in the direction of your thumb. + */ +__cross = function (v1, v2) { + if (v1.length !== v2.length || v1.length < 3) { + throw new Error('cross product requires at least 3 dimensions'); + } + return [ + v1[1] * v2[2] - v2[1] * v1[2], + v1[2] * v2[0] - v2[2] * v1[0], + v1[0] * v2[1] - v2[0] * v1[1], + ]; +}; + +/** + * Compute vector dot product. + * + * @param v1 {Array<Number} + * the first vector. + * @param v2 {Array<Number>} + * the second vector. + * @return {Number} + * the dot product. + */ +__dot = function (v1, v2) { + var i, sum; + sum = 0; + for (i = 0; i < v1.length; i++) { + sum += v1[i] * v2[i]; + } + return sum; +}; + +/** + * Check if two vectors are equal. + * + * @param v1 {Array<Number>} + * the first vector. + * @param v2 {Array<Number>} + * the second vector. + * @return {Boolean} + * true if vectors are same length and all elements are equal. + */ +__equals = function (v1, v2) { + var i; + if (v1.length !== v2.length) { + return false; + } + for (i = 0; i < v1.length; i++) { + if (v1[i] !== v2[i]) { + return false; + } + } + return true; +}; + +/** + * Compute length of vector. + * + * @param v1 {Array<Number>} + * vector. + * @return {Number} + * magnitude of vector. + */ +__magnitude = function (v1) { + var i, sum; + sum = 0; + for (i = 0; i < v1.length; i++) { + sum += v1[i] * v1[i]; + } + return Math.sqrt(sum); +}; + +/** + * Multiply vector by a constant. + * + * @param v1 {Array<Number>} + * vector to multiply. + * @param n {Number} + * number to multiply by. + * @return {Array<Number} + * result of multiplication. + */ +__multiply = function (v1, n) { + var i, v; + + v = []; + for (i = 0; i < v1.length; i++) { + v.push(v1[i] * n); + } + return v; +}; + +/** + * Compute angle from plane z=0 to vector. + * + * @param v {Array<Number>} + * the vector. + * @return {Number} + * angle from plane z=0 to vector. + * angle is positive when z > 0, negative when z < 0. + */ +__plunge = function (v) { + if (v.length < 3) { + throw new Error('__azimuth: vector must have at least 3 dimensions'); + } + return Math.asin(v[2] / __magnitude(v)); +}; + +/** + * Rotate a vector around an axis. + * + * From "6.2 The normalized matrix for rotation about an arbitrary line", + * http://inside.mines.edu/~gmurray/ArbitraryAxisRotation/ + * + * @param v1 {Array<Number>} + * the "point" to rotate. + * @param axis {Array<Number>} + * direction vector of rotation axis. + * @param theta {Number} + * angle of rotation in radians. + * @param origin {Array<Number>} + * default [0, 0, 0]. + * origin of axis of rotation. + */ +__rotate = function (v1, axis, theta, origin) { + var a, + au, + av, + aw, + b, + bu, + bv, + bw, + c, + cu, + cv, + cw, + cosT, + sinT, + u, + uu, + ux, + uy, + uz, + v, + vv, + vx, + vy, + vz, + w, + ww, + wx, + wy, + wz, + x, + y, + z; + + origin = origin || [0, 0, 0]; + a = origin[0]; + b = origin[1]; + c = origin[2]; + u = axis[0]; + v = axis[1]; + w = axis[2]; + x = v1[0]; + y = v1[1]; + z = v1[2]; + + cosT = Math.cos(theta); + sinT = Math.sin(theta); + au = a * u; + av = a * v; + aw = a * w; + bu = b * u; + bv = b * v; + bw = b * w; + cu = c * u; + cv = c * v; + cw = c * w; + uu = u * u; + ux = u * x; + uy = u * y; + uz = u * z; + vv = v * v; + vx = v * x; + vy = v * y; + vz = v * z; + ww = w * w; + wx = w * x; + wy = w * y; + wz = w * z; + + return [ + (a * (vv + ww) - u * (bv + cw - ux - vy - wz)) * (1 - cosT) + + x * cosT + + (-cv + bw - wy + vz) * sinT, + (b * (uu + ww) - v * (au + cw - ux - vy - wz)) * (1 - cosT) + + y * cosT + + (cu - aw + wx - uz) * sinT, + (c * (uu + vv) - w * (au + bv - ux - vy - wz)) * (1 - cosT) + + z * cosT + + (-bu + av - vx + uy) * sinT, + ]; +}; + +/** + * Subtract two vectors. + * + * @param v1 {Array<Number>} + * the first vector. + * @param v2 {Array<Number>} + * the vector to subtract. + * @return {Array<Number>} + * result of subtraction. + * @throws {Error} when vectors are different lengths. + */ +__subtract = function (v1, v2) { + var i, v; + + if (v1.length !== v2.length) { + throw new Error('__subtract: vectors must be same length'); + } + v = []; + for (i = 0; i < v1.length; i++) { + v.push(v1[i] - v2[i]); + } + return v; +}; + +/** + * Convert vector to length 1. + * + * Same as __multiply(v1, 1 / __magnitude(v1)) + * + * @param v1 {Array<Number>} + * the vector. + * @return {Array<Number>} + * vector converted to length 1. + * @throws {Error} if vector magnitude is 0. + */ +__unit = function (v1) { + var mag = __magnitude(v1); + if (mag === 0) { + throw new Error('__unit: cannot convert zero vector to unit vector'); + } + return __multiply(v1, 1 / mag); +}; + +/** + * Get, and optionally set, the x component of a vector. + * + * @param v {Array<Number>} + * the vector. + * @param value {Number} + * default undefined. + * when defined, set x component. + * @return {Number} + * the x component. + */ +__x = function (v, value) { + if (typeof value === 'number') { + v[0] = value; + } + return v[0]; +}; + +/** + * Get, and optionally set, the y component of a vector. + * + * @param v {Array<Number>} + * the vector. + * @param value {Number} + * default undefined. + * when defined, set y component. + * @return {Number} + * the y component. + */ +__y = function (v, value) { + if (typeof value === 'number') { + v[1] = value; + } + return v[1]; +}; + +/** + * Get, and optionally set, the z component of a vector. + * + * @param v {Array<Number>} + * the vector. + * @param value {Number} + * default undefined. + * when defined, set z component. + * @return {Number} + * the z component. + */ +__z = function (v, value) { + if (typeof value === 'number') { + v[2] = value; + } + return v[2]; +}; + +/** + * A vector object that wraps an array. + * + * This is a convenience object to call the static methods on the wrapped array. + * Only the methods x(), y(), and z() modify data; other methods return new + * Vector objects without modifying the existing object. + * + * @param data {Array<Number>} + * array to wrap. + */ +var Vector = function (data) { + var _this, + _initialize, + // variables + _data; + + if (data && typeof data.data === 'function') { + // copy existing object + data = data.data().slice(0); + } + + _this = { + _isa_vector: true, + }; + + _initialize = function (data) { + _data = data; + }; + + /** + * Add two vectors. + * + * @param that {Vector|Array<Number>} + * vector to add. + * @return {Vector} + * result of addition. + */ + _this.add = function (that) { + that = that._isa_vector ? that.data() : that; + return Vector(__add(_data, that)); + }; + + /** + * Compute angle between vectors. + * + * @param that {Vector|Array<Number>} + * vector to compute angle between. + * @return {Number} angle between vectors in radians. + */ + _this.angle = function (that) { + that = that._isa_vector ? that.data() : that; + return __angle(_data, that); + }; + + /** + * Compute azimuth of this vector. + * + * @return {Number} azimuth of this vector in radians. + */ + _this.azimuth = function () { + return __azimuth(_data); + }; + + /** + * Compute the cross product between vectors. + * + * @param that {Vector|Array<Number>} + * the vector to cross. + * @return {Vector} result of the cross product. + */ + _this.cross = function (that) { + that = that._isa_vector ? that.data() : that; + return Vector(__cross(_data, that)); + }; + + /** + * Access the wrapped array. + * + * @return {Array<Number>} + * the wrapped array. + */ + _this.data = function () { + return _data; + }; + + /** + * Compute dot product between vectors. + * + * @param that {Vector|Array<Number>} + * vector to dot. + * @return {Number} result of dot product. + */ + _this.dot = function (that) { + that = that._isa_vector ? that.data() : that; + return __dot(_data, that); + }; + + /** + * Check if two vectors are equal. + * + * @param that {Vector|Array<Number>} + * vector to compare. + * @return {Boolean} true if equal, false otherwise. + */ + _this.equals = function (that) { + that = that._isa_vector ? that.data() : that; + return __equals(_data, that); + }; + + /** + * Compute length of this vector. + * + * @return {Number} length of vector. + * Square root of the sum of squares of all components. + */ + _this.magnitude = function () { + return __magnitude(_data); + }; + + /** + * Multiply this vector by a number. + * + * @param n {Number} + * number to multiply. + * @return {Vector} result of multiplication. + */ + _this.multiply = function (n) { + return Vector(__multiply(_data, n)); + }; + + /** + * Same as multiply(-1). + */ + _this.negative = function () { + return _this.multiply(-1); + }; + + /** + * Compute plunge of this vector. + * + * Plunge is the angle between this vector and the plane z=0. + * + * @return {Number} plunge in radians. + * positive when z>0, negative when z<0. + */ + _this.plunge = function () { + return __plunge(_data); + }; + + /** + * Rotate this vector around an arbitrary axis. + * + * @param axis {Vector|Array<Number>} + * direction of axis of rotation. + * @param theta {Number} + * angle of rotation in radians. + * @param origin {Vector|Array<Number>} + * origin of axis of rotation. + * @return {Vector} result of rotation. + */ + _this.rotate = function (axis, theta, origin) { + axis = axis._isa_vector ? axis.data() : axis; + origin = origin && origin._isa_vector ? origin.data() : origin; + return Vector(__rotate(_data, axis, theta, origin)); + }; + + /** + * Subtract another vector. + * + * @param that {Vector|Array<Number>} + * vector to subtract. + * @return {Vector} result of subtraction. + */ + _this.subtract = function (that) { + that = that._isa_vector ? that.data() : that; + return Vector(__subtract(_data, that)); + }; + + /** + * Convert vector to string. + * + * @return {String} wrapped array converted to string. + */ + _this.toString = function () { + return '' + _data; + }; + + /** + * Convert this vector to length 1. + * + * @return {Vector} vector / |vector|. + */ + _this.unit = function () { + return Vector(__unit(_data)); + }; + + /** + * Get or set x component. + * + * @param value {Number} + * when defined, set x component to value. + * @return {Number} x component value. + */ + _this.x = function (value) { + return __x(_data, value); + }; + + /** + * Get or set y component. + * + * @param value {Number} + * when defined, set y component to value. + * @return {Number} y component value. + */ + _this.y = function (value) { + return __y(_data, value); + }; + + /** + * Get or set z component. + * + * @param value {Number} + * when defined, set z component to value. + * @return {Number} z component value. + */ + _this.z = function (value) { + return __z(_data, value); + }; + + _initialize(data); + data = null; + return _this; +}; + +// expose static methods +Vector.add = __add; +Vector.angle = __angle; +Vector.azimuth = __azimuth; +Vector.cross = __cross; +Vector.dot = __dot; +Vector.magnitude = __magnitude; +Vector.multiply = __multiply; +Vector.plunge = __plunge; +Vector.rotate = __rotate; +Vector.subtract = __subtract; +Vector.unit = __unit; +Vector.x = __x; +Vector.y = __y; +Vector.z = __z; + +module.exports = Vector; diff --git a/src/math/index.d.ts b/src/math/index.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d948efd57f278bff7430031b422278131ee2436 --- /dev/null +++ b/src/math/index.d.ts @@ -0,0 +1 @@ +export * from '../../types/math'; diff --git a/src/math/index.js b/src/math/index.js new file mode 100644 index 0000000000000000000000000000000000000000..9c8b8af5528b7fb1b8668a7e43459c9766041382 --- /dev/null +++ b/src/math/index.js @@ -0,0 +1,5 @@ +module.exports = { + Camera: require('./Camera'), + Matrix: require('./Matrix'), + Vector: require('./Vector'), +}; diff --git a/src/mvc/Collection.js b/src/mvc/Collection.js new file mode 100644 index 0000000000000000000000000000000000000000..d1098a673b57db954275f0f17556a2379d7c7b2c --- /dev/null +++ b/src/mvc/Collection.js @@ -0,0 +1,360 @@ +'use strict'; +/** + * A Lightweight collection, inspired by backbone. + * + * Lazily builds indexes to avoid overhead until needed. + */ + +var Events = require('../util/Events'), + Util = require('../util/Util'); + +/** + * Create a new Collection. + * + * @param data {Array} + * When omitted a new array is created. + */ +var Collection = function (data) { + var _this, _initialize, _data, _ids, _selected, _isSilent; + + _this = Events(); + + _initialize = function () { + _data = data || []; + _ids = null; + _selected = null; + + data = null; + }; + + /** + * Whether "silent" option is true. + * + * @param options {Object} + * @param options.silent {Boolean} + * default false. + * @return {Boolean} true if options.silent is true. + */ + _isSilent = function (options) { + return options && options.silent === true; + }; + + /** + * Add objects to the collection. + * + * Calls wrapped array.push, and clears the id cache. + * + * @param {Object…} + * a variable number of objects to append to the collection. + * @deprecated see #addAll() + */ + _this.add = function () { + _this.addAll(Array.prototype.slice.call(arguments, 0)); + }; + + /** + * Add objects to the collection. + * + * Calls wrapped array.push, and clears the id cache. + * + * @param toadd {Array<Object>} + * objects to be added to the collection. + */ + _this.addAll = function (toadd, options) { + _data.push.apply(_data, toadd); + _ids = null; + if (!_isSilent(options)) { + _this.trigger('add', toadd); + } + }; + + /** + * Get the wrapped array. + * + * @return + * the wrapped array. + */ + _this.data = function () { + return _data; + }; + + /** + * Deselect current selection. + */ + _this.deselect = function (options) { + if (_selected !== null) { + var oldSelected = _selected; + _selected = null; + if (!_isSilent(options)) { + _this.trigger('deselect', oldSelected); + } + } + }; + + /** + * Free the array and id cache. + * + * @param options {Object} + * passed to #deselect(). + */ + _this.destroy = Util.compose(function (options) { + _data = null; + _ids = null; + _selected = null; + if (!_isSilent(options)) { + _this.trigger('destroy'); + } + return options; + }, _this.destroy); + + /** + * Get an object in the collection by ID. + * + * Uses getIds(), so builds map of ID to INDEX on first access O(N). + * Subsequent access should be O(1). + * + * @param id {Any} + * if the collection contains more than one object with the same id, + * the last element with that id is returned. + */ + _this.get = function (id) { + var ids = _this.getIds(); + + if (ids.hasOwnProperty(id)) { + // use cached index + return _data[ids[id]]; + } else { + return null; + } + }; + + /** + * Get a map from ID to INDEX. + * + * @param force {Boolean} + * rebuild the map even if it exists. + */ + _this.getIds = function (force) { + var i = 0, + len = _data.length, + id; + + if (force || _ids === null) { + // build up ids first time through + _ids = {}; + + for (; i < len; i++) { + id = _data[i].id; + + if (_ids.hasOwnProperty(id)) { + throw 'model with duplicate id "' + id + '" found in collection'; + } + + _ids[id] = i; + } + } + + return _ids; + }; + + /** + * Get the currently selected object. + */ + _this.getSelected = function () { + return _selected; + }; + + /** + * Remove objects from the collection. + * + * This method calls array.splice to remove item from array. + * Reset would be faster if modifying large chunks of the array. + * + * @param o {Object} + * object to remove. + * @deprecated see #removeAll() + */ + _this.remove = function (/* o */) { + // trigger remove event + _this.removeAll(Array.prototype.slice.call(arguments, 0)); + }; + + /** + * Remove objects from the collection. + * + * Reset is faster if modifying large chunks of the array. + * + * @param toremove {Array<Object>} + * objects to remove. + * @param options {Object} + * @param options.silent {Boolean} + * default false. + * whether to trigger events (false), or not (true). + */ + _this.removeAll = function (toremove, options) { + var i, + len = toremove.length, + indexes = [], + ids = _this.getIds(), + o; + + // select indexes to be removed + for (i = 0; i < len; i++) { + o = toremove[i]; + + // clear current selection if being removed + if (o === _selected) { + _this.deselect(); + } + + // add to list to be removed + if (ids.hasOwnProperty(o.id)) { + indexes.push(ids[o.id]); + } else { + throw 'removing object not in collection'; + } + } + + // remove in descending index order + indexes.sort(function (a, b) { + return a - b; + }); + + for (i = indexes.length - 1; i >= 0; i--) { + _data.splice(indexes[i], 1); + } + + // reset id cache + _ids = null; + + if (!_isSilent(options)) { + // trigger remove event + _this.trigger('remove', toremove); + } + }; + + /** + * Replace the wrapped array with a new one. + */ + _this.reset = function (data, options) { + // check for existing selection + var selectedId = null; + if (_selected !== null) { + selectedId = _selected.id; + } + + // free array and id cache + _data = null; + _ids = null; + _selected = null; + + // set new array + _data = data || []; + + // notify listeners + if (!options || options.silent !== true) { + _this.trigger('reset', data); + } + + // reselect if there was a previous selection + if (selectedId !== null) { + var selected = _this.get(selectedId); + if (selected !== null) { + options = Util.extend({}, options, { reset: true }); + _this.select(selected, options); + } + } + }; + + /** + * Select an object in the collection. + * + * @param obj {Object} + * object in the collection to select. + * @throws exception + * if obj not in collection. + */ + _this.select = function (obj, options) { + // no selection + if (obj === null) { + _this.deselect(); + return; + } + // already selected + if (obj === _selected) { + return; + } + // deselect previous selection + if (_selected !== null) { + _this.deselect(options); + } + + if (obj === _this.get(obj.id)) { + // make sure it's part of this collection… + _selected = obj; + if (!options || options.silent !== true) { + _this.trigger('select', _selected, options); + } + } else { + throw 'selecting object not in collection'; + } + }; + + /** + * Utility method to select collection item using its id. + * + * Selects matching item if it exists, otherwise clears any selection. + * + * @param id {?} + * id of item to select. + * @param options {Object} + * options passed to #select() or #deselect(). + */ + _this.selectById = function (id, options) { + var obj = _this.get(id); + if (obj !== null) { + _this.select(obj, options); + } else { + _this.deselect(options); + } + }; + + /** + * Sorts the data. + * + * @param method {Function} + * javascript sort method. + * @param options {Object} + * passed to #reset() + */ + _this.sort = function (method, options) { + _data.sort(method); + + // "reset" to new sort order + _this.reset(_data, options); + }; + + /** + * Override toJSON method to serialize only collection data. + */ + _this.toJSON = function () { + var json = _data.slice(0), + item, + i, + len; + + for (i = 0, len = json.length; i < len; i++) { + item = json[i]; + if (typeof item === 'object' && item !== null && typeof item.toJSON === 'function') { + json[i] = item.toJSON(); + } + } + + return json; + }; + + _initialize(); + return _this; +}; + +module.exports = Collection; diff --git a/src/mvc/Model.js b/src/mvc/Model.js new file mode 100644 index 0000000000000000000000000000000000000000..78f7b3825357492ceb9edaec8d80a955cdc80224 --- /dev/null +++ b/src/mvc/Model.js @@ -0,0 +1,122 @@ +'use strict'; + +var Events = require('../util/Events'), + Util = require('../util/Util'); + +/** + * Constructor + * + * @param data {Object} + * key/value attributes of this model. + */ +var Model = function (data) { + var _this, _initialize, _model; + + _this = Events(); + + _initialize = function () { + _model = Util.extend({}, data); + + // track id at top level + if (data && data.hasOwnProperty('id')) { + _this.id = data.id; + } + + data = null; + }; + + /** + * Get one or more values. + * + * @param key {String} + * the value to get; when key is undefined, returns the object with all + * values. + * @return + * - if key is specified, the value or null if no value exists. + * - when key is not specified, the underlying object is returned. + * (Any changes to this underlying object will not trigger events!!!) + */ + _this.get = function (key) { + if (typeof key === 'undefined') { + return _model; + } + + if (_model.hasOwnProperty(key)) { + return _model[key]; + } + + return null; + }; + + /** + * Update one or more values. + * + * @param data {Object} + * the keys and values to update. + * @param options {Object} + * options for this method. + * @param options.silent {Boolean} + * default false. true to suppress any events that would otherwise be + * triggered. + */ + _this.set = function (data, options) { + // detect changes + var changed = {}, + anyChanged = false, + c; + + for (c in data) { + if (!_model.hasOwnProperty(c) || _model[c] !== data[c]) { + changed[c] = data[c]; + anyChanged = true; + } + } + + // persist changes + _model = Util.extend(_model, data); + + // if id is changing, update the model id + if (data && data.hasOwnProperty('id')) { + _this.id = data.id; + } + + if (options && options.hasOwnProperty('silent') && options.silent) { + // don't trigger any events + return; + } + + // trigger events based on changes + if (anyChanged || (options && options.hasOwnProperty('force') && options.force)) { + for (c in changed) { + // events specific to a property + _this.trigger('change:' + c, changed[c]); + } + // generic event for any change + _this.trigger('change', changed); + } + }; + + /** + * Override toJSON method to serialize only model data. + */ + _this.toJSON = function () { + var json = Util.extend({}, _model), + key, + value; + + for (key in json) { + value = json[key]; + + if (typeof value === 'object' && value !== null && typeof value.toJSON === 'function') { + json[key] = value.toJSON(); + } + } + + return json; + }; + + _initialize(); + return _this; +}; + +module.exports = Model; diff --git a/src/mvc/SelectedCollectionView.js b/src/mvc/SelectedCollectionView.js new file mode 100644 index 0000000000000000000000000000000000000000..1a0679842cbc0a7e3104c82d265ff4e1f458282b --- /dev/null +++ b/src/mvc/SelectedCollectionView.js @@ -0,0 +1,101 @@ +'use strict'; + +var Collection = require('./Collection'), + Events = require('../util/Events'); + +/** create a new view based on a collection of models. */ +var SelectedCollectionView = function (params) { + var _this, _initialize, _destroyCollection; + + _this = Events(); + + /** + * @constructor + * + */ + _initialize = function (params) { + params = params || {}; + + // Element where this view is rendered + _this.el = params.hasOwnProperty('el') ? params.el : document.createElement('div'); + + _this.collection = params.collection; + + if (!_this.collection) { + _this.collection = Collection(); + _destroyCollection = true; + } + + if (_this.collection.getSelected()) { + _this.onCollectionSelect(); + } + + _this.collection.on('deselect', 'onCollectionDeselect', _this); + _this.collection.on('reset', 'onCollectionReset', _this); + _this.collection.on('select', 'onCollectionSelect', _this); + }; + + /** + * clean up the view + */ + _this.destroy = function () { + // undo event bindings + if (_this.model) { + _this.onCollectionDeselect(); + } + _this.collection.off('deselect', 'onCollectionDeselect', _this); + _this.collection.off('reset', 'onCollectionReset', _this); + _this.collection.off('select', 'onCollectionSelect', _this); + + if (_destroyCollection) { + _this.collection.destroy(); + } + + _destroyCollection = null; + + _this = null; + _initialize = null; + }; + + /** + * unset the event bindings for the collection + */ + _this.onCollectionDeselect = function () { + if (_this.model) { + _this.model.off('change', 'render', _this); + _this.model = null; + } + _this.render({ model: _this.model }); + }; + + /** + * unset event bindings for the collection, if set. + */ + _this.onCollectionReset = function () { + if (_this.model) { + _this.model.off('change', 'render', _this); + _this.model = null; + } + _this.render({ model: _this.model }); + }; + + /** + * set event bindings for the collection + */ + _this.onCollectionSelect = function () { + _this.model = _this.collection.getSelected(); + _this.model.on('change', 'render', _this); + _this.render({ model: _this.model }); + }; + + /** + * render the selected model in the view + */ + _this.render = function () {}; + + _initialize(params); + params = null; + return _this; +}; + +module.exports = SelectedCollectionView; diff --git a/src/mvc/View.js b/src/mvc/View.js new file mode 100644 index 0000000000000000000000000000000000000000..bd328c212942d9c58ef901d23eaaa5de424ec196 --- /dev/null +++ b/src/mvc/View.js @@ -0,0 +1,81 @@ +'use strict'; +/** + * A lightweight view class. + * + * Primarily manages an element where a view can render its data. + */ + +var Model = require('./Model'), + Events = require('../util/Events'), + Util = require('../util/Util'); + +var _DEFAULTS = {}; + +/** create a new view. */ +var View = function (params) { + var _this, _initialize, _destroyModel; + + _this = Events(); + + /** + * @constructor + * + */ + _initialize = function (params) { + params = Util.extend({}, _DEFAULTS, params); + + // Element where this view is rendered + _this.el = params && params.hasOwnProperty('el') ? params.el : document.createElement('div'); + + _this.model = params.model; + + if (!_this.model) { + _this.model = Model({}); + _destroyModel = true; + } + + _this.model.on('change', 'render', _this); + }; + + /** + * API Method + * + * Renders the view + */ + _this.render = function () { + // Impelementations should update the view based on the current + // model properties. + }; + + /** + * API Method + * + * Cleans up resources allocated by the view. Should be called before + * discarding a view. + */ + _this.destroy = Util.compose(function () { + if (_this === null) { + return; // already destroyed + } + + _this.model.off('change', 'render', _this); + + if (_destroyModel) { + _this.model.destroy(); + } + + _destroyModel = null; + + _this.model = null; + _this.el = null; + + _initialize = null; + _this = null; + }, _this.destroy); + + _initialize(params); + params = null; + return _this; +}; + +module.exports = View; diff --git a/src/mvc/index.d.ts b/src/mvc/index.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..d543e04ce7122e0181519c9efaf966f773a18015 --- /dev/null +++ b/src/mvc/index.d.ts @@ -0,0 +1 @@ +export * from '../../types/mvc'; diff --git a/src/mvc/index.js b/src/mvc/index.js new file mode 100644 index 0000000000000000000000000000000000000000..663590a03d674bb97a335a4bc2baa0c362dec98c --- /dev/null +++ b/src/mvc/index.js @@ -0,0 +1,6 @@ +module.exports = { + Collection: require('./Collection'), + Model: require('./Model'), + SelectedCollectionView: require('./SelectedCollectionView'), + View: require('./View'), +}; diff --git a/src/util/Events.js b/src/util/Events.js new file mode 100644 index 0000000000000000000000000000000000000000..b13f05938d2ebed7e6ce0a3bea24b15b7ce2942b --- /dev/null +++ b/src/util/Events.js @@ -0,0 +1,183 @@ +'use strict'; + +var __INSTANCE__ = null; + +var __is_string = function (obj) { + return typeof obj === 'string' || obj instanceof String; +}; + +var Events = function () { + var _this, _initialize, _listeners; + + _this = {}; + + _initialize = function () { + // map of listeners by event type + _listeners = {}; + }; + + /** + * Free all references. + */ + _this.destroy = function () { + _initialize = null; + _listeners = null; + _this = null; + }; + + /** + * Remove an event listener + * + * Omitting callback clears all listeners for given event. + * Omitting event clears all listeners for all events. + * + * @param event {String} + * event name to unbind. + * @param callback {Function} + * callback to unbind. + * @param context {Object} + * context for "this" when callback is called + */ + _this.off = function (evt, callback, context) { + var i; + + if (typeof evt === 'undefined') { + // removing all listeners on this object + _listeners = null; + _listeners = {}; + } else if (!_listeners.hasOwnProperty(evt)) { + // no listeners, nothing to do + return; + } else if (typeof callback === 'undefined') { + // removing all listeners for this event + delete _listeners[evt]; + } else { + var listener = null; + + // search for callback to remove + for (i = _listeners[evt].length - 1; i >= 0; i--) { + listener = _listeners[evt][i]; + + if (listener.callback === callback && (!context || listener.context === context)) { + // found callback, remove + _listeners[evt].splice(i, 1); + + if (context) { + // found callback with context, stop searching + break; + } + } + } + + // cleanup if last callback of this type + if (_listeners[evt].length === 0) { + delete _listeners[evt]; + } + + listener = null; + } + }; + + /** + * Add an event listener + * + * @param event {String} + * event name (singular). E.g. 'reset' + * @param callback {Function} + * function to call when event is triggered. + * @param context {Object} + * context for "this" when callback is called + */ + _this.on = function (event, callback, context) { + if ( + !( + callback || + !callback.apply || + (context && __is_string(callback) && context[callback].apply) + ) + ) { + throw new Error('Callback parameter is not callable.'); + } + + if (!_listeners.hasOwnProperty(event)) { + // first listener for event type + _listeners[event] = []; + } + + // add listener + _listeners[event].push({ + callback: callback, + context: context, + }); + }; + + /** + * Trigger an event + * + * @param event {String} + * event name. + * @param args {…} + * variable length arguments after event are passed to listeners. + */ + _this.trigger = function (event) { + var args, i, len, listener, listeners; + + if (_listeners.hasOwnProperty(event)) { + args = Array.prototype.slice.call(arguments, 1); + listeners = _listeners[event].slice(0); + + for (i = 0, len = listeners.length; i < len; i++) { + listener = listeners[i]; + + // NOTE: if listener throws exception, this will stop... + if (__is_string(listener.callback)) { + listener.context[listener.callback].apply(listener.context, args); + } else { + listener.callback.apply(listener.context, args); + } + } + } + }; + + _initialize(); + return _this; +}; + +// make Events a global event source +__INSTANCE__ = Events(); +Events.on = function _events_on() { + return __INSTANCE__.on.apply(__INSTANCE__, arguments); +}; +Events.off = function _events_off() { + return __INSTANCE__.off.apply(__INSTANCE__, arguments); +}; +Events.trigger = function _events_trigger() { + return __INSTANCE__.trigger.apply(__INSTANCE__, arguments); +}; + +// intercept window.onhashchange events, or simulate if browser doesn't +// support, and send to global Events object +var _onHashChange = function (e) { + Events.trigger('hashchange', e); +}; + +// courtesy of: +// http://stackoverflow.com/questions/9339865/get-the-hashchange-event-to-work-in-all-browsers-including-ie7 +if (!('onhashchange' in window)) { + var oldHref = document.location.hash; + + setInterval(function () { + if (oldHref !== document.location.hash) { + oldHref = document.location.hash; + _onHashChange({ + type: 'hashchange', + newURL: document.location.hash, + oldURL: oldHref, + }); + } + }, 300); +} else if (window.addEventListener) { + window.addEventListener('hashchange', _onHashChange, false); +} + +module.exports = Events; diff --git a/src/util/Util.js b/src/util/Util.js new file mode 100644 index 0000000000000000000000000000000000000000..8ea74d59ab5b4912cd27aa016e3679bad8d51edb --- /dev/null +++ b/src/util/Util.js @@ -0,0 +1,73 @@ +'use strict'; + +// static object with utility methods +var Util = function () {}; + +/** + * Merge properties from a series of objects. + * + * @param dst {Object} + * target where merged properties are copied to. + * @param <variable> {Object} + * source objects for properties. When a source is non null, it's + * properties are copied to the dst object. Properties are copied in + * the order of arguments: a property on a later argument overrides a + * property on an earlier argument. + */ +Util.extend = function (dst) { + var i, len, src, prop; + + // iterate over sources where properties are read + for (i = 1, len = arguments.length; i < len; i++) { + src = arguments[i]; + if (src) { + for (prop in src) { + dst[prop] = src[prop]; + } + } + } + + // return updated object + return dst; +}; + +// remove an elements child nodes +Util.empty = function (el) { + while (el.firstChild) { + el.removeChild(el.firstChild); + } +}; + +/** + * Creates a function that is a composition of other functions. + * + * For example: + * a(b(c(x))) === compose(c, b, a)(x); + * + * Each function should accept as an argument, the result of the previous + * function call in the chain. It is allowable for all functions to have no + * return value as well. + * + * @param ... {Function} A variable set of functions to call, in order. + * + * @return {Function} The composition of the functions provided as arguments. + */ +Util.compose = function () { + var fns = arguments; + + return function (result) { + var i, fn, len; + + for (i = 0, len = fns.length; i < len; i++) { + fn = fns[i]; + + if (fn && fn.call) { + result = fn.call(this, result); + } + } + + return result; + }; +}; + +module.exports = Util; diff --git a/src/util/index.d.ts b/src/util/index.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..1fcea8e188be71f823e6fe74792840bdb2b5dbdd --- /dev/null +++ b/src/util/index.d.ts @@ -0,0 +1 @@ +export * from '../../types/util'; diff --git a/src/util/index.js b/src/util/index.js new file mode 100644 index 0000000000000000000000000000000000000000..7a10fdd9cc42ff113d87287b55620ece59332732 --- /dev/null +++ b/src/util/index.js @@ -0,0 +1,4 @@ +module.exports = { + Events: require('./Events'), + Util: require('./Util'), +};