feat: 어구 모선 추적 흐름도 시각화 (React Flow) 추가

- GearParentFlowViewer: React Flow 기반 인터랙티브 흐름도
- gear-parent-flow.html: standalone entry point
- vite.config.ts: multi-entry 빌드 (main + gearParentFlow)
- App.tsx: FLOW 링크 추가
- @xyflow/react 의존성 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-04 01:19:21 +09:00
부모 23828c742e
커밋 e11caf2767
9개의 변경된 파일1520개의 추가작업 그리고 0개의 파일을 삭제

파일 보기

@ -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>

파일 보기

@ -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>

파일 보기

@ -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));
}
}

파일 보기

@ -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;

파일 보기

@ -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에 기록" }
]
}

파일 보기

@ -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': {