Improve minimap relation interactions
This commit is contained in:
246
src/App.jsx
246
src/App.jsx
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user