blob: c90af61030cc475da5ce14926ed6a30b98c4131d [file] [log] [blame]
/**
* @fileoverview Implements the interactive parsing of component data and
* drawing it to the UI.
*/
/**
* Simple wrapper around the component mapper json API.
*/
class ComponentMapperApi {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.graphUrl = baseUrl + "api/component/graph";
}
/**
* Returns the complete json graph of all components.
*/
async getGraph() {
let graph = await this.request(this.graphUrl);
return graph;
}
/**
* Private internal API that returns a promise to the JSON response
*/
request(url) {
return new Promise(function(resolve, reject) {
const jsonRequest= new XMLHttpRequest();
jsonRequest.open("GET", url);
// Only accept 200 success.
jsonRequest.onload = function() {
if (this.status == 200) {
resolve(JSON.parse(jsonRequest.response));
} else {
reject({status: this.status, statusText: jsonRequest.statusText});
}
}
// Reject all errors.
jsonRequest.onerror = function() {
reject({status: this.status, statusText: jsonRequest.statusText});
}
jsonRequest.send();
});
}
}
/**
* Simple class that renders the view for the component mapper.
*/
class ComponentMapperView {
constructor(api) {
this.api = api;
this.graphData = null;
this.search = document.getElementById("cm-search");
this.search.addEventListener("input", this.autocompleteSearch);
this.sidebar = document.getElementById("cm-component-list");
this.map = document.getElementById("cm-map");
this.logo = document.getElementById("cm-logo");
document.body.onkeyup = (e) => {
if (e.keyCode == 32) {
this.resetGraphVisible();
}
}
}
/**
* Does the basic setup for the view and populates all the important elements.
*/
async init() {
// Retrieve all the important view state.
await this.refresh();
// Draw the sidebar.
this.drawSidebar(this.graphData["nodes"]);
// Draw the main graph.
this.drawGraph();
}
/**
* Refresh all the apis and retrieve new packages etc.
*/
async refresh() {
console.log("[ComponentManager] - Refresh Triggered");
this.graphData = await this.api.getGraph();
}
/**
* Draws the main graph.
*/
drawGraph(componentList) {
console.log("[ComponentView] - Drawing Graph");
const map = document.getElementById("cm-map");
const boundingRect = map.getBoundingClientRect();
const width = boundingRect.width;
const height = boundingRect.height;
const viewBox = `0 0 ${width} ${height}`;
const nodeRadius = 120;
const nodeBounding = 2*nodeRadius;
// Construct the map.
const svg = d3.select("#cm-map")
.append("svg")
.attr("preserveAspectRatio", "xMinYMin meet")
.attr("viewBox", viewBox)
.attr("width", width)
.attr("height", height)
.call(d3.zoom().on("zoom", function() {
svg.attr("transform", d3.event.transform)
}))
.append("g");
const view = this;
// Construct the graph.
d3.json(location.origin + '/api/component/graph').then(function(graph) {
console.log("[ComponentView] - Parsing Graph JSON");
const link = svg.append("g").selectAll("line")
.data(graph.links)
.enter()
.append("line")
.style("stroke", function(e) {
return "#64B5F6";
});
const node = svg.append("g").selectAll("circle")
.data(graph.nodes)
.enter()
.append("circle")
.attr("r", function(e) {
let calculatedRadius = nodeRadius + 5*e.consumers + 5*e.routes.uses.length;
if (e.type == "core") {
calculatedRadius = calculatedRadius*2;
}
e.radius = calculatedRadius;
return calculatedRadius;
})
.style("fill", function(e) {
if (e.source == "bootfs") {
return "#FFA726";
}
if (e.source == "unknown") {
return "#9C27B0";
}
if (e.routes.offers.length > 0) {
return "#FF0266";
}
if (e.routes.uses.length > 0) {
return "#1DE9B6";
}
return "#09A4AE";
})
.on("click", (e, i) => {
const query = "^" + e.name + "$";
view.search.value = query;
view.updateSidebarWithSearchResults("^"+e.name+"$");
});
const label = svg.append("g").attr("class", "labels")
.selectAll("text").data(graph.nodes).enter()
.append("text")
.attr("text-anchor","middle")
.attr("class", "label")
.style("font-size", function(e) {
let calculatedSize = 20 + (e.consumers * 2) + (e.routes.uses.length * 2);
if (e.type == "core") {
calculatedSize = calculatedSize*2;
}
return calculatedSize;
})
.style("font-weight", "bold")
.style("fill", "#e8eaed")
.on("click", (e, i) => {
const query = "^" + e.name + "$";
view.search.value = query;
view.updateSidebarWithSearchResults("^"+e.name+"$");
})
.text(function(e) { return e.name; });
const simulate = d3.forceSimulation()
.force("center", d3.forceCenter(width/2, height/2))
.force("charge", d3.forceManyBody())
.force("collide", d3.forceCollide().radius(function(e) { return 1.5* e.radius; }).iterations(1))
.force("link", d3.forceLink().id(function(e) { return e.id; }));
simulate
.nodes(graph.nodes)
.on("tick", function() {
link
.attr("x1", function(e) { return e.source.x; })
.attr("y1", function(e) { return e.source.y; })
.attr("x2", function(e) { return e.target.x; })
.attr("y2", function(e) { return e.target.y; });
node
.attr("cx", function(e) { return e.x + 5 })
.attr("cy", function(e) { return e.y - 3 });
label
.attr("x", function(e) { return e.x })
.attr("y", function(e) { return e.y });
})
.on("end", function() {
console.log("[ComponentView] Graph Stabalized");
});
simulate.force("link").links(graph.links);
// Calculate what to do next.
});
}
/**
* Draws the component sidebar with the listed items.
*/
drawSidebar(graphNodes) {
this.sidebar.innerHTML = "";
// TODO(benwright) - Move these to use CSS margins.
for (const graphNode of graphNodes) {
const listNode = document.createElement("li");
listNode.appendChild(document.createTextNode(graphNode["name"]));
if (graphNode["routes"]["uses"].length) {
listNode.appendChild(document.createElement("br"));
listNode.appendChild(document.createElement("br"));
listNode.appendChild(document.createTextNode("uses:"));
for (const use of graphNode["routes"]["uses"]) {
listNode.appendChild(document.createElement("br"));
listNode.appendChild(document.createTextNode(use));
}
}
if (graphNode["routes"]["offers"].length) {
listNode.appendChild(document.createElement("br"));
listNode.appendChild(document.createElement("br"));
listNode.appendChild(document.createTextNode("offers:"));
for (const offer of graphNode["routes"]["offers"]) {
listNode.appendChild(document.createElement("br"));
listNode.appendChild(document.createTextNode(offer));
}
}
listNode.appendChild(document.createElement("br"));
listNode.appendChild(document.createElement("br"));
listNode.appendChild(document.createTextNode("verson: " + graphNode["version"]));
if (graphNode["source"]) {
listNode.appendChild(document.createElement("br"));
listNode.appendChild(document.createElement("br"));
listNode.appendChild(document.createTextNode("source: " + graphNode["source"]));
}
if (graphNode["type"]) {
listNode.appendChild(document.createElement("br"));
listNode.appendChild(document.createElement("br"));
listNode.appendChild(document.createTextNode("type: " + graphNode["type"]));
}
if (graphNode["consumers"]) {
listNode.appendChild(document.createElement("br"));
listNode.appendChild(document.createElement("br"));
listNode.appendChild(document.createTextNode("consumers: " + graphNode["consumers"]));
}
this.sidebar.appendChild(listNode);
}
}
/**
* Hide components not related to the node.
*/
hideGraphComponents(node) {
this.resetGraphVisible();
const componentName = node.name;
const servicesOffered = node.routes.offers;
d3.select('g').selectAll('circle').each(function(node) {
if (node.name == componentName) {
d3.select(this).style("stroke", "#ffaf49").style("stroke-width", "12");
return;
}
const uses = servicesOffered.some(e => node.routes.uses.indexOf(e) >= 0);
if (!uses) {
d3.select(this).attr("visibility", "hidden");
}
});
d3.select('g').selectAll('line').each(function(link) {
if (link.source.name != componentName && link.target.name != componentName) {
d3.select(this).attr("visibility", "hidden");
}
});
}
/**
* Resets the graph back to the default state.
*/
resetGraphVisible() {
d3.select('g').selectAll('circle').each(function(node) {
d3.select(this).attr("visibility", "visible");
d3.select(this).style("stroke", "").style("stroke-width", "");
});
d3.select('g').selectAll('line').each(function(link) {
d3.select(this).attr("visibility", "visible");
});
}
/**
* Completes the search to make the API usable.
*/
autocompleteSearch = e => {
this.updateSidebarWithSearchResults(e.target.value);
}
updateSidebarWithSearchResults(query) {
if (this.graphData == null) {
return;
}
const options = [];
// Handle commands
if (query.startsWith("uses: ")) {
const subquery = query.split(" ")[1];
for (const node of this.graphData["nodes"]) {
for (const uses of node["routes"]["uses"]) {
if (uses.match(subquery)) {
options.push(node);
break;
}
}
}
} else if (query.startsWith("offers: ")) {
const subquery = query.split(" ")[1];
for (const node of this.graphData["nodes"]) {
for (const offers of node["routes"]["offers"]) {
if (offers.match(subquery)) {
options.push(node);
break;
}
}
}
} else {
for (const node of this.graphData["nodes"]) {
if (node["name"].match(query)) {
options.push(node);
}
}
}
if (query == "") {
this.resetGraphVisible();
} else if (options.length >= 1) {
this.resetGraphVisible();
d3.select('g').selectAll('circle').each(node => {
if (node.name == options[0].name) {
this.hideGraphComponents(node);
return;
}
});
} else {
this.resetGraphVisible();
}
this.drawSidebar(options);
}
}
window.onload = async function(e) {
const api = new ComponentMapperApi(location.origin + '/');
const view = new ComponentMapperView(api);
view.init();
}