Show relation graph in sidebar
This commit is contained in:
200
src/App.jsx
200
src/App.jsx
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user