diff --git a/CLAUDE.md b/CLAUDE.md index ab62d9f..d40c7c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,8 +39,8 @@ pnpm dlx shadcn@latest add Both `#/*` and `@/*` resolve to `./src/*`: ```typescript -import { cn } from '#/lib/utils' -import { cn } from '@/lib/utils' +import { cn } from '#/shared/utils/cn' +import { cn } from '@/shared/utils/cn' ``` ## Architecture @@ -53,26 +53,38 @@ Routes are defined in `src/routes/`. The route tree is auto-generated to `src/ro - `index.tsx` - Home page - `demo/` - Demo pages (can be safely deleted) -### Key Directories (Planned Structure) +### Key Directories + +Category-first, then domain: each top-level concern (`components`, `hooks`, `utils`, `constants`, `types`, `apis`, `store`, `mocks`) has its own folder, and within each, code is split by domain (`news`, `graph`, `analysis`, `verify`, `scrap`, `cover`). Code used by 2+ domains doesn't live inside a category — it goes in the single top-level `src/shared/` folder, mirroring the same category subfolders internally. Domain subfolders are only created where a file actually exists for that domain (e.g. `hooks/news/` doesn't exist since there's no news-specific hook). ``` src/ -├── routes/ # File-based routes -├── components/ -│ ├── ui/ # shadcn components -│ ├── news/ # News list, cards -│ ├── graph/ # React Flow graph components -│ ├── analysis/ # Impact analysis panel -│ ├── verify/ # Prediction verification -│ └── briefing/ # Watchlist briefing sheet -├── lib/ -│ ├── utils.ts # cn() utility -│ ├── api.ts # API fetch + Query hooks -│ └── layout.ts # Graph layout calculations +├── routes/ # File-based routes (unchanged by domain split) +├── shared/ # cross-domain code, one subfolder per category +│ ├── components/ # Header +│ ├── hooks/ # useInfiniteScrollTrigger +│ ├── utils/ # cn, format, graphIndex +│ ├── constants/ # tabs +│ ├── types/ # Direction +│ ├── apis/ # http, paginate +│ ├── store/ # viewAtom, selectedNewsIdAtom, ... +│ └── mocks/ # aggregated handlers, browser worker +├── components/{news,graph,analysis,verify,scrap,cover,ui}/ +│ └── ui/ # shadcn components — kept flat here, not under shared/, +│ # since `pnpm dlx shadcn add` targets this path by default +├── hooks/graph/ # usePropagation +├── utils/graph/ # layout.ts +├── constants/{graph,verify}/ +├── types/{news,graph,analysis,verify}/ +├── apis/{news,graph,analysis,verify}/ # mock.ts + queries.ts (+ mappers.ts) per domain +├── store/{news,graph,verify,scrap}/ # Jotai atoms, one file per domain +├── mocks/{news,graph,analysis,verify}/ # MSW handlers + mock data per domain ├── integrations/tanstack-query/ # Query provider setup -└── types/ +├── main.tsx, router.tsx, styles.css ``` +There is no `briefing` domain yet — none of the above folders have a `briefing/` subfolder until that feature is built. + ### State Management Pattern - **Server state**: TanStack Query only (news, graph, analysis, verification data) @@ -84,7 +96,7 @@ src/ When implementing the graph visualization: 1. **Declare nodeTypes/edgeTypes outside components** - Defining inside causes full graph remount on every render -2. **No layout library needed** - Use simple trigonometry for radial/cluster layouts in `lib/layout.ts` +2. **No layout library needed** - Use simple trigonometry for radial/cluster layouts in `utils/graph/layout.ts` 3. **Custom floating edges required** - Default edges snap to handles; calculate rectangle intersection points for proper connections 4. **One-shot animations only** - Never use React Flow's `animated: true`; use CSS with `animation-fill-mode: forwards` 5. **Cache fullLayout results** - Recalculating on view switches causes nodes to jump @@ -103,7 +115,7 @@ Base path is `/api`. No authentication. All endpoints wrapped with TanStack Quer - `GET /api/news` - News list, cursor-paginated (`sector?`, `cursor?`, `limit?` → `{ items, nextCursor }`), infinite scroll - `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/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 (`shared/utils/graphIndex.ts`'s `bfsBuild` + `utils/graph/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 @@ -116,5 +128,5 @@ Base path is `/api`. No authentication. All endpoints wrapped with TanStack Quer - React Flow `animated: true` - `nodeTypes`/`edgeTypes` declared inside components - Horizontal scroll news list on mobile -- Numbered pagination UI (1, 2, 3…) in news list or verification screen — both use cursor-based infinite scroll instead (`useInfiniteScrollTrigger`) +- Numbered pagination UI (1, 2, 3…) in news list or verification screen — both use cursor-based infinite scroll instead (`shared/hooks/useInfiniteScrollTrigger`) - shadcn Card inside graph nodes (interferes with size calculation) diff --git a/src/lib/mappers.ts b/src/apis/analysis/mappers.ts similarity index 59% rename from src/lib/mappers.ts rename to src/apis/analysis/mappers.ts index 7620fba..4e922da 100644 --- a/src/lib/mappers.ts +++ b/src/apis/analysis/mappers.ts @@ -1,30 +1,14 @@ -import type { - Direction, - NewsImpact, - NewsImpactGraph, - NewsListItem, - NewsListPage, -} from '#/types' +import type { Direction } from '#/shared/types' +import type { NewsImpact, OriginStock, RelatedStock } from '#/types/analysis' +import type { NewsImpactGraph } from '#/types/graph' /** * 실 백엔드가 내려줄 wire(snake_case) 응답 타입 + 프론트 도메인 타입(camelCase)으로의 - * 변환 함수 모음. lib/api.ts(목 데이터)와 MSW 핸들러는 이 wire 타입 그대로를 다루고, - * lib/queries.ts의 fetch 함수가 여기를 거쳐 컴포넌트에 camelCase 도메인 타입을 넘긴다. - * 실 백엔드가 붙을 때도 이 매퍼만 유지하면 컴포넌트 쪽은 손댈 필요가 없다. + * 변환 함수 모음. apis/analysis/mock.ts(목 데이터)와 mocks/analysis/handlers.ts는 이 wire + * 타입 그대로를 다루고, apis/analysis/queries.ts의 fetch 함수가 여기를 거쳐 컴포넌트에 + * camelCase 도메인 타입을 넘긴다. */ -export interface NewsListItemWire { - news_id: number - title: string - press: string - published_at: string -} - -export interface NewsListPageWire { - items: NewsListItemWire[] - nextCursor: string | null -} - export type StockStatus = 'up' | 'down' export interface OriginStockWire { @@ -56,20 +40,6 @@ function toDirection(status: StockStatus): Direction { return status === 'up' ? 'UP' : 'DOWN' } -export function mapNewsListPage(wire: NewsListPageWire): NewsListPage { - return { - items: wire.items.map( - (item): NewsListItem => ({ - id: item.news_id, - title: item.title, - press: item.press, - publishedAt: item.published_at, - }), - ), - nextCursor: wire.nextCursor, - } -} - /** newsId는 wire 응답에 담기지 않는다(그래프 안의 graph.newsId만 존재) — 요청한 id를 그대로 채운다. */ export function mapNewsImpact(newsId: number, wire: NewsImpactWire): NewsImpact { return { @@ -80,13 +50,13 @@ export function mapNewsImpact(newsId: number, wire: NewsImpactWire): NewsImpact publishedAt: wire.source.published_at, url: wire.source.url, }, - originStocks: wire.origin_stocks.map((o) => ({ + originStocks: wire.origin_stocks.map((o): OriginStock => ({ ticker: o.ticker, name: o.name, direction: toDirection(o.status), reason: o.reason, })), - relatedStocks: wire.related_stocks.map((r) => ({ + relatedStocks: wire.related_stocks.map((r): RelatedStock => ({ ticker: r.ticker, name: r.name, direction: toDirection(r.status), diff --git a/src/lib/api.ts b/src/apis/analysis/mock.ts similarity index 50% rename from src/lib/api.ts rename to src/apis/analysis/mock.ts index dd24e23..c97ec74 100644 --- a/src/lib/api.ts +++ b/src/apis/analysis/mock.ts @@ -1,27 +1,12 @@ -import { - ONTOLOGY_EDGES, - ONTOLOGY_NODES, - NEWS_RECORDS, - VERIFY_ENTRIES, - buildVerifyDaily, - pullRefreshBatch, -} from './data' -import type { NewsImpactWire, NewsListPageWire } from './mappers' -import type { - Direction, - ImpactGraphNode, - OntologyEdge, - OntologyNode, - VerifyResponse, -} from '#/types' +import { NEWS_RECORDS } from '#/mocks/news/data' +import { ONTOLOGY_EDGES, ONTOLOGY_NODES } from '#/mocks/graph/data' +import type { NewsImpactWire } from './mappers' +import type { Direction } from '#/shared/types' +import type { ImpactGraphNode, OntologyEdge, OntologyNode } from '#/types/graph' /** - * `docs/KOSLINK-FRONTEND.md` §5 API 명세와 같은 시그니처를 갖는 헬퍼 모음. - * 지금은 백엔드가 없어 lib/data.ts를 동기적으로 가공해 반환한다. 반환 모양은 실 - * 백엔드가 내려줄 wire(snake_case) 그대로다 — MSW 핸들러가 이 함수들을 그대로 - * HttpResponse.json()으로 흘려보내고, lib/queries.ts가 lib/mappers.ts를 거쳐 - * camelCase 도메인 타입으로 바꾼다. 실 API가 준비되면 이 함수들의 본문만 fetch - * 호출로 바꾸면 되고, 매퍼·호출부는 그대로 둘 수 있다. + * `docs/KOSLINK-FRONTEND.md` §5 API 명세와 같은 시그니처를 갖는 헬퍼. 지금은 백엔드가 + * 없어 mocks/news/data.ts + mocks/graph/data.ts를 동기적으로 가공해 반환한다. */ const nodeById = new Map(ONTOLOGY_NODES.map((n) => [n.id, n])) @@ -32,65 +17,6 @@ function requireNode(id: string): OntologyNode { return node } -/** - * 목록 커서 페이징 공통 로직. cursor는 "마지막으로 받은 항목의 커서 키" 관례를 - * 쓰지만, 클라이언트는 이 값을 해석하지 않고 nextCursor를 그대로 되돌려주기만 - * 한다. GET /api/news, GET /api/verify가 함께 쓴다. - */ -function paginate( - items: T[], - cursor: string | undefined, - limit: number, - cursorOf: (item: T) => string, -): { page: T[]; nextCursor: string | null } { - const startIndex = cursor - ? Math.max(items.findIndex((item) => cursorOf(item) === cursor) + 1, 0) - : 0 - const page = items.slice(startIndex, startIndex + limit) - const last = page.at(-1) - const nextCursor = - startIndex + limit < items.length && last ? cursorOf(last) : null - return { page, nextCursor } -} - -export interface GetNewsParams { - /** 이전 응답의 nextCursor. 첫 페이지는 생략한다. */ - cursor?: string - limit?: number -} - -const DEFAULT_NEWS_LIMIT = 20 - -export function getNews({ - cursor, - limit = DEFAULT_NEWS_LIMIT, -}: GetNewsParams = {}): NewsListPageWire { - const { page, nextCursor } = paginate(NEWS_RECORDS, cursor, limit, (n) => - String(n.id), - ) - - return { - items: page.map((n) => ({ - news_id: n.id, - title: n.title, - press: n.press, - published_at: n.publishedAt, - })), - nextCursor, - } -} - -/** - * "최신 뉴스 새로고침" 데모용 — 실 백엔드가 없어 pullRefreshBatch()로 몇 건을 - * 새 뉴스로 뽑아 NEWS_RECORDS 맨 앞에 끼워 넣고, 그 id 목록을 돌려준다. - * 호출부(NewsPanel)는 이 id로 새로 추가된 카드에 NEW 배지를 붙이고 몇 건이 - * 들어왔는지 안내한다. - */ -export function refreshNews(): { addedIds: number[] } { - const added = pullRefreshBatch() - return { addedIds: added.map((n) => n.id) } -} - function findOntologyEdge(a: string, b: string): OntologyEdge | undefined { return ONTOLOGY_EDGES.find( (e) => @@ -117,8 +43,8 @@ function buildFinalSummary(originNames: string[], relatedCount: number): string } /** - * GET /api/news/{id}/impact. 기존 analysis + graph 두 엔드포인트를 하나로 합친 것 — - * 분석 패널과 그래프 패널이 같은 화면 전환에서 동시에 필요한 데이터라서다. + * GET /api/news/{id}/impact. 분석 패널과 그래프 패널이 같은 화면 전환에서 동시에 + * 필요한 데이터를 하나로 합쳐 내려준다. */ export function getNewsImpact(newsId: number): NewsImpactWire | null { const record = NEWS_RECORDS.find((n) => n.id === newsId) @@ -199,46 +125,3 @@ export function getNewsImpact(newsId: number): NewsImpactWire | null { }, } } - -export interface GetGraphParams { - mode: 'full' -} - -export function getGraph( - _params?: GetGraphParams, -): { nodes: OntologyNode[]; edges: OntologyEdge[] } { - return { nodes: ONTOLOGY_NODES, edges: ONTOLOGY_EDGES } -} - -function bySector( - items: T[], - sector?: string, -): T[] { - return items.filter( - (n) => !sector || sector === '전체' || n.sector === sector, - ) -} - -export interface GetVerifyParams { - sector?: string - /** 이전 응답의 nextCursor. 첫 페이지는 생략한다. */ - cursor?: string - limit?: number -} - -const DEFAULT_VERIFY_LIMIT = 10 - -export function getVerify({ - sector, - cursor, - limit = DEFAULT_VERIFY_LIMIT, -}: GetVerifyParams = {}): VerifyResponse { - const { page, nextCursor } = paginate( - bySector(VERIFY_ENTRIES, sector), - cursor, - limit, - (v) => v.newsId, - ) - // daily 추이는 섹터/페이지와 무관한 전체 집계라 페이징하지 않는다. - return { daily: buildVerifyDaily(), news: page, nextCursor } -} diff --git a/src/apis/analysis/queries.ts b/src/apis/analysis/queries.ts new file mode 100644 index 0000000..3ff3bbe --- /dev/null +++ b/src/apis/analysis/queries.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query' +import { http } from '#/shared/apis/http' +import { getNewsImpact } from './mock' +import { mapNewsImpact } from './mappers' +import type { NewsImpactWire } from './mappers' +import type { NewsImpact } from '#/types/analysis' + +export const analysisKeys = { + impact: (newsId: number) => ['news', newsId, 'impact'] as const, +} + +async function fetchNewsImpact(newsId: number): Promise { + const wire = await http.get(`news/${newsId}/impact`).json() + return mapNewsImpact(newsId, wire) +} + +/** 뉴스 영향 분석 + 파급 경로 그래프 — 분석 패널과 그래프 패널이 같은 쿼리 키를 공유해 한 번만 조회한다. */ +export function useNewsImpactQuery(newsId: number | null) { + return useQuery({ + queryKey: analysisKeys.impact(newsId ?? -1), + queryFn: () => fetchNewsImpact(newsId as number), + enabled: newsId != null, + placeholderData: () => { + if (newsId == null) return undefined + const wire = getNewsImpact(newsId) + return wire ? mapNewsImpact(newsId, wire) : undefined + }, + }) +} diff --git a/src/apis/graph/mock.ts b/src/apis/graph/mock.ts new file mode 100644 index 0000000..a7b9044 --- /dev/null +++ b/src/apis/graph/mock.ts @@ -0,0 +1,12 @@ +import { ONTOLOGY_EDGES, ONTOLOGY_NODES } from '#/mocks/graph/data' +import type { OntologyEdge, OntologyNode } from '#/types/graph' + +export interface GetGraphParams { + mode: 'full' +} + +export function getGraph( + _params?: GetGraphParams, +): { nodes: OntologyNode[]; edges: OntologyEdge[] } { + return { nodes: ONTOLOGY_NODES, edges: ONTOLOGY_EDGES } +} diff --git a/src/apis/graph/queries.ts b/src/apis/graph/queries.ts new file mode 100644 index 0000000..e8320ea --- /dev/null +++ b/src/apis/graph/queries.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query' +import { http } from '#/shared/apis/http' +import { getGraph } from './mock' +import type { OntologyEdge, OntologyNode } from '#/types/graph' + +export const graphKeys = { + all: () => ['graph'] as const, +} + +async function fetchGraph(): Promise<{ + nodes: OntologyNode[] + edges: OntologyEdge[] +}> { + return http.get('graph', { searchParams: { mode: 'full' } }).json() +} + +export function useGraphQuery() { + return useQuery({ + queryKey: graphKeys.all(), + queryFn: fetchGraph, + placeholderData: () => getGraph({ mode: 'full' }), + staleTime: Infinity, + }) +} diff --git a/src/apis/news/mappers.ts b/src/apis/news/mappers.ts new file mode 100644 index 0000000..0a6021d --- /dev/null +++ b/src/apis/news/mappers.ts @@ -0,0 +1,35 @@ +import type { NewsListItem, NewsListPage } from '#/types/news' + +/** + * 실 백엔드가 내려줄 wire(snake_case) 응답 타입 + 프론트 도메인 타입(camelCase)으로의 + * 변환 함수 모음. apis/news/mock.ts(목 데이터)와 mocks/news/handlers.ts는 이 wire 타입 + * 그대로를 다루고, apis/news/queries.ts의 fetch 함수가 여기를 거쳐 컴포넌트에 camelCase + * 도메인 타입을 넘긴다. 실 백엔드가 붙을 때도 이 매퍼만 유지하면 컴포넌트 쪽은 손댈 + * 필요가 없다. + */ + +export interface NewsListItemWire { + news_id: number + title: string + press: string + published_at: string +} + +export interface NewsListPageWire { + items: NewsListItemWire[] + nextCursor: string | null +} + +export function mapNewsListPage(wire: NewsListPageWire): NewsListPage { + return { + items: wire.items.map( + (item): NewsListItem => ({ + id: item.news_id, + title: item.title, + press: item.press, + publishedAt: item.published_at, + }), + ), + nextCursor: wire.nextCursor, + } +} diff --git a/src/apis/news/mock.ts b/src/apis/news/mock.ts new file mode 100644 index 0000000..c287166 --- /dev/null +++ b/src/apis/news/mock.ts @@ -0,0 +1,50 @@ +import { NEWS_RECORDS, pullRefreshBatch } from '#/mocks/news/data' +import type { NewsListPageWire } from './mappers' +import { paginate } from '#/shared/apis/paginate' + +/** + * `docs/KOSLINK-FRONTEND.md` §5 API 명세와 같은 시그니처를 갖는 헬퍼 모음. + * 지금은 백엔드가 없어 mocks/news/data.ts를 동기적으로 가공해 반환한다. 반환 모양은 + * 실 백엔드가 내려줄 wire(snake_case) 그대로다 — mocks/news/handlers.ts가 이 함수들을 + * 그대로 HttpResponse.json()으로 흘려보내고, apis/news/queries.ts가 apis/news/mappers.ts를 + * 거쳐 camelCase 도메인 타입으로 바꾼다. 실 API가 준비되면 이 함수들의 본문만 fetch + * 호출로 바꾸면 되고, 매퍼·호출부는 그대로 둘 수 있다. + */ + +export interface GetNewsParams { + /** 이전 응답의 nextCursor. 첫 페이지는 생략한다. */ + cursor?: string + limit?: number +} + +const DEFAULT_NEWS_LIMIT = 20 + +export function getNews({ + cursor, + limit = DEFAULT_NEWS_LIMIT, +}: GetNewsParams = {}): NewsListPageWire { + const { page, nextCursor } = paginate(NEWS_RECORDS, cursor, limit, (n) => + String(n.id), + ) + + return { + items: page.map((n) => ({ + news_id: n.id, + title: n.title, + press: n.press, + published_at: n.publishedAt, + })), + nextCursor, + } +} + +/** + * "최신 뉴스 새로고침" 데모용 — 실 백엔드가 없어 pullRefreshBatch()로 몇 건을 + * 새 뉴스로 뽑아 NEWS_RECORDS 맨 앞에 끼워 넣고, 그 id 목록을 돌려준다. + * 호출부(NewsPanel)는 이 id로 새로 추가된 카드에 NEW 배지를 붙이고 몇 건이 + * 들어왔는지 안내한다. + */ +export function refreshNews(): { addedIds: number[] } { + const added = pullRefreshBatch() + return { addedIds: added.map((n) => n.id) } +} diff --git a/src/apis/news/queries.ts b/src/apis/news/queries.ts new file mode 100644 index 0000000..cc6c87d --- /dev/null +++ b/src/apis/news/queries.ts @@ -0,0 +1,33 @@ +import { useInfiniteQuery } from '@tanstack/react-query' +import { http } from '#/shared/apis/http' +import { getNews } from './mock' +import { mapNewsListPage } from './mappers' +import type { NewsListPageWire } from './mappers' +import type { NewsListPage } from '#/types/news' + +export const newsKeys = { + all: () => ['news'] as const, +} + +async function fetchNews(cursor?: string): Promise { + const searchParams: Record = {} + if (cursor) searchParams.cursor = cursor + const wire = await http + .get('news', { searchParams }) + .json() + return mapNewsListPage(wire) +} + +/** 뉴스 목록 — 무한 스크롤용 커서 기반 페이징. `data.pages.flatMap(p => p.items)`로 펼쳐 쓴다. */ +export function useNewsQuery() { + return useInfiniteQuery({ + queryKey: newsKeys.all(), + queryFn: ({ pageParam }) => fetchNews(pageParam), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + placeholderData: () => ({ + pages: [mapNewsListPage(getNews())], + pageParams: [undefined], + }), + }) +} diff --git a/src/apis/verify/mock.ts b/src/apis/verify/mock.ts new file mode 100644 index 0000000..04fc243 --- /dev/null +++ b/src/apis/verify/mock.ts @@ -0,0 +1,36 @@ +import { VERIFY_ENTRIES, buildVerifyDaily } from '#/mocks/verify/data' +import { paginate } from '#/shared/apis/paginate' +import type { VerifyResponse } from '#/types/verify' + +function bySector( + items: T[], + sector?: string, +): T[] { + return items.filter( + (n) => !sector || sector === '전체' || n.sector === sector, + ) +} + +export interface GetVerifyParams { + sector?: string + /** 이전 응답의 nextCursor. 첫 페이지는 생략한다. */ + cursor?: string + limit?: number +} + +const DEFAULT_VERIFY_LIMIT = 10 + +export function getVerify({ + sector, + cursor, + limit = DEFAULT_VERIFY_LIMIT, +}: GetVerifyParams = {}): VerifyResponse { + const { page, nextCursor } = paginate( + bySector(VERIFY_ENTRIES, sector), + cursor, + limit, + (v) => v.newsId, + ) + // daily 추이는 섹터/페이지와 무관한 전체 집계라 페이징하지 않는다. + return { daily: buildVerifyDaily(), news: page, nextCursor } +} diff --git a/src/apis/verify/queries.ts b/src/apis/verify/queries.ts new file mode 100644 index 0000000..284ab01 --- /dev/null +++ b/src/apis/verify/queries.ts @@ -0,0 +1,38 @@ +import { useInfiniteQuery } from '@tanstack/react-query' +import { http } from '#/shared/apis/http' +import { getVerify } from './mock' +import type { VerifyResponse } from '#/types/verify' + +export const verifyKeys = { + bySector: (sector: string) => ['verify', sector] as const, +} + +/** GET /api/verify가 쓰는 sector/cursor 쿼리스트링. */ +function pageSearchParams(sector: string, cursor?: string) { + const searchParams: Record = {} + if (sector && sector !== '전체') searchParams.sector = sector + if (cursor) searchParams.cursor = cursor + return searchParams +} + +async function fetchVerify( + sector: string, + cursor?: string, +): Promise { + const searchParams = pageSearchParams(sector, cursor) + return http.get('verify', { searchParams }).json() +} + +/** 검증 목록 — 무한 스크롤용 커서 기반 페이징. `data.pages.flatMap(p => p.news)`로 펼쳐 쓴다. */ +export function useVerifyQuery(sector: string) { + return useInfiniteQuery({ + queryKey: verifyKeys.bySector(sector), + queryFn: ({ pageParam }) => fetchVerify(sector, pageParam), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + placeholderData: () => ({ + pages: [getVerify({ sector })], + pageParams: [undefined], + }), + }) +} diff --git a/src/components/analysis/AnalysisPanel.tsx b/src/components/analysis/AnalysisPanel.tsx index 9acaa9c..4fa48ec 100644 --- a/src/components/analysis/AnalysisPanel.tsx +++ b/src/components/analysis/AnalysisPanel.tsx @@ -1,6 +1,7 @@ import { useAtom, useAtomValue } from 'jotai' -import { selectedNewsIdAtom, verifyContextAtom, viewAtom } from '#/lib/atoms' -import { useNewsImpactQuery } from '#/lib/queries' +import { selectedNewsIdAtom, viewAtom } from '#/shared/store/atoms' +import { verifyContextAtom } from '#/store/verify/atoms' +import { useNewsImpactQuery } from '#/apis/analysis/queries' import MainStockCard from './MainStockCard' import RelatedList from './RelatedList' import RationaleBox from './RationaleBox' diff --git a/src/components/analysis/MainStockCard.tsx b/src/components/analysis/MainStockCard.tsx index 6f9ebaa..f21b1fe 100644 --- a/src/components/analysis/MainStockCard.tsx +++ b/src/components/analysis/MainStockCard.tsx @@ -1,5 +1,5 @@ -import { arrow, directionLabel, impactOf } from '#/lib/format' -import type { OriginStock } from '#/types' +import { arrow, directionLabel, impactOf } from '#/shared/utils/format' +import type { OriginStock } from '#/types/analysis' function OriginCard({ stock }: { stock: OriginStock }) { const down = stock.direction === 'DOWN' diff --git a/src/components/analysis/RelatedList.tsx b/src/components/analysis/RelatedList.tsx index fd6bf00..23cb187 100644 --- a/src/components/analysis/RelatedList.tsx +++ b/src/components/analysis/RelatedList.tsx @@ -1,6 +1,7 @@ -import { arrow, directionLabel, impactOf } from '#/lib/format' -import { bfsBuild, buildGraphIndex } from '#/lib/graphIndex' -import type { NewsImpactGraph, RelatedStock } from '#/types' +import { arrow, directionLabel, impactOf } from '#/shared/utils/format' +import { bfsBuild, buildGraphIndex } from '#/shared/utils/graphIndex' +import type { NewsImpactGraph } from '#/types/graph' +import type { RelatedStock } from '#/types/analysis' function sortRelated(hopOf: (ticker: string) => number) { return (a: RelatedStock, b: RelatedStock) => { diff --git a/src/components/graph/GraphPanel.tsx b/src/components/graph/GraphPanel.tsx index f470aaa..0ba83bd 100644 --- a/src/components/graph/GraphPanel.tsx +++ b/src/components/graph/GraphPanel.tsx @@ -10,29 +10,28 @@ import { import type { Node, NodeMouseHandler } from '@xyflow/react' import '@xyflow/react/dist/style.css' // 필수 — 빠뜨리면 노드가 겹쳐 보인다 import { useAtom, useAtomValue } from 'jotai' -import { cn } from '#/lib/utils' -import { highlightedNodeAtom, selectedNewsIdAtom } from '#/lib/atoms' -import { useNewsImpactQuery } from '#/lib/queries' -import { sizeOf } from '#/lib/format' -import { fullLayout, graphIndex, radialLayout, tierOf } from '#/lib/layout' -import type { LayoutPoint } from '#/lib/layout' -import { bfsBuild, buildGraphIndex } from '#/lib/graphIndex' +import { cn } from '#/shared/utils/cn' +import { selectedNewsIdAtom } from '#/shared/store/atoms' +import { highlightedNodeAtom } from '#/store/graph/atoms' +import { useNewsImpactQuery } from '#/apis/analysis/queries' +import { sizeOf } from '#/shared/utils/format' +import { fullLayout, graphIndex, radialLayout, tierOf } from '#/utils/graph/layout' +import type { LayoutPoint } from '#/utils/graph/layout' +import { bfsBuild, buildGraphIndex } from '#/shared/utils/graphIndex' +import type { Direction } from '#/shared/types' import type { - Direction, + EdgeVisualState, NewsImpactGraph, + NodeVisualState, OntologyEdge, OntologyNode, -} from '#/types' -import StockNode from './StockNode' -import RelEdge from './RelEdge' -import { usePropagation } from './usePropagation' -import type { RevealEdge, RevealNode, Scene } from './usePropagation' -import type { - EdgeVisualState, - NodeVisualState, RelFlowEdge, StockFlowNode, -} from './types' +} from '#/types/graph' +import StockNode from './StockNode' +import RelEdge from './RelEdge' +import { usePropagation } from '#/hooks/graph/usePropagation' +import type { RevealEdge, RevealNode, Scene } from '#/hooks/graph/usePropagation' import './graph.css' // nodeTypes/edgeTypes는 반드시 컴포넌트 바깥(모듈 스코프)에 선언한다. diff --git a/src/components/graph/NetworkView.tsx b/src/components/graph/NetworkView.tsx index ba921d8..8d53375 100644 --- a/src/components/graph/NetworkView.tsx +++ b/src/components/graph/NetworkView.tsx @@ -1,8 +1,9 @@ import { useEffect, useMemo } from 'react' import { ReactFlowProvider } from '@xyflow/react' import { useAtom } from 'jotai' -import { highlightedNodeAtom, introPlayedAtom } from '#/lib/atoms' -import { useGraphQuery } from '#/lib/queries' +import { introPlayedAtom } from '#/shared/store/atoms' +import { highlightedNodeAtom } from '#/store/graph/atoms' +import { useGraphQuery } from '#/apis/graph/queries' import { GraphCanvas, buildAllScene, FULL_LAYOUT } from './GraphPanel' import type { Scene2 } from './GraphPanel' import './graph.css' diff --git a/src/components/graph/RelEdge.tsx b/src/components/graph/RelEdge.tsx index 8941a89..85778c4 100644 --- a/src/components/graph/RelEdge.tsx +++ b/src/components/graph/RelEdge.tsx @@ -6,13 +6,9 @@ import { useInternalNode, } from '@xyflow/react' import type { EdgeProps, InternalNode } from '@xyflow/react' -import { cn } from '#/lib/utils' -import type { RelFlowEdge, StockFlowNode } from './types' - -// graph.css의 .ep.draw 애니메이션(0.48s)과 맞춘 값. 실제 path 길이보다 넉넉하게 잡아 -// 어떤 엣지든 dashoffset을 0으로 보내면 완전히 그려진다. -const DASH_LEN = 1400 -const DRAW_DURATION_MS = 480 +import { cn } from '#/shared/utils/cn' +import type { RelFlowEdge, StockFlowNode } from '#/types/graph' +import { DASH_LEN, DRAW_DURATION_MS } from '#/constants/graph/edge' /** * 기본 엣지는 Handle 위치에 고정돼 방사형 배치에서 카드 옆구리에 어긋나 붙는다. diff --git a/src/components/graph/StockNode.tsx b/src/components/graph/StockNode.tsx index 6ec0ff7..349eeba 100644 --- a/src/components/graph/StockNode.tsx +++ b/src/components/graph/StockNode.tsx @@ -1,7 +1,7 @@ import { Handle, Position } from '@xyflow/react' import type { NodeProps } from '@xyflow/react' -import { cn } from '#/lib/utils' -import type { StockFlowNode } from './types' +import { cn } from '#/shared/utils/cn' +import type { StockFlowNode } from '#/types/graph' export default function StockNode({ data }: NodeProps) { return ( diff --git a/src/components/graph/types.ts b/src/components/graph/types.ts deleted file mode 100644 index 12b9d9c..0000000 --- a/src/components/graph/types.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Edge, Node } from '@xyflow/react' -import type { Direction } from '#/types' - -export type NodeVisualState = - | 'hide' - | 'idle' - | 'dim' - | 'via' - | 'via2' - | 'up' - | 'down' - | 'up2' - | 'down2' - | 'origin' - -export interface StockNodeData extends Record { - label: string - ticker?: string - abstract: boolean - tier?: 't1' | 't2' | 't3' - state: NodeVisualState - isMain?: boolean - badge?: Direction | null - pulseToken?: number -} - -export type StockFlowNode = Node - -export type EdgeVisualState = - 'hide' | 'idle' | 'dim' | 'near' | 'up' | 'down' | 'up2' | 'down2' - -export interface RelEdgeData extends Record { - relation: string - state: EdgeVisualState - draw?: boolean - /** draw가 true가 된 시각(performance.now()). 그리기 애니메이션의 진행률을 - * 경과 시간으로 계산해, 리렌더/리마운트가 일어나도 처음부터 다시 그려지지 - * 않고 진행 중이던 지점부터 이어 그리게 하는 데 쓰인다. */ - drawAt?: number -} - -export type RelFlowEdge = Edge diff --git a/src/components/news/MobileNewsBar.tsx b/src/components/news/MobileNewsBar.tsx index 42e5971..25f7f8f 100644 --- a/src/components/news/MobileNewsBar.tsx +++ b/src/components/news/MobileNewsBar.tsx @@ -1,7 +1,7 @@ import { useAtom } from 'jotai' -import { cn } from '#/lib/utils' -import { newsSheetOpenAtom, selectedNewsIdAtom } from '#/lib/atoms' -import { useNewsQuery } from '#/lib/queries' +import { cn } from '#/shared/utils/cn' +import { newsSheetOpenAtom, selectedNewsIdAtom } from '#/shared/store/atoms' +import { useNewsQuery } from '#/apis/news/queries' /** ≤1080px에서만 보이는 상단 현재뉴스 바 + 바텀시트 배경 스크림. CSS가 그 이상 폭에서는 숨긴다. */ export default function MobileNewsBar() { diff --git a/src/components/news/NewsCard.tsx b/src/components/news/NewsCard.tsx index beab3bb..7b76d35 100644 --- a/src/components/news/NewsCard.tsx +++ b/src/components/news/NewsCard.tsx @@ -1,10 +1,10 @@ import type { MouseEvent } from 'react' import { useAtom } from 'jotai' import { Star } from 'lucide-react' -import { cn } from '#/lib/utils' -import { formatRelativeTime } from '#/lib/format' -import { scrappedNewsIdsAtom } from '#/lib/atoms' -import type { NewsListItem } from '#/types' +import { cn } from '#/shared/utils/cn' +import { formatRelativeTime } from '#/shared/utils/format' +import { scrappedNewsIdsAtom } from '#/store/scrap/atoms' +import type { NewsListItem } from '#/types/news' interface NewsCardProps { news: NewsListItem diff --git a/src/components/news/NewsList.tsx b/src/components/news/NewsList.tsx index b9d704e..ef24522 100644 --- a/src/components/news/NewsList.tsx +++ b/src/components/news/NewsList.tsx @@ -1,12 +1,9 @@ import { useRef } from 'react' import { useAtom, useAtomValue, useSetAtom } from 'jotai' -import { - newlyAddedNewsIdsAtom, - newsSheetOpenAtom, - selectedNewsIdAtom, -} from '#/lib/atoms' -import { useNewsQuery } from '#/lib/queries' -import { useInfiniteScrollTrigger } from '#/lib/useInfiniteScrollTrigger' +import { newsSheetOpenAtom, selectedNewsIdAtom } from '#/shared/store/atoms' +import { newlyAddedNewsIdsAtom } from '#/store/news/atoms' +import { useNewsQuery } from '#/apis/news/queries' +import { useInfiniteScrollTrigger } from '#/shared/hooks/useInfiniteScrollTrigger' import NewsCard from './NewsCard' /** 뉴스 카드 목록. 데스크탑 패널과 모바일 바텀시트에서 함께 쓴다. */ diff --git a/src/components/news/NewsPanel.tsx b/src/components/news/NewsPanel.tsx index de46c19..d2adbb2 100644 --- a/src/components/news/NewsPanel.tsx +++ b/src/components/news/NewsPanel.tsx @@ -2,10 +2,11 @@ import { useEffect, useRef, useState } from 'react' import { useIsFetching, useQueryClient } from '@tanstack/react-query' import { useAtom, useSetAtom } from 'jotai' import { RefreshCw } from 'lucide-react' -import { cn } from '#/lib/utils' -import { newlyAddedNewsIdsAtom, newsSheetOpenAtom } from '#/lib/atoms' -import { refreshNews } from '#/lib/api' -import { queryKeys } from '#/lib/queries' +import { cn } from '#/shared/utils/cn' +import { newsSheetOpenAtom } from '#/shared/store/atoms' +import { newlyAddedNewsIdsAtom } from '#/store/news/atoms' +import { refreshNews } from '#/apis/news/mock' +import { newsKeys } from '#/apis/news/queries' import NewsList from './NewsList' const REFRESH_TOAST_MS = 4500 @@ -19,7 +20,7 @@ export default function NewsPanel() { const [sheetOpen, setSheetOpen] = useAtom(newsSheetOpenAtom) const setNewlyAdded = useSetAtom(newlyAddedNewsIdsAtom) const queryClient = useQueryClient() - const isRefreshing = useIsFetching({ queryKey: queryKeys.news() }) > 0 + const isRefreshing = useIsFetching({ queryKey: newsKeys.all() }) > 0 const [toastCount, setToastCount] = useState(null) const toastTimerRef = useRef() @@ -30,7 +31,7 @@ export default function NewsPanel() { // 첫 페이지부터 다시 불러온다 — 스크롤로 더 불러온 뒷페이지는 새로고침 시 // 버리고 최신순 1페이지로 되돌아간다(실제 뉴스 피드 새로고침과 동일한 동작). const { addedIds } = refreshNews() - await queryClient.resetQueries({ queryKey: queryKeys.news() }) + await queryClient.resetQueries({ queryKey: newsKeys.all() }) setNewlyAdded(addedIds) setToastCount(addedIds.length) diff --git a/src/components/scrap/ScrapSheet.tsx b/src/components/scrap/ScrapSheet.tsx index 3d786e3..5b60466 100644 --- a/src/components/scrap/ScrapSheet.tsx +++ b/src/components/scrap/ScrapSheet.tsx @@ -1,8 +1,9 @@ import { useState } from 'react' import { useAtom, useSetAtom } from 'jotai' -import { scrappedNewsIdsAtom, selectedNewsIdAtom, viewAtom } from '#/lib/atoms' -import { useNewsQuery } from '#/lib/queries' -import { formatRelativeTime } from '#/lib/format' +import { selectedNewsIdAtom, viewAtom } from '#/shared/store/atoms' +import { scrappedNewsIdsAtom } from '#/store/scrap/atoms' +import { useNewsQuery } from '#/apis/news/queries' +import { formatRelativeTime } from '#/shared/utils/format' import { Sheet, SheetContent, diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index f2b893e..8f32d23 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -3,7 +3,7 @@ import { cva } from 'class-variance-authority' import type { VariantProps } from 'class-variance-authority' import { Slot } from 'radix-ui' -import { cn } from '#/lib/utils.ts' +import { cn } from '#/shared/utils/cn' const badgeVariants = cva( 'inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3', diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index b3ea021..9aa8ddd 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -3,7 +3,7 @@ import { cva } from 'class-variance-authority' import type { VariantProps } from 'class-variance-authority' import { Slot } from 'radix-ui' -import { cn } from '#/lib/utils.ts' +import { cn } from '#/shared/utils/cn' const buttonVariants = cva( "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx index 4c7a5ae..412d42f 100644 --- a/src/components/ui/scroll-area.tsx +++ b/src/components/ui/scroll-area.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui' -import { cn } from '#/lib/utils.ts' +import { cn } from '#/shared/utils/cn' function ScrollArea({ className, diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx index 275bb8d..04fc90f 100644 --- a/src/components/ui/separator.tsx +++ b/src/components/ui/separator.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import { Separator as SeparatorPrimitive } from 'radix-ui' -import { cn } from '#/lib/utils.ts' +import { cn } from '#/shared/utils/cn' function Separator({ className, diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index af0dbd3..947279b 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { XIcon } from 'lucide-react' import { Dialog as SheetPrimitive } from 'radix-ui' -import { cn } from '#/lib/utils.ts' +import { cn } from '#/shared/utils/cn' function Sheet({ ...props }: React.ComponentProps) { return diff --git a/src/components/verify/VerifyDetail.tsx b/src/components/verify/VerifyDetail.tsx index 0885ff5..c427152 100644 --- a/src/components/verify/VerifyDetail.tsx +++ b/src/components/verify/VerifyDetail.tsx @@ -1,5 +1,5 @@ -import { arrow, directionLabel } from '#/lib/format' -import type { VerifyEntry } from '#/types' +import { arrow, directionLabel } from '#/shared/utils/format' +import type { VerifyEntry } from '#/types/verify' import { hitCount } from './VerifyList' /** 선택한 검증 뉴스의 적중률 도넛 + 종목별 예측 vs 실제 표 */ diff --git a/src/components/verify/VerifyList.tsx b/src/components/verify/VerifyList.tsx index 551d642..6c8c40e 100644 --- a/src/components/verify/VerifyList.tsx +++ b/src/components/verify/VerifyList.tsx @@ -1,8 +1,7 @@ import { useRef } from 'react' -import { useInfiniteScrollTrigger } from '#/lib/useInfiniteScrollTrigger' -import type { VerifyEntry } from '#/types' - -const SECTORS = ['전체', '반도체', '2차전지', '방산'] +import { useInfiniteScrollTrigger } from '#/shared/hooks/useInfiniteScrollTrigger' +import type { VerifyEntry } from '#/types/verify' +import { SECTORS } from '#/constants/verify/sectors' export function hitCount(entry: VerifyEntry): number { return entry.items.filter((i) => i.hit).length diff --git a/src/components/verify/VerifyView.tsx b/src/components/verify/VerifyView.tsx index 2a96cec..53efb95 100644 --- a/src/components/verify/VerifyView.tsx +++ b/src/components/verify/VerifyView.tsx @@ -1,8 +1,8 @@ import { useMemo, useState } from 'react' import { useAtom } from 'jotai' -import { verifyContextAtom } from '#/lib/atoms' -import { useVerifyQuery } from '#/lib/queries' -import type { VerifyDaily } from '#/types' +import { verifyContextAtom } from '#/store/verify/atoms' +import { useVerifyQuery } from '#/apis/verify/queries' +import type { VerifyDaily } from '#/types/verify' import VerifyList, { hitCount } from './VerifyList' import VerifyDetail from './VerifyDetail' diff --git a/src/constants/graph/edge.ts b/src/constants/graph/edge.ts new file mode 100644 index 0000000..07c3118 --- /dev/null +++ b/src/constants/graph/edge.ts @@ -0,0 +1,4 @@ +// graph.css의 .ep.draw 애니메이션(0.48s)과 맞춘 값. 실제 path 길이보다 넉넉하게 잡아 +// 어떤 엣지든 dashoffset을 0으로 보내면 완전히 그려진다. +export const DASH_LEN = 1400 +export const DRAW_DURATION_MS = 480 diff --git a/src/constants/graph/layout.ts b/src/constants/graph/layout.ts new file mode 100644 index 0000000..e40a899 --- /dev/null +++ b/src/constants/graph/layout.ts @@ -0,0 +1,7 @@ +import type { LayoutPoint } from '#/utils/graph/layout' + +export const RADIUS = [0, 300, 520, 700] + +export const SECTOR_CENTERS: Record = { + 반도체: { x: 0, y: 0 }, +} diff --git a/src/constants/verify/sectors.ts b/src/constants/verify/sectors.ts new file mode 100644 index 0000000..535395b --- /dev/null +++ b/src/constants/verify/sectors.ts @@ -0,0 +1 @@ +export const SECTORS = ['전체', '반도체', '2차전지', '방산'] diff --git a/src/components/graph/usePropagation.ts b/src/hooks/graph/usePropagation.ts similarity index 97% rename from src/components/graph/usePropagation.ts rename to src/hooks/graph/usePropagation.ts index 2cfdc19..ac28dc5 100644 --- a/src/components/graph/usePropagation.ts +++ b/src/hooks/graph/usePropagation.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' -import type { Direction } from '#/types' -import type { EdgeVisualState, NodeVisualState } from './types' +import type { Direction } from '#/shared/types' +import type { EdgeVisualState, NodeVisualState } from '#/types/graph' /** * 뉴스 파급/노드 기점 탐색/전체 관계망 제자리 강조가 공유하는 하이라이트 diff --git a/src/lib/atoms.ts b/src/lib/atoms.ts deleted file mode 100644 index a7b4829..0000000 --- a/src/lib/atoms.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { atom } from 'jotai' - -/** - * UI 상태와 관심종목 메모리만 담당하는 Jotai atom 모음. - * 뉴스/그래프/분석/검증 같은 서버성 데이터는 여기 넣지 않는다 — lib/api.ts에서 직접 읽는다. - * 세션 메모리 전용이며 localStorage 등 영속 저장은 쓰지 않는다. - */ - -export const viewAtom = atom<'network' | 'map' | 'verify'>('network') - -export const selectedNewsIdAtom = atom(null) - -/** 전체 관계망에서 배치를 유지한 채 특정 노드 기점으로 제자리 강조할 때의 대상 */ -export const highlightedNodeAtom = atom(null) - -/** 커버 화면 → /app 진입 시 전체 관계망 인트로(자동 하이라이트+줌)를 재생했는지 — 세션당 1회만 */ -export const introPlayedAtom = atom(false) - -export const verifyContextAtom = atom<{ - label: string - names: string[] -} | null>(null) - -/** 모바일 바텀시트(≤1080px) 개폐 */ -export const newsSheetOpenAtom = atom(false) - -/** 스크랩(저장) 뉴스 id 목록 — 메모리 전용, 새로고침하면 사라진다 */ -export const scrappedNewsIdsAtom = atom([]) - -/** "최신 뉴스" 새로고침으로 방금 추가된 뉴스 id 목록 — NEW 배지 표시용, 세션 메모리 전용 */ -export const newlyAddedNewsIdsAtom = atom([]) diff --git a/src/lib/queries.ts b/src/lib/queries.ts deleted file mode 100644 index 1d08d19..0000000 --- a/src/lib/queries.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { useInfiniteQuery, useQuery } from '@tanstack/react-query' -import { http } from '#/lib/http' -import { getGraph, getNews, getNewsImpact, getVerify } from '#/lib/api' -import { mapNewsImpact, mapNewsListPage } from '#/lib/mappers' -import type { NewsImpactWire, NewsListPageWire } from '#/lib/mappers' -import type { - NewsImpact, - NewsListPage, - OntologyEdge, - OntologyNode, - VerifyResponse, -} from '#/types' - -/** - * docs/KOSLINK-FRONTEND.md §5 API 명세에 맞춘 fetch 계층 + TanStack Query 훅. - * 각 훅의 placeholderData는 lib/api.ts의 동기 함수(§11 데모 안전장치)를 lib/mappers.ts로 - * 한 번 더 거쳐 반환한다 — MSW 핸들러도 같은 함수를 응답 생성에 쓰므로 두 경로가 항상 - * 같은 데이터를 본다. - */ - -export const queryKeys = { - news: () => ['news'] as const, - newsImpact: (newsId: number) => ['news', newsId, 'impact'] as const, - graph: () => ['graph'] as const, - verify: (sector: string) => ['verify', sector] as const, -} - -/** GET /api/verify가 쓰는 sector/cursor 쿼리스트링. */ -function pageSearchParams(sector: string, cursor?: string) { - const searchParams: Record = {} - if (sector && sector !== '전체') searchParams.sector = sector - if (cursor) searchParams.cursor = cursor - return searchParams -} - -async function fetchNews(cursor?: string): Promise { - const searchParams: Record = {} - if (cursor) searchParams.cursor = cursor - const wire = await http - .get('news', { searchParams }) - .json() - return mapNewsListPage(wire) -} - -async function fetchNewsImpact(newsId: number): Promise { - const wire = await http.get(`news/${newsId}/impact`).json() - return mapNewsImpact(newsId, wire) -} - -async function fetchGraph(): Promise<{ - nodes: OntologyNode[] - edges: OntologyEdge[] -}> { - return http.get('graph', { searchParams: { mode: 'full' } }).json() -} - -async function fetchVerify( - sector: string, - cursor?: string, -): Promise { - const searchParams = pageSearchParams(sector, cursor) - return http.get('verify', { searchParams }).json() -} - -/** 뉴스 목록 — 무한 스크롤용 커서 기반 페이징. `data.pages.flatMap(p => p.items)`로 펼쳐 쓴다. */ -export function useNewsQuery() { - return useInfiniteQuery({ - queryKey: queryKeys.news(), - queryFn: ({ pageParam }) => fetchNews(pageParam), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - placeholderData: () => ({ - pages: [mapNewsListPage(getNews())], - pageParams: [undefined], - }), - }) -} - -/** 뉴스 영향 분석 + 파급 경로 그래프 — 분석 패널과 그래프 패널이 같은 쿼리 키를 공유해 한 번만 조회한다. */ -export function useNewsImpactQuery(newsId: number | null) { - return useQuery({ - queryKey: queryKeys.newsImpact(newsId ?? -1), - queryFn: () => fetchNewsImpact(newsId as number), - enabled: newsId != null, - placeholderData: () => { - if (newsId == null) return undefined - const wire = getNewsImpact(newsId) - return wire ? mapNewsImpact(newsId, wire) : undefined - }, - }) -} - -export function useGraphQuery() { - return useQuery({ - queryKey: queryKeys.graph(), - queryFn: fetchGraph, - placeholderData: () => getGraph({ mode: 'full' }), - staleTime: Infinity, - }) -} - -/** 검증 목록 — 무한 스크롤용 커서 기반 페이징. `data.pages.flatMap(p => p.news)`로 펼쳐 쓴다. */ -export function useVerifyQuery(sector: string) { - return useInfiniteQuery({ - queryKey: queryKeys.verify(sector), - queryFn: ({ pageParam }) => fetchVerify(sector, pageParam), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - placeholderData: () => ({ - pages: [getVerify({ sector })], - pageParams: [undefined], - }), - }) -} diff --git a/src/main.tsx b/src/main.tsx index c32c73f..5556d3d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -13,7 +13,7 @@ import './styles.css' */ async function enableMocking() { if (import.meta.env.VITE_API_BASE_URL) return - const { worker } = await import('#/mocks/browser') + const { worker } = await import('#/shared/mocks/browser') await worker.start({ onUnhandledRequest: 'bypass' }) } diff --git a/src/mocks/analysis/handlers.ts b/src/mocks/analysis/handlers.ts new file mode 100644 index 0000000..f27f060 --- /dev/null +++ b/src/mocks/analysis/handlers.ts @@ -0,0 +1,13 @@ +import { HttpResponse, delay, http } from 'msw' +import { getNewsImpact } from '#/apis/analysis/mock' + +export const analysisHandlers = [ + http.get('/api/news/:id/impact', async ({ params }) => { + await delay(350) + const impact = getNewsImpact(Number(params.id)) + if (!impact) { + return new HttpResponse(null, { status: 404 }) + } + return HttpResponse.json(impact) + }), +] diff --git a/src/mocks/graph/data.ts b/src/mocks/graph/data.ts new file mode 100644 index 0000000..46a87c3 --- /dev/null +++ b/src/mocks/graph/data.ts @@ -0,0 +1,187 @@ +import type { OntologyEdge, OntologyNode, RelationType } from '#/types/graph' + +/** + * docs/koslink.html의 정적 데모 데이터를 포팅 + 확장한 것. + * 백엔드가 없는 지금은 apis/graph/mock.ts가 이 파일을 동기적으로 읽어 응답 모양을 만든다. + * 실 API가 준비되면 apis/graph/mock.ts 내부만 fetch로 바꾸면 되고, 이 파일과 컴포넌트는 + * 그대로 유지할 수 있다. + */ + +// 회사(COMPANY): [id, 이름, 티커, 섹터, 시가총액(억원)] +const RAW_COMPANIES: [string, string, string, string, number][] = [ + ['sk', 'SK하이닉스', '000660', '반도체', 1200000], + ['ss', '삼성전자', '005930', '반도체', 4600000], + ['hanmi', '한미반도체', '042700', '반도체', 120000], + ['eo', '이오테크닉스', '039030', '반도체', 42000], + ['jusung', '주성엔지니어링', '036930', '반도체', 18000], + ['wonik', '원익IPS', '240810', '반도체', 22000], + ['hpsp', 'HPSP', '403870', '반도체', 26000], + ['lino', '리노공업', '058470', '반도체', 32000], + ['tck', '티씨케이', '064760', '반도체', 12000], + ['dbh', 'DB하이텍', '000990', '반도체', 16000], + ['solb', '솔브레인', '357780', '반도체', 19000], + ['dj', '동진쎄미켐', '005290', '반도체', 15000], + // 후공정·패키징 + ['nepes', '네패스', '033640', '반도체', 8000], + ['hanamc', '하나마이크론', '067310', '반도체', 13000], + ['sfa', 'SFA반도체', '036540', '반도체', 9000], + ['simmtech', '심텍', '222800', '반도체', 14000], + ['haesungds', '해성디에스', '195870', '반도체', 8000], + ['daeduck', '대덕전자', '353200', '반도체', 9000], + // 테스트·검사 + ['unitest', '유니테스트', '086390', '반도체', 7000], + ['testna', '테스나', '131290', '반도체', 6000], + ['exicon', '엑시콘', '092870', '반도체', 3500], + ['isc', 'ISC', '095340', '반도체', 14000], + ['nextin', '넥스틴', '348340', '반도체', 9000], + ['park', '파크시스템스', '140860', '반도체', 17000], + // 장비 + ['wonikqnc', '원익QnC', '074600', '반도체', 11000], + ['kctech', '케이씨텍', '281820', '반도체', 15000], + ['psk', '피에스케이', '319660', '반도체', 18000], + ['eugene', '유진테크', '084370', '반도체', 16000], + ['comico', '코미코', '183300', '반도체', 12000], + ['miraecom', '미래컴퍼니', '049950', '반도체', 7000], + ['protec', '프로텍', '053610', '반도체', 4000], + ['yest', '예스티', '122640', '반도체', 3000], + // 소재·가스 + ['hansolchem', '한솔케미칼', '014680', '반도체', 28000], + ['foosung', '후성', '093370', '반도체', 20000], + ['enf', '이엔에프테크놀로지', '102710', '반도체', 10000], + ['dnf', '디엔에프', '092070', '반도체', 5000], + // 팹리스 + ['anapass', '아나패스', '123860', '반도체', 4000], + ['telechips', '텔레칩스', '054450', '반도체', 6000], + ['abov', '어보브반도체', '102120', '반도체', 3000], +] + +// 개념 노드(실제 온톨로지의 Role/Theme — 프론트는 거래 불가 노드로만 구분하면 되므로 CONCEPT로 통칭): [id, 이름, 섹터] +const RAW_CONCEPTS: [string, string, string][] = [ + ['hbm', 'HBM', '반도체'], + ['dram', 'D램', '반도체'], + ['fdry', '파운드리', '반도체'], + ['aidc', 'AI 데이터센터', '반도체'], + ['adpkg', '삼성전자', '반도체'], + ['glass', '유리기판', '반도체'], + ['euv', 'EUV', '반도체'], + ['ondevice', '온디바이스AI', '반도체'], + ['cxl', 'CXL', '반도체'], + ['wafermat', '웨이퍼소재', '반도체'], + ['specgas', '특수가스', '반도체'], + ['testinsp', '테스트·검사', '반도체'], +] + +export const ONTOLOGY_NODES: OntologyNode[] = [ + ...RAW_COMPANIES.map( + ([id, name, ticker, sector, marketCap]): OntologyNode => ({ + id, + name, + kind: 'STOCK', + ticker, + sector, + marketCap, + }), + ), + ...RAW_CONCEPTS.map(([id, name, sector]): OntologyNode => ({ + id, + name, + kind: 'CONCEPT', + sector, + })), +] + +// [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', '수요 견인'], + // 첨단패키징 + ['nepes', 'adpkg', '첨단패키징 후공정'], + ['hanamc', 'adpkg', '첨단패키징 후공정'], + ['sfa', 'adpkg', '첨단패키징 후공정'], + ['simmtech', 'adpkg', '반도체 기판 공급'], + ['haesungds', 'adpkg', '리드프레임 공급'], + ['daeduck', 'adpkg', '반도체 기판 공급'], + ['adpkg', 'sk', 'HBM 패키징 공급'], + ['adpkg', 'hbm', '패키징 필수 공정'], + ['nepes', 'hanamc', '경쟁', 'COMPETITOR'], + ['sfa', 'hanamc', '경쟁', 'COMPETITOR'], + // 유리기판 + ['glass', 'simmtech', '유리기판 개발'], + ['glass', 'daeduck', '유리기판 개발'], + ['glass', 'aidc', '차세대 패키징 수요'], + // EUV + ['ss', 'euv', '선단공정 노광 도입'], + ['euv', 'fdry', '미세공정 필수'], + ['park', 'euv', '계측 장비 공급'], + // 테스트·검사 + ['unitest', 'testinsp', '테스트 장비 공급'], + ['testna', 'testinsp', '테스트 서비스'], + ['exicon', 'testinsp', '테스트 장비 공급'], + ['nextin', 'testinsp', '웨이퍼 검사 장비 공급'], + ['park', 'testinsp', '계측 장비 공급'], + ['isc', 'testinsp', '테스트 소켓 공급'], + ['testinsp', 'hbm', 'HBM 검증 필수'], + ['unitest', 'exicon', '경쟁', 'COMPETITOR'], + ['unitest', 'testna', '경쟁', 'COMPETITOR'], + // 소재·가스 + ['hansolchem', 'wafermat', '웨이퍼 공정 소재 공급'], + ['enf', 'wafermat', '포토레지스트 공급'], + ['dnf', 'wafermat', '전구체 공급'], + ['wafermat', 'ss', '공정 소재 조달'], + ['foosung', 'specgas', '특수가스 공급'], + ['specgas', 'sk', '증착 공정 필수 가스'], + ['specgas', 'fdry', '파운드리 공정 필수'], + ['enf', 'dnf', '경쟁', 'COMPETITOR'], + // 장비 + ['kctech', 'ss', 'CMP 장비 공급'], + ['psk', 'sk', '식각 장비 공급'], + ['eugene', 'sk', '증착 장비 공급'], + ['wonikqnc', 'sk', '쿼츠 부품 공급'], + ['comico', 'sk', '부품 세정 코팅 공급'], + ['miraecom', 'ss', '반도체 장비 공급'], + ['protec', 'hanmi', '본딩 장비 공급'], + ['yest', 'sk', '열처리 장비 공급'], + ['eugene', 'wonik', '경쟁', 'COMPETITOR'], + ['psk', 'jusung', '경쟁', 'COMPETITOR'], + ['comico', 'tck', '경쟁', 'COMPETITOR'], + // 팹리스 · 온디바이스AI · CXL + ['anapass', 'ondevice', '온디바이스 AI 구동칩 개발'], + ['telechips', 'ondevice', '차량용 온디바이스 AI 칩 개발'], + ['abov', 'ondevice', 'MCU 공급'], + ['ondevice', 'aidc', 'AI 반도체 수요 확산'], + ['anapass', 'telechips', '경쟁', 'COMPETITOR'], + ['telechips', 'abov', '경쟁', 'COMPETITOR'], + ['cxl', 'ss', '차세대 메모리 인터페이스 개발'], + ['cxl', 'sk', '차세대 메모리 인터페이스 개발'], + ['cxl', 'dram', '메모리 확장 표준'], +] + +export const ONTOLOGY_EDGES: OntologyEdge[] = RAW_EDGES.map( + ([source, target, relation, relationType], i) => ({ + id: `e${i}`, + source, + target, + relation, + relationType, + }), +) diff --git a/src/mocks/graph/handlers.ts b/src/mocks/graph/handlers.ts new file mode 100644 index 0000000..68f6135 --- /dev/null +++ b/src/mocks/graph/handlers.ts @@ -0,0 +1,9 @@ +import { HttpResponse, delay, http } from 'msw' +import { getGraph } from '#/apis/graph/mock' + +export const graphHandlers = [ + http.get('/api/graph', async () => { + await delay(250) + return HttpResponse.json(getGraph({ mode: 'full' })) + }), +] diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts deleted file mode 100644 index 00ee775..0000000 --- a/src/mocks/handlers.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { HttpResponse, delay, http } from 'msw' -import { getGraph, getNews, getNewsImpact, getVerify } from '#/lib/api' - -/** - * docs/KOSLINK-FRONTEND.md §5 API 명세와 동일한 JSON 모양으로 응답한다. - * 응답 바디는 lib/api.ts의 기존 동기 함수를 그대로 호출해 만든다 — 이 "서버"와 - * TanStack Query의 placeholderData가 항상 같은 데이터 소스(lib/data.ts)를 본다. - */ - -export const handlers = [ - http.get('/api/news', async ({ request }) => { - await delay(300) - const searchParams = new URL(request.url).searchParams - return HttpResponse.json( - getNews({ - cursor: searchParams.get('cursor') ?? undefined, - limit: Number(searchParams.get('limit')) || undefined, - }), - ) - }), - - http.get('/api/news/:id/impact', async ({ params }) => { - await delay(350) - const impact = getNewsImpact(Number(params.id)) - if (!impact) { - return new HttpResponse(null, { status: 404 }) - } - return HttpResponse.json(impact) - }), - - http.get('/api/graph', async () => { - await delay(250) - return HttpResponse.json(getGraph({ mode: 'full' })) - }), - - http.get('/api/verify', async ({ request }) => { - await delay(300) - const searchParams = new URL(request.url).searchParams - return HttpResponse.json( - getVerify({ - sector: searchParams.get('sector') ?? undefined, - cursor: searchParams.get('cursor') ?? undefined, - limit: Number(searchParams.get('limit')) || undefined, - }), - ) - }), -] diff --git a/src/lib/data.ts b/src/mocks/news/data.ts similarity index 58% rename from src/lib/data.ts rename to src/mocks/news/data.ts index b05188d..d76685c 100644 --- a/src/lib/data.ts +++ b/src/mocks/news/data.ts @@ -1,202 +1,10 @@ -import type { - Direction, - OntologyEdge, - OntologyNode, - RelationType, - VerifyDaily, - VerifyEntry, -} from '#/types' - -/** - * docs/koslink.html의 정적 데모 데이터를 포팅 + 확장한 것. - * 백엔드가 없는 지금은 lib/api.ts가 이 파일을 동기적으로 읽어 응답 모양을 만든다. - * 실 API가 준비되면 lib/api.ts 내부만 fetch로 바꾸면 되고, 이 파일과 컴포넌트는 - * 그대로 유지할 수 있다. - */ - -// 회사(COMPANY): [id, 이름, 티커, 섹터, 시가총액(억원)] -const RAW_COMPANIES: [string, string, string, string, number][] = [ - ['sk', 'SK하이닉스', '000660', '반도체', 1200000], - ['ss', '삼성전자', '005930', '반도체', 4600000], - ['hanmi', '한미반도체', '042700', '반도체', 120000], - ['eo', '이오테크닉스', '039030', '반도체', 42000], - ['jusung', '주성엔지니어링', '036930', '반도체', 18000], - ['wonik', '원익IPS', '240810', '반도체', 22000], - ['hpsp', 'HPSP', '403870', '반도체', 26000], - ['lino', '리노공업', '058470', '반도체', 32000], - ['tck', '티씨케이', '064760', '반도체', 12000], - ['dbh', 'DB하이텍', '000990', '반도체', 16000], - ['solb', '솔브레인', '357780', '반도체', 19000], - ['dj', '동진쎄미켐', '005290', '반도체', 15000], - // 후공정·패키징 - ['nepes', '네패스', '033640', '반도체', 8000], - ['hanamc', '하나마이크론', '067310', '반도체', 13000], - ['sfa', 'SFA반도체', '036540', '반도체', 9000], - ['simmtech', '심텍', '222800', '반도체', 14000], - ['haesungds', '해성디에스', '195870', '반도체', 8000], - ['daeduck', '대덕전자', '353200', '반도체', 9000], - // 테스트·검사 - ['unitest', '유니테스트', '086390', '반도체', 7000], - ['testna', '테스나', '131290', '반도체', 6000], - ['exicon', '엑시콘', '092870', '반도체', 3500], - ['isc', 'ISC', '095340', '반도체', 14000], - ['nextin', '넥스틴', '348340', '반도체', 9000], - ['park', '파크시스템스', '140860', '반도체', 17000], - // 장비 - ['wonikqnc', '원익QnC', '074600', '반도체', 11000], - ['kctech', '케이씨텍', '281820', '반도체', 15000], - ['psk', '피에스케이', '319660', '반도체', 18000], - ['eugene', '유진테크', '084370', '반도체', 16000], - ['comico', '코미코', '183300', '반도체', 12000], - ['miraecom', '미래컴퍼니', '049950', '반도체', 7000], - ['protec', '프로텍', '053610', '반도체', 4000], - ['yest', '예스티', '122640', '반도체', 3000], - // 소재·가스 - ['hansolchem', '한솔케미칼', '014680', '반도체', 28000], - ['foosung', '후성', '093370', '반도체', 20000], - ['enf', '이엔에프테크놀로지', '102710', '반도체', 10000], - ['dnf', '디엔에프', '092070', '반도체', 5000], - // 팹리스 - ['anapass', '아나패스', '123860', '반도체', 4000], - ['telechips', '텔레칩스', '054450', '반도체', 6000], - ['abov', '어보브반도체', '102120', '반도체', 3000], -] - -// 개념 노드(실제 온톨로지의 Role/Theme — 프론트는 거래 불가 노드로만 구분하면 되므로 CONCEPT로 통칭): [id, 이름, 섹터] -const RAW_CONCEPTS: [string, string, string][] = [ - ['hbm', 'HBM', '반도체'], - ['dram', 'D램', '반도체'], - ['fdry', '파운드리', '반도체'], - ['aidc', 'AI 데이터센터', '반도체'], - ['adpkg', '삼성전자', '반도체'], - ['glass', '유리기판', '반도체'], - ['euv', 'EUV', '반도체'], - ['ondevice', '온디바이스AI', '반도체'], - ['cxl', 'CXL', '반도체'], - ['wafermat', '웨이퍼소재', '반도체'], - ['specgas', '특수가스', '반도체'], - ['testinsp', '테스트·검사', '반도체'], -] - -export const ONTOLOGY_NODES: OntologyNode[] = [ - ...RAW_COMPANIES.map( - ([id, name, ticker, sector, marketCap]): OntologyNode => ({ - id, - name, - kind: 'STOCK', - ticker, - sector, - marketCap, - }), - ), - ...RAW_CONCEPTS.map(([id, name, sector]): OntologyNode => ({ - id, - name, - kind: 'CONCEPT', - sector, - })), -] - -// [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', '수요 견인'], - // 첨단패키징 - ['nepes', 'adpkg', '첨단패키징 후공정'], - ['hanamc', 'adpkg', '첨단패키징 후공정'], - ['sfa', 'adpkg', '첨단패키징 후공정'], - ['simmtech', 'adpkg', '반도체 기판 공급'], - ['haesungds', 'adpkg', '리드프레임 공급'], - ['daeduck', 'adpkg', '반도체 기판 공급'], - ['adpkg', 'sk', 'HBM 패키징 공급'], - ['adpkg', 'hbm', '패키징 필수 공정'], - ['nepes', 'hanamc', '경쟁', 'COMPETITOR'], - ['sfa', 'hanamc', '경쟁', 'COMPETITOR'], - // 유리기판 - ['glass', 'simmtech', '유리기판 개발'], - ['glass', 'daeduck', '유리기판 개발'], - ['glass', 'aidc', '차세대 패키징 수요'], - // EUV - ['ss', 'euv', '선단공정 노광 도입'], - ['euv', 'fdry', '미세공정 필수'], - ['park', 'euv', '계측 장비 공급'], - // 테스트·검사 - ['unitest', 'testinsp', '테스트 장비 공급'], - ['testna', 'testinsp', '테스트 서비스'], - ['exicon', 'testinsp', '테스트 장비 공급'], - ['nextin', 'testinsp', '웨이퍼 검사 장비 공급'], - ['park', 'testinsp', '계측 장비 공급'], - ['isc', 'testinsp', '테스트 소켓 공급'], - ['testinsp', 'hbm', 'HBM 검증 필수'], - ['unitest', 'exicon', '경쟁', 'COMPETITOR'], - ['unitest', 'testna', '경쟁', 'COMPETITOR'], - // 소재·가스 - ['hansolchem', 'wafermat', '웨이퍼 공정 소재 공급'], - ['enf', 'wafermat', '포토레지스트 공급'], - ['dnf', 'wafermat', '전구체 공급'], - ['wafermat', 'ss', '공정 소재 조달'], - ['foosung', 'specgas', '특수가스 공급'], - ['specgas', 'sk', '증착 공정 필수 가스'], - ['specgas', 'fdry', '파운드리 공정 필수'], - ['enf', 'dnf', '경쟁', 'COMPETITOR'], - // 장비 - ['kctech', 'ss', 'CMP 장비 공급'], - ['psk', 'sk', '식각 장비 공급'], - ['eugene', 'sk', '증착 장비 공급'], - ['wonikqnc', 'sk', '쿼츠 부품 공급'], - ['comico', 'sk', '부품 세정 코팅 공급'], - ['miraecom', 'ss', '반도체 장비 공급'], - ['protec', 'hanmi', '본딩 장비 공급'], - ['yest', 'sk', '열처리 장비 공급'], - ['eugene', 'wonik', '경쟁', 'COMPETITOR'], - ['psk', 'jusung', '경쟁', 'COMPETITOR'], - ['comico', 'tck', '경쟁', 'COMPETITOR'], - // 팹리스 · 온디바이스AI · CXL - ['anapass', 'ondevice', '온디바이스 AI 구동칩 개발'], - ['telechips', 'ondevice', '차량용 온디바이스 AI 칩 개발'], - ['abov', 'ondevice', 'MCU 공급'], - ['ondevice', 'aidc', 'AI 반도체 수요 확산'], - ['anapass', 'telechips', '경쟁', 'COMPETITOR'], - ['telechips', 'abov', '경쟁', 'COMPETITOR'], - ['cxl', 'ss', '차세대 메모리 인터페이스 개발'], - ['cxl', 'sk', '차세대 메모리 인터페이스 개발'], - ['cxl', 'dram', '메모리 확장 표준'], -] - -export const ONTOLOGY_EDGES: OntologyEdge[] = RAW_EDGES.map( - ([source, target, relation, relationType], i) => ({ - id: `e${i}`, - source, - target, - relation, - relationType, - }), -) +import { ONTOLOGY_EDGES, ONTOLOGY_NODES } from '#/mocks/graph/data' +import type { Direction } from '#/shared/types' /** * 뉴스 목록/impact 응답을 만들기 위한 내부 표현. * `chain`은 파급 경로 그래프(노드/엣지) 산출에만 쓰고, 응답의 relation_path/propagation - * 텍스트는 lib/api.ts가 이 체인에서 템플릿으로 생성한다(§5.2 설계 노트와 동일한 방식). + * 텍스트는 apis/analysis/mock.ts가 이 체인에서 템플릿으로 생성한다. */ export interface NewsRecord { id: number @@ -971,11 +779,12 @@ export function pullRefreshBatch(): NewsRecord[] { return items } -const STOCK_IDS = RAW_COMPANIES.map(([id]) => id) -const nodeNameById = new Map([ - ...RAW_COMPANIES.map(([id, name]): [string, string] => [id, name]), - ...RAW_CONCEPTS.map(([id, name]): [string, string] => [id, name]), -]) +const STOCK_IDS = ONTOLOGY_NODES.filter((n) => n.kind === 'STOCK').map( + (n) => n.id, +) +const nodeNameById = new Map( + ONTOLOGY_NODES.map((n): [string, string] => [n.id, n.name]), +) const FILLER_TEMPLATES: { headline: (name: string) => string @@ -1107,443 +916,3 @@ export const NEWS_RECORDS: NewsRecord[] = [ ...CURATED_NEWS, ...buildFillerNews(51, 10010), ] - -export const VERIFY_ENTRIES: VerifyEntry[] = [ - { - newsId: 'v1', - date: '07-17', - sector: '반도체', - title: 'SK하이닉스, 엔비디아향 HBM 공급 계약 확대', - items: [ - { - name: '한미반도체', - predicted: 'UP', - actualReturn: 3.42, - hit: true, - pathLabel: '장비 공급 · 1단계', - }, - { - name: '삼성전자', - predicted: 'DOWN', - actualReturn: -0.81, - hit: true, - pathLabel: '경쟁 관계 · 1단계', - }, - { - name: 'HPSP', - predicted: 'UP', - actualReturn: 1.94, - hit: true, - pathLabel: '고압 어닐링 장비 · 1단계', - }, - { - name: '원익IPS', - predicted: 'UP', - actualReturn: -0.35, - hit: false, - pathLabel: '증착 장비 · 1단계', - }, - ], - }, - { - newsId: 'v2', - date: '07-17', - sector: '2차전지', - title: '유럽 배터리 규제 완화 논의 시작', - items: [ - { - name: 'LG에너지솔루션', - predicted: 'UP', - actualReturn: 1.62, - hit: true, - pathLabel: '뉴스 직접 언급', - }, - { - name: '삼성SDI', - predicted: 'UP', - actualReturn: 0.94, - hit: true, - pathLabel: '전기차 배터리 공급 · 2단계', - }, - { - name: 'SK이노베이션', - predicted: 'UP', - actualReturn: -0.42, - hit: false, - pathLabel: '전기차 배터리 공급 · 2단계', - }, - ], - }, - { - newsId: 'v3', - date: '07-16', - sector: '2차전지', - title: '美 전기차 보조금 개편안 발표', - items: [ - { - name: 'LG에너지솔루션', - predicted: 'UP', - actualReturn: 2.15, - hit: true, - pathLabel: '뉴스 직접 언급', - }, - { - name: '에코프로비엠', - predicted: 'UP', - actualReturn: -1.24, - hit: false, - pathLabel: '양극재 조달 · 1단계', - }, - { - name: '엘앤에프', - predicted: 'UP', - actualReturn: 0.88, - hit: true, - pathLabel: '양극재 조달 · 1단계', - }, - ], - }, - { - newsId: 'v4', - date: '07-16', - sector: '반도체', - title: 'D램 고정거래가 3개월 연속 상승', - items: [ - { - name: '삼성전자', - predicted: 'UP', - actualReturn: 1.85, - hit: true, - pathLabel: 'D램 생산 · 1단계', - }, - { - name: 'SK하이닉스', - predicted: 'UP', - actualReturn: 2.94, - hit: true, - pathLabel: 'D램 주력 생산 · 1단계', - }, - { - name: '리노공업', - predicted: 'UP', - actualReturn: 1.06, - hit: true, - pathLabel: '테스트 소켓 · 2단계', - }, - ], - }, - { - newsId: 'v5', - date: '07-15', - sector: '방산', - title: '루마니아, K9 자주포 도입 검토 보도', - items: [ - { - name: '한화에어로스페이스', - predicted: 'UP', - actualReturn: 4.86, - hit: true, - pathLabel: 'K9 생산 · 1단계', - }, - { - name: '풍산', - predicted: 'UP', - actualReturn: 2.03, - hit: true, - pathLabel: '탄약 공급 · 2단계', - }, - { - name: '현대로템', - predicted: 'UP', - actualReturn: 1.42, - hit: true, - pathLabel: '방산 수출 · 2단계', - }, - { - name: 'LIG넥스원', - predicted: 'DOWN', - actualReturn: -0.55, - hit: true, - pathLabel: '경쟁 관계 · 2단계', - }, - ], - }, - { - newsId: 'v6', - date: '07-15', - sector: '반도체', - title: '파운드리 선단공정 수율 개선 발표', - items: [ - { - name: '삼성전자', - predicted: 'UP', - actualReturn: 0.74, - hit: true, - pathLabel: '파운드리 영위 · 1단계', - }, - { - name: '동진쎄미켐', - predicted: 'UP', - actualReturn: 2.21, - hit: true, - pathLabel: '포토레지스트 · 2단계', - }, - { - name: '주성엔지니어링', - predicted: 'UP', - actualReturn: 1.55, - hit: true, - pathLabel: '장비 공급 · 2단계', - }, - ], - }, - { - newsId: 'v7', - date: '07-14', - sector: '2차전지', - title: '리튬 현물가 반등, 3주 만에 상승 전환', - items: [ - { - name: '에코프로비엠', - predicted: 'UP', - actualReturn: 1.77, - hit: true, - pathLabel: '양극재 판가 연동 · 2단계', - }, - { - name: '포스코퓨처엠', - predicted: 'UP', - actualReturn: 1.31, - hit: true, - pathLabel: '양극재 판가 연동 · 2단계', - }, - { - name: 'LG에너지솔루션', - predicted: 'DOWN', - actualReturn: 0.42, - hit: false, - pathLabel: '원가 상승 부담 · 3단계', - }, - ], - }, - { - newsId: 'v8', - date: '07-14', - sector: '방산', - title: '중동 방산 전시회서 수출 상담 확대', - items: [ - { - name: 'LIG넥스원', - predicted: 'UP', - actualReturn: 1.24, - hit: true, - pathLabel: '방산 수출 · 1단계', - }, - { - name: '한국항공우주', - predicted: 'UP', - actualReturn: 0.66, - hit: true, - pathLabel: '방산 수출 · 1단계', - }, - { - name: '풍산', - predicted: 'UP', - actualReturn: -0.31, - hit: false, - pathLabel: '탄약 수출 · 1단계', - }, - ], - }, - { - newsId: 'v9', - date: '07-11', - sector: '반도체', - title: '국내 파운드리 신규 수주 소식', - items: [ - { - name: 'DB하이텍', - predicted: 'UP', - actualReturn: 2.68, - hit: true, - pathLabel: '파운드리 영위 · 1단계', - }, - { - name: '삼성전자', - predicted: 'UP', - actualReturn: 1.12, - hit: true, - pathLabel: '파운드리 영위 · 1단계', - }, - { - name: '주성엔지니어링', - predicted: 'UP', - actualReturn: -0.92, - hit: false, - pathLabel: '장비 공급 · 2단계', - }, - ], - }, - { - newsId: 'v10', - date: '07-11', - sector: '2차전지', - title: '현대차 전용 전기차 플랫폼 증산 계획', - items: [ - { - name: '현대차', - predicted: 'UP', - actualReturn: 1.44, - hit: true, - pathLabel: '뉴스 직접 언급', - }, - { - name: 'LG에너지솔루션', - predicted: 'UP', - actualReturn: 1.02, - hit: true, - pathLabel: '배터리 공급 · 2단계', - }, - { - name: '천보', - predicted: 'UP', - actualReturn: 0.58, - hit: true, - pathLabel: '첨가제 조달 · 3단계', - }, - ], - }, - { - newsId: 'v11', - date: '07-10', - sector: '방산', - title: '중동 지역 유도무기 수출 협상 개시', - items: [ - { - name: 'LIG넥스원', - predicted: 'UP', - actualReturn: 3.55, - hit: true, - pathLabel: '유도무기 생산 · 1단계', - }, - { - name: '한화에어로스페이스', - predicted: 'DOWN', - actualReturn: -0.64, - hit: true, - pathLabel: '경쟁 관계 · 1단계', - }, - { - name: '한국항공우주', - predicted: 'UP', - actualReturn: 0.71, - hit: true, - pathLabel: '방산 수출 · 2단계', - }, - ], - }, - { - newsId: 'v12', - date: '07-10', - sector: '반도체', - title: 'HBM 후공정 장비 발주 확대 관측', - items: [ - { - name: '한미반도체', - predicted: 'UP', - actualReturn: 2.86, - hit: true, - pathLabel: 'TC본더 필수 · 1단계', - }, - { - name: '이오테크닉스', - predicted: 'UP', - actualReturn: 1.33, - hit: true, - pathLabel: '레이저 장비 · 2단계', - }, - { - name: '티씨케이', - predicted: 'UP', - actualReturn: -0.28, - hit: false, - pathLabel: '소모품 공급 · 2단계', - }, - ], - }, - { - newsId: 'v13', - date: '07-09', - sector: '방산', - title: '국방예산 증액안 국회 제출', - items: [ - { - name: '현대로템', - predicted: 'UP', - actualReturn: 1.18, - hit: true, - pathLabel: '방산 수출 · 1단계', - }, - { - name: '한화에어로스페이스', - predicted: 'UP', - actualReturn: 0.92, - hit: true, - pathLabel: '수출 주도 · 1단계', - }, - { - name: '풍산', - predicted: 'UP', - actualReturn: 1.47, - hit: true, - pathLabel: '탄약 수출 · 1단계', - }, - ], - }, - { - newsId: 'v14', - date: '07-08', - sector: '2차전지', - title: '양극재 신규 증설 계획 발표', - items: [ - { - name: '포스코퓨처엠', - predicted: 'UP', - actualReturn: 2.34, - hit: true, - pathLabel: '양극재 생산 · 1단계', - }, - { - name: '엘앤에프', - predicted: 'UP', - actualReturn: -1.06, - hit: false, - pathLabel: '양극재 생산 · 1단계', - }, - { - name: '에코프로비엠', - predicted: 'UP', - actualReturn: 0.85, - hit: true, - pathLabel: '양극재 생산 · 1단계', - }, - ], - }, -] - -const DAILY_HIT_RATES = [ - 0.62, 0.71, 0.55, 0.68, 0.74, 0.66, 0.58, 0.7, 0.77, 0.64, 0.6, 0.72, 0.69, - 0.75, 0.58, 0.63, 0.71, 0.8, 0.66, 0.61, 0.73, 0.68, 0.7, 0.59, 0.76, 0.72, - 0.65, 0.7, 0.74, 0.69, -] - -/** 최근 30거래일(=최근 30일, 어제까지)에 적중률을 매핑. 실행 시점 기준으로 계산해 데모가 언제 열려도 날짜가 맞는다. */ -export function buildVerifyDaily(): VerifyDaily[] { - const yesterday = new Date() - yesterday.setDate(yesterday.getDate() - 1) - return DAILY_HIT_RATES.map((hitRate, i) => { - const d = new Date(yesterday) - d.setDate(d.getDate() - (DAILY_HIT_RATES.length - 1 - i)) - return { date: d.toISOString().slice(0, 10), hitRate } - }) -} diff --git a/src/mocks/news/handlers.ts b/src/mocks/news/handlers.ts new file mode 100644 index 0000000..46530ea --- /dev/null +++ b/src/mocks/news/handlers.ts @@ -0,0 +1,21 @@ +import { HttpResponse, delay, http } from 'msw' +import { getNews } from '#/apis/news/mock' + +/** + * docs/KOSLINK-FRONTEND.md §5 API 명세와 동일한 JSON 모양으로 응답한다. + * 응답 바디는 apis/news/mock.ts의 기존 동기 함수를 그대로 호출해 만든다 — 이 "서버"와 + * TanStack Query의 placeholderData가 항상 같은 데이터 소스(mocks/news/data.ts)를 본다. + */ + +export const newsHandlers = [ + http.get('/api/news', async ({ request }) => { + await delay(300) + const searchParams = new URL(request.url).searchParams + return HttpResponse.json( + getNews({ + cursor: searchParams.get('cursor') ?? undefined, + limit: Number(searchParams.get('limit')) || undefined, + }), + ) + }), +] diff --git a/src/mocks/verify/data.ts b/src/mocks/verify/data.ts new file mode 100644 index 0000000..37c2754 --- /dev/null +++ b/src/mocks/verify/data.ts @@ -0,0 +1,441 @@ +import type { VerifyDaily, VerifyEntry } from '#/types/verify' + +export const VERIFY_ENTRIES: VerifyEntry[] = [ + { + newsId: 'v1', + date: '07-17', + sector: '반도체', + title: 'SK하이닉스, 엔비디아향 HBM 공급 계약 확대', + items: [ + { + name: '한미반도체', + predicted: 'UP', + actualReturn: 3.42, + hit: true, + pathLabel: '장비 공급 · 1단계', + }, + { + name: '삼성전자', + predicted: 'DOWN', + actualReturn: -0.81, + hit: true, + pathLabel: '경쟁 관계 · 1단계', + }, + { + name: 'HPSP', + predicted: 'UP', + actualReturn: 1.94, + hit: true, + pathLabel: '고압 어닐링 장비 · 1단계', + }, + { + name: '원익IPS', + predicted: 'UP', + actualReturn: -0.35, + hit: false, + pathLabel: '증착 장비 · 1단계', + }, + ], + }, + { + newsId: 'v2', + date: '07-17', + sector: '2차전지', + title: '유럽 배터리 규제 완화 논의 시작', + items: [ + { + name: 'LG에너지솔루션', + predicted: 'UP', + actualReturn: 1.62, + hit: true, + pathLabel: '뉴스 직접 언급', + }, + { + name: '삼성SDI', + predicted: 'UP', + actualReturn: 0.94, + hit: true, + pathLabel: '전기차 배터리 공급 · 2단계', + }, + { + name: 'SK이노베이션', + predicted: 'UP', + actualReturn: -0.42, + hit: false, + pathLabel: '전기차 배터리 공급 · 2단계', + }, + ], + }, + { + newsId: 'v3', + date: '07-16', + sector: '2차전지', + title: '美 전기차 보조금 개편안 발표', + items: [ + { + name: 'LG에너지솔루션', + predicted: 'UP', + actualReturn: 2.15, + hit: true, + pathLabel: '뉴스 직접 언급', + }, + { + name: '에코프로비엠', + predicted: 'UP', + actualReturn: -1.24, + hit: false, + pathLabel: '양극재 조달 · 1단계', + }, + { + name: '엘앤에프', + predicted: 'UP', + actualReturn: 0.88, + hit: true, + pathLabel: '양극재 조달 · 1단계', + }, + ], + }, + { + newsId: 'v4', + date: '07-16', + sector: '반도체', + title: 'D램 고정거래가 3개월 연속 상승', + items: [ + { + name: '삼성전자', + predicted: 'UP', + actualReturn: 1.85, + hit: true, + pathLabel: 'D램 생산 · 1단계', + }, + { + name: 'SK하이닉스', + predicted: 'UP', + actualReturn: 2.94, + hit: true, + pathLabel: 'D램 주력 생산 · 1단계', + }, + { + name: '리노공업', + predicted: 'UP', + actualReturn: 1.06, + hit: true, + pathLabel: '테스트 소켓 · 2단계', + }, + ], + }, + { + newsId: 'v5', + date: '07-15', + sector: '방산', + title: '루마니아, K9 자주포 도입 검토 보도', + items: [ + { + name: '한화에어로스페이스', + predicted: 'UP', + actualReturn: 4.86, + hit: true, + pathLabel: 'K9 생산 · 1단계', + }, + { + name: '풍산', + predicted: 'UP', + actualReturn: 2.03, + hit: true, + pathLabel: '탄약 공급 · 2단계', + }, + { + name: '현대로템', + predicted: 'UP', + actualReturn: 1.42, + hit: true, + pathLabel: '방산 수출 · 2단계', + }, + { + name: 'LIG넥스원', + predicted: 'DOWN', + actualReturn: -0.55, + hit: true, + pathLabel: '경쟁 관계 · 2단계', + }, + ], + }, + { + newsId: 'v6', + date: '07-15', + sector: '반도체', + title: '파운드리 선단공정 수율 개선 발표', + items: [ + { + name: '삼성전자', + predicted: 'UP', + actualReturn: 0.74, + hit: true, + pathLabel: '파운드리 영위 · 1단계', + }, + { + name: '동진쎄미켐', + predicted: 'UP', + actualReturn: 2.21, + hit: true, + pathLabel: '포토레지스트 · 2단계', + }, + { + name: '주성엔지니어링', + predicted: 'UP', + actualReturn: 1.55, + hit: true, + pathLabel: '장비 공급 · 2단계', + }, + ], + }, + { + newsId: 'v7', + date: '07-14', + sector: '2차전지', + title: '리튬 현물가 반등, 3주 만에 상승 전환', + items: [ + { + name: '에코프로비엠', + predicted: 'UP', + actualReturn: 1.77, + hit: true, + pathLabel: '양극재 판가 연동 · 2단계', + }, + { + name: '포스코퓨처엠', + predicted: 'UP', + actualReturn: 1.31, + hit: true, + pathLabel: '양극재 판가 연동 · 2단계', + }, + { + name: 'LG에너지솔루션', + predicted: 'DOWN', + actualReturn: 0.42, + hit: false, + pathLabel: '원가 상승 부담 · 3단계', + }, + ], + }, + { + newsId: 'v8', + date: '07-14', + sector: '방산', + title: '중동 방산 전시회서 수출 상담 확대', + items: [ + { + name: 'LIG넥스원', + predicted: 'UP', + actualReturn: 1.24, + hit: true, + pathLabel: '방산 수출 · 1단계', + }, + { + name: '한국항공우주', + predicted: 'UP', + actualReturn: 0.66, + hit: true, + pathLabel: '방산 수출 · 1단계', + }, + { + name: '풍산', + predicted: 'UP', + actualReturn: -0.31, + hit: false, + pathLabel: '탄약 수출 · 1단계', + }, + ], + }, + { + newsId: 'v9', + date: '07-11', + sector: '반도체', + title: '국내 파운드리 신규 수주 소식', + items: [ + { + name: 'DB하이텍', + predicted: 'UP', + actualReturn: 2.68, + hit: true, + pathLabel: '파운드리 영위 · 1단계', + }, + { + name: '삼성전자', + predicted: 'UP', + actualReturn: 1.12, + hit: true, + pathLabel: '파운드리 영위 · 1단계', + }, + { + name: '주성엔지니어링', + predicted: 'UP', + actualReturn: -0.92, + hit: false, + pathLabel: '장비 공급 · 2단계', + }, + ], + }, + { + newsId: 'v10', + date: '07-11', + sector: '2차전지', + title: '현대차 전용 전기차 플랫폼 증산 계획', + items: [ + { + name: '현대차', + predicted: 'UP', + actualReturn: 1.44, + hit: true, + pathLabel: '뉴스 직접 언급', + }, + { + name: 'LG에너지솔루션', + predicted: 'UP', + actualReturn: 1.02, + hit: true, + pathLabel: '배터리 공급 · 2단계', + }, + { + name: '천보', + predicted: 'UP', + actualReturn: 0.58, + hit: true, + pathLabel: '첨가제 조달 · 3단계', + }, + ], + }, + { + newsId: 'v11', + date: '07-10', + sector: '방산', + title: '중동 지역 유도무기 수출 협상 개시', + items: [ + { + name: 'LIG넥스원', + predicted: 'UP', + actualReturn: 3.55, + hit: true, + pathLabel: '유도무기 생산 · 1단계', + }, + { + name: '한화에어로스페이스', + predicted: 'DOWN', + actualReturn: -0.64, + hit: true, + pathLabel: '경쟁 관계 · 1단계', + }, + { + name: '한국항공우주', + predicted: 'UP', + actualReturn: 0.71, + hit: true, + pathLabel: '방산 수출 · 2단계', + }, + ], + }, + { + newsId: 'v12', + date: '07-10', + sector: '반도체', + title: 'HBM 후공정 장비 발주 확대 관측', + items: [ + { + name: '한미반도체', + predicted: 'UP', + actualReturn: 2.86, + hit: true, + pathLabel: 'TC본더 필수 · 1단계', + }, + { + name: '이오테크닉스', + predicted: 'UP', + actualReturn: 1.33, + hit: true, + pathLabel: '레이저 장비 · 2단계', + }, + { + name: '티씨케이', + predicted: 'UP', + actualReturn: -0.28, + hit: false, + pathLabel: '소모품 공급 · 2단계', + }, + ], + }, + { + newsId: 'v13', + date: '07-09', + sector: '방산', + title: '국방예산 증액안 국회 제출', + items: [ + { + name: '현대로템', + predicted: 'UP', + actualReturn: 1.18, + hit: true, + pathLabel: '방산 수출 · 1단계', + }, + { + name: '한화에어로스페이스', + predicted: 'UP', + actualReturn: 0.92, + hit: true, + pathLabel: '수출 주도 · 1단계', + }, + { + name: '풍산', + predicted: 'UP', + actualReturn: 1.47, + hit: true, + pathLabel: '탄약 수출 · 1단계', + }, + ], + }, + { + newsId: 'v14', + date: '07-08', + sector: '2차전지', + title: '양극재 신규 증설 계획 발표', + items: [ + { + name: '포스코퓨처엠', + predicted: 'UP', + actualReturn: 2.34, + hit: true, + pathLabel: '양극재 생산 · 1단계', + }, + { + name: '엘앤에프', + predicted: 'UP', + actualReturn: -1.06, + hit: false, + pathLabel: '양극재 생산 · 1단계', + }, + { + name: '에코프로비엠', + predicted: 'UP', + actualReturn: 0.85, + hit: true, + pathLabel: '양극재 생산 · 1단계', + }, + ], + }, +] + +const DAILY_HIT_RATES = [ + 0.62, 0.71, 0.55, 0.68, 0.74, 0.66, 0.58, 0.7, 0.77, 0.64, 0.6, 0.72, 0.69, + 0.75, 0.58, 0.63, 0.71, 0.8, 0.66, 0.61, 0.73, 0.68, 0.7, 0.59, 0.76, 0.72, + 0.65, 0.7, 0.74, 0.69, +] + +/** 최근 30거래일(=최근 30일, 어제까지)에 적중률을 매핑. 실행 시점 기준으로 계산해 데모가 언제 열려도 날짜가 맞는다. */ +export function buildVerifyDaily(): VerifyDaily[] { + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + return DAILY_HIT_RATES.map((hitRate, i) => { + const d = new Date(yesterday) + d.setDate(d.getDate() - (DAILY_HIT_RATES.length - 1 - i)) + return { date: d.toISOString().slice(0, 10), hitRate } + }) +} diff --git a/src/mocks/verify/handlers.ts b/src/mocks/verify/handlers.ts new file mode 100644 index 0000000..6c70132 --- /dev/null +++ b/src/mocks/verify/handlers.ts @@ -0,0 +1,16 @@ +import { HttpResponse, delay, http } from 'msw' +import { getVerify } from '#/apis/verify/mock' + +export const verifyHandlers = [ + http.get('/api/verify', async ({ request }) => { + await delay(300) + const searchParams = new URL(request.url).searchParams + return HttpResponse.json( + getVerify({ + sector: searchParams.get('sector') ?? undefined, + cursor: searchParams.get('cursor') ?? undefined, + limit: Number(searchParams.get('limit')) || undefined, + }), + ) + }), +] diff --git a/src/routes/app.tsx b/src/routes/app.tsx index 32b68ec..7fdf23e 100644 --- a/src/routes/app.tsx +++ b/src/routes/app.tsx @@ -2,9 +2,9 @@ import { useEffect } from 'react' import { createFileRoute } from '@tanstack/react-router' import { useAtom, useAtomValue } from 'jotai' import { motion } from 'motion/react' -import { selectedNewsIdAtom, viewAtom } from '#/lib/atoms' -import { getNews } from '#/lib/api' -import Header from '#/components/Header' +import { selectedNewsIdAtom, viewAtom } from '#/shared/store/atoms' +import { getNews } from '#/apis/news/mock' +import Header from '#/shared/components/Header' import ScrapSheet from '#/components/scrap/ScrapSheet' import NewsPanel from '#/components/news/NewsPanel' import MobileNewsBar from '#/components/news/MobileNewsBar' diff --git a/src/lib/http.ts b/src/shared/apis/http.ts similarity index 100% rename from src/lib/http.ts rename to src/shared/apis/http.ts diff --git a/src/shared/apis/paginate.ts b/src/shared/apis/paginate.ts new file mode 100644 index 0000000..d7dad0c --- /dev/null +++ b/src/shared/apis/paginate.ts @@ -0,0 +1,20 @@ +/** + * 목록 커서 페이징 공통 로직. cursor는 "마지막으로 받은 항목의 커서 키" 관례를 + * 쓰지만, 클라이언트는 이 값을 해석하지 않고 nextCursor를 그대로 되돌려주기만 + * 한다. apis/news/mock.ts, apis/verify/mock.ts가 함께 쓴다. + */ +export function paginate( + items: T[], + cursor: string | undefined, + limit: number, + cursorOf: (item: T) => string, +): { page: T[]; nextCursor: string | null } { + const startIndex = cursor + ? Math.max(items.findIndex((item) => cursorOf(item) === cursor) + 1, 0) + : 0 + const page = items.slice(startIndex, startIndex + limit) + const last = page.at(-1) + const nextCursor = + startIndex + limit < items.length && last ? cursorOf(last) : null + return { page, nextCursor } +} diff --git a/src/components/Header.tsx b/src/shared/components/Header.tsx similarity index 87% rename from src/components/Header.tsx rename to src/shared/components/Header.tsx index 94753d3..3d48863 100644 --- a/src/components/Header.tsx +++ b/src/shared/components/Header.tsx @@ -1,12 +1,7 @@ import { useAtom } from 'jotai' -import { viewAtom } from '#/lib/atoms' -import { useGraphQuery } from '#/lib/queries' - -const TABS = [ - { value: 'network', label: '전체 관계망' }, - { value: 'map', label: '뉴스맵' }, - { value: 'verify', label: '예측 검증' }, -] as const +import { viewAtom } from '#/shared/store/atoms' +import { useGraphQuery } from '#/apis/graph/queries' +import { TABS } from '#/shared/constants/tabs' export default function Header() { const [view, setView] = useAtom(viewAtom) diff --git a/src/shared/constants/tabs.ts b/src/shared/constants/tabs.ts new file mode 100644 index 0000000..ca82fe0 --- /dev/null +++ b/src/shared/constants/tabs.ts @@ -0,0 +1,5 @@ +export const TABS = [ + { value: 'network', label: '전체 관계망' }, + { value: 'map', label: '뉴스맵' }, + { value: 'verify', label: '예측 검증' }, +] as const diff --git a/src/lib/useInfiniteScrollTrigger.ts b/src/shared/hooks/useInfiniteScrollTrigger.ts similarity index 100% rename from src/lib/useInfiniteScrollTrigger.ts rename to src/shared/hooks/useInfiniteScrollTrigger.ts diff --git a/src/mocks/browser.ts b/src/shared/mocks/browser.ts similarity index 100% rename from src/mocks/browser.ts rename to src/shared/mocks/browser.ts diff --git a/src/shared/mocks/handlers.ts b/src/shared/mocks/handlers.ts new file mode 100644 index 0000000..ce089b9 --- /dev/null +++ b/src/shared/mocks/handlers.ts @@ -0,0 +1,11 @@ +import { newsHandlers } from '#/mocks/news/handlers' +import { analysisHandlers } from '#/mocks/analysis/handlers' +import { graphHandlers } from '#/mocks/graph/handlers' +import { verifyHandlers } from '#/mocks/verify/handlers' + +export const handlers = [ + ...newsHandlers, + ...analysisHandlers, + ...graphHandlers, + ...verifyHandlers, +] diff --git a/src/shared/store/atoms.ts b/src/shared/store/atoms.ts new file mode 100644 index 0000000..711e0b2 --- /dev/null +++ b/src/shared/store/atoms.ts @@ -0,0 +1,17 @@ +import { atom } from 'jotai' + +/** + * 여러 도메인이 같이 쓰는 UI 상태 atom 모음. 서버성 데이터는 여기 넣지 않는다 — + * apis/에서 직접 읽는다. 세션 메모리 전용이며 localStorage 등 영속 저장은 쓰지 않는다. + */ + +export const viewAtom = atom<'network' | 'map' | 'verify'>('network') + +/** 현재 선택된 뉴스 id — news/graph/analysis/scrap 도메인이 공유한다 */ +export const selectedNewsIdAtom = atom(null) + +/** 커버 화면 → /app 진입 시 전체 관계망 인트로(자동 하이라이트+줌)를 재생했는지 — 세션당 1회만 */ +export const introPlayedAtom = atom(false) + +/** 모바일 바텀시트(≤1080px) 개폐 */ +export const newsSheetOpenAtom = atom(false) diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts new file mode 100644 index 0000000..c2cce4f --- /dev/null +++ b/src/shared/types/index.ts @@ -0,0 +1 @@ +export type Direction = 'UP' | 'DOWN' diff --git a/src/lib/utils.ts b/src/shared/utils/cn.ts similarity index 100% rename from src/lib/utils.ts rename to src/shared/utils/cn.ts diff --git a/src/lib/format.ts b/src/shared/utils/format.ts similarity index 94% rename from src/lib/format.ts rename to src/shared/utils/format.ts index ee5b603..b654d91 100644 --- a/src/lib/format.ts +++ b/src/shared/utils/format.ts @@ -1,4 +1,5 @@ -import type { Direction, OntologyNode } from '#/types' +import type { Direction } from '#/shared/types' +import type { OntologyNode } from '#/types/graph' export function arrow(direction: Direction): '▲' | '▼' { return direction === 'UP' ? '▲' : '▼' diff --git a/src/lib/graphIndex.ts b/src/shared/utils/graphIndex.ts similarity index 97% rename from src/lib/graphIndex.ts rename to src/shared/utils/graphIndex.ts index bc8e07c..5e7823e 100644 --- a/src/lib/graphIndex.ts +++ b/src/shared/utils/graphIndex.ts @@ -1,8 +1,8 @@ -import type { OntologyEdge, OntologyNode } from '#/types' +import type { OntologyEdge, OntologyNode } from '#/types/graph' /** * 노드/엣지 배열로부터 인접 리스트·관계 조회 인덱스를 만들고, 기점에서 BFS로 - * hop 레벨을 계산하는 재사용 유틸리티. 정적 전체 온톨로지(lib/layout.ts의 + * hop 레벨을 계산하는 재사용 유틸리티. 정적 전체 온톨로지(utils/graph/layout.ts의 * graphIndex)와 뉴스별 파급 경로 그래프(GET /api/news/{id}/graph) 양쪽에서 쓴다. */ diff --git a/src/store/graph/atoms.ts b/src/store/graph/atoms.ts new file mode 100644 index 0000000..e407a87 --- /dev/null +++ b/src/store/graph/atoms.ts @@ -0,0 +1,4 @@ +import { atom } from 'jotai' + +/** 전체 관계망에서 배치를 유지한 채 특정 노드 기점으로 제자리 강조할 때의 대상 */ +export const highlightedNodeAtom = atom(null) diff --git a/src/store/news/atoms.ts b/src/store/news/atoms.ts new file mode 100644 index 0000000..7e9af2d --- /dev/null +++ b/src/store/news/atoms.ts @@ -0,0 +1,4 @@ +import { atom } from 'jotai' + +/** "최신 뉴스" 새로고침으로 방금 추가된 뉴스 id 목록 — NEW 배지 표시용, 세션 메모리 전용 */ +export const newlyAddedNewsIdsAtom = atom([]) diff --git a/src/store/scrap/atoms.ts b/src/store/scrap/atoms.ts new file mode 100644 index 0000000..0ad3538 --- /dev/null +++ b/src/store/scrap/atoms.ts @@ -0,0 +1,4 @@ +import { atom } from 'jotai' + +/** 스크랩(저장) 뉴스 id 목록 — 메모리 전용, 새로고침하면 사라진다 */ +export const scrappedNewsIdsAtom = atom([]) diff --git a/src/store/verify/atoms.ts b/src/store/verify/atoms.ts new file mode 100644 index 0000000..76ae180 --- /dev/null +++ b/src/store/verify/atoms.ts @@ -0,0 +1,6 @@ +import { atom } from 'jotai' + +export const verifyContextAtom = atom<{ + label: string + names: string[] +} | null>(null) diff --git a/src/types/analysis/index.ts b/src/types/analysis/index.ts new file mode 100644 index 0000000..8c49723 --- /dev/null +++ b/src/types/analysis/index.ts @@ -0,0 +1,34 @@ +import type { Direction } from '#/shared/types' +import type { NewsImpactGraph } from '#/types/graph' + +/** 뉴스에 직접 언급된 당사자 종목 (구 main). 배열이라 여러 종목이 동시에 언급된 뉴스도 표현한다. */ +export interface OriginStock { + ticker: string + name: string + direction: Direction + reason: string +} + +/** 온톨로지로 파생된 관련 종목. relationPath는 표시용 문자열("SK하이닉스 → 한미반도체")이며 그래프 좌표 계산에는 쓰이지 않는다. */ +export interface RelatedStock { + ticker: string + name: string + direction: Direction + relationLabel: string + relationPath: string + propagation: string +} + +export interface NewsImpact { + newsId: number + newsSummary: string[] // 3줄 + source: { + press: string + publishedAt: string + url: string + } + originStocks: OriginStock[] + relatedStocks: RelatedStock[] + finalSummary: string + graph: NewsImpactGraph +} diff --git a/src/types/graph/index.ts b/src/types/graph/index.ts new file mode 100644 index 0000000..58104a4 --- /dev/null +++ b/src/types/graph/index.ts @@ -0,0 +1,92 @@ +import type { Edge, Node } from '@xyflow/react' +import type { Direction } from '#/shared/types' + +/** + * 실제 온톨로지(Neo4j)는 Stock/Role/Theme 3계층 + BELONGS_TO/SUPPLY_TO/RELATED_TO + * 관계로 이뤄져 있지만, 프론트는 "거래 가능한 종목인지" 여부만 렌더링에 쓴다 + * (카드 크기·티커 표시 여부). 그래서 Role/Theme는 CONCEPT 하나로 뭉뚱그려 받는다. + */ +export type NodeKind = 'STOCK' | 'CONCEPT' + +export interface OntologyNode { + id: string + name: string + kind: NodeKind + ticker?: string // STOCK만 + sector: string // '반도체' | '2차전지' | '방산' + 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 // 화면에 보여줄 관계 레이블 — '장비 공급', '경쟁', '양극재 조달' ... + /** SUPPLY_TO·BELONGS_TO 등 relation_type이 없는 엣지는 생략된다(항상 동조로 취급). */ + relationType?: RelationType +} + +/** 파급 경로 그래프의 노드. direction은 이 뉴스의 파급 경로에 포함된 STOCK에만 붙는다. */ +export interface ImpactGraphNode extends OntologyNode { + direction?: Direction +} + +/** + * GET /api/news/{id}/impact 응답의 graph 필드. 좌표는 내려주지 않는다 — 화면에 그릴 + * 위치는 프론트가 hop 레벨(originId부터의 BFS 거리)로 계산한다. + */ +export interface NewsImpactGraph { + newsId: number + originId: string + nodes: ImpactGraphNode[] + edges: OntologyEdge[] +} + +export type NodeVisualState = + | 'hide' + | 'idle' + | 'dim' + | 'via' + | 'via2' + | 'up' + | 'down' + | 'up2' + | 'down2' + | 'origin' + +export interface StockNodeData extends Record { + label: string + ticker?: string + abstract: boolean + tier?: 't1' | 't2' | 't3' + state: NodeVisualState + isMain?: boolean + badge?: Direction | null + pulseToken?: number +} + +export type StockFlowNode = Node + +export type EdgeVisualState = + 'hide' | 'idle' | 'dim' | 'near' | 'up' | 'down' | 'up2' | 'down2' + +export interface RelEdgeData extends Record { + relation: string + state: EdgeVisualState + draw?: boolean + /** draw가 true가 된 시각(performance.now()). 그리기 애니메이션의 진행률을 + * 경과 시간으로 계산해, 리렌더/리마운트가 일어나도 처음부터 다시 그려지지 + * 않고 진행 중이던 지점부터 이어 그리게 하는 데 쓰인다. */ + drawAt?: number +} + +export type RelFlowEdge = Edge diff --git a/src/types/index.ts b/src/types/index.ts deleted file mode 100644 index 07466db..0000000 --- a/src/types/index.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * 실제 온톨로지(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 // STOCK만 - sector: string // '반도체' | '2차전지' | '방산' - 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 // 화면에 보여줄 관계 레이블 — '장비 공급', '경쟁', '양극재 조달' ... - /** SUPPLY_TO·BELONGS_TO 등 relation_type이 없는 엣지는 생략된다(항상 동조로 취급). */ - relationType?: RelationType -} - -export interface NewsListItem { - id: number - title: string - press: string - publishedAt: string // ISO -} - -/** GET /api/news의 커서 기반 페이지 응답. cursor는 클라이언트가 해석하지 않는 opaque 값이다. */ -export interface NewsListPage { - items: NewsListItem[] - nextCursor: string | null -} - -/** 뉴스에 직접 언급된 당사자 종목 (구 main). 배열이라 여러 종목이 동시에 언급된 뉴스도 표현한다. */ -export interface OriginStock { - ticker: string - name: string - direction: Direction - reason: string -} - -/** 온톨로지로 파생된 관련 종목. relationPath는 표시용 문자열("SK하이닉스 → 한미반도체")이며 그래프 좌표 계산에는 쓰이지 않는다. */ -export interface RelatedStock { - ticker: string - name: string - direction: Direction - relationLabel: string - relationPath: string - propagation: string -} - -export interface NewsImpact { - newsId: number - newsSummary: string[] // 3줄 - source: { - press: string - publishedAt: string - url: string - } - originStocks: OriginStock[] - relatedStocks: RelatedStock[] - finalSummary: string - graph: NewsImpactGraph -} - -/** 파급 경로 그래프의 노드. direction은 이 뉴스의 파급 경로에 포함된 STOCK에만 붙는다. */ -export interface ImpactGraphNode extends OntologyNode { - direction?: Direction -} - -/** - * GET /api/news/{id}/impact 응답의 graph 필드. 좌표는 내려주지 않는다 — 화면에 그릴 - * 위치는 프론트가 hop 레벨(originId부터의 BFS 거리)로 계산한다. - */ -export interface NewsImpactGraph { - newsId: number - originId: string - nodes: ImpactGraphNode[] - edges: OntologyEdge[] -} - -export interface VerifyItem { - name: string - predicted: Direction - actualReturn: number - hit: boolean - pathLabel: string -} - -export interface VerifyEntry { - newsId: string - date: string - sector: string - title: string - items: VerifyItem[] -} - -export interface VerifyDaily { - date: string - hitRate: number -} - -/** GET /api/verify 응답. news는 커서 기반 페이지, daily는 필터와 무관한 전체 추이라 페이징하지 않는다. */ -export interface VerifyResponse { - daily: VerifyDaily[] - news: VerifyEntry[] - nextCursor: string | null -} diff --git a/src/types/news/index.ts b/src/types/news/index.ts new file mode 100644 index 0000000..a3cb6ef --- /dev/null +++ b/src/types/news/index.ts @@ -0,0 +1,12 @@ +export interface NewsListItem { + id: number + title: string + press: string + publishedAt: string // ISO +} + +/** GET /api/news의 커서 기반 페이지 응답. cursor는 클라이언트가 해석하지 않는 opaque 값이다. */ +export interface NewsListPage { + items: NewsListItem[] + nextCursor: string | null +} diff --git a/src/types/verify/index.ts b/src/types/verify/index.ts new file mode 100644 index 0000000..b694fab --- /dev/null +++ b/src/types/verify/index.ts @@ -0,0 +1,29 @@ +import type { Direction } from '#/shared/types' + +export interface VerifyItem { + name: string + predicted: Direction + actualReturn: number + hit: boolean + pathLabel: string +} + +export interface VerifyEntry { + newsId: string + date: string + sector: string + title: string + items: VerifyItem[] +} + +export interface VerifyDaily { + date: string + hitRate: number +} + +/** GET /api/verify 응답. news는 커서 기반 페이지, daily는 필터와 무관한 전체 추이라 페이징하지 않는다. */ +export interface VerifyResponse { + daily: VerifyDaily[] + news: VerifyEntry[] + nextCursor: string | null +} diff --git a/src/lib/layout.ts b/src/utils/graph/layout.ts similarity index 92% rename from src/lib/layout.ts rename to src/utils/graph/layout.ts index 5e36f9a..2132598 100644 --- a/src/lib/layout.ts +++ b/src/utils/graph/layout.ts @@ -1,6 +1,7 @@ -import { ONTOLOGY_EDGES, ONTOLOGY_NODES } from './data' -import { buildGraphIndex } from './graphIndex' -import type { OntologyNode } from '#/types' +import { ONTOLOGY_EDGES, ONTOLOGY_NODES } from '#/mocks/graph/data' +import { buildGraphIndex } from '#/shared/utils/graphIndex' +import { RADIUS, SECTOR_CENTERS } from '#/constants/graph/layout' +import type { OntologyNode } from '#/types/graph' /** * React Flow에는 레이아웃 엔진이 없다. 좌표는 여기서 삼각함수로 직접 계산한다. @@ -15,8 +16,6 @@ export interface LayoutPoint { /** 전체 온톨로지 인덱스. 정적 데이터 기준으로 한 번만 계산해 캐시한다. */ export const graphIndex = buildGraphIndex(ONTOLOGY_NODES, ONTOLOGY_EDGES) -const RADIUS = [0, 300, 520, 700] - /** 파급 경로: 기점 중앙, hop 레벨별 동심원(타원) 배치 */ export function radialLayout( ids: string[], @@ -46,10 +45,6 @@ export function radialLayout( return pos } -const SECTOR_CENTERS: Record = { - 반도체: { x: 0, y: 0 }, -} - export interface FullLayout { pos: Record sectorLabelPos: Record