Show relation graph in sidebar

This commit is contained in:
2026-06-24 09:55:12 +09:00
parent 25e78ab2f6
commit 9710272e42

View File

@@ -1452,10 +1452,78 @@ function RelationPopup({ programs, onToggleRelation, onClose }) {
function RelationTreePanel({ programs, onProgramClick, onOpenRelationPopup }) {
const [expandedProgramIds, setExpandedProgramIds] = useState(() => new Set(programs.map((program) => program.id)));
const [mapOffset, setMapOffset] = useState(0);
const programMap = Object.fromEntries(programs.map((program) => [program.id, program]));
const relations = programs.flatMap((program) =>
(program.successors ?? [])
.map((successorId) => ({
from: program,
to: programs.find((item) => item.id === successorId)
}))
.filter((relation) => relation.to)
);
const connectedProgramIds = new Set(relations.flatMap((relation) => [relation.from.id, relation.to.id]));
const linkedChildIds = new Set(programs.flatMap((program) => program.successors ?? []));
const rootPrograms = programs.filter((program) => !linkedChildIds.has(program.id));
const visibleRoots = rootPrograms.length ? rootPrograms : programs.slice(0, 1);
const levelMap = programs.reduce((levels, program) => ({ ...levels, [program.id]: 0 }), {});
for (let pass = 0; pass < programs.length; pass += 1) {
relations.forEach((relation) => {
levelMap[relation.to.id] = Math.max(levelMap[relation.to.id], levelMap[relation.from.id] + 1);
});
}
const graphPrograms = programs.filter((program) => connectedProgramIds.has(program.id));
const graphLevels = graphPrograms.reduce((levels, program) => {
const level = levelMap[program.id] ?? 0;
levels[level] = [...(levels[level] ?? []), program];
return levels;
}, []);
const miniNodeWidth = 128;
const miniNodeHeight = 38;
const miniColumnGap = 52;
const miniRowGap = 78;
const miniPadding = 34;
const miniSideLaneWidth = 96;
const miniViewportWidth = 248;
const miniMaxLevelCount = Math.max(1, ...graphLevels.map((level) => level?.length ?? 0));
const miniGraphWidth = Math.max(
560,
miniPadding * 2 + miniSideLaneWidth + miniMaxLevelCount * miniNodeWidth + Math.max(0, miniMaxLevelCount - 1) * miniColumnGap
);
const miniGraphHeight = Math.max(
500,
miniPadding * 2 + graphLevels.length * miniNodeHeight + Math.max(0, graphLevels.length - 1) * miniRowGap
);
const miniNodePositions = Object.fromEntries(
graphLevels.flatMap((levelPrograms = [], levelIndex) => {
const rowWidth = levelPrograms.length * miniNodeWidth + Math.max(0, levelPrograms.length - 1) * miniColumnGap;
const startX = (miniGraphWidth - rowWidth) / 2;
const y = miniPadding + levelIndex * (miniNodeHeight + miniRowGap);
return levelPrograms.map((program, rowIndex) => [
program.id,
{
x: startX + rowIndex * (miniNodeWidth + miniColumnGap),
y
}
]);
})
);
const maxMapOffset = Math.max(0, miniGraphWidth - miniViewportWidth);
const defaultMapOffset = Math.max(0, Math.round((miniGraphWidth - miniViewportWidth) / 2));
useEffect(() => {
setMapOffset((current) => (
current === 0
? Math.min(defaultMapOffset, maxMapOffset)
: Math.min(current, maxMapOffset)
));
}, [defaultMapOffset, maxMapOffset]);
const moveMap = (direction) => {
setMapOffset((current) => Math.min(maxMapOffset, Math.max(0, current + direction * 150)));
};
const toggleExpanded = (programId) => {
setExpandedProgramIds((current) => {
@@ -1547,7 +1615,7 @@ function RelationTreePanel({ programs, onProgramClick, onOpenRelationPopup }) {
<p className="text-[11px] font-black uppercase tracking-wide text-blue-700">Program Map</p>
<h2 className="mt-1 text-lg font-black text-slate-950">프로그램 연결</h2>
<p className="mt-1 text-xs font-semibold leading-5 text-slate-500">
선행·후행 관계를 트리로 니다.
화살표 흐름을 좌우 밀어 확인합니다.
</p>
</div>
<button
@@ -1560,8 +1628,134 @@ function RelationTreePanel({ programs, onProgramClick, onOpenRelationPopup }) {
!
</button>
</div>
<div className="space-y-1">
{visibleRoots.map((program) => renderNode(program))}
<div className="rounded-3xl bg-white/70 p-2 shadow-inner ring-1 ring-slate-100">
<div className="mb-2 flex items-center justify-between gap-2 px-1">
<span className="rounded-full bg-blue-50 px-2.5 py-1 text-[11px] font-black text-blue-700 ring-1 ring-blue-100">
{relations.length} 연결
</span>
<div className="flex items-center gap-1">
<button
type="button"
disabled={mapOffset <= 0}
onClick={() => moveMap(-1)}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white text-slate-500 shadow-sm ring-1 ring-slate-200 hover:bg-slate-50 disabled:opacity-25"
aria-label="연결도 왼쪽 보기"
>
</button>
<button
type="button"
disabled={mapOffset >= maxMapOffset}
onClick={() => moveMap(1)}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white text-slate-500 shadow-sm ring-1 ring-slate-200 hover:bg-slate-50 disabled:opacity-25"
aria-label="연결도 오른쪽 보기"
>
</button>
</div>
</div>
{relations.length > 0 ? (
<div className="relative h-[520px] overflow-hidden rounded-2xl bg-gradient-to-br from-slate-50 to-white ring-1 ring-slate-100">
<div
className="absolute left-0 top-0 transition-transform duration-300 ease-out"
style={{
width: miniGraphWidth,
height: miniGraphHeight,
transform: `translateX(-${mapOffset}px)`
}}
>
<svg
className="absolute inset-0 h-full w-full overflow-visible"
viewBox={`0 0 ${miniGraphWidth} ${miniGraphHeight}`}
preserveAspectRatio="none"
>
<defs>
<marker id="sidebar-relation-arrow" markerWidth="9" markerHeight="9" refX="8" refY="4.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 9 4.5 L 0 9 z" fill="#2563eb" />
</marker>
<marker id="sidebar-relation-arrow-skip" markerWidth="9" markerHeight="9" refX="8" refY="4.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 9 4.5 L 0 9 z" fill="#7c3aed" />
</marker>
</defs>
{relations.map((relation) => {
const from = miniNodePositions[relation.from.id];
const to = miniNodePositions[relation.to.id];
if (!from || !to) return null;
const fromLevel = levelMap[relation.from.id] ?? 0;
const toLevel = levelMap[relation.to.id] ?? 0;
const isSkipEdge = toLevel - fromLevel > 1;
const fromCenterX = from.x + miniNodeWidth / 2;
const toCenterX = to.x + miniNodeWidth / 2;
const startY = from.y + miniNodeHeight;
const endY = to.y;
const midY = startY + Math.max(30, (endY - startY) / 2);
const skipEdges = relations.filter((item) => (levelMap[item.to.id] ?? 0) - (levelMap[item.from.id] ?? 0) > 1);
const skipFromLevels = [...new Set(skipEdges.map((item) => levelMap[item.from.id] ?? 0))].sort((a, b) => a - b);
const leftMostNodeX = Math.min(...Object.values(miniNodePositions).map((position) => position.x));
const outerLaneX = 16;
const innerLaneX = Math.max(outerLaneX + 28, leftMostNodeX - 42);
const getSkipLaneX = (level) => {
const levelIndex = Math.max(0, skipFromLevels.indexOf(level));
const laneRatio = skipFromLevels.length <= 1 ? 1 : levelIndex / (skipFromLevels.length - 1);
return outerLaneX + (innerLaneX - outerLaneX) * laneRatio;
};
const skipIncoming = skipEdges.filter((item) => item.to.id === relation.to.id);
const innermostSkipLevel = Math.max(...skipIncoming.map((item) => levelMap[item.from.id] ?? 0));
const isInnermostSkipEdge = fromLevel === innermostSkipLevel;
const innermostLaneX = getSkipLaneX(innermostSkipLevel);
const laneX = getSkipLaneX(fromLevel);
const pathD = isSkipEdge
? `M ${from.x} ${from.y + miniNodeHeight / 2} H ${laneX} V ${to.y + miniNodeHeight / 2} H ${isInnermostSkipEdge ? to.x - 8 : innermostLaneX}`
: Math.abs(fromCenterX - toCenterX) < 6
? `M ${fromCenterX} ${startY} L ${toCenterX} ${endY - 8}`
: `M ${fromCenterX} ${startY} C ${fromCenterX} ${midY}, ${toCenterX} ${midY}, ${toCenterX} ${endY - 8}`;
return (
<path
key={`${relation.from.id}-${relation.to.id}`}
d={pathD}
fill="none"
stroke={isSkipEdge ? '#7c3aed' : '#2563eb'}
strokeWidth={isSkipEdge ? '2.2' : '2'}
strokeDasharray={isSkipEdge ? '6 5' : undefined}
opacity={isSkipEdge ? '0.82' : '1'}
markerEnd={isSkipEdge ? (isInnermostSkipEdge ? 'url(#sidebar-relation-arrow-skip)' : undefined) : 'url(#sidebar-relation-arrow)'}
/>
);
})}
</svg>
{graphPrograms.map((program) => {
const position = miniNodePositions[program.id];
if (!position) return null;
const typeMeta = getProgramTypeMeta(program.programType);
return (
<button
key={program.id}
type="button"
onClick={() => onProgramClick(program.id)}
className={`absolute flex flex-col items-center justify-center rounded-xl border px-2 text-center shadow-sm transition hover:-translate-y-0.5 ${typeMeta.rowClass}`}
style={{
left: position.x,
top: position.y,
width: miniNodeWidth,
height: miniNodeHeight
}}
>
<span className="max-w-full truncate text-[11px] font-black text-slate-950">
{program.name}
</span>
<span className={`mt-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-black ring-1 ${typeMeta.badgeClass}`}>
{typeMeta.shortLabel}
</span>
</button>
);
})}
</div>
</div>
) : (
<div className="rounded-2xl bg-white px-3 py-6 text-center text-xs font-bold text-slate-500 ring-1 ring-slate-100">
아직 연결된 프로그램이 없습니다.
</div>
)}
</div>
</aside>
);