diff --git a/src/components/Header.tsx b/src/components/Header.tsx index ff4bc35..94753d3 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -3,8 +3,8 @@ import { viewAtom } from '#/lib/atoms' import { useGraphQuery } from '#/lib/queries' const TABS = [ - { value: 'map', label: '뉴스맵' }, { value: 'network', label: '전체 관계망' }, + { value: 'map', label: '뉴스맵' }, { value: 'verify', label: '예측 검증' }, ] as const diff --git a/src/components/graph/GraphPanel.tsx b/src/components/graph/GraphPanel.tsx index ae122c3..f470aaa 100644 --- a/src/components/graph/GraphPanel.tsx +++ b/src/components/graph/GraphPanel.tsx @@ -42,7 +42,8 @@ const edgeTypes = { rel: RelEdge } // 정적 온톨로지 기준으로 결정적으로 계산되는 좌표라 모듈 스코프에서 한 번만 // 계산해 캐시한다. 뷰를 오갈 때마다 재계산하면(성능 낭비는 물론) 노드가 튈 수 있다. -const FULL_LAYOUT = fullLayout() +// export해서 NetworkView가 centerId(인트로 하이라이트 기점)를 재계산 없이 가져다 쓴다. +export const FULL_LAYOUT = fullLayout() interface EdgeSpec { id: string @@ -274,17 +275,47 @@ export function GraphCanvas({ const prevNodesRef = useRef(new Map()) const prevEdgesRef = useRef(new Map()) - const { nodeOverrides, edgeOverrides, stageText, tick } = usePropagation( - scene2.scene, - ) - + const { nodeOverrides, edgeOverrides, stageText, tick, settled } = + usePropagation(scene2.scene) + + // mode==='focus'(뉴스 파급 경로, GraphPanel)는 지금까지처럼 항상 전체 노드에 + // 즉시 fitView한다 — 아래 서브그래프 확대 줌은 mode==='all'(전체 관계망, + // NetworkView)에서만 적용한다. + // + // mode==='all'에서 하이라이트가 없으면(전체 보기) 전체 노드에 즉시 맞추고, + // 하이라이트 중(scene2.scene 존재)이면 리빌 애니메이션이 끝나(settled) + // 진정될 때까지는 줌을 건드리지 않고 기존 화면 그대로 하이라이트가 퍼지는 + // 걸 보여준 뒤, 다 끝나면 그제서야 기점+리빌 노드로만 fitView를 좁혀 + // 보기 좋은 크기로 줌인한다. SK하이닉스처럼 연결이 아주 많은 허브는 리빌 + // 노드 전부를 담으려 하면 다시 확 줌아웃돼 버리므로, minZoom으로 하한선을 + // 둬 일부 노드가 화면 밖으로 밀려나더라도 카드가 잘 보이는 크기 밑으로는 + // 안 내려가게 한다. useEffect(() => { + if (mode !== 'all') { + const id = window.setTimeout( + () => fitView({ padding: 0.2, duration: 400 }), + 60, + ) + return () => window.clearTimeout(id) + } + + const scene = scene2.scene + if (scene && !settled) return + + const targetNodes = scene + ? [scene.originId, ...scene.nodes.map((n) => n.id)].map((id) => ({ id })) + : undefined const id = window.setTimeout( - () => fitView({ padding: 0.2, duration: 400 }), + () => + fitView({ + padding: 0.2, + duration: 400, + ...(targetNodes ? { nodes: targetNodes, minZoom: 1 } : {}), + }), 60, ) return () => window.clearTimeout(id) - }, [fitView, scene2.scene?.key, scene2.ids.length]) + }, [fitView, mode, scene2.scene?.key, scene2.ids.length, settled]) // scene2/override가 실제로 바뀔 때만 재계산한다. React Flow는 노드/엣지를 id별로 // 내부 스토어에 구독시키기 때문에, 값이 같아도 매 틱마다 전체 배열을 새 객체로 diff --git a/src/components/graph/NetworkView.tsx b/src/components/graph/NetworkView.tsx index 38fdfb9..ba921d8 100644 --- a/src/components/graph/NetworkView.tsx +++ b/src/components/graph/NetworkView.tsx @@ -1,9 +1,9 @@ -import { useMemo } from 'react' +import { useEffect, useMemo } from 'react' import { ReactFlowProvider } from '@xyflow/react' -import { useAtomValue } from 'jotai' -import { highlightedNodeAtom } from '#/lib/atoms' +import { useAtom } from 'jotai' +import { highlightedNodeAtom, introPlayedAtom } from '#/lib/atoms' import { useGraphQuery } from '#/lib/queries' -import { GraphCanvas, buildAllScene } from './GraphPanel' +import { GraphCanvas, buildAllScene, FULL_LAYOUT } from './GraphPanel' import type { Scene2 } from './GraphPanel' import './graph.css' @@ -11,9 +11,18 @@ import './graph.css' * 뉴스맵 3분할 레이아웃 밖 큰 화면에 보여준다. 노드 클릭 시 제자리 강조하는 * highlightedNodeAtom은 GraphCanvas와 공유해 씬 계산과 상호작용을 동기화한다. */ export default function NetworkView() { - const highlightedNode = useAtomValue(highlightedNodeAtom) + const [highlightedNode, setHighlightedNode] = useAtom(highlightedNodeAtom) + const [introPlayed, setIntroPlayed] = useAtom(introPlayedAtom) const { data: graph } = useGraphQuery() + // 커버 화면 → /app 최초 진입 시 허브 노드를 자동으로 한 번 하이라이트한다. + // introPlayedAtom이 전역이라 GNB 탭을 오가며 NetworkView가 재마운트돼도 재생되지 않는다. + useEffect(() => { + if (introPlayed || highlightedNode || !graph) return + setHighlightedNode(FULL_LAYOUT.centerId) + setIntroPlayed(true) + }, [introPlayed, highlightedNode, graph, setHighlightedNode, setIntroPlayed]) + const scene2: Scene2 | null = useMemo(() => { if (!graph) return null return buildAllScene(highlightedNode, graph) diff --git a/src/components/graph/graph.css b/src/components/graph/graph.css index d11aad5..33fc943 100644 --- a/src/components/graph/graph.css +++ b/src/components/graph/graph.css @@ -83,18 +83,18 @@ } .nd.origin { - background: var(--n-900); + background: var(--o-500); border-color: #fff; border-width: 3px; box-shadow: - 0 0 0 3px var(--n-900), - 0 6px 22px rgba(28, 25, 23, 0.28); + 0 0 0 3px var(--o-500), + 0 6px 22px rgba(242, 103, 34, 0.34); } .nd.origin .nd-name { color: #fff; } .nd.origin .nd-tk { - color: var(--n-400); + color: var(--o-100); } .nd.hide { diff --git a/src/components/news/NewsCard.tsx b/src/components/news/NewsCard.tsx index 84ae568..beab3bb 100644 --- a/src/components/news/NewsCard.tsx +++ b/src/components/news/NewsCard.tsx @@ -9,10 +9,16 @@ import type { NewsListItem } from '#/types' interface NewsCardProps { news: NewsListItem selected: boolean + isNew?: boolean onSelect: () => void } -export default function NewsCard({ news, selected, onSelect }: NewsCardProps) { +export default function NewsCard({ + news, + selected, + isNew, + onSelect, +}: NewsCardProps) { const [scrapped, setScrapped] = useAtom(scrappedNewsIdsAtom) const isScrapped = scrapped.includes(news.id) @@ -71,6 +77,7 @@ export default function NewsCard({ news, selected, onSelect }: NewsCardProps) { {news.press} {' '} · {formatRelativeTime(news.publishedAt)} + {isNew && NEW} ) diff --git a/src/components/news/NewsList.tsx b/src/components/news/NewsList.tsx index fa35ab0..b9d704e 100644 --- a/src/components/news/NewsList.tsx +++ b/src/components/news/NewsList.tsx @@ -1,6 +1,10 @@ import { useRef } from 'react' -import { useAtom, useSetAtom } from 'jotai' -import { newsSheetOpenAtom, selectedNewsIdAtom } from '#/lib/atoms' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import { + newlyAddedNewsIdsAtom, + newsSheetOpenAtom, + selectedNewsIdAtom, +} from '#/lib/atoms' import { useNewsQuery } from '#/lib/queries' import { useInfiniteScrollTrigger } from '#/lib/useInfiniteScrollTrigger' import NewsCard from './NewsCard' @@ -9,6 +13,7 @@ import NewsCard from './NewsCard' export default function NewsList() { const [selectedId, setSelectedId] = useAtom(selectedNewsIdAtom) const setSheetOpen = useSetAtom(newsSheetOpenAtom) + const newlyAdded = useAtomValue(newlyAddedNewsIdsAtom) const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useNewsQuery() @@ -37,6 +42,7 @@ export default function NewsList() { key={n.id} news={n} selected={n.id === selectedId} + isNew={newlyAdded.includes(n.id)} onSelect={() => { setSelectedId(n.id) setSheetOpen(false) diff --git a/src/components/news/NewsPanel.tsx b/src/components/news/NewsPanel.tsx index a2b4e9a..de46c19 100644 --- a/src/components/news/NewsPanel.tsx +++ b/src/components/news/NewsPanel.tsx @@ -1,11 +1,15 @@ +import { useEffect, useRef, useState } from 'react' import { useIsFetching, useQueryClient } from '@tanstack/react-query' -import { useAtom } from 'jotai' +import { useAtom, useSetAtom } from 'jotai' import { RefreshCw } from 'lucide-react' import { cn } from '#/lib/utils' -import { newsSheetOpenAtom } from '#/lib/atoms' +import { newlyAddedNewsIdsAtom, newsSheetOpenAtom } from '#/lib/atoms' +import { refreshNews } from '#/lib/api' import { queryKeys } from '#/lib/queries' import NewsList from './NewsList' +const REFRESH_TOAST_MS = 4500 + /** * 데스크탑 3분할에서는 일반 패널, ≤1080px에서는 CSS(.col-news)가 이 섹션을 * 그대로 바텀시트로 바꾼다. MobileNewsBar의 "현재뉴스" 버튼이 newsSheetOpenAtom을 @@ -13,8 +17,29 @@ import NewsList from './NewsList' */ export default function NewsPanel() { const [sheetOpen, setSheetOpen] = useAtom(newsSheetOpenAtom) + const setNewlyAdded = useSetAtom(newlyAddedNewsIdsAtom) const queryClient = useQueryClient() const isRefreshing = useIsFetching({ queryKey: queryKeys.news() }) > 0 + const [toastCount, setToastCount] = useState(null) + const toastTimerRef = useRef() + + useEffect(() => () => window.clearTimeout(toastTimerRef.current), []) + + async function handleRefresh() { + // mock 데이터 계층에서 데모용 새 뉴스 몇 건을 먼저 끼워 넣은 뒤, 목록 전체를 + // 첫 페이지부터 다시 불러온다 — 스크롤로 더 불러온 뒷페이지는 새로고침 시 + // 버리고 최신순 1페이지로 되돌아간다(실제 뉴스 피드 새로고침과 동일한 동작). + const { addedIds } = refreshNews() + await queryClient.resetQueries({ queryKey: queryKeys.news() }) + + setNewlyAdded(addedIds) + setToastCount(addedIds.length) + window.clearTimeout(toastTimerRef.current) + toastTimerRef.current = window.setTimeout(() => { + setToastCount(null) + setNewlyAdded([]) + }, REFRESH_TOAST_MS) + } return (
@@ -25,9 +50,7 @@ export default function NewsPanel() { + {toastCount !== null && ( +
+ {toastCount > 0 + ? `새 뉴스 ${toastCount}건이 도착했습니다` + : '새 뉴스가 없습니다'} +
+ )}
) diff --git a/src/lib/api.ts b/src/lib/api.ts index da417f4..dd24e23 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -4,6 +4,7 @@ import { NEWS_RECORDS, VERIFY_ENTRIES, buildVerifyDaily, + pullRefreshBatch, } from './data' import type { NewsImpactWire, NewsListPageWire } from './mappers' import type { @@ -79,6 +80,17 @@ export function getNews({ } } +/** + * "최신 뉴스 새로고침" 데모용 — 실 백엔드가 없어 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) => diff --git a/src/lib/atoms.ts b/src/lib/atoms.ts index 8483778..a7b4829 100644 --- a/src/lib/atoms.ts +++ b/src/lib/atoms.ts @@ -6,13 +6,16 @@ import { atom } from 'jotai' * 세션 메모리 전용이며 localStorage 등 영속 저장은 쓰지 않는다. */ -export const viewAtom = atom<'map' | 'network' | 'verify'>('map') +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[] @@ -23,3 +26,6 @@ export const newsSheetOpenAtom = atom(false) /** 스크랩(저장) 뉴스 id 목록 — 메모리 전용, 새로고침하면 사라진다 */ export const scrappedNewsIdsAtom = atom([]) + +/** "최신 뉴스" 새로고침으로 방금 추가된 뉴스 id 목록 — NEW 배지 표시용, 세션 메모리 전용 */ +export const newlyAddedNewsIdsAtom = atom([]) diff --git a/src/lib/data.ts b/src/lib/data.ts index ba3b4a4..b05188d 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -28,6 +28,38 @@ const RAW_COMPANIES: [string, string, string, string, number][] = [ ['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, 이름, 섹터] @@ -36,6 +68,14 @@ const RAW_CONCEPTS: [string, string, string][] = [ ['dram', 'D램', '반도체'], ['fdry', '파운드리', '반도체'], ['aidc', 'AI 데이터센터', '반도체'], + ['adpkg', '삼성전자', '반도체'], + ['glass', '유리기판', '반도체'], + ['euv', 'EUV', '반도체'], + ['ondevice', '온디바이스AI', '반도체'], + ['cxl', 'CXL', '반도체'], + ['wafermat', '웨이퍼소재', '반도체'], + ['specgas', '특수가스', '반도체'], + ['testinsp', '테스트·검사', '반도체'], ] export const ONTOLOGY_NODES: OntologyNode[] = [ @@ -81,6 +121,66 @@ const RAW_EDGES: [string, string, string, RelationType?][] = [ ['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( @@ -137,10 +237,30 @@ const CURATED_NEWS: NewsRecord[] = [ }, ], relatedStocks: [ - { nodeId: 'hanmi', direction: 'UP', relationLabel: '장비 공급', chain: ['sk', 'hanmi'] }, - { nodeId: 'hpsp', direction: 'UP', relationLabel: '고압 어닐링 장비', chain: ['sk', 'hpsp'] }, - { nodeId: 'wonik', direction: 'UP', relationLabel: '증착 장비', chain: ['sk', 'wonik'] }, - { nodeId: 'ss', direction: 'DOWN', relationLabel: '경쟁 관계', chain: ['sk', 'ss'] }, + { + nodeId: 'hanmi', + direction: 'UP', + relationLabel: '장비 공급', + chain: ['sk', 'hanmi'], + }, + { + nodeId: 'hpsp', + direction: 'UP', + relationLabel: '고압 어닐링 장비', + chain: ['sk', 'hpsp'], + }, + { + nodeId: 'wonik', + direction: 'UP', + relationLabel: '증착 장비', + chain: ['sk', 'wonik'], + }, + { + nodeId: 'ss', + direction: 'DOWN', + relationLabel: '경쟁 관계', + chain: ['sk', 'ss'], + }, ], }, { @@ -162,10 +282,30 @@ const CURATED_NEWS: NewsRecord[] = [ }, ], relatedStocks: [ - { nodeId: 'sk', direction: 'UP', relationLabel: 'HBM 주력 생산', chain: ['aidc', 'hbm', 'sk'] }, - { nodeId: 'hanmi', direction: 'UP', relationLabel: 'HBM 필수 장비', chain: ['aidc', 'hbm', 'hanmi'] }, - { nodeId: 'ss', direction: 'UP', relationLabel: 'HBM 생산', chain: ['aidc', 'hbm', 'ss'] }, - { nodeId: 'eo', direction: 'UP', relationLabel: '장비 납품', chain: ['aidc', 'hbm', 'sk', 'eo'] }, + { + nodeId: 'sk', + direction: 'UP', + relationLabel: 'HBM 주력 생산', + chain: ['aidc', 'hbm', 'sk'], + }, + { + nodeId: 'hanmi', + direction: 'UP', + relationLabel: 'HBM 필수 장비', + chain: ['aidc', 'hbm', 'hanmi'], + }, + { + nodeId: 'ss', + direction: 'UP', + relationLabel: 'HBM 생산', + chain: ['aidc', 'hbm', 'ss'], + }, + { + nodeId: 'eo', + direction: 'UP', + relationLabel: '장비 납품', + chain: ['aidc', 'hbm', 'sk', 'eo'], + }, ], }, { @@ -187,10 +327,30 @@ const CURATED_NEWS: NewsRecord[] = [ }, ], relatedStocks: [ - { nodeId: 'jusung', direction: 'UP', relationLabel: '장비 공급', chain: ['ss', 'jusung'] }, - { nodeId: 'lino', direction: 'UP', relationLabel: '테스트 소켓', chain: ['ss', 'lino'] }, - { nodeId: 'dj', direction: 'UP', relationLabel: '포토레지스트', chain: ['ss', 'dj'] }, - { nodeId: 'sk', direction: 'DOWN', relationLabel: '경쟁', chain: ['ss', 'sk'] }, + { + nodeId: 'jusung', + direction: 'UP', + relationLabel: '장비 공급', + chain: ['ss', 'jusung'], + }, + { + nodeId: 'lino', + direction: 'UP', + relationLabel: '테스트 소켓', + chain: ['ss', 'lino'], + }, + { + nodeId: 'dj', + direction: 'UP', + relationLabel: '포토레지스트', + chain: ['ss', 'dj'], + }, + { + nodeId: 'sk', + direction: 'DOWN', + relationLabel: '경쟁', + chain: ['ss', 'sk'], + }, ], }, { @@ -212,10 +372,30 @@ const CURATED_NEWS: NewsRecord[] = [ }, ], relatedStocks: [ - { nodeId: 'sk', direction: 'UP', relationLabel: '주력 생산', chain: ['hbm', 'sk'] }, - { nodeId: 'ss', direction: 'UP', relationLabel: '생산', chain: ['hbm', 'ss'] }, - { nodeId: 'hanmi', direction: 'UP', relationLabel: 'TC본더 필수', chain: ['hbm', 'hanmi'] }, - { nodeId: 'aidc', direction: 'UP', relationLabel: '수요 견인', chain: ['hbm', 'aidc'] }, + { + nodeId: 'sk', + direction: 'UP', + relationLabel: '주력 생산', + chain: ['hbm', 'sk'], + }, + { + nodeId: 'ss', + direction: 'UP', + relationLabel: '생산', + chain: ['hbm', 'ss'], + }, + { + nodeId: 'hanmi', + direction: 'UP', + relationLabel: 'TC본더 필수', + chain: ['hbm', 'hanmi'], + }, + { + nodeId: 'aidc', + direction: 'UP', + relationLabel: '수요 견인', + chain: ['hbm', 'aidc'], + }, ], }, { @@ -237,9 +417,24 @@ const CURATED_NEWS: NewsRecord[] = [ }, ], relatedStocks: [ - { nodeId: 'sk', direction: 'UP', relationLabel: '주력 생산', chain: ['dram', 'sk'] }, - { nodeId: 'ss', direction: 'UP', relationLabel: '생산', chain: ['dram', 'ss'] }, - { nodeId: 'aidc', direction: 'UP', relationLabel: '수요 견인', chain: ['dram', 'aidc'] }, + { + nodeId: 'sk', + direction: 'UP', + relationLabel: '주력 생산', + chain: ['dram', 'sk'], + }, + { + nodeId: 'ss', + direction: 'UP', + relationLabel: '생산', + chain: ['dram', 'ss'], + }, + { + nodeId: 'aidc', + direction: 'UP', + relationLabel: '수요 견인', + chain: ['dram', 'aidc'], + }, ], }, { @@ -261,7 +456,12 @@ const CURATED_NEWS: NewsRecord[] = [ }, ], relatedStocks: [ - { nodeId: 'sk', direction: 'UP', relationLabel: '증착 장비', chain: ['wonik', 'sk'] }, + { + nodeId: 'sk', + direction: 'UP', + relationLabel: '증착 장비', + chain: ['wonik', 'sk'], + }, ], }, { @@ -283,7 +483,12 @@ const CURATED_NEWS: NewsRecord[] = [ }, ], relatedStocks: [ - { nodeId: 'sk', direction: 'UP', relationLabel: '고압 어닐링', chain: ['hpsp', 'sk'] }, + { + nodeId: 'sk', + direction: 'UP', + relationLabel: '고압 어닐링', + chain: ['hpsp', 'sk'], + }, ], }, { @@ -305,7 +510,12 @@ const CURATED_NEWS: NewsRecord[] = [ }, ], relatedStocks: [ - { nodeId: 'sk', direction: 'UP', relationLabel: '소모품 공급', chain: ['tck', 'sk'] }, + { + nodeId: 'sk', + direction: 'UP', + relationLabel: '소모품 공급', + chain: ['tck', 'sk'], + }, ], }, { @@ -327,11 +537,440 @@ const CURATED_NEWS: NewsRecord[] = [ }, ], relatedStocks: [ - { nodeId: 'fdry', direction: 'UP', relationLabel: '사업 영위', chain: ['dbh', 'fdry'] }, + { + nodeId: 'fdry', + direction: 'UP', + relationLabel: '사업 영위', + chain: ['dbh', 'fdry'], + }, ], }, ] +/** + * "최신 뉴스 새로고침" 시연용 풀. 실 백엔드가 없으니 새로고침 버튼을 누를 때마다 + * pullRefreshBatch()가 여기서 몇 건을 순환해 뽑아 NEWS_RECORDS 맨 앞에 끼워 넣는다. + * 실 API가 준비되면 이 풀과 pullRefreshBatch는 통째로 지우고 새로고침을 그냥 + * 쿼리 재조회로 대체하면 된다. + */ +const REFRESH_POOL: NewsRecord[] = [ + { + id: 30001, + title: '유니테스트, HBM 검증 수요 급증에 테스트 장비 수주 확대', + press: '전자신문', + publishedAt: TODAY, + url: 'https://www.etnews.com/', + summary: [ + '유니테스트가 HBM 검증용 테스트 장비 수주를 확대했다고 밝혔다.', + 'HBM 검증 물량 증가가 수주 확대의 배경으로 꼽힌다.', + '하반기까지 관련 수주가 이어질 것으로 전망된다.', + ], + originStocks: [ + { + nodeId: 'unitest', + direction: 'UP', + reason: 'HBM 검증용 테스트 장비 수주 확대의 직접 당사자', + }, + ], + relatedStocks: [ + { + nodeId: 'testinsp', + direction: 'UP', + relationLabel: '테스트 장비 공급', + chain: ['unitest', 'testinsp'], + }, + { + nodeId: 'testna', + direction: 'UP', + relationLabel: '테스트 서비스', + chain: ['unitest', 'testinsp', 'testna'], + }, + { + nodeId: 'isc', + direction: 'UP', + relationLabel: '테스트 소켓 공급', + chain: ['unitest', 'testinsp', 'isc'], + }, + ], + }, + { + id: 30002, + title: '삼성전자, EUV 노광 장비 신규 발주 확정', + press: '한국경제', + publishedAt: TODAY, + url: 'https://www.hankyung.com/', + summary: [ + '삼성전자가 선단공정 EUV 노광 장비 신규 발주를 확정했다.', + '미세공정 전환 대응이 목적으로 파악된다.', + '계측 장비 협력사 수주도 함께 늘어날 전망이다.', + ], + originStocks: [ + { + nodeId: 'ss', + direction: 'UP', + reason: 'EUV 장비 신규 발주를 직접 확정한 당사자', + }, + ], + relatedStocks: [ + { + nodeId: 'euv', + direction: 'UP', + relationLabel: '선단공정 노광 도입', + chain: ['ss', 'euv'], + }, + { + nodeId: 'park', + direction: 'UP', + relationLabel: '계측 장비 공급', + chain: ['ss', 'euv', 'park'], + }, + { + nodeId: 'fdry', + direction: 'UP', + relationLabel: '미세공정 필수', + chain: ['ss', 'euv', 'fdry'], + }, + ], + }, + { + id: 30003, + title: 'SK하이닉스, 첨단패키징 라인 증설 검토', + press: '연합뉴스', + publishedAt: TODAY, + url: 'https://www.yna.co.kr/', + summary: [ + 'SK하이닉스가 첨단패키징 라인 증설을 검토 중이라고 밝혔다.', + 'HBM 후공정 대응 능력 확대가 목적이다.', + '후공정 협력사 수주 기대감이 커지고 있다.', + ], + originStocks: [ + { + nodeId: 'sk', + direction: 'UP', + reason: '첨단패키징 라인 증설을 직접 검토하는 당사자', + }, + ], + relatedStocks: [ + { + nodeId: 'adpkg', + direction: 'UP', + relationLabel: 'HBM 패키징 공급', + chain: ['sk', 'adpkg'], + }, + { + nodeId: 'hanamc', + direction: 'UP', + relationLabel: '첨단패키징 후공정', + chain: ['sk', 'adpkg', 'hanamc'], + }, + { + nodeId: 'nepes', + direction: 'UP', + relationLabel: '첨단패키징 후공정', + chain: ['sk', 'adpkg', 'nepes'], + }, + { + nodeId: 'sfa', + direction: 'UP', + relationLabel: '첨단패키징 후공정', + chain: ['sk', 'adpkg', 'sfa'], + }, + ], + }, + { + id: 30004, + title: '웨이퍼 공정 소재 가격 상승… 한솔케미칼 수혜', + press: '머니투데이', + publishedAt: TODAY, + url: 'https://www.mt.co.kr/', + summary: [ + '웨이퍼 공정 소재 가격이 오르며 관련주가 강세다.', + '수급 타이트로 소재 업체 협상력이 개선되고 있다.', + '증설 투자로 이어질지 시장이 주목하고 있다.', + ], + originStocks: [ + { + nodeId: 'hansolchem', + direction: 'UP', + reason: '웨이퍼 공정 소재 가격 상승의 직접 수혜 당사자', + }, + ], + relatedStocks: [ + { + nodeId: 'wafermat', + direction: 'UP', + relationLabel: '웨이퍼 공정 소재 공급', + chain: ['hansolchem', 'wafermat'], + }, + { + nodeId: 'ss', + direction: 'UP', + relationLabel: '공정 소재 조달', + chain: ['hansolchem', 'wafermat', 'ss'], + }, + { + nodeId: 'enf', + direction: 'UP', + relationLabel: '포토레지스트 공급', + chain: ['hansolchem', 'wafermat', 'enf'], + }, + ], + }, + { + id: 30005, + title: '텔레칩스, 온디바이스 AI 확산에 차량용 구동칩 수주 확대', + press: '이데일리', + publishedAt: TODAY, + url: 'https://www.edaily.co.kr/', + summary: [ + '텔레칩스가 차량용 온디바이스 AI 구동칩 수주를 확대했다.', + '온디바이스 AI 탑재 차량이 늘어난 영향으로 풀이된다.', + 'AI 반도체 팹리스 전반으로 온기가 확산되는 모습이다.', + ], + originStocks: [ + { + nodeId: 'telechips', + direction: 'UP', + reason: '차량용 온디바이스 AI 구동칩 수주 확대의 직접 당사자', + }, + ], + relatedStocks: [ + { + nodeId: 'ondevice', + direction: 'UP', + relationLabel: '차량용 온디바이스 AI 칩 개발', + chain: ['telechips', 'ondevice'], + }, + { + nodeId: 'anapass', + direction: 'UP', + relationLabel: '온디바이스 AI 구동칩 개발', + chain: ['telechips', 'ondevice', 'anapass'], + }, + { + nodeId: 'abov', + direction: 'UP', + relationLabel: 'MCU 공급', + chain: ['telechips', 'ondevice', 'abov'], + }, + ], + }, + { + id: 30006, + title: '심텍, 유리기판 상용화 임박에 시제품 검증 마무리', + press: '파이낸셜뉴스', + publishedAt: TODAY, + url: 'https://www.fnnews.com/', + summary: [ + '심텍이 차세대 패키징용 유리기판 시제품 검증을 마무리했다.', + '상용화 일정이 당초 계획보다 앞당겨질 전망이다.', + 'AI 반도체 패키징 수요와 맞물려 주목받고 있다.', + ], + originStocks: [ + { + nodeId: 'simmtech', + direction: 'UP', + reason: '유리기판 시제품 검증 마무리의 직접 당사자', + }, + ], + relatedStocks: [ + { + nodeId: 'glass', + direction: 'UP', + relationLabel: '유리기판 개발', + chain: ['simmtech', 'glass'], + }, + { + nodeId: 'daeduck', + direction: 'UP', + relationLabel: '유리기판 개발', + chain: ['simmtech', 'glass', 'daeduck'], + }, + { + nodeId: 'aidc', + direction: 'UP', + relationLabel: '차세대 패키징 수요', + chain: ['simmtech', 'glass', 'aidc'], + }, + ], + }, + { + id: 30007, + title: 'SK하이닉스, CXL 표준 확산에 차세대 메모리 개발 박차', + press: '서울경제', + publishedAt: TODAY, + url: 'https://www.sedaily.com/', + summary: [ + 'SK하이닉스가 차세대 메모리 인터페이스 CXL 제품 개발에 속도를 내고 있다.', + '데이터센터향 메모리 확장 수요 대응이 목적이다.', + '관련 생태계 투자도 함께 확대될 전망이다.', + ], + originStocks: [ + { + nodeId: 'sk', + direction: 'UP', + reason: 'CXL 차세대 메모리 개발을 직접 주도하는 당사자', + }, + ], + relatedStocks: [ + { + nodeId: 'cxl', + direction: 'UP', + relationLabel: '차세대 메모리 인터페이스 개발', + chain: ['sk', 'cxl'], + }, + { + nodeId: 'ss', + direction: 'UP', + relationLabel: '차세대 메모리 인터페이스 개발', + chain: ['sk', 'cxl', 'ss'], + }, + { + nodeId: 'dram', + direction: 'UP', + relationLabel: '메모리 확장 표준', + chain: ['sk', 'cxl', 'dram'], + }, + ], + }, + { + id: 30008, + title: '후성, 특수가스 공급 부족 심화에 반사이익', + press: '뉴스1', + publishedAt: TODAY, + url: 'https://www.news1.kr/', + summary: [ + '후성이 반도체 공정용 특수가스 공급 부족의 반사이익을 누리고 있다.', + '증착·파운드리 공정 전반의 수요 확대가 배경이다.', + '가격 협상력도 함께 개선되는 모습이다.', + ], + originStocks: [ + { + nodeId: 'foosung', + direction: 'UP', + reason: '특수가스 공급 부족 심화의 직접 반사이익 당사자', + }, + ], + relatedStocks: [ + { + nodeId: 'specgas', + direction: 'UP', + relationLabel: '특수가스 공급', + chain: ['foosung', 'specgas'], + }, + { + nodeId: 'sk', + direction: 'UP', + relationLabel: '증착 공정 필수 가스', + chain: ['foosung', 'specgas', 'sk'], + }, + { + nodeId: 'fdry', + direction: 'UP', + relationLabel: '파운드리 공정 필수', + chain: ['foosung', 'specgas', 'fdry'], + }, + ], + }, + { + id: 30009, + title: '피에스케이, 대형 식각장비 수주 공시', + press: '아시아경제', + publishedAt: TODAY, + url: 'https://www.asiae.co.kr/', + summary: [ + '피에스케이가 대형 식각장비 공급 계약을 공시했다.', + '고객사 라인 증설에 대응하는 수주로 파악된다.', + '경쟁사 대비 수주 모멘텀이 부각되고 있다.', + ], + originStocks: [ + { + nodeId: 'psk', + direction: 'UP', + reason: '대형 식각장비 수주 공시의 직접 당사자', + }, + ], + relatedStocks: [ + { + nodeId: 'sk', + direction: 'UP', + relationLabel: '식각 장비 공급', + chain: ['psk', 'sk'], + }, + { + nodeId: 'jusung', + direction: 'DOWN', + relationLabel: '경쟁', + chain: ['psk', 'jusung'], + }, + ], + }, + { + id: 30010, + title: '코미코, 반도체 부품 세정 수주 확대', + press: '매일경제', + publishedAt: TODAY, + url: 'https://www.mk.co.kr/', + summary: [ + '코미코의 반도체 부품 세정·코팅 수주가 확대됐다.', + '고객사 가동률 상승이 배경으로 꼽힌다.', + '경쟁사 대비 점유율 확대가 기대된다.', + ], + originStocks: [ + { + nodeId: 'comico', + direction: 'UP', + reason: '부품 세정 코팅 수주 확대의 직접 당사자', + }, + ], + relatedStocks: [ + { + nodeId: 'sk', + direction: 'UP', + relationLabel: '부품 세정 코팅 공급', + chain: ['comico', 'sk'], + }, + { + nodeId: 'tck', + direction: 'DOWN', + relationLabel: '경쟁', + chain: ['comico', 'tck'], + }, + ], + }, +] + +const REFRESH_BATCH_SIZES = [3, 2, 2, 3] +let refreshClickCount = 0 +let refreshPoolCursor = 0 + +/** + * "새로고침" 클릭 데모용 — REFRESH_POOL을 순환하며 몇 건을 뽑아 발행 시각을 + * 지금으로 찍고 NEWS_RECORDS 맨 앞에 끼워 넣는다. 이미 나온 뉴스라도 풀을 + * 다 돌면 새 id로 다시 등장한다(데모를 몇 번이고 반복할 수 있도록). + */ +export function pullRefreshBatch(): NewsRecord[] { + const batchSize = + REFRESH_BATCH_SIZES[refreshClickCount % REFRESH_BATCH_SIZES.length] + refreshClickCount += 1 + + const now = Date.now() + const items: NewsRecord[] = [] + for (let i = 0; i < batchSize; i++) { + const template = REFRESH_POOL[refreshPoolCursor % REFRESH_POOL.length] + const cycle = Math.floor(refreshPoolCursor / REFRESH_POOL.length) + refreshPoolCursor += 1 + items.push({ + ...template, + id: template.id + cycle * 10000, + publishedAt: new Date(now - i * 45_000).toISOString(), + }) + } + + NEWS_RECORDS.unshift(...items) + return items +} + const STOCK_IDS = RAW_COMPANIES.map(([id]) => id) const nodeNameById = new Map([ ...RAW_COMPANIES.map(([id, name]): [string, string] => [id, name]), @@ -454,7 +1093,9 @@ function buildFillerNews(count: number, startId: number): NewsRecord[] { publishedAt: cursor.toISOString(), url: 'https://example.com/', summary: template.summary(originName), - originStocks: [{ nodeId: originId, direction, reason: template.reason(originName) }], + originStocks: [ + { nodeId: originId, direction, reason: template.reason(originName) }, + ], relatedStocks, }) } diff --git a/src/lib/layout.ts b/src/lib/layout.ts index 1e281e9..5e36f9a 100644 --- a/src/lib/layout.ts +++ b/src/lib/layout.ts @@ -53,15 +53,19 @@ const SECTOR_CENTERS: Record = { export interface FullLayout { pos: Record sectorLabelPos: Record + /** 연결 수 기준 최고 허브 노드 — 진입 인트로 하이라이트 기점으로 재사용된다 */ + centerId: string } /** * 섹터별 고정 좌표(현재는 반도체 단일 클러스터, 중앙 배치). 클러스터 안은 연결 수 - * 내림차순 동심원. 호출부(GraphPanel)에서 useMemo로 캐시해 뷰 전환 시 재계산되지 않게 한다. + * 내림차순 3단 동심원(51개 규모에서 바깥 링 하나에 다 몰아넣으면 카드가 겹친다). + * 호출부(GraphPanel)에서 useMemo로 캐시해 뷰 전환 시 재계산되지 않게 한다. */ export function fullLayout(): FullLayout { const pos: Record = {} const sectorLabelPos: Record = {} + let centerId = '' function ring( nodes: OntologyNode[], @@ -87,18 +91,15 @@ export function fullLayout(): FullLayout { if (list.length === 0) return pos[list[0].id] = { x: center.x, y: center.y } + centerId = list[0].id const rest = list.slice(1) - ring(rest.slice(0, 6), center, 275, -Math.PI / 2) - ring( - rest.slice(6), - center, - 480, - -Math.PI / 2 + Math.PI / Math.max(rest.length - 6, 1), - ) + ring(rest.slice(0, 8), center, 260, -Math.PI / 2) + ring(rest.slice(8, 24), center, 460, -Math.PI / 2 + Math.PI / 8) + ring(rest.slice(24), center, 700, -Math.PI / 2 + Math.PI / 16) sectorLabelPos[sector] = { x: center.x, y: center.y - 330 } }) - return { pos, sectorLabelPos } + return { pos, sectorLabelPos, centerId } } /** 전체 관계망 위계(연결 수 기준 3단계) — 진한 잉크(t1)일수록 핵심 노드 */ diff --git a/src/styles.css b/src/styles.css index 87525a0..bc34da5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -186,6 +186,43 @@ body { opacity: 0.5; cursor: default; } +/* 새로고침으로 새 뉴스가 몇 건 들어왔는지 알려주는 배너 — 그래프 임팩트와 + 무관한 일반 UI 알림이라 주황은 쓰지 않는다(koslink-design-constraints). */ +.news-refresh-toast { + margin: 0 14px 10px; + padding: 7px 12px; + border-radius: 9px; + background: var(--n-900); + color: #fff; + font-size: 12px; + font-weight: 600; + text-align: center; + animation: koslink-toast-in 0.25s ease-out; +} +@keyframes koslink-toast-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +/* 새로고침으로 방금 추가된 카드 표시 — "새로움"이라는 별개 의미라 그래프 임팩트 + 색(주황)과 겹치지 않게 미사용 토큰인 --ok(초록)를 쓴다. */ +.news-new-badge { + display: inline-block; + margin-left: 6px; + padding: 1px 6px; + border-radius: 5px; + font-size: 9.5px; + font-weight: 800; + letter-spacing: 0.02em; + color: #fff; + background: var(--ok); + vertical-align: middle; +} .pbody { flex: 1; overflow-y: auto;