Skip to content

ricky-antonio/bloom

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

55 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

bloom

Every idea has roots.

A concept-mapping tool that turns any word into a living, force-directed graph of connected ideas — powered by Claude AI and animated with D3 physics. Click any node to re-centre the universe around it.

Live: bloom.rickycodes.dev

Next.js 15 · TypeScript (strict) · D3.js · Anthropic Claude Sonnet 4.6


Overview

bloom explores two genuinely hard frontend problems: real-time physics simulation alongside a React component tree, and integrating a streaming AI response directly into a live data structure.

The core challenge is that D3 force physics runs at 60fps but React can't re-render at that rate. The AI response streams token-by-token but the graph can't wait for it to finish. Every architectural decision follows from those two constraints — D3 owns positions and mutates them directly on tick; React owns the node structure and only re-renders when that structure changes. The result is a responsive, physics-driven graph that never drops frames.

The interesting problems here are performance, perception, and making an AI interaction feel alive.


Tech Stack

Frontend

Technology Usage
Next.js 15 (App Router) File-based routing, server components, API route handlers
TypeScript (strict mode) End-to-end type safety; noImplicitAny, strictNullChecks
Tailwind CSS Utility-first styling; warm cream palette, light mode only
D3.js (force/zoom/drag) Force simulation, zoom/pan, drag — dynamically imported, never bundled
@tabler/icons-react Tree-shakable icon system
react-hot-toast Auto-dismissing error toasts for AI failures

AI

Technology Usage
Anthropic Claude Sonnet 4.6 Concept expansion (ring1 + ring2) and streaming definitions
@anthropic-ai/sdk SSE streaming via stream.toReadableStream() for expansion

Testing & Quality

Technology Usage
Vitest Unit and integration test runner
React Testing Library Component tests from the user's perspective
@vitest/coverage-v8 Istanbul coverage with enforced per-phase targets
jsdom Browser environment simulation

How It Works

The graph model

Every concept lives at one of four rings:

Ring Role Colour
core The current concept — pinned at the origin Sky blue border
ring1 6 AI-generated direct associations Category-coded
ring2 12 AI-generated second-degree associations (2 per ring1) Muted category tones
ring3 Promoted nodes pushed outward by re-centring Near-invisible

Nodes carry two orthogonal category fields — a deliberate design decision:

  • semanticDistance (direct / adjacent / tangential / distant) — drives ring assignment and pruning priority
  • category (awareness / identity / experiential) — drives colour coding independently

A node can be semantically close but experiential in nature. The two fields don't interfere.

AI expansion

POST /api/expand streams a single JSON object containing 18 nodes (6 ring1 + 12 ring2) as a server-sent event stream. The client reads the stream, accumulates the response, and parses it once complete — then dispatches ADD_EXPANSION_NODES into the React reducer. The D3 simulation is restarted with alpha(0.8) and the graph reshuffles.

POST /api/define returns a non-streaming 2–3 sentence definition and exactly 4 related concept tags. The StreamingDefinition component animates the text character-by-character client-side, independent of the SSE stream.

Re-centring

Clicking "Expand this concept" on any node triggers a state promotion cascade:

  1. Clicked node → ring: 'core', pinned at (0, 0)
  2. Old core → ring: 'ring2', fixed position released
  3. Old ring1 nodes still connected to the new core → ring: 'ring2'
  4. Old ring1 nodes not connected → ring: 'ring3' (pruning candidates)
  5. pruneGraph enforces the 40-node hard cap — ring3 nodes without definitions pruned first, core and ring1 never touched
  6. New ring1 + ring2 nodes generated by AI and streamed in

Ring 3 is never AI-generated — it emerges from node promotion as the graph re-centres, giving the graph natural depth without additional AI calls.


Architecture

D3 owns positions, React owns structure. Calling setState on every D3 tick causes 60 React re-renders per second and kills animation smoothness. Instead, D3 mutates node x/y directly on tick and updates SVG attributes via setAttribute. React only re-renders when the node array structurally changes (add or remove). This gives smooth 60fps physics alongside a fully declarative component tree.

Dynamic import for D3. D3 is ~500kb. It's loaded with await import('d3') inside a useEffect on mount — never at the module level. It never appears in the initial bundle and doesn't block page load.

