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/miserables

Selection: 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]
}