Created
February 15, 2026 17:30
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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">🔥</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">✨</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">🏕️</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">🔍</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">📌</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">📝</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>📎</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">🔗</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">→</span> | |
| <span class="edge-predicate">${escapeHTML(edge.name || 'related to')}</span> | |
| <span class="edge-arrow">→</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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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 — 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">💬</div> | |
| <div class="stat-value" id="statEpisodes">--</div> | |
| <div class="stat-label">Episodes Captured</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-icon">⭐</div> | |
| <div class="stat-value" id="statEntities">--</div> | |
| <div class="stat-label">Unique Entities</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-icon">🔗</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">👥</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">🏗</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">💡</span> Ideas & 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">🌍</span> Places & 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">⚡</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