공용 파일(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>
45 lines
5.4 KiB
TypeScript
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];}
|
|
}
|