Add one to one program comparison
This commit is contained in:
620
src/App.jsx
620
src/App.jsx
@@ -9,6 +9,8 @@ import {
|
||||
Edit3,
|
||||
Layers3,
|
||||
Map,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Mountain,
|
||||
Plus,
|
||||
Route,
|
||||
@@ -218,6 +220,7 @@ const defaultContent = {
|
||||
successors: [],
|
||||
linkLabel: '천지인 산출 모델을 WayPrimal 설계 입력으로 연계'
|
||||
},
|
||||
comparisons: [],
|
||||
extraPrograms: []
|
||||
};
|
||||
|
||||
@@ -246,6 +249,7 @@ function normalizeStoredContent(parsed) {
|
||||
? defaultContent.wayPrimal.format
|
||||
: (parsed.wayPrimal?.format ?? defaultContent.wayPrimal.format)
|
||||
},
|
||||
comparisons: parsed.comparisons ?? [],
|
||||
extraPrograms: (parsed.extraPrograms ?? []).map((program, index, programs) => ({
|
||||
...program,
|
||||
programType: getProgramType(program.programType),
|
||||
@@ -1146,55 +1150,6 @@ function DetailPopup({
|
||||
}
|
||||
|
||||
function RelationPopup({ programs, onToggleRelation, onClose }) {
|
||||
const [isRelationEditorOpen, setIsRelationEditorOpen] = useState(false);
|
||||
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 isolatedPrograms = programs.filter((program) => !connectedProgramIds.has(program.id));
|
||||
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 nodeWidth = 160;
|
||||
const nodeHeight = 48;
|
||||
const columnGap = 42;
|
||||
const rowGap = 132;
|
||||
const graphPadding = 54;
|
||||
const sideLaneWidth = 240;
|
||||
const maxLevelCount = Math.max(1, ...graphLevels.map((level) => level?.length ?? 0));
|
||||
const graphWidth = Math.max(560, graphPadding * 2 + sideLaneWidth + maxLevelCount * nodeWidth + Math.max(0, maxLevelCount - 1) * columnGap);
|
||||
const graphHeight = Math.max(620, graphPadding * 2 + graphLevels.length * nodeHeight + Math.max(0, graphLevels.length - 1) * rowGap);
|
||||
const nodePositions = Object.fromEntries(
|
||||
graphLevels.flatMap((levelPrograms = [], levelIndex) => {
|
||||
const rowWidth = levelPrograms.length * nodeWidth + Math.max(0, levelPrograms.length - 1) * columnGap;
|
||||
const startX = (graphWidth - rowWidth) / 2;
|
||||
const y = graphPadding + levelIndex * (nodeHeight + rowGap);
|
||||
return levelPrograms.map((program, rowIndex) => [
|
||||
program.id,
|
||||
{
|
||||
x: startX + rowIndex * (nodeWidth + columnGap),
|
||||
y
|
||||
}
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-slate-950/30 px-4 backdrop-blur-sm">
|
||||
<section className="relative flex max-h-[calc(100vh-48px)] w-full max-w-4xl flex-col overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl">
|
||||
@@ -1203,7 +1158,7 @@ function RelationPopup({ programs, onToggleRelation, onClose }) {
|
||||
<p className="text-[11px] font-extrabold uppercase tracking-wide text-amber-300">
|
||||
프로그램 연관관계
|
||||
</p>
|
||||
<h2 className="mt-1 text-lg font-extrabold">전체 프로그램 연결도 및 관계 수정</h2>
|
||||
<h2 className="mt-1 text-lg font-extrabold">프로그램 선행·후행 설정</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1214,245 +1169,216 @@ function RelationPopup({ programs, onToggleRelation, onClose }) {
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-auto p-5">
|
||||
<div className="rounded-3xl border border-blue-100 bg-gradient-to-br from-blue-50 to-white p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-extrabold text-slate-950">연결도</h3>
|
||||
<p className="mt-1 text-[12px] font-bold text-slate-500">
|
||||
입력한 선행/후행 관계를 화살표로 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-white px-3 py-1.5 text-[12px] font-extrabold text-blue-700 ring-1 ring-blue-100">
|
||||
{relations.length}개 연결
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRelationEditorOpen((current) => !current)}
|
||||
className="rounded-full bg-slate-950 px-4 py-2 text-[12px] font-extrabold text-white shadow-sm hover:bg-slate-800"
|
||||
>
|
||||
{isRelationEditorOpen ? '연결 수정 닫기' : '프로그램 연결 수정하기'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 space-y-3">
|
||||
{relations.length > 0 ? (
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-blue-100">
|
||||
<div className="relative h-[780px] w-full">
|
||||
<svg className="absolute inset-0 h-full w-full overflow-visible" viewBox={`0 0 ${graphWidth} ${graphHeight}`} preserveAspectRatio="none">
|
||||
<defs>
|
||||
<marker id="relation-arrow" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2563eb" />
|
||||
</marker>
|
||||
<marker id="relation-arrow-skip" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#7c3aed" />
|
||||
</marker>
|
||||
</defs>
|
||||
{relations.map((relation) => {
|
||||
const from = nodePositions[relation.from.id];
|
||||
const to = nodePositions[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 normalOutgoing = relations.filter(
|
||||
(item) =>
|
||||
item.from.id === relation.from.id &&
|
||||
(levelMap[item.to.id] ?? 0) - (levelMap[item.from.id] ?? 0) === 1
|
||||
);
|
||||
const normalIncoming = relations.filter(
|
||||
(item) =>
|
||||
item.to.id === relation.to.id &&
|
||||
(levelMap[item.to.id] ?? 0) - (levelMap[item.from.id] ?? 0) === 1
|
||||
);
|
||||
const outgoingIndex = normalOutgoing.findIndex((item) => item.to.id === relation.to.id);
|
||||
const incomingIndex = normalIncoming.findIndex((item) => item.from.id === relation.from.id);
|
||||
const fromCenterX = from.x + nodeWidth / 2;
|
||||
const toCenterX = to.x + nodeWidth / 2;
|
||||
const startY = from.y + nodeHeight;
|
||||
const endY = to.y;
|
||||
const isCenterAligned = Math.abs(fromCenterX - toCenterX) < 6;
|
||||
const useStraightLine = !isSkipEdge && isCenterAligned;
|
||||
const branchDirection = Math.sign(toCenterX - fromCenterX);
|
||||
const mergeDirection = Math.sign(fromCenterX - toCenterX);
|
||||
const startOffset = useStraightLine
|
||||
? 0
|
||||
: branchDirection !== 0 && normalOutgoing.length > 1
|
||||
? branchDirection * 28
|
||||
: (outgoingIndex - (normalOutgoing.length - 1) / 2) * 22;
|
||||
const endOffset = useStraightLine
|
||||
? 0
|
||||
: mergeDirection !== 0 && normalIncoming.length > 1
|
||||
? mergeDirection * 20
|
||||
: (incomingIndex - (normalIncoming.length - 1) / 2) * 18;
|
||||
const startX = fromCenterX + startOffset;
|
||||
const endX = toCenterX + endOffset;
|
||||
const midY = startY + Math.max(38, (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 skipFromLevelIndex = skipFromLevels.indexOf(fromLevel);
|
||||
const leftMostNodeX = Math.min(...Object.values(nodePositions).map((position) => position.x));
|
||||
const outerLaneX = 24;
|
||||
const innerLaneX = Math.max(outerLaneX + 42, leftMostNodeX - 86);
|
||||
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 skipStartX = from.x;
|
||||
const skipStartY = from.y + nodeHeight / 2;
|
||||
const skipEndX = to.x;
|
||||
const skipEndY = to.y + nodeHeight / 2;
|
||||
const pathD = isSkipEdge
|
||||
? `M ${skipStartX} ${skipStartY} H ${laneX} V ${skipEndY} H ${isInnermostSkipEdge ? skipEndX - 10 : innermostLaneX}`
|
||||
: useStraightLine
|
||||
? `M ${fromCenterX} ${startY} L ${toCenterX} ${endY - 10}`
|
||||
: `M ${startX} ${startY} C ${startX} ${midY}, ${endX} ${midY}, ${endX} ${endY - 10}`;
|
||||
return (
|
||||
<g key={`${relation.from.id}-${relation.to.id}`}>
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke={isSkipEdge ? '#7c3aed' : '#2563eb'}
|
||||
strokeWidth={isSkipEdge ? '2.5' : '2'}
|
||||
strokeDasharray={isSkipEdge ? '7 6' : undefined}
|
||||
opacity={isSkipEdge ? '0.9' : '1'}
|
||||
markerEnd={
|
||||
isSkipEdge
|
||||
? isInnermostSkipEdge
|
||||
? 'url(#relation-arrow-skip)'
|
||||
: undefined
|
||||
: 'url(#relation-arrow)'
|
||||
}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{graphPrograms.map((program) => {
|
||||
const position = nodePositions[program.id];
|
||||
if (!position) return null;
|
||||
const typeMeta = getProgramTypeMeta(program.programType);
|
||||
return (
|
||||
<div
|
||||
key={program.id}
|
||||
className={`absolute flex flex-col items-center justify-center rounded-2xl border px-3 text-center shadow-sm ${typeMeta.rowClass}`}
|
||||
style={{
|
||||
left: `${(position.x / graphWidth) * 100}%`,
|
||||
top: `${(position.y / graphHeight) * 100}%`,
|
||||
width: `${(nodeWidth / graphWidth) * 100}%`,
|
||||
height: `${(nodeHeight / graphHeight) * 100}%`
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-extrabold text-slate-950">{program.name}</p>
|
||||
<span className={`mt-1 rounded-full px-2 py-0.5 text-[10px] font-black ring-1 ${typeMeta.badgeClass}`}>
|
||||
{typeMeta.shortLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl bg-white px-4 py-6 text-center text-sm font-bold text-slate-500 ring-1 ring-blue-100">
|
||||
아직 연결된 프로그램이 없습니다. 아래에서 선행/후행을 선택하세요.
|
||||
</div>
|
||||
)}
|
||||
{isolatedPrograms.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-2xl bg-white/70 px-4 py-3 text-[12px] font-bold text-slate-500 ring-1 ring-blue-50">
|
||||
<span className="font-extrabold text-slate-700">미연결</span>
|
||||
{isolatedPrograms.map((program) => (
|
||||
<span key={program.id} className="rounded-full bg-slate-100 px-2.5 py-1 text-slate-600">
|
||||
{program.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-auto bg-slate-50 p-5">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-base font-extrabold text-slate-950">프로그램 연결 수정</h3>
|
||||
<p className="mt-1 text-[12px] font-bold text-slate-500">
|
||||
각 프로그램의 선행/후행을 선택하면 메인 연결도에 바로 반영됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isRelationEditorOpen && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-slate-950/30 px-5 backdrop-blur-sm">
|
||||
<div className="max-h-[calc(100%-48px)] w-full max-w-3xl overflow-auto rounded-3xl border border-white/80 bg-slate-50 p-5 shadow-2xl">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="space-y-3">
|
||||
{programs.map((program) => {
|
||||
const candidates = programs.filter((item) => item.id !== program.id);
|
||||
return (
|
||||
<div key={program.id} className="grid gap-3 rounded-2xl bg-white p-4 ring-1 ring-slate-100 lg:grid-cols-[180px_1fr_1fr]">
|
||||
<div>
|
||||
<h3 className="text-base font-extrabold text-slate-950">프로그램 연결 수정</h3>
|
||||
<p className="mt-1 text-[12px] font-bold text-slate-500">
|
||||
각 프로그램의 선행/후행을 선택하면 연결도가 바로 반영됩니다
|
||||
</p>
|
||||
<p className="text-[11px] font-extrabold uppercase tracking-wide text-slate-400">프로그램</p>
|
||||
<h4 className="mt-1 text-base font-extrabold text-slate-950">{program.name}</h4>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRelationEditorOpen(false)}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-900 text-white hover:bg-slate-700"
|
||||
aria-label="프로그램 연결 수정 닫기"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{programs.map((program) => {
|
||||
const candidates = programs.filter((item) => item.id !== program.id);
|
||||
return (
|
||||
<div key={program.id} className="grid gap-3 rounded-2xl bg-white p-4 ring-1 ring-slate-100 lg:grid-cols-[180px_1fr_1fr]">
|
||||
<div>
|
||||
<p className="text-[11px] font-extrabold uppercase tracking-wide text-slate-400">프로그램</p>
|
||||
<h4 className="mt-1 text-base font-extrabold text-slate-950">{program.name}</h4>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-[12px] font-extrabold text-amber-700">선행</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{candidates.map((candidate) => (
|
||||
<label key={candidate.id} className="flex cursor-pointer items-center gap-1.5 rounded-full bg-amber-50 px-2.5 py-1.5 text-[11px] font-extrabold text-amber-800 ring-1 ring-amber-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(program.predecessors ?? []).includes(candidate.id)}
|
||||
onChange={(event) => onToggleRelation(program.id, 'predecessors', candidate.id, event.target.checked)}
|
||||
className="h-3.5 w-3.5 accent-amber-500"
|
||||
/>
|
||||
{candidate.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-[12px] font-extrabold text-blue-700">후행</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{candidates.map((candidate) => (
|
||||
<label key={candidate.id} className="flex cursor-pointer items-center gap-1.5 rounded-full bg-blue-50 px-2.5 py-1.5 text-[11px] font-extrabold text-blue-800 ring-1 ring-blue-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(program.successors ?? []).includes(candidate.id)}
|
||||
onChange={(event) => onToggleRelation(program.id, 'successors', candidate.id, event.target.checked)}
|
||||
className="h-3.5 w-3.5 accent-blue-500"
|
||||
/>
|
||||
{candidate.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-[12px] font-extrabold text-amber-700">선행</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{candidates.map((candidate) => (
|
||||
<label key={candidate.id} className="flex cursor-pointer items-center gap-1.5 rounded-full bg-amber-50 px-2.5 py-1.5 text-[11px] font-extrabold text-amber-800 ring-1 ring-amber-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(program.predecessors ?? []).includes(candidate.id)}
|
||||
onChange={(event) => onToggleRelation(program.id, 'predecessors', candidate.id, event.target.checked)}
|
||||
className="h-3.5 w-3.5 accent-amber-500"
|
||||
/>
|
||||
{candidate.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-[12px] font-extrabold text-blue-700">후행</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{candidates.map((candidate) => (
|
||||
<label key={candidate.id} className="flex cursor-pointer items-center gap-1.5 rounded-full bg-blue-50 px-2.5 py-1.5 text-[11px] font-extrabold text-blue-800 ring-1 ring-blue-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(program.successors ?? []).includes(candidate.id)}
|
||||
onChange={(event) => onToggleRelation(program.id, 'successors', candidate.id, event.target.checked)}
|
||||
className="h-3.5 w-3.5 accent-blue-500"
|
||||
/>
|
||||
{candidate.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RelationTreePanel({ programs, onProgramClick, onOpenRelationPopup }) {
|
||||
function ProgramComparePopup({ programs, comparisons, onComparisonChange, onClose }) {
|
||||
const internalProgram = programs.find((program) => getProgramType(program.programType) === 'internal');
|
||||
const commercialProgram = programs.find((program) => getProgramType(program.programType) === 'commercial');
|
||||
const [leftProgramId, setLeftProgramId] = useState(internalProgram?.id ?? programs[0]?.id ?? '');
|
||||
const [rightProgramId, setRightProgramId] = useState(
|
||||
commercialProgram?.id ?? programs.find((program) => program.id !== (internalProgram?.id ?? programs[0]?.id))?.id ?? ''
|
||||
);
|
||||
const leftProgram = programs.find((program) => program.id === leftProgramId);
|
||||
const rightProgram = programs.find((program) => program.id === rightProgramId);
|
||||
const comparison =
|
||||
comparisons.find((item) => item.leftProgramId === leftProgramId && item.rightProgramId === rightProgramId) ?? {};
|
||||
|
||||
const updateComparison = (field, value) => {
|
||||
if (!leftProgramId || !rightProgramId || leftProgramId === rightProgramId) return;
|
||||
onComparisonChange(leftProgramId, rightProgramId, field, value);
|
||||
};
|
||||
|
||||
const renderProgramSummary = (program) => {
|
||||
if (!program) return null;
|
||||
const typeMeta = getProgramTypeMeta(program.programType);
|
||||
return (
|
||||
<div className={`rounded-2xl border p-4 ${typeMeta.rowClass}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-extrabold uppercase tracking-wide text-slate-400">선택 프로그램</p>
|
||||
<h4 className="mt-1 text-lg font-black text-slate-950">{program.name}</h4>
|
||||
</div>
|
||||
<span className={`rounded-full px-2.5 py-1 text-[11px] font-black ring-1 ${typeMeta.badgeClass}`}>
|
||||
{typeMeta.shortLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm font-bold leading-5 text-slate-700">{program.description}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{(program.deliverables ?? []).map((deliverable) => (
|
||||
<span key={deliverable} className="rounded-full bg-white/80 px-2.5 py-1 text-[11px] font-black text-slate-600 ring-1 ring-white">
|
||||
{deliverable}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-[12px] font-extrabold text-slate-500">
|
||||
포맷: <span className="text-slate-800">{program.format || '-'}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-slate-950/30 px-4 backdrop-blur-sm">
|
||||
<section className="relative flex max-h-[calc(100vh-48px)] w-full max-w-5xl flex-col overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between bg-slate-950 px-5 py-4 text-white">
|
||||
<div>
|
||||
<p className="text-[11px] font-extrabold uppercase tracking-wide text-blue-300">
|
||||
1:1 프로그램 비교
|
||||
</p>
|
||||
<h2 className="mt-1 text-lg font-extrabold">사내 한계와 상용 활용 기능 정리</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
|
||||
aria-label="비교 닫기"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-auto bg-slate-50 p-5">
|
||||
<div className="grid gap-3 rounded-3xl bg-white p-4 ring-1 ring-slate-100 lg:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-[12px] font-black text-slate-500">비교 프로그램 A</span>
|
||||
<select
|
||||
value={leftProgramId}
|
||||
onChange={(event) => setLeftProgramId(event.target.value)}
|
||||
className="mt-1 w-full rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm font-extrabold text-slate-800 outline-none focus:border-blue-400"
|
||||
>
|
||||
{programs.map((program) => (
|
||||
<option key={program.id} value={program.id}>
|
||||
{program.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-[12px] font-black text-slate-500">비교 프로그램 B</span>
|
||||
<select
|
||||
value={rightProgramId}
|
||||
onChange={(event) => setRightProgramId(event.target.value)}
|
||||
className="mt-1 w-full rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm font-extrabold text-slate-800 outline-none focus:border-blue-400"
|
||||
>
|
||||
{programs.map((program) => (
|
||||
<option key={program.id} value={program.id} disabled={program.id === leftProgramId}>
|
||||
{program.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
{renderProgramSummary(leftProgram)}
|
||||
{renderProgramSummary(rightProgram)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 rounded-3xl bg-white p-4 ring-1 ring-slate-100 lg:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-[12px] font-black text-amber-700">A에서 안 되는 기능</span>
|
||||
<textarea
|
||||
value={comparison.missingFeature ?? ''}
|
||||
onChange={(event) => updateComparison('missingFeature', event.target.value)}
|
||||
rows={4}
|
||||
placeholder="예: 3D 형상 직접 편집, obj/ifc 포맷 변환 등"
|
||||
className="mt-1 w-full resize-y rounded-2xl border border-amber-100 bg-amber-50/50 px-3 py-2 text-sm font-bold leading-6 text-slate-800 outline-none focus:border-amber-300"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-[12px] font-black text-blue-700">B에서 수행하는 기능</span>
|
||||
<textarea
|
||||
value={comparison.alternativeFeature ?? ''}
|
||||
onChange={(event) => updateComparison('alternativeFeature', event.target.value)}
|
||||
rows={4}
|
||||
placeholder="예: 모델 방향 수정, 속성정보 입력, 포맷 변환"
|
||||
className="mt-1 w-full resize-y rounded-2xl border border-blue-100 bg-blue-50/50 px-3 py-2 text-sm font-bold leading-6 text-slate-800 outline-none focus:border-blue-300"
|
||||
/>
|
||||
</label>
|
||||
<label className="block lg:col-span-2">
|
||||
<span className="text-[12px] font-black text-slate-700">사용 이유 / 결론</span>
|
||||
<textarea
|
||||
value={comparison.reason ?? ''}
|
||||
onChange={(event) => updateComparison('reason', event.target.value)}
|
||||
rows={3}
|
||||
placeholder="예: 사내 프로그램에서 해당 편집 기능이 없어 현재는 상용 프로그램을 보완 도구로 사용"
|
||||
className="mt-1 w-full resize-y rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm font-bold leading-6 text-slate-800 outline-none focus:border-slate-400"
|
||||
/>
|
||||
</label>
|
||||
<label className="block lg:col-span-2">
|
||||
<span className="text-[12px] font-black text-slate-500">비고</span>
|
||||
<textarea
|
||||
value={comparison.note ?? ''}
|
||||
onChange={(event) => updateComparison('note', event.target.value)}
|
||||
rows={2}
|
||||
placeholder="추가 검토사항이나 향후 대체 가능성 등을 적어주세요."
|
||||
className="mt-1 w-full resize-y rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm font-bold leading-6 text-slate-700 outline-none focus:border-slate-400"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RelationTreePanel({ programs, onProgramClick, onOpenRelationPopup, sidebarWidth, onSidebarWidthChange }) {
|
||||
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 ?? [])
|
||||
@@ -1483,17 +1409,19 @@ function RelationTreePanel({ programs, onProgramClick, onOpenRelationPopup }) {
|
||||
const miniNodeWidth = 128;
|
||||
const miniNodeHeight = 38;
|
||||
const miniColumnGap = 52;
|
||||
const miniRowGap = 78;
|
||||
const miniRowGap = 96;
|
||||
const miniPadding = 34;
|
||||
const miniSideLaneWidth = 96;
|
||||
const miniViewportWidth = 248;
|
||||
const sidebarSizes = [280, 420, 560];
|
||||
const resolvedSidebarWidth = sidebarWidth ?? sidebarSizes[0];
|
||||
const miniViewportWidth = Math.max(230, resolvedSidebarWidth - 32);
|
||||
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,
|
||||
660,
|
||||
miniPadding * 2 + graphLevels.length * miniNodeHeight + Math.max(0, graphLevels.length - 1) * miniRowGap
|
||||
);
|
||||
const miniNodePositions = Object.fromEntries(
|
||||
@@ -1510,19 +1438,11 @@ function RelationTreePanel({ programs, onProgramClick, onOpenRelationPopup }) {
|
||||
]);
|
||||
})
|
||||
);
|
||||
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 mapOffset = Math.max(0, Math.round((miniGraphWidth - miniViewportWidth) / 2));
|
||||
const canShrinkSidebar = resolvedSidebarWidth > sidebarSizes[0];
|
||||
const canGrowSidebar = resolvedSidebarWidth < sidebarSizes[sidebarSizes.length - 1];
|
||||
const setSidebarSize = (size) => {
|
||||
onSidebarWidthChange?.(size);
|
||||
};
|
||||
|
||||
const toggleExpanded = (programId) => {
|
||||
@@ -1609,13 +1529,13 @@ function RelationTreePanel({ programs, onProgramClick, onOpenRelationPopup }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="sticky top-5 max-h-[calc(100vh-40px)] overflow-y-auto overflow-x-hidden rounded-[26px] border border-white/75 bg-white/75 p-3.5 shadow-sm backdrop-blur">
|
||||
<aside className="sticky top-5 max-h-[calc(100vh-40px)] overflow-y-auto overflow-x-hidden rounded-[26px] border border-white/75 bg-white/75 p-3.5 shadow-sm backdrop-blur transition-[width] duration-300">
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<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
|
||||
@@ -1630,32 +1550,43 @@ function RelationTreePanel({ programs, onProgramClick, onOpenRelationPopup }) {
|
||||
</div>
|
||||
<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 className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-1 text-[10px] font-black text-emerald-700 ring-1 ring-emerald-100">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
사내
|
||||
</span>
|
||||
<span className="flex items-center gap-1 rounded-full bg-violet-50 px-2 py-1 text-[10px] font-black text-violet-700 ring-1 ring-violet-100">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-violet-500" />
|
||||
상용
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canShrinkSidebar}
|
||||
onClick={() => setSidebarSize(sidebarSizes[0])}
|
||||
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="연결도 영역 최소"
|
||||
title="연결도 영역 최소"
|
||||
>
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canGrowSidebar}
|
||||
onClick={() => setSidebarSize(sidebarSizes[sidebarSizes.length - 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="연결도 영역 최대"
|
||||
title="연결도 영역 최대"
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</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="relative h-[660px] 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={{
|
||||
@@ -1743,9 +1674,6 @@ function RelationTreePanel({ programs, onProgramClick, onOpenRelationPopup }) {
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
@@ -1769,6 +1697,8 @@ export default function App() {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [content, setContent] = useState(readStoredContent);
|
||||
const [isRelationPopupOpen, setIsRelationPopupOpen] = useState(false);
|
||||
const [isComparePopupOpen, setIsComparePopupOpen] = useState(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(280);
|
||||
const [isServerLoaded, setIsServerLoaded] = useState(false);
|
||||
const editableCheonjiinFlow = mergeStepContent(cheonjiinFlow, content.cheonjiin.steps);
|
||||
const editableWayPrimalFlow = mergeStepContent(wayPrimalFlow, content.wayPrimal.steps);
|
||||
@@ -2151,6 +2081,28 @@ export default function App() {
|
||||
});
|
||||
};
|
||||
|
||||
const updateComparison = (leftProgramId, rightProgramId, field, value) => {
|
||||
setContent((current) => {
|
||||
const comparisons = current.comparisons ?? [];
|
||||
const comparisonIndex = comparisons.findIndex(
|
||||
(comparison) => comparison.leftProgramId === leftProgramId && comparison.rightProgramId === rightProgramId
|
||||
);
|
||||
const nextComparison = {
|
||||
...(comparisonIndex >= 0 ? comparisons[comparisonIndex] : {}),
|
||||
leftProgramId,
|
||||
rightProgramId,
|
||||
[field]: value
|
||||
};
|
||||
return {
|
||||
...current,
|
||||
comparisons:
|
||||
comparisonIndex >= 0
|
||||
? comparisons.map((comparison, index) => (index === comparisonIndex ? nextComparison : comparison))
|
||||
: [...comparisons, nextComparison]
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const updateProgramTitle = (programId, field, value) => {
|
||||
if (programId === 'cheonjiin' || programId === 'wayPrimal') {
|
||||
const target = programId === 'cheonjiin' ? 'cheonjiin' : 'wayPrimal';
|
||||
@@ -2411,6 +2363,14 @@ export default function App() {
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,#dffcf4_0,#f7f8fa_34%,#eef2ff_100%)] text-slate-900">
|
||||
<div className="mx-auto max-w-[1760px] space-y-4 px-3 py-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsComparePopupOpen(true)}
|
||||
className="flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-bold text-slate-700 shadow-sm ring-1 ring-slate-200 hover:bg-slate-50"
|
||||
>
|
||||
<Waypoints className="h-4 w-4" />
|
||||
1:1 비교
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleProgramEdit}
|
||||
@@ -2425,11 +2385,16 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)] xl:items-start">
|
||||
<div
|
||||
className="grid gap-4 xl:items-start"
|
||||
style={{ gridTemplateColumns: `${sidebarWidth}px minmax(0, 1fr)` }}
|
||||
>
|
||||
<RelationTreePanel
|
||||
programs={programs}
|
||||
onProgramClick={openProgramWindow}
|
||||
onOpenRelationPopup={() => setIsRelationPopupOpen(true)}
|
||||
sidebarWidth={sidebarWidth}
|
||||
onSidebarWidthChange={setSidebarWidth}
|
||||
/>
|
||||
|
||||
<section className="min-w-0 space-y-5">
|
||||
@@ -2630,6 +2595,15 @@ export default function App() {
|
||||
onClose={() => setIsRelationPopupOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isComparePopupOpen && (
|
||||
<ProgramComparePopup
|
||||
programs={programs}
|
||||
comparisons={content.comparisons ?? []}
|
||||
onComparisonChange={updateComparison}
|
||||
onClose={() => setIsComparePopupOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user