Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 38 additions & 7 deletions src/components/graph/GraphPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ const edgeTypes = { rel: RelEdge }

// 정적 온톨로지 기준으로 결정적으로 계산되는 좌표라 모듈 스코프에서 한 번만
// 계산해 캐시한다. 뷰를 오갈 때마다 재계산하면(성능 낭비는 물론) 노드가 튈 수 있다.
const FULL_LAYOUT = fullLayout()
// export해서 NetworkView가 centerId(인트로 하이라이트 기점)를 재계산 없이 가져다 쓴다.
export const FULL_LAYOUT = fullLayout()

interface EdgeSpec {
id: string
Expand Down Expand Up @@ -274,17 +275,47 @@ export function GraphCanvas({
const prevNodesRef = useRef(new Map<string, StockFlowNode>())
const prevEdgesRef = useRef(new Map<string, RelFlowEdge>())

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별로
// 내부 스토어에 구독시키기 때문에, 값이 같아도 매 틱마다 전체 배열을 새 객체로
Expand Down
19 changes: 14 additions & 5 deletions src/components/graph/NetworkView.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
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'

/** GNB "전체 관계망" 탭 — GraphPanel의 파급 경로 뷰와 별개로, 온톨로지 전체를
* 뉴스맵 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)
Expand Down
8 changes: 4 additions & 4 deletions src/components/graph/graph.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 8 additions & 1 deletion src/components/news/NewsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -71,6 +77,7 @@ export default function NewsCard({ news, selected, onSelect }: NewsCardProps) {
{news.press}
</b>{' '}
· {formatRelativeTime(news.publishedAt)}
{isNew && <span className="news-new-badge">NEW</span>}
</div>
</div>
)
Expand Down
10 changes: 8 additions & 2 deletions src/components/news/NewsList.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
40 changes: 35 additions & 5 deletions src/components/news/NewsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,45 @@
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을
* 켜면 이 컴포넌트에 .open이 붙는다.
*/
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<number | null>(null)
const toastTimerRef = useRef<number>()

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 (
<section className={cn('panel col-news', sheetOpen && 'open')}>
Expand All @@ -25,9 +50,7 @@ export default function NewsPanel() {
<button
type="button"
className="phead-refresh"
onClick={() =>
queryClient.invalidateQueries({ queryKey: queryKeys.news() })
}
onClick={handleRefresh}
disabled={isRefreshing}
aria-label="최신 뉴스 새로고침"
>
Expand All @@ -42,6 +65,13 @@ export default function NewsPanel() {
×
</button>
</div>
{toastCount !== null && (
<div className="news-refresh-toast" role="status">
{toastCount > 0
? `새 뉴스 ${toastCount}건이 도착했습니다`
: '새 뉴스가 없습니다'}
</div>
)}
<NewsList />
</section>
)
Expand Down
12 changes: 12 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
NEWS_RECORDS,
VERIFY_ENTRIES,
buildVerifyDaily,
pullRefreshBatch,
} from './data'
import type { NewsImpactWire, NewsListPageWire } from './mappers'
import type {
Expand Down Expand Up @@ -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) =>
Expand Down
8 changes: 7 additions & 1 deletion src/lib/atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | null>(null)

/** 전체 관계망에서 배치를 유지한 채 특정 노드 기점으로 제자리 강조할 때의 대상 */
export const highlightedNodeAtom = atom<string | null>(null)

/** 커버 화면 → /app 진입 시 전체 관계망 인트로(자동 하이라이트+줌)를 재생했는지 — 세션당 1회만 */
export const introPlayedAtom = atom(false)

export const verifyContextAtom = atom<{
label: string
names: string[]
Expand All @@ -23,3 +26,6 @@ export const newsSheetOpenAtom = atom(false)

/** 스크랩(저장) 뉴스 id 목록 — 메모리 전용, 새로고침하면 사라진다 */
export const scrappedNewsIdsAtom = atom<number[]>([])

/** "최신 뉴스" 새로고침으로 방금 추가된 뉴스 id 목록 — NEW 배지 표시용, 세션 메모리 전용 */
export const newlyAddedNewsIdsAtom = atom<number[]>([])
Loading