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
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>
|
|
);
|
|
}
|