// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'chrome://resources/d3/d3.min.js';
import type {FavIconInfo, FrameInfo, GraphChangeStreamInterface, PageInfo, ProcessInfo, WorkerInfo} from './discards.mojom-webui.js';
// Radius of a node circle.
const kNodeRadius: number = 6;
// Target y position for page nodes.
const kPageNodesTargetY: number = 20;
// Range occupied by page nodes at the top of the graph view.
const kPageNodesYRange: number = 100;
// Range occupied by process nodes at the bottom of the graph view.
const kProcessNodesYRange: number = 100;
// Range occupied by worker nodes at the bottom of the graph view, above
// process nodes.
const kWorkerNodesYRange: number = 200;
// Target y position for frame nodes.
const kFrameNodesTargetY: number = kPageNodesYRange + 50;
// Range that frame nodes cannot enter at the top/bottom of the graph view.
const kFrameNodesTopMargin: number = kPageNodesYRange;
const kFrameNodesBottomMargin: number = kWorkerNodesYRange + 50;
// The maximum strength of a boundary force.
// According to https://github.com/d3/d3-force#positioning, strength values
// outside the range [0,1] are "not recommended".
const kMaxBoundaryStrength: number = 1;
// The strength of a high Y-force. This is appropriate for forces that
// strongly pull towards an attractor, but can still be overridden by the
// strongest force.
const kHighYStrength: number = 0.9;
// The strength of a weak Y-force. This is appropriate for forces that exert
// some influence but can be easily overridden.
const kWeakYStrength: number = 0.1;
/**
* Helper function to return a DOM class attribute for a given tooltip object
* index. All rows in a tooltip that are part of the same describer object will
* have the same class so that they can be toggled together.
*/
function tooltipClassForIndex(objectIndex: number): string {
return `object${objectIndex}`;
}
/**
* Helper function to toggle the visibility of a set of rows in the tooltip
* table.
*/
function toggleTooltipRows(clickedRow: HTMLElement, objectIndex: number) {
// Toggle visibility of only the value rows with the same index in the same
// tooltip.
const valueClasses = `tr.value.${tooltipClassForIndex(objectIndex)}`;
const tooltip = d3.select(clickedRow.parentElement);
const isCollapsed = tooltip.select(valueClasses).classed('collapsed');
tooltip.selectAll(valueClasses).classed('collapsed', !isCollapsed);
}
class ToolTipRowData {
// The contents of each cell in the row.
contents: [string, string];
// Class to apply to the <tr> element.
rowClass: 'heading'|'value';
// Index used to group rows in the same object.
objectIndex: number;
}
class ToolTip {
floating: boolean = true;
x: number;
y: number;
node: GraphNode;
private graph_: Graph;
private div_: d3.Selection<HTMLDivElement, unknown, null, undefined>;
private descriptionJson_: string = '';
constructor(div: Element, node: GraphNode, graph: Graph) {
this.x = node.x;
this.y = node.y - 28;
this.node = node;
this.graph_ = graph;
this.div_ = d3.select(div)
.append('div')
.attr('class', 'tooltip')
.style('opacity', 0)
.style('left', `${this.x}px`)
.style('top', `${this.y}px`);
this.div_.append('table').append('tbody');
this.div_.transition().duration(200).style('opacity', .9);
// Set up a drag behavior for this object's div.
const drag = d3.drag().subject(() => this) as unknown as
d3.DragBehavior<HTMLDivElement, unknown, unknown>;
drag.on('start', this.onDragStart_.bind(this));
drag.on('drag', this.onDrag_.bind(this));
this.div_.call(drag);
this.onDescription(JSON.stringify({}));
}
nodeMoved() {
if (!this.floating) {
return;
}
const node = this.node;
this.x = node.x;
this.y = node.y - 28;
this.div_.style('left', `${this.x}px`).style('top', `${this.y}px`);
}
/**
* @return The [x, y] center of the ToolTip's div element.
*/
getCenter(): [number, number] {
const rect = this.div_.node()!.getBoundingClientRect();
return [rect.x + rect.width / 2, rect.y + rect.height / 2];
}
goAway() {
this.div_.transition().duration(200).style('opacity', 0).remove();
}
/**
* Updates the description displayed.
*/
onDescription(descriptionJson: string) {
if (this.descriptionJson_ === descriptionJson) {
return;
}
/**
* Helper for recursively flattening an Object.
*
* @param visited The set of visited objects, excluding
* {@code object}.
* @param flattened The flattened object being built.
* @param path The current flattened path.
* @param objectIndex An index used to identify this object in expanding
* table rows.
* @param object The nested dict to be flattened.
* @returns The last index used by any sub-object of this object.
*/
function flattenObjectRec(
visited: Set<object>, flattened: ToolTipRowData[], path: string,
objectIndex: number, object: {[key: string]: any}): number {
if (typeof object !== 'object' || visited.has(object)) {
return objectIndex;
}
visited.add(object);
objectIndex++;
// When entering a nested object, add a header row.
if (path) {
flattened.push({
contents: [path, ''],
rowClass: 'heading',
objectIndex: objectIndex,
});
}
const subObjects: Array<[string, object]> = [];
for (const [key, value] of Object.entries(object)) {
// Save non-null objects for recursion at bottom of list.
if (!!value && typeof value === 'object') {
subObjects.push([key, value]);
} else {
// Everything else is considered a leaf value.
let strValue = String(value);
if (strValue.length > 50) {
strValue = `${strValue.substring(0, 47)}...`;
}
flattened.push({
contents: [key, strValue],
rowClass: 'value',
objectIndex: objectIndex,
});
}
}
// Now recurse into sub-objects.
for (const [key, value] of subObjects) {
const fullPath = path ? `${path} > ${key}` : key;
objectIndex =
flattenObjectRec(visited, flattened, fullPath, objectIndex, value);
}
return objectIndex;
}
/**
* Recursively flattens an Object of key/value pairs. Nested objects will be
* flattened to a list with a subheader row showing the nested key. Each
* list element includes metadata that will be used to format a table row.
*
* Nested objects are always sorted to the end. If there are circular
* dependencies, they will not be expanded.
*
* For example, converting:
*
* 'describer': {
* 'foo': 'hello',
* 'bar': 1,
* 'baz': {
* 'x': 43.5,
* 'y': 'fox',
* 'z': [1, 2],
* 'a': 0,
* },
* 'monkey': 3,
* 'self': (reference to self)
* }
*
* will yield:
*
* [
* {contents: ['describer', ''], rowClass: 'header', objectIndex: 1},
* {contents: ['foo', 'hello'], rowClass: 'value', objectIndex: 1},
* {contents: ['bar', '1'], rowClass: 'value', objectIndex: 1},
* {contents: ['monkey', '3]', rowClass: 'value', objectIndex: 1},
* {contents: ['describer > baz', ''], rowClass: 'header',
* objectIndex: 2},
* {contents: ['x', '43.5'], rowClass: 'value', objectIndex: 2},
* {contents: ['y', 'fox'], rowClass: 'value', objectIndex: 2},
* {contents: ['a', '0'], rowClass: 'value', objectIndex: 2},
* {contents: ['describer > baz > z', ''], rowClass: 'header',
* objectIndex: 3},
* {contents: ['0', '1'], rowClass: 'value', objectIndex: 3},
* {contents: ['1', '2'], rowClass: 'value', objectIndex: 3},
* ]
*/
function flattenObject(object: {[key: string]: any}): ToolTipRowData[] {
const flattened: ToolTipRowData[] = [];
flattenObjectRec(new Set(), flattened, '', 0, object);
return flattened;
}
// The JSON is a dictionary of data describer name to their data. Assuming a
// convention that describers emit a dictionary from string->string, this is
// flattened to an array. Each top-level dictionary entry is flattened to a
// 'heading' with [`the describer's name`, ''], followed by some number of
// entries with a two-element list, each representing a key/value pair.
this.descriptionJson_ = descriptionJson;
const flattenedDescription: ToolTipRowData[] =
flattenObject(JSON.parse(descriptionJson));
if (flattenedDescription.length === 0) {
flattenedDescription.push(
{contents: ['No Data', ''], rowClass: 'heading', objectIndex: 0});
}
// Attach each TooltipRowData element to a table row as data.
let tr =
this.div_.selectAll('tbody').selectAll('tr').data(flattenedDescription);
// Create <tr> and <td> elements for each row that's new in this update.
tr.enter()
.append('tr')
.selectAll('td')
.data((d: unknown) => (d as ToolTipRowData).contents)
.enter()
.append('td');
// Delete the <tr> elements for each row that's disappeared in this update.
tr.exit().remove();
// Update the selection to match the elements that were added or removed.
tr = this.div_.selectAll('tr');
// Apply style and content to all <tr> and <td> elements. Elements that
// already existed in the last update will already have settings so each
// change must be idempotent.
// Make the first cell of each header row 2 columns wide.
tr.select('td').attr(
'colspan', (_d: unknown, i: number, nodes: ArrayLike<unknown>) => {
const parent = d3.select((nodes[i] as HTMLElement).parentElement);
const parentData = parent.datum() as ToolTipRowData;
return parentData.rowClass === 'heading' ? 2 : null;
});
// Set the text of each cell.
tr.selectAll('td')
// Assign the <tr>'s full row of data to the selection.
.data((d: unknown) => (d as ToolTipRowData).contents)
// Assign the elements of the row array to the <td>'s in the selection.
.text((d: unknown) => d as string);
// Make each row clickable.
tr.on('click',
(event: any, d: ToolTipRowData) => {
toggleTooltipRows(
event.currentTarget as HTMLElement, d.objectIndex);
})
// And add classes to them.
.each((d: unknown, i: number, nodes: ArrayLike<unknown>) => {
const el = nodes[i] as HTMLElement;
const rowData = d as ToolTipRowData;
// Add the row's fixed classes if they're not already present. This
// won't overwrite the "collapsed" class if it's there.
el.classList.add(
rowData.rowClass, tooltipClassForIndex(rowData.objectIndex));
});
}
private onDragStart_() {
this.floating = false;
}
private onDrag_(event: any) {
this.x = event.x;
this.y = event.y;
this.div_.style('left', `${this.x}px`).style('top', `${this.y}px`);
this.graph_.updateToolTipLinks();
}
}
class GraphNode implements d3.SimulationNodeDatum {
id: bigint;
color: string = 'black';
iconUrl: string = '';
tooltip: ToolTip|null = null;
/**
* Implementation of the d3.SimulationNodeDatum interface.
* See https://github.com/d3/d3-force#simulation_nodes.
*/
index?: number;
x: number;
y: number;
vx?: number;
vy?: number;
fx: number|null = null;
fy: number|null = null;
constructor(id: bigint) {
this.id = id;
}
get title(): string {
return '';
}
/**
* Sets the initial x and y position of this node, also resets
* vx and vy.
* @param graphWidth Width of the graph view (svg).
* @param graphHeight Height of the graph view (svg).
*/
setInitialPosition(graphWidth: number, graphHeight: number) {
this.x = graphWidth / 2;
this.y = this.targetPositionY(graphHeight);
this.vx = 0;
this.vy = 0;
}
/**
* @param graphHeight Height of the graph view (svg).
*/
targetPositionY(graphHeight: number): number {
const bounds = this.allowedRangeY(graphHeight);
return (bounds[0] + bounds[1]) / 2;
}
/**
* @return The strength of the force that pulls the node towards
* its target y position.
*/
get targetYPositionStrength(): number {
return kWeakYStrength;
}
/**
* @return A scaling factor applied to the strength of links to this
* node.
*/
get linkStrengthScalingFactor(): number {
return 1;
}
/**
* @param graphHeight Height of the graph view.
*/
allowedRangeY(graphHeight: number): [number, number] {
// By default, nodes just need to be in bounds of the graph.
return [0, graphHeight];
}
/** @return The strength of the repulsion force with other nodes. */
get manyBodyStrength(): number {
return -200;
}
/** @return an array of node ids. */
get linkTargets(): bigint[] {
return [];
}
/**
* Dashed links express ownership relationships. An object can own multiple
* things, but be owned by exactly one (per relationship type). As such, the
* relationship is expressed on the *owned* object. These links are drawn with
* an arrow at the beginning of the link, pointing to the owned object.
* @return an array of node ids.
*/
get dashedLinkTargets(): bigint[] {
return [];
}
/**
* Selects a color string from an id.
* @param id The id the returned color is selected from.
*/
selectColor(id: bigint): string {
if (id < 0) {
id = -id;
}
return d3.schemeSet3[Number(id % BigInt(12))];
}
}
class PageNode extends GraphNode {
page: PageInfo;
constructor(page: PageInfo) {
super(page.id);
this.page = page;
this.y = kPageNodesTargetY;
}
override get title() {
return this.page.mainFrameUrl.url.length > 0 ? this.page.mainFrameUrl.url :
'Page';
}
override get targetYPositionStrength() {
// Gravitate strongly towards the top of the graph. Can be overridden by
// the bounding force which uses kMaxBoundaryStrength.
return kHighYStrength;
}
override get linkStrengthScalingFactor() {
// Give links from frame nodes to page nodes less weight than links between
// frame nodes, so the that Y forces pulling page nodes into their area can
// dominate over link forces pulling them towards frame nodes.
return 0.5;
}
override allowedRangeY(_graphHeight: number): [number, number] {
return [0, kPageNodesYRange];
}
override get manyBodyStrength() {
return -600;
}
override get dashedLinkTargets() {
const targets = [];
if (this.page.openerFrameId) {
targets.push(this.page.openerFrameId);
}
if (this.page.embedderFrameId) {
targets.push(this.page.embedderFrameId);
}
return targets;
}
}
class FrameNode extends GraphNode {
frame: FrameInfo;
constructor(frame: FrameInfo) {
super(frame.id);
this.frame = frame;
this.color = this.selectColor(frame.processId);
}
override get title() {
return this.frame.url.url.length > 0 ? this.frame.url.url : 'Frame';
}
override targetPositionY(_graphHeight: number) {
return kFrameNodesTargetY;
}
override allowedRangeY(graphHeight: number): [number, number] {
return [kFrameNodesTopMargin, graphHeight - kFrameNodesBottomMargin];
}
override get linkTargets() {
// Only link to the page if there isn't a parent frame.
return [
this.frame.parentFrameId || this.frame.pageId,
this.frame.processId,
];
}
}
class ProcessNode extends GraphNode {
process: ProcessInfo;
constructor(process: ProcessInfo) {
super(process.id);
this.process = process;
this.color = this.selectColor(process.id);
}
override get title() {
return `PID: ${this.process.pid.pid}`;
}
override get targetYPositionStrength() {
// Gravitate strongly towards the bottom of the graph. Can be overridden by
// the bounding force which uses kMaxBoundaryStrength.
return kHighYStrength;
}
override get linkStrengthScalingFactor() {
// Give links to process nodes less weight than links between frame nodes,
// so the that Y forces pulling process nodes into their area can dominate
// over link forces pulling them towards frame nodes.
return 0.5;
}
override allowedRangeY(graphHeight: number): [number, number] {
return [graphHeight - kProcessNodesYRange, graphHeight];
}
override get manyBodyStrength() {
return -600;
}
}
class WorkerNode extends GraphNode {
worker: WorkerInfo;
constructor(worker: WorkerInfo) {
super(worker.id);
this.worker = worker;
this.color = this.selectColor(worker.processId);
}
override get title() {
return this.worker.url.url.length > 0 ? this.worker.url.url : 'Worker';
}
override get targetYPositionStrength() {
// Gravitate strongly towards the worker area of the graph. Can be
// overridden by the bounding force which uses kMaxBoundaryStrength.
return kHighYStrength;
}
override allowedRangeY(graphHeight: number): [number, number] {
return [
graphHeight - kWorkerNodesYRange,
graphHeight - kProcessNodesYRange,
];
}
override get manyBodyStrength() {
return -600;
}
override get linkTargets() {
// Link the process, in addition to all the client and child workers.
return [
this.worker.processId,
...this.worker.clientFrameIds,
...this.worker.clientWorkerIds,
...this.worker.childWorkerIds,
];
}
}
/**
* A force that bounds GraphNodes |allowedRangeY| in Y,
* as well as bounding them to stay in page bounds in X.
*/
function boundingForce(graphHeight: number, graphWidth: number) {
let nodes: GraphNode[] = [];
let bounds: Array<[number, number]> = [];
const xBounds: [number, number] =
[2 * kNodeRadius, graphWidth - 2 * kNodeRadius];
const boundPosition = (pos: number, bound: [number, number]) =>
Math.max(bound[0], Math.min(pos, bound[1]));
function force(_alpha: number) {
const n = nodes.length;
for (let i = 0; i < n; ++i) {
const bound = bounds[i];
const node = nodes[i];
// Calculate where the node will end up after movement. If it will be out
// of bounds apply a counter-force to bring it back in.
const yNextPosition = node.y + node.vy!;
const yBoundedPosition = boundPosition(yNextPosition, bound);
if (yNextPosition !== yBoundedPosition) {
// Do not include alpha because we want to be strongly repelled from
// the boundary even if alpha has decayed.
node.vy! += (yBoundedPosition - yNextPosition) * kMaxBoundaryStrength;
}
const xNextPosition = node.x + node.vx!;
const xBoundedPosition = boundPosition(xNextPosition, xBounds);
if (xNextPosition !== xBoundedPosition) {
// Do not include alpha because we want to be strongly repelled from
// the boundary even if alpha has decayed.
node.vx! += (xBoundedPosition - xNextPosition) * kMaxBoundaryStrength;
}
}
}
force.initialize = function(n: GraphNode[]) {
nodes = n;
bounds = nodes.map(node => {
const nodeBounds = node.allowedRangeY(graphHeight);
// Leave space for the node circle plus a small border.
nodeBounds[0] += kNodeRadius * 2;
nodeBounds[1] -= kNodeRadius * 2;
return nodeBounds;
});
};
return force;
}
export class Graph implements GraphChangeStreamInterface {
private svg_: SVGElement;
private div_: Element;
private wasResized_: boolean = false;
private width_: number = 0;
private height_: number = 0;
private simulation_: d3.Simulation<GraphNode, undefined>|null = null;
/** A selection for the top-level <g> node that contains all tooltip links. */
private toolTipLinkGroup_:
d3.Selection<SVGGElement, unknown, null, undefined>|null = null;
/** A selection for the top-level <g> node that contains all separators. */
private separatorGroup_: d3.Selection<SVGGElement, unknown, null, undefined>|
null = null;
/** A selection for the top-level <g> node that contains all nodes. */
private nodeGroup_: d3.Selection<SVGGElement, unknown, null, undefined>|null =
null;
/** A selection for the top-level <g> node that contains all edges. */
private linkGroup_: d3.Selection<
SVGGElement, d3.SimulationLinkDatum<GraphNode>, null, undefined>|null =
null;
/** A selection for the top-level <g> node that contains all dashed edges. */
private dashedLinkGroup_: d3.Selection<
SVGGElement, d3.SimulationLinkDatum<GraphNode>, null, undefined>|null =
null;
private nodes_: Map<bigint, GraphNode> = new Map();
private links_: Array<d3.SimulationLinkDatum<GraphNode>> = [];
private dashedLinks_: Array<d3.SimulationLinkDatum<GraphNode>> = [];
private hostWindow_: Window|null = null;
/** The interval timer used to poll for node descriptions. */
private pollDescriptionsInterval_: number = 0;
/** The d3.drag instance applied to nodes. */
private drag_: d3.DragBehavior<SVGGElement, GraphNode, unknown>|null = null;
constructor(svg: SVGElement, div: Element) {
this.svg_ = svg;
this.div_ = div;
}
initialize() {
// Create the simulation and set up the permanent forces.
const simulation =
d3.forceSimulation() as d3.Simulation<GraphNode, undefined>;
simulation.on('tick', this.onTick_.bind(this));
const linkForce =
(d3.forceLink() as
d3.ForceLink<GraphNode, d3.SimulationLinkDatum<GraphNode>>)
.id(d => d.id.toString());
const defaultStrength = linkForce.strength();
// Override the default link strength function to apply scaling factors
// from the source and target nodes to the link strength. This lets
// different node types balance link forces with other forces that act on
// them.
simulation.force(
'link',
linkForce.strength(
(l, i, n) => defaultStrength(l, i, n) *
(l.source as GraphNode).linkStrengthScalingFactor *
(l.target as GraphNode).linkStrengthScalingFactor));
// Sets the repulsion force between nodes (positive number is attraction,
// negative number is repulsion).
simulation.force(
'charge',
(d3.forceManyBody() as d3.ForceManyBody<GraphNode>)
.strength(this.getManyBodyStrength_.bind(this)));
this.simulation_ = simulation;
// Create the <g> elements that host nodes and links.
// The link groups are created first so that all links end up behind nodes.
const svg = d3.select(this.svg_);
this.toolTipLinkGroup_ = svg.append('g').attr('class', 'tool-tip-links');
this.linkGroup_ =
svg.append('g').attr('class', 'links') as d3.Selection<
SVGGElement, d3.SimulationLinkDatum<GraphNode>, null, undefined>;
this.dashedLinkGroup_ =
svg.append('g').attr('class', 'dashed-links') as d3.Selection<
SVGGElement, d3.SimulationLinkDatum<GraphNode>, null, undefined>;
this.nodeGroup_ = svg.append('g').attr('class', 'nodes');
this.separatorGroup_ = svg.append('g').attr('class', 'separators');
const drag = d3.drag() as d3.DragBehavior<any, GraphNode, unknown>;
drag.clickDistance(4);
drag.on('start', this.onDragStart_.bind(this));
drag.on('drag', this.onDrag_.bind(this));
drag.on('end', this.onDragEnd_.bind(this));
this.drag_ = drag;
}
frameCreated(frame: FrameInfo) {
this.addNode_(new FrameNode(frame));
this.render_();
}
pageCreated(page: PageInfo) {
this.addNode_(new PageNode(page));
this.render_();
}
processCreated(process: ProcessInfo) {
this.addNode_(new ProcessNode(process));
this.render_();
}
workerCreated(worker: WorkerInfo) {
this.addNode_(new WorkerNode(worker));
this.render_();
}
frameChanged(frame: FrameInfo) {
const frameNode = this.nodes_.get(frame.id) as FrameNode;
frameNode.frame = frame;
this.render_();
}
pageChanged(page: PageInfo) {
const pageNode = this.nodes_.get(page.id) as PageNode;
// Page node dashed links may change dynamically, so account for that here.
this.removeDashedNodeLinks_(pageNode);
pageNode.page = page;
this.addDashedNodeLinks_(pageNode);
this.render_();
}
processChanged(process: ProcessInfo) {
const processNode = this.nodes_.get(process.id) as ProcessNode;
processNode.process = process;
this.render_();
}
workerChanged(worker: WorkerInfo) {
const workerNode = this.nodes_.get(worker.id) as WorkerNode;
// Worker node links may change dynamically, so account for that here.
this.removeNodeLinks_(workerNode);
workerNode.worker = worker;
this.addNodeLinks_(workerNode);
this.render_();
}
favIconDataAvailable(iconInfo: FavIconInfo) {
const graphNode = this.nodes_.get(iconInfo.nodeId);
if (graphNode) {
graphNode.iconUrl = 'data:image/png;base64,' + iconInfo.iconData;
}
this.render_();
}
nodeDeleted(nodeId: bigint) {
const node = this.nodes_.get(nodeId)!;
// Remove any links, and then the node itself.
this.removeNodeLinks_(node);
this.removeDashedNodeLinks_(node);
this.nodes_.delete(nodeId);
this.render_();
}
nodeDescriptions(nodeDescriptions: Map<bigint, any>) {
for (const [nodeId, nodeDescription] of nodeDescriptions) {
const node = this.nodes_.get(nodeId);
if (node && node.tooltip) {
node.tooltip.onDescription(nodeDescription);
}
}
this.render_();
}
/** Updates floating tooltip positions as well as links to pinned tooltips */
updateToolTipLinks() {
const pinnedTooltips = [];
for (const node of this.nodes_.values()) {
const tooltip = node.tooltip;
if (tooltip) {
if (tooltip.floating) {
tooltip.nodeMoved();
} else {
pinnedTooltips.push(tooltip);
}
}
}
function setLineEndpoints(
d: ToolTip, line: d3.Selection<any, unknown, null, unknown>) {
const center = d.getCenter();
line.attr('x1', _d => center[0])
.attr('y1', _d => center[1])
.attr('x2', d => (d as {node: {x: number, y: number}}).node.x)
.attr('y2', d => (d as {node: {x: number, y: number}}).node.y);
}
const toolTipLinks =
this.toolTipLinkGroup_!.selectAll('line').data(pinnedTooltips);
toolTipLinks.enter()
.append('line')
.attr('stroke', 'LightGray')
.attr('stroke-dasharray', '1')
.attr('stroke-opacity', '0.8')
.each(function(d: ToolTip) {
const line = d3.select(this);
setLineEndpoints(d, line);
});
toolTipLinks.each(function(d: ToolTip) {
const line = d3.select(this);
setLineEndpoints(d, line);
});
toolTipLinks.exit().remove();
}
private removeNodeLinks_(node: GraphNode) {
// Filter away any links to or from the provided node.
this.links_ = this.links_.filter(
link => link.source !== node && link.target !== node);
}
private removeDashedNodeLinks_(node: GraphNode) {
// Filter away any dashed links to or from the provided node.
this.dashedLinks_ = this.dashedLinks_.filter(
link => link.source !== node && link.target !== node);
}
private pollForNodeDescriptions_() {
const nodeIds: bigint[] = [];
for (const node of this.nodes_.values()) {
if (node.tooltip) {
nodeIds.push(node.id);
}
}
if (nodeIds.length) {
this.div_.dispatchEvent(new CustomEvent('request-node-descriptions',
{ bubbles: true,
composed: true,
detail: nodeIds }));
if (this.pollDescriptionsInterval_ === 0) {
// Start polling if not already in progress.
this.pollDescriptionsInterval_ =
setInterval(this.pollForNodeDescriptions_.bind(this), 1000);
}
} else {
// No tooltips, stop polling.
clearInterval(this.pollDescriptionsInterval_);
this.pollDescriptionsInterval_ = 0;
}
}
private onGraphNodeClick_(_event: any, node: GraphNode) {
if (node.tooltip) {
node.tooltip.goAway();
node.tooltip = null;
} else {
node.tooltip = new ToolTip(this.div_, node, this);
// Poll for all tooltip node descriptions immediately.
this.pollForNodeDescriptions_();
}
}
/**
* Renders nodes_ and edges_ to the SVG DOM.
*
* Each edge is a line element.
* Each node is represented as a group element with three children:
* 1. A circle that has a color and which animates the node on creation
* and deletion.
* 2. An image that is provided a data URL for the nodes favicon, when
* available.
* 3. A title element that presents the nodes URL on hover-over, if
* available.
* Deleted nodes are classed '.dead', and CSS takes care of hiding their
* image element if it's been populated with an icon.
*/
private render_() {
// Select the links.
const link = this.linkGroup_!.selectAll('line').data(this.links_);
// Add new links.
link.enter().append('line');
// Remove dead links.
link.exit().remove();
// Select the dashed links.
const dashedLink =
this.dashedLinkGroup_!.selectAll('line').data(this.dashedLinks_);
// Add new dashed links.
dashedLink.enter().append('line');
// Remove dead dashed links.
dashedLink.exit().remove();
// Select the nodes, except for any dead ones that are still transitioning.
const nodes = Array.from(this.nodes_.values());
const node = (this.nodeGroup_!.selectAll('g:not(.dead)') as
d3.Selection<any, GraphNode, SVGGElement, unknown>)
.data(nodes, d => d.id as unknown as number);
// Add new nodes, if any.
if (!node.enter().empty()) {
const newNodes = node.enter()
.append('g')
.call(this.drag_!)
.on('click', this.onGraphNodeClick_.bind(this));
const circles = newNodes.append('circle')
.attr('id', d => `circle-${d.id}`)
.attr('r', kNodeRadius * 1.5)
.attr('fill', 'green'); // New nodes appear green.
newNodes.append('image')
.attr('x', -8)
.attr('y', -8)
.attr('width', 16)
.attr('height', 16);
newNodes.append('title');
// Transition new nodes to their chosen color in 2 seconds.
circles.transition()
.duration(2000)
.attr('fill', (d: unknown) => (d as {color: string}).color)
.attr('r', kNodeRadius);
}
if (!node.exit().empty()) {
// Give dead nodes a distinguishing class to exclude them from the
// selection above.
const deletedNodes = node.exit().classed('dead', true) as
d3.Selection<any, GraphNode, SVGGElement, unknown>;
// Interrupt any ongoing transitions.
deletedNodes.interrupt();
// Turn down the node associated tooltips.
deletedNodes.each(d => {
if (d.tooltip) {
d.tooltip!.goAway();
}
});
// Transition the nodes out and remove them at the end of transition.
deletedNodes.transition()
.remove()
.select('circle')
.attr('r', 9)
.attr('fill', 'red')
.transition()
.duration(2000)
.attr('r', 0);
}
// Update the title for all nodes.
(node.selectAll('title') as d3.Selection<any, GraphNode, any, unknown>)
.text(d => d.title);
// Update the favicon for all nodes.
(node.selectAll('image') as d3.Selection<any, GraphNode, any, unknown>)
.attr('href', d => d.iconUrl);
// Update and restart the simulation if the graph changed.
if (!node.enter().empty() || !node.exit().empty() ||
!link.enter().empty() || !link.exit().empty() ||
!dashedLink.enter().empty() || !dashedLink.exit().empty()) {
this.simulation_!.nodes(nodes);
const links = this.links_.concat(this.dashedLinks_);
(this.simulation_!.force('link')! as d3.ForceLink<GraphNode, any>)
.links(links);
this.restartSimulation_();
}
}
private onTick_() {
const nodes: d3.Selection<SVGGElement, GraphNode, SVGGElement, unknown> =
this.nodeGroup_!.selectAll('g');
nodes.attr('transform', d => `translate(${d.x},${d.y})`);
const lines: d3.Selection<
SVGLineElement, d3.SimulationLinkDatum<GraphNode>, SVGGElement,
d3.SimulationLinkDatum<GraphNode>> = this.linkGroup_!.selectAll('line');
lines.attr('x1', d => (d.source as GraphNode).x)
.attr('y1', d => (d.source as GraphNode).y)
.attr('x2', d => (d.target as GraphNode).x)
.attr('y2', d => (d.target as GraphNode).y);
const dashedLines: d3.Selection<
SVGLineElement, d3.SimulationLinkDatum<GraphNode>, SVGGElement,
d3.SimulationLinkDatum<GraphNode>> =
this.dashedLinkGroup_!.selectAll('line');
dashedLines.attr('x1', d => (d.source as GraphNode).x)
.attr('y1', d => (d.source as GraphNode).y)
.attr('x2', d => (d.target as GraphNode).x)
.attr('y2', d => (d.target as GraphNode).y);
this.updateToolTipLinks();
}
/**
* Adds a new node to the graph, populates its links and gives it an initial
* position.
*/
private addNode_(node: GraphNode) {
this.nodes_.set(node.id, node);
this.addNodeLinks_(node);
this.addDashedNodeLinks_(node);
node.setInitialPosition(this.width_, this.height_);
}
/**
* Adds all the links for a node to the graph.
*/
private addNodeLinks_(node: GraphNode) {
for (const linkTarget of node.linkTargets) {
const target = this.nodes_.get(linkTarget);
if (target) {
this.links_.push({source: node, target: target});
}
}
}
/**
* Adds all the dashed links for a node to the graph.
*/
private addDashedNodeLinks_(node: GraphNode) {
for (const dashedLinkTarget of node.dashedLinkTargets) {
const target = this.nodes_.get(dashedLinkTarget);
if (target) {
this.dashedLinks_.push({source: node, target: target});
}
}
}
/**
* @param d The dragged node.
*/
private onDragStart_(event: any, d: GraphNode) {
if (!event.active) {
this.restartSimulation_();
}
d.fx = d.x;
d.fy = d.y;
}
/**
* @param d The dragged node.
*/
private onDrag_(event: any, d: GraphNode) {
d.fx = event.x;
d.fy = event.y;
}
/**
* @param d The dragged node.
*/
private onDragEnd_(event: any, d: GraphNode) {
if (!event.active) {
this.simulation_!.alphaTarget(0);
}
// Leave the node pinned where it was dropped. Return it to free
// positioning if it's dropped outside its designated area.
const bounds = d.allowedRangeY(this.height_);
if (event.y < bounds[0] || event.y > bounds[1]) {
d.fx = null;
d.fy = null;
}
// Toggle the pinned class as appropriate for the circle backing this node.
d3.select(`#circle-${d.id}`).classed('pinned', d.fx != null);
}
private getTargetPositionY_(d: GraphNode): number {
return d.targetPositionY(this.height_);
}
private getTargetPositionStrengthY_(d: GraphNode): number {
return d.targetYPositionStrength;
}
private getManyBodyStrength_(d: GraphNode): number {
return d.manyBodyStrength;
}
/**
* @param graphWidth Width of the graph view (svg).
* @param graphHeight Height of the graph view (svg).
*/
private updateSeparators_(graphWidth: number, graphHeight: number) {
const separators = [
['Pages', 'Frame Tree', kPageNodesYRange],
['', 'Workers', graphHeight - kWorkerNodesYRange],
['', 'Processes', graphHeight - kProcessNodesYRange],
];
const kAboveLabelOffset = -6;
const kBelowLabelOffset = 14;
const groups = this.separatorGroup_!.selectAll('g').data(separators);
if (groups.enter()) {
const group = groups.enter().append('g').attr(
'transform', (d: Array<number|string>) => `translate(0,${d[2]})`);
group.append('line')
.attr('x1', 10)
.attr('y1', 0)
.attr('x2', graphWidth - 10)
.attr('y2', 0)
.attr('stroke', 'black')
.attr('stroke-dasharray', '4');
group.each(function(d: unknown) {
const parentGroup = d3.select(this);
if ((d as Array<string|number>)[0]) {
parentGroup.append('text')
.attr('x', 20)
.attr('y', kAboveLabelOffset)
.attr('class', 'separator')
.text(d => (d as Array<string|number>)[0]);
}
if ((d as Array<string|number>)[1]) {
parentGroup.append('text')
.attr('x', 20)
.attr('y', kBelowLabelOffset)
.attr('class', 'separator')
.text(d => (d as Array<string|number>)[1]);
}
});
}
groups.attr('transform', (d: unknown) => {
const value = (d as Array<string|number>)[2];
return `translate(0,${value})`;
});
groups.selectAll('line').attr('x2', graphWidth - 10);
}
private restartSimulation_() {
// Restart the simulation.
this.simulation_!.alphaTarget(0.3).restart();
}
/**
* Resizes and restarts the animation after a size change.
*/
onResize() {
this.width_ = this.svg_.clientWidth;
this.height_ = this.svg_.clientHeight;
this.updateSeparators_(this.width_, this.height_);
// Reset both X and Y attractive forces, as they're cached.
const xForce = d3.forceX().x(this.width_ / 2).strength(0.1);
const yForce = (d3.forceY() as d3.ForceY<GraphNode>)
.y(this.getTargetPositionY_.bind(this))
.strength(this.getTargetPositionStrengthY_.bind(this));
this.simulation_!.force('x_pos', xForce);
this.simulation_!.force('y_pos', yForce);
this.simulation_!.force(
'y_bound', boundingForce(this.height_, this.width_));
if (!this.wasResized_) {
this.wasResized_ = true;
// Reinitialize all node positions on first resize.
this.nodes_.forEach(
node => node.setInitialPosition(this.width_, this.height_));
// Allow the simulation to settle by running it for a bit.
for (let i = 0; i < 200; ++i) {
this.simulation_!.tick();
}
}
this.restartSimulation_();
}
}