From 8aa7dee0d9e4653bd9841bd63ce4414c07748cf6 Mon Sep 17 00:00:00 2001 From: 10tentacion Date: Tue, 28 Jul 2026 11:17:57 +0900 Subject: [PATCH 01/10] =?UTF-8?q?refactor:=20=EB=A9=94=EC=9D=B8=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=A7=84=EC=9E=85=20=EC=8B=9C=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EA=B4=80=EA=B3=84=EB=A7=9D=20=EA=B7=B8=EB=9E=98?= =?UTF-8?q?=ED=94=84=20=ED=83=AD=EC=9D=B4=20=EB=A8=BC=EC=A0=80=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Header.tsx | 2 +- src/lib/atoms.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/lib/atoms.ts b/src/lib/atoms.ts index 8483778..247458d 100644 --- a/src/lib/atoms.ts +++ b/src/lib/atoms.ts @@ -6,7 +6,7 @@ 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) From ac7fc7feafdce904ee0a7ddec5d9f1d095fe2129 Mon Sep 17 00:00:00 2001 From: 10tentacion Date: Tue, 28 Jul 2026 11:38:41 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=EC=A0=84=EC=B2=B4=20=EA=B4=80?= =?UTF-8?q?=EA=B3=84=EB=A7=9D=20mock=20=EB=85=B8=EB=93=9C=2051=EA=B0=9C?= =?UTF-8?q?=EB=A1=9C=20=ED=99=95=EC=9E=A5=20+=20=EC=A7=84=EC=9E=85=20?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=EB=A1=9C=20=ED=95=98=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8/=EC=A4=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 반도체 종목/개념노드를 51개(관계 74개)로 확장하고 fullLayout을 3단 링으로 재배치 - 커버 화면 -> /app 최초 진입 시 허브 노드를 한 번 자동 하이라이트하고, 하이라이트된 서브그래프에 맞춰 fitView가 줌인되도록 변경 Co-Authored-By: Claude Sonnet 5 --- src/components/graph/GraphPanel.tsx | 16 ++++- src/components/graph/NetworkView.tsx | 19 +++-- src/lib/atoms.ts | 3 + src/lib/data.ts | 100 +++++++++++++++++++++++++++ src/lib/layout.ts | 19 ++--- 5 files changed, 141 insertions(+), 16 deletions(-) diff --git a/src/components/graph/GraphPanel.tsx b/src/components/graph/GraphPanel.tsx index ae122c3..837180e 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 @@ -278,9 +279,20 @@ export function GraphCanvas({ scene2.scene, ) + // 하이라이트 중(scene2.scene 존재)이면 기점+리빌 노드로만 fitView를 좁혀서 + // 보기 좋은 크기로 줌인시키고, 아니면(전체 보기) 지금처럼 전체 노드에 맞춘다. useEffect(() => { + const scene = scene2.scene + 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 } : {}), + }), 60, ) return () => window.clearTimeout(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/lib/atoms.ts b/src/lib/atoms.ts index 247458d..7f9bbdb 100644 --- a/src/lib/atoms.ts +++ b/src/lib/atoms.ts @@ -13,6 +13,9 @@ 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[] diff --git a/src/lib/data.ts b/src/lib/data.ts index ba3b4a4..766086b 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( 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)일수록 핵심 노드 */ From bb06bd3d2f4be9fc34a637341f6fab3977641ce2 Mon Sep 17 00:00:00 2001 From: 10tentacion Date: Tue, 28 Jul 2026 11:53:09 +0900 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20=EC=A0=84=EC=B2=B4=20=EA=B4=80?= =?UTF-8?q?=EA=B3=84=EB=A7=9D=20=ED=95=98=EC=9D=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=A4=8C=EC=9D=B8=20=EC=8B=9C=20minZoom=20?= =?UTF-8?q?=ED=95=98=ED=95=9C=EC=84=A0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SK하이닉스처럼 연결이 많은 허브를 하이라이트하면 리빌 노드를 전부 담으려다 다시 크게 줌아웃되던 문제를 고침 — 일부 노드가 화면 밖으로 밀려나더라도 카드가 잘 보이는 배율 밑으로는 내려가지 않도록 함 Co-Authored-By: Claude Sonnet 5 --- src/components/graph/GraphPanel.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/graph/GraphPanel.tsx b/src/components/graph/GraphPanel.tsx index 837180e..56215c1 100644 --- a/src/components/graph/GraphPanel.tsx +++ b/src/components/graph/GraphPanel.tsx @@ -281,6 +281,9 @@ export function GraphCanvas({ // 하이라이트 중(scene2.scene 존재)이면 기점+리빌 노드로만 fitView를 좁혀서 // 보기 좋은 크기로 줌인시키고, 아니면(전체 보기) 지금처럼 전체 노드에 맞춘다. + // SK하이닉스처럼 연결이 아주 많은 허브는 리빌 노드 전부를 담으려 하면 다시 + // 확 줌아웃돼 버리므로, 하이라이트 중에는 minZoom으로 하한선을 둬 일부 노드가 + // 화면 밖으로 밀려나더라도 카드가 잘 보이는 크기 밑으로는 안 내려가게 한다. useEffect(() => { const scene = scene2.scene const targetNodes = scene @@ -291,7 +294,7 @@ export function GraphCanvas({ fitView({ padding: 0.2, duration: 400, - ...(targetNodes ? { nodes: targetNodes } : {}), + ...(targetNodes ? { nodes: targetNodes, minZoom: 0.7 } : {}), }), 60, ) From 5987429e7081f5d1c107535bdb51f9f9fd467100 Mon Sep 17 00:00:00 2001 From: 10tentacion Date: Tue, 28 Jul 2026 12:04:37 +0900 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20=ED=95=98=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=A6=AC=EB=B9=8C=20=EC=95=A0=EB=8B=88?= =?UTF-8?q?=EB=A9=94=EC=9D=B4=EC=85=98=EC=9D=B4=20=EB=81=9D=EB=82=9C=20?= =?UTF-8?q?=EB=92=A4=EC=97=90=20=EC=A4=8C=EC=9D=B8=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존에는 하이라이트 시작과 동시에 서브그래프로 줌인해서 확대된 화면 위에서 리빌 애니메이션이 재생됐다. usePropagation의 settled 상태를 확인해, 리빌이 다 끝나기 전에는 기존 화면 그대로 하이라이트가 퍼지는 걸 보여주고, 끝난 뒤에야 fitView로 줌인하도록 순서를 바꿈 Co-Authored-By: Claude Sonnet 5 --- src/components/graph/GraphPanel.tsx | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/components/graph/GraphPanel.tsx b/src/components/graph/GraphPanel.tsx index 56215c1..c77b608 100644 --- a/src/components/graph/GraphPanel.tsx +++ b/src/components/graph/GraphPanel.tsx @@ -275,17 +275,21 @@ export function GraphCanvas({ const prevNodesRef = useRef(new Map()) const prevEdgesRef = useRef(new Map()) - const { nodeOverrides, edgeOverrides, stageText, tick } = usePropagation( - scene2.scene, - ) - - // 하이라이트 중(scene2.scene 존재)이면 기점+리빌 노드로만 fitView를 좁혀서 - // 보기 좋은 크기로 줌인시키고, 아니면(전체 보기) 지금처럼 전체 노드에 맞춘다. - // SK하이닉스처럼 연결이 아주 많은 허브는 리빌 노드 전부를 담으려 하면 다시 - // 확 줌아웃돼 버리므로, 하이라이트 중에는 minZoom으로 하한선을 둬 일부 노드가 - // 화면 밖으로 밀려나더라도 카드가 잘 보이는 크기 밑으로는 안 내려가게 한다. + const { nodeOverrides, edgeOverrides, stageText, tick, settled } = + usePropagation(scene2.scene) + + // 하이라이트가 없으면(전체 보기) 지금처럼 전체 노드에 즉시 맞춘다. + // 하이라이트 중(scene2.scene 존재)이면 리빌 애니메이션이 끝나(settled) + // 진정될 때까지는 줌을 건드리지 않고 기존 화면 그대로 하이라이트가 퍼지는 + // 걸 보여준 뒤, 다 끝나면 그제서야 기점+리빌 노드로만 fitView를 좁혀 + // 보기 좋은 크기로 줌인한다. SK하이닉스처럼 연결이 아주 많은 허브는 리빌 + // 노드 전부를 담으려 하면 다시 확 줌아웃돼 버리므로, minZoom으로 하한선을 + // 둬 일부 노드가 화면 밖으로 밀려나더라도 카드가 잘 보이는 크기 밑으로는 + // 안 내려가게 한다. useEffect(() => { const scene = scene2.scene + if (scene && !settled) return + const targetNodes = scene ? [scene.originId, ...scene.nodes.map((n) => n.id)].map((id) => ({ id })) : undefined @@ -299,7 +303,7 @@ export function GraphCanvas({ 60, ) return () => window.clearTimeout(id) - }, [fitView, scene2.scene?.key, scene2.ids.length]) + }, [fitView, scene2.scene?.key, scene2.ids.length, settled]) // scene2/override가 실제로 바뀔 때만 재계산한다. React Flow는 노드/엣지를 id별로 // 내부 스토어에 구독시키기 때문에, 값이 같아도 매 틱마다 전체 배열을 새 객체로 From e69e0fbb35ca5e095e88e984daab9ff3ff663b30 Mon Sep 17 00:00:00 2001 From: 10tentacion Date: Tue, 28 Jul 2026 12:07:41 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20=ED=95=98=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=A4=8C=EC=9D=B8=20=EB=B0=B0=EC=9C=A8?= =?UTF-8?q?=EC=9D=84=200.7=20->=201.0=EC=9C=BC=EB=A1=9C=20=EC=83=81?= =?UTF-8?q?=ED=96=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카드가 더 크고 선명하게 보이도록 minZoom 하한선을 높임 Co-Authored-By: Claude Sonnet 5 --- src/components/graph/GraphPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/graph/GraphPanel.tsx b/src/components/graph/GraphPanel.tsx index c77b608..fc927a4 100644 --- a/src/components/graph/GraphPanel.tsx +++ b/src/components/graph/GraphPanel.tsx @@ -298,7 +298,7 @@ export function GraphCanvas({ fitView({ padding: 0.2, duration: 400, - ...(targetNodes ? { nodes: targetNodes, minZoom: 0.7 } : {}), + ...(targetNodes ? { nodes: targetNodes, minZoom: 1 } : {}), }), 60, ) From 96a68469910a7ddffd7dbbdc148b9afb582b9abd Mon Sep 17 00:00:00 2001 From: 10tentacion Date: Tue, 28 Jul 2026 13:03:13 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=ED=95=98=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=84=9C=EB=B8=8C=EA=B7=B8=EB=9E=98?= =?UTF-8?q?=ED=94=84=20=EC=A4=8C=EC=9D=B8=EC=9D=84=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EA=B4=80=EA=B3=84=EB=A7=9D(all)=EC=97=90=EB=A7=8C=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fitView 이펙트가 mode를 구분하지 않아 뉴스맵의 관계 그래프(focus)에도 settled 대기 + minZoom 하한선이 적용되고 있었다. mode==='focus'는 기존처럼 즉시 전체 노드에 fitView하고, mode==='all'(전체 관계망)에서만 리빌 완료 후 서브그래프로 줌인하도록 분기 Co-Authored-By: Claude Sonnet 5 --- src/components/graph/GraphPanel.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/components/graph/GraphPanel.tsx b/src/components/graph/GraphPanel.tsx index fc927a4..f470aaa 100644 --- a/src/components/graph/GraphPanel.tsx +++ b/src/components/graph/GraphPanel.tsx @@ -278,7 +278,11 @@ export function GraphCanvas({ const { nodeOverrides, edgeOverrides, stageText, tick, settled } = usePropagation(scene2.scene) - // 하이라이트가 없으면(전체 보기) 지금처럼 전체 노드에 즉시 맞춘다. + // mode==='focus'(뉴스 파급 경로, GraphPanel)는 지금까지처럼 항상 전체 노드에 + // 즉시 fitView한다 — 아래 서브그래프 확대 줌은 mode==='all'(전체 관계망, + // NetworkView)에서만 적용한다. + // + // mode==='all'에서 하이라이트가 없으면(전체 보기) 전체 노드에 즉시 맞추고, // 하이라이트 중(scene2.scene 존재)이면 리빌 애니메이션이 끝나(settled) // 진정될 때까지는 줌을 건드리지 않고 기존 화면 그대로 하이라이트가 퍼지는 // 걸 보여준 뒤, 다 끝나면 그제서야 기점+리빌 노드로만 fitView를 좁혀 @@ -287,6 +291,14 @@ export function GraphCanvas({ // 둬 일부 노드가 화면 밖으로 밀려나더라도 카드가 잘 보이는 크기 밑으로는 // 안 내려가게 한다. 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 @@ -303,7 +315,7 @@ export function GraphCanvas({ 60, ) return () => window.clearTimeout(id) - }, [fitView, scene2.scene?.key, scene2.ids.length, settled]) + }, [fitView, mode, scene2.scene?.key, scene2.ids.length, settled]) // scene2/override가 실제로 바뀔 때만 재계산한다. React Flow는 노드/엣지를 id별로 // 내부 스토어에 구독시키기 때문에, 값이 같아도 매 틱마다 전체 배열을 새 객체로 From 02f071d9567054e6e209395d194dc16d36a03b03 Mon Sep 17 00:00:00 2001 From: 10tentacion Date: Tue, 28 Jul 2026 13:13:03 +0900 Subject: [PATCH 07/10] =?UTF-8?q?style:=20=EC=A0=84=EC=B2=B4=20=EA=B4=80?= =?UTF-8?q?=EA=B3=84=EB=A7=9D=20=EA=B8=B0=EC=A0=90=20=EB=85=B8=EB=93=9C=20?= =?UTF-8?q?=EC=83=89=EC=9D=84=20=EA=B2=80=EC=A0=95=20->=20=EC=A3=BC?= =?UTF-8?q?=ED=99=A9(#F26722)=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .nd.origin이 var(--n-900)(검정)을 쓰고 있었는데, 그래프 임팩트 기점이라는 의미상 디자인 가이드라인(주황은 그래프 임팩트 전용)에 맞춰 .nd.up과 같은 --o-500 주황 배경으로 통일 Co-Authored-By: Claude Sonnet 5 --- src/components/graph/graph.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 { From 6f99e21a0f30abe76de6bf7120b31ce3c5ca3882 Mon Sep 17 00:00:00 2001 From: 10tentacion Date: Tue, 28 Jul 2026 13:40:39 +0900 Subject: [PATCH 08/10] =?UTF-8?q?feat:=20=EC=B5=9C=EC=8B=A0=20=EB=89=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=EC=97=90=20?= =?UTF-8?q?=EC=8B=A0=EA=B7=9C=20=EA=B1=B4=EC=88=98=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?+=20=EB=8D=B0=EB=AA=A8=EC=9A=A9=20mock=20=EC=83=88=20=EB=89=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - data.ts에 REFRESH_POOL(10건)과 pullRefreshBatch()를 추가해 새로고침을 누를 때마다 새 뉴스 몇 건을 순환 발급해 목록 맨 앞에 끼워 넣도록 함(시연용) - lib/api.ts에 refreshNews() 래퍼 추가, NewsPanel의 새로고침 버튼이 이를 호출한 뒤 뉴스 쿼리를 첫 페이지부터 재조회 - 새로고침 결과("새 뉴스 N건이 도착했습니다")를 알리는 토스트 배너와, 새로 추가된 카드에 NEW 배지를 표시 — 그래프 임팩트 전용인 주황 대신 중립/--ok 토큰 사용 Co-Authored-By: Claude Sonnet 5 --- src/components/news/NewsCard.tsx | 9 +- src/components/news/NewsList.tsx | 10 +- src/components/news/NewsPanel.tsx | 40 ++++- src/lib/api.ts | 12 ++ src/lib/atoms.ts | 3 + src/lib/data.ts | 279 ++++++++++++++++++++++++++++++ src/styles.css | 37 ++++ 7 files changed, 382 insertions(+), 8 deletions(-) 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 7f9bbdb..a7b4829 100644 --- a/src/lib/atoms.ts +++ b/src/lib/atoms.ts @@ -26,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 766086b..b8224fb 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -432,6 +432,285 @@ const CURATED_NEWS: NewsRecord[] = [ }, ] +/** + * "최신 뉴스 새로고침" 시연용 풀. 실 백엔드가 없으니 새로고침 버튼을 누를 때마다 + * pullRefreshBatch()가 여기서 몇 건을 순환해 뽑아 NEWS_RECORDS 맨 앞에 끼워 넣는다. + * 실 API가 준비되면 이 풀과 pullRefreshBatch는 통째로 지우고 새로고침을 그냥 + * 쿼리 재조회로 대체하면 된다. + */ +const REFRESH_POOL: NewsRecord[] = [ + { + id: 30001, + title: 'HBM 검증 수요 급증… 유니테스트·테스나 테스트 장비 발주 확대', + press: '전자신문', + publishedAt: TODAY, + url: 'https://www.etnews.com/', + summary: [ + 'HBM 검증 물량이 늘면서 테스트 장비 발주가 확대되고 있다.', + '후공정 테스트 업체들의 가동률이 동반 상승하는 모습이다.', + '하반기까지 관련 수주가 이어질 것으로 전망된다.', + ], + originStocks: [ + { + nodeId: 'testinsp', + direction: 'UP', + reason: 'HBM 검증 수요 급증이 직접 확인된 테마', + }, + ], + relatedStocks: [ + { nodeId: 'unitest', direction: 'UP', relationLabel: '테스트 장비 공급', chain: ['testinsp', 'unitest'] }, + { nodeId: 'testna', direction: 'UP', relationLabel: '테스트 서비스', chain: ['testinsp', 'testna'] }, + { nodeId: 'isc', direction: 'UP', relationLabel: '테스트 소켓 공급', chain: ['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: 'wafermat', + direction: 'UP', + reason: '웨이퍼 공정 소재 가격 상승이 직접 확인된 품목', + }, + ], + relatedStocks: [ + { nodeId: 'hansolchem', direction: 'UP', relationLabel: '웨이퍼 공정 소재 공급', chain: ['wafermat', 'hansolchem'] }, + { nodeId: 'enf', direction: 'UP', relationLabel: '포토레지스트 공급', chain: ['wafermat', 'enf'] }, + { nodeId: 'dnf', direction: 'UP', relationLabel: '전구체 공급', chain: ['wafermat', 'dnf'] }, + ], + }, + { + id: 30005, + title: '온디바이스 AI 확산… 텔레칩스·아나패스 강세', + press: '이데일리', + publishedAt: TODAY, + url: 'https://www.edaily.co.kr/', + summary: [ + '온디바이스 AI 탑재 기기가 확산되며 관련 팹리스가 강세다.', + '차량용·가전용 구동칩 수요가 함께 늘고 있다.', + 'AI 반도체 전반으로 온기가 확산되는 모습이다.', + ], + originStocks: [ + { + nodeId: 'ondevice', + direction: 'UP', + reason: '온디바이스 AI 수요 확산이 직접 확인된 테마', + }, + ], + relatedStocks: [ + { nodeId: 'telechips', direction: 'UP', relationLabel: '차량용 온디바이스 AI 칩 개발', chain: ['ondevice', 'telechips'] }, + { nodeId: 'anapass', direction: 'UP', relationLabel: '온디바이스 AI 구동칩 개발', chain: ['ondevice', 'anapass'] }, + { nodeId: 'abov', direction: 'UP', relationLabel: 'MCU 공급', chain: ['ondevice', 'abov'] }, + ], + }, + { + id: 30006, + title: '유리기판 상용화 임박… 관련주 재조명', + press: '파이낸셜뉴스', + publishedAt: TODAY, + url: 'https://www.fnnews.com/', + summary: [ + '차세대 패키징용 유리기판 상용화 일정이 앞당겨지고 있다.', + '기판 업체들의 시제품 검증이 마무리 단계에 접어들었다.', + 'AI 반도체 패키징 수요와 맞물려 주목받고 있다.', + ], + originStocks: [ + { + nodeId: 'glass', + direction: 'UP', + reason: '유리기판 상용화 일정 단축이 직접 확인된 테마', + }, + ], + relatedStocks: [ + { nodeId: 'simmtech', direction: 'UP', relationLabel: '유리기판 개발', chain: ['glass', 'simmtech'] }, + { nodeId: 'daeduck', direction: 'UP', relationLabel: '유리기판 개발', chain: ['glass', 'daeduck'] }, + { nodeId: 'aidc', direction: 'UP', relationLabel: '차세대 패키징 수요', chain: ['glass', 'aidc'] }, + ], + }, + { + id: 30007, + title: 'CXL 표준 확산… 메모리 대형주 재조명', + press: '서울경제', + publishedAt: TODAY, + url: 'https://www.sedaily.com/', + summary: [ + '차세대 메모리 인터페이스 CXL 채택이 확산되고 있다.', + '메모리 확장 수요가 늘며 대형 메모리 업체가 주목받는다.', + '관련 생태계 투자도 함께 확대될 전망이다.', + ], + originStocks: [ + { + nodeId: 'cxl', + direction: 'UP', + reason: 'CXL 표준 채택 확산이 직접 확인된 테마', + }, + ], + relatedStocks: [ + { nodeId: 'ss', direction: 'UP', relationLabel: '차세대 메모리 인터페이스 개발', chain: ['cxl', 'ss'] }, + { nodeId: 'sk', direction: 'UP', relationLabel: '차세대 메모리 인터페이스 개발', chain: ['cxl', 'sk'] }, + { nodeId: 'dram', direction: 'UP', relationLabel: '메모리 확장 표준', chain: ['cxl', 'dram'] }, + ], + }, + { + id: 30008, + title: '특수가스 공급 부족 심화… 후성 반사이익', + press: '뉴스1', + publishedAt: TODAY, + url: 'https://www.news1.kr/', + summary: [ + '반도체 공정용 특수가스 공급 부족이 심화하고 있다.', + '증착·파운드리 공정 전반에 영향이 확산되는 모습이다.', + '공급사의 가격 협상력이 개선되고 있다.', + ], + originStocks: [ + { + nodeId: 'specgas', + direction: 'UP', + reason: '특수가스 공급 부족 심화가 직접 확인된 품목', + }, + ], + relatedStocks: [ + { nodeId: 'foosung', direction: 'UP', relationLabel: '특수가스 공급', chain: ['specgas', 'foosung'] }, + { nodeId: 'sk', direction: 'UP', relationLabel: '증착 공정 필수 가스', chain: ['specgas', 'sk'] }, + { nodeId: 'fdry', direction: 'UP', relationLabel: '파운드리 공정 필수', chain: ['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]), 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; From 8b476f5e85053e3e099287bd75ff3c110339cc01 Mon Sep 17 00:00:00 2001 From: 10tentacion Date: Tue, 28 Jul 2026 13:48:10 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20=EC=83=88=EB=A1=9C=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8=20=EB=8D=B0=EB=AA=A8=20=EB=89=B4=EC=8A=A4=EC=9D=98=20?= =?UTF-8?q?=EC=98=81=ED=96=A5=20=EA=B8=B0=EC=A0=90=EC=9D=84=20=EA=B0=9C?= =?UTF-8?q?=EB=85=90=EB=85=B8=EB=93=9C=20->=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EA=B8=B0=EC=97=85=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REFRESH_POOL 10건 중 6건이 CONCEPT 노드(테스트·검사, 웨이퍼소재, 온디바이스AI, 유리기판, CXL, 특수가스)를 영향 기점으로 쓰고 있어 "영향 기점" 패널에 티커 없는 테마명이 노출됐다. 각 헤드라인의 기점을 해당 테마와 직접 연결된 실제 종목(유니테스트, 한솔케미칼, 텔레칩스, 심텍, SK하이닉스, 후성)으로 바꿈 Co-Authored-By: Claude Sonnet 5 --- src/lib/data.ts | 94 ++++++++++++++++++++++++------------------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/src/lib/data.ts b/src/lib/data.ts index b8224fb..7f9a98e 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -441,26 +441,26 @@ const CURATED_NEWS: NewsRecord[] = [ const REFRESH_POOL: NewsRecord[] = [ { id: 30001, - title: 'HBM 검증 수요 급증… 유니테스트·테스나 테스트 장비 발주 확대', + title: '유니테스트, HBM 검증 수요 급증에 테스트 장비 수주 확대', press: '전자신문', publishedAt: TODAY, url: 'https://www.etnews.com/', summary: [ - 'HBM 검증 물량이 늘면서 테스트 장비 발주가 확대되고 있다.', - '후공정 테스트 업체들의 가동률이 동반 상승하는 모습이다.', + '유니테스트가 HBM 검증용 테스트 장비 수주를 확대했다고 밝혔다.', + 'HBM 검증 물량 증가가 수주 확대의 배경으로 꼽힌다.', '하반기까지 관련 수주가 이어질 것으로 전망된다.', ], originStocks: [ { - nodeId: 'testinsp', + nodeId: 'unitest', direction: 'UP', - reason: 'HBM 검증 수요 급증이 직접 확인된 테마', + reason: 'HBM 검증용 테스트 장비 수주 확대의 직접 당사자', }, ], relatedStocks: [ - { nodeId: 'unitest', direction: 'UP', relationLabel: '테스트 장비 공급', chain: ['testinsp', 'unitest'] }, - { nodeId: 'testna', direction: 'UP', relationLabel: '테스트 서비스', chain: ['testinsp', 'testna'] }, - { nodeId: 'isc', direction: 'UP', relationLabel: '테스트 소켓 공급', chain: ['testinsp', 'isc'] }, + { 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'] }, ], }, { @@ -525,111 +525,111 @@ const REFRESH_POOL: NewsRecord[] = [ ], originStocks: [ { - nodeId: 'wafermat', + nodeId: 'hansolchem', direction: 'UP', - reason: '웨이퍼 공정 소재 가격 상승이 직접 확인된 품목', + reason: '웨이퍼 공정 소재 가격 상승의 직접 수혜 당사자', }, ], relatedStocks: [ - { nodeId: 'hansolchem', direction: 'UP', relationLabel: '웨이퍼 공정 소재 공급', chain: ['wafermat', 'hansolchem'] }, - { nodeId: 'enf', direction: 'UP', relationLabel: '포토레지스트 공급', chain: ['wafermat', 'enf'] }, - { nodeId: 'dnf', direction: 'UP', relationLabel: '전구체 공급', chain: ['wafermat', 'dnf'] }, + { 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 확산… 텔레칩스·아나패스 강세', + title: '텔레칩스, 온디바이스 AI 확산에 차량용 구동칩 수주 확대', press: '이데일리', publishedAt: TODAY, url: 'https://www.edaily.co.kr/', summary: [ - '온디바이스 AI 탑재 기기가 확산되며 관련 팹리스가 강세다.', - '차량용·가전용 구동칩 수요가 함께 늘고 있다.', - 'AI 반도체 전반으로 온기가 확산되는 모습이다.', + '텔레칩스가 차량용 온디바이스 AI 구동칩 수주를 확대했다.', + '온디바이스 AI 탑재 차량이 늘어난 영향으로 풀이된다.', + 'AI 반도체 팹리스 전반으로 온기가 확산되는 모습이다.', ], originStocks: [ { - nodeId: 'ondevice', + nodeId: 'telechips', direction: 'UP', - reason: '온디바이스 AI 수요 확산이 직접 확인된 테마', + reason: '차량용 온디바이스 AI 구동칩 수주 확대의 직접 당사자', }, ], relatedStocks: [ - { nodeId: 'telechips', direction: 'UP', relationLabel: '차량용 온디바이스 AI 칩 개발', chain: ['ondevice', 'telechips'] }, - { nodeId: 'anapass', direction: 'UP', relationLabel: '온디바이스 AI 구동칩 개발', chain: ['ondevice', 'anapass'] }, - { nodeId: 'abov', direction: 'UP', relationLabel: 'MCU 공급', chain: ['ondevice', 'abov'] }, + { 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: '유리기판 상용화 임박… 관련주 재조명', + title: '심텍, 유리기판 상용화 임박에 시제품 검증 마무리', press: '파이낸셜뉴스', publishedAt: TODAY, url: 'https://www.fnnews.com/', summary: [ - '차세대 패키징용 유리기판 상용화 일정이 앞당겨지고 있다.', - '기판 업체들의 시제품 검증이 마무리 단계에 접어들었다.', + '심텍이 차세대 패키징용 유리기판 시제품 검증을 마무리했다.', + '상용화 일정이 당초 계획보다 앞당겨질 전망이다.', 'AI 반도체 패키징 수요와 맞물려 주목받고 있다.', ], originStocks: [ { - nodeId: 'glass', + nodeId: 'simmtech', direction: 'UP', - reason: '유리기판 상용화 일정 단축이 직접 확인된 테마', + reason: '유리기판 시제품 검증 마무리의 직접 당사자', }, ], relatedStocks: [ - { nodeId: 'simmtech', direction: 'UP', relationLabel: '유리기판 개발', chain: ['glass', 'simmtech'] }, - { nodeId: 'daeduck', direction: 'UP', relationLabel: '유리기판 개발', chain: ['glass', 'daeduck'] }, - { nodeId: 'aidc', direction: 'UP', relationLabel: '차세대 패키징 수요', chain: ['glass', 'aidc'] }, + { 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: 'CXL 표준 확산… 메모리 대형주 재조명', + title: 'SK하이닉스, CXL 표준 확산에 차세대 메모리 개발 박차', press: '서울경제', publishedAt: TODAY, url: 'https://www.sedaily.com/', summary: [ - '차세대 메모리 인터페이스 CXL 채택이 확산되고 있다.', - '메모리 확장 수요가 늘며 대형 메모리 업체가 주목받는다.', + 'SK하이닉스가 차세대 메모리 인터페이스 CXL 제품 개발에 속도를 내고 있다.', + '데이터센터향 메모리 확장 수요 대응이 목적이다.', '관련 생태계 투자도 함께 확대될 전망이다.', ], originStocks: [ { - nodeId: 'cxl', + nodeId: 'sk', direction: 'UP', - reason: 'CXL 표준 채택 확산이 직접 확인된 테마', + reason: 'CXL 차세대 메모리 개발을 직접 주도하는 당사자', }, ], relatedStocks: [ - { nodeId: 'ss', direction: 'UP', relationLabel: '차세대 메모리 인터페이스 개발', chain: ['cxl', 'ss'] }, - { nodeId: 'sk', direction: 'UP', relationLabel: '차세대 메모리 인터페이스 개발', chain: ['cxl', 'sk'] }, - { nodeId: 'dram', direction: 'UP', relationLabel: '메모리 확장 표준', chain: ['cxl', 'dram'] }, + { 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: '특수가스 공급 부족 심화… 후성 반사이익', + title: '후성, 특수가스 공급 부족 심화에 반사이익', press: '뉴스1', publishedAt: TODAY, url: 'https://www.news1.kr/', summary: [ - '반도체 공정용 특수가스 공급 부족이 심화하고 있다.', - '증착·파운드리 공정 전반에 영향이 확산되는 모습이다.', - '공급사의 가격 협상력이 개선되고 있다.', + '후성이 반도체 공정용 특수가스 공급 부족의 반사이익을 누리고 있다.', + '증착·파운드리 공정 전반의 수요 확대가 배경이다.', + '가격 협상력도 함께 개선되는 모습이다.', ], originStocks: [ { - nodeId: 'specgas', + nodeId: 'foosung', direction: 'UP', - reason: '특수가스 공급 부족 심화가 직접 확인된 품목', + reason: '특수가스 공급 부족 심화의 직접 반사이익 당사자', }, ], relatedStocks: [ - { nodeId: 'foosung', direction: 'UP', relationLabel: '특수가스 공급', chain: ['specgas', 'foosung'] }, - { nodeId: 'sk', direction: 'UP', relationLabel: '증착 공정 필수 가스', chain: ['specgas', 'sk'] }, - { nodeId: 'fdry', direction: 'UP', relationLabel: '파운드리 공정 필수', chain: ['specgas', 'fdry'] }, + { 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'] }, ], }, { From 31effb0d44c9b7d7c20f11b142920817300b0b72 Mon Sep 17 00:00:00 2001 From: 10tentacion Date: Tue, 28 Jul 2026 13:53:56 +0900 Subject: [PATCH 10/10] =?UTF-8?q?chore:=20mock=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/data.ts | 370 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 316 insertions(+), 54 deletions(-) diff --git a/src/lib/data.ts b/src/lib/data.ts index 7f9a98e..b05188d 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -68,7 +68,7 @@ const RAW_CONCEPTS: [string, string, string][] = [ ['dram', 'D램', '반도체'], ['fdry', '파운드리', '반도체'], ['aidc', 'AI 데이터센터', '반도체'], - ['adpkg', '첨단패키징', '반도체'], + ['adpkg', '삼성전자', '반도체'], ['glass', '유리기판', '반도체'], ['euv', 'EUV', '반도체'], ['ondevice', '온디바이스AI', '반도체'], @@ -237,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'], + }, ], }, { @@ -262,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'], + }, ], }, { @@ -287,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'], + }, ], }, { @@ -312,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'], + }, ], }, { @@ -337,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'], + }, ], }, { @@ -361,7 +456,12 @@ const CURATED_NEWS: NewsRecord[] = [ }, ], relatedStocks: [ - { nodeId: 'sk', direction: 'UP', relationLabel: '증착 장비', chain: ['wonik', 'sk'] }, + { + nodeId: 'sk', + direction: 'UP', + relationLabel: '증착 장비', + chain: ['wonik', 'sk'], + }, ], }, { @@ -383,7 +483,12 @@ const CURATED_NEWS: NewsRecord[] = [ }, ], relatedStocks: [ - { nodeId: 'sk', direction: 'UP', relationLabel: '고압 어닐링', chain: ['hpsp', 'sk'] }, + { + nodeId: 'sk', + direction: 'UP', + relationLabel: '고압 어닐링', + chain: ['hpsp', 'sk'], + }, ], }, { @@ -405,7 +510,12 @@ const CURATED_NEWS: NewsRecord[] = [ }, ], relatedStocks: [ - { nodeId: 'sk', direction: 'UP', relationLabel: '소모품 공급', chain: ['tck', 'sk'] }, + { + nodeId: 'sk', + direction: 'UP', + relationLabel: '소모품 공급', + chain: ['tck', 'sk'], + }, ], }, { @@ -427,7 +537,12 @@ const CURATED_NEWS: NewsRecord[] = [ }, ], relatedStocks: [ - { nodeId: 'fdry', direction: 'UP', relationLabel: '사업 영위', chain: ['dbh', 'fdry'] }, + { + nodeId: 'fdry', + direction: 'UP', + relationLabel: '사업 영위', + chain: ['dbh', 'fdry'], + }, ], }, ] @@ -458,9 +573,24 @@ const REFRESH_POOL: NewsRecord[] = [ }, ], 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'] }, + { + 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'], + }, ], }, { @@ -482,9 +612,24 @@ const REFRESH_POOL: NewsRecord[] = [ }, ], 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'] }, + { + 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'], + }, ], }, { @@ -506,10 +651,30 @@ const REFRESH_POOL: NewsRecord[] = [ }, ], 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'] }, + { + 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'], + }, ], }, { @@ -531,9 +696,24 @@ const REFRESH_POOL: NewsRecord[] = [ }, ], 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'] }, + { + 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'], + }, ], }, { @@ -555,9 +735,24 @@ const REFRESH_POOL: NewsRecord[] = [ }, ], 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'] }, + { + 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'], + }, ], }, { @@ -579,9 +774,24 @@ const REFRESH_POOL: NewsRecord[] = [ }, ], 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'] }, + { + 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'], + }, ], }, { @@ -603,9 +813,24 @@ const REFRESH_POOL: NewsRecord[] = [ }, ], 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'] }, + { + 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'], + }, ], }, { @@ -627,9 +852,24 @@ const REFRESH_POOL: NewsRecord[] = [ }, ], 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'] }, + { + 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'], + }, ], }, { @@ -651,8 +891,18 @@ const REFRESH_POOL: NewsRecord[] = [ }, ], relatedStocks: [ - { nodeId: 'sk', direction: 'UP', relationLabel: '식각 장비 공급', chain: ['psk', 'sk'] }, - { nodeId: 'jusung', direction: 'DOWN', relationLabel: '경쟁', chain: ['psk', 'jusung'] }, + { + nodeId: 'sk', + direction: 'UP', + relationLabel: '식각 장비 공급', + chain: ['psk', 'sk'], + }, + { + nodeId: 'jusung', + direction: 'DOWN', + relationLabel: '경쟁', + chain: ['psk', 'jusung'], + }, ], }, { @@ -674,8 +924,18 @@ const REFRESH_POOL: NewsRecord[] = [ }, ], relatedStocks: [ - { nodeId: 'sk', direction: 'UP', relationLabel: '부품 세정 코팅 공급', chain: ['comico', 'sk'] }, - { nodeId: 'tck', direction: 'DOWN', relationLabel: '경쟁', chain: ['comico', 'tck'] }, + { + nodeId: 'sk', + direction: 'UP', + relationLabel: '부품 세정 코팅 공급', + chain: ['comico', 'sk'], + }, + { + nodeId: 'tck', + direction: 'DOWN', + relationLabel: '경쟁', + chain: ['comico', 'tck'], + }, ], }, ] @@ -833,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, }) }