From aba58b22274e0e3aff8f839705edb962008fe4f2 Mon Sep 17 00:00:00 2001 From: Nan Kyung Lee Date: Sun, 1 Mar 2026 21:28:30 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(map):=203D=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=ED=86=A0=EA=B8=80=20=EA=B5=AC=ED=98=84=20(VWorld=20=EC=9C=84?= =?UTF-8?q?=EC=84=B1=20+=20OSM=20=EA=B1=B4=EB=AC=BC=20extrusion)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mapStore.ts(신규): Zustand 기반 mapToggles 전역 상태 (s57/s101/threeD/satellite) - TopBar.tsx: 로컬 상태 → mapStore 전환 (3D 토글 전역 공유) - MapView.tsx: - SATELLITE_3D_STYLE 추가 (VWorld WMTS 위성 + OpenFreeMap 벡터타일) - MapLibre fill-extrusion으로 3D 건물 렌더링 (zoom 13+, render_height 사용) - MapPitchController: 3D ON → pitch 45°/bearing -17°, OFF → 0° 복귀 - mapToggles.threeD 상태에 따라 지도 스타일 전환 (BASE_STYLE ↔ SATELLITE_3D_STYLE) - deps: @deck.gl/mesh-layers, @deck.gl/extensions 추가 (관련 기능용) Co-Authored-By: Claude Sonnet 4.6 --- frontend/package-lock.json | 37 +++++++++ frontend/package.json | 2 + .../src/common/components/layout/TopBar.tsx | 5 +- .../src/common/components/map/MapView.tsx | 82 ++++++++++++++++++- frontend/src/common/store/mapStore.ts | 21 +++++ 5 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 frontend/src/common/store/mapStore.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7182874..25e3ed4 100755 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,9 +10,11 @@ "dependencies": { "@deck.gl/aggregation-layers": "^9.2.10", "@deck.gl/core": "^9.2.10", + "@deck.gl/extensions": "^9.2.10", "@deck.gl/geo-layers": "^9.2.10", "@deck.gl/layers": "^9.2.10", "@deck.gl/mapbox": "^9.2.10", + "@deck.gl/mesh-layers": "^9.2.10", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -387,6 +389,22 @@ "mjolnir.js": "^3.0.0" } }, + "node_modules/@deck.gl/extensions": { + "version": "9.2.10", + "resolved": "https://registry.npmjs.org/@deck.gl/extensions/-/extensions-9.2.10.tgz", + "integrity": "sha512-GMKmps67kX2d4nMbEZYDxGDZWmHDQJkFa9YbL/kSbTyt8OpFe9H5zTuqNog3l75F9Fyop/nq7bQYD2pKOUGPBg==", + "license": "MIT", + "dependencies": { + "@luma.gl/constants": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" + } + }, "node_modules/@deck.gl/geo-layers": { "version": "9.2.10", "resolved": "https://registry.npmjs.org/@deck.gl/geo-layers/-/geo-layers-9.2.10.tgz", @@ -459,6 +477,25 @@ "@math.gl/web-mercator": "^4.1.0" } }, + "node_modules/@deck.gl/mesh-layers": { + "version": "9.2.10", + "resolved": "https://registry.npmjs.org/@deck.gl/mesh-layers/-/mesh-layers-9.2.10.tgz", + "integrity": "sha512-YeVIWnODdFkz5yNYzpCtzF6wRhBpe2Gg+oc1hNNrRwdQUV3bsRLJUO13SKNagUKWIbey9K/c4ps2Ns0Dg2AzGg==", + "license": "MIT", + "dependencies": { + "@loaders.gl/gltf": "^4.3.4", + "@loaders.gl/schema": "^4.3.4", + "@luma.gl/gltf": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6", + "@luma.gl/gltf": "~9.2.6", + "@luma.gl/shadertools": "~9.2.6" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index d37aeb5..a439898 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,9 +12,11 @@ "dependencies": { "@deck.gl/aggregation-layers": "^9.2.10", "@deck.gl/core": "^9.2.10", + "@deck.gl/extensions": "^9.2.10", "@deck.gl/geo-layers": "^9.2.10", "@deck.gl/layers": "^9.2.10", "@deck.gl/mapbox": "^9.2.10", + "@deck.gl/mesh-layers": "^9.2.10", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/frontend/src/common/components/layout/TopBar.tsx b/frontend/src/common/components/layout/TopBar.tsx index 211fd83..5695c75 100755 --- a/frontend/src/common/components/layout/TopBar.tsx +++ b/frontend/src/common/components/layout/TopBar.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useMemo } from 'react' import type { MainTab } from '../../types/navigation' import { useAuthStore } from '../../store/authStore' import { useMenuStore } from '../../store/menuStore' +import { useMapStore } from '../../store/mapStore' interface TopBarProps { activeTab: MainTab @@ -10,10 +11,10 @@ interface TopBarProps { export function TopBar({ activeTab, onTabChange }: TopBarProps) { const [showQuickMenu, setShowQuickMenu] = useState(false) - const [mapToggles, setMapToggles] = useState({ s57: true, s101: false, threeD: false, satellite: false }) const quickMenuRef = useRef(null) const { hasPermission, user, logout } = useAuthStore() const { menuConfig, isLoaded } = useMenuStore() + const { mapToggles, toggleMap } = useMapStore() const tabs = useMemo(() => { if (!isLoaded || menuConfig.length === 0) return [] @@ -31,8 +32,6 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) { return () => document.removeEventListener('mousedown', handler) }, [showQuickMenu]) - const toggleMap = (key: keyof typeof mapToggles) => setMapToggles(p => ({ ...p, [key]: !p[key] })) - return (
{/* Left Section */} diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 7f4dd6a..42d79c1 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -13,8 +13,10 @@ import type { BoomLine, BoomLineCoord } from '@common/types/boomLine' import type { ReplayShip, CollisionEvent } from '@common/types/backtrack' import { createBacktrackLayers } from './BacktrackReplayOverlay' import { hexToRgba } from './mapUtils' +import { useMapStore } from '@common/store/mapStore' const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080' +const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY || '' // 남해안 중심 좌표 (여수 앞바다) const DEFAULT_CENTER: [number, number] = [34.5, 127.8] @@ -46,6 +48,59 @@ const BASE_STYLE: StyleSpecification = { ], } +// 3D 위성 스타일 (VWorld 위성 배경 + OpenFreeMap 건물 extrusion) +// VWorld WMTS: {z}/{y}/{x} (row/col 순서) +// OpenFreeMap: CORS 허용, 전 세계 OSM 벡터타일 (building: render_height 포함) +const SATELLITE_3D_STYLE: StyleSpecification = { + version: 8, + sources: { + 'vworld-satellite': { + type: 'raster', + tiles: [`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Satellite/{z}/{y}/{x}.jpeg`], + tileSize: 256, + attribution: '© 국토지리정보원 VWorld', + }, + 'ofm': { + type: 'vector', + url: 'https://tiles.openfreemap.org/planet', + }, + }, + layers: [ + { + id: 'satellite-base', + type: 'raster', + source: 'vworld-satellite', + minzoom: 0, + maxzoom: 22, + }, + { + id: 'roads-3d', + type: 'line', + source: 'ofm', + 'source-layer': 'transportation', + filter: ['in', ['get', 'class'], ['literal', ['motorway', 'trunk', 'primary', 'secondary']]], + paint: { + 'line-color': 'rgba(255,255,200,0.3)', + 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1, 16, 3], + }, + }, + { + id: '3d-buildings', + type: 'fill-extrusion', + source: 'ofm', + 'source-layer': 'building', + minzoom: 13, + filter: ['!=', ['get', 'hide_3d'], true], + paint: { + 'fill-extrusion-color': '#c8b99a', + 'fill-extrusion-height': ['max', ['coalesce', ['get', 'render_height'], 0], 3], + 'fill-extrusion-base': ['coalesce', ['get', 'render_min_height'], 0], + 'fill-extrusion-opacity': 0.85, + }, + }, + ], +} + // 모델별 색상 매핑 const MODEL_COLORS: Record = { 'KOSPS': '#06b6d4', @@ -124,7 +179,7 @@ interface MapViewProps { sensitiveResources?: SensitiveResource[] } -// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록) +// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved) // eslint-disable-next-line @typescript-eslint/no-explicit-any function DeckGLOverlay({ layers }: { layers: any[] }) { const overlay = useControl(() => new MapboxOverlay({ interleaved: true })) @@ -132,6 +187,20 @@ function DeckGLOverlay({ layers }: { layers: any[] }) { return null } +// 3D 모드 pitch/bearing 제어 컴포넌트 (Map 내부에서 useMap() 사용) +function MapPitchController({ threeD }: { threeD: boolean }) { + const { current: map } = useMap() + useEffect(() => { + if (!map) return + map.easeTo( + threeD + ? { pitch: 45, bearing: -17, duration: 800 } + : { pitch: 0, bearing: 0, duration: 800 } + ) + }, [threeD, map]) + return null +} + // 팝업 정보 interface PopupInfo { longitude: number @@ -157,6 +226,7 @@ export function MapView({ backtrackReplay, sensitiveResources = [], }: MapViewProps) { + const { mapToggles } = useMapStore() const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER) const [currentTime, setCurrentTime] = useState(0) const [isPlaying, setIsPlaying] = useState(false) @@ -535,6 +605,9 @@ export function MapView({ sensitiveResources, ]) + // 3D 모드에 따른 지도 스타일 전환 + const currentMapStyle = mapToggles.threeD ? SATELLITE_3D_STYLE : BASE_STYLE + return (
+ {/* 3D 모드 pitch 제어 */} + + {/* WMS 레이어 */} {wmsLayers.map(layer => ( ))} - {/* deck.gl 오버레이 */} + {/* deck.gl 오버레이 (인터리브드: 일반 레이어) */} {/* 사고 위치 마커 (MapLibre Marker) */} diff --git a/frontend/src/common/store/mapStore.ts b/frontend/src/common/store/mapStore.ts new file mode 100644 index 0000000..a5f939c --- /dev/null +++ b/frontend/src/common/store/mapStore.ts @@ -0,0 +1,21 @@ +import { create } from 'zustand' + +interface MapToggles { + s57: boolean; + s101: boolean; + threeD: boolean; + satellite: boolean; +} + +interface MapState { + mapToggles: MapToggles; + toggleMap: (key: keyof MapToggles) => void; +} + +export const useMapStore = create((set) => ({ + mapToggles: { s57: true, s101: false, threeD: false, satellite: false }, + toggleMap: (key) => + set((s) => ({ + mapToggles: { ...s.mapToggles, [key]: !s.mapToggles[key] }, + })), +})) From 374a4878784c056d1a7cb5b211f88719023f8a78 Mon Sep 17 00:00:00 2001 From: Nan Kyung Lee Date: Mon, 2 Mar 2026 10:07:06 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(reports):=20HWP=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20HWPX=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 HTML Blob → .doc 저장 방식을 OWPML 표준 HWPX(ZIP+XML) 포맷으로 교체. JSZip으로 HWPX 파일을 순수 브라우저에서 생성하여 한글에서 직접 열 수 있도록 구현. - hwpxExport.ts 신규: HWPX ZIP 패키징 (mimetype, header.xml, section0.xml 등) - reportUtils.ts: exportAsHWP → dynamic import로 HWPX 위임 - ReportsView.tsx, TemplateFormEditor.tsx: 구조화 데이터 직접 전달 Co-Authored-By: Claude Opus 4.6 --- frontend/package-lock.json | 1 + frontend/package.json | 1 + .../tabs/reports/components/ReportsView.tsx | 12 +- .../reports/components/TemplateFormEditor.tsx | 17 +- .../src/tabs/reports/components/hwpxExport.ts | 703 ++++++++++++++++++ .../tabs/reports/components/reportUtils.ts | 19 +- 6 files changed, 729 insertions(+), 24 deletions(-) create mode 100644 frontend/src/tabs/reports/components/hwpxExport.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7182874..1e42f8c 100755 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "@vis.gl/react-maplibre": "^8.1.0", "axios": "^1.13.5", "emoji-mart": "^5.6.0", + "jszip": "^3.10.1", "lucide-react": "^0.564.0", "maplibre-gl": "^5.19.0", "react": "^19.2.0", diff --git a/frontend/package.json b/frontend/package.json index d37aeb5..6876fd4 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "@vis.gl/react-maplibre": "^8.1.0", "axios": "^1.13.5", "emoji-mart": "^5.6.0", + "jszip": "^3.10.1", "lucide-react": "^0.564.0", "maplibre-gl": "^5.19.0", "react": "^19.2.0", diff --git a/frontend/src/tabs/reports/components/ReportsView.tsx b/frontend/src/tabs/reports/components/ReportsView.tsx index 52af3a5..014c1a6 100755 --- a/frontend/src/tabs/reports/components/ReportsView.tsx +++ b/frontend/src/tabs/reports/components/ReportsView.tsx @@ -16,7 +16,8 @@ import { analysisCatColors, inferAnalysisCategory, type ViewState, -} from './reportUtils' +} from './reportUtils'; +import type { TemplateType } from './reportTypes'; import TemplateFormEditor from './TemplateFormEditor' import ReportGenerator from './ReportGenerator' @@ -296,7 +297,7 @@ export function ReportsView() {
diff --git a/frontend/src/tabs/reports/components/TemplateFormEditor.tsx b/frontend/src/tabs/reports/components/TemplateFormEditor.tsx index 9ac4401..b3bf134 100644 --- a/frontend/src/tabs/reports/components/TemplateFormEditor.tsx +++ b/frontend/src/tabs/reports/components/TemplateFormEditor.tsx @@ -67,15 +67,14 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) { } const doExport = (format: 'pdf' | 'hwp') => { - const html = generateReportHTML( - template.label, - { writeTime: reportMeta.writeTime, author: reportMeta.author, jurisdiction: reportMeta.jurisdiction }, - template.sections, - getVal - ) + const meta = { writeTime: reportMeta.writeTime, author: reportMeta.author, jurisdiction: reportMeta.jurisdiction } const filename = formData['incident.name'] || `${template.label}_${reportMeta.writeTime.replace(/[\s:]/g, '_')}` - if (format === 'pdf') exportAsPDF(html, filename) - else exportAsHWP(html, filename) + if (format === 'pdf') { + const html = generateReportHTML(template.label, meta, template.sections, getVal) + exportAsPDF(html, filename) + } else { + exportAsHWP(template.label, meta, template.sections, getVal, filename) + } } return ( @@ -185,7 +184,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
- +