Source
197 chart lines · 2 files · 5.4 kBBenchmark harness excluded · shared/mount.ts
Les Misérables character network331 records · JSON · 18.8 kB
@charts-poc/demo-data/miserablesSelection: Complete published snapshot
- nodes
- array
- links
- array
Donald Knuth, Stanford Graph Base@observablehq/sample-datasets@1.0.1 · revision 732c0148de74 · miserables.json · ISC distribution; upstream source credited · SHA-256 e84d8083667cPinned snapshot
cases/40-force-directed-network/tanstack.ts63 lines · entry
cases/40-force-directed-network/tanstack.ts
import { defineChart, dot, link, text } from '@tanstack/charts'
import { miserables } from '@charts-poc/demo-data/miserables'
import { scaleLinear } from 'd3-scale'
import { networkLayout } from './layout'
import { tanstackMount } from '../../shared/mount'
import type { ConformanceInput } from '../../types'
const colors = ['#2563eb', '#f97316', '#10b981']
const definition = (input: ConformanceInput) => {
const graph = networkLayout(miserables, input.revision)
return defineChart({
marks: [
link(graph.links, {
x1: 'x1',
y1: 'y1',
x2: 'x2',
y2: 'y2',
stroke: '#94a3b8',
strokeOpacity: 0.6,
strokeWidth: 2,
}),
dot(graph.nodes, {
x: 'x',
y: 'y',
color: 'group',
r: 7,
stroke: '#ffffff',
strokeWidth: 1.5,
}),
text(graph.nodes, {
x: 'x',
y: 'y',
text: 'id',
dy: -12,
fontSize: 10,
fontWeight: 600,
}),
],
x: {
scale: scaleLinear().domain(graph.xDomain),
},
y: {
scale: scaleLinear().domain(graph.yDomain),
},
guides: false,
color: {
range: colors,
},
})
}
export const mount = tanstackMount(
definition,
'Force-directed Les Misérables character network',
{
format: ({ datum }) =>
'group' in datum
? `${datum.id} · Group ${datum.group}`
: `${datum.source} → ${datum.target} · Value ${datum.value}`,
},
)cases/40-force-directed-network/layout.ts134 lines · support
cases/40-force-directed-network/layout.ts
import { extent } from 'd3-array'
import {
forceCenter,
forceCollide,
forceLink,
forceManyBody,
forceSimulation,
forceX,
forceY,
} from 'd3-force'
import type {
MiserablesGraph,
MiserablesLink,
MiserablesNode,
} from '@charts-poc/demo-data/miserables'
import type { SimulationLinkDatum, SimulationNodeDatum } from 'd3-force'
export interface PositionedNetworkNode extends MiserablesNode {
x: number
y: number
}
export interface PositionedNetworkLink {
source: string
target: string
value: number
x1: number
y1: number
x2: number
y2: number
}
export interface PositionedNetwork {
nodes: readonly PositionedNetworkNode[]
links: readonly PositionedNetworkLink[]
xDomain: readonly [number, number]
yDomain: readonly [number, number]
}
interface LayoutNode extends MiserablesNode, SimulationNodeDatum {}
type LayoutLink = Omit<MiserablesLink, 'source' | 'target'> &
SimulationLinkDatum<LayoutNode> & {
source: string | LayoutNode
target: string | LayoutNode
}
export function networkLayout(
source: MiserablesGraph,
revision = 0,
): PositionedNetwork {
const networkNodes = source.nodes.slice(0, 13)
const networkNodeIds = new Set(networkNodes.map((node) => node.id))
const networkEdges = source.links.filter(
(link) =>
networkNodeIds.has(link.source) && networkNodeIds.has(link.target),
)
const layoutNodes: LayoutNode[] = networkNodes.map((node) => ({ ...node }))
const layoutLinks: LayoutLink[] = networkEdges.map((edge) => ({ ...edge }))
const distanceDelta = Math.abs(revision % 2) * 3
const simulation = forceSimulation(layoutNodes)
.force(
'link',
forceLink<LayoutNode, LayoutLink>(layoutLinks)
.id((node) => node.id)
.distance((edge) => 54 - Math.min(edge.value, 10) * 1.8 + distanceDelta)
.strength((edge) => 0.2 + Math.min(edge.value, 10) * 0.045),
)
.force('charge', forceManyBody<LayoutNode>().strength(-165))
.force('center', forceCenter(0, 0))
.force('collision', forceCollide<LayoutNode>(15).strength(0.9))
.force('x', forceX<LayoutNode>(0).strength(0.035))
.force('y', forceY<LayoutNode>(0).strength(0.035))
.stop()
simulation.tick(300)
const positionedNodes = layoutNodes.map((node) => ({
id: node.id,
group: node.group,
x: coordinate(node.x, node.id, 'x'),
y: coordinate(node.y, node.id, 'y'),
}))
const positionedLinks = layoutLinks.map((edge) => {
const source = resolvedNode(edge.source, 'source')
const target = resolvedNode(edge.target, 'target')
return {
source: source.id,
target: target.id,
value: edge.value,
x1: coordinate(source.x, source.id, 'x'),
y1: coordinate(source.y, source.id, 'y'),
x2: coordinate(target.x, target.id, 'x'),
y2: coordinate(target.y, target.id, 'y'),
}
})
return {
nodes: positionedNodes,
links: positionedLinks,
xDomain: paddedDomain(positionedNodes.map((node) => node.x)),
yDomain: paddedDomain(positionedNodes.map((node) => node.y)),
}
}
function resolvedNode(
value: string | LayoutNode,
endpoint: 'source' | 'target',
): LayoutNode {
if (typeof value !== 'string') return value
throw new TypeError(`Force layout did not resolve ${endpoint} ${value}`)
}
function coordinate(
value: number | undefined,
nodeId: string,
axis: 'x' | 'y',
): number {
if (value === undefined || !Number.isFinite(value)) {
throw new TypeError(
`Force layout produced no ${axis} coordinate for ${nodeId}`,
)
}
return value
}
function paddedDomain(values: readonly number[]): [number, number] {
const [minimum, maximum] = extent(values)
if (minimum === undefined || maximum === undefined) return [-1, 1]
const span = Math.max(1, maximum - minimum)
const padding = span * 0.2
return [minimum - padding, maximum + padding]
}