Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions timeline-scratch/src/FirstCenturyChurchApp.css
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,29 @@
}
.fcc-journey-btn--active .fcc-journey-dot { background: #faf6eb; }

.fcc-web-style-toggles {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem;
}
.fcc-web-style-btn {
font-family: inherit;
font-size: 0.78rem;
padding: 0.3rem 0.6rem;
border-radius: 999px;
border: 1px solid #c9bd9c;
background: #faf6eb;
color: #2c2418;
cursor: pointer;
}
.fcc-web-style-btn:hover { background: #fff; }
.fcc-web-style-btn--active {
background: #6b5b45;
border-color: #6b5b45;
color: #faf6eb;
}

.fcc-controls-actions {
display: flex;
gap: 0.5rem;
Expand Down
8 changes: 8 additions & 0 deletions timeline-scratch/src/FirstCenturyChurchApp.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ function FirstCenturyChurchApp() {
const [selectedNode, setSelectedNode] = useState(null); // { type, id } | null
const [activeJourney, setActiveJourney] = useState(null); // journey_id | null
const [showGlobalGraph, setShowGlobalGraph] = useState(false);
// Connection-line style for the focused city's web: spokes (radial),
// web (person↔person mesh), or hidden (just the dots + bridges on hover).
const [webStyle, setWebStyle] = useState('spokes');
const [isAdmin, setIsAdmin] = useState(false);
const [isContributor, setIsContributor] = useState(false);
const mapRef = useRef(null);
Expand Down Expand Up @@ -246,6 +249,7 @@ function FirstCenturyChurchApp() {
selectedNodeId={selectedNodeId}
journeyStops={activeJourneyStops}
journeyColor={activeJourneyObj?.color}
webStyle={webStyle}
/>

<JourneyOverlayControl
Expand All @@ -255,6 +259,9 @@ function FirstCenturyChurchApp() {
onShowGlobalGraph={() => setShowGlobalGraph(true)}
showReset={showReset}
onReset={handleReset}
webStyle={webStyle}
onWebStyleChange={setWebStyle}
showWebStyle={!!selectedChurchId}
/>

{activeJourneyObj && (
Expand All @@ -279,6 +286,7 @@ function FirstCenturyChurchApp() {

{selectedNode && (
<NodeDetailCard
key={`${selectedNode.type}:${selectedNode.id}`}
data={data}
node={selectedNode}
onNavigate={handleCardNavigate}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const ChurchMap = forwardRef(function ChurchMap(
{
churches, churchPersonCount, selectedChurchId, onSelectChurch,
onBackgroundClick, focusedGraph, onSelectNode, selectedNodeId,
journeyStops, journeyColor,
journeyStops, journeyColor, webStyle,
},
ref,
) {
Expand Down Expand Up @@ -394,6 +394,7 @@ export const ChurchMap = forwardRef(function ChurchMap(
graph={focusedGraph}
onSelectNode={onSelectNode}
selectedId={selectedNodeId}
webStyle={webStyle}
/>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { forceSimulation, forceLink, forceManyBody, forceCollide } from 'd3-force';
import { iconKeyFor, drawNodeIcon } from './personIcons.js';
import './ChurchWebOverlay.css';

// Bubble radius. Non-church nodes are drawn larger than their force-layout
// `val` so their iconography (profile / house / foot) reads at a glance.
function nodeRadius(n) {
if (n.type === 'church') return Math.max(3, n.val || 4);
if (n.type === 'household') return 11;
if (n.type === 'person') return n.isTraveler || n.isVisitor ? 12 : 11;
return Math.max(3, n.val || 4);
}

Expand Down Expand Up @@ -57,11 +63,17 @@ const REF_ZOOM = 5;
* the map's canvas container, so the map keeps its native interactions and
* empty-space clicks still reach it.
*/
export function ChurchWebOverlay({ map, graph, onSelectNode, selectedId }) {
export function ChurchWebOverlay({
map, graph, onSelectNode, selectedId, webStyle = 'spokes',
}) {
const canvasRef = useRef(null);
const simRef = useRef(null);
const nodesRef = useRef([]);
const linksRef = useRef([]);
// Person-to-person links derived from the focused church's membership —
// rendered when webStyle === 'web'. Built once per graph; the simulation
// never sees these so the orbit layout stays identical across modes.
const peerLinksRef = useRef([]);
const nodeByIdRef = useRef(new Map());
const focusChurchRef = useRef(null);
const radiiRef = useRef({
Expand All @@ -73,6 +85,7 @@ export function ChurchWebOverlay({ map, graph, onSelectNode, selectedId }) {
});
const hoverRef = useRef(null);
const selectedRef = useRef(selectedId);
const webStyleRef = useRef(webStyle);
const onSelectRef = useRef(onSelectNode);
const [tooltip, setTooltip] = useState(null);

Expand Down Expand Up @@ -139,20 +152,46 @@ export function ChurchWebOverlay({ map, graph, onSelectNode, selectedId }) {
const links = linksRef.current;
if (!nodes.length) return;

const style = webStyleRef.current;

// Click-pinned highlight stays on; hover takes precedence when active.
const hoverId = hoverRef.current;
const selId = selectedRef.current;
const activeId = hoverId || selId;
// For neighbour-set computation we always use the spoke graph — peer
// links in 'web' mode are a visual layer, not a navigation one.
const neighborIds = activeId ? neighborSet(links, activeId) : null;
const focus = focusChurchRef.current;

// Person↔person mesh (only in 'web' mode). Drawn under structural links
// so the bridges and active spokes always read on top.
if (style === 'web') {
ctx.strokeStyle = 'rgba(107, 91, 69, 0.18)';
ctx.lineWidth = 0.8;
peerLinksRef.current.forEach(pl => {
const s = nodeByIdRef.current.get(pl.source);
const t = nodeByIdRef.current.get(pl.target);
if (!s || !t || s.x == null || t.x == null) return;
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(t.x, t.y);
ctx.stroke();
});
}

links.forEach(l => {
const s = typeof l.source === 'object' ? l.source : nodeByIdRef.current.get(l.source);
const t = typeof l.target === 'object' ? l.target : nodeByIdRef.current.get(l.target);
if (!s || !t || s.x == null || t.x == null) return;
const isBridge = l.kind === 'bridge';
const on = neighborIds ? (neighborIds.has(s.id) && neighborIds.has(t.id)) : null;

// Structural spokes (member/visitor/household/household-member) hide
// entirely in 'hidden' and 'web' modes — unless this spoke belongs to
// the active (hover/selected) node, in which case it lights up so the
// user can still trace what's connected to what.
if (!isBridge && style !== 'spokes' && !on) return;

if (isBridge) {
// Bridge connections only render when the active (hovered/selected)
// node is involved. The default view stays clean — the dots are the
Expand Down Expand Up @@ -240,11 +279,19 @@ export function ChurchWebOverlay({ map, graph, onSelectNode, selectedId }) {
ctx.lineWidth = n.isOtherChurch ? 1.5 : 3;
ctx.strokeStyle = n.isOtherChurch ? '#faf6eb' : '#2c2418';
} else {
ctx.lineWidth = 1;
ctx.lineWidth = 1.25;
ctx.strokeStyle = '#faf6eb';
}
ctx.stroke();

// Iconography (person/household/traveler). Church nodes remain plain
// coloured discs so they read as places, not people.
const iconKey = iconKeyFor(n);
if (iconKey) {
ctx.fillStyle = '#faf6eb';
drawNodeIcon(ctx, iconKey, n.x, n.y, r);
}

// Labels: always visible in the focused web — but the hovered node's
// name is taken over by the floating tooltip, so suppress it here.
const isHovered = n.id === hoverId;
Expand Down Expand Up @@ -287,6 +334,12 @@ export function ChurchWebOverlay({ map, graph, onSelectNode, selectedId }) {
draw();
}, [selectedId, draw]);

// Webstyle changes (spokes / web / hidden) are pure render — no relayout.
useEffect(() => {
webStyleRef.current = webStyle;
draw();
}, [webStyle, draw]);

// Build / rebuild the simulation when the focused graph changes.
useEffect(() => {
if (!map || !graph || !graph.nodes || !graph.nodes.length) return undefined;
Expand Down Expand Up @@ -345,9 +398,35 @@ export function ChurchWebOverlay({ map, graph, onSelectNode, selectedId }) {
.alphaDecay(0.028)
.on('tick', draw);

// Build the person↔person mesh once per graph: everyone tied to the
// focused church (member or visitor) gets linked to every other such
// person. Households join the mesh through their head/members too, so
// "Network" mode reads as a true community web, not just disconnected
// dots. Capped at a few hundred edges — bigger Roman-style clusters
// stay readable because the lines are very faint.
const focusChurchId = focusChurch?.id;
const peerLinks = [];
if (focusChurchId) {
const peers = nodes.filter(n =>
(n.type === 'person' || n.type === 'household')
&& links.some(l => {
const s = typeof l.source === 'object' ? l.source.id : l.source;
const t = typeof l.target === 'object' ? l.target.id : l.target;
return (l.kind === 'member' || l.kind === 'visitor' || l.kind === 'household')
&& ((s === n.id && t === focusChurchId) || (t === n.id && s === focusChurchId));
}),
);
for (let i = 0; i < peers.length; i++) {
for (let j = i + 1; j < peers.length; j++) {
peerLinks.push({ source: peers[i].id, target: peers[j].id });
}
}
}

simRef.current = sim;
nodesRef.current = nodes;
linksRef.current = links;
peerLinksRef.current = peerLinks;
nodeByIdRef.current = byId;
draw();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
/**
* Bottom control bar: the site title, the four missionary-journey toggles,
* the "see the whole web" button, and a reset button.
* the connection-style toggle (only when a city is focused), the "see the
* whole web" button, and a reset button.
*/

const WEB_STYLES = [
{ id: 'spokes', label: 'Spokes', title: 'People connect to the city as spokes on a wheel.' },
{ id: 'web', label: 'Network', title: 'People at the same city link to each other in a faint mesh.' },
{ id: 'hidden', label: 'Just dots', title: 'Hide structural lines — only the dots and active connections show.' },
];

export function JourneyOverlayControl({
journeys, activeJourney, onJourneySelect,
onShowGlobalGraph, showReset, onReset,
webStyle = 'spokes', onWebStyleChange, showWebStyle = false,
}) {
return (
<div className="fcc-controls">
Expand All @@ -29,6 +38,24 @@ export function JourneyOverlayControl({
))}
</div>

{showWebStyle && (
<div className="fcc-web-style-toggles" role="group" aria-label="Connection style">
<span className="fcc-controls-label">Connections</span>
{WEB_STYLES.map(s => (
<button
key={s.id}
type="button"
className={`fcc-web-style-btn${webStyle === s.id ? ' fcc-web-style-btn--active' : ''}`}
onClick={() => onWebStyleChange?.(s.id)}
title={s.title}
aria-pressed={webStyle === s.id}
>
{s.label}
</button>
))}
</div>
)}

<div className="fcc-controls-actions">
<button className="fcc-global-btn" onClick={onShowGlobalGraph}>
See the whole web
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@
margin: 0.28rem 0 0;
}

.fcc-card-close {
.fcc-card-close,
.fcc-card-collapse {
border: none;
background: transparent;
font-size: 1.4rem;
Expand All @@ -62,7 +63,11 @@
cursor: pointer;
padding: 0 0.15rem;
}
.fcc-card-close:hover { color: #2c2418; }
.fcc-card-close:hover,
.fcc-card-collapse:hover { color: #2c2418; }
.fcc-card-collapse { font-size: 1rem; padding-top: 0.2rem; }
/* Collapse is a mobile-only affordance — on desktop the card scrolls. */
.fcc-card-collapse { display: none; }

.fcc-card-body {
padding: 0.75rem 0.85rem 1rem;
Expand Down Expand Up @@ -181,4 +186,19 @@
max-width: none;
max-height: 55vh;
}
.fcc-card-collapse { display: inline-block; }
/* Collapsed: header-only card — title + subtitle remain so the user can
still see who/what is pinned, while the city + connections behind stay
visible. The detail body is removed from the layout entirely. */
.fcc-detail-card--collapsed {
max-height: none;
}
.fcc-detail-card--collapsed .fcc-card-body { display: none; }
.fcc-detail-card--collapsed .fcc-card-header { border-bottom: none; }
.fcc-detail-card--collapsed .fcc-card-subtitle {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from 'react';
import { ScriptureVerse } from '../BiblicalPlaces/ScriptureVerse.jsx';
import { churchColor, householdLabel } from '../../data/firstCenturyChurchSupabaseAdapter.js';
import './NodeDetailCard.css';
Expand Down Expand Up @@ -27,6 +28,12 @@ const CHURCH_TYPE_LABELS = {
* onNavigate(type, id): jump to another entity (church re-focuses the web).
*/
export function NodeDetailCard({ data, node, onNavigate, onClose }) {
// Mobile collapse: shrink the card to just the header + a 2-3 line teaser
// so the person / city under it stays visible. The app re-mounts this
// component (via key={type:id}) when the selection changes, so this
// resets to expanded for every new pick — no effect needed.
const [collapsed, setCollapsed] = useState(false);

if (!node) return null;

let accent = '#8b7355';
Expand Down Expand Up @@ -185,13 +192,25 @@ export function NodeDetailCard({ data, node, onNavigate, onClose }) {
}

return (
<div className={`fcc-detail-card fcc-detail-card--${node.type}`} style={{ '--accent': accent }}>
<div
className={`fcc-detail-card fcc-detail-card--${node.type}${collapsed ? ' fcc-detail-card--collapsed' : ''}`}
style={{ '--accent': accent }}
>
<div className="fcc-card-header">
<span className="fcc-card-dot fcc-card-dot--lg" />
<div className="fcc-card-titles">
<h2 className="fcc-card-title">{title}</h2>
{subtitle && <p className="fcc-card-subtitle">{subtitle}</p>}
</div>
<button
className="fcc-card-collapse"
onClick={() => setCollapsed(c => !c)}
aria-label={collapsed ? 'Expand details' : 'Collapse details'}
aria-expanded={!collapsed}
title={collapsed ? 'Expand' : 'Collapse'}
>
{collapsed ? '▴' : '▾'}
</button>
<button className="fcc-card-close" onClick={onClose} aria-label="Close">×</button>
</div>
<div className="fcc-card-body">{body}</div>
Expand Down
Loading
Loading