Improve minimap relation interactions

This commit is contained in:
2026-06-24 15:50:19 +09:00
parent 82371d3abe
commit 5fefad6c3c

View File

@@ -550,7 +550,8 @@ function FlowRow({
onAddStep,
onRemoveStep,
onMoveStep,
onProgramDelete
onProgramDelete,
rowRef
}) {
const activeIndex = steps.findIndex((step) => step.id === activeStep);
@@ -631,6 +632,7 @@ function FlowRow({
return (
<section
ref={rowRef}
onClick={isRowClickable ? onLabelClick : undefined}
className={`rounded-[28px] border p-5 shadow-[0_18px_50px_rgba(15,23,42,0.08)] backdrop-blur ${programTypeMeta.rowClass} ${
isRowClickable ? 'cursor-pointer transition hover:-translate-y-0.5 hover:shadow-[0_22px_60px_rgba(15,23,42,0.12)]' : ''
@@ -1900,6 +1902,8 @@ function RelationTreePanel({
programs,
onProgramClick,
onOpenComparePair,
onRelationSelect,
selectedRelationId,
onOpenRelationPopup,
onOpenMapWindow,
sidebarWidth,
@@ -2287,7 +2291,7 @@ function RelationTreePanel({
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"
className="absolute z-20 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,
@@ -2301,7 +2305,7 @@ function RelationTreePanel({
</button>
))}
<svg
className="pointer-events-none absolute inset-0 h-full w-full overflow-visible"
className="pointer-events-none absolute inset-0 z-40 h-full w-full overflow-visible"
viewBox={`0 0 ${miniGraphWidth} ${miniGraphHeight}`}
preserveAspectRatio="none"
>
@@ -2312,11 +2316,17 @@ function RelationTreePanel({
<marker id="sidebar-relation-arrow-skip" markerWidth="6" markerHeight="6" refX="5.5" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 6 3 L 0 6 z" fill="#7c3aed" />
</marker>
<marker id="sidebar-relation-arrow-selected" markerWidth="7" markerHeight="7" refX="6.5" refY="3.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 7 3.5 L 0 7 z" fill="#f97316" />
</marker>
</defs>
{relations.map((relation) => {
const from = miniNodePositions[relation.from.id];
const to = miniNodePositions[relation.to.id];
if (!from || !to) return null;
const selectedToId = groupRepresentativeMap[relation.toGroupKey] ?? relation.to.id;
const relationId = `${relation.from.id}->${selectedToId}`;
const isSelectedRelation = selectedRelationId === relationId;
const toGroupBox = mergeGroupBoxMap[relation.toGroupKey];
const fromLevel = levelMap[relation.from.id] ?? 0;
const toLevel = levelMap[relation.to.id] ?? 0;
@@ -2351,17 +2361,50 @@ function RelationTreePanel({
: Math.abs(fromCenterX - toCenterX) < 6
? `M ${fromCenterX} ${startY} L ${toCenterX} ${endY - 8}`
: `M ${fromCenterX} ${startY} C ${fromCenterX} ${midY}, ${toCenterX} ${midY}, ${toCenterX} ${endY - 8}`;
const handleRelationClick = (event) => {
event.stopPropagation();
onRelationSelect?.(relation.from.id, selectedToId);
};
const stopRelationDrag = (event) => {
event.stopPropagation();
};
return (
<path
key={`${relation.from.id}-${relation.to.id}`}
d={pathD}
fill="none"
stroke={isSkipEdge ? '#7c3aed' : '#2563eb'}
strokeWidth={isSkipEdge ? '2.2' : '2'}
strokeDasharray={isSkipEdge ? '6 5' : undefined}
opacity={isSkipEdge ? '0.82' : '1'}
markerEnd={isSkipEdge ? 'url(#sidebar-relation-arrow-skip)' : 'url(#sidebar-relation-arrow)'}
/>
<g key={`${relation.from.id}-${relation.to.id}`}>
<path
d={pathD}
fill="none"
stroke="transparent"
strokeWidth="24"
className="cursor-pointer"
style={{ pointerEvents: 'stroke' }}
onPointerDown={stopRelationDrag}
onClick={handleRelationClick}
/>
{isSelectedRelation && (
<path
d={pathD}
fill="none"
stroke="#fed7aa"
strokeWidth="5.5"
strokeLinecap="round"
opacity="0.65"
style={{ pointerEvents: 'none' }}
/>
)}
<path
d={pathD}
fill="none"
stroke={isSelectedRelation ? '#f97316' : isSkipEdge ? '#7c3aed' : '#2563eb'}
strokeWidth={isSelectedRelation ? '2.9' : isSkipEdge ? '2.2' : '2'}
strokeDasharray={isSkipEdge ? '6 5' : undefined}
opacity={isSelectedRelation ? '1' : isSkipEdge ? '0.82' : '1'}
markerEnd={isSelectedRelation ? 'url(#sidebar-relation-arrow-selected)' : isSkipEdge ? 'url(#sidebar-relation-arrow-skip)' : 'url(#sidebar-relation-arrow)'}
className="cursor-pointer"
style={{ pointerEvents: 'stroke' }}
onPointerDown={stopRelationDrag}
onClick={handleRelationClick}
/>
</g>
);
})}
</svg>
@@ -2373,7 +2416,11 @@ function RelationTreePanel({
<button
key={program.id}
type="button"
className={`pointer-events-none absolute flex flex-col items-center justify-center rounded-xl border px-2 text-center shadow-sm ${typeMeta.rowClass}`}
onClick={(event) => {
event.stopPropagation();
onProgramClick(program.id);
}}
className={`pointer-events-auto absolute z-50 flex flex-col items-center justify-center rounded-xl border px-2 text-center shadow-sm transition hover:-translate-y-0.5 hover:shadow-md ${typeMeta.rowClass}`}
style={{
left: position.x,
top: position.y,
@@ -2414,8 +2461,11 @@ export default function App() {
const [isRelationPopupOpen, setIsRelationPopupOpen] = useState(false);
const [isComparePopupOpen, setIsComparePopupOpen] = useState(false);
const [compareInitialPair, setCompareInitialPair] = useState(null);
const [selectedRelationId, setSelectedRelationId] = useState('');
const [sidebarWidth, setSidebarWidth] = useState(420);
const [isServerLoaded, setIsServerLoaded] = useState(false);
const relationBlockRefs = useRef({});
const programSectionRefs = useRef({});
const editableCheonjiinFlow = mergeStepContent(cheonjiinFlow, content.cheonjiin.steps);
const editableWayPrimalFlow = mergeStepContent(wayPrimalFlow, content.wayPrimal.steps);
const programs = [
@@ -2478,6 +2528,122 @@ export default function App() {
? content.wayPrimal
: content.extraPrograms.find((program) => program.id === programId)
);
const programById = Object.fromEntries(programs.map((program) => [program.id, program]));
const programOrderMap = Object.fromEntries(programs.map((program, index) => [program.id, index]));
const getProgramGroupKey = (program) => program.mergeGroup || program.id;
const groupRepresentativeByKey = programs.reduce((representatives, program) => {
const groupKey = getProgramGroupKey(program);
if (!representatives[groupKey]) representatives[groupKey] = program.id;
return representatives;
}, {});
const getRelationId = (fromId, toId) => `${fromId}->${toId}`;
const getRelationLabel = (fromProgram, toPrograms) => {
const targetPrograms = Array.isArray(toPrograms) ? toPrograms : [toPrograms];
const storedTarget = targetPrograms.map((program) => getStoredProgram(program.id)).find((program) => program?.linkLabel);
const targetName = targetPrograms.map((program) => program.name).join(' / ');
return storedTarget?.linkLabel || `${fromProgram.name} 결과물을 ${targetName}로 연계`;
};
const selectRelation = (fromId, toId) => {
const relationId = getRelationId(fromId, toId);
setSelectedRelationId(relationId);
requestAnimationFrame(() => {
relationBlockRefs.current[relationId]?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
});
};
const scrollProgramIntoView = (programId) => {
programSectionRefs.current[programId]?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
};
const renderRelationBlock = (fromProgram) => {
const successorGroups = Object.values(
(fromProgram.successors ?? [])
.map((successorId) => programById[successorId])
.filter(Boolean)
.reduce((groups, successorProgram) => {
const groupKey = getProgramGroupKey(successorProgram);
groups[groupKey] = groups[groupKey] ?? {
groupKey,
programs: [],
representativeId: groupRepresentativeByKey[groupKey] ?? successorProgram.id
};
groups[groupKey].programs.push(successorProgram);
return groups;
}, {})
).sort((left, right) => {
const leftOrder = Math.min(...left.programs.map((program) => programOrderMap[program.id] ?? 9999));
const rightOrder = Math.min(...right.programs.map((program) => programOrderMap[program.id] ?? 9999));
return leftOrder - rightOrder;
});
if (successorGroups.length === 0) return null;
return (
<section className="rounded-[22px] border border-slate-200/80 bg-white px-4 py-3 shadow-sm">
<div className="mb-2 flex items-center gap-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-slate-50 text-slate-500 ring-1 ring-slate-200">
<ArrowDown className="h-4 w-4" />
</div>
<div>
<p className="text-[11px] font-black uppercase tracking-wide text-blue-700">연계 흐름</p>
<h3 className="text-sm font-black text-slate-900">
{fromProgram.name} 후행 프로그램 연계
</h3>
</div>
</div>
<div className="grid gap-2">
{successorGroups.map((successorGroup) => {
const toPrograms = successorGroup.programs;
const representativeId = successorGroup.representativeId;
const relationId = getRelationId(fromProgram.id, representativeId);
const isSelected = selectedRelationId === relationId;
const label = getRelationLabel(fromProgram, toPrograms);
const targetName = toPrograms.map((program) => program.name).join(' / ');
return (
<div
key={relationId}
ref={(node) => {
if (node) relationBlockRefs.current[relationId] = node;
}}
className={`rounded-2xl border px-3 py-2 transition ${
isSelected
? 'border-orange-300 bg-orange-50 shadow-sm ring-2 ring-orange-100'
: 'border-slate-100 bg-slate-50/80'
}`}
>
<button
type="button"
onClick={() => selectRelation(fromProgram.id, representativeId)}
className="flex w-full items-center gap-2 text-left"
>
<span className="rounded-full bg-white px-2 py-1 text-[11px] font-black text-slate-700 ring-1 ring-slate-200">
{fromProgram.name}
</span>
<ArrowRight className={`h-4 w-4 shrink-0 ${isSelected ? 'text-orange-500' : 'text-blue-500'}`} />
<span className="rounded-full bg-white px-2 py-1 text-[11px] font-black text-blue-700 ring-1 ring-blue-100">
{targetName}
</span>
</button>
{isEditing ? (
<input
value={label}
onChange={(event) => updateProgramLinkLabel(representativeId, event.target.value)}
className="mt-2 w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm font-bold text-slate-700 outline-none focus:border-blue-400"
/>
) : (
<p className="mt-2 text-sm font-bold leading-5 text-slate-600">
{label}
</p>
)}
</div>
);
})}
</div>
</section>
);
};
const getProgramReviewItems = (program) => buildReviewItems(
program.id,
program.steps,
@@ -3165,6 +3331,8 @@ export default function App() {
programs={programs}
onProgramClick={openProgramWindow}
onOpenComparePair={openComparePair}
onRelationSelect={selectRelation}
selectedRelationId={selectedRelationId}
onOpenRelationPopup={() => setIsRelationPopupOpen(true)}
sidebarWidth={1280}
onSidebarWidthChange={() => {}}
@@ -3211,8 +3379,10 @@ export default function App() {
>
<RelationTreePanel
programs={programs}
onProgramClick={openProgramWindow}
onProgramClick={scrollProgramIntoView}
onOpenComparePair={openComparePair}
onRelationSelect={selectRelation}
selectedRelationId={selectedRelationId}
onOpenRelationPopup={() => setIsRelationPopupOpen(true)}
onOpenMapWindow={openRelationMapWindow}
sidebarWidth={sidebarWidth}
@@ -3260,6 +3430,9 @@ export default function App() {
onAddStep={() => addStep('cheonjiin')}
onRemoveStep={(index) => removeStep('cheonjiin', index)}
onMoveStep={(index, nextIndex) => moveStep('cheonjiin', index, nextIndex)}
rowRef={(node) => {
programSectionRefs.current.cheonjiin = node;
}}
accent={{
iconBg: 'bg-teal-50',
iconText: 'text-teal-700',
@@ -3267,23 +3440,7 @@ export default function App() {
arrowText: 'text-teal-600'
}}
/>
<div className="flex items-center gap-4 px-3 py-1">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-white/85 text-slate-500 shadow-sm ring-1 ring-white">
<ArrowDown className="h-5 w-5" />
</div>
{isEditing ? (
<input
value={content.wayPrimal.linkLabel}
onChange={(event) => updateProgramLinkLabel('wayPrimal', event.target.value)}
className="min-w-[360px] rounded-full border border-slate-200 bg-white/85 px-3 py-2 text-sm font-bold text-slate-700 outline-none focus:border-indigo-400"
/>
) : (
<div className="text-sm font-bold text-slate-600">
{content.wayPrimal.linkLabel}
</div>
)}
</div>
{renderRelationBlock(programById.cheonjiin)}
<FlowRow
label={content.wayPrimal.name}
@@ -3304,6 +3461,9 @@ export default function App() {
onAddStep={() => addStep('wayPrimal')}
onRemoveStep={(index) => removeStep('wayPrimal', index)}
onMoveStep={(index, nextIndex) => moveStep('wayPrimal', index, nextIndex)}
rowRef={(node) => {
programSectionRefs.current.wayPrimal = node;
}}
accent={{
iconBg: 'bg-indigo-50',
iconText: 'text-indigo-700',
@@ -3311,26 +3471,10 @@ export default function App() {
arrowText: 'text-indigo-600'
}}
/>
{renderRelationBlock(programById.wayPrimal)}
{content.extraPrograms.map((program, programIndex) => (
<React.Fragment key={program.id}>
<div className="flex items-center gap-4 px-3 py-1">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-white/85 text-slate-500 shadow-sm ring-1 ring-white">
<ArrowDown className="h-5 w-5" />
</div>
{isEditing ? (
<input
value={program.linkLabel ?? `이전 프로그램 산출물을 ${program.name} 입력으로 연계`}
onChange={(event) => updateProgramLinkLabel(program.id, event.target.value)}
className="min-w-[360px] rounded-full border border-slate-200 bg-white/85 px-3 py-2 text-sm font-bold text-slate-700 outline-none focus:border-blue-400"
/>
) : (
<div className="text-sm font-bold text-slate-600">
{program.linkLabel ?? `이전 프로그램 산출물을 ${program.name} 입력으로 연계`}
</div>
)}
</div>
{isEditing && (
<section className="rounded-[24px] border border-white/70 bg-white/70 px-5 py-4 shadow-sm backdrop-blur">
<div className="grid gap-3 lg:grid-cols-[220px_1fr_auto] lg:items-start">
@@ -3396,6 +3540,9 @@ export default function App() {
onRemoveStep={(index) => removeStep(program.id, index)}
onMoveStep={(index, nextIndex) => moveStep(program.id, index, nextIndex)}
onProgramDelete={() => removeProgram(programIndex)}
rowRef={(node) => {
programSectionRefs.current[program.id] = node;
}}
accent={{
iconBg: 'bg-blue-50',
iconText: 'text-blue-700',
@@ -3403,6 +3550,7 @@ export default function App() {
arrowText: 'text-blue-600'
}}
/>
{renderRelationBlock(programById[program.id])}
</React.Fragment>
))}