From 7fb7f0d2fb0b18eae29a1e1eb3d0762482b90e11 Mon Sep 17 00:00:00 2001 From: 10tentacion Date: Thu, 23 Jul 2026 23:38:28 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[CHORE]=20TanStack=20Start=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=ED=95=98=EA=B3=A0=20=EC=88=9C=EC=88=98=20CSR=EB=A1=9C?= =?UTF-8?q?=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSR이 필요 없는 프로젝트라 TanStack Start의 서버 렌더링/하이드레이션 계층을 걷어내고, TanStack Router는 그대로 유지한 채 index.html + main.tsx 기반의 일반 Vite React SPA 구조로 변경한다. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 2 +- README.md | 2 +- index.html | 14 + package.json | 2 - pnpm-lock.yaml | 366 ------------------ src/client.tsx | 27 -- .../tanstack-query/root-provider.tsx | 1 - src/main.tsx | 24 ++ src/routeTree.gen.ts | 9 - src/router.tsx | 16 +- src/routes/__root.tsx | 76 +--- vite.config.ts | 9 +- 12 files changed, 74 insertions(+), 474 deletions(-) create mode 100644 index.html delete mode 100644 src/client.tsx create mode 100644 src/main.tsx diff --git a/CLAUDE.md b/CLAUDE.md index cc7a38b..b0d7903 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ pnpm dlx shadcn@latest add ## Tech Stack -- **Framework**: TanStack Start (React 19 + Vite SSR) +- **Framework**: React 19 + Vite (CSR) - **Routing**: TanStack Router with file-based routing (`src/routes/`) - **Data Fetching**: TanStack Query - **Styling**: Tailwind CSS v4 + shadcn/ui (new-york style) diff --git a/README.md b/README.md index 4cc8905..7b2a7ba 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ ## Tech Stack -- **Framework**: TanStack Start (React 19 + Vite SSR) +- **Framework**: React 19 + Vite (CSR) - **Routing**: TanStack Router (파일 기반) - **Data Fetching**: TanStack Query - **Styling**: Tailwind CSS v4 + shadcn/ui diff --git a/index.html b/index.html new file mode 100644 index 0000000..ed0eb74 --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + KOSLINK — 뉴스로 읽는 종목 관계 + + + + +
+ + + diff --git a/package.json b/package.json index 8e75ae2..a49b7a0 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,6 @@ "@tanstack/react-query-devtools": "latest", "@tanstack/react-router": "latest", "@tanstack/react-router-devtools": "latest", - "@tanstack/react-router-ssr-query": "latest", - "@tanstack/react-start": "latest", "@tanstack/router-plugin": "^1.132.0", "@xyflow/react": "^12.11.2", "class-variance-authority": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12204f2..82634af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,12 +26,6 @@ importers: '@tanstack/react-router-devtools': specifier: latest version: 1.167.0(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.15)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/react-router-ssr-query': - specifier: latest - version: 1.167.1(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@19.2.7))(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/react-start': - specifier: latest - version: 1.168.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)) '@tanstack/router-plugin': specifier: ^1.132.0 version: 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(rolldown@1.1.5)(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)) @@ -146,10 +140,6 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -431,22 +421,6 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@oozcitak/dom@2.0.2': - resolution: {integrity: sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==} - engines: {node: '>=20.0'} - - '@oozcitak/infra@2.0.2': - resolution: {integrity: sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==} - engines: {node: '>=20.0'} - - '@oozcitak/url@3.0.0': - resolution: {integrity: sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==} - engines: {node: '>=20.0'} - - '@oozcitak/util@10.0.0': - resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==} - engines: {node: '>=20.0'} - '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -1581,16 +1555,6 @@ packages: '@tanstack/router-core': optional: true - '@tanstack/react-router-ssr-query@1.167.1': - resolution: {integrity: sha512-W9j5JPnBikyafvuUfykFfHIWod58OAbAAa5leNkXBcoDoocghMmu6w9uZOmUZvAWT7CSvgj5tBUtF7CM2OoHXQ==} - engines: {node: '>=20.19'} - peerDependencies: - '@tanstack/query-core': '>=5.90.0' - '@tanstack/react-query': '>=5.90.0' - '@tanstack/react-router': '>=1.127.0' - react: '>=18.0.0 || >=19.0.0' - react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-router@1.170.18': resolution: {integrity: sha512-wpbGYZEp/fmz1q4bn7BD8VZ+/VZ7GBqSJv5V969pU+chP8y7dquWDmKTFMohvUegb9lg12m1uPVvD6kB2wORvQ==} engines: {node: '>=20.19'} @@ -1598,54 +1562,6 @@ packages: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-start-client@1.168.16': - resolution: {integrity: sha512-1OfHgy0wpHwe2tlB3FxMeA+IMX6Il/QAMf+8UdXuimReIc2Lz3BkMLBL38k4GIxBguX9sI8EMLO5jlTZ4e1olw==} - engines: {node: '>=22.12.0'} - peerDependencies: - react: '>=18.0.0 || >=19.0.0' - react-dom: '>=18.0.0 || >=19.0.0' - - '@tanstack/react-start-rsc@0.1.31': - resolution: {integrity: sha512-WxjkXYflq550vTNJpdPyMaPC+Vyh88L5wOL+SiDTjPMGne9ad7FZmoJxqfCFEv1e7HVKMH/mMoE8619TsTNVzQ==} - engines: {node: '>=22.12.0'} - peerDependencies: - '@rspack/core': '>=2.0.0-0' - '@vitejs/plugin-rsc': '>=0.5.20' - react: '>=18.0.0 || >=19.0.0' - react-dom: '>=18.0.0 || >=19.0.0' - react-server-dom-rspack: '>=0.0.2' - peerDependenciesMeta: - '@rspack/core': - optional: true - '@vitejs/plugin-rsc': - optional: true - react-server-dom-rspack: - optional: true - - '@tanstack/react-start-server@1.167.22': - resolution: {integrity: sha512-eH2PeHuLfL3R5YzE9+y2FfcE4Ld1LNV2ZfrCNVPJMMJFt+9nXDaRHg9BsEmc+JkTAGzz3FKLyQEoWwpbG6Ehqg==} - engines: {node: '>=22.12.0'} - peerDependencies: - react: '>=18.0.0 || >=19.0.0' - react-dom: '>=18.0.0 || >=19.0.0' - - '@tanstack/react-start@1.168.32': - resolution: {integrity: sha512-y1WXHo+jPfHxiuuN1m+br06IcriiBQnEWryBdbKdEOS5vw2PmnOj+Cgf1/YcGOqtSougScWFeE2rL1FXWvsLLg==} - engines: {node: '>=22.12.0'} - peerDependencies: - '@rsbuild/core': ^2.0.0 - '@vitejs/plugin-rsc': '*' - react: '>=18.0.0 || >=19.0.0' - react-dom: '>=18.0.0 || >=19.0.0' - vite: '>=7.0.0' - peerDependenciesMeta: - '@rsbuild/core': - optional: true - '@vitejs/plugin-rsc': - optional: true - vite: - optional: true - '@tanstack/react-store@0.9.3': resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} peerDependencies: @@ -1696,45 +1612,10 @@ packages: webpack: optional: true - '@tanstack/router-ssr-query-core@1.169.1': - resolution: {integrity: sha512-rngux8s/3mPQzcjLYDLkNU31coYVyCgrVTfpdwqUdY5jIEHqGTXrO73DTkPR1PppwYUeVhmNCgl8TctRcnupjg==} - engines: {node: '>=20.19'} - peerDependencies: - '@tanstack/query-core': '>=5.90.0' - '@tanstack/router-core': '>=1.127.0' - '@tanstack/router-utils@1.162.2': resolution: {integrity: sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==} engines: {node: '>=20.19'} - '@tanstack/start-client-core@1.170.14': - resolution: {integrity: sha512-yasBgEIFSWysL4EiFIGwp638nCoXXKiTqkc48EP2oty4OyNsZPTC1yfJ82zjq2KGkTAYtIaeMl7otqqRl1n85Q==} - engines: {node: '>=22.12.0'} - - '@tanstack/start-fn-stubs@1.162.0': - resolution: {integrity: sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ==} - engines: {node: '>=22.12.0'} - - '@tanstack/start-plugin-core@1.171.24': - resolution: {integrity: sha512-l/tm+T0ntXHeIzr9kJDTJ2IDNZC0yFazjkvbEVeZsDOrJ8F+HiZmY+tXYqI5/nDYkwxY0DVQr+kGsTRVb6y2Jw==} - engines: {node: '>=22.12.0'} - peerDependencies: - '@rsbuild/core': ^2.0.0 - vite: '>=7.0.0' - peerDependenciesMeta: - '@rsbuild/core': - optional: true - vite: - optional: true - - '@tanstack/start-server-core@1.169.17': - resolution: {integrity: sha512-u0N+PHJhMHnzfnlXYI9F+A/qweDe3E2X0mfkORPGIEkNQgvS548RA9fjwvixR2en5b848CfpEqUzwFhm/tQ40Q==} - engines: {node: '>=22.12.0'} - - '@tanstack/start-storage-context@1.167.17': - resolution: {integrity: sha512-ntkDyGx0PE0opIlWNAMpkMb8qkjR4uyCUOfC0CiT0STM25+EcwPuwYNfDXXeVObMrTAPgsQ4yOj3xdY0Xr4ptw==} - engines: {node: '>=22.12.0'} - '@tanstack/store@0.9.3': resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} @@ -2410,9 +2291,6 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} - exsolve@1.1.0: - resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2440,9 +2318,6 @@ packages: picomatch: optional: true - fetchdts@0.1.7: - resolution: {integrity: sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==} - file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2509,16 +2384,6 @@ packages: resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} - h3@2.0.1-rc.20: - resolution: {integrity: sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg==} - engines: {node: '>=20.11.1'} - hasBin: true - peerDependencies: - crossws: ^0.4.1 - peerDependenciesMeta: - crossws: - optional: true - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -3021,9 +2886,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rou3@0.8.1: - resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==} - saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -3079,15 +2941,6 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map@0.7.6: - resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} - engines: {node: '>= 12'} - - srvx@0.11.22: - resolution: {integrity: sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ==} - engines: {node: '>=20.16.0'} - hasBin: true - stable-hash-x@0.2.0: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} @@ -3205,9 +3058,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - ufo@1.6.4: - resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} - undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -3334,14 +3184,6 @@ packages: yaml: optional: true - vitefu@1.1.3: - resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} - peerDependencies: - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - vite: - optional: true - vitest@4.1.10: resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -3442,10 +3284,6 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} - xmlbuilder2@4.0.3: - resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} - engines: {node: '>=20.0'} - xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -3510,12 +3348,6 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} - '@babel/code-frame@7.27.1': - dependencies: - '@babel/helper-validator-identifier': 7.29.7 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -3832,23 +3664,6 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@oozcitak/dom@2.0.2': - dependencies: - '@oozcitak/infra': 2.0.2 - '@oozcitak/url': 3.0.0 - '@oozcitak/util': 10.0.0 - - '@oozcitak/infra@2.0.2': - dependencies: - '@oozcitak/util': 10.0.0 - - '@oozcitak/url@3.0.0': - dependencies: - '@oozcitak/infra': 2.0.2 - '@oozcitak/util': 10.0.0 - - '@oozcitak/util@10.0.0': {} - '@open-draft/deferred-promise@2.2.0': {} '@open-draft/deferred-promise@3.0.0': {} @@ -4957,17 +4772,6 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/react-router-ssr-query@1.167.1(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@19.2.7))(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@tanstack/query-core': 5.101.2 - '@tanstack/react-query': 5.101.2(react@19.2.7) - '@tanstack/react-router': 1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/router-ssr-query-core': 1.169.1(@tanstack/query-core@5.101.2)(@tanstack/router-core@1.171.15) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - transitivePeerDependencies: - - '@tanstack/router-core' - '@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@tanstack/history': 1.162.0 @@ -4977,80 +4781,6 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@tanstack/react-start-client@1.168.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@tanstack/react-router': 1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/router-core': 1.171.15 - '@tanstack/start-client-core': 1.170.14 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - - '@tanstack/react-start-rsc@0.1.31(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0))': - dependencies: - '@tanstack/react-router': 1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/router-core': 1.171.15 - '@tanstack/router-utils': 1.162.2 - '@tanstack/start-client-core': 1.170.14 - '@tanstack/start-fn-stubs': 1.162.0 - '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(rolldown@1.1.5)(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)) - '@tanstack/start-server-core': 1.169.17 - '@tanstack/start-storage-context': 1.167.17 - pathe: 2.0.3 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - transitivePeerDependencies: - - '@farmfe/core' - - '@rsbuild/core' - - bun-types-no-globals - - crossws - - esbuild - - rolldown - - rollup - - supports-color - - unloader - - vite - - vite-plugin-solid - - webpack - - '@tanstack/react-start-server@1.167.22(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@tanstack/react-router': 1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/router-core': 1.171.15 - '@tanstack/start-server-core': 1.169.17 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - transitivePeerDependencies: - - crossws - - '@tanstack/react-start@1.168.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0))': - dependencies: - '@tanstack/react-router': 1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/react-start-client': 1.168.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/react-start-rsc': 0.1.31(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)) - '@tanstack/react-start-server': 1.167.22(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/router-utils': 1.162.2 - '@tanstack/start-client-core': 1.170.14 - '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(rolldown@1.1.5)(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)) - '@tanstack/start-server-core': 1.169.17 - pathe: 2.0.3 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - optionalDependencies: - vite: 8.1.5(@types/node@22.20.1)(jiti@2.7.0) - transitivePeerDependencies: - - '@farmfe/core' - - '@rspack/core' - - bun-types-no-globals - - crossws - - esbuild - - react-server-dom-rspack - - rolldown - - rollup - - supports-color - - unloader - - vite-plugin-solid - - webpack - '@tanstack/react-store@0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@tanstack/store': 0.9.3 @@ -5118,11 +4848,6 @@ snapshots: - supports-color - unloader - '@tanstack/router-ssr-query-core@1.169.1(@tanstack/query-core@5.101.2)(@tanstack/router-core@1.171.15)': - dependencies: - '@tanstack/query-core': 5.101.2 - '@tanstack/router-core': 1.171.15 - '@tanstack/router-utils@1.162.2': dependencies: '@babel/generator': 7.29.7 @@ -5136,69 +4861,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/start-client-core@1.170.14': - dependencies: - '@tanstack/router-core': 1.171.15 - '@tanstack/start-fn-stubs': 1.162.0 - '@tanstack/start-storage-context': 1.167.17 - seroval: 1.5.6 - - '@tanstack/start-fn-stubs@1.162.0': {} - - '@tanstack/start-plugin-core@1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(rolldown@1.1.5)(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0))': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/core': 7.29.7 - '@babel/types': 7.29.7 - '@tanstack/router-core': 1.171.15 - '@tanstack/router-generator': 1.167.21 - '@tanstack/router-plugin': 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(rolldown@1.1.5)(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)) - '@tanstack/router-utils': 1.162.2 - '@tanstack/start-server-core': 1.169.17 - exsolve: 1.1.0 - lightningcss: 1.33.0 - pathe: 2.0.3 - picomatch: 4.0.5 - seroval: 1.5.6 - source-map: 0.7.6 - srvx: 0.11.22 - tinyglobby: 0.2.17 - ufo: 1.6.4 - vitefu: 1.1.3(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)) - xmlbuilder2: 4.0.3 - zod: 4.4.3 - optionalDependencies: - vite: 8.1.5(@types/node@22.20.1)(jiti@2.7.0) - transitivePeerDependencies: - - '@farmfe/core' - - '@rspack/core' - - '@tanstack/react-router' - - bun-types-no-globals - - crossws - - esbuild - - rolldown - - rollup - - supports-color - - unloader - - vite-plugin-solid - - webpack - - '@tanstack/start-server-core@1.169.17': - dependencies: - '@tanstack/history': 1.162.0 - '@tanstack/router-core': 1.171.15 - '@tanstack/start-client-core': 1.170.14 - '@tanstack/start-storage-context': 1.167.17 - fetchdts: 0.1.7 - h3-v2: h3@2.0.1-rc.20 - seroval: 1.5.6 - transitivePeerDependencies: - - crossws - - '@tanstack/start-storage-context@1.167.17': - dependencies: - '@tanstack/router-core': 1.171.15 - '@tanstack/store@0.9.3': {} '@tanstack/virtual-file-routes@1.162.0': {} @@ -5880,8 +5542,6 @@ snapshots: expect-type@1.4.0: {} - exsolve@1.1.0: {} - fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -5902,8 +5562,6 @@ snapshots: optionalDependencies: picomatch: 4.0.5 - fetchdts@0.1.7: {} - file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -5953,11 +5611,6 @@ snapshots: graphql@16.14.2: {} - h3@2.0.1-rc.20: - dependencies: - rou3: 0.8.1 - srvx: 0.11.22 - has-flag@4.0.0: {} headers-polyfill@5.0.1: @@ -6473,8 +6126,6 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.5 '@rolldown/binding-win32-x64-msvc': 1.1.5 - rou3@0.8.1: {} - saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -6513,10 +6164,6 @@ snapshots: source-map-js@1.2.1: {} - source-map@0.7.6: {} - - srvx@0.11.22: {} - stable-hash-x@0.2.0: {} stackback@0.0.2: {} @@ -6612,8 +6259,6 @@ snapshots: typescript@6.0.3: {} - ufo@1.6.4: {} - undici-types@6.21.0: {} undici@7.28.0: {} @@ -6699,10 +6344,6 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 - vitefu@1.1.3(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)): - optionalDependencies: - vite: 8.1.5(@types/node@22.20.1)(jiti@2.7.0) - vitest@4.1.10(@types/node@22.20.1)(jsdom@28.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)): dependencies: '@vitest/expect': 4.1.10 @@ -6782,13 +6423,6 @@ snapshots: xml-name-validator@5.0.0: {} - xmlbuilder2@4.0.3: - dependencies: - '@oozcitak/dom': 2.0.2 - '@oozcitak/infra': 2.0.2 - '@oozcitak/util': 10.0.0 - js-yaml: 4.3.0 - xmlchars@2.2.0: {} y18n@5.0.8: {} diff --git a/src/client.tsx b/src/client.tsx deleted file mode 100644 index 637e221..0000000 --- a/src/client.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { StrictMode, startTransition } from 'react' -import { hydrateRoot } from 'react-dom/client' -import { StartClient } from '@tanstack/react-start/client' - -/** - * TanStack Start의 기본 클라이언트 엔트리(node_modules/@tanstack/react-start/ - * dist/plugin/default-entry/client.tsx)를 오버라이드한다. 개발 모드에서는 - * MSW 워커가 뜬 뒤에 하이드레이션을 시작해야 첫 쿼리 요청부터 목 응답을 - * 받는다. import.meta.env.DEV는 프로덕션 빌드에서 false로 인라인되어 이 - * 분기와 동적 import가 통째로 트리쉐이킹된다. - */ -async function enableMocking() { - if (!import.meta.env.DEV) return - const { worker } = await import('#/mocks/browser') - await worker.start({ onUnhandledRequest: 'bypass' }) -} - -enableMocking().then(() => { - startTransition(() => { - hydrateRoot( - document, - - - , - ) - }) -}) diff --git a/src/integrations/tanstack-query/root-provider.tsx b/src/integrations/tanstack-query/root-provider.tsx index 395b890..0b21708 100644 --- a/src/integrations/tanstack-query/root-provider.tsx +++ b/src/integrations/tanstack-query/root-provider.tsx @@ -15,4 +15,3 @@ export function getContext() { queryClient, } } -export default function TanstackQueryProvider() {} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..73fc4a4 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,24 @@ +import { StrictMode, startTransition } from 'react' +import { createRoot } from 'react-dom/client' +import { RouterProvider } from '@tanstack/react-router' +import { getRouter } from './router' +import './styles.css' + +async function enableMocking() { + if (!import.meta.env.DEV) return + const { worker } = await import('#/mocks/browser') + await worker.start({ onUnhandledRequest: 'bypass' }) +} + +const router = getRouter() + +enableMocking().then(() => { + const rootEl = document.getElementById('root')! + startTransition(() => { + createRoot(rootEl).render( + + + , + ) + }) +}) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index dceedff..d204c26 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -57,12 +57,3 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() - -import type { getRouter } from './router.tsx' -import type { createStart } from '@tanstack/react-start' -declare module '@tanstack/react-start' { - interface Register { - ssr: true - router: Awaited> - } -} diff --git a/src/router.tsx b/src/router.tsx index 2161efb..6fa4373 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -1,12 +1,7 @@ import { createRouter as createTanStackRouter } from '@tanstack/react-router' +import { QueryClientProvider } from '@tanstack/react-query' import { routeTree } from './routeTree.gen' - -import type { ReactNode } from 'react' -import { QueryClient } from '@tanstack/react-query' -import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query' -import TanstackQueryProvider, { - getContext, -} from './integrations/tanstack-query/root-provider' +import { getContext } from './integrations/tanstack-query/root-provider' export function getRouter() { const context = getContext() @@ -17,10 +12,13 @@ export function getRouter() { scrollRestoration: true, defaultPreload: 'intent', defaultPreloadStaleTime: 0, + Wrap: ({ children }) => ( + + {children} + + ), }) - setupRouterSsrQueryIntegration({ router, queryClient: context.queryClient }) - return router } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index f8afe73..42329cd 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,8 +1,4 @@ -import { - HeadContent, - Scripts, - createRootRouteWithContext, -} from '@tanstack/react-router' +import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' import { TanStackDevtools } from '@tanstack/react-devtools' import { Provider as JotaiProvider } from 'jotai' @@ -11,8 +7,6 @@ import BriefingSheet from '../components/briefing/BriefingSheet' import TanStackQueryDevtools from '../integrations/tanstack-query/devtools' -import appCss from '../styles.css?url' - import type { QueryClient } from '@tanstack/react-query' interface MyRouterContext { @@ -20,57 +14,27 @@ interface MyRouterContext { } export const Route = createRootRouteWithContext()({ - head: () => ({ - meta: [ - { - charSet: 'utf-8', - }, - { - name: 'viewport', - content: 'width=device-width, initial-scale=1', - }, - { - title: 'KOSLINK — 뉴스로 읽는 종목 관계', - }, - ], - links: [ - { - rel: 'stylesheet', - href: appCss, - }, - ], - }), - shellComponent: RootDocument, + component: RootComponent, }) -function RootDocument({ children }: { children: React.ReactNode }) { +function RootComponent() { return ( - - - - - - {/* Provider 없이 기본 store를 쓰면 SSR에서 요청 간 상태가 새는 모듈 - 전역 store가 되므로, 렌더마다 새 store를 만드는 Provider로 감싼다. */} - -
- {children} - - - , - }, - TanStackQueryDevtools, - ]} - /> - - - + +
+ + + , + }, + TanStackQueryDevtools, + ]} + /> + ) } diff --git a/vite.config.ts b/vite.config.ts index b0f53d3..8468697 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,14 +1,19 @@ import { defineConfig } from 'vite' import { devtools } from '@tanstack/devtools-vite' -import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import { tanstackRouter } from '@tanstack/router-plugin/vite' import viteReact from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' const config = defineConfig({ resolve: { tsconfigPaths: true }, - plugins: [devtools(), tailwindcss(), tanstackStart(), viteReact()], + plugins: [ + devtools(), + tailwindcss(), + tanstackRouter({ target: 'react', autoCodeSplitting: true }), + viteReact(), + ], }) export default config From 07361deb1ea3efb964f32aecefc1435fc1cf8a23 Mon Sep 17 00:00:00 2001 From: 10tentacion Date: Sun, 26 Jul 2026 00:09:11 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[FEAT]=20=EB=89=B4=EC=8A=A4=20=ED=8C=8C?= =?UTF-8?q?=EA=B8=89=20=EA=B2=BD=EB=A1=9C=20=EA=B7=B8=EB=9E=98=ED=94=84?= =?UTF-8?q?=EB=A5=BC=20=EC=8B=A4=20=EC=84=9C=EB=B2=84=20=EC=8A=A4=ED=8E=99?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실제 Neo4j 온톨로지(Stock/Role/Theme, SUPPLY_TO/RELATED_TO)를 가정해 mock API 형태를 정리한다. 프론트가 실제로 렌더링에 쓰는 최소 필드만 받도록 노드 종류를 STOCK/CONCEPT로, 엣지 극성은 relationType(COMPETITOR)에서 유도하도록 바꾸고, 뉴스 클릭 시 파급 경로 그래프를 GET /api/news/{id}/graph로 분리했다. 좌표는 서버가 주지 않으므로 hop 레벨 기반 배치를 프론트에서 계산하도록 그래프 BFS/인덱싱 로직을 lib/graphIndex.ts로 재사용 가능하게 뽑아냈다. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 5 +- docs/KOSLINK-FRONTEND.md | 77 ++++++++++-- src/components/analysis/RelatedList.tsx | 32 +++-- src/components/graph/GraphPanel.tsx | 54 ++++----- src/lib/api.ts | 49 +++++++- src/lib/data.ts | 153 ++++++++++++------------ src/lib/graphIndex.ts | 131 ++++++++++++++++++++ src/lib/layout.ts | 142 +--------------------- src/lib/queries.ts | 25 +++- src/mocks/handlers.ts | 10 ++ src/types/index.ts | 42 ++++++- 11 files changed, 440 insertions(+), 280 deletions(-) create mode 100644 src/lib/graphIndex.ts diff --git a/CLAUDE.md b/CLAUDE.md index b0d7903..ab62d9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,8 +102,9 @@ When implementing the graph visualization: Base path is `/api`. No authentication. All endpoints wrapped with TanStack Query: - `GET /api/news` - News list, cursor-paginated (`sector?`, `cursor?`, `limit?` → `{ items, nextCursor }`), infinite scroll -- `GET /api/news/{id}/analysis` - Impact analysis with propagation chains (includes `title`/`sector` so the panel doesn't need the paginated list) -- `GET /api/graph` - Full ontology graph +- `GET /api/news/{id}/analysis` - Impact analysis text panel (article summary, main stock, related stocks, rationale). Includes `title`/`sector` so the panel doesn't need the paginated list. No graph/coordinate data — see the next endpoint +- `GET /api/news/{id}/graph` - Propagation subgraph for this news (`{ newsId, originId, nodes, edges }`), Neo4j-derived. Nodes carry `direction` only where relevant; no coordinates — the frontend computes hop-based layout itself (`lib/graphIndex.ts`'s `bfsBuild` + `lib/layout.ts`'s `radialLayout`) +- `GET /api/graph` - Full ontology graph, same trimmed node/edge shape as above (`kind: 'STOCK' | 'CONCEPT'`, edges carry `relationType` only for competitor/affiliate-type relations) - `POST /api/briefing` - Watchlist reverse lookup - `GET /api/verify` - Prediction verification data; `news` is cursor-paginated the same way as `/api/news`, `daily` is not diff --git a/docs/KOSLINK-FRONTEND.md b/docs/KOSLINK-FRONTEND.md index f748195..c7c5d8c 100644 --- a/docs/KOSLINK-FRONTEND.md +++ b/docs/KOSLINK-FRONTEND.md @@ -97,28 +97,46 @@ import '@xyflow/react/dist/style.css' // 필수. 빠뜨리면 노드가 겹쳐 ## 4. 데이터 모델 +실제 온톨로지(Neo4j)는 Stock/Role/Theme 3계층 + BELONGS_TO/SUPPLY_TO/RELATED_TO +관계로 이뤄져 있다. 프론트는 이 중 렌더링에 실제로 쓰는 최소 필드만 받는다 — +거래 가능 여부(STOCK/CONCEPT), 화면에 표시할 관계 레이블, 경쟁 관계 여부뿐이다. +좌표는 절대 내려주지 않는다. hop 레벨 기반 배치는 항상 프론트가 계산한다 +(`lib/graphIndex.ts`의 `bfsBuild`, `lib/layout.ts`의 `radialLayout`). + ```ts -type NodeKind = 'COMPANY' | 'PRODUCT' | 'THEME' | 'MATERIAL' +type NodeKind = 'STOCK' | 'CONCEPT' // Role/Theme는 CONCEPT로 통칭 — 프론트는 거래 가능 여부만 구분하면 된다 interface OntologyNode { id: string // 'sk' name: string // 'SK하이닉스' kind: NodeKind - ticker?: string // COMPANY만 + ticker?: string // STOCK만 sector: string // '반도체' | '2차전지' | '방산' - marketCap?: number // 억원, COMPANY만 + marketCap?: number // 억원, STOCK만 } +// 실제 RELATED_TO.relation_type 중 일부. STOCK-STOCK 엣지에만 존재한다. +type RelationType = + | 'EQUITY_INVESTMENT' + | 'AFFILIATE' + | 'LICENSING' + | 'COMPETITOR' + | 'MNA' + | 'OTHER' + interface OntologyEdge { id: string source: string target: string - relation: string // '장비 공급', '경쟁', '양극재 조달' - polarity: 1 | -1 // 1 동조 / -1 반대(경쟁·대체) + relation: string // 화면에 보여줄 관계 레이블 — '장비 공급', '경쟁', '양극재 조달' + relationType?: RelationType // SUPPLY_TO·BELONGS_TO 등은 생략(= 항상 동조) } ``` -`polarity: -1`이 핵심이다. 같은 뉴스에서 상승주와 하락주가 동시에 나오는 근거다. +`relationType === 'COMPETITOR'`가 핵심이다. 같은 뉴스에서 상승주와 하락주가 +동시에 나오는 근거이며, 프론트는 이 값으로 극성(동조/반대)을 계산한다 — +`polarity`라는 별도 필드를 서버에 요청하지 않는다(`lib/graphIndex.ts`의 +`polarityOf`). --- @@ -183,14 +201,12 @@ GET /api/news/{id}/analysis "ticker": "042700", "direction": "UP", "relation": "장비 공급", - "chain": ["sk", "hanmi"], }, { "nodeId": "ss", "name": "삼성전자", "direction": "DOWN", "relation": "경쟁 관계", - "chain": ["sk", "ss"], }, ], "rationale": { @@ -204,21 +220,56 @@ GET /api/news/{id}/analysis **주의** - `confidence` 같은 확률값은 받지도, 표시하지도 않는다 -- 영향의 크기는 `chain.length - 1` (hop 수)로만 표현한다 -- `chain`은 기점부터 대상까지의 노드 ID 배열. 그래프 배치와 애니메이션 순서가 전부 여기서 나온다 +- 그래프 경로(`chain`)는 이 응답에 없다 — §5.3 파급 경로 그래프에서 받는다. + hop 수는 그 응답의 노드/엣지를 기점부터 BFS로 펼쳐서 프론트가 계산한다 - `rationale.propagation`은 서버가 그래프 경로에서 템플릿 생성한다 - `title`/`sector`는 뉴스 목록(§5.1)이 페이지네이션되어 선택된 뉴스가 이미 로드된 페이지에 없을 수 있으므로, 분석 패널이 목록을 다시 훑지 않고도 헤더에 표시할 수 있게 여기 포함한다 -### 5.3 전체 그래프 +### 5.3 뉴스 파급 경로 그래프 + +``` +GET /api/news/{id}/graph +``` + +```jsonc +{ + "newsId": "n1", + "originId": "sk", + "nodes": [ + { "id": "sk", "name": "SK하이닉스", "kind": "STOCK", "ticker": "000660", "sector": "반도체", "marketCap": 1200000, "direction": "UP" }, + { "id": "hanmi", "name": "한미반도체", "kind": "STOCK", "ticker": "042700", "sector": "반도체", "marketCap": 120000, "direction": "UP" }, + { "id": "ss", "name": "삼성전자", "kind": "STOCK", "ticker": "005930", "sector": "반도체", "marketCap": 4600000, "direction": "DOWN" }, + ], + "edges": [ + { "id": "e2", "source": "sk", "target": "hanmi", "relation": "장비 공급" }, + { "id": "e3", "source": "sk", "target": "ss", "relation": "경쟁", "relationType": "COMPETITOR" }, + ], +} +``` + +**주의** + +- 뉴스 클릭 시 그래프 패널이 그리는 서브그래프는 전부 이 응답에서 나온다 — + §5.2 analysis 응답과는 독립적으로 조회된다(`lib/queries.ts`의 + `useNewsImpactGraphQuery`) +- `direction`은 이 파급 경로에 포함된 노드에만 붙는다. CONCEPT 노드나 파급 + 경로 밖의 노드에는 없다 +- **좌표를 내려주지 않는다.** 배치는 `originId`에서 BFS로 계산한 hop + 레벨을 기준으로 프론트가 동심원으로 계산한다(`lib/graphIndex.ts`의 + `bfsBuild` + `lib/layout.ts`의 `radialLayout`) +- 노드가 5개면 응답에도 5개만 온다 — 전체 온톨로지가 아니라 이 뉴스의 + 파급 경로에 해당하는 서브그래프만 내려준다 + +### 5.4 전체 그래프 ``` GET /api/graph → { "nodes": OntologyNode[], "edges": OntologyEdge[] } ``` -### 5.4 관심종목 브리핑 +### 5.5 관심종목 브리핑 ``` POST /api/briefing @@ -245,7 +296,7 @@ POST /api/briefing 요청에 담는 `tickers`는 서버에 저장되지 않는다. 매번 클라이언트 메모리에서 보낸다. -### 5.5 예측 검증 (news는 커서 기반 페이징) +### 5.6 예측 검증 (news는 커서 기반 페이징) ``` GET /api/verify?days=30§or=반도체&cursor=v10&limit=10 diff --git a/src/components/analysis/RelatedList.tsx b/src/components/analysis/RelatedList.tsx index 07e0aa3..c3381a2 100644 --- a/src/components/analysis/RelatedList.tsx +++ b/src/components/analysis/RelatedList.tsx @@ -1,17 +1,27 @@ import { arrow, directionLabel, impactOf } from '#/lib/format' -import { graphIndex } from '#/lib/layout' +import { bfsBuild, buildGraphIndex, pathTo } from '#/lib/graphIndex' +import { useNewsImpactGraphQuery } from '#/lib/queries' import type { NewsAnalysis, NewsRelatedStock } from '#/types' -function sortRelated(a: NewsRelatedStock, b: NewsRelatedStock) { - const hopDiff = a.chain.length - b.chain.length - if (hopDiff !== 0) return hopDiff - if (a.direction === b.direction) return 0 - return a.direction === 'UP' ? -1 : 1 +function sortRelated(hopOf: (nodeId: string) => number) { + return (a: NewsRelatedStock, b: NewsRelatedStock) => { + const hopDiff = hopOf(a.nodeId) - hopOf(b.nodeId) + if (hopDiff !== 0) return hopDiff + if (a.direction === b.direction) return 0 + return a.direction === 'UP' ? -1 : 1 + } } -/** 관계로 이어진 종목 — 기점에서 chain을 따라 파급되는 관련주 목록 */ +/** 관계로 이어진 종목 — 기점에서 파급 경로 그래프(BFS)를 따라 이어지는 관련주 목록 */ export default function RelatedList({ analysis }: { analysis: NewsAnalysis }) { - const sorted = [...analysis.related].sort(sortRelated) + const { data: impactGraph } = useNewsImpactGraphQuery(analysis.newsId) + if (!impactGraph) return null + + const index = buildGraphIndex(impactGraph.nodes, impactGraph.edges) + const built = bfsBuild(index, impactGraph.originId) + const hopOf = (nodeId: string) => built.level[nodeId] ?? 0 + + const sorted = [...analysis.related].sort(sortRelated(hopOf)) return (
@@ -23,7 +33,7 @@ export default function RelatedList({ analysis }: { analysis: NewsAnalysis }) {
{sorted.map((r) => { - const hop = r.chain.length - 1 + const hop = hopOf(r.nodeId) const impact = impactOf(hop) const down = r.direction === 'DOWN' return ( @@ -66,8 +76,8 @@ export default function RelatedList({ analysis }: { analysis: NewsAnalysis }) { className="mt-[7px] text-[11.5px]" style={{ color: 'var(--n-400)' }} > - {r.chain - .map((id) => graphIndex.byId.get(id)?.name ?? id) + {pathTo(built, r.nodeId) + .map((id) => index.byId.get(id)?.name ?? id) .join(' → ')}
diff --git a/src/components/graph/GraphPanel.tsx b/src/components/graph/GraphPanel.tsx index 05d338f..1837042 100644 --- a/src/components/graph/GraphPanel.tsx +++ b/src/components/graph/GraphPanel.tsx @@ -16,19 +16,14 @@ import { highlightedNodeAtom, selectedNewsIdAtom, } from '#/lib/atoms' -import { useGraphQuery, useNewsAnalysisQuery } from '#/lib/queries' +import { useGraphQuery, useNewsImpactGraphQuery } from '#/lib/queries' import { sizeOf } from '#/lib/format' -import { - focusBuild, - fullLayout, - graphIndex, - nodeBuild, - tierOf, -} from '#/lib/layout' +import { fullLayout, graphIndex, radialLayout, tierOf } from '#/lib/layout' import type { LayoutPoint } from '#/lib/layout' +import { bfsBuild, buildGraphIndex } from '#/lib/graphIndex' import type { Direction, - NewsAnalysis, + NewsImpactGraph, OntologyEdge, OntologyNode, } from '#/types' @@ -86,21 +81,25 @@ function polarityEdgeState(polarity: 1 | -1, tier: 1 | 2): EdgeVisualState { return polarity === -1 ? 'down2' : 'up2' } -/** 뉴스 파급 경로: 기점 중앙, related[].chain 기반 동심원 배치 */ -function buildFocusScene(analysis: NewsAnalysis): Scene2 { - const built = focusBuild(analysis.main.nodeId, analysis.related) +/** 뉴스 파급 경로: 기점 중앙, GET /api/news/{id}/graph로 받은 서브그래프를 BFS로 펼쳐 동심원 배치 */ +function buildFocusScene(impactGraph: NewsImpactGraph): Scene2 { + const index = buildGraphIndex(impactGraph.nodes, impactGraph.edges) + const built = bfsBuild(index, impactGraph.originId) + const pos = radialLayout(built.ids, built.level) + const dirOf = new Map() - dirOf.set(analysis.main.nodeId, analysis.main.direction) - analysis.related.forEach((r) => dirOf.set(r.nodeId, r.direction)) + impactGraph.nodes.forEach((n) => { + if (n.direction) dirOf.set(n.id, n.direction) + }) const nodes: NodeSpec[] = built.ids.map((id) => { - const isMain = id === analysis.main.nodeId + const isMain = id === impactGraph.originId const dir = dirOf.get(id) return { id, isMain, restingState: 'hide', - badge: isMain ? analysis.main.direction : (dir ?? null), + badge: dir ?? null, tier: undefined, } }) @@ -115,7 +114,7 @@ function buildFocusScene(analysis: NewsAnalysis): Scene2 { })) const revealNodes: RevealNode[] = built.ids - .filter((id) => id !== analysis.main.nodeId) + .filter((id) => id !== impactGraph.originId) .map((id) => { const dir = dirOf.get(id) return { @@ -133,17 +132,18 @@ function buildFocusScene(analysis: NewsAnalysis): Scene2 { })) const maxHop = built.pairs.reduce((m, p) => Math.max(m, p.level), 0) + const originDirection = dirOf.get(impactGraph.originId) ?? 'UP' return { ids: built.ids, - positions: built.pos, + positions: pos, nodes, edges, scene: { - key: `focus:${analysis.newsId}`, - originId: analysis.main.nodeId, - originName: graphIndex.byId.get(analysis.main.nodeId)?.name ?? '', - originState: analysis.main.direction === 'UP' ? 'up' : 'down', + key: `focus:${impactGraph.newsId}`, + originId: impactGraph.originId, + originName: index.byId.get(impactGraph.originId)?.name ?? '', + originState: originDirection === 'UP' ? 'up' : 'down', nodes: revealNodes, edges: revealEdges, }, @@ -190,7 +190,7 @@ function buildAllScene( } } - const built = nodeBuild(highlightId, 2) + const built = bfsBuild(graphIndex, highlightId, 2) const origin = graphIndex.byId.get(highlightId)! function ontologyEdgeId(source: string, target: string): string { @@ -327,7 +327,7 @@ function GraphCanvas({ scene2 }: GraphCanvasProps) { data: { label: ontologyNode.name, ticker: ontologyNode.ticker, - abstract: ontologyNode.kind !== 'COMPANY', + abstract: ontologyNode.kind !== 'STOCK', tier: spec.tier, state, // .main은 파급 경로 뷰의 상승/하락 색 위에 강조 링을 더하는 용도라 @@ -610,7 +610,7 @@ export default function GraphPanel() { const [highlightedNode, setHighlightedNode] = useAtom(highlightedNodeAtom) const selectedNewsId = useAtomValue(selectedNewsIdAtom) const { data: graph } = useGraphQuery() - const { data: analysis } = useNewsAnalysisQuery(selectedNewsId) + const { data: impactGraph } = useNewsImpactGraphQuery(selectedNewsId) // 뉴스가 바뀌면 제자리 강조 상태를 초기화하고 파급 경로 뷰로 돌아간다 useEffect(() => { @@ -621,9 +621,9 @@ export default function GraphPanel() { const scene2: Scene2 | null = useMemo(() => { if (mode === 'all') return graph ? buildAllScene(highlightedNode, graph) : null - if (selectedNewsId && analysis) return buildFocusScene(analysis) + if (selectedNewsId && impactGraph) return buildFocusScene(impactGraph) return null - }, [mode, highlightedNode, selectedNewsId, graph, analysis]) + }, [mode, highlightedNode, selectedNewsId, graph, impactGraph]) return (
diff --git a/src/lib/api.ts b/src/lib/api.ts index 7959fc7..98e74c1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -9,7 +9,9 @@ import type { BriefingMatch, BriefingResult, BriefingUnmatched, + ImpactGraphNode, NewsAnalysis, + NewsImpactGraph, NewsListPage, OntologyEdge, OntologyNode, @@ -124,7 +126,6 @@ export function getNewsAnalysis(newsId: string): NewsAnalysis | null { ticker: node.ticker, direction: r.direction, relation: r.relation, - chain: r.chain, } }), rationale: record.rationale, @@ -135,6 +136,50 @@ export function getGraph(): { nodes: OntologyNode[]; edges: OntologyEdge[] } { return { nodes: ONTOLOGY_NODES, edges: ONTOLOGY_EDGES } } +function findOntologyEdge(a: string, b: string): OntologyEdge | undefined { + return ONTOLOGY_EDGES.find( + (e) => + (e.source === a && e.target === b) || + (e.source === b && e.target === a), + ) +} + +/** + * GET /api/news/{id}/graph. 뉴스별 파급 경로에 등장하는 노드·엣지만 추려서 + * 내려준다 — 좌표는 포함하지 않는다(프론트가 hop 레벨로 계산한다). + */ +export function getNewsImpactGraph(newsId: string): NewsImpactGraph | null { + const record = NEWS_RECORDS.find((n) => n.id === newsId) + if (!record) return null + + const nodeIds = new Set([record.main.nodeId]) + const edgeById = new Map() + record.related.forEach((r) => { + r.chain.forEach((id) => nodeIds.add(id)) + for (let i = 1; i < r.chain.length; i++) { + const edge = findOntologyEdge(r.chain[i - 1], r.chain[i]) + if (edge) edgeById.set(edge.id, edge) + } + }) + + const directionById = new Map() + directionById.set(record.main.nodeId, record.main.direction) + record.related.forEach((r) => directionById.set(r.nodeId, r.direction)) + + const nodes: ImpactGraphNode[] = Array.from(nodeIds).map((id) => { + const node = requireNode(id) + const direction = directionById.get(id) + return direction ? { ...node, direction } : { ...node } + }) + + return { + newsId: record.id, + originId: record.main.nodeId, + nodes, + edges: Array.from(edgeById.values()), + } +} + export interface GetVerifyParams { sector?: string /** 이전 응답의 nextCursor. 첫 페이지는 생략한다. */ @@ -163,7 +208,7 @@ export function getVerify({ function findCompanyNode(query: string): OntologyNode | undefined { return ONTOLOGY_NODES.find( (n) => - n.kind === 'COMPANY' && + n.kind === 'STOCK' && (n.ticker === query || n.id === query || n.name === query || diff --git a/src/lib/data.ts b/src/lib/data.ts index 83b7013..4e23cb4 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -2,6 +2,7 @@ import type { Direction, OntologyEdge, OntologyNode, + RelationType, VerifyDaily, VerifyEntry, } from '#/types' @@ -42,110 +43,104 @@ const RAW_COMPANIES: [string, string, string, string, number][] = [ ['pung', '풍산', '103140', '방산', 20000], ] -// 개념 노드(PRODUCT/THEME/MATERIAL): [id, 이름, 종류, 섹터] -const RAW_CONCEPTS: [string, string, NodeKindKo, string][] = [ - ['hbm', 'HBM', '제품', '반도체'], - ['dram', 'D램', '제품', '반도체'], - ['fdry', '파운드리', '테마', '반도체'], - ['aidc', 'AI 데이터센터', '테마', '반도체'], - ['cath', '양극재', '제품', '2차전지'], - ['li', '리튬', '원자재', '2차전지'], - ['ev', '전기차', '테마', '2차전지'], - ['k9', 'K9 자주포', '제품', '방산'], - ['gm', '유도무기', '제품', '방산'], - ['dex', '방산 수출', '테마', '방산'], - ['agr', '해외 정부계약', '테마', '방산'], +// 개념 노드(실제 온톨로지의 Role/Theme — 프론트는 거래 불가 노드로만 구분하면 되므로 CONCEPT로 통칭): [id, 이름, 섹터] +const RAW_CONCEPTS: [string, string, string][] = [ + ['hbm', 'HBM', '반도체'], + ['dram', 'D램', '반도체'], + ['fdry', '파운드리', '반도체'], + ['aidc', 'AI 데이터센터', '반도체'], + ['cath', '양극재', '2차전지'], + ['li', '리튬', '2차전지'], + ['ev', '전기차', '2차전지'], + ['k9', 'K9 자주포', '방산'], + ['gm', '유도무기', '방산'], + ['dex', '방산 수출', '방산'], + ['agr', '해외 정부계약', '방산'], ] -type NodeKindKo = '제품' | '테마' | '원자재' -const KIND_MAP: Record = { - 제품: 'PRODUCT', - 테마: 'THEME', - 원자재: 'MATERIAL', -} - export const ONTOLOGY_NODES: OntologyNode[] = [ ...RAW_COMPANIES.map( ([id, name, ticker, sector, marketCap]): OntologyNode => ({ id, name, - kind: 'COMPANY', + kind: 'STOCK', ticker, sector, marketCap, }), ), - ...RAW_CONCEPTS.map(([id, name, kindKo, sector]): OntologyNode => ({ + ...RAW_CONCEPTS.map(([id, name, sector]): OntologyNode => ({ id, name, - kind: KIND_MAP[kindKo], + kind: 'CONCEPT', sector, })), ] -// [source, target, relation, polarity] -const RAW_EDGES: [string, string, string, 1 | -1][] = [ - ['sk', 'hbm', '주력 생산', 1], - ['sk', 'dram', '주력 생산', 1], - ['sk', 'hanmi', '장비 공급', 1], - ['sk', 'ss', '경쟁', -1], - ['sk', 'eo', '레이저 장비', 1], - ['sk', 'wonik', '증착 장비', 1], - ['sk', 'hpsp', '고압 어닐링', 1], - ['sk', 'lino', '테스트 소켓', 1], - ['sk', 'tck', '소모품 공급', 1], - ['sk', 'solb', '공정 소재', 1], - ['hbm', 'hanmi', 'TC본더 필수', 1], - ['hbm', 'aidc', '수요 견인', 1], - ['ss', 'hbm', '생산', 1], - ['ss', 'dram', '생산', 1], - ['ss', 'fdry', '사업 영위', 1], - ['ss', 'jusung', '장비 공급', 1], - ['ss', 'lino', '테스트 소켓', 1], - ['ss', 'dj', '포토레지스트', 1], - ['dbh', 'fdry', '사업 영위', 1], - ['aidc', 'fdry', '수요 견인', 1], - ['dram', 'aidc', '수요 견인', 1], - ['lgen', 'cath', '핵심 소재', 1], - ['lgen', 'ecop', '양극재 조달', 1], - ['lgen', 'lnf', '양극재 조달', 1], - ['lgen', 'sdi', '경쟁', -1], - ['lgen', 'ev', '배터리 공급', 1], - ['lgen', 'cb', '첨가제 조달', 1], - ['lgen', 'skinn', '경쟁', -1], - ['sdi', 'posf', '양극재 조달', 1], - ['sdi', 'ev', '배터리 공급', 1], - ['ecop', 'cath', '생산', 1], - ['posf', 'cath', '생산', 1], - ['lnf', 'cath', '생산', 1], - ['cath', 'li', '원재료', 1], - ['skinn', 'ev', '배터리 공급', 1], - ['ev', 'hmc', '완성차', 1], - ['hwa', 'k9', '생산', 1], - ['hwa', 'dex', '수출 주도', 1], - ['hwa', 'pung', '탄약 조달', 1], - ['hwa', 'lig', '경쟁', -1], - ['lig', 'gm', '생산', 1], - ['lig', 'dex', '수출', 1], - ['rotem', 'dex', '수출', 1], - ['kai', 'dex', '수출', 1], - ['pung', 'dex', '탄약 수출', 1], - ['k9', 'dex', '대표 품목', 1], - ['gm', 'dex', '대표 품목', 1], - ['dex', 'agr', '계약 형태', 1], - ['k9', 'agr', '계약 대상', 1], - ['kai', 'hwa', '엔진 협력', 1], - ['rotem', 'hmc', '계열사', 1], - ['pung', 'k9', '탄약 공급', 1], +// [source, target, relation, relationType?] — relationType은 실제 RELATED_TO.relation_type이며 +// STOCK-STOCK 경쟁·계열 관계에만 붙는다. 생략된 엣지(SUPPLY_TO 등)는 항상 동조로 취급된다. +const RAW_EDGES: [string, string, string, RelationType?][] = [ + ['sk', 'hbm', '주력 생산'], + ['sk', 'dram', '주력 생산'], + ['sk', 'hanmi', '장비 공급'], + ['sk', 'ss', '경쟁', 'COMPETITOR'], + ['sk', 'eo', '레이저 장비'], + ['sk', 'wonik', '증착 장비'], + ['sk', 'hpsp', '고압 어닐링'], + ['sk', 'lino', '테스트 소켓'], + ['sk', 'tck', '소모품 공급'], + ['sk', 'solb', '공정 소재'], + ['hbm', 'hanmi', 'TC본더 필수'], + ['hbm', 'aidc', '수요 견인'], + ['ss', 'hbm', '생산'], + ['ss', 'dram', '생산'], + ['ss', 'fdry', '사업 영위'], + ['ss', 'jusung', '장비 공급'], + ['ss', 'lino', '테스트 소켓'], + ['ss', 'dj', '포토레지스트'], + ['dbh', 'fdry', '사업 영위'], + ['aidc', 'fdry', '수요 견인'], + ['dram', 'aidc', '수요 견인'], + ['lgen', 'cath', '핵심 소재'], + ['lgen', 'ecop', '양극재 조달'], + ['lgen', 'lnf', '양극재 조달'], + ['lgen', 'sdi', '경쟁', 'COMPETITOR'], + ['lgen', 'ev', '배터리 공급'], + ['lgen', 'cb', '첨가제 조달'], + ['lgen', 'skinn', '경쟁', 'COMPETITOR'], + ['sdi', 'posf', '양극재 조달'], + ['sdi', 'ev', '배터리 공급'], + ['ecop', 'cath', '생산'], + ['posf', 'cath', '생산'], + ['lnf', 'cath', '생산'], + ['cath', 'li', '원재료'], + ['skinn', 'ev', '배터리 공급'], + ['ev', 'hmc', '완성차'], + ['hwa', 'k9', '생산'], + ['hwa', 'dex', '수출 주도'], + ['hwa', 'pung', '탄약 조달'], + ['hwa', 'lig', '경쟁', 'COMPETITOR'], + ['lig', 'gm', '생산'], + ['lig', 'dex', '수출'], + ['rotem', 'dex', '수출'], + ['kai', 'dex', '수출'], + ['pung', 'dex', '탄약 수출'], + ['k9', 'dex', '대표 품목'], + ['gm', 'dex', '대표 품목'], + ['dex', 'agr', '계약 형태'], + ['k9', 'agr', '계약 대상'], + ['kai', 'hwa', '엔진 협력'], + ['rotem', 'hmc', '계열사', 'AFFILIATE'], + ['pung', 'k9', '탄약 공급'], ] export const ONTOLOGY_EDGES: OntologyEdge[] = RAW_EDGES.map( - ([source, target, relation, polarity], i) => ({ + ([source, target, relation, relationType], i) => ({ id: `e${i}`, source, target, relation, - polarity, + relationType, }), ) diff --git a/src/lib/graphIndex.ts b/src/lib/graphIndex.ts new file mode 100644 index 0000000..bc8e07c --- /dev/null +++ b/src/lib/graphIndex.ts @@ -0,0 +1,131 @@ +import type { OntologyEdge, OntologyNode } from '#/types' + +/** + * 노드/엣지 배열로부터 인접 리스트·관계 조회 인덱스를 만들고, 기점에서 BFS로 + * hop 레벨을 계산하는 재사용 유틸리티. 정적 전체 온톨로지(lib/layout.ts의 + * graphIndex)와 뉴스별 파급 경로 그래프(GET /api/news/{id}/graph) 양쪽에서 쓴다. + */ + +export interface GraphIndex { + byId: Map + adjacency: Map + relationByKey: Map +} + +/** 실제 RELATED_TO의 relation_type만 반대 극성(경쟁·대체)이고, 그 외(SUPPLY_TO 포함)는 항상 동조다. */ +export function polarityOf(edge: Pick): 1 | -1 { + return edge.relationType === 'COMPETITOR' ? -1 : 1 +} + +export function edgeKey(a: string, b: string): string { + return [a, b].sort().join('|') +} + +export function buildGraphIndex( + nodes: OntologyNode[], + edges: OntologyEdge[], +): GraphIndex { + const byId = new Map(nodes.map((n) => [n.id, n])) + const adjacency = new Map() + nodes.forEach((n) => adjacency.set(n.id, [])) + const relationByKey = new Map< + string, + { relation: string; polarity: 1 | -1 } + >() + edges.forEach((e) => { + adjacency.get(e.source)?.push(e.target) + adjacency.get(e.target)?.push(e.source) + relationByKey.set(edgeKey(e.source, e.target), { + relation: e.relation, + polarity: polarityOf(e), + }) + }) + return { byId, adjacency, relationByKey } +} + +function lookupRelation( + index: GraphIndex, + a: string, + b: string, +): { relation: string; polarity: 1 | -1 } { + return ( + index.relationByKey.get(edgeKey(a, b)) ?? { relation: '연관', polarity: 1 } + ) +} + +export interface GraphPair { + source: string + target: string + relation: string + polarity: 1 | -1 + level: number +} + +export interface BuiltGraph { + ids: string[] + pairs: GraphPair[] + level: Record + /** BFS 트리에서의 부모 노드. pathTo()로 기점까지의 경로를 복원할 때 쓴다. */ + parent: Partial> + maxLevel: number +} + +/** 기점에서 BFS로 각 노드까지의 hop 레벨과 경유 엣지를 산출한다. maxHop 생략 시 도달 가능한 전체를 펼친다. */ +export function bfsBuild( + index: GraphIndex, + originId: string, + maxHop = Infinity, +): BuiltGraph { + const level = new Map([[originId, 0]]) + const parent = new Map() + const pairs: GraphPair[] = [] + const seen = new Set() + const queue = [originId] + + while (queue.length) { + const current = queue.shift()! + const currentLevel = level.get(current) ?? 0 + if (currentLevel >= maxHop) continue + for (const neighbor of index.adjacency.get(current) ?? []) { + const key = edgeKey(current, neighbor) + if (!level.has(neighbor)) { + level.set(neighbor, currentLevel + 1) + parent.set(neighbor, current) + queue.push(neighbor) + } + if (level.get(neighbor) === currentLevel + 1 && !seen.has(key)) { + seen.add(key) + const rel = lookupRelation(index, current, neighbor) + pairs.push({ + source: current, + target: neighbor, + relation: rel.relation, + polarity: rel.polarity, + level: currentLevel + 1, + }) + } + } + } + + const levelObj = Object.fromEntries(level) + const ids = Object.keys(levelObj) + const maxLevel = ids.reduce((m, id) => Math.max(m, levelObj[id]), 0) + return { + ids, + pairs, + level: levelObj, + parent: Object.fromEntries(parent), + maxLevel, + } +} + +/** BFS 결과에서 기점→id까지의 노드 ID 경로를 복원한다(관련 종목 정렬·경로 표시용). */ +export function pathTo(built: BuiltGraph, id: string): string[] { + const path = [id] + let cur = id + for (let next = built.parent[cur]; next; next = built.parent[cur]) { + cur = next + path.unshift(cur) + } + return path +} diff --git a/src/lib/layout.ts b/src/lib/layout.ts index 7b40e32..600314f 100644 --- a/src/lib/layout.ts +++ b/src/lib/layout.ts @@ -1,4 +1,5 @@ import { ONTOLOGY_EDGES, ONTOLOGY_NODES } from './data' +import { buildGraphIndex } from './graphIndex' import type { OntologyNode } from '#/types' /** @@ -11,57 +12,8 @@ export interface LayoutPoint { y: number } -export interface GraphPair { - source: string - target: string - relation: string - polarity: 1 | -1 - level: number -} - -export interface BuiltGraph { - ids: string[] - pairs: GraphPair[] - level: Record - maxLevel: number - pos: Record -} - -function edgeKey(a: string, b: string) { - return [a, b].sort().join('|') -} - -/** 노드/인접 리스트/관계 조회용 인덱스. 정적 온톨로지 기준으로 한 번만 계산한다. */ -export const graphIndex = (() => { - const byId = new Map(ONTOLOGY_NODES.map((n) => [n.id, n])) - const adjacency = new Map() - ONTOLOGY_NODES.forEach((n) => adjacency.set(n.id, [])) - const relationByKey = new Map< - string, - { relation: string; polarity: 1 | -1 } - >() - ONTOLOGY_EDGES.forEach((e) => { - adjacency.get(e.source)?.push(e.target) - adjacency.get(e.target)?.push(e.source) - relationByKey.set(edgeKey(e.source, e.target), { - relation: e.relation, - polarity: e.polarity, - }) - }) - return { byId, adjacency, relationByKey } -})() - -function lookupRelation( - a: string, - b: string, -): { relation: string; polarity: 1 | -1 } { - return ( - graphIndex.relationByKey.get(edgeKey(a, b)) ?? { - relation: '연관', - polarity: 1, - } - ) -} +/** 전체 온톨로지 인덱스. 정적 데이터 기준으로 한 번만 계산해 캐시한다. */ +export const graphIndex = buildGraphIndex(ONTOLOGY_NODES, ONTOLOGY_EDGES) const RADIUS = [0, 300, 520, 700] @@ -94,94 +46,6 @@ export function radialLayout( return pos } -/** 뉴스 분석의 related[].chain을 펼쳐 노드·엣지·hop 레벨을 산출한다. */ -export function focusBuild( - mainNodeId: string, - related: { nodeId: string; chain: string[] }[], -): BuiltGraph { - const level = new Map([[mainNodeId, 0]]) - const set = new Set([mainNodeId]) - const pairs: GraphPair[] = [] - const seen = new Set() - - related.forEach((r) => { - r.chain.forEach((id, k) => { - set.add(id) - const existing = level.get(id) - if (existing === undefined || k < existing) level.set(id, k) - if (k > 0) { - const a = r.chain[k - 1] - const key = `${a}>${id}` - if (!seen.has(key)) { - seen.add(key) - const rel = lookupRelation(a, id) - pairs.push({ - source: a, - target: id, - relation: rel.relation, - polarity: rel.polarity, - level: k, - }) - } - } - }) - }) - - const ids = Array.from(set) - const levelObj = Object.fromEntries(level) - const maxLevel = ids.reduce((m, id) => Math.max(m, levelObj[id] ?? 0), 0) - return { - ids, - pairs, - level: levelObj, - maxLevel, - pos: radialLayout(ids, levelObj), - } -} - -/** 특정 노드 기점 BFS(기본 2단계) */ -export function nodeBuild(originId: string, maxHop = 2): BuiltGraph { - const level = new Map([[originId, 0]]) - const pairs: GraphPair[] = [] - const seen = new Set() - const queue = [originId] - - while (queue.length) { - const current = queue.shift()! - const currentLevel = level.get(current) ?? 0 - if (currentLevel >= maxHop) continue - for (const neighbor of graphIndex.adjacency.get(current) ?? []) { - const key = edgeKey(current, neighbor) - if (!level.has(neighbor)) { - level.set(neighbor, currentLevel + 1) - queue.push(neighbor) - } - if (level.get(neighbor) === currentLevel + 1 && !seen.has(key)) { - seen.add(key) - const rel = lookupRelation(current, neighbor) - pairs.push({ - source: current, - target: neighbor, - relation: rel.relation, - polarity: rel.polarity, - level: currentLevel + 1, - }) - } - } - } - - const levelObj = Object.fromEntries(level) - const ids = Object.keys(levelObj) - const maxLevel = ids.reduce((m, id) => Math.max(m, levelObj[id]), 0) - return { - ids, - pairs, - level: levelObj, - maxLevel, - pos: radialLayout(ids, levelObj), - } -} - const SECTOR_CENTERS: Record = { 반도체: { x: -820, y: -60 }, '2차전지': { x: 760, y: -100 }, diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 6df99c8..5ae3650 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,9 +1,16 @@ import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query' import { http } from '#/lib/http' -import { getGraph, getNews, getNewsAnalysis, getVerify } from '#/lib/api' +import { + getGraph, + getNews, + getNewsAnalysis, + getNewsImpactGraph, + getVerify, +} from '#/lib/api' import type { BriefingResult, NewsAnalysis, + NewsImpactGraph, NewsListPage, OntologyEdge, OntologyNode, @@ -20,6 +27,7 @@ import type { export const queryKeys = { news: (sector: string) => ['news', sector] as const, newsAnalysis: (newsId: string) => ['news', newsId, 'analysis'] as const, + newsImpactGraph: (newsId: string) => ['news', newsId, 'graph'] as const, graph: () => ['graph'] as const, verify: (sector: string) => ['verify', sector] as const, } @@ -44,6 +52,10 @@ async function fetchNewsAnalysis(newsId: string): Promise { return http.get(`news/${newsId}/analysis`).json() } +async function fetchNewsImpactGraph(newsId: string): Promise { + return http.get(`news/${newsId}/graph`).json() +} + async function fetchGraph(): Promise<{ nodes: OntologyNode[] edges: OntologyEdge[] @@ -87,6 +99,17 @@ export function useNewsAnalysisQuery(newsId: string | null) { }) } +/** 뉴스 파급 경로 그래프 — 좌표 없이 노드/엣지만 온다, 배치는 프론트(lib/graphIndex.ts)가 계산한다. */ +export function useNewsImpactGraphQuery(newsId: string | null) { + return useQuery({ + queryKey: queryKeys.newsImpactGraph(newsId ?? ''), + queryFn: () => fetchNewsImpactGraph(newsId as string), + enabled: !!newsId, + placeholderData: () => + newsId ? (getNewsImpactGraph(newsId) ?? undefined) : undefined, + }) +} + export function useGraphQuery() { return useQuery({ queryKey: queryKeys.graph(), diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 9709d07..f60bca0 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -3,6 +3,7 @@ import { getGraph, getNews, getNewsAnalysis, + getNewsImpactGraph, getVerify, runBriefing, } from '#/lib/api' @@ -38,6 +39,15 @@ export const handlers = [ return HttpResponse.json(analysis) }), + http.get('/api/news/:id/graph', async ({ params }) => { + await delay(250) + const impactGraph = getNewsImpactGraph(params.id as string) + if (!impactGraph) { + return new HttpResponse(null, { status: 404 }) + } + return HttpResponse.json(impactGraph) + }), + http.get('/api/graph', async () => { await delay(250) return HttpResponse.json(getGraph()) diff --git a/src/types/index.ts b/src/types/index.ts index 082a5c2..51be13a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,21 +1,36 @@ -export type NodeKind = 'COMPANY' | 'PRODUCT' | 'THEME' | 'MATERIAL' +/** + * 실제 온톨로지(Neo4j)는 Stock/Role/Theme 3계층 + BELONGS_TO/SUPPLY_TO/RELATED_TO + * 관계로 이뤄져 있지만, 프론트는 "거래 가능한 종목인지" 여부만 렌더링에 쓴다 + * (카드 크기·티커 표시 여부). 그래서 Role/Theme는 CONCEPT 하나로 뭉뚱그려 받는다. + */ +export type NodeKind = 'STOCK' | 'CONCEPT' export type Direction = 'UP' | 'DOWN' export interface OntologyNode { id: string name: string kind: NodeKind - ticker?: string // COMPANY만 + ticker?: string // STOCK만 sector: string // '반도체' | '2차전지' | '방산' - marketCap?: number // 억원, COMPANY만 + marketCap?: number // 억원, STOCK만 } +/** 실제 RELATED_TO 엣지의 relation_type 값 중 일부. STOCK-STOCK 엣지에만 존재한다. */ +export type RelationType = + | 'EQUITY_INVESTMENT' + | 'AFFILIATE' + | 'LICENSING' + | 'COMPETITOR' + | 'MNA' + | 'OTHER' + export interface OntologyEdge { id: string source: string target: string - relation: string // '장비 공급', '경쟁', '양극재 조달' ... - polarity: 1 | -1 // 1 동조 / -1 반대(경쟁·대체) + relation: string // 화면에 보여줄 관계 레이블 — '장비 공급', '경쟁', '양극재 조달' ... + /** SUPPLY_TO·BELONGS_TO 등 relation_type이 없는 엣지는 생략된다(항상 동조로 취급). */ + relationType?: RelationType } export interface NewsListItem { @@ -40,7 +55,6 @@ export interface NewsRelatedStock { ticker?: string direction: Direction relation: string - chain: string[] // 기점부터 대상까지의 노드 ID 배열 } export interface NewsAnalysis { @@ -69,6 +83,22 @@ export interface NewsAnalysis { } } +/** 파급 경로 그래프의 노드. direction은 이 뉴스의 파급 경로에 포함된 STOCK에만 붙는다. */ +export interface ImpactGraphNode extends OntologyNode { + direction?: Direction +} + +/** + * GET /api/news/{id}/graph 응답. 좌표는 내려주지 않는다 — 화면에 그릴 위치는 + * 프론트가 hop 레벨(originId부터의 BFS 거리)로 계산한다. + */ +export interface NewsImpactGraph { + newsId: string + originId: string + nodes: ImpactGraphNode[] + edges: OntologyEdge[] +} + export interface VerifyItem { name: string predicted: Direction From c4c305032682a68e3c4c33b53cb979dcb5531de1 Mon Sep 17 00:00:00 2001 From: 10tentacion Date: Sun, 26 Jul 2026 18:07:01 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[FIX]=20=EC=98=88=EC=B8=A1=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=ED=83=AD=EC=9D=B4=20=ED=99=94=EB=A9=B4=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4=EB=A5=BC=20=EB=84=98=EC=96=B4=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=A0=84=EC=B2=B4=EA=B0=80=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 뉴스맵 탭과 달리 검증 탭 컨테이너에 높이 고정이 없어 콘텐츠가 늘어나는 만큼 페이지 전체가 스크롤됐다. .map과 동일하게 화면 높이에 맞추고, 목록·상세 카드 내부에서만 스크롤되도록 flex 레이아웃을 조정했다. Co-Authored-By: Claude Sonnet 5 --- src/components/verify/VerifyDetail.tsx | 8 ++++---- src/components/verify/VerifyView.tsx | 2 +- src/routes/index.tsx | 2 +- src/styles.css | 21 +++++++++++++++++++-- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/components/verify/VerifyDetail.tsx b/src/components/verify/VerifyDetail.tsx index de32208..be78d8e 100644 --- a/src/components/verify/VerifyDetail.tsx +++ b/src/components/verify/VerifyDetail.tsx @@ -7,7 +7,7 @@ export default function VerifyDetail({ entry }: { entry: VerifyEntry | null }) { if (!entry) { return (
@@ -102,7 +102,7 @@ export default function VerifyDetail({ entry }: { entry: VerifyEntry | null }) {
-
+
+
{ctx && (
+
) diff --git a/src/styles.css b/src/styles.css index 0402d22..9303a30 100644 --- a/src/styles.css +++ b/src/styles.css @@ -309,9 +309,18 @@ body { } /* ── 예측 검증 레이아웃 (SC-02) ── */ +/* 뉴스맵(.map)과 동일하게 화면 높이에 딱 맞추고, 내부 카드에서만 스크롤되게 한다. */ +.verify { + display: flex; + flex-direction: column; + height: calc(100vh - var(--gnb-h) - 42px); + min-height: 640px; + overflow: hidden; +} .vsumcard { display: grid; grid-template-columns: 300px minmax(0, 1fr); + flex: none; } .vmets { border-right: 1px solid var(--n-100); @@ -320,10 +329,11 @@ body { display: grid; grid-template-columns: 392px minmax(0, 1fr); gap: 14px; - align-items: start; + flex: 1; + min-height: 0; } .vlist { - max-height: 620px; + height: 100%; } @media (max-width: 1240px) { @@ -336,10 +346,17 @@ body { } @media (max-width: 1080px) { + .verify { + height: auto; + min-height: 0; + overflow: visible; + } .vsplit { grid-template-columns: minmax(0, 1fr); + flex: initial; } .vlist { + height: auto; max-height: 340px; } .vsumcard { From fee37570a7fd59ffa4b431016a1ca1c07e84f5b6 Mon Sep 17 00:00:00 2001 From: 10tentacion Date: Sun, 26 Jul 2026 18:47:50 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[FIX]=20=ED=8C=8C=EA=B8=89=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EA=B7=B8=EB=9E=98=ED=94=84=EC=97=90=EC=84=9C=20cha?= =?UTF-8?q?in=20=EB=A7=88=EC=A7=80=EB=A7=89=20=EC=9B=90=EC=86=8C=EA=B0=80?= =?UTF-8?q?=20nodeId=EC=99=80=20=EB=8B=A4=EB=A5=B4=EB=A9=B4=20=EB=85=B8?= =?UTF-8?q?=EB=93=9C=EA=B0=80=20=EB=88=84=EB=9D=BD=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getNewsImpactGraph가 related[].chain 원소만으로 nodeIds를 모으고 있어, chain의 마지막 원소가 r.nodeId와 다른 레코드가 생기면 directionById에는 값이 있지만 해당 노드가 응답 nodes 배열에는 없는 상태가 될 수 있었다. r.nodeId를 항상 명시적으로 nodeIds에 추가해 계약을 고정한다. Co-Authored-By: Claude Sonnet 5 --- src/lib/api.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/api.ts b/src/lib/api.ts index 98e74c1..49cc12b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -155,6 +155,7 @@ export function getNewsImpactGraph(newsId: string): NewsImpactGraph | null { const nodeIds = new Set([record.main.nodeId]) const edgeById = new Map() record.related.forEach((r) => { + nodeIds.add(r.nodeId) r.chain.forEach((id) => nodeIds.add(id)) for (let i = 1; i < r.chain.length; i++) { const edge = findOntologyEdge(r.chain[i - 1], r.chain[i]) From 9c903856e9826aac4be6164da2f2d29d4ac5cb51 Mon Sep 17 00:00:00 2001 From: 10tentacion Date: Sun, 26 Jul 2026 18:57:17 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[FIX]=20=ED=94=84=EB=A1=9C=EB=8D=95?= =?UTF-8?q?=EC=85=98=20=EB=B9=8C=EB=93=9C=20=ED=94=84=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B7=B8=EB=9E=98=ED=94=84=EA=B0=80=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=20=EC=A7=81=ED=9B=84=20=EC=82=AC=EB=9D=BC?= =?UTF-8?q?=EC=A7=80=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enableMocking이 import.meta.env.DEV로만 게이팅돼 있어 pnpm build && pnpm preview에서는 MSW가 뜨지 않았다. placeholderData로 잠깐 정상 렌더된 뒤, 404난 실 fetch가 재시도까지 실패하면 쿼리가 error 상태가 되며 data가 undefined로 떨어져 그래프·목록이 통째로 사라졌다. docs/KOSLINK-FRONTEND.md §11이 "빌드된 정적 파일로 발표한다"고 명시하고 있어 이 경로가 반드시 동작해야 한다. http.ts와 동일하게 VITE_API_BASE_URL 유무로 모킹 여부를 판단하도록 통일했다. Co-Authored-By: Claude Sonnet 5 --- src/main.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main.tsx b/src/main.tsx index 73fc4a4..c32c73f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,8 +4,15 @@ import { RouterProvider } from '@tanstack/react-router' import { getRouter } from './router' import './styles.css' +/** + * 아직 실 백엔드가 없으므로 dev든 프로덕션 빌드든 항상 MSW를 띄운다. + * docs/KOSLINK-FRONTEND.md §11(데모 안전장치)은 "빌드된 정적 파일로 발표한다"고 + * 못박아 두고 있어서, DEV로만 게이팅하면 pnpm build && pnpm preview에서 + * /api/* 요청이 전부 404가 나고 placeholderData가 error 상태로 사라진다. + * 실 백엔드가 준비되면 VITE_API_BASE_URL을 설정해 이 워커를 끄면 된다. + */ async function enableMocking() { - if (!import.meta.env.DEV) return + if (import.meta.env.VITE_API_BASE_URL) return const { worker } = await import('#/mocks/browser') await worker.start({ onUnhandledRequest: 'bypass' }) }