Commit 00ce0f79 authored by James Long's avatar James Long Committed by Commit Bot

Add filtering to Chrome Android dependency visualization

Somewhat glitchy filtering has been added.
* Type in a package name (exact match) and click "Add" to add to filter.
* Click on any filter in the list to remove it.
Known issues:
* Locked nodes no longer appear locked when a filter is added/removed.
* Zoom/pan is reset when a new filter is added/removed.

These issues are mostly because of the way we interact with the d3
graph. A big upcoming TODO is to rework `renderGraph` into an API of
sorts which will give us finer control over what parts of the graph to
rerender, if nodes should keep their metadata (position, lock) upon
rerendering, etc.

This commit also pulls in and uses Vue.js via a CDN for the current
prototyping work. We will likely install via npm in the future.

Bug: 1093962
Change-Id: Ide0b6a649424deac56e2f4d8121b0029d9286e7f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2252378
Commit-Queue: James Long <yjlong@google.com>
Reviewed-by: default avatarHenrique Nakashima <hnakashima@chromium.org>
Reviewed-by: default avatarSamuel Huang <huangs@chromium.org>
Cr-Commit-Position: refs/heads/master@{#780428}
parent 481372c4
...@@ -5,6 +5,8 @@ ...@@ -5,6 +5,8 @@
* @fileoverview Data structures for representing a directed graph. * @fileoverview Data structures for representing a directed graph.
*/ */
import {GraphStore} from './graph_store.js';
/** A node in a directed graph. */ /** A node in a directed graph. */
class Node { class Node {
/** /**
...@@ -44,23 +46,33 @@ class Node { ...@@ -44,23 +46,33 @@ class Node {
/** /**
* An edge in a directed graph. * An edge in a directed graph.
* Note that source/target are stored as strings, not Node objects.
*/ */
class Edge { class Edge {
/** /**
* @param {string} source The ID of the source node. * @param {string} id The unique ID for the edge.
* @param {string} target The ID of the target node. * @param {!Node} source The source Node object.
* @param {!Node} target The target Node object.
*/ */
constructor(source, target) { constructor(id, source, target) {
/** @public @const {string} */
this.id = `${source} > ${target}`;
/** @public @const {string} */ /** @public @const {string} */
this.id = id;
/** @public @const {!Node} */
this.source = source; this.source = source;
/** @public @const {string} */ /** @public @const {!Node} */
this.target = target; this.target = target;
} }
} }
/**
* Generates and returns a unique edge ID from its source/target Node IDs.
* @param {string} sourceId The ID of the source node.
* @param {string} targetId The ID of the target node.
* @return {string} The ID uniquely identifying the edge source -> target.
*/
function getEdgeIdFromNodes(sourceId, targetId) {
return `${sourceId} > ${targetId}`;
}
/** A directed graph. */ /** A directed graph. */
class Graph { class Graph {
constructor() { constructor() {
...@@ -90,38 +102,50 @@ class Graph { ...@@ -90,38 +102,50 @@ class Graph {
} }
/** /**
* Adds an Edge to the edge set. * Creates and adds an Edge to the edge set.
* Also updates the inbound/outbound sets of the edge's nodes, if they exist. * Also updates the inbound/outbound sets of the edge's nodes.
* @param {!Edge} edge The edge to add. * @param {!Node} sourceNode The node at the start of the edge.
* @param {!Node} targetNode The node at the end of the edge.
*/ */
addEdgeIfNew(edge) { addEdgeIfNew(sourceNode, targetNode) {
if (!this.edges.has(edge.id)) { const edgeId = getEdgeIdFromNodes(sourceNode.id, targetNode.id);
this.edges.set(edge.id, edge); if (!this.edges.has(edgeId)) {
const sourceNode = this.getNodeById(edge.source); const edge = new Edge(edgeId, sourceNode, targetNode);
if (sourceNode !== null) { this.edges.set(edgeId, edge);
const targetNode = this.getNodeById(edge.target); sourceNode.addOutbound(targetNode);
if (targetNode !== null) { targetNode.addInbound(sourceNode);
sourceNode.addOutbound(targetNode);
targetNode.addInbound(sourceNode);
}
}
} }
} }
/** /**
* Retrieves the list of nodes for visualization with d3. * Retrieves the list of nodes for visualization with d3.
* @param {GraphStore} filter The filter to apply to the data set.
* @return {Array<Node>} The nodes to visualize. * @return {Array<Node>} The nodes to visualize.
*/ */
getNodesForD3() { getNodesForD3(filter) {
return [...this.nodes.values()]; const resultNodes = [];
for (const node of this.nodes.values()) {
if (filter.includedNodeSet.has(node.id)) {
resultNodes.push(node);
}
}
return resultNodes;
} }
/** /**
* Retrieves the list of edges for visualization with d3. * Retrieves the list of edges for visualization with d3.
* @param {GraphStore} filter The filter to apply to the data set.
* @return {Array<Edge>} The edges to visualize. * @return {Array<Edge>} The edges to visualize.
*/ */
getEdgesForD3() { getEdgesForD3(filter) {
return [...this.edges.values()]; const resultEdges = [];
for (const edge of this.edges.values()) {
if (filter.includedNodeSet.has(edge.source.id) &&
filter.includedNodeSet.has(edge.target.id)) {
resultEdges.push(edge);
}
}
return resultEdges;
} }
} }
......
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Shared state storage for runtime graph configuration. Based on
* the store pattern in (https://vuejs.org/v2/guide/state-management.html).
*
* TODO(yjlong): I'm not really sure if this is a good way to do things (global
* store accessible anywhere). Once more user config is available, revisit this
* and see if there is need for an alternative.
*/
/**
* A global store for graph state data.
*
* An instance of this will be exported and available anywhere via import. The
* `state` property will be included in the `data` of UI controls so that
* changes to the state are synced with the UI.
*/
class GraphStore {
/**
* Vue does not currently support reactivity on ES6 Sets. (Planned addition
* for 3.0 https://github.com/vuejs/vue/issues/2410#issuecomment-434990853).
* For performance, we maintain a Set for lookups when filtering nodes/edges
* and expose an Array to the UI for reactivity. We sync the data in these
* two structures manually.
*/
constructor() {
/** @public {!Set<string>} */
this.includedNodeSet = new Set();
// TODO(yjlong): Holding off on annotating this until we have a better idea
// of what the state will look like.
this.state = {
includedNodes: [],
};
}
/**
* Adds a node to the included node set + array.
* @param {string} nodeName The name of the node to add.
*/
addIncludedNode(nodeName) {
if (!this.includedNodeSet.has(nodeName)) {
this.includedNodeSet.add(nodeName);
this.state.includedNodes.push(nodeName);
}
}
/**
* Removes a node from the included node set + array.
* @param {string} nodeName The name of the node to remove.
*/
removeIncludedNode(nodeName) {
const deleted = this.includedNodeSet.delete(nodeName);
if (deleted) {
const deleteIndex = this.state.includedNodes.indexOf(nodeName);
// TODO(yjlong): If order turns out to be unimportant, just swap the last
// element and the deleted element, then pop.
this.state.includedNodes.splice(deleteIndex, 1);
}
}
}
GraphStore.instance = new GraphStore();
export {
GraphStore,
};
...@@ -2,6 +2,32 @@ ...@@ -2,6 +2,32 @@
* Use of this source code is governed by a BSD-style license that can be * Use of this source code is governed by a BSD-style license that can be
* found in the LICENSE file. */ * found in the LICENSE file. */
#page-container {
display: flex;
flex-direction: column;
}
#page-controls {
display: flex;
flex-direction: row;
height: 15vh;
}
#filter-input {
display: flex;
flex-direction: column;
}
#filter-items {
overflow: hidden;
overflow-y: scroll;
min-width: 100px;
}
#graph-svg {
background-color: #eee;
}
.graph-edges line { .graph-edges line {
stroke: #999; stroke: #999;
stroke-opacity: 0.6; stroke-opacity: 0.6;
......
...@@ -3,9 +3,26 @@ ...@@ -3,9 +3,26 @@
<head> <head>
<link rel="stylesheet" type="text/css" href="./index.css"></link> <link rel="stylesheet" type="text/css" href="./index.css"></link>
<script type="text/javascript" src="./node_modules/d3/dist/d3.min.js"></script> <script type="text/javascript" src="./node_modules/d3/dist/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
</head> </head>
<body> <body>
<svg width="960" height="600"></svg> <div id="page-container">
<div id="page-controls">
<div id="filter-input-group">
<label for="filter-input">Add node to filter (exact name):</label>
<input v-model="filterInputText" type="text" id="filter-input">
<button @click="submitFilter" type="button">Add</button>
</div>
<ul id="filter-items">
<li @click="removeFilter" v-for="node in sharedState.includedNodes">
{{ node }}
</li>
</ul>
</div>
<div id="graph-container">
<svg id="graph-svg" width="960" height="600"></svg>
</div>
</div>
<script type="module" src="./index.js"></script> <script type="module" src="./index.js"></script>
</body> </body>
</html> </html>
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
import {Node, Edge} from './graph_model.js'; import {Node, Edge} from './graph_model.js';
import {parseGraphFromJson} from './process_graph_json.js'; import {parseGraphFromJson} from './process_graph_json.js';
import {GraphStore} from './graph_store.js';
// For ease of development, we currently serve all our JSON and other assets // For ease of development, we currently serve all our JSON and other assets
// through a simple Python server at localhost:8888. This should be changed // through a simple Python server at localhost:8888. This should be changed
...@@ -43,7 +44,11 @@ function addArrowMarkerDef(defs, id, length, width) { ...@@ -43,7 +44,11 @@ function addArrowMarkerDef(defs, id, length, width) {
* @param {Array<Edge>} inputEdges An array of edges to render. * @param {Array<Edge>} inputEdges An array of edges to render.
*/ */
function renderGraph(inputNodes, inputEdges) { function renderGraph(inputNodes, inputEdges) {
// TODO(yjlong): Reorganize this function so that we can selectively rerender
// (as opposed to removing all elements and rerendering on each call),
const svg = d3.select('svg'); const svg = d3.select('svg');
svg.selectAll('*').remove();
const svgDefs = svg.append('defs'); const svgDefs = svg.append('defs');
const graphGroup = svg.append('g'); // Contains entire graph (for zoom/pan). const graphGroup = svg.append('g'); // Contains entire graph (for zoom/pan).
...@@ -102,6 +107,7 @@ function renderGraph(inputNodes, inputEdges) { ...@@ -102,6 +107,7 @@ function renderGraph(inputNodes, inputEdges) {
if (node.classed('locked')) { if (node.classed('locked')) {
d.fx = null; d.fx = null;
d.fy = null; d.fy = null;
reheatSimulation();
} else { } else {
d.fx = d.x; d.fx = d.x;
d.fy = d.y; d.fy = d.y;
...@@ -144,7 +150,46 @@ function renderGraph(inputNodes, inputEdges) { ...@@ -144,7 +150,46 @@ function renderGraph(inputNodes, inputEdges) {
// the side. Replace this with a user upload or pull from some other source. // the side. Replace this with a user upload or pull from some other source.
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
d3.json(`${LOCALHOST}/json_graph.txt`).then(data => { d3.json(`${LOCALHOST}/json_graph.txt`).then(data => {
const graphStore = GraphStore.instance;
const graph = parseGraphFromJson(data.package_graph); const graph = parseGraphFromJson(data.package_graph);
renderGraph(graph.getNodesForD3(), graph.getEdgesForD3()); // TODO(yjlong): This is test data. Remove this when no longer needed.
graphStore.addIncludedNode('org.chromium.base');
graphStore.addIncludedNode('org.chromium.chrome.browser.gsa');
graphStore.addIncludedNode('org.chromium.chrome.browser.omaha');
graphStore.addIncludedNode('org.chromium.chrome.browser.media');
graphStore.addIncludedNode('org.chromium.ui.base');
new Vue({
el: '#filter-input-group',
data: {
filterInputText: '',
sharedState: graphStore.state,
},
methods: {
submitFilter: function() {
graphStore.addIncludedNode(this.filterInputText);
renderGraph(
graph.getNodesForD3(graphStore), graph.getEdgesForD3(graphStore));
},
},
});
new Vue({
el: '#filter-items',
data: {
sharedState: graphStore.state,
},
methods: {
removeFilter: function(e) {
const filterText = e.target.textContent;
graphStore.removeIncludedNode(filterText.trim());
renderGraph(
graph.getNodesForD3(graphStore), graph.getEdgesForD3(graphStore));
},
},
});
renderGraph(
graph.getNodesForD3(graphStore), graph.getEdgesForD3(graphStore));
}); });
}); });
...@@ -32,8 +32,10 @@ function parseGraphFromJson(jsonGraph) { ...@@ -32,8 +32,10 @@ function parseGraphFromJson(jsonGraph) {
graph.addNodeIfNew(node); graph.addNodeIfNew(node);
} }
for (const edgeData of jsonGraph.edges) { for (const edgeData of jsonGraph.edges) {
const edge = new Edge(edgeData.begin, edgeData.end); // Assuming correctness of the JSON, we can assert non-null Nodes here.
graph.addEdgeIfNew(edge); const /** !Node */ beginNode = graph.getNodeById(edgeData.begin);
const /** !Node */ endNode = graph.getNodeById(edgeData.end);
graph.addEdgeIfNew(beginNode, endNode);
} }
return graph; return graph;
} }
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment