Improve program relation map comparison flow
This commit is contained in:
629
src/App.jsx
629
src/App.jsx
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -207,7 +207,8 @@ const defaultContent = {
|
|||||||
format: 'glb',
|
format: 'glb',
|
||||||
programType: 'internal',
|
programType: 'internal',
|
||||||
predecessors: [],
|
predecessors: [],
|
||||||
successors: []
|
successors: [],
|
||||||
|
mergeGroup: ''
|
||||||
},
|
},
|
||||||
wayPrimal: {
|
wayPrimal: {
|
||||||
name: 'WayPrimal',
|
name: 'WayPrimal',
|
||||||
@@ -218,6 +219,7 @@ const defaultContent = {
|
|||||||
programType: 'internal',
|
programType: 'internal',
|
||||||
predecessors: [],
|
predecessors: [],
|
||||||
successors: [],
|
successors: [],
|
||||||
|
mergeGroup: '',
|
||||||
linkLabel: '천지인 산출 모델을 WayPrimal 설계 입력으로 연계'
|
linkLabel: '천지인 산출 모델을 WayPrimal 설계 입력으로 연계'
|
||||||
},
|
},
|
||||||
comparisons: [],
|
comparisons: [],
|
||||||
@@ -233,6 +235,7 @@ function normalizeStoredContent(parsed) {
|
|||||||
programType: getProgramType(parsed.cheonjiin?.programType ?? defaultContent.cheonjiin.programType),
|
programType: getProgramType(parsed.cheonjiin?.programType ?? defaultContent.cheonjiin.programType),
|
||||||
predecessors: parsed.cheonjiin?.predecessors ?? [],
|
predecessors: parsed.cheonjiin?.predecessors ?? [],
|
||||||
successors: parsed.cheonjiin?.successors ?? [],
|
successors: parsed.cheonjiin?.successors ?? [],
|
||||||
|
mergeGroup: parsed.cheonjiin?.mergeGroup ?? '',
|
||||||
format:
|
format:
|
||||||
!parsed.cheonjiin?.format || parsed.cheonjiin.format === '예: DXF, SHP, GeoTIFF, 수치지형도 v2 등'
|
!parsed.cheonjiin?.format || parsed.cheonjiin.format === '예: DXF, SHP, GeoTIFF, 수치지형도 v2 등'
|
||||||
? defaultContent.cheonjiin.format
|
? defaultContent.cheonjiin.format
|
||||||
@@ -244,17 +247,23 @@ function normalizeStoredContent(parsed) {
|
|||||||
programType: getProgramType(parsed.wayPrimal?.programType ?? defaultContent.wayPrimal.programType),
|
programType: getProgramType(parsed.wayPrimal?.programType ?? defaultContent.wayPrimal.programType),
|
||||||
predecessors: parsed.wayPrimal?.predecessors ?? [],
|
predecessors: parsed.wayPrimal?.predecessors ?? [],
|
||||||
successors: parsed.wayPrimal?.successors ?? [],
|
successors: parsed.wayPrimal?.successors ?? [],
|
||||||
|
mergeGroup: parsed.wayPrimal?.mergeGroup ?? '',
|
||||||
format:
|
format:
|
||||||
parsed.wayPrimal?.format === '예: DWG, LandXML, XLSX, 도공계산서, 기본설계 모델 등'
|
parsed.wayPrimal?.format === '예: DWG, LandXML, XLSX, 도공계산서, 기본설계 모델 등'
|
||||||
? defaultContent.wayPrimal.format
|
? defaultContent.wayPrimal.format
|
||||||
: (parsed.wayPrimal?.format ?? defaultContent.wayPrimal.format)
|
: (parsed.wayPrimal?.format ?? defaultContent.wayPrimal.format)
|
||||||
},
|
},
|
||||||
comparisons: parsed.comparisons ?? [],
|
comparisons: (parsed.comparisons ?? []).map((comparison, index) => ({
|
||||||
|
...comparison,
|
||||||
|
id: comparison.id ?? `comparison-${comparison.leftProgramId}-${comparison.rightProgramId}-${index}`,
|
||||||
|
title: comparison.title ?? ''
|
||||||
|
})),
|
||||||
extraPrograms: (parsed.extraPrograms ?? []).map((program, index, programs) => ({
|
extraPrograms: (parsed.extraPrograms ?? []).map((program, index, programs) => ({
|
||||||
...program,
|
...program,
|
||||||
programType: getProgramType(program.programType),
|
programType: getProgramType(program.programType),
|
||||||
predecessors: program.predecessors ?? [],
|
predecessors: program.predecessors ?? [],
|
||||||
successors: program.successors ?? [],
|
successors: program.successors ?? [],
|
||||||
|
mergeGroup: program.mergeGroup ?? '',
|
||||||
linkLabel: program.linkLabel ?? `이전 프로그램 산출물을 ${program.name} 입력으로 연계`
|
linkLabel: program.linkLabel ?? `이전 프로그램 산출물을 ${program.name} 입력으로 연계`
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
@@ -1149,7 +1158,7 @@ function DetailPopup({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RelationPopup({ programs, onToggleRelation, onClose }) {
|
function RelationPopup({ programs, onToggleRelation, onMergeGroupChange, onClose }) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-slate-950/30 px-4 backdrop-blur-sm">
|
<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">
|
<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">
|
||||||
@@ -1180,7 +1189,7 @@ function RelationPopup({ programs, onToggleRelation, onClose }) {
|
|||||||
{programs.map((program) => {
|
{programs.map((program) => {
|
||||||
const candidates = programs.filter((item) => item.id !== program.id);
|
const candidates = programs.filter((item) => item.id !== program.id);
|
||||||
return (
|
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 key={program.id} className="grid gap-3 rounded-2xl bg-white p-4 ring-1 ring-slate-100 lg:grid-cols-[180px_1fr_1fr_220px]">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-extrabold uppercase tracking-wide text-slate-400">프로그램</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>
|
<h4 className="mt-1 text-base font-extrabold text-slate-950">{program.name}</h4>
|
||||||
@@ -1217,6 +1226,27 @@ function RelationPopup({ programs, onToggleRelation, onClose }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-[12px] font-extrabold text-violet-700">하나로 인식</p>
|
||||||
|
<select
|
||||||
|
value={program.mergeGroup ?? ''}
|
||||||
|
onChange={(event) => onMergeGroupChange(program.id, event.target.value)}
|
||||||
|
className="w-full rounded-xl border border-violet-100 bg-violet-50 px-3 py-2 text-[12px] font-extrabold text-violet-800 outline-none focus:border-violet-300"
|
||||||
|
>
|
||||||
|
<option value="">개별 프로그램</option>
|
||||||
|
{candidates.map((candidate) => {
|
||||||
|
const groupValue = candidate.mergeGroup || candidate.id;
|
||||||
|
return (
|
||||||
|
<option key={candidate.id} value={groupValue}>
|
||||||
|
{candidate.name}와 같은 프로그램
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
<p className="mt-2 text-[11px] font-bold leading-4 text-slate-400">
|
||||||
|
같은 단계로 보는 프로그램을 묶으면 연결도에서는 하나의 박스로 표시됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1227,67 +1257,125 @@ function RelationPopup({ programs, onToggleRelation, onClose }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProgramComparePopup({ programs, comparisons, onComparisonChange, onClose }) {
|
function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonSave, onComparisonDelete, onClose }) {
|
||||||
const internalProgram = programs.find((program) => getProgramType(program.programType) === 'internal');
|
const internalProgram = programs.find((program) => getProgramType(program.programType) === 'internal');
|
||||||
const commercialProgram = programs.find((program) => getProgramType(program.programType) === 'commercial');
|
const commercialProgram = programs.find((program) => getProgramType(program.programType) === 'commercial');
|
||||||
const savedComparisons = comparisons.filter(
|
const savedComparisons = comparisons
|
||||||
|
.filter(
|
||||||
(comparison) =>
|
(comparison) =>
|
||||||
programs.some((program) => program.id === comparison.leftProgramId) &&
|
programs.some((program) => program.id === comparison.leftProgramId) &&
|
||||||
programs.some((program) => program.id === comparison.rightProgramId) &&
|
programs.some((program) => program.id === comparison.rightProgramId) &&
|
||||||
((comparison.stepMatches ?? []).some((match) => match.reason?.trim()) || comparison.note?.trim())
|
(comparison.title?.trim() || (comparison.stepMatches ?? []).some((match) => match.reason?.trim()) || comparison.note?.trim())
|
||||||
).sort((left, right) => (right.updatedAt ?? '').localeCompare(left.updatedAt ?? ''));
|
)
|
||||||
const [leftProgramId, setLeftProgramId] = useState(savedComparisons[0]?.leftProgramId ?? internalProgram?.id ?? programs[0]?.id ?? '');
|
.sort((left, right) => (right.updatedAt ?? '').localeCompare(left.updatedAt ?? ''));
|
||||||
const [rightProgramId, setRightProgramId] = useState(
|
const createComparisonId = (leftProgramId, rightProgramId) => `comparison-${[leftProgramId, rightProgramId].sort().join('__')}`;
|
||||||
savedComparisons[0]?.rightProgramId ??
|
const isValidProgramId = (programId) => programs.some((program) => program.id === programId);
|
||||||
commercialProgram?.id ??
|
const pairMatches = (comparison, leftProgramId, rightProgramId) =>
|
||||||
programs.find((program) => program.id !== (internalProgram?.id ?? programs[0]?.id))?.id ??
|
[comparison.leftProgramId, comparison.rightProgramId].sort().join('__') === [leftProgramId, rightProgramId].sort().join('__');
|
||||||
''
|
const defaultLeftProgramId = isValidProgramId(initialPair?.leftProgramId)
|
||||||
);
|
? initialPair.leftProgramId
|
||||||
const leftProgram = programs.find((program) => program.id === leftProgramId);
|
: internalProgram?.id ?? programs[0]?.id ?? '';
|
||||||
const rightProgram = programs.find((program) => program.id === rightProgramId);
|
const defaultRightProgramId = isValidProgramId(initialPair?.rightProgramId) && initialPair.rightProgramId !== defaultLeftProgramId
|
||||||
const comparison =
|
? initialPair.rightProgramId
|
||||||
comparisons.find((item) => item.leftProgramId === leftProgramId && item.rightProgramId === rightProgramId) ?? {};
|
: commercialProgram?.id ?? programs.find((program) => program.id !== defaultLeftProgramId)?.id ?? '';
|
||||||
const hasSavedComparison = Boolean((comparison.stepMatches ?? []).some((match) => match.reason?.trim()) || comparison.note?.trim());
|
const findSavedComparisonByPair = (leftProgramId, rightProgramId) =>
|
||||||
|
savedComparisons.find((comparison) => pairMatches(comparison, leftProgramId, rightProgramId));
|
||||||
|
const createEmptyDraft = (leftProgramId = defaultLeftProgramId, rightProgramId = defaultRightProgramId) => ({
|
||||||
|
id: createComparisonId(leftProgramId, rightProgramId),
|
||||||
|
title: '',
|
||||||
|
leftProgramId,
|
||||||
|
rightProgramId,
|
||||||
|
stepMatches: [],
|
||||||
|
note: ''
|
||||||
|
});
|
||||||
|
const [draft, setDraft] = useState(() => {
|
||||||
|
const pairComparison = initialPair ? findSavedComparisonByPair(defaultLeftProgramId, defaultRightProgramId) : null;
|
||||||
|
return pairComparison ?? (initialPair ? createEmptyDraft(defaultLeftProgramId, defaultRightProgramId) : savedComparisons[0] ?? createEmptyDraft());
|
||||||
|
});
|
||||||
|
const [loadTargetId, setLoadTargetId] = useState(() => {
|
||||||
|
const pairComparison = initialPair ? findSavedComparisonByPair(defaultLeftProgramId, defaultRightProgramId) : null;
|
||||||
|
return pairComparison?.id ?? (initialPair ? '' : savedComparisons[0]?.id ?? '');
|
||||||
|
});
|
||||||
|
const leftProgram = programs.find((program) => program.id === draft.leftProgramId);
|
||||||
|
const rightProgram = programs.find((program) => program.id === draft.rightProgramId);
|
||||||
const leftSteps = leftProgram?.steps ?? [];
|
const leftSteps = leftProgram?.steps ?? [];
|
||||||
const rightSteps = rightProgram?.steps ?? [];
|
const rightSteps = rightProgram?.steps ?? [];
|
||||||
const stepMatches = comparison.stepMatches ?? [];
|
const stepMatches = draft.stepMatches ?? [];
|
||||||
const maxStepCount = Math.max(leftSteps.length, rightSteps.length);
|
const maxStepCount = Math.max(leftSteps.length, rightSteps.length);
|
||||||
|
const isSavedDraft = savedComparisons.some((comparison) => comparison.id === draft.id);
|
||||||
|
|
||||||
const updateComparison = (field, value) => {
|
const updateDraft = (field, value) => {
|
||||||
if (!leftProgramId || !rightProgramId || leftProgramId === rightProgramId) return;
|
setDraft((current) => ({ ...current, [field]: value }));
|
||||||
onComparisonChange(leftProgramId, rightProgramId, field, value);
|
};
|
||||||
|
const startNewComparison = () => {
|
||||||
|
const nextDraft = createEmptyDraft();
|
||||||
|
setDraft(nextDraft);
|
||||||
|
setLoadTargetId('');
|
||||||
|
};
|
||||||
|
const loadSelectedComparison = () => {
|
||||||
|
const selected = savedComparisons.find((comparison) => comparison.id === loadTargetId);
|
||||||
|
if (!selected) return;
|
||||||
|
setDraft({
|
||||||
|
...selected,
|
||||||
|
stepMatches: selected.stepMatches ?? [],
|
||||||
|
note: selected.note ?? '',
|
||||||
|
title: selected.title ?? ''
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const saveDraft = () => {
|
||||||
|
if (!draft.leftProgramId || !draft.rightProgramId || draft.leftProgramId === draft.rightProgramId) return;
|
||||||
|
onComparisonSave({
|
||||||
|
...draft,
|
||||||
|
id: createComparisonId(draft.leftProgramId, draft.rightProgramId),
|
||||||
|
title: draft.title ?? '',
|
||||||
|
stepMatches: (draft.stepMatches ?? []).filter((match) => match.reason?.trim()),
|
||||||
|
note: draft.note ?? '',
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
setLoadTargetId(draft.id);
|
||||||
|
};
|
||||||
|
const deleteDraft = () => {
|
||||||
|
if (isSavedDraft) onComparisonDelete(draft.id);
|
||||||
|
startNewComparison();
|
||||||
};
|
};
|
||||||
const getStepMatch = (stepIndex) =>
|
const getStepMatch = (stepIndex) =>
|
||||||
stepMatches.find(
|
stepMatches.find(
|
||||||
(match) => String(match.leftStepIndex) === String(stepIndex) && String(match.rightStepIndex) === String(stepIndex)
|
(match) => String(match.leftStepIndex) === String(stepIndex) && String(match.rightStepIndex) === String(stepIndex)
|
||||||
) ?? {};
|
) ?? {};
|
||||||
const updateStepReason = (stepIndex, value) => {
|
const updateStepReason = (stepIndex, value) => {
|
||||||
const existingIndex = stepMatches.findIndex(
|
setDraft((current) => {
|
||||||
|
const currentMatches = current.stepMatches ?? [];
|
||||||
|
const existingIndex = currentMatches.findIndex(
|
||||||
(match) => String(match.leftStepIndex) === String(stepIndex) && String(match.rightStepIndex) === String(stepIndex)
|
(match) => String(match.leftStepIndex) === String(stepIndex) && String(match.rightStepIndex) === String(stepIndex)
|
||||||
);
|
);
|
||||||
if (!value.trim() && existingIndex >= 0) {
|
if (!value.trim()) {
|
||||||
updateComparison('stepMatches', stepMatches.filter((_, index) => index !== existingIndex));
|
return {
|
||||||
return;
|
...current,
|
||||||
|
stepMatches: existingIndex >= 0 ? currentMatches.filter((_, index) => index !== existingIndex) : currentMatches
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (!value.trim()) return;
|
|
||||||
const nextMatch = {
|
const nextMatch = {
|
||||||
...(existingIndex >= 0 ? stepMatches[existingIndex] : {}),
|
...(existingIndex >= 0 ? currentMatches[existingIndex] : {}),
|
||||||
id: `step-${stepIndex}`,
|
id: `step-${stepIndex}`,
|
||||||
leftStepIndex: String(stepIndex),
|
leftStepIndex: String(stepIndex),
|
||||||
rightStepIndex: String(stepIndex),
|
rightStepIndex: String(stepIndex),
|
||||||
reason: value
|
reason: value
|
||||||
};
|
};
|
||||||
updateComparison(
|
return {
|
||||||
'stepMatches',
|
...current,
|
||||||
|
stepMatches:
|
||||||
existingIndex >= 0
|
existingIndex >= 0
|
||||||
? stepMatches.map((match, index) => (index === existingIndex ? nextMatch : match))
|
? currentMatches.map((match, index) => (index === existingIndex ? nextMatch : match))
|
||||||
: [...stepMatches, nextMatch]
|
: [...currentMatches, nextMatch]
|
||||||
);
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const getStepLabel = (step, index) => `${index + 1}. ${step?.title ?? '-'}`;
|
const getStepLabel = (step, index) => `${index + 1}. ${step?.title ?? '-'}`;
|
||||||
const getProgramName = (programId) => programs.find((program) => program.id === programId)?.name ?? programId;
|
const getProgramName = (programId) => programs.find((program) => program.id === programId)?.name ?? programId;
|
||||||
const getComparisonMemoCount = (savedComparison) =>
|
const getComparisonTitle = (comparison) =>
|
||||||
(savedComparison.stepMatches ?? []).filter((match) => match.reason?.trim()).length;
|
comparison.title?.trim() || `${getProgramName(comparison.leftProgramId)} ↔ ${getProgramName(comparison.rightProgramId)}`;
|
||||||
|
const getComparisonMemoCount = (comparison) =>
|
||||||
|
(comparison.stepMatches ?? []).filter((match) => match.reason?.trim()).length;
|
||||||
|
|
||||||
const renderProgramSummary = (program) => {
|
const renderProgramSummary = (program) => {
|
||||||
if (!program) return null;
|
if (!program) return null;
|
||||||
@@ -1323,9 +1411,7 @@ function ProgramComparePopup({ programs, comparisons, onComparisonChange, onClos
|
|||||||
<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">
|
<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 className="flex items-center justify-between bg-slate-950 px-5 py-4 text-white">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-extrabold uppercase tracking-wide text-blue-300">
|
<p className="text-[11px] font-extrabold uppercase tracking-wide text-blue-300">1:1 프로그램 비교</p>
|
||||||
1:1 프로그램 비교
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-1 text-lg font-extrabold">사내 한계와 상용 활용 기능 정리</h2>
|
<h2 className="mt-1 text-lg font-extrabold">사내 한계와 상용 활용 기능 정리</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -1338,74 +1424,94 @@ function ProgramComparePopup({ programs, comparisons, onComparisonChange, onClos
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-auto bg-slate-50 p-5">
|
<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">
|
<div className="rounded-3xl bg-white p-4 ring-1 ring-slate-100">
|
||||||
|
<div className="mb-3 grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto_auto] lg:items-end">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-[12px] font-black text-slate-500">저장된 비교</span>
|
||||||
|
<select
|
||||||
|
value={loadTargetId}
|
||||||
|
onChange={(event) => setLoadTargetId(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"
|
||||||
|
>
|
||||||
|
<option value="">불러올 비교를 선택</option>
|
||||||
|
{savedComparisons.map((comparison) => (
|
||||||
|
<option key={comparison.id} value={comparison.id}>
|
||||||
|
{getComparisonTitle(comparison)}{getComparisonMemoCount(comparison) > 0 ? ` · ${getComparisonMemoCount(comparison)}개 메모` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadSelectedComparison}
|
||||||
|
disabled={!loadTargetId}
|
||||||
|
className="rounded-full bg-slate-950 px-4 py-2 text-sm font-black text-white shadow-sm hover:bg-slate-800 disabled:opacity-35"
|
||||||
|
>
|
||||||
|
불러오기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startNewComparison}
|
||||||
|
className="rounded-full bg-blue-600 px-4 py-2 text-sm font-black text-white shadow-sm hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
새 비교
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 flex flex-wrap items-end gap-3">
|
||||||
|
<label className="min-w-[260px] flex-1">
|
||||||
|
<span className="text-[12px] font-black text-slate-500">비교 제목</span>
|
||||||
|
<input
|
||||||
|
value={draft.title ?? ''}
|
||||||
|
onChange={(event) => updateDraft('title', event.target.value)}
|
||||||
|
placeholder="예: EG-BIM Modeler에서 Rhino 보완 기능 비교"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{isSavedDraft && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={deleteDraft}
|
||||||
|
className="rounded-full bg-white px-4 py-2 text-sm font-black text-red-600 ring-1 ring-red-100 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-[12px] font-black text-slate-500">비교 프로그램 A</span>
|
<span className="text-[12px] font-black text-slate-500">비교 프로그램 A</span>
|
||||||
<select
|
<select
|
||||||
value={leftProgramId}
|
value={draft.leftProgramId}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextProgramId = event.target.value;
|
const nextProgramId = event.target.value;
|
||||||
setLeftProgramId(nextProgramId);
|
setDraft((current) => {
|
||||||
if (nextProgramId === rightProgramId) {
|
const nextRightProgramId = nextProgramId === current.rightProgramId
|
||||||
setRightProgramId(programs.find((program) => program.id !== nextProgramId)?.id ?? '');
|
? programs.find((program) => program.id !== nextProgramId)?.id ?? ''
|
||||||
}
|
: current.rightProgramId;
|
||||||
|
return { ...current, leftProgramId: nextProgramId, rightProgramId: nextRightProgramId };
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
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"
|
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) => (
|
{programs.map((program) => (
|
||||||
<option key={program.id} value={program.id}>
|
<option key={program.id} value={program.id}>{program.name}</option>
|
||||||
{program.name}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-[12px] font-black text-slate-500">비교 프로그램 B</span>
|
<span className="text-[12px] font-black text-slate-500">비교 프로그램 B</span>
|
||||||
<select
|
<select
|
||||||
value={rightProgramId}
|
value={draft.rightProgramId}
|
||||||
onChange={(event) => setRightProgramId(event.target.value)}
|
onChange={(event) => updateDraft('rightProgramId', 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"
|
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) => (
|
{programs.map((program) => (
|
||||||
<option key={program.id} value={program.id} disabled={program.id === leftProgramId}>
|
<option key={program.id} value={program.id} disabled={program.id === draft.leftProgramId}>{program.name}</option>
|
||||||
{program.name}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{savedComparisons.length > 0 && (
|
|
||||||
<div className="mt-3 rounded-3xl bg-white px-4 py-3 ring-1 ring-slate-100">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="mr-1 text-[12px] font-black text-slate-500">저장된 비교</span>
|
|
||||||
{savedComparisons.map((savedComparison) => {
|
|
||||||
const active =
|
|
||||||
savedComparison.leftProgramId === leftProgramId &&
|
|
||||||
savedComparison.rightProgramId === rightProgramId;
|
|
||||||
const memoCount = getComparisonMemoCount(savedComparison);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`${savedComparison.leftProgramId}-${savedComparison.rightProgramId}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setLeftProgramId(savedComparison.leftProgramId);
|
|
||||||
setRightProgramId(savedComparison.rightProgramId);
|
|
||||||
}}
|
|
||||||
className={`rounded-full px-3 py-1.5 text-[12px] font-black ring-1 transition ${
|
|
||||||
active
|
|
||||||
? 'bg-slate-950 text-white ring-slate-950'
|
|
||||||
: 'bg-slate-50 text-slate-600 ring-slate-200 hover:bg-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{getProgramName(savedComparison.leftProgramId)} ↔ {getProgramName(savedComparison.rightProgramId)}
|
|
||||||
{memoCount > 0 ? ` · ${memoCount}개 메모` : ''}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||||
{renderProgramSummary(leftProgram)}
|
{renderProgramSummary(leftProgram)}
|
||||||
@@ -1413,15 +1519,12 @@ function ProgramComparePopup({ programs, comparisons, onComparisonChange, onClos
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 rounded-3xl bg-white p-4 ring-1 ring-slate-100">
|
<div className="mt-4 rounded-3xl bg-white p-4 ring-1 ring-slate-100">
|
||||||
<div>
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-black text-slate-950">전체 스텝 1:1 비교</h3>
|
<h3 className="text-base font-black text-slate-950">전체 스텝 1:1 비교</h3>
|
||||||
<p className="mt-1 text-[12px] font-bold text-slate-500">
|
<p className="mt-1 text-[12px] font-bold text-slate-500">
|
||||||
두 프로그램의 스텝을 전부 펼친 뒤, 가운데에 해당 스텝에서 상용 프로그램을 쓰는 이유를 적습니다.
|
두 프로그램의 스텝을 전부 펼친 뒤, 가운데에 해당 스텝에서 상용 프로그램을 쓰는 이유를 적습니다.
|
||||||
{hasSavedComparison ? ' 저장된 비교 내용을 불러왔습니다.' : ''}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{maxStepCount === 0 ? (
|
{maxStepCount === 0 ? (
|
||||||
@@ -1447,9 +1550,7 @@ function ProgramComparePopup({ programs, comparisons, onComparisonChange, onClos
|
|||||||
{leftStep?.feature || '해당 스텝 없음'}
|
{leftStep?.feature || '해당 스텝 없음'}
|
||||||
</p>
|
</p>
|
||||||
{leftStep?.note && (
|
{leftStep?.note && (
|
||||||
<p className="mt-2 whitespace-pre-line text-[11px] font-bold leading-5 text-slate-500">
|
<p className="mt-2 whitespace-pre-line text-[11px] font-bold leading-5 text-slate-500">{leftStep.note}</p>
|
||||||
{leftStep.note}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
@@ -1471,9 +1572,7 @@ function ProgramComparePopup({ programs, comparisons, onComparisonChange, onClos
|
|||||||
{rightStep?.feature || '해당 스텝 없음'}
|
{rightStep?.feature || '해당 스텝 없음'}
|
||||||
</p>
|
</p>
|
||||||
{rightStep?.note && (
|
{rightStep?.note && (
|
||||||
<p className="mt-2 whitespace-pre-line text-[11px] font-bold leading-5 text-slate-500">
|
<p className="mt-2 whitespace-pre-line text-[11px] font-bold leading-5 text-slate-500">{rightStep.note}</p>
|
||||||
{rightStep.note}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1485,13 +1584,22 @@ function ProgramComparePopup({ programs, comparisons, onComparisonChange, onClos
|
|||||||
<label className="mt-4 block">
|
<label className="mt-4 block">
|
||||||
<span className="text-[12px] font-black text-slate-500">비교 결론</span>
|
<span className="text-[12px] font-black text-slate-500">비교 결론</span>
|
||||||
<textarea
|
<textarea
|
||||||
value={comparison.note ?? ''}
|
value={draft.note ?? ''}
|
||||||
onChange={(event) => updateComparison('note', event.target.value)}
|
onChange={(event) => updateDraft('note', event.target.value)}
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="예: 현재는 해당 스텝 보완을 위해 상용 프로그램 사용이 필요함."
|
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"
|
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>
|
</label>
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={saveDraft}
|
||||||
|
className="rounded-full bg-emerald-600 px-5 py-2.5 text-sm font-black text-white shadow-sm hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
저장하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1502,6 +1610,7 @@ function ProgramComparePopup({ programs, comparisons, onComparisonChange, onClos
|
|||||||
function RelationTreePanel({
|
function RelationTreePanel({
|
||||||
programs,
|
programs,
|
||||||
onProgramClick,
|
onProgramClick,
|
||||||
|
onOpenComparePair,
|
||||||
onOpenRelationPopup,
|
onOpenRelationPopup,
|
||||||
onOpenMapWindow,
|
onOpenMapWindow,
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
@@ -1509,42 +1618,93 @@ function RelationTreePanel({
|
|||||||
fullPage = false
|
fullPage = false
|
||||||
}) {
|
}) {
|
||||||
const [expandedProgramIds, setExpandedProgramIds] = useState(() => new Set(programs.map((program) => program.id)));
|
const [expandedProgramIds, setExpandedProgramIds] = useState(() => new Set(programs.map((program) => program.id)));
|
||||||
const programMap = Object.fromEntries(programs.map((program) => [program.id, program]));
|
const [mapZoom, setMapZoom] = useState(1);
|
||||||
const relations = programs.flatMap((program) =>
|
const [mapPan, setMapPan] = useState({ x: 0, y: 0 });
|
||||||
(program.successors ?? [])
|
const [mapDrag, setMapDrag] = useState(null);
|
||||||
.map((successorId) => ({
|
const mapViewportRef = useRef(null);
|
||||||
from: program,
|
const programGroupKey = (program) => program.mergeGroup || program.id;
|
||||||
to: programs.find((item) => item.id === successorId)
|
const groupMap = programs.reduce((groups, program) => {
|
||||||
}))
|
const groupKey = programGroupKey(program);
|
||||||
.filter((relation) => relation.to)
|
groups[groupKey] = [...(groups[groupKey] ?? []), program];
|
||||||
|
return groups;
|
||||||
|
}, {});
|
||||||
|
const programToGroupKey = Object.fromEntries(programs.map((program) => [program.id, programGroupKey(program)]));
|
||||||
|
const groupRepresentativeMap = Object.fromEntries(
|
||||||
|
Object.entries(groupMap).map(([groupKey, groupPrograms]) => [groupKey, groupPrograms[0].id])
|
||||||
);
|
);
|
||||||
const connectedProgramIds = new Set(relations.flatMap((relation) => [relation.from.id, relation.to.id]));
|
const relationPrograms = programs;
|
||||||
const linkedChildIds = new Set(programs.flatMap((program) => program.successors ?? []));
|
const programMap = Object.fromEntries(relationPrograms.map((program) => [program.id, program]));
|
||||||
const rootPrograms = programs.filter((program) => !linkedChildIds.has(program.id));
|
const relationMap = new globalThis.Map();
|
||||||
const visibleRoots = rootPrograms.length ? rootPrograms : programs.slice(0, 1);
|
relationPrograms.forEach((program) => {
|
||||||
const levelMap = programs.reduce((levels, program) => ({ ...levels, [program.id]: 0 }), {});
|
(program.successors ?? []).forEach((successorId) => {
|
||||||
|
const successor = programMap[successorId];
|
||||||
|
if (!successor) return;
|
||||||
|
const fromGroupKey = programToGroupKey[program.id];
|
||||||
|
const toGroupKey = programToGroupKey[successor.id];
|
||||||
|
if (!fromGroupKey || !toGroupKey || fromGroupKey === toGroupKey) return;
|
||||||
|
const relationKey = `${fromGroupKey}->${toGroupKey}`;
|
||||||
|
if (!relationMap.has(relationKey)) {
|
||||||
|
relationMap.set(relationKey, {
|
||||||
|
from: programMap[groupRepresentativeMap[fromGroupKey]] ?? program,
|
||||||
|
to: programMap[groupRepresentativeMap[toGroupKey]] ?? successor,
|
||||||
|
fromGroupKey,
|
||||||
|
toGroupKey
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const relations = [...relationMap.values()];
|
||||||
|
const connectedGroupKeys = new Set(relations.flatMap((relation) => [relation.fromGroupKey, relation.toGroupKey]));
|
||||||
|
const connectedProgramIds = new Set(
|
||||||
|
relationPrograms
|
||||||
|
.filter((program) => connectedGroupKeys.has(programToGroupKey[program.id]))
|
||||||
|
.map((program) => program.id)
|
||||||
|
);
|
||||||
|
const linkedChildIds = new Set(relationPrograms.flatMap((program) => program.successors ?? []));
|
||||||
|
const rootPrograms = relationPrograms.filter((program) => !linkedChildIds.has(program.id));
|
||||||
|
const visibleRoots = rootPrograms.length ? rootPrograms : relationPrograms.slice(0, 1);
|
||||||
|
const levelMap = relationPrograms.reduce((levels, program) => ({ ...levels, [program.id]: 0 }), {});
|
||||||
|
|
||||||
for (let pass = 0; pass < programs.length; pass += 1) {
|
for (let pass = 0; pass < relationPrograms.length; pass += 1) {
|
||||||
relations.forEach((relation) => {
|
relations.forEach((relation) => {
|
||||||
levelMap[relation.to.id] = Math.max(levelMap[relation.to.id], levelMap[relation.from.id] + 1);
|
levelMap[relation.to.id] = Math.max(levelMap[relation.to.id], levelMap[relation.from.id] + 1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const graphPrograms = programs.filter((program) => connectedProgramIds.has(program.id));
|
Object.values(groupMap).forEach((groupPrograms) => {
|
||||||
|
const groupLevel = Math.max(...groupPrograms.map((program) => levelMap[program.id] ?? 0));
|
||||||
|
groupPrograms.forEach((program) => {
|
||||||
|
levelMap[program.id] = groupLevel;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const graphPrograms = relationPrograms.filter((program) => connectedProgramIds.has(program.id));
|
||||||
const graphLevels = graphPrograms.reduce((levels, program) => {
|
const graphLevels = graphPrograms.reduce((levels, program) => {
|
||||||
const level = levelMap[program.id] ?? 0;
|
const level = levelMap[program.id] ?? 0;
|
||||||
levels[level] = [...(levels[level] ?? []), program];
|
levels[level] = [...(levels[level] ?? []), program];
|
||||||
return levels;
|
return levels;
|
||||||
|
}, []).map((levelPrograms = []) => {
|
||||||
|
const levelGroups = levelPrograms.reduce((groups, program) => {
|
||||||
|
const groupKey = programToGroupKey[program.id];
|
||||||
|
const group = groups.find((item) => item.groupKey === groupKey);
|
||||||
|
if (group) {
|
||||||
|
group.programs.push(program);
|
||||||
|
} else {
|
||||||
|
groups.push({ groupKey, programs: [program] });
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
}, []);
|
}, []);
|
||||||
|
return levelGroups.flatMap((group) => group.programs);
|
||||||
|
});
|
||||||
const miniNodeWidth = 128;
|
const miniNodeWidth = 128;
|
||||||
const miniNodeHeight = 38;
|
const miniNodeHeight = 38;
|
||||||
const miniColumnGap = 52;
|
const miniColumnGap = 52;
|
||||||
const miniRowGap = 96;
|
const miniRowGap = 96;
|
||||||
const miniPadding = 34;
|
const miniPadding = 34;
|
||||||
const miniSideLaneWidth = 96;
|
const miniSideLaneWidth = 96;
|
||||||
const sidebarSizes = [280, 420, 560];
|
const sidebarSizes = [420, 560, 720];
|
||||||
const resolvedSidebarWidth = sidebarWidth ?? sidebarSizes[0];
|
const resolvedSidebarWidth = sidebarWidth ?? sidebarSizes[0];
|
||||||
const miniViewportWidth = Math.max(230, fullPage ? 0 : resolvedSidebarWidth - 32);
|
const miniViewportWidth = Math.max(290, fullPage ? 0 : resolvedSidebarWidth - 24);
|
||||||
const miniMaxLevelCount = Math.max(1, ...graphLevels.map((level) => level?.length ?? 0));
|
const miniMaxLevelCount = Math.max(1, ...graphLevels.map((level) => level?.length ?? 0));
|
||||||
const miniGraphWidth = Math.max(
|
const miniGraphWidth = Math.max(
|
||||||
fullPage ? 1120 : 560,
|
fullPage ? 1120 : 560,
|
||||||
@@ -1568,12 +1728,84 @@ function RelationTreePanel({
|
|||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const mapOffset = fullPage ? 0 : Math.max(0, Math.round((miniGraphWidth - miniViewportWidth) / 2));
|
const mergeGroupBoxes = Object.entries(groupMap)
|
||||||
|
.map(([groupKey, groupPrograms]) => {
|
||||||
|
const positionedPrograms = groupPrograms
|
||||||
|
.map((program) => ({ program, position: miniNodePositions[program.id] }))
|
||||||
|
.filter((item) => item.position);
|
||||||
|
if (positionedPrograms.length < 2) return null;
|
||||||
|
const left = Math.min(...positionedPrograms.map((item) => item.position.x));
|
||||||
|
const top = Math.min(...positionedPrograms.map((item) => item.position.y));
|
||||||
|
const right = Math.max(...positionedPrograms.map((item) => item.position.x + miniNodeWidth));
|
||||||
|
const bottom = Math.max(...positionedPrograms.map((item) => item.position.y + miniNodeHeight));
|
||||||
|
return {
|
||||||
|
groupKey,
|
||||||
|
programIds: positionedPrograms.map((item) => item.program.id),
|
||||||
|
left: left - 12,
|
||||||
|
top: top - 12,
|
||||||
|
width: right - left + 24,
|
||||||
|
height: bottom - top + 24
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
const mergeGroupBoxMap = Object.fromEntries(mergeGroupBoxes.map((box) => [box.groupKey, box]));
|
||||||
|
const baseMiniGraphScale = fullPage ? 1 : 0.72;
|
||||||
|
const miniGraphScale = baseMiniGraphScale * mapZoom;
|
||||||
|
const miniScaledWidth = miniGraphWidth * miniGraphScale;
|
||||||
|
const mapOffset = fullPage ? 0 : Math.max(0, Math.round((miniScaledWidth - miniViewportWidth) / 2 / miniGraphScale));
|
||||||
const canShrinkSidebar = resolvedSidebarWidth > sidebarSizes[0];
|
const canShrinkSidebar = resolvedSidebarWidth > sidebarSizes[0];
|
||||||
const setSidebarSize = (size) => {
|
const setSidebarSize = (size) => {
|
||||||
onSidebarWidthChange?.(size);
|
onSidebarWidthChange?.(size);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMapWheel = useCallback((event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation?.();
|
||||||
|
const zoomStep = event.deltaY < 0 ? 1.08 : 0.92;
|
||||||
|
setMapZoom((currentZoom) => {
|
||||||
|
const minZoom = fullPage ? 0.5 : 0.78;
|
||||||
|
const maxZoom = fullPage ? 2.2 : 1.8;
|
||||||
|
return Math.min(maxZoom, Math.max(minZoom, currentZoom * zoomStep));
|
||||||
|
});
|
||||||
|
}, [fullPage]);
|
||||||
|
|
||||||
|
const handleMapViewportRef = useCallback((node) => {
|
||||||
|
if (mapViewportRef.current) {
|
||||||
|
mapViewportRef.current.removeEventListener('wheel', handleMapWheel);
|
||||||
|
}
|
||||||
|
if (node) {
|
||||||
|
node.addEventListener('wheel', handleMapWheel, { passive: false });
|
||||||
|
}
|
||||||
|
mapViewportRef.current = node;
|
||||||
|
}, [handleMapWheel]);
|
||||||
|
|
||||||
|
const handleMapPointerDown = (event) => {
|
||||||
|
if (event.target.closest?.('button')) return;
|
||||||
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||||
|
setMapDrag({
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
panX: mapPan.x,
|
||||||
|
panY: mapPan.y
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMapPointerMove = (event) => {
|
||||||
|
if (!mapDrag || mapDrag.pointerId !== event.pointerId) return;
|
||||||
|
setMapPan({
|
||||||
|
x: mapDrag.panX + event.clientX - mapDrag.startX,
|
||||||
|
y: mapDrag.panY + event.clientY - mapDrag.startY
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMapPointerEnd = (event) => {
|
||||||
|
if (mapDrag?.pointerId === event.pointerId) {
|
||||||
|
setMapDrag(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleExpanded = (programId) => {
|
const toggleExpanded = (programId) => {
|
||||||
setExpandedProgramIds((current) => {
|
setExpandedProgramIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
@@ -1718,17 +1950,48 @@ function RelationTreePanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{relations.length > 0 ? (
|
{relations.length > 0 ? (
|
||||||
<div className={`${fullPage ? 'h-[calc(100vh-150px)] overflow-auto' : 'h-[660px] overflow-hidden'} relative rounded-2xl bg-gradient-to-br from-slate-50 to-white ring-1 ring-slate-100`}>
|
<div
|
||||||
|
ref={handleMapViewportRef}
|
||||||
|
className={`${fullPage ? 'h-[calc(100vh-150px)] overflow-hidden' : 'h-[660px] overflow-hidden'} relative cursor-grab touch-none overscroll-contain rounded-2xl bg-white ring-1 ring-slate-100 ${mapDrag ? 'cursor-grabbing' : ''}`}
|
||||||
|
onPointerDown={handleMapPointerDown}
|
||||||
|
onPointerMove={handleMapPointerMove}
|
||||||
|
onPointerUp={handleMapPointerEnd}
|
||||||
|
onPointerCancel={handleMapPointerEnd}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={`${fullPage ? 'relative' : 'absolute left-0 top-0 transition-transform duration-300 ease-out'}`}
|
className={`${fullPage ? 'relative' : 'absolute left-0 top-0 transition-transform duration-300 ease-out'}`}
|
||||||
style={{
|
style={{
|
||||||
width: miniGraphWidth,
|
width: miniGraphWidth,
|
||||||
height: miniGraphHeight,
|
height: miniGraphHeight,
|
||||||
transform: `translateX(-${mapOffset}px)`
|
transform: `translate(${mapPan.x - mapOffset}px, ${mapPan.y}px) scale(${miniGraphScale})`,
|
||||||
|
transformOrigin: 'top left'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{mergeGroupBoxes.map((box) => (
|
||||||
|
<button
|
||||||
|
key={box.groupKey}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const [leftProgramId, rightProgramId] = box.programIds;
|
||||||
|
if (leftProgramId && rightProgramId) {
|
||||||
|
onOpenComparePair?.({ leftProgramId, rightProgramId });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="absolute rounded-[18px] border-2 border-dashed border-violet-300 bg-violet-50/25 text-left transition hover:border-violet-400 hover:bg-violet-50/45"
|
||||||
|
style={{
|
||||||
|
left: box.left,
|
||||||
|
top: box.top,
|
||||||
|
width: box.width,
|
||||||
|
height: box.height
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="absolute -top-3 left-3 rounded-full bg-white px-2 py-0.5 text-[10px] font-black text-violet-600 shadow-sm ring-1 ring-violet-100">
|
||||||
|
비교 프로그램
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
<svg
|
<svg
|
||||||
className="absolute inset-0 h-full w-full overflow-visible"
|
className="pointer-events-none absolute inset-0 h-full w-full overflow-visible"
|
||||||
viewBox={`0 0 ${miniGraphWidth} ${miniGraphHeight}`}
|
viewBox={`0 0 ${miniGraphWidth} ${miniGraphHeight}`}
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
>
|
>
|
||||||
@@ -1744,23 +2007,37 @@ function RelationTreePanel({
|
|||||||
const from = miniNodePositions[relation.from.id];
|
const from = miniNodePositions[relation.from.id];
|
||||||
const to = miniNodePositions[relation.to.id];
|
const to = miniNodePositions[relation.to.id];
|
||||||
if (!from || !to) return null;
|
if (!from || !to) return null;
|
||||||
|
const toGroupBox = mergeGroupBoxMap[relation.toGroupKey];
|
||||||
const fromLevel = levelMap[relation.from.id] ?? 0;
|
const fromLevel = levelMap[relation.from.id] ?? 0;
|
||||||
const toLevel = levelMap[relation.to.id] ?? 0;
|
const toLevel = levelMap[relation.to.id] ?? 0;
|
||||||
const isSkipEdge = toLevel - fromLevel > 1;
|
const isSkipEdge = toLevel - fromLevel > 1;
|
||||||
const fromCenterX = from.x + miniNodeWidth / 2;
|
const fromCenterX = from.x + miniNodeWidth / 2;
|
||||||
const toCenterX = to.x + miniNodeWidth / 2;
|
const toCenterX = toGroupBox ? toGroupBox.left + toGroupBox.width / 2 : to.x + miniNodeWidth / 2;
|
||||||
const startY = from.y + miniNodeHeight;
|
const startY = from.y + miniNodeHeight;
|
||||||
const endY = to.y;
|
const endY = toGroupBox ? toGroupBox.top : to.y;
|
||||||
const midY = startY + Math.max(30, (endY - startY) / 2);
|
const midY = startY + Math.max(30, (endY - startY) / 2);
|
||||||
const isRightSkipEdge = toCenterX > fromCenterX;
|
const isRightSkipEdge = toCenterX > fromCenterX;
|
||||||
const laneX = isRightSkipEdge
|
const laneX = isRightSkipEdge
|
||||||
? from.x + miniNodeWidth + 40
|
? Math.min(
|
||||||
: from.x - 40;
|
miniGraphWidth - 16,
|
||||||
const skipTargetY = to.y + miniNodeHeight / 2;
|
Math.max(from.x + miniNodeWidth, toGroupBox ? toGroupBox.left + toGroupBox.width : to.x + miniNodeWidth) + 46
|
||||||
|
)
|
||||||
|
: Math.max(
|
||||||
|
16,
|
||||||
|
Math.min(from.x, toGroupBox ? toGroupBox.left : to.x) - 46
|
||||||
|
);
|
||||||
|
const skipTargetY = toGroupBox ? toGroupBox.top + toGroupBox.height / 2 : to.y + miniNodeHeight / 2;
|
||||||
|
const skipTargetX = toGroupBox
|
||||||
|
? isRightSkipEdge
|
||||||
|
? toGroupBox.left + toGroupBox.width + 10
|
||||||
|
: toGroupBox.left - 10
|
||||||
|
: isRightSkipEdge
|
||||||
|
? to.x + miniNodeWidth + 8
|
||||||
|
: to.x - 8;
|
||||||
const pathD = isSkipEdge
|
const pathD = isSkipEdge
|
||||||
? isRightSkipEdge
|
? isRightSkipEdge
|
||||||
? `M ${from.x + miniNodeWidth} ${from.y + miniNodeHeight / 2} H ${laneX} V ${skipTargetY} H ${to.x - 8}`
|
? `M ${from.x + miniNodeWidth} ${from.y + miniNodeHeight / 2} H ${laneX} V ${skipTargetY} H ${skipTargetX}`
|
||||||
: `M ${from.x} ${from.y + miniNodeHeight / 2} H ${laneX} V ${skipTargetY} H ${to.x + miniNodeWidth + 8}`
|
: `M ${from.x} ${from.y + miniNodeHeight / 2} H ${laneX} V ${skipTargetY} H ${skipTargetX}`
|
||||||
: Math.abs(fromCenterX - toCenterX) < 6
|
: Math.abs(fromCenterX - toCenterX) < 6
|
||||||
? `M ${fromCenterX} ${startY} L ${toCenterX} ${endY - 8}`
|
? `M ${fromCenterX} ${startY} L ${toCenterX} ${endY - 8}`
|
||||||
: `M ${fromCenterX} ${startY} C ${fromCenterX} ${midY}, ${toCenterX} ${midY}, ${toCenterX} ${endY - 8}`;
|
: `M ${fromCenterX} ${startY} C ${fromCenterX} ${midY}, ${toCenterX} ${midY}, ${toCenterX} ${endY - 8}`;
|
||||||
@@ -1786,8 +2063,7 @@ function RelationTreePanel({
|
|||||||
<button
|
<button
|
||||||
key={program.id}
|
key={program.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onProgramClick(program.id)}
|
className={`pointer-events-none absolute flex flex-col items-center justify-center rounded-xl border px-2 text-center shadow-sm ${typeMeta.rowClass}`}
|
||||||
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={{
|
style={{
|
||||||
left: position.x,
|
left: position.x,
|
||||||
top: position.y,
|
top: position.y,
|
||||||
@@ -1823,7 +2099,8 @@ export default function App() {
|
|||||||
const [content, setContent] = useState(readStoredContent);
|
const [content, setContent] = useState(readStoredContent);
|
||||||
const [isRelationPopupOpen, setIsRelationPopupOpen] = useState(false);
|
const [isRelationPopupOpen, setIsRelationPopupOpen] = useState(false);
|
||||||
const [isComparePopupOpen, setIsComparePopupOpen] = useState(false);
|
const [isComparePopupOpen, setIsComparePopupOpen] = useState(false);
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(280);
|
const [compareInitialPair, setCompareInitialPair] = useState(null);
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(420);
|
||||||
const [isServerLoaded, setIsServerLoaded] = useState(false);
|
const [isServerLoaded, setIsServerLoaded] = useState(false);
|
||||||
const editableCheonjiinFlow = mergeStepContent(cheonjiinFlow, content.cheonjiin.steps);
|
const editableCheonjiinFlow = mergeStepContent(cheonjiinFlow, content.cheonjiin.steps);
|
||||||
const editableWayPrimalFlow = mergeStepContent(wayPrimalFlow, content.wayPrimal.steps);
|
const editableWayPrimalFlow = mergeStepContent(wayPrimalFlow, content.wayPrimal.steps);
|
||||||
@@ -1837,6 +2114,7 @@ export default function App() {
|
|||||||
programType: getProgramType(content.cheonjiin.programType),
|
programType: getProgramType(content.cheonjiin.programType),
|
||||||
predecessors: content.cheonjiin.predecessors ?? [],
|
predecessors: content.cheonjiin.predecessors ?? [],
|
||||||
successors: content.cheonjiin.successors ?? [],
|
successors: content.cheonjiin.successors ?? [],
|
||||||
|
mergeGroup: content.cheonjiin.mergeGroup ?? '',
|
||||||
accent: {
|
accent: {
|
||||||
iconBg: 'bg-teal-50',
|
iconBg: 'bg-teal-50',
|
||||||
iconText: 'text-teal-700',
|
iconText: 'text-teal-700',
|
||||||
@@ -1853,6 +2131,7 @@ export default function App() {
|
|||||||
programType: getProgramType(content.wayPrimal.programType),
|
programType: getProgramType(content.wayPrimal.programType),
|
||||||
predecessors: content.wayPrimal.predecessors ?? [],
|
predecessors: content.wayPrimal.predecessors ?? [],
|
||||||
successors: content.wayPrimal.successors ?? [],
|
successors: content.wayPrimal.successors ?? [],
|
||||||
|
mergeGroup: content.wayPrimal.mergeGroup ?? '',
|
||||||
accent: {
|
accent: {
|
||||||
iconBg: 'bg-indigo-50',
|
iconBg: 'bg-indigo-50',
|
||||||
iconText: 'text-indigo-700',
|
iconText: 'text-indigo-700',
|
||||||
@@ -1869,6 +2148,7 @@ export default function App() {
|
|||||||
programType: getProgramType(program.programType),
|
programType: getProgramType(program.programType),
|
||||||
predecessors: program.predecessors ?? [],
|
predecessors: program.predecessors ?? [],
|
||||||
successors: program.successors ?? [],
|
successors: program.successors ?? [],
|
||||||
|
mergeGroup: program.mergeGroup ?? '',
|
||||||
accent: {
|
accent: {
|
||||||
iconBg: 'bg-blue-50',
|
iconBg: 'bg-blue-50',
|
||||||
iconText: 'text-blue-700',
|
iconText: 'text-blue-700',
|
||||||
@@ -2206,20 +2486,49 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateComparison = (leftProgramId, rightProgramId, field, value) => {
|
const updateProgramMergeGroup = (programId, mergeGroup) => {
|
||||||
|
setContent((current) => {
|
||||||
|
if (!mergeGroup) {
|
||||||
|
return updateProgramRecord(current, programId, (program) => ({
|
||||||
|
...program,
|
||||||
|
mergeGroup: ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetProgram = programs.find((program) => program.id === mergeGroup || program.mergeGroup === mergeGroup);
|
||||||
|
const nextMergeGroup = targetProgram?.mergeGroup || targetProgram?.id || mergeGroup;
|
||||||
|
let nextContent = updateProgramRecord(current, programId, (program) => ({
|
||||||
|
...program,
|
||||||
|
mergeGroup: nextMergeGroup
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (targetProgram) {
|
||||||
|
nextContent = updateProgramRecord(nextContent, targetProgram.id, (program) => ({
|
||||||
|
...program,
|
||||||
|
mergeGroup: nextMergeGroup
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextContent;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateComparison = (comparisonId, leftProgramId, rightProgramId, field, value) => {
|
||||||
setContent((current) => {
|
setContent((current) => {
|
||||||
const comparisons = current.comparisons ?? [];
|
const comparisons = current.comparisons ?? [];
|
||||||
const comparisonIndex = comparisons.findIndex(
|
const comparisonIndex = comparisons.findIndex(
|
||||||
(comparison) => comparison.leftProgramId === leftProgramId && comparison.rightProgramId === rightProgramId
|
(comparison) => comparison.id === comparisonId
|
||||||
);
|
);
|
||||||
const nextComparison = {
|
const nextComparison = {
|
||||||
...(comparisonIndex >= 0 ? comparisons[comparisonIndex] : {}),
|
...(comparisonIndex >= 0 ? comparisons[comparisonIndex] : {}),
|
||||||
|
id: comparisonId,
|
||||||
leftProgramId,
|
leftProgramId,
|
||||||
rightProgramId,
|
rightProgramId,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
[field]: value
|
[field]: value
|
||||||
};
|
};
|
||||||
const hasComparisonContent =
|
const hasComparisonContent =
|
||||||
|
nextComparison.title?.trim() ||
|
||||||
(nextComparison.stepMatches ?? []).some((match) => match.reason?.trim()) ||
|
(nextComparison.stepMatches ?? []).some((match) => match.reason?.trim()) ||
|
||||||
nextComparison.note?.trim();
|
nextComparison.note?.trim();
|
||||||
return {
|
return {
|
||||||
@@ -2235,6 +2544,41 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveComparison = (comparison) => {
|
||||||
|
setContent((current) => {
|
||||||
|
const comparisons = current.comparisons ?? [];
|
||||||
|
const comparisonIndex = comparisons.findIndex((item) => item.id === comparison.id);
|
||||||
|
const nextComparison = {
|
||||||
|
...comparison,
|
||||||
|
updatedAt: comparison.updatedAt ?? new Date().toISOString()
|
||||||
|
};
|
||||||
|
const hasComparisonContent =
|
||||||
|
nextComparison.title?.trim() ||
|
||||||
|
(nextComparison.stepMatches ?? []).some((match) => match.reason?.trim()) ||
|
||||||
|
nextComparison.note?.trim();
|
||||||
|
if (!hasComparisonContent) return current;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
comparisons:
|
||||||
|
comparisonIndex >= 0
|
||||||
|
? comparisons.map((item, index) => (index === comparisonIndex ? nextComparison : item))
|
||||||
|
: [...comparisons, nextComparison]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteComparison = (comparisonId) => {
|
||||||
|
setContent((current) => ({
|
||||||
|
...current,
|
||||||
|
comparisons: (current.comparisons ?? []).filter((comparison) => comparison.id !== comparisonId)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openComparePair = (pair) => {
|
||||||
|
setCompareInitialPair(pair);
|
||||||
|
setIsComparePopupOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const updateProgramTitle = (programId, field, value) => {
|
const updateProgramTitle = (programId, field, value) => {
|
||||||
if (programId === 'cheonjiin' || programId === 'wayPrimal') {
|
if (programId === 'cheonjiin' || programId === 'wayPrimal') {
|
||||||
const target = programId === 'cheonjiin' ? 'cheonjiin' : 'wayPrimal';
|
const target = programId === 'cheonjiin' ? 'cheonjiin' : 'wayPrimal';
|
||||||
@@ -2287,6 +2631,7 @@ export default function App() {
|
|||||||
programType: 'internal',
|
programType: 'internal',
|
||||||
predecessors: [],
|
predecessors: [],
|
||||||
successors: [],
|
successors: [],
|
||||||
|
mergeGroup: '',
|
||||||
linkLabel: '이전 프로그램 산출물을 새 프로그램 입력으로 연계'
|
linkLabel: '이전 프로그램 산출물을 새 프로그램 입력으로 연계'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2501,10 +2846,11 @@ export default function App() {
|
|||||||
|
|
||||||
if (isRelationMapWindow) {
|
if (isRelationMapWindow) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,#dffcf4_0,#f7f8fa_34%,#eef2ff_100%)] text-slate-900">
|
<main className="min-h-screen bg-white text-slate-900">
|
||||||
<RelationTreePanel
|
<RelationTreePanel
|
||||||
programs={programs}
|
programs={programs}
|
||||||
onProgramClick={openProgramWindow}
|
onProgramClick={openProgramWindow}
|
||||||
|
onOpenComparePair={openComparePair}
|
||||||
onOpenRelationPopup={() => setIsRelationPopupOpen(true)}
|
onOpenRelationPopup={() => setIsRelationPopupOpen(true)}
|
||||||
sidebarWidth={1280}
|
sidebarWidth={1280}
|
||||||
onSidebarWidthChange={() => {}}
|
onSidebarWidthChange={() => {}}
|
||||||
@@ -2515,17 +2861,9 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,#dffcf4_0,#f7f8fa_34%,#eef2ff_100%)] text-slate-900">
|
<main className="min-h-screen bg-white text-slate-900">
|
||||||
<div className="mx-auto max-w-[1760px] space-y-4 px-3 py-4">
|
<div className="mx-auto max-w-[1760px] space-y-4 px-3 py-4">
|
||||||
<div className="flex justify-end gap-2">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleProgramEdit}
|
onClick={toggleProgramEdit}
|
||||||
@@ -2547,6 +2885,7 @@ export default function App() {
|
|||||||
<RelationTreePanel
|
<RelationTreePanel
|
||||||
programs={programs}
|
programs={programs}
|
||||||
onProgramClick={openProgramWindow}
|
onProgramClick={openProgramWindow}
|
||||||
|
onOpenComparePair={openComparePair}
|
||||||
onOpenRelationPopup={() => setIsRelationPopupOpen(true)}
|
onOpenRelationPopup={() => setIsRelationPopupOpen(true)}
|
||||||
onOpenMapWindow={openRelationMapWindow}
|
onOpenMapWindow={openRelationMapWindow}
|
||||||
sidebarWidth={sidebarWidth}
|
sidebarWidth={sidebarWidth}
|
||||||
@@ -2748,6 +3087,7 @@ export default function App() {
|
|||||||
<RelationPopup
|
<RelationPopup
|
||||||
programs={programs}
|
programs={programs}
|
||||||
onToggleRelation={toggleProgramRelation}
|
onToggleRelation={toggleProgramRelation}
|
||||||
|
onMergeGroupChange={updateProgramMergeGroup}
|
||||||
onClose={() => setIsRelationPopupOpen(false)}
|
onClose={() => setIsRelationPopupOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -2756,8 +3096,13 @@ export default function App() {
|
|||||||
<ProgramComparePopup
|
<ProgramComparePopup
|
||||||
programs={programs}
|
programs={programs}
|
||||||
comparisons={content.comparisons ?? []}
|
comparisons={content.comparisons ?? []}
|
||||||
onComparisonChange={updateComparison}
|
initialPair={compareInitialPair}
|
||||||
onClose={() => setIsComparePopupOpen(false)}
|
onComparisonSave={saveComparison}
|
||||||
|
onComparisonDelete={deleteComparison}
|
||||||
|
onClose={() => {
|
||||||
|
setIsComparePopupOpen(false);
|
||||||
|
setCompareInitialPair(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user