mirror of
https://github.com/kennethreitz/kjvstudy.org.git
synced 2026-06-05 23:00:16 +00:00
Enhance interactive family tree and Strong's derivation display
Interactive family tree improvements: - Add hamburger menu toggle for sidebar with localStorage persistence - Smooth tween animation when navigating between people - Preserve zoom level during navigation - Narrower node cards (120px) with adjusted font sizes - Remove minus indicators, keep only + for expandable nodes - Add info button (i) on each node to open sidebar - Enhanced info panel with more fields: title, Kekulé number, siblings, children links, scripture references - Add View Ancestors/Descendants links in sidebar - Make ancestor nodes semi-transparent (60% opacity) - Fix search input width with box-sizing Strong's Concordance: - Embed related Strong's entries in derivation section - Show preview cards for referenced entries (e.g., H1 on H2 page) - Display word, transliteration, and definition excerpt 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+18
-1
@@ -2488,6 +2488,8 @@ def strongs_greek_index(request: Request, page: int = 1):
|
||||
@app.get("/strongs/{strongs_number}", response_class=HTMLResponse)
|
||||
def strongs_entry(request: Request, strongs_number: str):
|
||||
"""View a single Strong's concordance entry."""
|
||||
import re
|
||||
|
||||
entry = format_strongs_entry(strongs_number)
|
||||
|
||||
if not entry:
|
||||
@@ -2517,6 +2519,20 @@ def strongs_entry(request: Request, strongs_number: str):
|
||||
else:
|
||||
occ["verse_text"] = ""
|
||||
|
||||
# Extract and fetch related Strong's entries from derivation
|
||||
related_entries = []
|
||||
if entry.get("derivation"):
|
||||
# Find all Strong's references like H1234 or G5678
|
||||
strongs_refs = re.findall(r'([HG])(\d+)', entry["derivation"])
|
||||
seen = set()
|
||||
for prefix, num in strongs_refs:
|
||||
ref = f"{prefix}{num}"
|
||||
if ref.upper() != strongs_number.upper() and ref not in seen:
|
||||
seen.add(ref)
|
||||
related = format_strongs_entry(ref)
|
||||
if related:
|
||||
related_entries.append(related)
|
||||
|
||||
books = bible.get_books()
|
||||
breadcrumbs = [
|
||||
{"text": "Home", "url": "/"},
|
||||
@@ -2532,6 +2548,7 @@ def strongs_entry(request: Request, strongs_number: str):
|
||||
"books": books,
|
||||
"breadcrumbs": breadcrumbs,
|
||||
"verse_occurrences": verse_occurrences,
|
||||
"total_occurrences": total_occurrences
|
||||
"total_occurrences": total_occurrences,
|
||||
"related_entries": related_entries
|
||||
}
|
||||
)
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-form input:focus {
|
||||
|
||||
@@ -203,6 +203,59 @@
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Hamburger toggle button */
|
||||
.sidebar-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
width: 28px;
|
||||
height: 48px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-right: none;
|
||||
border-radius: 6px 0 0 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
box-shadow: -2px 0 6px rgba(0,0,0,0.1);
|
||||
z-index: 101;
|
||||
transition: right 0.3s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.sidebar-toggle .hamburger-line {
|
||||
width: 16px;
|
||||
height: 2px;
|
||||
background: #666;
|
||||
border-radius: 1px;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* When sidebar is open, move toggle and show X */
|
||||
.info-sidebar.open + .sidebar-toggle,
|
||||
.sidebar-toggle.active {
|
||||
right: 280px;
|
||||
}
|
||||
|
||||
.sidebar-toggle.active .hamburger-line:nth-child(1) {
|
||||
transform: rotate(45deg) translate(4px, 4px);
|
||||
}
|
||||
|
||||
.sidebar-toggle.active .hamburger-line:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-toggle.active .hamburger-line:nth-child(3) {
|
||||
transform: rotate(-45deg) translate(4px, -4px);
|
||||
}
|
||||
|
||||
.info-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -252,13 +305,28 @@
|
||||
color: #3b6ea5;
|
||||
}
|
||||
|
||||
.info-field-value a.tree-nav-link {
|
||||
color: #4a7c59;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
.info-field-value a.tree-nav-link:hover {
|
||||
color: #3d6a4b;
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
.info-actions {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #eee;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.info-actions a {
|
||||
.info-actions a.info-btn-primary {
|
||||
display: block;
|
||||
padding: 0.6rem 1rem;
|
||||
background: #4a7c59;
|
||||
@@ -270,10 +338,28 @@
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.info-actions a:hover {
|
||||
.info-actions a.info-btn-primary:hover {
|
||||
background: #3d6a4b;
|
||||
}
|
||||
|
||||
.info-actions a.info-btn-secondary {
|
||||
display: block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
color: #3b6ea5;
|
||||
text-align: center;
|
||||
border: 1px solid #3b6ea5;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.info-actions a.info-btn-secondary:hover {
|
||||
background: #3b6ea5;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Navigation hint */
|
||||
.tree-hint {
|
||||
position: absolute;
|
||||
@@ -525,6 +611,19 @@
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .sidebar-toggle {
|
||||
background: #1a1a1a;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .sidebar-toggle:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .sidebar-toggle .hamburger-line {
|
||||
background: #aaa;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .info-sidebar {
|
||||
background: #1a1a1a;
|
||||
border-left-color: #444;
|
||||
@@ -551,10 +650,28 @@
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .info-field-value a.tree-nav-link {
|
||||
color: #6b9b7a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .info-field-value a.tree-nav-link:hover {
|
||||
color: #8bc49a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .info-actions {
|
||||
border-top-color: #333;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .info-actions a.info-btn-secondary {
|
||||
color: #6b9b7a;
|
||||
border-color: #6b9b7a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .info-actions a.info-btn-secondary:hover {
|
||||
background: #6b9b7a;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .tree-hint {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border-color: #444;
|
||||
@@ -698,20 +815,33 @@
|
||||
<button class="info-sidebar-close" id="sidebar-close">×</button>
|
||||
</div>
|
||||
<div class="info-sidebar-content">
|
||||
<div class="info-field" id="field-title"></div>
|
||||
<div class="info-field" id="field-dates"></div>
|
||||
<div class="info-field" id="field-age"></div>
|
||||
<div class="info-field" id="field-generation"></div>
|
||||
<div class="info-field" id="field-kekule"></div>
|
||||
<div class="info-field" id="field-spouse"></div>
|
||||
<div class="info-field" id="field-parents"></div>
|
||||
<div class="info-field" id="field-siblings"></div>
|
||||
<div class="info-field" id="field-children"></div>
|
||||
<div class="info-field" id="field-verse"></div>
|
||||
<div class="info-field" id="field-name-meaning"></div>
|
||||
<div class="info-actions">
|
||||
<a href="#" id="view-profile-link">View Full Profile</a>
|
||||
<a href="#" id="view-profile-link" class="info-btn-primary">View Full Profile →</a>
|
||||
<a href="#" id="view-ancestors-link" class="info-btn-secondary">View Ancestors</a>
|
||||
<a href="#" id="view-descendants-link" class="info-btn-secondary">View Descendants</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="sidebar-toggle" id="sidebar-toggle" title="Toggle details panel">
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
</button>
|
||||
|
||||
<div class="tree-hint">
|
||||
Click to expand/collapse • Double-click to focus • Scroll to zoom • Drag to pan
|
||||
Click <b>i</b> for info • Click <b>+</b> to expand • Double-click to focus • Scroll to zoom • Drag to pan
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1016,6 +1146,7 @@ function createPersonCard(d, showSpouse = true) {
|
||||
const hasExpandableChildren = data.hasChildren && !d.children && !d._children;
|
||||
const isCollapsed = hasHiddenChildren || hasExpandableChildren;
|
||||
const hasKekule = data.kekule_number !== null && data.kekule_number !== undefined;
|
||||
const isAncestor = data.isAncestor === true; // Check if this is an ancestor node
|
||||
|
||||
let dates = '';
|
||||
if (data.birth_year && data.birth_year !== 'Unknown') {
|
||||
@@ -1026,13 +1157,14 @@ function createPersonCard(d, showSpouse = true) {
|
||||
}
|
||||
|
||||
const group = d3.create('svg:g')
|
||||
.attr('class', 'person-node')
|
||||
.attr('class', 'person-node' + (isAncestor ? ' ancestor-node' : ''))
|
||||
.attr('data-id', d.data.id)
|
||||
.style('cursor', 'pointer');
|
||||
.style('cursor', 'pointer')
|
||||
.style('opacity', isAncestor ? 0.6 : 1); // Make ancestors semi-transparent
|
||||
|
||||
// Card dimensions
|
||||
const cardWidth = 150;
|
||||
const cardHeight = dates ? 58 : 42;
|
||||
// Card dimensions (narrower cards)
|
||||
const cardWidth = 120;
|
||||
const cardHeight = dates ? 54 : 40;
|
||||
|
||||
// Determine colors based on gender and Kekulé status
|
||||
let fillColor, strokeColor;
|
||||
@@ -1079,10 +1211,10 @@ function createPersonCard(d, showSpouse = true) {
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'middle')
|
||||
.attr('font-family', '"ETBembo", Palatino, "Book Antiqua", serif')
|
||||
.attr('font-size', '13px')
|
||||
.attr('font-size', '12px')
|
||||
.attr('font-weight', '600')
|
||||
.attr('fill', '#222')
|
||||
.text(data.name.length > 18 ? data.name.substring(0, 16) + '…' : data.name);
|
||||
.text(data.name.length > 14 ? data.name.substring(0, 12) + '…' : data.name);
|
||||
|
||||
// Dates text
|
||||
if (dates) {
|
||||
@@ -1092,7 +1224,7 @@ function createPersonCard(d, showSpouse = true) {
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'middle')
|
||||
.attr('font-family', '"ETBembo", Palatino, "Book Antiqua", serif')
|
||||
.attr('font-size', '11px')
|
||||
.attr('font-size', '10px')
|
||||
.attr('fill', '#666')
|
||||
.text(dates);
|
||||
}
|
||||
@@ -1156,7 +1288,6 @@ function createPersonCard(d, showSpouse = true) {
|
||||
navigationHistory.push(currentRootId);
|
||||
}
|
||||
currentRootId = spouseId;
|
||||
transform = d3.zoomIdentity;
|
||||
renderTreeFromId(spouseId);
|
||||
updateBreadcrumb(spouseId);
|
||||
}
|
||||
@@ -1179,9 +1310,9 @@ function createPersonCard(d, showSpouse = true) {
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'middle')
|
||||
.attr('font-family', '"ETBembo", Palatino, "Book Antiqua", serif')
|
||||
.attr('font-size', '11px')
|
||||
.attr('font-size', '10px')
|
||||
.attr('fill', '#555')
|
||||
.text(data.spouse.length > 16 ? data.spouse.substring(0, 14) + '…' : data.spouse);
|
||||
.text(data.spouse.length > 12 ? data.spouse.substring(0, 10) + '…' : data.spouse);
|
||||
|
||||
// Ring icon to indicate marriage
|
||||
spouseGroup.append('text')
|
||||
@@ -1193,8 +1324,8 @@ function createPersonCard(d, showSpouse = true) {
|
||||
.text('💍');
|
||||
}
|
||||
|
||||
// Expand/collapse indicator (shows + if collapsed with children, - if expanded)
|
||||
if (isCollapsed || (d.children && d.children.length > 0)) {
|
||||
// Expand indicator (only shows + when there are hidden/expandable children)
|
||||
if (isCollapsed) {
|
||||
const indicatorX = cardWidth / 2 + 8;
|
||||
const indicatorY = 0;
|
||||
|
||||
@@ -1203,7 +1334,7 @@ function createPersonCard(d, showSpouse = true) {
|
||||
.attr('cx', indicatorX)
|
||||
.attr('cy', indicatorY)
|
||||
.attr('r', 10)
|
||||
.attr('fill', isCollapsed ? '#4a7c59' : '#888')
|
||||
.attr('fill', '#4a7c59')
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 1);
|
||||
|
||||
@@ -1216,9 +1347,35 @@ function createPersonCard(d, showSpouse = true) {
|
||||
.attr('font-size', '14px')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('fill', 'white')
|
||||
.text(isCollapsed ? '+' : '−');
|
||||
.text('+');
|
||||
}
|
||||
|
||||
// Info button (always shown, on the left side)
|
||||
const infoX = -cardWidth / 2 - 8;
|
||||
const infoY = 0;
|
||||
|
||||
group.append('circle')
|
||||
.attr('class', 'info-btn')
|
||||
.attr('cx', infoX)
|
||||
.attr('cy', infoY)
|
||||
.attr('r', 10)
|
||||
.attr('fill', '#3b6ea5')
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 1)
|
||||
.style('cursor', 'pointer');
|
||||
|
||||
group.append('text')
|
||||
.attr('class', 'info-icon')
|
||||
.attr('x', infoX)
|
||||
.attr('y', infoY)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'middle')
|
||||
.attr('font-size', '12px')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('fill', 'white')
|
||||
.attr('pointer-events', 'none')
|
||||
.text('i');
|
||||
|
||||
return group.node();
|
||||
}
|
||||
|
||||
@@ -1314,9 +1471,9 @@ function renderTree() {
|
||||
// Create hierarchy
|
||||
const root = d3.hierarchy(treeData);
|
||||
|
||||
// Calculate tree dimensions
|
||||
const nodeWidth = 180;
|
||||
const nodeHeight = 80;
|
||||
// Calculate tree dimensions (narrower spacing for narrower cards)
|
||||
const nodeWidth = 145;
|
||||
const nodeHeight = 75;
|
||||
const isHorizontal = currentOrientation === 'left-right';
|
||||
|
||||
// Tree layout
|
||||
@@ -1371,26 +1528,34 @@ function renderTree() {
|
||||
this.appendChild(card);
|
||||
});
|
||||
|
||||
// Single click: expand/collapse children OR show info if leaf node
|
||||
// Single click handler
|
||||
nodes.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
|
||||
// Check if clicking on the expand indicator
|
||||
const target = event.target;
|
||||
const isExpandClick = target.classList.contains('expand-indicator') ||
|
||||
target.classList.contains('expand-icon') ||
|
||||
target.closest('.expand-indicator');
|
||||
|
||||
// If has children (visible or hidden) or can be expanded, toggle on click
|
||||
const canToggle = d.children || d._children ||
|
||||
(d.data.data && d.data.data.hasChildren);
|
||||
// Check if clicking on the info button
|
||||
const isInfoClick = target.classList.contains('info-btn') ||
|
||||
target.classList.contains('info-icon');
|
||||
|
||||
if (canToggle) {
|
||||
toggleNode(d);
|
||||
} else {
|
||||
// Leaf node - show info
|
||||
if (isInfoClick) {
|
||||
showPersonInfo(d.data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if clicking on the expand indicator
|
||||
const isExpandClick = target.classList.contains('expand-indicator') ||
|
||||
target.classList.contains('expand-icon');
|
||||
|
||||
// Check if node can be expanded/collapsed
|
||||
const hasHiddenChildren = d._children && d._children.length > 0;
|
||||
const hasExpandableChildren = d.data.data && d.data.data.hasChildren && !d.children && !d._children;
|
||||
const canExpand = hasHiddenChildren || hasExpandableChildren;
|
||||
|
||||
if (isExpandClick && canExpand) {
|
||||
toggleNode(d);
|
||||
}
|
||||
// Clicking anywhere else on the card does nothing (use info button or double-click)
|
||||
});
|
||||
|
||||
// Double click: re-center tree on this person
|
||||
@@ -1429,10 +1594,7 @@ function renderTree() {
|
||||
rootSelect.value = 'custom';
|
||||
}
|
||||
|
||||
// Reset zoom transform for fresh view
|
||||
transform = d3.zoomIdentity;
|
||||
|
||||
// Re-render with new root
|
||||
// Re-render with new root (zoom level preserved, view tweens to center)
|
||||
renderTreeFromId(personId);
|
||||
updateBreadcrumb(personId);
|
||||
});
|
||||
@@ -1458,7 +1620,10 @@ function renderTree() {
|
||||
}
|
||||
|
||||
// Re-render tree from a specific person ID (bidirectional - shows parents and children)
|
||||
function renderTreeFromId(personId) {
|
||||
function renderTreeFromId(personId, animate = true) {
|
||||
// Store current zoom scale to preserve it
|
||||
const previousScale = transform.k || 1;
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
const treeData = buildBidirectionalTreeData(personId, currentDepth, 2);
|
||||
@@ -1488,8 +1653,8 @@ function renderTreeFromId(personId) {
|
||||
|
||||
svg.call(zoom);
|
||||
|
||||
const nodeWidth = 180;
|
||||
const nodeHeight = 100;
|
||||
const nodeWidth = 145;
|
||||
const nodeHeight = 90;
|
||||
const isHorizontal = currentOrientation === 'left-right';
|
||||
|
||||
// We'll render two trees: ancestors above and descendants below
|
||||
@@ -1614,10 +1779,20 @@ function renderTreeFromId(personId) {
|
||||
this.appendChild(card);
|
||||
});
|
||||
|
||||
// Single click: show info
|
||||
// Single click: handle info button
|
||||
nodes.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
showPersonInfo(d.data);
|
||||
|
||||
const target = event.target;
|
||||
|
||||
// Check if clicking on the info button
|
||||
const isInfoClick = target.classList.contains('info-btn') ||
|
||||
target.classList.contains('info-icon');
|
||||
|
||||
if (isInfoClick) {
|
||||
showPersonInfo(d.data);
|
||||
}
|
||||
// Clicking elsewhere does nothing (use info button or double-click)
|
||||
});
|
||||
|
||||
// Double click: re-center
|
||||
@@ -1632,7 +1807,6 @@ function renderTreeFromId(personId) {
|
||||
}
|
||||
currentRootId = personId;
|
||||
|
||||
transform = d3.zoomIdentity;
|
||||
renderTreeFromId(personId);
|
||||
updateBreadcrumb(personId);
|
||||
});
|
||||
@@ -1653,7 +1827,8 @@ function renderTreeFromId(personId) {
|
||||
window.treeSvg = svg;
|
||||
window.treeG = g;
|
||||
|
||||
fitToView();
|
||||
// Center on root person with smooth animation, preserving zoom level
|
||||
centerOnRootPerson(previousScale, animate);
|
||||
}
|
||||
|
||||
// Update tree after expand/collapse with animation
|
||||
@@ -1662,8 +1837,8 @@ function updateTree(source) {
|
||||
|
||||
const g = window.treeG;
|
||||
const isHorizontal = currentOrientation === 'left-right';
|
||||
const nodeWidth = 180;
|
||||
const nodeHeight = 80;
|
||||
const nodeWidth = 145;
|
||||
const nodeHeight = 75;
|
||||
|
||||
// Re-compute tree layout
|
||||
const treeLayout = d3.tree()
|
||||
@@ -1735,12 +1910,29 @@ function updateTree(source) {
|
||||
nodesEnter
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
const canToggle = d.children || d._children ||
|
||||
(d.data.data && d.data.data.hasChildren);
|
||||
if (canToggle) {
|
||||
toggleNode(d);
|
||||
} else {
|
||||
|
||||
const target = event.target;
|
||||
|
||||
// Check if clicking on the info button
|
||||
const isInfoClick = target.classList.contains('info-btn') ||
|
||||
target.classList.contains('info-icon');
|
||||
|
||||
if (isInfoClick) {
|
||||
showPersonInfo(d.data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if clicking on the expand indicator
|
||||
const isExpandClick = target.classList.contains('expand-indicator') ||
|
||||
target.classList.contains('expand-icon');
|
||||
|
||||
// Check if node can be expanded
|
||||
const hasHiddenChildren = d._children && d._children.length > 0;
|
||||
const hasExpandableChildren = d.data.data && d.data.data.hasChildren && !d.children && !d._children;
|
||||
const canExpand = hasHiddenChildren || hasExpandableChildren;
|
||||
|
||||
if (isExpandClick && canExpand) {
|
||||
toggleNode(d);
|
||||
}
|
||||
})
|
||||
.on('dblclick', (event, d) => {
|
||||
@@ -1754,7 +1946,6 @@ function updateTree(source) {
|
||||
}
|
||||
currentRootId = personId;
|
||||
|
||||
transform = d3.zoomIdentity;
|
||||
renderTreeFromId(personId);
|
||||
updateBreadcrumb(personId);
|
||||
})
|
||||
@@ -1813,6 +2004,39 @@ function updateTree(source) {
|
||||
});
|
||||
}
|
||||
|
||||
// Center view on the root person (selected person) with smooth animation
|
||||
function centerOnRootPerson(targetScale = 1, animate = true) {
|
||||
const svg = window.treeSvg;
|
||||
const zoom = window.treeZoom;
|
||||
if (!svg || !zoom || !treeRoot) return;
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
const isHorizontal = currentOrientation === 'left-right';
|
||||
|
||||
// Get root person's position (treeRoot is always the selected person)
|
||||
const rootX = isHorizontal ? treeRoot.y : treeRoot.x;
|
||||
const rootY = isHorizontal ? treeRoot.x : treeRoot.y;
|
||||
|
||||
// Clamp scale to reasonable bounds
|
||||
const scale = Math.max(0.5, Math.min(targetScale, 2.0));
|
||||
|
||||
// Calculate transform to center root person
|
||||
const tx = width / 2 - rootX * scale;
|
||||
const ty = height / 2 - rootY * scale;
|
||||
|
||||
const newTransform = d3.zoomIdentity.translate(tx, ty).scale(scale);
|
||||
|
||||
if (animate) {
|
||||
svg.transition()
|
||||
.duration(600)
|
||||
.ease(d3.easeCubicInOut)
|
||||
.call(zoom.transform, newTransform);
|
||||
} else {
|
||||
svg.call(zoom.transform, newTransform);
|
||||
}
|
||||
}
|
||||
|
||||
// Fit tree to view
|
||||
function fitToView() {
|
||||
const svg = window.treeSvg;
|
||||
@@ -1849,6 +2073,17 @@ function showPersonInfo(person) {
|
||||
|
||||
document.getElementById('sidebar-name').textContent = data.name;
|
||||
document.getElementById('view-profile-link').href = `/family-tree/person/${person.id}`;
|
||||
document.getElementById('view-ancestors-link').href = `/family-tree/person/${person.id}/ancestors`;
|
||||
document.getElementById('view-descendants-link').href = `/family-tree/person/${person.id}/descendants`;
|
||||
|
||||
// Title/Occupation
|
||||
const titleField = document.getElementById('field-title');
|
||||
if (fullPerson && fullPerson.title && fullPerson.title !== 'Biblical Figure') {
|
||||
titleField.innerHTML = `<div class="info-field-label">Title</div><div class="info-field-value">${fullPerson.title}</div>`;
|
||||
titleField.style.display = 'block';
|
||||
} else {
|
||||
titleField.style.display = 'none';
|
||||
}
|
||||
|
||||
// Dates
|
||||
const datesField = document.getElementById('field-dates');
|
||||
@@ -1883,11 +2118,20 @@ function showPersonInfo(person) {
|
||||
genField.style.display = 'none';
|
||||
}
|
||||
|
||||
// Kekulé number (bloodline to Christ)
|
||||
const kekuleField = document.getElementById('field-kekule');
|
||||
if (data.kekule_number !== null && data.kekule_number !== undefined) {
|
||||
kekuleField.innerHTML = `<div class="info-field-label">Kekulé Number</div><div class="info-field-value">#${data.kekule_number} <span style="font-size:0.85em;color:#888;">(ancestor of Christ)</span></div>`;
|
||||
kekuleField.style.display = 'block';
|
||||
} else {
|
||||
kekuleField.style.display = 'none';
|
||||
}
|
||||
|
||||
// Spouse
|
||||
const spouseField = document.getElementById('field-spouse');
|
||||
if (data.spouse) {
|
||||
const spouseId = findPersonId(data.spouse);
|
||||
const spouseLink = spouseId ? `<a href="/family-tree/person/${spouseId}">${data.spouse}</a>` : data.spouse;
|
||||
const spouseLink = spouseId ? `<a href="#" class="tree-nav-link" data-person-id="${spouseId}">${data.spouse}</a>` : data.spouse;
|
||||
spouseField.innerHTML = `<div class="info-field-label">Spouse</div><div class="info-field-value">${spouseLink}</div>`;
|
||||
spouseField.style.display = 'block';
|
||||
} else {
|
||||
@@ -1899,7 +2143,7 @@ function showPersonInfo(person) {
|
||||
if (fullPerson && fullPerson.parents && fullPerson.parents.length > 0) {
|
||||
const parentLinks = fullPerson.parents.map(pid => {
|
||||
const parent = familyTreeData[pid];
|
||||
return parent ? `<a href="/family-tree/person/${pid}">${parent.name}</a>` : pid;
|
||||
return parent ? `<a href="#" class="tree-nav-link" data-person-id="${pid}">${parent.name}</a>` : pid;
|
||||
}).join(', ');
|
||||
parentsField.innerHTML = `<div class="info-field-label">Parents</div><div class="info-field-value">${parentLinks}</div>`;
|
||||
parentsField.style.display = 'block';
|
||||
@@ -1907,24 +2151,105 @@ function showPersonInfo(person) {
|
||||
parentsField.style.display = 'none';
|
||||
}
|
||||
|
||||
// Siblings
|
||||
const siblingsField = document.getElementById('field-siblings');
|
||||
if (fullPerson && fullPerson.siblings && fullPerson.siblings.length > 0) {
|
||||
const siblingLinks = fullPerson.siblings.slice(0, 5).map(sid => {
|
||||
const sibling = familyTreeData[sid];
|
||||
return sibling ? `<a href="#" class="tree-nav-link" data-person-id="${sid}">${sibling.name}</a>` : sid;
|
||||
}).join(', ');
|
||||
const moreText = fullPerson.siblings.length > 5 ? ` +${fullPerson.siblings.length - 5} more` : '';
|
||||
siblingsField.innerHTML = `<div class="info-field-label">Siblings</div><div class="info-field-value">${siblingLinks}${moreText}</div>`;
|
||||
siblingsField.style.display = 'block';
|
||||
} else {
|
||||
siblingsField.style.display = 'none';
|
||||
}
|
||||
|
||||
// Children
|
||||
const childrenField = document.getElementById('field-children');
|
||||
if (fullPerson && fullPerson.children && fullPerson.children.length > 0) {
|
||||
const childCount = fullPerson.children.length;
|
||||
childrenField.innerHTML = `<div class="info-field-label">Children</div><div class="info-field-value">${childCount} child${childCount !== 1 ? 'ren' : ''}</div>`;
|
||||
const childLinks = fullPerson.children.slice(0, 5).map(cid => {
|
||||
const child = familyTreeData[cid];
|
||||
return child ? `<a href="#" class="tree-nav-link" data-person-id="${cid}">${child.name}</a>` : cid;
|
||||
}).join(', ');
|
||||
const moreText = fullPerson.children.length > 5 ? ` +${fullPerson.children.length - 5} more` : '';
|
||||
childrenField.innerHTML = `<div class="info-field-label">Children</div><div class="info-field-value">${childLinks}${moreText}</div>`;
|
||||
childrenField.style.display = 'block';
|
||||
} else {
|
||||
childrenField.style.display = 'none';
|
||||
}
|
||||
|
||||
// Biblical verse reference
|
||||
const verseField = document.getElementById('field-verse');
|
||||
if (fullPerson && fullPerson.verses && fullPerson.verses.length > 0) {
|
||||
const verse = fullPerson.verses[0];
|
||||
const verseHtml = `<a href="#" onclick="return false;">${verse.reference}</a><br><em>"${verse.text.substring(0, 100)}${verse.text.length > 100 ? '...' : ''}"</em>`;
|
||||
verseField.innerHTML = `<div class="info-field-label">Scripture</div><div class="info-field-value">${verseHtml}</div>`;
|
||||
verseField.style.display = 'block';
|
||||
} else {
|
||||
verseField.style.display = 'none';
|
||||
}
|
||||
|
||||
sidebar.classList.add('open');
|
||||
sidebarToggle.classList.add('active');
|
||||
|
||||
// Add click handlers for tree navigation links in sidebar
|
||||
sidebar.querySelectorAll('.tree-nav-link').forEach(link => {
|
||||
link.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
const personId = link.dataset.personId;
|
||||
if (personId) {
|
||||
// Push current root to history before navigating
|
||||
if (currentRootId && currentRootId !== personId) {
|
||||
navigationHistory.push(currentRootId);
|
||||
}
|
||||
currentRootId = personId;
|
||||
|
||||
// Close sidebar if not pinned
|
||||
if (!sidebarPinned) {
|
||||
sidebar.classList.remove('open');
|
||||
sidebarToggle.classList.remove('active');
|
||||
}
|
||||
|
||||
// Render tree from this person (zoom preserved, view tweens)
|
||||
renderTreeFromId(personId);
|
||||
updateBreadcrumb(personId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Close sidebar
|
||||
document.getElementById('sidebar-close').addEventListener('click', () => {
|
||||
sidebar.classList.remove('open');
|
||||
// Sidebar toggle functionality with persistence
|
||||
const sidebarToggle = document.getElementById('sidebar-toggle');
|
||||
let sidebarPinned = localStorage.getItem('familyTreeSidebarPinned') === 'true';
|
||||
|
||||
function updateSidebarState() {
|
||||
if (sidebarPinned) {
|
||||
sidebar.classList.add('open');
|
||||
sidebarToggle.classList.add('active');
|
||||
} else {
|
||||
sidebar.classList.remove('open');
|
||||
sidebarToggle.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle sidebar when hamburger is clicked
|
||||
sidebarToggle.addEventListener('click', () => {
|
||||
sidebarPinned = !sidebarPinned;
|
||||
localStorage.setItem('familyTreeSidebarPinned', sidebarPinned);
|
||||
updateSidebarState();
|
||||
});
|
||||
|
||||
// Close sidebar (X button inside sidebar)
|
||||
document.getElementById('sidebar-close').addEventListener('click', () => {
|
||||
sidebarPinned = false;
|
||||
localStorage.setItem('familyTreeSidebarPinned', 'false');
|
||||
updateSidebarState();
|
||||
});
|
||||
|
||||
// Initialize sidebar state from localStorage
|
||||
updateSidebarState();
|
||||
|
||||
// Controls
|
||||
document.getElementById('root-select').addEventListener('change', (e) => {
|
||||
currentRoot = e.target.value;
|
||||
@@ -2044,8 +2369,7 @@ function selectPerson(personId) {
|
||||
customOpt.textContent = personName;
|
||||
rootSelect.value = 'custom';
|
||||
|
||||
// Reset zoom and render
|
||||
transform = d3.zoomIdentity;
|
||||
// Render with smooth transition (zoom preserved)
|
||||
renderTreeFromId(personId);
|
||||
updateBreadcrumb(personId);
|
||||
}
|
||||
|
||||
@@ -132,12 +132,77 @@
|
||||
border-left: 4px solid #4169E1;
|
||||
}
|
||||
|
||||
.info-card.derivation p {
|
||||
.info-card.derivation > p {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Related entries embedded in derivation */
|
||||
.related-entries {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.related-entry-card {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #6495ED;
|
||||
}
|
||||
|
||||
.related-entry-card:hover {
|
||||
border-color: #4169E1;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.related-entry-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.related-entry-header a {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: #4169E1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.related-entry-header a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.related-entry-lemma {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.related-entry-translit {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.related-entry-def {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .related-entry-card {
|
||||
background: #1a1a1a;
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .related-entry-header a {
|
||||
color: #6495ED;
|
||||
}
|
||||
|
||||
.strongs-ref {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
@@ -348,6 +413,20 @@
|
||||
<div class="info-card derivation">
|
||||
<h2>Derivation</h2>
|
||||
<p>{{ entry.derivation | linkify_strongs | safe }}</p>
|
||||
{% if related_entries %}
|
||||
<div class="related-entries">
|
||||
{% for rel in related_entries %}
|
||||
<div class="related-entry-card">
|
||||
<div class="related-entry-header">
|
||||
<a href="/strongs/{{ rel.strongs }}">{{ rel.strongs }}</a>
|
||||
<span class="related-entry-lemma">{{ rel.word }}</span>
|
||||
{% if rel.transliteration %}<span class="related-entry-translit">({{ rel.transliteration }})</span>{% endif %}
|
||||
</div>
|
||||
<p class="related-entry-def">{{ rel.definition[:150] }}{% if rel.definition|length > 150 %}...{% endif %}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user