fix: vessel_store 타임존 수정 + 모선 추론 이식 + 검토 목록 동기화 #221
13
frontend/gear-parent-flow.html
Normal file
13
frontend/gear-parent-flow.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/kcg.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>gear-parent-flow-viewer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/gearParentFlowMain.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
169
frontend/package-lock.json
generated
169
frontend/package-lock.json
generated
@ -19,6 +19,7 @@
|
||||
"@turf/boolean-point-in-polygon": "^7.3.4",
|
||||
"@turf/helpers": "^7.3.4",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"i18next": "^25.8.18",
|
||||
@ -383,6 +384,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.2.11.tgz",
|
||||
"integrity": "sha512-2FSb0Qa6YR+Rg6GWhYOGTUug3vtZ4uKcFdnrdiJoVXGyibKJMScKZIsivY0r/yQQZsaBjYqty5QuVJvdtEHxSA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@loaders.gl/images": "~4.3.4",
|
||||
"@loaders.gl/schema": "~4.3.4",
|
||||
@ -2512,6 +2514,15 @@
|
||||
"version": "3.1.3",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"license": "MIT"
|
||||
@ -2534,6 +2545,12 @@
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"license": "MIT",
|
||||
@ -2549,6 +2566,25 @@
|
||||
"version": "3.0.2",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"license": "MIT"
|
||||
@ -2951,6 +2987,66 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz",
|
||||
"integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.76",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react/node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.76",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz",
|
||||
"integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
"@types/d3-interpolate": "^3.0.4",
|
||||
"@types/d3-selection": "^3.0.10",
|
||||
"@types/d3-transition": "^3.0.8",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/a5-js": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.5.0.tgz",
|
||||
@ -3192,6 +3288,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"license": "MIT",
|
||||
@ -3288,6 +3390,28 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"license": "BSD-3-Clause",
|
||||
@ -3333,6 +3457,16 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"license": "ISC",
|
||||
@ -3370,6 +3504,41 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"license": "MIT",
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
"@turf/boolean-point-in-polygon": "^7.3.4",
|
||||
"@turf/helpers": "^7.3.4",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"i18next": "^25.8.18",
|
||||
|
||||
@ -106,6 +106,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
>
|
||||
MON
|
||||
</button>
|
||||
<a
|
||||
className="header-toggle-btn"
|
||||
href="/gear-parent-flow.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="어구 모선 추적 흐름도"
|
||||
>
|
||||
FLOW
|
||||
</a>
|
||||
<button type="button" className="header-toggle-btn" onClick={toggleLang} title="Language">
|
||||
{i18n.language === 'ko' ? 'KO' : 'EN'}
|
||||
</button>
|
||||
|
||||
415
frontend/src/flow/GearParentFlowViewer.css
Normal file
415
frontend/src/flow/GearParentFlowViewer.css
Normal file
@ -0,0 +1,415 @@
|
||||
.gear-flow-app {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(43, 108, 176, 0.16), transparent 28%),
|
||||
radial-gradient(circle at bottom right, rgba(15, 118, 110, 0.14), transparent 24%),
|
||||
#07111f;
|
||||
color: #dce7f3;
|
||||
}
|
||||
|
||||
.gear-flow-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 332px minmax(880px, 1fr) 392px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.gear-flow-sidebar,
|
||||
.gear-flow-detail {
|
||||
backdrop-filter: blur(18px);
|
||||
background: rgba(7, 17, 31, 0.86);
|
||||
border-color: rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
.gear-flow-sidebar {
|
||||
border-right-width: 1px;
|
||||
}
|
||||
|
||||
.gear-flow-detail {
|
||||
border-left-width: 1px;
|
||||
}
|
||||
|
||||
.gear-flow-hero {
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.gear-flow-sidebar .gear-flow-hero,
|
||||
.gear-flow-detail .gear-flow-hero {
|
||||
padding-right: 1.75rem;
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
.gear-flow-panel-heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.gear-flow-panel-kicker {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.gear-flow-panel-title {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.22;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.gear-flow-panel-description {
|
||||
margin: 0;
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.72;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.gear-flow-meta-card {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: rgba(15, 23, 42, 0.64);
|
||||
padding: 1rem 1.05rem;
|
||||
}
|
||||
|
||||
.gear-flow-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.gear-flow-meta-row span {
|
||||
color: #e2e8f0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gear-flow-input,
|
||||
.gear-flow-select {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(15, 23, 42, 0.84);
|
||||
padding: 0.72rem 0.9rem;
|
||||
color: #f8fafc;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gear-flow-input:focus,
|
||||
.gear-flow-select:focus {
|
||||
border-color: rgba(96, 165, 250, 0.7);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.18);
|
||||
}
|
||||
|
||||
.gear-flow-node-card {
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: 16px;
|
||||
background: rgba(15, 23, 42, 0.68);
|
||||
overflow: hidden;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.gear-flow-node-card[data-active="true"] {
|
||||
border-color: rgba(96, 165, 250, 0.7);
|
||||
box-shadow: 0 0 0 1px rgba(96, 165, 250, 0.4);
|
||||
background: rgba(20, 35, 59, 0.86);
|
||||
}
|
||||
|
||||
.gear-flow-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
padding: 0.42rem 1rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gear-flow-chip[data-tone="implemented"] {
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.gear-flow-chip[data-tone="proposed"] {
|
||||
background: rgba(245, 158, 11, 0.16);
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.gear-flow-chip[data-tone="neutral"] {
|
||||
background: rgba(148, 163, 184, 0.14);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.gear-flow-section {
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.gear-flow-list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.gear-flow-list-item {
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
padding: 0.75rem 0.88rem;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
color: #cbd5e1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-canvas {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gear-flow-topbar {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
top: 20px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gear-flow-topbar-card {
|
||||
pointer-events: auto;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 18px;
|
||||
background: rgba(7, 17, 31, 0.82);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.gear-flow-topbar-card--wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
max-width: min(1080px, calc(100vw - 840px));
|
||||
}
|
||||
|
||||
.gear-flow-topbar-title {
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gear-flow-topbar-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: rgba(15, 23, 42, 0.58);
|
||||
padding: 0.34rem 0.72rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gear-flow-react-node {
|
||||
overflow: visible !important;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
border-color 160ms ease;
|
||||
}
|
||||
|
||||
.gear-flow-react-node.is-selected {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.gear-flow-react-node--proposal {
|
||||
border-style: dashed !important;
|
||||
}
|
||||
|
||||
.gear-flow-node {
|
||||
--gear-flow-node-text-offset: 0.98rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.02rem;
|
||||
padding: 1.78rem 2.08rem 1.68rem;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.gear-flow-node--function,
|
||||
.gear-flow-node--table,
|
||||
.gear-flow-node--component,
|
||||
.gear-flow-node--artifact,
|
||||
.gear-flow-node--proposal {
|
||||
padding-right: 2rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.gear-flow-node__accent {
|
||||
position: absolute;
|
||||
left: 1.18rem;
|
||||
top: 1.18rem;
|
||||
bottom: 1.18rem;
|
||||
width: 5px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--gear-flow-accent) 82%, white 18%);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.gear-flow-node__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.1rem;
|
||||
padding-left: var(--gear-flow-node-text-offset);
|
||||
}
|
||||
|
||||
.gear-flow-node__heading {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.gear-flow-node__stage {
|
||||
font-size: 1.04rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
line-height: 1.2;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gear-flow-node__title {
|
||||
margin-top: 0;
|
||||
font-size: 1.54rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.32;
|
||||
color: #f8fafc;
|
||||
padding-right: 0.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-node__symbol {
|
||||
font-size: 1.14rem;
|
||||
line-height: 1.55;
|
||||
color: #cbd5e1;
|
||||
overflow-wrap: anywhere;
|
||||
padding-right: 0.2rem;
|
||||
padding-left: var(--gear-flow-node-text-offset);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.gear-flow-node__role {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.62;
|
||||
color: #94a3b8;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
padding-right: 0.18rem;
|
||||
padding-left: var(--gear-flow-node-text-offset);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.gear-flow-summary {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-detail-empty {
|
||||
border: 1px dashed rgba(148, 163, 184, 0.2);
|
||||
border-radius: 18px;
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.gear-flow-detail-card {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: rgba(15, 23, 42, 0.62);
|
||||
padding: 1.15rem 1.2rem;
|
||||
}
|
||||
|
||||
.gear-flow-detail-title {
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.34;
|
||||
color: #f8fafc;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-detail-symbol {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.6;
|
||||
color: #7dd3fc;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-detail-text {
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.78;
|
||||
color: #cbd5e1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-detail-file {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.65;
|
||||
color: #94a3b8;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-section-title {
|
||||
margin-bottom: 0.78rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.gear-flow-link {
|
||||
color: #93c5fd;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.gear-flow-link:hover {
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
.gear-flow-detail .gear-flow-section {
|
||||
padding-right: 0.15rem;
|
||||
}
|
||||
|
||||
.gear-flow-detail .space-y-6 {
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1680px) {
|
||||
.gear-flow-shell {
|
||||
grid-template-columns: 304px minmax(760px, 1fr) 348px;
|
||||
}
|
||||
|
||||
.gear-flow-topbar-card--wrap {
|
||||
max-width: min(860px, calc(100vw - 740px));
|
||||
}
|
||||
}
|
||||
565
frontend/src/flow/GearParentFlowViewer.tsx
Normal file
565
frontend/src/flow/GearParentFlowViewer.tsx
Normal file
@ -0,0 +1,565 @@
|
||||
import { useMemo, useState, type CSSProperties } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
MarkerType,
|
||||
Position,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeMouseHandler,
|
||||
type EdgeMouseHandler,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import manifest from './gearParentFlowManifest.json';
|
||||
import './GearParentFlowViewer.css';
|
||||
|
||||
type FlowStatus = 'implemented' | 'proposed';
|
||||
|
||||
type FlowNodeMeta = {
|
||||
id: string;
|
||||
label: string;
|
||||
stage: string;
|
||||
kind: string;
|
||||
position: { x: number; y: number };
|
||||
file: string;
|
||||
symbol: string;
|
||||
role: string;
|
||||
params: string[];
|
||||
rules: string[];
|
||||
storageReads: string[];
|
||||
storageWrites: string[];
|
||||
outputs: string[];
|
||||
impacts: string[];
|
||||
status: FlowStatus;
|
||||
};
|
||||
|
||||
type FlowEdgeMeta = {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label?: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
type FlowManifest = {
|
||||
meta: {
|
||||
title: string;
|
||||
version: string;
|
||||
updatedAt: string;
|
||||
description: string;
|
||||
};
|
||||
nodes: FlowNodeMeta[];
|
||||
edges: FlowEdgeMeta[];
|
||||
};
|
||||
|
||||
const flowManifest = manifest as FlowManifest;
|
||||
|
||||
const stageColors: Record<string, string> = {
|
||||
'원천': '#38bdf8',
|
||||
'시간 모델': '#60a5fa',
|
||||
'적재': '#818cf8',
|
||||
'캐시': '#a78bfa',
|
||||
'정규화': '#c084fc',
|
||||
'그룹핑': '#f472b6',
|
||||
'후보 추적': '#fb7185',
|
||||
'검토 워크플로우': '#f97316',
|
||||
'최종 추론': '#f59e0b',
|
||||
'조회 계층': '#22c55e',
|
||||
'프론트': '#14b8a6',
|
||||
'문서': '#06b6d4',
|
||||
'미래 설계': '#eab308',
|
||||
};
|
||||
|
||||
const stageOrder = [
|
||||
'원천',
|
||||
'시간 모델',
|
||||
'적재',
|
||||
'캐시',
|
||||
'정규화',
|
||||
'그룹핑',
|
||||
'후보 추적',
|
||||
'검토 워크플로우',
|
||||
'최종 추론',
|
||||
'조회 계층',
|
||||
'프론트',
|
||||
'문서',
|
||||
'미래 설계',
|
||||
] as const;
|
||||
|
||||
const layoutConfig = {
|
||||
startX: 52,
|
||||
startY: 88,
|
||||
columnGap: 816,
|
||||
rowGap: 309,
|
||||
};
|
||||
|
||||
const semanticSlots: Record<string, { col: number; row: number; yOffset?: number }> = {
|
||||
source_tracks: { col: 0, row: 0 },
|
||||
safe_window: { col: 0, row: 1, yOffset: 36 },
|
||||
snpdb_fetch: { col: 0, row: 2, yOffset: 92 },
|
||||
vessel_store: { col: 0, row: 3, yOffset: 156 },
|
||||
gear_identity: { col: 1, row: 0 },
|
||||
detect_groups: { col: 1, row: 1 },
|
||||
group_snapshots: { col: 1, row: 2 },
|
||||
gear_correlation: { col: 1, row: 3 },
|
||||
workflow_exclusions: { col: 1, row: 4 },
|
||||
score_breakdown: { col: 2, row: 0 },
|
||||
parent_inference: { col: 2, row: 1 },
|
||||
backend_read_model: { col: 2, row: 2 },
|
||||
workflow_api: { col: 2, row: 3 },
|
||||
review_ui: { col: 2, row: 4 },
|
||||
future_episode: { col: 2, row: 5 },
|
||||
mermaid_docs: { col: 0, row: 5, yOffset: 240 },
|
||||
react_flow_viewer: { col: 1, row: 5, yOffset: 182 },
|
||||
};
|
||||
|
||||
function summarizeNode(node: FlowNodeMeta): string {
|
||||
return [node.symbol, node.role].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function matchesQuery(node: FlowNodeMeta, query: string): boolean {
|
||||
if (!query) return true;
|
||||
const normalizedQuery = query.replace(/\s+/g, '').toLowerCase();
|
||||
const haystack = [
|
||||
node.label,
|
||||
node.stage,
|
||||
node.kind,
|
||||
node.file,
|
||||
node.symbol,
|
||||
node.role,
|
||||
...(node.params ?? []),
|
||||
...(node.rules ?? []),
|
||||
...(node.outputs ?? []),
|
||||
...(node.impacts ?? []),
|
||||
]
|
||||
.join(' ')
|
||||
.replace(/\s+/g, '')
|
||||
.toLowerCase();
|
||||
return haystack.includes(normalizedQuery);
|
||||
}
|
||||
|
||||
function stageTone(stage: string): string {
|
||||
return stageColors[stage] ?? '#94a3b8';
|
||||
}
|
||||
|
||||
function shapeClipPath(kind: string): string | undefined {
|
||||
switch (kind) {
|
||||
case 'function':
|
||||
return 'polygon(4% 0, 100% 0, 96% 100%, 0 100%)';
|
||||
case 'table':
|
||||
return 'polygon(0 6%, 8% 0, 100% 0, 100% 94%, 92% 100%, 0 100%)';
|
||||
case 'component':
|
||||
return 'polygon(7% 0, 93% 0, 100% 16%, 100% 84%, 93% 100%, 7% 100%, 0 84%, 0 16%)';
|
||||
case 'artifact':
|
||||
return 'polygon(0 0, 86% 0, 100% 14%, 100% 100%, 0 100%)';
|
||||
case 'proposal':
|
||||
return 'polygon(7% 0, 93% 0, 100% 20%, 100% 80%, 93% 100%, 7% 100%, 0 80%, 0 20%)';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function compareNodeOrder(a: FlowNodeMeta, b: FlowNodeMeta): number {
|
||||
const stageGap = stageOrder.indexOf(a.stage as (typeof stageOrder)[number])
|
||||
- stageOrder.indexOf(b.stage as (typeof stageOrder)[number]);
|
||||
if (stageGap !== 0) return stageGap;
|
||||
if (a.position.y !== b.position.y) return a.position.y - b.position.y;
|
||||
if (a.position.x !== b.position.x) return a.position.x - b.position.x;
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
|
||||
function layoutNodeMeta(nodes: FlowNodeMeta[], _edges: FlowEdgeMeta[]): FlowNodeMeta[] {
|
||||
const sortedNodes = [...nodes].sort(compareNodeOrder);
|
||||
const fallbackSlots = new Map<number, number>();
|
||||
const positioned = new Map<string, { x: number; y: number }>();
|
||||
|
||||
for (const node of sortedNodes) {
|
||||
const semantic = semanticSlots[node.id];
|
||||
if (semantic) {
|
||||
positioned.set(node.id, {
|
||||
x: layoutConfig.startX + semantic.col * layoutConfig.columnGap,
|
||||
y: layoutConfig.startY + semantic.row * layoutConfig.rowGap + (semantic.yOffset ?? 0),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackCol = Math.min(3, stageOrder.indexOf(node.stage as (typeof stageOrder)[number]) % 4);
|
||||
const fallbackRow = fallbackSlots.get(fallbackCol) ?? 5;
|
||||
fallbackSlots.set(fallbackCol, fallbackRow + 1);
|
||||
positioned.set(node.id, {
|
||||
x: layoutConfig.startX + fallbackCol * layoutConfig.columnGap,
|
||||
y: layoutConfig.startY + fallbackRow * layoutConfig.rowGap,
|
||||
});
|
||||
}
|
||||
|
||||
return nodes.map((node) => ({ ...node, position: positioned.get(node.id) ?? node.position }));
|
||||
}
|
||||
|
||||
function buildNodes(nodes: FlowNodeMeta[], selectedNodeId: string | null): Node[] {
|
||||
return nodes.map((node) => {
|
||||
const color = stageTone(node.stage);
|
||||
const clipPath = shapeClipPath(node.kind);
|
||||
const style = {
|
||||
'--gear-flow-accent': color,
|
||||
'--gear-flow-node-text-offset': '0.98rem',
|
||||
width: 380,
|
||||
borderRadius: node.kind === 'component' || node.kind === 'proposal' ? 22 : 18,
|
||||
padding: 0,
|
||||
color: '#e2e8f0',
|
||||
border: `2px solid ${selectedNodeId === node.id ? color : `${color}88`}`,
|
||||
background: 'linear-gradient(180deg, rgba(15,23,42,0.98), rgba(15,23,42,0.84))',
|
||||
boxShadow: selectedNodeId === node.id
|
||||
? `0 0 0 4px ${color}33, 0 26px 52px rgba(2, 6, 23, 0.4)`
|
||||
: '0 18px 36px rgba(2, 6, 23, 0.24)',
|
||||
overflow: 'visible',
|
||||
...(clipPath ? { clipPath } : {}),
|
||||
} as CSSProperties;
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
position: node.position,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
className: `gear-flow-react-node gear-flow-react-node--${node.kind}${selectedNodeId === node.id ? ' is-selected' : ''}`,
|
||||
style,
|
||||
data: { label: (
|
||||
<div className={`gear-flow-node gear-flow-node--${node.kind}`}>
|
||||
<div className="gear-flow-node__accent" />
|
||||
<div className="gear-flow-node__header">
|
||||
<div className="gear-flow-node__heading">
|
||||
<div className="gear-flow-node__stage">{node.stage}</div>
|
||||
<div className="gear-flow-node__title">{node.label}</div>
|
||||
</div>
|
||||
<span
|
||||
className="gear-flow-chip shrink-0"
|
||||
data-tone={node.status === 'implemented' ? 'implemented' : 'proposed'}
|
||||
>
|
||||
{node.status === 'implemented' ? '구현됨' : '제안됨'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="gear-flow-node__symbol">{node.symbol}</div>
|
||||
<div className="gear-flow-node__role">{node.role}</div>
|
||||
</div>
|
||||
)},
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildEdges(edges: FlowEdgeMeta[], selectedEdgeId: string | null): Edge[] {
|
||||
return edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: 'smoothstep',
|
||||
pathOptions: { borderRadius: 18, offset: 18 },
|
||||
label: edge.label,
|
||||
animated: selectedEdgeId === edge.id,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: selectedEdgeId === edge.id ? '#f8fafc' : '#94a3b8',
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
style: {
|
||||
stroke: selectedEdgeId === edge.id ? '#f8fafc' : '#94a3b8',
|
||||
strokeWidth: selectedEdgeId === edge.id ? 2.8 : 1.9,
|
||||
},
|
||||
labelStyle: {
|
||||
fill: '#e2e8f0',
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
labelBgStyle: {
|
||||
fill: 'rgba(7, 17, 31, 0.94)',
|
||||
fillOpacity: 0.96,
|
||||
stroke: 'rgba(148, 163, 184, 0.24)',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
labelBgPadding: [8, 5],
|
||||
labelBgBorderRadius: 12,
|
||||
}));
|
||||
}
|
||||
|
||||
function DetailList({ items }: { items: string[] }) {
|
||||
if (!items.length) {
|
||||
return <div className="text-sm text-slate-500">없음</div>;
|
||||
}
|
||||
return (
|
||||
<div className="gear-flow-list">
|
||||
{items.map((item) => (
|
||||
<div key={item} className="gear-flow-list-item">{item}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GearParentFlowViewer() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [stageFilter, setStageFilter] = useState('전체');
|
||||
const [statusFilter, setStatusFilter] = useState<'전체' | FlowStatus>('전체');
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(flowManifest.nodes[0]?.id ?? null);
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
|
||||
const filteredNodeMeta = useMemo(() => {
|
||||
return flowManifest.nodes.filter((node) => {
|
||||
if (stageFilter !== '전체' && node.stage !== stageFilter) return false;
|
||||
if (statusFilter !== '전체' && node.status !== statusFilter) return false;
|
||||
return matchesQuery(node, search);
|
||||
});
|
||||
}, [search, stageFilter, statusFilter]);
|
||||
|
||||
const visibleNodeIds = useMemo(() => new Set(filteredNodeMeta.map((node) => node.id)), [filteredNodeMeta]);
|
||||
|
||||
const filteredEdgeMeta = useMemo(() => {
|
||||
return flowManifest.edges.filter((edge) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target));
|
||||
}, [visibleNodeIds]);
|
||||
|
||||
const layoutedNodeMeta = useMemo(
|
||||
() => layoutNodeMeta(filteredNodeMeta, filteredEdgeMeta),
|
||||
[filteredNodeMeta, filteredEdgeMeta],
|
||||
);
|
||||
|
||||
const reactFlowNodes = useMemo(
|
||||
() => buildNodes(layoutedNodeMeta, selectedNodeId),
|
||||
[layoutedNodeMeta, selectedNodeId],
|
||||
);
|
||||
const reactFlowEdges = useMemo(
|
||||
() => buildEdges(filteredEdgeMeta, selectedEdgeId),
|
||||
[filteredEdgeMeta, selectedEdgeId],
|
||||
);
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() => flowManifest.nodes.find((node) => node.id === selectedNodeId) ?? null,
|
||||
[selectedNodeId],
|
||||
);
|
||||
const selectedEdge = useMemo(
|
||||
() => flowManifest.edges.find((edge) => edge.id === selectedEdgeId) ?? null,
|
||||
[selectedEdgeId],
|
||||
);
|
||||
|
||||
const onNodeClick: NodeMouseHandler = (_event, node) => {
|
||||
setSelectedEdgeId(null);
|
||||
setSelectedNodeId(node.id);
|
||||
};
|
||||
|
||||
const onEdgeClick: EdgeMouseHandler = (_event, edge) => {
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(edge.id);
|
||||
};
|
||||
|
||||
const onNodeMouseEnter: NodeMouseHandler = (_event, node) => {
|
||||
if (!selectedNodeId) setSelectedNodeId(node.id);
|
||||
};
|
||||
|
||||
const stageOptions = useMemo(
|
||||
() => ['전체', ...Array.from(new Set(flowManifest.nodes.map((node) => node.stage)))],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="gear-flow-app">
|
||||
<div className="gear-flow-shell">
|
||||
<aside className="gear-flow-sidebar flex flex-col">
|
||||
<div className="gear-flow-hero space-y-4 px-6 py-6">
|
||||
<div className="gear-flow-panel-heading">
|
||||
<div className="gear-flow-panel-kicker">Flow Source</div>
|
||||
<h1 className="gear-flow-panel-title">{flowManifest.meta.title}</h1>
|
||||
<p className="gear-flow-panel-description">{flowManifest.meta.description}</p>
|
||||
</div>
|
||||
<div className="gear-flow-meta-card">
|
||||
<div className="gear-flow-meta-row">버전 <span>{flowManifest.meta.version}</span></div>
|
||||
<div className="gear-flow-meta-row">갱신일 <span>{flowManifest.meta.updatedAt}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">검색</label>
|
||||
<input
|
||||
className="gear-flow-input"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="모듈, 메서드, 규칙, 파일 검색"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">단계</label>
|
||||
<select className="gear-flow-select" value={stageFilter} onChange={(event) => setStageFilter(event.target.value)}>
|
||||
{stageOptions.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">상태</label>
|
||||
<select
|
||||
className="gear-flow-select"
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as '전체' | FlowStatus)}
|
||||
>
|
||||
<option value="전체">전체</option>
|
||||
<option value="implemented">구현됨</option>
|
||||
<option value="proposed">제안됨</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
<div className="mb-3 flex items-center justify-between text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
<span>노드 목록</span>
|
||||
<span>{filteredNodeMeta.length}</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{layoutedNodeMeta.map((node) => (
|
||||
<button
|
||||
key={node.id}
|
||||
type="button"
|
||||
className="gear-flow-node-card w-full p-4 text-left transition"
|
||||
data-active={selectedNodeId === node.id}
|
||||
onClick={() => {
|
||||
setSelectedEdgeId(null);
|
||||
setSelectedNodeId(node.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">{node.stage}</div>
|
||||
<div className="mt-1 text-base font-semibold text-slate-50">{node.label}</div>
|
||||
<div className="gear-flow-summary mt-2 text-sm leading-6 text-slate-400">{summarizeNode(node)}</div>
|
||||
</div>
|
||||
<span className="gear-flow-chip shrink-0" data-tone={node.status === 'implemented' ? 'implemented' : 'proposed'}>
|
||||
{node.status === 'implemented' ? '구현됨' : '제안됨'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="gear-flow-canvas">
|
||||
<div className="gear-flow-topbar">
|
||||
<div className="gear-flow-topbar-card gear-flow-topbar-card--wrap px-5 py-3 text-sm text-slate-300">
|
||||
<span className="gear-flow-topbar-title">React Flow Viewer</span>
|
||||
<span className="gear-flow-topbar-pill">노드 클릭 시 상세 스펙</span>
|
||||
<span className="gear-flow-topbar-pill">엣지 클릭 시 전달 의미</span>
|
||||
<span className="gear-flow-topbar-pill">검색/단계/상태 필터</span>
|
||||
</div>
|
||||
</div>
|
||||
<ReactFlow
|
||||
nodes={reactFlowNodes}
|
||||
edges={reactFlowEdges}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.03, minZoom: 0.7, maxZoom: 1.2 }}
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeMouseEnter={onNodeMouseEnter}
|
||||
onEdgeClick={onEdgeClick}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable
|
||||
minZoom={0.35}
|
||||
maxZoom={1.8}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<MiniMap
|
||||
pannable
|
||||
zoomable
|
||||
nodeStrokeColor={(node) => {
|
||||
const meta = flowManifest.nodes.find((item) => item.id === node.id);
|
||||
return meta ? stageTone(meta.stage) : '#94a3b8';
|
||||
}}
|
||||
nodeColor={(node) => {
|
||||
const meta = flowManifest.nodes.find((item) => item.id === node.id);
|
||||
return meta ? `${stageTone(meta.stage)}55` : '#334155';
|
||||
}}
|
||||
maskColor="rgba(7, 17, 31, 0.68)"
|
||||
/>
|
||||
<Controls showInteractive={false} />
|
||||
<Background color="#20324d" gap={24} size={1.2} />
|
||||
</ReactFlow>
|
||||
</main>
|
||||
|
||||
<aside className="gear-flow-detail flex flex-col">
|
||||
<div className="gear-flow-hero px-6 py-6">
|
||||
<div className="gear-flow-panel-heading">
|
||||
<div className="gear-flow-panel-kicker">Detail</div>
|
||||
<h2 className="gear-flow-panel-title">상세 정보</h2>
|
||||
<p className="gear-flow-panel-description">
|
||||
노드를 클릭하면 역할, 파라미터, 판단 기준, 저장소 영향과 downstream 관계를 확인할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
{selectedNode ? (
|
||||
<div className="space-y-6">
|
||||
<div className="gear-flow-detail-card">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="gear-flow-chip" data-tone={selectedNode.status === 'implemented' ? 'implemented' : 'proposed'}>
|
||||
{selectedNode.status === 'implemented' ? '구현됨' : '제안됨'}
|
||||
</span>
|
||||
<span className="gear-flow-chip" data-tone="neutral">{selectedNode.stage}</span>
|
||||
<span className="gear-flow-chip" data-tone="neutral">{selectedNode.kind}</span>
|
||||
</div>
|
||||
<div className="gear-flow-detail-title">{selectedNode.label}</div>
|
||||
<div className="gear-flow-detail-symbol">{selectedNode.symbol}</div>
|
||||
<div className="gear-flow-detail-text">{selectedNode.role}</div>
|
||||
<div className="gear-flow-detail-file">{selectedNode.file}</div>
|
||||
</div>
|
||||
|
||||
<section className="gear-flow-section pt-5">
|
||||
<div className="gear-flow-section-title">파라미터</div>
|
||||
<DetailList items={selectedNode.params} />
|
||||
</section>
|
||||
|
||||
<section className="gear-flow-section pt-5">
|
||||
<div className="gear-flow-section-title">판단 기준</div>
|
||||
<DetailList items={selectedNode.rules} />
|
||||
</section>
|
||||
|
||||
<section className="gear-flow-section pt-5">
|
||||
<div className="gear-flow-section-title">읽는 저장소</div>
|
||||
<DetailList items={selectedNode.storageReads} />
|
||||
</section>
|
||||
|
||||
<section className="gear-flow-section pt-5">
|
||||
<div className="gear-flow-section-title">쓰는 저장소</div>
|
||||
<DetailList items={selectedNode.storageWrites} />
|
||||
</section>
|
||||
|
||||
<section className="gear-flow-section pt-5">
|
||||
<div className="gear-flow-section-title">출력</div>
|
||||
<DetailList items={selectedNode.outputs} />
|
||||
</section>
|
||||
|
||||
<section className="gear-flow-section pt-5">
|
||||
<div className="gear-flow-section-title">영향 관계</div>
|
||||
<DetailList items={selectedNode.impacts} />
|
||||
</section>
|
||||
</div>
|
||||
) : selectedEdge ? (
|
||||
<div className="space-y-6">
|
||||
<div className="gear-flow-detail-card">
|
||||
<span className="gear-flow-chip" data-tone="neutral">엣지</span>
|
||||
<div className="gear-flow-detail-title">{selectedEdge.label || selectedEdge.id}</div>
|
||||
<div className="gear-flow-detail-text">{selectedEdge.detail || '설명 없음'}</div>
|
||||
<div className="gear-flow-detail-file">{selectedEdge.source} → {selectedEdge.target}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="gear-flow-detail-empty px-5 py-6 text-sm leading-7 text-slate-400">
|
||||
노드나 엣지를 선택하면 상세 정보가 여기에 표시됩니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GearParentFlowViewer;
|
||||
325
frontend/src/flow/gearParentFlowManifest.json
Normal file
325
frontend/src/flow/gearParentFlowManifest.json
Normal file
@ -0,0 +1,325 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "어구 모선 추적 데이터 흐름",
|
||||
"version": "2026-04-03",
|
||||
"updatedAt": "2026-04-03",
|
||||
"description": "snpdb 적재부터 review/label workflow와 episode continuity + prior bonus까지의 전체 흐름"
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "source_tracks",
|
||||
"label": "5분 원천 궤적",
|
||||
"stage": "원천",
|
||||
"kind": "table",
|
||||
"position": { "x": 0, "y": 20 },
|
||||
"file": "signal.t_vessel_tracks_5min",
|
||||
"symbol": "signal.t_vessel_tracks_5min",
|
||||
"role": "5분 bucket 단위 AIS 궤적 원천 테이블",
|
||||
"params": ["1 row = 1 MMSI = 5분 linestringM"],
|
||||
"rules": ["bbox 122,31,132,39", "LineStringM dump 후 point timestamp 사용"],
|
||||
"storageReads": [],
|
||||
"storageWrites": [],
|
||||
"outputs": ["mmsi", "time_bucket", "timestamp", "lat", "lon", "raw_sog"],
|
||||
"impacts": ["모든 그룹/점수 계산의 원천 입력"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "safe_window",
|
||||
"label": "safe watermark",
|
||||
"stage": "시간 모델",
|
||||
"kind": "function",
|
||||
"position": { "x": 260, "y": 20 },
|
||||
"file": "prediction/time_bucket.py",
|
||||
"symbol": "compute_safe_bucket / compute_incremental_window_start",
|
||||
"role": "미완결 bucket 차단과 overlap backfill 시작점 계산",
|
||||
"params": ["SNPDB_SAFE_DELAY_MIN", "SNPDB_BACKFILL_BUCKETS"],
|
||||
"rules": ["safe bucket까지만 조회", "last_bucket보다 과거도 일부 재조회"],
|
||||
"storageReads": [],
|
||||
"storageWrites": [],
|
||||
"outputs": ["safe_bucket", "window_start", "from_bucket"],
|
||||
"impacts": ["live cache drift 완화", "재기동 spike 억제"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "snpdb_fetch",
|
||||
"label": "snpdb 적재",
|
||||
"stage": "적재",
|
||||
"kind": "module",
|
||||
"position": { "x": 520, "y": 20 },
|
||||
"file": "prediction/db/snpdb.py",
|
||||
"symbol": "fetch_all_tracks / fetch_incremental",
|
||||
"role": "safe bucket까지 초기/증분 궤적 적재",
|
||||
"params": ["hours=24", "last_bucket"],
|
||||
"rules": ["time_bucket > from_bucket", "time_bucket <= safe_bucket"],
|
||||
"storageReads": ["signal.t_vessel_tracks_5min"],
|
||||
"storageWrites": [],
|
||||
"outputs": ["DataFrame of points"],
|
||||
"impacts": ["VesselStore 초기화와 증분 merge 입력"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "vessel_store",
|
||||
"label": "VesselStore 캐시",
|
||||
"stage": "캐시",
|
||||
"kind": "module",
|
||||
"position": { "x": 800, "y": 20 },
|
||||
"file": "prediction/cache/vessel_store.py",
|
||||
"symbol": "load_initial / merge_incremental / evict_stale",
|
||||
"role": "24시간 sliding in-memory cache 유지",
|
||||
"params": ["_tracks", "_last_bucket"],
|
||||
"rules": ["timestamp dedupe", "safe bucket 기준 24h eviction"],
|
||||
"storageReads": [],
|
||||
"storageWrites": [],
|
||||
"outputs": ["latest positions", "tracks by MMSI"],
|
||||
"impacts": ["identity, grouping, correlation, inference 공통 입력"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "gear_identity",
|
||||
"label": "어구 identity",
|
||||
"stage": "정규화",
|
||||
"kind": "module",
|
||||
"position": { "x": 1080, "y": 20 },
|
||||
"file": "prediction/fleet_tracker.py",
|
||||
"symbol": "track_gear_identity",
|
||||
"role": "어구 이름 패턴 파싱과 gear_identity_log 유지",
|
||||
"params": ["parent_name", "gear_index_1", "gear_index_2"],
|
||||
"rules": ["정규화 길이 4 미만 제외", "같은 이름 다른 MMSI면 identity migration"],
|
||||
"storageReads": ["fleet_vessels"],
|
||||
"storageWrites": ["gear_identity_log", "gear_correlation_scores(target_mmsi transfer)"],
|
||||
"outputs": ["active gear identity rows"],
|
||||
"impacts": ["grouping과 parent_mmsi 보조 입력"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "detect_groups",
|
||||
"label": "어구 그룹 검출",
|
||||
"stage": "그룹핑",
|
||||
"kind": "function",
|
||||
"position": { "x": 260, "y": 220 },
|
||||
"file": "prediction/algorithms/polygon_builder.py",
|
||||
"symbol": "detect_gear_groups",
|
||||
"role": "이름 기반 raw group과 거리 기반 sub-cluster 생성",
|
||||
"params": ["MAX_DIST_DEG=0.15", "STALE_SEC", "is_trackable_parent_name"],
|
||||
"rules": ["440/441 제외", "single cluster면 sc#0", "multi cluster면 sc#1..N", "재병합 시 sc#0"],
|
||||
"storageReads": [],
|
||||
"storageWrites": [],
|
||||
"outputs": ["gear_groups[]"],
|
||||
"impacts": ["sub_cluster_id는 순간 라벨일 뿐 영구 ID가 아님"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "group_snapshots",
|
||||
"label": "그룹 스냅샷 생성",
|
||||
"stage": "그룹핑",
|
||||
"kind": "function",
|
||||
"position": { "x": 520, "y": 220 },
|
||||
"file": "prediction/algorithms/polygon_builder.py",
|
||||
"symbol": "build_all_group_snapshots",
|
||||
"role": "1h/1h-fb/6h polygon snapshot 생성",
|
||||
"params": ["parent_active_1h", "MIN_GEAR_GROUP_SIZE"],
|
||||
"rules": ["1h 활성<2이면 1h-fb", "수역 외 소수 멤버 제외", "parent nearby면 isParent=true"],
|
||||
"storageReads": [],
|
||||
"storageWrites": ["group_polygon_snapshots"],
|
||||
"outputs": ["group snapshots"],
|
||||
"impacts": ["backend live 현황과 parent inference center track 입력"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "gear_correlation",
|
||||
"label": "correlation 모델",
|
||||
"stage": "후보 추적",
|
||||
"kind": "module",
|
||||
"position": { "x": 800, "y": 220 },
|
||||
"file": "prediction/algorithms/gear_correlation.py",
|
||||
"symbol": "run_gear_correlation",
|
||||
"role": "후보 선박/어구 raw metric과 EMA score 계산",
|
||||
"params": ["active models", "track_threshold", "decay_fast", "candidate max=30"],
|
||||
"rules": ["선박은 track 기반", "어구 후보는 GEAR_BUOY", "후보 이탈 시 fast decay"],
|
||||
"storageReads": ["group snapshots", "vessel_store", "correlation_param_models"],
|
||||
"storageWrites": ["gear_correlation_raw_metrics", "gear_correlation_scores"],
|
||||
"outputs": ["raw metrics", "EMA score rows"],
|
||||
"impacts": ["parent inference 후보 seed"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "workflow_exclusions",
|
||||
"label": "후보 제외 / 라벨",
|
||||
"stage": "검토 워크플로우",
|
||||
"kind": "table",
|
||||
"position": { "x": 1080, "y": 220 },
|
||||
"file": "database/migration/014_gear_parent_workflow_v2_phase1.sql",
|
||||
"symbol": "gear_parent_candidate_exclusions / gear_parent_label_sessions",
|
||||
"role": "사람 판단 데이터를 자동 추론과 분리 저장",
|
||||
"params": ["scope=GROUP|GLOBAL", "duration=1|3|5d"],
|
||||
"rules": ["GLOBAL은 모든 그룹에서 제거", "ACTIVE label session만 tracking"],
|
||||
"storageReads": [],
|
||||
"storageWrites": ["gear_parent_candidate_exclusions", "gear_parent_label_sessions"],
|
||||
"outputs": ["active exclusions", "active label sessions"],
|
||||
"impacts": ["parent inference candidate pruning", "label tracking"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "parent_inference",
|
||||
"label": "모선 추론",
|
||||
"stage": "최종 추론",
|
||||
"kind": "module",
|
||||
"position": { "x": 260, "y": 420 },
|
||||
"file": "prediction/algorithms/gear_parent_inference.py",
|
||||
"symbol": "run_gear_parent_inference",
|
||||
"role": "후보 생성, coverage-aware scoring, 상태 전이, resolution 저장",
|
||||
"params": ["auto score 0.72/0.15/3", "review threshold 0.60", "412/413 bonus +15%"],
|
||||
"rules": ["DIRECT_PARENT_MATCH", "SKIPPED_SHORT_NAME", "NO_CANDIDATE", "AUTO_PROMOTED", "REVIEW_REQUIRED", "UNRESOLVED"],
|
||||
"storageReads": ["gear_correlation_scores", "gear_correlation_raw_metrics", "group_polygon_snapshots", "active exclusions", "active label sessions"],
|
||||
"storageWrites": ["gear_group_parent_candidate_snapshots", "gear_group_parent_resolution", "gear_parent_label_tracking_cycles"],
|
||||
"outputs": ["candidate snapshots", "resolution current state", "label tracking rows"],
|
||||
"impacts": ["review queue", "group detail", "future prior feature source"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "score_breakdown",
|
||||
"label": "점수 보정",
|
||||
"stage": "최종 추론",
|
||||
"kind": "function",
|
||||
"position": { "x": 520, "y": 420 },
|
||||
"file": "prediction/algorithms/gear_parent_inference.py",
|
||||
"symbol": "_build_candidate_scores / _build_track_coverage_metrics",
|
||||
"role": "이름, 궤적, 방문, 근접, 활동, 안정성, bonus를 합산",
|
||||
"params": ["name 1.0/0.8/0.5/0.3", "coverage factors", "registry +0.05", "china prefix +0.15"],
|
||||
"rules": ["raw->effective 보정", "preBonusScore>=0.30일 때만 412/413 bonus"],
|
||||
"storageReads": [],
|
||||
"storageWrites": ["candidate evidence JSON"],
|
||||
"outputs": ["final_score", "coverage metrics", "evidenceConfidence"],
|
||||
"impacts": ["review UI 설명력", "future signal/prior 분리 설계"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "backend_read_model",
|
||||
"label": "backend read model",
|
||||
"stage": "조회 계층",
|
||||
"kind": "module",
|
||||
"position": { "x": 800, "y": 420 },
|
||||
"file": "backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java",
|
||||
"symbol": "group list / review queue / detail SQL",
|
||||
"role": "최신 전역 1h live snapshot과 fresh inference만 노출",
|
||||
"params": ["snapshot_time max where resolution=1h"],
|
||||
"rules": ["last_evaluated_at >= snapshot_time", "사라진 과거 sub-cluster 숨김"],
|
||||
"storageReads": ["group_polygon_snapshots", "gear_group_parent_resolution", "gear_group_parent_candidate_snapshots"],
|
||||
"storageWrites": [],
|
||||
"outputs": ["GroupPolygonDto", "GroupParentInferenceDto", "review queue rows"],
|
||||
"impacts": ["stale inference 차단", "프론트 live 상세 일관성"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "workflow_api",
|
||||
"label": "workflow API",
|
||||
"stage": "조회 계층",
|
||||
"kind": "module",
|
||||
"position": { "x": 1080, "y": 420 },
|
||||
"file": "backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java",
|
||||
"symbol": "candidate-exclusions / label-sessions endpoints",
|
||||
"role": "그룹 제외, 전역 제외, 라벨 세션, tracking 조회 API",
|
||||
"params": ["POST/GET workflow actions"],
|
||||
"rules": ["activeOnly query", "release/cancel action"],
|
||||
"storageReads": ["workflow tables"],
|
||||
"storageWrites": ["workflow tables", "review log"],
|
||||
"outputs": ["workflow DTO responses"],
|
||||
"impacts": ["human-in-the-loop 데이터 축적"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "review_ui",
|
||||
"label": "모선 검토 UI",
|
||||
"stage": "프론트",
|
||||
"kind": "component",
|
||||
"position": { "x": 520, "y": 620 },
|
||||
"file": "frontend/src/components/korea/ParentReviewPanel.tsx",
|
||||
"symbol": "ParentReviewPanel",
|
||||
"role": "후보 비교, 필터, 라벨/제외 액션, coverage evidence 표시",
|
||||
"params": ["min score", "min gear count", "search", "spatial filter"],
|
||||
"rules": ["30% 미만 후보 비표시", "검색/범위/어구수 필터 AND", "hover 기반 overlay 강조"],
|
||||
"storageReads": ["review/detail API", "localStorage filters"],
|
||||
"storageWrites": ["workflow API actions", "localStorage filters"],
|
||||
"outputs": ["review decisions", "candidate interpretation"],
|
||||
"impacts": ["사람 판단 백데이터 생성"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "mermaid_docs",
|
||||
"label": "Mermaid 산출물",
|
||||
"stage": "문서",
|
||||
"kind": "artifact",
|
||||
"position": { "x": 800, "y": 620 },
|
||||
"file": "docs/generated/gear-parent-flow-overview.md",
|
||||
"symbol": "generated Mermaid docs",
|
||||
"role": "정적 흐름도와 노드 인덱스 문서",
|
||||
"params": ["manifest JSON"],
|
||||
"rules": ["generator 재실행 시 갱신"],
|
||||
"storageReads": ["flow manifest"],
|
||||
"storageWrites": ["docs/generated/*.md", "docs/generated/*.mmd"],
|
||||
"outputs": ["overview flowchart", "node index"],
|
||||
"impacts": ["정적 문서 기반 리뷰/공유"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "react_flow_viewer",
|
||||
"label": "React Flow viewer",
|
||||
"stage": "문서",
|
||||
"kind": "component",
|
||||
"position": { "x": 1080, "y": 620 },
|
||||
"file": "frontend/src/flow/GearParentFlowViewer.tsx",
|
||||
"symbol": "GearParentFlowViewer",
|
||||
"role": "노드 클릭/검색/필터/상세 패널이 있는 인터랙티브 흐름 뷰어",
|
||||
"params": ["stage filter", "search", "node detail"],
|
||||
"rules": ["별도 HTML entry", "manifest를 단일 source로 사용"],
|
||||
"storageReads": ["flow manifest"],
|
||||
"storageWrites": [],
|
||||
"outputs": ["interactive HTML graph"],
|
||||
"impacts": ["개발/검토/설명 자료"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "future_episode",
|
||||
"label": "episode continuity",
|
||||
"stage": "후보 추적",
|
||||
"kind": "module",
|
||||
"position": { "x": 260, "y": 620 },
|
||||
"file": "prediction/algorithms/gear_parent_episode.py",
|
||||
"symbol": "build_episode_plan / compute_prior_bonus_components",
|
||||
"role": "sub_cluster continuity와 episode/lineage/label prior bonus를 계산하는 계층",
|
||||
"params": ["split", "merge", "expire", "24h/7d/30d prior windows"],
|
||||
"rules": ["small member change는 same episode", "true merge는 new episode", "prior bonus는 weak carry-over + cap 0.20"],
|
||||
"storageReads": ["gear_group_episodes", "gear_group_episode_snapshots", "candidate snapshots", "label history"],
|
||||
"storageWrites": ["gear_group_episodes", "gear_group_episode_snapshots"],
|
||||
"outputs": ["episode assignment", "continuity source/score", "prior bonus components"],
|
||||
"impacts": ["장기 기억 기반 추론", "split/merge 이후 후보 관성 완화"],
|
||||
"status": "implemented"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "id": "e1", "source": "source_tracks", "target": "safe_window", "label": "bucket window", "detail": "원천 5분 bucket에 safe delay와 overlap backfill 적용" },
|
||||
{ "id": "e2", "source": "safe_window", "target": "snpdb_fetch", "label": "fetch bounds", "detail": "safe_bucket, from_bucket, window_start 전달" },
|
||||
{ "id": "e3", "source": "snpdb_fetch", "target": "vessel_store", "label": "points", "detail": "초기/증분 point DataFrame 적재" },
|
||||
{ "id": "e4", "source": "vessel_store", "target": "gear_identity", "label": "latest positions", "detail": "어구 이름 패턴과 parent_name 파싱" },
|
||||
{ "id": "e5", "source": "vessel_store", "target": "detect_groups", "label": "latest positions", "detail": "어구 raw group과 서브클러스터 생성" },
|
||||
{ "id": "e6", "source": "detect_groups", "target": "group_snapshots", "label": "gear_groups", "detail": "1h/1h-fb/6h polygon snapshot 생성" },
|
||||
{ "id": "e7", "source": "vessel_store", "target": "gear_correlation", "label": "tracks", "detail": "후보 선박 6h track과 latest positions 입력" },
|
||||
{ "id": "e8", "source": "detect_groups", "target": "gear_correlation", "label": "groups", "detail": "그룹 중심, 반경, active ratio 계산 입력" },
|
||||
{ "id": "e9", "source": "group_snapshots", "target": "backend_read_model", "label": "snapshots", "detail": "최신 1h live group read model 구성" },
|
||||
{ "id": "e10", "source": "group_snapshots", "target": "parent_inference", "label": "center tracks", "detail": "최근 6h 그룹 중심 이동과 live parent membership 입력" },
|
||||
{ "id": "e11", "source": "gear_correlation", "target": "parent_inference", "label": "scores + raw", "detail": "correlation score와 raw metrics 사용" },
|
||||
{ "id": "e11a", "source": "detect_groups", "target": "future_episode", "label": "current clusters", "detail": "현재 gear group 멤버/중심점으로 episode continuity 계산" },
|
||||
{ "id": "e11b", "source": "workflow_exclusions", "target": "future_episode", "label": "label history", "detail": "label session lineage를 label prior 입력으로 사용" },
|
||||
{ "id": "e11c", "source": "future_episode", "target": "parent_inference", "label": "episode assignment", "detail": "episode_id, continuity source, prior aggregate를 candidate build에 반영" },
|
||||
{ "id": "e12", "source": "workflow_exclusions", "target": "parent_inference", "label": "active gates", "detail": "group/global exclusion과 label session을 candidate build에 반영" },
|
||||
{ "id": "e13", "source": "parent_inference", "target": "score_breakdown", "label": "candidate scoring", "detail": "이름/track/visit/proximity/activity/stability와 bonus 계산" },
|
||||
{ "id": "e13a", "source": "future_episode", "target": "score_breakdown", "label": "prior bonus", "detail": "episode/lineage/label prior bonus를 final score 마지막 단계에 가산" },
|
||||
{ "id": "e14", "source": "score_breakdown", "target": "backend_read_model", "label": "fresh candidate/resolution", "detail": "fresh inference만 group detail과 review queue에 노출" },
|
||||
{ "id": "e15", "source": "workflow_api", "target": "workflow_exclusions", "label": "CRUD", "detail": "exclusion/label 생성, 취소, 조회" },
|
||||
{ "id": "e16", "source": "backend_read_model", "target": "review_ui", "label": "review/detail API", "detail": "모선 검토 UI의 기본 데이터 소스" },
|
||||
{ "id": "e17", "source": "workflow_api", "target": "review_ui", "label": "actions", "detail": "라벨/그룹 제외/전체 제외/해제 액션 처리" },
|
||||
{ "id": "e18", "source": "review_ui", "target": "mermaid_docs", "label": "human-readable spec", "detail": "정적 문서와 UI 해석 흐름 연결" },
|
||||
{ "id": "e19", "source": "review_ui", "target": "react_flow_viewer", "label": "same manifest", "detail": "문서와 viewer가 같은 구조 설명을 공유" },
|
||||
{ "id": "e20", "source": "parent_inference", "target": "future_episode", "label": "episode snapshots", "detail": "current resolution과 top candidate를 episode snapshot/history에 기록" }
|
||||
]
|
||||
}
|
||||
14
frontend/src/gearParentFlowMain.tsx
Normal file
14
frontend/src/gearParentFlowMain.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@fontsource-variable/inter';
|
||||
import '@fontsource-variable/noto-sans-kr';
|
||||
import '@fontsource-variable/fira-code';
|
||||
import './styles/tailwind.css';
|
||||
import './index.css';
|
||||
import GearParentFlowViewer from './flow/GearParentFlowViewer';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<GearParentFlowViewer />
|
||||
</StrictMode>,
|
||||
);
|
||||
@ -1,3 +1,4 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig, type UserConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
@ -6,6 +7,14 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
export default defineConfig(({ mode }): UserConfig => ({
|
||||
plugins: [tailwindcss(), react()],
|
||||
esbuild: mode === 'production' ? { drop: ['console', 'debugger'] } : {},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, 'index.html'),
|
||||
gearParentFlow: resolve(__dirname, 'gear-parent-flow.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api/ais': {
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user