feat(reports): 보고서 조위/기상 섹션 실데이터 삽입 및 HWPX 이미지 내보내기 수정 #126
@ -1,6 +1,6 @@
|
||||
{
|
||||
"applied_global_version": "1.6.1",
|
||||
"applied_date": "2026-03-25",
|
||||
"applied_date": "2026-03-26",
|
||||
"project_type": "react-ts",
|
||||
"gitea_url": "https://gitea.gc-si.dev",
|
||||
"custom_pre_commit": true
|
||||
|
||||
@ -183,7 +183,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
|
||||
GROUP BY ACDNT_SN
|
||||
) B ON B.ACDNT_SN = A.ACDNT_SN
|
||||
${whereClause}
|
||||
ORDER BY A.OCCRN_DTM DESC, P.RUN_DTM DESC NULLS LAST
|
||||
ORDER BY P.RUN_DTM DESC NULLS LAST, A.OCCRN_DTM DESC
|
||||
`;
|
||||
|
||||
const { rows } = await wingPool.query(sql, params);
|
||||
|
||||
@ -4,6 +4,13 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 추가
|
||||
- 보고서: 조위/기상(oil-tide) 섹션에 실데이터 렌더링 추가 (풍향/풍속·파고·수온·유향 등)
|
||||
|
||||
### 수정
|
||||
- 보고서: HWPX 이미지 내보내기 구조를 HWPX 스펙(hc:img + manifest 방식)으로 수정
|
||||
- 확산예측: 분석 목록 정렬 기준 변경 (RUN_DTM DESC 우선)
|
||||
|
||||
## [2026-03-25.2]
|
||||
|
||||
### 추가
|
||||
|
||||
@ -206,6 +206,32 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
content = `${simBanner}<table style="width:100%;border-collapse:collapse;font-size:12px;">${trs}</table>`;
|
||||
}
|
||||
}
|
||||
if (activeCat === 0 && sec.id === 'oil-tide') {
|
||||
const wx = oilPayload?.weather;
|
||||
if (wx) {
|
||||
const stationLabel = weatherSnapshot
|
||||
? `${weatherSnapshot.stationName} 조위관측소`
|
||||
: '조위관측소';
|
||||
const capturedAt = weatherSnapshot
|
||||
? new Date(weatherSnapshot.capturedAt).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
: '';
|
||||
const rows = [
|
||||
['풍향/풍속', `${wx.windDir} / ${wx.windSpeed}`],
|
||||
['파고', wx.waveHeight + (wx.waveMaxHeight ? ` (최대 ${wx.waveMaxHeight})` : '')],
|
||||
['파주기', wx.wavePeriod ?? '—'],
|
||||
['수온', wx.temp],
|
||||
['기압', wx.pressure ?? '—'],
|
||||
['시정', wx.visibility ?? '—'],
|
||||
['염분', wx.salinity ?? '—'],
|
||||
...(wx.currentDir ? [['유향/유속', `${wx.currentDir} / ${wx.currentSpeed ?? '—'}`]] : []),
|
||||
];
|
||||
const headerHtml = `<p style="font-size:11px;font-weight:bold;color:#0891b2;margin-bottom:8px;">${stationLabel}${capturedAt ? ` <span style="font-size:10px;color:#999;font-weight:normal;">수집: ${capturedAt}</span>` : ''}</p>`;
|
||||
const trs = rows.map(r =>
|
||||
`<tr><td style="padding:6px 8px;border:1px solid #ddd;color:#888;width:100px;">${r[0]}</td><td style="padding:6px 8px;border:1px solid #ddd;font-weight:bold;">${r[1]}</td></tr>`
|
||||
).join('');
|
||||
content = `${headerHtml}<table style="width:100%;border-collapse:collapse;font-size:12px;">${trs}</table>`;
|
||||
}
|
||||
}
|
||||
|
||||
// HNS 섹션에 실 데이터 삽입
|
||||
if (activeCat === 1 && hnsPayload) {
|
||||
|
||||
@ -498,17 +498,40 @@ function buildEmptyPara(): string {
|
||||
function buildPicParagraph(binDataId: number, widthHwp: number, heightHwp: number): string {
|
||||
const pId = nextId();
|
||||
const picId = nextId();
|
||||
const fileId = `image${binDataId}`;
|
||||
const halfW = Math.round(widthHwp / 2);
|
||||
const halfH = Math.round(heightHwp / 2);
|
||||
return (
|
||||
`<hp:p id="${pId}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">` +
|
||||
'<hp:run charPrIDRef="0">' +
|
||||
`<hp:pic id="${picId}" zOrder="0" numberingType="FIGURE" textWrap="FLOAT" ` +
|
||||
`textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" pageBreak="CELL">` +
|
||||
`<hp:sz width="${widthHwp}" height="${heightHwp}" widthRelTo="ABSOLUTE" heightRelTo="ABSOLUTE" protect="0"/>` +
|
||||
`<hp:pic id="${picId}" zOrder="0" numberingType="PICTURE" textWrap="TOP_AND_BOTTOM" ` +
|
||||
`textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" href="" groupLevel="0" instid="${picId}" reverse="0">` +
|
||||
'<hp:offset x="0" y="0"/>' +
|
||||
`<hp:orgSz width="${widthHwp}" height="${heightHwp}"/>` +
|
||||
`<hp:curSz width="${widthHwp}" height="${heightHwp}"/>` +
|
||||
'<hp:flip horizontal="0" vertical="0"/>' +
|
||||
`<hp:rotationInfo angle="0" centerX="${halfW}" centerY="${halfH}" rotateimage="1"/>` +
|
||||
'<hp:renderingInfo>' +
|
||||
'<hc:transMatrix e1="1" e2="0" e3="0" e4="0" e5="1" e6="0"/>' +
|
||||
'<hc:scaMatrix e1="1" e2="0" e3="0" e4="0" e5="1" e6="0"/>' +
|
||||
'<hc:rotMatrix e1="1" e2="0" e3="0" e4="0" e5="1" e6="0"/>' +
|
||||
'</hp:renderingInfo>' +
|
||||
'<hp:imgRect>' +
|
||||
`<hc:pt0 x="0" y="0"/>` +
|
||||
`<hc:pt1 x="${widthHwp}" y="0"/>` +
|
||||
`<hc:pt2 x="${widthHwp}" y="${heightHwp}"/>` +
|
||||
`<hc:pt3 x="0" y="${heightHwp}"/>` +
|
||||
'</hp:imgRect>' +
|
||||
`<hp:imgClip left="0" right="${widthHwp}" top="0" bottom="${heightHwp}"/>` +
|
||||
'<hp:inMargin left="0" right="0" top="0" bottom="0"/>' +
|
||||
`<hp:imgDim dimwidth="${widthHwp}" dimheight="${heightHwp}"/>` +
|
||||
`<hc:img binaryItemIDRef="${fileId}" bright="0" contrast="0" effect="REAL_PIC" alpha="0"/>` +
|
||||
'<hp:effects/>' +
|
||||
`<hp:sz width="${widthHwp}" widthRelTo="ABSOLUTE" height="${heightHwp}" heightRelTo="ABSOLUTE" protect="0"/>` +
|
||||
'<hp:pos treatAsChar="1" affectLSpacing="0" flowWithText="1" allowOverlap="0" ' +
|
||||
'holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="COLUMN" ' +
|
||||
'holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="PARA" ' +
|
||||
'vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/>' +
|
||||
'<hp:outMargin left="0" right="0" top="0" bottom="0"/>' +
|
||||
`<hp:img binDataIDRef="${binDataId}" effect="REAL_PIC" alpha="0"/>` +
|
||||
'</hp:pic>' +
|
||||
'</hp:run>' +
|
||||
'</hp:p>'
|
||||
@ -879,8 +902,6 @@ export async function exportAsHWPX(
|
||||
// 이미지 처리
|
||||
let imageBinIds: { step3?: number; step6?: number; sensitiveMap?: number; sensitivityMap?: number } | undefined;
|
||||
let extraManifestItems = '';
|
||||
let binDataListXml = '';
|
||||
let binCount = 0;
|
||||
|
||||
const processImage = (src: string, binId: number, fileId: string) => {
|
||||
// 실제 이미지 포맷 감지 (JPEG vs PNG)
|
||||
@ -890,13 +911,10 @@ export async function exportAsHWPX(
|
||||
const filePath = `BinData/image${binId}.${ext}`;
|
||||
|
||||
const base64 = src.replace(/^data:image\/\w+;base64,/, '');
|
||||
// HWPX 스펙: 이미지는 ZIP 루트의 BinData/에 저장 (Contents/ 아래 아님)
|
||||
zip.file(filePath, base64, { base64: true });
|
||||
extraManifestItems += `<opf:item id="${fileId}" href="${filePath}" media-type="${mediaType}"/>`;
|
||||
// inMemory="NO": 데이터는 ZIP 내 파일로 저장, 요소 내용은 파일 경로
|
||||
binDataListXml +=
|
||||
`<hh:binData id="${binId}" isSameDocData="0" compress="NO" inMemory="NO" ` +
|
||||
`doNotCompressFile="0" blockDecompress="0" limitWidth="0" limitHeight="0">${filePath}</hh:binData>`;
|
||||
binCount++;
|
||||
// isEmbeded="1": 한글 HWPX 표준 속성, hc:img binaryItemIDRef가 이 id로 참조
|
||||
extraManifestItems += `<opf:item id="${fileId}" href="${filePath}" media-type="${mediaType}" isEmbeded="1"/>`;
|
||||
};
|
||||
|
||||
if (images?.step3 || images?.step6) {
|
||||
@ -921,16 +939,8 @@ export async function exportAsHWPX(
|
||||
processImage(images.sensitivityMap, 4, 'image4');
|
||||
}
|
||||
|
||||
// header.xml: binDataList를 hh:refList 뒤에 삽입 (HWPML 스펙 준수)
|
||||
let headerXml = HEADER_XML;
|
||||
if (binCount > 0) {
|
||||
const binDataList =
|
||||
`<hh:binDataList itemCnt="${binCount}">` +
|
||||
binDataListXml +
|
||||
'</hh:binDataList>';
|
||||
// refList 닫힘 태그 직후에 삽입 (binDataList는 head의 직접 자식)
|
||||
headerXml = HEADER_XML.replace('</hh:refList>', '</hh:refList>' + binDataList);
|
||||
}
|
||||
// header.xml: hh:binDataList 불필요 — hc:img binaryItemIDRef → content.hpf manifest 방식 사용
|
||||
const headerXml = HEADER_XML;
|
||||
|
||||
// Contents
|
||||
zip.file('Contents/content.hpf', buildContentHpf(extraManifestItems));
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user