@hively/living-hive
v2.0.0
Published
A React library for visualizing interconnected stories using UMAP and hex grid layouts
Maintainers
Readme
Living Hive React
A React library for visualizing interconnected stories using UMAP dimensionality reduction and interactive hex grid layouts.
Features
- 🎨 Interactive hex grid visualization with zoom and pan
- 🤖 OpenAI integration for story embeddings and theme generation
- 🎯 Automatic theme extraction from stories
- 🎨 Customizable color palettes (defaults to warm palette)
- ♿ Full keyboard navigation and accessibility support
- ⚡ Web worker-based UMAP computation for smooth performance
- 🎭 Customizable story rendering
- 🔄 Support for both client-side and server-side embedding generation
- 💾 Pre-generated embeddings and themes support for faster loading
- 🚀 Optimized batch API calls with retry logic and rate limiting
- 📊 Theme legend with story counts
Installation
npm install @hively/living-hivePeer Dependencies
This library requires the following peer dependencies:
npm install react react-dom tailwindcss class-variance-authority clsx tailwind-mergeQuick Start
Basic Usage
You need to generate embeddings and themes before passing them to the LivingHive component. Living Hive does not include network helpers in the public API, so you can source embeddings and themes from your own data pipeline or reuse the mock data shipped in examples/.
import { LivingHive, DEFAULT_WORKER_URL, type BaseStory, type Theme } from '@hively/living-hive'
import { useMemo, useState } from 'react'
function App() {
const stories = [
{ id: '1', text: 'My first story about teamwork...' },
{ id: '2', text: 'Another story about collaboration...' },
]
// Provide embeddings and themes from your own data pipeline.
const [embeddings] = useState(
() =>
new Map<string, number[]>([
['1', [0.1, 0.2, 0.3]],
['2', [0.3, 0.1, 0.4]],
]),
)
const [themes] = useState<Theme[]>([
{ id: 'teamwork', label: 'Teamwork' },
{ id: 'collaboration', label: 'Collaboration' },
])
return (
<LivingHive
stories={stories}
embeddings={embeddings}
themes={themes}
workerUrl={DEFAULT_WORKER_URL}
/>
)
}With Pre-generated Embeddings and Themes
For faster loading and to avoid API calls, you can pre-generate embeddings and themes:
import { LivingHive, type BaseStory, type Theme } from '@hively/living-hive'
const stories: BaseStory[] = [
{ id: '1', text: 'Story about teamwork' },
{ id: '2', text: 'Story about collaboration' },
]
const embeddings = new Map<string, number[]>(
stories.map((story, index) => [story.id, [index / 10, index / 5, index / 2]]),
)
const themes: Theme[] = [
{ id: 'teamwork', label: 'Teamwork' },
{ id: 'collaboration', label: 'Collaboration' },
]
export function App() {
return <LivingHive stories={stories} embeddings={embeddings} themes={themes} />
}Note: When using pre-generated embeddings and themes, no API calls are made. This is ideal for deployed examples or when you want to avoid API costs.
Configuring the UMAP worker asset
The UMAP algorithm runs in a standalone worker that ships with the package at dist/workers/umap-worker.js.
Recommended approach (works with any bundler):
Pass the worker URL explicitly using your bundler's worker import syntax:
Vite:
import { LivingHive } from '@hively/living-hive'
// Option 1: Use the source worker path (recommended for Vite)
import workerUrl from '@hively/living-hive/workers/umap-placement.worker?worker&url'
// Option 2: Use the built worker file directly
// import workerUrl from '@hively/living-hive/workers/umap-worker.js?worker&url'
;<LivingHive stories={stories} embeddings={embeddings} themes={themes} workerUrl={workerUrl} />Note: The library's package.json exports support both umap-placement.worker and umap-worker.js paths. Vite's ?worker&url query string will work with either.
Webpack 5+ (native worker support):
import { LivingHive } from '@hively/living-hive'
import Worker from '@hively/living-hive/workers/umap-placement.worker?worker'
// Create worker URL using the Worker constructor
const workerUrl = new URL(Worker, import.meta.url).href
<LivingHive
stories={stories}
embeddings={embeddings}
themes={themes}
workerUrl={workerUrl}
/>Webpack 4 or older (with worker-loader):
import { LivingHive } from '@hively/living-hive'
// Configure worker-loader in your webpack config
;<LivingHive
stories={stories}
embeddings={embeddings}
themes={themes}
workerUrl={require('@hively/living-hive/workers/umap-worker.js')}
/>Manual setup (copy worker to public directory):
If your bundler doesn't support worker imports, you can manually copy the worker file:
- Copy
node_modules/@hively/living-hive/dist/workers/umap-worker.jsto yourpublic/workers/directory - Pass the URL:
<LivingHive
stories={stories}
embeddings={embeddings}
themes={themes}
workerUrl="/workers/umap-worker.js"
/>Automatic resolution (fallback):
If you don't provide workerUrl, the library will attempt to auto-resolve it using import.meta.url. This may work with some modern bundlers, but results vary. It's recommended to pass workerUrl explicitly for reliable behavior across all bundlers.
Environment variables (alternative):
You can also set the worker URL via environment variables that your bundler exposes:
VITE_LIVING_HIVE_WORKER_URL(Vite)NEXT_PUBLIC_LIVING_HIVE_WORKER_URL(Next.js)LIVING_HIVE_WORKER_URL(generic)
With Custom Themes
import { LivingHive, StoryDataGenerator, type Theme } from '@hively/living-hive'
import { useState, useEffect } from 'react'
function App() {
const [embeddings, setEmbeddings] = useState<Map<string, number[]>>(new Map())
const themes: Theme[] = [
{ id: 'teamwork', label: 'Teamwork' },
{ id: 'collaboration', label: 'Collaboration' },
]
useEffect(() => {
const generator = new StoryDataGenerator(stories, 'your-api-key-here')
generator.generateEmbeddings().then(setEmbeddings)
}, [stories])
return <LivingHive stories={stories} embeddings={embeddings} themes={themes} />
}With Custom Color Palette
const customPalette = ['#FF6E7F', '#4F81B0', '#CDB15E', '#DAA5AD', '#AEBEC5']
// Assuming `stories`, `embeddings`, and `themes` are defined as in the basic example
<LivingHive
stories={stories}
embeddings={embeddings}
themes={themes}
colorPalette={customPalette}
/>With Custom Story Rendering
<LivingHive
stories={stories}
embeddings={embeddings}
themes={themes}
renderStory={story => (
<div>
<h3>{story.title}</h3>
<p>{story.text}</p>
{story.metadata && <span>{story.metadata}</span>}
</div>
)}
/>With Extended Story Types
interface MyStory extends BaseStory {
title: string
author: string
date: string
}
const stories: MyStory[] = [
{
id: '1',
text: 'Story content...',
title: 'My Story',
author: 'John Doe',
date: '2024-01-01',
},
]
const embeddings = new Map<string, number[]>([['1', [0.1, 0.2, 0.3]]])
const themes: Theme[] = [{ id: 'teamwork', label: 'Teamwork' }]
<LivingHive<MyStory>
stories={stories}
embeddings={embeddings}
themes={themes}
renderStory={(story) => (
<div>
<h3>{story.title}</h3>
<p>{story.text}</p>
<small>By {story.author} on {story.date}</small>
</div>
)}
/>Understanding Embeddings and Themes
Embeddings are numerical vectors that capture the meaning of each story. Living Hive typically generates them with OpenAI's text-embedding-3-small model, which converts every story's text into a fixed-length array of numbers. Stories that discuss similar ideas end up with vectors that are close together in multidimensional space. The visualization uses those distances to place related stories near one another when UMAP projects the vectors down to the hex grid.
Themes are concise narrative labels that explain why clusters of stories belong together. After embeddings are computed, Living Hive prompts a language model (defaults to gpt-4-turbo-preview) to summarize the clusters and produce human-readable theme names. You can think of themes as the legend for the map: they describe the neighborhoods formed by the embeddings and act as the connective tissue between the qualitative stories and the quantitative placement.
Both embeddings and themes can be generated anywhere—client, server, or build scripts—and then shipped with your app. The component simply needs a Map of story IDs to embedding vectors plus an array of theme objects; how you produce them is up to your data pipeline.
Custom Styling
LivingHive ships with a set of CSS custom properties so you can restyle the experience without forking the component. The library injects the styles automatically; opt into the default charcoal palette by wrapping the component in the provided .living-hive-theme class:
export function App() {
return (
<div className="living-hive-theme">
<LivingHive stories={stories} embeddings={embeddings} themes={themes} />
</div>
)
}Override any variable by scoping new values to your own class or element:
.my-product-hive {
--living-hive-canvas-background: #ffffff;
--living-hive-canvas-outline: rgba(28, 28, 28, 0.12);
--living-hive-toggle-bg: rgba(255, 255, 255, 0.85);
--living-hive-legend-background: rgba(28, 28, 28, 0.85);
--living-hive-dialog-color: #1c1c1c;
}<div className="my-product-hive">
<LivingHive stories={stories} embeddings={embeddings} themes={themes} canvasHeight={480} />
</div>Available variables include:
| Variable | Purpose |
| --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| --living-hive-width | Canvas width (default 100%) |
| --living-hive-height | Canvas height (default calc(100vh - 312px)) |
| --living-hive-canvas-background | Canvas background color shown behind hexes |
| --living-hive-canvas-outline | Canvas border color |
| --living-hive-canvas-border-radius | Canvas corner radius |
| --living-hive-focus-ring / --living-hive-focus-ring-offset | Focus ring colors for keyboard users |
| --living-hive-toggle-bg / --living-hive-toggle-bg-hover / --living-hive-toggle-color / --living-hive-toggle-border | Fullscreen toggle palette |
| --living-hive-legend-background / --living-hive-legend-border / --living-hive-legend-color / --living-hive-legend-opacity | Legend chip visuals |
You can also supply explicit dimensions via the new canvasWidth and canvasHeight props. Numbers are treated as pixel values (e.g. canvasHeight={480}), while strings accept any CSS length (e.g. canvasHeight="70vh"). When these props are omitted, the component falls back to the CSS custom properties so host applications can manage sizing through styles alone.
Handling Hex Clicks
The LivingHive component provides an onHexClick callback that fires when a hex is clicked or activated via keyboard navigation. You can use this to implement your own dialog, popover, or modal solution.
Important: As of version 1.0.0, the component no longer includes built-in dialog/popover components. You must implement your own UI for displaying story details.
Basic Example
import { LivingHive } from '@hively/living-hive'
import { useState, useCallback } from 'react'
// Import your preferred dialog/popover component
import { Dialog, DialogContent } from './components/Dialog'
function App() {
const [selectedStory, setSelectedStory] = useState(null)
const [selectedTheme, setSelectedTheme] = useState(null)
const [isDialogOpen, setIsDialogOpen] = useState(false)
// Wrap in useCallback to prevent unnecessary re-renders
const handleHexClick = useCallback((story, theme) => {
setSelectedStory(story)
setSelectedTheme(theme)
setIsDialogOpen(true)
}, [])
return (
<>
<LivingHive
stories={stories}
embeddings={embeddings}
themes={themes}
onHexClick={handleHexClick}
/>
{selectedStory && (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
{selectedTheme && <h2>{selectedTheme.label}</h2>}
<p>{selectedStory.text}</p>
</DialogContent>
</Dialog>
)}
</>
)
}Complete Example
See examples/src/examples/BasicExample.tsx for a complete example including a side panel dialog implementation.
Generating Embeddings and Themes
Living Hive ships with the StoryDataGenerator class for producing embeddings and themes. Use it client-side, server-side, or in build scripts—whichever best fits your data pipeline.
Using StoryDataGenerator
The StoryDataGenerator class stores stories and API key once, making it easy to generate embeddings and themes:
import { StoryDataGenerator } from '@hively/living-hive'
// Create generator with stories and API key
const generator = new StoryDataGenerator(stories, apiKey)
// Generate embeddings
const embeddings = await generator.generateEmbeddings({
model: 'text-embedding-3-small',
dimensions: 384,
batchSize: 100,
})
// Generate themes using the embeddings
const themes = await generator.generateThemes(embeddings, {
model: 'gpt-4-turbo-preview',
minThemes: 5,
maxThemes: 10,
})
// Assign stories to themes (synchronous)
const assignments = generator.assignStoriesToThemes(embeddings, themes)Client-Side Usage (React)
import { StoryDataGenerator } from '@hively/living-hive'
import { useState, useEffect } from 'react'
function MyComponent() {
const [embeddings, setEmbeddings] = useState<Map<string, number[]>>(new Map())
const [themes, setThemes] = useState<Theme[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
const generateData = async () => {
setLoading(true)
try {
const generator = new StoryDataGenerator(stories, apiKey)
const embeddings = await generator.generateEmbeddings()
const themes = await generator.generateThemes(embeddings)
setEmbeddings(embeddings)
setThemes(themes)
} finally {
setLoading(false)
}
}
generateData()
}, [stories])
return <LivingHive stories={stories} embeddings={embeddings} themes={themes} loading={loading} />
}Server-Side Usage (API Routes)
// In Next.js API route or similar
import { StoryDataGenerator } from '@hively/living-hive'
export async function GET(request: Request) {
const stories = await getStories()
const generator = new StoryDataGenerator(stories, process.env.OPENAI_API_KEY)
const embeddings = await generator.generateEmbeddings()
const themes = await generator.generateThemes(embeddings)
return Response.json({ embeddings, themes })
}Script Usage (Pre-computation)
// In build script or pre-computation script
import { StoryDataGenerator } from '@hively/living-hive'
import fs from 'fs'
const stories = [...]
const generator = new StoryDataGenerator(stories, process.env.OPENAI_API_KEY)
const embeddings = await generator.generateEmbeddings({
model: 'text-embedding-3-small',
dimensions: 384,
batchSize: 100
})
const themes = await generator.generateThemes(embeddings, {
model: 'gpt-4-turbo-preview',
minThemes: 5,
maxThemes: 10
})
// Save to JSON files for use in component
fs.writeFileSync('embeddings.json', JSON.stringify(Object.fromEntries(embeddings)))
fs.writeFileSync('themes.json', JSON.stringify(themes))OpenAI Parameter Configuration
StoryDataGenerator accepts configuration options for both embeddings and themes:
Embedding Options:
model?: string- Embedding model (default:"text-embedding-3-small")dimensions?: number- Embedding dimensions (default:384)batchSize?: number- Batch size for API calls (default:100)apiEndpoint?: string- Server endpoint URL (for server-side usage)onError?: (error: Error) => void- Error callback
Theme Options:
model?: string- Theme generation model (default:"gpt-4-turbo-preview")minThemes?: number- Minimum number of themes (default:5)maxThemes?: number- Maximum number of themes (default:10)apiEndpoint?: string- Server endpoint URL (for server-side usage)
Zoom and Pan
The visualization supports interactive zoom and pan:
- Scroll wheel: Zoom in/out (towards mouse cursor)
- Click and drag: Pan around the visualization
- Auto-fit: Automatically centers and zooms to fit all hexes on initial load
The canvas has a visible background and border to show the visualization boundaries.
API Reference
LivingHive Component
Props
| Prop | Type | Required | Default | Description |
| ---------------------- | -------------------------- | -------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------- |
| stories | Story<T>[] | Yes | - | Array of stories to visualize |
| embeddings | Map<string, number[]> | Yes | - | Pre-generated embeddings (Map of storyId to embedding vector). Can be empty Map if not yet loaded. |
| themes | Theme[] | Yes | - | Pre-generated themes. Can be empty array if not yet loaded. |
| loading | boolean | No | - | Loading state to show shimmer while data is being fetched |
| openaiApiKey | string | No | - | Not used by component (only needed when using helper utilities like StoryDataGenerator) |
| apiEndpoint | string | No | - | Custom endpoint used by helper utilities in server-side mode |
| colorPalette | string[] | No | Warm palette | Array of hex color strings |
| onHexClick | (story, theme) => void | No | - | Callback when a hex is clicked or activated via keyboard navigation |
| onError | (error) => void | No | - | Error handler callback |
| onThemesChange | (themes) => void | No | - | Callback when themes are updated |
| onAssignmentsChange | (assignments) => void | No | - | Callback when story-to-theme assignments change |
| className | string | No | - | Additional CSS classes |
| canvasWidth | number \| string | No | 100% | Optional canvas width. Numbers are treated as pixel values; strings can be any CSS length. |
| canvasHeight | number \| string | No | calc(100vh - 312px) | Optional canvas height. Numbers are treated as pixel values; strings can be any CSS length. |
| config | Partial<PlacementConfig> | No | - | Canvas/hex configuration (used as internal defaults for placement math). |
| workerUrl | string | No | prop → env → "/workers/umap-worker.js" | URL that resolves to the compiled UMAP worker asset. |
| throwIfMissingWorker | boolean | No | true | When false, the component surfaces worker issues via error state instead of throwing. |
Types
interface BaseStory {
id: string
text: string
}
interface Theme {
id: string
label: string
}
interface PlacementConfig {
canvasWidth: number
canvasHeight: number
hexRadius: number
margin: number
}StoryDataGenerator Class
const generator = new StoryDataGenerator<T extends BaseStory>(
stories: T[],
apiKey: string
)
// Methods:
await generator.generateEmbeddings(options?: GenerateEmbeddingsOptions): Promise<Map<string, number[]>>
await generator.generateThemes(embeddings?: Map<string, number[]>, options?: GenerateThemesOptions): Promise<Theme[]>
generator.assignStoriesToThemes(embeddings?: Map<string, number[]>, themes?: Theme[]): Map<string, string>useUMAPPlacement Hook
If you prefer a hook for computing coordinates inside your own components, useUMAPPlacement remains available. It accepts the same workerUrl and throwIfMissingWorker options as the component and falls back to the environment variables / default URL when no prop is supplied:
import { useUMAPPlacement, DEFAULT_WORKER_URL } from '@hively/living-hive'
const { computePlacement, loading, error } = useUMAPPlacement({
workerUrl: DEFAULT_WORKER_URL,
throwIfMissingWorker: false,
})Interaction
Keyboard Navigation
- Arrow Keys: Navigate between hexes
- Enter/Space: Trigger
onHexClickcallback for focused hex - Escape: Clear selection
Mouse Controls
- Scroll Wheel: Zoom in/out (towards cursor position)
- Click and Drag: Pan around the visualization
- Click Hex: Trigger
onHexClickcallback with story and theme data
Accessibility
The component includes:
- ARIA labels and roles
- Keyboard navigation support
- Focus management
- Screen reader friendly
Error Handling
Errors can be handled in two ways:
- Callback: Use the
onErrorprop for error handling integration (e.g., Sentry) - Try/Catch: Errors are also thrown and can be caught in try/catch blocks
<LivingHive
stories={stories}
embeddings={embeddings}
themes={themes}
onError={error => {
// Send to error tracking service
Sentry.captureException(error)
}}
/>Server-Side Setup
For server-side embedding generation, create an API endpoint that accepts:
{
"text": "Story text here"
}And returns:
{
"embedding": [0.1, 0.2, ...]
}Note: If you're using server-side embeddings (apiEndpoint), you can also provide a server-side theme generation endpoint. The library will automatically call {apiEndpoint.replace('/embeddings', '/themes')} for theme generation if using server-side mode.
Example Netlify Function:
import { Handler } from '@netlify/functions'
export const handler: Handler = async event => {
const { text } = JSON.parse(event.body || '{}')
const response = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
input: text,
model: 'text-embedding-3-small',
dimensions: 384,
}),
})
const data = await response.json()
return {
statusCode: 200,
body: JSON.stringify({ embedding: data.data[0].embedding }),
}
}Development Scripts
Building the package
# Compile the worker asset and library bundles
npm run build
# Or run each step individually
npm run build:worker
npm run build:libraryQuality and Testing
Run these commands before opening a pull request:
# Static analysis
npm run lint
npm run format:check
npm run type-check
# Unit tests
npm testData Utilities
The project includes several utility scripts:
# Fetch stories from Reddit and sanitize with OpenAI
npm run fetch-stories
# Generate embeddings for sample stories (saves to mockEmbeddings.json)
npm run regenerate-embeddings
# Generate themes for sample stories (saves to mockThemes.json)
npm run regenerate-themesThese scripts require an OpenAI API key in .env.local:
VITE_OPENAI_API_KEY=sk-your-key-hereExamples
See the examples/ directory for a complete working example:
- Basic Example: Features stories from r/work Reddit, theme legend, zoom/pan controls, and support for pre-generated embeddings/themes
To run the example:
# Install dependencies (from project root)
npm install
# Set up environment variables (see .env.local.example)
cp .env.local.example .env.local
# Edit .env.local and add your OpenAI API key
# Run the example app
npm run devThe examples app will start at http://localhost:5173.
Using Mock Mode (No API Calls)
Deployment to Netlify
The examples directory includes a netlify.toml configuration file. To deploy:
- Push your code to a Git repository
- Connect the repository to Netlify
- Set build command:
npm install && npm run build:examples - Set publish directory:
examples/dist
Default Color Palette
The default warm color palette includes:
#4F81B0- Blue#AEBEC5- Light Gray#DAA5AD- Pink#FF6E7F- Coral#CDB15E- Gold
Performance Considerations
- UMAP computation runs in a web worker to avoid blocking the UI
- Embeddings use batch API calls (up to 100 stories per request) for efficiency
- Built-in caching prevents duplicate embedding generation
- Retry logic with exponential backoff handles rate limiting gracefully
- Pre-generated embeddings/themes eliminate API calls entirely
- Canvas rendering is optimized for smooth interactions
- Zoom and pan are hardware-accelerated
Version History
See CHANGELOG.md for a complete list of changes.
Breaking Changes in 1.0.0
Version 1.0.0 includes breaking changes to simplify the API and give you more control over dialog/popover implementations:
- Removed internal Dialog/Popover: The component no longer manages dialogs internally
- Removed Popover exports:
Popover,PopoverTrigger,PopoverContentare no longer available - Removed props:
dialogConfigandrenderStoryprops have been removed
Migration required: If you're upgrading from 0.4.0, see MIGRATION.md for detailed migration instructions.
License
MIT
Contributing
Contributions are welcome! See CONTRIBUTING.md for detailed guidelines on
development workflow, testing, and pull request expectations.
