michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this file, michael@0: * You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: "use strict"; michael@0: michael@0: Cu.import("resource:///modules/devtools/VariablesView.jsm"); michael@0: Cu.import("resource:///modules/devtools/VariablesViewController.jsm"); michael@0: const { debounce } = require("sdk/lang/functional"); michael@0: michael@0: // Globals for d3 stuff michael@0: // Width/height in pixels of SVG graph michael@0: // TODO investigate to see how this works in other host types bug 994257 michael@0: const WIDTH = 1000; michael@0: const HEIGHT = 400; michael@0: michael@0: // Sizes of SVG arrows in graph michael@0: const ARROW_HEIGHT = 5; michael@0: const ARROW_WIDTH = 8; michael@0: michael@0: const GRAPH_DEBOUNCE_TIMER = 100; michael@0: michael@0: const GENERIC_VARIABLES_VIEW_SETTINGS = { michael@0: lazyEmpty: true, michael@0: lazyEmptyDelay: 10, // ms michael@0: searchEnabled: false, michael@0: editableValueTooltip: "", michael@0: editableNameTooltip: "", michael@0: preventDisableOnChange: true, michael@0: preventDescriptorModifiers: true, michael@0: eval: () => {} michael@0: }; michael@0: michael@0: /** michael@0: * Functions handling the graph UI. michael@0: */ michael@0: let WebAudioGraphView = { michael@0: /** michael@0: * Initialization function, called when the tool is started. michael@0: */ michael@0: initialize: function() { michael@0: this._onGraphNodeClick = this._onGraphNodeClick.bind(this); michael@0: this.draw = debounce(this.draw.bind(this), GRAPH_DEBOUNCE_TIMER); michael@0: }, michael@0: michael@0: /** michael@0: * Destruction function, called when the tool is closed. michael@0: */ michael@0: destroy: function() { michael@0: if (this._zoomBinding) { michael@0: this._zoomBinding.on("zoom", null); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Called when a page is reloaded and waiting for a "start-context" event michael@0: * and clears out old content michael@0: */ michael@0: resetUI: function () { michael@0: $("#reload-notice").hidden = true; michael@0: $("#waiting-notice").hidden = false; michael@0: $("#content").hidden = true; michael@0: this.resetGraph(); michael@0: }, michael@0: michael@0: /** michael@0: * Called once "start-context" is fired, indicating that there is audio context michael@0: * activity to view and inspect michael@0: */ michael@0: showContent: function () { michael@0: $("#reload-notice").hidden = true; michael@0: $("#waiting-notice").hidden = true; michael@0: $("#content").hidden = false; michael@0: this.draw(); michael@0: }, michael@0: michael@0: /** michael@0: * Clears out the rendered graph, called when resetting the SVG elements to draw again, michael@0: * or when resetting the entire UI tool michael@0: */ michael@0: resetGraph: function () { michael@0: $("#graph-target").innerHTML = ""; michael@0: }, michael@0: michael@0: /** michael@0: * Makes the corresponding graph node appear "focused", called from WebAudioParamView michael@0: */ michael@0: focusNode: function (actorID) { michael@0: // Remove class "selected" from all nodes michael@0: Array.prototype.forEach.call($$(".nodes > g"), $node => $node.classList.remove("selected")); michael@0: // Add to "selected" michael@0: this._getNodeByID(actorID).classList.add("selected"); michael@0: }, michael@0: michael@0: /** michael@0: * Unfocuses the corresponding graph node, called from WebAudioParamView michael@0: */ michael@0: blurNode: function (actorID) { michael@0: this._getNodeByID(actorID).classList.remove("selected"); michael@0: }, michael@0: michael@0: /** michael@0: * Takes an actorID and returns the corresponding DOM SVG element in the graph michael@0: */ michael@0: _getNodeByID: function (actorID) { michael@0: return $(".nodes > g[data-id='" + actorID + "']"); michael@0: }, michael@0: michael@0: /** michael@0: * `draw` renders the ViewNodes currently available in `AudioNodes` with `AudioNodeConnections`, michael@0: * and is throttled to be called at most every `GRAPH_DEBOUNCE_TIMER` milliseconds. Is called michael@0: * whenever the audio context routing changes, after being debounced. michael@0: */ michael@0: draw: function () { michael@0: // Clear out previous SVG information michael@0: this.resetGraph(); michael@0: michael@0: let graph = new dagreD3.Digraph(); michael@0: let edges = []; michael@0: michael@0: AudioNodes.forEach(node => { michael@0: // Add node to graph michael@0: graph.addNode(node.id, { label: node.type, id: node.id }); michael@0: michael@0: // Add all of the connections from this node to the edge array to be added michael@0: // after all the nodes are added, otherwise edges will attempted to be created michael@0: // for nodes that have not yet been added michael@0: AudioNodeConnections.get(node, []).forEach(dest => edges.push([node, dest])); michael@0: }); michael@0: michael@0: edges.forEach(([node, dest]) => graph.addEdge(null, node.id, dest.id, { michael@0: source: node.id, michael@0: target: dest.id michael@0: })); michael@0: michael@0: let renderer = new dagreD3.Renderer(); michael@0: michael@0: // Post-render manipulation of the nodes michael@0: let oldDrawNodes = renderer.drawNodes(); michael@0: renderer.drawNodes(function(graph, root) { michael@0: let svgNodes = oldDrawNodes(graph, root); michael@0: svgNodes.attr("class", (n) => { michael@0: let node = graph.node(n); michael@0: return "type-" + node.label; michael@0: }); michael@0: svgNodes.attr("data-id", (n) => { michael@0: let node = graph.node(n); michael@0: return node.id; michael@0: }); michael@0: return svgNodes; michael@0: }); michael@0: michael@0: // Post-render manipulation of edges michael@0: let oldDrawEdgePaths = renderer.drawEdgePaths(); michael@0: renderer.drawEdgePaths(function(graph, root) { michael@0: let svgNodes = oldDrawEdgePaths(graph, root); michael@0: svgNodes.attr("data-source", (n) => { michael@0: let edge = graph.edge(n); michael@0: return edge.source; michael@0: }); michael@0: svgNodes.attr("data-target", (n) => { michael@0: let edge = graph.edge(n); michael@0: return edge.target; michael@0: }); michael@0: return svgNodes; michael@0: }); michael@0: michael@0: // Override Dagre-d3's post render function by passing in our own. michael@0: // This way we can leave styles out of it. michael@0: renderer.postRender(function (graph, root) { michael@0: // TODO change arrowhead color depending on theme-dark/theme-light michael@0: // and possibly refactor rendering this as it's ugly michael@0: // Bug 994256 michael@0: // let color = window.classList.contains("theme-dark") ? "#f5f7fa" : "#585959"; michael@0: if (graph.isDirected() && root.select("#arrowhead").empty()) { michael@0: root michael@0: .append("svg:defs") michael@0: .append("svg:marker") michael@0: .attr("id", "arrowhead") michael@0: .attr("viewBox", "0 0 10 10") michael@0: .attr("refX", ARROW_WIDTH) michael@0: .attr("refY", ARROW_HEIGHT) michael@0: .attr("markerUnits", "strokewidth") michael@0: .attr("markerWidth", ARROW_WIDTH) michael@0: .attr("markerHeight", ARROW_HEIGHT) michael@0: .attr("orient", "auto") michael@0: .attr("style", "fill: #f5f7fa") michael@0: .append("svg:path") michael@0: .attr("d", "M 0 0 L 10 5 L 0 10 z"); michael@0: } michael@0: michael@0: // Fire an event upon completed rendering michael@0: window.emit(EVENTS.UI_GRAPH_RENDERED, AudioNodes.length, edges.length); michael@0: }); michael@0: michael@0: let layout = dagreD3.layout().rankDir("LR"); michael@0: renderer.layout(layout).run(graph, d3.select("#graph-target")); michael@0: michael@0: // Handle the sliding and zooming of the graph, michael@0: // store as `this._zoomBinding` so we can unbind during destruction michael@0: if (!this._zoomBinding) { michael@0: this._zoomBinding = d3.behavior.zoom().on("zoom", function () { michael@0: var ev = d3.event; michael@0: d3.select("#graph-target") michael@0: .attr("transform", "translate(" + ev.translate + ") scale(" + ev.scale + ")"); michael@0: }); michael@0: d3.select("svg").call(this._zoomBinding); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Event handlers michael@0: */ michael@0: michael@0: /** michael@0: * Fired when a node in the svg graph is clicked. Used to handle triggering the AudioNodePane. michael@0: * michael@0: * @param Object AudioNodeView michael@0: * The object stored in `AudioNodes` which contains render information, but most importantly, michael@0: * the actorID under `id` property. michael@0: */ michael@0: _onGraphNodeClick: function (node) { michael@0: WebAudioParamView.focusNode(node.id); michael@0: } michael@0: }; michael@0: michael@0: let WebAudioParamView = { michael@0: _paramsView: null, michael@0: michael@0: /** michael@0: * Initialization function called when the tool starts up. michael@0: */ michael@0: initialize: function () { michael@0: this._paramsView = new VariablesView($("#web-audio-inspector-content"), GENERIC_VARIABLES_VIEW_SETTINGS); michael@0: this._paramsView.eval = this._onEval.bind(this); michael@0: window.on(EVENTS.CREATE_NODE, this.addNode = this.addNode.bind(this)); michael@0: window.on(EVENTS.DESTROY_NODE, this.removeNode = this.removeNode.bind(this)); michael@0: }, michael@0: michael@0: /** michael@0: * Destruction function called when the tool cleans up. michael@0: */ michael@0: destroy: function() { michael@0: window.off(EVENTS.CREATE_NODE, this.addNode); michael@0: window.off(EVENTS.DESTROY_NODE, this.removeNode); michael@0: }, michael@0: michael@0: /** michael@0: * Empties out the params view. michael@0: */ michael@0: resetUI: function () { michael@0: this._paramsView.empty(); michael@0: }, michael@0: michael@0: /** michael@0: * Takes an `id` and focuses and expands the corresponding scope. michael@0: */ michael@0: focusNode: function (id) { michael@0: let scope = this._getScopeByID(id); michael@0: if (!scope) return; michael@0: michael@0: scope.focus(); michael@0: scope.expand(); michael@0: }, michael@0: michael@0: /** michael@0: * Executed when an audio param is changed in the UI. michael@0: */ michael@0: _onEval: Task.async(function* (variable, value) { michael@0: let ownerScope = variable.ownerView; michael@0: let node = getViewNodeById(ownerScope.actorID); michael@0: let propName = variable.name; michael@0: let errorMessage = yield node.actor.setParam(propName, value); michael@0: michael@0: // TODO figure out how to handle and display set param errors michael@0: // and enable `test/brorwser_wa_params_view_edit_error.js` michael@0: // Bug 994258 michael@0: if (!errorMessage) { michael@0: ownerScope.get(propName).setGrip(value); michael@0: window.emit(EVENTS.UI_SET_PARAM, node.id, propName, value); michael@0: } else { michael@0: window.emit(EVENTS.UI_SET_PARAM_ERROR, node.id, propName, value); michael@0: } michael@0: }), michael@0: michael@0: /** michael@0: * Takes an `id` and returns the corresponding variables scope. michael@0: */ michael@0: _getScopeByID: function (id) { michael@0: let view = this._paramsView; michael@0: for (let i = 0; i < view._store.length; i++) { michael@0: let scope = view.getScopeAtIndex(i); michael@0: if (scope.actorID === id) michael@0: return scope; michael@0: } michael@0: return null; michael@0: }, michael@0: michael@0: /** michael@0: * Called when hovering over a variable scope. michael@0: */ michael@0: _onMouseOver: function (e) { michael@0: let id = WebAudioParamView._getScopeID(this); michael@0: michael@0: if (!id) return; michael@0: michael@0: WebAudioGraphView.focusNode(id); michael@0: }, michael@0: michael@0: /** michael@0: * Called when hovering out of a variable scope. michael@0: */ michael@0: _onMouseOut: function (e) { michael@0: let id = WebAudioParamView._getScopeID(this); michael@0: michael@0: if (!id) return; michael@0: michael@0: WebAudioGraphView.blurNode(id); michael@0: }, michael@0: michael@0: /** michael@0: * Uses in event handlers, takes an element `$el` and finds the michael@0: * associated actor ID with that variable scope to be used in other contexts. michael@0: */ michael@0: _getScopeID: function ($el) { michael@0: let match = $el.parentNode.id.match(/\(([^\)]*)\)/); michael@0: return match ? match[1] : null; michael@0: }, michael@0: michael@0: /** michael@0: * Called when `CREATE_NODE` is fired to update the params view with the michael@0: * freshly created audio node. michael@0: */ michael@0: addNode: Task.async(function* (_, id) { michael@0: let viewNode = getViewNodeById(id); michael@0: let type = viewNode.type; michael@0: michael@0: let audioParamsTitle = type + " (" + id + ")"; michael@0: let paramsView = this._paramsView; michael@0: let paramsScopeView = paramsView.addScope(audioParamsTitle); michael@0: michael@0: paramsScopeView.actorID = id; michael@0: paramsScopeView.expanded = false; michael@0: michael@0: paramsScopeView.addEventListener("mouseover", this._onMouseOver, false); michael@0: paramsScopeView.addEventListener("mouseout", this._onMouseOut, false); michael@0: michael@0: let params = yield viewNode.getParams(); michael@0: params.forEach(({ param, value }) => { michael@0: let descriptor = { value: value }; michael@0: paramsScopeView.addItem(param, descriptor); michael@0: }); michael@0: michael@0: window.emit(EVENTS.UI_ADD_NODE_LIST, id); michael@0: }), michael@0: michael@0: /** michael@0: * Called when `DESTROY_NODE` is fired to remove the node from params view. michael@0: * TODO bug 994263, dependent on node GC events michael@0: */ michael@0: removeNode: Task.async(function* (viewNode) { michael@0: michael@0: }) michael@0: };