diff --git a/frontend/gear-parent-flow.html b/frontend/gear-parent-flow.html new file mode 100644 index 0000000..7c6e93f --- /dev/null +++ b/frontend/gear-parent-flow.html @@ -0,0 +1,13 @@ + + + + + + + gear-parent-flow-viewer + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3016ed2..dd7650d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 971200c..5292959 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ec2334b..e36ad27 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -106,6 +106,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { > MON + + FLOW + diff --git a/frontend/src/flow/GearParentFlowViewer.css b/frontend/src/flow/GearParentFlowViewer.css new file mode 100644 index 0000000..9aec6e3 --- /dev/null +++ b/frontend/src/flow/GearParentFlowViewer.css @@ -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)); + } +} diff --git a/frontend/src/flow/GearParentFlowViewer.tsx b/frontend/src/flow/GearParentFlowViewer.tsx new file mode 100644 index 0000000..fde640a --- /dev/null +++ b/frontend/src/flow/GearParentFlowViewer.tsx @@ -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 = { + '원천': '#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 = { + 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(); + const positioned = new Map(); + + 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: ( +
+
+
+
+
{node.stage}
+
{node.label}
+
+ + {node.status === 'implemented' ? '구현됨' : '제안됨'} + +
+
{node.symbol}
+
{node.role}
+
+ )}, + 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
없음
; + } + return ( +
+ {items.map((item) => ( +
{item}
+ ))} +
+ ); +} + +export function GearParentFlowViewer() { + const [search, setSearch] = useState(''); + const [stageFilter, setStageFilter] = useState('전체'); + const [statusFilter, setStatusFilter] = useState<'전체' | FlowStatus>('전체'); + const [selectedNodeId, setSelectedNodeId] = useState(flowManifest.nodes[0]?.id ?? null); + const [selectedEdgeId, setSelectedEdgeId] = useState(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 ( +
+
+ + +
+
+
+ React Flow Viewer + 노드 클릭 시 상세 스펙 + 엣지 클릭 시 전달 의미 + 검색/단계/상태 필터 +
+
+ + { + 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)" + /> + + + +
+ + +
+
+ ); +} + +export default GearParentFlowViewer; diff --git a/frontend/src/flow/gearParentFlowManifest.json b/frontend/src/flow/gearParentFlowManifest.json new file mode 100644 index 0000000..7728270 --- /dev/null +++ b/frontend/src/flow/gearParentFlowManifest.json @@ -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에 기록" } + ] +} diff --git a/frontend/src/gearParentFlowMain.tsx b/frontend/src/gearParentFlowMain.tsx new file mode 100644 index 0000000..e3c6c8d --- /dev/null +++ b/frontend/src/gearParentFlowMain.tsx @@ -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( + + + , +); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3472b81..3b49529 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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': {