Skip to the content

Capsid menu

src/science/network.ts

196 lines and 20 functions in DrDustinEdwards/enarratio, at commit 4c5695a on 2026-10-24. This file on GitHub.

Functions

The functions of network.ts
FunctionLinesComplexityCoveredCRAPMutation
networkChart69 to 2101593%WatchCRAP 15.1No data: changed since the last mutation run
<anonymous:76>76 to 761100%OkCRAP 1No data: changed since the last mutation run
<anonymous:79>79 to 83583%WatchCRAP 5.1No data: changed since the last mutation run
<anonymous:86>86 to 974100%OkCRAP 4No data: changed since the last mutation run
<anonymous:102>102 to 1021100%OkCRAP 1No data: changed since the last mutation run
<anonymous:120>120 to 1202100%OkCRAP 2No data: changed since the last mutation run
<anonymous:121>121 to 1212100%OkCRAP 2No data: changed since the last mutation run
span123 to 1272100%OkCRAP 2Critical37.5%
describe129 to 1303100%OkCRAP 3Watch66.7%
x1134 to 1342100%OkCRAP 2Ok100%
y1135 to 1352100%OkCRAP 2Ok100%
x2136 to 1362100%OkCRAP 2Ok100%
y2137 to 1372100%OkCRAP 2Ok100%
x142 to 1422100%OkCRAP 2Ok100%
y143 to 1432100%OkCRAP 2Ok100%
<anonymous:149>149 to 1563100%OkCRAP 3No data: changed since the last mutation run
x162 to 1622100%OkCRAP 2Ok100%
y163 to 1632100%OkCRAP 2Ok100%
<anonymous:189>189 to 1891100%OkCRAP 1No data: changed since the last mutation run
<anonymous:203>203 to 2074100%OkCRAP 4No data: changed since the last mutation run

Imports

It imports src/render/data.ts, src/render/figure.ts, src/render/plot.ts, src/render/series.ts, src/render/size.ts. Imported by src/science/index.ts.

Source

import * as Plot from "@observablehq/plot";
import {
  forceCollide,
  forceLink,
  forceManyBody,
  forceSimulation,
  forceX,
  forceY,
  type SimulationLinkDatum,
  type SimulationNodeDatum,
} from "d3-force";
import { maxOf, minOf } from "../render/data.js";
import { type FigureOptions, figure, type SeriesColor, validateFigure } from "../render/figure.js";
import { keyed, renderPlot } from "../render/plot.js";
import { planSeries } from "../render/series.js";
import { DEFAULT_WIDTH } from "../render/size.js";

/** A node in a {@link networkChart}. */
export interface NetworkNode {
  /** Unique within the chart. */
  readonly id: string;
  /** The text shown beside the node; defaults to `id`. */
  readonly label?: string;
  /** A group the node belongs to, colored as a series and listed in the legend. */
  readonly group?: string;
}

/** A link between two nodes, by id. */
export interface NetworkLink {
  readonly source: string;
  readonly target: string;
}

/** Options for {@link networkChart}. */
export interface NetworkChartOptions extends FigureOptions {
  readonly nodes: readonly NetworkNode[];
  readonly links: readonly NetworkLink[];
  /** Every group in display and color order. Without it, first-seen order. */
  readonly groupDomain?: readonly string[];
  readonly colors?: Readonly<Record<string, SeriesColor>>;
  /** Print node labels (default true when there are 40 nodes or fewer). */
  readonly nodeLabels?: boolean;
  /** Simulation steps; more settles a large network further (default 300). */
  readonly iterations?: number;
}

interface LaidOutNode extends SimulationNodeDatum {
  readonly id: string;
  readonly label: string;
  readonly group: string;
  degree: number;
}

const KIND = "networkChart";
const RADIUS = 6;

/**
 * A force-directed network, laid out on the server with d3-force so it needs no script and draws
 * the same every time: collaborators, co-authorship, transmission clusters. Groups are colored
 * as series; the data table lists every node with its connections.
 *
 * @example
 * networkChart({
 *   nodes: [{ id: "a", group: "Lab" }, { id: "b", group: "Clinic" }],
 *   links: [{ source: "a", target: "b" }],
 *   alt: "Two collaborators ...",
 * });
 */
export function networkChart(options: NetworkChartOptions): string {
  const { nodes, links } = options;
  validateFigure(KIND, options);
  if (nodes.length === 0) throw new Error(`${KIND}: nodes is empty`);
  const width = options.width ?? DEFAULT_WIDTH;
  const height = options.height ?? 420;
  const ungrouped = "Nodes";
  const grouped = nodes.some((n) => n.group !== undefined);

  const byId = new Map<string, LaidOutNode>();
  nodes.forEach((n, i) => {
    if (n.id === "") throw new Error(`${KIND}: node ${i + 1} has an empty id`);
    if (byId.has(n.id)) throw new Error(`${KIND}: two nodes have id "${n.id}"`);
    byId.set(n.id, { id: n.id, label: n.label ?? n.id, group: n.group ?? ungrouped, degree: 0 });
  });
  const laidOut = [...byId.values()];
  const edges = links.map(
    (l, i): SimulationLinkDatum<LaidOutNode> & { a: LaidOutNode; b: LaidOutNode } => {
      const a = byId.get(l.source);
      const b = byId.get(l.target);
      if (!a || !b) {
        throw new Error(
          `${KIND}: link ${i + 1} names "${a ? l.target : l.source}", which is not a node`,
        );
      }
      a.degree += 1;
      b.degree += 1;
      return { source: a, target: b, a, b };
    },
  );

  const groupNames = options.groupDomain
    ? [...options.groupDomain]
    : [...new Set(laidOut.map((n) => n.group))];
  for (const n of laidOut) {
    if (!groupNames.includes(n.group)) {
      throw new Error(`${KIND}: group "${n.group}" is missing from groupDomain`);
    }
  }
  const plan = planSeries(KIND, groupNames, options.colors);

  // d3-force seeds its layout deterministically, so the same data always yields the same drawing.
  const simulation = forceSimulation<LaidOutNode>(laidOut)
    .force("link", forceLink<LaidOutNode, (typeof edges)[number]>(edges).distance(40))
    .force("charge", forceManyBody().strength(-90))
    .force("x", forceX(0).strength(0.06))
    .force("y", forceY(0).strength(0.06 * (width / height)))
    .force("collide", forceCollide(RADIUS + 3))
    .stop();
  simulation.tick(options.iterations ?? 300);

  const xs = laidOut.map((n) => n.x ?? 0);
  const ys = laidOut.map((n) => n.y ?? 0);
  const pad = 1;
  const span = (values: number[]): [number, number] => {
    const lo = minOf(values);
    const hi = maxOf(values);
    return lo === hi ? [lo - pad, hi + pad] : [lo, hi];
  };
  const showLabels = options.nodeLabels ?? laidOut.length <= 40;
  const describe = (n: LaidOutNode): string =>
    `${n.label}${grouped ? ` (${n.group})` : ""}: ${n.degree} connection${n.degree === 1 ? "" : "s"}`;

  const marks: Plot.Markish[] = [
    Plot.link(edges, {
      x1: (e: (typeof edges)[number]) => e.a.x ?? 0,
      y1: (e: (typeof edges)[number]) => e.a.y ?? 0,
      x2: (e: (typeof edges)[number]) => e.b.x ?? 0,
      y2: (e: (typeof edges)[number]) => e.b.y ?? 0,
      stroke: "var(--enarratio-text-muted)",
      strokeOpacity: 0.5,
    }),
    Plot.dot(laidOut, {
      x: (n: LaidOutNode) => n.x ?? 0,
      y: (n: LaidOutNode) => n.y ?? 0,
      r: RADIUS,
      fill: "group",
      stroke: "var(--enarratio-background)",
      strokeWidth: 1.5,
      title: describe,
      render: keyed(laidOut, (n) => ({
        key: n.id,
        column: laidOut.indexOf(n),
        row: 0,
        filter: grouped ? { field: "group", value: n.group } : { field: "id", value: n.id },
        x: n.label,
        ...(grouped ? { series: n.group } : {}),
      })),
    }),
  ];
  if (showLabels) {
    marks.push(
      Plot.text(laidOut, {
        x: (n: LaidOutNode) => n.x ?? 0,
        y: (n: LaidOutNode) => n.y ?? 0,
        text: "label",
        dx: RADIUS + 3,
        textAnchor: "start",
        fill: "var(--enarratio-text)",
        stroke: "var(--enarratio-background)",
        strokeWidth: 3,
        paintOrder: "stroke",
      }),
    );
  }

  const { svg } = renderPlot(
    {
      width,
      height,
      margin: 16,
      marginRight: showLabels ? 96 : 16,
      x: { domain: span(xs), axis: null },
      y: { domain: span(ys), axis: null },
      color: { domain: groupNames, range: [...plan.range] },
      marks,
    },
    options.alt,
  );

  const neighbors = new Map<string, string[]>(laidOut.map((n) => [n.id, []]));
  for (const e of edges) {
    neighbors.get(e.a.id)?.push(e.b.label);
    neighbors.get(e.b.id)?.push(e.a.label);
  }

  return figure(options, {
    kind: "network",
    svg,
    legend: grouped ? plan.legend : [],
    ...(grouped ? { seriesField: "group" } : {}),
    slotColors: plan.slotColors,
    table: {
      columns: ["Node", ...(grouped ? ["Group"] : []), "Connected to"],
      rows: laidOut.map((n) => [
        n.label,
        ...(grouped ? [n.group] : []),
        (neighbors.get(n.id) ?? []).join(", ") || "none",
      ]),
    },
  });
}