Skip to content

Instantly share code, notes, and snippets.

@unforced
Created February 15, 2026 17:30
Show Gist options
  • Select an option

  • Save unforced/1c5aab586640644961c6ae1b5870db39 to your computer and use it in GitHub Desktop.

Select an option

Save unforced/1c5aab586640644961c6ae1b5870db39 to your computer and use it in GitHub Desktop.
Bonfires Knowledge Graph Experiments — EthBoulder 2026. Live dashboard + interactive explorer for the Delve API.
#!/usr/bin/env python3
"""Bonfires Knowledge Ingester — CLI tool to pipe content into the Bonfires knowledge graph.
Usage:
python ingest.py text "some content" --source "live-notes"
python ingest.py file ./notes.md --source "session-notes"
echo "content" | python ingest.py stdin --source "pipe"
python ingest.py triple "Subject" "predicate" "Object"
python ingest.py search "query string"
python ingest.py conversation ./chat-log.txt --source "discord-export"
Requires Python 3.10+, no external dependencies.
"""
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
# ---------------------------------------------------------------------------
# Configuration — override with environment variables if needed
# ---------------------------------------------------------------------------
API_KEY = os.environ.get("DELVE_API_KEY", "8n5l-sJnrHjywrTnJ3rJCjo1f1uLyTPYy_yLgq_bf-d")
BONFIRE_ID = os.environ.get("BONFIRE_ID", "698b70002849d936f4259848")
BASE_URL = os.environ.get("DELVE_BASE_URL", "https://tnt-v2.api.bonfires.ai")
# ---------------------------------------------------------------------------
# ANSI colours
# ---------------------------------------------------------------------------
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
CYAN = "\033[36m"
def c(text: str, color: str) -> str:
"""Wrap *text* in an ANSI colour code."""
return f"{color}{text}{RESET}"
# ---------------------------------------------------------------------------
# Banner
# ---------------------------------------------------------------------------
BANNER = f"""{c('=' * 52, DIM)}
{c(' Bonfires Knowledge Ingester', BOLD + CYAN)}
{c(' Pipe knowledge into the graph.', DIM)}
{c('=' * 52, DIM)}"""
def print_banner() -> None:
print(BANNER)
# ---------------------------------------------------------------------------
# HTTP helpers (stdlib only)
# ---------------------------------------------------------------------------
def _make_request(endpoint: str, payload: dict) -> dict:
"""POST JSON to *endpoint* and return the parsed response body."""
url = f"{BASE_URL}/{endpoint.lstrip('/')}"
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode("utf-8")
return json.loads(body) if body else {}
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
return {"error": True, "status": exc.code, "detail": body}
except urllib.error.URLError as exc:
return {"error": True, "detail": str(exc.reason)}
# ---------------------------------------------------------------------------
# Core actions
# ---------------------------------------------------------------------------
def ingest_content(content: str, source: str, *, dry_run: bool = False) -> dict | None:
"""Send arbitrary text to the /ingest_content endpoint."""
payload = {
"bonfire_id": BONFIRE_ID,
"content": content,
"source": source,
}
if dry_run:
_print_dry_run("POST /ingest_content", payload)
return None
print(c(" Ingesting content ...", DIM))
result = _make_request("/ingest_content", payload)
_print_result(result)
return result
def add_triplet(subject: str, predicate: str, obj: str, *, dry_run: bool = False) -> dict | None:
"""Add a knowledge triple via /api/kg/add-triplet."""
payload = {
"bonfire_id": BONFIRE_ID,
"source_node": {"name": subject, "node_type": "entity"},
"edge": {"name": predicate, "relationship_type": predicate},
"target_node": {"name": obj, "node_type": "entity"},
}
if dry_run:
_print_dry_run("POST /api/kg/add-triplet", payload)
return None
print(c(" Adding triple ...", DIM))
result = _make_request("/api/kg/add-triplet", payload)
_print_result(result)
return result
def search(query: str, num_results: int = 5, *, dry_run: bool = False) -> dict | None:
"""Query the knowledge graph via /delve."""
payload = {
"query": query,
"bonfire_id": BONFIRE_ID,
"num_results": num_results,
}
if dry_run:
_print_dry_run("POST /delve", payload)
return None
print(c(" Searching ...", DIM))
result = _make_request("/delve", payload)
_print_search_result(result)
return result
# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------
def _print_dry_run(method: str, payload: dict) -> None:
print()
print(c(" [DRY RUN]", YELLOW + BOLD), c(method, YELLOW))
print(c(" Payload:", DIM))
for key, value in payload.items():
display = str(value)
if len(display) > 120:
display = display[:117] + "..."
print(f" {c(key, CYAN)}: {display}")
print()
def _print_result(result: dict) -> None:
if result.get("error"):
status = result.get("status", "?")
detail = result.get("detail", "unknown error")
print(c(f" FAIL", RED + BOLD) + f" status={status}")
print(c(f" {detail}", RED))
elif result.get("success"):
doc_id = result.get("document_id", "(no id returned)")
print(c(" OK", GREEN + BOLD) + f" document_id={c(doc_id, CYAN)}")
else:
# Some endpoints return a different shape — print what we got.
print(c(" Response:", GREEN))
print(f" {json.dumps(result, indent=2)}")
def _print_search_result(result: dict) -> None:
if result.get("error"):
_print_result(result)
return
num = result.get("num_results", 0)
episodes = result.get("episodes", [])
entities = result.get("entities", [])
edges = result.get("edges", [])
print(c(" Search results:", GREEN + BOLD))
print(f" {c(str(len(episodes)), CYAN)} episodes, "
f"{c(str(len(entities)), CYAN)} entities, "
f"{c(str(len(edges)), CYAN)} edges")
print()
if episodes:
print(c(" Episodes:", BOLD))
for i, ep in enumerate(episodes[:10], 1):
name = ep.get("name", "unnamed")
content = ep.get("content", "")
# Parse JSON content if applicable
if isinstance(content, str) and content.startswith("{"):
try:
parsed = json.loads(content)
content = parsed.get("content", content)
except json.JSONDecodeError:
pass
# Truncate long content
if len(str(content)) > 200:
content = str(content)[:200] + "..."
print(f" {c(f'[{i}]', BOLD)} {c(name, CYAN)}")
if content:
for line in str(content).splitlines()[:3]:
print(f" {line}")
print()
if entities:
print(c(" Entities:", BOLD))
for ent in entities[:10]:
name = ent.get("name", "unnamed")
print(f" {c('*', YELLOW)} {name}")
print()
if edges:
print(c(" Relationships:", BOLD))
for edge in edges[:10]:
name = edge.get("name", edge.get("fact", ""))
print(f" {c('->', MAGENTA)} {name}")
print()
# ---------------------------------------------------------------------------
# File & conversation readers
# ---------------------------------------------------------------------------
def read_file(path: str) -> str:
"""Read a file and return its contents."""
p = Path(path).expanduser().resolve()
if not p.exists():
print(c(f" Error: file not found: {p}", RED))
sys.exit(1)
return p.read_text(encoding="utf-8")
def format_conversation(raw_text: str) -> str:
"""Format alternating lines as a conversation transcript.
Blank lines are preserved as paragraph breaks. Each non-blank line is
prefixed with an alternating speaker label (A / B).
"""
lines = raw_text.strip().splitlines()
formatted: list[str] = []
speaker_index = 0
speakers = ["A", "B"]
for line in lines:
stripped = line.strip()
if not stripped:
formatted.append("")
continue
speaker = speakers[speaker_index % len(speakers)]
formatted.append(f"[{speaker}]: {stripped}")
speaker_index += 1
return "\n".join(formatted)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="ingest.py",
description="Bonfires Knowledge Ingester — pipe content into the knowledge graph.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be sent without making any API calls.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# --- text ---
p_text = subparsers.add_parser("text", help="Ingest a text string directly.")
p_text.add_argument("content", help="The text content to ingest.")
p_text.add_argument("--source", default="cli", help="Source label (default: cli).")
# --- file ---
p_file = subparsers.add_parser("file", help="Ingest the contents of a file.")
p_file.add_argument("path", help="Path to the file to ingest.")
p_file.add_argument("--source", default="file", help="Source label (default: file).")
# --- stdin ---
p_stdin = subparsers.add_parser("stdin", help="Ingest from stdin (pipe-friendly).")
p_stdin.add_argument("--source", default="stdin", help="Source label (default: stdin).")
# --- triple ---
p_triple = subparsers.add_parser("triple", help="Add a knowledge triple to the graph.")
p_triple.add_argument("subject", help="Triple subject.")
p_triple.add_argument("predicate", help="Triple predicate / relation.")
p_triple.add_argument("object", help="Triple object.")
# --- search ---
p_search = subparsers.add_parser("search", help="Search the knowledge graph.")
p_search.add_argument("query", help="Search query string.")
p_search.add_argument(
"-n", "--num-results", type=int, default=5, help="Number of results (default: 5)."
)
# --- conversation ---
p_conv = subparsers.add_parser("conversation", help="Ingest a conversation transcript.")
p_conv.add_argument("path", help="Path to the conversation text file.")
p_conv.add_argument("--source", default="conversation", help="Source label (default: conversation).")
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
print_banner()
match args.command:
case "text":
preview = args.content[:80] + ("..." if len(args.content) > 80 else "")
print(f"\n {c('Mode:', BOLD)} text")
print(f" {c('Source:', BOLD)} {args.source}")
print(f" {c('Content:', BOLD)} {preview}\n")
ingest_content(args.content, args.source, dry_run=args.dry_run)
case "file":
content = read_file(args.path)
chars = len(content)
lines = content.count("\n")
print(f"\n {c('Mode:', BOLD)} file")
print(f" {c('Source:', BOLD)} {args.source}")
print(f" {c('File:', BOLD)} {args.path} ({lines} lines, {chars} chars)\n")
ingest_content(content, args.source, dry_run=args.dry_run)
case "stdin":
if sys.stdin.isatty():
print(c("\n Reading from stdin (Ctrl-D to finish) ...\n", DIM), file=sys.stderr)
content = sys.stdin.read()
if not content.strip():
print(c(" Error: no input received on stdin.", RED))
sys.exit(1)
chars = len(content)
print(f"\n {c('Mode:', BOLD)} stdin")
print(f" {c('Source:', BOLD)} {args.source}")
print(f" {c('Received:', BOLD)} {chars} chars\n")
ingest_content(content, args.source, dry_run=args.dry_run)
case "triple":
print(f"\n {c('Mode:', BOLD)} triple")
print(f" {c('Triple:', BOLD)} ({args.subject}) --[{args.predicate}]--> ({args.object})\n")
add_triplet(args.subject, args.predicate, args.object, dry_run=args.dry_run)
case "search":
print(f"\n {c('Mode:', BOLD)} search")
print(f" {c('Query:', BOLD)} {args.query}")
print(f" {c('Results:', BOLD)} {args.num_results}\n")
search(args.query, args.num_results, dry_run=args.dry_run)
case "conversation":
raw = read_file(args.path)
content = format_conversation(raw)
lines = content.count("\n") + 1
print(f"\n {c('Mode:', BOLD)} conversation")
print(f" {c('Source:', BOLD)} {args.source}")
print(f" {c('File:', BOLD)} {args.path} ({lines} lines)\n")
# Show a short preview of the formatted conversation
preview_lines = content.splitlines()[:4]
for pl in preview_lines:
print(f" {c(pl, DIM)}")
if len(content.splitlines()) > 4:
print(c(f" ... ({len(content.splitlines()) - 4} more lines)", DIM))
print()
ingest_content(content, args.source, dry_run=args.dry_run)
print()
if __name__ == "__main__":
main()
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EthBoulder Collective Memory Explorer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ============================================
CSS RESET & VARIABLES
============================================ */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg-primary: #0d0b0a;
--bg-secondary: #151210;
--bg-tertiary: #1c1815;
--bg-card: #1a1613;
--bg-card-hover: #211d19;
--border-subtle: #2a2420;
--border-warm: #3d2e1e;
--border-glow: #c8743040;
--text-primary: #f0e6db;
--text-secondary: #a8998a;
--text-tertiary: #6b5f54;
--accent-amber: #e8943a;
--accent-amber-dim: #c87430;
--accent-orange: #f07028;
--accent-ember: #d45020;
--accent-warm-white: #fff0e0;
--glow-amber: #e8943a30;
--glow-orange: #f0702820;
--glow-ember: #d4502015;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--radius-xl: 20px;
--shadow-card: 0 2px 8px rgba(0,0,0,0.3), 0 1px 3px rgba(0,0,0,0.2);
--shadow-glow: 0 0 20px var(--glow-amber), 0 0 60px var(--glow-orange);
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-med: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
position: relative;
}
/* ============================================
EMBER PARTICLE BACKGROUND
============================================ */
.ember-container {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.ember {
position: absolute;
border-radius: 50%;
opacity: 0;
animation: ember-rise linear infinite;
}
@keyframes ember-rise {
0% {
opacity: 0;
transform: translateY(0) translateX(0) scale(1);
}
10% {
opacity: 0.8;
}
50% {
opacity: 0.4;
}
100% {
opacity: 0;
transform: translateY(-100vh) translateX(var(--drift)) scale(0.2);
}
}
/* Generate ember particles with pure CSS */
.ember:nth-child(1) { left: 5%; width: 3px; height: 3px; background: #e8943a; animation-duration: 14s; animation-delay: 0s; --drift: 40px; bottom: -10px; }
.ember:nth-child(2) { left: 15%; width: 2px; height: 2px; background: #f07028; animation-duration: 18s; animation-delay: 2s; --drift: -30px; bottom: -10px; }
.ember:nth-child(3) { left: 25%; width: 4px; height: 4px; background: #d45020; animation-duration: 12s; animation-delay: 4s; --drift: 60px; bottom: -10px; }
.ember:nth-child(4) { left: 35%; width: 2px; height: 2px; background: #e8943a; animation-duration: 20s; animation-delay: 1s; --drift: -50px; bottom: -10px; }
.ember:nth-child(5) { left: 45%; width: 3px; height: 3px; background: #f07028; animation-duration: 15s; animation-delay: 3s; --drift: 35px; bottom: -10px; }
.ember:nth-child(6) { left: 55%; width: 2px; height: 2px; background: #c87430; animation-duration: 22s; animation-delay: 5s; --drift: -45px; bottom: -10px; }
.ember:nth-child(7) { left: 65%; width: 3px; height: 3px; background: #e8943a; animation-duration: 16s; animation-delay: 0.5s; --drift: 55px; bottom: -10px; }
.ember:nth-child(8) { left: 75%; width: 2px; height: 2px; background: #d45020; animation-duration: 19s; animation-delay: 6s; --drift: -25px; bottom: -10px; }
.ember:nth-child(9) { left: 85%; width: 4px; height: 4px; background: #f07028; animation-duration: 13s; animation-delay: 2.5s; --drift: 30px; bottom: -10px; }
.ember:nth-child(10) { left: 95%; width: 2px; height: 2px; background: #c87430; animation-duration: 17s; animation-delay: 7s; --drift: -60px; bottom: -10px; }
.ember:nth-child(11) { left: 10%; width: 2px; height: 2px; background: #e8943a80; animation-duration: 24s; animation-delay: 8s; --drift: 20px; bottom: -10px; }
.ember:nth-child(12) { left: 30%; width: 3px; height: 3px; background: #f0702860; animation-duration: 21s; animation-delay: 3.5s; --drift: -40px; bottom: -10px; }
.ember:nth-child(13) { left: 50%; width: 2px; height: 2px; background: #d4502080; animation-duration: 25s; animation-delay: 9s; --drift: 50px; bottom: -10px; }
.ember:nth-child(14) { left: 70%; width: 3px; height: 3px; background: #c8743060; animation-duration: 23s; animation-delay: 4.5s; --drift: -35px; bottom: -10px; }
.ember:nth-child(15) { left: 90%; width: 2px; height: 2px; background: #e8943a60; animation-duration: 26s; animation-delay: 6.5s; --drift: 45px; bottom: -10px; }
/* Warm ambient glow at the bottom */
.ambient-glow {
position: fixed;
bottom: -200px;
left: 50%;
transform: translateX(-50%);
width: 120%;
height: 400px;
background: radial-gradient(ellipse at center, #e8943a08 0%, #f0702805 30%, transparent 70%);
pointer-events: none;
z-index: 0;
}
/* ============================================
LAYOUT
============================================ */
.app {
position: relative;
z-index: 1;
max-width: 860px;
margin: 0 auto;
padding: 0 20px;
min-height: 100vh;
}
/* ============================================
HEADER
============================================ */
.header {
padding: 48px 0 8px;
text-align: center;
}
.header-brand {
display: inline-flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.flame-icon {
font-size: 28px;
display: inline-block;
animation: flame-flicker 2s ease-in-out infinite alternate;
filter: drop-shadow(0 0 8px #e8943a80);
}
@keyframes flame-flicker {
0%, 100% { transform: scale(1) rotate(-2deg); opacity: 1; }
33% { transform: scale(1.05) rotate(1deg); opacity: 0.9; }
66% { transform: scale(0.97) rotate(-1deg); opacity: 1; }
}
.header h1 {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.03em;
color: var(--text-primary);
line-height: 1.2;
}
.header-sub {
font-size: 0.8rem;
color: var(--text-tertiary);
font-weight: 400;
letter-spacing: 0.04em;
text-transform: uppercase;
margin-top: 4px;
}
/* ============================================
SEARCH AREA
============================================ */
.search-area {
margin: 28px 0 16px;
position: relative;
}
.search-wrapper {
position: relative;
display: flex;
align-items: center;
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
transition: border-color var(--transition-med), box-shadow var(--transition-med);
overflow: hidden;
}
.search-wrapper:focus-within {
border-color: var(--accent-amber-dim);
box-shadow: 0 0 0 3px var(--glow-amber), var(--shadow-glow);
}
.search-icon {
position: absolute;
left: 18px;
color: var(--text-tertiary);
font-size: 18px;
pointer-events: none;
transition: color var(--transition-fast);
}
.search-wrapper:focus-within .search-icon {
color: var(--accent-amber);
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
padding: 16px 16px 16px 48px;
font-size: 1rem;
font-family: inherit;
color: var(--text-primary);
font-weight: 400;
}
.search-input::placeholder {
color: var(--text-tertiary);
font-weight: 300;
}
.search-btn {
padding: 10px 20px;
margin: 6px;
background: linear-gradient(135deg, var(--accent-amber), var(--accent-amber-dim));
border: none;
border-radius: var(--radius-md);
color: #0d0b0a;
font-family: inherit;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.search-btn:hover {
background: linear-gradient(135deg, #f0a048, var(--accent-amber));
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--glow-amber);
}
.search-btn:active {
transform: translateY(0);
}
.search-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* Action bar under search */
.action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
}
.random-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-family: inherit;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.random-btn:hover {
background: var(--bg-card-hover);
border-color: var(--border-warm);
color: var(--accent-amber);
}
.random-btn .dice {
font-size: 14px;
}
.stats-bar {
display: flex;
align-items: center;
gap: 16px;
font-size: 0.75rem;
color: var(--text-tertiary);
font-weight: 400;
}
.stat-item {
display: flex;
align-items: center;
gap: 4px;
}
.stat-count {
color: var(--accent-amber);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* ============================================
LOADING STATE
============================================ */
.loading-container {
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 0;
gap: 20px;
}
.loading-container.active {
display: flex;
}
.loading-embers {
display: flex;
align-items: center;
gap: 8px;
}
.loading-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-amber);
animation: loading-pulse 1.4s ease-in-out infinite;
}
.loading-dot:nth-child(2) { animation-delay: 0.2s; background: var(--accent-orange); }
.loading-dot:nth-child(3) { animation-delay: 0.4s; background: var(--accent-ember); }
.loading-dot:nth-child(4) { animation-delay: 0.6s; background: var(--accent-orange); }
.loading-dot:nth-child(5) { animation-delay: 0.8s; background: var(--accent-amber); }
@keyframes loading-pulse {
0%, 80%, 100% {
transform: scale(0.6);
opacity: 0.3;
}
40% {
transform: scale(1.2);
opacity: 1;
box-shadow: 0 0 12px var(--glow-amber);
}
}
.loading-text {
font-size: 0.85rem;
color: var(--text-tertiary);
font-weight: 400;
}
/* ============================================
RESULTS
============================================ */
.results-container {
padding-bottom: 80px;
}
.results-section {
margin-top: 28px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-subtle);
}
.section-icon {
font-size: 14px;
opacity: 0.7;
}
.section-title {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-tertiary);
}
.section-count {
font-size: 0.7rem;
color: var(--text-tertiary);
margin-left: auto;
font-variant-numeric: tabular-nums;
}
/* Episode Cards */
.episode-card {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 20px;
margin-bottom: 12px;
transition: all var(--transition-med);
position: relative;
overflow: hidden;
}
.episode-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: var(--radius-lg);
padding: 1px;
background: linear-gradient(135deg, var(--accent-amber-dim), transparent, var(--accent-ember));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity var(--transition-med);
pointer-events: none;
}
.episode-card:hover {
background: var(--bg-card-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-card), 0 0 30px var(--glow-ember);
}
.episode-card:hover::before {
opacity: 1;
}
.episode-name {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 10px;
line-height: 1.4;
display: flex;
align-items: flex-start;
gap: 8px;
}
.episode-name .ep-dot {
width: 6px;
height: 6px;
min-width: 6px;
border-radius: 50%;
background: var(--accent-amber);
margin-top: 7px;
box-shadow: 0 0 6px var(--glow-amber);
}
.episode-content {
font-size: 0.85rem;
line-height: 1.65;
color: var(--text-secondary);
font-weight: 400;
max-height: 200px;
overflow: hidden;
position: relative;
transition: max-height var(--transition-slow);
}
.episode-content.expanded {
max-height: none;
}
.episode-content-fade {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 50px;
background: linear-gradient(transparent, var(--bg-card));
pointer-events: none;
transition: opacity var(--transition-fast);
}
.episode-card:hover .episode-content-fade {
background: linear-gradient(transparent, var(--bg-card-hover));
}
.episode-content.expanded + .episode-content-fade {
opacity: 0;
}
.episode-source {
margin-top: 12px;
font-size: 0.72rem;
color: var(--text-tertiary);
display: flex;
align-items: center;
gap: 6px;
}
.episode-toggle {
margin-top: 8px;
background: none;
border: none;
color: var(--accent-amber);
font-family: inherit;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
padding: 2px 0;
opacity: 0.8;
transition: opacity var(--transition-fast);
}
.episode-toggle:hover {
opacity: 1;
}
/* JSON content styling */
.json-content {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 0.8rem;
}
.json-content .json-key {
color: var(--accent-amber);
}
.json-content .json-value {
color: var(--text-secondary);
}
.json-content .json-string {
color: #8abf7a;
}
/* Entity Chips */
.entity-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.entity-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: 100px;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.entity-chip:hover {
background: var(--bg-card-hover);
border-color: var(--accent-amber-dim);
color: var(--accent-amber);
box-shadow: 0 0 12px var(--glow-amber);
transform: translateY(-1px);
}
.entity-chip .chip-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent-amber-dim);
}
.entity-chip:hover .chip-dot {
background: var(--accent-amber);
box-shadow: 0 0 6px var(--accent-amber);
}
/* Edge / Relationship Cards */
.edge-card {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 14px 18px;
margin-bottom: 8px;
transition: all var(--transition-fast);
}
.edge-card:hover {
background: var(--bg-card-hover);
border-color: var(--border-warm);
}
.edge-flow {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 0.82rem;
line-height: 1.6;
}
.edge-node {
font-weight: 600;
color: var(--text-primary);
padding: 2px 10px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
border: 1px solid var(--border-subtle);
cursor: pointer;
transition: all var(--transition-fast);
}
.edge-node:hover {
border-color: var(--accent-amber-dim);
color: var(--accent-amber);
}
.edge-arrow {
color: var(--accent-amber-dim);
font-size: 0.9rem;
flex-shrink: 0;
}
.edge-predicate {
color: var(--text-secondary);
font-weight: 400;
font-style: italic;
}
.edge-fact {
margin-top: 8px;
font-size: 0.78rem;
color: var(--text-tertiary);
line-height: 1.5;
padding-left: 12px;
border-left: 2px solid var(--border-warm);
}
/* ============================================
EMPTY / ERROR STATES
============================================ */
.empty-state {
text-align: center;
padding: 80px 20px;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.4;
}
.empty-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 8px;
}
.empty-text {
font-size: 0.85rem;
color: var(--text-tertiary);
max-width: 400px;
margin: 0 auto;
line-height: 1.6;
}
.error-banner {
display: none;
background: #2a1515;
border: 1px solid #4a2020;
border-radius: var(--radius-md);
padding: 14px 18px;
margin-top: 16px;
font-size: 0.85rem;
color: #e8a0a0;
line-height: 1.5;
}
.error-banner.active {
display: block;
}
.error-banner strong {
color: #f0b0b0;
}
/* ============================================
WELCOME STATE
============================================ */
.welcome-state {
text-align: center;
padding: 48px 20px 80px;
}
.welcome-state .welcome-fire {
font-size: 56px;
margin-bottom: 20px;
display: inline-block;
filter: drop-shadow(0 0 20px #e8943a40);
}
.welcome-title {
font-size: 1.3rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 10px;
}
.welcome-text {
font-size: 0.9rem;
color: var(--text-tertiary);
max-width: 480px;
margin: 0 auto 32px;
line-height: 1.65;
}
.welcome-suggestions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
max-width: 560px;
margin: 0 auto;
}
.suggestion-chip {
padding: 8px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: 100px;
color: var(--text-secondary);
font-family: inherit;
font-size: 0.8rem;
font-weight: 400;
cursor: pointer;
transition: all var(--transition-fast);
}
.suggestion-chip:hover {
background: var(--bg-card-hover);
border-color: var(--accent-amber-dim);
color: var(--accent-amber);
}
/* ============================================
ANIMATIONS
============================================ */
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-in {
animation: fade-in-up 0.35s ease-out both;
}
/* Stagger children */
.results-section .episode-card:nth-child(1),
.results-section .edge-card:nth-child(1) { animation-delay: 0.05s; }
.results-section .episode-card:nth-child(2),
.results-section .edge-card:nth-child(2) { animation-delay: 0.1s; }
.results-section .episode-card:nth-child(3),
.results-section .edge-card:nth-child(3) { animation-delay: 0.15s; }
.results-section .episode-card:nth-child(4),
.results-section .edge-card:nth-child(4) { animation-delay: 0.2s; }
.results-section .episode-card:nth-child(5),
.results-section .edge-card:nth-child(5) { animation-delay: 0.25s; }
.results-section .episode-card:nth-child(6) { animation-delay: 0.3s; }
.results-section .episode-card:nth-child(7) { animation-delay: 0.35s; }
.results-section .episode-card:nth-child(8) { animation-delay: 0.4s; }
.results-section .episode-card:nth-child(9) { animation-delay: 0.45s; }
.results-section .episode-card:nth-child(10) { animation-delay: 0.5s; }
/* ============================================
SCROLLBAR
============================================ */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--border-subtle);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-warm);
}
/* ============================================
RESPONSIVE
============================================ */
@media (max-width: 640px) {
.app {
padding: 0 14px;
}
.header {
padding-top: 32px;
}
.header h1 {
font-size: 1.3rem;
}
.search-input {
padding: 14px 12px 14px 42px;
font-size: 0.95rem;
}
.search-btn {
padding: 8px 14px;
font-size: 0.8rem;
}
.action-bar {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.stats-bar {
justify-content: center;
}
.random-btn {
justify-content: center;
}
.episode-card {
padding: 16px;
}
.edge-flow {
font-size: 0.78rem;
}
.welcome-suggestions {
gap: 6px;
}
.suggestion-chip {
font-size: 0.75rem;
padding: 6px 12px;
}
}
/* ============================================
FOCUS VISIBLE STYLES
============================================ */
:focus-visible {
outline: 2px solid var(--accent-amber);
outline-offset: 2px;
}
button:focus-visible {
outline: 2px solid var(--accent-amber);
outline-offset: 2px;
}
.search-input:focus-visible {
outline: none;
}
</style>
</head>
<body>
<!-- Ember particles -->
<div class="ember-container">
<div class="ember"></div><div class="ember"></div><div class="ember"></div>
<div class="ember"></div><div class="ember"></div><div class="ember"></div>
<div class="ember"></div><div class="ember"></div><div class="ember"></div>
<div class="ember"></div><div class="ember"></div><div class="ember"></div>
<div class="ember"></div><div class="ember"></div><div class="ember"></div>
</div>
<div class="ambient-glow"></div>
<div class="app">
<!-- Header -->
<header class="header">
<div class="header-brand">
<span class="flame-icon" aria-hidden="true">&#x1F525;</span>
<h1>EthBoulder Collective Memory</h1>
</div>
<p class="header-sub">Bonfires Knowledge Graph Explorer</p>
</header>
<!-- Search -->
<div class="search-area">
<div class="search-wrapper">
<span class="search-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="M21 21l-4.35-4.35"/>
</svg>
</span>
<input
type="text"
class="search-input"
id="searchInput"
placeholder="What does the collective memory hold?"
autocomplete="off"
spellcheck="false"
>
<button class="search-btn" id="searchBtn" type="button">Search</button>
</div>
<div class="action-bar">
<button class="random-btn" id="randomBtn" type="button">
<span class="dice" aria-hidden="true">&#x2728;</span>
Random exploration
</button>
<div class="stats-bar" id="statsBar" style="display: none;">
<span class="stat-item"><span class="stat-count" id="statEpisodes">0</span> episodes</span>
<span class="stat-item"><span class="stat-count" id="statEntities">0</span> entities</span>
<span class="stat-item"><span class="stat-count" id="statEdges">0</span> relationships</span>
</div>
</div>
<div class="error-banner" id="errorBanner"></div>
</div>
<!-- Loading -->
<div class="loading-container" id="loadingState">
<div class="loading-embers">
<div class="loading-dot"></div>
<div class="loading-dot"></div>
<div class="loading-dot"></div>
<div class="loading-dot"></div>
<div class="loading-dot"></div>
</div>
<span class="loading-text">Searching the collective memory...</span>
</div>
<!-- Results -->
<div class="results-container" id="resultsContainer"></div>
<!-- Welcome State -->
<div class="welcome-state" id="welcomeState">
<div class="welcome-fire" aria-hidden="true">&#x1F3D5;&#xFE0F;</div>
<h2 class="welcome-title">Gather around the bonfire</h2>
<p class="welcome-text">
Explore the collective knowledge graph from EthBoulder. Search for people, projects, ideas, or connections that emerged from the gathering.
</p>
<div class="welcome-suggestions">
<button class="suggestion-chip" data-query="what projects are people building">Projects being built</button>
<button class="suggestion-chip" data-query="ethereum infrastructure and tooling">Infrastructure</button>
<button class="suggestion-chip" data-query="collaboration and partnerships">Collaborations</button>
<button class="suggestion-chip" data-query="decentralized identity and reputation">Identity</button>
<button class="suggestion-chip" data-query="emerging themes and patterns">Themes</button>
<button class="suggestion-chip" data-query="governance and coordination">Governance</button>
</div>
</div>
</div>
<script>
// ============================================
// CONFIGURATION
// ============================================
const CONFIG = {
API_BASE_URL: 'https://tnt-v2.api.bonfires.ai',
API_KEY: '8n5l-sJnrHjywrTnJ3rJCjo1f1uLyTPYy_yLgq_bf-d',
BONFIRE_ID: '698b70002849d936f4259848',
NUM_RESULTS: 10,
};
const RANDOM_PROMPTS = [
'surprising connections between people',
'emerging themes and patterns',
'who is building what',
'key insights and takeaways',
'community values and principles',
'technical innovations discussed',
'future plans and roadmaps',
'cross-project collaborations',
'challenges and problems to solve',
'decentralized coordination',
'open source projects',
'ethereum ecosystem developments',
];
// ============================================
// DOM REFS
// ============================================
const searchInput = document.getElementById('searchInput');
const searchBtn = document.getElementById('searchBtn');
const randomBtn = document.getElementById('randomBtn');
const loadingState = document.getElementById('loadingState');
const resultsContainer = document.getElementById('resultsContainer');
const welcomeState = document.getElementById('welcomeState');
const errorBanner = document.getElementById('errorBanner');
const statsBar = document.getElementById('statsBar');
const statEpisodes = document.getElementById('statEpisodes');
const statEntities = document.getElementById('statEntities');
const statEdges = document.getElementById('statEdges');
// ============================================
// STATE
// ============================================
let isLoading = false;
// ============================================
// API
// ============================================
async function searchDelve(query) {
const response = await fetch(`${CONFIG.API_BASE_URL}/delve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${CONFIG.API_KEY}`,
},
body: JSON.stringify({
query: query,
bonfire_id: CONFIG.BONFIRE_ID,
num_results: CONFIG.NUM_RESULTS,
}),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`API returned ${response.status}${text ? ': ' + text : ''}`);
}
return response.json();
}
// ============================================
// PARSING HELPERS
// ============================================
function tryParseJSON(str) {
if (typeof str !== 'string') return null;
const trimmed = str.trim();
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try {
return JSON.parse(trimmed);
} catch {
return null;
}
}
return null;
}
function escapeHTML(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatJSONContent(obj, depth = 0) {
if (depth > 4) return escapeHTML(JSON.stringify(obj));
if (Array.isArray(obj)) {
if (obj.length === 0) return '<span class="json-value">[]</span>';
return obj.map((item, i) => {
if (typeof item === 'object' && item !== null) {
return formatJSONContent(item, depth + 1);
}
return `<span class="json-value">${escapeHTML(String(item))}</span>`;
}).join('<br>');
}
if (typeof obj === 'object' && obj !== null) {
const entries = Object.entries(obj);
return entries.map(([key, value]) => {
if (typeof value === 'object' && value !== null) {
return `<span class="json-key">${escapeHTML(key)}:</span><br>${formatJSONContent(value, depth + 1)}`;
}
const displayVal = typeof value === 'string' ? value : JSON.stringify(value);
return `<span class="json-key">${escapeHTML(key)}:</span> <span class="json-string">${escapeHTML(String(displayVal))}</span>`;
}).join('<br>');
}
return `<span class="json-value">${escapeHTML(String(obj))}</span>`;
}
function renderContent(content) {
if (!content) return '<span style="color:var(--text-tertiary);">No content available</span>';
const parsed = tryParseJSON(content);
if (parsed) {
return `<div class="json-content">${formatJSONContent(parsed)}</div>`;
}
return escapeHTML(String(content)).replace(/\n/g, '<br>');
}
// Build an entity map from edges for resolving UUIDs to names
function buildEntityMap(entities, edges) {
const map = {};
if (entities) {
entities.forEach(e => {
if (e.uuid && e.name) map[e.uuid] = e.name;
});
}
return map;
}
// ============================================
// RENDER
// ============================================
function renderResults(data) {
const episodes = data.episodes || [];
const entities = data.entities || [];
const edges = data.edges || [];
const entityMap = buildEntityMap(entities, edges);
// Update stats
statEpisodes.textContent = episodes.length;
statEntities.textContent = entities.length;
statEdges.textContent = edges.length;
statsBar.style.display = (episodes.length || entities.length || edges.length) ? 'flex' : 'none';
if (!episodes.length && !entities.length && !edges.length) {
resultsContainer.innerHTML = `
<div class="empty-state animate-in">
<div class="empty-icon">&#x1F50D;</div>
<div class="empty-title">No memories found</div>
<div class="empty-text">Try a different search term, or use the random exploration button to discover what's in the knowledge graph.</div>
</div>
`;
return;
}
let html = '';
// Entities section
if (entities.length) {
html += `
<div class="results-section animate-in">
<div class="section-header">
<span class="section-icon">&#x1F4CC;</span>
<span class="section-title">Entities</span>
<span class="section-count">${entities.length}</span>
</div>
<div class="entity-grid">
${entities.map(e => `
<button class="entity-chip" data-query="${escapeHTML(e.name)}" title="Search for ${escapeHTML(e.name)}">
<span class="chip-dot"></span>
${escapeHTML(e.name)}
</button>
`).join('')}
</div>
</div>
`;
}
// Episodes section
if (episodes.length) {
html += `
<div class="results-section">
<div class="section-header animate-in">
<span class="section-icon">&#x1F4DD;</span>
<span class="section-title">Episodes</span>
<span class="section-count">${episodes.length}</span>
</div>
${episodes.map((ep, i) => {
const contentHTML = renderContent(ep.content);
const needsExpand = ep.content && String(ep.content).length > 400;
return `
<div class="episode-card animate-in">
<div class="episode-name">
<span class="ep-dot"></span>
<span>${escapeHTML(ep.name || 'Untitled Episode')}</span>
</div>
<div class="episode-content${needsExpand ? '' : ' expanded'}" id="ep-content-${i}">
${contentHTML}
</div>
${needsExpand ? `<div class="episode-content-fade" id="ep-fade-${i}"></div>` : ''}
${needsExpand ? `<button class="episode-toggle" onclick="toggleEpisode(${i})">Show more</button>` : ''}
${ep.source_description ? `
<div class="episode-source">
<span>&#x1F4CE;</span>
${escapeHTML(ep.source_description)}
</div>
` : ''}
</div>
`;
}).join('')}
</div>
`;
}
// Edges section
if (edges.length) {
html += `
<div class="results-section">
<div class="section-header animate-in">
<span class="section-icon">&#x1F517;</span>
<span class="section-title">Relationships</span>
<span class="section-count">${edges.length}</span>
</div>
${edges.map(edge => {
const sourceName = entityMap[edge.source_uuid] || edge.source_uuid || '?';
const targetName = entityMap[edge.target_uuid] || edge.target_uuid || '?';
return `
<div class="edge-card animate-in">
<div class="edge-flow">
<span class="edge-node" data-query="${escapeHTML(sourceName)}" role="button" tabindex="0">${escapeHTML(sourceName)}</span>
<span class="edge-arrow">&rarr;</span>
<span class="edge-predicate">${escapeHTML(edge.name || 'related to')}</span>
<span class="edge-arrow">&rarr;</span>
<span class="edge-node" data-query="${escapeHTML(targetName)}" role="button" tabindex="0">${escapeHTML(targetName)}</span>
</div>
${edge.fact ? `<div class="edge-fact">${escapeHTML(edge.fact)}</div>` : ''}
</div>
`;
}).join('')}
</div>
`;
}
resultsContainer.innerHTML = html;
// Bind clicks on entity chips and edge nodes
resultsContainer.querySelectorAll('.entity-chip, .edge-node').forEach(el => {
el.addEventListener('click', () => {
const query = el.dataset.query;
if (query) {
searchInput.value = query;
performSearch(query);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
el.click();
}
});
});
}
function toggleEpisode(index) {
const content = document.getElementById(`ep-content-${index}`);
const fade = document.getElementById(`ep-fade-${index}`);
const btn = content.parentElement.querySelector('.episode-toggle');
if (content.classList.contains('expanded')) {
content.classList.remove('expanded');
if (fade) fade.style.opacity = '';
btn.textContent = 'Show more';
} else {
content.classList.add('expanded');
if (fade) fade.style.opacity = '0';
btn.textContent = 'Show less';
}
}
// Make toggleEpisode available globally
window.toggleEpisode = toggleEpisode;
// ============================================
// SEARCH LOGIC
// ============================================
async function performSearch(query) {
if (isLoading || !query.trim()) return;
isLoading = true;
searchBtn.disabled = true;
randomBtn.disabled = true;
errorBanner.classList.remove('active');
welcomeState.style.display = 'none';
resultsContainer.innerHTML = '';
statsBar.style.display = 'none';
loadingState.classList.add('active');
try {
const data = await searchDelve(query.trim());
if (!data.success && data.success !== undefined) {
throw new Error('The API indicated the request was not successful.');
}
renderResults(data);
} catch (err) {
console.error('Search error:', err);
errorBanner.innerHTML = `<strong>Something went wrong.</strong> ${escapeHTML(err.message || 'Could not reach the Bonfires API. Please check your connection and try again.')}`;
errorBanner.classList.add('active');
resultsContainer.innerHTML = '';
statsBar.style.display = 'none';
} finally {
isLoading = false;
searchBtn.disabled = false;
randomBtn.disabled = false;
loadingState.classList.remove('active');
}
}
// ============================================
// EVENT LISTENERS
// ============================================
searchBtn.addEventListener('click', () => {
performSearch(searchInput.value);
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
performSearch(searchInput.value);
}
});
randomBtn.addEventListener('click', () => {
const prompt = RANDOM_PROMPTS[Math.floor(Math.random() * RANDOM_PROMPTS.length)];
searchInput.value = prompt;
performSearch(prompt);
});
// Welcome suggestion chips
document.querySelectorAll('.suggestion-chip').forEach(chip => {
chip.addEventListener('click', () => {
const query = chip.dataset.query;
if (query) {
searchInput.value = query;
performSearch(query);
}
});
});
// Focus search on load
searchInput.focus();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bonfire Pulse — EthBoulder 2026</title>
<style>
/* ─────────────────────────────────────────────
RESET & BASE
───────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-deep: #0d0a07;
--bg-surface: #1a1410;
--bg-card: #231c14;
--bg-card-hi: #2e2418;
--amber-dim: #7a5c2e;
--amber: #d4952b;
--amber-hot: #f5a623;
--amber-glow: #ffbd45;
--orange: #e8742a;
--ember: #c94a1a;
--cream: #f0e6d3;
--cream-dim: #b8a88e;
--cream-faint:#8a7d6a;
--radius: 12px;
--radius-sm: 8px;
--shadow-glow: 0 0 30px rgba(212, 149, 43, 0.08);
--shadow-card: 0 2px 12px rgba(0,0,0,0.4);
--transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
html { font-size: 15px; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg-deep);
color: var(--cream);
min-height: 100vh;
overflow-x: hidden;
line-height: 1.5;
}
/* Ambient animated background */
body::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(ellipse 80% 60% at 20% 10%, rgba(212,149,43,0.06) 0%, transparent 60%),
radial-gradient(ellipse 60% 80% at 80% 90%, rgba(232,116,42,0.04) 0%, transparent 50%),
radial-gradient(ellipse 50% 50% at 50% 50%, rgba(201,74,26,0.03) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
animation: ambientShift 20s ease-in-out infinite alternate;
}
@keyframes ambientShift {
0% { opacity: 0.7; transform: scale(1); }
50% { opacity: 1; transform: scale(1.05); }
100% { opacity: 0.8; transform: scale(0.98); }
}
/* ─────────────────────────────────────────────
LAYOUT
───────────────────────────────────────────── */
.dashboard {
position: relative;
z-index: 1;
max-width: 1440px;
margin: 0 auto;
padding: 20px 24px 40px;
display: grid;
gap: 20px;
grid-template-columns: 1fr;
grid-template-rows: auto auto auto auto;
}
/* ─────────────────────────────────────────────
HEADER
───────────────────────────────────────────── */
.header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 16px 0 8px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header h1 {
font-size: 1.8rem;
font-weight: 800;
letter-spacing: -0.02em;
background: linear-gradient(135deg, var(--amber-glow), var(--orange));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.pulse-dot {
width: 12px; height: 12px;
border-radius: 50%;
background: var(--amber-hot);
box-shadow: 0 0 8px var(--amber-hot), 0 0 20px rgba(245,166,35,0.4);
animation: pulseDot 2s ease-in-out infinite;
flex-shrink: 0;
}
@keyframes pulseDot {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.4); opacity: 0.6; }
}
.header-subtitle {
font-size: 0.85rem;
color: var(--cream-dim);
font-weight: 400;
letter-spacing: 0.04em;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
font-size: 0.8rem;
color: var(--cream-faint);
}
.refresh-timer {
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-card);
padding: 6px 14px;
border-radius: 20px;
border: 1px solid rgba(212,149,43,0.15);
}
.refresh-timer .ring {
width: 18px; height: 18px;
border-radius: 50%;
border: 2px solid var(--amber-dim);
position: relative;
}
.refresh-timer .ring::after {
content: '';
position: absolute;
inset: 2px;
border-radius: 50%;
border: 2px solid transparent;
border-top-color: var(--amber-hot);
animation: timerSpin 60s linear infinite;
}
@keyframes timerSpin {
to { transform: rotate(360deg); }
}
.status-badge {
padding: 5px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.status-badge.loading {
background: rgba(212,149,43,0.15);
color: var(--amber-hot);
border: 1px solid rgba(212,149,43,0.3);
animation: statusPulse 1.5s ease-in-out infinite;
}
.status-badge.live {
background: rgba(76,175,80,0.15);
color: #81c784;
border: 1px solid rgba(76,175,80,0.3);
}
.status-badge.error {
background: rgba(201,74,26,0.15);
color: #e57373;
border: 1px solid rgba(201,74,26,0.3);
}
@keyframes statusPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ─────────────────────────────────────────────
STAT CARDS
───────────────────────────────────────────── */
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.stat-card {
background: var(--bg-card);
border: 1px solid rgba(212,149,43,0.1);
border-radius: var(--radius);
padding: 24px;
text-align: center;
position: relative;
overflow: hidden;
transition: var(--transition);
box-shadow: var(--shadow-card);
}
.stat-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 3px;
background: linear-gradient(90deg, var(--amber), var(--orange));
opacity: 0.6;
}
.stat-card:hover {
border-color: rgba(212,149,43,0.25);
transform: translateY(-2px);
box-shadow: var(--shadow-glow), var(--shadow-card);
}
.stat-card .stat-value {
font-size: 2.8rem;
font-weight: 800;
background: linear-gradient(135deg, var(--amber-glow), var(--orange));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1.1;
margin-bottom: 4px;
transition: var(--transition);
}
.stat-card .stat-label {
font-size: 0.8rem;
color: var(--cream-dim);
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 500;
}
.stat-card .stat-icon {
font-size: 1.4rem;
margin-bottom: 8px;
opacity: 0.7;
}
/* ─────────────────────────────────────────────
MAIN CONTENT GRID
───────────────────────────────────────────── */
.content-grid {
display: grid;
grid-template-columns: 60fr 40fr;
gap: 20px;
min-height: 500px;
}
/* ─── PANEL BASE ─── */
.panel {
background: var(--bg-card);
border: 1px solid rgba(212,149,43,0.08);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
overflow: hidden;
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px;
border-bottom: 1px solid rgba(212,149,43,0.08);
}
.panel-title {
font-size: 0.95rem;
font-weight: 700;
color: var(--amber-glow);
letter-spacing: 0.02em;
}
.panel-count {
font-size: 0.7rem;
background: rgba(212,149,43,0.12);
color: var(--amber);
padding: 3px 10px;
border-radius: 12px;
font-weight: 600;
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
}
/* scrollbar */
.panel-body::-webkit-scrollbar { width: 5px; }
.panel-body::-webkit-scrollbar-track { background: transparent; }
.panel-body::-webkit-scrollbar-thumb {
background: var(--amber-dim);
border-radius: 4px;
}
/* ─── EPISODES TIMELINE ─── */
.episode-card {
background: var(--bg-surface);
border: 1px solid rgba(212,149,43,0.06);
border-radius: var(--radius-sm);
padding: 14px 16px;
margin-bottom: 10px;
transition: var(--transition);
cursor: default;
position: relative;
}
.episode-card::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: linear-gradient(180deg, var(--amber-hot), var(--orange));
border-radius: 3px 0 0 3px;
opacity: 0;
transition: var(--transition);
}
.episode-card:hover {
border-color: rgba(212,149,43,0.2);
background: var(--bg-card-hi);
box-shadow: 0 0 20px rgba(212,149,43,0.06);
transform: translateX(3px);
}
.episode-card:hover::before { opacity: 1; }
.episode-name {
font-weight: 700;
font-size: 0.9rem;
color: var(--cream);
margin-bottom: 6px;
line-height: 1.3;
}
.episode-content {
font-size: 0.8rem;
color: var(--cream-dim);
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.episode-source {
font-size: 0.7rem;
color: var(--cream-faint);
margin-top: 8px;
font-style: italic;
}
/* ─── RIGHT COLUMN ─── */
.right-column {
display: flex;
flex-direction: column;
gap: 20px;
}
/* ─── ENTITIES LIST ─── */
.entity-cloud {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 4px;
align-content: flex-start;
}
.entity-tag {
display: inline-flex;
align-items: center;
padding: 5px 14px;
border-radius: 20px;
font-weight: 600;
white-space: nowrap;
transition: var(--transition);
cursor: default;
border: 1px solid;
}
.entity-tag:hover {
transform: scale(1.08);
box-shadow: 0 0 16px rgba(212,149,43,0.2);
}
.entity-tag.size-xl {
font-size: 1rem; padding: 7px 18px;
background: rgba(245,166,35,0.18); color: var(--amber-glow);
border-color: rgba(245,166,35,0.35);
}
.entity-tag.size-lg {
font-size: 0.88rem; padding: 6px 15px;
background: rgba(212,149,43,0.14); color: var(--amber-hot);
border-color: rgba(212,149,43,0.28);
}
.entity-tag.size-md {
font-size: 0.78rem;
background: rgba(232,116,42,0.1); color: var(--orange);
border-color: rgba(232,116,42,0.22);
}
.entity-tag.size-sm {
font-size: 0.72rem; padding: 4px 11px;
background: rgba(122,92,46,0.12); color: var(--cream-dim);
border-color: rgba(122,92,46,0.2);
}
/* ─── FORCE GRAPH ─── */
.graph-container {
flex: 1;
min-height: 280px;
position: relative;
}
.graph-container canvas {
width: 100%;
height: 100%;
display: block;
border-radius: 0 0 var(--radius) var(--radius);
}
/* ─────────────────────────────────────────────
PULSE TOPICS (BOTTOM)
───────────────────────────────────────────── */
.pulse-topics {
background: var(--bg-card);
border: 1px solid rgba(212,149,43,0.08);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.pulse-topics .panel-header {
border-bottom: 1px solid rgba(212,149,43,0.08);
}
.topics-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 1px;
background: rgba(212,149,43,0.06);
}
.topic-column {
background: var(--bg-card);
padding: 16px;
min-height: 200px;
}
.topic-heading {
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 2px solid;
display: flex;
align-items: center;
gap: 8px;
}
.topic-heading .topic-icon { font-size: 1rem; }
.topic-heading.people { color: #f5a623; border-color: #f5a623; }
.topic-heading.projects { color: #e8742a; border-color: #e8742a; }
.topic-heading.ideas { color: #c94a1a; border-color: #c94a1a; }
.topic-heading.places { color: #d4952b; border-color: #d4952b; }
.topic-heading.emerging { color: #ffbd45; border-color: #ffbd45; }
.topic-item {
font-size: 0.78rem;
color: var(--cream-dim);
padding: 7px 0;
border-bottom: 1px solid rgba(255,255,255,0.03);
line-height: 1.4;
transition: var(--transition);
}
.topic-item:hover { color: var(--cream); padding-left: 4px; }
.topic-item .topic-item-name {
font-weight: 600;
color: var(--cream);
display: block;
margin-bottom: 2px;
}
.topic-item .topic-item-detail {
font-size: 0.72rem;
color: var(--cream-faint);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ─── LOADING / PLACEHOLDER ─── */
.loading-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 120px;
color: var(--cream-faint);
font-size: 0.85rem;
gap: 10px;
}
.loading-spinner {
width: 18px; height: 18px;
border: 2px solid var(--amber-dim);
border-top-color: var(--amber-hot);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ─────────────────────────────────────────────
RESPONSIVE
───────────────────────────────────────────── */
@media (max-width: 1100px) {
.content-grid { grid-template-columns: 1fr; }
.topics-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 768px) {
html { font-size: 14px; }
.dashboard { padding: 12px 14px 30px; }
.stats-row { grid-template-columns: 1fr; }
.topics-grid { grid-template-columns: 1fr 1fr; }
.header h1 { font-size: 1.4rem; }
.stat-card .stat-value { font-size: 2rem; }
}
@media (max-width: 480px) {
.topics-grid { grid-template-columns: 1fr; }
}
/* ─── FADE-IN ANIMATION ─── */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeUp 0.5s ease-out both;
}
</style>
</head>
<body>
<div class="dashboard">
<!-- ═══════ HEADER ═══════ -->
<header class="header">
<div class="header-left">
<span class="pulse-dot"></span>
<div>
<h1>Bonfire Pulse</h1>
<div class="header-subtitle">EthBoulder 2026 &mdash; Collective Intelligence Dashboard</div>
</div>
</div>
<div class="header-right">
<div class="refresh-timer">
<div class="ring"></div>
<span id="countdown">60s</span>
</div>
<span class="status-badge loading" id="statusBadge">Loading</span>
</div>
</header>
<!-- ═══════ STATS ROW ═══════ -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-icon">&#x1F4AC;</div>
<div class="stat-value" id="statEpisodes">--</div>
<div class="stat-label">Episodes Captured</div>
</div>
<div class="stat-card">
<div class="stat-icon">&#x2B50;</div>
<div class="stat-value" id="statEntities">--</div>
<div class="stat-label">Unique Entities</div>
</div>
<div class="stat-card">
<div class="stat-icon">&#x1F517;</div>
<div class="stat-value" id="statEdges">--</div>
<div class="stat-label">Connections Mapped</div>
</div>
</div>
<!-- ═══════ MAIN CONTENT ═══════ -->
<div class="content-grid">
<!-- LEFT: Episodes Timeline -->
<div class="panel" id="episodesPanel">
<div class="panel-header">
<span class="panel-title">Recent Memories</span>
<span class="panel-count" id="episodesCount">--</span>
</div>
<div class="panel-body" id="episodesList">
<div class="loading-placeholder"><div class="loading-spinner"></div> Retrieving memories...</div>
</div>
</div>
<!-- RIGHT COLUMN -->
<div class="right-column">
<!-- Key Entities -->
<div class="panel" style="max-height: 320px;">
<div class="panel-header">
<span class="panel-title">Key Entities</span>
<span class="panel-count" id="entitiesCount">--</span>
</div>
<div class="panel-body" id="entitiesList">
<div class="loading-placeholder"><div class="loading-spinner"></div> Mapping entities...</div>
</div>
</div>
<!-- Force Graph -->
<div class="panel" style="flex: 1;">
<div class="panel-header">
<span class="panel-title">Relationship Web</span>
<span class="panel-count" id="graphCount">--</span>
</div>
<div class="graph-container" id="graphContainer">
<canvas id="forceCanvas"></canvas>
</div>
</div>
</div>
</div>
<!-- ═══════ PULSE TOPICS ═══════ -->
<div class="pulse-topics">
<div class="panel-header">
<span class="panel-title">Pulse Topics</span>
<span class="panel-count" id="topicsStatus">Loading 5 queries...</span>
</div>
<div class="topics-grid" id="topicsGrid">
<div class="topic-column" id="topicPeople">
<div class="topic-heading people"><span class="topic-icon">&#x1F465;</span> People</div>
<div class="loading-placeholder" style="min-height:80px"><div class="loading-spinner"></div></div>
</div>
<div class="topic-column" id="topicProjects">
<div class="topic-heading projects"><span class="topic-icon">&#x1F3D7;</span> Projects</div>
<div class="loading-placeholder" style="min-height:80px"><div class="loading-spinner"></div></div>
</div>
<div class="topic-column" id="topicIdeas">
<div class="topic-heading ideas"><span class="topic-icon">&#x1F4A1;</span> Ideas &amp; Themes</div>
<div class="loading-placeholder" style="min-height:80px"><div class="loading-spinner"></div></div>
</div>
<div class="topic-column" id="topicPlaces">
<div class="topic-heading places"><span class="topic-icon">&#x1F30D;</span> Places &amp; Spaces</div>
<div class="loading-placeholder" style="min-height:80px"><div class="loading-spinner"></div></div>
</div>
<div class="topic-column" id="topicEmerging">
<div class="topic-heading emerging"><span class="topic-icon">&#x26A1;</span> Emerging</div>
<div class="loading-placeholder" style="min-height:80px"><div class="loading-spinner"></div></div>
</div>
</div>
</div>
</div>
<script>
// ═══════════════════════════════════════════════════
// CONFIGURATION
// ═══════════════════════════════════════════════════
const CONFIG = {
API_BASE: 'https://tnt-v2.api.bonfires.ai',
API_KEY: '8n5l-sJnrHjywrTnJ3rJCjo1f1uLyTPYy_yLgq_bf-d',
BONFIRE_ID: '698b70002849d936f4259848',
REFRESH_SEC: 60,
NUM_RESULTS: 20,
};
// ═══════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════
let allEpisodes = new Map(); // uuid -> episode
let allEntities = new Map(); // uuid -> { name, edgeCount }
let allEdges = [];
let countdown = CONFIG.REFRESH_SEC;
let refreshTimer = null;
let graphSim = null;
// ═══════════════════════════════════════════════════
// API
// ═══════════════════════════════════════════════════
async function delve(query) {
const res = await fetch(`${CONFIG.API_BASE}/delve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${CONFIG.API_KEY}`,
},
body: JSON.stringify({
query,
bonfire_id: CONFIG.BONFIRE_ID,
num_results: CONFIG.NUM_RESULTS,
}),
});
if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
return res.json();
}
// ═══════════════════════════════════════════════════
// DATA AGGREGATION
// ═══════════════════════════════════════════════════
function mergeResults(data) {
if (!data || !data.success) return;
// Episodes
if (data.episodes) {
for (const ep of data.episodes) {
if (!allEpisodes.has(ep.uuid)) {
allEpisodes.set(ep.uuid, ep);
}
}
}
// Entities — count edges per entity
if (data.entities) {
for (const ent of data.entities) {
if (!allEntities.has(ent.uuid)) {
allEntities.set(ent.uuid, { ...ent, edgeCount: 0 });
}
}
}
// Edges
if (data.edges) {
for (const edge of data.edges) {
const exists = allEdges.some(e =>
e.source_uuid === edge.source_uuid &&
e.target_uuid === edge.target_uuid &&
e.name === edge.name
);
if (!exists) {
allEdges.push(edge);
// increment edge counts
const src = allEntities.get(edge.source_uuid);
if (src) src.edgeCount++;
const tgt = allEntities.get(edge.target_uuid);
if (tgt) tgt.edgeCount++;
}
}
}
}
// ═══════════════════════════════════════════════════
// RENDER: Stats
// ═══════════════════════════════════════════════════
function animateNumber(el, target) {
const current = parseInt(el.textContent) || 0;
if (current === target) return;
const diff = target - current;
const steps = Math.min(Math.abs(diff), 30);
const stepTime = 600 / steps;
let i = 0;
const interval = setInterval(() => {
i++;
const progress = i / steps;
const eased = 1 - Math.pow(1 - progress, 3);
el.textContent = Math.round(current + diff * eased);
if (i >= steps) { el.textContent = target; clearInterval(interval); }
}, stepTime);
}
function renderStats() {
animateNumber(document.getElementById('statEpisodes'), allEpisodes.size);
animateNumber(document.getElementById('statEntities'), allEntities.size);
animateNumber(document.getElementById('statEdges'), allEdges.length);
}
// ═══════════════════════════════════════════════════
// RENDER: Episodes
// ═══════════════════════════════════════════════════
function renderEpisodes() {
const container = document.getElementById('episodesList');
const episodes = [...allEpisodes.values()]
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
document.getElementById('episodesCount').textContent = episodes.length;
if (episodes.length === 0) {
container.innerHTML = '<div class="loading-placeholder">No episodes found</div>';
return;
}
container.innerHTML = episodes.map((ep, i) => `
<div class="episode-card fade-in" style="animation-delay: ${i * 40}ms">
<div class="episode-name">${escHtml(ep.name || 'Unnamed')}</div>
<div class="episode-content">${escHtml(truncate(ep.content || '', 220))}</div>
${ep.source_description ? `<div class="episode-source">${escHtml(ep.source_description)}</div>` : ''}
</div>
`).join('');
}
// ═══════════════════════════════════════════════════
// RENDER: Entities
// ═══════════════════════════════════════════════════
function renderEntities() {
const container = document.getElementById('entitiesList');
const entities = [...allEntities.values()]
.sort((a, b) => b.edgeCount - a.edgeCount);
document.getElementById('entitiesCount').textContent = entities.length;
if (entities.length === 0) {
container.innerHTML = '<div class="loading-placeholder">No entities found</div>';
return;
}
const maxEdges = Math.max(1, entities[0].edgeCount);
container.innerHTML = `<div class="entity-cloud">` +
entities.map((ent, i) => {
const ratio = ent.edgeCount / maxEdges;
let size = 'sm';
if (ratio > 0.7) size = 'xl';
else if (ratio > 0.4) size = 'lg';
else if (ratio > 0.15) size = 'md';
return `<span class="entity-tag size-${size} fade-in" style="animation-delay: ${i * 25}ms" title="${ent.edgeCount} connections">${escHtml(ent.name)}</span>`;
}).join('') +
`</div>`;
}
// ═══════════════════════════════════════════════════
// RENDER: Force Graph
// ═══════════════════════════════════════════════════
function initForceGraph() {
const canvas = document.getElementById('forceCanvas');
const container = document.getElementById('graphContainer');
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
function resize() {
const rect = container.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
resize();
window.addEventListener('resize', resize);
// Build node/edge arrays from entity/edge data
let nodes = [];
let links = [];
let dragNode = null;
let mouseX = 0, mouseY = 0;
let hoveredNode = null;
function rebuild() {
const entityArr = [...allEntities.values()];
// Only use entities that appear in edges (to keep graph clean)
const edgeEntityIds = new Set();
for (const e of allEdges) {
edgeEntityIds.add(e.source_uuid);
edgeEntityIds.add(e.target_uuid);
}
const relevantEntities = entityArr.filter(e => edgeEntityIds.has(e.uuid));
// Cap at 60 nodes for performance
const capped = relevantEntities
.sort((a, b) => b.edgeCount - a.edgeCount)
.slice(0, 60);
const nodeMap = new Map();
const w = canvas.width / dpr;
const h = canvas.height / dpr;
// Preserve positions of existing nodes
const oldPositions = new Map();
for (const n of nodes) oldPositions.set(n.id, { x: n.x, y: n.y });
nodes = capped.map(ent => {
const old = oldPositions.get(ent.uuid);
return {
id: ent.uuid,
label: ent.name,
edgeCount: ent.edgeCount,
x: old ? old.x : w * 0.2 + Math.random() * w * 0.6,
y: old ? old.y : h * 0.2 + Math.random() * h * 0.6,
vx: 0, vy: 0,
};
});
for (const n of nodes) nodeMap.set(n.id, n);
links = allEdges
.filter(e => nodeMap.has(e.source_uuid) && nodeMap.has(e.target_uuid))
.map(e => ({
source: nodeMap.get(e.source_uuid),
target: nodeMap.get(e.target_uuid),
label: e.name,
}));
document.getElementById('graphCount').textContent = `${nodes.length} nodes, ${links.length} edges`;
}
// Physics step
function simulate() {
const w = canvas.width / dpr;
const h = canvas.height / dpr;
const repulsion = 1800;
const attraction = 0.008;
const damping = 0.88;
const centerPull = 0.002;
// Repulsion between all pairs
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i], b = nodes[j];
let dx = a.x - b.x;
let dy = a.y - b.y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
let force = repulsion / (dist * dist);
let fx = (dx / dist) * force;
let fy = (dy / dist) * force;
a.vx += fx; a.vy += fy;
b.vx -= fx; b.vy -= fy;
}
}
// Attraction along edges
for (const link of links) {
const a = link.source, b = link.target;
let dx = b.x - a.x;
let dy = b.y - a.y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
let force = (dist - 80) * attraction;
let fx = (dx / dist) * force;
let fy = (dy / dist) * force;
a.vx += fx; a.vy += fy;
b.vx -= fx; b.vy -= fy;
}
// Pull toward center
const cx = w / 2, cy = h / 2;
for (const n of nodes) {
n.vx += (cx - n.x) * centerPull;
n.vy += (cy - n.y) * centerPull;
}
// Apply velocity
for (const n of nodes) {
if (n === dragNode) { n.vx = 0; n.vy = 0; continue; }
n.vx *= damping;
n.vy *= damping;
n.x += n.vx;
n.y += n.vy;
// Bounds
n.x = Math.max(20, Math.min(w - 20, n.x));
n.y = Math.max(20, Math.min(h - 20, n.y));
}
}
// Draw
function draw() {
const w = canvas.width / dpr;
const h = canvas.height / dpr;
ctx.clearRect(0, 0, w, h);
if (nodes.length === 0) {
ctx.fillStyle = '#8a7d6a';
ctx.font = '13px Inter, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Waiting for graph data...', w / 2, h / 2);
return;
}
// Edges
for (const link of links) {
const isHovered = hoveredNode && (link.source === hoveredNode || link.target === hoveredNode);
ctx.beginPath();
ctx.moveTo(link.source.x, link.source.y);
ctx.lineTo(link.target.x, link.target.y);
ctx.strokeStyle = isHovered ? 'rgba(245,166,35,0.5)' : 'rgba(212,149,43,0.12)';
ctx.lineWidth = isHovered ? 1.5 : 0.7;
ctx.stroke();
}
// Nodes
const maxEdge = Math.max(1, ...nodes.map(n => n.edgeCount));
for (const n of nodes) {
const ratio = n.edgeCount / maxEdge;
const r = 4 + ratio * 10;
const isHovered = n === hoveredNode;
// Glow
if (ratio > 0.3 || isHovered) {
const glow = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, r * 3);
glow.addColorStop(0, isHovered ? 'rgba(255,189,69,0.35)' : 'rgba(245,166,35,0.15)');
glow.addColorStop(1, 'rgba(245,166,35,0)');
ctx.beginPath();
ctx.arc(n.x, n.y, r * 3, 0, Math.PI * 2);
ctx.fillStyle = glow;
ctx.fill();
}
// Node circle
ctx.beginPath();
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
const gradient = ctx.createRadialGradient(n.x - r * 0.3, n.y - r * 0.3, 0, n.x, n.y, r);
if (isHovered) {
gradient.addColorStop(0, '#ffce54');
gradient.addColorStop(1, '#f5a623');
} else {
gradient.addColorStop(0, lerpColor('#d4952b', '#f5a623', ratio));
gradient.addColorStop(1, lerpColor('#7a5c2e', '#e8742a', ratio));
}
ctx.fillStyle = gradient;
ctx.fill();
ctx.strokeStyle = isHovered ? 'rgba(255,189,69,0.8)' : 'rgba(255,255,255,0.15)';
ctx.lineWidth = isHovered ? 2 : 0.5;
ctx.stroke();
// Label
if (ratio > 0.2 || isHovered || nodes.length < 25) {
ctx.fillStyle = isHovered ? '#fff' : `rgba(240,230,211,${0.4 + ratio * 0.6})`;
ctx.font = `${isHovered ? 'bold ' : ''}${Math.round(10 + ratio * 3)}px Inter, system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(n.label, n.x, n.y - r - 4);
}
}
}
function lerpColor(a, b, t) {
const ah = parseInt(a.slice(1), 16);
const bh = parseInt(b.slice(1), 16);
const ar = (ah >> 16) & 0xff, ag = (ah >> 8) & 0xff, ab = ah & 0xff;
const br = (bh >> 16) & 0xff, bg = (bh >> 8) & 0xff, bb = bh & 0xff;
const rr = Math.round(ar + (br - ar) * t);
const rg = Math.round(ag + (bg - ag) * t);
const rb = Math.round(ab + (bb - ab) * t);
return `rgb(${rr},${rg},${rb})`;
}
// Animation loop
function tick() {
simulate();
draw();
requestAnimationFrame(tick);
}
// Mouse interaction
function getMousePos(e) {
const rect = canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
function findNode(x, y) {
const maxEdge = Math.max(1, ...nodes.map(n => n.edgeCount));
for (let i = nodes.length - 1; i >= 0; i--) {
const n = nodes[i];
const r = 4 + (n.edgeCount / maxEdge) * 10 + 4;
const dx = n.x - x, dy = n.y - y;
if (dx * dx + dy * dy < r * r) return n;
}
return null;
}
canvas.addEventListener('mousedown', (e) => {
const pos = getMousePos(e);
dragNode = findNode(pos.x, pos.y);
});
canvas.addEventListener('mousemove', (e) => {
const pos = getMousePos(e);
mouseX = pos.x; mouseY = pos.y;
hoveredNode = findNode(pos.x, pos.y);
canvas.style.cursor = hoveredNode ? 'grab' : 'default';
if (dragNode) {
dragNode.x = pos.x;
dragNode.y = pos.y;
canvas.style.cursor = 'grabbing';
}
});
canvas.addEventListener('mouseup', () => { dragNode = null; });
canvas.addEventListener('mouseleave', () => { dragNode = null; hoveredNode = null; });
// Touch support
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
const pos = { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
dragNode = findNode(pos.x, pos.y);
}, { passive: false });
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
if (!dragNode) return;
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
dragNode.x = touch.clientX - rect.left;
dragNode.y = touch.clientY - rect.top;
}, { passive: false });
canvas.addEventListener('touchend', () => { dragNode = null; });
// Expose rebuild for external calls
graphSim = { rebuild };
rebuild();
tick();
}
// ═══════════════════════════════════════════════════
// RENDER: Topics
// ═══════════════════════════════════════════════════
function renderTopicColumn(columnId, data) {
const col = document.getElementById(columnId);
const heading = col.querySelector('.topic-heading');
// Remove loading placeholder
const placeholder = col.querySelector('.loading-placeholder');
if (placeholder) placeholder.remove();
if (!data || !data.success) {
col.insertAdjacentHTML('beforeend', '<div class="topic-item" style="color:var(--cream-faint)">No data</div>');
return;
}
let html = '';
// Show entities first, then edges as context
if (data.entities && data.entities.length > 0) {
// Deduplicate by name
const seen = new Set();
for (const ent of data.entities) {
if (seen.has(ent.name)) continue;
seen.add(ent.name);
// Find a relevant fact from edges
const relatedEdge = (data.edges || []).find(
e => e.source_uuid === ent.uuid || e.target_uuid === ent.uuid
);
html += `<div class="topic-item fade-in">
<span class="topic-item-name">${escHtml(ent.name)}</span>
${relatedEdge ? `<span class="topic-item-detail">${escHtml(relatedEdge.fact || relatedEdge.name)}</span>` : ''}
</div>`;
}
}
// If no entities, show episodes
if (!html && data.episodes && data.episodes.length > 0) {
for (const ep of data.episodes.slice(0, 8)) {
html += `<div class="topic-item fade-in">
<span class="topic-item-name">${escHtml(ep.name || 'Memory')}</span>
<span class="topic-item-detail">${escHtml(truncate(ep.content || '', 100))}</span>
</div>`;
}
}
if (!html) {
html = '<div class="topic-item" style="color:var(--cream-faint)">Exploring...</div>';
}
col.insertAdjacentHTML('beforeend', html);
}
// ═══════════════════════════════════════════════════
// HELPERS
// ═══════════════════════════════════════════════════
function escHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function truncate(str, len) {
if (str.length <= len) return str;
return str.slice(0, len).replace(/\s+\S*$/, '') + '...';
}
// ═══════════════════════════════════════════════════
// MAIN LOAD LOGIC
// ═══════════════════════════════════════════════════
async function loadDashboard() {
const badge = document.getElementById('statusBadge');
badge.className = 'status-badge loading';
badge.textContent = 'Loading';
try {
// Phase 1: Main broad query to get the overview
const mainQueries = [
delve('everything happening at EthBoulder'),
delve('all people participants speakers'),
delve('projects tools platforms being built'),
];
// Phase 2: Topic queries
const topicQueries = [
{ query: 'people and who they are', col: 'topicPeople' },
{ query: 'projects being built', col: 'topicProjects' },
{ query: 'ideas and themes', col: 'topicIdeas' },
{ query: 'places and spaces', col: 'topicPlaces' },
{ query: 'emerging connections and patterns', col: 'topicEmerging' },
];
// Fire all queries in parallel
const allPromises = [
...mainQueries,
...topicQueries.map(t => delve(t.query)),
];
const results = await Promise.allSettled(allPromises);
// Process main queries (first 3)
for (let i = 0; i < 3; i++) {
if (results[i].status === 'fulfilled') {
mergeResults(results[i].value);
}
}
// Process topic queries (next 5) — also merge into global state
let topicsLoaded = 0;
for (let i = 0; i < topicQueries.length; i++) {
const result = results[3 + i];
if (result.status === 'fulfilled') {
mergeResults(result.value);
renderTopicColumn(topicQueries[i].col, result.value);
topicsLoaded++;
} else {
renderTopicColumn(topicQueries[i].col, null);
}
}
document.getElementById('topicsStatus').textContent = `${topicsLoaded}/5 queries complete`;
// Render all sections
renderStats();
renderEpisodes();
renderEntities();
if (graphSim) {
graphSim.rebuild();
}
badge.className = 'status-badge live';
badge.textContent = 'Live';
} catch (err) {
console.error('Dashboard load error:', err);
const badge = document.getElementById('statusBadge');
badge.className = 'status-badge error';
badge.textContent = 'Error';
}
}
// ═══════════════════════════════════════════════════
// COUNTDOWN & AUTO-REFRESH
// ═══════════════════════════════════════════════════
function startCountdown() {
countdown = CONFIG.REFRESH_SEC;
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(() => {
countdown--;
document.getElementById('countdown').textContent = countdown + 's';
if (countdown <= 0) {
clearInterval(refreshTimer);
loadDashboard().then(() => startCountdown());
}
}, 1000);
}
// ═══════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
initForceGraph();
loadDashboard().then(() => startCountdown());
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment