Path: blob/main/web/ui/src/features/graph/ComponentGraph.tsx
5285 views
import { FC, useEffect, useRef } from 'react';1import { useHref } from 'react-router-dom';2import * as d3 from 'd3';3import { coordSimplex, dagStratify, decrossTwoLayer, layeringCoffmanGraham, NodeSizeAccessor, sugiyama } from 'd3-dag';4import { Point } from 'd3-dag/dist/dag';5import { IdOperator, ParentIdsOperator } from 'd3-dag/dist/dag/create';6import * as d3Zoom from 'd3-zoom';78import { ComponentHealthState, ComponentInfo } from '../component/types';910let canvas: HTMLCanvasElement | undefined;1112/**13* calcTextWidth calculates the width of text if it were to be rendered on14* screen.15*16* font should be a font specifier like "bold 16pt arial"17*/18function calcTextWidth(text: string, font: string): number | null {19// Adapted from https://stackoverflow.com/a/210153932021// Lazy-load the canvas if it hasn't been created yet.22if (canvas === undefined) {23canvas = document.createElement('canvas');24}2526const context = canvas.getContext('2d');27if (context == null) {28return null;29}30context.font = font;31return context.measureText(text).width;32}3334/**35* intersectsBox reports whether a point intersects a box.36*/37function intersectsBox(point: Point, box: Box): boolean {38return (39point.x >= box.x && // after starting X40point.y >= box.y && // after starting Y41point.x <= box.x + box.w && // before ending X42point.y <= box.y + box.h // before ending Y43);44}4546interface Line {47start: Point;48end: Point;49}5051/*52* boxIntersectionPoint returns the point where line intersects box.53*/54function boxIntersectionPoint(line: Line, box: Box): Point {55const boxTop: Line = { start: { x: box.x, y: box.y }, end: { x: box.x + box.w, y: box.y } };56const topIntersectionPoint = lineIntersectionPoint(line, boxTop);57if (topIntersectionPoint !== undefined) {58return topIntersectionPoint;59}6061const boxRight: Line = { start: { x: box.x + box.w, y: box.y }, end: { x: box.x + box.w, y: box.y + box.h } };62const rightIntersectionPoint = lineIntersectionPoint(line, boxRight);63if (rightIntersectionPoint !== undefined) {64return rightIntersectionPoint;65}6667const boxBottom: Line = { start: { x: box.x, y: box.y + box.h }, end: { x: box.x + box.w, y: box.y + box.h } };68const bottomIntersectionPoint = lineIntersectionPoint(line, boxBottom);69if (bottomIntersectionPoint !== undefined) {70return bottomIntersectionPoint;71}7273const boxLeft: Line = { start: { x: box.x, y: box.y }, end: { x: box.x, y: box.y + box.h } };74const leftInsersectionPoint = lineIntersectionPoint(line, boxLeft);75if (leftInsersectionPoint !== undefined) {76return leftInsersectionPoint;77}7879// No intersection; just return the last point of the line.80return line.end;81}8283/*84* lineIntersectionPoint returns the point where l1 and l2 intersect.85*86* Returns undefined if the lines do not intersect.87*/88function lineIntersectionPoint(l1: Line, l2: Line): Point | undefined {89// https://en.wikipedia.org/wiki/Line%E2%80%93line_intersection#Given_two_points_on_each_line_segment9091// l1 = (x1, y1) -> (x2, y2)92// l2 = (x3, y3) -> (x4, y4)93const [x1, y1] = [l1.start.x, l1.start.y];94const [x2, y2] = [l1.end.x, l1.end.y];95const [x3, y3] = [l2.start.x, l2.start.y];96const [x4, y4] = [l2.end.x, l2.end.y];9798const denominator = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);99if (denominator === 0) {100return undefined;101}102103const t_numerator = (x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4);104const u_numerator = (x1 - x3) * (y1 - y2) - (y1 - y3) * (x1 - x2);105106// Only t is used for calculating the point, but both t and u must be defined107// to ensure the intersection exists.108const [t, u] = [t_numerator / denominator, u_numerator / denominator];109110// There is an intersection if and only if 0 <= t <= 1 and 0 <= u <= 1111if (0 <= t && t <= 1 && 0 <= u && u <= 1) {112return {113x: x1 + t * (x2 - x1),114y: y1 + t * (y2 - y1),115};116}117118return undefined;119}120121interface Box {122x: number;123y: number;124w: number;125h: number;126}127128export interface ComponentGraphProps {129components: ComponentInfo[];130}131132/**133* ComponentGraph renders an SVG with relationships between defined components.134* The components prop must be a non-empty array.135*/136export const ComponentGraph: FC<ComponentGraphProps> = (props) => {137const baseComponentPath = useHref('/component');138const svgRef = useRef<SVGSVGElement>(null);139140useEffect(() => {141// NOTE(rfratto): The default units of svg are in pixels.142143const [nodeWidth, nodeHeight] = [150, 75];144const nodeMargin = 25;145const nodePadding = 5;146147const contentHeight = nodeHeight - nodePadding * 2;148149const widthCache: Record<string, number> = {150foo: 5,151};152153const builder = dagStratify()154.id<IdOperator<ComponentInfo>>((n) => n.id)155.parentIds<ParentIdsOperator<ComponentInfo>>((n) => n.referencedBy);156const dag = builder(props.components);157158// Our graph layout is optimized for graphs of 50 components or more. The159// decross method is where most of the layout time is spent; decrossOpt is160// far too slow.161//162// We also use Coffman Graham for layering, which constrains the final163// width of the graph as much as possible.164const layout = sugiyama()165.layering(layeringCoffmanGraham())166.decross(decrossTwoLayer())167.coord(coordSimplex())168.nodeSize<NodeSizeAccessor<ComponentInfo, undefined>>((n) => {169// nodeSize is the full amount of space you want the node to take up.170//171// It can be considered similar to the box model: margin and padding should172// be added to the size.173174// n will be undefined for synthetic nodes in a layer. These synthetic175// nodes can be given sizes, but we keep them at [0, 0] to minimize the176// total width of the graph.177if (n === undefined) {178return [0, 0];179}180181// Calculate how much width the text needs to be displayed.182let width = nodeWidth;183184const displayFont = "bold 13px 'Roboto', sans-serif";185186const nameWidth = calcTextWidth(n.data.name, displayFont);187if (nameWidth != null && nameWidth > width) {188width = nameWidth;189}190191const labelWidth = calcTextWidth(n.data.label || '', displayFont);192if (labelWidth != null && labelWidth > width) {193width = labelWidth;194}195196// Cache the width so it can be used while plotting the SVG.197widthCache[n.data.id] = width;198199return [width + nodeMargin + nodePadding * 2, nodeHeight + nodeMargin + nodePadding * 2];200});201const { width, height } = layout(dag);202203// svgRef.current needs to be cast to an Element for type checks to work.204// SVGSVGElement doesn't extend element and prevents zoom from205// typechecking.206//207// Everything still seems to work even with the type cast.208const svgSelection = d3.select(svgRef.current as Element);209svgSelection.selectAll('*').remove(); // Clear svg content before adding new elements210svgSelection.attr('viewBox', [0, 0, width, height].join(' '));211212const svgWrapper = svgSelection.append('g');213214// TODO(rfratto): determine a reasonable zoom scale extent based on size of215// layout rather than hard coding 0.1x to 10x.216//217// As it is now, you can zoom in way too close on really small graphs.218const zoom = d3Zoom219.zoom()220.scaleExtent([0.1, 10])221.on('zoom', (e) => {222svgWrapper.attr('transform', e.transform);223});224225svgSelection.call(zoom).call(zoom.transform, d3Zoom.zoomIdentity);226227// Add a marker element so we can draw an arrow pointing between nodes.228svgWrapper229.append('defs')230.append('marker')231.attr('id', 'arrow')232.attr('viewBox', [0, 0, 20, 20])233.attr('refX', 17)234.attr('refY', 10)235.attr('markerWidth', 5)236.attr('markerHeight', 5)237.attr('orient', 'auto-start-reverse')238.append('path')239.attr(240'd',241// Draw an arrow shape242d3.line()([243[0, 0], // Bottom left of arrow244[0, 20], // Top left of arrow245[20, 10], // Middle point of arrow246])247)248.attr('fill', '#c8c9ca');249250const line = d3251.line<Point>()252.curve(d3.curveCatmullRom)253.x((d) => d.x)254.y((d) => d.y);255256// Plot edges257svgWrapper258.append('g')259.selectAll('path')260.data(dag.links())261.enter()262.append('path')263.attr('marker-end', 'url(#arrow)')264.attr('d', (node) => {265// We want to draw arrows between boxes, but by default the arrows are266// obscured; d3-dag points lines to the middle of a box which is hidden267// by the rectangle.268//269// To fix this, we do the following:270//271// 1. Retrieve the set of generated points for d3-dag272// 2. Remove all points after the first point which intersects the box273// 3. Move the final point to the coordinates where it intersects the274// box275// 4. The line will now stop at the box edge as expected.276277const nodeBox: Box = {278x: (node.target.x || 0) - widthCache[node.target.data.id] / 2 - nodePadding,279y: (node.target.y || 0) - nodeHeight / 2 - nodePadding,280w: widthCache[node.target.data.id] + nodePadding * 2,281h: nodeHeight + nodePadding * 2,282};283284const idx = node.points.findIndex((p) => {285return intersectsBox(p, nodeBox);286});287if (idx === -1) {288// It shouldn't be possible for this to happen; we know that the289// final point always goes to the center of the target box so there290// should always be an intersection.291throw new Error('could not find point of intersection with target node');292}293const trimmedPoints = node.points.slice(0, idx + 1);294295const intersectingLine = {296start: trimmedPoints[trimmedPoints.length - 2],297end: trimmedPoints[trimmedPoints.length - 1],298};299const fixedPoint = boxIntersectionPoint(intersectingLine, nodeBox);300trimmedPoints[trimmedPoints.length - 1] = fixedPoint;301302return line(trimmedPoints);303})304.attr('fill', 'none')305.attr('stroke-width', '2px')306.attr('stroke', '#c8c9ca')307.append('title') // Append tooltip to edge308.text((n) => {309return `${n.source.data.id} to ${n.target.data.id}`;310});311312// Select nodes313const nodes = svgWrapper314.append('g')315.selectAll('g')316.data(dag.descendants())317.enter()318.append('g')319.attr('transform', (node) => {320// node.x, node.y refer to the absolute center of the box.321//322// We translate the group to the top-left corner to make it easier to323// position all the elements. Top left corner should account for324// padding space.325const x = (node.x || 0) - widthCache[node.data.id] / 2 - nodePadding;326const y = (node.y || 0) - nodeHeight / 2 - nodePadding;327return `translate(${x}, ${y})`;328});329330const linkedNodes = nodes.append('a').attr('href', (n) => `${baseComponentPath}/${n.data.id}`);331332// Plot nodes333linkedNodes334.append('rect')335.attr('fill', '#f2f2f3')336.attr('rx', 3)337.attr('height', nodeHeight + nodePadding * 2)338.attr('width', (node) => {339return widthCache[node.data.id] + nodePadding * 2;340})341.attr('stroke-width', '1')342.attr('stroke', '#e4e5e6');343344// Create a group for node content which is anchored inside of the padding345// area.346const nodeContent = linkedNodes.append('g').attr('transform', `translate(${nodePadding}, ${nodePadding})`);347348// Add component name text349nodeContent350.append('text')351.text((d) => d.data.name)352.attr('font-size', '13')353.attr('font-weight', 'bold')354.attr('font-family', '"Roboto", sans-serif')355.attr('text-anchor', 'start')356.attr('alignment-baseline', 'hanging')357.attr('fill', 'rgb(36, 41, 46, 0.75)');358359// Add component label text360nodeContent361.append('text')362.text((d) => d.data.label || '')363.attr('y', 13 /* font size */ + 2 /* margin from previous text line */)364.attr('font-size', '13')365.attr('font-weight', 'normal')366.attr('font-family', '"Roboto", sans-serif')367.attr('text-anchor', 'start')368.attr('alignment-baseline', 'hanging')369.attr('fill', 'rgb(36, 41, 46, 0.75)');370371// Draw health status372const healthBox = nodeContent373.append('g')374.attr('transform', `translate(0, ${contentHeight - 3})`); /* 1/4 height (why?) */375376healthBox377.append('rect')378.attr('fill', (node) => {379switch (node.data.health.state || ComponentHealthState.UNKNOWN) {380case ComponentHealthState.HEALTHY:381return '#3b8160';382case ComponentHealthState.UNHEALTHY:383return '#d2476d';384case ComponentHealthState.EXITED:385return '#d2476d';386case ComponentHealthState.UNKNOWN:387return '#f5d65b';388}389})390.attr('rx', 1)391.attr('height', 14)392.attr('width', 45);393394healthBox395.append('text')396.text((d) => {397const text = d.data.health.state || 'unknown';398return text.charAt(0).toUpperCase() + text.substring(1);399})400.attr('x', 45 / 2) // Anchor to middle of box401.attr('y', 14 / 2) // Middle of box402.attr('font-size', '7')403.attr('font-weight', 'bold')404.attr('font-family', '"Roboto", sans-serif')405.attr('text-anchor', 'middle')406.attr('alignment-baseline', 'middle')407.attr('fill', (node) => {408if (node.data.health.state === ComponentHealthState.UNKNOWN) {409return '#000000';410}411return '#ffffff';412});413});414415return <svg ref={svgRef} style={{ width: '100%', height: '100%', display: 'block' }} />;416};417418419