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
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.
| 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 |
| Technology | Usage |
|---|---|
| Anthropic Claude Sonnet 4.6 | Concept expansion (ring1 + ring2) and streaming definitions |
| @anthropic-ai/sdk | SSE streaming via stream.toReadableStream() for expansion |
| 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 |
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 prioritycategory(awareness / identity / experiential) — drives colour coding independently
A node can be semantically close but experiential in nature. The two fields don't interfere.
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.
Clicking "Expand this concept" on any node triggers a state promotion cascade:
- Clicked node →
ring: 'core', pinned at(0, 0) - Old core →
ring: 'ring2', fixed position released - Old ring1 nodes still connected to the new core →
ring: 'ring2' - Old ring1 nodes not connected →
ring: 'ring3'(pruning candidates) pruneGraphenforces the 40-node hard cap — ring3 nodes without definitions pruned first, core and ring1 never touched- 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.
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.
- 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;
LoadingBloomanimation 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;
AbortControllercancels 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"withtabIndex, 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
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
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 devANTHROPIC_API_KEY=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 thresholdsapp/
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