Tendril Knowledge Graph
Configurable, framework-agnostic knowledge graph engine with Cytoscape.js rendering, custom physics simulation, and full admin UI
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)
nullandundefinedvalues 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):
DEFAULT_CONFIG(baseline)options.config(deep-merged on top)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:
- Collect: Scans all content collections (posts, journal, docs) and extracts frontmatter:
title,tags,category,related_posts,pubDate - Map: Content becomes nodes. Tags become shared nodes.
related_postscreate explicit edges - Score: Edges weighted by type — explicit links > category connections > shared tags
- 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 processingheight(optional): CSS height valueinitialFilter(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:
- Coulomb repulsion: Nodes push apart —
repelForce / distance² - Hooke's law springs: Edges pull connected nodes together —
linkForce × (distance - linkDistance) - Center gravity: Pulls toward viewport center —
centerForce × distance - Velocity damping: Prevents eternal oscillation —
velocity × dampingper 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):
cy.fit(null, 50)frames all nodes with 50px padding- Physics disabled — node positions freeze
onSettlecallback fires
Users can re-enable physics via the toggle.
Tag Hierarchy
Tags can be grouped hierarchically (e.g., gentoo, ubuntu, fedora → linux). 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.