@whatpull/webjet
v2.0.18
Published
Hybrid canvas+CSS slot machine for React
Maintainers
Readme
Whatpull Web‑Jet Series (React)
두 가지 고성능 UI 모듈을 제공합니다.
- 🎰 SlotMachineHybrid — Canvas + CSS 하이브리드 슬롯머신
- 🏆 ReaderBoard — FLIP 재배치 애니메이션과 테마(Provider) 기반 리더보드
패키지명이 다르면 아래
@whatpull/webjet을 실제 패키지명으로 바꿔 사용하세요.
설치
# 런타임
pnpm add styled-components
# (npm/yarn 사용 가능)
# 본 라이브러리
pnpm add @whatpull/webjetReact 18+ / Next.js 13+(App Router) 권장. TypeScript 사용 시 아래 테마 타입 설정 참고.
빠른 데모 (두 모듈 한 화면)
App.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 (요약)
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+ReaderBoardStyleenum - 카드형 Row: 배지/타이틀/통계/Progress(또는 Lock) 레이아웃
- 상승/하락 Δ 표시(옵션): 이전 랭크 대비 변화 아이콘/색상
사용
<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>타입
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
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 };
};<ReaderBoardThemeProvider
styleKey={ReaderBoardStyle.Basic}
// overrideTheme={{ colors: { accent: "#ff55aa" } }}
>
<ReaderBoard ... />
</ReaderBoardThemeProvider>테마 타입 설정 (TypeScript)
styled-components v6 사용 시:
src/definition/styled.d.ts
import "styled-components";
import type { ReaderBoardTheme } from "../readerboard/readerboard-types";
declare module "styled-components" {
export interface DefaultTheme extends ReaderBoardTheme {}
}tsconfig.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)
// 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"만 남기세요.
- v6에서는 타입이 내장되어 있으므로
- Property 'layout' does not exist on type 'DefaultTheme'
- 위
styled.d.ts선언 병합이 빠졌거나include경로 밖에 있을 수 있습니다.
- 위
- FLIP 애니메이션이 보이지 않음
- Row의
ref콜백이 제대로 주입됐는지,key가 안정적인지 확인하세요. - 정렬 기준 변경/데이터 업데이트 시
keys배열이 실제로 바뀌는지 확인하세요.
- Row의
- Form elements must have labels
- 읽기전용 URL input에는
<label htmlFor="...">연결 또는aria-label을 추가하세요.
- 읽기전용 URL input에는
라이선스
ISC © whatpull
