// frontend/components/journey/JourneyBoard.tsx // 4컬럼 보드 + 실시간 베지어 커넥터 (원본 Board). // DOM 위치를 측정해 SVG cubic Bézier 곡선을 그린다. "use client"; import { useRef, useState, useCallback, useLayoutEffect, useMemo } from "react"; import { JourneyColumn } from "./JourneyColumn"; import { RoutineFlowCard, TaskFlowCard, MeetFlowCard } from "./FlowCard"; import { FinishTile } from "./FinishTile"; import type { JourneyStage, JourneyCards, JourneyLink, JourneyPerson } from "@/lib/types"; type Props = { stages: JourneyStage[]; cards: JourneyCards; links: JourneyLink[]; people: JourneyPerson[]; }; type ComputedPath = { key: string; from: string; to: string; color: string; dash: boolean; d: string; x1: number; y1: number; x2: number; y2: number; }; export function JourneyBoard({ stages, cards, links, people }: Props) { const boardRef = useRef(null); const nodes = useRef>({}); const [paths, setPaths] = useState([]); const [size, setSize] = useState({ w: 0, h: 0 }); const [hover, setHover] = useState(null); // 오전 집중(s2) 작업의 done 토글은 로컬 state (원본 toggle) const [done, setDone] = useState>(() => Object.fromEntries(cards.s2.map((c) => [c.node, !!c.done])), ); const peopleById = useMemo( () => Object.fromEntries(people.map((p) => [p.id, p])), [people], ); const setNode = (id: string) => (el: HTMLElement | null) => { nodes.current[id] = el; }; const compute = useCallback(() => { const board = boardRef.current; if (!board) return; const br = board.getBoundingClientRect(); const out: ComputedPath[] = []; for (const lk of links) { const a = nodes.current[lk.from_node]; const b = nodes.current[lk.to_node]; if (!a || !b) continue; const ar = a.getBoundingClientRect(); const bb = b.getBoundingClientRect(); const x1 = ar.right - br.left; const y1 = ar.top - br.top + ar.height / 2; const x2 = bb.left - br.left; const y2 = bb.top - br.top + bb.height / 2; const dx = Math.max(36, (x2 - x1) * 0.5); out.push({ key: lk.from_node + ">" + lk.to_node, from: lk.from_node, to: lk.to_node, color: lk.tone === "ink" ? "var(--ink)" : `var(--${lk.tone})`, dash: lk.dash, d: `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`, x1, y1, x2, y2, }); } setPaths(out); setSize({ w: br.width, h: br.height }); }, [links]); useLayoutEffect(() => { compute(); const raf = requestAnimationFrame(compute); const onR = () => compute(); window.addEventListener("resize", onR); const ro = new ResizeObserver(compute); if (boardRef.current) ro.observe(boardRef.current); if (document.fonts?.ready) document.fonts.ready.then(compute); return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", onR); ro.disconnect(); }; }, [compute, done]); // 이웃 맵(양방향) — 호버 강조용 (원본 neighbors) const neighbors = useMemo(() => { const m: Record> = {}; for (const l of links) { (m[l.from_node] = m[l.from_node] || new Set()).add(l.to_node); (m[l.to_node] = m[l.to_node] || new Set()).add(l.from_node); } return m; }, [links]); const isLit = (node: string) => !!hover && (node === hover || (neighbors[hover]?.has(node) ?? false)); const cls = (node: string, base: string) => base + (isLit(node) ? " lit" : "") + (hover && !isLit(node) ? " mute" : ""); const pathState = (p: ComputedPath) => !hover ? "" : p.from === hover || p.to === hover ? " on" : " off"; const hoverProps = (node: string, base: string) => ({ className: cls(node, base), onMouseEnter: () => setHover(node), onMouseLeave: () => setHover(null), }); const toggle = (node: string) => setDone((d) => ({ ...d, [node]: !d[node] })); return (
{paths.map((p) => ( ))} {/* 1 — 아침 준비 (routine) */} {cards.s1.map((c) => ( ))} {/* 2 — 오전 집중 (task, 체크 토글) */} !done[t.node]).length}/4`} > {cards.s2.map((t) => ( toggle(t.node)} ref={setNode(t.node)} hover={hoverProps(t.node, "fcard task" + (done[t.node] ? " done" : ""))} /> ))} {/* 3 — 오후 협업 (meet) */} {cards.s3.map((e) => ( ))} {/* 4 — 마무리 & 내일 (tile) */}
{cards.s4.map((t) => ( ))}
); }