You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

205 lines
6.3 KiB
TypeScript

// 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<HTMLDivElement>(null);
const nodes = useRef<Record<string, HTMLElement | null>>({});
const [paths, setPaths] = useState<ComputedPath[]>([]);
const [size, setSize] = useState({ w: 0, h: 0 });
const [hover, setHover] = useState<string | null>(null);
// 오전 집중(s2) 작업의 done 토글은 로컬 state (원본 toggle)
const [done, setDone] = useState<Record<string, boolean>>(() =>
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<string, Set<string>> = {};
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 (
<div className="jx-board" ref={boardRef}>
<svg
className="jx-svg"
width={size.w}
height={size.h}
viewBox={`0 0 ${size.w} ${size.h}`}
aria-hidden
>
{paths.map((p) => (
<g key={p.key} className={"jx-link" + pathState(p)}>
<path
className="jx-line"
d={p.d}
stroke={p.color}
fill="none"
strokeDasharray={p.dash ? "1 7" : "none"}
/>
<circle cx={p.x1} cy={p.y1} r="3.4" fill={p.color} className="jx-dot" />
<circle cx={p.x2} cy={p.y2} r="3.4" fill={p.color} className="jx-dot" />
</g>
))}
</svg>
{/* 1 — 아침 준비 (routine) */}
<JourneyColumn stage={stages[0]}>
{cards.s1.map((c) => (
<RoutineFlowCard
key={c.node}
card={c}
ref={setNode(c.node)}
hover={hoverProps(c.node, "fcard")}
/>
))}
</JourneyColumn>
{/* 2 — 오전 집중 (task, 체크 토글) */}
<JourneyColumn
stage={stages[1]}
count={`${cards.s2.filter((t) => !done[t.node]).length}/4`}
>
{cards.s2.map((t) => (
<TaskFlowCard
key={t.node}
card={t}
person={peopleById[t.who]}
done={!!done[t.node]}
onToggle={() => toggle(t.node)}
ref={setNode(t.node)}
hover={hoverProps(t.node, "fcard task" + (done[t.node] ? " done" : ""))}
/>
))}
</JourneyColumn>
{/* 3 — 오후 협업 (meet) */}
<JourneyColumn stage={stages[2]}>
{cards.s3.map((e) => (
<MeetFlowCard
key={e.node}
card={e}
person={peopleById[e.who]}
ref={setNode(e.node)}
hover={hoverProps(e.node, "fcard")}
/>
))}
</JourneyColumn>
{/* 4 — 마무리 & 내일 (tile) */}
<JourneyColumn stage={stages[3]}>
<div className="tiles">
{cards.s4.map((t) => (
<FinishTile
key={t.node}
card={t}
ref={setNode(t.node)}
hover={hoverProps(t.node, "tile" + (t.hi ? " hi" : ""))}
/>
))}
</div>
</JourneyColumn>
</div>
);
}