Skip to main content
Projects

Tendril Knowledge Graph

Configurable, framework-agnostic knowledge graph engine with Cytoscape.js rendering, custom physics simulation, and full admin UI

February 23, 2026 Updated February 28, 2026

Tendril Knowledge Graph

Tendril is a configurable, framework-agnostic knowledge graph engine that visualizes content relationships as interactive, physics-based networks. It takes metadata from blog posts, journal entries, and documentation pages — tags, categories, related posts — and renders a navigable graph where everything connects.

Tendril is designed as a standalone library first, deeply integrated with ArgoBox second. Any Astro site (or any site that can render HTML + JS) can use it. ArgoBox extends it with a full admin UI for tweaking every parameter without touching code.

Repositories

Repo Purpose Location
tendril Standalone engine + Astro template ~/Development/tendril/ / Gitea
argobox Vendored copy + admin integration ~/Development/argobox/packages/tendril-graph/

The canonical source lives in the Tendril repo. ArgoBox vendors a copy under packages/tendril-graph/ and extends it with KV-backed admin configuration.

Package Structure

packages/tendril-graph/
├── src/
│   ├── config.js              # DEFAULT_CONFIG, deepMerge(), buildCytoscapeStyles()
│   └── config.d.ts            # Full TypeScript type declarations
├── dist/
│   ├── tendril.esm.js         # ES module bundle
│   ├── tendril.js             # UMD bundle
│   ├── tendril.bundled.esm.js # Bundled with Cytoscape (ESM)
│   └── tendril.bundled.js     # Bundled with Cytoscape (UMD)
├── package.json               # @argobox/tendril-graph
└── README.md

The package is referenced as @argobox/tendril-graph in ArgoBox's package.json via workspace linking.

Configuration System

TendrilGraphConfig

Every visual and behavioral parameter is controlled through a single typed config object. The full interface:

interface TendrilGraphConfig {
  physics?: Partial<TendrilPhysicsConfig>;
  container?: Partial<TendrilContainerConfig>;
  nodeColors?: Partial<TendrilNodeColorConfig>;
  nodeSizing?: Partial<TendrilNodeSizingConfig>;
  nodeStyle?: Partial<TendrilNodeStyleConfig>;
  spotlight?: Partial<TendrilSpotlightConfig>;
  edges?: Partial<TendrilEdgeConfig>;
  layout?: Partial<TendrilLayoutConfig>;
  behavior?: Partial<TendrilBehaviorConfig>;
}

All 9 sub-configs are optional. Anything not provided falls back to DEFAULT_CONFIG.

Sub-Config Reference

Physics (TendrilPhysicsConfig)

Parameter Default Effect
centerForce 0.12 Gravitational pull toward viewport center
repelForce 2000 Coulomb repulsion constant (inverse-square falloff)
linkForce 0.2 Hooke's law spring constant for edges
linkDistance 160 Rest length of edge springs in pixels
damping 0.82 Velocity retention per frame (lower = more friction)
enabled true Physics simulation active on load
boundingBox false Nodes expand freely without viewport clamping
settleThreshold 0.5 Velocity threshold per node for calm detection
settleFrames 300 Consecutive calm frames before auto-fit (~5s at 60fps)

Container (TendrilContainerConfig)

Parameter Default Effect
maxWidth 960 Maximum container width in pixels
aspectRatio '1 / 1' CSS aspect-ratio value
widthExpression 'min(90vw, 960px)' CSS width expression

