Compare commits

...

6 커밋

작성자 SHA1 메시지 날짜
ebe76176e3 Merge pull request 'release: 2026-03-26 (5건 커밋)' (#128) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 38s
2026-03-26 14:19:33 +09:00
9cfa357f7e Merge pull request 'docs: ������ ��Ʈ ���� (2026-03-26)' (#127) from release/2026-03-26-notes into develop 2026-03-26 14:17:58 +09:00
3d4801c7ea docs: 릴리즈 노트 정리 (2026-03-26) 2026-03-26 14:08:33 +09:00
0e34b6fa90 Merge pull request 'feat(reports): 보고서 조위/기상 섹션 실데이터 삽입 및 HWPX 이미지 내보내기 수정' (#126) from feature/20260326 into develop 2026-03-26 14:02:25 +09:00
c01db13b22 docs: 릴리즈 노트 업데이트 2026-03-26 13:50:25 +09:00
fbbf36020b feat(reports): 보고서 조위/기상 섹션 실데이터 삽입 및 HWPX 이미지 내보내기 수정
- 보고서 oil-tide 섹션에 기상/조위 실데이터 렌더링 추가
- HWPX 이미지 내보내기 구조를 HWPX 스펙(hc:img + manifest 방식)으로 수정
- 확산 분석 목록 정렬 기준 변경: RUN_DTM DESC 우선
2026-03-26 13:43:56 +09:00
5개의 변경된 파일70개의 추가작업 그리고 25개의 파일을 삭제

파일 보기

@ -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,15 @@
## [Unreleased]
## [2026-03-26]
### 추가
- 보고서: 조위/기상(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));