UNPKG

@whatpull/webjet

Version:

Hybrid canvas+CSS slot machine for React

364 lines (305 loc) 11.5 kB
# Whatpull Web‑Jet Series (React) 가지 고성능 UI 모듈을 제공합니다. 1) **🎰 SlotMachineHybrid** Canvas + CSS 하이브리드 슬롯머신 2) **🏆 ReaderBoard** FLIP 재배치 애니메이션과 테마(Provider) 기반 리더보드 ![npm](https://img.shields.io/npm/v/@whatpull/webjet?style=flat-square) ![license](https://img.shields.io/npm/l/@whatpull/webjet?style=flat-square) ![build](https://img.shields.io/badge/build-pnpm-green?style=flat-square) > 패키지명이 다르면 아래 `@whatpull/webjet`을 실제 패키지명으로 바꿔 사용하세요. --- ## 설치 ```bash # 런타임 pnpm add styled-components # (npm/yarn 사용 가능) # 본 라이브러리 pnpm add @whatpull/webjet ``` > React 18+ / Next.js 13+(App Router) 권장. TypeScript 사용 아래 **테마 타입 설정** 참고. --- ## 빠른 데모 (두 모듈 한 화면) `App.tsx` 예시(요약): ```tsx import React, { useRef, useState, useEffect } from "react"; import SlotMachineHybrid, { type SlotMachineHandle, type SymbolItem } from "@whatpull/webjet"; import { ReaderBoard, ReaderBoardThemeProvider, ReaderBoardStyle, type RankCriteria, type UnitOption, type DisplayFields, } from "@whatpull/webjet"; export default function App() { // ── SlotMachineHybrid const slotRef = useRef<SlotMachineHandle>(null); const symbols: SymbolItem[] = [ { seq: 1, image: "https://picsum.photos/id/1015/800/600" }, { seq: 2, image: "https://picsum.photos/id/1016/800/600" }, { seq: 3, image: "https://picsum.photos/id/1025/800/600" }, ]; // ── ReaderBoard const [styleKey] = useState(ReaderBoardStyle.Basic); const [criteria, setCriteria] = useState<RankCriteria>("supportScore"); const unit: UnitOption = { label: "만 캐시", scale: 10_000 }; const display: DisplayFields = { rank: true, rankDelta: true, supportScore: true, contribution: true }; type User = { id: number; nickname: string; support: number; contribution: number }; const [data, setData] = useState<User[]>([ { id: 1, nickname: "Alice", support: 541000, contribution: 20000 }, { id: 2, nickname: "Bob", support: 230000, contribution: 32000 }, { id: 3, nickname: "Chan", support: 120000, contribution: 50000 }, ]); useEffect(() => { const t = setInterval(() => { setData(prev => prev.map(u => ({ ...u, support: Math.max(0, u.support + Math.round((Math.random() - 0.5) * 50000)), }))); }, 2000); return () => clearInterval(t); }, []); return ( <div style={{ padding: 40, display: "grid", gap: 32 }}> {/* 1) SlotMachineHybrid */} <SlotMachineHybrid ref={slotRef} reels={3} symbols={symbols} size={{ w: 140, h: 220 }} imageFit="cover" reelDirections={["up", "up", "up"]} rampUpDuration={110} spinSpeed={3600} minSpinCycles={5} stopDuration={1000} settleOvershootPx={Math.round(220 * 0.12)} settleSplit={0.72} reelDelayStep={120} stopOrder="left-to-right" stopDelayStep={1000} autoPxPerFrameCap spinFilter="blur(0.6px)" edgeFade={0.12} reelgap="12px" /> {/* 2) ReaderBoard */} <ReaderBoardThemeProvider styleKey={styleKey}> <ReaderBoard<User> criteria={criteria} unit={unit} display={display} animateOnRankChange data={data} accessors={{ getKey: u => u.id, getRole: u => u.nickname, getSupportScore: u => u.support, getContribution: u => u.contribution, }} /> </ReaderBoardThemeProvider> <button onClick={() => setCriteria(c => (c === "supportScore" ? "contribution" : "supportScore"))}> 기준 토글 </button> </div> ); } ``` --- ## 1) 🎰 SlotMachineHybrid ### 특징 - **Hybrid Canvas + CSS transform**: rAF 기반 부드러운 회전 - **터보 스핀/가속**: `spinSpeed`, `rampUpDuration` - **자연 정지(오버슈트→안착)**: `settleOvershootPx`, `settleSplit`, `stopDuration` - **릴 순차 정지**: `stopOrder`, `stopDelayStep` - **시각 피로 완화**: `spinFilter`, `edgeFade` - **릴 간격**: `reelgap` ### 주요 props (요약) ```ts export type SymbolItem = { seq: number; label?: string; bg?: string; fg?: string; image?: string; crossOrigin?: "" | "anonymous" | "use-credentials"; }; export type SlotMachineHybridProps = { reels?: number; symbols?: SymbolItem[]; size?: { w: number; h: number }; spinSpeed?: number; rampUpDuration?: number; minSpinCycles?: number; stopDuration?: number; settleOvershootPx?: number; settleSplit?: number; reelDelayStep?: number; reelDirections?: ("down" | "up")[]; stopOrder?: "left-to-right" | "right-to-left" | "simultaneous"; stopDelayStep?: number; imageFit?: "cover" | "contain" | "fill"; autoPxPerFrameCap?: boolean; pxPerFrameCap?: number; spinFilter?: string; edgeFade?: number; reelgap?: string; onStop?: (stops: number[]) => void; }; export type SlotMachineHandle = { spin(): void; spinTo(indices: number[], opts?: { stopDuration?: number; delayStep?: number }): void; spinToLabels(labels: string[], opts?: { stopDuration?: number; delayStep?: number }): void; spinToSequences(seqs: number[], opts?: { stopDuration?: number; delayStep?: number }): void; isSpinning(): boolean; stop(opts?: { stopDuration?: number }): void; }; ``` --- ## 2) 🏆 ReaderBoard ### 특징 - **제네릭 + forwardRef**: `<ReaderBoard<User> ...>` 형태로 외부에서 `T` 지정 - **FLIP 재배치 애니메이션**: 순위/정렬 변경 행이 드래그앤드롭처럼 자연 이동 - **styled‑components ThemeProvider 스킨**: `ReaderBoardThemeProvider` + `ReaderBoardStyle` enum - **카드형 Row**: 배지/타이틀/통계/Progress(또는 Lock) 레이아웃 - **상승/하락 Δ 표시(옵션)**: 이전 랭크 대비 변화 아이콘/색상 ### 사용 ```tsx <ReaderBoardThemeProvider styleKey={ReaderBoardStyle.Basic}> <ReaderBoard<User> criteria="supportScore" unit={{ label: "만 캐시", scale: 10_000 }} display={{ rank: true, rankDelta: true, supportScore: true, contribution: true }} animateOnRankChange data={users} accessors={{ getKey: u => u.id, getRole: u => u.nickname, getSupportScore: u => u.support, getContribution: u => u.contribution, // (옵션) getSubtitle, getProgress(0~100), getLocked 확장 가능 }} /> </ReaderBoardThemeProvider> ``` ### 타입 ```ts export type RankCriteria = "supportScore" | "contribution"; export type UnitOption = { label: string; scale: number }; export type DisplayFields = { season?: boolean; broadcast?: boolean; rank?: boolean; rankDelta?: boolean; role?: boolean; supportScore?: boolean; contribution?: boolean; itemName?: boolean | string; itemCount?: boolean | string; }; export type ReaderBoardAccessors<T> = { getKey: (item: T, idx: number) => React.Key; getSeason?: (item: T) => string | number; getBroadcast?: (item: T) => string | number; getRank?: (item: T) => number; getRole?: (item: T) => string; getSupportScore?: (item: T) => number; getContribution?: (item: T) => number; getItemName?: (item: T) => string; getItemCount?: (item: T) => number; // 카드형 보조정보(옵션) getSubtitle?: (item: T) => string; getProgress?: (item: T) => number; // 0~100 getLocked?: (item: T) => boolean; // 잠금 표시 }; export type ReaderBoardProps<T> = { widgetUrl?: string; onOpenWidgetUrl?: (url: string) => void; onCopyWidgetUrl?: (url: string) => void; criteria: RankCriteria; unit: UnitOption; display: DisplayFields; animateOnRankChange: boolean; data: T[]; accessors: ReaderBoardAccessors<T>; }; export type ReaderBoardHandle = { stop: () => void; spin?: () => void }; ``` ### 테마 & Provider ```ts export enum ReaderBoardStyle { Basic = "basic", Dark = "dark", Neon = "neon" } export type ReaderBoardTheme = { name: ReaderBoardStyle; colors: { bg: string; headerBg: string; rowBg: string; rowAltBg?: string; rowHoverBg?: string; rowBorder: string; text: string; subtext: string; accent: string; rankBadgeBg: string; rankBadgeText: string; topRankBg?: string; upColor?: string; downColor?: string; }; layout: { radius: number; gap: number; padX: number; padY: number; rowMinHeight: number; colMinWidth?: number; zebra?: boolean; stickyHeader?: boolean; }; font: { family: string; size: number; headerSize?: number; weight: number; headerWeight?: number; mono?: string }; shadow: { table?: string; rowHover?: string; badge?: string }; effects: { reorderMs: number; easing: string; highlightMs: number }; rankBadge: { show?: boolean; width: number; radius: number }; }; ``` ```tsx <ReaderBoardThemeProvider styleKey={ReaderBoardStyle.Basic} // overrideTheme={{ colors: { accent: "#ff55aa" } }} > <ReaderBoard ... /> </ReaderBoardThemeProvider> ``` --- ## 테마 타입 설정 (TypeScript) **styled-components v6** 사용 시: `src/definition/styled.d.ts` ```ts import "styled-components"; import type { ReaderBoardTheme } from "../readerboard/readerboard-types"; declare module "styled-components" { export interface DefaultTheme extends ReaderBoardTheme {} } ``` `tsconfig.json` ```json { "compilerOptions": { "jsx": "react-jsx", "module": "ESNext", "target": "ES2020", "moduleResolution": "Bundler", "strict": true, "skipLibCheck": true, "types": ["react", "react-dom", "styled-components"], "forceConsistentCasingInFileNames": true }, "include": ["src", "src/**/*.d.ts"] } ``` > v5를 사용할 경우에만 `@types/styled-components`를 devDependency로 설치하고, tsconfig의 `types`에 추가하세요. --- ## 패키지 내보내기 (Exports) ```ts // index.ts export { default as SlotMachineHybrid } from "./slotmachine/SlotMachineHybrid"; export type { SlotMachineHybridProps, SymbolItem, SlotMachineHandle } from "./slotmachine/SlotMachineHybrid"; export { ReaderBoard } from "./readerboard/ReaderBoard"; export type { ReaderBoardHandle, ReaderBoardProps, ReaderBoardAccessors } from "./readerboard/ReaderBoard"; export { ReaderBoardThemeProvider, READERBOARD_PRESETS } from "./readerboard/ReaderBoardThemeProvider"; export { ReaderBoardStyle } from "./readerboard/readerboard-types"; export type { ReaderBoardTheme, RankCriteria, UnitOption, DisplayFields } from "./readerboard/readerboard-types"; ``` --- ## 트러블슈팅 - **Cannot find type definition file for '@types/styled-components'** - v6에서는 타입이 내장되어 있으므로 `tsconfig.compilerOptions.types`에서 `@types/styled-components`를 제거하고 `"styled-components"`만 남기세요. - **Property 'layout' does not exist on type 'DefaultTheme'** - `styled.d.ts` 선언 병합이 빠졌거나 `include` 경로 밖에 있을 있습니다. - **FLIP 애니메이션이 보이지 않음** - Row의 `ref` 콜백이 제대로 주입됐는지, `key`가 안정적인지 확인하세요. - 정렬 기준 변경/데이터 업데이트 `keys` 배열이 실제로 바뀌는지 확인하세요. - **Form elements must have labels** - 읽기전용 URL input에는 `<label htmlFor="...">` 연결 또는 `aria-label`을 추가하세요. --- ## 라이선스 ISC © whatpull