/** * Abstract class for D3 Components: {@link Ext.d3.canvas.Canvas} and {@link Ext.d3.svg.Svg}. * * Notes: * * Unlike the Charts package with its Draw layer, the D3 package does not provide * an abstraction layer and the user is expected to deal with the SVG and Canvas * directly. * * D3 package supports IE starting from version 9, as neither Canvas nor SVG * are supported by prior IE versions. */Ext.define('Ext.d3.Component', { extend: 'Ext.d3.ComponentBase', requires: [ 'Ext.d3.lib.d3' ], isD3: true, config: { /** * The store with data to render. * @cfg {Ext.data.Store} store */ store: null, /** * The CSS class used by a subclass of the D3 Component. * Normally, the lower-cased name of a subclass. * @cfg {String} componentCls */ componentCls: '', /** * The list of interaction configs for this D3 component. * D3 package interactions are very simular to native D3 behaviors. * However, D3 behaviors (and its event system), are incompatible * with ExtJS event system. D3 package interactions may also support * certain features that D3 behaviors lack, like kinetic scrolling, * elastic borders and scroll indicators (see the {@link Ext.d3.interaction.PanZoom panzoom} * interaction for more information). * */ interactions: [], /** * A map of transition configs. For example: * * transitions: { * select: { * duration: 500, * ease: 'cubicInOut' * }, * zoom: { * name: 'zoom', * duration: 1000 * }, * ... * } * * A class would define the defaults for its transitions, and a user only needs * to set the `transitions` config of an instance to disable a transition, e.g.: * * transitions: { * // transitions are enabled by default, `true` should never be used here * select: false * } * * or alter its config: * * transitions: { * select: { * // the `duration` stays the same, * // only the easing function is altered * ease: 'bounceOut' * } * } * * The transitions defined this way are merely configs. To create an actual transition * from one of these configs, use the {@link #createTransition} method. For example: * * this.createTransition('select') * * A transition object can optionally specify a name, if it's different from * the key in the `transitions` config. For example: * * transitions: { * layout: { * name: 'foo', * duration: 500 * } * } * * Otherwise the name will be set automatically, for example: * * transition.name = this.getId() + '-' + key * * Transition names (whether explicitly given or not) are prefixed by component ID * to prevent transitions with the same name but on a different component from * cancelling each other out. * * However, transitions with the same name on the same component will still cancel * each other out, if created via {@link #createTransition} on the same selection * or with no selection provided. * * `duration`, `ease` and `name` properties of transition objects in this config * are reserved, and will be used to configure a `d3.transition` instance. * However, transition objects may also have other properties that are related to * this transition. For example: * * transitions: { * select: { * duration: 500, * ease: 'cubicInOut', * targetScale: 1.1 * } * } * * The `targetScale` property here won't be consumed by a `d3.transition` instance; * instead a component can make use of it in whichever way it sees fit to animate * the selected element. */ transitions: { none: { name: undefined, duration: 0, ease: Ext.identityFn } }, touchAction: { panX: false, panY: false, pinchZoom: false, doubleTapZoom: false } }, baseCls: Ext.baseCSSPrefix + 'd3', /* * @private * Some configs in the D3 package are saved as properties on the class instance * for faster access. Prefixed properties are not used, as prefix can in theory change. * `$configPrefixed: false` is not used, as there's really no need to change the default * for all configs. All instance properties that are accessed bypassing getters/setters * are listed on the prototype to keep things explicit. */ defaultBindProperty: 'store', isInitializing: true, refreshDelay: 100, // in milliseconds resizeDelay: 250, // in milliseconds resizeTimerId: 0, size: null, // cached size d3Components: null, constructor: function(config) { var me = this; me.d3Components = {}; me.callParent(arguments); me.isInitializing = false; me.on('resize', 'onElementResize', me); }, destroy: function() { var me = this; if (me.resizeTimerId) { clearTimeout(me.resizeTimerId); } me.un('resize', 'onElementResize', me); me.setInteractions(); me.callParent(); }, updateComponentCls: function(componentCls, oldComponentCls) { var baseCls = this.baseCls, el = this.element; if (componentCls && Ext.isString(componentCls)) { el.addCls(componentCls, baseCls); if (oldComponentCls) { el.removeCls(oldComponentCls, baseCls); } } }, register: function(component) { var map = this.d3Components, id = component.getId(); //<debug> if (id === undefined) { Ext.raise('Attempting to register a component with no ID.'); } if (id in map) { Ext.raise('Component with ID "' + id + '" is already registered.'); } //</debug> map[id] = component; }, unregister: function(component) { var map = this.d3Components, id = component.getId(); delete map[id]; }, applyInteractions: function(interactions, oldInteractions) { if (!oldInteractions) { oldInteractions = []; oldInteractions.map = {}; } // eslint-disable-next-line vars-on-top var me = this, result = [], oldMap = oldInteractions.map, i, ln, interaction, id; result.map = {}; interactions = Ext.Array.from(interactions, true); // `true` to clone for (i = 0, ln = interactions.length; i < ln; i++) { interaction = interactions[i]; if (!interaction) { continue; } if (interaction.isInteraction) { id = interaction.getId(); } else { id = interaction.id; interaction.element = me.element; } // Create a new interaction by alias or reconfigure the old one. interaction = Ext.factory(interaction, null, oldMap[id], 'd3.interaction'); if (interaction) { interaction.setComponent(me); me.addInteraction(interaction); result.push(interaction); result.map[interaction.getId()] = interaction; } } // Destroy old interactions that were not reused. for (i in oldMap) { if (!result.map[i]) { interaction = oldMap[i]; interaction.destroy(); } } return result; }, applyTransitions: function(transitions) { var name, transition, ease, ret = {}; for (name in transitions) { transition = transitions[name]; //<debug> if (transition === true) { // eslint-disable-next-line max-len Ext.raise("'true' is not a valid transition value (should be an object or 'false')."); } //</debug> ret[name] = transition = Ext.apply({}, transition || transitions.none); ease = transition.ease; if (typeof ease === 'string') { transition.ease = d3['ease' + ease.charAt(0).toUpperCase() + ease.substr(1)]; } if (!('name' in transition)) { transition.name = this.getId() + '-' + name; } } return ret; }, /** * Creates a `d3.transition` instance from a named object in the {@link #transitions} config. * @param {String} name The name of the transition in the {@link #transitions} config. * @param {d3.selection} [selection] The selection to create this transition on. * @return {d3.transition} */ createTransition: function(name, selection) { var transition = this.getTransitions()[name]; //<debug> if (!transition) { Ext.raise('No transition named "' + name + '"'); } //</debug> if (!selection) { selection = d3; } return selection.transition(transition.name) .ease(transition.ease) .duration(transition.duration); }, /** * @property {Ext.d3.interaction.PanZoom} panZoom * If the `panzoom` interaction has been added, a reference to it is saved on the * component instance for quick access. */ panZoom: null, /** * @protected * When {@link Ext.d3.interaction.PanZoom `panzoom`} interaction is added to the component, * this method is used as a listener for the interaction's `panzoom` event. * This method should be implemented by subclasses what wish to be affected by the interaction. * @param {Ext.d3.interaction.PanZoom} interaction * @param {Number[]} scaling * @param {Number[]} translation */ onPanZoom: Ext.emptyFn, /** * @protected * Returns the bounding box of the content before transformations. * This method should be implemented by subclasses that wish to support constrained panning * via {@link Ext.d3.interaction.PanZoom `panzoom`} interaction. * @return {Object} rect * @return {Number} return.x * @return {Number} return.y * @return {Number} return.width * @return {Number} return.height */ getContentRect: Ext.emptyFn, /** * @protected * Returns the position and size of the viewport in component's coordinates. * This method should be implemented by subclasses that wish to support constrained panning * via {@link Ext.d3.interaction.PanZoom `panzoom`} interaction. * @return {Object} rect * @return {Number} return.x * @return {Number} return.y * @return {Number} return.width * @return {Number} return.height */ getViewportRect: Ext.emptyFn, addInteraction: function(interaction) { var me = this; if (interaction.isPanZoom) { interaction.setContentRect(me.getContentRect.bind(me)); interaction.setViewportRect(me.getViewportRect.bind(me)); me.panZoom = interaction; interaction.on('panzoom', me.onPanZoom, me); } interaction.on('destroy', me.onInteractionDestroy, me); }, removeInteraction: function(interaction) { if (interaction.isPanZoom) { interaction.setContentRect(null); interaction.setViewportRect(null); this.panZoom = null; interaction.un('panzoom', this.onPanZoom, this); } }, onInteractionDestroy: function(interaction) { interaction.un('destroy', this.onInteractionDestroy, this); this.removeInteraction(interaction); }, applyStore: function(store) { return store && Ext.StoreManager.lookup(store); }, updateStore: function(store, oldStore) { var me = this, storeEvents = { // remove listeners for update and load since they trigger datachanged datachanged: 'onDataChange', // delay listener until all store changes (add/update/remove) are ready // and call it one time only buffer: me.refreshDelay, scope: me, order: 'after' }, root; if (oldStore) { oldStore.un(storeEvents); if (oldStore.getAutoDestroy()) { oldStore.destroy(); } } if (store) { store.on(storeEvents); if (store.isTreeStore) { root = store.getRoot(); // If the root is not yet expanded, suspend all 'datachanged' events and // wait until it has fully expanded, then fire a single 'datachanged' event. if (!root.isExpanded()) { store.suspendEvent('datachanged'); root.on({ expand: function() { store.resumeEvent('datachanged'); store.fireEvent('datachanged', store); }, single: true }); } } } me.onDataChange(store); }, onDataChange: function(store) { var me = this; if (me.isInitializing) { return; } me.processDataChange(store); }, onDataUpdate: function(store, record, operation, modifiedFieldNames, details) { var me = this; if (me.isInitializing) { return; } me.processDataUpdate(store, record, operation, modifiedFieldNames, details); }, onDataLoad: function(store, records, successful, operation) { this.processDataLoad(store, records, successful, operation); }, processDataChange: Ext.emptyFn, processDataUpdate: Ext.emptyFn, processDataLoad: Ext.emptyFn, maskEl: null, /** * @private */ showMask: function(msg) { var me = this; if (me.maskEl) { me.maskEl.dom.firstChild.textContent = msg; me.maskEl.setStyle('display', 'flex'); } else { me.maskEl = this.element.createChild({ style: { position: 'absolute', display: 'flex', alignItems: 'center', justifyContent: 'center', left: 0, top: 0, bottom: 0, right: 0, background: 'rgba(0,0,0,0.5)' }, children: [{ html: msg, style: { background: 'rgba(0,0,0,0.8)', color: 'white', fontWeight: 'bold', borderRadius: '10px', padding: '10px' } }] }); } }, /** * @private */ hideMask: function() { if (this.maskEl) { this.maskEl.setStyle('display', 'none'); } }, handleResize: function(size, instantly) { var me = this, el = me.element; size = size || (el && el.getSize()); if (!(size && size.width && size.height)) { return; } clearTimeout(me.resizeTimerId); if (instantly) { me.resizeTimerId = 0; } else { me.resizeTimerId = Ext.defer(me.handleResize, me.resizeDelay, me, [size, true]); return; } me.resizeHandler(size); if (me.panZoom && me.panZoom.updateIndicator) { me.panZoom.updateIndicator(); } me.size = size; }, resizeHandler: Ext.emptyFn, /** * Converts event coordinates from page coordinates to view coordinates. * @param {Ext.event.Event} event * @param {Ext.dom.Element} [view] If omitted, the component's element will be used. * @return {Number[]} */ toLocalXY: function(event, view) { var pageXY = event.getXY(), viewXY = view ? view.getXY() : this.element.getXY(); return [ pageXY[0] - viewXY[0], pageXY[1] - viewXY[1] ]; } });