Bible Cross-References
Technical Reconstruction
A comprehensive technical analysis and reconstruction plan for the Bible cross-reference arc visualization — covering 344,799 cross-references across 31,102 verses in 66 books.
Live Visualization Demo
Interactive D3.js arc diagram built with the techniques documented in this analysis. Scroll to zoom, drag to pan, hover arcs for details.
Loading visualization...
Phase 1: UX Inventory
3 subsections
UI Elements & Layout Structure
The original Bible Cross-References visualization at trekhleb.dev presents a full-viewport, single-page application with two primary views: Arcs and Threads.
Header Bar (50px height, full-width):
- •Title: "Bible Cross-References" in Iowan Old Style, 18px, weight 500
- •Segmented control: Arcs | Threads toggle (pill-shaped, #161615 background, 8px border-radius, 1px solid #2c2b28)
- •Translation selector: BSB dropdown
- •Filters button
- •Search input (right-aligned, 352px wide)
Main Visualization Area (full remaining viewport):
- •Background: solid #0d0d0d
- •The arc diagram occupies 100% width × ~85% height
- •Horizontal baseline near bottom showing all 66 books
- •Book labels in uppercase at the baseline ("OLD TESTAMENT" / "NEW TESTAMENT" dividers)
- •Stats bar showing: "31,102 verses · 153,419 of 344,799 links shown"
- •Interaction hints: "scroll to zoom · drag to pan · click to focus"
Responsive Behavior:
- •The visualization is zoom/pan-capable via scroll and drag
- •On narrower viewports, book labels compress or hide
- •The header remains fixed at top
- •No dedicated mobile breakpoint detected; relies on pan/zoom for small screens
Interactions & User Flows
Zoom/Pan System:
- •Mouse wheel: zoom in/out centered on cursor position
- •Click + drag: pan the visualization horizontally
- •Click on book label: zoom to and highlight that book's references
- •Click on individual arc: show tooltip with reference details
Filtering:
- •Genre filter: Law, History, Poetry, Prophecy, Gospels, Epistles, Apocalyptic
- •Testament filter: Old Testament, New Testament, Inter-testament
- •Arc opacity responds to filter state (non-matching arcs fade to ~5% opacity)
Threads View (secondary mode):
- •Displays chapter-level reading view
- •Shows bar chart of connection counts across all 66 books
- •Chapter text with highlighted verses that have cross-references
- •Click a verse to see its connections
Performance Observations:
- •344,799 cross-references rendered as SVG path elements
- •Uses virtual rendering — only arcs in the viewport are drawn
- •Smooth 60fps pan/zoom via requestAnimationFrame
- •Service Worker caches all data for offline use
Visual Design Tokens
Color Palette (extracted from computed styles):
| Token | Value | Usage |
|---|---|---|
| Background Primary | #0d0d0d | Main canvas, ~77% of area |
| Background Secondary | #161615 | Cards, header elements |
| Text Primary | #ecebe6 | Headings, body text |
| Text Secondary | #9a9890 | Labels, muted content |
| Border | #2c2b28 | Element borders |
| Arc colors | HSL-mapped | Hue from book genre |
Typography:
| Role | Font | Weight | Size |
|---|---|---|---|
| Title | Iowan Old Style / Palatino | 500 | 18px |
| Body | system-ui stack | 400 | 15px |
| Labels | system-ui stack | 400 | 12-13px |
| Stats | system-ui stack | 400 | 14px |
Spacing: 8px base grid, 16px padding on header, 2px pill controls
Border Radius: 0px for buttons, 8px for segmented controls
Shadows: None detected — flat design language
| Element | Background | Text | Border | Radius |
|---|---|---|---|---|
| Page | #0d0d0d | #ecebe6 | none | 0 |
| Header | transparent | #ecebe6 | none | 0 |
| Segmented Control | #161615 | #ecebe6 | #2c2b28 | 8px |
| Active Segment | #2c2b28 | #ecebe6 | none | 6px |
| Search Input | #161615 | #9a9890 | #2c2b28 | 6px |
| Filter Button | transparent | #9a9890 | #2c2b28 | 6px |
Phase 2: Tech Stack Analysis
2 subsections
Framework & Build System
Confirmed Technology Stack (from GitHub repository analysis):
| Technology | Purpose | Confidence |
|---|---|---|
| TypeScript | Primary language | ✅ 100% — repo is 95%+ TS |
| Vite | Build tool & dev server | ✅ 100% — vite.config.ts present |
| React | UI framework | ✅ 100% — JSX components |
| Service Worker | Offline PWA | ✅ 100% — sw/ directory |
| GitHub Pages | Hosting | ✅ 100% — static deploy |
| CSS Modules | Styling | ✅ 100% — .module.css files |
Key Architectural Decisions in Original:
- •No server-side rendering — pure client-side SPA
- •No external UI framework (no Material UI, Tailwind, etc.)
- •CSS Modules with BEM-like naming (
_view_ul7e8_4) - •Custom data pipeline in
scripts/data/for build-time processing - •Data validated against canonical KJV versification (31,102 verses)
Our Reconstruction Stack:
| Technology | Purpose | Rationale |
|---|---|---|
| Next.js + React 19 | App framework | SSR support, API routes, better SEO |
| TypeScript | Type safety | Match original |
| D3.js | Visualization | Industry standard for arc diagrams |
| PostgreSQL + Prisma | Data persistence | Queryable cross-reference storage |
| Tailwind CSS | Styling | Rapid development, design tokens |
| SVG rendering | Arc diagram | Matches original approach |
Rendering Engine Analysis
SVG vs Canvas Investigation:
The original site uses SVG for rendering arcs. Evidence:
- •DOM inspection shows
elements within an SVG container - •CSS class names on individual paths (
_arc_xxxx) - •Per-element mouse event handling (hover/click on individual arcs)
- •Style attributes including stroke-opacity manipulation
Performance Strategy:
- •Virtual viewport rendering: only arcs intersecting the visible area are in the DOM
- •Arcs outside viewport are removed from DOM on pan/zoom
- •Transform-based zoom (CSS
transform: scale()+translate()) rather than re-rendering - •requestAnimationFrame for smooth 60fps interactions
- •Total data loaded once (~2MB gzipped), filtered client-side
Canvas was NOT used because:
1. Individual arc interaction (hover tooltips) requires hit-testing — easier with SVG elements
2. Color/opacity per-arc styling is more natural in SVG
3. The site achieves smooth performance with virtual rendering
4. Accessibility: SVG elements can carry ARIA attributes
Our reconstruction will use SVG with the same virtual rendering strategy, implemented via D3.js selections.
Phase 3: Data Architecture
3 subsections
Data Sources & Formats
Primary Data Sources:
1. Cross-Reference Data: Treasury of Scripture Knowledge via OpenBible.info
- •License: CC BY 4.0
- •Format: TSV/CSV with verse-to-verse mappings
- •Total: 344,799 cross-reference pairs
- •Each entry:
from_verse → to_versewith optional vote/confidence weight
2. Bible Text: Berean Standard Bible (BSB)
- •License: Free for use with attribution
- •Format: JSON per chapter
- •KJV also available as optional download
3. Versification: Standard Protestant canon
- •66 books, 1,189 chapters, 31,102 verses
- •Absolute verse indexing: verse 1 = Genesis 1:1, verse 31102 = Revelation 22:21
Original Data Pipeline (from scripts/data/):
1. Fetch raw TSV from OpenBible.info
2. Validate against KJV versification checksums (SHA-256)
3. Parse into normalized JSON structure
4. Build optimized lookup indices
5. Output: static JSON files bundled with the app
Our Data Pipeline:
1. Parse the same public TSV source
2. Load into PostgreSQL via Prisma seed script
3. Build REST API with filtering/pagination
4. Cache frequently-accessed queries
Entity Relationship Model
Core Entities:
BibleBook (66 records)
├── id: 1-66 (canonical order)
├── name: "Genesis" .. "Revelation"
├── abbreviation: "Gen" .. "Rev"
├── testament: "OT" | "NT"
├── genre: "Law" | "History" | ...
├── chapters: number of chapters
├── verses: total verse count
├── startVerse: cumulative offset
└── color: HSL string for visualization
CrossReference (~344,799 records)
├── fromBook, fromChapter, fromVerse
├── toBook, toChapter, toVerse
├── fromAbsolute: computed verse position (1-31102)
├── toAbsolute: computed verse position (1-31102)
└── votes: confidence weight
Verse (31,102 records — optional for full text)
├── book, chapter, verse
├── absolutePosition
└── text: verse contentAbsolute Verse Calculation:
absolutePosition = sum(verses in all preceding books) + sum(verses in preceding chapters of current book) + verse_number
This linear mapping is the foundation of the x-axis in the arc visualization.
-- PostgreSQL DDL for Bible Cross-References
CREATE TABLE bible_books (
id INTEGER PRIMARY KEY,
name VARCHAR(50) NOT NULL,
abbreviation VARCHAR(10) NOT NULL,
testament VARCHAR(2) NOT NULL CHECK (testament IN ('OT', 'NT')),
genre VARCHAR(20) NOT NULL,
chapters INTEGER NOT NULL,
verses INTEGER NOT NULL,
start_verse INTEGER NOT NULL, -- cumulative offset
color VARCHAR(30) NOT NULL
);
CREATE TABLE cross_references (
id SERIAL PRIMARY KEY,
from_book INTEGER NOT NULL REFERENCES bible_books(id),
from_chapter INTEGER NOT NULL,
from_verse INTEGER NOT NULL,
to_book INTEGER NOT NULL REFERENCES bible_books(id),
to_chapter INTEGER NOT NULL,
to_verse INTEGER NOT NULL,
from_absolute INTEGER NOT NULL,
to_absolute INTEGER NOT NULL,
votes INTEGER DEFAULT 1
);
-- Performance indexes
CREATE INDEX idx_xref_from ON cross_references(from_book, from_chapter);
CREATE INDEX idx_xref_to ON cross_references(to_book, to_chapter);
CREATE INDEX idx_xref_from_abs ON cross_references(from_absolute);
CREATE INDEX idx_xref_to_abs ON cross_references(to_absolute);Cross-Reference Data Format
Raw TSV Format (from OpenBible.info):
From Verse To Verse Votes
Gen.1.1 John.1.1 25
Gen.1.1 Heb.11.3 18
Gen.1.1 Psa.33.6 15
...```
**Parsed JSON Format** (for API responses):{
"crossReferences": [
{
"from": { "book": 1, "chapter": 1, "verse": 1, "absolute": 1 },
"to": { "book": 43, "chapter": 1, "verse": 1, "absolute": 26046 },
"votes": 25
}
],
"meta": {
"total": 344799,
"filtered": 153419,
"books": 66,
"verses": 31102
}
}
Phase 4: Visualization Reverse Engineering
4 subsections
Coordinate System & Mapping
The Linear Verse Space:
The entire Bible is mapped to a single horizontal axis. Each of the 31,102 verses occupies an equal-width slot:
x(verse) = (absolutePosition / totalVerses) × viewportWidth
This creates a proportional layout where longer books (Psalms: 2,461 verses) take more horizontal space than shorter ones (Obadiah: 21 verses).
Viewport Coordinate System:
- •Origin (0,0): top-left of SVG container
- •X-axis: 0 → viewportWidth (verse space)
- •Y-axis: 0 (top, where arc peaks reach) → baselineY (where the verse line sits)
- •Baseline typically at 80-85% of container height
Zoom Transform:
The visualization uses a D3-style zoom transform {k, tx, ty}:
- •
k: scale factor (1 = fit all, higher = zoomed in) - •
tx: horizontal translation - •
ty: vertical translation (typically 0) - •Screen position:
screenX = x × k + tx
Arc Rendering Mathematics
Arc Path Generation:
Each cross-reference is rendered as a circular arc connecting two points on the baseline. The arc is an SVG using a quadratic Bézier curve.
Algorithm:
1. Compute start x: x1 = (fromAbsolute / 31102) × width
2. Compute end x: x2 = (toAbsolute / 31102) × width
3. Compute midpoint: mx = (x1 + x2) / 2
4. Compute arc height: h = Math.sqrt(Math.abs(x2 - x1)) × scaleFactor
- •The square root produces a sublinear relationship: short arcs rise quickly, long arcs grow more gently
- •
scaleFactoris typically 0.4-0.6 of the viewport height
5. Control point: cy = baselineY - h
6. SVG path: M x1,baselineY Q mx,cy x2,baselineY
Alternative height formulas observed in similar projects:
- •Linear:
h = |x2 - x1| × 0.5(arcs too tall for distant references) - •Logarithmic:
h = log(|x2 - x1| + 1) × scale(more compression) - •Square root:
h = sqrt(|x2 - x1|) × scale← used in original
Why square root?
It provides the best visual balance:
- •Short-distance refs (within same book): visible, proportional arcs
- •Long-distance refs (OT → NT): arcs tall but not overwhelming
- •The rainbow effect emerges naturally from this distribution
interface ArcData {
fromAbsolute: number;
toAbsolute: number;
bookIndex: number;
}
const TOTAL_VERSES = 31102;
function generateArcPath(
arc: ArcData,
width: number,
baselineY: number,
heightScale: number = 0.5
): string {
const x1 = (arc.fromAbsolute / TOTAL_VERSES) * width;
const x2 = (arc.toAbsolute / TOTAL_VERSES) * width;
const mx = (x1 + x2) / 2;
const distance = Math.abs(x2 - x1);
const h = Math.sqrt(distance) * heightScale;
const cy = baselineY - h;
return `M ${x1},${baselineY} Q ${mx},${cy} ${x2},${baselineY}`;
}
function getArcColor(bookIndex: number, totalBooks: number = 66): string {
const hue = (bookIndex / totalBooks) * 360;
return `hsl(${hue}, 80%, 55%)`;
}Color Generation System
HSL Color Mapping:
Each arc is colored based on the source book's genre, creating visual clusters:
| Genre | Books | Hue Range | Example Color |
|---|---|---|---|
| Law | Gen-Deut (1-5) | 0-30° | Red-Orange |
| History | Josh-Esth (6-17) | 30-90° | Orange-Yellow |
| Poetry | Job-Song (18-22) | 90-130° | Yellow-Green |
| Major Prophets | Isa-Dan (23-27) | 130-170° | Green |
| Minor Prophets | Hos-Mal (28-39) | 170-220° | Cyan-Blue |
| Gospels | Matt-John (40-43) | 220-250° | Blue |
| Acts + Epistles | Acts-Jude (44-65) | 250-340° | Purple-Magenta |
| Apocalyptic | Rev (66) | 340-360° | Red |
Color Formula:
hue = (bookIndex / 66) × 360
saturation = 80%
lightness = 55%
opacity = 0.15 (default) → 0.8 (on hover/filter)The low default opacity (0.15) is critical — it allows 300K+ overlapping arcs to create the characteristic "rainbow glow" effect without becoming an opaque mass.
Filter Effects:
- •Matching arcs: opacity 0.6-0.8
- •Non-matching arcs: opacity 0.02-0.05
- •Hovered arc: opacity 1.0, stroke-width increases from 1px to 2px
- •Selected arc: opacity 1.0, tooltip shown
Rendering Performance Strategy
Challenge: Rendering 344,799 SVG path elements simultaneously would crash any browser.
Solution: Virtual Viewport Rendering:
1. Spatial Index: Pre-compute which arcs are visible at any zoom level
2. Viewport Culling: Only render arcs whose x-range intersects the visible area
3. Level of Detail (LOD):
- •Zoom < 1.5×: show only arcs with votes ≥ 5 (~50K arcs)
- •Zoom 1.5-3×: show arcs with votes ≥ 2 (~150K arcs)
- •Zoom > 3×: show all arcs in viewport
4. DOM Recycling: Reuse SVG path elements via D3's enter/update/exit pattern
5. Batch Updates: Use requestAnimationFrame to batch DOM mutations
Memory Optimization:
- •Arc data stored as typed arrays (Float32Array for positions)
- •Color computed on-the-fly from book index (no string storage)
- •Path strings generated lazily on render
Benchmark Targets:
| Metric | Target | Original (observed) |
|---|---|---|
| Initial render | < 500ms | ~300ms |
| Pan/zoom frame | < 16ms | ~8-12ms |
| Filter toggle | < 200ms | ~150ms |
| Memory usage | < 100MB | ~60-80MB |
Phase 5: Application Architecture
3 subsections
Component Hierarchy
Reconstructed Component Tree:
App (RootLayout)
├── Header
│ ├── Logo ("Bible Cross-References")
│ ├── ViewToggle (Arcs | Threads)
│ ├── TranslationSelector (BSB | KJV)
│ ├── FilterPanel
│ │ ├── GenreFilter
│ │ ├── TestamentFilter
│ │ └── VoteThreshold
│ └── SearchInput
├── ArcsView (main visualization)
│ ├── SVGCanvas
│ │ ├── BookBaseline
│ │ │ ├── BookLabel[] (66 labels)
│ │ │ └── TestamentDivider
│ │ ├── ArcLayer
│ │ │ └── ArcPath[] (virtual rendered)
│ │ └── ZoomControls
│ ├── StatsBar
│ └── Tooltip
├── ThreadsView (alternate)
│ ├── ChapterSelector
│ ├── VerseList
│ ├── ConnectionChart
│ └── BookBarChart
└── Footer
└── DataAttributionState Management:
- •View mode (arcs/threads): URL parameter or React state
- •Zoom transform: D3 zoom behavior (internal state)
- •Active filters: React state, lifted to App level
- •Selected verse/arc: React state with tooltip positioning
- •Data: loaded once into memory, filtered in-place
API Route Design
REST API Endpoints:
| Method | Path | Purpose |
|---|---|---|
| GET | /api/books | List all 66 books with metadata |
| GET | /api/cross-references | Paginated cross-references |
| GET | /api/cross-references/stats | Aggregate statistics |
| GET | /api/cross-references/book/:id | References for a specific book |
| GET | /api/visualization-data | Optimized arc data (typed arrays) |
| POST | /api/chat | LLM-powered analysis chat |
Query Parameters (for /api/cross-references):
- •
fromBook: filter by source book ID - •
toBook: filter by target book ID - •
testament: 'OT' | 'NT' | 'inter' - •
minVotes: minimum confidence threshold - •
limit: pagination limit (default 100) - •
offset: pagination offset
Response Format:
{
"data": [...],
"meta": {
"total": 344799,
"page": 1,
"limit": 100,
"hasMore": true
}
}import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
const books = await prisma.bibleBook.findMany({
orderBy: { id: 'asc' },
});
return NextResponse.json({
data: books,
meta: { total: books?.length ?? 0 },
});
} catch (error: any) {
console.error('Failed to fetch books:', error);
return NextResponse.json(
{ error: 'Failed to fetch books' },
{ status: 500 }
);
}
}State Management Design
State Architecture (Zustand recommended):
interface VisualizationStore {
// View state
viewMode: 'arcs' | 'threads';
setViewMode: (mode: 'arcs' | 'threads') => void;
// Filter state
filters: {
genres: string[];
testament: 'all' | 'OT' | 'NT' | 'inter';
minVotes: number;
searchQuery: string;
};
setFilter: (key: string, value: any) => void;
// Zoom state (managed by D3, mirrored here for UI)
zoomTransform: { k: number; x: number; y: number };
setZoomTransform: (t: { k: number; x: number; y: number }) => void;
// Selection
selectedArc: CrossReference | null;
hoveredBook: number | null;
// Data
books: BibleBook[];
crossReferences: CrossReference[];
isLoading: boolean;
}Why Zustand over Redux/Context:
- •Minimal boilerplate (no providers needed)
- •Direct D3 ↔ React state bridging
- •Selector-based re-rendering (only components using changed state re-render)
- •Simple async actions for API calls
Phase 6: Component Catalog
3 subsections
ArcCanvas Component
The primary visualization component — renders the SVG arc diagram.
'use client';
import { useRef, useEffect, useState, useCallback } from 'react';
import * as d3 from 'd3';
interface ArcCanvasProps {
data: {
fromAbsolute: number;
toAbsolute: number;
bookIndex: number;
votes: number;
}[];
books: {
id: number;
name: string;
abbreviation: string;
startVerse: number;
verses: number;
color: string;
}[];
width: number;
height: number;
filters?: {
genres?: string[];
testament?: string;
minVotes?: number;
};
onArcHover?: (arc: any | null) => void;
onArcClick?: (arc: any) => void;
}
const TOTAL_VERSES = 31102;
export function ArcCanvas({
data,
books,
width,
height,
filters,
onArcHover,
onArcClick,
}: ArcCanvasProps) {
const svgRef = useRef<SVGSVGElement>(null);
const baselineY = height * 0.82;
const heightScale = height * 0.5;
useEffect(() => {
if (!svgRef?.current || !data?.length) return;
const svg = d3.select(svgRef.current);
// Clear previous
svg.selectAll('.arc-group').remove();
const g = svg.append('g').attr('class', 'arc-group');
// Zoom behavior
const zoom = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([1, 50])
.on('zoom', (event: any) => {
g.attr('transform', event?.transform);
});
svg.call(zoom);
// Render arcs
const arcs = g.selectAll('.arc')
.data(data ?? [])
.enter()
.append('path')
.attr('class', 'arc')
.attr('d', (d: any) => {
const x1 = ((d?.fromAbsolute ?? 0) / TOTAL_VERSES) * width;
const x2 = ((d?.toAbsolute ?? 0) / TOTAL_VERSES) * width;
const mx = (x1 + x2) / 2;
const dist = Math.abs(x2 - x1);
const h = Math.sqrt(dist) * (heightScale / Math.sqrt(width));
return `M ${x1},${baselineY} Q ${mx},${baselineY - h} ${x2},${baselineY}`;
})
.attr('fill', 'none')
.attr('stroke', (d: any) => {
const hue = ((d?.bookIndex ?? 0) / 66) * 360;
return `hsl(${hue}, 80%, 55%)`;
})
.attr('stroke-width', 1)
.attr('stroke-opacity', 0.15)
.on('mouseenter', function(event: any, d: any) {
d3.select(this)
.attr('stroke-opacity', 1)
.attr('stroke-width', 2);
onArcHover?.(d);
})
.on('mouseleave', function() {
d3.select(this)
.attr('stroke-opacity', 0.15)
.attr('stroke-width', 1);
onArcHover?.(null);
})
.on('click', (event: any, d: any) => {
onArcClick?.(d);
});
// Book labels on baseline
g.selectAll('.book-label')
.data(books ?? [])
.enter()
.append('text')
.attr('class', 'book-label')
.attr('x', (d: any) => {
const start = ((d?.startVerse ?? 0) / TOTAL_VERSES) * width;
const bookWidth = ((d?.verses ?? 0) / TOTAL_VERSES) * width;
return start + bookWidth / 2;
})
.attr('y', baselineY + 16)
.attr('text-anchor', 'middle')
.attr('fill', '#9a9890')
.attr('font-size', '8px')
.text((d: any) => d?.abbreviation ?? '');
// Baseline
g.append('line')
.attr('x1', 0)
.attr('y1', baselineY)
.attr('x2', width)
.attr('y2', baselineY)
.attr('stroke', '#2c2b28')
.attr('stroke-width', 1);
return () => { svg.selectAll('.arc-group').remove(); };
}, [data, books, width, height, baselineY, heightScale, onArcHover, onArcClick]);
return (
<svg
ref={svgRef}
width={width}
height={height}
style={{ background: '#0d0d0d' }}
/>
);
}Header & Navigation Components
Header Component — matches original styling:
| Prop | Type | Purpose | |
|---|---|---|---|
| viewMode | 'arcs' | 'threads' | Current view |
| onViewChange | (mode) => void | Toggle handler | |
| onSearch | (query) => void | Search handler | |
| onFilterOpen | () => void | Opens filter panel |
Segmented Control — the Arcs/Threads toggle:
- •Container: #161615 bg, 8px radius, 1px #2c2b28 border
- •Active segment: #2c2b28 bg, 6px radius
- •Inactive segment: transparent
- •Transition: 150ms ease-out on background-color
- •Text: 15px system-ui, #ecebe6
'use client';
import { useState } from 'react';
interface SegmentedControlProps {
options: { value: string; label: string }[];
value: string;
onChange: (value: string) => void;
}
export function SegmentedControl({ options, value, onChange }: SegmentedControlProps) {
return (
<div className="flex rounded-lg border border-[#2c2b28] bg-[#161615] p-[2px]">
{(options ?? []).map((opt: any) => (
<button
key={opt?.value ?? ''}
onClick={() => onChange?.(opt?.value)}
className={`px-4 py-1 text-sm transition-all duration-150 rounded-md ${
value === opt?.value
? 'bg-[#2c2b28] text-[#ecebe6]'
: 'text-[#9a9890] hover:text-[#ecebe6]'
}`}
>
{opt?.label ?? ''}
</button>
))}
</div>
);
}Filter & Search Components
FilterPanel Component:
interface FilterPanelProps {
filters: {
genres: string[];
testament: 'all' | 'OT' | 'NT' | 'inter';
minVotes: number;
};
onChange: (filters: FilterPanelProps['filters']) => void;
isOpen: boolean;
onClose: () => void;
}Genre Checkboxes: Law, History, Poetry, Major Prophets, Minor Prophets, Gospels, Acts, Epistles, Apocalyptic
Testament Radio: All, Old Testament, New Testament, Inter-testament
Vote Threshold Slider: 1-50 (controls minimum votes for displayed arcs)
Search Component:
- •Debounced input (300ms)
- •Searches book names, chapter:verse references
- •Results highlight matching arcs on the visualization
- •Keyboard shortcut: / to focus
Phase 7: Build Plan (7-Sprint Roadmap)
3 subsections
Sprint Overview
7-Sprint Development Roadmap (2-week sprints):
| Sprint | Focus | Key Deliverables | Story Points |
|---|---|---|---|
| 1 | Foundation | Project setup, DB schema, data pipeline | 21 |
| 2 | Data Layer | API routes, seed script, data validation | 18 |
| 3 | Core Visualization | SVG canvas, arc rendering, baseline | 34 |
| 4 | Interactions | Zoom/pan, hover, click, tooltips | 26 |
| 5 | Filtering & Search | Genre/testament filters, search, URL state | 21 |
| 6 | Threads View | Chapter reader, connection chart, book bars | 26 |
| 7 | Polish & Deploy | Performance optimization, PWA, accessibility | 18 |
| Total | 164 SP |
Team Composition (recommended):
- •1 Lead Developer (full-stack)
- •1 D3/Visualization Specialist
- •1 Designer (part-time, Sprints 1, 6-7)
Critical Path: Sprint 1 → 2 → 3 → 4 (core visualization must be complete before filtering/threads)
Sprint Task Breakdown
Sprint 1: Foundation (Week 1-2)
- •Initialize Next.js project with TypeScript
- •Configure Tailwind CSS with brand tokens
- •Set up PostgreSQL + Prisma schema
- •Write data pipeline: fetch OpenBible.info TSV
- •Parse and validate against KJV versification
- •Seed database with 66 books + verse metadata
- •Basic layout: header, main content area
Sprint 2: Data Layer (Week 3-4)
- •Build REST API routes (books, cross-references, stats)
- •Implement pagination and filtering on API
- •Load ~344K cross-references into database
- •Build optimized data fetching for visualization
- •Add response caching headers
- •Write API integration tests
Sprint 3: Core Visualization (Week 5-6)
- •Implement SVG canvas with D3.js
- •Arc path generation with quadratic Bézier curves
- •Book baseline with labels
- •Color mapping by genre/book index
- •Virtual viewport rendering (cull off-screen arcs)
- •Stats bar (verse count, link count, connectivity %)
Sprint 4: Interactions (Week 7-8)
- •D3 zoom/pan behavior
- •Arc hover effects (opacity, stroke-width)
- •Arc click → tooltip with reference details
- •Book label click → zoom to book
- •Smooth animated transitions
- •Keyboard navigation support
Sprint 5: Filtering & Search (Week 9-10)
- •Genre filter checkboxes
- •Testament filter radio buttons
- •Vote threshold slider
- •Search input with debounce
- •URL query parameter sync
- •Filter animation (fade non-matching arcs)
Sprint 6: Threads View (Week 11-12)
- •Chapter selector component
- •Verse list with highlighted cross-references
- •Book connection bar chart
- •Click-to-navigate between connected passages
- •Smooth view transitions (Arcs ↔ Threads)
Sprint 7: Polish & Deploy (Week 13-14)
- •Performance audit (Lighthouse, bundle analysis)
- •Service worker for offline support
- •Accessibility audit (WCAG 2.1 AA)
- •Responsive design tweaks
- •Deploy to production
- •Documentation and README
Dependency Graph
Task Dependencies:
DB Schema ──→ Seed Script ──→ API Routes ──→ Data Fetching
│
▼
SVG Canvas ──→ Arc Rendering ──→ Virtual Rendering ──→ Zoom/Pan
│ │ │
│ ▼ ▼
│ Color System ──────────────────→ Filter Animation
│ │
▼ ▼
Book Baseline ──→ Book Labels ──→ Book Click ──→ Search
│
▼
Threads ViewExternal Dependencies:
| Package | Version | Purpose |
|---|---|---|
| d3 | ^7.0 | Visualization core |
| @types/d3 | ^7.0 | TypeScript types |
| zustand | ^5.0 | State management |
| prisma | ^6.0 | Database ORM |
| next | ^16.0 | Application framework |
| tailwindcss | ^3.4 | Styling |
| framer-motion | ^12.0 | UI animations |
Phase 8: Code Generation
3 subsections
Data Loading Pipeline
Complete TypeScript script for loading the Treasury of Scripture Knowledge cross-reference data into PostgreSQL.
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Bible books with verse counts and cumulative offsets
const BOOKS = [
{ id: 1, name: 'Genesis', abbr: 'Gen', testament: 'OT', genre: 'Law', chapters: 50, verses: 1533 },
{ id: 2, name: 'Exodus', abbr: 'Exod', testament: 'OT', genre: 'Law', chapters: 40, verses: 1213 },
{ id: 3, name: 'Leviticus', abbr: 'Lev', testament: 'OT', genre: 'Law', chapters: 27, verses: 859 },
{ id: 4, name: 'Numbers', abbr: 'Num', testament: 'OT', genre: 'Law', chapters: 36, verses: 1288 },
{ id: 5, name: 'Deuteronomy', abbr: 'Deut', testament: 'OT', genre: 'Law', chapters: 34, verses: 959 },
// ... remaining 61 books follow same pattern
{ id: 66, name: 'Revelation', abbr: 'Rev', testament: 'NT', genre: 'Apocalyptic', chapters: 22, verses: 404 },
];
// Compute cumulative start positions
let offset = 1;
const booksWithOffset = BOOKS.map(b => {
const book = { ...b, startVerse: offset };
offset += b.verses;
return book;
});
async function loadBooks() {
for (const book of booksWithOffset) {
const hue = Math.round((book.id / 66) * 360);
await prisma.bibleBook.upsert({
where: { id: book.id },
update: {},
create: {
id: book.id,
name: book.name,
abbreviation: book.abbr,
testament: book.testament,
genre: book.genre,
chapters: book.chapters,
verses: book.verses,
startVerse: book.startVerse,
color: `hsl(${hue}, 80%, 55%)`,
},
});
}
console.log(`Loaded ${booksWithOffset.length} books`);
}
async function parseTSV(url: string): Promise<any[]> {
const response = await fetch(url);
const text = await response.text();
return text.split('\n')
.filter((line: string) => line?.trim() && !line?.startsWith('From'))
.map((line: string) => {
const [from, to, votes] = (line ?? '').split('\t');
return { from, to, votes: parseInt(votes ?? '1', 10) || 1 };
});
}
async function main() {
await loadBooks();
console.log('Books loaded. Ready for cross-reference import.');
await prisma.$disconnect();
}
main().catch(console.error);D3.js Visualization Engine
Complete D3.js arc visualization implementation with zoom, pan, and filtering.
import * as d3 from 'd3';
export interface VisConfig {
container: SVGSVGElement;
width: number;
height: number;
baselineRatio?: number; // default 0.82
heightScale?: number; // default 0.5
defaultOpacity?: number; // default 0.15
}
export interface ArcDatum {
fromAbsolute: number;
toAbsolute: number;
bookIndex: number;
votes: number;
}
export interface BookDatum {
id: number;
name: string;
abbreviation: string;
startVerse: number;
verses: number;
color: string;
}
const TOTAL_VERSES = 31102;
export class BibleArcEngine {
private svg: d3.Selection<SVGSVGElement, unknown, null, undefined>;
private arcGroup: d3.Selection<SVGGElement, unknown, null, undefined>;
private labelGroup: d3.Selection<SVGGElement, unknown, null, undefined>;
private config: Required<VisConfig>;
private zoom: d3.ZoomBehavior<SVGSVGElement, unknown>;
constructor(config: VisConfig) {
this.config = {
...config,
baselineRatio: config?.baselineRatio ?? 0.82,
heightScale: config?.heightScale ?? 0.5,
defaultOpacity: config?.defaultOpacity ?? 0.15,
};
this.svg = d3.select(this.config.container);
this.arcGroup = this.svg.append('g').attr('class', 'arcs');
this.labelGroup = this.svg.append('g').attr('class', 'labels');
this.zoom = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.5, 100])
.on('zoom', (event: any) => {
this.arcGroup.attr('transform', event?.transform);
this.labelGroup.attr('transform', event?.transform);
});
this.svg.call(this.zoom);
}
renderArcs(data: ArcDatum[]): void {
const { width, baselineRatio, heightScale, defaultOpacity } = this.config;
const by = this.config.height * baselineRatio;
const hs = this.config.height * heightScale / Math.sqrt(width);
this.arcGroup.selectAll('.arc')
.data(data ?? [])
.join('path')
.attr('class', 'arc')
.attr('d', (d: ArcDatum) => {
const x1 = ((d?.fromAbsolute ?? 0) / TOTAL_VERSES) * width;
const x2 = ((d?.toAbsolute ?? 0) / TOTAL_VERSES) * width;
const mx = (x1 + x2) / 2;
const dist = Math.abs(x2 - x1);
const h = Math.sqrt(dist) * hs;
return `M ${x1},${by} Q ${mx},${by - h} ${x2},${by}`;
})
.attr('fill', 'none')
.attr('stroke', (d: ArcDatum) =>
`hsl(${((d?.bookIndex ?? 0) / 66) * 360}, 80%, 55%)`
)
.attr('stroke-width', 1)
.attr('stroke-opacity', defaultOpacity);
}
renderBaseline(books: BookDatum[]): void {
const { width } = this.config;
const by = this.config.height * this.config.baselineRatio;
// Baseline
this.labelGroup.append('line')
.attr('x1', 0).attr('y1', by)
.attr('x2', width).attr('y2', by)
.attr('stroke', '#2c2b28').attr('stroke-width', 1);
// Book sections + labels
this.labelGroup.selectAll('.book-section')
.data(books ?? [])
.join('g')
.attr('class', 'book-section')
.each(function(d: any) {
const g = d3.select(this);
const x = ((d?.startVerse ?? 0) / TOTAL_VERSES) * width;
const w = ((d?.verses ?? 0) / TOTAL_VERSES) * width;
g.append('rect')
.attr('x', x).attr('y', by)
.attr('width', w).attr('height', 4)
.attr('fill', d?.color ?? '#333');
if (w > 15) {
g.append('text')
.attr('x', x + w / 2).attr('y', by + 16)
.attr('text-anchor', 'middle')
.attr('fill', '#9a9890')
.attr('font-size', '7px')
.text(d?.abbreviation ?? '');
}
});
}
destroy(): void {
this.svg.selectAll('*').remove();
}
}Docker & Deployment Configuration
Complete Docker setup for the reconstructed application.
# Multi-stage build for production
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN yarn build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/bible_xref
- NODE_ENV=production
depends_on:
- db
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=bible_xref
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
pgdata:Phase 9: Validation Plan
3 subsections
Visual Fidelity Metrics
Comparison Metrics (rebuilt vs. original):
| Metric | Method | Target |
|---|---|---|
| Arc count accuracy | Count rendered arcs at default zoom | ±0.1% of 153,419 |
| Color distribution | Sample 1000 arcs, compare hue values | ΔE < 5 (perceptual) |
| Arc height distribution | Measure 100 arcs, compare heights | R² > 0.95 |
| Zoom range | Test min/max zoom levels | Match 1× to 50× |
| Pan smoothness | Frame rate during pan at 10× zoom | ≥ 55 fps |
| Filter accuracy | Apply genre filter, count visible arcs | Exact match |
| Book label positions | Screenshot comparison at 1× zoom | < 2px offset |
| Baseline alignment | Overlay screenshots | Pixel-perfect |
Automated Testing Suite:
describe('Arc Visualization', () => {
it('should render correct number of arcs', () => {
expect(svg.querySelectorAll('.arc').length).toBeGreaterThan(0);
});
it('should color arcs by book index', () => {
const arc = svg.querySelector('.arc');
const stroke = arc.getAttribute('stroke');
expect(stroke).toMatch(/^hsl\(/);
});
it('should maintain 60fps during pan', async () => {
const frames = await measureFrameRate(panInteraction);
expect(frames.average).toBeGreaterThan(55);
});
});Data Integrity Checks
Data Validation Rules:
1. Book Count: Exactly 66 books loaded
2. Verse Count: Exactly 31,102 total verses across all books
3. Cross-Reference Count: ~344,799 references (or subset matching source)
4. Reference Validity: All fromBook and toBook values in range [1, 66]
5. Absolute Position: All values in range [1, 31102]
6. No Self-References: fromAbsolute ≠ toAbsolute
7. No Duplicates: Unique (fromAbsolute, toAbsolute) pairs
8. Genre Coverage: All 7+ genres represented in book metadata
9. Testament Distribution: ~23,145 OT verses + ~7,957 NT verses = 31,102
SQL Validation Queries:
-- Verify total verses
SELECT SUM(verses) AS total FROM bible_books;
-- Expected: 31102
-- Verify no orphan references
SELECT COUNT(*) FROM cross_references cr
WHERE NOT EXISTS (SELECT 1 FROM bible_books b WHERE b.id = cr.from_book);
-- Expected: 0
-- Verify absolute position range
SELECT MIN(from_absolute), MAX(from_absolute),
MIN(to_absolute), MAX(to_absolute)
FROM cross_references;
-- Expected: 1, 31102, 1, 31102
-- Cross-reference density per book
SELECT b.name, COUNT(*) as refs
FROM cross_references cr
JOIN bible_books b ON b.id = cr.from_book
GROUP BY b.name ORDER BY refs DESC;Performance Benchmarks
Lighthouse Targets:
| Category | Target Score |
|---|---|
| Performance | ≥ 85 |
| Accessibility | ≥ 90 |
| Best Practices | ≥ 95 |
| SEO | ≥ 90 |
Core Web Vitals Targets:
| Metric | Target | Measurement |
|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | First meaningful paint of visualization |
| FID (First Input Delay) | < 100ms | Time to respond to first zoom/pan |
| CLS (Cumulative Layout Shift) | < 0.1 | No layout shifts after initial render |
| TTFB (Time to First Byte) | < 600ms | Server response time |
Bundle Size Targets:
| Bundle | Max Size (gzipped) |
|---|---|
| Main JS | < 200KB |
| D3.js (tree-shaken) | < 80KB |
| CSS | < 15KB |
| Cross-reference data | < 2MB |
| Total initial load | < 500KB |
Memory Targets:
- •Idle: < 50MB
- •Full data loaded: < 100MB
- •During zoom/pan: < 120MB peak