Files
Pier_v1/수량산출/formula.ts
T
ypshimandClaude Haiku 5.5 fc520ec03d feat: 프로젝트 공통 지반모델·교대 지반 통합·재료표/수량 진입·옥성교 랜딩 히어로 (11-06~11-09 작업분)
공용 파일(main.ts·index.html·style.css)에서 서로 얽혀 있어 한 커밋으로 묶음.
- 지반: ① 형상 프로젝트 지반모델 관리창(생성·JSON 불러오기·교각별 비교·선택 적용),
  프로젝트 JSON 내장 groundModel 추출, 교대 8행 통합 적용·공통지형 중복격자 제외
- 재료표·수량: 상단 ⑤ 메뉴·배근 바로가기, 결과 교각/탭 기억, 수량 워크북·정합 보정
- 랜딩: 옥성교 6교각 히어로 이미지·투영 강조점·좌→우 광선·지형 라운드/도로 연결
- 검증 스크립트(verify-project-ground/ground-abutments/okseong-user-files/quantity-*/landing-hero 등)
정본: DOC/04_실행/실행_*_2026-11-09_v0.1.00.md

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-09 18:01:02 +09:00

45 lines
5.4 KiB
TypeScript

import {template,type Cell} from './template-data.ts';
export type Overrides=Record<string,string|number|null>;
export class FormulaError extends Error{}
type Node={kind:'number';n:number}|{kind:'ref';sheet?:string;cell:string}|{kind:'range';a:Node;b:Node}|{kind:'unary';op:string;a:Node}|{kind:'op';op:string;a:Node;b:Node}|{kind:'call';name:string;args:Node[]};
const trees=new Map<string,Node>();
function parse(formula:string):Node{
if(trees.has(formula))return trees.get(formula)!;
const tokens=formula.slice(1).match(/'(?:[^']|'')+'!\$?[A-Z]+\$?\d+|\$?[A-Z]+\$?\d+|(?:\d+(?:\.\d*)?|\.\d+)(?:[Ee][+-]?\d+)?|[A-Z][A-Z0-9_.]*|[()+\-*/^,:]/g)??[];
if(tokens.join('').replace(/\s+/g,'')!==formula.slice(1).replace(/\s+/g,''))throw new FormulaError('지원하지 않는 수식: '+formula);
let i=0;
const expression=(min=0):Node=>{let a=atom();while(i<tokens.length){const op=tokens[i],p=op==='+'||op==='-'?1:op==='*'||op==='/'?2:op==='^'?3:0;if(p<=min)break;i++;a={kind:'op',op,a,b:expression(op==='^'?p-1:p)};}return a;};
const atom=():Node=>{const t=tokens[i++];if(!t)throw new FormulaError('수식 끝 오류');
if(t==='+'||t==='-')return {kind:'unary',op:t,a:atom()};
if(t==='('){const a=expression();if(tokens[i++]!==')')throw new FormulaError('괄호 오류');return a;}
if(/^\d|^\./.test(t))return {kind:'number',n:Number(t)};
if(t.includes('!')||/^\$?[A-Z]+\$?\d+$/.test(t)){const split=t.lastIndexOf('!');let a:Node={kind:'ref',sheet:split>=0?t.slice(1,split-1).replace(/''/g,"'"):undefined,cell:t.slice(split+1).replace(/\$/g,'')};if(tokens[i]===':'){i++;a={kind:'range',a,b:atom()};}return a;}
if(tokens[i++]!=='(')throw new FormulaError('함수 오류: '+t);const args:Node[]=[];if(tokens[i]!==')'){args.push(expression());while(tokens[i]===','){i++;args.push(expression());}}
if(tokens[i++]!==')')throw new FormulaError('함수 괄호 오류: '+t);return {kind:'call',name:t,args};
};
const result=expression();if(i!==tokens.length)throw new FormulaError('수식 토큰 잔여: '+formula);trees.set(formula,result);return result;
}
const col=(s:string)=>[...s].reduce((n,c)=>n*26+c.charCodeAt(0)-64,0);
const letters=(v:number):string=>v?letters(Math.floor((v-1)/26))+String.fromCharCode(65+(v-1)%26):'';
export const cellKey=(sheet:string,cell:string)=>sheet+'!'+cell;
export class FormulaBook{
cache=new Map<string,number|string>();active=new Set<string>();
constructor(public overrides:Overrides={},public data:Record<string,Record<string,Cell>>=template,public formulas:Record<string,string>={}){}
cell(sheet:string,cell:string):number|string{const k=cellKey(sheet,cell);if(this.cache.has(k))return this.cache.get(k)!;if(this.active.has(k))throw new FormulaError('순환참조 '+k);this.active.add(k);
try{const raw=Object.hasOwn(this.overrides,k)?this.overrides[k]:this.formulas[k]??this.data[sheet]?.[cell]?.value;if(raw===null)throw new FormulaError('필수입력 누락 '+k);const value=typeof raw==='string'&&raw.startsWith('=')&&raw.length>1?this.evaluate(parse(raw),sheet):raw??0;
if(typeof value==='number'&&!Number.isFinite(value))throw new FormulaError('정의역/0 나누기 '+k);const result=typeof value==='boolean'?Number(value):value as number|string;this.cache.set(k,result);return result;
}finally{this.active.delete(k);}}
private numeric(v:unknown):number{const n=typeof v==='number'?v:Number(v);if(!Number.isFinite(n))throw new FormulaError('숫자 변환 불가 '+v);return n;}
private evaluate(n:Node,sheet:string):number|number[]{
if(n.kind==='number')return n.n;
if(n.kind==='ref')return this.numeric(this.cell(n.sheet??sheet,n.cell));
if(n.kind==='range'){if(n.a.kind!=='ref'||n.b.kind!=='ref')throw new FormulaError('범위 오류');const a=n.a.cell.match(/([A-Z]+)(\d+)/)!,b=n.b.cell.match(/([A-Z]+)(\d+)/)!;const out:number[]=[];for(let r=+a[2];r<=+b[2];r++)for(let c=col(a[1]);c<=col(b[1]);c++)out.push(this.numeric(this.cell(n.a.sheet??sheet,letters(c)+r)));return out;}
if(n.kind==='unary')return (n.op==='-'?-1:1)*this.numeric(this.evaluate(n.a,sheet));
if(n.kind==='op'){const a=this.numeric(this.evaluate(n.a,sheet)),b=this.numeric(this.evaluate(n.b,sheet));return n.op==='+'?a+b:n.op==='-'?a-b:n.op==='*'?a*b:n.op==='/'?a/b:a**b;}
const args=n.args.flatMap(a=>this.evaluate(a,sheet));const f:Record<string,()=>number>={SUM:()=>args.reduce((a,b)=>a+b,0),MAX:()=>Math.max(...args),MIN:()=>Math.min(...args),SQRT:()=>Math.sqrt(args[0]),PI:()=>Math.PI,ASIN:()=>Math.asin(args[0]),SIN:()=>Math.sin(args[0])};if(!f[n.name])throw new FormulaError('지원하지 않는 함수 '+n.name);return f[n.name]();
}
substitution(sheet:string,formula:string):string{return formula.replace(/'(?:[^']|'')+'!\$?[A-Z]+\$?\d+|\$?[A-Z]+\$?\d+/g,t=>{const p=t.lastIndexOf('!');try{return String(this.cell(p>=0?t.slice(1,p-1).replace(/''/g,"'"):sheet,t.slice(p+1).replace(/\$/g,'')));}catch{return '미입력';}});}
references(sheet:string,cell:string):string[]{const result=new Set<string>(),visiting=new Set<string>();const walk=(s:string,c:string)=>{const key=cellKey(s,c);if(visiting.has(key))return;visiting.add(key);result.add(key);if(Object.hasOwn(this.overrides,key))return;const f=this.formulas[key]??this.data[s]?.[c]?.value;if(typeof f!=='string'||(!f.startsWith('=')||f.length===1))return;
for(const t of f.slice(1).match(/'(?:[^']|'')+'!\$?[A-Z]+\$?\d+|\$?[A-Z]+\$?\d+/g)??[]){const p=t.lastIndexOf('!');walk(p>=0?t.slice(1,p-1).replace(/''/g,"'"):s,t.slice(p+1).replace(/\$/g,''));}};walk(sheet,cell);return [...result];}
}