/** * pop-dashboard 수식 파싱 및 평가 유틸리티 * * 보안: eval()/new Function() 사용 금지. 직접 사칙연산 파서 구현. */ import type { FormulaConfig, FormulaDisplayFormat } from "../../types"; // ===== 토큰 타입 ===== type TokenType = "number" | "variable" | "operator" | "lparen" | "rparen"; interface Token { type: TokenType; value: string; } // ===== 토크나이저 ===== /** 수식 문자열을 토큰 배열로 분리 */ function tokenize(expression: string): Token[] { const tokens: Token[] = []; let i = 0; const expr = expression.replace(/\s+/g, ""); while (i < expr.length) { const ch = expr[i]; // 숫자 (정수, 소수) if (/\d/.test(ch) || (ch === "." && i + 1 < expr.length && /\d/.test(expr[i + 1]))) { let num = ""; while (i < expr.length && (/\d/.test(expr[i]) || expr[i] === ".")) { num += expr[i]; i++; } tokens.push({ type: "number", value: num }); continue; } // 변수 (A, B, C 등 알파벳) if (/[A-Za-z]/.test(ch)) { let varName = ""; while (i < expr.length && /[A-Za-z0-9_]/.test(expr[i])) { varName += expr[i]; i++; } tokens.push({ type: "variable", value: varName }); continue; } // 연산자 if ("+-*/".includes(ch)) { tokens.push({ type: "operator", value: ch }); i++; continue; } // 괄호 if (ch === "(") { tokens.push({ type: "lparen", value: "(" }); i++; continue; } if (ch === ")") { tokens.push({ type: "rparen", value: ")" }); i++; continue; } // 알 수 없는 문자는 건너뜀 i++; } return tokens; } // ===== 재귀 하강 파서 ===== /** * 사칙연산 수식을 안전하게 평가 (재귀 하강 파서) * * 문법: * expr = term (('+' | '-') term)* * term = factor (('*' | '/') factor)* * factor = NUMBER | VARIABLE | '(' expr ')' * * @param expression - 수식 문자열 (예: "A / B * 100") * @param values - 변수값 맵 (예: { A: 1234, B: 5678 }) * @returns 계산 결과 (0으로 나누기 시 0 반환) */ export function evaluateFormula( expression: string, values: Record ): number { const tokens = tokenize(expression); let pos = 0; function peek(): Token | undefined { return tokens[pos]; } function consume(): Token { return tokens[pos++]; } // factor = NUMBER | VARIABLE | '(' expr ')' function parseFactor(): number { const token = peek(); if (!token) return 0; if (token.type === "number") { consume(); return parseFloat(token.value); } if (token.type === "variable") { consume(); return values[token.value] ?? 0; } if (token.type === "lparen") { consume(); // '(' 소비 const result = parseExpr(); if (peek()?.type === "rparen") { consume(); // ')' 소비 } return result; } // 예상치 못한 토큰 consume(); return 0; } // term = factor (('*' | '/') factor)* function parseTerm(): number { let result = parseFactor(); while (peek()?.type === "operator" && (peek()!.value === "*" || peek()!.value === "/")) { const op = consume().value; const right = parseFactor(); if (op === "*") { result *= right; } else { // 0으로 나누기 방지 result = right === 0 ? 0 : result / right; } } return result; } // expr = term (('+' | '-') term)* function parseExpr(): number { let result = parseTerm(); while (peek()?.type === "operator" && (peek()!.value === "+" || peek()!.value === "-")) { const op = consume().value; const right = parseTerm(); result = op === "+" ? result + right : result - right; } return result; } const result = parseExpr(); return Number.isFinite(result) ? result : 0; } /** * 수식 결과를 displayFormat에 맞게 포맷팅 * * @param config - 수식 설정 * @param values - 변수값 맵 (예: { A: 1234, B: 5678 }) * @returns 포맷된 문자열 */ export function formatFormulaResult( config: FormulaConfig, values: Record ): string { const formatMap: Record string> = { value: () => { const result = evaluateFormula(config.expression, values); return formatNumber(result); }, fraction: () => { // "1,234 / 5,678" 형태 const ids = config.values.map((v) => v.id); if (ids.length >= 2) { return `${formatNumber(values[ids[0]] ?? 0)} / ${formatNumber(values[ids[1]] ?? 0)}`; } return formatNumber(evaluateFormula(config.expression, values)); }, percent: () => { const result = evaluateFormula(config.expression, values); return `${(result * 100).toFixed(1)}%`; }, ratio: () => { // "1,234 : 5,678" 형태 const ids = config.values.map((v) => v.id); if (ids.length >= 2) { return `${formatNumber(values[ids[0]] ?? 0)} : ${formatNumber(values[ids[1]] ?? 0)}`; } return formatNumber(evaluateFormula(config.expression, values)); }, }; return formatMap[config.displayFormat](); } /** * 수식에 사용된 변수 ID가 모두 존재하는지 검증 * * @param expression - 수식 문자열 * @param availableIds - 사용 가능한 변수 ID 배열 * @returns 유효 여부 */ export function validateExpression( expression: string, availableIds: string[] ): boolean { const tokens = tokenize(expression); const usedVars = tokens .filter((t) => t.type === "variable") .map((t) => t.value); return usedVars.every((v) => availableIds.includes(v)); } /** * 큰 숫자 축약 (Container Query 축소 시 사용) * * 1234 -> "1,234" * 12345 -> "1.2만" * 1234567 -> "123.5만" * 123456789 -> "1.2억" */ export function abbreviateNumber(value: number): string { const abs = Math.abs(value); const sign = value < 0 ? "-" : ""; if (abs >= 100_000_000) { return `${sign}${(abs / 100_000_000).toFixed(1)}억`; } if (abs >= 10_000) { return `${sign}${(abs / 10_000).toFixed(1)}만`; } return `${sign}${formatNumber(abs)}`; } // ===== 내부 헬퍼 ===== /** 숫자를 천 단위 콤마 포맷 */ function formatNumber(value: number): string { if (Number.isInteger(value)) { return value.toLocaleString("ko-KR"); } // 소수점 이하 최대 2자리 return value.toLocaleString("ko-KR", { minimumFractionDigits: 0, maximumFractionDigits: 2, }); }