React Context + useReducer. All graph mutations (createCoreNode, addExpansionNodes, recentreGraph, pruneGraph) are pure functions in lib/graph.ts. The reducer calls them; components dispatch actions. This keeps business logic independently testable and gives predictable state transitions without the overhead of an external state library.

Parse-and-fallback for AI reliability. Every AI response is parsed inside a try/catch. On failure, one automatic retry with the same prompt. On second failure, a typed fallback (EXPANSION_FALLBACK, DEFINITION_FALLBACK) keeps the graph alive. A bad API response never crashes the UI — it degrades gracefully with a toast.

In-memory rate limiting. Each AI route maintains a Map<string, number[]> sliding window — 15 requests per minute per IP, checked before input validation and before any Anthropic SDK call. No external dependency required.


Features

  • Force-directed graph — D3 force simulation with ring-based charge and link distances; nodes settle into orbital rings naturally
  • Streaming concept expansion — AI response streams directly into the graph via SSE; LoadingBloom animation plays until ring1 nodes appear
  • Re-centring — any node can become the new core; the graph reshuffles with alpha(0.8) energy
  • Pruning — hard 40-node cap enforced after every re-centre; ring3 nodes pruned first, core and ring1 never pruned
  • Definition panel — character-by-character definition reveal with 4 related concept chips; AbortController cancels in-flight requests on unmount
  • Category colour system — awareness (sky #BADDFF), identity (peach #FFDBBB), experiential (mint #BAFFF5); ring2 at 40% opacity; ring3 near-invisible
  • Keyboard accessibility — all nodes role="button" with tabIndex, Escape closes panels, full aria labelling
  • Rate limiting — 15 req/min/IP on all AI routes with graceful 429 toast response
  • Input validation — server-side concept length and type checks on every route, mirrored client-side with inline feedback

Testing

Coverage thresholds enforced on every run: ≥ 75% lines · ≥ 75% functions · ≥ 70% branches

Test categories:

  • Unit tests for all pure lib/ functions — graph mutations, colour system, AI prompt builders and response parsers
  • Integration tests for every API route — rate limiting, input validation, Anthropic SDK error paths, streamed response shape
  • Component tests with React Testing Library — behaviour only, never CSS classes or D3 internals
  • Shared mocks in tests/mocks/anthropic.ts — never duplicated inline

Getting Started

git clone https://github.com/ricky-antonio/bloom.git
cd bloom
npm install
cp .env.example .env.local   # add your ANTHROPIC_API_KEY
npm run dev

Environment Variables

ANTHROPIC_API_KEY=

Scripts

npm run dev            # start dev server
npm run build          # production build
npm run type-check     # tsc --noEmit (strict)
npm test               # vitest run
npm run test:watch     # vitest watch mode
npm run test:coverage  # coverage report with thresholds

Project Structure

app/
  api/
    expand/route.ts     # POST — streams ring1 + ring2 as SSE
    define/route.ts     # POST — returns definition + 4 related tags
  page.tsx              # single page, full-viewport graph
  layout.tsx            # Inter font, Toaster
components/
  graph/                # ConceptGraph, GraphCanvas, GraphNode, GraphEdge
  ui/                   # SearchBar, DetailPanel, StreamingDefinition, ConceptTags,
                        # LoadingBloom, EmptyState, Legend, ZoomControls
  layout/               # Toolbar
lib/
  types.ts              # all shared types — source of truth
  colour.ts             # getNodeColour, CATEGORY_COLOURS
  graph.ts              # createCoreNode, addExpansionNodes, recentreGraph, pruneGraph, exportGraph
  force.ts              # D3 simulation config
  context/
    GraphContext.tsx     # GraphStateContext, graphReducer
  ai/
    expand.ts           # buildExpansionPrompt, parseExpansionResponse
    define.ts           # buildDefinitionPrompt, parseDefinitionResponse
tests/
  lib/                  # colour, graph, ai unit tests
  components/           # RTL component tests
  mocks/
    anthropic.ts        # shared Anthropic SDK mock

Built by Ricardo Monterrosa

About

bloom — An interactive concept exploration tool that maps ideas as a living graph. Enter any concept and watch it bloom into a web of related ideas across awareness, identity, and experience — powered by Claude AI and animated with D3 force physics.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors