In the past I wrote a bunch of posts that explained how to integrate D3 library and React. In this post I’ll do the same but this time I’ll show you how to integrate D3 in Qwik apps. We will build together a D3 tree and host it inside a basic Qwik app.
Are you ready?
Things to do before you start adding the suggested code:
Create a new Qwik app using the npm create qwik@latest .
Add a new route to your app, which will be used to show the D3 graph.
Creating a Qwik D3 Container Component
First you are going to add a new TreeContainer component. This component will be responsible to hold the D3 container, which will be used to create the graph.
Let’s look at the code:
import { component$, useSignal, useVisibleTask$ } from "@builder.io/qwik"; | |
import { createGraph } from "~/components/tree-container/tree-generator"; | |
import { type tSymbol } from "~/components/tree-container/types"; | |
import styles from "./tree-container.module.css"; | |
type GraphContainerProps = { | |
symbols: tSymbol[]; | |
} | |
export const TreeContainer = component$(({ symbols }: GraphContainerProps) => { | |
const containerRef = useSignal<HTMLDivElement>(); | |
useVisibleTask$(({ cleanup }) => { | |
const { destroy } = createGraph(containerRef.value, symbols); | |
cleanup(() => destroy()); | |
}); | |
return ( | |
<div ref={containerRef} class={styles.container} /> | |
); | |
}); |
The TreeContainer is going to be the div that D3 will use to create the graph. You use a signal to reference the rendered div , so you will be able to use it’s in-memory Html element representation later on.
Another thing that you can notice is the usage of useVisibileTask$. Since D3 runs only in the client, you should use the useVisibileTask$ hook that makes sure that the code which is responsible to creates the graph runs after the component’s client rendering.
Building the Graph Generator
Once the container is ready it’s time to write the createGraph function.
Here is the full function code:
import * as d3 from "d3"; | |
import { type tSymbol, type Node } from "~/components/tree-container/types"; | |
function processSymbols(symbols: tSymbol[]) { | |
const map = new Map<string, Node>(); | |
const result: Node = { | |
name: 'root', | |
children: [] | |
}; | |
symbols.forEach((symbol) => { | |
const node = { | |
id: symbol.id, | |
children: [], | |
name: symbol.symbol, | |
value: `load: ${symbol.load}, delay time: ${symbol.delayTime}` | |
} | |
if (!map.has(symbol.previousSymbol)) { | |
map.set(symbol.symbol, node); | |
} else { | |
const prev = map.get(symbol.previousSymbol); | |
if (prev) { | |
prev.children.push(node); | |
map.set(symbol.previousSymbol, prev); | |
} | |
} | |
}); | |
for (const value of map.values()){ | |
result.children.push(value); | |
} | |
return result; | |
} | |
export function createGraph( | |
container: HTMLDivElement | undefined, | |
symbols: tSymbol[], | |
) { | |
if (!container) { | |
return { | |
destroy: () => {} | |
}; | |
} | |
const data = processSymbols(symbols); | |
const { width } = container.getBoundingClientRect(); | |
const root = d3.hierarchy(data); | |
const dx = 10; | |
const dy = width / (root.height + 1); | |
// Create a tree layout. | |
const tree = d3.tree().nodeSize([dx, dy]); | |
// Sort the tree and apply the layout. | |
root.sort((a, b) => d3.ascending(a.data.name, b.data.name)); | |
tree(root); | |
// Compute the extent of the tree. Note that x and y are swapped here | |
// because in the tree layout, x is the breadth, but when displayed, the | |
// tree extends right rather than down. | |
let x0 = Infinity; | |
let x1 = -x0; | |
root.each(d => { | |
if (d.x > x1) x1 = d.x; | |
if (d.x < x0) x0 = d.x; | |
}); | |
// Compute the adjusted height of the tree. | |
const height = x1 - x0 + dx * 2; | |
const svg = d3 | |
.select(container) | |
.append("svg") | |
.attr("width", width) | |
.attr("height", height) | |
.attr("viewBox", [-dy / 3, x0 - dx, width, height]) | |
.attr("style", "max-width: 100%; height: auto; font: 10px sans-serif;"); | |
const link = svg.append("g") | |
.attr("fill", "none") | |
.attr("stroke", "#555") | |
.attr("stroke-opacity", 0.4) | |
.attr("stroke-width", 1.5) | |
.selectAll() | |
.data(root.links()) | |
.join("path") | |
.attr("d", d3.linkHorizontal() | |
.x(d => d.y) | |
.y(d => d.x)); | |
const node = svg.append("g") | |
.attr("stroke-linejoin", "round") | |
.attr("stroke-width", 3) | |
.selectAll() | |
.data(root.descendants()) | |
.join("g") | |
.attr("transform", d => `translate(${d.y},${d.x})`); | |
node.append("circle") | |
.attr("fill", d => d.children ? "#555" : "#999") | |
.attr("r", 2.5); | |
node.append("text") | |
.attr("dy", "0.31em") | |
.attr("x", d => d.children ? -6 : 6) | |
.attr("text-anchor", d => d.children ? "end" : "start") | |
.text(d => d.data.name) | |
.clone(true).lower() | |
.attr("stroke", "white"); | |
return { | |
destroy: () => { | |
console.log('clean up!'); | |
}, | |
node: svg.node() | |
}; | |
} |
Let’s analyze the code to understand what is going on. We will start in the beginning with the processSymbols function.
function processSymbols(symbols: tSymbol[]) { | |
const map = new Map<string, Node>(); | |
const result: Node = { | |
name: 'root', | |
children: [] | |
}; | |
symbols.forEach((symbol) => { | |
const node = { | |
id: symbol.id, | |
children: [], | |
name: symbol.symbol, | |
value: `load: ${symbol.load}, delay time: ${symbol.delayTime}` | |
} | |
if (!map.has(symbol.previousSymbol)) { | |
map.set(symbol.symbol, node); | |
} else { | |
const prev = map.get(symbol.previousSymbol); | |
if (prev) { | |
prev.children.push(node); | |
map.set(symbol.previousSymbol, prev); | |
} | |
} | |
}); | |
for (const value of map.values()){ | |
result.children.push(value); | |
} | |
return result; | |
} |
Sometimes we have a model that doesn’t fit into the way the D3 graphs expect their data. The processSymbols function will transform the symbols array to the needed graph representation.
At the start of the createGraph function you set the d3 tree in-memory implementation. This is happening in these lines of code:
const root = d3.hierarchy(data); | |
const dx = 10; | |
const dy = width / (root.height + 1); | |
// Create a tree layout. | |
const tree = d3.tree().nodeSize([dx, dy]); | |
// Sort the tree and apply the layout. | |
root.sort((a, b) => d3.ascending(a.data.name, b.data.name)); | |
tree(root); | |
// Compute the extent of the tree. Note that x and y are swapped here | |
// because in the tree layout, x is the breadth, but when displayed, the | |
// tree extends right rather than down. | |
let x0 = Infinity; | |
let x1 = -x0; | |
root.each(d => { | |
if (d.x > x1) x1 = d.x; | |
if (d.x < x0) x0 = d.x; | |
}); | |
// Compute the adjusted height of the tree. | |
const height = x1 - x0 + dx * 2; |
Then, you create the tree visual representation using D3 API that creates SVG elements such as paths, circles, text and more:
const svg = d3 | |
.select(container) | |
.append("svg") | |
.attr("width", width) | |
.attr("height", height) | |
.attr("viewBox", [-dy / 3, x0 - dx, width, height]) | |
.attr("style", "max-width: 100%; height: auto; font: 10px sans-serif;"); | |
const link = svg.append("g") | |
.attr("fill", "none") | |
.attr("stroke", "#555") | |
.attr("stroke-opacity", 0.4) | |
.attr("stroke-width", 1.5) | |
.selectAll() | |
.data(root.links()) | |
.join("path") | |
.attr("d", d3.linkHorizontal() | |
.x(d => d.y) | |
.y(d => d.x)); | |
const node = svg.append("g") | |
.attr("stroke-linejoin", "round") | |
.attr("stroke-width", 3) | |
.selectAll() | |
.data(root.descendants()) | |
.join("g") | |
.attr("transform", d => `translate(${d.y},${d.x})`); | |
node.append("circle") | |
.attr("fill", d => d.children ? "#555" : "#999") | |
.attr("r", 2.5); | |
node.append("text") | |
.attr("dy", "0.31em") | |
.attr("x", d => d.children ? -6 : 6) | |
.attr("text-anchor", d => d.children ? "end" : "start") | |
.text(d => d.data.name) | |
.clone(true).lower() | |
.attr("stroke", "white"); |
The data that is being used in the example is the following symbols array:
export const list: tSymbol[] = [ | |
{ | |
id: 1, | |
sessionId: 'lmlv6nkqbhe', | |
symbol: '_hW', | |
previousSymbol: '', | |
load: 384, | |
delayTime: 928, | |
deltaInteraction: true | |
}, | |
{ | |
id: 2, | |
sessionId: 'lmlv6nkqbhe', | |
symbol: 'Header_component_useVisibleTask_9t1uPE4yoLA', | |
previousSymbol: '_hW', | |
load: 0, | |
delayTime: 34, | |
deltaInteraction: false | |
}, | |
{ | |
id: 3, | |
sessionId: 'lmlv6nkqbhe', | |
symbol: 'DocSearch_component_div_window_onKeyDown_uCl5Lf0Typ8', | |
previousSymbol: '', | |
load: 36, | |
delayTime: 1568, | |
deltaInteraction: true | |
}, | |
{ | |
id: 4, | |
sessionId: 'lmlv6nkqbhe', | |
symbol: 'Header_component__Fragment_header_div_button_onClick_S0wV0vUzzSo', | |
previousSymbol: '', | |
load: 24, | |
delayTime: 80941, | |
deltaInteraction: true | |
}, | |
{ | |
id: 5, | |
sessionId: 'lmlv6nkqbhe', | |
symbol: 'DocSearch_component_div_DocSearchButton_onClick_I5CyQjO9FjQ', | |
previousSymbol: '', | |
load: 11, | |
delayTime: 2893, | |
deltaInteraction: true | |
}, | |
{ | |
id: 6, | |
sessionId: 'lmlv6nkqbhe', | |
symbol: 'DocSearch_component_NsnidK2eXPg', | |
previousSymbol: 'DocSearch_component_div_DocSearchButton_onClick_I5CyQjO9FjQ', | |
load: 47, | |
delayTime: 1, | |
deltaInteraction: false | |
}, | |
{ | |
id: 7, | |
sessionId: 'lmlv6nkqbhe', | |
symbol: 'DocSearchModal_component_kDw0latGeM0', | |
previousSymbol: 'DocSearch_component_NsnidK2eXPg', | |
load: 299, | |
delayTime: 4, | |
deltaInteraction: false | |
}, | |
{ | |
id: 8, | |
sessionId: 'lmlv6nkqbhe', | |
symbol: 'SearchBox_component_7YcOLMha9lM', | |
previousSymbol: 'DocSearchModal_component_kDw0latGeM0', | |
load: 398, | |
delayTime: 2, | |
deltaInteraction: false | |
}, | |
{ | |
id: 9, | |
sessionId: 'lmlv6nkqbhe', | |
symbol: 'ScreenState_component_Ly5oFWTkofs', | |
previousSymbol: 'SearchBox_component_7YcOLMha9lM', | |
load: 408, | |
delayTime: -397, | |
deltaInteraction: false | |
}, | |
{ | |
id: 10, | |
sessionId: 'lmlv6nkqbhe', | |
symbol: 'AIButton_component_NCpn2iO0Vo0', | |
previousSymbol: 'ScreenState_component_Ly5oFWTkofs', | |
load: 738, | |
delayTime: -409, | |
deltaInteraction: false | |
}, | |
]; |
After you wire everything you can run the app to see the following graph on your screen:
Summary
In the post I showed you how easy it is to integrate D3 in Qwik apps. The post guided you how to create the graph container and then how to create the graph by using D3 functionality.
You can take a look at the whole example in the qwik-d3-example repository.
Top comments (0)