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 Arcturus-Prime second. Any Astro site (or any site that can render HTML + JS) can use it. Arcturus-Prime extends it with a full admin UI for tweaking every parameter without touching code.

Repositories

RepoPurposeLocation
tendrilStandalone engine + Astro template~/Development/tendril/ / Gitea
Arcturus-PrimeVendored copy + admin integration~/Development/Arcturus-Prime/packages/tendril-graph/

The canonical source lives in the Tendril repo. Arcturus-Prime 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
│   ├── graph-builder.js       # Builds graph data from content metadata
│   ├── graph-renderer.js      # Cytoscape.js rendering logic
│   ├── layout-engine.js       # Physics simulation parameters
│   └── types.ts               # Legacy TypeScript definitions
├── 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               # @tendril/graph
└── README.md

The package is referenced as @tendril/graph in Arcturus-Prime’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)

ParameterDefaultEffect
centerForce0.12Gravitational pull toward viewport center
repelForce2000Coulomb repulsion constant (inverse-square falloff)
linkForce0.2Hooke’s law spring constant for edges
linkDistance160Rest length of edge springs in pixels
damping0.82Velocity retention per frame (lower = more friction)
enabledtruePhysics simulation active on load
boundingBoxfalseNodes expand freely without viewport clamping
settleThreshold0.5Velocity threshold per node for calm detection
settleFrames300Consecutive calm frames before auto-fit (~5s at 60fps)

Container (TendrilContainerConfig)

ParameterDefaultEffect
maxWidth960Maximum 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)

ParameterDefaultEffect
minSize15Minimum node diameter (px)
maxSize35Maximum node diameter (px)
tagSizeRatio0.8Tag 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-ConfigKey Properties
primarybackgroundColor (#60A5FA), borderColor (#93C5FD), fontSize (12px), textColor (#F1F5F9)
neighborbackgroundColor (#FDE68A), borderColor (#FCD34D), fontSize (11px), textColor (#1E293B)
edgelineColor (#93C5FD), width (2), opacity (0.9)
fadednodeOpacity (0.25), textOpacity (0.4), bgOpacity (0.3), edgeOpacity (0.08), labelColor (#94a3b8)

Edges (TendrilEdgeConfig)

ParameterDefaultEffect
defaultColorrgba(148, 163, 184, 0.25)Base edge color
defaultOpacity0.5Base edge opacity
curveStyle'bezier'Cytoscape curve style
defaultWidth1Base edge width
postTagColorrgba(148, 163, 184, 0.2)Post-to-tag edges
postPostColorrgba(99, 102, 241, 0.3)Post-to-post edges
categoryColorrgba(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)

ParameterDefaultEffect
hoverSpotlighttrueHighlight node + neighbors on hover
clickSpotlighttrueSpotlight effect on click
filterSingleUseTagstrueHide 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 '@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 TypeRepresentsVisual
postBlog postLarge circle, colored by category
journalJournal entryMedium circle, muted tone
tagContent tagSmall diamond, accent color
categoryContent categoryMedium hexagon, category color
docDocumentation pageSmall square, neutral

Edge Types

Edge TypeConnectsWeight
relatedTwo posts linked via related_postsHigh (explicit connection)
taggedPost/journal to tagMedium
categorizedPost to categoryMedium-high
co-taggedTwo posts sharing 2+ tagsLow-medium (inferred)

Astro Components

KnowledgeGraph.astro (Full Interactive)

The main graph component (~3,360 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 (Arcturus-Prime)
---

<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 @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. Arcturus-Prime defines ~40 relationships; template users configure their own. Tags with only 1 connection are filtered by default (behavior.filterSingleUseTags).

Arcturus-Prime 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:

TabControls
Physics9 parameters: centerForce, repelForce, linkForce, linkDistance, damping, enabled, boundingBox, settleThreshold, settleFrames
StylingNode type colors (3), category palette (21), node sizing (min/max/ratio), label font/colors, edge colors (default, post-tag, post-post, category)
SpotlightPrimary, neighbor, edge highlight colors and opacities. Faded state (node/text/edge opacity, label color, border)
ContainerMax width, aspect ratio, CSS width expression
BehaviorHover/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.

FunctionPurpose
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

FilePurpose
packages/tendril-graph/src/config.jsDEFAULT_CONFIG, deepMerge, buildCytoscapeStyles
packages/tendril-graph/src/config.d.tsTypeScript type declarations
packages/tendril-graph/dist/*Rollup bundles (ESM + UMD, with/without Cytoscape)
src/components/KnowledgeGraph.astroFull interactive graph component
src/components/MiniGraph.astroCompact sidebar graph
src/lib/graph-config.tsKV storage layer
src/pages/api/admin/graph-config.tsAdmin REST API
src/pages/admin/graph-settings.astroAdmin settings page
src/config/modules/graph-settings.tsModule manifest
scripts/sync-tendril.jsArcturus-Prime → Tendril template sync

Compatibility

Framework Support

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

FrameworkHow to Use
AstroUse KnowledgeGraph.astro component directly. Pass config as a prop. Full admin UI available in Arcturus-Prime.
Next.jsImport TendrilGraph in a client component. Pass config to constructor.
Nuxt / VueImport in an onMounted hook with a ref to the container element.
SvelteKitImport in onMount. Bind container via bind:this.
ReactImport in a useEffect with a ref. Clean up with graph.destroy() on unmount.
Vanilla JS / HTMLImport from CDN or bundle. Call new TendrilGraph('#el', { ... }).
EleventyUse the UMD bundle in a <script> tag. Data from 11ty’s data cascade.
HugoUse the UMD bundle in a partial. Data from Hugo’s data templates.

Bundle Options

BundleFileUse Case
tendril.esm.jsES module, Cytoscape externalWhen you already have Cytoscape
tendril.jsUMD, Cytoscape externalLegacy / script tag, Cytoscape separate
tendril.bundled.esm.jsES module, Cytoscape includedRecommended for most projects
tendril.bundled.jsUMD, Cytoscape includedScript 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

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

Arcturus-Prime (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 Arcturus-Prime):

MetricValue
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/Arcturus-Prime
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 Arcturus-Prime/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 Arcturus-Prime vendor.

tendrilknowledge-graphcytoscapevisualizationcontentconfig