{"spec_id":"network-basic","library":"echarts","language":"javascript","code":"// anyplot.ai\n// network-basic: Basic Network Graph\n// Library: echarts 6.1.0 | JavaScript 22.23.1\n// Quality: 91/100 | Created: 2026-07-24\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: microservice dependency graph (in-memory, deterministic) --------\nconst GROUP_NAMES = [\"Frontend\", \"Backend\", \"Data\", \"Infra\"];\n\nconst NODES = [\n  { name: \"WebApp\", group: 0 },\n  { name: \"MobileApp\", group: 0 },\n  { name: \"AdminUI\", group: 0 },\n  { name: \"APIGateway\", group: 1 },\n  { name: \"AuthService\", group: 1 },\n  { name: \"OrderService\", group: 1 },\n  { name: \"PaymentService\", group: 1 },\n  { name: \"SearchService\", group: 1 },\n  { name: \"UserService\", group: 1 },\n  { name: \"PrimaryDB\", group: 2 },\n  { name: \"CacheStore\", group: 2 },\n  { name: \"SearchIndex\", group: 2 },\n  { name: \"MessageQueue\", group: 2 },\n  { name: \"DataWarehouse\", group: 2 },\n  { name: \"LoadBalancer\", group: 3 },\n  { name: \"CDN\", group: 3 },\n  { name: \"ConfigServer\", group: 3 },\n  { name: \"MetricsCollector\", group: 3 },\n];\n\nconst EDGES = [\n  [\"WebApp\", \"APIGateway\"],\n  [\"WebApp\", \"AuthService\"],\n  [\"MobileApp\", \"APIGateway\"],\n  [\"MobileApp\", \"AuthService\"],\n  [\"AdminUI\", \"APIGateway\"],\n  [\"AdminUI\", \"UserService\"],\n  [\"APIGateway\", \"AuthService\"],\n  [\"APIGateway\", \"OrderService\"],\n  [\"APIGateway\", \"PaymentService\"],\n  [\"APIGateway\", \"SearchService\"],\n  [\"APIGateway\", \"UserService\"],\n  [\"OrderService\", \"PaymentService\"],\n  [\"UserService\", \"AuthService\"],\n  [\"AuthService\", \"PrimaryDB\"],\n  [\"AuthService\", \"CacheStore\"],\n  [\"OrderService\", \"PrimaryDB\"],\n  [\"OrderService\", \"MessageQueue\"],\n  [\"PaymentService\", \"PrimaryDB\"],\n  [\"PaymentService\", \"MessageQueue\"],\n  [\"SearchService\", \"SearchIndex\"],\n  [\"UserService\", \"PrimaryDB\"],\n  [\"UserService\", \"CacheStore\"],\n  [\"MessageQueue\", \"DataWarehouse\"],\n  [\"PrimaryDB\", \"DataWarehouse\"],\n  [\"LoadBalancer\", \"APIGateway\"],\n  [\"LoadBalancer\", \"WebApp\"],\n  [\"CDN\", \"WebApp\"],\n  [\"ConfigServer\", \"APIGateway\"],\n  [\"MetricsCollector\", \"APIGateway\"],\n  [\"MetricsCollector\", \"PrimaryDB\"],\n];\n\n// Degree = number of connections per node (drives node size below).\nconst degree = Object.fromEntries(NODES.map((n) => [n.name, 0]));\nEDGES.forEach(([a, b]) => {\n  degree[a] += 1;\n  degree[b] += 1;\n});\n\n// --- Deterministic force-directed layout (Fruchterman-Reingold, fixed-seed) -\n// echarts' own layout:'force' runs a live physics simulation that keeps\n// nudging positions frame to frame — not reproducible and not guaranteed to\n// have settled by screenshot time. Precomputing a fixed-seed layout here\n// keeps both themes pixel-identical and renders instantly.\nfunction lcg(seed) {\n  let state = seed;\n  return () => {\n    state = (state * 1103515245 + 12345) % 2147483648;\n    return state / 2147483648;\n  };\n}\nconst rand = lcg(42);\n\nconst LAYOUT_W = 1200;\nconst LAYOUT_H = 750;\nconst positions = {};\nNODES.forEach((n) => {\n  positions[n.name] = { x: rand() * LAYOUT_W, y: rand() * LAYOUT_H };\n});\n\nconst k = Math.sqrt((LAYOUT_W * LAYOUT_H) / NODES.length);\nlet temperature = LAYOUT_W / 10;\n\nfor (let iter = 0; iter < 400; iter++) {\n  const disp = Object.fromEntries(NODES.map((n) => [n.name, { x: 0, y: 0 }]));\n\n  for (let i = 0; i < NODES.length; i++) {\n    for (let j = i + 1; j < NODES.length; j++) {\n      const a = NODES[i].name;\n      const b = NODES[j].name;\n      let dx = positions[a].x - positions[b].x;\n      let dy = positions[a].y - positions[b].y;\n      const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;\n      const repulsion = (k * k) / dist;\n      dx = (dx / dist) * repulsion;\n      dy = (dy / dist) * repulsion;\n      disp[a].x += dx;\n      disp[a].y += dy;\n      disp[b].x -= dx;\n      disp[b].y -= dy;\n    }\n  }\n\n  EDGES.forEach(([a, b]) => {\n    let dx = positions[a].x - positions[b].x;\n    let dy = positions[a].y - positions[b].y;\n    const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;\n    const attraction = (dist * dist) / k;\n    dx = (dx / dist) * attraction;\n    dy = (dy / dist) * attraction;\n    disp[a].x -= dx;\n    disp[a].y -= dy;\n    disp[b].x += dx;\n    disp[b].y += dy;\n  });\n\n  NODES.forEach((n) => {\n    const d = disp[n.name];\n    const len = Math.sqrt(d.x * d.x + d.y * d.y) || 0.01;\n    const capped = Math.min(len, temperature);\n    const p = positions[n.name];\n    p.x = Math.min(LAYOUT_W, Math.max(0, p.x + (d.x / len) * capped));\n    p.y = Math.min(LAYOUT_H, Math.max(0, p.y + (d.y / len) * capped));\n  });\n\n  temperature *= 0.995;\n}\n\n// Rescale the settled layout inward so the outermost nodes never sit flush\n// against the drawable-area edge — leaves room for their node radius and\n// (for the rightmost nodes) their label text.\nconst EDGE_PAD = 90;\nlet minX = Infinity;\nlet maxX = -Infinity;\nlet minY = Infinity;\nlet maxY = -Infinity;\nNODES.forEach((n) => {\n  const p = positions[n.name];\n  minX = Math.min(minX, p.x);\n  maxX = Math.max(maxX, p.x);\n  minY = Math.min(minY, p.y);\n  maxY = Math.max(maxY, p.y);\n});\nNODES.forEach((n) => {\n  const p = positions[n.name];\n  p.x = EDGE_PAD + ((p.x - minX) / (maxX - minX)) * (LAYOUT_W - 2 * EDGE_PAD);\n  p.y = EDGE_PAD + ((p.y - minY) / (maxY - minY)) * (LAYOUT_H - 2 * EDGE_PAD);\n});\n\n// Node size encodes degree (number of connections).\nconst degrees = Object.values(degree);\nconst minDeg = Math.min(...degrees);\nconst maxDeg = Math.max(...degrees);\nconst MIN_SIZE = 26;\nconst MAX_SIZE = 78;\nfunction sizeForDegree(d) {\n  if (maxDeg === minDeg) return (MIN_SIZE + MAX_SIZE) / 2;\n  return MIN_SIZE + ((d - minDeg) / (maxDeg - minDeg)) * (MAX_SIZE - MIN_SIZE);\n}\n\n// Labels default to the node's right side; the rightmost nodes flip their\n// label to the left so the text stays inside the canvas instead of running\n// off the right edge.\nconst LABEL_FLIP_X = LAYOUT_W * 0.74;\nconst labelSide = {};\nNODES.forEach((n) => {\n  labelSide[n.name] = positions[n.name].x > LABEL_FLIP_X ? \"left\" : \"right\";\n});\n\n// Second pass: two same-side labels on vertically close nodes can land close\n// enough horizontally that their background chips touch with no gap (reads\n// as one run-on phrase). echarts' graph \"view\" coordinate system stretches\n// our layout-space x/y independently (non-uniform, no aspect preservation)\n// to fill the series' left/right/top/bottom box, so the collision check\n// below re-derives real screen-pixel positions the same way before\n// estimating label chip spans — checking in raw layout units would miss\n// collisions the horizontal stretch factor closes up.\nconst GRID_LEFT = 70;\nconst GRID_RIGHT = 110;\nconst GRID_TOP = 150;\nconst GRID_BOTTOM = 60;\nconst DATA_W = LAYOUT_W - 2 * EDGE_PAD;\nconst DATA_H = LAYOUT_H - 2 * EDGE_PAD;\nconst drawW = window.ANYPLOT_SIZE.width - GRID_LEFT - GRID_RIGHT;\nconst drawH = window.ANYPLOT_SIZE.height - GRID_TOP - GRID_BOTTOM;\nconst SCALE_X = drawW / DATA_W;\nconst SCALE_Y = drawH / DATA_H;\nfunction screenX(x) {\n  return GRID_LEFT + (x - EDGE_PAD) * SCALE_X;\n}\nfunction screenY(y) {\n  return GRID_TOP + (y - EDGE_PAD) * SCALE_Y;\n}\n\nconst LABEL_DISTANCE = 6;\nconst LABEL_CHAR_W = 7.2;\nconst LABEL_PAD_X = 10;\nconst LABEL_ROW_GAP = 18;\n// Require a real visible gap between two chips, not just non-overlap — a\n// few px of clearance still reads as \"touching\" once anti-aliasing and the\n// chip's rounded corners are rendered.\nconst LABEL_MIN_GAP = 18;\nfunction labelSpan(n, side) {\n  const sx = screenX(positions[n.name].x);\n  const half = sizeForDegree(degree[n.name]) / 2;\n  const width = n.name.length * LABEL_CHAR_W + LABEL_PAD_X;\n  if (side === \"right\") {\n    const start = sx + half + LABEL_DISTANCE;\n    return { start, end: start + width };\n  }\n  const end = sx - half - LABEL_DISTANCE;\n  return { start: end - width, end };\n}\nfor (let i = 0; i < NODES.length; i++) {\n  for (let j = i + 1; j < NODES.length; j++) {\n    const a = NODES[i];\n    const b = NODES[j];\n    if (\n      Math.abs(screenY(positions[a.name].y) - screenY(positions[b.name].y)) >\n      LABEL_ROW_GAP\n    )\n      continue;\n    const spanA = labelSpan(a, labelSide[a.name]);\n    const spanB = labelSpan(b, labelSide[b.name]);\n    if (\n      spanA.start >= spanB.end + LABEL_MIN_GAP ||\n      spanB.start >= spanA.end + LABEL_MIN_GAP\n    )\n      continue;\n    // A collision here is most often two nearby nodes whose labels point\n    // *toward* each other into the same gap (e.g. the left node flipped\n    // \"right\" by the canvas-edge rule while the right node is naturally\n    // \"left\"). Point both labels away from each other instead — the\n    // leftward node's label goes left, the rightward node's label goes\n    // right — which is also the direction that opens the most free space.\n    const [leftNode, rightNode] =\n      positions[a.name].x <= positions[b.name].x ? [a, b] : [b, a];\n    labelSide[leftNode.name] = \"left\";\n    labelSide[rightNode.name] = \"right\";\n  }\n}\n\nconst graphNodes = NODES.map((n) => ({\n  name: n.name,\n  category: n.group,\n  x: positions[n.name].x,\n  y: positions[n.name].y,\n  symbolSize: sizeForDegree(degree[n.name]),\n  value: degree[n.name],\n  label: {\n    position: labelSide[n.name],\n  },\n}));\n\nconst graphEdges = EDGES.map(([source, target]) => ({ source, target }));\nconst categories = GROUP_NAMES.map((name) => ({ name }));\n\n// --- Init --------------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\nchart.on(\"finished\", () => {\n  window.__anyplotReady = true;\n});\n\n// --- Option --------------------------------------------------------------------\nconst title =\n  \"Microservice Dependency Graph · network-basic · javascript · echarts · anyplot.ai\";\nconst titleFontSize =\n  title.length > 67 ? Math.round(22 * (67 / title.length)) : 22;\n\nchart.setOption({\n  animation: false,\n  color: t.palette,\n  backgroundColor: \"transparent\",\n  title: {\n    text: title,\n    left: \"center\",\n    top: 24,\n    textStyle: { color: t.ink, fontSize: titleFontSize, fontWeight: 500 },\n  },\n  legend: {\n    data: GROUP_NAMES,\n    orient: \"horizontal\",\n    left: \"center\",\n    top: 66,\n    icon: \"circle\",\n    itemWidth: 12,\n    itemHeight: 12,\n    itemGap: 32,\n    textStyle: { color: t.inkSoft, fontSize: 16 },\n  },\n  tooltip: {\n    formatter: (params) =>\n      params.dataType === \"edge\"\n        ? `${params.data.source} → ${params.data.target}`\n        : `${params.data.name}<br/>connections: ${params.data.value}`,\n  },\n  series: [\n    {\n      type: \"graph\",\n      layout: \"none\",\n      left: GRID_LEFT,\n      right: GRID_RIGHT,\n      top: GRID_TOP,\n      bottom: GRID_BOTTOM,\n      roam: false,\n      draggable: false,\n      symbol: \"circle\",\n      categories,\n      label: {\n        show: true,\n        distance: LABEL_DISTANCE,\n        color: t.inkSoft,\n        fontSize: 14,\n        // Solid page-bg chip keeps labels legible where an edge crosses\n        // directly behind the text (e.g. two adjacent connected nodes).\n        backgroundColor: t.pageBg,\n        padding: [2, 5],\n        borderRadius: 3,\n      },\n      edgeSymbol: [\"none\", \"none\"],\n      lineStyle: {\n        color: t.inkSoft,\n        opacity: 0.35,\n        width: 1.6,\n        curveness: 0,\n      },\n      itemStyle: {\n        borderColor: t.pageBg,\n        borderWidth: 2,\n      },\n      emphasis: { disabled: true },\n      data: graphNodes,\n      links: graphEdges,\n    },\n  ],\n});\n"}