82 lines
13 KiB
TypeScript
82 lines
13 KiB
TypeScript
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;
|
||
}
|