@whatpull/webjet
Version:
Hybrid canvas+CSS slot machine for React
364 lines (305 loc) • 11.5 kB
Markdown
# Whatpull Web‑Jet Series (React)
두 가지 고성능 UI 모듈을 제공합니다.
1) **🎰 SlotMachineHybrid** — Canvas + CSS 하이브리드 슬롯머신
2) **🏆 ReaderBoard** — FLIP 재배치 애니메이션과 테마(Provider) 기반 리더보드



> 패키지명이 다르면 아래 `@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