Files
Pier_v1/수량산출/excel-preview.ts
T

82 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {sheetLayouts,excelStyles} from './template-layout';
import {template} from './template-data';
import {FormulaBook,cellKey,type Overrides} from './formula';
import {DETAIL,INPUTS,specifications} from './schema';
import type {QuantityInputs,calculate} from './model';
import './excel-preview.css';
import type {PierModel} from '../src/core/geometry';
import {quantityPlanSvg} from './plan-preview';
type Layout={firstRow:number;rows:number;cols:number;widths:number[];heights:number[];merges:number[][];cells:Record<string,number>};
type Style={font:string;size:number;bold:boolean;italic:boolean;color:string;background:string;align:string;valign:string;border:Record<string,{width:number;style:string;color:string}>;wrap:boolean};
const layouts=sheetLayouts as unknown as Record<string,Layout>,styles=excelStyles as unknown as Record<string,Style>;
const referenceImages:Record<string,string[]>={
'3.1 수량산출기준_토공':[new URL('./assets/reference-pdf-04.png',import.meta.url).href,new URL('./assets/reference-pdf-05.png',import.meta.url).href],
'3.1 수량산출기준_구조체 현황':[new URL('./assets/reference-pdf-06.png',import.meta.url).href],
'3.1 수량산출기준_구조체':[new URL('./assets/reference-pdf-07.png',import.meta.url).href,new URL('./assets/reference-pdf-08.png',import.meta.url).href],
};
const letters=(v:number):string=>v?letters(Math.floor((v-1)/26))+String.fromCharCode(65+(v-1)%26):'';
const node=<K extends keyof HTMLElementTagNameMap>(tag:K,text?:string)=>{const n=document.createElement(tag);if(text!==undefined)n.textContent=text;return n;};
export function createExcelPreview(options:{result:ReturnType<typeof calculate>;stored:QuantityInputs;pier:string;project:{name:string;bridge:string};projectRows?:{name:string;result:ReturnType<typeof calculate>}[];plan?:{selected:PierModel;models:readonly PierModel[]};compact?:boolean;state:{sheet:string;zoom:number};onCell:(cell:string)=>void}):HTMLElement{
const {result,stored,pier,state}=options,host=node('section');host.className='takeoff-excel-preview'+(options.compact?' is-compact':'');const toolbar=node('div');toolbar.className='takeoff-excel-toolbar';
const selector=node('select');selector.setAttribute('aria-label',options.compact?'미니 산출서 시트 선택':'Excel 샘플 시트 선택');
const names=Object.keys(layouts).filter(name=>name!=='3.3 수량산출_ex)'&&!/^3\.3 수량산출_P[2-6]$/.test(name));for(const name of ['__all__',...names]){const o=node('option',name==='__all__'?'표지부터 선택 교각 전체 보기':name===DETAIL?'3.3 수량산출_'+pier:name);o.value=name;o.selected=state.sheet===name;selector.append(o);}toolbar.append(selector);
const previous=node('button',options.compact?'‹':'이전 시트'),next=node('button',options.compact?'›':'다음 시트');previous.setAttribute('aria-label','이전 시트');next.setAttribute('aria-label','다음 시트');previous.type=next.type='button';const counter=node('output');counter.className='takeoff-excel-page-count';toolbar.append(previous,counter,next);
const zoom=node('select');zoom.setAttribute('aria-label','수량산출서 확대');for(const z of [0,...(options.compact?[.3,.4,.5,.6,.8,1]:[.8,1,1.1,1.25,1.5])]){const o=node('option',z===0?'한 장 맞춤':Math.round(z*100)+'%');o.value=String(z);o.selected=z===state.zoom;zoom.append(o);}toolbar.append(zoom,node('span','원본 Excel 양식 · 수량 셀을 누르면 산식 확인'));
const viewport=node('div');viewport.className='takeoff-excel-viewport';const paper=node('div');paper.className='takeoff-excel-paper';viewport.append(paper);host.append(toolbar,viewport);
const overrides:Overrides={...result.overrides};
for(let i=2;i<=6;i++){const s='3.3 수량산출_P'+i;for(const [cell,c]of Object.entries(template[s]))if(typeof c.value==='number'||typeof c.value==='string'&&c.value.startsWith('='))overrides[cellKey(s,cell)]=0;const col=['P','R','T','V','X'][i-2];for(let r=18;r<=46;r++)overrides[cellKey(INPUTS,col+r)]=0;}
for(let r=6;r<=34;r++){const c=template['3.3 수량산출_ex)']['BG'+r];if(c&&typeof c.cached==='number')overrides[cellKey('3.3 수량산출_ex)','BG'+r)]=c.cached;}
const book=new FormulaBook(overrides,template,result.formulas);
const display=(sheet:string,cell:string):unknown=>{
if(sheet==='표지및목차'){if(cell==='A6')return options.project.name;if(cell==='A14')return options.project.bridge+' · '+pier;if(cell==='A24')return new Date().toISOString().slice(0,10);}
if(/^3\.3 수량산출_P[2-6]$/.test(sheet)){if(cell==='F2')return '(미사용)';const raw=template[sheet]?.[cell]?.value;if(typeof raw==='number'||typeof raw==='string'&&raw.startsWith('=')&&raw.length>1)return '';}
if(sheet===DETAIL){if(cell==='F2')return '('+pier+')';const spec=specifications.find(v=>v.cell===cell);if(spec)return stored.values[spec.key]||'규격 확인 필요';const row=result.results.find(r=>r.cell===cell);if(row)return row.value;}
if(sheet===INPUTS){if(cell==='N30'&&result.copingMode==='straight')return '직선';if(cell==='N17')return pier;if(/^[PRTVX](1[89]|[234]\d)$/.test(cell))return '';if(/^[PRTVX]17$/.test(cell))return '미사용';if(/^N\d+$/.test(cell)&&Object.hasOwn(result.overrides,cellKey(INPUTS,cell)))return result.overrides[cellKey(INPUTS,cell)];}
if(sheet==='1. 수량집계'&&options.projectRows){const match=cell.match(/^([A-Z]+)(\d+)$/)!;let col=0;for(const letter of match[1])col=col*26+letter.charCodeAt(0)-64;const row=Number(match[2]),source=template[sheet]['N'+row],ref=String(source?.value??'').match(/!\$?([A-Z]+)\$?(\d+)/),values=ref?options.projectRows.map(p=>p.result.results.find(r=>r.cell===ref[1]+ref[2])?.value??null):[];
if(col>=14){const p=options.projectRows[Math.floor((col-14)/3)];if(!p)return '';if(row===4||row===48)return p.name;if(ref)return values[Math.floor((col-14)/3)];}
if(col===11&&ref)return values.some(v=>v===null)?null:values.reduce((a,b)=>a!+b!,0);
}
const c=template[sheet]?.[cell];if(!c)return '';if(typeof c.value==='string'&&c.value.startsWith('=')&&c.value.length>1){try{return book.cell(sheet,cell);}catch{return null;}}return c.value;
};
const formatted=(value:unknown,format:string)=>{if(value===null)return '미산정';if(value===undefined)return '';if(typeof value!=='number')return String(value);if(format==='General')return String(value);
if(value===0&&format.includes('"-"'))return '−';const m=format.split(';')[0].match(/\.([0#]+)/),digits=m?.[1].length??0;
return value.toLocaleString('ko-KR',{minimumFractionDigits:digits,maximumFractionDigits:digits,useGrouping:format.includes(',')});};
const fit=()=>{if(state.zoom!==0||!host.isConnected)return;paper.style.zoom='1';const css=getComputedStyle(viewport),w=viewport.clientWidth-parseFloat(css.paddingLeft)-parseFloat(css.paddingRight)-4,h=viewport.clientHeight-parseFloat(css.paddingTop)-parseFloat(css.paddingBottom)-4;paper.style.zoom=String(Math.max(.05,Math.min(w/paper.offsetWidth,h/paper.offsetHeight,1.5)));viewport.scrollTo(0,0);};
const observer=new ResizeObserver(()=>{if(host.isConnected)fit();else observer.disconnect();});observer.observe(viewport);
const draw=()=>{state.sheet=selector.value;state.zoom=Number(zoom.value);paper.replaceChildren();paper.style.zoom=String(state.zoom||1);viewport.classList.toggle('is-page-fit',state.zoom===0);counter.textContent=state.sheet==='__all__'?'전체 / '+names.length:(names.indexOf(state.sheet)+1)+' / '+names.length;
const renderSheet=(sheet:string)=>{
const page=node('article');page.className='takeoff-excel-page';page.dataset.previewSheet=sheet;
const title=sheet===DETAIL?'3.3 수량산출 · '+pier:sheet==='3.3 수량산출_ex)'?sheet+' · 원본 참고 예시':/^3\.3 수량산출_P[2-6]$/.test(sheet)?sheet+' · 미사용':sheet;
page.append(node('h4',title));paper.append(page);
let layout=layouts[sheet];
if(sheet==='1. 수량집계'&&options.projectRows){const n=options.projectRows.length,widths=[...layout.widths.slice(0,13),...Array.from({length:n},()=>layout.widths.slice(13,16)).flat()],cells={...layout.cells},merges=layout.merges.filter(m=>m[3]<14);for(let i=0;i<n;i++){for(const m of layout.merges.filter(m=>m[1]>=14&&m[3]<=16))merges.push([m[0],m[1]+3*i,m[2],m[3]+3*i]);for(let r=1;r<=layout.rows;r++)for(let c=0;c<3;c++)cells[letters(14+3*i+c)+r]=layout.cells[letters(14+c)+r];}layout={...layout,widths,cols:widths.length,cells,merges};}
const table=node('table');table.className='takeoff-excel-sheet';table.dataset.sheet=sheet;table.style.width=layout.widths.reduce((a,b)=>a+b,0)+'px';
if(referenceImages[sheet]){for(const [i,src]of referenceImages[sheet].entries()){const image=node('img');image.src=src;image.onload=fit;image.alt=sheet+' · 원본 PDF '+(i+1)+'페이지';image.className='takeoff-reference-page';image.style.width=table.style.width;page.append(image);}return;}
const cols=node('colgroup');for(const w of layout.widths){const c=node('col');c.style.width=w+'px';cols.append(c);}table.append(cols);
const merged=new Map<string,number[]>(),covered=new Set<string>();for(const [r,c,re,ce]of layout.merges){merged.set(r+','+c,[r,c,re,Math.min(ce,layout.cols)]);for(let y=r;y<=re;y++)for(let x=c;x<=ce;x++)if(y!==r||x!==c)covered.add(y+','+x);}
for(let r=layout.firstRow;r<=layout.rows;r++){const tr=node('tr');tr.style.height=layout.heights[r-1]+'px';for(let c=1;c<=layout.cols;c++){
if(covered.has(r+','+c))continue;const cell=letters(c)+r,td=node('td'),m=merged.get(r+','+c);if(m){td.rowSpan=m[2]-r+1;td.colSpan=m[3]-c+1;}
const style=styles[layout.cells[cell]],value=display(sheet,cell),raw=template[sheet]?.[cell];td.textContent=formatted(value,raw?.format??'General');td.dataset.excelCell=cell;
if(style){td.style.fontFamily=`"${style.font}","Malgun Gothic",sans-serif`;td.style.fontSize=style.size*4/3+'px';td.style.fontWeight=style.bold?'700':'400';td.style.fontStyle=style.italic?'italic':'normal';td.style.color=value===null?'#b45309':style.color;td.style.background=style.background;td.style.textAlign=style.align==='general'?(typeof value==='number'?'right':'left'):style.align;td.style.verticalAlign=style.valign==='center'?'middle':style.valign;
const border=(side:string,s:Style)=>{const b=s.border[side];return b?`${b.width}px ${b.style} ${b.color}`:'none';};
td.style.whiteSpace=style.wrap?'pre-wrap':'pre';td.style.overflowWrap=style.wrap?'anywhere':'normal';
if(!style.wrap&&typeof value==='string'&&value&&!Object.keys(style.border).length){const label=node('span',String(value));label.style.position='relative';label.style.zIndex='1';label.style.display='inline-block';label.style.whiteSpace='pre';td.replaceChildren(label);td.style.overflow='visible';}
td.style.borderTop=border('top',style);td.style.borderLeft=border('left',style);
td.style.borderRight=border('right',m?styles[layout.cells[letters(m[3])+r]]??style:style);td.style.borderBottom=border('bottom',m?styles[layout.cells[letters(c)+m[2]]]??style:style);
}
if(sheet===DETAIL&&result.results.some(x=>x.cell===cell)){td.tabIndex=0;td.classList.add('takeoff-excel-quantity');td.setAttribute('role','button');td.setAttribute('aria-label',cell+' 수량 산식 확인');td.onclick=()=>options.onCell(cell);td.onkeydown=event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();options.onCell(cell);}};}
tr.append(td);
}table.append(tr);}
const body=node('div');body.className='takeoff-sheet-body';body.append(table);page.append(body);
if(sheet===INPUTS){const plan=node('div');plan.className='takeoff-variables-plan';plan.style.width=table.style.width;plan.style.height=layout.heights.slice(layout.firstRow-1,16).reduce((a,b)=>a+b,0)+'px';plan.append(node('strong','3.2 수량 변수 · 프로젝트 KEY PLAN'));if(options.plan){const graphic=node('div');graphic.innerHTML=quantityPlanSvg(options.plan.selected,options.plan.models);plan.append(graphic);}else plan.append(node('p','프로젝트 평면도 연결 정보가 없습니다.'));body.append(plan);}
};
for(const sheet of state.sheet==='__all__'?names:[state.sheet])renderSheet(sheet);
previous.disabled=state.sheet!=='__all__'&&names.indexOf(state.sheet)===0;next.disabled=names.indexOf(state.sheet)===names.length-1;
requestAnimationFrame(fit);
};
previous.onclick=()=>{selector.value=names[Math.max(0,names.indexOf(state.sheet)-1)];draw();viewport.scrollTo(0,0);};
next.onclick=()=>{selector.value=names[Math.min(names.length-1,names.indexOf(state.sheet)+1)];draw();viewport.scrollTo(0,0);};
host.addEventListener('takeoff:focus-cells',((event:CustomEvent<string[]>)=>{selector.value=DETAIL;draw();const selected=event.detail.map(cell=>paper.querySelector<HTMLElement>(`[data-excel-cell="${cell}"]`)).filter((n):n is HTMLElement=>!!n);for(const cell of selected)cell.classList.add('is-linked');if(selected[0]){const rect=selected[0].getBoundingClientRect(),v=viewport.getBoundingClientRect();viewport.scrollTo({top:rect.top-v.top+viewport.scrollTop-viewport.clientHeight*.3,left:0});}}) as EventListener);
selector.onchange=()=>{draw();viewport.scrollTo(0,0);};zoom.onchange=draw;draw();return host;
}