diff --git a/timeline-scratch/src/FirstCenturyChurchApp.css b/timeline-scratch/src/FirstCenturyChurchApp.css index ef09079..0981e53 100644 --- a/timeline-scratch/src/FirstCenturyChurchApp.css +++ b/timeline-scratch/src/FirstCenturyChurchApp.css @@ -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; diff --git a/timeline-scratch/src/FirstCenturyChurchApp.jsx b/timeline-scratch/src/FirstCenturyChurchApp.jsx index 64283ca..526b6d0 100644 --- a/timeline-scratch/src/FirstCenturyChurchApp.jsx +++ b/timeline-scratch/src/FirstCenturyChurchApp.jsx @@ -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); @@ -246,6 +249,7 @@ function FirstCenturyChurchApp() { selectedNodeId={selectedNodeId} journeyStops={activeJourneyStops} journeyColor={activeJourneyObj?.color} + webStyle={webStyle} /> setShowGlobalGraph(true)} showReset={showReset} onReset={handleReset} + webStyle={webStyle} + onWebStyleChange={setWebStyle} + showWebStyle={!!selectedChurchId} /> {activeJourneyObj && ( @@ -279,6 +286,7 @@ function FirstCenturyChurchApp() { {selectedNode && ( )} diff --git a/timeline-scratch/src/components/FirstCenturyChurch/ChurchWebOverlay.jsx b/timeline-scratch/src/components/FirstCenturyChurch/ChurchWebOverlay.jsx index eddf1dc..bcf6cb1 100644 --- a/timeline-scratch/src/components/FirstCenturyChurch/ChurchWebOverlay.jsx +++ b/timeline-scratch/src/components/FirstCenturyChurch/ChurchWebOverlay.jsx @@ -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); } @@ -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({ @@ -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); @@ -139,13 +152,33 @@ 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); @@ -153,6 +186,12 @@ export function ChurchWebOverlay({ map, graph, onSelectNode, selectedId }) { 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 @@ -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; @@ -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; @@ -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(); diff --git a/timeline-scratch/src/components/FirstCenturyChurch/JourneyOverlayControl.jsx b/timeline-scratch/src/components/FirstCenturyChurch/JourneyOverlayControl.jsx index ff300de..df5d188 100644 --- a/timeline-scratch/src/components/FirstCenturyChurch/JourneyOverlayControl.jsx +++ b/timeline-scratch/src/components/FirstCenturyChurch/JourneyOverlayControl.jsx @@ -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 (
@@ -29,6 +38,24 @@ export function JourneyOverlayControl({ ))}
+ {showWebStyle && ( +
+ Connections + {WEB_STYLES.map(s => ( + + ))} +
+ )} +
{body}
diff --git a/timeline-scratch/src/components/FirstCenturyChurch/personIcons.js b/timeline-scratch/src/components/FirstCenturyChurch/personIcons.js new file mode 100644 index 0000000..4d2c628 --- /dev/null +++ b/timeline-scratch/src/components/FirstCenturyChurch/personIcons.js @@ -0,0 +1,76 @@ +// Small Path2D icons painted inside person/household/traveler bubbles on the +// connection web. SVG path strings live in a 24x24 viewBox; the renderer +// scales them to fit each node radius. + +const MALE_PROFILE = 'M6 3 C4 3 3 5 3 8 C3 11 4 13 6 14 L6 15.5 C3.5 15.5 2.5 17 2.5 19.5 L2.5 22 L17 22 L17 19.5 C17 17 16 15.5 13.5 15 L13.5 12.5 C15.5 11.5 16 9.5 16 8 L19 9.5 L16 11 L16 13 C15.2 13.4 14.2 13.4 13.5 12.5 Z'; + +const FEMALE_PROFILE = 'M7 3 C4 3 3 5.5 3 8 C3 9.5 3.2 10.8 3.6 11.5 L1.5 12.5 C1 14.5 2.2 16.2 4.5 16 L4.5 16.5 L5.5 16.5 L5.5 17.5 C3 17.5 2.2 19.2 2.2 22 L17 22 L17 19.5 C17 17 16 15.5 13.5 15 L13.5 12.5 C15.2 11.5 16 9.5 16 8 L19 9.5 L16 11 L16 13 C15.2 13.4 14.2 13.4 13.5 12.5 Z'; + +const HOUSE = 'M12 2.5 L1.5 12 L4 12 L4 21.5 L9.5 21.5 L9.5 15 L14.5 15 L14.5 21.5 L20 21.5 L20 12 L22.5 12 Z'; + +// Footprint heel/arch body (sole). Toes are added as ellipses. +const FOOT_BODY = 'M11 14 C11 9 13 7 11.5 6 C9 4.5 6 8 6 13 C6 19 8 21.5 11 21.5 C13.5 21.5 14.5 19 14.5 16 C14.5 15 13.5 14 11 14 Z'; +const FOOT_TOES = [ + { cx: 14, cy: 5.5, rx: 1.7, ry: 2.1 }, + { cx: 16.6, cy: 7, rx: 1.4, ry: 1.8 }, + { cx: 18.3, cy: 8.8, rx: 1.2, ry: 1.5 }, + { cx: 19.5, cy: 10.7, rx: 1.0, ry: 1.3 }, + { cx: 20.1, cy: 12.7, rx: 0.9, ry: 1.1 }, +]; + +function buildFootPath() { + const p = new Path2D(FOOT_BODY); + FOOT_TOES.forEach(t => { + const e = new Path2D(); + e.ellipse(t.cx, t.cy, t.rx, t.ry, 0, 0, Math.PI * 2); + p.addPath(e); + }); + return p; +} + +let cachedPaths = null; + +function getPaths() { + if (cachedPaths) return cachedPaths; + cachedPaths = { + male: new Path2D(MALE_PROFILE), + female: new Path2D(FEMALE_PROFILE), + house: new Path2D(HOUSE), + foot: buildFootPath(), + }; + return cachedPaths; +} + +/** + * Choose which icon represents a node. + * - traveler → foot (person with 2+ churches) + * - household → house + * - person → female/male profile based on gender (falls back to male) + */ +export function iconKeyFor(node) { + if (node.type === 'household') return 'house'; + if (node.type === 'person') { + if (node.isTraveler) return 'foot'; + const g = (node.gender || '').toLowerCase(); + if (g === 'f' || g === 'female' || g === 'w' || g === 'woman') return 'female'; + return 'male'; + } + return null; +} + +/** + * Draw a single icon centred at (x, y) sized to fit a circle of radius r. + * Pass a contrasting fillStyle on ctx before calling. + */ +export function drawNodeIcon(ctx, key, x, y, r) { + if (!key) return; + const path = getPaths()[key]; + if (!path) return; + const scale = (r * 1.7) / 24; // icon fills ~85% of the circle + ctx.save(); + ctx.translate(x, y); + ctx.scale(scale, scale); + ctx.translate(-12, -12); + ctx.fill(path); + ctx.restore(); +}