feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG
web (Vite+React19+TS, Cloudflare Pages 배포): - 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브 - 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정 - ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서 - 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨 api (FastAPI): - 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존) - services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 / 턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG - store: DB off 폴백(in-memory), sessions 실구현 검증: - web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200 - api: app.main import 통과 - 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시) - E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
This commit is contained in:
parent
859ab26314
commit
24b1b7a6e1
84 changed files with 19645 additions and 107 deletions
250
apps/web/src/lib/api.ts
Normal file
250
apps/web/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
/* =====================================================================
|
||||
Vignette — API 클라이언트 (fetch wrapper + SSE 헬퍼)
|
||||
백엔드 계약: apps/api/app/routes (auth.py, sessions.py).
|
||||
- 모든 요청은 credentials:"include" (BFF __Host-vignette_sid HttpOnly 쿠키).
|
||||
- 에러는 ApiError 로 표준화.
|
||||
- SSE: GET /sessions/{id}/stream 의 token/done/ping/error 이벤트를 콜백으로 전달.
|
||||
===================================================================== */
|
||||
|
||||
// Vite 환경변수. 기본 "/api" (vite proxy 또는 nginx 가 백엔드로 라우팅).
|
||||
const API_BASE: string =
|
||||
(import.meta.env.VITE_API_BASE as string | undefined) ?? "/api";
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly detail: string;
|
||||
readonly body: unknown;
|
||||
|
||||
constructor(status: number, detail: string, body?: unknown) {
|
||||
super(`API ${status}: ${detail}`);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.detail = detail;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
|
||||
interface RequestOptions {
|
||||
method?: HttpMethod;
|
||||
/** JSON 직렬화될 요청 바디 */
|
||||
body?: unknown;
|
||||
/** 추가 헤더 */
|
||||
headers?: Record<string, string>;
|
||||
/** AbortSignal (취소·타임아웃) */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
function joinUrl(path: string): string {
|
||||
if (/^https?:\/\//.test(path)) return path;
|
||||
const base = API_BASE.replace(/\/$/, "");
|
||||
const p = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${base}${p}`;
|
||||
}
|
||||
|
||||
async function parseError(res: Response): Promise<ApiError> {
|
||||
let detail = res.statusText || "request failed";
|
||||
let body: unknown = undefined;
|
||||
try {
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
if (ct.includes("application/json")) {
|
||||
body = await res.json();
|
||||
// FastAPI 표준 에러는 { detail: ... }
|
||||
const d = (body as { detail?: unknown }).detail;
|
||||
if (typeof d === "string") detail = d;
|
||||
else if (d != null) detail = JSON.stringify(d);
|
||||
} else {
|
||||
const text = await res.text();
|
||||
if (text) detail = text;
|
||||
body = text;
|
||||
}
|
||||
} catch {
|
||||
/* 본문 파싱 실패는 무시하고 statusText 사용 */
|
||||
}
|
||||
return new ApiError(res.status, detail, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON API 호출. 2xx 가 아니면 ApiError throw.
|
||||
* 204/빈 응답은 undefined 반환.
|
||||
*/
|
||||
export async function apiFetch<T = unknown>(
|
||||
path: string,
|
||||
options: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const { method = "GET", body, headers = {}, signal } = options;
|
||||
|
||||
const finalHeaders: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
...headers,
|
||||
};
|
||||
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
credentials: "include", // __Host-vignette_sid 쿠키 전송 (BFF)
|
||||
signal,
|
||||
};
|
||||
|
||||
if (body !== undefined) {
|
||||
finalHeaders["Content-Type"] = "application/json";
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
init.headers = finalHeaders;
|
||||
|
||||
const res = await fetch(joinUrl(path), init);
|
||||
|
||||
if (!res.ok) {
|
||||
throw await parseError(res);
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
if (!ct.includes("application/json")) {
|
||||
return (await res.text()) as unknown as T;
|
||||
}
|
||||
const len = res.headers.get("content-length");
|
||||
if (len === "0") return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T = unknown>(path: string, opts?: Omit<RequestOptions, "method" | "body">) =>
|
||||
apiFetch<T>(path, { ...opts, method: "GET" }),
|
||||
post: <T = unknown>(path: string, body?: unknown, opts?: Omit<RequestOptions, "method">) =>
|
||||
apiFetch<T>(path, { ...opts, method: "POST", body }),
|
||||
put: <T = unknown>(path: string, body?: unknown, opts?: Omit<RequestOptions, "method">) =>
|
||||
apiFetch<T>(path, { ...opts, method: "PUT", body }),
|
||||
del: <T = unknown>(path: string, opts?: Omit<RequestOptions, "method" | "body">) =>
|
||||
apiFetch<T>(path, { ...opts, method: "DELETE" }),
|
||||
};
|
||||
|
||||
/* =====================================================================
|
||||
백엔드 응답 타입 (apps/api 계약 미러)
|
||||
===================================================================== */
|
||||
|
||||
/** GET /auth/me — auth.py MeResponse */
|
||||
export interface MeResponse {
|
||||
user_id: string;
|
||||
role: string; // "learner" | "teacher" | "admin"
|
||||
cohort_ids: string[];
|
||||
}
|
||||
|
||||
export type SessionStage = "라포" | "탐색" | "개입" | "정리";
|
||||
|
||||
/** POST /sessions — sessions.py SessionStartResponse */
|
||||
export interface SessionStartResponse {
|
||||
session_id: string;
|
||||
case_id: string;
|
||||
session_no: number;
|
||||
stage: SessionStage;
|
||||
recall_summary: string | null;
|
||||
}
|
||||
|
||||
/** POST /sessions/{id}/turn — sessions.py TurnResponse */
|
||||
export interface TurnResponse {
|
||||
turn_seq: number;
|
||||
stage: SessionStage;
|
||||
effective_openness: number;
|
||||
client_reply: string | null;
|
||||
safety_flagged: boolean;
|
||||
}
|
||||
|
||||
/** POST /sessions/{id}/end — sessions.py SessionEndResponse */
|
||||
export interface SessionEndResponse {
|
||||
session_id: string;
|
||||
session_no: number;
|
||||
digest_pending: boolean;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
SSE 헬퍼 — GET /sessions/{id}/stream
|
||||
백엔드 이벤트(sse_starlette): "token" | "done" | "ping" | "safety" | "error"
|
||||
EventSource 는 쿠키를 same-origin 으로 자동 전송하므로 BFF 인증과 호환.
|
||||
===================================================================== */
|
||||
|
||||
export interface SessionStreamHandlers {
|
||||
/** 내담자 AI 토큰 1조각 */
|
||||
onToken?: (chunk: string) => void;
|
||||
/** 스트림 정상 종료 */
|
||||
onDone?: (data: { session_id: string }) => void;
|
||||
/** 안전(위기) 신호 */
|
||||
onSafety?: (data: unknown) => void;
|
||||
/** 에러 이벤트(백엔드 EngineError) 또는 연결 오류 */
|
||||
onError?: (err: { detail: string }) => void;
|
||||
/** keep-alive ping (Cloudflare 타임아웃 회피용 heartbeat) */
|
||||
onPing?: () => void;
|
||||
}
|
||||
|
||||
export interface SessionStreamHandle {
|
||||
/** 스트림 종료(EventSource close) */
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
function safeParse(data: string): unknown {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 내담자 AI 응답 SSE 스트림을 연다.
|
||||
* @returns close() 가능한 핸들. 컴포넌트 언마운트 시 반드시 close 호출.
|
||||
*/
|
||||
export function openSessionStream(
|
||||
sessionId: string,
|
||||
handlers: SessionStreamHandlers,
|
||||
): SessionStreamHandle {
|
||||
const url = joinUrl(`/sessions/${encodeURIComponent(sessionId)}/stream`);
|
||||
// withCredentials: same-origin 쿠키 전송 (BFF). cross-origin SSE 는 CORS 필요.
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
|
||||
// addEventListener 의 커스텀 이벤트 리스너 시그니처는 Event 를 받으므로
|
||||
// MessageEvent 로 안전하게 좁힌다(.data 접근).
|
||||
const dataOf = (ev: Event): string | undefined =>
|
||||
(ev as MessageEvent).data as string | undefined;
|
||||
|
||||
es.addEventListener("token", (ev: Event) => {
|
||||
const data = dataOf(ev);
|
||||
if (data != null) handlers.onToken?.(data);
|
||||
});
|
||||
es.addEventListener("done", (ev: Event) => {
|
||||
const parsed = safeParse(dataOf(ev) ?? "{}") as { session_id?: string };
|
||||
handlers.onDone?.({ session_id: parsed.session_id ?? sessionId });
|
||||
es.close();
|
||||
});
|
||||
es.addEventListener("safety", (ev: Event) => {
|
||||
handlers.onSafety?.(safeParse(dataOf(ev) ?? "null"));
|
||||
});
|
||||
es.addEventListener("ping", () => {
|
||||
handlers.onPing?.();
|
||||
});
|
||||
es.addEventListener("error", (ev: Event) => {
|
||||
// sse_starlette 의 명시적 error 이벤트는 data 를 가짐.
|
||||
// 브라우저 연결 오류 이벤트는 data 가 없음 → 일반 연결 오류로 처리.
|
||||
const data = dataOf(ev);
|
||||
if (data) {
|
||||
const parsed = safeParse(data) as { detail?: string };
|
||||
handlers.onError?.({ detail: parsed.detail ?? "stream error" });
|
||||
} else if (es.readyState === EventSource.CLOSED) {
|
||||
handlers.onError?.({ detail: "스트림 연결이 종료되었습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
close: () => es.close(),
|
||||
};
|
||||
}
|
||||
|
||||
/* === 세션 API 헬퍼 (Features 단계 Session 페이지가 사용) === */
|
||||
export const sessionApi = {
|
||||
start: (persona_code: string, theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic") =>
|
||||
api.post<SessionStartResponse>("/sessions", { persona_code, theory_mode }),
|
||||
turn: (sessionId: string, text: string) =>
|
||||
api.post<TurnResponse>(`/sessions/${encodeURIComponent(sessionId)}/turn`, { text }),
|
||||
end: (sessionId: string) =>
|
||||
api.post<SessionEndResponse>(`/sessions/${encodeURIComponent(sessionId)}/end`),
|
||||
stream: openSessionStream,
|
||||
};
|
||||
183
apps/web/src/lib/auth.tsx
Normal file
183
apps/web/src/lib/auth.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/* =====================================================================
|
||||
Vignette — AuthContext
|
||||
- user/role 상태, login/logout.
|
||||
- 개발용 mock 로그인(역할 선택) + 실서버 /auth/me 부트스트랩(있으면 사용).
|
||||
- role 에 따라 <body data-role>·data-theme 반영 → tokens.css §6.2 accent 스왑.
|
||||
백엔드 Role enum(deps.py): learner | teacher | admin.
|
||||
디자인 accent(DESIGN_CONCEPT §6.2): learner | instructor | admin.
|
||||
teacher → data-role="instructor" 로 매핑(인디고-블루).
|
||||
===================================================================== */
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { api, ApiError, type MeResponse } from "./api";
|
||||
|
||||
/** 인증·인가 도메인 역할 (백엔드 deps.py Role 미러). */
|
||||
export type Role = "learner" | "teacher" | "admin";
|
||||
|
||||
/** tokens.css §6.2 accent 스왑용 data-role 값. */
|
||||
export type DesignRole = "learner" | "instructor" | "admin";
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
cohortIds: string[];
|
||||
}
|
||||
|
||||
export interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
role: Role | null;
|
||||
/** 부트스트랩(/auth/me) 진행 여부 — 가드 라우트의 깜빡임 방지 */
|
||||
loading: boolean;
|
||||
/** 개발용 mock 로그인: 역할 선택으로 즉시 인증 상태 진입 */
|
||||
login: (role: Role, opts?: { name?: string; userId?: string }) => void;
|
||||
/** 로그아웃 — 서버 세션 무효화 시도 후 로컬 상태 클리어 */
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "vignette.dev-auth";
|
||||
|
||||
/** API role → 디자인 data-role 매핑. */
|
||||
export function designRoleOf(role: Role): DesignRole {
|
||||
return role === "teacher" ? "instructor" : role;
|
||||
}
|
||||
|
||||
/** 역할별 한국어 컨텍스트 라벨 (톱바 좌측, §6.3). */
|
||||
export function roleLabel(role: Role): string {
|
||||
switch (role) {
|
||||
case "learner":
|
||||
return "학습 대시보드";
|
||||
case "teacher":
|
||||
return "교수 콘솔";
|
||||
case "admin":
|
||||
return "운영 콘솔";
|
||||
}
|
||||
}
|
||||
|
||||
/** 역할 진입 기본 경로. */
|
||||
export function roleHomePath(role: Role): string {
|
||||
switch (role) {
|
||||
case "learner":
|
||||
return "/learn";
|
||||
case "teacher":
|
||||
return "/teach";
|
||||
case "admin":
|
||||
return "/admin";
|
||||
}
|
||||
}
|
||||
|
||||
const ROLE_DEFAULT_NAME: Record<Role, string> = {
|
||||
learner: "김수련",
|
||||
teacher: "이교수",
|
||||
admin: "운영자",
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
function loadStored(): AuthUser | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as AuthUser;
|
||||
if (parsed && typeof parsed.role === "string") return parsed;
|
||||
} catch {
|
||||
/* 무시 */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(() => loadStored());
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 부트스트랩: 실서버 세션이 있으면 우선. 없으면(401/네트워크오류) 저장된 mock 유지.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const me = await api.get<MeResponse>("/auth/me");
|
||||
if (!alive) return;
|
||||
const serverUser: AuthUser = {
|
||||
userId: me.user_id,
|
||||
name: me.user_id,
|
||||
role: (me.role as Role) ?? "learner",
|
||||
cohortIds: me.cohort_ids ?? [],
|
||||
};
|
||||
setUser(serverUser);
|
||||
} catch (err) {
|
||||
// 401(미인증) 또는 백엔드 미가동 → mock/로그아웃 상태 유지(에러 아님).
|
||||
if (!(err instanceof ApiError) && !(err instanceof TypeError)) {
|
||||
// 예기치 못한 오류는 콘솔로만 (UX 차단 안 함)
|
||||
console.warn("[auth] /auth/me bootstrap failed", err);
|
||||
}
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// role → <body data-role> 반영 (accent 스왑). 미인증이면 속성 제거.
|
||||
useEffect(() => {
|
||||
const body = document.body;
|
||||
if (user) {
|
||||
body.setAttribute("data-role", designRoleOf(user.role));
|
||||
} else {
|
||||
body.removeAttribute("data-role");
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const login = useCallback<AuthContextValue["login"]>((role, opts) => {
|
||||
const next: AuthUser = {
|
||||
userId: opts?.userId ?? `dev-${role}`,
|
||||
name: opts?.name ?? ROLE_DEFAULT_NAME[role],
|
||||
role,
|
||||
cohortIds: [],
|
||||
};
|
||||
setUser(next);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* 저장 실패 무시 */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback<AuthContextValue["logout"]>(async () => {
|
||||
try {
|
||||
await api.post("/auth/logout");
|
||||
} catch {
|
||||
// 서버 미가동/스텁이어도 로컬 클리어는 진행
|
||||
}
|
||||
setUser(null);
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
/* 무시 */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({ user, role: user?.role ?? null, loading, login, logout }),
|
||||
[user, loading, login, logout],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAuth 는 <AuthProvider> 내부에서만 사용할 수 있습니다.");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
85
apps/web/src/lib/format.ts
Normal file
85
apps/web/src/lib/format.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/* =====================================================================
|
||||
Vignette — 포맷 유틸 (시간/타이머/숫자). 전부 tabular-nums 전제.
|
||||
===================================================================== */
|
||||
|
||||
/** 초 → "M:SS" (세션 타이머용, 0~59분). 1시간 넘으면 "H:MM:SS". */
|
||||
export function formatElapsed(totalSeconds: number): string {
|
||||
const s = Math.max(0, Math.floor(totalSeconds));
|
||||
const hours = Math.floor(s / 3600);
|
||||
const minutes = Math.floor((s % 3600) / 60);
|
||||
const seconds = s % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
if (hours > 0) return `${hours}:${pad(minutes)}:${pad(seconds)}`;
|
||||
return `${minutes}:${pad(seconds)}`;
|
||||
}
|
||||
|
||||
/** 초 → "MM:SS" (항상 2자리 분). 자막 타임코드용. */
|
||||
export function formatTimecode(totalSeconds: number): string {
|
||||
const s = Math.max(0, Math.floor(totalSeconds));
|
||||
const minutes = Math.floor(s / 60);
|
||||
const seconds = s % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(minutes)}:${pad(seconds)}`;
|
||||
}
|
||||
|
||||
/** 초 → "12분", "1시간 5분" 등 한국어 사람친화 표기. */
|
||||
export function formatDurationKo(totalSeconds: number): string {
|
||||
const s = Math.max(0, Math.floor(totalSeconds));
|
||||
const hours = Math.floor(s / 3600);
|
||||
const minutes = Math.floor((s % 3600) / 60);
|
||||
if (hours > 0 && minutes > 0) return `${hours}시간 ${minutes}분`;
|
||||
if (hours > 0) return `${hours}시간`;
|
||||
if (minutes > 0) return `${minutes}분`;
|
||||
return `${s}초`;
|
||||
}
|
||||
|
||||
/** 정수에 천단위 콤마 (tabular 표기 전제). */
|
||||
export function formatNumber(n: number): string {
|
||||
return new Intl.NumberFormat("ko-KR").format(n);
|
||||
}
|
||||
|
||||
/** 0~1 비율 → 백분율 정수 문자열 "72%". */
|
||||
export function formatPercent(ratio: number, fractionDigits = 0): string {
|
||||
const pct = clamp01(ratio) * 100;
|
||||
return `${pct.toFixed(fractionDigits)}%`;
|
||||
}
|
||||
|
||||
/** 변화량 표기: +6 / -3 / 0 (부호 명시). */
|
||||
export function formatDelta(delta: number): string {
|
||||
if (delta > 0) return `+${delta}`;
|
||||
return String(delta);
|
||||
}
|
||||
|
||||
/** 추세 방향 (화살표는 컴포넌트가 토큰 색으로 렌더). 이모지 아님. */
|
||||
export type Trend = "up" | "down" | "flat";
|
||||
export function trendOf(delta: number, deadband = 0): Trend {
|
||||
if (delta > deadband) return "up";
|
||||
if (delta < -deadband) return "down";
|
||||
return "flat";
|
||||
}
|
||||
|
||||
/** ISO 날짜/Date → "6/24", "2026-06-24" 등. */
|
||||
export function formatDateShort(input: string | Date): string {
|
||||
const d = typeof input === "string" ? new Date(input) : input;
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}
|
||||
|
||||
export function formatDateISO(input: string | Date): string {
|
||||
const d = typeof input === "string" ? new Date(input) : input;
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** 0~1 클램프. */
|
||||
export function clamp01(v: number): number {
|
||||
if (v < 0) return 0;
|
||||
if (v > 1) return 1;
|
||||
return v;
|
||||
}
|
||||
|
||||
/** 임의 범위 클램프. */
|
||||
export function clamp(v: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(v, min), max);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue