From bd13641f5ebcccb19c2c3525ec4c3178ed2ecbd3 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sat, 13 Jun 2026 00:05:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(deepfield):=20=EB=A0=8C=EB=8D=94=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=ED=8C=90=EC=A0=95(TDD)=20+=20useFieldMode?= =?UTF-8?q?=20=ED=9B=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/deepfield/useFieldMode.ts | 29 ++++++++++++++++++++++++ lib/__tests__/deepfield-mode.test.ts | 26 +++++++++++++++++++++ lib/deepfield-mode.ts | 17 ++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 app/components/deepfield/useFieldMode.ts create mode 100644 lib/__tests__/deepfield-mode.test.ts create mode 100644 lib/deepfield-mode.ts diff --git a/app/components/deepfield/useFieldMode.ts b/app/components/deepfield/useFieldMode.ts new file mode 100644 index 0000000..05d07a6 --- /dev/null +++ b/app/components/deepfield/useFieldMode.ts @@ -0,0 +1,29 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { decideFieldMode, type FieldMode } from '@/lib/deepfield-mode'; + +function detectWebGL(): boolean { + try { + const canvas = document.createElement('canvas'); + return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl')); + } catch { + return false; + } +} + +/** SSR/첫 페인트는 'static'으로 시작 — 클라이언트에서 승격 (hydration 불일치 방지) */ +export function useFieldMode(): FieldMode { + const [mode, setMode] = useState('static'); + useEffect(() => { + setMode( + decideFieldMode({ + reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches, + webglSupported: detectWebGL(), + hardwareConcurrency: navigator.hardwareConcurrency ?? 0, + viewportWidth: window.innerWidth, + }), + ); + }, []); + return mode; +} diff --git a/lib/__tests__/deepfield-mode.test.ts b/lib/__tests__/deepfield-mode.test.ts new file mode 100644 index 0000000..5dc8df5 --- /dev/null +++ b/lib/__tests__/deepfield-mode.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { decideFieldMode } from '@/lib/deepfield-mode'; + +const base = { reducedMotion: false, webglSupported: true, hardwareConcurrency: 8, viewportWidth: 1440 }; + +describe('decideFieldMode', () => { + it('데스크톱 + WebGL = full', () => { + expect(decideFieldMode(base)).toBe('full'); + }); + it('reduced-motion이면 무조건 static', () => { + expect(decideFieldMode({ ...base, reducedMotion: true })).toBe('static'); + expect(decideFieldMode({ ...base, reducedMotion: true, viewportWidth: 375 })).toBe('static'); + }); + it('WebGL 미지원이면 static', () => { + expect(decideFieldMode({ ...base, webglSupported: false })).toBe('static'); + }); + it('모바일 뷰포트(<768)는 lite', () => { + expect(decideFieldMode({ ...base, viewportWidth: 767 })).toBe('lite'); + }); + it('저성능 코어(<4)는 lite', () => { + expect(decideFieldMode({ ...base, hardwareConcurrency: 2 })).toBe('lite'); + }); + it('hardwareConcurrency 미보고(0/undefined)는 lite로 보수적 판정', () => { + expect(decideFieldMode({ ...base, hardwareConcurrency: 0 })).toBe('lite'); + }); +}); diff --git a/lib/deepfield-mode.ts b/lib/deepfield-mode.ts new file mode 100644 index 0000000..378f9a4 --- /dev/null +++ b/lib/deepfield-mode.ts @@ -0,0 +1,17 @@ +export type FieldMode = 'full' | 'lite' | 'static'; + +export interface FieldEnv { + reducedMotion: boolean; + webglSupported: boolean; + hardwareConcurrency: number; // 미보고 시 0 + viewportWidth: number; +} + +/** Deep Field 렌더 모드 판정 — 우선순위: 접근성 > 지원 여부 > 성능 */ +export function decideFieldMode(env: FieldEnv): FieldMode { + if (env.reducedMotion) return 'static'; + if (!env.webglSupported) return 'static'; + if (env.viewportWidth < 768) return 'lite'; + if (!env.hardwareConcurrency || env.hardwareConcurrency < 4) return 'lite'; + return 'full'; +}