From 7d5ef98fa96b1192f377bea66a890b5036c089d6 Mon Sep 17 00:00:00 2001 From: okorion Date: Fri, 10 Jul 2026 19:23:01 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[feat]=20=ED=99=9C=EB=8F=99=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=ED=83=90=EC=83=89=20=EC=9B=8C=ED=81=AC=EB=B2=A4?= =?UTF-8?q?=EC=B9=98=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 활동 유형 필터와 상세 인스펙터 추가 - 3D 자동 회전과 시점 초기화 및 반응형 카메라 구현 - 밝은 토큰 기반 운영 레이아웃으로 재구성 Co-authored-by: Codex --- package.json | 1 + pnpm-lock.yaml | 12 + src/App.tsx | 393 +++++++++++++-------------- src/activity-data.ts | 124 +++++++++ src/components/GalaxyScene.tsx | 141 ++++++++++ src/index.css | 475 +++++++++++++++++++++++++-------- 6 files changed, 835 insertions(+), 311 deletions(-) create mode 100644 src/activity-data.ts create mode 100644 src/components/GalaxyScene.tsx diff --git a/package.json b/package.json index 1aac42b..f454a36 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@react-three/drei": "^10.0.0", "@react-three/fiber": "^9.0.0", "@react-three/postprocessing": "^3.0.0", + "lucide-react": "^1.24.0", "postprocessing": "^6.36.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59d2228..27e5ac0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@react-three/postprocessing': specifier: ^3.0.0 version: 3.0.4(@react-three/fiber@9.6.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.180.0))(@types/three@0.180.0)(react@19.2.4)(three@0.180.0) + lucide-react: + specifier: ^1.24.0 + version: 1.24.0(react@19.2.4) postprocessing: specifier: ^6.36.0 version: 6.39.1(three@0.180.0) @@ -1109,6 +1112,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@1.24.0: + resolution: {integrity: sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + maath@0.10.8: resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==} peerDependencies: @@ -2539,6 +2547,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@1.24.0(react@19.2.4): + dependencies: + react: 19.2.4 + maath@0.10.8(@types/three@0.180.0)(three@0.180.0): dependencies: '@types/three': 0.180.0 diff --git a/src/App.tsx b/src/App.tsx index d915e63..506fff2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,207 +1,214 @@ -import { Html, OrbitControls } from '@react-three/drei' -import { Canvas, useFrame } from '@react-three/fiber' -import { Bloom, EffectComposer } from '@react-three/postprocessing' -import { useMemo, useRef, useState } from 'react' -import * as THREE from 'three' - -type ActivityBucket = { - label: string - displayLabel: string - count: number - color: string - radius: number -} - -type ActivityPoint = { - key: string - color: string - position: [number, number, number] -} - -type PeriodOption = { - mode: PeriodMode - buttonLabel: string - metricLabel: string - insight: string - buckets: readonly ActivityBucket[] -} - -type PeriodMode = 'week' | 'month' - -const PERIOD_OPTIONS = [ - { - mode: 'week', - buttonLabel: '이번 주', - metricLabel: '주간', - insight: '이번 주는 커밋 밀도가 높고, 이슈 대응 궤도가 두 번째로 활발합니다.', - buckets: [ - { label: 'PR', displayLabel: 'PR', count: 18, color: '#007da5', radius: 1.2 }, - { label: 'Issue', displayLabel: '이슈', count: 31, color: '#4f8f32', radius: 1.8 }, - { label: 'Commit', displayLabel: '커밋', count: 74, color: '#c4457c', radius: 2.45 }, - { label: 'Review', displayLabel: '리뷰', count: 12, color: '#b77900', radius: 3.0 }, - ], - }, - { - mode: 'month', - buttonLabel: '이번 달', - metricLabel: '월간', - insight: '이번 달은 이슈와 커밋 궤도가 넓게 쌓여 장기 흐름 비교에 적합합니다.', - buckets: [ - { label: 'PR', displayLabel: 'PR', count: 52, color: '#007da5', radius: 1.2 }, - { label: 'Issue', displayLabel: '이슈', count: 86, color: '#4f8f32', radius: 1.8 }, - { label: 'Commit', displayLabel: '커밋', count: 164, color: '#c4457c', radius: 2.45 }, - { label: 'Review', displayLabel: '리뷰', count: 41, color: '#b77900', radius: 3.0 }, - ], - }, -] as const satisfies readonly PeriodOption[] - -const createActivityPoint = (bucket: ActivityBucket, bucketIndex: number, index: number): ActivityPoint => { - const angle = (index / bucket.count) * Math.PI * 2 + bucketIndex * 0.7 - const spiral = bucket.radius + index * 0.006 - - return { - key: `${bucket.label}-${index}`, - color: bucket.color, - position: [ - Math.cos(angle) * spiral, - Math.sin(index * 1.7) * 0.22 + bucketIndex * 0.08, - Math.sin(angle) * spiral, - ], - } -} - -const createActivityPoints = (buckets: readonly ActivityBucket[]): ActivityPoint[] => - buckets.flatMap((bucket, bucketIndex) => - Array.from({ length: bucket.count }, (_, index) => createActivityPoint(bucket, bucketIndex, index)), - ) - -const sumActivityCount = (buckets: readonly ActivityBucket[]) => buckets.reduce((sum, item) => sum + item.count, 0) - -const getTopBucket = (buckets: readonly ActivityBucket[]) => - buckets.reduce((topBucket, bucket) => (bucket.count > topBucket.count ? bucket : topBucket), buckets[0]) - -const getPeriodOption = (mode: PeriodMode) => - PERIOD_OPTIONS.find((option) => option.mode === mode) ?? PERIOD_OPTIONS[0] - -type GalaxyProps = { - buckets: readonly ActivityBucket[] -} - -function Galaxy({ buckets }: GalaxyProps) { - const groupRef = useRef(null) - const activityPoints = useMemo(() => createActivityPoints(buckets), [buckets]) - - useFrame(({ clock }) => { - if (groupRef.current) groupRef.current.rotation.y = clock.getElapsedTime() * 0.08 - }) - - return ( - - {buckets.map((bucket) => ( - - - - - ))} - {activityPoints.map((point) => ( - - - - - ))} - {buckets.map((bucket, index) => ( - - - {bucket.label} {bucket.count} - - - ))} - - ) -} +import { Pause, Play, RotateCcw, ScanSearch } from 'lucide-react' +import { useState } from 'react' +import { + getActivityShare, + getPeriodOption, + getTopBucket, + PERIOD_OPTIONS, + sumActivityCount, + type ActivityKind, + type PeriodMode, +} from './activity-data' +import { GalaxyScene } from './components/GalaxyScene' + +const prefersReducedMotion = () => + typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches export default function App() { const [mode, setMode] = useState('week') + const [visibleKinds, setVisibleKinds] = useState>( + () => new Set(['pull-request', 'issue', 'commit', 'review']), + ) + const [selectedKind, setSelectedKind] = useState('commit') + const [isRotating, setIsRotating] = useState(() => !prefersReducedMotion()) + const [resetRevision, setResetRevision] = useState(0) const selectedPeriod = getPeriodOption(mode) - const totalActivityCount = sumActivityCount(selectedPeriod.buckets) - const topBucket = getTopBucket(selectedPeriod.buckets) + const allActivityCount = sumActivityCount(selectedPeriod.buckets) + const visibleBuckets = selectedPeriod.buckets.filter((bucket) => visibleKinds.has(bucket.kind)) + const visibleActivityCount = sumActivityCount(visibleBuckets) + const topBucket = getTopBucket(visibleBuckets) + const selectedBucket = selectedPeriod.buckets.find((bucket) => bucket.kind === selectedKind) ?? topBucket + + const toggleBucketVisibility = (kind: ActivityKind) => { + if (visibleKinds.has(kind) && visibleKinds.size === 1) return + + const nextVisibleKinds = new Set(visibleKinds) + if (nextVisibleKinds.has(kind)) nextVisibleKinds.delete(kind) + else nextVisibleKinds.add(kind) + + setVisibleKinds(nextVisibleKinds) + if (!nextVisibleKinds.has(selectedKind)) { + setSelectedKind(nextVisibleKinds.values().next().value ?? kind) + } + } return ( -
-
-
-

