diff --git a/packages/widget/src/constants.ts b/packages/widget/src/constants.ts index 7ef1dbb9..94733b05 100644 --- a/packages/widget/src/constants.ts +++ b/packages/widget/src/constants.ts @@ -1,3 +1,7 @@ +import { SimulationLinkDatum } from 'd3'; +import { SimulationNodeDatum } from 'd3-force'; +import * as Dagre from 'dagre'; + export const WIDGET_SIZE: number = 500; export const GRAPH_CANVAS_HEIGHT: number = 475; export const GRAPH_CANVAS_ID: string = 'd3-canvas'; @@ -124,3 +128,8 @@ export type DisplayObjectGraph = { nodes: DisplayObject[]; edges: DisplayObjectEdge[]; }; + +// We override x & y since they're optional in SimulationNodeDatum, but not in our use case +export type D3Node = SimulationNodeDatum & DisplayObject & { x: number; y: number }; +export type D3Edge = SimulationLinkDatum; +export type DagreGraph = Dagre.graphlib.Graph; diff --git a/packages/widget/src/docmap-controller.ts b/packages/widget/src/docmap-controller.ts index 05cbf810..92f7bee7 100644 --- a/packages/widget/src/docmap-controller.ts +++ b/packages/widget/src/docmap-controller.ts @@ -1,6 +1,6 @@ import { MakeHttpClient } from '@docmaps/http-client'; import { TaskFunction } from '@lit/task'; -import { ActionT, ActorT, Docmap, DocmapT, RoleInTimeT, StepT, ThingT } from 'docmaps-sdk'; +import { ActorT, Docmap, DocmapT, ManifestationT, RoleInTimeT, StepT, ThingT } from 'docmaps-sdk'; import { pipe } from 'fp-ts/lib/function'; import * as E from 'fp-ts/lib/Either'; import { ALL_KNOWN_TYPES, DisplayObject, DisplayObjectEdge, DisplayObjectGraph } from './constants'; @@ -60,50 +60,53 @@ function getOrderedSteps(docmap: DocmapT): StepT[] { } export function stepsToGraph(steps: StepT[]): DisplayObjectGraph { + const nodesById: { [id: string]: DisplayObject } = {}; const edges: DisplayObjectEdge[] = []; - const seenIds: Set = new Set(); - const nodesById: { [id: string]: DisplayObject } = {}; let idCounter: number = 1; - - const processThing = (thing: ThingT, participants: RoleInTimeT[] = []) => { - let inputId = thing.doi || thing.id; - if (!inputId) { - inputId = `n${idCounter}`; - idCounter++; - } - - if (!seenIds.has(inputId)) { - nodesById[inputId] = thingToDisplayObject(thing, inputId, participants); - } - - for (const id of [thing.doi, thing.id]) { - if (id) { - seenIds.add(id); - } - } - return inputId; + const idGenerator = (): string => { + const newId = `n${idCounter}`; + idCounter++; + return newId; }; - steps.forEach((step: StepT) => { - // Process step inputs - const inputIds: string[] = step.inputs?.map((input) => processThing(input)) || []; + steps.forEach((step) => processStep(step, nodesById, edges, idGenerator)); - // Process step outputs - step.actions.forEach((action: ActionT) => { - action.outputs.map((output: ThingT) => { - const outputId = processThing(output, action.participants); + const nodes: DisplayObject[] = Object.values(nodesById); + return { nodes, edges }; +} - // Add an edge from every step input to this node - inputIds.forEach((inputId: string) => - edges.push({ sourceId: inputId, targetId: outputId }), - ); +function processStep( + step: StepT, + nodesById: { [id: string]: DisplayObject }, + edges: DisplayObjectEdge[], + generateId: () => string, +) { + const inputIds: string[] = + step.inputs?.map((input) => processThing(input, nodesById, [], generateId)) || []; + + step.actions.forEach((action) => { + action.outputs.forEach((output) => { + const outputId = processThing(output, nodesById, action.participants, generateId); + + inputIds.forEach((inputId: string) => { + edges.push({ sourceId: inputId, targetId: outputId }); }); }); }); +} - const nodes: DisplayObject[] = Object.values(nodesById); - return { nodes, edges }; +function processThing( + thing: ThingT, + nodesById: { [id: string]: DisplayObject }, + participants: RoleInTimeT[] = [], + generateId: () => string, +): string { + const id: string = thing.doi || thing.id || generateId(); + if (!(id in nodesById)) { + nodesById[id] = thingToDisplayObject(thing, id, participants); + } + return id; } interface NameHaver { @@ -115,32 +118,10 @@ function thingToDisplayObject( nodeId: string, participants: RoleInTimeT[], ): DisplayObject { - // Make sure type is a string (not an array), and that it's one of the types we support displaying - const providedType = (Array.isArray(thing.type) ? thing.type[0] : thing.type) ?? '??'; - const displayType = ALL_KNOWN_TYPES.indexOf(providedType) >= 0 ? providedType : '??'; - - const published: string | undefined = - thing.published && thing.published instanceof Date - ? formatDate(thing.published) - : thing.published; - - const content = thing.content - ? thing.content - .map((manifestation) => { - return manifestation.url?.toString(); - }) - .filter((url): url is string => url !== undefined) - : undefined; - - const actors = participants - .map((participant) => participant.actor) - .filter((actor: ActorT): actor is NameHaver => { - // Actors can be anything, so we have to check that they have a name - // @ts-ignore - return actor && actor?.name; - }) - .map((actor: NameHaver) => actor.name) - .join(', '); + const displayType: string = determineDisplayType(thing.type); + const published: string | undefined = formatDateIfAvailable(thing.published); + let content: string[] | undefined = extractContentUrls(thing.content); + const actors: string = extractActorNames(participants); return { nodeId, @@ -154,13 +135,40 @@ function thingToDisplayObject( }; } +function formatDateIfAvailable(date: Date | string | undefined) { + return date && date instanceof Date ? formatDate(date) : date; +} + +function determineDisplayType(ty: string | string[] | undefined): string { + const singleType: string = (Array.isArray(ty) ? ty[0] : ty) ?? '??'; + return ALL_KNOWN_TYPES.includes(singleType) ? singleType : '??'; +} + +function extractContentUrls(content: ManifestationT[] | undefined) { + return content + ?.map((manifestation: ManifestationT) => manifestation.url?.toString()) + .filter((url: string | undefined): url is string => url !== undefined); +} + +function extractActorNames(participants: RoleInTimeT[]) { + return participants + .map((participant) => participant.actor) + .filter((actor: ActorT): actor is NameHaver => { + // Actors can be anything, so we have to check that they have a name + // @ts-ignore + return actor && actor?.name; + }) + .map((actor: NameHaver) => actor.name) + .join(', '); +} + function formatDate(date: Date) { const yyyy = date.getFullYear(); // The getMonth() method returns the month (0-11) for the specified date, // so you need to add 1 to get the correct month. - const month = date.getMonth() + 1; - const day = date.getDate(); + const month: number = date.getMonth() + 1; + const day: number = date.getDate(); // Convert month and day numbers to strings and prefix them with a zero if they're below 10 let mm = month.toString(); @@ -177,9 +185,13 @@ function formatDate(date: Date) { } export function sortDisplayObjects(objects: DisplayObject[]): DisplayObject[] { - return [...objects].sort((a, b) => a.nodeId.localeCompare(b.nodeId)); + return [...objects].sort((a: DisplayObject, b: DisplayObject) => + a.nodeId.localeCompare(b.nodeId), + ); } export function sortDisplayObjectEdges(edges: DisplayObjectEdge[]): DisplayObjectEdge[] { - return [...edges].sort((a, b) => a.sourceId.localeCompare(b.sourceId)); + return [...edges].sort((a: DisplayObjectEdge, b: DisplayObjectEdge) => + a.sourceId.localeCompare(b.sourceId), + ); } diff --git a/packages/widget/src/docmaps-widget.ts b/packages/widget/src/docmaps-widget.ts index 6355cb9e..92183033 100644 --- a/packages/widget/src/docmaps-widget.ts +++ b/packages/widget/src/docmaps-widget.ts @@ -3,12 +3,13 @@ import { customElement, property, state } from 'lit/decorators.js'; import { customCss } from './styles'; import { closeDetailsButton, logo, timelinePlaceholder } from './assets'; import * as d3 from 'd3'; -import { SimulationLinkDatum } from 'd3'; import { Task } from '@lit/task'; import { DocmapFetchingParams, getDocmap } from './docmap-controller'; -import { SimulationNodeDatum } from 'd3-force'; import * as Dagre from 'dagre'; import { + D3Edge, + D3Node, + DagreGraph, DisplayObject, DisplayObjectEdge, DisplayObjectGraph, @@ -22,9 +23,6 @@ import { WIDGET_SIZE, } from './constants'; -export type D3Node = SimulationNodeDatum & DisplayObject & { x: number; y: number }; // We override x & y since they're optional in SimulationNodeDatum, but not in our use case -export type D3Edge = SimulationLinkDatum; - // TODO name should be singular not plural @customElement('docmaps-widget') export class DocmapsWidget extends LitElement { @@ -45,8 +43,12 @@ export class DocmapsWidget extends LitElement { static styles = [customCss]; - render() { - const content = this.selectedNode + firstUpdated() { + loadFont(); + } + + render(): HTMLTemplateResult { + const content: HTMLTemplateResult = this.selectedNode ? this.renderDetailsView(this.selectedNode) : html`
@@ -66,137 +68,90 @@ export class DocmapsWidget extends LitElement { `; } - private onNodeClick(node: DisplayObject) { - this.selectedNode = node; - this.requestUpdate(); // Trigger re-render - } - private renderDocmap({ nodes, edges }: DisplayObjectGraph) { - this.drawGraph(nodes, edges); - // D3 draws the graph for us within the GRAPH_CANVAS_ID div, so we have nothing to actually render here - return nothing; - } + if (this.shadowRoot) { + const { d3Nodes, d3Edges, graphWidth } = prepareGraphForSimulation(nodes, edges); - private drawGraph(nodes: DisplayObject[], edges: DisplayObjectEdge[]) { - if (!this.shadowRoot) { - // We cannot draw a graph if we aren't able to find the place we want to draw it - return; + const canvas: Element | null = this.getCanvasElement(); + const svg = this.createEmptySvgForGraph(canvas, graphWidth); + this.drawGraph(d3Nodes, d3Edges, graphWidth, svg); } - this.clearGraph(); + // D3 draws the graph for us, so we have nothing to actually render here + return nothing; + } - const canvas = this.shadowRoot.querySelector(`#${GRAPH_CANVAS_ID}`); - if (!canvas) { - throw new Error('SVG element not found'); - } + private drawGraph( + d3Nodes: D3Node[], + d3Edges: D3Edge[], + graphWidth: number, + svg: d3.Selection, + ) { + const simulation = createForceSimulation(d3Nodes, d3Edges, graphWidth); + const linkElements = createLinkElements(svg, d3Edges); + const nodeElements = createNodeElements(svg, d3Nodes); + const labels = createLabels(svg, d3Nodes); + setupSimulationTicks(simulation, linkElements, nodeElements, labels); + this.setupInteractivity(nodeElements, labels); + } + private onNodeClick(node: DisplayObject) { + this.selectedNode = node; + this.requestUpdate(); // Trigger re-render + } + private createEmptySvgForGraph( + canvas: Element | null, + graphWidth: number, + ): d3.Selection { + this.clearGraph(); const svg = d3 .select(canvas) .append('svg') .attr('width', WIDGET_SIZE) .attr('height', GRAPH_CANVAS_HEIGHT); - const { d3Nodes, d3Edges, graphWidth } = prepareGraphForSimulation(nodes, edges); - if (graphWidth) { svg.attr('viewBox', `0 0 ${graphWidth} ${GRAPH_CANVAS_HEIGHT}`); } - - const simulation: d3.Simulation = d3 - .forceSimulation(d3Nodes) - .force( - 'link', - d3 - .forceLink(d3Edges) - .id((d: d3.SimulationNodeDatum) => { - // @ts-ignore - return d.nodeId; - }) - .distance(RANK_SEPARATION * 1.2) - .strength(0.2), - ) - .force('charge', d3.forceManyBody()) - .force('collide', d3.forceCollide(NODE_RADIUS * 1.3)) - .force( - 'center', - d3.forceCenter(Math.floor(graphWidth / 2), Math.floor(GRAPH_CANVAS_HEIGHT / 2)), - ); - - const linkElements = svg - .append('g') - .attr('class', 'links') - .selectAll('line') - .data(d3Edges) - .enter() - .append('line') - .attr('stroke', 'black') - .attr('class', 'link'); - - const nodeElements = svg - .append('g') - .attr('class', 'nodes') - .selectAll('circle') - .data(d3Nodes) - .enter() - .append('circle') - .attr('class', 'node clickable') - .attr('fill', (d) => TYPE_DISPLAY_OPTIONS[d.type].backgroundColor) - .attr('r', (_, i: number): number => (i === 0 ? FIRST_NODE_RADIUS : NODE_RADIUS)) - .attr('stroke', (d: D3Node): string => - TYPE_DISPLAY_OPTIONS[d.type].dottedBorder ? '#777' : 'none', - ) - .attr('stroke-width', (d: D3Node): string => - TYPE_DISPLAY_OPTIONS[d.type].dottedBorder ? '2px' : 'none', - ) - .attr('stroke-dasharray', (d: D3Node): string => - TYPE_DISPLAY_OPTIONS[d.type].dottedBorder ? '8 4' : 'none', - ); - - const labels = svg - .append('g') - .attr('class', 'labels') - .selectAll('text') - .data(d3Nodes) - .enter() - .append('text') - .attr('class', 'label clickable') - .attr('text-anchor', 'middle') - .attr('dominant-baseline', 'central') - .attr('fill', (d) => TYPE_DISPLAY_OPTIONS[d.type].textColor) // Set the text color - .text((d) => TYPE_DISPLAY_OPTIONS[d.type].shortLabel); - - simulation.on('tick', () => { - linkElements - .attr('x1', (d) => (d.source as D3Node).x ?? 0) - .attr('y1', (d) => (d.source as D3Node).y ?? 0) - .attr('x2', (d) => (d.target as D3Node).x ?? 0) - .attr('y2', (d) => (d.target as D3Node).y ?? 0); - - nodeElements.attr('cx', getNodeX).attr('cy', getNodeY); - labels - // We offset x slightly because otherwise the label looks a tiny bit off-center horizontally - .attr('x', (d: D3Node) => getNodeX(d) + 0.8) - .attr('y', getNodeY); - }); - - nodeElements.on('click', (_event, d) => this.onNodeClick(d)); - labels.on('click', (_event, d) => this.onNodeClick(d)); - - this.setUpTooltips(nodeElements); - this.setUpTooltips(labels); + return svg; } private clearGraph() { if (!this.shadowRoot) { return; } + d3.select(this.shadowRoot.querySelector(`#${GRAPH_CANVAS_ID} svg`)).remove(); } + private getCanvasElement(): Element | null { + if (!this.shadowRoot) { + return null; + } + + const canvas = this.shadowRoot.querySelector(`#${GRAPH_CANVAS_ID}`); + if (!canvas) { + throw new Error('SVG element not found'); + } + return canvas; + } + + private setupInteractivity( + nodeElements: d3.Selection, + labels: d3.Selection, + ) { + nodeElements.on('click', (_event, d: D3Node) => this.onNodeClick(d)); + labels.on('click', (_event, d: D3Node) => this.onNodeClick(d)); + + this.setUpTooltips(nodeElements); + this.setUpTooltips(labels); + } + private setUpTooltips(selection: d3.Selection) { if (!this.shadowRoot) { return; } + const tooltip = d3.select(this.shadowRoot.querySelector('#tooltip')); selection @@ -216,18 +171,16 @@ export class DocmapsWidget extends LitElement { private renderDetailsView(node: DisplayObject): HTMLTemplateResult { this.clearGraph(); const opts = TYPE_DISPLAY_OPTIONS[node.type]; - const metadataEntries = this.filterMetadataEntries(node); + const metadataEntries: [string, any][] = this.filterMetadataEntries(node); - const metadataBody = + const metadataBody: HTMLTemplateResult = metadataEntries.length > 0 ? this.createMetadataGrid(metadataEntries) : this.emptyMetadataMessage(); - const backgroundColor = opts.detailBackgroundColor - ? opts.detailBackgroundColor - : opts.backgroundColor; + const backgroundColor = opts.detailBackgroundColor || opts.backgroundColor; + const textColor = opts.detailTextColor || opts.textColor; - const textColor = opts.detailTextColor ? opts.detailTextColor : opts.textColor; return html`
${timelinePlaceholder}
@@ -242,12 +195,20 @@ export class DocmapsWidget extends LitElement { `; } + // Method to clear the selected node and go back to the graph + private closeDetailsView() { + this.selectedNode = undefined; + this.requestUpdate(); // Trigger re-render + } + private filterMetadataEntries(node: DisplayObject): [string, any][] { return Object.entries(node).filter(([key, value]) => isFieldToDisplay(key) && value); } private createMetadataGrid(metadataEntries: [string, any][]): HTMLTemplateResult { - const gridItems = metadataEntries.map((entry, index) => this.createGridItem(entry, index)); + const gridItems: HTMLTemplateResult[] = metadataEntries.map((entry, index) => + this.createGridItem(entry, index), + ); return html` `; } @@ -276,15 +237,109 @@ export class DocmapsWidget extends LitElement { `; } +} - // Method to clear the selected node and go back to the graph - private closeDetailsView() { - this.selectedNode = undefined; - this.requestUpdate(); // Trigger re-render - } +function createLabels( + svg: d3.Selection, + d3Nodes: D3Node[], +) { + return svg + .append('g') + .attr('class', 'labels') + .selectAll('text') + .data(d3Nodes) + .enter() + .append('text') + .attr('class', 'label clickable') + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'central') + .attr('fill', (d) => TYPE_DISPLAY_OPTIONS[d.type].textColor) // Set the text color + .text((d) => TYPE_DISPLAY_OPTIONS[d.type].shortLabel); +} + +function createNodeElements( + svg: d3.Selection, + d3Nodes: D3Node[], +) { + return svg + .append('g') + .attr('class', 'nodes') + .selectAll('circle') + .data(d3Nodes) + .enter() + .append('circle') + .attr('class', 'node clickable') + .attr('fill', (d) => TYPE_DISPLAY_OPTIONS[d.type].backgroundColor) + .attr('r', (_, i: number): number => (i === 0 ? FIRST_NODE_RADIUS : NODE_RADIUS)) + .attr('stroke', (d: D3Node): string => + TYPE_DISPLAY_OPTIONS[d.type].dottedBorder ? '#777' : 'none', + ) + .attr('stroke-width', (d: D3Node): string => + TYPE_DISPLAY_OPTIONS[d.type].dottedBorder ? '2px' : 'none', + ) + .attr('stroke-dasharray', (d: D3Node): string => + TYPE_DISPLAY_OPTIONS[d.type].dottedBorder ? '8 4' : 'none', + ); +} + +function createLinkElements( + svg: d3.Selection, + d3Edges: D3Edge[], +): d3.Selection { + return svg + .append('g') + .attr('class', 'links') + .selectAll('line') + .data(d3Edges) + .enter() + .append('line') + .attr('stroke', 'black') + .attr('class', 'link'); +} + +function createForceSimulation(d3Nodes: D3Node[], d3Edges: D3Edge[], graphWidth: number) { + return d3 + .forceSimulation(d3Nodes) + .force( + 'link', + d3 + .forceLink(d3Edges) + .id((d: d3.SimulationNodeDatum) => { + // @ts-ignore + return d.nodeId; + }) + .distance(RANK_SEPARATION * 1.2) + .strength(0.2), + ) + .force('charge', d3.forceManyBody()) + .force('collide', d3.forceCollide(NODE_RADIUS * 1.3)) + .force( + 'center', + d3.forceCenter(Math.floor(graphWidth / 2), Math.floor(GRAPH_CANVAS_HEIGHT / 2)), + ); +} + +function setupSimulationTicks( + simulation: d3.Simulation, + linkElements: d3.Selection, + nodeElements: d3.Selection, + labels: d3.Selection, +) { + simulation.on('tick', () => { + linkElements + .attr('x1', (d) => (d.source as D3Node).x ?? 0) + .attr('y1', (d) => (d.source as D3Node).y ?? 0) + .attr('x2', (d) => (d.target as D3Node).x ?? 0) + .attr('y2', (d) => (d.target as D3Node).y ?? 0); + + nodeElements.attr('cx', getNodeX).attr('cy', getNodeY); + labels + // We offset x slightly because otherwise the label looks a tiny bit off-center horizontally + .attr('x', (d: D3Node) => getNodeX(d) + 0.8) + .attr('y', getNodeY); + }); } -type DagreGraph = Dagre.graphlib.Graph; // Dagre is a tool for laying out directed graphs. We use it to generate initial positions for // our nodes, which we then pass to d3 to animate into their final positions. @@ -309,21 +364,18 @@ function getInitialNodePositions(nodes: DisplayObject[], edges: DisplayObjectEdg g.setNode(node.nodeId, { ...node, width: size, height: size }); } - for (const edge of edges) { - g.setEdge(edge.sourceId, edge.targetId); - } + edges.forEach((edge) => g.setEdge(edge.sourceId, edge.targetId)); Dagre.layout(g); return g; } -function groupNodesByYCoordinate(nodeIds: string[], dagreGraph: DagreGraph) { - const yLevelNodeMap = new Map(); - nodeIds.forEach((nodeId) => { - const node = dagreGraph.node(nodeId); - const yLevel = node.y; +function groupNodesByYCoordinate(nodeIds: string[], dagreGraph: DagreGraph): Map { + const yLevelNodeMap: Map = new Map(); + nodeIds.forEach((nodeId: string) => { + const node: Dagre.Node = dagreGraph.node(nodeId); + const yLevel: number = node.y; - // Initialize the array for this y level if it doesn't exist yet if (!yLevelNodeMap.has(yLevel)) { yLevelNodeMap.set(yLevel, []); } @@ -335,51 +387,86 @@ function groupNodesByYCoordinate(nodeIds: string[], dagreGraph: DagreGraph) { // Convert the naive "DisplayObject" nodes and edges we get from the Docmap controller // into nodes and edges that are ready to render via d3 -// // Along the way, we also calculate initial positions for the nodes. function prepareGraphForSimulation( nodes: DisplayObject[], edges: DisplayObjectEdge[], ): { d3Edges: D3Edge[]; d3Nodes: D3Node[]; graphWidth: number } { + // Use Dagre to get initial node positions based on graph layout const dagreGraph: DagreGraph = getInitialNodePositions(nodes, edges); - const graphBounds = dagreGraph.graph(); - let graphWidth = WIDGET_SIZE; - if ( - graphBounds.width && - graphBounds.height && - (graphBounds.width > WIDGET_SIZE || graphBounds.height > GRAPH_CANVAS_HEIGHT) - ) { - const aspectRatio = (1.1 * graphBounds.width) / graphBounds.height; - graphWidth = aspectRatio * GRAPH_CANVAS_HEIGHT; + // If the graph is too big to fit in the widget, we will need to zoom out + const { width, height }: Dagre.GraphLabel = dagreGraph.graph(); + let graphWidth: number = WIDGET_SIZE; + if (width && height && graphIsTooBigForCanvas(width, height)) { + graphWidth = calculateGraphWidth(width, height); } - const nodeIds: string[] = dagreGraph.nodes(); + // Group nodes by their y position for level-based processing + const yLevelNodeMap: Map = groupNodesByYCoordinate( + dagreGraph.nodes(), + dagreGraph, + ); - // Group nodes by their y position - // So we can determine later if a node is the only one on its level - const yLevelNodeMap = groupNodesByYCoordinate(nodeIds, dagreGraph); + // Transform DisplayObjects into D3Nodes with fixed positions as per Dagre layout + const d3Nodes: D3Node[] = transformDisplayObjectsToD3Nodes(dagreGraph, yLevelNodeMap, graphWidth); - const displayNodes: D3Node[] = nodeIds.map((nodeId) => { - const node = dagreGraph.node(nodeId); - const nodesOnThisLevel = yLevelNodeMap.get(node.y); - const isOnlyNodeOnLevel = nodesOnThisLevel && nodesOnThisLevel.length === 1; + // Transform DisplayObjectEdges into edges that D3 can use + const d3Edges: D3Edge[] = edges.map( + (e: DisplayObjectEdge): D3Edge => ({ source: e.sourceId, target: e.targetId }), + ); + + return { d3Nodes, d3Edges, graphWidth }; +} + +function transformDisplayObjectsToD3Nodes( + dagreGraph: Dagre.graphlib.Graph, + yLevelNodeMap: Map, + graphWidth: number, +) { + return dagreGraph.nodes().map((nodeId) => { + const node: Dagre.Node = dagreGraph.node(nodeId); + const nodesOnThisLevel: D3Node[] | undefined = yLevelNodeMap.get(node.y); + const isOnlyNodeOnLevel: boolean = nodesOnThisLevel?.length === 1; return { ...node, - // We fix the nodes' vertical position to whatever dagre decided to maintain the hierarchy + // Always maintain vertical position from Dagre fy: node.y, - - // Fix the x coordinate to the center if it's the only node on this level + // Fix center horizontally if node is alone on its level. Otherwise, let d3 decide the x position. ...(isOnlyNodeOnLevel ? { fx: Math.floor(graphWidth / 2) } : {}), }; }); +} - const displayEdges: D3Edge[] = edges.map( - (e: DisplayObjectEdge): D3Edge => ({ source: e.sourceId, target: e.targetId }), +function graphIsTooBigForCanvas(width: number, height: number): boolean { + return width > WIDGET_SIZE || height > GRAPH_CANVAS_HEIGHT; +} + +function calculateGraphWidth(width: number, height: number) { + const aspectRatio: number = (1.1 * width) / height; + return aspectRatio * GRAPH_CANVAS_HEIGHT; +} + +function loadFont() { + // Load IBM Plex Mono font + // It would be nice to do this in styles.ts, but `@import` is not supported there. + addLinkToDocumentHeader('preconnect', 'https://fonts.googleapis.com'); + addLinkToDocumentHeader('preconnect', 'https://fonts.gstatic.com', 'anonymous'); + addLinkToDocumentHeader( + 'stylesheet', + 'https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,300;0,400;0,500;0,600;1,300&display=swap', ); +} - return { d3Nodes: displayNodes, d3Edges: displayEdges, graphWidth }; +function addLinkToDocumentHeader(rel: string, href: string, crossorigin?: string) { + const link = document.createElement('link'); + link.rel = rel; + link.href = href; + if (crossorigin) { + link.crossOrigin = crossorigin; + } + document.head.appendChild(link); } declare global {