Node Colors (TendrilNodeColorConfig)

  • typeColors: Colors for post (#3B82F6 blue), tag (#10B981 green), category (#8B5CF6 purple)
  • categoryColors: Named category colors (21 categories defined in defaults, e.g., Kubernetes: '#326CE5', Security: '#EF4444')

Node Sizing (TendrilNodeSizingConfig)

Parameter Default Effect
minSize 15 Minimum node diameter (px)
maxSize 35 Maximum node diameter (px)
tagSizeRatio 0.8 Tag node scaling relative to post nodes

Node Style (TendrilNodeStyleConfig)

Label appearance: fontSize (10px), textColor (#E2E8F0), textBgColor (#0F1219), textBgOpacity (0.85), textBgPadding (3px), borderColor (#334155), borderWidth (1px).

Spotlight (TendrilSpotlightConfig)

Controls the visual state of nodes during highlight interactions:

Sub-Config Key Properties
primary backgroundColor (#60A5FA), borderColor (#93C5FD), fontSize (12px), textColor (#F1F5F9)
neighbor backgroundColor (#FDE68A), borderColor (#FCD34D), fontSize (11px), textColor (#1E293B)
edge lineColor (#93C5FD), width (2), opacity (0.9)
faded nodeOpacity (0.25), textOpacity (0.4), bgOpacity (0.3), edgeOpacity (0.08), labelColor (#94a3b8)

Edges (TendrilEdgeConfig)

Parameter Default Effect
defaultColor rgba(148, 163, 184, 0.25) Base edge color
defaultOpacity 0.5 Base edge opacity
curveStyle 'bezier' Cytoscape curve style
defaultWidth 1 Base edge width
postTagColor rgba(148, 163, 184, 0.2) Post-to-tag edges
postPostColor rgba(99, 102, 241, 0.3) Post-to-post edges
categoryColor rgba(139, 92, 246, 0.3) Category edges

Layout (TendrilLayoutConfig)

  • initialLayout: Default layout on page load ('concentric')
  • availableLayouts: Array of layout algorithm names (force, spiral, concentric, radial, clustered, grid, seaurchin, organic, tree)

Behavior (TendrilBehaviorConfig)

Parameter Default Effect
hoverSpotlight true Highlight node + neighbors on hover
clickSpotlight true Spotlight effect on click
filterSingleUseTags true Hide tags with only 1 connection
tagHierarchy {} Map child tags to parent categories

deepMerge

The deepMerge(defaults, overrides) utility recursively merges config objects:

  • Objects are merged recursively
  • Arrays are replaced (not concatenated)
  • null and undefined values are skipped
  • Primitives are overwritten

buildCytoscapeStyles

buildCytoscapeStyles(config) converts a TendrilGraphConfig into a Cytoscape.js stylesheet array (~20 selectors) covering base node/edge styles, spotlight states (.spotlight-primary, .spotlight-neighbor, .spotlight-edge), faded states, and interaction states (.highlighted, .filtered, .grabbed).

Engine API

Constructor

import { TendrilGraph, DEFAULT_CONFIG, deepMerge } from '@argobox/tendril-graph';

const graph = new TendrilGraph('#container', {
  nodes: [...],
  edges: [...],
  config: {
    physics: { repelForce: 2500 },
    nodeColors: { categoryColors: { 'Tutorial': '#3B82F6' } },
  },
  // Optional: override auto-generated styles
  // styles: [...],
  // Optional: override individual physics (takes precedence over config.physics)
  // physics: { centerForce: 0.15 },
  onNodeClick: (node) => console.log(node),
  onSettle: () => console.log('Graph settled'),
});

Config resolution order (most specific wins):

  1. DEFAULT_CONFIG (baseline)
  2. options.config (deep-merged on top)
  3. options.physics / options.styles (direct overrides, highest priority)

If config is provided and styles is not, styles are auto-generated via buildCytoscapeStyles().

Runtime Methods

graph.setConfig({ physics: { repelForce: 3000 } });  // Live update config
graph.getConfig();                                      // Read current config

graph.setLayout('force');      // Change layout algorithm
graph.enablePhysics(true);     // Toggle physics
graph.zoom(1.2);               // Zoom in
graph.reset();                 // Reset viewport
graph.filterByType('post');    // Filter by node type

setConfig() deep-merges partial updates and rebuilds Cytoscape styles live.

Data Pipeline

Tendril builds its graph at build time from content metadata:

  1. Collect: Scans all content collections (posts, journal, docs) and extracts frontmatter: title, tags, category, related_posts, pubDate
  2. Map: Content becomes nodes. Tags become shared nodes. related_posts create explicit edges
  3. Score: Edges weighted by type — explicit links > category connections > shared tags
  4. Output: Serialized as JSON, embedded in the page for client-side rendering

Node Types

Node Type Represents Visual
post Blog post Large circle, colored by category
journal Journal entry Medium circle, muted tone
tag Content tag Small diamond, accent color
category Content category Medium hexagon, category color
doc Documentation page Small square, neutral

Edge Types

Edge Type Connects Weight
related Two posts linked via related_posts High (explicit connection)
tagged Post/journal to tag Medium
categorized Post to category Medium-high
co-tagged Two posts sharing 2+ tags Low-medium (inferred)

Astro Components

KnowledgeGraph.astro (Full Interactive)

The main graph component (3,911 lines). Accepts an optional config prop:

---
import KnowledgeGraph from '../../components/KnowledgeGraph.astro';
import { getGraphConfig } from '../../lib/graph-config';

const graphConfig = await getGraphConfig(); // Read from KV (ArgoBox)
---

<KnowledgeGraph graphData={graphData} config={graphConfig} />

Props:

  • graphData (required): { nodes, edges } from build-time processing
  • height (optional): CSS height value
  • initialFilter (optional): Default filter ('all', 'posts', 'tags')
  • config (optional): Partial<TendrilGraphConfig> — merged with defaults

When no config is passed, the component uses DEFAULT_CONFIG and produces identical output to the original hardcoded version.

Script architecture: The component uses two <script> blocks. An is:inline define:vars block receives serialized config from Astro frontmatter and stores parsed objects on window.GRAPH_CONFIG. A separate module <script> imports TendrilGraph from @argobox/tendril-graph and reads config back from window. This two-block pattern is required because define:vars (SSR data injection) and ES module import statements cannot coexist in a single script tag. See Search & Knowledge Graph — Script Architecture for the full data flow.

Features:

  • Full graph with all nodes and edges
  • Pan, zoom, drag interactions
  • Click a node to spotlight its connections — non-connected nodes dim to configurable opacity with readable labels
  • Search/filter by tag, category, or content type
  • Physics simulation with configurable forces
  • 9+ layout algorithms
  • Fullscreen mode
  • Responsive container sizing via CSS custom properties

MiniGraph.astro (Compact Sidebar)

Lightweight version for blog post pages (~1,578 lines). Shows the current post's immediate neighborhood:

  • 3-level neighborhood (current post → related posts → their connections)
  • Fullscreen mode with side-by-side layout
  • Content preview on node click
  • Same physics engine with tighter parameters

Physics Engine

Custom 4-Force Simulation

The graph uses a custom physics engine (not Cytoscape's built-in layouts) running via requestAnimationFrame:

  1. Coulomb repulsion: Nodes push apart — repelForce / distance²
  2. Hooke's law springs: Edges pull connected nodes together — linkForce × (distance - linkDistance)
  3. Center gravity: Pulls toward viewport center — centerForce × distance
  4. Velocity damping: Prevents eternal oscillation — velocity × damping per frame

Free Expansion & Settle

With boundingBox: false, nodes expand freely during the simulation without viewport clamping. After average velocity drops below settleThreshold for settleFrames consecutive frames (~5s at 60fps):

  1. cy.fit(null, 50) frames all nodes with 50px padding
  2. Physics disabled — node positions freeze
  3. onSettle callback fires

Users can re-enable physics via the toggle.

Tag Hierarchy

Tags can be grouped hierarchically (e.g., gentoo, ubuntu, fedoralinux). Configured via behavior.tagHierarchy. ArgoBox defines ~40 relationships; template users configure their own. Tags with only 1 connection are filtered by default (behavior.filterSingleUseTags).

ArgoBox Admin Integration

Admin Settings Page

/admin/graph-settings provides a full UI for configuring every parameter:

Quick Adjust (top of page): The 6 most impactful settings:

  • Container max width (slider, 400–1400px)
  • Repel force (slider, 500–5000)
  • Link distance (slider, 50–400px)
  • Faded node opacity (slider, 0–1)
  • Primary highlight color (color picker)
  • Neighbor highlight color (color picker)

5 Detailed Tabs:

Tab Controls
Physics 9 parameters: centerForce, repelForce, linkForce, linkDistance, damping, enabled, boundingBox, settleThreshold, settleFrames
Styling Node type colors (3), category palette (21), node sizing (min/max/ratio), label font/colors, edge colors (default, post-tag, post-post, category)
Spotlight Primary, neighbor, edge highlight colors and opacities. Faded state (node/text/edge opacity, label color, border)
Container Max width, aspect ratio, CSS width expression
Behavior Hover/click spotlight toggles, single-use tag filtering, initial layout selector

Changes save automatically with 500ms debounce. "Reset All" button restores factory defaults. JSON output panel shows raw config for debugging.

KV Storage

Config is stored site-wide in Cloudflare KV (not per-user). Graph appearance is consistent for all visitors.

Function Purpose
getGraphConfig() Read stored overrides (60s in-memory cache)
getResolvedGraphConfig() Defaults merged with overrides
setGraphConfig(partial) Deep-merge and store
resetGraphConfig() Delete stored overrides

KV key: data:site-config:graph

API Route

/api/admin/graph-config (admin-only):

  • GET: Returns { overrides, resolved, defaults }
  • POST: Deep-merges partial config, returns updated
  • DELETE: Resets to defaults

Config Flow

Admin UI ──POST──► /api/admin/graph-config ──► Cloudflare KV
                                                    │
                                         (next deploy)
                                                    │
blog/index.astro ──build──► getGraphConfig() ──► KnowledgeGraph
tag/[tag].astro  ──build──► getGraphConfig() ──► KnowledgeGraph

Blog and tag pages are statically generated. Config changes take effect on the next Cloudflare Pages deploy.

Key Files

File Purpose
packages/tendril-graph/src/config.js DEFAULT_CONFIG, deepMerge, buildCytoscapeStyles
packages/tendril-graph/src/config.d.ts TypeScript type declarations
packages/tendril-graph/dist/* Rollup bundles (ESM + UMD, with/without Cytoscape)
src/components/KnowledgeGraph.astro Full interactive graph component
src/components/MiniGraph.astro Compact sidebar graph
src/lib/graph-config.ts KV storage layer
src/pages/api/admin/graph-config.ts Admin REST API
src/pages/admin/graph-settings.astro Admin settings page
src/config/modules/graph-settings.ts Module manifest
scripts/sync-tendril.js ArgoBox → Tendril template sync

Compatibility

Framework Support

Tendril's core engine (@argobox/tendril-graph) is framework-agnostic. It only needs a DOM element and works anywhere JavaScript runs:

Framework How to Use
Astro Use KnowledgeGraph.astro component directly. Pass config as a prop. Full admin UI available in ArgoBox.
Next.js Import TendrilGraph in a client component. Pass config to constructor.
Nuxt / Vue Import in an onMounted hook with a ref to the container element.
SvelteKit Import in onMount. Bind container via bind:this.
React Import in a useEffect with a ref. Clean up with graph.destroy() on unmount.
Vanilla JS / HTML Import from CDN or bundle. Call new TendrilGraph('#el', { ... }).
Eleventy Use the UMD bundle in a <script> tag. Data from 11ty's data cascade.
Hugo Use the UMD bundle in a partial. Data from Hugo's data templates.

Bundle Options

Bundle File Use Case
tendril.esm.js ES module, Cytoscape external When you already have Cytoscape
tendril.js UMD, Cytoscape external Legacy / script tag, Cytoscape separate
tendril.bundled.esm.js ES module, Cytoscape included Recommended for most projects
tendril.bundled.js UMD, Cytoscape included Script tag, zero dependencies

Template

The Tendril repo includes a full Astro blog template at template/ with the graph pre-configured. Template users customize via src/config/tendril.config.ts:

import type { TendrilGraphConfig } from '../../packages/graph/src/config';

export const tendrilConfig: Partial<TendrilGraphConfig> = {
  physics: { repelForce: 2500 },
  nodeColors: { categoryColors: { 'Tutorial': '#3B82F6' } },
};

Pass it to the component: <KnowledgeGraph graphData={data} config={tendrilConfig} />

Sync Workflow

ArgoBox is the development environment. Changes flow to Tendril template via scripts/sync-tendril.js:

ArgoBox (develop) ──sync──► Tendril template (publish)

Synced files: KnowledgeGraph.astro, config.js, config.d.ts, BaseLayout.astro, BlogPost.astro

Content Metadata Requirements

For Tendril to include content in the graph, the frontmatter needs at minimum:

---
title: "Post Title"
tags:
  - at-least-one-tag
---

Optional enrichment fields:

---
category: "homelab"
related_posts:
  - "2026-01-15-build-swarm-launch"
  - "2026-02-01-argo-os-part-5"
---

Posts without tags are isolated nodes. Posts with related_posts get high-weight explicit edges.

Performance

On a graph with ~300 nodes and ~1200 edges (typical for ArgoBox):

Metric Value
Build time ~2s to compute graph data
Bundle size ~45KB graph JSON, ~85KB Cytoscape.js (gzipped)
Render time ~500ms to initial paint, ~2.5s to physics stabilization
Memory ~15MB peak during physics simulation

MiniGraph is lighter: 10–20 nodes, 15–30 edges, <200ms render.

Development

Working Locally

cd ~/Development/argobox
npm run dev
# Visit http://localhost:4321/blog to see the graph

# To work on the engine itself:
cd ~/Development/tendril/packages/graph
# Edit src/config.js, src/index.js
../../node_modules/.bin/rollup -c   # Rebuild bundles
# Copy dist/ to argobox/packages/tendril-graph/dist/

Adding New Node Types

Update graph-builder.js to include the new collection, add a type color to DEFAULT_CONFIG.nodeColors.typeColors, and rebuild.

Modifying Default Config

Edit packages/tendril-graph/src/config.js. The DEFAULT_CONFIG object is the single source of truth for all default values. After editing, rebuild the Tendril bundles and copy to ArgoBox vendor.

tendrilknowledge-graphcytoscapevisualizationcontentconfig