GitHub Activity Galaxy

-

저장소 활동을 밝은 3D 운영 지도로 읽습니다.

-

- PR, 이슈, 커밋, 리뷰 신호를 궤도로 배치해 프로젝트 흐름을 시각화합니다. 초기 버전은 - 민감 정보 없이 샘플 데이터로 동작하고, 이후 PR에서 GitHub API 연결을 안전하게 확장합니다. -

-
- {PERIOD_OPTIONS.map((option) => ( +
+
+
+
+
+ {PERIOD_OPTIONS.map((option) => ( + + ))} +
+
+ +
+ + +
+
+
+

3D 활동 지도

+

{selectedBucket.shortLabel} 궤도 탐색

+
+
+ - ))} -
-

- {selectedPeriod.insight} -

-
-
-
활동 노드
-
{totalActivityCount}
-

{selectedPeriod.metricLabel} 샘플 활동 합계

-
-
-
집중 신호
-
{topBucket.displayLabel}
-

{topBucket.count}개 노드로 가장 큰 궤도

-
-
    - {selectedPeriod.buckets.map((bucket) => { - const share = Math.round((bucket.count / totalActivityCount) * 100) - - return ( -
  • -
  • - ) - })} -
-
-
- - - - - - - - - - - -
-
+ +
+ +
+
+
+ +
) } diff --git a/src/activity-data.ts b/src/activity-data.ts new file mode 100644 index 0000000..0fb7d52 --- /dev/null +++ b/src/activity-data.ts @@ -0,0 +1,124 @@ +export type ActivityKind = 'pull-request' | 'issue' | 'commit' | 'review' + +export type ActivityBucket = { + kind: ActivityKind + label: string + shortLabel: string + count: number + color: string + radius: number + description: string +} + +export type PeriodMode = 'week' | 'month' + +export type PeriodOption = { + mode: PeriodMode + buttonLabel: string + metricLabel: string + insight: string + buckets: readonly ActivityBucket[] +} + +export const PERIOD_OPTIONS = [ + { + mode: 'week', + buttonLabel: '이번 주', + metricLabel: '주간', + insight: '커밋 밀도가 높고, 이슈 대응 궤도가 두 번째로 활발합니다.', + buckets: [ + { + kind: 'pull-request', + label: 'Pull request', + shortLabel: 'PR', + count: 18, + color: '#007da5', + radius: 1.2, + description: '변경 제안과 병합 준비 흐름', + }, + { + kind: 'issue', + label: '이슈', + shortLabel: '이슈', + count: 31, + color: '#4f8f32', + radius: 1.8, + description: '논의 중인 문제와 개선 요청', + }, + { + kind: 'commit', + label: '커밋', + shortLabel: '커밋', + count: 74, + color: '#c4457c', + radius: 2.45, + description: '저장소에 반영된 코드 변화', + }, + { + kind: 'review', + label: '리뷰', + shortLabel: '리뷰', + count: 12, + color: '#b77900', + radius: 3, + description: '변경을 검토하고 판단한 기록', + }, + ], + }, + { + mode: 'month', + buttonLabel: '이번 달', + metricLabel: '월간', + insight: '이슈와 커밋 궤도가 넓게 쌓여 장기 흐름 비교에 적합합니다.', + buckets: [ + { + kind: 'pull-request', + label: 'Pull request', + shortLabel: 'PR', + count: 52, + color: '#007da5', + radius: 1.2, + description: '변경 제안과 병합 준비 흐름', + }, + { + kind: 'issue', + label: '이슈', + shortLabel: '이슈', + count: 86, + color: '#4f8f32', + radius: 1.8, + description: '논의 중인 문제와 개선 요청', + }, + { + kind: 'commit', + label: '커밋', + shortLabel: '커밋', + count: 164, + color: '#c4457c', + radius: 2.45, + description: '저장소에 반영된 코드 변화', + }, + { + kind: 'review', + label: '리뷰', + shortLabel: '리뷰', + count: 41, + color: '#b77900', + radius: 3, + description: '변경을 검토하고 판단한 기록', + }, + ], + }, +] as const satisfies readonly PeriodOption[] + +export const getPeriodOption = (mode: PeriodMode) => + PERIOD_OPTIONS.find((option) => option.mode === mode) ?? PERIOD_OPTIONS[0] + +export const sumActivityCount = (buckets: readonly ActivityBucket[]) => + buckets.reduce((sum, bucket) => sum + bucket.count, 0) + +export const getTopBucket = (buckets: readonly ActivityBucket[]) => + buckets.reduce((topBucket, bucket) => (bucket.count > topBucket.count ? bucket : topBucket), buckets[0]) + +export const getActivityShare = (bucket: ActivityBucket, total: number) => + total === 0 ? 0 : Math.round((bucket.count / total) * 100) diff --git a/src/components/GalaxyScene.tsx b/src/components/GalaxyScene.tsx new file mode 100644 index 0000000..88f20d0 --- /dev/null +++ b/src/components/GalaxyScene.tsx @@ -0,0 +1,141 @@ +import { Html, OrbitControls } from '@react-three/drei' +import { Canvas, useFrame, useThree } from '@react-three/fiber' +import { Bloom, EffectComposer } from '@react-three/postprocessing' +import { type ComponentRef, type RefObject, useEffect, useMemo, useRef } from 'react' +import * as THREE from 'three' +import type { ActivityBucket, ActivityKind } from '../activity-data' + +type ActivityPoint = { + key: string + kind: ActivityKind + color: string + position: [number, number, number] +} + +type GalaxyProps = { + buckets: readonly ActivityBucket[] + selectedKind: ActivityKind + isRotating: boolean +} + +type GalaxySceneProps = GalaxyProps & { + resetRevision: number +} + +type CameraRigProps = { + controlsRef: RefObject | null> + resetRevision: number +} + +const INITIAL_CAMERA = { + position: [0, 3.2, 6.2] as [number, number, number], + fov: 48, +} +const CANVAS_PIXEL_RATIO: [number, number] = [1, 1.75] +const CANVAS_BACKGROUND: [string] = ['#f8fbff'] + +const createActivityPoint = (bucket: ActivityBucket, bucketIndex: number, index: number): ActivityPoint => { + const angle = (index / bucket.count) * Math.PI * 2 + bucketIndex * 0.7 + const spiral = bucket.radius + index * 0.006 + + return { + key: `${bucket.kind}-${index}`, + kind: bucket.kind, + color: bucket.color, + position: [ + Math.cos(angle) * spiral, + Math.sin(index * 1.7) * 0.22 + bucketIndex * 0.08, + Math.sin(angle) * spiral, + ], + } +} + +const createActivityPoints = (buckets: readonly ActivityBucket[]) => + buckets.flatMap((bucket, bucketIndex) => + Array.from({ length: bucket.count }, (_, index) => createActivityPoint(bucket, bucketIndex, index)), + ) + +function Galaxy({ buckets, selectedKind, isRotating }: GalaxyProps) { + const groupRef = useRef(null) + const activityPoints = useMemo(() => createActivityPoints(buckets), [buckets]) + + useFrame(({ clock }) => { + if (groupRef.current && isRotating) groupRef.current.rotation.y = clock.getElapsedTime() * 0.08 + }) + + return ( + + {buckets.map((bucket) => { + const isSelected = bucket.kind === selectedKind + + return ( + + + + + ) + })} + {activityPoints.map((point) => { + const isSelected = point.kind === selectedKind + + return ( + + + + + ) + })} + {buckets.map((bucket, index) => ( + + + {bucket.shortLabel} {bucket.count} + + + ))} + + ) +} + +function CameraRig({ controlsRef, resetRevision }: CameraRigProps) { + const { camera, size } = useThree() + + useEffect(() => { + const cameraDistance = size.width <= 560 ? 8.4 : size.width <= 900 ? 7.2 : 6.2 + camera.position.set(0, 3.2, cameraDistance) + camera.lookAt(0, 0, 0) + camera.updateProjectionMatrix() + controlsRef.current?.target.set(0, 0, 0) + controlsRef.current?.update() + controlsRef.current?.saveState() + }, [camera, controlsRef, resetRevision, size.width]) + + return null +} + +export function GalaxyScene({ buckets, selectedKind, isRotating, resetRevision }: GalaxySceneProps) { + const controlsRef = useRef>(null) + + return ( + + + + + + + + + + + + + ) +} diff --git a/src/index.css b/src/index.css index 3e713e3..6a67648 100644 --- a/src/index.css +++ b/src/index.css @@ -17,240 +17,479 @@ body { margin: 0; min-width: 320px; min-height: 100vh; + background: var(--color-bg); } -button { +button, +input { font: inherit; } -.shell { +button:focus-visible, +input:focus-visible { + outline: 3px solid color-mix(in srgb, var(--color-primary) 24%, transparent); + outline-offset: 2px; +} + +.app-shell { min-height: 100vh; - padding: var(--space-shell); - background: - linear-gradient(135deg, rgb(0 125 165 / 12%), transparent 36%), - radial-gradient(circle at 82% 18%, rgb(183 121 0 / 12%), transparent 34%), - linear-gradient(180deg, #ffffff 0%, var(--color-bg) 58%, #eef6ff 100%); + min-height: 100dvh; + background: var(--color-bg); } -.panel { - min-height: calc(100vh - (var(--space-shell) * 2)); - display: grid; - grid-template-columns: minmax(320px, 0.8fr) minmax(360px, 1.2fr); - gap: var(--space-section); +.app-header { + min-height: 72px; + display: flex; align-items: center; + justify-content: space-between; + gap: 24px; + padding: 14px 28px; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface-strong); } -.summary { - max-width: 610px; +.brand-lockup { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; } -.kicker { - margin: 0 0 16px; - color: var(--color-primary); - font-size: 14px; - font-weight: 800; +.brand-mark { + width: 26px; + height: 26px; + flex: 0 0 auto; + border: 6px solid var(--color-primary); + border-right-color: var(--color-attention); + border-radius: 50%; } -h1 { +.brand-lockup h1 { margin: 0; - font-size: clamp(40px, 6vw, 76px); - line-height: 1.04; + font-size: 18px; + line-height: 1.25; letter-spacing: 0; - word-break: keep-all; - overflow-wrap: break-word; } -.lead { - margin: 24px 0 0; - color: var(--color-muted); - font-size: 18px; - line-height: 1.75; - word-break: keep-all; - overflow-wrap: break-word; +.brand-lockup p { + margin: 3px 0 0; + color: var(--color-subtle); + font-size: 12px; } -.toolbar { - display: inline-flex; - gap: 8px; - margin-top: 30px; - padding: 6px; +.period-switch { + display: grid; + grid-template-columns: repeat(2, minmax(82px, 1fr)); + gap: 4px; + padding: 4px; border: 1px solid var(--color-border); - border-radius: var(--radius-pill); - background: var(--color-surface); - box-shadow: 0 14px 40px rgb(54 82 116 / 10%); + border-radius: var(--radius-frame); + background: var(--color-bg); } -.toolbar button { - min-height: 38px; +.period-switch button { + min-height: 36px; border: 0; - border-radius: var(--radius-pill); - padding: 0 16px; - color: var(--color-text); + border-radius: 5px; + padding: 0 14px; + color: var(--color-muted); background: transparent; cursor: pointer; - transition: - background var(--motion-standard), - color var(--motion-standard); } -.toolbar button.active { +.period-switch button.active { color: var(--color-primary-ink); background: var(--color-primary); } +.workspace { + min-height: calc(100vh - 72px); + min-height: calc(100dvh - 72px); + display: grid; + grid-template-columns: minmax(320px, 390px) minmax(0, 1fr); +} + +.activity-sidebar { + z-index: 1; + overflow: auto; + border-right: 1px solid var(--color-border); + background: var(--color-surface-strong); +} + +.sidebar-section { + padding: 24px 28px; + border-bottom: 1px solid var(--color-border); +} + +.eyebrow { + margin: 0 0 6px; + color: var(--color-primary); + font-size: 11px; + font-weight: 800; + line-height: 1.3; + text-transform: uppercase; +} + +.sidebar-section h2, +.stage-toolbar h2 { + margin: 0; + font-size: 20px; + line-height: 1.3; + letter-spacing: 0; + word-break: keep-all; +} + .period-note { - max-width: 56ch; - margin: 18px 0 0; + margin: 12px 0 0; color: var(--color-muted); font-size: 14px; line-height: 1.6; word-break: keep-all; - overflow-wrap: break-word; } .metrics { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; - margin: 28px 0 0; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin: 22px 0 0; + border-block: 1px solid var(--color-border); } .metrics div { - border: 1px solid var(--color-border); - border-radius: var(--radius-frame); - padding: 16px; - background: var(--color-surface-strong); - box-shadow: 0 16px 42px rgb(54 82 116 / 10%); + min-width: 0; + padding: 14px 10px; +} + +.metrics div + div { + border-left: 1px solid var(--color-border); } .metrics dt { color: var(--color-subtle); - font-size: 13px; + font-size: 11px; } .metrics dd { - margin: 8px 0 0; - font-size: 28px; + margin: 6px 0 0; + overflow: hidden; + font-size: 22px; font-weight: 800; line-height: 1.2; - word-break: keep-all; + text-overflow: ellipsis; + white-space: nowrap; } .metrics p { - margin: 6px 0 0; + margin: 5px 0 0; color: var(--color-muted); - font-size: 13px; - line-height: 1.45; - word-break: keep-all; - overflow-wrap: break-word; + font-size: 10px; + line-height: 1.35; +} + +.section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.section-heading > span { + flex: 0 0 auto; + color: var(--color-subtle); + font-size: 12px; + font-variant-numeric: tabular-nums; } .bucket-list { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - margin: 18px 0 0; + gap: 6px; + margin: 16px 0 0; padding: 0; list-style: none; } .bucket-list li { + min-height: 48px; display: grid; - grid-template-columns: 10px minmax(0, 1fr) auto auto; - grid-template-rows: auto 5px; - gap: 8px; - align-items: center; - min-height: 54px; - padding: 10px 12px 12px; - border: 1px solid var(--color-border); + grid-template-columns: minmax(0, 1fr) 38px; + align-items: stretch; + overflow: hidden; + border: 1px solid transparent; border-radius: var(--radius-frame); - background: rgb(255 255 255 / 68%); + background: var(--color-bg); +} + +.bucket-list li.selected { + border-color: color-mix(in srgb, var(--color-primary) 32%, transparent); + background: color-mix(in srgb, var(--color-primary) 7%, transparent); +} + +.bucket-toggle { + min-width: 0; + display: grid; + grid-template-columns: 16px 9px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 8px 4px 8px 12px; + cursor: pointer; +} + +.bucket-toggle input { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--color-primary); +} + +.bucket-toggle input:disabled { + cursor: not-allowed; } .bucket-swatch { - width: 10px; - height: 10px; - border-radius: 999px; + width: 9px; + height: 9px; + border-radius: 50%; } -.bucket-label { - color: var(--color-text); - font-size: 14px; - font-weight: 700; +.bucket-name { + min-width: 0; + display: flex; + align-items: baseline; + gap: 7px; +} + +.bucket-name strong { overflow: hidden; + font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } -.bucket-list strong { - font-size: 15px; +.bucket-name small { + color: var(--color-subtle); + font-size: 11px; font-variant-numeric: tabular-nums; } -.bucket-list small { - color: var(--color-subtle); - font-size: 12px; +.bucket-count { + color: var(--color-text); + font-size: 13px; + font-weight: 800; font-variant-numeric: tabular-nums; } -.bucket-share { - grid-column: 2 / -1; - width: 100%; +.inspect-button, +.stage-actions button { + display: inline-grid; + place-items: center; + width: 38px; + height: 38px; + border: 0; + color: var(--color-muted); + background: transparent; + cursor: pointer; +} + +.inspect-button { + align-self: center; + border-left: 1px solid var(--color-border); +} + +.inspect-button:hover:not(:disabled), +.inspect-button[aria-pressed='true'], +.stage-actions button:hover { + color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 8%, transparent); +} + +.inspect-button:disabled { + color: var(--color-border); + cursor: not-allowed; +} + +.inspector p { + margin: 12px 0 0; + color: var(--color-muted); + font-size: 13px; + line-height: 1.55; +} + +.section-heading .inspector-count { + color: inherit; + font-size: 24px; + font-weight: 800; +} + +.share-meter { height: 5px; + margin-top: 14px; overflow: hidden; border-radius: var(--radius-pill); - background: rgb(17 70 105 / 8%); + background: color-mix(in srgb, var(--color-text) 8%, transparent); } -.bucket-share span { +.share-meter span { display: block; height: 100%; min-width: 5px; border-radius: inherit; - box-shadow: 0 0 10px currentColor; +} + +.galaxy-stage { + min-width: 0; + min-height: 620px; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + background: var(--color-bg); +} + +.stage-toolbar, +.stage-status { + z-index: 1; + display: flex; + align-items: center; + background: var(--color-surface); + backdrop-filter: blur(12px); +} + +.stage-toolbar { + justify-content: space-between; + gap: 20px; + min-height: 74px; + padding: 14px 24px; + border-bottom: 1px solid var(--color-border); +} + +.stage-actions { + display: flex; + gap: 2px; + padding: 3px; + border: 1px solid var(--color-border); + border-radius: var(--radius-frame); + background: var(--color-surface-strong); +} + +.stage-actions button { + border-radius: 5px; +} + +.galaxy-canvas { + min-width: 0; + min-height: 0; + position: relative; +} + +.galaxy-canvas canvas { + display: block; +} + +.stage-status { + min-height: 44px; + gap: 8px; + padding: 10px 24px; + border-top: 1px solid var(--color-border); + color: var(--color-muted); + font-size: 12px; +} + +.stage-status strong { + color: var(--color-text); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.motion-status { + margin-left: auto; } .orbit-label { display: block; min-width: max-content; - padding: 2px 6px; - border: 1px solid rgb(255 255 255 / 86%); - border-radius: 6px; - background: rgb(255 255 255 / 78%); - box-shadow: 0 8px 18px rgb(54 82 116 / 10%); - font-size: 12px; + padding: 3px 7px; + border: 1px solid var(--color-border); + border-radius: 5px; + background: var(--color-surface-strong); + box-shadow: 0 6px 16px rgb(54 82 116 / 10%); + font-size: 11px; font-weight: 800; line-height: 1.2; + opacity: 0.68; pointer-events: none; transform: translateX(-100%); white-space: nowrap; } -.galaxy { - height: min(74vh, 780px); - min-height: 440px; - overflow: hidden; - border: 1px solid var(--color-border); - border-radius: var(--radius-frame); - background: - radial-gradient(circle at 50% 40%, rgb(0 125 165 / 10%), transparent 38%), - linear-gradient(180deg, #ffffff, #eef7ff); - box-shadow: var(--shadow-stage); +.orbit-label.selected { + border-color: currentColor; + opacity: 1; } @media (max-width: 900px) { - .panel { + .workspace { grid-template-columns: 1fr; } - .galaxy { - min-height: 360px; - height: 56vh; + .activity-sidebar { + overflow: visible; + border-right: 0; + } + + .galaxy-stage { + min-height: 620px; } } @media (max-width: 560px) { - .bucket-list { + .app-header { + align-items: stretch; + flex-direction: column; + gap: 12px; + padding: 14px 18px; + } + + .period-switch { + width: 100%; + } + + .sidebar-section { + padding: 22px 18px; + } + + .metrics { grid-template-columns: 1fr; } + + .metrics div { + display: grid; + grid-template-columns: minmax(80px, 1fr) auto; + gap: 4px 12px; + align-items: baseline; + padding: 11px 4px; + } + + .metrics div + div { + border-top: 1px solid var(--color-border); + border-left: 0; + } + + .metrics dd { + grid-row: 1 / 3; + grid-column: 2; + margin: 0; + } + + .stage-toolbar { + padding: 13px 18px; + } + + .stage-status { + padding-inline: 18px; + } + + .motion-status { + display: none; + } } From ab560344c469c06d7209231419488ded4f1166f5 Mon Sep 17 00:00:00 2001 From: okorion Date: Wed, 15 Jul 2026 10:54:05 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[feat]=20=ED=99=9C=EB=8F=99=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=EC=A7=91=EC=A4=91=20=EB=B3=B4=EA=B8=B0=EC=99=80=20?= =?UTF-8?q?=ED=83=90=EC=83=89=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 64 ++++++++++++++++++++++++++++++--- src/index.css | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 506fff2..92cb298 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { Pause, Play, RotateCcw, ScanSearch } from 'lucide-react' +import { ChevronLeft, ChevronRight, Focus, Layers3, Pause, Play, RotateCcw, ScanSearch } from 'lucide-react' import { useState } from 'react' import { getActivityShare, @@ -16,6 +16,8 @@ const prefersReducedMotion = () => export default function App() { const [mode, setMode] = useState('week') + const [viewMode, setViewMode] = useState<'overview' | 'focus'>('overview') + const [listOrder, setListOrder] = useState<'default' | 'count'>('default') const [visibleKinds, setVisibleKinds] = useState>( () => new Set(['pull-request', 'issue', 'commit', 'review']), ) @@ -28,6 +30,11 @@ export default function App() { const visibleActivityCount = sumActivityCount(visibleBuckets) const topBucket = getTopBucket(visibleBuckets) const selectedBucket = selectedPeriod.buckets.find((bucket) => bucket.kind === selectedKind) ?? topBucket + const listedBuckets = [...selectedPeriod.buckets].sort((left, right) => + listOrder === 'count' ? right.count - left.count : 0, + ) + const sceneBuckets = viewMode === 'focus' ? visibleBuckets.filter((bucket) => bucket.kind === selectedKind) : visibleBuckets + const sceneActivityCount = sumActivityCount(sceneBuckets) const toggleBucketVisibility = (kind: ActivityKind) => { if (visibleKinds.has(kind) && visibleKinds.size === 1) return @@ -42,6 +49,13 @@ export default function App() { } } + const selectRelativeBucket = (direction: -1 | 1) => { + const navigableBuckets = listedBuckets.filter((bucket) => visibleKinds.has(bucket.kind)) + const currentIndex = navigableBuckets.findIndex((bucket) => bucket.kind === selectedKind) + const nextIndex = (currentIndex + direction + navigableBuckets.length) % navigableBuckets.length + setSelectedKind(navigableBuckets[nextIndex]?.kind ?? selectedKind) + } + return (
@@ -92,6 +106,26 @@ export default function App() {

{topBucket.count}개 노드

+
+ + +
@@ -100,10 +134,20 @@ export default function App() {

레이어

활동 유형

- {visibleBuckets.length}/4 표시 +
+ + {visibleBuckets.length}/4 표시 +
    - {selectedPeriod.buckets.map((bucket) => { + {listedBuckets.map((bucket) => { const isVisible = visibleKinds.has(bucket.kind) const isOnlyVisibleBucket = isVisible && visibleKinds.size === 1 const share = getActivityShare(bucket, allActivityCount) @@ -151,6 +195,15 @@ export default function App() { {selectedBucket.count} +
    + + 화살표로 비교 + +

    {selectedBucket.description}

diff --git a/src/index.css b/src/index.css index 6a67648..aa62492 100644 --- a/src/index.css +++ b/src/index.css @@ -185,6 +185,38 @@ input:focus-visible { line-height: 1.35; } +.display-mode { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; + margin-top: 16px; + padding: 4px; + border: 1px solid var(--color-border); + border-radius: var(--radius-frame); + background: var(--color-bg); +} + +.display-mode button { + min-height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border: 0; + border-radius: 5px; + color: var(--color-muted); + background: transparent; + cursor: pointer; + font-size: 11px; + font-weight: 800; +} + +.display-mode button:hover, +.display-mode button.active { + color: var(--color-primary-ink); + background: var(--color-primary); +} + .section-heading { display: flex; align-items: center; @@ -199,6 +231,31 @@ input:focus-visible { font-variant-numeric: tabular-nums; } +.filter-tools { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.filter-tools button { + min-height: 28px; + border: 1px solid var(--color-border); + border-radius: 5px; + padding: 0 8px; + color: var(--color-muted); + background: var(--color-surface-strong); + cursor: pointer; + font-size: 10px; + font-weight: 800; +} + +.filter-tools button:hover, +.filter-tools button.active { + color: var(--color-primary); + border-color: color-mix(in srgb, var(--color-primary) 34%, var(--color-border)); + background: color-mix(in srgb, var(--color-primary) 7%, transparent); +} + .bucket-list { display: grid; gap: 6px; @@ -313,6 +370,37 @@ input:focus-visible { line-height: 1.55; } +.inspector-navigation { + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 12px; + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 3px; + color: var(--color-subtle); + background: var(--color-bg); + font-size: 10px; + font-weight: 700; +} + +.inspector-navigation button { + width: 26px; + height: 26px; + display: inline-grid; + place-items: center; + border: 0; + border-radius: 4px; + color: var(--color-muted); + background: transparent; + cursor: pointer; +} + +.inspector-navigation button:hover { + color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 8%, transparent); +} + .section-heading .inspector-count { color: inherit; font-size: 24px; @@ -405,6 +493,12 @@ input:focus-visible { margin-left: auto; } +.view-status { + padding-left: 8px; + border-left: 1px solid var(--color-border); + color: var(--color-subtle); +} + .orbit-label { display: block; min-width: max-content; @@ -492,4 +586,8 @@ input:focus-visible { .motion-status { display: none; } + + .view-status { + margin-left: auto; + } } From d901b8e433a133ba1352f9c21883f8ff2f300e8c Mon Sep 17 00:00:00 2001 From: okorion Date: Wed, 15 Jul 2026 12:53:07 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[fix]=20=ED=9A=8C=EC=A0=84=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=97=B0=EC=86=8D=EC=84=B1=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 6 +++--- src/components/GalaxyScene.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 92cb298..750d59a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -225,9 +225,9 @@ export default function App() {