From 38d931db65e2b4ad970428532ecc45fc1c58bec7 Mon Sep 17 00:00:00 2001 From: leedano Date: Thu, 16 Apr 2026 17:38:49 +0900 Subject: [PATCH] =?UTF-8?q?refactor(mpa):=20=ED=83=AD=20=EB=94=94=EB=A0=89?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=EB=A5=BC=20MPA=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EC=9E=AC=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.json | 5 +- .claude/workflow-version.json | 4 +- CLAUDE.md | 4 +- README.md | 4 +- docs/COMMON-GUIDE.md | 10 +- docs/CRUD-API-GUIDE.md | 6 +- docs/DEVELOPMENT-GUIDE.md | 18 +- docs/MENU-TAB-GUIDE.md | 36 +- docs/MOCK-TO-API-GUIDE.md | 14 +- docs/README.md | 4 +- frontend/src/App.tsx | 28 +- frontend/src/common/hooks/useBaseMapStyle.ts | 2 +- frontend/src/common/hooks/useSubMenu.ts | 4 +- frontend/src/common/hooks/useVesselSignals.ts | 2 +- frontend/src/common/mock/vesselMockData.ts | 4 +- frontend/src/common/services/vesselApi.ts | 2 +- .../src/common/services/vesselSignalClient.ts | 2 +- .../src/common/store/weatherSnapshotStore.ts | 41 +- frontend/src/common/types/hns.ts | 67 - frontend/src/common/utils/geo.ts | 2 +- .../src/common/utils/imageAnalysisSignal.ts | 2 +- .../admin/components/AdminPlaceholder.tsx | 0 .../admin/components/AdminSidebar.tsx | 0 .../admin/components/AdminView.tsx | 4 +- .../admin/components/AssetUploadPanel.tsx | 20 +- .../admin/components/BoardMgmtPanel.tsx | 38 +- .../admin/components/CleanupEquipPanel.tsx | 22 +- .../admin/components/CollectHrPanel.tsx | 20 +- .../admin/components/DeidentifyPanel.tsx | 567 ++ .../admin/components/DispersingZonePanel.tsx | 8 +- .../admin/components/LayerPanel.tsx | 16 +- .../admin/components/MapBasePanel.tsx | 21 +- .../admin/components/MenusPanel.tsx | 2 +- .../admin/components/MonitorForecastPanel.tsx | 36 +- .../admin/components/MonitorRealtimePanel.tsx | 67 +- .../admin/components/MonitorVesselPanel.tsx | 28 +- .../admin/components/PermissionsPanel.tsx | 417 ++ .../admin/components/RndHnsAtmosPanel.tsx | 62 +- .../admin/components/RndKospsPanel.tsx | 62 +- .../admin/components/RndPoseidonPanel.tsx | 62 +- .../admin/components/RndRescuePanel.tsx | 62 +- .../admin/components/SensitiveLayerPanel.tsx | 4 +- .../admin/components/SettingsPanel.tsx | 24 +- .../admin/components/SortableMenuItem.tsx | 0 .../admin/components/SystemArchPanel.tsx | 57 + .../admin/components/UsersPanel.tsx | 525 +- .../admin/components/VesselMaterialsPanel.tsx | 21 +- .../admin/components/VesselSignalPanel.tsx | 6 +- .../admin/components/adminConstants.ts | 0 .../admin/components/adminMenuConfig.ts | 0 .../components/contents/AuditLogModal.tsx | 212 + .../components/contents/CommonFeaturesTab.tsx | 765 ++ .../components/contents/FrameworkTab.tsx | 171 + .../components/contents/HeterogeneousTab.tsx | 367 + .../components/contents/InterfaceTab.tsx | 236 + .../admin/components/contents/PermCell.tsx | 70 + .../admin/components/contents/PermLegend.tsx | 36 + .../admin/components/contents/ProgressBar.tsx | 15 + .../components/contents/RegisterModal.tsx | 228 + .../admin/components/contents/RolePermTab.tsx | 312 + .../admin/components/contents/Step1.tsx | 120 + .../admin/components/contents/Step2.tsx | 77 + .../admin/components/contents/Step3.tsx | 97 + .../admin/components/contents/Step4.tsx | 160 + .../admin/components/contents/Step5.tsx | 62 + .../components/contents/StepIndicator.tsx | 53 + .../components/contents/TargetArchTab.tsx | 196 + .../admin/components/contents/TaskTable.tsx | 100 + .../admin/components/contents/TreeRow.tsx | 103 + .../components/contents/UserDetailModal.tsx | 293 + .../admin/components/contents/UserPermTab.tsx | 336 + .../admin/components/contents/WizardModal.tsx | 112 + .../src/{tabs => components}/admin/index.ts | 0 .../admin/services/monitorApi.ts | 0 .../aerial/components/AerialTheoryView.tsx | 73 + .../aerial/components/AerialView.tsx | 0 .../aerial/components/CCTVPlayer.tsx | 0 .../aerial/components/CctvView.tsx | 6 +- .../aerial/components/MediaManagement.tsx | 2 +- .../aerial/components/OilAreaAnalysis.tsx | 2 +- .../aerial/components/OilDetectionOverlay.tsx | 2 +- .../aerial/components/RealtimeDrone.tsx | 4 +- .../aerial/components/SatelliteRequest.tsx | 4 +- .../aerial/components/SensorAnalysis.tsx | 0 .../components/aerial/components/WingAI.tsx | 101 + .../aerial/components/contents/AoiPanel.tsx | 800 +++ .../components/contents/ChangeDetectPanel.tsx | 601 ++ .../components/contents/DetectPanel.tsx | 360 + .../components/contents/PanelAreaCalc.tsx | 130 + .../components/contents/PanelDetection.tsx | 256 + .../components/contents/PanelESIMap.tsx | 128 + .../components/contents/PanelOverview.tsx | 144 + .../components/contents/PanelReferences.tsx | 361 + .../contents/PanelRemoteSensing.tsx | 139 + .../components/contents/PanelSpreadModel.tsx | 208 + .../aerial/hooks/useOilDetection.ts | 2 +- .../src/{tabs => components}/aerial/index.ts | 0 .../aerial/services/aerialApi.ts | 103 +- .../aerial/utils/oilDetection.ts | 43 +- .../aerial/utils/streamUtils.ts | 0 .../assets/components/AssetManagement.tsx | 2 +- .../assets/components/AssetMap.tsx | 10 +- .../assets/components/AssetTheory.tsx | 0 .../assets/components/AssetUpload.tsx | 2 +- .../assets/components/AssetsView.tsx | 0 .../assets/components/ShipInsurance.tsx | 2 +- .../assets/components/assetTypes.ts | 33 - .../src/{tabs => components}/assets/index.ts | 0 .../assets/services/assetsApi.ts | 106 +- .../board/components/BoardDetailView.tsx | 7 +- .../board/components/BoardListTable.tsx | 5 +- .../board/components/BoardView.tsx | 17 +- .../board/components/BoardWriteForm.tsx | 2 +- .../src/{tabs => components}/board/index.ts | 0 .../board/services/boardApi.ts | 89 +- .../common}/auth/LoginPage.tsx | 2 +- .../common}/layer/LayerTree.tsx | 0 .../common}/layout/MainLayout.tsx | 2 +- .../common}/layout/SubMenuBar.tsx | 4 +- .../common}/layout/TopBar.tsx | 11 +- .../common}/map/BacktrackReplayBar.tsx | 2 +- .../common}/map/BacktrackReplayOverlay.tsx | 2 +- .../common}/map/BaseMap.tsx | 0 .../common}/map/DeckGLOverlay.tsx | 0 .../common}/map/FlyToController.tsx | 0 .../common}/map/HydrParticleOverlay.tsx | 2 +- .../common}/map/MapBoundsTracker.tsx | 2 +- .../common}/map/MapView.tsx | 14 +- .../common}/map/MeasureOverlay.tsx | 2 +- .../common}/map/S57EncOverlay.tsx | 3 +- .../common}/map/SrOverlay.tsx | 7 +- .../common}/map/TimelineControl.tsx | 0 .../common}/map/VesselInteraction.tsx | 2 +- .../common}/map/VesselLayer.ts | 2 +- .../common}/map/mapStyles.ts | 0 .../common}/map/mapUtils.ts | 0 .../common}/map/measureLayers.ts | 4 +- .../common}/map/srStyles.ts | 0 .../common}/ui/ComboBox.tsx | 0 .../common}/ui/UserManualPopup.tsx | 0 .../hns/components/HNSAnalysisListTable.tsx | 3 +- .../hns/components/HNSLeftPanel.tsx | 38 +- .../hns/components/HNSRecalcModal.tsx | 12 +- .../hns/components/HNSRightPanel.tsx | 2 +- .../hns/components/HNSScenarioView.tsx | 514 ++ .../hns/components/HNSSubstanceView.tsx | 1040 +-- .../hns/components/HNSTheoryView.tsx | 143 + .../hns/components/HNSView.tsx | 180 +- .../contents/GaussianModelPanel.tsx | 718 ++ .../components/contents/HNSManualViewer.tsx | 162 + .../components/contents/HmsDetailPanel.tsx | 1008 +++ .../hns/components/contents/InfoBoxRow.tsx | 29 + .../components/contents/NewScenarioModal.tsx | 325 + .../contents/OceanCorrectionPanel.tsx | 200 + .../contents/RealtimeComparePanel.tsx | 215 + .../contents/ScenarioComparison.tsx | 395 + .../components/contents/ScenarioDetail.tsx | 129 + .../contents/ScenarioMapOverlay.tsx | 29 + .../contents/SubstanceScenarioPanel.tsx | 682 ++ .../contents/SystemOverviewPanel.tsx | 1940 +++++ .../components/contents/VerificationPanel.tsx | 273 + .../hns/components/contents/WrfChemPanel.tsx | 2203 ++++++ .../hns/hooks/useWeatherFetch.ts | 5 +- .../src/{tabs => components}/hns/index.ts | 0 .../hns/services/hnsApi.ts | 43 +- .../hns/utils/dispersionEngine.ts | 5 +- .../hns/utils/toxicityData.ts | 2 +- .../components/DischargeZonePanel.tsx | 0 .../components/ImageAnalysisModal.tsx | 0 .../incidents/components/IncidentTable.tsx | 2 +- .../components/IncidentsLeftPanel.tsx | 20 +- .../components/IncidentsRightPanel.tsx | 8 +- .../incidents/components/IncidentsView.tsx | 1297 +--- .../incidents/components/MediaModal.tsx | 4 +- .../components/contents/FlyToController.tsx | 21 + .../contents/IncidentPopupContent.tsx | 155 + .../contents/IncidentTooltipContent.tsx | 30 + .../components/contents/SplitPanelContent.tsx | 135 + .../components/contents/VesselDetailModal.tsx | 678 ++ .../components/contents/VesselPopupPanel.tsx | 222 + .../contents/VesselTooltipContent.tsx | 23 + .../{tabs => components}/incidents/index.ts | 0 .../incidents/services/incidentsApi.ts | 141 +- .../incidents/services/vesselService.ts | 32 +- .../incidents/utils/dischargeZoneData.ts | 0 .../components/AnalysisListTable.tsx | 8 +- .../prediction/components/BacktrackModal.tsx | 2 +- .../components/BoomDeploymentTheoryView.tsx | 82 + .../components/InfoLayerSection.tsx | 2 +- .../prediction/components/LeftPanel.tsx | 0 .../prediction/components/OilBoomSection.tsx | 2 +- .../components/OilSpillTheoryView.tsx | 163 + .../prediction/components/OilSpillView.tsx | 38 +- .../components/PredictionInputSection.tsx | 6 +- .../prediction/components/RecalcModal.tsx | 2 +- .../prediction/components/RightPanel.tsx | 2 +- .../components/SimulationErrorModal.tsx | 0 .../components/SimulationLoadingOverlay.tsx | 0 .../contents/DeploymentTheoryPanel.tsx | 351 + .../components/contents/EnsemblePanel.tsx | 36 + .../contents/FieldApplicationPanel.tsx | 131 + .../contents/FluidDynamicsPanel.tsx | 142 + .../components/contents/KospsPanel.tsx | 1036 +++ .../components/contents/LagrangianPanel.tsx | 143 + .../components/contents/OceanInputPanel.tsx | 64 + .../components/contents/OpenDriftPanel.tsx | 595 ++ .../components/contents/OptimizationPanel.tsx | 314 + .../components/contents/OverviewPanel.tsx | 264 + .../components/contents/PoseidonPanel.tsx | 496 ++ .../components/contents/ReferencesPanel.tsx | 153 + .../components/contents/RoadmapPanel.tsx | 134 + .../contents/SystemOverviewPanel.tsx | 372 + .../components/contents/VerificationPanel.tsx | 251 + .../components/contents/WeatheringPanel.tsx | 113 + .../prediction/components/leftPanelTypes.ts | 13 +- .../{tabs => components}/prediction/index.ts | 0 .../prediction/services/predictionApi.ts | 124 + .../components/OilSpillReportTemplate.tsx | 693 ++ .../reports/components/OilSpreadMapPanel.tsx | 2 +- .../reports/components/ReportGenerator.tsx | 6 +- .../reports/components/ReportsView.tsx | 2 +- .../reports/components/TemplateEditPage.tsx | 10 +- .../reports/components/TemplateFormEditor.tsx | 3 +- .../reports/components/contents/AddRowBtn.tsx | 10 + .../reports/components/contents/ECell.tsx | 40 + .../reports/components/contents/Page1.tsx | 135 + .../reports/components/contents/Page2.tsx | 276 + .../reports/components/contents/Page3.tsx | 58 + .../reports/components/contents/Page4.tsx | 233 + .../reports/components/contents/Page5.tsx | 58 + .../reports/components/contents/Page6.tsx | 177 + .../reports/components/contents/Page7.tsx | 100 + .../contents/SensitiveResourceMapSection.tsx | 601 ++ .../contents/SensitivityMapSection.tsx | 325 + .../reports/components/hwpxExport.ts | 0 .../reports/components/reportTypes.ts | 40 +- .../reports/components/reportUtils.ts | 2 +- .../src/{tabs => components}/reports/index.ts | 0 .../reports/services/reportsApi.ts | 119 +- .../rescue/components/RescueScenarioView.tsx | 961 +++ .../rescue/components/RescueTheoryView.tsx | 0 .../rescue/components/RescueView.tsx | 266 + .../rescue/components/contents/BottomBar.tsx | 61 + .../contents/DamageStabilityPanel.tsx | 260 + .../rescue/components/contents/LeftPanel.tsx | 215 + .../components/contents/LongStrengthPanel.tsx | 195 + .../rescue/components/contents/MetricCard.tsx | 24 + .../components/contents/NewScenarioModal.tsx | 507 ++ .../components/contents/RescueListView.tsx | 138 + .../components/contents/RescuePanel.tsx | 366 + .../rescue/components/contents/RightPanel.tsx | 57 + .../contents/ScenarioComparison.tsx | 326 + .../contents/ScenarioMapOverlay.tsx | 391 + .../rescue/components/contents/TopInfoBar.tsx | 51 + .../src/{tabs => components}/rescue/index.ts | 0 .../components/rescue/services/rescueApi.ts | 25 + .../scat/components/DistributionView.tsx | 0 .../scat/components/PreScatView.tsx | 4 +- .../scat/components/ScatLeftPanel.tsx | 4 +- .../scat/components/ScatMap.tsx | 10 +- .../scat/components/ScatPopup.tsx | 4 +- .../scat/components/ScatRightPanel.tsx | 2 +- .../scat/components/ScatTimeline.tsx | 2 +- .../scat/components/ScatView.tsx | 0 .../scat/components/SurveyView.tsx | 0 .../scat/components/scatConstants.ts | 0 .../src/{tabs => components}/scat/index.ts | 0 .../scat/services/scatApi.ts | 63 +- .../weather/components/OceanCurrentLayer.tsx | 3 +- .../components/OceanCurrentParticleLayer.tsx | 1 + .../components/OceanForecastOverlay.tsx | 2 +- .../components/WaterTemperatureLayer.tsx | 0 .../weather/components/WeatherMapControls.tsx | 0 .../weather/components/WeatherMapOverlay.tsx | 2 +- .../weather/components/WeatherRightPanel.tsx | 0 .../weather/components/WeatherView.tsx | 4 +- .../weather/components/WindParticleLayer.tsx | 0 .../weather/hooks/useOceanForecast.ts | 9 +- .../weather/hooks/useWeatherData.ts | 0 .../src/{tabs => components}/weather/index.ts | 0 .../weather/services/khoaApi.ts | 20 +- .../weather/services/weatherApi.ts | 22 +- .../weather/services/weatherService.ts | 0 .../weather/services/weatherUtils.ts | 2 +- .../src/interfaces/aerial/AerialInterface.ts | 143 + .../src/interfaces/assets/AssetsInterface.ts | 106 + .../src/interfaces/board/BoardInterface.ts | 92 + frontend/src/interfaces/hns/HnsInterface.ts | 309 + .../incidents/IncidentsInterface.ts | 170 + .../prediction/PredictionInterface.ts} | 155 +- .../interfaces/reports/ReportsInterface.ts | 233 + .../src/interfaces/rescue/RescueInterface.ts | 88 + frontend/src/interfaces/scat/ScatInterface.ts | 102 + .../interfaces/weather/WeatherInterface.ts | 95 + .../src/pages/design/ColorPaletteContent.tsx | 1180 ++- .../design/float/FloatDropdownContent.tsx | 4 +- .../tabs/admin/components/DeidentifyPanel.tsx | 1569 ---- .../admin/components/PermissionsPanel.tsx | 1269 ---- .../tabs/admin/components/SystemArchPanel.tsx | 1804 ----- .../aerial/components/AerialTheoryView.tsx | 1445 ---- .../src/tabs/aerial/components/WingAI.tsx | 1851 ----- .../tabs/assets/components/assetMockData.ts | 2296 ------ .../tabs/hns/components/HNSScenarioView.tsx | 1430 ---- .../src/tabs/hns/components/HNSTheoryView.tsx | 6363 ----------------- .../src/tabs/hns/utils/dispersionTypes.ts | 130 - .../components/BoomDeploymentTheoryView.tsx | 1443 ---- .../components/OilSpillTheoryView.tsx | 3388 --------- .../components/OilSpillReportTemplate.tsx | 2743 ------- .../rescue/components/RescueScenarioView.tsx | 2189 ------ .../src/tabs/rescue/components/RescueView.tsx | 1622 ----- .../src/tabs/rescue/services/rescueApi.ts | 72 - .../src/tabs/scat/components/scatTypes.ts | 38 - frontend/src/{common => }/types/backtrack.ts | 0 frontend/src/{common => }/types/boomLine.ts | 0 frontend/src/types/hns/HnsType.ts | 22 + frontend/src/{common => }/types/navigation.ts | 0 .../src/types/prediction/PredictionType.ts | 9 + frontend/src/types/reports/ReportsType.ts | 19 + frontend/src/types/rescue/RescueType.ts | 20 + frontend/src/{common => }/types/vessel.ts | 0 frontend/tsconfig.app.json | 4 +- frontend/vite.config.ts | 4 +- scripts/fix-satellite-request.mjs | 2 +- 323 files changed, 33134 insertions(+), 34602 deletions(-) delete mode 100644 frontend/src/common/types/hns.ts rename frontend/src/{tabs => components}/admin/components/AdminPlaceholder.tsx (100%) rename frontend/src/{tabs => components}/admin/components/AdminSidebar.tsx (100%) rename frontend/src/{tabs => components}/admin/components/AdminView.tsx (96%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/admin/components/AssetUploadPanel.tsx (96%) rename frontend/src/{tabs => components}/admin/components/BoardMgmtPanel.tsx (89%) rename frontend/src/{tabs => components}/admin/components/CleanupEquipPanel.tsx (95%) rename frontend/src/{tabs => components}/admin/components/CollectHrPanel.tsx (94%) create mode 100644 frontend/src/components/admin/components/DeidentifyPanel.tsx rename frontend/src/{tabs => components}/admin/components/DispersingZonePanel.tsx (96%) rename frontend/src/{tabs => components}/admin/components/LayerPanel.tsx (97%) rename frontend/src/{tabs => components}/admin/components/MapBasePanel.tsx (95%) rename frontend/src/{tabs => components}/admin/components/MenusPanel.tsx (98%) rename frontend/src/{tabs => components}/admin/components/MonitorForecastPanel.tsx (87%) rename frontend/src/{tabs => components}/admin/components/MonitorRealtimePanel.tsx (88%) rename frontend/src/{tabs => components}/admin/components/MonitorVesselPanel.tsx (94%) create mode 100644 frontend/src/components/admin/components/PermissionsPanel.tsx rename frontend/src/{tabs => components}/admin/components/RndHnsAtmosPanel.tsx (89%) rename frontend/src/{tabs => components}/admin/components/RndKospsPanel.tsx (89%) rename frontend/src/{tabs => components}/admin/components/RndPoseidonPanel.tsx (90%) rename frontend/src/{tabs => components}/admin/components/RndRescuePanel.tsx (90%) rename frontend/src/{tabs => components}/admin/components/SensitiveLayerPanel.tsx (98%) rename frontend/src/{tabs => components}/admin/components/SettingsPanel.tsx (91%) rename frontend/src/{tabs => components}/admin/components/SortableMenuItem.tsx (100%) create mode 100644 frontend/src/components/admin/components/SystemArchPanel.tsx rename frontend/src/{tabs => components}/admin/components/UsersPanel.tsx (52%) rename frontend/src/{tabs => components}/admin/components/VesselMaterialsPanel.tsx (94%) rename frontend/src/{tabs => components}/admin/components/VesselSignalPanel.tsx (98%) rename frontend/src/{tabs => components}/admin/components/adminConstants.ts (100%) rename frontend/src/{tabs => components}/admin/components/adminMenuConfig.ts (100%) create mode 100644 frontend/src/components/admin/components/contents/AuditLogModal.tsx create mode 100644 frontend/src/components/admin/components/contents/CommonFeaturesTab.tsx create mode 100644 frontend/src/components/admin/components/contents/FrameworkTab.tsx create mode 100644 frontend/src/components/admin/components/contents/HeterogeneousTab.tsx create mode 100644 frontend/src/components/admin/components/contents/InterfaceTab.tsx create mode 100644 frontend/src/components/admin/components/contents/PermCell.tsx create mode 100644 frontend/src/components/admin/components/contents/PermLegend.tsx create mode 100644 frontend/src/components/admin/components/contents/ProgressBar.tsx create mode 100644 frontend/src/components/admin/components/contents/RegisterModal.tsx create mode 100644 frontend/src/components/admin/components/contents/RolePermTab.tsx create mode 100644 frontend/src/components/admin/components/contents/Step1.tsx create mode 100644 frontend/src/components/admin/components/contents/Step2.tsx create mode 100644 frontend/src/components/admin/components/contents/Step3.tsx create mode 100644 frontend/src/components/admin/components/contents/Step4.tsx create mode 100644 frontend/src/components/admin/components/contents/Step5.tsx create mode 100644 frontend/src/components/admin/components/contents/StepIndicator.tsx create mode 100644 frontend/src/components/admin/components/contents/TargetArchTab.tsx create mode 100644 frontend/src/components/admin/components/contents/TaskTable.tsx create mode 100644 frontend/src/components/admin/components/contents/TreeRow.tsx create mode 100644 frontend/src/components/admin/components/contents/UserDetailModal.tsx create mode 100644 frontend/src/components/admin/components/contents/UserPermTab.tsx create mode 100644 frontend/src/components/admin/components/contents/WizardModal.tsx rename frontend/src/{tabs => components}/admin/index.ts (100%) rename frontend/src/{tabs => components}/admin/services/monitorApi.ts (100%) create mode 100644 frontend/src/components/aerial/components/AerialTheoryView.tsx rename frontend/src/{tabs => components}/aerial/components/AerialView.tsx (100%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/aerial/components/CCTVPlayer.tsx (100%) rename frontend/src/{tabs => components}/aerial/components/CctvView.tsx (99%) rename frontend/src/{tabs => components}/aerial/components/MediaManagement.tsx (99%) rename frontend/src/{tabs => components}/aerial/components/OilAreaAnalysis.tsx (99%) rename frontend/src/{tabs => components}/aerial/components/OilDetectionOverlay.tsx (98%) rename frontend/src/{tabs => components}/aerial/components/RealtimeDrone.tsx (99%) rename frontend/src/{tabs => components}/aerial/components/SatelliteRequest.tsx (99%) rename frontend/src/{tabs => components}/aerial/components/SensorAnalysis.tsx (100%) create mode 100644 frontend/src/components/aerial/components/WingAI.tsx create mode 100644 frontend/src/components/aerial/components/contents/AoiPanel.tsx create mode 100644 frontend/src/components/aerial/components/contents/ChangeDetectPanel.tsx create mode 100644 frontend/src/components/aerial/components/contents/DetectPanel.tsx create mode 100644 frontend/src/components/aerial/components/contents/PanelAreaCalc.tsx create mode 100644 frontend/src/components/aerial/components/contents/PanelDetection.tsx create mode 100644 frontend/src/components/aerial/components/contents/PanelESIMap.tsx create mode 100644 frontend/src/components/aerial/components/contents/PanelOverview.tsx create mode 100644 frontend/src/components/aerial/components/contents/PanelReferences.tsx create mode 100644 frontend/src/components/aerial/components/contents/PanelRemoteSensing.tsx create mode 100644 frontend/src/components/aerial/components/contents/PanelSpreadModel.tsx rename frontend/src/{tabs => components}/aerial/hooks/useOilDetection.ts (96%) rename frontend/src/{tabs => components}/aerial/index.ts (100%) rename frontend/src/{tabs => components}/aerial/services/aerialApi.ts (65%) rename frontend/src/{tabs => components}/aerial/utils/oilDetection.ts (78%) rename frontend/src/{tabs => components}/aerial/utils/streamUtils.ts (100%) rename frontend/src/{tabs => components}/assets/components/AssetManagement.tsx (99%) rename frontend/src/{tabs => components}/assets/components/AssetMap.tsx (93%) rename frontend/src/{tabs => components}/assets/components/AssetTheory.tsx (100%) rename frontend/src/{tabs => components}/assets/components/AssetUpload.tsx (99%) rename frontend/src/{tabs => components}/assets/components/AssetsView.tsx (100%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/assets/components/ShipInsurance.tsx (99%) rename frontend/src/{tabs => components}/assets/components/assetTypes.ts (77%) rename frontend/src/{tabs => components}/assets/index.ts (100%) rename frontend/src/{tabs => components}/assets/services/assetsApi.ts (54%) rename frontend/src/{tabs => components}/board/components/BoardDetailView.tsx (95%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/board/components/BoardListTable.tsx (97%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/board/components/BoardView.tsx (97%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/board/components/BoardWriteForm.tsx (98%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/board/index.ts (100%) rename frontend/src/{tabs => components}/board/services/boardApi.ts (58%) rename frontend/src/{common/components => components/common}/auth/LoginPage.tsx (99%) rename frontend/src/{common/components => components/common}/layer/LayerTree.tsx (100%) mode change 100755 => 100644 rename frontend/src/{common/components => components/common}/layout/MainLayout.tsx (93%) mode change 100755 => 100644 rename frontend/src/{common/components => components/common}/layout/SubMenuBar.tsx (89%) mode change 100755 => 100644 rename frontend/src/{common/components => components/common}/layout/TopBar.tsx (97%) mode change 100755 => 100644 rename frontend/src/{common/components => components/common}/map/BacktrackReplayBar.tsx (99%) mode change 100755 => 100644 rename frontend/src/{common/components => components/common}/map/BacktrackReplayOverlay.tsx (99%) mode change 100755 => 100644 rename frontend/src/{common/components => components/common}/map/BaseMap.tsx (100%) rename frontend/src/{common/components => components/common}/map/DeckGLOverlay.tsx (100%) rename frontend/src/{common/components => components/common}/map/FlyToController.tsx (100%) rename frontend/src/{common/components => components/common}/map/HydrParticleOverlay.tsx (98%) rename frontend/src/{common/components => components/common}/map/MapBoundsTracker.tsx (94%) rename frontend/src/{common/components => components/common}/map/MapView.tsx (99%) mode change 100755 => 100644 rename frontend/src/{common/components => components/common}/map/MeasureOverlay.tsx (95%) rename frontend/src/{common/components => components/common}/map/S57EncOverlay.tsx (98%) rename frontend/src/{common/components => components/common}/map/SrOverlay.tsx (97%) rename frontend/src/{common/components => components/common}/map/TimelineControl.tsx (100%) rename frontend/src/{common/components => components/common}/map/VesselInteraction.tsx (99%) rename frontend/src/{common/components => components/common}/map/VesselLayer.ts (98%) rename frontend/src/{common/components => components/common}/map/mapStyles.ts (100%) rename frontend/src/{common/components => components/common}/map/mapUtils.ts (100%) rename frontend/src/{common/components => components/common}/map/measureLayers.ts (97%) rename frontend/src/{common/components => components/common}/map/srStyles.ts (100%) rename frontend/src/{common/components => components/common}/ui/ComboBox.tsx (100%) mode change 100755 => 100644 rename frontend/src/{common/components => components/common}/ui/UserManualPopup.tsx (100%) rename frontend/src/{tabs => components}/hns/components/HNSAnalysisListTable.tsx (99%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/hns/components/HNSLeftPanel.tsx (96%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/hns/components/HNSRecalcModal.tsx (96%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/hns/components/HNSRightPanel.tsx (98%) mode change 100755 => 100644 create mode 100644 frontend/src/components/hns/components/HNSScenarioView.tsx rename frontend/src/{tabs => components}/hns/components/HNSSubstanceView.tsx (66%) mode change 100755 => 100644 create mode 100644 frontend/src/components/hns/components/HNSTheoryView.tsx rename frontend/src/{tabs => components}/hns/components/HNSView.tsx (83%) mode change 100755 => 100644 create mode 100644 frontend/src/components/hns/components/contents/GaussianModelPanel.tsx create mode 100644 frontend/src/components/hns/components/contents/HNSManualViewer.tsx create mode 100644 frontend/src/components/hns/components/contents/HmsDetailPanel.tsx create mode 100644 frontend/src/components/hns/components/contents/InfoBoxRow.tsx create mode 100644 frontend/src/components/hns/components/contents/NewScenarioModal.tsx create mode 100644 frontend/src/components/hns/components/contents/OceanCorrectionPanel.tsx create mode 100644 frontend/src/components/hns/components/contents/RealtimeComparePanel.tsx create mode 100644 frontend/src/components/hns/components/contents/ScenarioComparison.tsx create mode 100644 frontend/src/components/hns/components/contents/ScenarioDetail.tsx create mode 100644 frontend/src/components/hns/components/contents/ScenarioMapOverlay.tsx create mode 100644 frontend/src/components/hns/components/contents/SubstanceScenarioPanel.tsx create mode 100644 frontend/src/components/hns/components/contents/SystemOverviewPanel.tsx create mode 100644 frontend/src/components/hns/components/contents/VerificationPanel.tsx create mode 100644 frontend/src/components/hns/components/contents/WrfChemPanel.tsx rename frontend/src/{tabs => components}/hns/hooks/useWeatherFetch.ts (95%) rename frontend/src/{tabs => components}/hns/index.ts (100%) rename frontend/src/{tabs => components}/hns/services/hnsApi.ts (56%) rename frontend/src/{tabs => components}/hns/utils/dispersionEngine.ts (99%) rename frontend/src/{tabs => components}/hns/utils/toxicityData.ts (98%) rename frontend/src/{tabs => components}/incidents/components/DischargeZonePanel.tsx (100%) rename frontend/src/{tabs => components}/incidents/components/ImageAnalysisModal.tsx (100%) rename frontend/src/{tabs => components}/incidents/components/IncidentTable.tsx (98%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/incidents/components/IncidentsLeftPanel.tsx (98%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/incidents/components/IncidentsRightPanel.tsx (98%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/incidents/components/IncidentsView.tsx (50%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/incidents/components/MediaModal.tsx (99%) mode change 100755 => 100644 create mode 100644 frontend/src/components/incidents/components/contents/FlyToController.tsx create mode 100644 frontend/src/components/incidents/components/contents/IncidentPopupContent.tsx create mode 100644 frontend/src/components/incidents/components/contents/IncidentTooltipContent.tsx create mode 100644 frontend/src/components/incidents/components/contents/SplitPanelContent.tsx create mode 100644 frontend/src/components/incidents/components/contents/VesselDetailModal.tsx create mode 100644 frontend/src/components/incidents/components/contents/VesselPopupPanel.tsx create mode 100644 frontend/src/components/incidents/components/contents/VesselTooltipContent.tsx rename frontend/src/{tabs => components}/incidents/index.ts (100%) rename frontend/src/{tabs => components}/incidents/services/incidentsApi.ts (54%) rename frontend/src/{tabs => components}/incidents/services/vesselService.ts (88%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/incidents/utils/dischargeZoneData.ts (100%) rename frontend/src/{tabs => components}/prediction/components/AnalysisListTable.tsx (98%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/prediction/components/BacktrackModal.tsx (99%) mode change 100755 => 100644 create mode 100644 frontend/src/components/prediction/components/BoomDeploymentTheoryView.tsx rename frontend/src/{tabs => components}/prediction/components/InfoLayerSection.tsx (98%) rename frontend/src/{tabs => components}/prediction/components/LeftPanel.tsx (100%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/prediction/components/OilBoomSection.tsx (99%) create mode 100644 frontend/src/components/prediction/components/OilSpillTheoryView.tsx rename frontend/src/{tabs => components}/prediction/components/OilSpillView.tsx (98%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/prediction/components/PredictionInputSection.tsx (99%) rename frontend/src/{tabs => components}/prediction/components/RecalcModal.tsx (99%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/prediction/components/RightPanel.tsx (99%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/prediction/components/SimulationErrorModal.tsx (100%) rename frontend/src/{tabs => components}/prediction/components/SimulationLoadingOverlay.tsx (100%) create mode 100644 frontend/src/components/prediction/components/contents/DeploymentTheoryPanel.tsx create mode 100644 frontend/src/components/prediction/components/contents/EnsemblePanel.tsx create mode 100644 frontend/src/components/prediction/components/contents/FieldApplicationPanel.tsx create mode 100644 frontend/src/components/prediction/components/contents/FluidDynamicsPanel.tsx create mode 100644 frontend/src/components/prediction/components/contents/KospsPanel.tsx create mode 100644 frontend/src/components/prediction/components/contents/LagrangianPanel.tsx create mode 100644 frontend/src/components/prediction/components/contents/OceanInputPanel.tsx create mode 100644 frontend/src/components/prediction/components/contents/OpenDriftPanel.tsx create mode 100644 frontend/src/components/prediction/components/contents/OptimizationPanel.tsx create mode 100644 frontend/src/components/prediction/components/contents/OverviewPanel.tsx create mode 100644 frontend/src/components/prediction/components/contents/PoseidonPanel.tsx create mode 100644 frontend/src/components/prediction/components/contents/ReferencesPanel.tsx create mode 100644 frontend/src/components/prediction/components/contents/RoadmapPanel.tsx create mode 100644 frontend/src/components/prediction/components/contents/SystemOverviewPanel.tsx create mode 100644 frontend/src/components/prediction/components/contents/VerificationPanel.tsx create mode 100644 frontend/src/components/prediction/components/contents/WeatheringPanel.tsx rename frontend/src/{tabs => components}/prediction/components/leftPanelTypes.ts (90%) rename frontend/src/{tabs => components}/prediction/index.ts (100%) create mode 100644 frontend/src/components/prediction/services/predictionApi.ts create mode 100644 frontend/src/components/reports/components/OilSpillReportTemplate.tsx rename frontend/src/{tabs => components}/reports/components/OilSpreadMapPanel.tsx (99%) rename frontend/src/{tabs => components}/reports/components/ReportGenerator.tsx (99%) rename frontend/src/{tabs => components}/reports/components/ReportsView.tsx (99%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/reports/components/TemplateEditPage.tsx (98%) rename frontend/src/{tabs => components}/reports/components/TemplateFormEditor.tsx (99%) create mode 100644 frontend/src/components/reports/components/contents/AddRowBtn.tsx create mode 100644 frontend/src/components/reports/components/contents/ECell.tsx create mode 100644 frontend/src/components/reports/components/contents/Page1.tsx create mode 100644 frontend/src/components/reports/components/contents/Page2.tsx create mode 100644 frontend/src/components/reports/components/contents/Page3.tsx create mode 100644 frontend/src/components/reports/components/contents/Page4.tsx create mode 100644 frontend/src/components/reports/components/contents/Page5.tsx create mode 100644 frontend/src/components/reports/components/contents/Page6.tsx create mode 100644 frontend/src/components/reports/components/contents/Page7.tsx create mode 100644 frontend/src/components/reports/components/contents/SensitiveResourceMapSection.tsx create mode 100644 frontend/src/components/reports/components/contents/SensitivityMapSection.tsx rename frontend/src/{tabs => components}/reports/components/hwpxExport.ts (100%) rename frontend/src/{tabs => components}/reports/components/reportTypes.ts (96%) rename frontend/src/{tabs => components}/reports/components/reportUtils.ts (99%) rename frontend/src/{tabs => components}/reports/index.ts (100%) rename frontend/src/{tabs => components}/reports/services/reportsApi.ts (80%) create mode 100644 frontend/src/components/rescue/components/RescueScenarioView.tsx rename frontend/src/{tabs => components}/rescue/components/RescueTheoryView.tsx (100%) mode change 100755 => 100644 create mode 100644 frontend/src/components/rescue/components/RescueView.tsx create mode 100644 frontend/src/components/rescue/components/contents/BottomBar.tsx create mode 100644 frontend/src/components/rescue/components/contents/DamageStabilityPanel.tsx create mode 100644 frontend/src/components/rescue/components/contents/LeftPanel.tsx create mode 100644 frontend/src/components/rescue/components/contents/LongStrengthPanel.tsx create mode 100644 frontend/src/components/rescue/components/contents/MetricCard.tsx create mode 100644 frontend/src/components/rescue/components/contents/NewScenarioModal.tsx create mode 100644 frontend/src/components/rescue/components/contents/RescueListView.tsx create mode 100644 frontend/src/components/rescue/components/contents/RescuePanel.tsx create mode 100644 frontend/src/components/rescue/components/contents/RightPanel.tsx create mode 100644 frontend/src/components/rescue/components/contents/ScenarioComparison.tsx create mode 100644 frontend/src/components/rescue/components/contents/ScenarioMapOverlay.tsx create mode 100644 frontend/src/components/rescue/components/contents/TopInfoBar.tsx rename frontend/src/{tabs => components}/rescue/index.ts (100%) create mode 100644 frontend/src/components/rescue/services/rescueApi.ts rename frontend/src/{tabs => components}/scat/components/DistributionView.tsx (100%) rename frontend/src/{tabs => components}/scat/components/PreScatView.tsx (98%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/scat/components/ScatLeftPanel.tsx (98%) rename frontend/src/{tabs => components}/scat/components/ScatMap.tsx (97%) rename frontend/src/{tabs => components}/scat/components/ScatPopup.tsx (99%) rename frontend/src/{tabs => components}/scat/components/ScatRightPanel.tsx (99%) rename frontend/src/{tabs => components}/scat/components/ScatTimeline.tsx (99%) rename frontend/src/{tabs => components}/scat/components/ScatView.tsx (100%) rename frontend/src/{tabs => components}/scat/components/SurveyView.tsx (100%) rename frontend/src/{tabs => components}/scat/components/scatConstants.ts (100%) rename frontend/src/{tabs => components}/scat/index.ts (100%) rename frontend/src/{tabs => components}/scat/services/scatApi.ts (74%) rename frontend/src/{tabs => components}/weather/components/OceanCurrentLayer.tsx (97%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/weather/components/OceanCurrentParticleLayer.tsx (99%) rename frontend/src/{tabs => components}/weather/components/OceanForecastOverlay.tsx (97%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/weather/components/WaterTemperatureLayer.tsx (100%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/weather/components/WeatherMapControls.tsx (100%) rename frontend/src/{tabs => components}/weather/components/WeatherMapOverlay.tsx (99%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/weather/components/WeatherRightPanel.tsx (100%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/weather/components/WeatherView.tsx (99%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/weather/components/WindParticleLayer.tsx (100%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/weather/hooks/useOceanForecast.ts (93%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/weather/hooks/useWeatherData.ts (100%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/weather/index.ts (100%) rename frontend/src/{tabs => components}/weather/services/khoaApi.ts (86%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/weather/services/weatherApi.ts (93%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/weather/services/weatherService.ts (100%) mode change 100755 => 100644 rename frontend/src/{tabs => components}/weather/services/weatherUtils.ts (98%) create mode 100644 frontend/src/interfaces/aerial/AerialInterface.ts create mode 100644 frontend/src/interfaces/assets/AssetsInterface.ts create mode 100644 frontend/src/interfaces/board/BoardInterface.ts create mode 100644 frontend/src/interfaces/hns/HnsInterface.ts create mode 100644 frontend/src/interfaces/incidents/IncidentsInterface.ts rename frontend/src/{tabs/prediction/services/predictionApi.ts => interfaces/prediction/PredictionInterface.ts} (55%) create mode 100644 frontend/src/interfaces/reports/ReportsInterface.ts create mode 100644 frontend/src/interfaces/rescue/RescueInterface.ts create mode 100644 frontend/src/interfaces/scat/ScatInterface.ts create mode 100644 frontend/src/interfaces/weather/WeatherInterface.ts delete mode 100644 frontend/src/tabs/admin/components/DeidentifyPanel.tsx delete mode 100644 frontend/src/tabs/admin/components/PermissionsPanel.tsx delete mode 100644 frontend/src/tabs/admin/components/SystemArchPanel.tsx delete mode 100755 frontend/src/tabs/aerial/components/AerialTheoryView.tsx delete mode 100644 frontend/src/tabs/aerial/components/WingAI.tsx delete mode 100644 frontend/src/tabs/assets/components/assetMockData.ts delete mode 100755 frontend/src/tabs/hns/components/HNSScenarioView.tsx delete mode 100755 frontend/src/tabs/hns/components/HNSTheoryView.tsx delete mode 100644 frontend/src/tabs/hns/utils/dispersionTypes.ts delete mode 100755 frontend/src/tabs/prediction/components/BoomDeploymentTheoryView.tsx delete mode 100755 frontend/src/tabs/prediction/components/OilSpillTheoryView.tsx delete mode 100755 frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx delete mode 100755 frontend/src/tabs/rescue/components/RescueScenarioView.tsx delete mode 100755 frontend/src/tabs/rescue/components/RescueView.tsx delete mode 100644 frontend/src/tabs/rescue/services/rescueApi.ts delete mode 100644 frontend/src/tabs/scat/components/scatTypes.ts rename frontend/src/{common => }/types/backtrack.ts (100%) mode change 100755 => 100644 rename frontend/src/{common => }/types/boomLine.ts (100%) mode change 100755 => 100644 create mode 100644 frontend/src/types/hns/HnsType.ts rename frontend/src/{common => }/types/navigation.ts (100%) create mode 100644 frontend/src/types/prediction/PredictionType.ts create mode 100644 frontend/src/types/reports/ReportsType.ts create mode 100644 frontend/src/types/rescue/RescueType.ts rename frontend/src/{common => }/types/vessel.ts (100%) diff --git a/.claude/settings.json b/.claude/settings.json index c8c5d77..6c2b037 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -84,5 +84,8 @@ ] } ] + }, + "enabledPlugins": { + "frontend-design@claude-plugins-official": true } -} +} \ No newline at end of file diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index ffa771a..87e0806 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,7 +1,7 @@ { "applied_global_version": "1.6.1", - "applied_date": "2026-04-14", + "applied_date": "2026-04-16", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true -} +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 6c428ab..eb09ccd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,7 @@ wing/ │ │ ├── types/ backtrack, boomLine, hns, navigation │ │ ├── utils/ coordinates, geo, sanitize, cn.ts │ │ └── data/ layerData.ts (UI 레이어 트리) -│ └── tabs/ 탭 단위 패키지 (@tabs/ alias) +│ └── components/ 탭 단위 패키지 (@components/ alias) │ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐) │ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산) │ ├── rescue/ 구조 시나리오 @@ -96,7 +96,7 @@ wing/ ### Path Alias - `@common/*` -> `src/common/*` (공통 모듈) -- `@tabs/*` -> `src/tabs/*` (탭 패키지) +- `@components/*` -> `src/components/*` (탭 패키지) ## 팀 컨벤션 diff --git a/README.md b/README.md index c709935..e334e7b 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ cd backend && npm run db:seed # DB 초기 데이터 ## 프로젝트 구조 -Path Alias: `@common/*` -> `src/common/*`, `@tabs/*` -> `src/tabs/*` +Path Alias: `@common/*` -> `src/common/*`, `@components/*` -> `src/components/*` ``` wing/ @@ -95,7 +95,7 @@ wing/ │ │ ├── types/ backtrack, boomLine, hns, navigation │ │ ├── utils/ coordinates, geo, sanitize, cn.ts │ │ └── data/ layerData.ts (UI 레이어 트리) -│ └── tabs/ 탭 단위 패키지 (@tabs/ alias) +│ └── tabs/ 탭 단위 패키지 (@components/ alias) │ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐) │ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산) │ ├── rescue/ 구조 시나리오 diff --git a/docs/COMMON-GUIDE.md b/docs/COMMON-GUIDE.md index 656f795..660504f 100644 --- a/docs/COMMON-GUIDE.md +++ b/docs/COMMON-GUIDE.md @@ -378,7 +378,7 @@ PUT, DELETE, PATCH 등 기타 메서드는 사용하지 않는다. 각 탭은 `tabs/{탭명}/services/{탭명}Api.ts`에 API 함수를 정의한다. ```typescript -// frontend/src/tabs/board/services/boardApi.ts +// frontend/src/components/board/services/boardApi.ts import { api } from '@common/services/api'; // 인터페이스 정의 @@ -490,7 +490,7 @@ interface MenuConfigItem { ```typescript // frontend/src/common/store/newStore.ts (공통) 또는 -// frontend/src/tabs/{탭}/store/newStore.ts (탭 전용) +// frontend/src/components/{탭}/store/newStore.ts (탭 전용) import { create } from 'zustand'; interface MyState { @@ -514,7 +514,7 @@ export const useMyStore = create((set) => ({ ```typescript import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { fetchBoardPosts, createBoardPost } from '@tabs/board/services/boardApi'; +import { fetchBoardPosts, createBoardPost } from '@components/board/services/boardApi'; // 조회 (캐싱 + 자동 리페치) const { data, isLoading, error } = useQuery({ @@ -1491,13 +1491,13 @@ const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1' ### 파일 위치 ``` -frontend/src/tabs/{탭명}/services/{탭명}Api.ts +frontend/src/components/{탭명}/services/{탭명}Api.ts ``` ### 작성 패턴 ```typescript -// frontend/src/tabs/{탭명}/services/{탭명}Api.ts +// frontend/src/components/{탭명}/services/{탭명}Api.ts import { api } from '@common/services/api'; // ============================================================ diff --git a/docs/CRUD-API-GUIDE.md b/docs/CRUD-API-GUIDE.md index 7212063..050d50e 100644 --- a/docs/CRUD-API-GUIDE.md +++ b/docs/CRUD-API-GUIDE.md @@ -736,13 +736,13 @@ ON CONFLICT DO NOTHING; ### 파일 위치 ``` -frontend/src/tabs/{탭명}/services/{tabName}Api.ts +frontend/src/components/{탭명}/services/{tabName}Api.ts ``` ### 기본 구조 ```ts -// frontend/src/tabs/{탭명}/services/{tabName}Api.ts +// frontend/src/components/{탭명}/services/{tabName}Api.ts import { api } from '@common/services/api'; @@ -1376,7 +1376,7 @@ export default router; ### 4단계: 프론트엔드 API 서비스 ```ts -// frontend/src/tabs/assets/services/equipmentApi.ts +// frontend/src/components/assets/services/equipmentApi.ts import { api } from '@common/services/api'; diff --git a/docs/DEVELOPMENT-GUIDE.md b/docs/DEVELOPMENT-GUIDE.md index 429d75e..ff14f54 100644 --- a/docs/DEVELOPMENT-GUIDE.md +++ b/docs/DEVELOPMENT-GUIDE.md @@ -163,11 +163,11 @@ Frontend에서 두 가지 경로 별칭을 사용한다: | Alias | 실제 경로 | 용도 | |-------|----------|------| | `@common/*` | `src/common/*` | 공통 모듈 (컴포넌트, 훅, 서비스, 스토어) | -| `@tabs/*` | `src/tabs/*` | 탭별 패키지 (11개 탭) | +| `@components/*` | `src/components/*` | 탭별 패키지 (11개 탭) | ```tsx import { useAuth } from '@common/hooks/useAuth'; -import OilSpillView from '@tabs/prediction/components/OilSpillView'; +import OilSpillView from '@components/prediction/components/OilSpillView'; ``` --- @@ -495,7 +495,7 @@ pre-commit: [backend] 타입 체크 성공 git status # 스테이징 (파일 지정) -git add frontend/src/tabs/incidents/components/IncidentDetailView.tsx +git add frontend/src/components/incidents/components/IncidentDetailView.tsx git add backend/src/incidents/incidentService.ts # 커밋 (pre-commit + commit-msg 검증 자동 실행) @@ -540,7 +540,7 @@ curl -X POST "https://gitea.gc-si.dev/api/v1/repos/gc/wing-ops/pulls" \ - 변경 내용을 1~3줄로 요약 ## 변경 파일 -- `frontend/src/tabs/incidents/components/IncidentDetailView.tsx` (신규) +- `frontend/src/components/incidents/components/IncidentDetailView.tsx` (신규) - `backend/src/incidents/incidentService.ts` (수정) ## Test plan @@ -754,8 +754,8 @@ chmod +x .githooks/pre-commit .githooks/commit-msg | `database/migration/017_incident_detail.sql` | DB 마이그레이션 (필요 시) | | `backend/src/incidents/incidentService.ts` | 상세 조회 함수 추가 | | `backend/src/incidents/incidentRouter.ts` | `GET /api/incidents/:id` 라우트 | -| `frontend/src/tabs/incidents/services/incidentsApi.ts` | API 호출 함수 | -| `frontend/src/tabs/incidents/components/IncidentDetailView.tsx` | 상세 뷰 컴포넌트 | +| `frontend/src/components/incidents/services/incidentsApi.ts` | API 호출 함수 | +| `frontend/src/components/incidents/components/IncidentDetailView.tsx` | 상세 뷰 컴포넌트 | #### Step 2. 브랜치 생성 @@ -797,7 +797,7 @@ router.get('/:id', requireAuth, async (req, res) => { **Frontend - API:** ```typescript -// frontend/src/tabs/incidents/services/incidentsApi.ts +// frontend/src/components/incidents/services/incidentsApi.ts export async function fetchIncidentById(id: number) { const { data } = await api.get(`/incidents/${id}`); return data; @@ -807,7 +807,7 @@ export async function fetchIncidentById(id: number) { **Frontend - Component:** ```tsx -// frontend/src/tabs/incidents/components/IncidentDetailView.tsx +// frontend/src/components/incidents/components/IncidentDetailView.tsx const IncidentDetailView = ({ incidentId }: IncidentDetailViewProps) => { const { data, isLoading } = useQuery({ queryKey: ['incident', incidentId], @@ -829,7 +829,7 @@ cd ../backend && npx tsc --noEmit #### Step 5. 커밋 & 푸시 ```bash -git add backend/src/incidents/ frontend/src/tabs/incidents/ +git add backend/src/incidents/ frontend/src/components/incidents/ git commit -m "feat(incidents): 사고 상세 조회 페이지 추가" # pre-commit: TypeScript OK, ESLint OK # commit-msg: Conventional Commits OK diff --git a/docs/MENU-TAB-GUIDE.md b/docs/MENU-TAB-GUIDE.md index 141a5d5..93071ea 100644 --- a/docs/MENU-TAB-GUIDE.md +++ b/docs/MENU-TAB-GUIDE.md @@ -31,9 +31,9 @@ board 탭을 기준 템플릿으로 사용하며, 각 단계별 실제 코드 | 단계 | 파일 | 작업 | |------|------|------| -| **Step 1** | `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` | 뷰 컴포넌트 생성 | -| | `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` | API 서비스 생성 | -| | `frontend/src/tabs/{탭명}/index.ts` | re-export | +| **Step 1** | `frontend/src/components/{탭명}/components/{TabName}View.tsx` | 뷰 컴포넌트 생성 | +| | `frontend/src/components/{탭명}/services/{tabName}Api.ts` | API 서비스 생성 | +| | `frontend/src/components/{탭명}/index.ts` | re-export | | **Step 2** | `frontend/src/common/types/navigation.ts` | MainTab 타입 추가 | | | `frontend/src/App.tsx` | import + renderView case 추가 | | | `frontend/src/common/hooks/useSubMenu.ts` | 서브메뉴 설정 (서브탭이 있는 경우) | @@ -52,7 +52,7 @@ board 탭을 기준 템플릿으로 사용하며, 각 단계별 실제 코드 ### 1-1. 디렉토리 구조 ``` -frontend/src/tabs/{탭명}/ +frontend/src/components/{탭명}/ components/ {TabName}View.tsx # 메인 뷰 컴포넌트 services/ @@ -65,7 +65,7 @@ frontend/src/tabs/{탭명}/ 서브탭이 **없는** 간단한 탭: ```tsx -// frontend/src/tabs/monitoring/components/MonitoringView.tsx +// frontend/src/components/monitoring/components/MonitoringView.tsx export function MonitoringView() { return ( @@ -91,7 +91,7 @@ export function MonitoringView() { 서브탭이 **있는** 탭 (board 패턴): ```tsx -// frontend/src/tabs/monitoring/components/MonitoringView.tsx +// frontend/src/components/monitoring/components/MonitoringView.tsx import { useSubMenu } from '@common/hooks/useSubMenu'; @@ -122,7 +122,7 @@ export function MonitoringView() { ### 1-3. API 서비스 (보일러플레이트) ```ts -// frontend/src/tabs/monitoring/services/monitoringApi.ts +// frontend/src/components/monitoring/services/monitoringApi.ts import { api } from '@common/services/api'; @@ -180,7 +180,7 @@ export async function createMonitoring(input: CreateMonitoringInput): Promise<{ ### 1-4. index.ts (re-export) ```ts -// frontend/src/tabs/monitoring/index.ts +// frontend/src/components/monitoring/index.ts export { MonitoringView } from './components/MonitoringView'; ``` @@ -209,7 +209,7 @@ export type MainTab = 'prediction' | 'hns' | 'rescue' | ... | 'monitoring' | 'ad // frontend/src/App.tsx // 1. import 추가 -import { MonitoringView } from '@tabs/monitoring'; +import { MonitoringView } from '@components/monitoring'; // 2. renderView switch에 case 추가 const renderView = () => { @@ -577,13 +577,13 @@ CREATE INDEX IF NOT EXISTS IDX_MONITORING_REG_DTM ON MONITORING(REG_DTM DESC); ### 1단계: 프론트엔드 파일 생성 ```bash -mkdir -p frontend/src/tabs/monitoring/components -mkdir -p frontend/src/tabs/monitoring/services +mkdir -p frontend/src/components/monitoring/components +mkdir -p frontend/src/components/monitoring/services ``` -- `frontend/src/tabs/monitoring/components/MonitoringView.tsx` 생성 -- `frontend/src/tabs/monitoring/services/monitoringApi.ts` 생성 -- `frontend/src/tabs/monitoring/index.ts` 생성 +- `frontend/src/components/monitoring/components/MonitoringView.tsx` 생성 +- `frontend/src/components/monitoring/services/monitoringApi.ts` 생성 +- `frontend/src/components/monitoring/index.ts` 생성 ### 2단계: 프론트엔드 기존 파일 수정 @@ -592,7 +592,7 @@ mkdir -p frontend/src/tabs/monitoring/services + export type MainTab = '...' | 'monitoring' | 'admin'; --- frontend/src/App.tsx -+ import { MonitoringView } from '@tabs/monitoring'; ++ import { MonitoringView } from '@components/monitoring'; // renderView switch 내: + case 'monitoring': + return ; @@ -644,9 +644,9 @@ cd backend && npx tsc --noEmit # 백엔드 컴파일 검증 ## 체크리스트 ### 프론트엔드 -- [ ] `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` 생성 -- [ ] `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` 생성 -- [ ] `frontend/src/tabs/{탭명}/index.ts` re-export 생성 +- [ ] `frontend/src/components/{탭명}/components/{TabName}View.tsx` 생성 +- [ ] `frontend/src/components/{탭명}/services/{tabName}Api.ts` 생성 +- [ ] `frontend/src/components/{탭명}/index.ts` re-export 생성 - [ ] `navigation.ts` MainTab 타입에 새 ID 추가 - [ ] `App.tsx` import + renderView switch case 추가 - [ ] `useSubMenu.ts` subMenuConfigs + subMenuState 추가 (서브탭 있는 경우) diff --git a/docs/MOCK-TO-API-GUIDE.md b/docs/MOCK-TO-API-GUIDE.md index 171e23a..6ceb996 100644 --- a/docs/MOCK-TO-API-GUIDE.md +++ b/docs/MOCK-TO-API-GUIDE.md @@ -49,7 +49,7 @@ git checkout -b feature/{탭명}-crud ```bash # 탭 디렉토리 내 mock 데이터 검색 grep -rn "mock\|Mock\|MOCK\|sample\|initial\|hardcod\|localStorage" \ - frontend/src/tabs/{탭명}/ + frontend/src/components/{탭명}/ # 공통 디렉토리에서 해당 탭 관련 데이터 확인 (반드시!) grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/ @@ -302,7 +302,7 @@ app.use('/api/{탭명}', newtabRouter); **1) API 서비스 파일 생성:** -파일 위치: `frontend/src/tabs/{탭명}/services/{탭명}Api.ts` +파일 위치: `frontend/src/components/{탭명}/services/{탭명}Api.ts` ```typescript import { api } from '@common/services/api'; @@ -476,7 +476,7 @@ CRUD 전체 흐름(생성 -> 조회 -> 수정 -> 삭제)을 확인하고 테스 ```bash # 해당 탭 디렉토리에서 mock 잔여 검색 -grep -rn "mock\|Mock\|MOCK\|localStorage" frontend/src/tabs/{탭명}/ +grep -rn "mock\|Mock\|MOCK\|localStorage" frontend/src/components/{탭명}/ # 공통 mock/data 디렉토리에서 해당 탭 관련 검색 grep -rn "{탭명}" frontend/src/common/mock/ @@ -497,7 +497,7 @@ git status git add database/migration/017_{탭명}.sql git add backend/src/{탭명}/ git add backend/src/server.ts -git add frontend/src/tabs/{탭명}/ +git add frontend/src/components/{탭명}/ # 커밋 (Conventional Commits, 한국어) git commit -m "feat({탭명}): mock 데이터를 PostgreSQL + REST API로 전환" @@ -602,7 +602,7 @@ AUTH_USER 주요 컬럼 참조: ```bash # 불충분 -- 탭 디렉토리만 검색 -grep -rn "mock" frontend/src/tabs/{탭명}/ +grep -rn "mock" frontend/src/components/{탭명}/ # 반드시 공통 디렉토리도 검색 grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/ @@ -780,8 +780,8 @@ export async function fetchCategories(): Promise { - [ ] 프론트 타입 체크 통과: `cd frontend && npx tsc --noEmit` - [ ] ESLint 통과: `cd frontend && npx eslint .` - [ ] CRUD 테스트: curl로 생성/조회/수정/삭제 정상 동작 확인 -- [ ] Mock 잔여 0건: `grep -rn "mock\|Mock" frontend/src/tabs/{탭명}/` (UI 상수 제외) -- [ ] PUT/DELETE 사용 0건: `grep -rn "api\.put\|api\.delete" frontend/src/tabs/{탭명}/` +- [ ] Mock 잔여 0건: `grep -rn "mock\|Mock" frontend/src/components/{탭명}/` (UI 상수 제외) +- [ ] PUT/DELETE 사용 0건: `grep -rn "api\.put\|api\.delete" frontend/src/components/{탭명}/` - [ ] 라우터 등록 확인: `server.ts`에 `app.use('/api/{탭명}', ...)` 추가됨 - [ ] 마이그레이션 실행 확인: psql로 테이블 생성 및 검증 SELECT 통과 - [ ] 커밋 + 푸시 + MR 생성 diff --git a/docs/README.md b/docs/README.md index dea0ae3..79fffc3 100755 --- a/docs/README.md +++ b/docs/README.md @@ -66,7 +66,7 @@ wing/ │ │ ├── utils/ cn, coordinates, geo, sanitize │ │ ├── styles/ base.css, components.css, wing.css (@layer) │ │ └── constants/ featureIds.ts (FEATURE_ID 상수 체계) -│ └── tabs/ @tabs/ alias (11개 탭) +│ └── tabs/ @components/ alias (11개 탭) │ ├── prediction/ 유류 확산 예측 │ ├── hns/ HNS 분석 │ ├── rescue/ 구조 시나리오 @@ -103,7 +103,7 @@ wing/ | Alias | 경로 | |-------|------| | `@common/*` | `src/common/*` | -| `@tabs/*` | `src/tabs/*` | +| `@components/*` | `src/components/*` | --- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 69a512d..46ae0f8 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,25 +1,25 @@ import { useState, useEffect } from 'react'; import { Routes, Route } from 'react-router-dom'; import { GoogleOAuthProvider } from '@react-oauth/google'; -import type { MainTab } from '@common/types/navigation'; -import { MainLayout } from '@common/components/layout/MainLayout'; -import { LoginPage } from '@common/components/auth/LoginPage'; +import type { MainTab } from '@/types/navigation'; +import { MainLayout } from '@components/common/layout/MainLayout'; +import { LoginPage } from '@components/common/auth/LoginPage'; import { registerMainTabSwitcher } from '@common/hooks/useSubMenu'; import { useAuthStore } from '@common/store/authStore'; import { useMenuStore } from '@common/store/menuStore'; import { useMapStore } from '@common/store/mapStore'; import { API_BASE_URL } from '@common/services/api'; -import { OilSpillView } from '@tabs/prediction'; -import { ReportsView } from '@tabs/reports'; -import { HNSView } from '@tabs/hns'; -import { AerialView } from '@tabs/aerial'; -import { AssetsView } from '@tabs/assets'; -import { BoardView } from '@tabs/board'; -import { WeatherView } from '@tabs/weather'; -import { IncidentsView } from '@tabs/incidents'; -import { AdminView } from '@tabs/admin'; -import { ScatView } from '@tabs/scat'; -import { RescueView } from '@tabs/rescue'; +import { OilSpillView } from '@components/prediction'; +import { ReportsView } from '@components/reports'; +import { HNSView } from '@components/hns'; +import { AerialView } from '@components/aerial'; +import { AssetsView } from '@components/assets'; +import { BoardView } from '@components/board'; +import { WeatherView } from '@components/weather'; +import { IncidentsView } from '@components/incidents'; +import { AdminView } from '@components/admin'; +import { ScatView } from '@components/scat'; +import { RescueView } from '@components/rescue'; import { DesignPage } from '@/pages/design/DesignPage'; const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || ''; diff --git a/frontend/src/common/hooks/useBaseMapStyle.ts b/frontend/src/common/hooks/useBaseMapStyle.ts index d067173..512eecf 100644 --- a/frontend/src/common/hooks/useBaseMapStyle.ts +++ b/frontend/src/common/hooks/useBaseMapStyle.ts @@ -1,6 +1,6 @@ import type { StyleSpecification } from 'maplibre-gl'; import { useMapStore } from '@common/store/mapStore'; -import { LIGHT_STYLE, SATELLITE_3D_STYLE, ENC_EMPTY_STYLE } from '@common/components/map/mapStyles'; +import { LIGHT_STYLE, SATELLITE_3D_STYLE, ENC_EMPTY_STYLE } from '@components/common/map/mapStyles'; export function useBaseMapStyle(): StyleSpecification { const mapToggles = useMapStore((s) => s.mapToggles); diff --git a/frontend/src/common/hooks/useSubMenu.ts b/frontend/src/common/hooks/useSubMenu.ts index b2c9225..f97c814 100755 --- a/frontend/src/common/hooks/useSubMenu.ts +++ b/frontend/src/common/hooks/useSubMenu.ts @@ -1,5 +1,5 @@ import { useEffect, useSyncExternalStore } from 'react'; -import type { MainTab } from '../types/navigation'; +import type { MainTab } from '@/types/navigation'; import { useAuthStore } from '@common/store/authStore'; import { API_BASE_URL } from '@common/services/api'; @@ -61,6 +61,7 @@ const subMenuConfigs: Record = { { id: 'manual', label: '해경매뉴얼', icon: '📘' }, ], weather: null, + monitor: null, admin: null, // 관리자 화면은 자체 사이드바 사용 (AdminSidebar.tsx) }; @@ -76,6 +77,7 @@ const subMenuState: Record = { incidents: '', board: 'all', weather: '', + monitor: '', admin: 'users', }; diff --git a/frontend/src/common/hooks/useVesselSignals.ts b/frontend/src/common/hooks/useVesselSignals.ts index 734c35f..709b7ac 100644 --- a/frontend/src/common/hooks/useVesselSignals.ts +++ b/frontend/src/common/hooks/useVesselSignals.ts @@ -7,7 +7,7 @@ import { getInitialVesselSnapshot, isVesselInitEnabled, } from '@common/services/vesselApi'; -import type { VesselPosition, MapBounds } from '@common/types/vessel'; +import type { VesselPosition, MapBounds } from '@/types/vessel'; /** * 선박 신호 실시간 수신 훅 diff --git a/frontend/src/common/mock/vesselMockData.ts b/frontend/src/common/mock/vesselMockData.ts index d49e83d..9c9de86 100755 --- a/frontend/src/common/mock/vesselMockData.ts +++ b/frontend/src/common/mock/vesselMockData.ts @@ -1,4 +1,4 @@ // Deprecated: Mock 선박 데이터는 제거되었습니다. -// 실제 선박 신호는 @common/hooks/useVesselSignals + @common/components/map/VesselLayer 를 사용합니다. -// 범례는 @common/components/map/VesselLayer 의 VESSEL_LEGEND 를 import 하세요. +// 실제 선박 신호는 @common/hooks/useVesselSignals + @components/common/map/VesselLayer 를 사용합니다. +// 범례는 @components/common/map/VesselLayer 의 VESSEL_LEGEND 를 import 하세요. export {}; diff --git a/frontend/src/common/services/vesselApi.ts b/frontend/src/common/services/vesselApi.ts index de3a2f2..00c9ce5 100644 --- a/frontend/src/common/services/vesselApi.ts +++ b/frontend/src/common/services/vesselApi.ts @@ -1,5 +1,5 @@ import { api } from './api'; -import type { VesselPosition, MapBounds } from '@common/types/vessel'; +import type { VesselPosition, MapBounds } from '@/types/vessel'; export async function getVesselsInArea(bounds: MapBounds): Promise { const res = await api.post('/vessels/in-area', { bounds }); diff --git a/frontend/src/common/services/vesselSignalClient.ts b/frontend/src/common/services/vesselSignalClient.ts index 201b4bc..8a47fe2 100644 --- a/frontend/src/common/services/vesselSignalClient.ts +++ b/frontend/src/common/services/vesselSignalClient.ts @@ -1,4 +1,4 @@ -import type { VesselPosition, MapBounds } from '@common/types/vessel'; +import type { VesselPosition, MapBounds } from '@/types/vessel'; import { getVesselsInArea } from './vesselApi'; export interface VesselSignalClient { diff --git a/frontend/src/common/store/weatherSnapshotStore.ts b/frontend/src/common/store/weatherSnapshotStore.ts index 79847dd..c6e6711 100644 --- a/frontend/src/common/store/weatherSnapshotStore.ts +++ b/frontend/src/common/store/weatherSnapshotStore.ts @@ -1,44 +1,7 @@ import { create } from 'zustand'; +import type { WeatherSnapshot } from '@interfaces/weather/WeatherInterface'; -export interface WeatherSnapshot { - stationName: string; - capturedAt: string; - wind: { - speed: number; - direction: number; - directionLabel: string; - speed_1k: number; - speed_3k: number; - }; - wave: { - height: number; - maxHeight: number; - period: number; - direction: string; - }; - temperature: { - current: number; - feelsLike: number; - }; - pressure: number; - visibility: number; - salinity: number; - astronomy?: { - sunrise: string; - sunset: string; - moonrise: string; - moonset: string; - moonPhase: string; - tidalRange: number; - }; - alert?: string; - forecast?: Array<{ - time: string; - icon: string; - temperature: number; - windSpeed: number; - }>; -} +export type { WeatherSnapshot }; interface WeatherSnapshotStore { snapshot: WeatherSnapshot | null; diff --git a/frontend/src/common/types/hns.ts b/frontend/src/common/types/hns.ts deleted file mode 100644 index 72392b1..0000000 --- a/frontend/src/common/types/hns.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* HNS 물질 검색 데이터 타입 */ - -export interface HNSSearchSubstance { - id: number; - abbreviation: string; // 약자/제품명 (화물적부도 코드) - nameKr: string; // 국문명 - nameEn: string; // 영문명 - synonymsEn: string; // 영문 동의어 - synonymsKr: string; // 국문 동의어/용도 - unNumber: string; // UN번호 - casNumber: string; // CAS번호 - transportMethod: string; // 운송방법 - sebc: string; // SEBC 거동분류 - /* 물리·화학적 특성 */ - usage: string; - state: string; - color: string; - odor: string; - flashPoint: string; - autoIgnition: string; - boilingPoint: string; - density: string; // 비중 (물=1) - solubility: string; - vaporPressure: string; - vaporDensity: string; // 증기밀도 (공기=1) - explosionRange: string; // 폭발범위 - /* 위험등급·농도기준 */ - nfpa: { health: number; fire: number; reactivity: number; special: string }; - hazardClass: string; - ergNumber: string; - idlh: string; - aegl2: string; - erpg2: string; - /* 방제거리 */ - responseDistanceFire: string; - responseDistanceSpillDay: string; - responseDistanceSpillNight: string; - marineResponse: string; - /* PPE */ - ppeClose: string; - ppeFar: string; - /* MSDS 요약 */ - msds: { - hazard: string; - firstAid: string; - fireFighting: string; - spillResponse: string; - exposure: string; - regulation: string; - }; - /* IBC CODE */ - ibcHazard: string; - ibcShipType: string; - ibcTankType: string; - ibcDetection: string; - ibcFireFighting: string; - ibcMinRequirement: string; - /* EmS */ - emsCode: string; - emsFire: string; - emsSpill: string; - emsFirstAid: string; - /* 화물적부도 코드 */ - cargoCodes: Array<{ code: string; name: string; company: string; source: string }>; - /* 항구별 반입 */ - portFrequency: Array<{ port: string; portCode: string; lastImport: string; frequency: string }>; -} diff --git a/frontend/src/common/utils/geo.ts b/frontend/src/common/utils/geo.ts index 424987e..22b21d1 100755 --- a/frontend/src/common/utils/geo.ts +++ b/frontend/src/common/utils/geo.ts @@ -3,7 +3,7 @@ import type { BoomLineCoord, AlgorithmSettings, ContainmentResult, -} from '../types/boomLine'; +} from '@/types/boomLine'; const DEG2RAD = Math.PI / 180; const RAD2DEG = 180 / Math.PI; diff --git a/frontend/src/common/utils/imageAnalysisSignal.ts b/frontend/src/common/utils/imageAnalysisSignal.ts index 84569f4..6c99530 100644 --- a/frontend/src/common/utils/imageAnalysisSignal.ts +++ b/frontend/src/common/utils/imageAnalysisSignal.ts @@ -1,4 +1,4 @@ -import type { ImageAnalyzeResult } from '@tabs/prediction/services/predictionApi'; +import type { ImageAnalyzeResult } from '@interfaces/prediction/PredictionInterface'; /** * 항공탐색(유출유면적분석) → 유출유 확산예측 탭 간 데이터 전달용 모듈 레벨 시그널. diff --git a/frontend/src/tabs/admin/components/AdminPlaceholder.tsx b/frontend/src/components/admin/components/AdminPlaceholder.tsx similarity index 100% rename from frontend/src/tabs/admin/components/AdminPlaceholder.tsx rename to frontend/src/components/admin/components/AdminPlaceholder.tsx diff --git a/frontend/src/tabs/admin/components/AdminSidebar.tsx b/frontend/src/components/admin/components/AdminSidebar.tsx similarity index 100% rename from frontend/src/tabs/admin/components/AdminSidebar.tsx rename to frontend/src/components/admin/components/AdminSidebar.tsx diff --git a/frontend/src/tabs/admin/components/AdminView.tsx b/frontend/src/components/admin/components/AdminView.tsx old mode 100755 new mode 100644 similarity index 96% rename from frontend/src/tabs/admin/components/AdminView.tsx rename to frontend/src/components/admin/components/AdminView.tsx index 574dd66..6703374 --- a/frontend/src/tabs/admin/components/AdminView.tsx +++ b/frontend/src/components/admin/components/AdminView.tsx @@ -69,7 +69,9 @@ export function AdminView() { return (
-
{renderContent()}
+
+ {renderContent()} +
); } diff --git a/frontend/src/tabs/admin/components/AssetUploadPanel.tsx b/frontend/src/components/admin/components/AssetUploadPanel.tsx similarity index 96% rename from frontend/src/tabs/admin/components/AssetUploadPanel.tsx rename to frontend/src/components/admin/components/AssetUploadPanel.tsx index 0112a4c..3c67304 100644 --- a/frontend/src/tabs/admin/components/AssetUploadPanel.tsx +++ b/frontend/src/components/admin/components/AssetUploadPanel.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; -import { fetchUploadLogs } from '@tabs/assets/services/assetsApi'; -import type { UploadLogItem } from '@tabs/assets/services/assetsApi'; +import { fetchUploadLogs } from '@components/assets/services/assetsApi'; +import type { UploadLogItem } from '@interfaces/assets/AssetsInterface'; const ASSET_CATEGORIES = [ '전체', @@ -20,29 +20,29 @@ const PERM_ITEMS = [ icon: '👑', role: '시스템관리자', desc: '전체 자산 업로드/삭제 가능', - bg: 'rgba(245,158,11,0.15)', - color: 'text-yellow-400', + bg: 'rgba(6,182,212,0.12)', + color: 'text-color-accent', }, { icon: '🔧', role: '운영관리자', desc: '관할청 내 자산 업로드 가능', - bg: 'rgba(6,182,212,0.15)', + bg: 'rgba(6,182,212,0.08)', color: 'text-color-accent', }, { icon: '👁', role: '조회자', desc: '현황 조회만 가능', - bg: 'rgba(148,163,184,0.15)', + bg: 'rgba(6,182,212,0.08)', color: 'text-fg-sub', }, { icon: '🚫', role: '게스트', desc: '접근 불가', - bg: 'rgba(239,68,68,0.1)', - color: 'text-red-400', + bg: 'rgba(6,182,212,0.08)', + color: 'text-fg-sub', }, ]; @@ -102,7 +102,7 @@ function AssetUploadPanel() {
{/* 헤더 */}
-

자산 현행화

+

자산 현행화

자산 데이터를 업로드하여 현행화합니다

@@ -130,7 +130,7 @@ function AssetUploadPanel() { className={`rounded-lg border-2 border-dashed py-8 text-center cursor-pointer transition-colors ${ dragging ? 'border-color-accent bg-[rgba(6,182,212,0.05)]' - : 'border-stroke hover:border-[rgba(6,182,212,0.5)] bg-bg-elevated' + : 'border-stroke hover:border-[rgba(6,182,212,0.3)] bg-bg-elevated' }`} >
📁
diff --git a/frontend/src/tabs/admin/components/BoardMgmtPanel.tsx b/frontend/src/components/admin/components/BoardMgmtPanel.tsx similarity index 89% rename from frontend/src/tabs/admin/components/BoardMgmtPanel.tsx rename to frontend/src/components/admin/components/BoardMgmtPanel.tsx index 7a77c9a..a05dd37 100644 --- a/frontend/src/tabs/admin/components/BoardMgmtPanel.tsx +++ b/frontend/src/components/admin/components/BoardMgmtPanel.tsx @@ -1,10 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; -import { - fetchBoardPosts, - adminDeleteBoardPost, - type BoardPostItem, - type BoardListResponse, -} from '@tabs/board/services/boardApi'; +import { fetchBoardPosts, adminDeleteBoardPost } from '@components/board/services/boardApi'; +import type { BoardPostItem, BoardListResponse } from '@interfaces/board/BoardInterface'; // ─── 상수 ────────────────────────────────────────────────── const PAGE_SIZE = 20; @@ -118,13 +114,13 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP return (
{/* 헤더 */} -
+

게시판 관리

총 {data?.totalCount ?? 0}건
{/* 카테고리 탭 + 검색 */} -
+
{CATEGORY_TABS.map((tab) => ( @@ -158,11 +154,11 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
{/* 액션 바 */} -
+
@@ -172,7 +168,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
- + + @@ -275,17 +271,17 @@ function PostRow({ post, checked, onToggle }: PostRowProps) { {CATEGORY_LABELS[post.categoryCd] ?? post.categoryCd} diff --git a/frontend/src/tabs/admin/components/CleanupEquipPanel.tsx b/frontend/src/components/admin/components/CleanupEquipPanel.tsx similarity index 95% rename from frontend/src/tabs/admin/components/CleanupEquipPanel.tsx rename to frontend/src/components/admin/components/CleanupEquipPanel.tsx index 0f60686..8157fb7 100644 --- a/frontend/src/tabs/admin/components/CleanupEquipPanel.tsx +++ b/frontend/src/components/admin/components/CleanupEquipPanel.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useMemo } from 'react'; -import { fetchOrganizations } from '@tabs/assets/services/assetsApi'; -import type { AssetOrgCompat } from '@tabs/assets/services/assetsApi'; -import { typeTagCls } from '@tabs/assets/components/assetTypes'; +import { fetchOrganizations } from '@components/assets/services/assetsApi'; +import type { AssetOrgCompat } from '@interfaces/assets/AssetsInterface'; +import { typeTagCls } from '@components/assets/components/assetTypes'; +/* eslint-disable react-refresh/only-export-components */ const PAGE_SIZE = 20; @@ -98,7 +99,7 @@ function CleanupEquipPanel() { {/* 헤더 */}
-

방제장비 현황

+

방제장비 현황

총 {filtered.length}개 기관

@@ -341,16 +342,11 @@ function CleanupEquipPanel() { diff --git a/frontend/src/tabs/admin/components/CollectHrPanel.tsx b/frontend/src/components/admin/components/CollectHrPanel.tsx similarity index 94% rename from frontend/src/tabs/admin/components/CollectHrPanel.tsx rename to frontend/src/components/admin/components/CollectHrPanel.tsx index 5cd324e..a0e1214 100644 --- a/frontend/src/tabs/admin/components/CollectHrPanel.tsx +++ b/frontend/src/components/admin/components/CollectHrPanel.tsx @@ -176,9 +176,9 @@ function getCollectStatus(item: HrCollectItem): { label: string; color: string } return { label: '비활성', color: 'text-t3 bg-bg-elevated' }; } if (item.etaClctList.length > 0) { - return { label: '완료', color: 'text-emerald-400 bg-emerald-500/10' }; + return { label: '완료', color: 'text-color-success bg-[rgba(34,197,94,0.08)]' }; } - return { label: '대기', color: 'text-yellow-400 bg-yellow-500/10' }; + return { label: '대기', color: 'text-color-caution bg-[rgba(234,179,8,0.08)]' }; } // ─── cron 표현식 → 읽기 쉬운 형태 ───────────────────────── @@ -217,7 +217,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean }) {HEADERS.map((h) => (
@@ -227,7 +227,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean }) {loading && rows.length === 0 ? Array.from({ length: 5 }).map((_, i) => ( - + {HEADERS.map((_, j) => ( @@ -134,7 +134,7 @@ function ForecastTable({ rows, loading }: { rows: NumericalDataStatus[]; loading {loading && rows.length === 0 ? Array.from({ length: 6 }).map((_, i) => ( - + {TABLE_HEADERS.map((_, j) => ( )) : rows.map((row) => ( - + @@ -192,7 +192,7 @@ export default function MonitorForecastPanel() { return (
{/* 헤더 */} -
+

수치예측자료 모니터링

{lastUpdate && ( @@ -229,14 +229,14 @@ export default function MonitorForecastPanel() {
{/* 탭 */} -
+
{TABS.map((tab) => (
{/* 상태 표시줄 */} -
+
{!loading && totalCount > 0 && ( 모델 {totalCount}개 diff --git a/frontend/src/tabs/admin/components/MonitorRealtimePanel.tsx b/frontend/src/components/admin/components/MonitorRealtimePanel.tsx similarity index 88% rename from frontend/src/tabs/admin/components/MonitorRealtimePanel.tsx rename to frontend/src/components/admin/components/MonitorRealtimePanel.tsx index 28d99f1..080fd26 100644 --- a/frontend/src/tabs/admin/components/MonitorRealtimePanel.tsx +++ b/frontend/src/components/admin/components/MonitorRealtimePanel.tsx @@ -1,18 +1,17 @@ import { useState, useEffect, useCallback } from 'react'; -import { - getRecentObservation, - OBS_STATION_CODES, - type RecentObservation, -} from '@tabs/weather/services/khoaApi'; +import { getRecentObservation, OBS_STATION_CODES } from '@components/weather/services/khoaApi'; import { getUltraShortForecast, getMarineForecast, convertToGridCoords, getCurrentBaseDateTime, MARINE_REGIONS, - type WeatherForecastData, - type MarineWeatherData, -} from '@tabs/weather/services/weatherApi'; +} from '@components/weather/services/weatherApi'; +import type { + RecentObservation, + WeatherForecastData, + MarineWeatherData, +} from '@interfaces/weather/WeatherInterface'; const KEY_TO_NAME: Record = { incheon: '인천', @@ -85,30 +84,30 @@ function StatusBadge({ if (loading) { return ( - + 조회 중... ); } if (errorCount === total) { return ( - - + + 연계 오류 ); } if (errorCount > 0) { return ( - - + + 일부 오류 ({errorCount}/{total}) ); } return ( - - + + 정상 ); @@ -136,7 +135,7 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) { {headers.map((h) => (
@@ -146,7 +145,7 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) { {loading && rows.length === 0 ? Array.from({ length: 5 }).map((_, i) => ( - + {headers.map((_, j) => ( @@ -217,7 +216,7 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea {loading && rows.length === 0 ? Array.from({ length: 3 }).map((_, i) => ( - + {headers.map((_, j) => ( @@ -277,7 +276,7 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean }) {loading && rows.length === 0 ? Array.from({ length: 4 }).map((_, i) => ( - + {headers.map((_, j) => ( )) : rows.map((row) => ( - + @@ -294,9 +293,9 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean }) @@ -392,7 +392,7 @@ function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boo {loading && rows.length === 0 ? Array.from({ length: 8 }).map((_, i) => ( - + {HEADERS.map((_, j) => ( @@ -383,7 +383,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean {loading && rows.length === 0 ? Array.from({ length: 8 }).map((_, i) => ( - + {LOG_HEADERS.map((_, j) => ( )) : rows.map((row) => ( - + @@ -507,7 +507,7 @@ export default function RndHnsAtmosPanel() { return (
{/* ── 헤더 ── */} -
+

HNS 대기확산 (충북대) 연계 모니터링

@@ -544,21 +544,21 @@ export default function RndHnsAtmosPanel() {
{/* 요약 통계 바 */} -
+
- 정상 수신: {totalReceived}건 + 정상 수신: {totalReceived}건 | - 지연: {totalDelayed}건 + 지연: {totalDelayed}건 | - 실패: {totalFailed}건 + 실패: {totalFailed}건 | - 금일 예측 완료: 2 / 4회 + 금일 예측 완료: 2 / 4회
@@ -566,7 +566,7 @@ export default function RndHnsAtmosPanel() { {/* ── 스크롤 영역 ── */}
{/* 파이프라인 현황 */} -
+

데이터 파이프라인 현황

@@ -574,7 +574,7 @@ export default function RndHnsAtmosPanel() {
{/* 필터 바 + 수신 이력 테이블 */} -
+

데이터 수신 이력 @@ -584,7 +584,7 @@ export default function RndHnsAtmosPanel() { setFilterReceive(e.target.value as FilterReceive)} - className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors" + className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors" > @@ -607,7 +607,7 @@ export default function RndHnsAtmosPanel() {

@@ -383,7 +383,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean {loading && rows.length === 0 ? Array.from({ length: 8 }).map((_, i) => ( - + {LOG_HEADERS.map((_, j) => ( )) : rows.map((row) => ( - + @@ -507,7 +507,7 @@ export default function RndKospsPanel() { return (
{/* ── 헤더 ── */} -
+

유출유확산예측 (KOSPS) 연계 모니터링

@@ -544,21 +544,21 @@ export default function RndKospsPanel() {
{/* 요약 통계 바 */} -
+
- 정상 수신: {totalReceived}건 + 정상 수신: {totalReceived}건 | - 지연: {totalDelayed}건 + 지연: {totalDelayed}건 | - 실패: {totalFailed}건 + 실패: {totalFailed}건 | - 금일 예측 완료: 3 / 6회 + 금일 예측 완료: 3 / 6회
@@ -566,7 +566,7 @@ export default function RndKospsPanel() { {/* ── 스크롤 영역 ── */}
{/* 파이프라인 현황 */} -
+

데이터 파이프라인 현황

@@ -574,7 +574,7 @@ export default function RndKospsPanel() {
{/* 필터 바 + 수신 이력 테이블 */} -
+

데이터 수신 이력 @@ -584,7 +584,7 @@ export default function RndKospsPanel() { setFilterReceive(e.target.value as FilterReceive)} - className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors" + className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors" > @@ -607,7 +607,7 @@ export default function RndKospsPanel() {

@@ -410,7 +410,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean {loading && rows.length === 0 ? Array.from({ length: 8 }).map((_, i) => ( - + {LOG_HEADERS.map((_, j) => ( )) : rows.map((row) => ( - + @@ -534,7 +534,7 @@ export default function RndPoseidonPanel() { return (
{/* ── 헤더 ── */} -
+

유출유확산예측 (포세이돈) 연계 모니터링

@@ -571,21 +571,21 @@ export default function RndPoseidonPanel() {
{/* 요약 통계 바 */} -
+
- 정상 수신: {totalReceived}건 + 정상 수신: {totalReceived}건 | - 지연: {totalDelayed}건 + 지연: {totalDelayed}건 | - 실패: {totalFailed}건 + 실패: {totalFailed}건 | - 금일 예측 완료: 4 / 8회 + 금일 예측 완료: 4 / 8회
@@ -593,7 +593,7 @@ export default function RndPoseidonPanel() { {/* ── 스크롤 영역 ── */}
{/* 파이프라인 현황 */} -
+

데이터 파이프라인 현황

@@ -601,7 +601,7 @@ export default function RndPoseidonPanel() {
{/* 필터 바 + 수신 이력 테이블 */} -
+

데이터 수신 이력 @@ -611,7 +611,7 @@ export default function RndPoseidonPanel() { setFilterReceive(e.target.value as FilterReceive)} - className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors" + className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors" > @@ -634,7 +634,7 @@ export default function RndPoseidonPanel() {

@@ -383,7 +383,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean {loading && rows.length === 0 ? Array.from({ length: 8 }).map((_, i) => ( - + {LOG_HEADERS.map((_, j) => ( )) : rows.map((row) => ( - + @@ -507,7 +507,7 @@ export default function RndRescuePanel() { return (
{/* ── 헤더 ── */} -
+

긴급구난과제 연계 모니터링

@@ -544,21 +544,21 @@ export default function RndRescuePanel() {
{/* 요약 통계 바 */} -
+
- 정상 수신: {totalReceived}건 + 정상 수신: {totalReceived}건 | - 지연: {totalDelayed}건 + 지연: {totalDelayed}건 | - 실패: {totalFailed}건 + 실패: {totalFailed}건 | - 금일 분석 완료: 5 / 6회 + 금일 분석 완료: 5 / 6회
@@ -566,7 +566,7 @@ export default function RndRescuePanel() { {/* ── 스크롤 영역 ── */}
{/* 파이프라인 현황 */} -
+

데이터 파이프라인 현황

@@ -574,7 +574,7 @@ export default function RndRescuePanel() {
{/* 필터 바 + 수신 이력 테이블 */} -
+

데이터 수신 이력 @@ -584,7 +584,7 @@ export default function RndRescuePanel() { setFilterReceive(e.target.value as FilterReceive)} - className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors" + className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors" > @@ -607,7 +607,7 @@ export default function RndRescuePanel() { setAccount(e.target.value)} - placeholder="로그인 계정 ID" - className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono" - /> -

- - {/* 비밀번호 */} -
- - setPassword(e.target.value)} - placeholder="초기 비밀번호" - className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono" - /> -
- - {/* 사용자명 */} -
- - setName(e.target.value)} - placeholder="실명" - className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean" - /> -
- - {/* 직급 */} -
- - setRank(e.target.value)} - placeholder="예: 팀장, 주임 등" - className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean" - /> -
- - {/* 소속 */} -
- - -
- - {/* 이메일 */} -
- - setEmail(e.target.value)} - placeholder="이메일 주소" - className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono" - /> -
- - {/* 역할 */} -
- -
- {allRoles.length === 0 ? ( -

역할 없음

- ) : ( - allRoles.map((role, idx) => { - const color = getRoleColor(role.code, idx); - return ( - - ); - }) - )} -
-
- - {/* 에러 메시지 */} - {error &&

{error}

} -
- - {/* 푸터 */} -
- - -
- -
-
- ); -} - -// ─── 사용자 상세/수정 모달 ──────────────────────────────────── -interface UserDetailModalProps { - user: UserListItem; - allRoles: RoleWithPermissions[]; - allOrgs: OrgItem[]; - onClose: () => void; - onUpdated: () => void; -} - -function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalProps) { - const [name, setName] = useState(user.name); - const [rank, setRank] = useState(user.rank || ''); - const [orgSn, setOrgSn] = useState(user.orgSn ?? ''); - const [saving, setSaving] = useState(false); - const [newPassword, setNewPassword] = useState(''); - const [resetPwLoading, setResetPwLoading] = useState(false); - const [resetPwDone, setResetPwDone] = useState(false); - const [unlockLoading, setUnlockLoading] = useState(false); - const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null); - - const handleSaveInfo = async () => { - setSaving(true); - setMessage(null); - try { - await updateUserApi(user.id, { - name: name.trim(), - rank: rank.trim() || undefined, - orgSn: orgSn !== '' ? orgSn : null, - }); - setMessage({ text: '사용자 정보가 수정되었습니다.', type: 'success' }); - onUpdated(); - } catch { - setMessage({ text: '사용자 정보 수정에 실패했습니다.', type: 'error' }); - } finally { - setSaving(false); - } - }; - - const handleResetPassword = async () => { - if (!newPassword.trim()) { - setMessage({ text: '새 비밀번호를 입력하세요.', type: 'error' }); - return; - } - setResetPwLoading(true); - setMessage(null); - try { - await changePasswordApi(user.id, newPassword); - setMessage({ text: '비밀번호가 초기화되었습니다.', type: 'success' }); - setResetPwDone(true); - setNewPassword(''); - } catch { - setMessage({ text: '비밀번호 초기화에 실패했습니다.', type: 'error' }); - } finally { - setResetPwLoading(false); - } - }; - - const handleUnlock = async () => { - setUnlockLoading(true); - setMessage(null); - try { - await updateUserApi(user.id, { status: 'ACTIVE' }); - setMessage({ text: '계정 잠금이 해제되었습니다.', type: 'success' }); - onUpdated(); - } catch { - setMessage({ text: '잠금 해제에 실패했습니다.', type: 'error' }); - } finally { - setUnlockLoading(false); - } - }; - - return ( -
-
- {/* 헤더 */} -
-
-

사용자 정보

-

{user.account}

-
- -
- -
- {/* 기본 정보 수정 */} -
-

- 기본 정보 수정 -

-
-
- - setName(e.target.value)} - className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean" - /> -
-
-
- - setRank(e.target.value)} - placeholder="예: 팀장" - className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean" - /> -
-
- - -
-
- -
-
- - {/* 구분선 */} -
- - {/* 비밀번호 초기화 */} -
-

- 비밀번호 초기화 -

-
-
- - setNewPassword(e.target.value)} - placeholder="새 비밀번호 입력" - className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono" - /> -
- - -
-

- 초기화 후 사용자에게 새 비밀번호를 전달하세요. -

-
- - {/* 구분선 */} -
- - {/* 계정 잠금 해제 */} -
-

계정 상태

-
-
-
- - - {(statusLabels[user.status] || statusLabels.INACTIVE).label} - - {user.failCount > 0 && ( - - (로그인 실패 {user.failCount}회) - - )} -
- {user.status === 'LOCKED' && ( -

- 비밀번호 5회 이상 오류로 잠금 처리됨 -

- )} -
- {user.status === 'LOCKED' && ( - - )} -
-
- - {/* 기타 정보 (읽기 전용) */} -
-

기타 정보

-
-
- 이메일: - {user.email || '-'} -
-
- OAuth: - {user.oauthProvider || '-'} -
-
- 최종 로그인: - - {user.lastLogin ? formatDate(user.lastLogin) : '-'} - -
-
- 등록일: - {formatDate(user.regDtm)} -
-
-
- - {/* 메시지 */} - {message && ( -
- {message.text} -
- )} -
- - {/* 푸터 */} -
- -
-
-
- ); -} - -// ─── 사용자 관리 패널 ───────────────────────────────────────── function UsersPanel() { const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState(''); @@ -679,7 +164,7 @@ function UsersPanel() {
-

사용자 관리

+

사용자 관리

총 {filteredUsers.length}명

diff --git a/frontend/src/tabs/admin/components/VesselMaterialsPanel.tsx b/frontend/src/components/admin/components/VesselMaterialsPanel.tsx similarity index 94% rename from frontend/src/tabs/admin/components/VesselMaterialsPanel.tsx rename to frontend/src/components/admin/components/VesselMaterialsPanel.tsx index 1efd57b..8b85731 100644 --- a/frontend/src/tabs/admin/components/VesselMaterialsPanel.tsx +++ b/frontend/src/components/admin/components/VesselMaterialsPanel.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo } from 'react'; -import { fetchOrganizations } from '@tabs/assets/services/assetsApi'; -import type { AssetOrgCompat } from '@tabs/assets/services/assetsApi'; -import { typeTagCls } from '@tabs/assets/components/assetTypes'; +import { fetchOrganizations } from '@components/assets/services/assetsApi'; +import type { AssetOrgCompat } from '@interfaces/assets/AssetsInterface'; +import { typeTagCls } from '@components/assets/components/assetTypes'; const PAGE_SIZE = 20; @@ -89,7 +89,7 @@ function VesselMaterialsPanel() { {/* 헤더 */}
-

방제선 보유자재 현황

+

방제선 보유자재 현황

총 {filtered.length}개 기관 (방제선 보유)

@@ -327,16 +327,11 @@ function VesselMaterialsPanel() { diff --git a/frontend/src/tabs/admin/components/VesselSignalPanel.tsx b/frontend/src/components/admin/components/VesselSignalPanel.tsx similarity index 98% rename from frontend/src/tabs/admin/components/VesselSignalPanel.tsx rename to frontend/src/components/admin/components/VesselSignalPanel.tsx index 425532a..36ab62e 100644 --- a/frontend/src/tabs/admin/components/VesselSignalPanel.tsx +++ b/frontend/src/components/admin/components/VesselSignalPanel.tsx @@ -133,18 +133,18 @@ export default function VesselSignalPanel() { return (
{/* 헤더 */} -
+

선박신호 수신 현황

setDate(e.target.value)} - className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-fg" + className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-fg" /> diff --git a/frontend/src/tabs/admin/components/adminConstants.ts b/frontend/src/components/admin/components/adminConstants.ts similarity index 100% rename from frontend/src/tabs/admin/components/adminConstants.ts rename to frontend/src/components/admin/components/adminConstants.ts diff --git a/frontend/src/tabs/admin/components/adminMenuConfig.ts b/frontend/src/components/admin/components/adminMenuConfig.ts similarity index 100% rename from frontend/src/tabs/admin/components/adminMenuConfig.ts rename to frontend/src/components/admin/components/adminMenuConfig.ts diff --git a/frontend/src/components/admin/components/contents/AuditLogModal.tsx b/frontend/src/components/admin/components/contents/AuditLogModal.tsx new file mode 100644 index 0000000..b986b36 --- /dev/null +++ b/frontend/src/components/admin/components/contents/AuditLogModal.tsx @@ -0,0 +1,212 @@ +import { useState } from 'react'; +import type { AuditLogEntry, DeidentifyTask } from '../DeidentifyPanel'; +import { MOCK_AUDIT_LOGS } from '../DeidentifyPanel'; + +function getAuditResultClass(type: AuditLogEntry['resultType']): string { + switch (type) { + case '성공': + return 'text-emerald-400 bg-emerald-500/10'; + case '진행중': + return 'text-cyan-400 bg-cyan-500/10'; + case '실패': + return 'text-red-400 bg-red-500/10'; + case '거부': + return 'text-yellow-400 bg-yellow-500/10'; + } +} + +interface AuditLogModalProps { + task: DeidentifyTask; + onClose: () => void; +} + +export function AuditLogModal({ task, onClose }: AuditLogModalProps) { + const logs = MOCK_AUDIT_LOGS[task.id] ?? []; + const [selectedLog, setSelectedLog] = useState(null); + const [filterOperator, setFilterOperator] = useState('모두'); + const [startDate, setStartDate] = useState('2026-04-01'); + const [endDate, setEndDate] = useState('2026-04-11'); + + const operators = ['모두', ...Array.from(new Set(logs.map((l) => l.operator)))]; + const filteredLogs = logs.filter((l) => { + if (filterOperator !== '모두' && l.operator !== filterOperator) return false; + return true; + }); + + return ( +
+
+ {/* 헤더 */} +
+

감시 감독 (감사로그) — {task.name}

+ +
+ + {/* 필터 바 */} +
+ 기간: + setStartDate(e.target.value)} + className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent" + /> + ~ + setEndDate(e.target.value)} + className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent" + /> + 작업자: + +
+ + {/* 로그 테이블 */} +
+
1 && ( -
+
- {post.pinnedYn === 'Y' && [고정]} + {post.pinnedYn === 'Y' && [고정]} {post.title} {post.authorName} {h}
@@ -240,7 +240,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean }) return (
{idx + 1} @@ -258,7 +258,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean }) @@ -316,7 +316,7 @@ export default function CollectHrPanel() { return (
{/* 헤더 */} -
+

인사정보 수집 현황

{lastUpdate && ( @@ -353,9 +353,9 @@ export default function CollectHrPanel() {
{/* 상태 표시줄 */} -
- - +
+ + 수집 완료 {completedCount}건 diff --git a/frontend/src/components/admin/components/DeidentifyPanel.tsx b/frontend/src/components/admin/components/DeidentifyPanel.tsx new file mode 100644 index 0000000..935f683 --- /dev/null +++ b/frontend/src/components/admin/components/DeidentifyPanel.tsx @@ -0,0 +1,567 @@ +import { useState, useEffect, useCallback } from 'react'; +import { TaskTable } from './contents/TaskTable'; +import { AuditLogModal } from './contents/AuditLogModal'; +import { WizardModal } from './contents/WizardModal'; +/* eslint-disable react-refresh/only-export-components */ + +// ─── 타입 ────────────────────────────────────────────────── + +export type TaskStatus = '완료' | '진행중' | '대기' | '오류'; + +export interface AuditLogEntry { + id: string; + time: string; + operator: string; + operatorId: string; + action: string; + targetData: string; + result: string; + resultType: '성공' | '실패' | '거부' | '진행중'; + ip: string; + browser: string; + detail: { + dataCount: number; + rulesApplied: string; + processedCount: number; + errorCount: number; + }; +} + +export interface DeidentifyTask { + id: string; + name: string; + target: string; + status: TaskStatus; + startTime: string; + progress: number; + createdBy: string; +} + +export type SourceType = 'db' | 'file' | 'api'; +export type ProcessMode = 'immediate' | 'scheduled' | 'oneshot'; +export type RepeatType = 'daily' | 'weekly' | 'monthly'; +export type DeidentifyTechnique = '마스킹' | '삭제' | '범주화' | '암호화' | '샘플링' | '가명처리' | '유지'; + +export interface FieldConfig { + name: string; + dataType: string; + technique: DeidentifyTechnique; + configValue: string; + selected: boolean; +} + +export interface DbConfig { + host: string; + port: string; + database: string; + tableName: string; +} + +export interface ApiConfig { + url: string; + method: 'GET' | 'POST'; +} + +export interface ScheduleConfig { + hour: string; + repeatType: RepeatType; + weekday: string; + startDate: string; + notifyOnComplete: boolean; + notifyOnError: boolean; +} + +export interface OneshotConfig { + date: string; + hour: string; +} + +export interface WizardState { + step: number; + taskName: string; + sourceType: SourceType; + dbConfig: DbConfig; + apiConfig: ApiConfig; + fields: FieldConfig[]; + processMode: ProcessMode; + scheduleConfig: ScheduleConfig; + oneshotConfig: OneshotConfig; + saveAsTemplate: boolean; + applyTemplate: string; + confirmed: boolean; +} + +// ─── Mock 데이터 ──────────────────────────────────────────── + +export const MOCK_TASKS: DeidentifyTask[] = [ + { + id: '001', + name: 'customer_2024', + target: '선박/운항 - 선장·선원 성명', + status: '완료', + startTime: '2026-04-10 14:30', + progress: 100, + createdBy: '관리자', + }, + { + id: '002', + name: 'transaction_04', + target: '사고 현장 - 현장사진, 영상내 인물', + status: '진행중', + startTime: '2026-04-10 14:15', + progress: 82, + createdBy: '김담당', + }, + { + id: '003', + name: 'employee_info', + target: '인사정보 - 계정, 로그인 정보', + status: '대기', + startTime: '2026-04-10 22:00', + progress: 0, + createdBy: '이담당', + }, + { + id: '004', + name: 'vendor_data', + target: 'HNS 대응 - 화학물질 취급자, 방제업체 연락처', + status: '오류', + startTime: '2026-04-09 13:45', + progress: 45, + createdBy: '관리자', + }, + { + id: '005', + name: 'partner_contacts', + target: '시스템 운영 - 관리자, 운영자 접속로그', + status: '완료', + startTime: '2026-04-08 09:00', + progress: 100, + createdBy: '박담당', + }, +]; + +export const DEFAULT_FIELDS: FieldConfig[] = [ + { name: '고객ID', dataType: '문자열', technique: '삭제', configValue: '-', selected: true }, + { + name: '이름', + dataType: '문자열', + technique: '마스킹', + configValue: '*로 치환', + selected: true, + }, + { + name: '휴대폰', + dataType: '문자열', + technique: '마스킹', + configValue: '010-****-****', + selected: true, + }, + { + name: '주소', + dataType: '문자열', + technique: '범주화', + configValue: '시/도만 표시', + selected: true, + }, + { + name: '이메일', + dataType: '문자열', + technique: '가명처리', + configValue: '키: random_001', + selected: true, + }, + { + name: '생년월일', + dataType: '날짜', + technique: '범주화', + configValue: '연도만 표시', + selected: true, + }, + { name: '회사', dataType: '문자열', technique: '유지', configValue: '변경 없음', selected: true }, +]; + +export const TECHNIQUES: DeidentifyTechnique[] = [ + '마스킹', + '삭제', + '범주화', + '암호화', + '샘플링', + '가명처리', + '유지', +]; + +export const HOURS = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`); + +export const WEEKDAYS = ['월', '화', '수', '목', '금', '토', '일']; + +export const TEMPLATES = ['기본 개인정보', '금융데이터', '의료데이터']; + +export const MOCK_AUDIT_LOGS: Record = { + '001': [ + { + id: 'LOG_20260410_001', + time: '2026-04-10 14:30:45', + operator: '김철수', + operatorId: 'user_12345', + action: '처리완료', + targetData: 'customer_2024', + result: '성공 (100%)', + resultType: '성공', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { + dataCount: 15240, + rulesApplied: '마스킹 3, 범주화 2, 삭제 2', + processedCount: 15240, + errorCount: 0, + }, + }, + { + id: 'LOG_20260410_002', + time: '2026-04-10 14:15:10', + operator: '김철수', + operatorId: 'user_12345', + action: '처리시작', + targetData: 'customer_2024', + result: '성공', + resultType: '성공', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { + dataCount: 15240, + rulesApplied: '마스킹 3, 범주화 2, 삭제 2', + processedCount: 0, + errorCount: 0, + }, + }, + { + id: 'LOG_20260410_003', + time: '2026-04-10 14:10:30', + operator: '김철수', + operatorId: 'user_12345', + action: '규칙설정', + targetData: 'customer_2024', + result: '성공', + resultType: '성공', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { dataCount: 15240, rulesApplied: '7개 규칙 적용', processedCount: 0, errorCount: 0 }, + }, + ], + '002': [ + { + id: 'LOG_20260410_004', + time: '2026-04-10 14:15:22', + operator: '이영희', + operatorId: 'user_23456', + action: '처리시작', + targetData: 'transaction_04', + result: '진행중 (82%)', + resultType: '진행중', + ip: '192.168.1.101', + browser: 'Firefox 124.0', + detail: { + dataCount: 8920, + rulesApplied: '마스킹 2, 암호화 1, 삭제 3', + processedCount: 7314, + errorCount: 0, + }, + }, + ], + '003': [ + { + id: 'LOG_20260410_005', + time: '2026-04-10 13:45:30', + operator: '박민준', + operatorId: 'user_34567', + action: '규칙수정', + targetData: 'employee_info', + result: '성공', + resultType: '성공', + ip: '192.168.1.102', + browser: 'Chrome 123.0', + detail: { + dataCount: 3200, + rulesApplied: '마스킹 4, 가명처리 1', + processedCount: 0, + errorCount: 0, + }, + }, + ], + '004': [ + { + id: 'LOG_20260409_001', + time: '2026-04-09 13:45:30', + operator: '관리자', + operatorId: 'user_admin', + action: '처리오류', + targetData: 'vendor_data', + result: '오류 (45%)', + resultType: '실패', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { + dataCount: 5100, + rulesApplied: '마스킹 2, 범주화 1, 삭제 1', + processedCount: 2295, + errorCount: 12, + }, + }, + { + id: 'LOG_20260409_002', + time: '2026-04-09 13:40:15', + operator: '김철수', + operatorId: 'user_12345', + action: '규칙조회', + targetData: 'vendor_data', + result: '성공', + resultType: '성공', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { dataCount: 5100, rulesApplied: '4개 규칙', processedCount: 0, errorCount: 0 }, + }, + { + id: 'LOG_20260409_003', + time: '2026-04-09 09:25:00', + operator: '이영희', + operatorId: 'user_23456', + action: '삭제시도', + targetData: 'vendor_data', + result: '거부 (권한부족)', + resultType: '거부', + ip: '192.168.1.101', + browser: 'Firefox 124.0', + detail: { dataCount: 5100, rulesApplied: '-', processedCount: 0, errorCount: 0 }, + }, + ], + '005': [ + { + id: 'LOG_20260408_001', + time: '2026-04-08 09:15:00', + operator: '박담당', + operatorId: 'user_45678', + action: '처리완료', + targetData: 'partner_contacts', + result: '성공 (100%)', + resultType: '성공', + ip: '192.168.1.103', + browser: 'Edge 122.0', + detail: { + dataCount: 1850, + rulesApplied: '마스킹 2, 유지 3', + processedCount: 1850, + errorCount: 0, + }, + }, + ], +}; + +function fetchTasks(): Promise { + return new Promise((resolve) => { + setTimeout(() => resolve(MOCK_TASKS), 300); + }); +} + +// ─── 상태 뱃지 ───────────────────────────────────────────── + +export function getStatusBadgeClass(status: TaskStatus): string { + switch (status) { + case '완료': + return 'text-color-success bg-[rgba(34,197,94,0.1)]'; + case '진행중': + return 'text-color-accent bg-[rgba(6,182,212,0.1)]'; + case '대기': + return 'text-color-caution bg-[rgba(234,179,8,0.1)]'; + case '오류': + return 'text-color-danger bg-[rgba(239,68,68,0.1)]'; + } +} + +// ─── 진행률 바 ───────────────────────────────────────────── + +export const TABLE_HEADERS = ['작업ID', '작업명', '대상', '상태', '시작시간', '진행률', '등록자', '액션']; + +export const STEP_LABELS = ['소스선택', '데이터검증', '비식별화규칙', '처리방식', '최종확인']; + +export const INITIAL_WIZARD: WizardState = { + step: 1, + taskName: '', + sourceType: 'db', + dbConfig: { host: '', port: '5432', database: '', tableName: '' }, + apiConfig: { url: '', method: 'GET' }, + fields: DEFAULT_FIELDS, + processMode: 'immediate', + scheduleConfig: { + hour: '02:00', + repeatType: 'daily', + weekday: '월', + startDate: '', + notifyOnComplete: true, + notifyOnError: true, + }, + oneshotConfig: { date: '', hour: '02:00' }, + saveAsTemplate: false, + applyTemplate: '', + confirmed: false, +}; + +// ─── 메인 패널 ────────────────────────────────────────────── + +type FilterStatus = '모두' | TaskStatus; + +export default function DeidentifyPanel() { + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(false); + const [showWizard, setShowWizard] = useState(false); + const [auditTask, setAuditTask] = useState(null); + const [searchName, setSearchName] = useState(''); + const [filterStatus, setFilterStatus] = useState('모두'); + const [filterPeriod, setFilterPeriod] = useState<'7' | '30' | '90'>('30'); + + const loadTasks = useCallback(async () => { + setLoading(true); + const data = await fetchTasks(); + setTasks(data); + setLoading(false); + }, []); + + useEffect(() => { + let isMounted = true; + if (tasks.length === 0) { + void Promise.resolve().then(() => { + if (isMounted) void loadTasks(); + }); + } + return () => { + isMounted = false; + }; + }, [tasks.length, loadTasks]); + + const handleAction = useCallback((action: string, task: DeidentifyTask) => { + // TODO: 실제 API 연동 시 각 액션에 맞는 API 호출로 교체 + if (action === 'delete') { + setTasks((prev) => prev.filter((t) => t.id !== task.id)); + } else if (action === 'audit') { + setAuditTask(task); + } + }, []); + + const handleWizardSubmit = useCallback( + (wizard: WizardState) => { + const selectedFields = wizard.fields.filter((f) => f.selected).map((f) => f.name); + const newTask: DeidentifyTask = { + id: String(tasks.length + 1).padStart(3, '0'), + name: wizard.taskName, + target: selectedFields.join(', ') || '-', + status: wizard.processMode === 'immediate' ? '진행중' : '대기', + startTime: new Date() + .toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) + .replace(/\. /g, '-') + .replace('.', ''), + progress: 0, + createdBy: '관리자', + }; + setTasks((prev) => [newTask, ...prev]); + }, + [tasks.length], + ); + + const filteredTasks = tasks.filter((t) => { + if (searchName && !t.name.includes(searchName)) return false; + if (filterStatus !== '모두' && t.status !== filterStatus) return false; + return true; + }); + + const completedCount = tasks.filter((t) => t.status === '완료').length; + const inProgressCount = tasks.filter((t) => t.status === '진행중').length; + const errorCount = tasks.filter((t) => t.status === '오류').length; + + return ( +
+ {/* 헤더 */} +
+

비식별화조치

+ +
+ + {/* 상태 요약 */} +
+ + + 완료 {completedCount}건 + + + + 진행중 {inProgressCount}건 + + {errorCount > 0 && ( + + + 오류 {errorCount}건 + + )} + 전체 {tasks.length}건 +
+ + {/* 검색/필터 */} +
+ setSearchName(e.target.value)} + placeholder="작업명 검색" + className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 placeholder:text-t3 focus:outline-none focus:border-color-accent w-40" + /> + + +
+ + {/* 테이블 */} +
+ +
+ + {/* 감사로그 모달 */} + {auditTask && setAuditTask(null)} />} + + {/* 마법사 모달 */} + {showWizard && ( + setShowWizard(false)} onSubmit={handleWizardSubmit} /> + )} +
+ ); +} diff --git a/frontend/src/tabs/admin/components/DispersingZonePanel.tsx b/frontend/src/components/admin/components/DispersingZonePanel.tsx similarity index 96% rename from frontend/src/tabs/admin/components/DispersingZonePanel.tsx rename to frontend/src/components/admin/components/DispersingZonePanel.tsx index 884bd7b..14f7549 100644 --- a/frontend/src/tabs/admin/components/DispersingZonePanel.tsx +++ b/frontend/src/components/admin/components/DispersingZonePanel.tsx @@ -5,7 +5,7 @@ import { GeoJsonLayer } from '@deck.gl/layers'; import type { Layer } from '@deck.gl/core'; import 'maplibre-gl/dist/maplibre-gl.css'; import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; -import { S57EncOverlay } from '@common/components/map/S57EncOverlay'; +import { S57EncOverlay } from '@components/common/map/S57EncOverlay'; import { useMapStore } from '@common/store/mapStore'; const MAP_CENTER: [number, number] = [127.5, 36.0]; @@ -119,7 +119,7 @@ const DispersingZonePanel = () => { const isConsider = zone === 'consider'; const showLayer = isConsider ? showConsider : showRestrict; const setShowLayer = isConsider ? setShowConsider : setShowRestrict; - const swatchColor = isConsider ? 'bg-blue-500' : 'bg-red-500'; + const swatchColor = isConsider ? 'bg-color-info' : 'bg-color-danger'; const isExpanded = expandedZone === zone; return ( @@ -197,11 +197,11 @@ const DispersingZonePanel = () => { {/* 범례 */}
- + 사용고려해역
- + 사용제한해역
diff --git a/frontend/src/tabs/admin/components/LayerPanel.tsx b/frontend/src/components/admin/components/LayerPanel.tsx similarity index 97% rename from frontend/src/tabs/admin/components/LayerPanel.tsx rename to frontend/src/components/admin/components/LayerPanel.tsx index 533d81f..5ceef78 100644 --- a/frontend/src/tabs/admin/components/LayerPanel.tsx +++ b/frontend/src/components/admin/components/LayerPanel.tsx @@ -229,7 +229,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP {/* 레이어코드 */}
-

{formError}

+

{formError}

)} {/* 버튼 */} @@ -448,7 +448,7 @@ const LayerPanel = () => {
-

레이어 관리

+

레이어 관리

총 {total}개

diff --git a/frontend/src/tabs/admin/components/MapBasePanel.tsx b/frontend/src/components/admin/components/MapBasePanel.tsx similarity index 95% rename from frontend/src/tabs/admin/components/MapBasePanel.tsx rename to frontend/src/components/admin/components/MapBasePanel.tsx index 539f26f..45e463b 100644 --- a/frontend/src/tabs/admin/components/MapBasePanel.tsx +++ b/frontend/src/components/admin/components/MapBasePanel.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { api } from '@common/services/api'; import { useMapStore } from '@common/store/mapStore'; +/* eslint-disable react-refresh/only-export-components */ // ─── 타입 ───────────────────────────────────────────────── interface MapBaseItem { @@ -101,7 +102,7 @@ function MapBaseModal({ {/* 지도 이름 */}
setField('useYn', form.useYn === 'Y' ? 'N' : 'Y')} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ - form.useYn === 'Y' ? 'bg-color-accent' : 'bg-border' + form.useYn === 'Y' ? 'bg-color-accent' : 'bg-bg-elevated' }`} > {/* 에러 */} - {modalError &&

{modalError}

} + {modalError &&

{modalError}

}
{/* 모달 푸터 */} @@ -349,7 +350,7 @@ function MapBasePanel() { {/* 헤더 */}
-

지도 관리

+

지도 관리

총 {total}건

@@ -478,7 +479,7 @@ function MapBasePanel() { onClick={() => setPage(p)} className={`w-7 h-7 text-caption rounded ${ p === page - ? 'bg-blue-500/20 text-blue-400 font-medium' + ? 'bg-[rgba(6,182,212,0.15)] text-color-accent font-medium' : 'text-fg-disabled hover:bg-bg-elevated' }`} > diff --git a/frontend/src/tabs/admin/components/MenusPanel.tsx b/frontend/src/components/admin/components/MenusPanel.tsx similarity index 98% rename from frontend/src/tabs/admin/components/MenusPanel.tsx rename to frontend/src/components/admin/components/MenusPanel.tsx index d9e603b..14e6bf3 100644 --- a/frontend/src/tabs/admin/components/MenusPanel.tsx +++ b/frontend/src/components/admin/components/MenusPanel.tsx @@ -135,7 +135,7 @@ function MenusPanel() {
-

메뉴 관리

+

메뉴 관리

메뉴 표시 여부, 순서, 라벨, 아이콘을 관리합니다

diff --git a/frontend/src/tabs/admin/components/MonitorForecastPanel.tsx b/frontend/src/components/admin/components/MonitorForecastPanel.tsx similarity index 87% rename from frontend/src/tabs/admin/components/MonitorForecastPanel.tsx rename to frontend/src/components/admin/components/MonitorForecastPanel.tsx index 79388d8..fde3f59 100644 --- a/frontend/src/tabs/admin/components/MonitorForecastPanel.tsx +++ b/frontend/src/components/admin/components/MonitorForecastPanel.tsx @@ -45,19 +45,19 @@ function formatTime(iso: string | null): string { function StatusCell({ row }: { row: NumericalDataStatus }) { if (row.lastStatus === 'COMPLETED') { - return 정상; + return 정상; } if (row.lastStatus === 'FAILED') { return ( - + 오류{row.consecutiveFailures > 0 ? ` (${row.consecutiveFailures}회 연속)` : ''} ); } if (row.lastStatus === 'STARTED') { return ( - - + + 실행 중 ); @@ -77,30 +77,30 @@ function StatusBadge({ if (loading) { return ( - + 조회 중... ); } if (errorCount === total && total > 0) { return ( - - + + 연계 오류 ); } if (errorCount > 0) { return ( - - + + 일부 오류 ({errorCount}/{total}) ); } return ( - - + + 정상 ); @@ -124,7 +124,7 @@ function ForecastTable({ rows, loading }: { rows: NumericalDataStatus[]; loading {TABLE_HEADERS.map((h) => (
{h}
@@ -143,7 +143,7 @@ function ForecastTable({ rows, loading }: { rows: NumericalDataStatus[]; loading
{row.modelName} {h}
@@ -157,7 +156,7 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) { : rows.map((row) => (
{row.stationName} @@ -172,9 +171,9 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) { {fmt(row.data?.tide_level, 0)} {row.error ? ( - 오류 + 오류 ) : row.data ? ( - 정상 + 정상 ) : ( - )} @@ -207,7 +206,7 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea {headers.map((h) => ( {h}
@@ -228,7 +227,7 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea : rows.map((row) => (
{row.stationName} @@ -241,9 +240,9 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea {fmt(row.data?.humidity, 0)} {row.error ? ( - 오류 + 오류 ) : row.data ? ( - 정상 + 정상 ) : ( - )} @@ -267,7 +266,7 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean }) {headers.map((h) => ( {h}
@@ -286,7 +285,7 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean })
{row.name} {fmt(row.data?.waveHeight)} {fmt(row.data?.windSpeed)}{fmt(row.data?.temperature)} {row.error ? ( - 오류 + 오류 ) : row.data ? ( - 정상 + 정상 ) : ( - )} @@ -440,7 +439,7 @@ export default function MonitorRealtimePanel() { return (
{/* 헤더 */} -
+

실시간 관측자료 모니터링

{lastUpdate && ( @@ -477,14 +476,14 @@ export default function MonitorRealtimePanel() {
{/* 탭 */} -
+
{TABS.map((tab) => (
{/* 상태 표시줄 */} -
+
{activeTab === 'khoa' && `관측소 ${totalCount}개`} diff --git a/frontend/src/tabs/admin/components/MonitorVesselPanel.tsx b/frontend/src/components/admin/components/MonitorVesselPanel.tsx similarity index 94% rename from frontend/src/tabs/admin/components/MonitorVesselPanel.tsx rename to frontend/src/components/admin/components/MonitorVesselPanel.tsx index aecfd69..abea8d4 100644 --- a/frontend/src/tabs/admin/components/MonitorVesselPanel.tsx +++ b/frontend/src/components/admin/components/MonitorVesselPanel.tsx @@ -301,7 +301,7 @@ function StatusBadge({ if (loading) { return ( - + 조회 중... ); @@ -309,23 +309,23 @@ function StatusBadge({ const offCount = total - onCount; if (offCount === total) { return ( - - + + 전체 OFF ); } if (offCount > 0) { return ( - - + + 일부 OFF ({offCount}/{total}) ); } return ( - - + + 전체 정상 ); @@ -342,7 +342,7 @@ function ConnectionBadge({ if (isNormal) { return (
- + ON {lastMessageTime && {lastMessageTime}} @@ -351,7 +351,7 @@ function ConnectionBadge({ } return (
- + OFF {lastMessageTime && {lastMessageTime}} @@ -382,7 +382,7 @@ function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boo {HEADERS.map((h) => (
{h}
@@ -403,7 +403,7 @@ function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boo : rows.map((row, idx) => (
{idx + 1} @@ -461,7 +461,7 @@ export default function MonitorVesselPanel() { return (
{/* 헤더 */} -
+

선박위치정보 모니터링

{lastUpdate && ( @@ -498,7 +498,7 @@ export default function MonitorVesselPanel() {
{/* 상태 표시줄 */} -
+
연계 채널 {rows.length}개 (ON: {onCount} / OFF: {rows.length - onCount}) diff --git a/frontend/src/components/admin/components/PermissionsPanel.tsx b/frontend/src/components/admin/components/PermissionsPanel.tsx new file mode 100644 index 0000000..aa1d016 --- /dev/null +++ b/frontend/src/components/admin/components/PermissionsPanel.tsx @@ -0,0 +1,417 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + fetchRoles, + fetchPermTree, + type RoleWithPermissions, + type PermTreeNode, +} from '@common/services/authApi'; +import { RolePermTab } from './contents/RolePermTab'; +import { UserPermTab } from './contents/UserPermTab'; +/* eslint-disable react-refresh/only-export-components */ + +// ─── 오퍼레이션 코드 ───────────────────────────────── +export const OPER_CODES = ['READ', 'CREATE', 'UPDATE', 'DELETE'] as const; +export type OperCode = (typeof OPER_CODES)[number]; +export const OPER_LABELS: Record = { READ: 'R', CREATE: 'C', UPDATE: 'U', DELETE: 'D' }; +export const OPER_FULL_LABELS: Record = { + READ: '조회', + CREATE: '생성', + UPDATE: '수정', + DELETE: '삭제', +}; + +// ─── 권한 상태 타입 ───────────────────────────────────── +export type PermState = 'explicit-granted' | 'inherited-granted' | 'explicit-denied' | 'forced-denied'; + +// ─── 키 유틸 ────────────────────────────────────────── +export function makeKey(rsrc: string, oper: string): string { + return `${rsrc}::${oper}`; +} + +// ─── 유틸: 플랫 노드 목록 추출 (트리 DFS) ───────────── +export function flattenTree(nodes: PermTreeNode[]): PermTreeNode[] { + const result: PermTreeNode[] = []; + function walk(list: PermTreeNode[]) { + for (const n of list) { + result.push(n); + if (n.children.length > 0) walk(n.children); + } + } + walk(nodes); + return result; +} + +// ─── 유틸: 권한 상태 계산 (오퍼레이션별) ────────────── +function resolvePermStateForOper( + code: string, + parentCode: string | null, + operCd: string, + explicitPerms: Map, + cache: Map, +): PermState { + const key = makeKey(code, operCd); + const cached = cache.get(key); + if (cached) return cached; + + const explicit = explicitPerms.get(key); + + if (parentCode === null) { + const state: PermState = + explicit === true + ? 'explicit-granted' + : explicit === false + ? 'explicit-denied' + : 'explicit-denied'; + cache.set(key, state); + return state; + } + + // 부모 READ 확인 (접근 게이트) + const parentReadKey = makeKey(parentCode, 'READ'); + const parentReadState = cache.get(parentReadKey); + if (parentReadState === 'explicit-denied' || parentReadState === 'forced-denied') { + cache.set(key, 'forced-denied'); + return 'forced-denied'; + } + + if (explicit === true) { + cache.set(key, 'explicit-granted'); + return 'explicit-granted'; + } + if (explicit === false) { + cache.set(key, 'explicit-denied'); + return 'explicit-denied'; + } + + // 부모의 같은 오퍼레이션 상속 + const parentOperKey = makeKey(parentCode, operCd); + const parentOperState = cache.get(parentOperKey); + if (parentOperState === 'explicit-granted' || parentOperState === 'inherited-granted') { + cache.set(key, 'inherited-granted'); + return 'inherited-granted'; + } + if (parentOperState === 'forced-denied') { + cache.set(key, 'forced-denied'); + return 'forced-denied'; + } + + cache.set(key, 'explicit-denied'); + return 'explicit-denied'; +} + +export function buildEffectiveStates( + flatNodes: PermTreeNode[], + explicitPerms: Map, +): Map { + const cache = new Map(); + for (const node of flatNodes) { + // READ 먼저 (CUD는 READ에 의존) + resolvePermStateForOper(node.code, node.parentCode, 'READ', explicitPerms, cache); + for (const oper of OPER_CODES) { + if (oper === 'READ') continue; + resolvePermStateForOper(node.code, node.parentCode, oper, explicitPerms, cache); + } + } + return cache; +} + +type ActiveTab = 'role' | 'user'; + +function PermissionsPanel() { + const [activeTab, setActiveTab] = useState('role'); + const [roles, setRoles] = useState([]); + const [permTree, setPermTree] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [dirty, setDirty] = useState(false); + const [showCreateForm, setShowCreateForm] = useState(false); + const [newRoleCode, setNewRoleCode] = useState(''); + const [newRoleName, setNewRoleName] = useState(''); + const [newRoleDesc, setNewRoleDesc] = useState(''); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(''); + const [editingRoleSn, setEditingRoleSn] = useState(null); + const [editRoleName, setEditRoleName] = useState(''); + const [expanded, setExpanded] = useState>(new Set()); + const [selectedRoleSn, setSelectedRoleSn] = useState(null); + + // 역할별 명시적 권한: Map> + const [rolePerms, setRolePerms] = useState>>(new Map()); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const [rolesData, treeData] = await Promise.all([fetchRoles(), fetchPermTree()]); + setRoles(rolesData); + setPermTree(treeData); + + // 명시적 권한 맵 초기화 (rsrc::oper 키 형식) + const permsMap = new Map>(); + for (const role of rolesData) { + const roleMap = new Map(); + for (const p of role.permissions) { + roleMap.set(makeKey(p.resourceCode, p.operationCode), p.granted); + } + permsMap.set(role.sn, roleMap); + } + setRolePerms(permsMap); + + // 최상위 노드 기본 펼침 + setExpanded(new Set(treeData.map((n) => n.code))); + // 첫 번째 역할 선택 + if (rolesData.length > 0 && !selectedRoleSn) { + setSelectedRoleSn(rolesData[0].sn); + } + setDirty(false); + } catch (err) { + console.error('권한 데이터 조회 실패:', err); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- 초기 마운트 시 1회만 실행 + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + // 플랫 노드 목록 + const flatNodes = flattenTree(permTree); + + const handleToggleExpand = useCallback((code: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(code)) next.delete(code); + else next.add(code); + return next; + }); + }, []); + + const handleTogglePerm = useCallback( + (code: string, oper: OperCode, currentState: PermState) => { + if (!selectedRoleSn) return; + + setRolePerms((prev) => { + const next = new Map(prev); + const roleMap = new Map(next.get(selectedRoleSn) ?? new Map()); + + const key = makeKey(code, oper); + const node = flatNodes.find((n) => n.code === code); + const isRoot = node ? node.parentCode === null : false; + + switch (currentState) { + case 'explicit-granted': + roleMap.set(key, false); + break; + case 'inherited-granted': + roleMap.set(key, false); + break; + case 'explicit-denied': + if (isRoot) { + roleMap.set(key, true); + } else { + roleMap.delete(key); + } + break; + default: + return prev; + } + + next.set(selectedRoleSn, roleMap); + return next; + }); + setDirty(true); + }, + [selectedRoleSn, flatNodes], + ); + + const handleSave = async () => { + setSaving(true); + setSaveError(null); + try { + for (const role of roles) { + const perms = rolePerms.get(role.sn); + if (!perms) continue; + + const permsList: Array<{ resourceCode: string; operationCode: string; granted: boolean }> = + []; + for (const [key, granted] of perms) { + const sepIdx = key.indexOf('::'); + permsList.push({ + resourceCode: key.substring(0, sepIdx), + operationCode: key.substring(sepIdx + 2), + granted, + }); + } + await updatePermissionsApi(role.sn, permsList); + } + setDirty(false); + } catch (err) { + console.error('권한 저장 실패:', err); + setSaveError('권한 저장에 실패했습니다. 다시 시도해주세요.'); + } finally { + setSaving(false); + } + }; + + const handleCreateRole = async () => { + setCreating(true); + setCreateError(''); + try { + await createRoleApi({ + code: newRoleCode, + name: newRoleName, + description: newRoleDesc || undefined, + }); + await loadData(); + setShowCreateForm(false); + setNewRoleCode(''); + setNewRoleName(''); + setNewRoleDesc(''); + } catch (err) { + const message = err instanceof Error ? err.message : '역할 생성에 실패했습니다.'; + setCreateError(message); + } finally { + setCreating(false); + } + }; + + const handleDeleteRole = async (roleSn: number, roleName: string) => { + if ( + !window.confirm( + `"${roleName}" 역할을 삭제하시겠습니까?\n이 역할을 가진 모든 사용자에서 해당 역할이 제거됩니다.`, + ) + ) { + return; + } + try { + await deleteRoleApi(roleSn); + if (selectedRoleSn === roleSn) setSelectedRoleSn(null); + await loadData(); + } catch (err) { + console.error('역할 삭제 실패:', err); + } + }; + + const handleStartEditName = (role: RoleWithPermissions) => { + setEditingRoleSn(role.sn); + setEditRoleName(role.name); + }; + + const handleSaveRoleName = async (roleSn: number) => { + if (!editRoleName.trim()) return; + try { + await updateRoleApi(roleSn, { name: editRoleName.trim() }); + setRoles((prev) => + prev.map((r) => (r.sn === roleSn ? { ...r, name: editRoleName.trim() } : r)), + ); + setEditingRoleSn(null); + } catch (err) { + console.error('역할 이름 수정 실패:', err); + } + }; + + const toggleDefault = async (roleSn: number) => { + const role = roles.find((r) => r.sn === roleSn); + if (!role) return; + const newValue = !role.isDefault; + try { + await updateRoleDefaultApi(roleSn, newValue); + setRoles((prev) => prev.map((r) => (r.sn === roleSn ? { ...r, isDefault: newValue } : r))); + } catch (err) { + console.error('기본 역할 변경 실패:', err); + } + }; + + if (loading) { + return ( +
+ 불러오는 중... +
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+

권한 관리

+

+ 역할별 리소스 × CRUD 권한 설정 +

+
+ {/* 탭 전환 */} +
+ + +
+
+ + {activeTab === 'role' ? ( + + ) : ( + + )} +
+ ); +} + +export default PermissionsPanel; diff --git a/frontend/src/tabs/admin/components/RndHnsAtmosPanel.tsx b/frontend/src/components/admin/components/RndHnsAtmosPanel.tsx similarity index 89% rename from frontend/src/tabs/admin/components/RndHnsAtmosPanel.tsx rename to frontend/src/components/admin/components/RndHnsAtmosPanel.tsx index abbdf50..1e7d4e7 100644 --- a/frontend/src/tabs/admin/components/RndHnsAtmosPanel.tsx +++ b/frontend/src/components/admin/components/RndHnsAtmosPanel.tsx @@ -257,34 +257,34 @@ function fetchHnsAtmosData(): Promise { // ─── 유틸 ─────────────────────────────────────────────────────────────────────── function getPipelineStatusStyle(status: PipelineStatus): string { - if (status === '정상') return 'text-emerald-400 bg-emerald-500/10'; - if (status === '지연') return 'text-yellow-400 bg-yellow-500/10'; - return 'text-red-400 bg-red-500/10'; + if (status === '정상') return 'text-color-success bg-[rgba(34,197,94,0.08)]'; + if (status === '지연') return 'text-color-caution bg-[rgba(234,179,8,0.08)]'; + return 'text-color-danger bg-[rgba(239,68,68,0.08)]'; } function getPipelineBorderStyle(status: PipelineStatus): string { - if (status === '정상') return 'border-l-emerald-500'; - if (status === '지연') return 'border-l-yellow-500'; - return 'border-l-red-500'; + if (status === '정상') return 'border-l-color-success'; + if (status === '지연') return 'border-l-color-caution'; + return 'border-l-color-danger'; } function getReceiveStatusStyle(status: ReceiveStatus): string { - if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10'; - if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10'; - return 'text-red-400 bg-red-500/10'; + if (status === '수신완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]'; + if (status === '수신대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]'; + return 'text-color-danger bg-[rgba(239,68,68,0.08)]'; } function getProcessStatusStyle(status: ProcessStatus): string { - if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10'; - if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10'; - if (status === '대기') return 'text-yellow-400 bg-yellow-500/10'; - return 'text-red-400 bg-red-500/10'; + if (status === '처리완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]'; + if (status === '처리중') return 'text-color-accent bg-[rgba(6,182,212,0.08)]'; + if (status === '대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]'; + return 'text-color-danger bg-[rgba(239,68,68,0.08)]'; } function getAlertStyle(level: AlertLevel): string { - if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30'; - if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30'; - return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30'; + if (level === '경고') return 'text-color-danger bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)]'; + if (level === '주의') return 'text-color-caution bg-[rgba(234,179,8,0.08)] border-[rgba(234,179,8,0.3)]'; + return 'text-color-accent bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)]'; } // ─── 파이프라인 카드 ───────────────────────────────────────────────────────────── @@ -295,7 +295,7 @@ function PipelineCard({ node }: { node: PipelineNode }) { return (
{node.name}
(
{h}
@@ -392,7 +392,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
{row.timestamp} {row.source} {row.dataType} {h}
@@ -392,7 +392,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
{row.timestamp} {row.source} {row.dataType} {h}
@@ -419,7 +419,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
{row.timestamp} {row.source} {row.dataType} {h}
@@ -392,7 +392,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
{row.timestamp} {row.source} {row.dataType}
+ + + {['시간', '작업자', '작업', '대상 데이터', '결과', '상세'].map((h) => ( + + ))} + + + + {filteredLogs.length === 0 ? ( + + + + ) : ( + filteredLogs.map((log) => ( + setSelectedLog(log)} + > + + + + + + + + )) + )} + +
+ {h} +
+ 감사로그가 없습니다. +
+ {log.time.split(' ')[1]} + {log.operator}{log.action} + {log.targetData} + + + {log.result} + + + +
+
+ + {/* 로그 상세 정보 */} + {selectedLog && ( +
+

로그 상세 정보

+
+
+ 로그ID:{' '} + {selectedLog.id} +
+
+ 타임스탬프:{' '} + {selectedLog.time} +
+
+ 작업자:{' '} + + {selectedLog.operator} ({selectedLog.operatorId}) + +
+
+ 작업 유형:{' '} + {selectedLog.action} +
+
+ 대상:{' '} + + {selectedLog.targetData} ({selectedLog.detail.dataCount.toLocaleString()}건) + +
+
+ 적용 규칙:{' '} + {selectedLog.detail.rulesApplied} +
+
+ 결과:{' '} + + {selectedLog.result} (처리: {selectedLog.detail.processedCount.toLocaleString()}, + 오류: {selectedLog.detail.errorCount}) + +
+
+ IP 주소:{' '} + {selectedLog.ip} +
+
+ 브라우저:{' '} + {selectedLog.browser} +
+
+
+ )} + + {/* 하단 버튼 */} +
+ + + +
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/CommonFeaturesTab.tsx b/frontend/src/components/admin/components/contents/CommonFeaturesTab.tsx new file mode 100644 index 0000000..fa3f4c2 --- /dev/null +++ b/frontend/src/components/admin/components/contents/CommonFeaturesTab.tsx @@ -0,0 +1,765 @@ +interface CommonFeatureItem { + title: string; + description: string; + details: string[]; +} + +const COMMON_FEATURES: CommonFeatureItem[] = [ + { + title: '인증 시스템', + description: 'JWT 기반 세션 인증 + Google OAuth 소셜 로그인', + details: [ + 'HttpOnly 쿠키(WING_SESSION) 기반 토큰 관리 — XSS 방어', + 'Access Token(15분) + Refresh Token(7일) 이중 토큰 구조', + 'Google OAuth 2.0 소셜 로그인 지원', + 'Zustand authStore 기반 프론트엔드 인증 상태 통합 관리', + ], + }, + { + title: 'RBAC 2차원 권한', + description: 'AUTH_PERM 기반 기능별·역할별 2차원 권한 엔진', + details: [ + 'OPER_CD (R: 조회, C: 생성, U: 수정, D: 삭제) 4단계 조작 권한', + '역할(Role) × 기능(Feature) 매트릭스 기반 권한 매핑', + 'permResolver 엔진으로 백엔드·프론트엔드 동시 권한 검증', + '메뉴 접근, 버튼 노출, API 호출 3중 권한 통제', + ], + }, + { + title: 'API 통신 패턴', + description: 'Axios 기반 공통 API 클라이언트 + 자동 인증·에러 처리', + details: [ + 'GET/POST만 사용 (PUT/DELETE/PATCH 금지 — 보안취약점 점검 가이드 준수)', + '요청 인터셉터: 쿠키 자동 첨부 (withCredentials)', + '응답 인터셉터: 401 시 자동 토큰 갱신, 실패 시 로그아웃', + 'TanStack Query 기반 서버 상태 캐싱 및 자동 재검증', + ], + }, + { + title: '상태 관리', + description: 'Zustand(클라이언트) + TanStack Query(서버) 이중 상태 관리', + details: [ + 'Zustand: authStore(인증), menuStore(메뉴) 등 클라이언트 전역 상태', + 'TanStack Query: API 응답 캐싱, 자동 재요청, 낙관적 업데이트', + '컴포넌트 로컬 상태: useState 활용', + ], + }, + { + title: '메뉴 시스템', + description: 'DB 기반 동적 메뉴 + 권한 연동 자동 필터링', + details: [ + 'DB에서 메뉴 트리 구조를 동적으로 로드', + '사용자 권한에 따라 메뉴 항목 자동 필터링 (접근 불가 메뉴 미노출)', + '관리자 화면에서 메뉴 순서·표시 여부·아이콘 실시간 편집', + 'menuStore(Zustand)로 현재 활성 메뉴 상태 전역 관리', + ], + }, + { + title: '지도 엔진', + description: 'MapLibre GL JS 5.x + deck.gl 9.x 기반 GIS 시각화', + details: [ + 'MapLibre GL JS: 오픈소스 벡터 타일 기반 지도 렌더링', + 'deck.gl: 대규모 공간 데이터(파티클, 히트맵, 궤적) 고성능 시각화', + 'PostGIS 공간 쿼리 → GeoJSON → deck.gl 레이어 파이프라인', + '레이어 트리 UI로 사용자별 레이어 표시·숨김 제어', + ], + }, + { + title: '스타일링', + description: 'Tailwind CSS @layer 아키텍처 + CSS 변수 디자인 시스템', + details: [ + '@layer base → components → wing 3단계 CSS 계층 구조', + 'CSS 변수 기반 시맨틱 컬러 (bg-bg-base, text-t1, border-stroke 등)', + '다크 모드 기본 적용 — CSS 변수 전환으로 테마 일괄 변경', + '인라인 스타일 지양, Tailwind 유틸리티 클래스 우선', + ], + }, + { + title: '감사 로그', + description: '사용자 행위 자동 기록 — 접속·조회·변경 이력 추적', + details: [ + '로그인/로그아웃, 메뉴 접근, 데이터 변경 자동 기록', + 'App.tsx에서 탭 전환 시 감사 로그 자동 전송', + '관리자 화면에서 사용자별·기간별 감사 로그 조회 가능', + 'IP 주소, User-Agent, 요청 경로 등 부가 정보 기록', + ], + }, + { + title: '보안', + description: '입력 살균·CORS·CSP·Rate Limiting 다층 보안 정책', + details: [ + '입력 살균(sanitize): XSS·SQL Injection 방어 미들웨어 적용', + 'Helmet: CSP, X-Frame-Options, HSTS 등 보안 헤더 자동 설정', + 'CORS: 허용 오리진 화이트리스트 제한', + 'Rate Limiting: API 요청 빈도 제한으로 DoS 방어', + ], + }, +]; + +// ─── 방제대응 프로세스 데이터 ───────────────────────────────────────────────────── + +interface ProcessStep { + phase: string; + description: string; + modules: string[]; +} + +const RESPONSE_PROCESS: ProcessStep[] = [ + { + phase: '사고 접수', + description: '해양오염 사고 신고 접수 및 초동 상황 등록', + modules: ['사건/사고'], + }, + { + phase: '상황 파악', + description: '사고 현장 기상·해상 조건 확인, 유출원·유출량 파악', + modules: ['해양기상', '사건/사고'], + }, + { + phase: '확산 예측', + description: '유출유/HNS 확산 시뮬레이션 및 역추적 분석 수행', + modules: ['확산예측', 'HNS분석'], + }, + { + phase: '방제 계획', + description: '오일붐 배치, 유처리제 살포 구역, 방제선 투입 계획 수립', + modules: ['확산예측', '자산관리'], + }, + { + phase: '구조 작전', + description: '인명 구조 시나리오 수립, 표류 예측 기반 수색 구역 결정', + modules: ['구조시나리오'], + }, + { + phase: '항공 감시', + description: '위성·드론 영상으로 유막 면적 모니터링 및 방제 효과 확인', + modules: ['항공방제'], + }, + { + phase: '해안 조사', + description: 'Pre-SCAT 해안 오염 조사, 피해 범위 기록', + modules: ['SCAT조사'], + }, + { + phase: '상황 종료', + description: '방제 완료 보고, 감사 이력 정리, 사후 분석', + modules: ['사건/사고', '관리자'], + }, +]; + +// ─── 시스템별 기능 유무 매트릭스 데이터 ──────────────────────────────────────────── + +const SYSTEM_MODULES = [ + '확산예측', + 'HNS분석', + '구조시나리오', + '항공방제', + '해양기상', + '사건/사고', + '자산관리', + 'SCAT조사', + '게시판', + '관리자', +] as const; + +interface FeatureMatrixRow { + feature: string; + category: '공통기능' | '기본정보관리' | '업무기능'; + integrated: boolean; + systems: Record; +} + +const FEATURE_MATRIX: FeatureMatrixRow[] = [ + { + feature: '사용자 인증 (JWT)', + category: '공통기능', + integrated: true, + systems: { + 확산예측: true, + HNS분석: true, + 구조시나리오: true, + 항공방제: true, + 해양기상: true, + '사건/사고': true, + 자산관리: true, + SCAT조사: true, + 게시판: true, + 관리자: true, + }, + }, + { + feature: 'RBAC 권한 제어', + category: '공통기능', + integrated: true, + systems: { + 확산예측: true, + HNS분석: true, + 구조시나리오: true, + 항공방제: true, + 해양기상: true, + '사건/사고': true, + 자산관리: true, + SCAT조사: true, + 게시판: true, + 관리자: true, + }, + }, + { + feature: '감사 로그', + category: '공통기능', + integrated: true, + systems: { + 확산예측: true, + HNS분석: true, + 구조시나리오: true, + 항공방제: true, + 해양기상: true, + '사건/사고': true, + 자산관리: true, + SCAT조사: true, + 게시판: true, + 관리자: true, + }, + }, + { + feature: 'API 통신 (Axios)', + category: '공통기능', + integrated: true, + systems: { + 확산예측: true, + HNS분석: true, + 구조시나리오: true, + 항공방제: true, + 해양기상: true, + '사건/사고': true, + 자산관리: true, + SCAT조사: true, + 게시판: true, + 관리자: true, + }, + }, + { + feature: '입력 살균/보안', + category: '공통기능', + integrated: true, + systems: { + 확산예측: true, + HNS분석: true, + 구조시나리오: true, + 항공방제: true, + 해양기상: true, + '사건/사고': true, + 자산관리: true, + SCAT조사: true, + 게시판: true, + 관리자: true, + }, + }, + { + feature: '사용자 관리', + category: '기본정보관리', + integrated: true, + systems: { + 확산예측: false, + HNS분석: false, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: true, + }, + }, + { + feature: '지도 엔진 (MapLibre)', + category: '기본정보관리', + integrated: true, + systems: { + 확산예측: true, + HNS분석: true, + 구조시나리오: true, + 항공방제: true, + 해양기상: true, + '사건/사고': true, + 자산관리: false, + SCAT조사: true, + 게시판: false, + 관리자: false, + }, + }, + { + feature: '레이어 관리', + category: '기본정보관리', + integrated: true, + systems: { + 확산예측: true, + HNS분석: true, + 구조시나리오: true, + 항공방제: true, + 해양기상: true, + '사건/사고': true, + 자산관리: false, + SCAT조사: true, + 게시판: false, + 관리자: true, + }, + }, + { + feature: '메뉴 관리', + category: '기본정보관리', + integrated: true, + systems: { + 확산예측: false, + HNS분석: false, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: true, + }, + }, + { + feature: '시스템 설정', + category: '기본정보관리', + integrated: true, + systems: { + 확산예측: false, + HNS분석: false, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: true, + }, + }, + { + feature: '확산 시뮬레이션', + category: '업무기능', + integrated: false, + systems: { + 확산예측: true, + HNS분석: false, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: false, + }, + }, + { + feature: 'HNS 대기확산', + category: '업무기능', + integrated: false, + systems: { + 확산예측: false, + HNS분석: true, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: false, + }, + }, + { + feature: '표류 예측', + category: '업무기능', + integrated: false, + systems: { + 확산예측: false, + HNS분석: false, + 구조시나리오: true, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: false, + }, + }, + { + feature: '위성/드론 영상', + category: '업무기능', + integrated: false, + systems: { + 확산예측: false, + HNS분석: false, + 구조시나리오: false, + 항공방제: true, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: false, + }, + }, + { + feature: '기상/해상 정보', + category: '업무기능', + integrated: false, + systems: { + 확산예측: true, + HNS분석: true, + 구조시나리오: true, + 항공방제: false, + 해양기상: true, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: false, + }, + }, + { + feature: '역추적 분석', + category: '업무기능', + integrated: false, + systems: { + 확산예측: true, + HNS분석: false, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: false, + }, + }, + { + feature: '사고 등록/이력', + category: '업무기능', + integrated: false, + systems: { + 확산예측: false, + HNS분석: false, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': true, + 자산관리: false, + SCAT조사: false, + 게시판: false, + 관리자: false, + }, + }, + { + feature: '장비/선박 관리', + category: '업무기능', + integrated: false, + systems: { + 확산예측: false, + HNS분석: false, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: true, + SCAT조사: false, + 게시판: false, + 관리자: false, + }, + }, + { + feature: '해안 조사', + category: '업무기능', + integrated: false, + systems: { + 확산예측: false, + HNS분석: false, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: true, + 게시판: false, + 관리자: false, + }, + }, + { + feature: '게시판 CRUD', + category: '업무기능', + integrated: false, + systems: { + 확산예측: false, + HNS분석: false, + 구조시나리오: false, + 항공방제: false, + 해양기상: false, + '사건/사고': false, + 자산관리: false, + SCAT조사: false, + 게시판: true, + 관리자: false, + }, + }, +]; + +const CATEGORY_STYLES: Record = { + 공통기능: 'bg-[rgba(6,182,212,0.2)] text-color-accent', + 기본정보관리: 'bg-[rgba(34,197,94,0.2)] text-color-success', + 업무기능: 'bg-bg-elevated text-t3', +}; + +export function CommonFeaturesTab() { + return ( +
+ {/* 1. 방제대응 프로세스 */} +
+

1. 방제대응 프로세스

+

+ 해양오염 사고 발생 시 사고 접수부터 상황 종료까지의 단계별 대응 프로세스이며, 각 단계에서 + 활용하는 시스템 모듈을 표시한다. +

+ {/* 프로세스 흐름도 */} +
+ {RESPONSE_PROCESS.map((step, idx) => ( +
+
+

{step.phase}

+
+ {step.modules.map((mod) => ( + + {mod} + + ))} +
+
+ {idx < RESPONSE_PROCESS.length - 1 && ( + + )} +
+ ))} +
+ {/* 프로세스 상세 */} +
+ {RESPONSE_PROCESS.map((step, idx) => ( +
+ + {idx + 1} + +
+

{step.phase}

+

{step.description}

+
+
+ {step.modules.map((mod) => ( + + {mod} + + ))} +
+
+ ))} +
+
+ + {/* 2. 시스템별 기능 유무 매트릭스 */} +
+

2. 시스템별 기능 유무 매트릭스

+

+ 각 시스템(업무 모듈)별 기능의 유무를 파악하여 공통기능, 기본정보 관리(사용자, 지도 등) 등 + 통합할 수 있는 기능을 표시한다.{' '} + 통합 대상 기능은 공통 모듈로 일원화하여 + 중복 개발을 방지한다. +

+
+ + + + + + + {SYSTEM_MODULES.map((mod) => ( + + ))} + + + + {FEATURE_MATRIX.map((row) => ( + + + + + {SYSTEM_MODULES.map((mod) => ( + + ))} + + ))} + +
+ 기능 + + 분류 + + 통합 + + {mod} +
+ {row.feature} + + + {row.category} + + + {row.integrated ? ( + 통합 + ) : ( + 개별 + )} + + {row.systems[mod] ? ( + O + ) : ( + - + )} +
+
+ {/* 범례 */} +
+
+ + 공통기능 + + 전 모듈 공통 적용 +
+
+ + 기본정보관리 + + 사용자·지도·메뉴·설정 통합 관리 +
+
+ + 업무기능 + + 모듈별 고유 기능 +
+
+
+ + {/* 3. 공통기능 상세 */} +
+

3. 공통기능 상세

+
+ {COMMON_FEATURES.map((feature, idx) => ( +
+
+ + {idx + 1} + +

{feature.title}

+
+

{feature.description}

+
    + {feature.details.map((detail) => ( +
  • + {detail} +
  • + ))} +
+
+ ))} +
+
+ + {/* 4. 공통 모듈 구조 */} +
+

4. 공통 모듈 디렉토리 구조

+
+ + + + {['디렉토리', '역할', '주요 파일'].map((h) => ( + + ))} + + + + {[ + { + dir: 'common/components/', + role: '공통 UI 컴포넌트', + files: 'auth/, layout/, map/, ui/, layer/', + }, + { + dir: 'common/hooks/', + role: '공통 커스텀 훅', + files: 'useLayers, useSubMenu, useFeatureTracking', + }, + { + dir: 'common/services/', + role: 'API 통신 모듈', + files: 'api.ts, authApi.ts, layerService.ts', + }, + { + dir: 'common/store/', + role: '전역 상태 스토어', + files: 'authStore.ts, menuStore.ts', + }, + { + dir: 'common/styles/', + role: 'CSS @layer 스타일', + files: 'base.css, components.css, wing.css', + }, + { + dir: 'common/types/', + role: '공통 타입 정의', + files: 'backtrack, hns, navigation 등', + }, + { + dir: 'common/utils/', + role: '유틸리티 함수', + files: 'coordinates, geo, sanitize, cn.ts', + }, + { dir: 'common/constants/', role: '상수 정의', files: 'featureIds.ts' }, + { dir: 'common/data/', role: 'UI 데이터', files: 'layerData.ts (레이어 트리)' }, + ].map((row) => ( + + + + + + ))} + +
+ {h} +
+ {row.dir} + {row.role}{row.files}
+
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/FrameworkTab.tsx b/frontend/src/components/admin/components/contents/FrameworkTab.tsx new file mode 100644 index 0000000..61e0e20 --- /dev/null +++ b/frontend/src/components/admin/components/contents/FrameworkTab.tsx @@ -0,0 +1,171 @@ +interface TechStackRow { + category: string; + tech: string; + version: string; + description: string; +} + +const TECH_STACK: TechStackRow[] = [ + { category: 'Frontend', tech: 'React', version: '19.x', description: '컴포넌트 기반 SPA' }, + { category: 'Frontend', tech: 'TypeScript', version: '5.9', description: '정적 타입 시스템' }, + { category: 'Frontend', tech: 'Vite', version: '7.x', description: '빌드 도구 (HMR)' }, + { category: 'Frontend', tech: 'Tailwind CSS', version: '3.x', description: '유틸리티 기반 CSS' }, + { category: 'Frontend', tech: 'MapLibre GL', version: '5.x', description: '오픈소스 GIS 엔진' }, + { category: 'Frontend', tech: 'deck.gl', version: '9.x', description: '대규모 데이터 시각화' }, + { category: 'Frontend', tech: 'Zustand', version: '-', description: '클라이언트 상태관리' }, + { category: 'Frontend', tech: 'TanStack Query', version: '-', description: '서버 상태관리/캐싱' }, + { category: 'Backend', tech: 'Express', version: '4.x', description: 'REST API 서버' }, + { category: 'Backend', tech: 'Socket.IO', version: '-', description: '실시간 양방향 통신' }, + { category: 'DB', tech: 'PostgreSQL', version: '16', description: '관계형 데이터베이스' }, + { category: 'DB', tech: 'PostGIS', version: '-', description: '공간정보 확장' }, + { + category: '인증', + tech: 'JWT', + version: '-', + description: '토큰 기반 인증 (HttpOnly Cookie)', + }, + { category: '인증', tech: 'Google OAuth', version: '2.0', description: 'SSO 연동' }, + { category: '보안', tech: 'Helmet', version: '-', description: 'HTTP 헤더 보안' }, + { category: '보안', tech: 'Rate Limiting', version: '-', description: 'API 호출 제한' }, + { category: 'CI/CD', tech: 'Gitea Actions', version: '-', description: '자동 빌드/배포' }, +]; + +// ─── 탭 모듈 데이터 ─────────────────────────────────────────────────────────────── + +export function FrameworkTab() { + return ( +
+ {/* 1. 개발 프레임워크 구성 */} +
+

1. 개발 프레임워크 구성

+
+ {/* 프레젠테이션 계층 */} +
+

프레젠테이션 계층

+

React 19 + TypeScript 5.9 + Tailwind CSS 3

+
+ {[ + { name: 'MapLibre', sub: 'GL JS 5' }, + { name: 'deck.gl', sub: '9.x' }, + { name: 'Zustand', sub: '상태관리' }, + { name: 'TanStack', sub: 'Query' }, + ].map((item) => ( +
+

{item.name}

+

{item.sub}

+
+ ))} +
+
+ {/* 비즈니스 로직 계층 */} +
+

비즈니스 로직 계층

+

Express 4 + TypeScript

+
+ {[ + { name: 'JWT 인증', sub: 'OAuth2.0' }, + { name: 'RBAC', sub: '권한엔진' }, + { name: 'Socket.IO', sub: '실시간' }, + { name: 'Helmet', sub: '보안' }, + ].map((item) => ( +
+

{item.name}

+

{item.sub}

+
+ ))} +
+
+ {/* 데이터 접근 계층 */} +
+

데이터 접근 계층

+

PostgreSQL 16 + PostGIS

+
+ {[ + { name: 'wing DB', sub: '운영 DB' }, + { name: 'wing_auth', sub: '인증 DB' }, + { name: 'PostGIS', sub: '공간정보' }, + ].map((item) => ( +
+

{item.name}

+

{item.sub}

+
+ ))} +
+
+
+
+ + {/* 2. 기술 스택 상세 */} +
+

2. 기술 스택 상세

+
+ + + + {['구분', '기술', '버전', '설명'].map((h) => ( + + ))} + + + + {TECH_STACK.map((row, idx) => ( + + + + + + + ))} + +
+ {h} +
+ {row.category} + {row.tech}{row.version}{row.description}
+
+
+ + {/* 3. 개발 표준 및 규칙 */} +
+

3. 개발 표준 및 규칙

+
+ {[ + { + title: 'HTTP 정책', + content: 'GET/POST만 사용 (PUT/DELETE/PATCH 금지) — 한국 보안취약점 점검 가이드 준수', + }, + { + title: '코드 표준', + content: 'ESLint + Prettier 적용, TypeScript strict 모드 필수', + }, + { + title: '모듈 구조', + content: '@common/ (공통 모듈) + @components/ (업무별 탭) Path Alias 기반 분리', + }, + { + title: '보안', + content: '입력 살균(sanitize), XSS/SQL Injection 방지, CORS 정책, Rate Limiting', + }, + ].map((item) => ( +
+

{item.title}

+

{item.content}

+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/HeterogeneousTab.tsx b/frontend/src/components/admin/components/contents/HeterogeneousTab.tsx new file mode 100644 index 0000000..bae4d8a --- /dev/null +++ b/frontend/src/components/admin/components/contents/HeterogeneousTab.tsx @@ -0,0 +1,367 @@ +interface HeterogeneousSystemRow { + system: string; + lang: string; + os: string; + location: string; + protocol: string; + description: string; +} + +const HETEROGENEOUS_SYSTEMS: HeterogeneousSystemRow[] = [ + { + system: 'KOSPS', + lang: 'Fortran', + os: 'Linux', + location: '광주', + protocol: 'HTTPS (REST 래퍼)', + description: '유출유 확산 예측 — Fortran DLL을 REST API로 래핑하여 연계', + }, + { + system: '충북대 HNS', + lang: 'Python / C++', + os: 'Linux', + location: '충북대', + protocol: 'HTTPS', + description: 'HNS 대기확산 예측 — Python/C++ 모델을 REST API로 호출', + }, + { + system: '긴급구난', + lang: 'Python', + os: 'Linux', + location: '해경 내부', + protocol: '내부망 API', + description: '구난 표류 분석 — Python 모델을 내부망 REST API로 연계', + }, + { + system: 'HYCOM', + lang: 'Fortran / NetCDF', + os: 'Linux HPC', + location: '미 해군 공개', + protocol: 'HTTPS / FTP', + description: '전지구 해류·수온 예측 — NetCDF 파일 수신 후 ETL 전처리', + }, + { + system: '기상청', + lang: '-', + os: '-', + location: '기상청 API Hub', + protocol: 'HTTPS', + description: '풍향·풍속·기온·강수 등 기상 데이터 REST API 수집', + }, + { + system: 'KHOA', + lang: '-', + os: '-', + location: '해양조사원', + protocol: 'HTTPS', + description: '조위·해류·수온 등 해양관측 데이터 REST API 수집', + }, + { + system: '해경 KBP', + lang: 'Java 전자정부', + os: 'Linux', + location: '해경 내부망', + protocol: '내부망 API', + description: '사용자·조직·직위 인사 데이터 배치 수집 (비식별화 적용)', + }, + { + system: 'AIS', + lang: '-', + os: '-', + location: '해경 AIS 서버', + protocol: 'Socket / API', + description: '선박 위치·속도·방향 실시간 수신', + }, +]; + +interface HeterogeneousStrategyCard { + challenge: string; + solution: string; + description: string; +} + +interface IntegrationPlanItem { + title: string; + description: string; + details?: string[]; +} + +const INTEGRATION_PLANS: IntegrationPlanItem[] = [ + { + title: '사용자 정보 연계', + description: + '해양경찰청의 인사관리플랫폼과 연계 또는 사용자 정보를 제공받아 구성할 수 있어야 함', + }, + { + title: '해양공간 데이터 연계', + description: + "해경 해양공간 데이터 구축사업(해양공간정보 활용체계 구축 및 빅데이터 관련 사업)의 '데이터통합저장소' 시스템과 연계하여 현장탐색 전자지도 자동표출 기술 등에 영상 및 사진자료를 연계하여 구축", + }, + { + title: 'DB 통합설계 기반 맞춤형 인터페이스', + description: + '플랫폼 변경 및 신규 통합설계 되는 데이터베이스(DB) 구조 설계를 기반으로 사용자 맞춤형 화면 인터페이스를 구현해야 함', + details: [ + 'DBMS는 분리되어 있는 시스템들을 통합설계를 통하여 공통, 분야별 등으로 설계하여야 함', + ], + }, + { + title: '유출유 확산예측 정확성 향상 (KOSPS 연계)', + description: + '유출유 확산예측 정확성 향상을 위해, 해양오염방제지원시스템(KOSPS)를 연계·탑재하여야 함', + details: [ + '다양한 유출유 확산 예측 결과를 사용자가 한눈에 확인 가능하여야 함', + '확산예측 기반으로 역추적, 최초 유출유 발생지점을 예측할 수 있어야 함', + '그 밖에 유출유 확산예측 정확성 향상을 위한 대책을 마련하여야 함', + ], + }, + { + title: '기타 시스템 연계', + description: '그 밖에 시스템 구축 중 효율적인 사고대응을 위한 타 시스템 연계할 수 있음', + }, +]; + +const HETEROGENEOUS_STRATEGIES: HeterogeneousStrategyCard[] = [ + { + challenge: '언어 이질성', + solution: 'REST API 래퍼 계층', + description: + 'Fortran, Python, C++, Java 등 각 언어로 작성된 모델을 REST API 래퍼로 감싸 언어·플랫폼 독립적인 표준 인터페이스 제공', + }, + { + challenge: '데이터 형식 차이', + solution: 'ETL 전처리 파이프라인', + description: + 'NetCDF, CSV, Binary, JSON 등 이기종 포맷을 ETL 파이프라인으로 표준 JSON/GeoJSON 형식으로 변환 후 DB 적재', + }, + { + challenge: '네트워크 분리', + solution: '이중 네트워크 연계', + description: + '외부망(인터넷) 연계와 내부망(해경 내부) 연계를 분리 운영하여 보안 정책 준수 및 데이터 안전성 확보', + }, + { + challenge: '가용성·장애 대응', + solution: '연계 모니터링 + 알림', + description: + '연계 상태를 실시간 모니터링하고 수신 지연·실패 발생 시 운영자에게 즉시 알림 발송하여 신속 대응', + }, + { + challenge: '인증·보안 차이', + solution: 'API Gateway 패턴', + description: + '시스템별 상이한 인증 방식(API Key, JWT, IP 제한 등)을 API Gateway 계층에서 통합 관리하여 단일 보안 정책 적용', + }, + { + challenge: '프로토콜 차이', + solution: '어댑터 패턴 적용', + description: + 'HTTP REST, FTP, Socket, 배치 파일 등 다양한 프로토콜을 어댑터 패턴으로 추상화하여 표준 인터페이스로 통일', + }, +]; + +const HETEROGENEOUS_FLOW_STEPS = [ + '원본 데이터', + '수집 어댑터', + 'ETL 전처리', + '표준 변환', + 'DB 적재', + 'API 제공', +]; + +interface SecurityPolicyCard { + title: string; + items: string[]; +} + +const HETEROGENEOUS_SECURITY: SecurityPolicyCard[] = [ + { + title: '외부망 연계', + items: [ + 'TLS 1.2+ 암호화 통신', + 'API Key / OAuth 인증', + 'IP 화이트리스트 제한', + 'Rate Limiting 적용', + ], + }, + { + title: '내부망 연계', + items: [ + '전용 내부망 구간 분리', + '상호 인증서 검증', + '비식별화 자동 처리', + '접근 이력 감사로그', + ], + }, + { + title: '데이터 보호', + items: [ + '개인정보 수집 최소화', + 'ETL 단계 비식별화', + '전송 구간 암호화', + '저장 데이터 접근 제어', + ], + }, +]; + +// ─── 탭 4: 이기종시스템연계 ─────────────────────────────────────────────────────── + +export function HeterogeneousTab() { + return ( +
+ {/* 1. 이기종시스템 연계 개요 */} +
+

1. 이기종시스템 연계 개요

+

+ 통합지원시스템은 Fortran, Python, C++, Java 등 다양한 언어와 플랫폼으로 구현된 이기종 + 시스템과 연계한다. REST API 표준화, ETL 전처리, 어댑터 패턴을 통해 언어·플랫폼 독립적인 + 연계 구조를 구현하며, 외부망·내부망 이중 네트워크 정책을 준수한다. +

+
+
+

이기종 시스템

+ {['Fortran KOSPS', 'Python/C++ 충북대', 'Java 해경KBP', 'NetCDF HYCOM'].map((item) => ( +

+ {item} +

+ ))} +
+
+ + +
+
+

연계 어댑터 계층

+ {['REST API 래퍼', 'ETL 전처리', '프로토콜 변환', '인증 통합'].map((item) => ( +

+ {item} +

+ ))} +
+
+ + +
+
+

통합지원시스템

+ {['Express REST API', 'PostgreSQL+PostGIS', 'React SPA', '표준 JSON'].map((item) => ( +

+ {item} +

+ ))} +
+
+
+ + {/* 2. 이기종 시스템 간의 연계 방안 */} +
+

2. 이기종 시스템 간의 연계 방안

+
+ {INTEGRATION_PLANS.map((item, idx) => ( +
+

+ {idx + 1}. {item.title} +

+

{item.description}

+ {item.details && ( +
    + {item.details.map((detail) => ( +
  • + {detail} +
  • + ))} +
+ )} +
+ ))} +
+
+ + {/* 3. 연계 대상 이기종 시스템 목록 */} +
+

3. 연계 대상 이기종 시스템 목록

+
+ + + + {['시스템', '구현 언어', 'OS', '위치', '연계 프로토콜', '연계 설명'].map((h) => ( + + ))} + + + + {HETEROGENEOUS_SYSTEMS.map((row) => ( + + + + + + + + + ))} + +
+ {h} +
{row.system}{row.lang}{row.os}{row.location}{row.protocol}{row.description}
+
+
+ + {/* 4. 이기종 연계 전략 */} +
+

4. 이기종 연계 전략

+
+ {HETEROGENEOUS_STRATEGIES.map((card) => ( +
+
+ {card.challenge} + + {card.solution} +
+

{card.description}

+
+ ))} +
+
+ + {/* 5. 이기종 데이터 변환 흐름 */} +
+

5. 이기종 데이터 변환 흐름

+
+ {HETEROGENEOUS_FLOW_STEPS.map((step, idx) => ( +
+
+

{step}

+
+ {idx < HETEROGENEOUS_FLOW_STEPS.length - 1 && ( + + )} +
+ ))} +
+
+ + {/* 6. 이기종 연계 보안 정책 */} +
+

6. 이기종 연계 보안 정책

+
+ {HETEROGENEOUS_SECURITY.map((card) => ( +
+

{card.title}

+
    + {card.items.map((item) => ( +
  • + · {item} +
  • + ))} +
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/InterfaceTab.tsx b/frontend/src/components/admin/components/contents/InterfaceTab.tsx new file mode 100644 index 0000000..bfd8f8c --- /dev/null +++ b/frontend/src/components/admin/components/contents/InterfaceTab.tsx @@ -0,0 +1,236 @@ +interface InterfaceRow { + system: string; + method: string; + data: string; + cycle: string; + protocol: string; +} + +const INTERFACES: InterfaceRow[] = [ + { + system: 'KHOA (해양조사원)', + method: 'REST API', + data: '조위, 해류, 수온', + cycle: '실시간/1시간', + protocol: 'HTTPS', + }, + { + system: '기상청', + method: 'REST API', + data: '풍향/풍속, 기압, 기온, 강수', + cycle: '3시간', + protocol: 'HTTPS', + }, + { + system: 'HYCOM', + method: '파일 수신', + data: 'SST, 해류(U/V), SSH', + cycle: '6시간', + protocol: 'HTTPS/FTP', + }, + { + system: '해경 KBP (인사)', + method: '배치 수집', + data: '사용자, 부서, 직위, 조직', + cycle: '1일 1회', + protocol: '내부망 API', + }, + { + system: 'AIS 선박위치', + method: '실시간 수집', + data: '선박 위치, 속도, 방향', + cycle: '실시간', + protocol: 'Socket/API', + }, + { + system: '포세이돈 R&D', + method: 'API 연계', + data: '유출유 확산 예측 결과', + cycle: '요청 시', + protocol: 'HTTPS', + }, + { + system: 'KOSPS (광주)', + method: 'DLL 호출', + data: '유출유 확산 예측 결과', + cycle: '요청 시', + protocol: 'HTTPS (Fortran DLL)', + }, + { + system: '충북대 HNS', + method: 'API 호출', + data: 'HNS 대기확산 결과', + cycle: '요청 시', + protocol: 'HTTPS', + }, + { + system: '긴급구난 R&D', + method: '내부 연계', + data: '구난 분석 결과', + cycle: '요청 시', + protocol: '내부망 API', + }, +]; + +// ─── 탭 1: 표준 프레임워크 ──────────────────────────────────────────────────────── + +export function InterfaceTab() { + const dataFlowSteps = ['수집', '전처리', '저장', '분석/예측', '시각화', '의사결정지원']; + + return ( +
+ {/* 1. 외부 시스템 연계 구성도 */} +
+

1. 외부 시스템 연계 구성도

+
+ {/* 외부 시스템 */} +
+

외부 시스템

+ {['KHOA API', '기상청 API', '해경 KBP', 'AIS 선박'].map((item) => ( +
+

{item}

+
+ ))} +
+ {/* 화살표 */} +
+ + +
+ {/* 통합지원시스템 */} +
+

+ 해양환경 위기대응 +
+ 통합지원시스템 +

+
+

연계관리 모듈

+
+ {['수집자료 관리', '연계 모니터링', '비식별화 조치'].map((item) => ( +

+ - {item} +

+ ))} +
+
+
+ {/* 화살표 */} +
+ + +
+ {/* R&D 시스템 */} +
+

R&D 시스템

+ {['포세이돈', 'KOSPS', '충북대 HNS', '긴급구난'].map((item) => ( +
+

{item}

+
+ ))} +
+
+
+ + {/* 2. 연계 인터페이스 목록 */} +
+

2. 연계 인터페이스 목록

+
+ + + + {['연계 시스템', '연계 방식', '데이터', '주기', '프로토콜'].map((h) => ( + + ))} + + + + {INTERFACES.map((row) => ( + + + + + + + + ))} + +
+ {h} +
{row.system}{row.method}{row.data}{row.cycle}{row.protocol}
+
+
+ + {/* 3. 데이터 흐름도 */} +
+

3. 데이터 흐름도

+
+ {dataFlowSteps.map((step, idx) => ( +
+
+

{step}

+
+ {idx < dataFlowSteps.length - 1 && ( + + )} +
+ ))} +
+
+ {[ + { step: '수집', desc: 'KHOA, 기상청, HYCOM, AIS 등 외부 원천 데이터 수신' }, + { step: '전처리', desc: '포맷 변환, 좌표계 통일, 비식별화, 품질 검사' }, + { step: '저장', desc: 'PostgreSQL 16 + PostGIS 공간정보 DB 적재' }, + { step: '분석/예측', desc: 'R&D 모델 연계 (포세이돈, KOSPS, 충북대, 긴급구난)' }, + { step: '시각화', desc: 'MapLibre GL + deck.gl 기반 지도 레이어 렌더링' }, + { step: '의사결정지원', desc: '방제작전 시나리오, 구조분석, 경보 발령 지원' }, + ].map((item) => ( +
+

{item.step}

+

{item.desc}

+
+ ))} +
+
+ + {/* 4. 연계 장애 대응 */} +
+

4. 연계 장애 대응

+
+ {[ + { + title: '연계 모니터링', + content: '관리자 > 연계관리 > 연계모니터링에서 실시간 연계 상태 확인', + }, + { + title: 'R&D 파이프라인 모니터링', + content: '관리자 > 연계관리 > R&D과제에서 과제별 데이터 수신 이력 및 처리 현황 확인', + }, + { + title: '장애 알림', + content: '데이터 수신 지연/실패 발생 시 알림 발생 — 운영자 즉시 인지 가능', + }, + { + title: '비식별화 조치', + content: '개인정보 포함 데이터(해경 KBP 인사 등) 수집 시 자동 비식별화 처리 적용', + }, + ].map((item) => ( +
+

{item.title}

+

{item.content}

+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/PermCell.tsx b/frontend/src/components/admin/components/contents/PermCell.tsx new file mode 100644 index 0000000..ded1995 --- /dev/null +++ b/frontend/src/components/admin/components/contents/PermCell.tsx @@ -0,0 +1,70 @@ +import type { PermState } from '../PermissionsPanel'; + +interface PermCellProps { + state: PermState; + onToggle: () => void; + label?: string; + readOnly?: boolean; +} + +export function PermCell({ state, onToggle, label, readOnly = false }: PermCellProps) { + const isDisabled = state === 'forced-denied' || readOnly; + + const baseClasses = + 'w-5 h-5 rounded border text-caption font-bold transition-all flex items-center justify-center'; + + let classes: string; + let icon: string; + + switch (state) { + case 'explicit-granted': + classes = readOnly + ? `${baseClasses} bg-[rgba(6,182,212,0.2)] border-color-accent text-color-accent cursor-default` + : `${baseClasses} bg-[rgba(6,182,212,0.2)] border-color-accent text-color-accent cursor-pointer hover:bg-[rgba(6,182,212,0.3)]`; + icon = '✓'; + break; + case 'inherited-granted': + classes = readOnly + ? `${baseClasses} bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] cursor-default` + : `${baseClasses} bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] cursor-pointer hover:border-color-accent`; + icon = '✓'; + break; + case 'explicit-denied': + classes = readOnly + ? `${baseClasses} bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-color-danger cursor-default` + : `${baseClasses} bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-color-danger cursor-pointer hover:border-color-danger`; + icon = '—'; + break; + case 'forced-denied': + classes = `${baseClasses} bg-bg-elevated border-stroke text-fg-disabled opacity-40 cursor-not-allowed`; + icon = '—'; + break; + } + + return ( + + ); +} diff --git a/frontend/src/components/admin/components/contents/PermLegend.tsx b/frontend/src/components/admin/components/contents/PermLegend.tsx new file mode 100644 index 0000000..9268659 --- /dev/null +++ b/frontend/src/components/admin/components/contents/PermLegend.tsx @@ -0,0 +1,36 @@ +export function PermLegend() { + return ( +
+ + + ✓ + + 허용 + + + + ✓ + + 상속 + + + + — + + 거부 + + + + — + + 비활성 + + + R=조회 C=생성 U=수정 D=삭제 + +
+ ); +} diff --git a/frontend/src/components/admin/components/contents/ProgressBar.tsx b/frontend/src/components/admin/components/contents/ProgressBar.tsx new file mode 100644 index 0000000..74e2b6a --- /dev/null +++ b/frontend/src/components/admin/components/contents/ProgressBar.tsx @@ -0,0 +1,15 @@ +export function ProgressBar({ value }: { value: number }) { + const colorClass = + value === 100 ? 'bg-color-success' : value > 0 ? 'bg-color-accent' : 'bg-bg-elevated'; + return ( +
+
+
+
+ {value}% +
+ ); +} diff --git a/frontend/src/components/admin/components/contents/RegisterModal.tsx b/frontend/src/components/admin/components/contents/RegisterModal.tsx new file mode 100644 index 0000000..6e8a54c --- /dev/null +++ b/frontend/src/components/admin/components/contents/RegisterModal.tsx @@ -0,0 +1,228 @@ +import { useState } from 'react'; +import { createUserApi, type RoleWithPermissions, type OrgItem } from '@common/services/authApi'; +import { getRoleColor } from '../adminConstants'; + +interface RegisterModalProps { + allRoles: RoleWithPermissions[]; + allOrgs: OrgItem[]; + onClose: () => void; + onSuccess: () => void; +} + +export function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalProps) { + const [account, setAccount] = useState(''); + const [password, setPassword] = useState(''); + const [name, setName] = useState(''); + const [rank, setRank] = useState(''); + const [orgSn, setOrgSn] = useState(() => { + const defaultOrg = allOrgs.find((o) => o.orgNm === '기동방제과'); + return defaultOrg ? defaultOrg.orgSn : ''; + }); + const [email, setEmail] = useState(''); + const [roleSns, setRoleSns] = useState([]); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const toggleRole = (sn: number) => { + setRoleSns((prev) => (prev.includes(sn) ? prev.filter((s) => s !== sn) : [...prev, sn])); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!account.trim() || !password.trim() || !name.trim()) { + setError('계정, 비밀번호, 사용자명은 필수 항목입니다.'); + return; + } + setSubmitting(true); + setError(null); + try { + await createUserApi({ + account: account.trim(), + password, + name: name.trim(), + rank: rank.trim() || undefined, + orgSn: orgSn !== '' ? orgSn : undefined, + roleSns: roleSns.length > 0 ? roleSns : undefined, + }); + onSuccess(); + onClose(); + } catch (err) { + setError('사용자 등록에 실패했습니다.'); + console.error('사용자 등록 실패:', err); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ {/* 헤더 */} +
+

사용자 등록

+ +
+ + {/* 폼 */} +
+
+ {/* 계정 */} +
+ + setAccount(e.target.value)} + placeholder="로그인 계정 ID" + className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono" + /> +
+ + {/* 비밀번호 */} +
+ + setPassword(e.target.value)} + placeholder="초기 비밀번호" + className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono" + /> +
+ + {/* 사용자명 */} +
+ + setName(e.target.value)} + placeholder="실명" + className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean" + /> +
+ + {/* 직급 */} +
+ + setRank(e.target.value)} + placeholder="예: 팀장, 주임 등" + className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean" + /> +
+ + {/* 소속 */} +
+ + +
+ + {/* 이메일 */} +
+ + setEmail(e.target.value)} + placeholder="이메일 주소" + className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono" + /> +
+ + {/* 역할 */} +
+ +
+ {allRoles.length === 0 ? ( +

역할 없음

+ ) : ( + allRoles.map((role, idx) => { + const color = getRoleColor(role.code, idx); + return ( + + ); + }) + )} +
+
+ + {/* 에러 메시지 */} + {error &&

{error}

} +
+ + {/* 푸터 */} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/RolePermTab.tsx b/frontend/src/components/admin/components/contents/RolePermTab.tsx new file mode 100644 index 0000000..62c9b06 --- /dev/null +++ b/frontend/src/components/admin/components/contents/RolePermTab.tsx @@ -0,0 +1,312 @@ +import type { PermTreeNode, RoleWithPermissions } from '@common/services/authApi'; +import type { PermState, OperCode } from '../PermissionsPanel'; +import { OPER_CODES, OPER_FULL_LABELS, OPER_LABELS, buildEffectiveStates } from '../PermissionsPanel'; +import { TreeRow } from './TreeRow'; +import { PermLegend } from './PermLegend'; + +interface RolePermTabProps { + roles: RoleWithPermissions[]; + permTree: PermTreeNode[]; + rolePerms: Map>; + setRolePerms: React.Dispatch>>>; + selectedRoleSn: number | null; + setSelectedRoleSn: (sn: number | null) => void; + dirty: boolean; + saving: boolean; + saveError: string | null; + handleSave: () => Promise; + handleToggleExpand: (code: string) => void; + handleTogglePerm: (code: string, oper: OperCode, currentState: PermState) => void; + expanded: Set; + flatNodes: PermTreeNode[]; + editingRoleSn: number | null; + editRoleName: string; + setEditRoleName: (name: string) => void; + handleStartEditName: (role: RoleWithPermissions) => void; + handleSaveRoleName: (roleSn: number) => Promise; + setEditingRoleSn: (sn: number | null) => void; + toggleDefault: (roleSn: number) => Promise; + handleDeleteRole: (roleSn: number, roleName: string) => Promise; + showCreateForm: boolean; + setShowCreateForm: (show: boolean) => void; + setCreateError: (err: string) => void; + newRoleCode: string; + setNewRoleCode: (code: string) => void; + newRoleName: string; + setNewRoleName: (name: string) => void; + newRoleDesc: string; + setNewRoleDesc: (desc: string) => void; + creating: boolean; + createError: string; + handleCreateRole: () => Promise; +} + +export function RolePermTab({ + roles, + permTree, + selectedRoleSn, + setSelectedRoleSn, + dirty, + saving, + saveError, + handleSave, + handleToggleExpand, + handleTogglePerm, + expanded, + flatNodes, + rolePerms, + editingRoleSn, + editRoleName, + setEditRoleName, + handleStartEditName, + handleSaveRoleName, + setEditingRoleSn, + toggleDefault, + handleDeleteRole, + showCreateForm, + setShowCreateForm, + setCreateError, + newRoleCode, + setNewRoleCode, + newRoleName, + setNewRoleName, + newRoleDesc, + setNewRoleDesc, + creating, + createError, + handleCreateRole, +}: RolePermTabProps) { + const currentStateMap = selectedRoleSn + ? buildEffectiveStates(flatNodes, rolePerms.get(selectedRoleSn) ?? new Map()) + : new Map(); + + return ( + <> + {/* 헤더 액션 버튼 */} +
+ + + {saveError && ( + {saveError} + )} +
+ + {/* 역할 탭 바 */} +
+ {roles.map((role) => { + const isSelected = selectedRoleSn === role.sn; + return ( +
+ + {isSelected && ( +
+ + {role.code !== 'ADMIN' && ( + + )} +
+ )} +
+ ); + })} +
+ + {/* 범례 */} + + + {/* CRUD 매트릭스 테이블 */} + {selectedRoleSn ? ( +
+ + + + + {OPER_CODES.map((oper) => ( + + ))} + + + + {permTree.map((rootNode) => ( + + ))} + +
+ 기능 + +
+ {OPER_LABELS[oper]} +
+
+ {OPER_FULL_LABELS[oper]} +
+
+
+ ) : ( +
+ 역할을 선택하세요 +
+ )} + + {/* 역할 생성 모달 */} + {showCreateForm && ( +
+
+
+

새 역할 추가

+
+
+
+ + + setNewRoleCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '')) + } + placeholder="CUSTOM_ROLE" + className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono" + /> +

+ 영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가) +

+
+
+ + setNewRoleName(e.target.value)} + placeholder="사용자 정의 역할" + className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean" + /> +
+
+ + setNewRoleDesc(e.target.value)} + placeholder="역할에 대한 설명" + className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean" + /> +
+ {createError && ( +
+ {createError} +
+ )} +
+
+ + +
+
+
+ )} + + ); +} diff --git a/frontend/src/components/admin/components/contents/Step1.tsx b/frontend/src/components/admin/components/contents/Step1.tsx new file mode 100644 index 0000000..5a4cf52 --- /dev/null +++ b/frontend/src/components/admin/components/contents/Step1.tsx @@ -0,0 +1,120 @@ +import type { ApiConfig, DbConfig, SourceType, WizardState } from '../DeidentifyPanel'; + +interface Step1Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +export function Step1({ wizard, onChange }: Step1Props) { + const handleDbChange = (key: keyof DbConfig, value: string) => { + onChange({ dbConfig: { ...wizard.dbConfig, [key]: value } }); + }; + const handleApiChange = (key: keyof ApiConfig, value: string) => { + onChange({ apiConfig: { ...wizard.apiConfig, [key]: value } }); + }; + + return ( +
+
+ + onChange({ taskName: e.target.value })} + placeholder="작업 이름을 입력하세요" + className="w-full px-3 py-2 text-caption rounded bg-bg-elevated border border-stroke text-t1 placeholder:text-t3 focus:outline-none focus:border-color-accent" + /> +
+ +
+ +
+ {( + [ + ['db', '데이터베이스 연결'], + ['file', '파일 업로드'], + ['api', 'API 호출'], + ] as [SourceType, string][] + ).map(([val, label]) => ( + + ))} +
+
+ + {wizard.sourceType === 'db' && ( +
+ {( + [ + ['host', '호스트', 'localhost'], + ['port', '포트', '5432'], + ['database', '데이터베이스', 'wing'], + ['tableName', '테이블명', 'public.customers'], + ] as [keyof DbConfig, string, string][] + ).map(([key, labelText, placeholder]) => ( +
+ + handleDbChange(key, e.target.value)} + placeholder={placeholder} + className="w-full px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 placeholder:text-t3 focus:outline-none focus:border-color-accent" + /> +
+ ))} +
+ )} + + {wizard.sourceType === 'file' && ( +
+ + + +

파일을 드래그하거나 클릭하여 업로드

+

CSV, XLSX, JSON 지원 (최대 500MB)

+
+ )} + + {wizard.sourceType === 'api' && ( +
+
+ + handleApiChange('url', e.target.value)} + placeholder="https://api.example.com/data" + className="w-full px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 placeholder:text-t3 focus:outline-none focus:border-color-accent" + /> +
+
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/admin/components/contents/Step2.tsx b/frontend/src/components/admin/components/contents/Step2.tsx new file mode 100644 index 0000000..2194087 --- /dev/null +++ b/frontend/src/components/admin/components/contents/Step2.tsx @@ -0,0 +1,77 @@ +import type { WizardState } from '../DeidentifyPanel'; + +interface Step2Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +export function Step2({ wizard, onChange }: Step2Props) { + const toggleField = (idx: number) => { + const updated = wizard.fields.map((f, i) => (i === idx ? { ...f, selected: !f.selected } : f)); + onChange({ fields: updated }); + }; + + return ( +
+
+ {[ + { label: '총 데이터 건수', value: '15,240건', color: 'text-t1' }, + { label: '중복', value: '0건', color: 'text-color-success' }, + { label: '누락값', value: '23건', color: 'text-color-caution' }, + ].map((stat) => ( +
+

{stat.label}

+

{stat.value}

+
+ ))} +
+ +
+

스키마 분석 결과 — 포함 필드 선택

+
+ + + + + + + + + + {wizard.fields.map((field, idx) => ( + + + + + + ))} + +
+ f.selected)} + onChange={(e) => + onChange({ + fields: wizard.fields.map((f) => ({ ...f, selected: e.target.checked })), + }) + } + className="accent-cyan-500" + /> + 필드명 + 데이터 타입 +
+ toggleField(idx)} + className="accent-cyan-500" + /> + {field.name}{field.dataType}
+
+

+ {wizard.fields.filter((f) => f.selected).length}개 선택됨 (전체 {wizard.fields.length}개) +

+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/Step3.tsx b/frontend/src/components/admin/components/contents/Step3.tsx new file mode 100644 index 0000000..83dbecb --- /dev/null +++ b/frontend/src/components/admin/components/contents/Step3.tsx @@ -0,0 +1,97 @@ +import type { FieldConfig, WizardState } from '../DeidentifyPanel'; +import { TECHNIQUES, TEMPLATES } from '../DeidentifyPanel'; + +interface Step3Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +export function Step3({ wizard, onChange }: Step3Props) { + const updateField = (idx: number, key: keyof FieldConfig, value: string | boolean) => { + const updated = wizard.fields.map((f, i) => (i === idx ? { ...f, [key]: value } : f)); + onChange({ fields: updated }); + }; + + const selectedFields = wizard.fields.filter((f) => f.selected); + + return ( +
+
+ + + + + + + + + + + {selectedFields.map((field) => { + const globalIdx = wizard.fields.findIndex((f) => f.name === field.name); + return ( + + + + + + + ); + })} + +
필드명 + 데이터타입 + + 선택된 기법 + 설정값
{field.name}{field.dataType} + + + updateField(globalIdx, 'configValue', e.target.value)} + className="w-full px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent" + /> +
+
+ +
+ + +
+ 이전 템플릿 적용: + +
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/Step4.tsx b/frontend/src/components/admin/components/contents/Step4.tsx new file mode 100644 index 0000000..079c29b --- /dev/null +++ b/frontend/src/components/admin/components/contents/Step4.tsx @@ -0,0 +1,160 @@ +import type { OneshotConfig, ProcessMode, RepeatType, ScheduleConfig, WizardState } from '../DeidentifyPanel'; +import { HOURS, WEEKDAYS } from '../DeidentifyPanel'; + +interface Step4Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +export function Step4({ wizard, onChange }: Step4Props) { + const handleScheduleChange = (key: keyof ScheduleConfig, value: string | boolean) => { + onChange({ scheduleConfig: { ...wizard.scheduleConfig, [key]: value } }); + }; + const handleOneshotChange = (key: keyof OneshotConfig, value: string) => { + onChange({ oneshotConfig: { ...wizard.oneshotConfig, [key]: value } }); + }; + + return ( +
+
+ {( + [ + ['immediate', '즉시 처리', '지금 바로 데이터를 비식별화합니다.'], + ['scheduled', '배치 처리 - 정기 스케줄링', '반복 일정에 따라 자동으로 처리합니다.'], + ['oneshot', '배치 처리 - 일회성', '지정한 날짜/시간에 한 번 처리합니다.'], + ] as [ProcessMode, string, string][] + ).map(([val, label, desc]) => ( +
+ + + {val === 'scheduled' && wizard.processMode === 'scheduled' && ( +
+
+ + +
+
+ +
+ {( + [ + ['daily', '매일'], + ['weekly', '주 1회'], + ['monthly', '월 1회'], + ] as [RepeatType, string][] + ).map(([rt, rl]) => ( +
+ handleScheduleChange('repeatType', rt)} + className="accent-cyan-500" + /> + {rl} + {rt === 'weekly' && wizard.scheduleConfig.repeatType === 'weekly' && ( + + )} +
+ ))} +
+
+
+ + handleScheduleChange('startDate', e.target.value)} + className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent" + /> +
+
+ + +
+
+ )} + + {val === 'oneshot' && wizard.processMode === 'oneshot' && ( +
+
+ + handleOneshotChange('date', e.target.value)} + className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent" + /> +
+
+ + +
+
+ )} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/Step5.tsx b/frontend/src/components/admin/components/contents/Step5.tsx new file mode 100644 index 0000000..010307b --- /dev/null +++ b/frontend/src/components/admin/components/contents/Step5.tsx @@ -0,0 +1,62 @@ +import type { ProcessMode, WizardState } from '../DeidentifyPanel'; + +interface Step5Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +export function Step5({ wizard, onChange }: Step5Props) { + const selectedCount = wizard.fields.filter((f) => f.selected).length; + const ruleCount = wizard.fields.filter((f) => f.selected && f.technique !== '유지').length; + + const processModeLabel: Record = { + immediate: '즉시 처리', + scheduled: `배치 - 정기 (${wizard.scheduleConfig.hour} / ${wizard.scheduleConfig.repeatType === 'daily' ? '매일' : wizard.scheduleConfig.repeatType === 'weekly' ? `주1회 ${wizard.scheduleConfig.weekday}요일` : '월1회'})`, + oneshot: `배치 - 일회성 (${wizard.oneshotConfig.date} ${wizard.oneshotConfig.hour})`, + }; + + const summaryRows = [ + { label: '작업명', value: wizard.taskName || '(미입력)' }, + { + label: '소스', + value: + wizard.sourceType === 'db' + ? `DB: ${wizard.dbConfig.database}.${wizard.dbConfig.tableName}` + : wizard.sourceType === 'file' + ? '파일 업로드' + : `API: ${wizard.apiConfig.url}`, + }, + { label: '데이터 건수', value: '15,240건' }, + { label: '선택 필드 수', value: `${selectedCount}개` }, + { label: '비식별화 규칙 수', value: `${ruleCount}개` }, + { label: '처리 방식', value: processModeLabel[wizard.processMode] }, + { label: '예상 처리시간', value: '약 3~5분' }, + ]; + + return ( +
+
+ + + {summaryRows.map(({ label, value }) => ( + + + + + ))} + +
{label}{value}
+
+ + +
+ ); +} diff --git a/frontend/src/components/admin/components/contents/StepIndicator.tsx b/frontend/src/components/admin/components/contents/StepIndicator.tsx new file mode 100644 index 0000000..177cc61 --- /dev/null +++ b/frontend/src/components/admin/components/contents/StepIndicator.tsx @@ -0,0 +1,53 @@ +import { STEP_LABELS } from '../DeidentifyPanel'; + +export function StepIndicator({ current }: { current: number }) { + return ( +
+ {STEP_LABELS.map((label, i) => { + const stepNum = i + 1; + const isDone = stepNum < current; + const isActive = stepNum === current; + return ( +
+
+
+ {isDone ? ( + + + + ) : ( + stepNum + )} +
+ + {stepNum}.{label} + +
+ {i < STEP_LABELS.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/admin/components/contents/TargetArchTab.tsx b/frontend/src/components/admin/components/contents/TargetArchTab.tsx new file mode 100644 index 0000000..6e0657b --- /dev/null +++ b/frontend/src/components/admin/components/contents/TargetArchTab.tsx @@ -0,0 +1,196 @@ +interface TabModuleRow { + module: string; + name: string; + feature: string; + integration: string; +} + +const TAB_MODULES: TabModuleRow[] = [ + { + module: '확산예측', + name: 'prediction', + feature: '유출유 확산 시뮬레이션, 역추적 분석, 오일붐 배치', + integration: 'KOSPS, 포세이돈 R&D', + }, + { + module: 'HNS 분석', + name: 'hns', + feature: '화학물질 확산 예측, 물질 DB, 위험도 평가', + integration: '충북대 R&D, 물질 DB', + }, + { + module: '구조 시나리오', + name: 'rescue', + feature: '긴급구난 분석, 표류 예측', + integration: '긴급구난 R&D', + }, + { + module: '항공 방제', + name: 'aerial', + feature: '위성영상 분석, 드론 영상, 유막 면적 분석', + integration: '위성/드론 데이터', + }, + { + module: '해양 기상', + name: 'weather', + feature: '기상·해상 정보, 조위·해류 관측', + integration: 'KHOA API, 기상청 API', + }, + { + module: '사건/사고', + name: 'incidents', + feature: '해양오염 사고 등록·관리·이력', + integration: '해경 사고 DB', + }, + { + module: '자산 관리', + name: 'assets', + feature: '기관·장비·선박 보험 관리', + integration: '해경 자산 DB', + }, + { + module: 'SCAT 조사', + name: 'scat', + feature: 'Pre-SCAT 해안 조사 기록', + integration: '현장 조사 데이터', + }, + { + module: '관리자', + name: 'admin', + feature: '사용자/권한/메뉴/설정/연계 관리', + integration: '전체 시스템', + }, +]; + +// ─── 연계 인터페이스 데이터 ─────────────────────────────────────────────────────── + +export function TargetArchTab() { + return ( +
+ {/* 1. 시스템 전체 구성도 */} +
+

1. 시스템 전체 구성도

+
+ {/* 사용자 접근 계층 */} +
+

사용자 접근 계층

+

웹 브라우저 (React SPA)

+

+ 확산예측 | HNS분석 | 구조시나리오 | 항공방제 | 기상정보 | 사고관리 | SCAT조사 | + 자산관리 | 관리자 +

+
+ {/* 화살표 + 프로토콜 */} +
+ + HTTPS (TLS 1.2+) +
+ {/* API 서버 계층 */} +
+

API 서버 계층

+

Express 4 REST API (Port 3001)

+
+ {[ + 'JWT 인증 미들웨어', + 'RBAC 권한 엔진 (permResolver)', + '감사로그 자동 기록', + '입력 살균 / Rate Limiting / Helmet', + ].map((item) => ( +
+

{item}

+
+ ))} +
+
+ {/* 화살표 + 프로토콜 */} +
+ + pg connection pool +
+ {/* 데이터 계층 */} +
+

데이터 계층

+

PostgreSQL 16 + PostGIS

+
+ {[ + { name: 'wing DB', sub: '운영' }, + { name: 'wing_auth', sub: '인증' }, + ].map((item) => ( +
+

{item.name}

+

({item.sub})

+
+ ))} +
+
+
+
+ + {/* 2. 탭 기반 업무 모듈 구조 */} +
+

2. 탭 기반 업무 모듈 구조

+
+ + + + {['모듈', '패키지명', '기능', '주요 연계'].map((h) => ( + + ))} + + + + {TAB_MODULES.map((row) => ( + + + + + + + ))} + +
+ {h} +
{row.module}{row.name}{row.feature}{row.integration}
+
+
+ + {/* 3. RBAC 권한 체계 */} +
+

3. RBAC 권한 체계

+
+ {[ + { + title: '2차원 권한 엔진', + content: + 'AUTH_PERM OPER_CD 기반: R(조회), C(생성), U(수정), D(삭제) — 역할별 메뉴·기능 접근 제어', + }, + { + title: 'permResolver', + content: + '역할(Role)과 권한(Permission)의 2차원 매핑으로 메뉴 표시 여부 및 기능 사용 가능 여부를 동적으로 판단', + }, + { + title: '감사로그 자동 기록', + content: + '누가(사용자) / 언제(타임스탬프) / 무엇을(기능) / 어디서(IP, 메뉴) — 모든 주요 작업 자동 기록', + }, + ].map((item) => ( +
+

{item.title}

+

{item.content}

+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/TaskTable.tsx b/frontend/src/components/admin/components/contents/TaskTable.tsx new file mode 100644 index 0000000..932a85d --- /dev/null +++ b/frontend/src/components/admin/components/contents/TaskTable.tsx @@ -0,0 +1,100 @@ +import type { DeidentifyTask } from '../DeidentifyPanel'; +import { TABLE_HEADERS, getStatusBadgeClass } from '../DeidentifyPanel'; +import { ProgressBar } from './ProgressBar'; + +interface TaskTableProps { + rows: DeidentifyTask[]; + loading: boolean; + onAction: (action: string, task: DeidentifyTask) => void; +} + +export function TaskTable({ rows, loading, onAction }: TaskTableProps) { + return ( +
+ + + + {TABLE_HEADERS.map((h) => ( + + ))} + + + + {loading && rows.length === 0 + ? Array.from({ length: 5 }).map((_, i) => ( + + {TABLE_HEADERS.map((_, j) => ( + + ))} + + )) + : rows.map((row) => ( + + + + + + + + + + + ))} + +
+ {h} +
+
+
{row.id}{row.name} + {row.target} + + + {row.status} + + {row.startTime} + + {row.createdBy} +
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/TreeRow.tsx b/frontend/src/components/admin/components/contents/TreeRow.tsx new file mode 100644 index 0000000..3daf9c8 --- /dev/null +++ b/frontend/src/components/admin/components/contents/TreeRow.tsx @@ -0,0 +1,103 @@ +import type { PermTreeNode } from '@common/services/authApi'; +import type { PermState, OperCode } from '../PermissionsPanel'; +import { OPER_CODES, OPER_FULL_LABELS, makeKey } from '../PermissionsPanel'; +import { PermCell } from './PermCell'; + +interface TreeRowProps { + node: PermTreeNode; + stateMap: Map; + expanded: Set; + onToggleExpand: (code: string) => void; + onTogglePerm: (code: string, oper: OperCode, currentState: PermState) => void; + readOnly?: boolean; +} + +export function TreeRow({ + node, + stateMap, + expanded, + onToggleExpand, + onTogglePerm, + readOnly = false, +}: TreeRowProps) { + const hasChildren = node.children.length > 0; + const isExpanded = expanded.has(node.code); + const indent = node.level * 16; + + // 이 노드의 READ 상태 (CUD 비활성 판단용) + const readState = stateMap.get(makeKey(node.code, 'READ')) ?? 'forced-denied'; + const readDenied = readState === 'explicit-denied' || readState === 'forced-denied'; + + return ( + <> + + +
+ {hasChildren ? ( + + ) : ( + + {node.level > 0 ? '├' : ''} + + )} + {node.icon && {node.icon}} +
+
+ {node.name} +
+
+
+ + {OPER_CODES.map((oper) => { + const key = makeKey(node.code, oper); + const state = stateMap.get(key) ?? 'forced-denied'; + // READ 거부 시 CUD도 강제 거부 + const effectiveState = + oper !== 'READ' && readDenied ? ('forced-denied' as PermState) : state; + return ( + +
+ onTogglePerm(node.code, oper, effectiveState)} + readOnly={readOnly} + /> +
+ + ); + })} + + {hasChildren && + isExpanded && + node.children.map((child: PermTreeNode) => ( + + ))} + + ); +} diff --git a/frontend/src/components/admin/components/contents/UserDetailModal.tsx b/frontend/src/components/admin/components/contents/UserDetailModal.tsx new file mode 100644 index 0000000..7e7f3c0 --- /dev/null +++ b/frontend/src/components/admin/components/contents/UserDetailModal.tsx @@ -0,0 +1,293 @@ +import { useState } from 'react'; +import { changePasswordApi, updateUserApi, type UserListItem, type OrgItem, type RoleWithPermissions } from '@common/services/authApi'; +import { statusLabels } from '../adminConstants'; +import { formatDate } from '../UsersPanel'; + +interface UserDetailModalProps { + user: UserListItem; + allRoles: RoleWithPermissions[]; + allOrgs: OrgItem[]; + onClose: () => void; + onUpdated: () => void; +} + +export function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalProps) { + const [name, setName] = useState(user.name); + const [rank, setRank] = useState(user.rank || ''); + const [orgSn, setOrgSn] = useState(user.orgSn ?? ''); + const [saving, setSaving] = useState(false); + const [newPassword, setNewPassword] = useState(''); + const [resetPwLoading, setResetPwLoading] = useState(false); + const [resetPwDone, setResetPwDone] = useState(false); + const [unlockLoading, setUnlockLoading] = useState(false); + const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null); + + const handleSaveInfo = async () => { + setSaving(true); + setMessage(null); + try { + await updateUserApi(user.id, { + name: name.trim(), + rank: rank.trim() || undefined, + orgSn: orgSn !== '' ? orgSn : null, + }); + setMessage({ text: '사용자 정보가 수정되었습니다.', type: 'success' }); + onUpdated(); + } catch { + setMessage({ text: '사용자 정보 수정에 실패했습니다.', type: 'error' }); + } finally { + setSaving(false); + } + }; + + const handleResetPassword = async () => { + if (!newPassword.trim()) { + setMessage({ text: '새 비밀번호를 입력하세요.', type: 'error' }); + return; + } + setResetPwLoading(true); + setMessage(null); + try { + await changePasswordApi(user.id, newPassword); + setMessage({ text: '비밀번호가 초기화되었습니다.', type: 'success' }); + setResetPwDone(true); + setNewPassword(''); + } catch { + setMessage({ text: '비밀번호 초기화에 실패했습니다.', type: 'error' }); + } finally { + setResetPwLoading(false); + } + }; + + const handleUnlock = async () => { + setUnlockLoading(true); + setMessage(null); + try { + await updateUserApi(user.id, { status: 'ACTIVE' }); + setMessage({ text: '계정 잠금이 해제되었습니다.', type: 'success' }); + onUpdated(); + } catch { + setMessage({ text: '잠금 해제에 실패했습니다.', type: 'error' }); + } finally { + setUnlockLoading(false); + } + }; + + return ( +
+
+ {/* 헤더 */} +
+
+

사용자 정보

+

{user.account}

+
+ +
+ +
+ {/* 기본 정보 수정 */} +
+

+ 기본 정보 수정 +

+
+
+ + setName(e.target.value)} + className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean" + /> +
+
+
+ + setRank(e.target.value)} + placeholder="예: 팀장" + className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean" + /> +
+
+ + +
+
+ +
+
+ + {/* 구분선 */} +
+ + {/* 비밀번호 초기화 */} +
+

+ 비밀번호 초기화 +

+
+
+ + setNewPassword(e.target.value)} + placeholder="새 비밀번호 입력" + className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono" + /> +
+ + +
+

+ 초기화 후 사용자에게 새 비밀번호를 전달하세요. +

+
+ + {/* 구분선 */} +
+ + {/* 계정 잠금 해제 */} +
+

계정 상태

+
+
+
+ + + {(statusLabels[user.status] || statusLabels.INACTIVE).label} + + {user.failCount > 0 && ( + + (로그인 실패 {user.failCount}회) + + )} +
+ {user.status === 'LOCKED' && ( +

+ 비밀번호 5회 이상 오류로 잠금 처리됨 +

+ )} +
+ {user.status === 'LOCKED' && ( + + )} +
+
+ + {/* 기타 정보 (읽기 전용) */} +
+

기타 정보

+
+
+ 이메일: + {user.email || '-'} +
+
+ OAuth: + {user.oauthProvider || '-'} +
+
+ 최종 로그인: + + {user.lastLogin ? formatDate(user.lastLogin) : '-'} + +
+
+ 등록일: + {formatDate(user.regDtm)} +
+
+
+ + {/* 메시지 */} + {message && ( +
+ {message.text} +
+ )} +
+ + {/* 푸터 */} +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/admin/components/contents/UserPermTab.tsx b/frontend/src/components/admin/components/contents/UserPermTab.tsx new file mode 100644 index 0000000..3ffd7fe --- /dev/null +++ b/frontend/src/components/admin/components/contents/UserPermTab.tsx @@ -0,0 +1,336 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { UserListItem, PermTreeNode, RoleWithPermissions } from '@common/services/authApi'; +import { fetchUsers, assignRolesApi } from '@common/services/authApi'; +import { getRoleColor } from '../adminConstants'; +import type { OperCode, PermState } from '../PermissionsPanel'; +import { OPER_CODES, OPER_FULL_LABELS, OPER_LABELS, flattenTree, buildEffectiveStates } from '../PermissionsPanel'; +import { TreeRow } from './TreeRow'; +import { PermLegend } from './PermLegend'; + +interface UserPermTabProps { + roles: RoleWithPermissions[]; + permTree: PermTreeNode[]; + rolePerms: Map>; +} + +export function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) { + const [users, setUsers] = useState([]); + const [loadingUsers, setLoadingUsers] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [showDropdown, setShowDropdown] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [assignedRoleSns, setAssignedRoleSns] = useState([]); + const [savingRoles, setSavingRoles] = useState(false); + const [rolesDirty, setRolesDirty] = useState(false); + const [expanded, setExpanded] = useState>(new Set()); + const dropdownRef = useRef(null); + + const flatNodes = flattenTree(permTree); + + useEffect(() => { + const loadUsers = async () => { + setLoadingUsers(true); + try { + const data = await fetchUsers(); + setUsers(data); + } catch (err) { + console.error('사용자 목록 조회 실패:', err); + } finally { + setLoadingUsers(false); + } + }; + loadUsers(); + }, []); + + // 최상위 노드 기본 펼침 + useEffect(() => { + if (permTree.length > 0) { + setExpanded(new Set(permTree.map((n) => n.code))); + } + }, [permTree]); + + // 드롭다운 외부 클릭 시 닫기 + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setShowDropdown(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const filteredUsers = users.filter((u) => { + if (!searchQuery) return true; + const q = searchQuery.toLowerCase(); + return ( + u.name.toLowerCase().includes(q) || + u.account.toLowerCase().includes(q) || + (u.orgName?.toLowerCase().includes(q) ?? false) + ); + }); + + const handleSelectUser = (user: UserListItem) => { + setSelectedUser(user); + setSearchQuery(user.name); + setShowDropdown(false); + setAssignedRoleSns(user.roleSns ?? []); + setRolesDirty(false); + }; + + const handleToggleRole = (roleSn: number) => { + setAssignedRoleSns((prev) => { + const next = prev.includes(roleSn) ? prev.filter((sn) => sn !== roleSn) : [...prev, roleSn]; + return next; + }); + setRolesDirty(true); + }; + + const handleSaveRoles = async () => { + if (!selectedUser) return; + setSavingRoles(true); + try { + await assignRolesApi(selectedUser.id, assignedRoleSns); + setRolesDirty(false); + // 로컬 users 상태 갱신 + setUsers((prev) => + prev.map((u) => + u.id === selectedUser.id + ? { + ...u, + roleSns: assignedRoleSns, + roles: roles.filter((r) => assignedRoleSns.includes(r.sn)).map((r) => r.name), + } + : u, + ), + ); + setSelectedUser((prev) => + prev + ? { + ...prev, + roleSns: assignedRoleSns, + roles: roles.filter((r) => assignedRoleSns.includes(r.sn)).map((r) => r.name), + } + : null, + ); + } catch (err) { + console.error('역할 저장 실패:', err); + } finally { + setSavingRoles(false); + } + }; + + const handleToggleExpand = useCallback((code: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(code)) next.delete(code); + else next.add(code); + return next; + }); + }, []); + + // 사용자의 유효 권한: 할당된 역할들의 권한 병합 (OR 결합) + const effectiveStateMap = (() => { + if (!selectedUser || assignedRoleSns.length === 0) { + return new Map(); + } + + // 각 역할의 명시적 권한 병합: 어느 역할이든 granted=true면 허용 + const mergedPerms = new Map(); + for (const roleSn of assignedRoleSns) { + const perms = rolePerms.get(roleSn); + if (!perms) continue; + for (const [key, granted] of perms) { + if (granted) { + mergedPerms.set(key, true); + } else if (!mergedPerms.has(key)) { + mergedPerms.set(key, false); + } + } + } + + return buildEffectiveStates(flatNodes, mergedPerms); + })(); + + const noOpToggle = useCallback((_code: string, _oper: OperCode, _state: PermState): void => { + void _code; + void _oper; + void _state; + // 읽기 전용 — 토글 없음 + }, []); + + return ( +
+ {/* 사용자 검색/선택 */} +
+ +
+ { + setSearchQuery(e.target.value); + setShowDropdown(true); + if (selectedUser && e.target.value !== selectedUser.name) { + setSelectedUser(null); + setAssignedRoleSns([]); + setRolesDirty(false); + } + }} + onFocus={() => setShowDropdown(true)} + placeholder={loadingUsers ? '불러오는 중...' : '이름, 계정, 조직으로 검색...'} + disabled={loadingUsers} + className="w-full max-w-sm px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean disabled:opacity-50" + /> + {showDropdown && filteredUsers.length > 0 && ( +
+ {filteredUsers.map((user) => ( + + ))} +
+ )} + {showDropdown && !loadingUsers && filteredUsers.length === 0 && searchQuery && ( +
+ 검색 결과 없음 +
+ )} +
+
+ + {selectedUser ? ( + <> + {/* 역할 할당 섹션 */} +
+
+ 역할 할당 + +
+
+ {roles.map((role, idx) => { + const color = getRoleColor(role.code, idx); + const isChecked = assignedRoleSns.includes(role.sn); + return ( + + ); + })} +
+
+ + {/* 유효 권한 매트릭스 (읽기 전용) */} +
+ 유효 권한 (읽기 전용) + — 할당된 역할의 권한 합산 결과 +
+ + + + {assignedRoleSns.length > 0 ? ( +
+ + + + + {OPER_CODES.map((oper) => ( + + ))} + + + + {permTree.map((rootNode) => ( + + ))} + +
+ 기능 + +
+ {OPER_LABELS[oper]} +
+
+ {OPER_FULL_LABELS[oper]} +
+
+
+ ) : ( +
+ 역할을 하나 이상 할당하면 유효 권한이 표시됩니다 +
+ )} + + ) : ( +
+ 사용자를 선택하세요 +
+ )} +
+ ); +} diff --git a/frontend/src/components/admin/components/contents/WizardModal.tsx b/frontend/src/components/admin/components/contents/WizardModal.tsx new file mode 100644 index 0000000..0a252e9 --- /dev/null +++ b/frontend/src/components/admin/components/contents/WizardModal.tsx @@ -0,0 +1,112 @@ +import { useState, useCallback } from 'react'; +import type { WizardState } from '../DeidentifyPanel'; +import { INITIAL_WIZARD } from '../DeidentifyPanel'; +import { StepIndicator } from './StepIndicator'; +import { Step1 } from './Step1'; +import { Step2 } from './Step2'; +import { Step3 } from './Step3'; +import { Step4 } from './Step4'; +import { Step5 } from './Step5'; + +interface WizardModalProps { + onClose: () => void; + onSubmit: (wizard: WizardState) => void; +} + +export function WizardModal({ onClose, onSubmit }: WizardModalProps) { + const [wizard, setWizard] = useState(INITIAL_WIZARD); + + const patch = useCallback((update: Partial) => { + setWizard((prev) => ({ ...prev, ...update })); + }, []); + + const handleNext = () => { + if (wizard.step < 5) patch({ step: wizard.step + 1 }); + }; + const handlePrev = () => { + if (wizard.step > 1) patch({ step: wizard.step - 1 }); + }; + const handleSubmit = () => { + onSubmit(wizard); + onClose(); + }; + + const canProceed = () => { + if (wizard.step === 1) return wizard.taskName.trim().length > 0; + if (wizard.step === 2) return wizard.fields.some((f) => f.selected); + if (wizard.step === 5) return wizard.confirmed; + return true; + }; + + return ( +
+
+ {/* 모달 헤더 */} +
+

새 비식별화 작업

+ +
+ + {/* 단계 표시기 */} + + + {/* 단계 내용 */} +
+ {wizard.step === 1 && } + {wizard.step === 2 && } + {wizard.step === 3 && } + {wizard.step === 4 && } + {wizard.step === 5 && } +
+ + {/* 푸터 버튼 */} +
+ +
+ + {wizard.step < 5 ? ( + + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/tabs/admin/index.ts b/frontend/src/components/admin/index.ts similarity index 100% rename from frontend/src/tabs/admin/index.ts rename to frontend/src/components/admin/index.ts diff --git a/frontend/src/tabs/admin/services/monitorApi.ts b/frontend/src/components/admin/services/monitorApi.ts similarity index 100% rename from frontend/src/tabs/admin/services/monitorApi.ts rename to frontend/src/components/admin/services/monitorApi.ts diff --git a/frontend/src/components/aerial/components/AerialTheoryView.tsx b/frontend/src/components/aerial/components/AerialTheoryView.tsx new file mode 100644 index 0000000..aee0345 --- /dev/null +++ b/frontend/src/components/aerial/components/AerialTheoryView.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react'; +import { PanelOverview } from './contents/PanelOverview'; +import { PanelDetection } from './contents/PanelDetection'; +import { PanelRemoteSensing } from './contents/PanelRemoteSensing'; +import { PanelESIMap } from './contents/PanelESIMap'; +import { PanelAreaCalc } from './contents/PanelAreaCalc'; +import { PanelSpreadModel } from './contents/PanelSpreadModel'; +import { PanelReferences } from './contents/PanelReferences'; + +const panels = [ + { id: 0, icon: '🌐', label: '개요' }, + { id: 1, icon: '🛸', label: '탐지 장비' }, + { id: 2, icon: '🛰', label: '원격탐사' }, + { id: 3, icon: '🗺️', label: 'ESI 방제지도' }, + { id: 4, icon: '📏', label: '면적 산정' }, + { id: 5, icon: '🔗', label: '확산예측 연계' }, + { id: 6, icon: '📚', label: '논문·특허' }, +]; + + +export function AerialTheoryView() { + const [activePanel, setActivePanel] = useState(0); + + return ( +
+
+ {/* 헤더 */} +
+
+
+ 📐 +
+
+
해양 항공탐색 · 원격탐사 이론
+
+ 유출유 원격탐지 · 항공감시 기법 · ESI 방제정보지도 · 등록특허 10-1567431 기반 +
+
+
+
+ + {/* 내부 네비게이션 */} +
+ {panels.map((p) => ( + + ))} +
+ + {/* 패널 */} + {activePanel === 0 && } + {activePanel === 1 && } + {activePanel === 2 && } + {activePanel === 3 && } + {activePanel === 4 && } + {activePanel === 5 && } + {activePanel === 6 && } +
+
+ ); +} diff --git a/frontend/src/tabs/aerial/components/AerialView.tsx b/frontend/src/components/aerial/components/AerialView.tsx old mode 100755 new mode 100644 similarity index 100% rename from frontend/src/tabs/aerial/components/AerialView.tsx rename to frontend/src/components/aerial/components/AerialView.tsx diff --git a/frontend/src/tabs/aerial/components/CCTVPlayer.tsx b/frontend/src/components/aerial/components/CCTVPlayer.tsx similarity index 100% rename from frontend/src/tabs/aerial/components/CCTVPlayer.tsx rename to frontend/src/components/aerial/components/CCTVPlayer.tsx diff --git a/frontend/src/tabs/aerial/components/CctvView.tsx b/frontend/src/components/aerial/components/CctvView.tsx similarity index 99% rename from frontend/src/tabs/aerial/components/CctvView.tsx rename to frontend/src/components/aerial/components/CctvView.tsx index 8e08242..7764f06 100644 --- a/frontend/src/tabs/aerial/components/CctvView.tsx +++ b/frontend/src/components/aerial/components/CctvView.tsx @@ -3,10 +3,10 @@ import { Map, Marker, Popup } from '@vis.gl/react-maplibre'; import 'maplibre-gl/dist/maplibre-gl.css'; import { fetchCctvCameras } from '../services/aerialApi'; import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; -import { S57EncOverlay } from '@common/components/map/S57EncOverlay'; +import { S57EncOverlay } from '@components/common/map/S57EncOverlay'; import { useMapStore } from '@common/store/mapStore'; -import { BaseMap } from '@common/components/map/BaseMap'; -import type { CctvCameraItem } from '../services/aerialApi'; +import { BaseMap } from '@components/common/map/BaseMap'; +import type { CctvCameraItem } from '@interfaces/aerial/AerialInterface'; import { CCTVPlayer } from './CCTVPlayer'; import type { CCTVPlayerHandle } from './CCTVPlayer'; diff --git a/frontend/src/tabs/aerial/components/MediaManagement.tsx b/frontend/src/components/aerial/components/MediaManagement.tsx similarity index 99% rename from frontend/src/tabs/aerial/components/MediaManagement.tsx rename to frontend/src/components/aerial/components/MediaManagement.tsx index 93c21c6..014fd05 100644 --- a/frontend/src/tabs/aerial/components/MediaManagement.tsx +++ b/frontend/src/components/aerial/components/MediaManagement.tsx @@ -5,7 +5,7 @@ import { getAerialMediaViewUrl, uploadAerialMedia, } from '../services/aerialApi'; -import type { AerialMediaItem } from '../services/aerialApi'; +import type { AerialMediaItem } from '@interfaces/aerial/AerialInterface'; import { navigateToTab } from '@common/hooks/useSubMenu'; // ── Helpers ── diff --git a/frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx b/frontend/src/components/aerial/components/OilAreaAnalysis.tsx similarity index 99% rename from frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx rename to frontend/src/components/aerial/components/OilAreaAnalysis.tsx index 9d08323..c273bc6 100644 --- a/frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx +++ b/frontend/src/components/aerial/components/OilAreaAnalysis.tsx @@ -1,7 +1,7 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import * as exifr from 'exifr'; import { stitchImages } from '../services/aerialApi'; -import { analyzeImage } from '@tabs/prediction/services/predictionApi'; +import { analyzeImage } from '@components/prediction/services/predictionApi'; import { setPendingImageAnalysis } from '@common/utils/imageAnalysisSignal'; import { navigateToTab } from '@common/hooks/useSubMenu'; import { decimalToDMS } from '@common/utils/coordinates'; diff --git a/frontend/src/tabs/aerial/components/OilDetectionOverlay.tsx b/frontend/src/components/aerial/components/OilDetectionOverlay.tsx similarity index 98% rename from frontend/src/tabs/aerial/components/OilDetectionOverlay.tsx rename to frontend/src/components/aerial/components/OilDetectionOverlay.tsx index 7fc0fcf..9a1f2ba 100644 --- a/frontend/src/tabs/aerial/components/OilDetectionOverlay.tsx +++ b/frontend/src/components/aerial/components/OilDetectionOverlay.tsx @@ -1,5 +1,5 @@ import { useRef, useEffect, memo } from 'react'; -import type { OilDetectionResult } from '../utils/oilDetection'; +import type { OilDetectionResult } from '@interfaces/aerial/AerialInterface'; import { OIL_CLASSES, OIL_CLASS_NAMES } from '../utils/oilDetection'; export interface OilDetectionOverlayProps { diff --git a/frontend/src/tabs/aerial/components/RealtimeDrone.tsx b/frontend/src/components/aerial/components/RealtimeDrone.tsx similarity index 99% rename from frontend/src/tabs/aerial/components/RealtimeDrone.tsx rename to frontend/src/components/aerial/components/RealtimeDrone.tsx index ac5954b..a34f392 100644 --- a/frontend/src/tabs/aerial/components/RealtimeDrone.tsx +++ b/frontend/src/components/aerial/components/RealtimeDrone.tsx @@ -2,11 +2,11 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { Map, Marker, Popup } from '@vis.gl/react-maplibre'; import 'maplibre-gl/dist/maplibre-gl.css'; import { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi'; -import type { DroneStreamItem } from '../services/aerialApi'; +import type { DroneStreamItem } from '@interfaces/aerial/AerialInterface'; import { CCTVPlayer } from './CCTVPlayer'; import type { CCTVPlayerHandle } from './CCTVPlayer'; import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; -import { S57EncOverlay } from '@common/components/map/S57EncOverlay'; +import { S57EncOverlay } from '@components/common/map/S57EncOverlay'; import { useMapStore } from '@common/store/mapStore'; /** 함정 위치 + 드론 비행 위치 */ diff --git a/frontend/src/tabs/aerial/components/SatelliteRequest.tsx b/frontend/src/components/aerial/components/SatelliteRequest.tsx similarity index 99% rename from frontend/src/tabs/aerial/components/SatelliteRequest.tsx rename to frontend/src/components/aerial/components/SatelliteRequest.tsx index 8a16616..162a555 100644 --- a/frontend/src/tabs/aerial/components/SatelliteRequest.tsx +++ b/frontend/src/components/aerial/components/SatelliteRequest.tsx @@ -3,12 +3,12 @@ import { Map, Source, Layer } from '@vis.gl/react-maplibre'; import 'maplibre-gl/dist/maplibre-gl.css'; import { Marker } from '@vis.gl/react-maplibre'; import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; -import { S57EncOverlay } from '@common/components/map/S57EncOverlay'; +import { S57EncOverlay } from '@components/common/map/S57EncOverlay'; import { useMapStore } from '@common/store/mapStore'; import { fetchSatellitePasses } from '../services/aerialApi'; const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY || ''; -import type { SatellitePass } from '../services/aerialApi'; +import type { SatellitePass } from '@interfaces/aerial/AerialInterface'; interface SatRequest { id: string; diff --git a/frontend/src/tabs/aerial/components/SensorAnalysis.tsx b/frontend/src/components/aerial/components/SensorAnalysis.tsx similarity index 100% rename from frontend/src/tabs/aerial/components/SensorAnalysis.tsx rename to frontend/src/components/aerial/components/SensorAnalysis.tsx diff --git a/frontend/src/components/aerial/components/WingAI.tsx b/frontend/src/components/aerial/components/WingAI.tsx new file mode 100644 index 0000000..b8d51cf --- /dev/null +++ b/frontend/src/components/aerial/components/WingAI.tsx @@ -0,0 +1,101 @@ +import { useState } from 'react'; +import { useControl } from '@vis.gl/react-maplibre'; +import { MapboxOverlay } from '@deck.gl/mapbox'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import { DetectPanel } from './contents/DetectPanel'; +import { ChangeDetectPanel } from './contents/ChangeDetectPanel'; +import { AoiPanel } from './contents/AoiPanel'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function DeckGLOverlay({ layers }: { layers: any[] }) { + const overlay = useControl(() => new MapboxOverlay({ interleaved: true })); + overlay.setProps({ layers }); + return null; +} + +type WingAITab = 'detect' | 'change' | 'aoi'; + +const tabItems: { id: WingAITab; label: string; icon: string; desc: string }[] = [ + { + id: 'detect', + label: '객체 탐지', + icon: '🎯', + desc: '위성/드론 영상에서 선박·차량·시설물 자동 탐지 및 분류', + }, + { + id: 'change', + label: '변화 감지', + icon: '🔄', + desc: '동일 지역 다시점 영상 비교 분석 (Before/After)', + }, + { + id: 'aoi', + label: '연안자동감지', + icon: '📍', + desc: '연안 관심지역 등록 → 변화 자동 감지 및 알림', + }, +]; + +export function WingAI() { + const [activeTab, setActiveTab] = useState('detect'); + + return ( +
+ {/* 헤더 */} +
+
+
+ 🤖 +
+
AI 탐지/분석
+ {/* + WingAI + */} +
+
+ {tabItems.map((t) => ( + + ))} +
+
+ + {/* 탭 콘텐츠 */} + {activeTab === 'detect' && } + {activeTab === 'change' && } + {activeTab === 'aoi' && } +
+ ); +} diff --git a/frontend/src/components/aerial/components/contents/AoiPanel.tsx b/frontend/src/components/aerial/components/contents/AoiPanel.tsx new file mode 100644 index 0000000..607d0f0 --- /dev/null +++ b/frontend/src/components/aerial/components/contents/AoiPanel.tsx @@ -0,0 +1,800 @@ +import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; +import { Map } from '@vis.gl/react-maplibre'; +import { PolygonLayer, ScatterplotLayer } from '@deck.gl/layers'; +import type { MapMouseEvent } from 'maplibre-gl'; +import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; +import { useMapStore } from '@common/store/mapStore'; +import { S57EncOverlay } from '@components/common/map/S57EncOverlay'; +import { DeckGLOverlay } from '../WingAI'; + +type ZoneStatus = '정상' | '경보' | '주의'; +type MonitorSource = 'satellite' | 'cctv' | 'drone' | 'ais'; + +interface MonitorSourceConfig { + id: MonitorSource; + label: string; + icon: string; + color: string; + desc: string; +} + +const MONITOR_SOURCES: MonitorSourceConfig[] = [ + { + id: 'satellite', + label: '위성영상', + icon: '🛰', + color: 'var(--color-accent)', + desc: 'KOMPSAT/Sentinel 주기 촬영', + }, + { + id: 'cctv', + label: 'CCTV', + icon: '📹', + color: 'var(--color-info)', + desc: 'KHOA/KBS 해안 CCTV 실시간', + }, + { + id: 'drone', + label: '드론', + icon: '🛸', + color: 'var(--color-success)', + desc: '드론 정밀 촬영 / 열화상', + }, + { + id: 'ais', + label: 'AIS', + icon: '🚢', + color: 'var(--color-warning)', + desc: '선박 위치·항적 실시간 수신', + }, +]; + +interface MonitorZone { + id: string; + name: string; + interval: string; + lastCheck: string; + status: ZoneStatus; + alerts: number; + polygon: [number, number][]; + color: string; + monitoring: boolean; + /** 활성 모니터링 소스 */ + sources: MonitorSource[]; +} + +const INTERVAL_OPTIONS = ['1h', '3h', '6h', '12h', '24h']; + +const ZONE_COLORS = [ + 'var(--color-accent)', + 'var(--color-info)', + 'var(--color-success)', + 'var(--color-warning)', + 'var(--color-danger)', + 'var(--color-danger)', + 'var(--color-accent)', +]; + +const INITIAL_ZONES: MonitorZone[] = [ + { + id: 'AOI-001', + name: '여수항 반경', + interval: '6h', + lastCheck: '03-16 14:00', + status: '정상', + alerts: 0, + monitoring: true, + color: 'var(--color-info)', + polygon: [ + [127.68, 34.78], + [127.78, 34.78], + [127.78, 34.7], + [127.68, 34.7], + ], + sources: ['satellite', 'cctv', 'ais'], + }, + { + id: 'AOI-002', + name: '제주 서귀포 해상', + interval: '3h', + lastCheck: '03-16 13:30', + status: '경보', + alerts: 2, + monitoring: true, + color: 'var(--color-danger)', + polygon: [ + [126.45, 33.28], + [126.58, 33.28], + [126.58, 33.2], + [126.45, 33.2], + ], + sources: ['satellite', 'drone', 'cctv', 'ais'], + }, + { + id: 'AOI-003', + name: '부산항 외항', + interval: '12h', + lastCheck: '03-16 08:00', + status: '정상', + alerts: 0, + monitoring: true, + color: 'var(--color-success)', + polygon: [ + [129.05, 35.12], + [129.2, 35.12], + [129.2, 35.05], + [129.05, 35.05], + ], + sources: ['cctv', 'ais'], + }, + { + id: 'AOI-004', + name: '통영 ~ 거제 해역', + interval: '24h', + lastCheck: '03-15 20:00', + status: '주의', + alerts: 1, + monitoring: true, + color: 'var(--color-warning)', + polygon: [ + [128.3, 34.9], + [128.65, 34.9], + [128.65, 34.8], + [128.3, 34.8], + ], + sources: ['satellite', 'drone'], + }, +]; + +export function AoiPanel() { + const [zones, setZones] = useState(INITIAL_ZONES); + const [selectedZone, setSelectedZone] = useState(null); + const currentMapStyle = useBaseMapStyle(); + const mapToggles = useMapStore((s) => s.mapToggles); + // 드로잉 상태 + const [isDrawing, setIsDrawing] = useState(false); + const [drawingPoints, setDrawingPoints] = useState<[number, number][]>([]); + // 등록 폼 + const [showForm, setShowForm] = useState(false); + const [formName, setFormName] = useState(''); + // 실시간 시뮬 + const [now, setNow] = useState(() => new Date()); + + // 시계 갱신 (1분마다) + useEffect(() => { + const t = setInterval(() => setNow(new Date()), 60_000); + return () => clearInterval(t); + }, []); + + const nextId = useRef(zones.length + 1); + + // 지도 클릭 → 폴리곤 포인트 수집 + const handleMapClick = useCallback( + (e: MapMouseEvent) => { + if (!isDrawing) return; + setDrawingPoints((prev) => [...prev, [e.lngLat.lng, e.lngLat.lat]]); + }, + [isDrawing], + ); + + // 드로잉 시작 + const startDrawing = () => { + setDrawingPoints([]); + setIsDrawing(true); + setShowForm(false); + setSelectedZone(null); + }; + + // 드로잉 완료 → 폼 표시 + const finishDrawing = () => { + if (drawingPoints.length < 3) return; + setIsDrawing(false); + setShowForm(true); + setFormName(''); + }; + + // 드로잉 취소 + const cancelDrawing = () => { + setIsDrawing(false); + setDrawingPoints([]); + setShowForm(false); + }; + + // 구역 등록 (이름만 → 등록 후 설정) + const registerZone = () => { + if (!formName.trim() || drawingPoints.length < 3) return; + const newId = `AOI-${String(nextId.current++).padStart(3, '0')}`; + const newZone: MonitorZone = { + id: newId, + name: formName.trim(), + interval: '6h', + lastCheck: `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`, + status: '정상', + alerts: 0, + polygon: [...drawingPoints], + color: ZONE_COLORS[zones.length % ZONE_COLORS.length], + monitoring: true, + sources: ['satellite', 'cctv'], + }; + setZones((prev) => [...prev, newZone]); + setDrawingPoints([]); + setShowForm(false); + setSelectedZone(newId); + }; + + // 구역 설정 변경 + const updateZone = (id: string, patch: Partial) => { + setZones((prev) => prev.map((z) => (z.id === id ? { ...z, ...patch } : z))); + }; + + // 모니터링 소스 토글 + const toggleSource = (id: string, src: MonitorSource) => { + setZones((prev) => + prev.map((z) => { + if (z.id !== id) return z; + const has = z.sources.includes(src); + return { ...z, sources: has ? z.sources.filter((s) => s !== src) : [...z.sources, src] }; + }), + ); + }; + + // 모니터링 토글 + const toggleMonitoring = (id: string) => { + setZones((prev) => prev.map((z) => (z.id === id ? { ...z, monitoring: !z.monitoring } : z))); + }; + + // 구역 삭제 + const removeZone = (id: string) => { + setZones((prev) => prev.filter((z) => z.id !== id)); + if (selectedZone === id) setSelectedZone(null); + }; + + const statusStyle = (s: ZoneStatus) => { + if (s === '경보') + return { + background: 'color-mix(in srgb, var(--color-danger) 12%, transparent)', + color: 'var(--color-danger)', + border: '1px solid color-mix(in srgb, var(--color-danger) 25%, transparent)', + }; + if (s === '주의') + return { + background: 'color-mix(in srgb, var(--color-caution) 12%, transparent)', + color: 'var(--color-caution)', + border: '1px solid color-mix(in srgb, var(--color-caution) 25%, transparent)', + }; + return { + background: 'color-mix(in srgb, var(--color-success) 12%, transparent)', + color: 'var(--color-success)', + border: '1px solid color-mix(in srgb, var(--color-success) 25%, transparent)', + }; + }; + + // deck.gl 레이어: 등록된 폴리곤 + 드로잉 중 포인트 + const deckLayers = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result: any[] = []; + + // 등록된 구역 폴리곤 + const visibleZones = zones.filter((z) => z.monitoring); + if (visibleZones.length > 0) { + result.push( + new PolygonLayer({ + id: 'aoi-zones', + data: visibleZones, + getPolygon: (d: MonitorZone) => [...d.polygon, d.polygon[0]], + getFillColor: (d: MonitorZone) => { + const hex = d.color; + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + const alpha = selectedZone === d.id ? 60 : 30; + return [r, g, b, alpha]; + }, + getLineColor: (d: MonitorZone) => { + const hex = d.color; + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return [r, g, b, selectedZone === d.id ? 220 : 140]; + }, + getLineWidth: (d: MonitorZone) => (selectedZone === d.id ? 3 : 1.5), + lineWidthUnits: 'pixels', + pickable: true, + onClick: ({ object }: { object: MonitorZone }) => { + if (object && !isDrawing) + setSelectedZone(object.id === selectedZone ? null : object.id); + }, + updateTriggers: { + getFillColor: [selectedZone], + getLineColor: [selectedZone], + getLineWidth: [selectedZone], + }, + }), + ); + + // 경보/주의 구역 중심점 펄스 + const alertZones = visibleZones.filter((z) => z.status !== '정상'); + if (alertZones.length > 0) { + result.push( + new ScatterplotLayer({ + id: 'aoi-alert-pulse', + data: alertZones, + getPosition: (d: MonitorZone) => { + const lngs = d.polygon.map((p) => p[0]); + const lats = d.polygon.map((p) => p[1]); + return [ + (Math.min(...lngs) + Math.max(...lngs)) / 2, + (Math.min(...lats) + Math.max(...lats)) / 2, + ]; + }, + getRadius: 8000, + radiusUnits: 'meters' as const, + getFillColor: (d: MonitorZone) => + d.status === '경보' ? [239, 68, 68, 80] : [234, 179, 8, 60], + getLineColor: (d: MonitorZone) => + d.status === '경보' ? [239, 68, 68, 180] : [234, 179, 8, 150], + lineWidthMinPixels: 2, + stroked: true, + }), + ); + } + } + + // 드로잉 중 포인트 + if (drawingPoints.length > 0) { + result.push( + new ScatterplotLayer({ + id: 'drawing-points', + data: drawingPoints, + getPosition: (d: [number, number]) => d, + getRadius: 5, + radiusUnits: 'pixels' as const, + getFillColor: [6, 182, 212, 220], + getLineColor: [255, 255, 255, 255], + lineWidthMinPixels: 2, + stroked: true, + }), + ); + + // 드로잉 폴리곤 미리보기 + if (drawingPoints.length >= 3) { + result.push( + new PolygonLayer({ + id: 'drawing-preview', + data: [{ polygon: [...drawingPoints, drawingPoints[0]] }], + getPolygon: (d: { polygon: [number, number][] }) => d.polygon, + getFillColor: [6, 182, 212, 25], + getLineColor: [6, 182, 212, 200], + getLineWidth: 2, + lineWidthUnits: 'pixels', + getDashArray: [4, 4], + }), + ); + } + } + + return result; + }, [zones, drawingPoints, selectedZone, isDrawing]); + + const activeMonitoring = zones.filter((z) => z.monitoring).length; + const alertCount = zones.filter((z) => z.status === '경보').length; + const warningCount = zones.filter((z) => z.status === '주의').length; + const totalAlerts = zones.reduce((s, a) => s + a.alerts, 0); + + const inputCls = 'w-full px-2.5 py-1.5 rounded text-caption font-korean outline-none border'; + const inputStyle = { + background: 'var(--bg-elevated)', + borderColor: 'var(--stroke-default)', + color: 'var(--fg-default)', + }; + + return ( +
+ {/* 통계 */} +
+ {[ + { value: String(activeMonitoring), label: '감시 구역', color: 'var(--color-info)' }, + { value: String(alertCount), label: '경보', color: 'var(--color-danger)' }, + { value: String(warningCount), label: '주의', color: 'var(--color-caution)' }, + { value: String(totalAlerts), label: '미확인 알림', color: 'var(--color-tertiary)' }, + ].map((s, i) => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+ +
+ {/* 지도 영역 */} +
+ {/* 지도 헤더 */} +
+
+
📍 연안 감시 구역
+ {isDrawing && ( + + 드로잉 모드 · 지도를 클릭하여 꼭짓점 추가 ({drawingPoints.length}점) + + )} +
+
+ {isDrawing ? ( + <> + + + + + ) : ( + + )} +
+
+ + {/* MapLibre 지도 */} +
+ + + + +
+
+ + {/* 우측 패널 */} +
+ {/* 등록 폼: 이름만 입력 → 바로 등록 */} + {showForm && drawingPoints.length >= 3 && ( +
+
+
+ 새 감시 구역 등록 +
+
+ 폴리곤 {drawingPoints.length}점 설정 완료 +
+
+
+ + setFormName(e.target.value)} + placeholder="예: 여수항 북측 해안" + className={inputCls} + style={inputStyle} + onKeyDown={(e) => { + if (e.key === 'Enter') registerZone(); + }} + autoFocus + /> +
+ + +
+
+
+ )} + + {/* 감시 구역 목록 */} +
+
+
📋 등록된 감시 구역
+
{zones.length}건
+
+
+ {zones.map((z) => { + const isOpen = selectedZone === z.id; + return ( +
+ {/* 목록 행 */} +
setSelectedZone(isOpen ? null : z.id)} + className="px-4 py-2.5 hover:bg-bg-surface-hover/30 transition-colors cursor-pointer" + style={{ background: isOpen ? 'rgba(6,182,212,.04)' : undefined }} + > +
+
+ {z.id} + + {z.name} + +
+
+ + {z.status} + + + {isOpen ? '▲' : '▼'} + +
+
+ {/* 활성 소스 배지 + 주기 */} +
+ {z.sources.map((sid) => { + const cfg = MONITOR_SOURCES.find((s) => s.id === sid)!; + return ( + + {cfg.label} + + ); + })} + + {z.interval} · {z.lastCheck} + +
+ {z.alerts > 0 && ( +
+ 미확인 알림 {z.alerts}건 +
+ )} +
+ + {/* 인라인 설정 패널 (아코디언) */} + {isOpen && ( +
+ {/* 감시 주기 */} +
+ +
+ {INTERVAL_OPTIONS.map((iv) => ( + + ))} +
+
+ + {/* 모니터링 방법 */} +
+ +
+ {MONITOR_SOURCES.map((src) => { + const active = z.sources.includes(src.id); + return ( + + ); + })} +
+ {z.sources.length === 0 && ( +
+ 최소 1개 이상의 모니터링 방법을 선택하세요 +
+ )} +
+ + {/* 하단 컨트롤 */} +
+ + + {z.polygon.length}점 · {z.sources.length}소스 · {z.interval} + + +
+
+ )} +
+ ); + })} + {zones.length === 0 && ( +
+
📍
+
+ 등록된 감시 구역이 없습니다 +
+
+ "+ 감시 구역 등록" 버튼으로 시작하세요 +
+
+ )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/aerial/components/contents/ChangeDetectPanel.tsx b/frontend/src/components/aerial/components/contents/ChangeDetectPanel.tsx new file mode 100644 index 0000000..c0040c7 --- /dev/null +++ b/frontend/src/components/aerial/components/contents/ChangeDetectPanel.tsx @@ -0,0 +1,601 @@ +import { useState } from 'react'; + +type SourceType = 'satellite' | 'cctv' | 'drone' | 'ais'; + +interface SourceConfig { + id: SourceType; + label: string; + icon: string; + color: string; + desc: string; +} + +const SOURCES: SourceConfig[] = [ + { + id: 'satellite', + label: '위성영상', + icon: '🛰', + color: 'var(--color-accent)', + desc: 'KOMPSAT-3A / Sentinel SAR 수신 영상', + }, + { + id: 'cctv', + label: 'CCTV', + icon: '📹', + color: 'var(--color-info)', + desc: 'KHOA / KBS 해안 CCTV 스냅샷', + }, + { + id: 'drone', + label: '드론', + icon: '🛸', + color: 'var(--color-success)', + desc: '정밀 촬영 / 열화상 이미지', + }, + { + id: 'ais', + label: 'AIS', + icon: '🚢', + color: 'var(--color-warning)', + desc: '선박 위치·항적·MMSI 궤적', + }, +]; + +interface ChangeRecord { + id: string; + area: string; + type: string; + date1: string; + time1: string; + date2: string; + time2: string; + severity: '심각' | '보통' | '낮음'; + detail: string; + sources: SourceType[]; + crossRef?: string; + /** 각 정보원별 AS-IS 시점 요약 */ + asIsDetail: Partial>; + /** 각 정보원별 현재 시점 요약 */ + nowDetail: Partial>; +} + +export function ChangeDetectPanel() { + const [layers, setLayers] = useState>({ + satellite: true, + cctv: true, + drone: true, + ais: true, + }); + const [sourceFilter, setSourceFilter] = useState('all'); + const [selectedChange, setSelectedChange] = useState(null); + + const toggleLayer = (id: SourceType) => setLayers((prev) => ({ ...prev, [id]: !prev[id] })); + const activeCount = Object.values(layers).filter(Boolean).length; + + const changes: ChangeRecord[] = [ + { + id: 'CHG-001', + area: '여수항 북측 해안', + type: '선박 이동', + date1: '03-14', + time1: '14:00', + date2: '03-16', + time2: '14:23', + severity: '보통', + detail: '정박 선박 3척 → 7척 (증가)', + sources: ['satellite', 'ais', 'cctv'], + crossRef: 'AIS MMSI 440123456 외 3척 신규 입항 — 위성+CCTV 동시 확인', + asIsDetail: { + satellite: '정박 선박 3척 식별', + ais: 'MMSI 3건 정박 상태', + cctv: '여수 오동도 CCTV 정상', + }, + nowDetail: { + satellite: '선박 7척 식별 (4척 증가)', + ais: 'MMSI 7건 (신규 4건 입항)', + cctv: '여수 오동도 CCTV 선박 증가 확인', + }, + }, + { + id: 'CHG-002', + area: '제주 서귀포 해상', + type: '유막 확산', + date1: '03-15', + time1: '10:30', + date2: '03-16', + time2: '14:23', + severity: '심각', + detail: '유막 면적 2.1km² → 4.8km² (확산)', + sources: ['satellite', 'drone', 'cctv', 'ais'], + crossRef: '4개 정보원 교차확인 — 유막 남동 방향 확산 일치', + asIsDetail: { + satellite: 'SAR 유막 2.1km² 탐지', + drone: '열화상 유막 경계 포착', + cctv: '서귀포 카메라 해면 이상 없음', + ais: '인근 유조선 1척 정박', + }, + nowDetail: { + satellite: 'SAR 유막 4.8km² 확산', + drone: '열화상 유막 남동 2.7km 확대', + cctv: '서귀포 카메라 해면 변색 감지', + ais: '유조선 이탈, 방제선 2척 진입', + }, + }, + { + id: 'CHG-003', + area: '부산항 외항', + type: '방제장비 배치', + date1: '03-10', + time1: '09:00', + date2: '03-16', + time2: '14:23', + severity: '낮음', + detail: '부유식 오일펜스 신규 배치 확인', + sources: ['drone', 'cctv'], + crossRef: 'CCTV 부산항 #204 + 드론 정밀 촬영 일치', + asIsDetail: { drone: '오일펜스 미배치', cctv: '부산 민락항 CCTV 해상 장비 없음' }, + nowDetail: { drone: '오일펜스 300m 배치 확인', cctv: '부산 민락항 CCTV 오일펜스 포착' }, + }, + { + id: 'CHG-004', + area: '통영 해역 남측', + type: '미식별 선박', + date1: '03-13', + time1: '22:00', + date2: '03-16', + time2: '14:23', + severity: '보통', + detail: 'AIS 미송출 선박 2척 위성 포착', + sources: ['satellite', 'ais'], + crossRef: 'AIS 미등록 — 위성 SAR 반사 신호로 탐지, 불법 조업 의심', + asIsDetail: { satellite: '해역 내 선박 신호 없음', ais: '등록 선박 0척' }, + nowDetail: { satellite: 'SAR 반사 2건 포착 (선박 추정)', ais: 'MMSI 미수신 — 미식별 선박' }, + }, + { + id: 'CHG-005', + area: '인천 연안부두', + type: '야간 이상징후', + date1: '03-15', + time1: '03:42', + date2: '03-16', + time2: '03:45', + severity: '심각', + detail: 'CCTV 야간 유출 의심 + AIS 정박선 이탈', + sources: ['cctv', 'ais'], + crossRef: 'KBS CCTV #9981 03:42 해면 반사 이상 → AIS 03:45 정박선 이탈 연계', + asIsDetail: { cctv: '인천 연안부두 CCTV 야간 정상', ais: '정박선 5척 정상 정박' }, + nowDetail: { + cctv: '03:42 해면 반사광 이상 감지', + ais: '03:45 정박선 1척 이탈 (MMSI 441987654)', + }, + }, + { + id: 'CHG-006', + area: '마라도 주변 해역', + type: '해안선 변화', + date1: '03-12', + time1: '11:00', + date2: '03-16', + time2: '11:15', + severity: '낮음', + detail: '해안 퇴적물 분포 변경 감지', + sources: ['satellite', 'drone'], + crossRef: '위성 다분광 + 드론 정밀 촬영 퇴적 방향 확인', + asIsDetail: { satellite: '해안선 퇴적 분포 기준점', drone: '미촬영' }, + nowDetail: { satellite: '퇴적 남서 방향 이동 감지', drone: '정밀 촬영으로 퇴적 경계 확인' }, + }, + ]; + + const severityStyle = (s: '심각' | '보통' | '낮음') => { + if (s === '심각') + return { + background: 'color-mix(in srgb, var(--color-danger) 12%, transparent)', + color: 'var(--color-danger)', + border: '1px solid color-mix(in srgb, var(--color-danger) 25%, transparent)', + }; + if (s === '보통') + return { + background: 'color-mix(in srgb, var(--color-caution) 12%, transparent)', + color: 'var(--color-caution)', + border: '1px solid color-mix(in srgb, var(--color-caution) 25%, transparent)', + }; + return { + background: 'color-mix(in srgb, var(--color-success) 12%, transparent)', + color: 'var(--color-success)', + border: '1px solid color-mix(in srgb, var(--color-success) 25%, transparent)', + }; + }; + + const sourceStyle = (src: SourceConfig, active = true) => + active + ? { background: `${src.color}18`, color: src.color, border: `1px solid ${src.color}40` } + : { + background: 'var(--bg-card)', + color: 'var(--fg-disabled)', + border: '1px solid var(--stroke-default)', + opacity: 0.5, + }; + + const filteredChanges = + sourceFilter === 'all' ? changes : changes.filter((c) => c.sources.includes(sourceFilter)); + + return ( +
+ {/* 레이어 토글 바 */} +
+
+ 오버레이 레이어 +
+
+ {SOURCES.map((s) => ( + + ))} +
+
+ {activeCount}/4 레이어 활성 +
+
+ + {/* AS-IS / 현재 시점 비교 뷰 */} +
+ {/* AS-IS 시점 */} +
+
+
+ + AS-IS + + + 과거 시점 + +
+
+ + +
+
+ {/* 활성 레이어 표시 */} + {/*
+ {SOURCES.filter((s) => layers[s.id]).map((s) => ( + + {s.icon} {s.label} + + ))} + {activeCount === 0 && ( + 레이어를 선택하세요 + )} +
*/} + {/* 지도 플레이스홀더 */} +
+
+
+ {SOURCES.filter((s) => layers[s.id]).map((s) => ( + {s.icon} + ))} +
+
+ 과거 시점 복합 오버레이 +
+
+ {SOURCES.filter((s) => layers[s.id]) + .map((s) => s.label) + .join(' + ')}{' '} + 통합 표시 +
+
+
+
+ + {/* 현재 시점 */} +
+
+
+ + 현재 + + + NOW + +
+ 2026-03-16 14:23 +
+ {/* 활성 레이어 표시 */} + {/*
+ {SOURCES.filter((s) => layers[s.id]).map((s) => ( + + {s.icon} {s.label} + + ))} + {activeCount === 0 && ( + 레이어를 선택하세요 + )} +
*/} + {/* 지도 플레이스홀더 */} +
+
+
+ {SOURCES.filter((s) => layers[s.id]).map((s) => ( + {s.icon} + ))} +
+
+ 현재 시점 복합 오버레이 +
+
+ {SOURCES.filter((s) => layers[s.id]) + .map((s) => s.label) + .join(' + ')}{' '} + 실시간 통합 +
+
+
+
+
+ + {/* 복합 변화 감지 목록 */} +
+
+
+ 🔄 복합 변화 감지 타임라인 +
+
+
+ + {SOURCES.map((s) => ( + + ))} +
+
+ {filteredChanges.length}건 +
+
+
+ + {/* 데이터 행 */} + {filteredChanges.map((c) => { + const isOpen = selectedChange === c.id; + return ( +
+ {/* 요약 행 */} +
setSelectedChange(isOpen ? null : c.id)} + className="grid gap-0 px-4 py-3 items-center hover:bg-bg-surface-hover/30 transition-colors cursor-pointer" + style={{ + gridTemplateColumns: '52px 1fr 200px 150px 52px', + background: isOpen ? 'rgba(6,182,212,.04)' : undefined, + }} + > +
{c.id}
+
+
+ {c.area} + + {c.type} + +
+
{c.detail}
+
+
+ {c.sources.map((sid) => { + const cfg = SOURCES.find((s) => s.id === sid)!; + return ( + + {cfg.label} + + ); + })} +
+
+ + {c.date1} {c.time1} + + + + {c.date2} {c.time2} + +
+
+ + {c.severity} + +
+
+ + {/* 펼침: 정보원별 AS-IS → 현재 상세 */} + {isOpen && ( +
+ {/* 교차검증 */} + {c.crossRef && ( +
+ + 교차검증 + {' '} + {c.crossRef} +
+ )} + {/* 정보원별 비교 그리드 */} +
+
+
+ 정보원 +
+
+ AS-IS ({c.date1} {c.time1}) +
+
+ 현재 ({c.date2} {c.time2}) +
+
+ {c.sources.map((sid) => { + const cfg = SOURCES.find((s) => s.id === sid)!; + return ( +
+
+ + {cfg.icon} {cfg.label} + +
+
+ {c.asIsDetail[sid] || '-'} +
+
+ {c.nowDetail[sid] || '-'} +
+
+ ); + })} +
+
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/aerial/components/contents/DetectPanel.tsx b/frontend/src/components/aerial/components/contents/DetectPanel.tsx new file mode 100644 index 0000000..a576518 --- /dev/null +++ b/frontend/src/components/aerial/components/contents/DetectPanel.tsx @@ -0,0 +1,360 @@ +import { useState } from 'react'; +import { Map } from '@vis.gl/react-maplibre'; +import { ScatterplotLayer } from '@deck.gl/layers'; +import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; +import { useMapStore } from '@common/store/mapStore'; +import { S57EncOverlay } from '@components/common/map/S57EncOverlay'; +import { DeckGLOverlay } from '../WingAI'; + +type MismatchStatus = '불일치' | '의심' | '정상' | '확인중'; + +interface VesselDetection { + id: string; + mmsi: string; + vesselName: string; + /** AIS 등록 선종 */ + aisType: string; + /** AI 영상 분석 선종 */ + detectedType: string; + /** 불일치 여부 */ + mismatch: boolean; + status: MismatchStatus; + confidence: string; + coord: string; + lon: number; + lat: number; + time: string; + detail: string; +} + +export function DetectPanel() { + const [selectedId, setSelectedId] = useState(null); + const [filterStatus, setFilterStatus] = useState('전체'); + const currentMapStyle = useBaseMapStyle(); + const mapToggles = useMapStore((s) => s.mapToggles); + + const detections: VesselDetection[] = [ + { + id: 'VD-001', + mmsi: '440123456', + vesselName: 'OCEAN GLORY', + aisType: '화물선', + detectedType: '유조선', + mismatch: true, + status: '불일치', + confidence: '94.2%', + coord: '33.24°N 126.50°E', + lon: 126.5, + lat: 33.24, + time: '14:23', + detail: 'AIS 화물선 등록 → 영상 분석 결과 유조선 선형 + 탱크 구조 탐지', + }, + { + id: 'VD-002', + mmsi: '441987654', + vesselName: 'SEA PHOENIX', + aisType: '유조선', + detectedType: '화물선', + mismatch: true, + status: '불일치', + confidence: '91.7%', + coord: '34.73°N 127.68°E', + lon: 127.68, + lat: 34.73, + time: '14:18', + detail: 'AIS 유조선 등록 → 영상 분석 결과 컨테이너 적재 확인, 화물선 판정', + }, + { + id: 'VD-003', + mmsi: '440555123', + vesselName: 'DONGBANG 7', + aisType: '어선', + detectedType: '화물선', + mismatch: true, + status: '의심', + confidence: '78.3%', + coord: '35.15°N 129.13°E', + lon: 129.13, + lat: 35.15, + time: '14:10', + detail: 'AIS 어선 등록 → 선체 규모 및 구조가 어선 대비 과대, 화물선 의심', + }, + { + id: 'VD-004', + mmsi: '440678901', + vesselName: 'KOREA STAR', + aisType: '화물선', + detectedType: '화물선', + mismatch: false, + status: '정상', + confidence: '97.8%', + coord: '34.80°N 126.37°E', + lon: 126.37, + lat: 34.8, + time: '14:05', + detail: 'AIS 등록 선종과 영상 분석 결과 일치', + }, + { + id: 'VD-005', + mmsi: 'N/A', + vesselName: '미식별', + aisType: 'AIS 미수신', + detectedType: '유조선', + mismatch: true, + status: '확인중', + confidence: '85.6%', + coord: '33.11°N 126.27°E', + lon: 126.27, + lat: 33.11, + time: '14:01', + detail: 'AIS 신호 없음 → 위성 SAR로 유조선급 선형 탐지, 불법 운항 의심', + }, + { + id: 'VD-006', + mmsi: '440234567', + vesselName: 'BUSAN EXPRESS', + aisType: '컨테이너선', + detectedType: '유조선', + mismatch: true, + status: '불일치', + confidence: '89.1%', + coord: '35.05°N 129.10°E', + lon: 129.1, + lat: 35.05, + time: '13:55', + detail: 'AIS 컨테이너선 → 갑판 컨테이너 미확인, 탱크 구조 감지', + }, + { + id: 'VD-007', + mmsi: '440345678', + vesselName: 'JEJU BREEZE', + aisType: '여객선', + detectedType: '여객선', + mismatch: false, + status: '정상', + confidence: '98.1%', + coord: '33.49°N 126.52°E', + lon: 126.52, + lat: 33.49, + time: '13:50', + detail: 'AIS 등록 선종과 영상 분석 결과 일치', + }, + ]; + + const mismatchCount = detections.filter((d) => d.mismatch).length; + const confirmingCount = detections.filter((d) => d.status === '확인중').length; + + const stats = [ + { value: String(detections.length), label: '분석 선박', color: 'var(--fg-default)' }, + { value: String(mismatchCount), label: '선종 불일치', color: 'var(--fg-default)' }, + { value: String(confirmingCount), label: '확인 중', color: 'var(--fg-default)' }, + { + value: String(detections.filter((d) => !d.mismatch).length), + label: '정상', + color: 'var(--fg-default)', + }, + ]; + + const filtered = + filterStatus === '전체' ? detections : detections.filter((d) => d.status === filterStatus); + + const statusStyle = (s: MismatchStatus) => { + if (s === '불일치') + return { + background: 'color-mix(in srgb, var(--color-danger) 12%, transparent)', + color: 'var(--color-danger)', + border: '1px solid color-mix(in srgb, var(--color-danger) 25%, transparent)', + }; + if (s === '의심') + return { + background: 'color-mix(in srgb, var(--color-caution) 12%, transparent)', + color: 'var(--color-caution)', + border: '1px solid color-mix(in srgb, var(--color-caution) 25%, transparent)', + }; + if (s === '확인중') + return { + background: 'rgba(6,182,212,.08)', + color: 'var(--color-accent)', + border: '1px solid rgba(6,182,212,.25)', + }; + return { + background: 'color-mix(in srgb, var(--color-success) 12%, transparent)', + color: 'var(--color-success)', + border: '1px solid color-mix(in srgb, var(--color-success) 25%, transparent)', + }; + }; + + const filters: (MismatchStatus | '전체')[] = ['전체', '불일치', '의심', '확인중', '정상']; + + return ( +
+ {/* 통계 카드 */} +
+ {stats.map((s, i) => ( +
+
+ {s.value} +
+
{s.label}
+
+ ))} +
+ +
+ {/* 탐지 결과 지도 */} +
+
+
+ 🎯 선종 불일치 탐지 지도 +
+
{filtered.length}척 표시
+
+
+ + + [d.lon, d.lat], + getRadius: (d: VesselDetection) => (selectedId === d.id ? 10 : 7), + radiusUnits: 'pixels' as const, + getFillColor: (d: VesselDetection) => { + if (d.status === '불일치') return [239, 68, 68, 200]; + if (d.status === '의심') return [234, 179, 8, 200]; + if (d.status === '확인중') return [6, 182, 212, 200]; + return [34, 197, 94, 160]; + }, + getLineColor: [255, 255, 255, 255], + lineWidthMinPixels: 2, + stroked: true, + pickable: true, + onClick: ({ object }: { object: VesselDetection }) => { + if (object) setSelectedId(object.id === selectedId ? null : object.id); + }, + updateTriggers: { getRadius: [selectedId] }, + }), + ]} + /> + + {/* 범례 */} +
+
+ {[ + { color: 'var(--color-danger)', label: '불일치' }, + { color: 'var(--color-caution)', label: '의심' }, + { color: 'var(--color-accent)', label: '확인중' }, + { color: 'var(--color-success)', label: '정상' }, + ].map((l) => ( +
+
+ {l.label} +
+ ))} +
+
+
+
+ + {/* 탐지 목록 */} +
+
+
+
+ 📋 MMSI 선종 검증 목록 +
+
{filtered.length}건
+
+
+ {filters.map((f) => ( + + ))} +
+
+
+ {filtered.map((d) => ( +
setSelectedId(selectedId === d.id ? null : d.id)} + className="px-4 py-3 hover:bg-bg-surface-hover/30 transition-colors cursor-pointer" + style={{ background: selectedId === d.id ? 'rgba(6,182,212,.04)' : undefined }} + > +
+
+ {d.id} + + {d.vesselName} + +
+ + {d.status} + +
+ {/* 선종 비교 */} +
+ AIS: {d.aisType} + {d.mismatch && } + {!d.mismatch && =} + AI: {d.detectedType} +
+
+ MMSI {d.mmsi} + {d.coord} + {d.time} + 신뢰도 {d.confidence} +
+ {/* 펼침: 상세 */} + {selectedId === d.id && ( +
+ {d.detail} +
+ )} +
+ ))} +
+
+
+
+ ); +} diff --git a/frontend/src/components/aerial/components/contents/PanelAreaCalc.tsx b/frontend/src/components/aerial/components/contents/PanelAreaCalc.tsx new file mode 100644 index 0000000..654644c --- /dev/null +++ b/frontend/src/components/aerial/components/contents/PanelAreaCalc.tsx @@ -0,0 +1,130 @@ +export function PanelAreaCalc() { + return ( +
+ {/* 면적 산정 방법론 */} +
+
+ 📏 항공 영상 기반 유출유 면적 산정 방법론 +
+
+
+
+ 픽셀 분류법 (Pixel Classification) +
+
+ 광학 영상의 각 픽셀을 반사도·색상·질감 + 에 따라 기름/해수로 분류. 분류된 픽셀 수 × GSD² = 면적. +
+
+ A = Noil × (GSD)2 +
+ N: 기름 픽셀수, GSD: 지상 표본거리 +
+
+
+
+ 다중분광 지수법 (Spectral Index) +
+
+ 다중분광 센서로 촬영한 밴드 조합으로{' '} + 유막 특유의 분광 반응을 지수화하여 + 자동 분류. +
+
+ OSDI = (BNIR−BRed) / (BNIR+BRed) +
+ Oil Spill Detection Index +
+
+
+
+
+
SAR 임계값 분리법
+
+ SAR 영상에서 후방산란계수(σ°)의 임계값 이하를 유막으로 판정합니다. 단, 풍속 3m/s + 이하나 생물막, 강우 영역이 False Alarm{' '} + 오탐 발생 원인이 됩니다. +
+
+ {'Oil = {(x,y) | σ°(x,y) < T'} + th + {'}'} +
+ + Tth: 최적 임계값 (국소 적응형) + +
+
+
+
유량 역산 추정
+
+ 면적(A)과 평균 유막 두께(d) 및 풍화 경과 시간(t)으로부터 원래 유출유량(V₀)을 + 역산합니다. +
+
+ V₀ = A × d / (1 − fe(t)) +
+ + fe: 누적 증발비 (Stiver & Mackay 1984) + +
+
+
+
+ + {/* Bonn Agreement 색상 코드 */} +
+
+ 🎨 유막 두께 시각적 추정 기준 (Bonn Agreement Color Code) +
+
+
+
+
은회색
+
< 0.1μm
+
광택층
+
+
+
+
무지개색
+
0.1~0.3μm
+
박막층
+
+
+
+
메탈릭
+
0.3~5μm
+
광택층
+
+
+
+
갈색
+
5~200μm
+
두꺼운층
+
+
+
+
흑색
+
>200μm
+
농축층
+
+
+
+
+ ); +} diff --git a/frontend/src/components/aerial/components/contents/PanelDetection.tsx b/frontend/src/components/aerial/components/contents/PanelDetection.tsx new file mode 100644 index 0000000..2d394bc --- /dev/null +++ b/frontend/src/components/aerial/components/contents/PanelDetection.tsx @@ -0,0 +1,256 @@ +export function PanelDetection() { + return ( +
+ {/* 센서 비교표 */} +
+
+ 🔬 유출유 탐지 센서 종류 및 특성 비교 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 센서 + + 파장대 + + 탐지 원리 + + 강점 + + 한계 + + 탑재 플랫폼 +
광학(EO)0.4~0.7μm + 반사 휘도 차이 +
+ 유막 광택·색조 +
+ 고해상도 +
+ 직관적 식별 +
야간·구름 불가드론·항공기·위성
열적외선(IR)8~14μm + 유막 열방사 차이 +
+ 해수면 온도 대비 +
+ 야간 탐지 +
+ 두께 추정 가능 +
+ 구름 투과 불가 +
+ 얇은 유막 한계 +
+ 드론·항공기 +
+ NOAA AVHRR +
SAR + 1~10cm +
+ (마이크로파) +
+ 유막 표면장력 증가 +
→ 브래그 후방산란 감소 +
+ 구름·야간 무관 +
+ 광역 탐지 +
+ 유사 픽처 오탐 +
+ 고해상도 한계 +
+ 위성(Sentinel-1 +
+ KOMPSAT-5) +
SLAR/RAR마이크로파 + 측방향 레이더 +
+ 유막 감쇠대 탐지 +
+ 야간·구름 무관 +
+ 광역 감시선 +
+ 항공기 탑재 전용 +
+ 저해상도 +
해경 감시 항공기
UV 형광0.3~0.4μm + 자외선 조사 → 기름 +
+ 형광 방출 +
+ 박막 유막(μm급) +
+ 탐지 가능 +
+ 야간 전용 +
+ 주간 오탐 많음 +
항공기
마이크로파 복사계 + 수cm +
+ (수동형) +
+ 유막 방사율 변화 +
+ 밝기온도 차이 +
+ 구름 완전 투과 +
+ 야간 가능 +
+ 저해상도(50km) +
+ NGSST 혼합 사용 +
+ AMSR-E 위성 +
+ (NGSST 융합) +
+
+
+ + {/* NGSST 카드 */} +
+
+ 🌡️ NGSST (New Generation SST) 위성 수온자료 — 특허 10-1567431 적용 +
+
+
+ 일본 토호쿠대학 Kawamura 교수팀이 제공하는 북서태평양 광역 수온자료입니다.{' '} + 열적외선(AVHRR·MODIS)마이크로파(AMSR-E)를 융합하여 구름 유무와 관계없이 + 일별 수온을 제공합니다. +
+ SST(℃) = 0.15 × DN − 3.0 +
+ DN: 바이너리 파일 1byte 디지털 수치 +
+
+
+
+ + 영역 + {' '} + : 116~166°E, 13~63°N (북서태평양 50×50°) +
+
+ + 해상도 + {' '} + : 3분(약 5km) · 격자 1000×1000 +
+
+ + 갱신 + {' '} + : 매일 12시 FTP 자동수신 · 최근 5일 합성 +
+
+ + 보간 + {' '} + : Akima(1978) 2차원 5차다항식 → 500m 격자 변환 +
+
+ + WING 활용 + {' '} + : 유출유 증발·유상화·점도변화 모델 수온 입력 +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/aerial/components/contents/PanelESIMap.tsx b/frontend/src/components/aerial/components/contents/PanelESIMap.tsx new file mode 100644 index 0000000..efebbab --- /dev/null +++ b/frontend/src/components/aerial/components/contents/PanelESIMap.tsx @@ -0,0 +1,128 @@ +export function PanelESIMap() { + return ( +
+ {/* 헤더 카드 */} +
+
+ 🗺️ ESI 방제정보지도 (Environmental Sensitivity Index Map) +
+
+ ESI 지도는 이문진 박사 특허(등록특허 10-1567431)의 핵심 기반 데이터입니다. 해안선 + 유형·생태민감도·사회경제적 자원 분포를 통합 등급화하여{' '} + 방제 우선순위 결정의 근거가 됩니다. + 1999~2002년 구축, 해경 인천·태안·군산·목포·완도·제주·여수 관할해역 대상. +
+
+ 원전: NOAA ESI Mapping Program · 국내 적용: 해양수산부·한국해양과학기술원 +
+
+ + {/* ESI 3종 카드 */} +
+
+
+ 해안선 분류 (Shoreline) +
+
+ 해안선 유형을 1~10 등급으로 분류. 등급이 높을수록 오염 취약·방제 난이도 높음. +
+
+
+ ESI 1~2: 노출 암반·절벽 (낮은 민감도) +
+
+ ESI 5~7: 자갈·모래 해변 (중간) +
+
+ ESI 8~10: 조간대·갯벌·맹그로브 (최고) +
+
+
+
+
+ 생물자원 (Biological Resources) +
+
+ 오염 취약 생물 서식지·번식지·이동경로를 위치 기반으로 등록. +
+
+
+ 해조류·어류 산란장·양식장 +
+
+ 철새 도래지·해조류 번식지 +
+
+ 보호 해양생물 서식구역 +
+
+
+
+
+ 인문·사회자원 (Human-Use) +
+
+ 경제·사회적 피해 가능 지역을 방제 우선순위 결정에 활용. +
+
+
+ 취수원·정수장·발전소 냉각수 +
+
+ 항구·어항·수산물 위판장 +
+
+ 해수욕장·관광지·문화재 +
+
+
+
+ + {/* ESI 구축 현황 */} +
+
+ 📏 ESI 해안선 자료 구축 현황 (등록특허 10-1567431) +
+
+ 본 특허의 ESI 기반 데이터는 1999~2002년(약 3년) 구축된 방제정보지도{' '} + 25,000:1 해안선자료를 기반으로 합니다. 매립·준설공사로 변형된 + 부분은 국립해양조사원 전자해도(ENC) 해안선으로 보완하였습니다. 항공탐색에서 획득한 최신 + 영상 데이터는 이 ESI DB와 실시간 중첩되어 방제 우선구역을 즉시 산출합니다. +
+
+
+ ); +} diff --git a/frontend/src/components/aerial/components/contents/PanelOverview.tsx b/frontend/src/components/aerial/components/contents/PanelOverview.tsx new file mode 100644 index 0000000..a5947b7 --- /dev/null +++ b/frontend/src/components/aerial/components/contents/PanelOverview.tsx @@ -0,0 +1,144 @@ +export function PanelOverview() { + return ( +
+ {/* 개요 카드 */} +
+
+
+
+
+ 📋 +
+ 항공탐색이란? +
+
+ 해양 유류오염 발생 시{' '} + 드론·유인항공기·위성을 활용하여 + 유출유의 위치·면적·오염형태를 실시간으로 탐지하고, 방제정보지도(ESI)와 연계하여{' '} + 확산예측 모델의 검증·보정 입력자료로 + 활용하는 통합 탐색 체계입니다. +
+
+
+
+
+ 🎯 +
+ WING 항공탐색 목적 +
+
+
+ {/* */} + 유출유 실시간 위치·면적 파악 → 확산예측 초기조건 보정 +
+
+ {/* {' '} */} + 수온(SST) 위성자료 실시간 수신 → 유출유 풍화모델 입력 +
+
+ {/* {' '} */} + ESI 환경민감자원 현장 확인 → 방제 우선순위 결정 +
+
+ {/* */} + 드론 3D 재구성 → 선박 식별·오염원 정밀 분석 +
+
+
+
+
+ + {/* 통합 흐름 카드 */} +
+
+ ⚙️ 항공탐색 → 방제대응 통합 흐름 +
+
+ {[ + { title: '탐지 플랫폼', desc: '드론·항공기·위성' }, + { title: '센서 데이터', desc: '광학·IR·SAR·SST' }, + { title: '영상 처리', desc: '좌표변환·면적산정' }, + { title: '확산모델 입력', desc: '유출위치·유출량·SST' }, + { title: '방제 의사결정', desc: 'ESI 연계·자원 배치' }, + ].map((step, i) => ( +
+ {i > 0 && ( +
+
+ +
+ )} +
+
{step.title}
+
{step.desc}
+
+
+ ))} +
+
+ + {/* 플랫폼 3종 카드 */} +
+
+
드론 (UAV)
+
+ 현장 즉시 투입·저고도 정밀 촬영. 광학·적외선 카메라 탑재, 실시간 영상 전송, 3D 재구성. +
+
+
+ 고도: 30~500m · 속도: 15~25m/s +
+
+ GSD: 1~5cm/px (100m 고도 기준) +
+
+ 운용반경: 5~30km · 체공: 30~90분 +
+
+
+
+
유인 항공기
+
+ 광역 탐색·장시간 체공. 광학·IR·SAR·SLAR·UV 형광 센서 복합 탑재. 해경 해양오염 감시 + 항공기. +
+
+
+ 고도: 300~3,000m · 속도: 60~150m/s +
+
+ 탐색폭: 5~50km · 체공: 4~8시간 +
+
+ 야간·악기상 SAR 탐지 가능 +
+
+
+
+
위성
+
+ 광역·반복 관측. SST(NOAA AVHRR·NGSST)·SAR(Sentinel-1)·광학(KOMPSAT) 활용. 구름 문제 + 존재. +
+
+
+ NGSST: 5km 해상도 · 일 1회 갱신 +
+
+ SAR: 5~25m 해상도 · 구름 무관 +
+
+ KOMPSAT-5: X-band SAR 1m급 +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/aerial/components/contents/PanelReferences.tsx b/frontend/src/components/aerial/components/contents/PanelReferences.tsx new file mode 100644 index 0000000..9bf9ea5 --- /dev/null +++ b/frontend/src/components/aerial/components/contents/PanelReferences.tsx @@ -0,0 +1,361 @@ +export function PanelReferences() { + return ( +
+ {/* 섹션 헤더 */} +
+
+ 📜 +
+
+
+ 등록특허 원문 기반 이론 근거 +
+
+ WING 탑재 유출유 확산예측 시스템의 특허 원전 2건 전체 분석 +
+
+
+ + {/* 특허 1: 10-1567431 */} +
+
+
+
대한민국 등록특허
+
10-1567431
+
+ 등록: 2015.11.03 +
+
+
+
+ 해양 유류오염사고 발생시 효율적인 방제방안 수립을 위한 유출유 확산 예측 방법 +
+
+ 특허권자 : 한국해양과학기술원 | 발명자 : 이문진 · 김혜진 · + 이승현 · 전태병 +
+
+
+ + {/* 청구항 1 */} +
+
+ 청구항 1 — ESI 기반 실시간 확산 3단계 +
+
+
+
+ S10 +
+
+ 실시간 자료 수신 — + 기상예측시스템·위성영상수신시스템·검조소 인터넷 연결. FTP로 + 기상자료+수온자료(NGSST)+조석정보 자동 수신 +
+
+
+
+ S20 +
+
+ 조류·취송류 예측 — CHARRY모델 조화분석 + 취송류 + 경험식(0.029×Vw, θw+18.6°) +
+
+
+
+ S30 +
+
+ 유출유 확산 실시간 예측 — Monte Carlo 입자추적 + + fBm 난류확산 + 풍화 5단계 → ESI 방제정보지도 기반 방제방안 수립 +
+
+
+
+ + {/* fBm 난류확산 */} +
+
+ fBm 난류확산 |{' '} + σ²(t) = A·t^m, m=0.45~2.46 +
+
+ 분수 브라운운동(fBm) 기반 무작위 확산거리 생성. 등방성(isotropic) 확산 가정. +
+
+ + {/* 풍화 5단계 */} +
+
+ 유출유 풍화(Weathering) 5단계 +
+
+
+
① 퍼짐
+
+ Fay(1969): 중력-관성력. Mackay et al.(1980) 표면장력-점성력 +
+
+
+
② 증발
+
+ Stiver & Mackay(1984) 해석적 방법. 수일~10일간 약 25% +
+
+
+
③ 소산
+
+ 쇄파 기인. 파도에너지·풍속 함수. 전체 약 15% +
+
+
+
④ 유상화
+
+ Water-in-oil. Mackay et al.(1980) 풍속·수분 함수 +
+
+
+
⑤ 침강
+
+ 용해·미생물 분해. 질량 손실률 = 초기 누유량에 선형 비례 +
+
+
+
+
+ + {/* 특허 2: 10-1868791 */} +
+
+
+
대한민국 등록특허
+
10-1868791
+
+ 등록: 2018.06.12 +
+
+
+
+ 유출유(Oil spill) 확산 예측을 위한 입자 추적 모듈 최적화 방법 및 이를 이용한 예측 + 시스템 +
+
+ 특허권자 : 주식회사 아라종합기술 | 발명자 : + 김도연·김용혁·김충기·김성은·박상훈·오정환 +
+
+
+ + {/* 최적화 5단계 */} +
+
+ ⚙️ 입자 추적 모듈 최적화 5단계 +
+
+
+ (a) +
+ 뜰개 관측 + 예측자료 취득 : GPS 뜰개 투하 → 실제 이동경로 + 예측 기상·해양자료 취득 +
+
+
+ (b) +
+ 제1 입자 추적 모델 실행 : 예측자료 + 확산계수 → 제1 예측변화량(ΔModel) 산출 +
+
+
+ (c) +
+ 전처리 차분 : 관측경로 Δobs ↔ 제1모델 ΔModel 차분 처리 +
+
+
+ (d) +
+ 제2 입자 추적 모델 수립 : ΔModel 기반 제2모델 → ΔRevised 산출 +
+
+
+ (e) +
+ 최적화 알고리즘 적용 : ΔRevised ↔ Δobs 비교 → GA·DE·HS·PSO 매개변수 최적화 반복 수렴 +
+
+
+
+ + {/* 수학 모델 + 알고리즘 */} +
+
+
+ 입자 추적 수학 모델 +
+
+ 제1모델: Modelx = curu + ·Δt + c·wu·Δt +
+ 제2모델: Revx = a1·cur + u + +a2·curv+...+a9 +
+
+
+
+ 4대 최적화 알고리즘 +
+
+
+ GA : 유전 알고리즘 — 변이·교배 진화 +
+
+ DE : 미분 진화 — 벡터 차이 기반 전역최적화 +
+
+ HS : 하모니 서치 — 음악구성 수리모델 +
+
+ PSO : 입자군집 최적화 — 새떼 군집행동 모방 +
+
+
+
+
+ + {/* 선행기술 참고문헌 */} +
+
+ 📚 특허 원문 인용 선행기술문헌 (심사관 인용 포함) +
+
+ {[ + { + tag: '특허① 인용', + text: '해양환경안전학회지 제17권 4호 (김혜진·이문진 외) — KOSPS 상시 운용 체계 | 심사관 직접 인용', + }, + { + tag: '특허① 인용', + text: '해양환경안전학회 2008 춘계학술발표회 — CHARRY 조류모델 | 심사관 직접 인용', + }, + { + tag: '특허① 인용', + text: 'KR1020120121163 A — 심사관 인용 선행특허', + }, + { + tag: '특허② 인용', + text: 'KR101538668 B1 / KR101378463 B1 — 심사관 인용 선행특허 2건', + }, + { + tag: '특허② 인용', + text: '한국 등록특허 제10-1567431 — 발명배경 §[0007]에서 선행기술로 직접 인용', + }, + { + tag: '이론 원전', + text: 'Fay(1969) · Mackay et al.(1980) · Stiver & Mackay(1984) · Mooney(1951) — 풍화 5단계 원전', + }, + { + tag: '이론 원전', + text: 'Akima(1978a, 1978b) — 2차원 5차다항식 보간법 (수심·기상자료 보간)', + }, + { + tag: '이론 원전', + text: '이문진·강용균(2000) 한국해양학회지 — 취송류 경험식 0.029×Vw, θw+18.6° 원전', + }, + { + tag: '이론 원전', + text: 'Bowden(1983) — fBm 난류확산 σ²=At^m (m=0.45~2.46)', + }, + { + tag: '이론 원전', + text: 'Wahr(1981) — 조석 Love number (k=0.3, h=0.61) · 기조력 계수', + }, + { + tag: '이론 원전', + text: 'Flather & Heaps(1975) — 조석 간출지(tidal flat) 처리 기법', + }, + ].map((ref, i) => ( +
+
+ {ref.tag} +
+
{ref.text}
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/aerial/components/contents/PanelRemoteSensing.tsx b/frontend/src/components/aerial/components/contents/PanelRemoteSensing.tsx new file mode 100644 index 0000000..0751976 --- /dev/null +++ b/frontend/src/components/aerial/components/contents/PanelRemoteSensing.tsx @@ -0,0 +1,139 @@ +export function PanelRemoteSensing() { + return ( +
+ {/* 헤더 카드 */} +
+
+ 🛰️ 위성 원격탐사 — 유출유 탐지 원리 +
+
+ 해양 유출유는 해수면에 유막을 형성하여 전자기파 반사·방출·산란 특성을 변화시킵니다. 이 + 물리적 특성 변화를 위성·항공 센서로 감지하여 유막의 위치·범위·두께를 추정합니다. +
+
+ + {/* 원리 4종 그리드 */} +
+
+
열적외선 방출 원리
+
+ 해수면 위 유막은 해수보다 방사율이 낮아{' '} + 동일 온도에서도 적외선 방출량이 다릅니다. 파장 10~12μm 열적외선 밴드에서 유막과 주변 + 해수의{' '} + 밝기온도(Brightness Temperature) 차이로 + 유막을 탐지합니다. +
+
+ ⚠️ 구름은 열적외선을 완전 차단 → 마이크로파 보조 필요 (NGSST 융합 이유) +
+
+
+
+ SAR 브래그 산란 원리 +
+
+ 해수면 위 유막은 표면장력을 증가시켜 + 소파를 억제합니다. SAR에서 해수면 산란의 주원인인{' '} + 브래그(Bragg) 후방산란이 감소하여 유막 + 영역이 어둡게 나타납니다. +
+
+ 활용: Sentinel-1(C-band) · KOMPSAT-5(X-band) · ALOS PALSAR(L-band) +
+
+
+
UV 형광 탐지 원리
+
+ 석유계 탄화수소에 자외선(310~400nm)을 조사하면{' '} + 형광 발광합니다. 주야간 모두 활용 + 가능하나 야간 효과가 우수합니다. 수μm 수준의 매우 얇은 유막도 탐지 가능한 고감도 + 센서입니다. +
+
+ 적용: 해경 감시 항공기 야간 탐색 · 비정상 유출 신고 확인 +
+
+
+
+ 마이크로파 복사계 원리 +
+
+ 수동 마이크로파 복사계는 지구 방출 마이크로파를 수신합니다. 유막이 있으면{' '} + 방사율 변화로 밝기온도가 달라집니다. + 파장이 길어 구름 완전 투과·야간 관측 가능. +
+
+ 해상도 한계(50km)로 단독 사용 불가 → 열적외선과 융합 (NGSST 방식) +
+
+
+ + {/* 전자해도 카드 */} +
+
+ 🗺️ 전자해도(ENC) 수심자료 처리 — 특허 10-1567431 기반 +
+
+
+ 항공탐색 좌표 데이터는 전자해도(ENC) 수심격자와 중첩되어 유출유의 수심환경, + 조간대 분포, 해안선 형태를 분석합니다. 수심자료는 국립해양조사원 전자해도 약 300종에서 + 추출 후 Akima 보간으로 15초 등간격 격자에 정규화합니다. +
+
+
+ + 원본 + {' '} + : ENC 전자해도 무작위 측심점 +
+
+ + 삼각망 + {' '} + : TIN(Triangulated Irregular Network) 구성 +
+
+ + 보간 + {' '} + : Akima 2차원 5차다항식 (21개 계수) +
+
+ + 결과 + {' '} + : 15초(463m) 등간격 · 3,225,600 격자 +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/aerial/components/contents/PanelSpreadModel.tsx b/frontend/src/components/aerial/components/contents/PanelSpreadModel.tsx new file mode 100644 index 0000000..9648666 --- /dev/null +++ b/frontend/src/components/aerial/components/contents/PanelSpreadModel.tsx @@ -0,0 +1,208 @@ +export function PanelSpreadModel() { + return ( +
+ {/* 헤더 카드 */} +
+
+ 🔗 항공탐색 데이터 → 유출유 확산예측 연계 체계 +
+
+ 이문진 박사 특허(등록특허 10-1567431)는{' '} + 위성영상수신시스템(SST)을 + 기상예측시스템·검조소와 함께 인터넷으로 연결하여, 항공탐색 데이터가 실시간 확산 예측의 + 핵심 입력자료가 되는 통합 네트워크를 구성합니다. +
+
+ + {/* 입력/피드백 2열 카드 */} +
+
+
+ 📡 항공탐색 → 모델 입력 +
+
+
+
📍 유출 위치 보정
+
드론·위성 영상 → GPS 좌표 → 모델 유출지점 갱신
+
+
+
🛢️ 유출량 역산
+
면적×두께 → 유량 추정 → 확산모델 유출량 보정
+
+
+
🌡️ SST 수온 입력
+
NGSST FTP 수신 → Akima 보간 → 풍화모델 수온값
+
+
+
🎨 풍화 상태 확인
+
색상 분류 → 증발비 추정 → 풍화모델 초기값 보정
+
+
+
+
+
+ 🔄 모델 → 항공탐색 피드백 +
+
+
+
🗺️ 탐색 우선구역 제공
+
확산 예측 결과 → 다음 탐색 집중구역 자동 생성
+
+
+
📊 모델 검증 자료
+
+ 실측 유출유 위치 ↔ 예측값 오차 분석 → 정확도 평가 +
+
+
+
🏖️ ESI 피해 위험구역
+
+ 확산경로×ESI 중첩 → 항공탐색 ESI 현장확인 우선순위 +
+
+
+
🚢 방제자원 배치안
+
+ 예측 도달시간 → 오일펜스·방제정 최적 배치 좌표 제공 +
+
+
+
+
+ + {/* 네트워크 다이어그램 */} +
+
+ 📡 특허 10-1567431 실시간 자료 연계 네트워크 (도면 9) +
+
+
+
+
기상예측시스템
+
바람·기온·기압 · 국립환경과학원
+
+
+
위성영상수신시스템
+
SST(NGSST) · 토호쿠대학 FTP
+
+
+
검조소
+
실시간 조위 · 조석정보
+
+
+
+
+
+
+
+
+
+
+
+
+
🖥️
+
서버(WING)
+
+ 데이터 수신·처리 +
+ 모델 구동 +
+
+
+
+
+
클라이언트
+
+ 유출지점·유출량 +
+ 입력 및 결과 수령 +
+
+
+
+
+ 기상자료·수온자료·조석정보 실시간 수신 → CHARRY 조류 + 취송류 예측 → 유출유 확산 예측 + (S10→S20→S30) +
+
+
+ ); +} diff --git a/frontend/src/tabs/aerial/hooks/useOilDetection.ts b/frontend/src/components/aerial/hooks/useOilDetection.ts similarity index 96% rename from frontend/src/tabs/aerial/hooks/useOilDetection.ts rename to frontend/src/components/aerial/hooks/useOilDetection.ts index 514f6fd..c4d3779 100644 --- a/frontend/src/tabs/aerial/hooks/useOilDetection.ts +++ b/frontend/src/components/aerial/hooks/useOilDetection.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react'; -import type { OilDetectionResult, OilDetectionConfig } from '../utils/oilDetection'; +import type { OilDetectionResult, OilDetectionConfig } from '@interfaces/aerial/AerialInterface'; import { detectOilSpillAPI, DEFAULT_OIL_DETECTION_CONFIG } from '../utils/oilDetection'; interface UseOilDetectionOptions { diff --git a/frontend/src/tabs/aerial/index.ts b/frontend/src/components/aerial/index.ts similarity index 100% rename from frontend/src/tabs/aerial/index.ts rename to frontend/src/components/aerial/index.ts diff --git a/frontend/src/tabs/aerial/services/aerialApi.ts b/frontend/src/components/aerial/services/aerialApi.ts similarity index 65% rename from frontend/src/tabs/aerial/services/aerialApi.ts rename to frontend/src/components/aerial/services/aerialApi.ts index 25fb0d2..9e5f920 100644 --- a/frontend/src/tabs/aerial/services/aerialApi.ts +++ b/frontend/src/components/aerial/services/aerialApi.ts @@ -1,77 +1,17 @@ import { api } from '@common/services/api'; +import type { + AerialMediaItem, + CctvCameraItem, + SatRequestItem, + CreateSatRequestInput, + DroneStreamItem, + SatellitePass, +} from '@interfaces/aerial/AerialInterface'; // ============================================================ // 항공 방제 API // ============================================================ -// === AERIAL_MEDIA === -export interface AerialMediaItem { - aerialMediaSn: number; - acdntSn: number | null; - fileNm: string; - orgnlNm: string | null; - filePath: string | null; - lon: number | null; - lat: number | null; - locDc: string | null; - equipTpCd: string; // drone/plane/satellite - equipNm: string; - mediaTpCd: string; // 사진/영상/적외선/SAR/가시광/광학 - takngDtm: string | null; - fileSz: string | null; - resolution: string | null; -} - -// === CCTV_CAMERA === -export interface CctvCameraItem { - cctvSn: number; - cameraNm: string; - regionNm: string; - lon: number | null; - lat: number | null; - locDc: string | null; - coordDc: string | null; - sttsCd: string; // LIVE/OFFLINE/MAINT - ptzYn: string; - sourceNm: string | null; - streamUrl: string | null; -} - -// === SAT_REQUEST === -export interface SatRequestItem { - satReqSn: number; - reqCd: string; - acdntSn: number | null; - lon: number | null; - lat: number | null; - zoneDc: string | null; - coordDc: string | null; - zoneAreaKm2: number | null; - satNm: string | null; - providerNm: string | null; - resolution: string | null; - purposeDc: string | null; - reqstrNm: string | null; - reqDtm: string | null; - expectedRcvDtm: string | null; - sttsCd: string; // PENDING/SHOOTING/COMPLETED/CANCELLED -} - -export interface CreateSatRequestInput { - reqCd: string; - acdntSn?: number; - lon?: number; - lat?: number; - zoneDc?: string; - zoneAreaKm2?: number; - satNm?: string; - providerNm?: string; - resolution?: string; - purposeDc?: string; - reqstrNm?: string; - expectedRcvDtm?: string; -} - export async function fetchAerialMedia(params?: { equipType?: string; mediaType?: string; @@ -157,20 +97,6 @@ export async function stitchImages(files: File[]): Promise { return response.data; } -// === DRONE STREAM === -export interface DroneStreamItem { - id: string; - name: string; - shipName: string; - droneModel: string; - ip: string; - rtspUrl: string; - region: string; - status: 'idle' | 'starting' | 'streaming' | 'error'; - hlsUrl: string | null; - error: string | null; -} - export async function fetchDroneStreams(): Promise { const response = await api.get('/aerial/drone/streams'); return response.data; @@ -194,19 +120,6 @@ export async function stopDroneStreamApi(id: string): Promise<{ success: boolean // UP42 위성 패스 조회 // ============================================================ -export interface SatellitePass { - id: string; - satellite: string; - provider: string; - type: 'optical' | 'sar' | 'elevation'; - resolution: string; - color: string; - startTime: string; - endTime: string; - maxElevation: number; - direction: 'ascending' | 'descending'; - orbit: Array<{ lat: number; lon: number }>; -} export async function fetchSatellitePasses(): Promise { const response = await api.get<{ passes: SatellitePass[] }>('/aerial/satellite/passes'); diff --git a/frontend/src/tabs/aerial/utils/oilDetection.ts b/frontend/src/components/aerial/utils/oilDetection.ts similarity index 78% rename from frontend/src/tabs/aerial/utils/oilDetection.ts rename to frontend/src/components/aerial/utils/oilDetection.ts index 5d8687e..591e34b 100644 --- a/frontend/src/tabs/aerial/utils/oilDetection.ts +++ b/frontend/src/components/aerial/utils/oilDetection.ts @@ -8,43 +8,12 @@ */ import { api } from '@common/services/api'; - -// ── Types ────────────────────────────────────────────────────────────────── - -export interface OilDetectionConfig { - captureIntervalMs: number; // API 호출 주기 (ms), default 5000 - coverageAreaM2: number; // 카메라 커버리지 면적 (m²), default 10000 - captureWidth: number; // 캡처 해상도 (너비), default 512 -} - -/** 유류 클래스 정의 */ -export interface OilClass { - classId: number; - className: string; - color: [number, number, number]; // RGB - thicknessMm: number; -} - -/** 개별 유류 영역 (API 응답에서 변환) */ -export interface OilRegion { - classId: number; - className: string; - pixelCount: number; - percentage: number; - areaM2: number; - thicknessMm: number; -} - -/** 감지 결과 (오버레이에서 사용) */ -export interface OilDetectionResult { - regions: OilRegion[]; - totalPercentage: number; - totalAreaM2: number; - mask: Uint8Array; // 클래스 인덱스 (0-4) - maskWidth: number; - maskHeight: number; - timestamp: number; -} +import type { + OilDetectionConfig, + OilClass, + OilRegion, + OilDetectionResult, +} from '@interfaces/aerial/AerialInterface'; // ── Constants ────────────────────────────────────────────────────────────── diff --git a/frontend/src/tabs/aerial/utils/streamUtils.ts b/frontend/src/components/aerial/utils/streamUtils.ts similarity index 100% rename from frontend/src/tabs/aerial/utils/streamUtils.ts rename to frontend/src/components/aerial/utils/streamUtils.ts diff --git a/frontend/src/tabs/assets/components/AssetManagement.tsx b/frontend/src/components/assets/components/AssetManagement.tsx similarity index 99% rename from frontend/src/tabs/assets/components/AssetManagement.tsx rename to frontend/src/components/assets/components/AssetManagement.tsx index a0ec8ea..6f547bd 100644 --- a/frontend/src/tabs/assets/components/AssetManagement.tsx +++ b/frontend/src/components/assets/components/AssetManagement.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { fetchOrganizations, fetchOrganizationDetail } from '../services/assetsApi'; -import type { AssetOrgCompat } from '../services/assetsApi'; +import type { AssetOrgCompat } from '@interfaces/assets/AssetsInterface'; import AssetMap from './AssetMap'; function AssetManagement() { diff --git a/frontend/src/tabs/assets/components/AssetMap.tsx b/frontend/src/components/assets/components/AssetMap.tsx similarity index 93% rename from frontend/src/tabs/assets/components/AssetMap.tsx rename to frontend/src/components/assets/components/AssetMap.tsx index e2417b0..fb887c5 100644 --- a/frontend/src/tabs/assets/components/AssetMap.tsx +++ b/frontend/src/components/assets/components/AssetMap.tsx @@ -1,11 +1,11 @@ import { useMemo, useCallback, useState } from 'react'; import { ScatterplotLayer } from '@deck.gl/layers'; -import { BaseMap } from '@common/components/map/BaseMap'; -import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay'; -import { FlyToController } from '@common/components/map/FlyToController'; -import type { AssetOrgCompat } from '../services/assetsApi'; +import { BaseMap } from '@components/common/map/BaseMap'; +import { DeckGLOverlay } from '@components/common/map/DeckGLOverlay'; +import { FlyToController } from '@components/common/map/FlyToController'; +import type { AssetOrgCompat } from '@interfaces/assets/AssetsInterface'; import { typeColor } from './assetTypes'; -import { hexToRgba } from '@common/components/map/mapUtils'; +import { hexToRgba } from '@components/common/map/mapUtils'; interface AssetMapProps { organizations: AssetOrgCompat[]; diff --git a/frontend/src/tabs/assets/components/AssetTheory.tsx b/frontend/src/components/assets/components/AssetTheory.tsx similarity index 100% rename from frontend/src/tabs/assets/components/AssetTheory.tsx rename to frontend/src/components/assets/components/AssetTheory.tsx diff --git a/frontend/src/tabs/assets/components/AssetUpload.tsx b/frontend/src/components/assets/components/AssetUpload.tsx similarity index 99% rename from frontend/src/tabs/assets/components/AssetUpload.tsx rename to frontend/src/components/assets/components/AssetUpload.tsx index 30d09e8..e23b668 100644 --- a/frontend/src/tabs/assets/components/AssetUpload.tsx +++ b/frontend/src/components/assets/components/AssetUpload.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { fetchUploadLogs } from '../services/assetsApi'; -import type { UploadLogItem } from '../services/assetsApi'; +import type { UploadLogItem } from '@interfaces/assets/AssetsInterface'; function AssetUpload() { const [uploadMode, setUploadMode] = useState<'add' | 'replace'>('add'); diff --git a/frontend/src/tabs/assets/components/AssetsView.tsx b/frontend/src/components/assets/components/AssetsView.tsx old mode 100755 new mode 100644 similarity index 100% rename from frontend/src/tabs/assets/components/AssetsView.tsx rename to frontend/src/components/assets/components/AssetsView.tsx diff --git a/frontend/src/tabs/assets/components/ShipInsurance.tsx b/frontend/src/components/assets/components/ShipInsurance.tsx similarity index 99% rename from frontend/src/tabs/assets/components/ShipInsurance.tsx rename to frontend/src/components/assets/components/ShipInsurance.tsx index d10f484..15b3967 100644 --- a/frontend/src/tabs/assets/components/ShipInsurance.tsx +++ b/frontend/src/components/assets/components/ShipInsurance.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import * as XLSX from 'xlsx'; import { fetchInsurance } from '../services/assetsApi'; -import type { ShipInsuranceItem } from '../services/assetsApi'; +import type { ShipInsuranceItem } from '@interfaces/assets/AssetsInterface'; const PAGE_SIZE = 50; diff --git a/frontend/src/tabs/assets/components/assetTypes.ts b/frontend/src/components/assets/components/assetTypes.ts similarity index 77% rename from frontend/src/tabs/assets/components/assetTypes.ts rename to frontend/src/components/assets/components/assetTypes.ts index 44407d7..44d1107 100644 --- a/frontend/src/tabs/assets/components/assetTypes.ts +++ b/frontend/src/components/assets/components/assetTypes.ts @@ -1,38 +1,5 @@ export type AssetsTab = 'management' | 'upload' | 'theory' | 'insurance'; -export interface AssetOrg { - id: number; - type: string; - jurisdiction: string; - area: string; - name: string; - address: string; - vessel: number; - skimmer: number; - pump: number; - vehicle: number; - sprayer: number; - totalAssets: number; - phone: string; - lat: number; - lng: number; - pinSize: 'hq' | 'lg' | 'md'; - equipment: { category: string; icon: string; count: number }[]; - contacts: { role: string; name: string; phone: string }[]; -} - -export interface InsuranceRow { - shipName: string; - mmsi: string; - imo: string; - insType: string; - insurer: string; - policyNo: string; - start: string; - expiry: string; - limit: string; -} - export const typeTagCls = (type: string) => { if (type === '해경관할') return 'bg-[color-mix(in_srgb,var(--color-danger)_10%,transparent)] text-color-danger'; diff --git a/frontend/src/tabs/assets/index.ts b/frontend/src/components/assets/index.ts similarity index 100% rename from frontend/src/tabs/assets/index.ts rename to frontend/src/components/assets/index.ts diff --git a/frontend/src/tabs/assets/services/assetsApi.ts b/frontend/src/components/assets/services/assetsApi.ts similarity index 54% rename from frontend/src/tabs/assets/services/assetsApi.ts rename to frontend/src/components/assets/services/assetsApi.ts index 1880168..d56c06b 100644 --- a/frontend/src/tabs/assets/services/assetsApi.ts +++ b/frontend/src/components/assets/services/assetsApi.ts @@ -1,76 +1,13 @@ import { api } from '@common/services/api'; - -// ============================================================ -// 타입 -// ============================================================ - -export interface OrgListItem { - orgSn: number; - orgTp: string; - jrsdNm: string; - areaNm: string; - orgNm: string; - addr: string; - tel: string; - lat: number; - lng: number; - pinSize: 'hq' | 'lg' | 'md'; - vesselCnt: number; - skimmerCnt: number; - pumpCnt: number; - vehicleCnt: number; - sprayerCnt: number; - totalAssets: number; -} - -export interface EquipItem { - category: string; - icon: string; - count: number; -} - -export interface ContactItem { - role: string; - name: string; - phone: string; -} - -export interface OrgDetail extends OrgListItem { - equipment: EquipItem[]; - contacts: ContactItem[]; -} - -export interface UploadLogItem { - logSn: number; - fileNm: string; - uploaderNm: string; - uploadCnt: number; - regDtm: string; -} - -// ============================================================ -// AssetOrg 호환 인터페이스 (기존 컴포넌트와 호환) -// ============================================================ -export interface AssetOrgCompat { - id: number; - type: string; - jurisdiction: string; - area: string; - name: string; - address: string; - phone: string; - lat: number; - lng: number; - pinSize: 'hq' | 'lg' | 'md'; - vessel: number; - skimmer: number; - pump: number; - vehicle: number; - sprayer: number; - totalAssets: number; - equipment: EquipItem[]; - contacts: ContactItem[]; -} +import type { + OrgListItem, + EquipItem, + ContactItem, + OrgDetail, + UploadLogItem, + AssetOrgCompat, + InsuranceResponse, +} from '@interfaces/assets/AssetsInterface'; function toCompat( item: OrgListItem, @@ -122,31 +59,6 @@ export async function fetchUploadLogs(limit = 20): Promise { // 선박보험(유류오염보장계약) // ============================================================ -export interface ShipInsuranceItem { - insSn: number; - shipNo: string; - shipNm: string; - callSign: string; - imoNo: string; - shipTp: string; - shipTpDetail: string; - ownerNm: string; - grossTon: string; - insurerNm: string; - liabilityYn: string; - oilPollutionYn: string; - fuelOilYn: string; - wreckRemovalYn: string; - validStart: string; - validEnd: string; - issueOrg: string; -} - -interface InsuranceResponse { - rows: ShipInsuranceItem[]; - total: number; -} - export async function fetchInsurance(filters?: { search?: string; shipTp?: string; diff --git a/frontend/src/tabs/board/components/BoardDetailView.tsx b/frontend/src/components/board/components/BoardDetailView.tsx old mode 100755 new mode 100644 similarity index 95% rename from frontend/src/tabs/board/components/BoardDetailView.tsx rename to frontend/src/components/board/components/BoardDetailView.tsx index 2c1bc44..1d1e7fb --- a/frontend/src/tabs/board/components/BoardDetailView.tsx +++ b/frontend/src/components/board/components/BoardDetailView.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { useAuthStore } from '@common/store/authStore'; -import { fetchBoardPost, type BoardPostDetail } from '../services/boardApi'; +import { fetchBoardPost } from '../services/boardApi'; +import type { BoardPostDetail } from '@interfaces/board/BoardInterface'; // 카테고리 코드 → 표시명 const CATEGORY_LABELS: Record = { @@ -78,13 +79,13 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
diff --git a/frontend/src/tabs/board/components/BoardListTable.tsx b/frontend/src/components/board/components/BoardListTable.tsx old mode 100755 new mode 100644 similarity index 97% rename from frontend/src/tabs/board/components/BoardListTable.tsx rename to frontend/src/components/board/components/BoardListTable.tsx index e3076a7..30ce64b --- a/frontend/src/tabs/board/components/BoardListTable.tsx +++ b/frontend/src/components/board/components/BoardListTable.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useAuthStore } from '@common/store/authStore'; -import { fetchBoardPosts, type BoardPostItem } from '../services/boardApi'; +import { fetchBoardPosts } from '../services/boardApi'; +import type { BoardPostItem } from '@interfaces/board/BoardInterface'; // 카테고리 코드 ↔ 표시명 매핑 const CATEGORY_MAP: Record = { @@ -129,7 +130,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp {canWrite && ( @@ -283,7 +282,7 @@ export function BoardView() { return (
다운로드 @@ -464,7 +463,7 @@ export function BoardView() { onClick={() => setUploadForm((prev) => ({ ...prev, category: cat }))} className={`flex-1 py-2 px-1 rounded-md text-label-2 font-semibold cursor-pointer border ${ isActive - ? 'bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)] text-color-accent' + ? 'bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] border-stroke text-color-accent' : 'bg-bg-card border-stroke text-fg-disabled' }`} > @@ -612,7 +611,7 @@ export function BoardView() { alert((err as { message?: string })?.message || '저장에 실패했습니다.'); } }} - className="rounded-sm text-label-1 font-semibold cursor-pointer text-color-accent px-3.5 py-1.5 bg-[color-mix(in_srgb,var(--color-accent)_8%,transparent)] border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)]" + className="rounded-sm text-label-1 font-semibold cursor-pointer text-color-accent px-3.5 py-1.5 bg-[color-mix(in_srgb,var(--color-accent)_8%,transparent)] border border-stroke" > {editingManualId ? '수정' : '업로드'} @@ -685,7 +684,7 @@ export function BoardView() { {hasPermission(getWriteResource(), 'CREATE') && ( diff --git a/frontend/src/tabs/board/components/BoardWriteForm.tsx b/frontend/src/components/board/components/BoardWriteForm.tsx old mode 100755 new mode 100644 similarity index 98% rename from frontend/src/tabs/board/components/BoardWriteForm.tsx rename to frontend/src/components/board/components/BoardWriteForm.tsx index 5dc631c..82173d8 --- a/frontend/src/tabs/board/components/BoardWriteForm.tsx +++ b/frontend/src/components/board/components/BoardWriteForm.tsx @@ -149,7 +149,7 @@ export function BoardWriteForm({ diff --git a/frontend/src/tabs/board/index.ts b/frontend/src/components/board/index.ts similarity index 100% rename from frontend/src/tabs/board/index.ts rename to frontend/src/components/board/index.ts diff --git a/frontend/src/tabs/board/services/boardApi.ts b/frontend/src/components/board/services/boardApi.ts similarity index 58% rename from frontend/src/tabs/board/services/boardApi.ts rename to frontend/src/components/board/services/boardApi.ts index 5eaddaf..ad604d5 100644 --- a/frontend/src/tabs/board/services/boardApi.ts +++ b/frontend/src/components/board/services/boardApi.ts @@ -1,51 +1,14 @@ import { api } from '@common/services/api'; - -// ============================================================ -// 인터페이스 -// ============================================================ - -export interface BoardPostItem { - sn: number; - categoryCd: string; - title: string; - authorId: string; - authorName: string; - viewCnt: number; - pinnedYn: string; - regDtm: string; -} - -export interface BoardPostDetail extends BoardPostItem { - content: string | null; - mdfcnDtm: string | null; -} - -export interface BoardListResponse { - items: BoardPostItem[]; - totalCount: number; - page: number; - size: number; -} - -export interface BoardListParams { - categoryCd?: string; - search?: string; - page?: number; - size?: number; -} - -export interface CreateBoardPostInput { - categoryCd: string; - title: string; - content?: string; - pinnedYn?: string; -} - -export interface UpdateBoardPostInput { - title?: string; - content?: string; - pinnedYn?: string; -} +import type { + BoardPostDetail, + BoardListResponse, + BoardListParams, + CreateBoardPostInput, + UpdateBoardPostInput, + ManualItem, + CreateManualInput, + UpdateManualInput, +} from '@interfaces/board/BoardInterface'; // ============================================================ // API 함수 @@ -83,38 +46,6 @@ export async function adminDeleteBoardPost(sn: number): Promise { // 매뉴얼 API // ============================================================ -export interface ManualItem { - manualSn: number; - catgNm: string; - title: string; - version: string | null; - fileTp: string | null; - fileSz: string | null; - filePath: string | null; - authorNm: string | null; - dwnldCnt: number; - regDtm: string; -} - -export interface CreateManualInput { - catgNm: string; - title: string; - version?: string; - fileTp?: string; - fileSz?: string; - filePath?: string; - authorNm?: string; -} - -export interface UpdateManualInput { - catgNm?: string; - title?: string; - version?: string; - fileTp?: string; - fileSz?: string; - filePath?: string; -} - export async function fetchManuals(params?: { category?: string; search?: string; diff --git a/frontend/src/common/components/auth/LoginPage.tsx b/frontend/src/components/common/auth/LoginPage.tsx similarity index 99% rename from frontend/src/common/components/auth/LoginPage.tsx rename to frontend/src/components/common/auth/LoginPage.tsx index 717c7ec..510b1bb 100644 --- a/frontend/src/common/components/auth/LoginPage.tsx +++ b/frontend/src/components/common/auth/LoginPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { GoogleLogin, type CredentialResponse } from '@react-oauth/google'; -import { useAuthStore } from '../../store/authStore'; +import { useAuthStore } from '@common/store/authStore'; /* Demo accounts (개발 모드 전용) */ const DEMO_ACCOUNTS = [{ id: 'admin', password: 'admin1234', label: '관리자 (경정)' }]; diff --git a/frontend/src/common/components/layer/LayerTree.tsx b/frontend/src/components/common/layer/LayerTree.tsx old mode 100755 new mode 100644 similarity index 100% rename from frontend/src/common/components/layer/LayerTree.tsx rename to frontend/src/components/common/layer/LayerTree.tsx diff --git a/frontend/src/common/components/layout/MainLayout.tsx b/frontend/src/components/common/layout/MainLayout.tsx old mode 100755 new mode 100644 similarity index 93% rename from frontend/src/common/components/layout/MainLayout.tsx rename to frontend/src/components/common/layout/MainLayout.tsx index 68aa565..cd5a843 --- a/frontend/src/common/components/layout/MainLayout.tsx +++ b/frontend/src/components/common/layout/MainLayout.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import type { MainTab } from '../../types/navigation'; +import type { MainTab } from '@/types/navigation'; import { TopBar } from './TopBar'; import { SubMenuBar } from './SubMenuBar'; diff --git a/frontend/src/common/components/layout/SubMenuBar.tsx b/frontend/src/components/common/layout/SubMenuBar.tsx old mode 100755 new mode 100644 similarity index 89% rename from frontend/src/common/components/layout/SubMenuBar.tsx rename to frontend/src/components/common/layout/SubMenuBar.tsx index 666fdfd..f1ca947 --- a/frontend/src/common/components/layout/SubMenuBar.tsx +++ b/frontend/src/components/common/layout/SubMenuBar.tsx @@ -1,5 +1,5 @@ -import type { MainTab } from '../../types/navigation'; -import { useSubMenu } from '../../hooks/useSubMenu'; +import type { MainTab } from '@/types/navigation'; +import { useSubMenu } from '@common/hooks/useSubMenu'; interface SubMenuBarProps { activeMainTab: MainTab; diff --git a/frontend/src/common/components/layout/TopBar.tsx b/frontend/src/components/common/layout/TopBar.tsx old mode 100755 new mode 100644 similarity index 97% rename from frontend/src/common/components/layout/TopBar.tsx rename to frontend/src/components/common/layout/TopBar.tsx index 2168bfd..5dd452c --- a/frontend/src/common/components/layout/TopBar.tsx +++ b/frontend/src/components/common/layout/TopBar.tsx @@ -1,10 +1,11 @@ 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'; -import { useThemeStore } from '../../store/themeStore'; +import type { MainTab } from '@/types/navigation'; +import { useAuthStore } from '@common/store/authStore'; +import { useMenuStore } from '@common/store/menuStore'; +import { useMapStore } from '@common/store/mapStore'; +import { useThemeStore } from '@common/store/themeStore'; import UserManualPopup from '../ui/UserManualPopup'; +/* eslint-disable react-refresh/only-export-components */ interface TopBarProps { activeTab: MainTab; diff --git a/frontend/src/common/components/map/BacktrackReplayBar.tsx b/frontend/src/components/common/map/BacktrackReplayBar.tsx old mode 100755 new mode 100644 similarity index 99% rename from frontend/src/common/components/map/BacktrackReplayBar.tsx rename to frontend/src/components/common/map/BacktrackReplayBar.tsx index dfe63cc..9c810ab --- a/frontend/src/common/components/map/BacktrackReplayBar.tsx +++ b/frontend/src/components/common/map/BacktrackReplayBar.tsx @@ -1,5 +1,5 @@ import { useRef, useEffect } from 'react'; -import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'; +import type { ReplayShip, CollisionEvent } from '@/types/backtrack'; interface BacktrackReplayBarProps { isPlaying: boolean; diff --git a/frontend/src/common/components/map/BacktrackReplayOverlay.tsx b/frontend/src/components/common/map/BacktrackReplayOverlay.tsx old mode 100755 new mode 100644 similarity index 99% rename from frontend/src/common/components/map/BacktrackReplayOverlay.tsx rename to frontend/src/components/common/map/BacktrackReplayOverlay.tsx index f7226bf..eec89cc --- a/frontend/src/common/components/map/BacktrackReplayOverlay.tsx +++ b/frontend/src/components/common/map/BacktrackReplayOverlay.tsx @@ -4,7 +4,7 @@ import type { CollisionEvent, ReplayPathPoint, BackwardParticleStep, -} from '@common/types/backtrack'; +} from '@/types/backtrack'; import { hexToRgba } from './mapUtils'; // Andrew's monotone chain — 전체 파티클 경로의 외각 폴리곤 계산 diff --git a/frontend/src/common/components/map/BaseMap.tsx b/frontend/src/components/common/map/BaseMap.tsx similarity index 100% rename from frontend/src/common/components/map/BaseMap.tsx rename to frontend/src/components/common/map/BaseMap.tsx diff --git a/frontend/src/common/components/map/DeckGLOverlay.tsx b/frontend/src/components/common/map/DeckGLOverlay.tsx similarity index 100% rename from frontend/src/common/components/map/DeckGLOverlay.tsx rename to frontend/src/components/common/map/DeckGLOverlay.tsx diff --git a/frontend/src/common/components/map/FlyToController.tsx b/frontend/src/components/common/map/FlyToController.tsx similarity index 100% rename from frontend/src/common/components/map/FlyToController.tsx rename to frontend/src/components/common/map/FlyToController.tsx diff --git a/frontend/src/common/components/map/HydrParticleOverlay.tsx b/frontend/src/components/common/map/HydrParticleOverlay.tsx similarity index 98% rename from frontend/src/common/components/map/HydrParticleOverlay.tsx rename to frontend/src/components/common/map/HydrParticleOverlay.tsx index a825242..8b7b23f 100644 --- a/frontend/src/common/components/map/HydrParticleOverlay.tsx +++ b/frontend/src/components/common/map/HydrParticleOverlay.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react'; import { useMap } from '@vis.gl/react-maplibre'; -import type { HydrDataStep } from '@tabs/prediction/services/predictionApi'; +import type { HydrDataStep } from '@interfaces/prediction/PredictionInterface'; interface HydrParticleOverlayProps { hydrStep: HydrDataStep | null; diff --git a/frontend/src/common/components/map/MapBoundsTracker.tsx b/frontend/src/components/common/map/MapBoundsTracker.tsx similarity index 94% rename from frontend/src/common/components/map/MapBoundsTracker.tsx rename to frontend/src/components/common/map/MapBoundsTracker.tsx index 722bd4d..1bf0e89 100644 --- a/frontend/src/common/components/map/MapBoundsTracker.tsx +++ b/frontend/src/components/common/map/MapBoundsTracker.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { useMap } from '@vis.gl/react-maplibre'; -import type { MapBounds } from '@common/types/vessel'; +import type { MapBounds } from '@/types/vessel'; interface MapBoundsTrackerProps { onBoundsChange?: (bounds: MapBounds) => void; diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/components/common/map/MapView.tsx old mode 100755 new mode 100644 similarity index 99% rename from frontend/src/common/components/map/MapView.tsx rename to frontend/src/components/common/map/MapView.tsx index 1688364..9b7d423 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/components/common/map/MapView.tsx @@ -12,15 +12,16 @@ import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'; import type { MapLayerMouseEvent } from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { layerDatabase } from '@common/services/layerService'; -import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView'; +import type { PredictionModel } from '@/types/prediction/PredictionType'; +import type { SensitiveResource } from '@interfaces/prediction/PredictionInterface'; import type { HydrDataStep, SensitiveResourceFeatureCollection, -} from '@tabs/prediction/services/predictionApi'; +} from '@components/prediction/services/predictionApi'; import HydrParticleOverlay from './HydrParticleOverlay'; import { TimelineControl } from './TimelineControl'; -import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'; -import type { ReplayShip, CollisionEvent, BackwardParticleStep } from '@common/types/backtrack'; +import type { BoomLine, BoomLineCoord } from '@/types/boomLine'; +import type { ReplayShip, CollisionEvent, BackwardParticleStep } from '@/types/backtrack'; import { createBacktrackLayers } from './BacktrackReplayOverlay'; import { buildMeasureLayers } from './measureLayers'; import { MeasureOverlay } from './MeasureOverlay'; @@ -40,7 +41,8 @@ import { VesselDetailModal, type VesselHoverInfo, } from './VesselInteraction'; -import type { VesselPosition, MapBounds } from '@common/types/vessel'; +import type { VesselPosition, MapBounds } from '@/types/vessel'; +/* eslint-disable react-refresh/only-export-components */ const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080'; @@ -189,7 +191,7 @@ interface MapViewProps { onBoundsChange?: (bounds: MapBounds) => void; } -// DeckGLOverlay, FlyToController → @common/components/map/DeckGLOverlay, FlyToController 에서 import +// DeckGLOverlay, FlyToController → @components/common/map/DeckGLOverlay, FlyToController 에서 import // MapBoundsTracker는 './MapBoundsTracker' 모듈로 추출됨 (IncidentsView 등 BaseMap 직접 사용처에서도 재사용) diff --git a/frontend/src/common/components/map/MeasureOverlay.tsx b/frontend/src/components/common/map/MeasureOverlay.tsx similarity index 95% rename from frontend/src/common/components/map/MeasureOverlay.tsx rename to frontend/src/components/common/map/MeasureOverlay.tsx index b4455b7..ccb5c41 100644 --- a/frontend/src/common/components/map/MeasureOverlay.tsx +++ b/frontend/src/components/common/map/MeasureOverlay.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { Marker } from '@vis.gl/react-maplibre'; -import { useMapStore } from '../../store/mapStore'; +import { useMapStore } from '@common/store/mapStore'; import { midpointOf, centroid } from './measureLayers'; /** 완료된 측정 결과의 지우기 버튼을 Marker로 렌더 */ diff --git a/frontend/src/common/components/map/S57EncOverlay.tsx b/frontend/src/components/common/map/S57EncOverlay.tsx similarity index 98% rename from frontend/src/common/components/map/S57EncOverlay.tsx rename to frontend/src/components/common/map/S57EncOverlay.tsx index 2149de5..98b146e 100644 --- a/frontend/src/common/components/map/S57EncOverlay.tsx +++ b/frontend/src/components/common/map/S57EncOverlay.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; import { useMap } from '@vis.gl/react-maplibre'; -import { API_BASE_URL } from '../../services/api'; +import { API_BASE_URL } from '@common/services/api'; +/* eslint-disable react-refresh/only-export-components */ const PROXY_PREFIX = `${API_BASE_URL}/tiles/enc`; // MapLibre 내부 요청(sprite, tiles, glyphs)은 절대 URL이 필요 diff --git a/frontend/src/common/components/map/SrOverlay.tsx b/frontend/src/components/common/map/SrOverlay.tsx similarity index 97% rename from frontend/src/common/components/map/SrOverlay.tsx rename to frontend/src/components/common/map/SrOverlay.tsx index 13d12d3..5c3bf1f 100644 --- a/frontend/src/common/components/map/SrOverlay.tsx +++ b/frontend/src/components/common/map/SrOverlay.tsx @@ -1,9 +1,10 @@ import { useEffect, useRef, useCallback, useState } from 'react'; import { useMap } from '@vis.gl/react-maplibre'; -import { API_BASE_URL } from '../../services/api'; -import { useLayerTree } from '../../hooks/useLayers'; -import type { Layer } from '../../services/layerService'; +import { API_BASE_URL } from '@common/services/api'; +import { useLayerTree } from '@common/hooks/useLayers'; +import type { Layer } from '@common/services/layerService'; import { getOpacityProp, getColorProp } from './srStyles'; +/* eslint-disable react-refresh/only-export-components */ const SR_SOURCE_ID = 'sr'; const PROXY_PREFIX = `${API_BASE_URL}/tiles`; diff --git a/frontend/src/common/components/map/TimelineControl.tsx b/frontend/src/components/common/map/TimelineControl.tsx similarity index 100% rename from frontend/src/common/components/map/TimelineControl.tsx rename to frontend/src/components/common/map/TimelineControl.tsx diff --git a/frontend/src/common/components/map/VesselInteraction.tsx b/frontend/src/components/common/map/VesselInteraction.tsx similarity index 99% rename from frontend/src/common/components/map/VesselInteraction.tsx rename to frontend/src/components/common/map/VesselInteraction.tsx index c350fbf..a4a18e0 100644 --- a/frontend/src/common/components/map/VesselInteraction.tsx +++ b/frontend/src/components/common/map/VesselInteraction.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import type { VesselPosition } from '@common/types/vessel'; +import type { VesselPosition } from '@/types/vessel'; import { getShipKindLabel } from './VesselLayer'; export interface VesselHoverInfo { diff --git a/frontend/src/common/components/map/VesselLayer.ts b/frontend/src/components/common/map/VesselLayer.ts similarity index 98% rename from frontend/src/common/components/map/VesselLayer.ts rename to frontend/src/components/common/map/VesselLayer.ts index 8337729..2ea881a 100644 --- a/frontend/src/common/components/map/VesselLayer.ts +++ b/frontend/src/components/common/map/VesselLayer.ts @@ -1,5 +1,5 @@ import { IconLayer, TextLayer } from '@deck.gl/layers'; -import type { VesselPosition } from '@common/types/vessel'; +import type { VesselPosition } from '@/types/vessel'; export interface VesselLegendItem { code: string; diff --git a/frontend/src/common/components/map/mapStyles.ts b/frontend/src/components/common/map/mapStyles.ts similarity index 100% rename from frontend/src/common/components/map/mapStyles.ts rename to frontend/src/components/common/map/mapStyles.ts diff --git a/frontend/src/common/components/map/mapUtils.ts b/frontend/src/components/common/map/mapUtils.ts similarity index 100% rename from frontend/src/common/components/map/mapUtils.ts rename to frontend/src/components/common/map/mapUtils.ts diff --git a/frontend/src/common/components/map/measureLayers.ts b/frontend/src/components/common/map/measureLayers.ts similarity index 97% rename from frontend/src/common/components/map/measureLayers.ts rename to frontend/src/components/common/map/measureLayers.ts index dbc50df..52546ce 100644 --- a/frontend/src/common/components/map/measureLayers.ts +++ b/frontend/src/components/common/map/measureLayers.ts @@ -1,7 +1,7 @@ import { ScatterplotLayer, PathLayer, TextLayer, PolygonLayer } from '@deck.gl/layers'; import type { Layer as DeckLayer } from '@deck.gl/core'; -import type { MeasurePoint, MeasureResult } from '../../store/mapStore'; -import { formatDistance, formatArea } from '../../utils/geo'; +import type { MeasurePoint, MeasureResult } from '@common/store/mapStore'; +import { formatDistance, formatArea } from '@common/utils/geo'; const CYAN = [6, 182, 212, 220] as const; const CYAN_FILL = [6, 182, 212, 60] as const; diff --git a/frontend/src/common/components/map/srStyles.ts b/frontend/src/components/common/map/srStyles.ts similarity index 100% rename from frontend/src/common/components/map/srStyles.ts rename to frontend/src/components/common/map/srStyles.ts diff --git a/frontend/src/common/components/ui/ComboBox.tsx b/frontend/src/components/common/ui/ComboBox.tsx old mode 100755 new mode 100644 similarity index 100% rename from frontend/src/common/components/ui/ComboBox.tsx rename to frontend/src/components/common/ui/ComboBox.tsx diff --git a/frontend/src/common/components/ui/UserManualPopup.tsx b/frontend/src/components/common/ui/UserManualPopup.tsx similarity index 100% rename from frontend/src/common/components/ui/UserManualPopup.tsx rename to frontend/src/components/common/ui/UserManualPopup.tsx diff --git a/frontend/src/tabs/hns/components/HNSAnalysisListTable.tsx b/frontend/src/components/hns/components/HNSAnalysisListTable.tsx old mode 100755 new mode 100644 similarity index 99% rename from frontend/src/tabs/hns/components/HNSAnalysisListTable.tsx rename to frontend/src/components/hns/components/HNSAnalysisListTable.tsx index e7bad63..2952be4 --- a/frontend/src/tabs/hns/components/HNSAnalysisListTable.tsx +++ b/frontend/src/components/hns/components/HNSAnalysisListTable.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import type { Dispatch, SetStateAction } from 'react'; -import { fetchHnsAnalyses, type HnsAnalysisItem } from '../services/hnsApi'; +import { fetchHnsAnalyses } from '../services/hnsApi'; +import type { HnsAnalysisItem } from '@interfaces/hns/HnsInterface'; interface HNSAnalysisListTableProps { onTabChange: Dispatch>; diff --git a/frontend/src/tabs/hns/components/HNSLeftPanel.tsx b/frontend/src/components/hns/components/HNSLeftPanel.tsx old mode 100755 new mode 100644 similarity index 96% rename from frontend/src/tabs/hns/components/HNSLeftPanel.tsx rename to frontend/src/components/hns/components/HNSLeftPanel.tsx index 534cf0c..dae2a13 --- a/frontend/src/tabs/hns/components/HNSLeftPanel.tsx +++ b/frontend/src/components/hns/components/HNSLeftPanel.tsx @@ -1,37 +1,13 @@ import { useState, useEffect, useRef } from 'react'; -import { ComboBox } from '@common/components/ui/ComboBox'; +import { ComboBox } from '@components/common/ui/ComboBox'; import { useWeatherFetch } from '../hooks/useWeatherFetch'; import { getSubstanceToxicity } from '../utils/toxicityData'; -import type { WeatherFetchResult, ReleaseType } from '../utils/dispersionTypes'; -import { fetchGscAccidents } from '@tabs/prediction/services/predictionApi'; -import type { GscAccidentListItem } from '@tabs/prediction/services/predictionApi'; - -/** HNS 분석 입력 파라미터 (부모에 전달) */ -export interface HNSInputParams { - substance: string; - releaseType: ReleaseType; - /** 배출률 (g/s) — Plume, Dense Gas */ - emissionRate: number; - /** 총 누출량 (g) — Puff */ - totalRelease: number; - /** 누출 높이 (m) — 전 모델 */ - releaseHeight: number; - /** 누출 지속시간 (s) — Plume */ - releaseDuration: number; - /** 액체풀 반경 (m) — Dense Gas */ - poolRadius: number; - algorithm: string; - criteriaModel: string; - weather: WeatherFetchResult; - /** 사고 발생일 (YYYY-MM-DD) */ - accidentDate: string; - /** 사고 발생시각 (HH:mm) */ - accidentTime: string; - /** 예측시간 (예: '24시간') */ - predictionTime: string; - /** 사고명 (직접 입력 또는 사고 리스트 선택) */ - accidentName: string; -} +import type { + HNSInputParams, +} from '@interfaces/hns/HnsInterface'; +import type { ReleaseType } from '@/types/hns/HnsType'; +import { fetchGscAccidents } from '@components/prediction/services/predictionApi'; +import type { GscAccidentListItem } from '@interfaces/prediction/PredictionInterface'; interface HNSLeftPanelProps { activeSubTab: 'analysis' | 'list'; diff --git a/frontend/src/tabs/hns/components/HNSRecalcModal.tsx b/frontend/src/components/hns/components/HNSRecalcModal.tsx old mode 100755 new mode 100644 similarity index 96% rename from frontend/src/tabs/hns/components/HNSRecalcModal.tsx rename to frontend/src/components/hns/components/HNSRecalcModal.tsx index 3b4dd71..d3e060b --- a/frontend/src/tabs/hns/components/HNSRecalcModal.tsx +++ b/frontend/src/components/hns/components/HNSRecalcModal.tsx @@ -1,14 +1,6 @@ import { useState, useRef, useEffect } from 'react'; -import { ComboBox } from '@common/components/ui/ComboBox'; - -export interface RecalcParams { - substance: string; - releaseType: '연속 유출' | '순간 유출' | '밀도가스 유출'; - emissionRate: number; - totalRelease: number; - algorithm: string; - predictionTime: string; -} +import { ComboBox } from '@components/common/ui/ComboBox'; +import type { RecalcParams } from '@interfaces/hns/HnsInterface'; interface HNSRecalcModalProps { isOpen: boolean; diff --git a/frontend/src/tabs/hns/components/HNSRightPanel.tsx b/frontend/src/components/hns/components/HNSRightPanel.tsx old mode 100755 new mode 100644 similarity index 98% rename from frontend/src/tabs/hns/components/HNSRightPanel.tsx rename to frontend/src/components/hns/components/HNSRightPanel.tsx index df6ccd5..b9037a3 --- a/frontend/src/tabs/hns/components/HNSRightPanel.tsx +++ b/frontend/src/components/hns/components/HNSRightPanel.tsx @@ -1,4 +1,4 @@ -import type { DispersionGridResult, WeatherFetchResult } from '../utils/dispersionTypes'; +import type { DispersionGridResult, WeatherFetchResult } from '@interfaces/hns/HnsInterface'; import { windDirToCompass } from '../hooks/useWeatherFetch'; interface HNSRightPanelProps { diff --git a/frontend/src/components/hns/components/HNSScenarioView.tsx b/frontend/src/components/hns/components/HNSScenarioView.tsx new file mode 100644 index 0000000..6862c79 --- /dev/null +++ b/frontend/src/components/hns/components/HNSScenarioView.tsx @@ -0,0 +1,514 @@ +import { useState, useEffect } from 'react'; +import { fetchHnsAnalyses } from '../services/hnsApi'; +import type { HnsAnalysisItem, HnsScenario, HnsMaterial } from '@interfaces/hns/HnsInterface'; +import type { Severity } from '@/types/hns/HnsType'; +import { ScenarioDetail } from './contents/ScenarioDetail'; +import { ScenarioComparison } from './contents/ScenarioComparison'; +import { ScenarioMapOverlay } from './contents/ScenarioMapOverlay'; +import { NewScenarioModal } from './contents/NewScenarioModal'; +/* eslint-disable react-refresh/only-export-components */ + +// ─── Types ────────────────────────────────────────────── +type ViewTab = 0 | 1 | 2; + +export const SEVERITY_STYLE: Record = { + CRITICAL: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' }, + HIGH: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' }, + MEDIUM: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' }, + RESOLVED: { bg: 'rgba(34,197,94,0.15)', color: 'var(--color-success)' }, +}; + +// ─── Mock Data (시나리오 시뮬레이션 엔진 미구현 — 프론트 상수 유지) ── +const MOCK_SCENARIOS: HnsScenario[] = [ + { + id: 'S-01', + name: '유출 직후 (초기 확산)', + severity: 'CRITICAL', + timeStep: 'T+0h', + datetime: '2024.11.03 08:00 KST', + wind: '풍속 5.2m/s SW', + maxConc: '850 ppm', + idlhRadius: '1.2 km', + erpg2: '2.8 km', + population: '3,200명', + description: + '톨루엔 2.5톤 순간 유출. SW 풍향으로 온산 산업단지 방향 확산. IDLH 초과 구역 발생.', + detail: { + maxConc: '850ppm', + idlhRadius: '1.2km', + erpg2: '2.8km', + windDir: 'SW 225°', + windSpeed: '5.2 m/s', + population: '3,200명', + spillAmount: '2.5 ton', + }, + zones: { + idlh: '1.2 km (500ppm)', + erpg2: '2.8 km (300ppm)', + erpg1: '4.5 km (50ppm)', + twa: '6.2 km (20ppm)', + }, + weather: { + dir: 'SW 225°', + speed: '5.2 m/s', + temp: '18.5°C', + stability: 'D (중립)', + humidity: '65%', + mixHeight: '850 m', + }, + actions: [ + '반경 1.2km 즉시 대피 명령', + 'Level B 화학복 착용', + '화기 엄금 — 인화점 4°C', + '해양확산 동시 모니터링', + 'IDLH 경계 실시간 측정', + ], + }, + { + id: 'S-02', + name: '풍향 변화 시나리오', + severity: 'HIGH', + timeStep: 'T+1h', + datetime: '2024.11.03 09:00 KST', + wind: '풍속 4.8m/s SE', + maxConc: '420 ppm', + idlhRadius: '0.8 km', + erpg2: '2.1 km', + population: '5,100명', + description: '풍향 SE 전환. 주거지역 방향 확산 확대. 영향인구 증가. 대피 범위 조정 필요.', + detail: { + maxConc: '420ppm', + idlhRadius: '0.8km', + erpg2: '2.1km', + windDir: 'SE 135°', + windSpeed: '4.8 m/s', + population: '5,100명', + spillAmount: '2.5 ton', + }, + zones: { + idlh: '0.8 km (500ppm)', + erpg2: '2.1 km (300ppm)', + erpg1: '3.8 km (50ppm)', + twa: '5.5 km (20ppm)', + }, + weather: { + dir: 'SE 135°', + speed: '4.8 m/s', + temp: '19.2°C', + stability: 'C (약간 불안정)', + humidity: '62%', + mixHeight: '920 m', + }, + actions: ['대피 범위 SE 방향 확장', '주거지역 주민 대피 알림', '실시간 농도 모니터링 강화'], + }, + { + id: 'S-03', + name: '연속유출 확대', + severity: 'HIGH', + timeStep: 'T+3h', + datetime: '2024.11.03 11:00 KST', + wind: '풍속 3.5m/s S', + maxConc: '280 ppm', + idlhRadius: '0.5 km', + erpg2: '1.8 km', + population: '4,800명', + description: '연속유출 3시간 경과. 누적 유출량 증가. 풍속 감소로 체류 시간 증가.', + detail: { + maxConc: '280ppm', + idlhRadius: '0.5km', + erpg2: '1.8km', + windDir: 'S 180°', + windSpeed: '3.5 m/s', + population: '4,800명', + spillAmount: '4.2 ton', + }, + zones: { + idlh: '0.5 km (500ppm)', + erpg2: '1.8 km (300ppm)', + erpg1: '3.2 km (50ppm)', + twa: '4.8 km (20ppm)', + }, + weather: { + dir: 'S 180°', + speed: '3.5 m/s', + temp: '20.1°C', + stability: 'B (불안정)', + humidity: '58%', + mixHeight: '1,050 m', + }, + actions: ['유출원 차단 작업 투입', '풍속 감소 체류 경고', '추가 모니터링 포인트 설치'], + }, + { + id: 'S-04', + name: '유출 차단·잔류 확산', + severity: 'MEDIUM', + timeStep: 'T+6h', + datetime: '2024.11.03 14:00 KST', + wind: '풍속 6.1m/s W', + maxConc: '85 ppm', + idlhRadius: '—', + erpg2: '0.4 km', + population: '1,200명', + description: '유출원 차단 완료. 잔류 증기 자연 확산중. 풍속 증가로 희석 촉진.', + detail: { + maxConc: '85ppm', + idlhRadius: '—', + erpg2: '0.4km', + windDir: 'W 270°', + windSpeed: '6.1 m/s', + population: '1,200명', + spillAmount: '0 (차단)', + }, + zones: { + idlh: '— (해소)', + erpg2: '0.4 km (300ppm)', + erpg1: '1.2 km (50ppm)', + twa: '2.1 km (20ppm)', + }, + weather: { + dir: 'W 270°', + speed: '6.1 m/s', + temp: '21.3°C', + stability: 'C (약간 불안정)', + humidity: '52%', + mixHeight: '1,200 m', + }, + actions: ['IDLH 구역 해소 확인', '잔류 농도 지속 모니터링', '일부 대피 해제 검토'], + }, + { + id: 'S-05', + name: '대기확산 해제', + severity: 'RESOLVED', + timeStep: 'T+12h', + datetime: '2024.11.03 20:00 KST', + wind: '풍속 7.3m/s NW', + maxConc: '8 ppm', + idlhRadius: '—', + erpg2: '—', + population: '0명', + description: '전 구역 안전 농도 확인. 대피 해제. 잔류 오염 모니터링 지속.', + detail: { + maxConc: '8ppm', + idlhRadius: '—', + erpg2: '—', + windDir: 'NW 315°', + windSpeed: '7.3 m/s', + population: '0명', + spillAmount: '0 (종료)', + }, + zones: { idlh: '— (해소)', erpg2: '— (해소)', erpg1: '— (해소)', twa: '0.3 km (20ppm)' }, + weather: { + dir: 'NW 315°', + speed: '7.3 m/s', + temp: '16.8°C', + stability: 'D (중립)', + humidity: '68%', + mixHeight: '780 m', + }, + actions: ['전 구역 대피 해제', '잔류 오염 최종 모니터링', '사후 환경 평가 실시'], + }, +]; + +export const MATERIALS: HnsMaterial[] = [ + { + key: 'toluene', + name: '톨루엔', + mw: '92.14', + bp: '110.6°C', + fp: '4°C', + idlh: '500 ppm', + erpg2: '300 ppm', + }, + { + key: 'ammonia', + name: '암모니아', + mw: '17.03', + bp: '-33.3°C', + fp: 'N/A', + idlh: '300 ppm', + erpg2: '200 ppm', + }, + { + key: 'methanol', + name: '메탄올', + mw: '32.04', + bp: '64.7°C', + fp: '11°C', + idlh: '6,000 ppm', + erpg2: '1,000 ppm', + }, + { + key: 'hydrogen', + name: '수소', + mw: '2.016', + bp: '-252.9°C', + fp: 'N/A', + idlh: 'N/A', + erpg2: 'N/A', + }, + { + key: 'benzene', + name: '벤젠', + mw: '78.11', + bp: '80.1°C', + fp: '-11°C', + idlh: '500 ppm', + erpg2: '150 ppm', + }, + { + key: 'styrene', + name: '스티렌', + mw: '104.15', + bp: '145°C', + fp: '31°C', + idlh: '700 ppm', + erpg2: '250 ppm', + }, + { + key: 'lng', + name: 'LNG', + mw: '16.04', + bp: '-161.5°C', + fp: '-188°C', + idlh: 'N/A', + erpg2: '25,000 ppm', + }, +]; + +// ─── Main Component ───────────────────────────────────── +export function HNSScenarioView() { + const [incidents, setIncidents] = useState([]); + const [selectedIncident, setSelectedIncident] = useState(0); + const [scenarios, setScenarios] = useState(MOCK_SCENARIOS); + const [selectedIdx, setSelectedIdx] = useState(0); + const [checked, setChecked] = useState>(new Set([0, 1])); + const [activeView, setActiveView] = useState(0); + const [modalOpen, setModalOpen] = useState(false); + + useEffect(() => { + let cancelled = false; + fetchHnsAnalyses() + .then((items) => { + if (!cancelled) setIncidents(items); + }) + .catch((err) => console.error('[hns] 사고 목록 조회 실패:', err)); + return () => { + cancelled = true; + }; + }, []); + + const selected = scenarios[selectedIdx]; + + const toggleCheck = (idx: number) => { + setChecked((prev) => { + const next = new Set(prev); + if (next.has(idx)) next.delete(idx); + else next.add(idx); + return next; + }); + }; + + return ( +
+ {/* Header */} +
+
+ 📊 +
+
HNS 대기확산 시나리오 관리
+
+ 시간·조건별 대기확산 예측 시나리오 비교·검토 및 대응 의사결정 지원 +
+
+
+
+ + +
+
+ + {/* Body: Left list + Right detail */} +
+ {/* ── Left: Scenario List ── */} +
+
+ + 시나리오 목록 — 톨루엔 대기확산 + +
+ {['시간순', '위험도순'].map((label, i) => ( + + ))} +
+
+ + {/* Scrollable list */} +
+ {scenarios.map((scn, idx) => { + const sev = SEVERITY_STYLE[scn.severity]; + const isSel = selectedIdx === idx; + return ( +
{ + setSelectedIdx(idx); + setActiveView(0); + }} + > + {/* Title + badge */} +
+
+ toggleCheck(idx)} + onClick={(e) => e.stopPropagation()} + style={{ accentColor: 'var(--color-accent)' }} + /> + + {scn.id} {scn.name} + +
+ + {scn.severity} + +
+ + {/* Time row */} +
+ + {scn.timeStep} + + {scn.datetime} + {scn.wind} +
+ + {/* Metrics grid */} +
+ {[ + { label: '최대농도', value: scn.maxConc, color: 'var(--color-accent)' }, + { label: 'IDLH반경', value: scn.idlhRadius, color: 'var(--color-accent)' }, + { label: 'ERPG-2', value: scn.erpg2, color: 'var(--color-accent)' }, + { label: '영향인구', value: scn.population, color: 'var(--color-accent)' }, + ].map((m, i) => ( +
+
{m.label}
+
+ {m.value} +
+
+ ))} +
+ + {/* Description */} +
+ {scn.description} +
+
+ ); + })} +
+ + {/* Bottom buttons */} +
+ + +
+
+ + {/* ── Right: Detail Views ── */} +
+ {/* View Tabs */} +
+ {['시나리오 상세', '비교 차트', '확산범위 오버레이'].map((label, i) => ( + + ))} +
+ + {/* View 0: Detail */} + {activeView === 0 && selected && } + + {/* View 1: Comparison */} + {activeView === 1 && } + + {/* View 2: Map overlay */} + {activeView === 2 && } +
+
+ + {/* New Scenario Modal */} + setModalOpen(false)} + onSubmit={(name) => { + const newScn: HnsScenario = { + ...MOCK_SCENARIOS[0], + id: `S-${String(scenarios.length + 1).padStart(2, '0')}`, + name, + severity: 'MEDIUM', + }; + setScenarios((prev) => [...prev, newScn]); + setModalOpen(false); + }} + /> +
+ ); +} diff --git a/frontend/src/tabs/hns/components/HNSSubstanceView.tsx b/frontend/src/components/hns/components/HNSSubstanceView.tsx old mode 100755 new mode 100644 similarity index 66% rename from frontend/src/tabs/hns/components/HNSSubstanceView.tsx rename to frontend/src/components/hns/components/HNSSubstanceView.tsx index 8106060..eb8b0b9 --- a/frontend/src/tabs/hns/components/HNSSubstanceView.tsx +++ b/frontend/src/components/hns/components/HNSSubstanceView.tsx @@ -1,7 +1,8 @@ -import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import { sanitizeHtml } from '@common/utils/sanitize'; import { api } from '@common/services/api'; -import type { HNSSearchSubstance } from '@common/types/hns'; +import type { HNSSearchSubstance } from '@interfaces/hns/HnsInterface'; +import { HmsDetailPanel } from './contents/HmsDetailPanel'; /* ═══ HNS 물질 데이터베이스 ═══ */ interface HNSSubstance { @@ -1825,1038 +1826,3 @@ ${styles} ); } -/* ═══ HmsDetailPanel: 물질 상세정보 4-tab 패널 ═══ */ -function HmsDetailPanel({ - substance: s, - activeTab, - onTabChange, -}: { - substance: HNSSearchSubstance; - activeTab: number; - onTabChange: (t: number) => void; -}) { - const tabLabels = [ - '📊 물질특성·위험정보', - '🛡 방제거리·PPE·MSDS', - '⚓ IBC CODE·EmS 대응', - '🔗 화물적부도·항구별 코드', - ]; - const nfpa = s.nfpa; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const sebcColor = s.sebc.startsWith('G') - ? 'var(--color-accent)' - : s.sebc.startsWith('E') - ? 'var(--color-accent)' - : s.sebc.startsWith('F') - ? 'var(--color-caution)' - : s.sebc.startsWith('D') - ? 'var(--color-accent)' - : s.sebc.startsWith('S') - ? 'var(--color-accent)' - : 'var(--fg-sub)'; - - return ( -
- {/* Tab Navigation */} -
- {tabLabels.map((label, i) => ( - - ))} -
- - {/* TAB 0: 물질특성·위험정보 */} - {activeTab === 0 && ( -
- {/* Header */} -
-
- 🧪 -
-
-
- {s.nameKr}{' '} - ({s.nameEn}) -
-
- - CAS: {s.casNumber} - - - UN: {s.unNumber} - - - 운송방법: {s.transportMethod} - - - SEBC: {s.sebc} - -
-
- 유사명: {s.synonymsKr}  |  특성: {s.hazardClass} -
-
-
- -
- {/* Left: 물리·화학적 특성 */} -
-
- ⚗️ 물리·화학적 특성 -
-
- {( - [ - ['용도', s.usage, 'var(--color-accent)'], - ['상태', s.state, 'var(--color-accent)'], - ['색상', s.color, 'var(--color-accent)'], - ['냄새', s.odor, 'var(--color-accent)'], - ['인화점', s.flashPoint, 'var(--color-accent)'], - ['발화점', s.autoIgnition, 'var(--color-accent)'], - ['끓는점', s.boilingPoint, 'var(--color-accent)'], - ['비중 (물=1)', s.density, 'var(--color-accent)'], - ['용해도', s.solubility, 'var(--color-accent)'], - ['증기압', s.vaporPressure, 'var(--color-accent)'], - ['증기밀도 (공기=1)', s.vaporDensity, 'var(--color-caution)'], - ['폭발범위', s.explosionRange, 'var(--color-caution)'], - ] as [string, string, string][] - ).map(([label, value]) => ( -
- {label} -
- - {value} - -
- ))} -
-
- - {/* Right: NFPA + 위험등급 */} -
-
- ⚠️ 위험등급·농도기준 -
-
-
- - - - - {nfpa.health} - - - - {nfpa.fire} - - - - {nfpa.special} - - - - {nfpa.reactivity} - - -
- NFPA 704 -
-
-
-
- - 건강(적) {nfpa.health} - {' '} - —{' '} - {nfpa.health >= 4 - ? '치명적' - : nfpa.health >= 3 - ? '중상' - : nfpa.health >= 2 - ? '장해' - : nfpa.health >= 1 - ? '경미한 손상' - : '무해'} -
-
- - 인화성(황) {nfpa.fire} - {' '} - —{' '} - {nfpa.fire >= 4 - ? '93°F 미만' - : nfpa.fire >= 3 - ? '100°F 미만' - : nfpa.fire >= 2 - ? '200°F 미만' - : nfpa.fire >= 1 - ? '200°F 이상' - : '비가연'} -
-
- - 반응성(청) {nfpa.reactivity} - {' '} - —{' '} - {nfpa.reactivity >= 3 - ? '폭발 가능' - : nfpa.reactivity >= 2 - ? '격렬 반응' - : nfpa.reactivity >= 1 - ? '불안정 가능' - : '안정'} -
-
-
-
- - - - - -
-
-
-
- )} - - {/* TAB 1: 방제거리·PPE·MSDS */} - {activeTab === 1 && ( -
-
- {/* 방제거리 */} -
-
-
- 🚧 방제거리 (ERG {s.ergNumber}) -
-
-
-
-
🔥 화재 시
-
- 격리거리: {s.responseDistanceFire} 이상 -
-
-
-
- 💨 유출 시 (비화재) -
-
- 주간 방호활동거리:{' '} - {s.responseDistanceSpillDay} -
- 야간 방호활동거리:{' '} - {s.responseDistanceSpillNight} -
-
-
-
- 🌊 해상 유출 시 -
-
{s.marineResponse}
-
-
-
- -
- {/* PPE */} -
-
-
- 🛡 개인보호장구 (PPE) 추천 -
-
-
-
-
🧑‍🚒
-
근거리
-
{s.ppeClose}
-
-
-
🦺
-
원거리
-
{s.ppeFar}
-
-
-
- {/* MSDS */} -
-
-
- 📄 MSDS 주요 정보 -
- -
-
- §2 유해성·위험성: {s.msds.hazard} -
- §4 응급조치: {s.msds.firstAid} -
- §5 소화방법: {s.msds.fireFighting} -
- §6 누출대응: {s.msds.spillResponse} -
- §8 노출방지: {s.msds.exposure} -
- §15 법적규제: {s.msds.regulation} -
-
-
-
-
- )} - - {/* TAB 2: IBC CODE·EmS 대응 */} - {activeTab === 2 && ( -
-
- {/* IBC CODE */} -
-
-
- ⚓ IBC CODE 기반 주요 내용 -
-
-
-
- {( - [ - ['위험성', s.ibcHazard], - ['선박형식', s.ibcShipType], - ['탱크형식', s.ibcTankType], - ['탐지장비', s.ibcDetection], - ['소화설비', s.ibcFireFighting], - ['최소적재요건', s.ibcMinRequirement], - ] as [string, string][] - ).map(([label, value]) => ( - -
- {label} -
-
- {value} -
-
- ))} -
- {/* Tank diagram SVG */} -
- - - - - - CARGO - - - Tank 1 - - - CARGO - - - Tank 2 - - - CARGO - - - Tank 3 - - - {s.ibcShipType} — {s.ibcTankType} - - -
-
-
- - {/* EmS */} -
-
-
- 🆘 비상대응핸드북 (EmS) — ERG {s.ergNumber} -
-
-
-
-
🔥 화재 대응
-
{s.emsFire}
-
-
-
💧 유출 대응
-
{s.emsSpill}
-
-
-
🏥 응급조치
-
{s.emsFirstAid}
-
-
- -
-
-
-
-
- )} - - {/* TAB 3: 화물적부도·항구별 코드 */} - {activeTab === 3 && ( -
-
- {/* 화물적부도 */} -
-
-
- 📋 화물적부도 화물코드 -
-
클릭 시 물질검색창으로 이동
-
-
- - - - - - - - - - - {s.cargoCodes.map((c, i) => { - const srcColor = - c.source === '적부도' - ? 'var(--color-accent)' - : c.source === '용선자' - ? 'var(--color-accent)' - : 'var(--color-accent)'; - const srcBg = - c.source === '적부도' - ? 'rgba(6,182,212,.1)' - : c.source === '용선자' - ? 'rgba(6,182,212,.1)' - : 'rgba(6,182,212,.1)'; - return ( - - - - - - - ); - })} - -
- 화물코드 - - 약자/제품명 - - 국적/회사 - - 출처 -
- {c.code} - {c.name}{c.company} - - {c.source} - -
-
-
- - {/* 항구별 코드 */} -
-
-
🏗 항구별 코드
-
- Port-MIS 위험물반입신고현황 연동 -
-
-
- - - - - - - - - - - {s.portFrequency.map((p, i) => { - const freqColor = - p.frequency === '높음' - ? 'var(--color-accent)' - : p.frequency === '중간' - ? 'var(--color-accent)' - : 'var(--color-accent)'; - const freqBg = - p.frequency === '높음' - ? 'rgba(6,182,212,.1)' - : p.frequency === '중간' - ? 'rgba(6,182,212,.1)' - : 'rgba(6,182,212,.1)'; - return ( - - - - - - - ); - })} - -
- 항구 - - 청코드 - - 최근 반입 - - 빈도 -
{p.port} - {p.portCode} - {p.lastImport} - - {p.frequency} - -
-
- -
-
-
-
-
- )} -
- ); -} - -function InfoBoxRow({ - label, - value, - bg, - border, - labelColor, - valueColor, -}: { - label: string; - value: string; - bg: string; - border: string; - labelColor: string; - valueColor: string; -}) { - return ( -
- - {label} - - - {value} - -
- ); -} diff --git a/frontend/src/components/hns/components/HNSTheoryView.tsx b/frontend/src/components/hns/components/HNSTheoryView.tsx new file mode 100644 index 0000000..7215c03 --- /dev/null +++ b/frontend/src/components/hns/components/HNSTheoryView.tsx @@ -0,0 +1,143 @@ +import { useState, useRef } from 'react'; +import { SystemOverviewPanel } from './contents/SystemOverviewPanel'; +import { GaussianModelPanel } from './contents/GaussianModelPanel'; +import { SubstanceScenarioPanel } from './contents/SubstanceScenarioPanel'; +import { OceanCorrectionPanel } from './contents/OceanCorrectionPanel'; +import { VerificationPanel } from './contents/VerificationPanel'; +import { RealtimeComparePanel } from './contents/RealtimeComparePanel'; +import { WrfChemPanel } from './contents/WrfChemPanel'; +/* eslint-disable react-refresh/only-export-components */ + +const theoryTabs = [ + { icon: '🔬', name: '시스템 개요' }, + { icon: '🌀', name: '가우시안 모델' }, + { icon: '🧪', name: '물질별 시나리오' }, + { icon: '🌊', name: '해양환경 보정' }, + { icon: '✅', name: '모델 검증' }, + { icon: '⚡', name: '실시간 비교' }, + { icon: '🚀', name: 'WRF-Chem·발전' }, +]; + +/* ═══ 공통 스타일 유틸 ═══ */ +export const card = 'rounded-[10px] p-[14px] mb-4'; +export const cardBg = 'bg-bg-card border border-stroke'; +export const labelStyle = (color: string) => + ({ fontSize: 'var(--font-size-title-3)', fontWeight: 700, color, marginBottom: '10px' }) as const; +export const tag = (color: string) => + ({ + padding: '3px 8px', + borderRadius: '4px', + fontSize: 'var(--font-size-label-2)', + color, + background: `${color}14`, + border: `1px solid ${color}30`, + }) as const; +export const bodyText = 'text-label-1 text-fg-sub leading-[1.8]'; + +/* ═══ 패널 0: 시스템 개요 ═══ */ + + +export function HNSTheoryView() { + const [activePanel, setActivePanel] = useState(0); + const contentRef = useRef(null); + + const handleExportPDF = () => { + if (!contentRef.current) return; + const clone = contentRef.current.cloneNode(true) as HTMLElement; + clone.querySelectorAll('[data-html2pdf-ignore]').forEach((el) => el.remove()); + const content = clone.innerHTML; + const styles = Array.from(document.querySelectorAll('style, link[rel="stylesheet"]')) + .map((el) => el.outerHTML) + .join('\n'); + const fullHtml = ` + + +HNS 대기확산 모델 이론 +${styles} + +${content}`; + const blob = new Blob([fullHtml], { type: 'text/html; charset=utf-8' }); + const url = URL.createObjectURL(blob); + const win = window.open(url, '_blank'); + if (win) { + win.addEventListener('afterprint', () => URL.revokeObjectURL(url)); + setTimeout(() => { + win.document.title = 'HNS_대기확산_모델_이론'; + win.print(); + }, 500); + } + setTimeout(() => URL.revokeObjectURL(url), 30000); + }; + + return ( +
+
+ {/* 헤더 */} +
+
+
+ 📐 +
+
+
HNS 대기확산 모델 이론 및 검증
+
+ WRF-Chem · Gaussian Plume/Puff · ROMS · 해양환경 보정 — Based on Lee Moon-Jin et al. +
+
+
+ +
+ + {/* 서브탭 */} +
+ {theoryTabs.map((tab, i) => ( + + ))} +
+ + {/* 패널 콘텐츠 */} + {activePanel === 0 && } + {activePanel === 1 && } + {activePanel === 2 && } + {activePanel === 3 && } + {activePanel === 4 && } + {activePanel === 5 && } + {activePanel === 6 && } +
+
+ ); +} diff --git a/frontend/src/tabs/hns/components/HNSView.tsx b/frontend/src/components/hns/components/HNSView.tsx old mode 100755 new mode 100644 similarity index 83% rename from frontend/src/tabs/hns/components/HNSView.tsx rename to frontend/src/components/hns/components/HNSView.tsx index d2fc388..3197a99 --- a/frontend/src/tabs/hns/components/HNSView.tsx +++ b/frontend/src/components/hns/components/HNSView.tsx @@ -1,17 +1,18 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useVesselSignals } from '@common/hooks/useVesselSignals'; -import type { MapBounds } from '@common/types/vessel'; +import type { MapBounds } from '@/types/vessel'; import { HNSLeftPanel } from './HNSLeftPanel'; -import type { HNSInputParams } from './HNSLeftPanel'; +import type { HNSInputParams } from '@interfaces/hns/HnsInterface'; import { HNSRightPanel } from './HNSRightPanel'; -import { MapView } from '@common/components/map/MapView'; -import { TimelineControl } from '@common/components/map/TimelineControl'; +import { MapView } from '@components/common/map/MapView'; +import { TimelineControl } from '@components/common/map/TimelineControl'; import { HNSAnalysisListTable } from './HNSAnalysisListTable'; import { HNSTheoryView } from './HNSTheoryView'; import { HNSSubstanceView } from './HNSSubstanceView'; import { HNSScenarioView } from './HNSScenarioView'; +import { HNSManualViewer } from './contents/HNSManualViewer'; import { HNSRecalcModal } from './HNSRecalcModal'; -import type { RecalcParams } from './HNSRecalcModal'; +import type { RecalcParams } from '@interfaces/hns/HnsInterface'; import { useSubMenu, navigateToTab, @@ -26,176 +27,11 @@ import { getSubstanceToxicity } from '../utils/toxicityData'; import type { DispersionPoint, DispersionGridResult, - DispersionModel, MeteoParams, SourceParams, SimParams, - AlgorithmType, -} from '../utils/dispersionTypes'; - -/* ─── HNS 매뉴얼 뷰어 컴포넌트 ─── */ -function HNSManualViewer() { - const card = 'rounded-md p-4 mb-3'; - - return ( -
-
- {/* 헤더 */} -
-
-
📖 해양 HNS 대응 매뉴얼
-
- Marine HNS Response Manual — Bonn Agreement / HELCOM / REMPEC (WestMOPoCo 2024 - 한국어판) -
-
-
- - {/* 목차 카드 그리드 */} -
- {[ - { - icon: '📘', - title: '1. 서론', - desc: 'HNS 정의 · OPRC-HNS 의정서 · HNS 협약 범위 및 목적', - color: 'var(--color-accent)', - }, - { - icon: '⚖️', - title: '2. IMO 협약·의정서·규칙', - desc: 'SOLAS · MARPOL · IBC Code · IMDG Code · IGC Code', - color: 'var(--color-accent)', - }, - { - icon: '🔬', - title: '3. HNS 거동 및 유해요소', - desc: 'SEBC 거동분류 · MSDS · GESAMP · 물리화학적 특성', - color: 'var(--color-accent)', - }, - { - icon: '🛡️', - title: '4. 대비', - desc: '위험 평가 · 비상 계획 · 교육훈련 · 장비 비축', - color: 'var(--color-accent)', - }, - { - icon: '🚨', - title: '5. 대응', - desc: '최초 조치 · 안전구역 · PPE · 모니터링 · 대응 기술', - color: 'var(--color-info)', - }, - { - icon: '🔄', - title: '6. 유출 후 관리', - desc: '비용 문서화 · 환경 회복 · 사고 검토 · 교훈', - color: 'var(--color-accent)', - }, - { - icon: '📋', - title: '7. 사례연구', - desc: '실제 HNS 해양사고 사례 분석 및 교훈', - color: 'var(--color-accent)', - }, - { - icon: '📊', - title: '8. 자료표', - desc: '물질별 데이터시트 · AEGL · 노출 한계값', - color: 'var(--color-accent)', - }, - ].map((ch) => ( -
-
{ch.icon}
-
{ch.title}
-
{ch.desc}
-
- ))} -
- - {/* SEBC 거동 분류 */} -
-
- SEBC 거동 분류 (Standard European Behaviour Classification) -
-
- 물질의 물리적·화학적 특성(용해도, 밀도, 증기압, 점도)에 따라 이론적 거동을 5가지 주요 - 범주 + 7가지 하위 범주로 분류 -
-
- {[ - { - icon: '💨', - label: 'G — 가스', - desc: '대기 중 확산\n증기압 > 101.3kPa\n예: 암모니아, 염소', - color: 'rgba(139,92,246', - }, - { - icon: '🌫️', - label: 'E — 증발', - desc: '수면→대기 증발\n증기압 > 3kPa\n예: 벤젠, 톨루엔', - color: 'rgba(6,182,212', - }, - { - icon: '🟡', - label: 'F — 부유', - desc: '해수면에 부유\n밀도 < 1.025\n예: 스티렌, 크실렌', - color: 'rgba(251,191,36', - }, - { - icon: '💧', - label: 'D — 용해', - desc: '해수에 용해\n용해도 > 5%\n예: 메탄올, 염산', - color: 'rgba(6,182,212', - }, - { - icon: '⬇️', - label: 'S — 침강', - desc: '해저로 침강\n밀도 > 1.025\n예: EDC, 사염화탄소', - color: 'rgba(139,148,158', - }, - ].map((s) => ( -
-
{s.icon}
-
- {s.label} -
-
- {s.desc} -
-
- ))} -
-
- - {/* 출처 */} -
- 출처: Marine HNS Response Manual — Bonn Agreement / HELCOM / REMPEC (WestMOPoCo - Project, 2024 한국어판) -
- 번역: 원해민, 이시연, 양보경, 강성길, 이성엽 — KRISO 선박해양플랜트연구소 / NOWPAP MERRAC -
- 원본: Alcaro L., Brandt J., Giraud W., Mannozzi M., Nicolas-Kopec A. (2021) ISBN: - 978-2-87893-147-1 -
-
-
- ); -} +} from '@interfaces/hns/HnsInterface'; +import type { DispersionModel, AlgorithmType } from '@/types/hns/HnsType'; /** 히트맵 포인트에서 지도 자동 줌용 바운드 산정 (15% 패딩) */ function computeBoundsFromHeatmap( diff --git a/frontend/src/components/hns/components/contents/GaussianModelPanel.tsx b/frontend/src/components/hns/components/contents/GaussianModelPanel.tsx new file mode 100644 index 0000000..991d0da --- /dev/null +++ b/frontend/src/components/hns/components/contents/GaussianModelPanel.tsx @@ -0,0 +1,718 @@ +import { cardBg } from '../HNSTheoryView'; + +export function GaussianModelPanel() { + return ( + <> +
+ {/* 가우시안 플룸 모델 */} +
+
+
+ 🌀 +
+
+
+ Gaussian Plume Model +
+
+ 연속 방출 (Continuous Release) +
+
+
+
+
+ 📌 농도 산출식 (Concentration Equation) +
+
+ C(x,y,z) = Q / (2π ·{' '} + + σy + {' '} + ·{' '} + + σz + {' '} + · u) +
× exp(-y² / 2 + + σy + + ²) +
× [exp(-(z-H)² / 2 + + σz + + ²) + exp(-(z+H)² / 2 + + σz + + ²)] +
+
+
+
+ + Q + {' '} + = 배출률 (g/s) +
+
+ + u + {' '} + = 풍속 (m/s) +
+
+ + σy + {' '} + = 수평 확산계수 +
+
+ + σz + {' '} + = 연직 확산계수 +
+
+ + H + {' '} + + = 유효 방출고도 (m) — 물리적 높이 + 부력 상승 보정 + +
+
+
+ 💡 적용 조건: 정상 상태 연속 배출, 균일 + 풍속, 평탄 지형. 해양 HNS 사고에서 탱크 파손으로 인한 지속적 누출 시나리오에 적합. +
+
+ + {/* 가우시안 퍼프 모델 */} +
+
+
+ 💨 +
+
+
+ Gaussian Puff Model +
+
+ 순간 방출 (Instantaneous Release) +
+
+
+
+
+ 📌 농도 산출식 (Puff Concentration) +
+
+ C(x,y,z,t) = M / [(2π) + 3/2 ·{' '} + + σx + {' '} + ·{' '} + + σy + {' '} + ·{' '} + + σz + + ] +
× exp(-(x-ut)² / 2 + + σx + + ²) +
× exp(-y² / 2 + + σy + + ²) +
× [exp(-(z-H)² / 2 + + σz + + ²) + exp(-(z+H)² / 2 + + σz + + ²)] +
+
+
+
+ + M + {' '} + = 총 배출량 (g) +
+
+ + t + {' '} + = 경과시간 (s) +
+
+ + σx + {' '} + + = 풍하방향 확산계수 — Plume에서는 u에 의해 이미 반영 + +
+
+
+ 💡 적용 조건: 순간적 대량 방출, + 폭발·탱크 파열. 해양 사고 중 LPG/LNG 탱크 파열, 수소 연료선 폭발 시나리오에 적합. +
+
+
+ + {/* Pasquill-Gifford 확산 계수 테이블 */} +
+
+
+ 📊 Pasquill-Gifford 대기안정도 분류 및 확산계수 +
+
+ σy = a·xb , σz = c·xd + f +
+
+
+ + + + + + + + + + + + + + {[ + { + grade: 'A', + class: '매우 불안정', + cond: '맑은 날 강한 일사', + sy: '0.3658, 0.9024', + sz: '0.192, 0.936, 0', + wind: '< 2 m/s', + ocean: { + label: '드물게', + bg: 'rgba(6,182,212,.12)', + color: 'var(--color-accent)', + }, + gradeColor: 'var(--color-accent)', + rowBg: undefined, + }, + { + grade: 'B', + class: '불안정', + cond: '맑은 날 약한 일사', + sy: '0.2751, 0.9031', + sz: '0.156, 0.922, 0', + wind: '2–3 m/s', + ocean: { + label: '드물게', + bg: 'rgba(6,182,212,.12)', + color: 'var(--color-accent)', + }, + gradeColor: 'var(--color-accent)', + rowBg: undefined, + }, + { + grade: 'C', + class: '약간 불안정', + cond: '흐린 날 주간', + sy: '0.2090, 0.9031', + sz: '0.116, 0.905, 0', + wind: '3–5 m/s', + ocean: { + label: '적합', + bg: 'rgba(6,182,212,.12)', + color: 'var(--color-accent)', + }, + gradeColor: 'var(--color-accent)', + rowBg: undefined, + }, + { + grade: 'D ★', + class: '중립', + cond: '흐린 날 / 해상풍', + sy: '0.1471, 0.9031', + sz: '0.079, 0.881, 0', + wind: '5–6 m/s', + ocean: { + label: '★ 해양 대표', + bg: 'rgba(6,182,212,.15)', + color: 'var(--color-accent)', + fontWeight: 700, + }, + gradeColor: 'var(--color-accent)', + rowBg: 'rgba(6,182,212,.03)', + }, + { + grade: 'E', + class: '약간 안정', + cond: '야간 약한 바람', + sy: '0.1046, 0.9031', + sz: '0.064, 0.871, 0', + wind: '3–5 m/s', + ocean: { + label: '적합', + bg: 'rgba(6,182,212,.12)', + color: 'var(--color-accent)', + }, + gradeColor: 'var(--color-accent)', + rowBg: undefined, + }, + { + grade: 'F', + class: '안정', + cond: '야간 맑은 하늘', + sy: '0.0722, 0.9031', + sz: '0.051, 0.814, 0', + wind: '< 3 m/s', + ocean: { + label: '위험↑', + bg: 'rgba(6,182,212,.12)', + color: 'var(--color-accent)', + }, + gradeColor: 'var(--color-accent)', + rowBg: undefined, + }, + ].map((row, idx) => ( + + + + + + + + + + ))} + +
+ 안정도 + + 분류 + + 기상 조건 + + σy 계수 (a, b) + + σz 계수 (c, d, f) + + 풍속 범위 + + 해양 적용성 +
+ {row.grade} + + {row.class} + + {row.cond} + + {row.sy} + + {row.sz} + + {row.wind} + + + {row.ocean.label} + +
+
+
+ ★ 해양 환경에서는 D 등급(중립)이 가장 + 빈번하게 나타남. 해풍·육풍 전환 시 일시적으로 A~C 등급 출현 가능. F 등급(안정)은 농도가 + 국지적으로 높게 유지되어 위험도 상승. +
+
+ + {/* 플룸 vs 퍼프 비교 */} +
+
+ ⚖ Plume vs Puff — 모델 선택 기준 +
+
+
+
+ 🌀 Plume (연속 배출) 선택 기준 +
+
+ {[ + '유출 지속시간 > 10분', + '탱크 균열/배관 파손 — 지속적 누출', + '풍속 > 1.5 m/s (정상류 가정 가능)', + '톨루엔, 벤젠, 자일렌 등 증발성 액체', + '암모니아 냉동 저장탱크 누출', + ].map((item) => ( +
+ {item} +
+ ))} +
+
+
+
+ 💨 Puff (순간 배출) 선택 기준 +
+
+ {[ + '유출 지속시간 < 10분', + '탱크 폭발/BLEVE — 순간 방출', + '풍향 변동이 큰 경우 (여러 퍼프 중첩)', + 'LPG, 수소, LNG 탱크 파열', + '컨테이너 화학물질 돌발 유출', + ].map((item) => ( +
+ {item} +
+ ))} +
+
+
+
+ + ); +} diff --git a/frontend/src/components/hns/components/contents/HNSManualViewer.tsx b/frontend/src/components/hns/components/contents/HNSManualViewer.tsx new file mode 100644 index 0000000..30721fe --- /dev/null +++ b/frontend/src/components/hns/components/contents/HNSManualViewer.tsx @@ -0,0 +1,162 @@ +export function HNSManualViewer() { + const card = 'rounded-md p-4 mb-3'; + + return ( +
+
+ {/* 헤더 */} +
+
+
📖 해양 HNS 대응 매뉴얼
+
+ Marine HNS Response Manual — Bonn Agreement / HELCOM / REMPEC (WestMOPoCo 2024 + 한국어판) +
+
+
+ + {/* 목차 카드 그리드 */} +
+ {[ + { + icon: '📘', + title: '1. 서론', + desc: 'HNS 정의 · OPRC-HNS 의정서 · HNS 협약 범위 및 목적', + color: 'var(--color-accent)', + }, + { + icon: '⚖️', + title: '2. IMO 협약·의정서·규칙', + desc: 'SOLAS · MARPOL · IBC Code · IMDG Code · IGC Code', + color: 'var(--color-accent)', + }, + { + icon: '🔬', + title: '3. HNS 거동 및 유해요소', + desc: 'SEBC 거동분류 · MSDS · GESAMP · 물리화학적 특성', + color: 'var(--color-accent)', + }, + { + icon: '🛡️', + title: '4. 대비', + desc: '위험 평가 · 비상 계획 · 교육훈련 · 장비 비축', + color: 'var(--color-accent)', + }, + { + icon: '🚨', + title: '5. 대응', + desc: '최초 조치 · 안전구역 · PPE · 모니터링 · 대응 기술', + color: 'var(--color-info)', + }, + { + icon: '🔄', + title: '6. 유출 후 관리', + desc: '비용 문서화 · 환경 회복 · 사고 검토 · 교훈', + color: 'var(--color-accent)', + }, + { + icon: '📋', + title: '7. 사례연구', + desc: '실제 HNS 해양사고 사례 분석 및 교훈', + color: 'var(--color-accent)', + }, + { + icon: '📊', + title: '8. 자료표', + desc: '물질별 데이터시트 · AEGL · 노출 한계값', + color: 'var(--color-accent)', + }, + ].map((ch) => ( +
+
{ch.icon}
+
{ch.title}
+
{ch.desc}
+
+ ))} +
+ + {/* SEBC 거동 분류 */} +
+
+ SEBC 거동 분류 (Standard European Behaviour Classification) +
+
+ 물질의 물리적·화학적 특성(용해도, 밀도, 증기압, 점도)에 따라 이론적 거동을 5가지 주요 + 범주 + 7가지 하위 범주로 분류 +
+
+ {[ + { + icon: '💨', + label: 'G — 가스', + desc: '대기 중 확산\n증기압 > 101.3kPa\n예: 암모니아, 염소', + color: 'rgba(139,92,246', + }, + { + icon: '🌫️', + label: 'E — 증발', + desc: '수면→대기 증발\n증기압 > 3kPa\n예: 벤젠, 톨루엔', + color: 'rgba(6,182,212', + }, + { + icon: '🟡', + label: 'F — 부유', + desc: '해수면에 부유\n밀도 < 1.025\n예: 스티렌, 크실렌', + color: 'rgba(251,191,36', + }, + { + icon: '💧', + label: 'D — 용해', + desc: '해수에 용해\n용해도 > 5%\n예: 메탄올, 염산', + color: 'rgba(6,182,212', + }, + { + icon: '⬇️', + label: 'S — 침강', + desc: '해저로 침강\n밀도 > 1.025\n예: EDC, 사염화탄소', + color: 'rgba(139,148,158', + }, + ].map((s) => ( +
+
{s.icon}
+
+ {s.label} +
+
+ {s.desc} +
+
+ ))} +
+
+ + {/* 출처 */} +
+ 출처: Marine HNS Response Manual — Bonn Agreement / HELCOM / REMPEC (WestMOPoCo + Project, 2024 한국어판) +
+ 번역: 원해민, 이시연, 양보경, 강성길, 이성엽 — KRISO 선박해양플랜트연구소 / NOWPAP MERRAC +
+ 원본: Alcaro L., Brandt J., Giraud W., Mannozzi M., Nicolas-Kopec A. (2021) ISBN: + 978-2-87893-147-1 +
+
+
+ ); +} diff --git a/frontend/src/components/hns/components/contents/HmsDetailPanel.tsx b/frontend/src/components/hns/components/contents/HmsDetailPanel.tsx new file mode 100644 index 0000000..fa42919 --- /dev/null +++ b/frontend/src/components/hns/components/contents/HmsDetailPanel.tsx @@ -0,0 +1,1008 @@ +import React from 'react'; +import type { HNSSearchSubstance } from '@interfaces/hns/HnsInterface'; +import { InfoBoxRow } from './InfoBoxRow'; + +export function HmsDetailPanel({ + substance: s, + activeTab, + onTabChange, +}: { + substance: HNSSearchSubstance; + activeTab: number; + onTabChange: (t: number) => void; +}) { + const tabLabels = [ + '📊 물질특성·위험정보', + '🛡 방제거리·PPE·MSDS', + '⚓ IBC CODE·EmS 대응', + '🔗 화물적부도·항구별 코드', + ]; + const nfpa = s.nfpa; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const sebcColor = s.sebc.startsWith('G') + ? 'var(--color-accent)' + : s.sebc.startsWith('E') + ? 'var(--color-accent)' + : s.sebc.startsWith('F') + ? 'var(--color-caution)' + : s.sebc.startsWith('D') + ? 'var(--color-accent)' + : s.sebc.startsWith('S') + ? 'var(--color-accent)' + : 'var(--fg-sub)'; + + return ( +
+ {/* Tab Navigation */} +
+ {tabLabels.map((label, i) => ( + + ))} +
+ + {/* TAB 0: 물질특성·위험정보 */} + {activeTab === 0 && ( +
+ {/* Header */} +
+
+ 🧪 +
+
+
+ {s.nameKr}{' '} + ({s.nameEn}) +
+
+ + CAS: {s.casNumber} + + + UN: {s.unNumber} + + + 운송방법: {s.transportMethod} + + + SEBC: {s.sebc} + +
+
+ 유사명: {s.synonymsKr}  |  특성: {s.hazardClass} +
+
+
+ +
+ {/* Left: 물리·화학적 특성 */} +
+
+ ⚗️ 물리·화학적 특성 +
+
+ {( + [ + ['용도', s.usage, 'var(--color-accent)'], + ['상태', s.state, 'var(--color-accent)'], + ['색상', s.color, 'var(--color-accent)'], + ['냄새', s.odor, 'var(--color-accent)'], + ['인화점', s.flashPoint, 'var(--color-accent)'], + ['발화점', s.autoIgnition, 'var(--color-accent)'], + ['끓는점', s.boilingPoint, 'var(--color-accent)'], + ['비중 (물=1)', s.density, 'var(--color-accent)'], + ['용해도', s.solubility, 'var(--color-accent)'], + ['증기압', s.vaporPressure, 'var(--color-accent)'], + ['증기밀도 (공기=1)', s.vaporDensity, 'var(--color-caution)'], + ['폭발범위', s.explosionRange, 'var(--color-caution)'], + ] as [string, string, string][] + ).map(([label, value]) => ( +
+ {label} +
+ + {value} + +
+ ))} +
+
+ + {/* Right: NFPA + 위험등급 */} +
+
+ ⚠️ 위험등급·농도기준 +
+
+
+ + + + + {nfpa.health} + + + + {nfpa.fire} + + + + {nfpa.special} + + + + {nfpa.reactivity} + + +
+ NFPA 704 +
+
+
+
+ + 건강(적) {nfpa.health} + {' '} + —{' '} + {nfpa.health >= 4 + ? '치명적' + : nfpa.health >= 3 + ? '중상' + : nfpa.health >= 2 + ? '장해' + : nfpa.health >= 1 + ? '경미한 손상' + : '무해'} +
+
+ + 인화성(황) {nfpa.fire} + {' '} + —{' '} + {nfpa.fire >= 4 + ? '93°F 미만' + : nfpa.fire >= 3 + ? '100°F 미만' + : nfpa.fire >= 2 + ? '200°F 미만' + : nfpa.fire >= 1 + ? '200°F 이상' + : '비가연'} +
+
+ + 반응성(청) {nfpa.reactivity} + {' '} + —{' '} + {nfpa.reactivity >= 3 + ? '폭발 가능' + : nfpa.reactivity >= 2 + ? '격렬 반응' + : nfpa.reactivity >= 1 + ? '불안정 가능' + : '안정'} +
+
+
+
+ + + + + +
+
+
+
+ )} + + {/* TAB 1: 방제거리·PPE·MSDS */} + {activeTab === 1 && ( +
+
+ {/* 방제거리 */} +
+
+
+ 🚧 방제거리 (ERG {s.ergNumber}) +
+
+
+
+
🔥 화재 시
+
+ 격리거리: {s.responseDistanceFire} 이상 +
+
+
+
+ 💨 유출 시 (비화재) +
+
+ 주간 방호활동거리:{' '} + {s.responseDistanceSpillDay} +
+ 야간 방호활동거리:{' '} + {s.responseDistanceSpillNight} +
+
+
+
+ 🌊 해상 유출 시 +
+
{s.marineResponse}
+
+
+
+ +
+ {/* PPE */} +
+
+
+ 🛡 개인보호장구 (PPE) 추천 +
+
+
+
+
🧑‍🚒
+
근거리
+
{s.ppeClose}
+
+
+
🦺
+
원거리
+
{s.ppeFar}
+
+
+
+ {/* MSDS */} +
+
+
+ 📄 MSDS 주요 정보 +
+ +
+
+ §2 유해성·위험성: {s.msds.hazard} +
+ §4 응급조치: {s.msds.firstAid} +
+ §5 소화방법: {s.msds.fireFighting} +
+ §6 누출대응: {s.msds.spillResponse} +
+ §8 노출방지: {s.msds.exposure} +
+ §15 법적규제: {s.msds.regulation} +
+
+
+
+
+ )} + + {/* TAB 2: IBC CODE·EmS 대응 */} + {activeTab === 2 && ( +
+
+ {/* IBC CODE */} +
+
+
+ ⚓ IBC CODE 기반 주요 내용 +
+
+
+
+ {( + [ + ['위험성', s.ibcHazard], + ['선박형식', s.ibcShipType], + ['탱크형식', s.ibcTankType], + ['탐지장비', s.ibcDetection], + ['소화설비', s.ibcFireFighting], + ['최소적재요건', s.ibcMinRequirement], + ] as [string, string][] + ).map(([label, value]) => ( + +
+ {label} +
+
+ {value} +
+
+ ))} +
+ {/* Tank diagram SVG */} +
+ + + + + + CARGO + + + Tank 1 + + + CARGO + + + Tank 2 + + + CARGO + + + Tank 3 + + + {s.ibcShipType} — {s.ibcTankType} + + +
+
+
+ + {/* EmS */} +
+
+
+ 🆘 비상대응핸드북 (EmS) — ERG {s.ergNumber} +
+
+
+
+
🔥 화재 대응
+
{s.emsFire}
+
+
+
💧 유출 대응
+
{s.emsSpill}
+
+
+
🏥 응급조치
+
{s.emsFirstAid}
+
+
+ +
+
+
+
+
+ )} + + {/* TAB 3: 화물적부도·항구별 코드 */} + {activeTab === 3 && ( +
+
+ {/* 화물적부도 */} +
+
+
+ 📋 화물적부도 화물코드 +
+
클릭 시 물질검색창으로 이동
+
+
+ + + + + + + + + + + {s.cargoCodes.map((c, i) => { + const srcColor = + c.source === '적부도' + ? 'var(--color-accent)' + : c.source === '용선자' + ? 'var(--color-accent)' + : 'var(--color-accent)'; + const srcBg = + c.source === '적부도' + ? 'rgba(6,182,212,.1)' + : c.source === '용선자' + ? 'rgba(6,182,212,.1)' + : 'rgba(6,182,212,.1)'; + return ( + + + + + + + ); + })} + +
+ 화물코드 + + 약자/제품명 + + 국적/회사 + + 출처 +
+ {c.code} + {c.name}{c.company} + + {c.source} + +
+
+
+ + {/* 항구별 코드 */} +
+
+
🏗 항구별 코드
+
+ Port-MIS 위험물반입신고현황 연동 +
+
+
+ + + + + + + + + + + {s.portFrequency.map((p, i) => { + const freqColor = + p.frequency === '높음' + ? 'var(--color-accent)' + : p.frequency === '중간' + ? 'var(--color-accent)' + : 'var(--color-accent)'; + const freqBg = + p.frequency === '높음' + ? 'rgba(6,182,212,.1)' + : p.frequency === '중간' + ? 'rgba(6,182,212,.1)' + : 'rgba(6,182,212,.1)'; + return ( + + + + + + + ); + })} + +
+ 항구 + + 청코드 + + 최근 반입 + + 빈도 +
{p.port} + {p.portCode} + {p.lastImport} + + {p.frequency} + +
+
+ +
+
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/hns/components/contents/InfoBoxRow.tsx b/frontend/src/components/hns/components/contents/InfoBoxRow.tsx new file mode 100644 index 0000000..a430106 --- /dev/null +++ b/frontend/src/components/hns/components/contents/InfoBoxRow.tsx @@ -0,0 +1,29 @@ +export function InfoBoxRow({ + label, + value, + bg, + border, + labelColor, + valueColor, +}: { + label: string; + value: string; + bg: string; + border: string; + labelColor: string; + valueColor: string; +}) { + return ( +
+ + {label} + + + {value} + +
+ ); +} diff --git a/frontend/src/components/hns/components/contents/NewScenarioModal.tsx b/frontend/src/components/hns/components/contents/NewScenarioModal.tsx new file mode 100644 index 0000000..6e8fdf1 --- /dev/null +++ b/frontend/src/components/hns/components/contents/NewScenarioModal.tsx @@ -0,0 +1,325 @@ +import { useState, useEffect, useRef } from 'react'; +import { MATERIALS } from '../HNSScenarioView'; + +export function NewScenarioModal({ + isOpen, + onClose, + onSubmit, +}: { + isOpen: boolean; + onClose: () => void; + onSubmit: (name: string) => void; +}) { + const backdropRef = useRef(null); + const [name, setName] = useState(''); + const [material, setMaterial] = useState('toluene'); + const [releaseType, setReleaseType] = useState('instant'); + const [amount, setAmount] = useState('2.5'); + const [unit, setUnit] = useState('t'); + const [timeStep, setTimeStep] = useState('T+0h'); + const [windDir, setWindDir] = useState('SW'); + const [windSpeed, setWindSpeed] = useState('5.2'); + const [temp, setTemp] = useState('18.5'); + const [stability, setStability] = useState('D'); + const [model, setModel] = useState('ALOHA'); + const [predTime, setPredTime] = useState('6'); + + const mat = MATERIALS.find((m) => m.key === material) || MATERIALS[0]; + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (e.target === backdropRef.current) onClose(); + }; + if (isOpen) document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [isOpen, onClose]); + + useEffect(() => { + if (isOpen) setName(''); // eslint-disable-line react-hooks/set-state-in-effect -- 모달 열릴 때 이름 초기화 + }, [isOpen]); + + if (!isOpen) return null; + + const handleSubmit = () => { + if (!name.trim()) return; + onSubmit(name.trim()); + }; + + return ( +
+
+ {/* Header */} +
+
+ 🧪 +
+
+

신규 HNS 대기확산 시나리오

+
+ 물질·기상·유출조건을 설정하여 새 시나리오를 생성합니다 +
+
+ +
+ + {/* Scrollable content */} +
+ {/* 기본 정보 */} + + + setName(e.target.value)} + placeholder="예: 풍향 변화 시나리오" + /> + +
+ + + + + + +
+
+ + {/* 물질·유출 조건 */} + + + + + {/* Material properties card */} +
+ {[ + { label: 'MW', value: mat.mw }, + { label: 'BP', value: mat.bp }, + { label: 'FP', value: mat.fp }, + { label: 'IDLH', value: mat.idlh }, + { label: 'ERPG-2', value: mat.erpg2 }, + ].map((p, i) => ( +
+
{p.label}
+
+ {p.value} +
+
+ ))} +
+
+ + + + +
+ setAmount(e.target.value)} + /> + +
+
+
+
+ + {/* 기상 조건 */} + +
+ + + + + setWindSpeed(e.target.value)} + step={0.1} + /> + + + setTemp(e.target.value)} + step={0.1} + /> + +
+
+ + + + + + +
+ + + +
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +} + +// ─── Helpers ───────────────────────────────────────────── +function ModalSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+ {title} +
+
{children}
+
+ ); +} + +function ModalField({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+ {children} +
+ ); +} diff --git a/frontend/src/components/hns/components/contents/OceanCorrectionPanel.tsx b/frontend/src/components/hns/components/contents/OceanCorrectionPanel.tsx new file mode 100644 index 0000000..33e30ac --- /dev/null +++ b/frontend/src/components/hns/components/contents/OceanCorrectionPanel.tsx @@ -0,0 +1,200 @@ +import { card, cardBg, labelStyle, tag } from '../HNSTheoryView'; + +export function OceanCorrectionPanel() { + return ( +
+ {/* 해양 보정 인자 */} +
+
🌊 해양환경 특수 보정 인자
+
+ {/* 해풍·육풍 순환 보정 */} +
+
+ 해풍·육풍 순환 보정 + 핵심 인자 +
+
+ 해안에서는 주간(해풍)·야간(육풍) 풍향 전환 발생. 기존 가우시안 모델은 정상 풍향을 + 가정하므로, 풍향 전환 시점에서 플룸 + 방향 급변을 반영해야 함. +
+ + θ(t) = θ₀ + Δθ · sigmoid((t - tshift)/τ) + +
+
+ {/* 해수면 거칠기 */} +
+
+ 해수면 거칠기 (z₀) 보정 + 중요 +
+
+ 해수면은 육상 대비 표면 거칠기가 매우 낮음 ( + + z₀ ≈ 0.0002 m + {' '} + vs 도시 0.5~2.0 m). Charnock 관계식 적용: +
+ + z₀ = αc · u*² / g (αc ≈ 0.011~0.018) + +
+
+ {/* SST 영향 */} +
+
+ 해수면 온도(SST) 영향 + 보조 +
+
+ 해수면 온도가 기온보다 높으면 열적 불안정 촉진 → 수직 혼합 증가 → 지표 농도 감소. + 반대로 SST < Tair이면 안정층 형성으로{' '} + 농도 체류↑. +
+
+ {/* MABL */} +
+
+ 해상 대기경계층(MABL) 구조 + 중요 +
+
+ 해양 대기경계층은 육상과 구조가 상이. 혼합고{' '} + + 300~800 m + + (육상 1~2 km)으로 낮아 확산이 억제될 수 있음. Fumigation 발생 시{' '} + 지표 농도 급상승. +
+
+
+
+ + {/* 보정 계수 시각화 */} +
+
+
+ 📈 확산계수 보정 비교 (σy at 1km) +
+
+
+ P-G 원본 +
+
+
+ + 68 m + +
+
+ 해양 보정 +
+
+
+ + 92 m + +
+
+ ALOHA +
+
+
+ + 78 m + +
+
+
+ ※ D등급(중립), 풍속 5 m/s, 풍하거리 1 km 기준 +
+
+ +
+
🔬 ALOHA vs 이문진박사모델 비교
+
+
+
+ ALOHA (EPA) +
+
+ • 가우시안 플룸 기본 +
+ • 평탄 지형 가정 +
+ • 표면 거칠기 고정값 +
+ • 해양 특성 미반영 +
• 1차원 풍향 고정 +
+
+
+
+ 이문진박사모델 (해양 보정) +
+
+ • 가우시안 + 해양 보정 +
+ • 해안 지형 효과 반영 +
+ • Charnock z₀ 동적 계산 +
+ • 해풍/육풍 전환 반영 +
• MABL 혼합고 가변 +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/hns/components/contents/RealtimeComparePanel.tsx b/frontend/src/components/hns/components/contents/RealtimeComparePanel.tsx new file mode 100644 index 0000000..ebcda09 --- /dev/null +++ b/frontend/src/components/hns/components/contents/RealtimeComparePanel.tsx @@ -0,0 +1,215 @@ +import { card, cardBg } from '../HNSTheoryView'; + +export function RealtimeComparePanel() { + return ( +
+ {/* 헤더 */} +
+
⚡ 실시간 모델 비교 시뮬레이션
+
+ + +
+
+ + {/* 입력 필드 */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* 결과 카드 */} +
+ {/* ALOHA */} +
+
+ ALOHA (EPA) +
+
+ 3.8 km +
+
AEGL-2 도달거리
+
+
+ 면적 +
+ + 12.3 km² + +
+
+ 최대농도 +
+ + 520 ppm + +
+
+
+ {/* 이문진박사모델 */} +
+
+ 이문진박사모델 (해양보정) +
+
+ 2.9 km +
+
AEGL-2 도달거리
+
+
+ 면적 +
+ + 8.4 km² + +
+
+ 최대농도 +
+ + 410 ppm + +
+
+
+ {/* 차이 분석 */} +
+
+ 차이 분석 +
+
+ -23.7% +
+
도달거리 감소율
+
+
+ 면적차 +
+ + -31.7% + +
+
+ 농도차 +
+ + -21.2% + +
+
+
+
+ + {/* 결과 설명 */} +
+ ⚡ 실시간 비교 결과: 해양 환경에서 ALOHA의 + 과대 예측 경향이 확인됩니다. 이문진박사모델은 해수면 거칠기(z₀), MABL 높이, SST-기온 차이를 + 반영하여 보다 현실적인 확산 범위를 산출합니다.{' '} + + 대피 구역 설정 시 ALOHA 결과를 보수적 기준으로, 이문진박사모델을 현실적 기준으로 병행 활용 + + 을 권장합니다. +
+
+ ); +} diff --git a/frontend/src/components/hns/components/contents/ScenarioComparison.tsx b/frontend/src/components/hns/components/contents/ScenarioComparison.tsx new file mode 100644 index 0000000..60978b8 --- /dev/null +++ b/frontend/src/components/hns/components/contents/ScenarioComparison.tsx @@ -0,0 +1,395 @@ +import type { Severity } from '@/types/hns/HnsType'; + +const CHART_DATA = [ + { + id: 'S-01', + label: 'T+0h', + conc: 850, + idlh: 1.2, + erpg2: 2.8, + pop: 3200, + wind: 'SW 5.2', + severity: 'CRITICAL' as Severity, + }, + { + id: 'S-02', + label: 'T+1h', + conc: 620, + idlh: 0.9, + erpg2: 3.4, + pop: 5800, + wind: 'SE 6.8', + severity: 'CRITICAL' as Severity, + }, + { + id: 'S-03', + label: 'T+3h', + conc: 420, + idlh: 0.5, + erpg2: 2.1, + pop: 1800, + wind: 'S 4.1', + severity: 'HIGH' as Severity, + }, + { + id: 'S-04', + label: 'T+6h', + conc: 85, + idlh: 0, + erpg2: 0.6, + pop: 120, + wind: 'W 3.5', + severity: 'MEDIUM' as Severity, + }, + { + id: 'S-05', + label: 'T+12h', + conc: 8, + idlh: 0, + erpg2: 0, + pop: 0, + wind: 'NW 2.8', + severity: 'RESOLVED' as Severity, + }, +]; + +const SEV_COLOR: Record = { + CRITICAL: 'var(--color-danger)', + HIGH: 'var(--color-warning)', + MEDIUM: 'var(--color-caution)', + RESOLVED: 'var(--color-success)', +}; + +export function ScenarioComparison() { + const D = CHART_DATA; + // SVG coordinate helpers for concentration chart (viewBox 500x140) + const concMax = 900; + const concX = [50, 157, 264, 371, 480]; + const concY = D.map((d) => 10 + (1 - d.conc / concMax) * 110); + const concPoly = concX.map((x, i) => `${x},${concY[i]}`).join(' '); + const concArea = concPoly + ` ${concX[4]},120 ${concX[0]},120`; + + // Radius chart helpers (viewBox 240x100) + const radMax = 4; + const radX = [30, 80, 130, 180, 230]; + const idlhY = D.map((d) => 10 + (1 - d.idlh / radMax) * 75); + const erpgY = D.map((d) => 10 + (1 - d.erpg2 / radMax) * 75); + + // Population chart helpers + const popMax = 6000; + const barW = 30; + const barX = [30, 70, 110, 150, 190]; + + return ( +
+ {/* Title */} +
+ 📊 시나리오 비교 — 시간대별 대기확산 지표 추이 +
+ + {/* ── Chart 1: 최대 지표면 농도 추이 (Line + Area) ── */} +
+
최대 지표면 농도 (ppm) 변화 추이
+ + + + + + + + {/* Axes */} + + + {/* Threshold lines */} + + + 500 IDLH + + + + 300 ERPG2 + + {/* Area fill */} + + {/* Line */} + + {/* Data points + labels */} + {D.map((d, i) => ( + + + + {d.conc} + + + {d.label} + + + ))} + +
+ + {/* ── Charts 2 & 3: 2-column grid ── */} +
+ {/* Chart 2: 위험 반경 변화 (Multi-line) */} +
+
위험 반경 (km) 변화
+ + + + {/* IDLH line (red solid) */} + `${x},${idlhY[i]}`).join(' ')} + fill="none" + stroke="#ef4444" + strokeWidth={1.5} + /> + {/* ERPG-2 line (orange dashed) */} + `${x},${erpgY[i]}`).join(' ')} + fill="none" + stroke="#f97316" + strokeWidth={1.5} + strokeDasharray="4,2" + /> + {/* Data points */} + {D.map((d, i) => { + const c = + d.idlh > 0 + ? d.severity === 'CRITICAL' || d.severity === 'HIGH' + ? '#ef4444' + : '#fbbf24' + : '#22c55e'; + return ( + + + + {d.label.replace('+', '+')} + + + ); + })} + {/* Legend */} + + + IDLH + + + + ERPG-2 + + +
+ + {/* Chart 3: 영향 인구 변화 (Bar) */} +
+
영향 인구 (명) 변화
+ + + + {D.map((d, i) => { + const h = Math.max(1, (d.pop / popMax) * 75); + const y = 85 - h; + const color = SEV_COLOR[d.severity]; + const barColor = `${color}33`; + return ( + + + + {d.pop > 0 ? d.pop.toLocaleString() : '0'} + + + {d.label.replace('+', '+')} + + + ); + })} + +
+
+ + {/* ── Chart 4: 시나리오 비교표 ── */} +
+
📋 시나리오 비교표
+ + + + {['지표', ...D.map((d) => `${d.id} (${d.label})`)].map((h, i) => ( + + ))} + + + + {/* 최대농도 */} + + + {D.map((d) => ( + + ))} + + {/* IDLH 반경 */} + + + {D.map((d) => ( + + ))} + + {/* ERPG-2 반경 */} + + + {D.map((d) => ( + + ))} + + {/* 영향인구 */} + + + {D.map((d) => ( + + ))} + + {/* 풍향/풍속 */} + + + {D.map((d) => ( + + ))} + + {/* 위험 등급 */} + + + {D.map((d) => ( + + ))} + + +
+ {h} +
최대농도 (ppm) + {d.conc} +
IDLH 반경 (km) 0 ? 'var(--color-danger)' : 'var(--color-success)' }} + > + {d.idlh || 0} +
ERPG-2 반경 (km) 0 ? 'var(--color-warning)' : 'var(--color-success)' }} + > + {d.erpg2 || 0} +
영향인구 (명) + {d.pop.toLocaleString()} +
풍향 / 풍속 + {d.wind} +
위험 등급 + {d.severity} +
+
+
+ ); +} diff --git a/frontend/src/components/hns/components/contents/ScenarioDetail.tsx b/frontend/src/components/hns/components/contents/ScenarioDetail.tsx new file mode 100644 index 0000000..d719c7b --- /dev/null +++ b/frontend/src/components/hns/components/contents/ScenarioDetail.tsx @@ -0,0 +1,129 @@ +import type { HnsScenario } from '@interfaces/hns/HnsInterface'; +import { SEVERITY_STYLE } from '../HNSScenarioView'; + +export function ScenarioDetail({ scenario }: { scenario: HnsScenario }) { + const d = scenario.detail; + return ( +
+ {/* Hero card */} +
+
+ + {scenario.id} {scenario.name} + + + {scenario.severity} + + + {scenario.datetime} + +
+
+ {[ + { label: '최대농도', value: d.maxConc, color: 'var(--color-accent)' }, + { label: 'IDLH 반경', value: d.idlhRadius, color: 'var(--color-accent)' }, + { label: 'ERPG-2', value: d.erpg2, color: 'var(--color-accent)' }, + { + label: '풍향/풍속', + value: `${d.windDir}\n${d.windSpeed}`, + color: 'var(--color-accent)', + }, + { label: '영향인구', value: d.population, color: 'var(--color-accent)' }, + { label: '유출량', value: d.spillAmount, color: 'var(--color-accent)' }, + ].map((m, i) => ( +
+
{m.label}
+
+ {m.value} +
+
+ ))} +
+
+ + {/* Two-column section */} +
+ {/* Threat Zones */} +
+

위험 구역

+
+ {[ + { + label: 'IDLH (즉시위험)', + value: scenario.zones.idlh, + color: 'var(--color-fg)', + }, + { + label: 'ERPG-2 (대피권고)', + value: scenario.zones.erpg2, + color: 'var(--fg-sub)', + }, + { + label: 'ERPG-1 (주의권고)', + value: scenario.zones.erpg1, + color: 'var(--fg-sub)', + }, + { label: 'TWA (작업허용)', value: scenario.zones.twa, color: 'var(--fg-sub)' }, + ].map((z, i) => ( +
+ {z.label} + + {z.value} + +
+ ))} +
+
+ + {/* Actions */} +
+

대응 권고 사항

+
+ {scenario.actions.map((action, i) => ( +
+ {/* */} + {action} +
+ ))} +
+
+
+ + {/* Weather */} +
+

기상 조건

+
+ {[ + { label: '풍향', value: scenario.weather.dir, icon: '🌬' }, + { label: '풍속', value: scenario.weather.speed, icon: '💨' }, + { label: '기온', value: scenario.weather.temp, icon: '🌡' }, + { label: '대기안정도', value: scenario.weather.stability, icon: '☁️' }, + { label: '습도', value: scenario.weather.humidity, icon: '💧' }, + { label: '혼합층', value: scenario.weather.mixHeight, icon: '📏' }, + ].map((w, i) => ( +
+
{w.icon}
+
{w.value}
+
{w.label}
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/hns/components/contents/ScenarioMapOverlay.tsx b/frontend/src/components/hns/components/contents/ScenarioMapOverlay.tsx new file mode 100644 index 0000000..4213932 --- /dev/null +++ b/frontend/src/components/hns/components/contents/ScenarioMapOverlay.tsx @@ -0,0 +1,29 @@ +export function ScenarioMapOverlay() { + return ( +
+
+ [시나리오별 확산범위 오버레이 지도] +
+
+ {[ + { label: 'T+0h SW방향', color: 'var(--color-danger)' }, + { label: 'T+1h SE 전환', color: 'var(--color-warning)' }, + { label: 'T+3h S방향', color: 'var(--color-caution)' }, + { label: 'T+6h 차단 후', color: 'var(--color-success)' }, + ].map((item, i) => ( +
+
+ {item.label} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/hns/components/contents/SubstanceScenarioPanel.tsx b/frontend/src/components/hns/components/contents/SubstanceScenarioPanel.tsx new file mode 100644 index 0000000..ef0263e --- /dev/null +++ b/frontend/src/components/hns/components/contents/SubstanceScenarioPanel.tsx @@ -0,0 +1,682 @@ +import { cardBg } from '../HNSTheoryView'; + +export function SubstanceScenarioPanel() { + return ( + <> +
+ {/* 암모니아 */} +
+
+
+ 🧪 + + 암모니아 (NH₃) + +
+ + 독성 ★★★ + +
+
+ {[ + { val: '-33°C', label: '비등점', color: 'var(--color-accent)' }, + { val: '0.682', label: '비중', color: 'var(--color-accent)' }, + { val: '300 ppm', label: 'IDLH', color: 'var(--color-accent)' }, + ].map((item) => ( +
+
+ {item.val} +
+
+ {item.label} +
+
+ ))} +
+
+
+ • 해수면 유출 시 급속 기화 → Flash + Evaporation +
+
+ • 2D 기화율:{' '} + + ṁ = 0.0168t − 0.01 + +
+
+ • 3D 기화율:{' '} + + ṁ = 0.0182t − 0.0112 + +
+
+ • 독성 구름 풍하측 최대 10km까지 영향 +
+
+
+ + {/* 메탄올 */} +
+
+
+ 🧫 + + 메탄올 (CH₃OH) + +
+ + 인화성+독성 + +
+
+ {[ + { val: '64.7°C', label: '비등점', color: 'var(--color-accent)' }, + { val: '0.791', label: '비중', color: 'var(--color-accent)' }, + { val: '6,000 ppm', label: 'IDLH', color: 'var(--color-accent)' }, + ].map((item) => ( +
+
+ {item.val} +
+
+ {item.label} +
+
+ ))} +
+
+
+ • 수용성 → 해수 용해 + 증발 복합 거동 +
+
+ • 인화점{' '} + + 11°C + {' '} + → 상온에서 증기 인화 위험 +
+
+ • 무색·무취 → 감지 어려움, 계측기 필수 +
+
+ • LFL~UFL:{' '} + + 6.0% ~ 36.5% + +
+
+
+ + {/* 수소 */} +
+
+
+ + + 수소 (H₂) + +
+ + 폭발성 ★★★ + +
+
+ {[ + { val: '-253°C', label: '비등점', color: 'var(--color-accent)' }, + { val: '0.071', label: '비중', color: 'var(--color-accent)' }, + { val: 'N/A', label: 'IDLH(질식)', color: 'var(--color-accent)' }, + ].map((item) => ( +
+
+ {item.val} +
+
+ {item.label} +
+
+ ))} +
+
+
+ • 공기보다 14배 가벼움 → 급속 상승 + 확산 +
+
+ • LFL~UFL:{' '} + + 4.0% ~ 75.0% + {' '} + (극넓은 가연범위) +
+
+ • 점화에너지{' '} + + 0.017 mJ + {' '} + (정전기로도 폭발) +
+
+ • Puff 모델 필수 → BLEVE/VCE 시나리오 +
+
+
+ + {/* LNG */} +
+
+
+ 🔵 + + LNG (CH₄) + +
+ + 인화성 ★★ + +
+
+ {[ + { val: '-162°C', label: '비등점', color: 'var(--color-accent)' }, + { val: '0.450', label: '비중', color: 'var(--color-accent)' }, + { val: 'N/A', label: 'IDLH(질식)', color: 'var(--color-accent)' }, + ].map((item) => ( +
+
+ {item.val} +
+
+ {item.label} +
+
+ ))} +
+
+
+ • 극저온 유출 시 RPT(Rapid Phase + Transition) 발생 +
+
+ • 해수면 Pool 형성 → 기화율{' '} + + 0.025t + +
+
+ • 가연범위:{' '} + + 5.0% ~ 15.0% + +
+
+ • 초기 냉각 증기 → 지표면 체류 후 상승 +
+
+
+
+ + {/* 물질별 AEGL 비교표 */} +
+
+ 📊 물질별 AEGL/ERPG 기준 비교 (ppm, 1시간 노출) +
+
+ {/* 헤더 */} +
+
+ 기준 +
+
등급
+
+ {[ + { icon: '🧪', label: 'NH₃', color: 'var(--color-accent)' }, + { icon: '🧫', label: 'MeOH', color: 'var(--color-accent)' }, + { icon: '⚡', label: 'H₂', color: 'var(--color-accent)' }, + { icon: '🔵', label: 'LNG', color: 'var(--color-accent)' }, + ].map((item) => ( +
+
+ {item.icon} +
+
{item.label}
+
+ ))} + {/* AEGL-1 */} +
+ AEGL-1 +
+
+ 30 +
+
+ 670 +
+
+ N/A +
+
+ N/A +
+ {/* AEGL-2 */} +
+ AEGL-2 +
+
+ 160 +
+
+ 2,100 +
+
+ N/A +
+
+ N/A +
+ {/* AEGL-3 */} +
+ AEGL-3 +
+
+ 1,100 +
+
+ 14,000 +
+
+ N/A +
+
+ N/A +
+ {/* LFL */} +
+ LFL (%) +
+
+ 15.0 +
+
+ 6.0 +
+
+ 4.0 +
+
+ 5.0 +
+
+
+ ※ H₂, LNG는 독성이 아닌 질식/인화성 위험으로 AEGL 대신 LFL/UFL, 과압(kPa) 기준 적용 +
+
+ + ); +} diff --git a/frontend/src/components/hns/components/contents/SystemOverviewPanel.tsx b/frontend/src/components/hns/components/contents/SystemOverviewPanel.tsx new file mode 100644 index 0000000..c9615db --- /dev/null +++ b/frontend/src/components/hns/components/contents/SystemOverviewPanel.tsx @@ -0,0 +1,1940 @@ +import { card, cardBg, labelStyle, bodyText } from '../HNSTheoryView'; + +export function SystemOverviewPanel() { + return ( + <> + {/* 시스템 개요 */} +
+ {/* 1행: 정의 + 목적 */} +
+
+
📋 HNS 대기확산 모델이란?
+
+ 해상에서 유출된 위험유해물질(Hazardous & Noxious Substances, HNS)이 대기로 + 증발하여 퍼지는 농도를 물리·화학적 이론에 기반해 전산으로 모의하는 프로그램입니다. + 기상 예측 모델(WRF)과 대기화학 모델(Chem)을 결합하여 실시간 대기 확산을 + 시뮬레이션합니다. +
+
+
+
🎯 목적
+
+
+ 유출 사고 시{' '} + 신속한 위험 범위 예측 +
+
+ 독성물질 농도 + 기반 대피 명령 의사결정 +
+
+ 사고 대응 + 체계 구축 및 피해 최소화 +
+
+
+
+ {/* 2행: 물질 특성 + 기상 데이터 + 확산 알고리즘 */} +
+
+
🧪 물질 특성 반영
+
+ HNS는 약 6,000종 이상으로, 종류가 매우 다양합니다. 각 물질의 고유 거동 특성을 + 모델에 반영합니다. +
+
+ {['증발률', '독성', '기화 특성', '비중', '인화점', '용해도'].map((item) => ( + + {item} + + ))} +
+
+
+
🌬 기상 데이터 연동
+
+ 실시간 기상 정보를 입력 데이터로 사용하여 확산 방향과 속도를 결정합니다. +
+
+ {[ + '💨 풍향·풍속 (KMA·ECMWF)', + '🌡 기온·습도·강수', + '📊 대기 안정도 (Pasquill-Gifford)', + ].map((item) => ( +
+ {item} +
+ ))} +
+
+
+
🔬 확산 알고리즘
+
+ 가우시안 플룸/퍼프 모델 또는 WRF-Chem 수치 모델을 사용하여 대기 중 농도를 + 계산합니다. +
+
+ {['📍 Gaussian Plume/Puff', '⚡ WRF-Chem (기상+화학)', '🔧 ALOHA / CAMEO'].map( + (item) => ( +
+ {item} +
+ ), + )} +
+
+
+
+ + {/* 주요 기능 및 특징 */} +
+
+ ⚙ 주요 기능 및 특징 +
+
+
+
+ 📡 + + 실시간 모니터링 + +
+
+ 'HNS 유출 블랙박스 시스템'과 같이 사고 + 즉시 확산 방향과 농도를 예측하여 시각화합니다. 사고 발생 → 자동 감지 → 실시간 확산 맵 + 생성의 자동화 파이프라인을 제공합니다. +
+
+
+
+ 🎯 + + 위험 범위 예측 + +
+
+ 대기 중 독성물질 농도에 따른{' '} + 안전/경고/위험 구역을 AEGL·ERPG + 기준으로 자동 설정합니다. 시간대별 확산 경계를 지도 위에 오버레이합니다. +
+
+
+
+ + + 긴급 대응 + +
+
+ 해상 사고 시 조화상수 DB를 이용한 빠른 + 예측 기술을 포함합니다. 사전 계산된 시나리오 DB를 활용하여 초기 대응 시간을{' '} + 수 분 이내로 단축합니다. +
+
+
+
+ + {/* 주요 기술 아키텍처 */} +
+
+ 🏗 주요 기술 아키텍처 +
+ {/* Flow diagram */} +
+ {[ + { + icon: '🧪', + label: 'HNS 물질 DB', + sub: '6,000+ 물질', + }, + { + icon: '🌬', + label: '기상 데이터', + sub: 'KMA / AWS', + }, + { + icon: '🔬', + label: '확산 모델 엔진', + sub: 'WRF-Chem / Gaussian', + }, + { + icon: '🗺', + label: 'GIS 시각화', + sub: '위험 구역 맵', + }, + { + icon: '🚨', + label: '대응 의사결정', + sub: '대피·방제 명령', + }, + ].map((item, idx) => ( + <> +
+
+ {item.icon} +
+
+ {item.label} +
+
+ {item.sub} +
+
+ {idx < 4 && ( +
+ → +
+ )} + + ))} +
+ + {/* 기술 상세 비교 */} +
+
+
+ WRF-Chem +
+
+ 기상 예측 모델(WRF)과 대기화학 모델( + Chem)을 결합. 3차원 바람장·난류를 + 실시간 계산하여 화학물질 이류·확산·반응을 동시에 모의합니다. +
+ + 해상도: 1~3 km / 시간분해능: 1 hr + +
+
+
+
+ Gaussian Plume/Puff +
+
+ ALOHA/CAMEO 표준 알고리즘 기반. 연속 배출( + Plume) 또는 순간 배출( + Puff) 시나리오 선택. 빠른 계산 속도로{' '} + 초기 대응에 최적화. +
+ + 계산시간: < 10초 / 정확도: ±10~40% + +
+
+
+
+ ROMS 해양 연동 +
+
+ Regional Ocean Modeling System과 연동하여 해수면 유출물의{' '} + 해양 확산 + 대기 증발을 동시에 + 모의합니다. 입자 추적 방식의 한계를 극복한 수치 모델. +
+ + 장기 모의 가능 / 정밀 농도 계산 + +
+
+
+
+ + {/* WING 시스템 적용 전략 */} +
+
+
+ 🖥 WING 시스템 적용 전략 +
+ + 현재 구현 + +
+
+ {[ + { + icon: '🧪', + label: 'HNS DB 연동', + sub: 'CHRIS/CAMEO DB\n6,000+종 물질 검색', + bar: 'var(--color-accent)', + }, + { + icon: '⚡', + label: '가우시안 모델', + sub: 'ALOHA + 이문진박사모델\n초기 대응 10초 이내', + bar: 'var(--color-accent)', + }, + { + icon: '🌐', + label: 'WRF-Chem', + sub: '정밀 수치 모의\n3D 확산 시뮬레이션', + bar: 'var(--color-accent)', + }, + { + icon: '🌊', + label: 'ROMS 연동', + sub: '해양-대기 결합\n장기 모의 지원', + bar: 'var(--color-accent)', + }, + ].map((item) => ( +
+
+ {item.icon} +
+
+ {item.label} +
+
+ {item.sub.split('\n').map((line, i) => ( + + {line} + {i === 0 &&
} +
+ ))} +
+
+
+ ))} +
+
+ + {' '} + 구현 완료 + + + {' '} + 개발 진행중 + + + {' '} + 계획 + +
+
+ + {/* HNS 사고 실태 및 항만별 위험도 */} +
+
+
+
+ 🚨 +
+
+
+ HNS 사고 실태 및 항만별 위험도 +
+
+ 유영현 (2013), 한국위기관리논집 · 해양경찰청 (2010, 2011) +
+
+
+ + 전 세계 HNS 6,500+종 + +
+ +
+ {/* 주요 항만별 위험도 */} +
+
+ 🏭 주요 항만별 화재·폭발·누출 위험도 +
+
+ + + + + + + + + + + + {[ + { + name: '울산', + fire: '◎', + exp: '◎', + leak: '◎', + hns: '27,574', + danger: true, + }, + { + name: '여수·광양', + fire: '◎', + exp: '◎', + leak: '◎', + hns: '38,700', + danger: true, + }, + { name: '대산', fire: '◎', exp: '○', leak: '◎', hns: '19,401', danger: false }, + { name: '인천', fire: '○', exp: '◎', leak: '○', hns: '41,618', danger: false }, + { name: '부산', fire: '○', exp: '△', leak: '○', hns: '25,417', danger: false }, + { + name: '평택·당진', + fire: '○', + exp: '◎', + leak: '○', + hns: '29,248', + danger: false, + }, + ].map((row) => ( + + + + + + + + ))} + +
+ 항만 + + 화재 + + 폭발 + + 누출 + + HNS(천톤) +
+ {row.name} + {row.fire}{row.exp}{row.leak} + {row.hns} +
+
+
+ ◎ 아주높음 + ○ 높음 + △ 보통이상 + □ 보통 + 단위: 천톤, 2010년 기준 +
+
+ + {/* HNS 구분 및 사고 유형 */} +
+
+ ⚠ HNS 위험물질 vs 유해물질 +
+
+
+
+ 위험물질 +
+
+ 폭발·인화·독성·부식·질식·중합 등으로 생명체에 위험을 초래하는 물질 +
+
+ {['포장위험물', '산적고체', '산적액체', '액화가스'].map((t) => ( + + {t} + + ))} +
+
+
+
+ 유해물질 +
+
+ 해양에서 해로운 영향을 초래하는 물질·에너지 (해양환경관리법) +
+
+ {['유해액체', '포장유해', '대기오염', '폐기물'].map((t) => ( + + {t} + + ))} +
+
+
+
+ 📊 유류 vs HNS 사고 대응 차이 +
+
+
+ 구분 +
+
+ 유류 +
+
+ HNS +
+ {[ + { + label: '물질', + oil: '눈으로 확인 가능', + hns: '보이지 않는 경우 多', + hnsC: 'var(--color-accent)', + }, + { + label: '위험', + oil: '낮은 위험성', + hns: '독성·폭발·화재 복합', + hnsC: 'var(--color-accent)', + }, + { + label: '예측', + oil: '단기 예측 가능', + hns: '다매체 피해예측 필요', + hnsC: 'var(--color-accent)', + }, + { + label: '대응', + oil: '초기대응자 방제가능', + hns: '전문가 방제 필수', + hnsC: 'var(--color-accent)', + }, + ].map((row) => ( + <> +
+ {row.label} +
+
+ {row.oil} +
+
+ {row.hns} +
+ + ))} +
+
+
+
+ + {/* 국내외 대응 체계 비교 */} +
+ {/* 한국 */} +
+
+ 🇰🇷 + + 한국 HNS 대응 체계 + +
+
+ {[ + { + title: 'OPRC-HNS 의정서', + sub: '2008.1.11 가입 → 2008.4.11 발효', + color: 'var(--color-accent)', + }, + { + title: '국가/지역 긴급방제계획', + sub: "HNS 국가긴급방제계획 수립 ('09.6)", + color: 'var(--color-accent)', + }, + { + title: 'CARIS 대응정보시스템', + sub: '사고대응 매뉴얼 + 화학물질 정보', + color: 'var(--color-accent)', + }, + { + title: '통합 대응 체계', + sub: '대통령 → 중앙안전관리위 → 중앙방제대책본부(해양경찰청장)', + color: 'var(--color-accent)', + }, + { + title: '현장 대응팀', + sub: '상황관리팀 → 사고대응팀(통제반·물질탐지반·방제작업반·제독지원반)', + color: 'var(--color-accent)', + }, + ].map((item) => ( +
+
+ {item.title} +
+ {item.sub} +
+
+ ))} +
+
+ + {/* 미국 */} +
+
+ 🇺🇸 + + 미국 HNS 대응 체계 + +
+
+ {[ + { + title: 'OPA90 / NCP', + sub: '국가긴급계획 ESF #10 — HNS 포함', + color: 'var(--color-accent)', + }, + { + title: '공동의장 체제', + sub: '환경청(EPA) + 해안경비대(USCG) 공동 지휘', + color: 'var(--color-accent)', + }, + { + title: 'CAMEO / ALOHA / MARPLOT', + sub: '6,000+종 화학물질 통합 DB · 대기확산 모델', + color: 'var(--color-accent)', + }, + { + title: 'NRT / RRT / NSF', + sub: '국가방제팀 + 지역방제팀 + 국가기동타격대', + color: 'var(--color-accent)', + }, + { + title: '통합지휘시스템(ICS)', + sub: '16개 부처 + 주·지방정부 + 유출책임자 통합 조정', + color: 'var(--color-accent)', + }, + ].map((item) => ( +
+
+ {item.title} +
+ {item.sub} +
+
+ ))} +
+
+
+ + {/* 국제 협약 체계 */} +
+
+ + 🌐 HNS 관련 국제 협약 체계 + +
+
+ {[{ label: '구분', 협약: '협약', 담당: '담당', 대상: '대상', header: true }].map(() => ( + <> +
+ 구분 +
+
+ 협약 +
+
+ 담당 +
+
+ 대상 +
+ + ))} + {/* 안전 */} +
+ 안전 +
+
+ 74 SOLAS +
+
+ IMO +
+
+ 위험물질 +
+ {/* 보호 */} +
+ 보호 +
+
+ MARPOL 73/78 +
+
+ IMO +
+
+ 기름·유해액체·포장유해·하수·폐기물 +
+ {/* 대비·대응 */} +
+ 대비·대응 +
+
+ 2000 OPRC-HNS +
+
+ IMO +
+
+ 위험·유해물질(HNS) +
+ {/* 배상 */} +
+ 배상 +
+
+ 96 HNS협약 +
+
+ IMO +
+
+ 유해물질 및 위험물질 +
+
+
+ + {/* HATS 사고이력 통계 */} +
+
+
+
+ 📊 +
+
+
+ 국내 HNS 사고 통계 (23년간 76건) +
+
+ 장하용·이문진 외 (2017) HATS 분석 · 1994~2016년 해양경비안전본부 데이터 +
+
+
+ + 연평균 3.3건 + +
+ +
+ {[ + { + label: '사고시기 1위', + val: '41%', + title: '춘계 (3~5월)', + sub: '동절기 30%', + color: 'var(--color-accent)', + border: 'rgba(6,182,212,.12)', + }, + { + label: '사고장소 1위', + val: '51%', + title: '계류장', + sub: '항만 29% · 연안 17%', + color: 'var(--color-accent)', + border: 'rgba(6,182,212,.12)', + }, + { + label: '오염원 1위', + val: '49%', + title: '케미컬운반선', + sub: '육상시설 32%', + color: 'var(--color-accent)', + border: 'rgba(6,182,212,.12)', + }, + { + label: '사고원인 1위', + val: '45%', + title: '승무원 과실', + sub: '화물 22% · 외적원인 17%', + color: 'var(--color-accent)', + border: 'rgba(6,182,212,.12)', + }, + { + label: '사고물질 1위', + val: '12%', + title: '자일렌류', + sub: '옥탄올·라텍스·팜유 순', + color: 'var(--color-accent)', + border: 'rgba(6,182,212,.12)', + }, + ].map((item) => ( +
+
+ {item.label} +
+
+ {item.val} +
+
+ {item.title} +
+
+ {item.sub} +
+
+ ))} +
+ +
+ {/* 유출량 분포 */} +
+
+ 📈 유출량 분포 (76건) +
+
+ {[ + { + range: '0~50L', + width: '36%', + gradient: 'var(--color-accent)', + count: '27건', + color: 'var(--fg-default)', + }, + { + range: '1K~10KL', + width: '20%', + gradient: 'var(--color-accent)', + count: '15건', + color: 'var(--fg-default)', + }, + { + range: '101~500L', + width: '13%', + gradient: 'var(--color-accent)', + count: '10건', + color: 'var(--fg-default)', + }, + { + range: '100만L+', + width: '12%', + gradient: 'var(--color-accent)', + count: '9건', + color: 'var(--color-accent)', + rangeColor: 'var(--color-accent)', + }, + ].map((bar) => ( +
+
+ {bar.range} +
+
+
+
+ + {bar.count} + +
+ ))} +
+
+ ※ 소형(50L이하)과 대형(100만L+) 양극화 → 차별화된 대응 필요 +
+
+ + {/* HATS 표준코드 체계 */} +
+
+ 🏷 HATS 표준코드 10개 항목 +
+
+ {[ + { label: '1.오염원', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.06)' }, + { label: '2.위치·일시', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.06)' }, + { label: '3.기상환경', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.06)' }, + { label: '4.사고유형', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.06)' }, + { label: '5.유출물질', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.06)' }, + { label: '6.대응', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.06)' }, + { label: '7.피해복구', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.06)' }, + { label: '8.사고원인', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.06)' }, + { label: '9.피해상황', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.06)' }, + { label: '10.영향', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.06)' }, + ].map((item) => ( +
+ {item.label} +
+ ))} +
+
+ 해상 특수성 반영(GPS 좌표, 선박종류, 해상기상, 파고, 유출물질 CAS번호·SEBC 거동분류).{' '} + WING 사고이력 모듈과 표준코드 연동 + 설계. +
+
+
+
+ + {/* 국내외 사고관리시스템 비교 */} +
+
+ 🌐 국내외 HNS/화학 사고관리시스템 비교 +
+
+ + + + + + + + + + + + + {[ + { + label: '운영기관', + eu: 'EC', + us: 'CCPS', + jp: 'JST·AIST', + kr: '화학물질안전원', + wing: '해양경찰청', + }, + { + label: '대상', + eu: '중대화학사고', + us: '공정사고', + jp: '화학사고', + kr: '화학사고', + wing: '해상 HNS', + }, + { + label: '초점', + eu: '산업시설', + us: '산업시설', + jp: '사고원인·산업', + kr: '사고물질', + wing: '해역·선박·확산', + }, + { label: '형태', eu: '웹DB', us: '앱CD', jp: '웹', kr: '앱', wing: '웹+GIS 통합' }, + { + label: '해상 특화', + eu: '△', + us: '✕', + jp: '✕', + kr: '△', + wing: '◎', + wingColor: 'var(--color-accent)', + }, + ].map((row, i) => ( + + + + + + + + + ))} + +
+ 항목 + + eMARS (EU) + + PSID (미국) + + RISCAD (일본) + + CSC (한국) + + WING (개발중) +
{row.label}{row.eu}{row.us}{row.jp} + {row.kr} + + {row.wing} +
+
+
+ ※ WING은 HATS 표준코드 + CAMEO/ALOHA + WRF-Chem + ROMS를 통합한 해상 특화 위기대응 시스템 +
+
+ + {/* WING 적용 시사점 */} +
+
+ + 💡 논문 시사점 → WING 시스템 반영 + +
+
+ {[ + { + icon: '🔗', + title: '통합정보시스템', + sub: 'CARIS·CAMEO·기상·GIS 통합 → 사고 대응 의사결정 지원', + style: {}, + }, + { + icon: '🏭', + title: '항만별 특화 대응', + sub: '울산·여수·대산 등 고위험 항만 맞춤 시나리오 사전 구축', + style: {}, + }, + { + icon: '📡', + title: '실시간 모니터링', + sub: 'HNS 블랙박스 + 대기확산 예측 + 위험구역 자동 설정', + style: {}, + }, + { + icon: '🤝', + title: '통합지휘 지원', + sub: '미국 ICS 모델 참조 — 다기관 협업 정보공유 플랫폼', + style: {}, + }, + { + icon: '📊', + title: 'HATS 사고이력', + sub: '표준코드 DB 연동 → 동일해역·물질 과거사고 즉시 조회', + style: { + background: 'rgba(6,182,212,.04)', + border: '1px solid rgba(6,182,212,.15)', + }, + titleColor: 'var(--color-accent)', + }, + ].map((item) => ( +
+
+ {item.icon} +
+
+ {item.title} +
+
+ {item.sub} +
+
+ ))} +
+
+ + ); +} diff --git a/frontend/src/components/hns/components/contents/VerificationPanel.tsx b/frontend/src/components/hns/components/contents/VerificationPanel.tsx new file mode 100644 index 0000000..38a63c9 --- /dev/null +++ b/frontend/src/components/hns/components/contents/VerificationPanel.tsx @@ -0,0 +1,273 @@ +import { card, cardBg, labelStyle } from '../HNSTheoryView'; + +export function VerificationPanel() { + return ( +
+ {/* 검증 결과 테이블 */} +
+
+ ✅ 모델 검증 결과 — 실측 데이터 대비 정확도 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 검증 항목 + + ALOHA + + 이문진박사모델 + + 실측값 + + 오차율 +
NH₃ 최대 도달거리 (AEGL-2) + 8.2 km + + 7.1 km + 6.8 km + + +20.6% + + {' / '} + + +4.4% + +
풍하 1km 지점 농도 (ppm) + 245 + + 198 + 187 + + +31.0% + + {' / '} + + +5.9% + +
MeOH 증기 확산 면적 (km²) + 4.8 + + 3.6 + 3.4 + + +41.2% + + {' / '} + + +5.9% + +
H₂ 가연성 구름 반경 (m) + 280 + + 235 + 220 + + +27.3% + + {' / '} + + +6.8% + +
LNG Pool 확산 직경 (m) + 165 + + 142 + 138 + + +19.6% + + {' / '} + + +2.9% + +
+
+
+ 결론: ALOHA는 해양 환경에서{' '} + 평균 20~40% 과대 예측 경향. + 이문진박사모델은 해양 보정 인자 적용으로{' '} + 평균 오차 ±5~7%로 대폭 개선. 특히 풍하 1km + 이내 근거리 농도 예측 정확도가 현저히 향상됨. +
+
+ + {/* 참고문헌 + 시스템 적용 */} +
+
+
📚 참고 문헌
+
+
+ 이문진 외, "해양 HNS 사고 시 대기확산 예측을 위한 가우시안 모델의 해양환경 보정에 관한 + 연구", 한국해양환경·에너지학회지, 2014 +
+
+ 이문진 외, "친환경 선박연료 해양 유출 시 대기확산 특성 연구 — 암모니아 기화율 모델링", + KIOST, 2023 +
+
+ EPA, "ALOHA User's Manual", 2024 +
+
+ Pasquill, F., "The Estimation of the Dispersion of Windborne Material", Meteorological + Magazine, 1961 +
+
+ Charnock, H., "Wind stress on a water surface", Q.J.R. Meteorol. Soc., 1955 +
+
+ 유영현, "HNS 재난사고에 대한 해양경찰의 대응 방안", 한국위기관리논집, 9(11), pp.77-92, + 2013 +
+
+ 해양경찰청, "HNS 사고 국가 대비 대응 체제 선진화 방안 연구용역 최종보고서", 2011 +
+
+ 김창겸·이문진 외, "해양산업시설 배출 위험유해물질(HNS)의 해양확산 수치모의", + 한국해양환경·안전학회지, 30(s4), pp.42-51, 2024 +
+
+ 장하용·이문진 외, "신속한 의사결정을 위한 HNS 사고이력관리시스템 설계 및 구현", + 한국해양환경·안전학회지, 23(2), pp.168-176, 2017 +
+
+ 박경애·이진호·박재진·김태성·이문진, "인공위성 원격탐사 기반 AI 활용 위험·유해물질(HNS) + 탐지 기술 개발", 한국해양환경·에너지학회 추계학술대회, pp.125-126, 2025 — HNS의 + 열적외선 스펙트럼 수집·분석, Sentinel-2 광학위성 영상에 AI 기법을 적용하여 HNS + 탐지·분류, 스펙트럼 기반 분석 방법과 비교 검증. 해양수산부 지원(RS-2023-00254781) +
+
+ 오진덕·김주영·이득재·김용명·최훈·이문진, "다항목 HNS 데이터의 실시간 취득 및 AI를 + 활용한 결측값 실시간 처리 기술 개발", 한국해양과학기술협의회 공동학술대회, pp.85-86, + 2024 — LSTM(Long Short-Term Memory) 순환 신경망으로 HNS 시계열 데이터의 결측값을 + 예측·보정하는 방법 연구, 다양한 모의 자료로 성능 비교·검증. 해양수산부 + 지원(RS-2021-KS211535) +
+
+
+
+
+ 💡 시스템 적용 방안 +
+
+ WING 시스템은 기본 ALOHA 엔진 위에{' '} + 이문진박사모델 해양 보정 모듈을 레이어로 + 적용하여 두 모델의 결과를 동시에 제공합니다. 사용자는 보수적 예측(ALOHA)과 정밀 + 예측(이문진박사모델)을 비교하여 의사결정할 수 있습니다. +
+
+
+
+ ); +} diff --git a/frontend/src/components/hns/components/contents/WrfChemPanel.tsx b/frontend/src/components/hns/components/contents/WrfChemPanel.tsx new file mode 100644 index 0000000..3ca07e6 --- /dev/null +++ b/frontend/src/components/hns/components/contents/WrfChemPanel.tsx @@ -0,0 +1,2203 @@ +import { card, cardBg } from '../HNSTheoryView'; + +export function WrfChemPanel() { + return ( + <> + {/* WRF-Chem 모델 상세 */} +
+
+
+ 🌐 +
+
+
+ WRF-Chem 모델 상세 +
+
+ Weather Research and Forecasting model coupled with Chemistry +
+
+
+ +
+ {/* 구성 요소 */} +
+
+ 구성 요소 +
+
+ {/* WRF */} +
+
+ + WRF (기상) + + + v4.x + +
+
+ 비정수압(non-hydrostatic) 중규모 기상 모델. 3차원 바람장, 기온, 습도, 대기경계층 + 구조를 역학적으로 계산. 중첩 도메인(nesting)으로 해상도 1km까지 구현 가능. +
+
+ {/* Chem */} +
+
+ + Chem (화학) + + + Online + +
+
+ 기상 모델과 동일한 시간 스텝에서 화학종의 이류(advection), 난류확산(diffusion), + 건·습침적(deposition), 화학반응을 동시에 계산. Offline 방식 대비 정확도 향상. +
+
+ {/* 결합 효과 */} +
+
+ + 결합 효과 + + + Feedback + +
+
+ 화학물질의 복사 효과가 기상장에 되먹임(feedback). 예: 대량의 에어로졸 방출 시 + 일사량 변화 → 대기안정도 변화 → 확산 패턴 변화. +
+
+
+
+ + {/* HNS 사고 적용 시 장점 */} +
+
+ HNS 사고 적용 시 장점 +
+
+ {[ + { + num: '1', + text: ( + <> + 3D 확산 모의: 연직 방향 확산까지 + 포함한 정밀 농도장 계산. 고층 대기로의 확산과 fumigation 효과 반영 + + ), + }, + { + num: '2', + text: ( + <> + 비정상류 대응: 풍향 + 전환(해풍↔육풍), 돌풍, 전선 통과 등 시변 기상 조건을 실시간 반영 + + ), + }, + { + num: '3', + text: ( + <> + 복잡 지형 반영: 해안 절벽, 항만 + 구조물, 인접 도시의 건물 효과를 지형 데이터로 반영 + + ), + }, + { + num: '4', + text: ( + <> + 장기 모의: 24~72시간 예측으로 + 사고 수습 계획 및 장기 피해 평가 지원 + + ), + }, + ].map(({ num, text }) => ( +
+
+ {num} +
+
+ {text} +
+
+ ))} +
+
+ ⚠ 계산 비용: WRF-Chem은 가우시안 모델 + 대비 계산 시간이 100~1,000배 소요. 초기 대응에는 가우시안 모델, 정밀 분석에는 + WRF-Chem으로 이중 운용 전략 적용. +
+
+
+
+ + {/* ROMS 해양확산 수치모의 검증 */} +
+
+
+
+ 🌊 +
+
+
+ ROMS 해양확산 수치모의 검증 결과 +
+
+ 김창겸·이문진 외 (2024), 한국해양환경·안전학회지 Vol.30 +
+
+
+ + 정확도 75~96% + +
+ +
+ {/* Nesting 격자 구성 */} +
+
+ 📐 Nesting 격자 구성 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 영역 + + 격자크기 + + 시간간격 + + 수직층 +
+ 광역 (일본·대만 포함) + + 0.05° + + 5분 + + 20층 +
+ 중간역 (한반도 해역) + + 0.01° + + 1분 +
+ 상세역 (5개 우심지역) + + 0.002°(≈200m) + + 12초 +
+
+ 🔗 입력 데이터 +
+
+
+ ERA5{' '} + 기상(기압·풍속·기온) +
+
+ HYCOM{' '} + 수온·염분·유속 +
+
+ TPXO9{' '} + 조위 15개 분조 +
+
+ WAMIS{' '} + 5대강 일별유량 +
+
+
+ + {/* 관측-모델 비교 */} +
+
+ ✅ 페놀 관측-모델 비교 (인천·평택·대산) +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 관측점 + + 관측(mg/L) + + 모델(mg/L) + + 정확도 +
IC01(인천) + 0.0160 + + 0.0167 + + 95.32% +
IC03(인천) + 0.0240 + + 0.0256 + + 93.45% +
PT01(평택) + 0.0215 + + 0.0235 + + 90.55% +
PT02(평택) + 0.0200 + + 0.0249 + + 75.57% +
+ DS01(대산) + + 0.0040 + + 0.0042 + + 95.88% +
DS03(대산) + 0.0130 + + 0.0145 + + 88.60% +
+
+ ※ 현장조사: 2023.06.27~28 / 전체 10개 관측점 모두 모델 범위 내 관측값 위치 +
+
+
+ + {/* 페놀 거동 특성 + 최대 농도 */} +
+
+
+ 인천 표층 최대농도 +
+
+ 0.1089 mg/L +
+
+
+
+ 인천 저층 최대농도 +
+
+ 0.3822 mg/L +
+
+ 표층 대비 3.5배 ↑ +
+
+
+
+ 평택·대산 저층 최대 +
+
+ 0.1916 mg/L +
+
+
+
+ 핵심 발견: 페놀은 Sinker 특성으로{' '} + 저층에서 농도 축적이 두드러짐 → 표층 + 모니터링만으로는 불충분,{' '} + 저층까지 포함한 3D 모니터링 필수. 기존 + 입자추적 방식의 장기 모의·농도 계산 한계를 ROMS 농도확산모델이 해결. Opendrift(노르웨이 + 기상청)와 Cedre(프랑스) 실험 결과로 HNS 거동특성 검증 완료. +
+
+ + {/* 지역별 해양산업시설 현황 */} +
+
+ 🏭 지역별 해양산업시설 폐수 방류 현황 (10년 평균) +
+
+ {[ + { + name: '울산', + vol: '414,620', + sites: '1,037개소', + volColor: 'var(--fg-default)', + sitesColor: 'var(--color-accent)', + bg: 'rgba(6,182,212,.06)', + nameColor: 'var(--color-accent)', + }, + { + name: '여수', + vol: '124,890', + sites: '382개소', + volColor: 'var(--fg-default)', + sitesColor: 'var(--fg-disabled)', + bg: 'rgba(6,182,212,.04)', + nameColor: 'var(--color-accent)', + }, + { + name: '인천', + vol: '91,658', + sites: '3,073개소', + volColor: 'var(--fg-default)', + sitesColor: 'var(--fg-disabled)', + bg: 'rgba(6,182,212,.04)', + nameColor: 'var(--color-accent)', + }, + { + name: '광양', + vol: '86,395', + sites: '253개소', + volColor: 'var(--fg-default)', + sitesColor: 'var(--fg-disabled)', + bg: 'rgba(6,182,212,.03)', + nameColor: 'var(--fg-sub)', + }, + { + name: '평택', + vol: '85,475', + sites: '781개소', + volColor: 'var(--fg-default)', + sitesColor: 'var(--fg-disabled)', + bg: 'rgba(6,182,212,.03)', + nameColor: 'var(--fg-sub)', + }, + { + name: '대산', + vol: '65,101', + sites: '246개소', + volColor: 'var(--fg-default)', + sitesColor: 'var(--fg-disabled)', + bg: 'rgba(6,182,212,.03)', + nameColor: 'var(--fg-sub)', + }, + { + name: '부산', + vol: '38,292', + sites: '469개소', + volColor: 'var(--fg-default)', + sitesColor: 'var(--fg-disabled)', + bg: 'rgba(6,182,212,.03)', + nameColor: 'var(--fg-sub)', + }, + ].map(({ name, vol, sites, volColor, sitesColor, bg, nameColor }) => ( +
+
{name}
+
+ {vol} +
+
m³/일
+
+ {sites} +
+
+ ))} +
+
+ ※ 1종 사업장(2,000m³/일 이상) 하루 평균 페놀 배출량: 21.847 kg — 총 882개소 합계 31.851 + kg/일 (KOSIS·NICS 2021) +
+
+ + {/* 한계 및 발전 방향 */} +
+ {/* 현재 한계점 */} +
+
+ ⚠ 현재 한계점 +
+
+ {[ + { + title: '입자 추적 방식의 한계', + body: ( + <> + 기존 라그랑지안 입자 추적 방식은{' '} + 장기간 모의가 어렵고 정확한 농도 + 계산에 한계가 있음. 입자 수가 부족하면 통계적 노이즈 발생, 과다하면 계산량 폭증. + + ), + }, + { + title: '대용량 데이터 전송', + body: ( + <> + 외부 DB(기상청, 해양예보 등) 연동 시{' '} + 대용량 데이터의 실시간 전송에 + 대한 신속성 확보 과제. 특히 WRF-Chem 입력 데이터는 수십 GB 규모. + + ), + }, + { + title: '물질 다양성', + body: ( + <> + HNS 6,000+ 종 전체에 대한{' '} + 물리화학 거동 DB 부족. 많은 + 물질이 실험 데이터가 없어 유사 물질의 추정치를 사용하고 있음. + + ), + }, + { + title: '해양 환경 특수성', + body: ( + <> + 해수 온도에 따른 증발률 변화, 파랑에 의한 스프레이 효과, 선박 구조물에 의한 Wake + 난류 등 해양 고유 현상 반영 미흡 + . + + ), + }, + ].map(({ title, body }) => ( +
+
+ {title} +
+
+ {body} +
+
+ ))} +
+
+ + {/* 발전 방향 */} +
+
+ 🚀 발전 방향 +
+
+ {[ + { + title: 'ROMS 해양 수치모델 도입', + badge: { + text: '✅ 검증완료', + bg: 'rgba(6,182,212,.15)', + color: 'var(--color-accent)', + }, + body: ( + <> + 김창겸·이문진 외(2024) 연구에서{' '} + + ROMS 기반 페놀 해양확산 수치모의 검증 완료 + + . Nesting 기법(광역 0.05°→중간역 0.01°→상세역 0.002°/약200m)으로 5개 + 집중우심지역(인천·평택·대산·여수·울산) 고해상도 구축. +
+ 경계조건:{' '} + + ERA5(기상) + HYCOM(해양) + TPXO9(조위 15개분조) + + . 한국 5대강 + 중국 황하·양쯔강 하천유입 반영. +
+ + 현장검증 정확도: 75.57%(PT02) ~ 95.88%(DS01) + {' '} + — 10개 관측점 전체에서 모델 최대·최소값 범위 내 관측값 위치 확인. + + ), + }, + { + title: 'AI/ML 기반 신속 예측', + badge: { + text: '연구중', + bg: 'rgba(6,182,212,.1)', + color: 'var(--color-accent)', + }, + body: ( + <> + WRF-Chem 시뮬레이션 결과를 학습한{' '} + 서로게이트 모델(Surrogate Model) + 로 수초 내 근사 예측. 초기 대응 시간 단축과 정확도를 동시 확보. + + ), + }, + { + title: '실시간 데이터 파이프라인', + badge: { text: '개발중', bg: 'rgba(6,182,212,.1)', color: 'var(--color-accent)' }, + body: ( + <> + 조화상수 DB 기반 빠른 예측 + + 스트리밍 기상 데이터 파이프라인 구축. 외부 DB 연동 시 데이터 압축·캐싱으로 전송 + 병목 해소. + + ), + }, + { + title: 'HNS 블랙박스 시스템 고도화', + badge: { text: '목표', bg: 'rgba(6,182,212,.1)', color: 'var(--color-accent)' }, + body: ( + <> + 사고 자동 감지 → 물질 자동 식별 → 확산 예측 → 대피 명령까지{' '} + 전 과정 자동화. 센서 네트워크 + + AI 물질 식별 + 가우시안/WRF-Chem 이중 엔진으로 구성. + + ), + }, + ].map(({ title, badge, body }) => ( +
+
+ + {title} + + + {badge.text} + +
+
+ {body} +
+
+ ))} +
+
+
+ + {/* 관련 국내 논문 */} +
+
+
+ 📚 +
+
+
+ HNS 대기확산·위해성 평가 관련 국내 논문 +
+
+ 위해성 관리 · 초동대응 · 사회경제 민감자원 · 플랫폼 개발 · 재난관리 체제 +
+
+
+ +
+ {/* 논문 ① */} +
+
+
+ + ① + + + 해상 위험·유해물질(HNS) 관리 우선순위 선정에 관한 연구 + +
+ + KRISO + +
+
+ 김영윤·김태원·손민호·오상우·이문진 — 해양환경안전학회지 Vol.21 No.6, pp.672-678, 2015 +
+
+ EURAM/CRS-KOREA II 기반 위해성 스크리닝 시스템 개발.{' '} + Risk = Toxicity × Exposure{' '} + 프레임워크로 585종 유해액체물질 평가. 인체위해성(급성·만성·발암·기타독성)과 + 수생태위해성을 해상운송량·잔류성(BCF)·인화점·증기압과 결합하여 점수화.{' '} + 상위 20종 우선관리물질(Aniline, + Acrylonitrile, Phenol, Coal tar 등) 도출. GESAMP 위해도 평가 기준 적용. +
+
+ + 위해성 우선순위 + + + 585종 유해액체물질 + + + GESAMP 기준 + + + 이문진 공저 + +
+
+ + {/* 논문 ② */} +
+
+
+ + ② + + + 해양 유류유출 오염으로 인한 사회·경제적 민감자원 선정 및 지수화 방안 + +
+ + KEI + +
+
+ 노영희·김충기 — 환경영향평가 Vol.25 No.6, pp.402-413, 2016 +
+
+ NOAA ESI 가이드라인 기반 한국형 사회·경제적 민감자원 분류체계 구축.{' '} + + 수산물 획득·인구·토지이용·관리지역·문화유산/관광지 + {' '} + 5개 지표를 각 100점 환산(총 500점) 통합지수 산출. 연안 74개 시군구 단위 공간분석. + McLaughlin et al.(2002) 삼각도표, Fattal et al.(2010) SEv 공식 등 해외 지수화 기법 + 비교. HNS 사고 시 피해 평가 프레임워크 + 로 확장 가능. +
+
+ + ESI 민감자원 + + + 통합지수 500점 + + + 74개 시군구 + + + 김충기 공저 + +
+
+ + {/* 논문 ③ */} +
+
+
+ + ③ + + + 해양 HNS 유출사고 초동대응의 시스템 다이내믹스 모형 개발 연구 + +
+ + KAIST + +
+
+ 류제완·김남균·박희경 — 한국방재학회논문집 Vol.17 No.4, pp.307-315, 2017 +
+
+ 해양경찰청 HNS 대응 매뉴얼 기반{' '} + 시스템 다이내믹스(SD) 모형 구축. + 초동대응 4개 행위(HNS 탐지·의사결정·파공봉쇄·펜스전장) 인과지도 작성.{' '} + + 안전장비 수준이 DB 수준보다 대응역량에 더 큰 영향 + {' '} + — 접근성(Accessibility) 상한계 결정. Two-step 전략(초동대응 기동선단 ETF) 도입 시{' '} + 15% 대체 → 전체 대응시간 9.5% 단축 + (116→105분), 90분간 유출량 80.78톤 감소(약 20억원 경제효과). +
+
+ + SD 시뮬레이션 + + + 초동대응 골든타임 + + + ETF Two-step + + + MARITIME MAISIE 사례 + +
+
+ + {/* 논문 ④ */} +
+
+
+ + ④ + + + 상시배출되는 HNS의 해양환경 영향평가를 위한 해외 사례 분석과 국내용 플랫폼 개발 + 연구 + +
+ + KEI+KRISO + +
+
+ 여보현·김진호·김태윤·맹준호·이문진·김태성 — 해양환경안전학회지 Vol.29 특별호, pp.8-17, + 2023 +
+
+ NOAA CAMEO/CAFE · EU MARINER/Bonn Agreement 플랫폼 비교 분석 후{' '} + 국내용 HNS 해양환경 영향평가 플랫폼{' '} + 구축. 광역(5.4km)→중간역(1.8km)→상세역(80~110m, 여수) 3단계 격자. 유속 벡터 전처리 + 모듈, HNS 유출량·확산범위 동적 시각화,{' '} + + 연안환경 영향평가 + 사회경제 영향평가 GIS 모듈 + {' '} + 구현. SEBC 거동분류(G/E/F/D/S) 기반 물질 관리. 5대 항만(여수·부산·인천·평택/대산·울산) + 대상. +
+
+ + 국내용 플랫폼 + + + CAMEO/MARINER 비교 + + + SEBC 거동분류 + + + 이문진 공저 + +
+
+ + {/* 논문 ⑤ */} +
+
+
+ + ⑤ + + + HNS 재난사고에 대한 해양경찰의 대응 방안 + +
+ + 군산대 + +
+
+ 유영현 — 한국위기관리논집 제9권 제11호, pp.77-92, 2013 +
+
+ 허베이스피리트호 사고를 계기로 외국(미국·유럽) HNS 방제시스템과 한국 체제를 비교 분석.{' '} + 통합지휘 및 협업체제 구축, HNS + 방제장비 강화 및 효율적 배치,{' '} + + CAMEO/ALOHA/MARPLOT 기반 대응정보 지원체계 + {' '} + 구축, 68종 중점관리대상물질의 체계적 관리를 제안. 미국 NRT/NSF 통합대응체제와 한국의 + 분산관리방식을 비교하여{' '} + ICS(통합지휘시스템) 도입 필요성 강조. + 2006~2010년 국내 HNS 유출사고 13건 분석, 항만별(울산·여수·대산) 화재·폭발·누출{' '} + 위험도 매트릭스 제시. OPRC-HNS + 의정서(2008.4 발효) 국내 이행 현황 정리. +
+
+ + 재난관리 체제 + + + CAMEO/ALOHA + + + ICS 통합지휘 + + + OPRC-HNS 의정서 + +
+
+ + {/* 논문 ⑥ */} +
+
+
+ + ⑥ + + + 상시배출되는 HNS의 해양환경 영향평가를 위한 해외 사례 분석과 국내용 플랫폼 개발 + 연구 + +
+ + KEI+KRISO + +
+
+ 여보현·김진호·김태윤·맹준호·이문진·김태성 — 해양환경안전학회지 Vol.29 특별호, pp.8-17, + 2023 +
+
+ NOAA{' '} + + CAFE(Chemical Aquatic Fate and Effects) + {' '} + DB(32,377종 거동·4,498종 화학물질 데이터)와{' '} + CAMEO Suite, EU{' '} + MARINER 플랫폼(119건 HNS + 유출사고·187종 물질 DB) 및 Bonn Agreement 체계를 비교 분석. SEBC 거동분류(G/E/F/D/S) + 기반 물질 분류 체계 적용. 국내용 HNS 해양환경 영향평가 플랫폼 프로토타입 개발: + 광역(5.4km)→중간역(1.8km)→상세역(80~110m, 여수){' '} + 3단계 Nesting 격자 구성, 유속 벡터 + 전처리 모듈, HNS 유출량·확산범위 동적 시각화,{' '} + + 연안환경 영향평가 + 사회경제 영향평가 GIS 모듈 + {' '} + 구현. 5대 항만(여수·부산·인천·평택/대산·울산) 확대 적용 계획. +
+
+ + CAFE·CAMEO 비교 + + + MARINER 플랫폼 + + + SEBC 거동분류 + + + 이문진 공저 + +
+
+
+
+ + {/* WING HNS 모델 로드맵 */} +
+
+ 📅 WING 시스템 HNS 모델 로드맵 +
+
+ {/* Phase 1 */} +
+
+ ✅ Phase 1 (완료) +
+
+ 가우시안 모델 +
+
+ ALOHA + Lee 해양보정 +
+ 6,000+종 HNS DB +
+ AEGL/ERPG 기준 자동 적용 +
+
+ +
+ + {/* Phase 2 */} +
+
+ 🔧 Phase 2 (진행중) +
+
+ WRF-Chem 연동 +
+
+ 3D 확산 모의 +
+ 비정상류 기상 반영 +
+ 해양-대기 결합 모의 +
+
+ +
+ + {/* Phase 3 */} +
+
+ 🔬 Phase 3 (계획) +
+
+ ROMS + AI 융합 +
+
+ 해양 수치모델 결합 +
+ ML 서로게이트 모델 +
+ 실시간 데이터 파이프라인 +
+
+ +
+ + {/* Phase 4 */} +
+
+ 🎯 Phase 4 (목표) +
+
+ 자동 블랙박스 +
+
+ 사고 자동감지→예측→대응 +
+ 센서+AI 물질식별 +
전 과정 무인 자동화 +
+
+
+
+ + ); +} diff --git a/frontend/src/tabs/hns/hooks/useWeatherFetch.ts b/frontend/src/components/hns/hooks/useWeatherFetch.ts similarity index 95% rename from frontend/src/tabs/hns/hooks/useWeatherFetch.ts rename to frontend/src/components/hns/hooks/useWeatherFetch.ts index e30ad15..af947b9 100644 --- a/frontend/src/tabs/hns/hooks/useWeatherFetch.ts +++ b/frontend/src/components/hns/hooks/useWeatherFetch.ts @@ -3,8 +3,9 @@ import { convertToGridCoords, getUltraShortForecast, getCurrentBaseDateTime, -} from '@tabs/weather/services/weatherApi'; -import type { StabilityClass, WeatherFetchResult } from '../utils/dispersionTypes'; +} from '@components/weather/services/weatherApi'; +import type { WeatherFetchResult } from '@interfaces/hns/HnsInterface'; +import type { StabilityClass } from '@/types/hns/HnsType'; /** * Turner 간이법으로 Pasquill-Gifford 안정도 산출 diff --git a/frontend/src/tabs/hns/index.ts b/frontend/src/components/hns/index.ts similarity index 100% rename from frontend/src/tabs/hns/index.ts rename to frontend/src/components/hns/index.ts diff --git a/frontend/src/tabs/hns/services/hnsApi.ts b/frontend/src/components/hns/services/hnsApi.ts similarity index 56% rename from frontend/src/tabs/hns/services/hnsApi.ts rename to frontend/src/components/hns/services/hnsApi.ts index 985957c..7de2c29 100644 --- a/frontend/src/tabs/hns/services/hnsApi.ts +++ b/frontend/src/components/hns/services/hnsApi.ts @@ -1,51 +1,10 @@ import { api } from '@common/services/api'; +import type { HnsAnalysisItem, CreateHnsAnalysisInput } from '@interfaces/hns/HnsInterface'; // ============================================================ // HNS 분석 API // ============================================================ -export interface HnsAnalysisItem { - hnsAnlysSn: number; - anlysNm: string; - acdntDtm: string | null; - locNm: string | null; - lon: number | null; - lat: number | null; - sbstNm: string | null; - spilQty: number | null; - spilUnitCd: string | null; - fcstHr: number | null; - algoCd: string | null; - critMdlCd: string | null; - windSpd: number | null; - windDir: string | null; - execSttsCd: string; - riskCd: string | null; - analystNm: string | null; - rsltData: Record | null; - regDtm: string; -} - -export interface CreateHnsAnalysisInput { - anlysNm: string; - acdntDtm?: string; - locNm?: string; - lon?: number; - lat?: number; - sbstNm?: string; - spilQty?: number; - spilUnitCd?: string; - fcstHr?: number; - algoCd?: string; - critMdlCd?: string; - windSpd?: number; - windDir?: string; - temp?: number; - humid?: number; - atmStblCd?: string; - analystNm?: string; -} - export async function fetchHnsAnalyses(params?: { status?: string; substance?: string; diff --git a/frontend/src/tabs/hns/utils/dispersionEngine.ts b/frontend/src/components/hns/utils/dispersionEngine.ts similarity index 99% rename from frontend/src/tabs/hns/utils/dispersionEngine.ts rename to frontend/src/components/hns/utils/dispersionEngine.ts index 9ee586e..4261ad9 100644 --- a/frontend/src/tabs/hns/utils/dispersionEngine.ts +++ b/frontend/src/components/hns/utils/dispersionEngine.ts @@ -9,7 +9,6 @@ * 3. Dense Gas — 고밀도 가스 (Britter-McQuaid) */ import type { - StabilityClass, MeteoParams, SourceParams, SimParams, @@ -18,9 +17,9 @@ import type { ComputeDispersionParams, AeglDistances, AeglAreas, - AlgorithmType, ContourLine, -} from './dispersionTypes'; +} from '@interfaces/hns/HnsInterface'; +import type { StabilityClass, AlgorithmType } from '@/types/hns/HnsType'; import { getSubstanceToxicity } from './toxicityData'; // ────────────────────────────────────── diff --git a/frontend/src/tabs/hns/utils/toxicityData.ts b/frontend/src/components/hns/utils/toxicityData.ts similarity index 98% rename from frontend/src/tabs/hns/utils/toxicityData.ts rename to frontend/src/components/hns/utils/toxicityData.ts index 8019b93..8edad5d 100644 --- a/frontend/src/tabs/hns/utils/toxicityData.ts +++ b/frontend/src/components/hns/utils/toxicityData.ts @@ -1,4 +1,4 @@ -import type { SubstanceToxicity } from './dispersionTypes'; +import type { SubstanceToxicity } from '@interfaces/hns/HnsInterface'; /** * 주요 HNS 물질 독성 및 물리 데이터 diff --git a/frontend/src/tabs/incidents/components/DischargeZonePanel.tsx b/frontend/src/components/incidents/components/DischargeZonePanel.tsx similarity index 100% rename from frontend/src/tabs/incidents/components/DischargeZonePanel.tsx rename to frontend/src/components/incidents/components/DischargeZonePanel.tsx diff --git a/frontend/src/tabs/incidents/components/ImageAnalysisModal.tsx b/frontend/src/components/incidents/components/ImageAnalysisModal.tsx similarity index 100% rename from frontend/src/tabs/incidents/components/ImageAnalysisModal.tsx rename to frontend/src/components/incidents/components/ImageAnalysisModal.tsx diff --git a/frontend/src/tabs/incidents/components/IncidentTable.tsx b/frontend/src/components/incidents/components/IncidentTable.tsx old mode 100755 new mode 100644 similarity index 98% rename from frontend/src/tabs/incidents/components/IncidentTable.tsx rename to frontend/src/components/incidents/components/IncidentTable.tsx index aa7e603..6d9cd35 --- a/frontend/src/tabs/incidents/components/IncidentTable.tsx +++ b/frontend/src/components/incidents/components/IncidentTable.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import type { IncidentListItem } from '../services/incidentsApi'; +import type { IncidentListItem } from '@interfaces/incidents/IncidentsInterface'; import { fetchIncidentsRaw } from '../services/incidentsApi'; export function IncidentTable() { diff --git a/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx b/frontend/src/components/incidents/components/IncidentsLeftPanel.tsx old mode 100755 new mode 100644 similarity index 98% rename from frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx rename to frontend/src/components/incidents/components/IncidentsLeftPanel.tsx index a2d19b0..fd80494 --- a/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx +++ b/frontend/src/components/incidents/components/IncidentsLeftPanel.tsx @@ -1,22 +1,6 @@ import { useState, useMemo, useRef, useEffect, forwardRef } from 'react'; import { MediaModal } from './MediaModal'; - -export interface Incident { - id: string; - name: string; - status: 'active' | 'investigating' | 'closed'; - date: string; - time: string; - region: string; - office: string; - location: { lat: number; lon: number }; - causeType?: string; - oilType?: string; - prediction?: string; - vesselName?: string; - mediaCount?: number; - hasImgAnalysis?: boolean; -} +import type { Incident } from '@interfaces/incidents/IncidentsInterface'; interface IncidentsLeftPanelProps { incidents: Incident[]; @@ -29,7 +13,7 @@ const PERIOD_PRESETS = ['오늘', '1주일', '1개월', '3개월', '6개월', '1 const REGIONS = ['전체', '남해청', '서해청', '동해청', '제주청', '중부청'] as const; import { fetchIncidentWeather } from '../services/incidentsApi'; -import type { WeatherInfo } from '../services/incidentsApi'; +import type { WeatherInfo } from '@interfaces/incidents/IncidentsInterface'; function formatDate(d: Date) { const y = d.getFullYear(); diff --git a/frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx b/frontend/src/components/incidents/components/IncidentsRightPanel.tsx old mode 100755 new mode 100644 similarity index 98% rename from frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx rename to frontend/src/components/incidents/components/IncidentsRightPanel.tsx index d056caf..6748ea4 --- a/frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx +++ b/frontend/src/components/incidents/components/IncidentsRightPanel.tsx @@ -1,17 +1,17 @@ import { useState, useEffect } from 'react'; -import type { Incident } from './IncidentsLeftPanel'; +import type { Incident } from '@interfaces/incidents/IncidentsInterface'; import { fetchPredictionAnalyses, fetchSensitiveResources, fetchSensitiveResourcesGeojson, -} from '@tabs/prediction/services/predictionApi'; +} from '@components/prediction/services/predictionApi'; import type { PredictionAnalysis, SensitiveResourceCategory, SensitiveResourceFeatureCollection, -} from '@tabs/prediction/services/predictionApi'; +} from '@components/prediction/services/predictionApi'; import { fetchNearbyOrgs } from '../services/incidentsApi'; -import type { NearbyOrgItem } from '../services/incidentsApi'; +import type { NearbyOrgItem } from '@interfaces/incidents/IncidentsInterface'; export type ViewMode = 'overlay' | 'split2' | 'split3'; diff --git a/frontend/src/tabs/incidents/components/IncidentsView.tsx b/frontend/src/components/incidents/components/IncidentsView.tsx old mode 100755 new mode 100644 similarity index 50% rename from frontend/src/tabs/incidents/components/IncidentsView.tsx rename to frontend/src/components/incidents/components/IncidentsView.tsx index bc7830b..e220d0e --- a/frontend/src/tabs/incidents/components/IncidentsView.tsx +++ b/frontend/src/components/incidents/components/IncidentsView.tsx @@ -1,24 +1,24 @@ -import { useState, useEffect, useMemo, useRef } from 'react'; -import { Popup, useMap } from '@vis.gl/react-maplibre'; +import { useState, useEffect, useMemo } from 'react'; +import { Popup } from '@vis.gl/react-maplibre'; import { ScatterplotLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers'; import { PathStyleExtension } from '@deck.gl/extensions'; import 'maplibre-gl/dist/maplibre-gl.css'; -import { BaseMap } from '@common/components/map/BaseMap'; -import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay'; -import { MapBoundsTracker } from '@common/components/map/MapBoundsTracker'; -import { buildVesselLayers, VESSEL_LEGEND, getShipKindLabel } from '@common/components/map/VesselLayer'; +import { BaseMap } from '@components/common/map/BaseMap'; +import { DeckGLOverlay } from '@components/common/map/DeckGLOverlay'; +import { MapBoundsTracker } from '@components/common/map/MapBoundsTracker'; +import { buildVesselLayers, VESSEL_LEGEND } from '@components/common/map/VesselLayer'; import { useVesselSignals } from '@common/hooks/useVesselSignals'; -import type { MapBounds, VesselPosition } from '@common/types/vessel'; +import type { MapBounds, VesselPosition } from '@/types/vessel'; import { getVesselCacheStatus, type VesselCacheStatus } from '@common/services/vesselApi'; -import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel'; +import { IncidentsLeftPanel } from './IncidentsLeftPanel'; import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel'; import { fetchIncidents } from '../services/incidentsApi'; -import type { IncidentCompat } from '../services/incidentsApi'; -import { fetchAnalysisTrajectory } from '@tabs/prediction/services/predictionApi'; +import type { IncidentCompat } from '@interfaces/incidents/IncidentsInterface'; +import { fetchAnalysisTrajectory } from '@components/prediction/services/predictionApi'; import type { TrajectoryResponse, SensitiveResourceFeatureCollection, -} from '@tabs/prediction/services/predictionApi'; +} from '@components/prediction/services/predictionApi'; import { DischargeZonePanel } from './DischargeZonePanel'; import { estimateDistanceFromCoast, @@ -30,6 +30,14 @@ import { getCachedZones, } from '../utils/dischargeZoneData'; import { useMapStore } from '@common/store/mapStore'; +import { FlyToController } from './contents/FlyToController'; +import { SplitPanelContent } from './contents/SplitPanelContent'; +import { VesselPopupPanel } from './contents/VesselPopupPanel'; +import { IncidentPopupContent } from './contents/IncidentPopupContent'; +import { VesselDetailModal } from './contents/VesselDetailModal'; +import { VesselTooltipContent } from './contents/VesselTooltipContent'; +import { IncidentTooltipContent } from './contents/IncidentTooltipContent'; +/* eslint-disable react-refresh/only-export-components */ // ── 민감자원 카테고리별 색상 (목록 순서 인덱스 기반 — 중복 없음) ──────────── const CATEGORY_PALETTE: [number, number, number][] = [ @@ -55,25 +63,6 @@ function getCategoryColor(index: number): [number, number, number] { return CATEGORY_PALETTE[index % CATEGORY_PALETTE.length]; } -// ── FlyToController: 사고 선택 시 지도 이동 ────────── -function FlyToController({ incident }: { incident: IncidentCompat | null }) { - const { current: map } = useMap(); - const prevIdRef = useRef(null); - - useEffect(() => { - if (!map || !incident) return; - if (prevIdRef.current === incident.id) return; - prevIdRef.current = incident.id; - map.flyTo({ - center: [incident.location.lon, incident.location.lat], - zoom: 10, - duration: 800, - }); - }, [map, incident]); - - return null; -} - // ── 사고 상태 색상 ────────────────────────────────────── function getMarkerColor(s: string): [number, number, number, number] { if (s === 'active') return [239, 68, 68, 204]; @@ -87,7 +76,7 @@ function getMarkerStroke(s: string): [number, number, number, number] { return [75, 85, 99, 255]; } -const getStatusLabel = (s: string) => +export const getStatusLabel = (s: string) => s === 'active' ? '대응중' : s === 'investigating' ? '조사중' : s === 'closed' ? '종료' : ''; // 팝업 정보 @@ -1119,1259 +1108,17 @@ export function IncidentsView() { ); } -/* ════════════════════════════════════════════════════ - SplitPanelContent - ════════════════════════════════════════════════════ */ -function SplitPanelContent({ - tag, - incident, -}: { - tag?: { icon: string; label: string; color: string }; - incident: Incident | null; -}) { - if (!tag) { - return ( -
- R&D 분석 결과를 선택하세요 -
- ); - } - - const mockData: Record< - string, - { - title: string; - model: string; - items: { label: string; value: string; color?: string }[]; - summary: string; - } - > = { - 유출유: { - title: '유출유 확산예측 결과', - model: 'KOSPS + OpenDrift · BUNKER-C 150kL', - items: [ - { label: '예측 시간', value: '72시간 (3일)' }, - { label: '최대 확산거리', value: '12.3 NM', color: 'var(--color-warning)' }, - { label: '해안 도달 시간', value: '18시간 후', color: 'var(--color-danger)' }, - { label: '영향 해안선', value: '27.5 km' }, - { label: '풍화율', value: '32.4%' }, - { label: '잔존유량', value: '101.4 kL', color: 'var(--color-warning)' }, - ], - summary: - '여수항 남동쪽 방향 확산, 18시간 후 돌산도 해안 도달 예상. 조류 영향으로 남서쪽 이동.', - }, - HNS: { - title: 'HNS 대기확산 결과', - model: 'ALOHA + PHAST · 톨루엔 5톤', - items: [ - { label: 'IDLH 범위', value: '1.2 km', color: 'var(--color-danger)' }, - { label: 'ERPG-2 범위', value: '2.8 km', color: 'var(--color-warning)' }, - { label: 'ERPG-1 범위', value: '5.1 km', color: 'var(--color-caution)' }, - { label: '풍향', value: 'SW → NE 방향' }, - { label: '대기 안정도', value: 'D등급 (중립)' }, - { label: '영향 인구', value: '약 2,400명', color: 'var(--color-danger)' }, - ], - summary: '남서풍에 의해 북동쪽 내륙 방향 확산. IDLH 1.2km 이내 즉시 대피 필요.', - }, - 구난: { - title: '긴급구난 SAR 결과', - model: 'SAROPS · Monte Carlo 10,000회 시뮬레이션', - items: [ - { label: '95% 확률 범위', value: '8.5 NM²', color: 'var(--color-accent)' }, - { label: '최적 탐색 경로', value: 'Sector Search' }, - { label: '예상 표류 속도', value: '1.8 kn' }, - { label: '표류 방향', value: 'NE (045°)' }, - { label: '생존 가능 시간', value: '36시간', color: 'var(--color-danger)' }, - { label: '필요 자산', value: '헬기 2 + 경비정 3', color: 'var(--color-warning)' }, - ], - summary: '북동쪽 방향 표류 예상. 06:00 기준 최적 탐색 패턴: 섹터탐색(Sector Search).', - }, - }; - - const data = mockData[tag.label] || mockData['유출유']; - - return ( - <> -
-
- {tag.icon} {data.title} -
-
{data.model}
- {incident && ( -
- 사고: {incident.name} · {incident.date} {incident.time} -
- )} -
- -
- {data.items.map((item, i) => ( -
- {item.label} - - {item.value} - -
- ))} -
- -
- 💡 {data.summary} -
- -
-
{tag.icon}
-
시각화 영역
-
- - ); -} - /* ════════════════════════════════════════════════════ VesselPopupPanel / VesselDetailModal 공용 유틸 ════════════════════════════════════════════════════ */ -function formatDateTime(iso: string): string { +export function formatDateTime(iso: string): string { const d = new Date(iso); if (Number.isNaN(d.getTime())) return '-'; const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } -function displayVal(v: unknown): string { +export function displayVal(v: unknown): string { if (v === undefined || v === null || v === '') return '-'; return String(v); } - - -function VesselPopupPanel({ - vessel: v, - onClose, - onDetail, -}: { - vessel: VesselPosition; - onClose: () => void; - onDetail: () => void; -}) { - const statusText = v.status ?? '-'; - const isAccident = (v.status ?? '').includes('사고'); - const statusColor = isAccident ? 'var(--color-danger)' : 'var(--color-success)'; - const statusBg = isAccident - ? 'color-mix(in srgb, var(--color-danger) 15%, transparent)' - : 'color-mix(in srgb, var(--color-success) 10%, transparent)'; - const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-'; - const heading = v.heading ?? v.cog; - const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-'; - const receivedAt = v.lastUpdate ? formatDateTime(v.lastUpdate) : '-'; - - return ( -
- {/* Header */} -
-
- {v.nationalCode ?? '🚢'} -
-
-
- {v.shipNm ?? '(이름 없음)'} -
-
- MMSI: {v.mmsi} -
-
- - ✕ - -
- - {/* Ship Image */} -
- 🚢 -
- - {/* Tags */} -
- - {getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-'} - - - {statusText} - -
- - {/* Data rows */} -
- - -
-
- - 출항지 - - - - - -
-
- - 입항지 - - - {v.destination ?? '-'} - -
-
- -
- - {/* Buttons */} -
- - - -
-
- ); -} - -function PopupRow({ - label, - value, - accent, - muted, -}: { - label: string; - value: string; - accent?: boolean; - muted?: boolean; -}) { - return ( -
- {label} - - {value} - -
- ); -} - -/* ════════════════════════════════════════════════════ - IncidentPopupContent – 사고 마커 클릭 팝업 - ════════════════════════════════════════════════════ */ -function IncidentPopupContent({ - incident: inc, - onClose, -}: { - incident: IncidentCompat; - onClose: () => void; -}) { - const dotColor: Record = { - active: 'var(--color-danger)', - investigating: 'var(--color-warning)', - closed: 'var(--fg-disabled)', - }; - const stBg: Record = { - active: 'rgba(239,68,68,0.15)', - investigating: 'rgba(249,115,22,0.15)', - closed: 'rgba(100,116,139,0.15)', - }; - const stColor: Record = { - active: 'var(--color-danger)', - investigating: 'var(--color-warning)', - closed: 'var(--fg-disabled)', - }; - - return ( -
- {/* Header */} -
- -
- {inc.name} -
- - ✕ - -
- - {/* Tags */} -
- - {getStatusLabel(inc.status)} - - {inc.causeType && ( - - {inc.causeType} - - )} - {inc.oilType && ( - - {inc.oilType} - - )} -
- - {/* Info rows */} -
-
- 일시 - - {inc.date} {inc.time} - -
-
- 관할 - {inc.office} -
-
- 지역 - {inc.region} -
-
- - {/* Prediction badge */} - {inc.prediction && ( -
- - {inc.prediction} - -
- )} -
- ); -} - -/* ════════════════════════════════════════════════════ - VesselDetailModal - ════════════════════════════════════════════════════ */ -type DetTab = 'info' | 'nav' | 'spec' | 'ins' | 'dg'; -const TAB_LABELS: { key: DetTab; label: string }[] = [ - { key: 'info', label: '상세정보' }, - { key: 'nav', label: '항해정보' }, - { key: 'spec', label: '선박제원' }, - { key: 'ins', label: '보험정보' }, - { key: 'dg', label: '위험물정보' }, -]; - -function VesselDetailModal({ - vessel: v, - onClose, -}: { - vessel: VesselPosition; - onClose: () => void; -}) { - const [tab, setTab] = useState('info'); - - return ( -
{ - if (e.target === e.currentTarget) onClose(); - }} - className="fixed inset-0 z-[10000] flex items-center justify-center" - style={{ - background: 'rgba(0,0,0,0.65)', - backdropFilter: 'blur(6px)', - }} - > -
- {/* Header */} -
-
- {v.nationalCode ?? '🚢'} -
-
- {v.shipNm ?? '(이름 없음)'} -
-
- MMSI: {v.mmsi} · IMO: {displayVal(v.imo)} -
-
-
- - ✕ - -
- - {/* Tabs */} -
- {TAB_LABELS.map((t) => ( - - ))} -
- - {/* Body */} -
- {tab === 'info' && } - {tab === 'nav' && } - {tab === 'spec' && } - {tab === 'ins' && } - {tab === 'dg' && } -
-
-
- ); -} - -/* ── shared section helpers ──────────────────────── */ -function Sec({ - title, - borderColor, - bgColor, - badge, - children, -}: { - title: string; - borderColor?: string; - bgColor?: string; - badge?: React.ReactNode; - children: React.ReactNode; -}) { - return ( -
-
- {title} - {badge} -
- {children} -
- ); -} - -function Grid({ children }: { children: React.ReactNode }) { - return
{children}
; -} - -function Cell({ - label, - value, - span, - color, -}: { - label: string; - value: string; - span?: boolean; - color?: string; -}) { - return ( -
-
- {label} -
-
- {value} -
-
- ); -} - -function StatusBadge({ label, color }: { label: string; color: string }) { - return ( - - {label} - - ); -} - -/* ── Tab 0: 상세정보 ─────────────────────────────── */ -function TabInfo({ v }: { v: VesselPosition }) { - const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-'; - const heading = v.heading ?? v.cog; - const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-'; - return ( - <> -
- 🚢 -
- - - - - - - - - - - - - - - - - - - - - - ); -} - -/* ── Tab 1: 항해정보 ─────────────────────────────── */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function TabNav(_props: { v: VesselPosition }) { - const hours = ['08', '09', '10', '11', '12', '13', '14']; - const heights = [45, 60, 78, 82, 70, 85, 75]; - const colors = [ - 'color-mix(in srgb, var(--color-success) 30%, transparent)', - 'color-mix(in srgb, var(--color-success) 40%, transparent)', - 'color-mix(in srgb, var(--color-info) 40%, transparent)', - 'color-mix(in srgb, var(--color-info) 50%, transparent)', - 'color-mix(in srgb, var(--color-info) 50%, transparent)', - 'color-mix(in srgb, var(--color-info) 60%, transparent)', - 'color-mix(in srgb, var(--color-accent) 50%, transparent)', - ]; - - return ( - <> - -
- - - - - - - - 08:00 - - - 10:30 - - - 12:45 - - - 현재 - - -
-
- - -
-
- {hours.map((h, i) => ( -
-
- {h} -
- ))} -
-
- 평균: 8.4 kn · 최대:{' '} - 11.2 kn -
-
- - -
- - -
- - ); -} - -/* ── Tab 2: 선박제원 ─────────────────────────────── */ -function TabSpec({ v }: { v: VesselPosition }) { - const loa = v.length !== undefined ? `${v.length} m` : '-'; - const beam = v.width !== undefined ? `${v.width} m` : '-'; - return ( - <> - - - - - - - - - - - - - - - - - - - - - - - -
-
- 🛢 -
-
-
-
정보 없음
-
-
-
-
- - ); -} - -/* ── Tab 3: 보험정보 ─────────────────────────────── */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function TabInsurance(_props: { v: VesselPosition }) { - return ( - <> - - - - - - - - - } - > - - - - - - - - - } - > - - - - - - - - - } - > - - - - - - - - - - -
- 💡 보험정보는 한국해운조합(KSA) Open API 및 해양수산부 선박정보시스템 연동 데이터입니다. - 실시간 갱신 주기: 24시간 -
- - ); -} - -/* ── Tab 4: 위험물정보 ───────────────────────────── */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function TabDangerous(_props: { v: VesselPosition }) { - return ( - <> - - PORT-MIS - - } - > - - - - - - - - - - - - - - - -
-
- 화물창 2개이상 여부 - - - ✓ - - - -
- -
-
- - - - - - - - - - - - - -
- - - -
-
- -
- 💡 위험물정보는 PORT-MIS(항만운영정보시스템) 위험물 반입신고 데이터 연동입니다. IMDG Code - 최신 개정판(Amendment 42-24) 기준. -
- - ); -} - -function EmsRow({ - icon, - label, - value, - bg, - bd, -}: { - icon: string; - label: string; - value: string; - bg: string; - bd: string; -}) { - return ( -
- {icon} -
-
{label}
-
{value}
-
-
- ); -} - -function ActionBtn({ - icon, - label, - bg, - bd, - fg, -}: { - icon: string; - label: string; - bg: string; - bd: string; - fg: string; -}) { - return ( - - ); -} - -/* ════════════════════════════════════════════════════ - 호버 툴팁 컴포넌트 - ════════════════════════════════════════════════════ */ -function VesselTooltipContent({ vessel: v }: { vessel: VesselPosition }) { - const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '- kn'; - const heading = v.heading ?? v.cog; - const headingText = heading !== undefined ? `HDG ${Math.round(heading)}°` : 'HDG -'; - const typeText = [getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-', v.nationalCode].filter(Boolean).join(' · '); - return ( - <> -
- {v.shipNm ?? '(이름 없음)'} -
-
- {typeText} -
-
- {speed} - {headingText} -
- - ); -} - -function IncidentTooltipContent({ incident: i }: { incident: IncidentCompat }) { - const statusColor = - i.status === 'active' - ? 'var(--color-danger)' - : i.status === 'investigating' - ? 'var(--color-warning)' - : 'var(--fg-disabled)'; - - return ( - <> -
- {i.name} -
-
- {i.date} {i.time} -
-
- - {getStatusLabel(i.status)} - - - {i.location.lat.toFixed(3)}°N, {i.location.lon.toFixed(3)}°E - -
- - ); -} diff --git a/frontend/src/tabs/incidents/components/MediaModal.tsx b/frontend/src/components/incidents/components/MediaModal.tsx old mode 100755 new mode 100644 similarity index 99% rename from frontend/src/tabs/incidents/components/MediaModal.tsx rename to frontend/src/components/incidents/components/MediaModal.tsx index 0b6ff6a..c40ec28 --- a/frontend/src/tabs/incidents/components/MediaModal.tsx +++ b/frontend/src/components/incidents/components/MediaModal.tsx @@ -1,11 +1,11 @@ import { useState, useEffect } from 'react'; -import type { Incident } from './IncidentsLeftPanel'; +import type { Incident } from '@interfaces/incidents/IncidentsInterface'; import { fetchIncidentMedia, fetchIncidentAerialMedia, getMediaImageUrl, } from '../services/incidentsApi'; -import type { MediaInfo, AerialMediaItem } from '../services/incidentsApi'; +import type { MediaInfo, AerialMediaItem } from '@interfaces/incidents/IncidentsInterface'; type MediaTab = 'all' | 'photo' | 'video' | 'satellite' | 'cctv'; diff --git a/frontend/src/components/incidents/components/contents/FlyToController.tsx b/frontend/src/components/incidents/components/contents/FlyToController.tsx new file mode 100644 index 0000000..c7a87c0 --- /dev/null +++ b/frontend/src/components/incidents/components/contents/FlyToController.tsx @@ -0,0 +1,21 @@ +import { useEffect, useRef } from 'react'; +import { useMap } from '@vis.gl/react-maplibre'; +import type { IncidentCompat } from '@interfaces/incidents/IncidentsInterface'; + +export function FlyToController({ incident }: { incident: IncidentCompat | null }) { + const { current: map } = useMap(); + const prevIdRef = useRef(null); + + useEffect(() => { + if (!map || !incident) return; + if (prevIdRef.current === incident.id) return; + prevIdRef.current = incident.id; + map.flyTo({ + center: [incident.location.lon, incident.location.lat], + zoom: 10, + duration: 800, + }); + }, [map, incident]); + + return null; +} diff --git a/frontend/src/components/incidents/components/contents/IncidentPopupContent.tsx b/frontend/src/components/incidents/components/contents/IncidentPopupContent.tsx new file mode 100644 index 0000000..8add429 --- /dev/null +++ b/frontend/src/components/incidents/components/contents/IncidentPopupContent.tsx @@ -0,0 +1,155 @@ +import type { IncidentCompat } from '@interfaces/incidents/IncidentsInterface'; +import { getStatusLabel } from '../IncidentsView'; + +export function IncidentPopupContent({ + incident: inc, + onClose, +}: { + incident: IncidentCompat; + onClose: () => void; +}) { + const dotColor: Record = { + active: 'var(--color-danger)', + investigating: 'var(--color-warning)', + closed: 'var(--fg-disabled)', + }; + const stBg: Record = { + active: 'rgba(239,68,68,0.15)', + investigating: 'rgba(249,115,22,0.15)', + closed: 'rgba(100,116,139,0.15)', + }; + const stColor: Record = { + active: 'var(--color-danger)', + investigating: 'var(--color-warning)', + closed: 'var(--fg-disabled)', + }; + + return ( +
+ {/* Header */} +
+ +
+ {inc.name} +
+ + ✕ + +
+ + {/* Tags */} +
+ + {getStatusLabel(inc.status)} + + {inc.causeType && ( + + {inc.causeType} + + )} + {inc.oilType && ( + + {inc.oilType} + + )} +
+ + {/* Info rows */} +
+
+ 일시 + + {inc.date} {inc.time} + +
+
+ 관할 + {inc.office} +
+
+ 지역 + {inc.region} +
+
+ + {/* Prediction badge */} + {inc.prediction && ( +
+ + {inc.prediction} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/incidents/components/contents/IncidentTooltipContent.tsx b/frontend/src/components/incidents/components/contents/IncidentTooltipContent.tsx new file mode 100644 index 0000000..895a83e --- /dev/null +++ b/frontend/src/components/incidents/components/contents/IncidentTooltipContent.tsx @@ -0,0 +1,30 @@ +import type { IncidentCompat } from '@interfaces/incidents/IncidentsInterface'; +import { getStatusLabel } from '../IncidentsView'; + +export function IncidentTooltipContent({ incident: i }: { incident: IncidentCompat }) { + const statusColor = + i.status === 'active' + ? 'var(--color-danger)' + : i.status === 'investigating' + ? 'var(--color-warning)' + : 'var(--fg-disabled)'; + + return ( + <> +
+ {i.name} +
+
+ {i.date} {i.time} +
+
+ + {getStatusLabel(i.status)} + + + {i.location.lat.toFixed(3)}°N, {i.location.lon.toFixed(3)}°E + +
+ + ); +} diff --git a/frontend/src/components/incidents/components/contents/SplitPanelContent.tsx b/frontend/src/components/incidents/components/contents/SplitPanelContent.tsx new file mode 100644 index 0000000..d59cd10 --- /dev/null +++ b/frontend/src/components/incidents/components/contents/SplitPanelContent.tsx @@ -0,0 +1,135 @@ +import type { Incident } from '@interfaces/incidents/IncidentsInterface'; + +export function SplitPanelContent({ + tag, + incident, +}: { + tag?: { icon: string; label: string; color: string }; + incident: Incident | null; +}) { + if (!tag) { + return ( +
+ R&D 분석 결과를 선택하세요 +
+ ); + } + + const mockData: Record< + string, + { + title: string; + model: string; + items: { label: string; value: string; color?: string }[]; + summary: string; + } + > = { + 유출유: { + title: '유출유 확산예측 결과', + model: 'KOSPS + OpenDrift · BUNKER-C 150kL', + items: [ + { label: '예측 시간', value: '72시간 (3일)' }, + { label: '최대 확산거리', value: '12.3 NM', color: 'var(--color-warning)' }, + { label: '해안 도달 시간', value: '18시간 후', color: 'var(--color-danger)' }, + { label: '영향 해안선', value: '27.5 km' }, + { label: '풍화율', value: '32.4%' }, + { label: '잔존유량', value: '101.4 kL', color: 'var(--color-warning)' }, + ], + summary: + '여수항 남동쪽 방향 확산, 18시간 후 돌산도 해안 도달 예상. 조류 영향으로 남서쪽 이동.', + }, + HNS: { + title: 'HNS 대기확산 결과', + model: 'ALOHA + PHAST · 톨루엔 5톤', + items: [ + { label: 'IDLH 범위', value: '1.2 km', color: 'var(--color-danger)' }, + { label: 'ERPG-2 범위', value: '2.8 km', color: 'var(--color-warning)' }, + { label: 'ERPG-1 범위', value: '5.1 km', color: 'var(--color-caution)' }, + { label: '풍향', value: 'SW → NE 방향' }, + { label: '대기 안정도', value: 'D등급 (중립)' }, + { label: '영향 인구', value: '약 2,400명', color: 'var(--color-danger)' }, + ], + summary: '남서풍에 의해 북동쪽 내륙 방향 확산. IDLH 1.2km 이내 즉시 대피 필요.', + }, + 구난: { + title: '긴급구난 SAR 결과', + model: 'SAROPS · Monte Carlo 10,000회 시뮬레이션', + items: [ + { label: '95% 확률 범위', value: '8.5 NM²', color: 'var(--color-accent)' }, + { label: '최적 탐색 경로', value: 'Sector Search' }, + { label: '예상 표류 속도', value: '1.8 kn' }, + { label: '표류 방향', value: 'NE (045°)' }, + { label: '생존 가능 시간', value: '36시간', color: 'var(--color-danger)' }, + { label: '필요 자산', value: '헬기 2 + 경비정 3', color: 'var(--color-warning)' }, + ], + summary: '북동쪽 방향 표류 예상. 06:00 기준 최적 탐색 패턴: 섹터탐색(Sector Search).', + }, + }; + + const data = mockData[tag.label] || mockData['유출유']; + + return ( + <> +
+
+ {tag.icon} {data.title} +
+
{data.model}
+ {incident && ( +
+ 사고: {incident.name} · {incident.date} {incident.time} +
+ )} +
+ +
+ {data.items.map((item, i) => ( +
+ {item.label} + + {item.value} + +
+ ))} +
+ +
+ 💡 {data.summary} +
+ +
+
{tag.icon}
+
시각화 영역
+
+ + ); +} diff --git a/frontend/src/components/incidents/components/contents/VesselDetailModal.tsx b/frontend/src/components/incidents/components/contents/VesselDetailModal.tsx new file mode 100644 index 0000000..852e919 --- /dev/null +++ b/frontend/src/components/incidents/components/contents/VesselDetailModal.tsx @@ -0,0 +1,678 @@ +import { useState } from 'react'; +import type { VesselPosition } from '@/types/vessel'; +import { getShipKindLabel } from '@components/common/map/VesselLayer'; +import { formatDateTime, displayVal } from '../IncidentsView'; + +type DetTab = 'info' | 'nav' | 'spec' | 'ins' | 'dg'; +const TAB_LABELS: { key: DetTab; label: string }[] = [ + { key: 'info', label: '상세정보' }, + { key: 'nav', label: '항해정보' }, + { key: 'spec', label: '선박제원' }, + { key: 'ins', label: '보험정보' }, + { key: 'dg', label: '위험물정보' }, +]; + +export function VesselDetailModal({ + vessel: v, + onClose, +}: { + vessel: VesselPosition; + onClose: () => void; +}) { + const [tab, setTab] = useState('info'); + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + className="fixed inset-0 z-[10000] flex items-center justify-center" + style={{ + background: 'rgba(0,0,0,0.65)', + backdropFilter: 'blur(6px)', + }} + > +
+ {/* Header */} +
+
+ {v.nationalCode ?? '🚢'} +
+
+ {v.shipNm ?? '(이름 없음)'} +
+
+ MMSI: {v.mmsi} · IMO: {displayVal(v.imo)} +
+
+
+ + ✕ + +
+ + {/* Tabs */} +
+ {TAB_LABELS.map((t) => ( + + ))} +
+ + {/* Body */} +
+ {tab === 'info' && } + {tab === 'nav' && } + {tab === 'spec' && } + {tab === 'ins' && } + {tab === 'dg' && } +
+
+
+ ); +} + +/* ── shared section helpers ──────────────────────── */ +function Sec({ + title, + borderColor, + bgColor, + badge, + children, +}: { + title: string; + borderColor?: string; + bgColor?: string; + badge?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+ {title} + {badge} +
+ {children} +
+ ); +} + +function Grid({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function Cell({ + label, + value, + span, + color, +}: { + label: string; + value: string; + span?: boolean; + color?: string; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +function StatusBadge({ label, color }: { label: string; color: string }) { + return ( + + {label} + + ); +} + +/* ── Tab 0: 상세정보 ─────────────────────────────── */ +function TabInfo({ v }: { v: VesselPosition }) { + const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-'; + const heading = v.heading ?? v.cog; + const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-'; + return ( + <> +
+ 🚢 +
+ + + + + + + + + + + + + + + + + + + + + + ); +} + +/* ── Tab 1: 항해정보 ─────────────────────────────── */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function TabNav(_props: { v: VesselPosition }) { + const hours = ['08', '09', '10', '11', '12', '13', '14']; + const heights = [45, 60, 78, 82, 70, 85, 75]; + const colors = [ + 'color-mix(in srgb, var(--color-success) 30%, transparent)', + 'color-mix(in srgb, var(--color-success) 40%, transparent)', + 'color-mix(in srgb, var(--color-info) 40%, transparent)', + 'color-mix(in srgb, var(--color-info) 50%, transparent)', + 'color-mix(in srgb, var(--color-info) 50%, transparent)', + 'color-mix(in srgb, var(--color-info) 60%, transparent)', + 'color-mix(in srgb, var(--color-accent) 50%, transparent)', + ]; + + return ( + <> + +
+ + + + + + + + 08:00 + + + 10:30 + + + 12:45 + + + 현재 + + +
+
+ + +
+
+ {hours.map((h, i) => ( +
+
+ {h} +
+ ))} +
+
+ 평균: 8.4 kn · 최대:{' '} + 11.2 kn +
+
+ + +
+ + +
+ + ); +} + +/* ── Tab 2: 선박제원 ─────────────────────────────── */ +function TabSpec({ v }: { v: VesselPosition }) { + const loa = v.length !== undefined ? `${v.length} m` : '-'; + const beam = v.width !== undefined ? `${v.width} m` : '-'; + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + +
+
+ 🛢 +
+
-
+
정보 없음
+
+
+
+
+ + ); +} + +/* ── Tab 3: 보험정보 ─────────────────────────────── */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function TabInsurance(_props: { v: VesselPosition }) { + return ( + <> + + + + + + + + + } + > + + + + + + + + + } + > + + + + + + + + + } + > + + + + + + + + + + +
+ 💡 보험정보는 한국해운조합(KSA) Open API 및 해양수산부 선박정보시스템 연동 데이터입니다. + 실시간 갱신 주기: 24시간 +
+ + ); +} + +/* ── Tab 4: 위험물정보 ───────────────────────────── */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function TabDangerous(_props: { v: VesselPosition }) { + return ( + <> + + PORT-MIS + + } + > + + + + + + + + + + + + + + + +
+
+ 화물창 2개이상 여부 + + + ✓ + + + +
+ +
+
+ + + + + + + + + + + + + +
+ + + +
+
+ +
+ 💡 위험물정보는 PORT-MIS(항만운영정보시스템) 위험물 반입신고 데이터 연동입니다. IMDG Code + 최신 개정판(Amendment 42-24) 기준. +
+ + ); +} + +function EmsRow({ + icon, + label, + value, + bg, + bd, +}: { + icon: string; + label: string; + value: string; + bg: string; + bd: string; +}) { + return ( +
+ {icon} +
+
{label}
+
{value}
+
+
+ ); +} + +function ActionBtn({ + icon, + label, + bg, + bd, + fg, +}: { + icon: string; + label: string; + bg: string; + bd: string; + fg: string; +}) { + return ( + + ); +} diff --git a/frontend/src/components/incidents/components/contents/VesselPopupPanel.tsx b/frontend/src/components/incidents/components/contents/VesselPopupPanel.tsx new file mode 100644 index 0000000..9719414 --- /dev/null +++ b/frontend/src/components/incidents/components/contents/VesselPopupPanel.tsx @@ -0,0 +1,222 @@ +import type { VesselPosition } from '@/types/vessel'; +import { getShipKindLabel } from '@components/common/map/VesselLayer'; +import { formatDateTime } from '../IncidentsView'; + +export function VesselPopupPanel({ + vessel: v, + onClose, + onDetail, +}: { + vessel: VesselPosition; + onClose: () => void; + onDetail: () => void; +}) { + const statusText = v.status ?? '-'; + const isAccident = (v.status ?? '').includes('사고'); + const statusColor = isAccident ? 'var(--color-danger)' : 'var(--color-success)'; + const statusBg = isAccident + ? 'color-mix(in srgb, var(--color-danger) 15%, transparent)' + : 'color-mix(in srgb, var(--color-success) 10%, transparent)'; + const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-'; + const heading = v.heading ?? v.cog; + const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-'; + const receivedAt = v.lastUpdate ? formatDateTime(v.lastUpdate) : '-'; + + return ( +
+ {/* Header */} +
+
+ {v.nationalCode ?? '🚢'} +
+
+
+ {v.shipNm ?? '(이름 없음)'} +
+
+ MMSI: {v.mmsi} +
+
+ + ✕ + +
+ + {/* Ship Image */} +
+ 🚢 +
+ + {/* Tags */} +
+ + {getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-'} + + + {statusText} + +
+ + {/* Data rows */} +
+ + +
+
+ + 출항지 + + + - + +
+
+ + 입항지 + + + {v.destination ?? '-'} + +
+
+ +
+ + {/* Buttons */} +
+ + + +
+
+ ); +} + +function PopupRow({ + label, + value, + accent, + muted, +}: { + label: string; + value: string; + accent?: boolean; + muted?: boolean; +}) { + return ( +
+ {label} + + {value} + +
+ ); +} diff --git a/frontend/src/components/incidents/components/contents/VesselTooltipContent.tsx b/frontend/src/components/incidents/components/contents/VesselTooltipContent.tsx new file mode 100644 index 0000000..faf5621 --- /dev/null +++ b/frontend/src/components/incidents/components/contents/VesselTooltipContent.tsx @@ -0,0 +1,23 @@ +import type { VesselPosition } from '@/types/vessel'; +import { getShipKindLabel } from '@components/common/map/VesselLayer'; + +export function VesselTooltipContent({ vessel: v }: { vessel: VesselPosition }) { + const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '- kn'; + const heading = v.heading ?? v.cog; + const headingText = heading !== undefined ? `HDG ${Math.round(heading)}°` : 'HDG -'; + const typeText = [getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-', v.nationalCode].filter(Boolean).join(' · '); + return ( + <> +
+ {v.shipNm ?? '(이름 없음)'} +
+
+ {typeText} +
+
+ {speed} + {headingText} +
+ + ); +} diff --git a/frontend/src/tabs/incidents/index.ts b/frontend/src/components/incidents/index.ts similarity index 100% rename from frontend/src/tabs/incidents/index.ts rename to frontend/src/components/incidents/index.ts diff --git a/frontend/src/tabs/incidents/services/incidentsApi.ts b/frontend/src/components/incidents/services/incidentsApi.ts similarity index 54% rename from frontend/src/tabs/incidents/services/incidentsApi.ts rename to frontend/src/components/incidents/services/incidentsApi.ts index 88769d6..2ec08c2 100644 --- a/frontend/src/tabs/incidents/services/incidentsApi.ts +++ b/frontend/src/components/incidents/services/incidentsApi.ts @@ -1,98 +1,14 @@ import { api } from '@common/services/api'; - -// ============================================================ -// 백엔드 API 응답 타입 -// ============================================================ - -export interface IncidentListItem { - acdntSn: number; - acdntCd: string; - acdntNm: string; - acdntTpCd: string; - acdntSttsCd: string; - lat: number; - lng: number; - locDc: string; - occrnDtm: string; - regionNm: string; - officeNm: string; - svrtCd: string | null; - vesselTp: string | null; - phaseCd: string; - analystNm: string | null; - oilTpCd: string | null; - spilQty: number | null; - spilUnitCd: string | null; - fcstHr: number | null; - hasPredCompleted: boolean; - mediaCnt: number; - hasImgAnalysis: boolean; -} - -export interface PredExecItem { - predExecSn: number; - algoCd: string; - execSttsCd: string; - bgngDtm: string | null; - cmplDtm: string | null; - reqdSec: number | null; -} - -export interface WeatherInfo { - locNm: string; - obsDtm: string; - icon: string; - temp: string; - weatherDc: string; - wind: string; - wave: string; - humid: string; - vis: string; - sst: string; - tide: string; - highTide: string; - lowTide: string; - forecast: Array<{ hour: string; icon: string; temp: string }>; - impactDc: string; -} - -export interface MediaInfo { - photoCnt: number; - videoCnt: number; - satCnt: number; - cctvCnt: number; - photoMeta: Record | null; - droneMeta: Record | null; - satMeta: Record | null; - cctvMeta: Record | null; -} - -export interface IncidentDetail extends IncidentListItem { - predictions: PredExecItem[]; - weather: WeatherInfo | null; - media: MediaInfo | null; -} - -// ============================================================ -// 프론트 호환 타입 -// ============================================================ - -export interface IncidentCompat { - id: string; - name: string; - status: 'active' | 'investigating' | 'closed'; - date: string; - time: string; - region: string; - office: string; - location: { lat: number; lon: number }; - causeType?: string; - oilType?: string; - prediction?: string; - vesselName?: string; - mediaCount?: number; - hasImgAnalysis?: boolean; -} +import type { + IncidentListItem, + PredExecItem, + WeatherInfo, + MediaInfo, + IncidentDetail, + IncidentCompat, + NearbyOrgItem, +} from '@interfaces/incidents/IncidentsInterface'; +import type { AerialMediaItem } from '@interfaces/aerial/AerialInterface'; function toCompat(item: IncidentListItem): IncidentCompat { const dt = new Date(item.occrnDtm); @@ -175,26 +91,6 @@ export async function fetchIncidentPredictions(sn: number): Promise { try { diff --git a/frontend/src/tabs/incidents/services/vesselService.ts b/frontend/src/components/incidents/services/vesselService.ts old mode 100755 new mode 100644 similarity index 88% rename from frontend/src/tabs/incidents/services/vesselService.ts rename to frontend/src/components/incidents/services/vesselService.ts index 78fca2d..451880a --- a/frontend/src/tabs/incidents/services/vesselService.ts +++ b/frontend/src/components/incidents/services/vesselService.ts @@ -1,40 +1,10 @@ import axios from 'axios'; +import type { VesselPosition, BoundingBox } from '@interfaces/incidents/IncidentsInterface'; // API Key를 환경변수에서 로드 (소스코드 노출 방지) const AIS_API_KEY = import.meta.env.VITE_AIS_API_KEY || ''; const AIS_BASE_URL = import.meta.env.VITE_AIS_API_URL || 'https://ais-api.spgi.kr/api/v1'; -// 선박 위치 데이터 타입 -export interface VesselPosition { - mmsi: string; // Maritime Mobile Service Identity - imo?: string; // International Maritime Organization number - shipName: string; // 선박명 - callSign?: string; // 호출부호 - shipType: number; // 선박 유형 코드 - shipTypeText?: string; // 선박 유형 (한글) - latitude: number; // 위도 - longitude: number; // 경도 - speed: number; // 속도 (노트) - course: number; // 침로 (0-359도) - heading: number; // 선수방위 - navStatus: number; // 항해 상태 코드 - navStatusText?: string; // 항해 상태 (한글) - timestamp: string; // 데이터 수신 시각 - destination?: string; // 목적지 - eta?: string; // 예정 도착 시각 - draught?: number; // 흘수 (미터) - length?: number; // 전장 (미터) - width?: number; // 폭 (미터) -} - -// 영역 내 선박 조회 파라미터 -export interface BoundingBox { - minLat: number; // 최소 위도 - maxLat: number; // 최대 위도 - minLng: number; // 최소 경도 - maxLng: number; // 최대 경도 -} - // 선박 유형 코드 → 텍스트 변환 export function getShipTypeText(code: number): string { const types: { [key: number]: string } = { diff --git a/frontend/src/tabs/incidents/utils/dischargeZoneData.ts b/frontend/src/components/incidents/utils/dischargeZoneData.ts similarity index 100% rename from frontend/src/tabs/incidents/utils/dischargeZoneData.ts rename to frontend/src/components/incidents/utils/dischargeZoneData.ts diff --git a/frontend/src/tabs/prediction/components/AnalysisListTable.tsx b/frontend/src/components/prediction/components/AnalysisListTable.tsx old mode 100755 new mode 100644 similarity index 98% rename from frontend/src/tabs/prediction/components/AnalysisListTable.tsx rename to frontend/src/components/prediction/components/AnalysisListTable.tsx index 4e50ecb..43c4fc2 --- a/frontend/src/tabs/prediction/components/AnalysisListTable.tsx +++ b/frontend/src/components/prediction/components/AnalysisListTable.tsx @@ -1,16 +1,14 @@ import { useState, useEffect, useCallback } from 'react'; import { fetchPredictionAnalyses } from '../services/predictionApi'; -import type { PredictionAnalysis } from '../services/predictionApi'; - -export type Analysis = PredictionAnalysis; +import type { PredictionAnalysis } from '@interfaces/prediction/PredictionInterface'; interface AnalysisListTableProps { onTabChange: (tab: string) => void; - onSelectAnalysis?: (analysis: Analysis) => void; + onSelectAnalysis?: (analysis: PredictionAnalysis) => void; } export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisListTableProps) { - const [analyses, setAnalyses] = useState([]); + const [analyses, setAnalyses] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); const [currentPage, setCurrentPage] = useState(1); diff --git a/frontend/src/tabs/prediction/components/BacktrackModal.tsx b/frontend/src/components/prediction/components/BacktrackModal.tsx old mode 100755 new mode 100644 similarity index 99% rename from frontend/src/tabs/prediction/components/BacktrackModal.tsx rename to frontend/src/components/prediction/components/BacktrackModal.tsx index 52745c9..1f99fd7 --- a/frontend/src/tabs/prediction/components/BacktrackModal.tsx +++ b/frontend/src/components/prediction/components/BacktrackModal.tsx @@ -4,7 +4,7 @@ import type { BacktrackVessel, BacktrackConditions, BacktrackInputConditions, -} from '@common/types/backtrack'; +} from '@/types/backtrack'; interface BacktrackModalProps { isOpen: boolean; diff --git a/frontend/src/components/prediction/components/BoomDeploymentTheoryView.tsx b/frontend/src/components/prediction/components/BoomDeploymentTheoryView.tsx new file mode 100644 index 0000000..2ef060c --- /dev/null +++ b/frontend/src/components/prediction/components/BoomDeploymentTheoryView.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react'; +import { OverviewPanel } from './contents/OverviewPanel'; +import { DeploymentTheoryPanel } from './contents/DeploymentTheoryPanel'; +import { OptimizationPanel } from './contents/OptimizationPanel'; +import { FluidDynamicsPanel } from './contents/FluidDynamicsPanel'; +import { FieldApplicationPanel } from './contents/FieldApplicationPanel'; +import { ReferencesPanel } from './contents/ReferencesPanel'; + +const boomTabs = [ + { id: 0, label: '개요' }, + { id: 1, label: '배치 이론' }, + { id: 2, label: '최적화 알고리즘' }, + { id: 3, label: '유체역학 모델' }, + { id: 4, label: '현장 적용' }, + { id: 5, label: '참고문헌' }, +]; + +export function BoomDeploymentTheoryView() { + const [activePanel, setActivePanel] = useState(0); + + const handleExportPDF = () => { + window.print(); + }; + + return ( +
+
+ {/* 헤더 */} +
+
+
+ 🛡️ +
+
+
오일펜스 배치 최적화 알고리즘 이론
+
+ Oil Boom Deployment Optimization · 유출유 확산예측 연동 · 방제효율 최대화 +
+
+
+ +
+ + {/* 내부 네비게이션 */} +
+
+ {boomTabs.map((tab) => ( + + ))} +
+
+ + {/* ═══ PANEL 0: 개요 ═══ */} + {activePanel === 0 && } + {activePanel === 1 && } + {activePanel === 2 && } + {activePanel === 3 && } + {activePanel === 4 && } + {activePanel === 5 && } +
+
+ ); +} diff --git a/frontend/src/tabs/prediction/components/InfoLayerSection.tsx b/frontend/src/components/prediction/components/InfoLayerSection.tsx similarity index 98% rename from frontend/src/tabs/prediction/components/InfoLayerSection.tsx rename to frontend/src/components/prediction/components/InfoLayerSection.tsx index b75a28c..a906a5c 100644 --- a/frontend/src/tabs/prediction/components/InfoLayerSection.tsx +++ b/frontend/src/components/prediction/components/InfoLayerSection.tsx @@ -1,4 +1,4 @@ -import { LayerTree } from '@common/components/layer/LayerTree'; +import { LayerTree } from '@components/common/layer/LayerTree'; import { useLayerTree } from '@common/hooks/useLayers'; import type { Layer } from '@common/services/layerService'; diff --git a/frontend/src/tabs/prediction/components/LeftPanel.tsx b/frontend/src/components/prediction/components/LeftPanel.tsx old mode 100755 new mode 100644 similarity index 100% rename from frontend/src/tabs/prediction/components/LeftPanel.tsx rename to frontend/src/components/prediction/components/LeftPanel.tsx diff --git a/frontend/src/tabs/prediction/components/OilBoomSection.tsx b/frontend/src/components/prediction/components/OilBoomSection.tsx similarity index 99% rename from frontend/src/tabs/prediction/components/OilBoomSection.tsx rename to frontend/src/components/prediction/components/OilBoomSection.tsx index a3ce490..1b4bfc8 100644 --- a/frontend/src/tabs/prediction/components/OilBoomSection.tsx +++ b/frontend/src/components/prediction/components/OilBoomSection.tsx @@ -5,7 +5,7 @@ import type { BoomLineCoord, AlgorithmSettings, ContainmentResult, -} from '@common/types/boomLine'; +} from '@/types/boomLine'; import { generateAIBoomLines, runContainmentAnalysis } from '@common/utils/geo'; interface OilBoomSectionProps { diff --git a/frontend/src/components/prediction/components/OilSpillTheoryView.tsx b/frontend/src/components/prediction/components/OilSpillTheoryView.tsx new file mode 100644 index 0000000..41d498e --- /dev/null +++ b/frontend/src/components/prediction/components/OilSpillTheoryView.tsx @@ -0,0 +1,163 @@ +import { useState, useRef } from 'react'; +import { sanitizeHtml } from '@common/utils/sanitize'; +import { SystemOverviewPanel } from './contents/SystemOverviewPanel'; +import { KospsPanel } from './contents/KospsPanel'; +import { PoseidonPanel } from './contents/PoseidonPanel'; +import { OpenDriftPanel } from './contents/OpenDriftPanel'; +import { LagrangianPanel } from './contents/LagrangianPanel'; +import { WeatheringPanel } from './contents/WeatheringPanel'; +import { OceanInputPanel } from './contents/OceanInputPanel'; +import { VerificationPanel } from './contents/VerificationPanel'; +import { EnsemblePanel } from './contents/EnsemblePanel'; +import { RoadmapPanel } from './contents/RoadmapPanel'; +/* eslint-disable react-refresh/only-export-components */ + +const theoryTabs: { id: number; icon: string; name: string; nameColor?: string }[] = [ + { id: 0, icon: '🌊', name: '시스템 개요' }, + { id: 7, icon: '🔷', name: 'KOSPS', nameColor: '#06b6d4' }, + { id: 8, icon: '🔴', name: 'POSEIDON', nameColor: '#ef4444' }, + { id: 9, icon: '🔵', name: 'OpenDrift', nameColor: '#3b82f6' }, + { id: 1, icon: '🧭', name: '입자추적법' }, + { id: 2, icon: '🔁', name: '풍화 프로세스' }, + { id: 3, icon: '🌊', name: '해양환경 입력' }, + { id: 4, icon: '✅', name: '모델 검증' }, + { id: 5, icon: '⚡', name: '앙상블', nameColor: '#a855f7' }, + { id: 6, icon: '🚀', name: '발전 방향' }, +]; + +export function OilSpillTheoryView() { + const [activePanel, setActivePanel] = useState(0); + const contentRef = useRef(null); + + const handleExportPDF = () => { + if (!contentRef.current) return; + + // 컨텐츠를 복제하고 PDF 버튼 제거 + const clone = contentRef.current.cloneNode(true) as HTMLElement; + clone.querySelectorAll('[data-html2pdf-ignore]').forEach((el) => el.remove()); + // XSS 방지: innerHTML을 살균 처리 + const content = sanitizeHtml(clone.innerHTML); + + // 현재 페이지의 모든 스타일시트를 복사 + const styles = Array.from(document.querySelectorAll('style, link[rel="stylesheet"]')) + .map((el) => el.outerHTML) + .join('\n'); + + // document.write 대신 Blob URL 사용 (보안 강화) + const fullHtml = ` + + +유출유 확산 모델 이론 및 검증 +${styles} + +${content}`; + + const blob = new Blob([fullHtml], { type: 'text/html; charset=utf-8' }); + const url = URL.createObjectURL(blob); + const win = window.open(url, '_blank'); + if (win) { + win.addEventListener('afterprint', () => URL.revokeObjectURL(url)); + setTimeout(() => { + win.document.title = '유출유_확산모델_이론_및_검증'; + win.print(); + }, 500); + } + setTimeout(() => URL.revokeObjectURL(url), 30000); + }; + + return ( +
+
+ {/* 헤더 */} +
+
+
+ 📐 +
+
+
유출유 확산 모델 이론 및 검증
+
+ 🔷 KOSPS + 🔴 POSEIDON + 🔵 OpenDrift + ⚡ 앙상블 + 라그랑지안 입자추적 이론 기반 +
+
+
+ +
+ + {/* 내부 네비게이션 탭 */} +
+ {theoryTabs.map((tab) => ( + + ))} +
+ + {activePanel === 0 && } + {activePanel === 7 && } + {activePanel === 8 && } + {activePanel === 9 && } + {activePanel === 1 && } + {activePanel === 2 && } + {activePanel === 3 && } + {activePanel === 4 && } + {activePanel === 5 && } + {activePanel === 6 && } +
+
+ ); +} + +/* ═══ 공통 스타일 유틸 ═══ */ +export const card = 'rounded-[10px] p-[14px] mb-4'; +export const cardBg = 'bg-bg-card border border-stroke'; +export const labelStyle = (color: string) => + ({ fontSize: 'var(--font-size-title-3)', fontWeight: 700, color, marginBottom: '10px' }) as const; +export const bodyText = 'text-label-1 text-fg-sub leading-[1.8]'; +export const codeBox = 'bg-bg-base rounded-sm p-[10px] font-mono text-label-1 leading-loose'; +export const tag = (color: string) => + ({ + padding: '3px 8px', + borderRadius: '4px', + fontSize: 'var(--font-size-label-2)', + color, + background: `${color}14`, + border: `1px solid ${color}30`, + }) as const; diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/components/prediction/components/OilSpillView.tsx old mode 100755 new mode 100644 similarity index 98% rename from frontend/src/tabs/prediction/components/OilSpillView.tsx rename to frontend/src/components/prediction/components/OilSpillView.tsx index e432d25..3c70ccd --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/components/prediction/components/OilSpillView.tsx @@ -1,15 +1,16 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useVesselSignals } from '@common/hooks/useVesselSignals'; -import type { MapBounds } from '@common/types/vessel'; +import type { MapBounds } from '@/types/vessel'; import { LeftPanel } from './LeftPanel'; import { RightPanel } from './RightPanel'; -import { MapView } from '@common/components/map/MapView'; -import { AnalysisListTable, type Analysis } from './AnalysisListTable'; +import { MapView } from '@components/common/map/MapView'; +import { AnalysisListTable } from './AnalysisListTable'; +import type { PredictionAnalysis } from '@interfaces/prediction/PredictionInterface'; import { OilSpillTheoryView } from './OilSpillTheoryView'; import { BoomDeploymentTheoryView } from './BoomDeploymentTheoryView'; import { BacktrackModal } from './BacktrackModal'; import { RecalcModal } from './RecalcModal'; -import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar'; +import { BacktrackReplayBar } from '@components/common/map/BacktrackReplayBar'; import { useSubMenu, navigateToTab, @@ -17,14 +18,14 @@ import { setOilReportPayload, type OilReportPayload, } from '@common/hooks/useSubMenu'; -import { fetchWeatherSnapshotForCoord } from '@tabs/weather/services/weatherUtils'; +import { fetchWeatherSnapshotForCoord } from '@components/weather/services/weatherUtils'; import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore'; import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord, -} from '@common/types/boomLine'; +} from '@/types/boomLine'; import type { BacktrackPhase, BacktrackVessel, @@ -33,8 +34,8 @@ import type { ReplayShip, CollisionEvent, BackwardParticleStep, -} from '@common/types/backtrack'; -import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'; +} from '@/types/backtrack'; +import { TOTAL_REPLAY_FRAMES } from '@/types/backtrack'; import { fetchBacktrackByAcdnt, createBacktrack, @@ -54,7 +55,7 @@ import type { SensitiveResourceCategory, SensitiveResourceFeatureCollection, WindPoint, -} from '../services/predictionApi'; +} from '@interfaces/prediction/PredictionInterface'; import SimulationLoadingOverlay from './SimulationLoadingOverlay'; import SimulationErrorModal from './SimulationErrorModal'; import { api } from '@common/services/api'; @@ -67,7 +68,7 @@ import { } from '@common/utils/geo'; import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal'; -export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'; +import type { PredictionModel } from '@/types/prediction/PredictionType'; const toLocalDateTimeStr = (raw: string): string => { const d = new Date(raw); @@ -79,15 +80,8 @@ const toLocalDateTimeStr = (raw: string): string => { // --------------------------------------------------------------------------- // 민감자원 타입 + 데모 데이터 // --------------------------------------------------------------------------- -export interface SensitiveResource { - id: string; - name: string; - type: 'aquaculture' | 'beach' | 'ecology' | 'intake'; - lat: number; - lon: number; - radiusM: number; - arrivalTimeH: number; -} +import type { SensitiveResource } from '@interfaces/prediction/PredictionInterface'; +/* eslint-disable react-refresh/only-export-components */ export interface DisplayControls { showCurrent: boolean; // 유향/유속 @@ -252,7 +246,7 @@ export function OilSpillView() { const [replaySpeed, setReplaySpeed] = useState(1); // 선택된 분석 (목록에서 클릭 시) - const [selectedAnalysis, setSelectedAnalysis] = useState(null); + const [selectedAnalysis, setSelectedAnalysis] = useState(null); // 분석 상세 (API에서 가져온 선박/기상 정보) const [analysisDetail, setAnalysisDetail] = useState(null); @@ -572,7 +566,7 @@ export function OilSpillView() { }, [isPlaying, currentStep, playSpeed, timeSteps, maxTime]); // 분석 목록에서 사고명 클릭 시 - const handleSelectAnalysis = async (analysis: Analysis) => { + const handleSelectAnalysis = async (analysis: PredictionAnalysis) => { setIsPlaying(false); setCurrentStep(0); setSelectedAnalysis(analysis); @@ -896,7 +890,7 @@ export function OilSpillView() { backtrackStatus: 'pending', analyst: '', officeName: '', - } as Analysis); + } as PredictionAnalysis); setIncidentName(''); } diff --git a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx b/frontend/src/components/prediction/components/PredictionInputSection.tsx similarity index 99% rename from frontend/src/tabs/prediction/components/PredictionInputSection.tsx rename to frontend/src/components/prediction/components/PredictionInputSection.tsx index 079a892..420d664 100644 --- a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx +++ b/frontend/src/components/prediction/components/PredictionInputSection.tsx @@ -1,8 +1,8 @@ import { useState, useRef, useEffect } from 'react'; -import { ComboBox } from '@common/components/ui/ComboBox'; -import type { PredictionModel } from './OilSpillView'; +import { ComboBox } from '@components/common/ui/ComboBox'; +import type { PredictionModel } from '@/types/prediction/PredictionType'; import { analyzeImage, fetchGscAccidents } from '../services/predictionApi'; -import type { ImageAnalyzeResult, GscAccidentListItem } from '../services/predictionApi'; +import type { ImageAnalyzeResult, GscAccidentListItem } from '@interfaces/prediction/PredictionInterface'; interface PredictionInputSectionProps { expanded: boolean; diff --git a/frontend/src/tabs/prediction/components/RecalcModal.tsx b/frontend/src/components/prediction/components/RecalcModal.tsx old mode 100755 new mode 100644 similarity index 99% rename from frontend/src/tabs/prediction/components/RecalcModal.tsx rename to frontend/src/components/prediction/components/RecalcModal.tsx index 2ab79a3..00ac0c1 --- a/frontend/src/tabs/prediction/components/RecalcModal.tsx +++ b/frontend/src/components/prediction/components/RecalcModal.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react'; -import type { PredictionModel } from './OilSpillView'; +import type { PredictionModel } from '@/types/prediction/PredictionType'; interface RecalcModalProps { isOpen: boolean; diff --git a/frontend/src/tabs/prediction/components/RightPanel.tsx b/frontend/src/components/prediction/components/RightPanel.tsx old mode 100755 new mode 100644 similarity index 99% rename from frontend/src/tabs/prediction/components/RightPanel.tsx rename to frontend/src/components/prediction/components/RightPanel.tsx index 4edadf7..4c4ecde --- a/frontend/src/tabs/prediction/components/RightPanel.tsx +++ b/frontend/src/components/prediction/components/RightPanel.tsx @@ -1,5 +1,5 @@ import { useState, useMemo } from 'react'; -import type { PredictionDetail, SimulationSummary, CenterPoint } from '../services/predictionApi'; +import type { PredictionDetail, SimulationSummary, CenterPoint } from '@interfaces/prediction/PredictionInterface'; import type { DisplayControls } from './OilSpillView'; import { haversineDistance, computeBearing } from '@common/utils/geo'; diff --git a/frontend/src/tabs/prediction/components/SimulationErrorModal.tsx b/frontend/src/components/prediction/components/SimulationErrorModal.tsx similarity index 100% rename from frontend/src/tabs/prediction/components/SimulationErrorModal.tsx rename to frontend/src/components/prediction/components/SimulationErrorModal.tsx diff --git a/frontend/src/tabs/prediction/components/SimulationLoadingOverlay.tsx b/frontend/src/components/prediction/components/SimulationLoadingOverlay.tsx similarity index 100% rename from frontend/src/tabs/prediction/components/SimulationLoadingOverlay.tsx rename to frontend/src/components/prediction/components/SimulationLoadingOverlay.tsx diff --git a/frontend/src/components/prediction/components/contents/DeploymentTheoryPanel.tsx b/frontend/src/components/prediction/components/contents/DeploymentTheoryPanel.tsx new file mode 100644 index 0000000..cc9f711 --- /dev/null +++ b/frontend/src/components/prediction/components/contents/DeploymentTheoryPanel.tsx @@ -0,0 +1,351 @@ +export function DeploymentTheoryPanel() { + return ( + <> + {/* 차단 효율 이론 */} +
+
+ 📐 오일펜스 차단 효율 이론 (Boom Containment Efficiency) +
+
+
+
+ ① 차단 효율 함수 E(θ, U) +
+
+ 오일펜스의 차단 효율은 조류 유속(U)오일펜스 방향각(θ)의 함수입니다. + 조류가 오일펜스에 수직으로 입사할수록 차단 효율이 낮아지며, 임계유속 초과 시 기름이 + 오일펜스 하부로 통과합니다. +
+
+ E(θ,U) = 1 −{' '} + + Floss(Un) + +
Un = U · sin(θ){' '} + (법선방향 유속) +
E = 1 (Un ≤ Uc)
E = max(0, 1 − (Un/U + c)²) (Un > Uc)
+ + Uc: 임계유속(약 0.35m/s = 0.7 knot) + +
+
+
+
+ ② 최적 방향각 θ* 산정 +
+
+ 오일펜스 방향각은 조류 방향에 따라 최적화됩니다. 차단 면적과 오일 수집 효율의{' '} + 트레이드오프를 고려하여, 일반적으로 조류에 대해{' '} + 30°~45° 예각 배치가 최적입니다. +
+
+ θ* = arcsin(Uc / U){' '} + (임계조건) +
θopt = argmax [Ablock(θ) · E(θ,U)] +
+ 실용범위: 15° ≤ θ ≤ 60° +
+ + 단, θ < arcsin(Uc/U) 이면 기름 통과 발생 + +
+
+
+
+ + {/* V형·U형·J형 배치 패턴 */} +
+
🔷 오일펜스 배치 형태별 이론
+
+ {/* V형 */} +
+
V형 (Chevron)
+
+ + + + + + + + + + + 집유점 + + + + 조류 + + +
+
+ 조류 방향 정면에서 양측으로 펼친 V형. 기름을 중앙 집유점으로 유도. 회수선 배치 용이. +
+
+ AV = L²·sin(2α)/2 +
+ α: 반개각, L: 편측 길이 +
+ 최적 α = 30°~45° +
+
+ + {/* U형 */} +
+
U형 (Horseshoe)
+
+ + + + + + + + + + 회수선 + + + + 조류 + + +
+
+ 말굽형으로 기름을 완전 포위. 폐쇄형 구조로 회수 효율 최고. 저조류 해역 적합. +
+
+ AU = π·r²/2 + 2r·h +
+ r: 반경, h: 직선부 길이 +
+ 전제: U < 0.5 knot +
+
+ + {/* J형 */} +
+
J형 (Skimming)
+
+ + + + + + + + + + + 회수 + + + + 조류 + + +
+
+ 직선+곡선 조합. 기름을 한쪽으로 편향 유도하여 집유. 강조류·연안 배치에 최적. +
+
+ θJ = arcsin(Uc/U) + δ
+ δ: 안전여유각(5°~10°) +
+ 활용: U > 0.7 knot +
+
+
+
+ + {/* 다단 배치 이론 */} +
+
🔢 다단계 차단선(Multi-Boom) 배치 이론
+
+
+ 단일 오일펜스로 차단 불가한 경우 직렬 다단 배치로 누적 차단 효율을 향상합니다. + n개 직렬 배치 시 누적 차단 효율: +
+ Etotal = 1 − ∏(1−Ei)
+ + Ei: i번째 오일펜스 단독 차단효율 + +
+
+
+ {[ + { + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.05)', + bd: 'rgba(6,182,212,.12)', + label: '2단 직렬', + text: ': E_total = E₁+E₂−E₁·E₂ (예: 70%+70% → 91%)', + }, + { + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.05)', + bd: 'rgba(6,182,212,.12)', + label: '단간 거리', + text: ': 부표 집적 방지를 위해 ≥ 200m 이격 권장', + }, + { + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.05)', + bd: 'rgba(6,182,212,.12)', + label: '배치 우선순위', + text: ': ESI 고등급 구역 보호 → 취수원 → 어항 순', + }, + { + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.05)', + bd: 'rgba(6,182,212,.12)', + label: '조석 변화', + text: ': 창조·낙조 전환 시 오일펜스 방향 재조정 필요', + }, + ].map((item, i) => ( +
+ {item.label} + {item.text} +
+ ))} +
+
+
+ + ); +} diff --git a/frontend/src/components/prediction/components/contents/EnsemblePanel.tsx b/frontend/src/components/prediction/components/contents/EnsemblePanel.tsx new file mode 100644 index 0000000..87bfd8c --- /dev/null +++ b/frontend/src/components/prediction/components/contents/EnsemblePanel.tsx @@ -0,0 +1,36 @@ +import { bodyText, card, cardBg, codeBox, labelStyle } from '../OilSpillTheoryView'; + +export function EnsemblePanel() { + return ( +
+
+
앙상블 예측(Ensemble Prediction)
+
+ WING 시스템은 3종의 유출유 확산 모델(KOSPS·POSEIDON·OpenDrift)을 동시 운용하여{' '} + 앙상블 결과를 생산합니다. 단일 모델 대비 불확실성을 + 정량화하고 신뢰 구간을 제공합니다. +
+
+
+
+
앙상블 가중 평균
+
+ P_ens(x,t) = w₁·P_KOSPS + w₂·P_POSEIDON + w₃·P_OpenDrift +
+
+ 과거 사고 재현 정확도 기반으로 각 모델별 가중치를 동적으로 산정합니다. 기본값: 각 1/3 + 균등 가중. +
+
+
+
최악 시나리오(Worst Case)
+
+ 방제 전략 수립을 위해 3종 모델 중{' '} + 가장 넓은 오염 범위를 예측한 결과를 최악 시나리오로 + 별도 표시합니다. 의사결정자에게 보수적 방제 계획 수립 근거를 제공합니다. +
+
+
+
+ ); +} diff --git a/frontend/src/components/prediction/components/contents/FieldApplicationPanel.tsx b/frontend/src/components/prediction/components/contents/FieldApplicationPanel.tsx new file mode 100644 index 0000000..00925ce --- /dev/null +++ b/frontend/src/components/prediction/components/contents/FieldApplicationPanel.tsx @@ -0,0 +1,131 @@ +export function FieldApplicationPanel() { + const steps = [ + { + num: 1, + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.05)', + bd: 'rgba(6,182,212,.15)', + numBg: 'rgba(6,182,212,.15)', + numBd: 'rgba(6,182,212,.3)', + title: '확산예측 결과 분석 — 위협 구역 및 도달시간 산출', + desc: 'KOSPS·POSEIDON·OpenDrift 앙상블 예측 결과에서 유출유 확산 경계선(Pollution Boundary)과 각 ESI 구역별 도달 예상시간(T_arrive)을 산출합니다. 신뢰도 70% 이상 예측 경계를 기준으로 차단 전략 영역을 설정합니다.', + }, + { + num: 2, + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.05)', + bd: 'rgba(6,182,212,.15)', + numBg: 'rgba(6,182,212,.15)', + numBd: 'rgba(6,182,212,.3)', + title: '해양환경 조건 확인 — 조류·파고·수심·기상 입력', + desc: 'CHARRY 채리모델 조류예측값, KMA UM 풍속·풍향, 수심격자, NGSST 수온을 자동 연계하여 각 후보 배치지점의 U_n(법선방향 유속)을 계산합니다. 임계유속 0.7 knot를 초과하는 지점은 자동으로 J형·다단 배치로 변환합니다.', + }, + { + num: 3, + color: 'var(--color-caution)', + bg: 'rgba(6,182,212,.05)', + bd: 'rgba(6,182,212,.15)', + numBg: 'rgba(6,182,212,.15)', + numBd: 'rgba(6,182,212,.3)', + title: '후보 차단선 격자 탐색 — 배치 가능지점 생성', + desc: '확산 예측 경계를 따라 500m 간격 격자로 후보 배치지점을 생성합니다. 각 지점에서 조류 조건·수심·해안선 이격·방제정 접근 가능성을 동시 검토합니다. ESI 고등급 구역 전방 2km 이내 지점에 우선 가중치를 부여합니다.', + }, + { + num: 4, + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.05)', + bd: 'rgba(6,182,212,.15)', + numBg: 'rgba(6,182,212,.15)', + numBd: 'rgba(6,182,212,.3)', + title: 'NSGA-II 최적화 실행 — 파레토 최적 배치안 산출', + desc: '후보 배치지점·방향각·형태 조합을 염색체로 인코딩하여 NSGA-II 다목적 유전알고리즘을 실행합니다. 수렴 후 파레토 전면에서 3~5개 추천 배치안을 제시하며, 의사결정자가 차단 효율과 자원 사용량의 트레이드오프를 보고 선택합니다.', + }, + { + num: 5, + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.05)', + bd: 'rgba(6,182,212,.15)', + numBg: 'rgba(6,182,212,.15)', + numBd: 'rgba(6,182,212,.3)', + title: '실시간 재최적화 — 조석 변환·조류 변화 대응', + desc: '창조→낙조 전환 시(약 6시간 주기) 조류 방향 역전에 따른 오일펜스 재배치 알람을 발령합니다. 확산 예측이 갱신될 때마다 배치 최적화를 자동 재실행하여 방제대응 체계를 동적으로 업데이트합니다.', + }, + ]; + + return ( + <> + {/* 배치 5단계 절차 */} +
+
🗺️ WING 오일펜스 배치 의사결정 5단계
+
+ {steps.map((step) => ( +
+
+ {step.num} +
+
+
{step.title}
+
{step.desc}
+
+
+ ))} +
+
+ + {/* 해역별 적용 특성 */} +
+
📍 해역별 적용 특성 및 전략
+
+ {[ + { + icon: '🌊', + title: '서해 (조차 대형)', + color: 'var(--color-info)', + bg: 'rgba(59,130,246,.05)', + bd: 'rgba(59,130,246,.12)', + desc: '최대 조차 9m (인천), 조류 최대 3~5 knot. J형 배치 주력. 조석 전환 재배치 필수. 앵커링 수심 급변화 주의.', + }, + { + icon: '🌿', + title: '남해 (다도해)', + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.05)', + bd: 'rgba(6,182,212,.12)', + desc: '복잡한 해안선·섬. 조류 1~2 knot. V형·U형 복합 배치. 좁은 수로 통제 우선. ESI 고등급 갯벌 보호.', + }, + { + icon: '🏔️', + title: '동해 (심해형)', + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.05)', + bd: 'rgba(6,182,212,.12)', + desc: '조차 소(0.3m), 너울·파고 높음. 조류 0.5~1 knot. V형 집중. 고파랑 시 배치 제한. 수온약층 고려.', + }, + ].map((area, i) => ( +
+
+ {area.icon} {area.title} +
+
{area.desc}
+
+ ))} +
+
+ + ); +} diff --git a/frontend/src/components/prediction/components/contents/FluidDynamicsPanel.tsx b/frontend/src/components/prediction/components/contents/FluidDynamicsPanel.tsx new file mode 100644 index 0000000..315995e --- /dev/null +++ b/frontend/src/components/prediction/components/contents/FluidDynamicsPanel.tsx @@ -0,0 +1,142 @@ +export function FluidDynamicsPanel() { + return ( + <> + {/* 유동 수치 모델 */} +
+
🌊 오일펜스 주변 유동 수치 모델
+
+
+
① 오일펜스 항력 모델
+
+ 오일펜스에 작용하는 항력은 조류속도의 제곱에 비례합니다. 오일펜스 구조 + 변형(catenary형태)을 고려한 동적 항력 계산. +
+
+ FD = ½ · ρ · CD · A · Un²
T = FD · L + / (2·sin(α)) +
+ + CD: 항력계수(≈1.2), A: 수중 투영면적 + +
+ T: 연결부 장력, α: 체인각도 +
+
+
+
+ ② 기름 통과(Splash-over) 조건 +
+
+ 조류 유속이 임계값을 초과하면 기름이 파도를 타고 오일펜스를 넘어가는 Splash-over가 + 발생합니다. +
+
+ Fr = Un / √(g·Δρ/ρ·h) +
+ Splash-over: Fr > 0.5~0.6 +
+ + Fr: 수정 Froude수, h: 오일펜스 수중깊이 + +
+ Δρ/ρ: 기름-해수 밀도비 (~0.15) +
+
+
+
+ + {/* Catenary 변형 모델 */} +
+
🔗 오일펜스 현수선(Catenary) 변형 모델
+
+
+ 조류와 바람에 의해 오일펜스는 현수선(Catenary) 형태로 변형됩니다. 실제 차단 길이가 설계 + 길이보다 짧아지며, 최적화 알고리즘에서 변형 후 유효 차단 길이 Leff를 + 계산합니다. +
+ y(x) = a·cosh(x/a) − a
Larc = 2a·sinh(Lspan/(2a)) +
Leff = Lspan · cos(φmax)
+ + a: catenary 파라미터, φ: 최대 편향각 + +
+
+
+
+ 변형 단계별 유효 차단 길이 보정 +
+ {[ + { + cond: 'U < 0.3 knot', + result: 'L_eff ≈ L (직선 유지)', + bg: 'rgba(6,182,212,.05)', + bd: 'rgba(6,182,212,.12)', + }, + { + cond: '0.3~0.7 knot', + result: 'L_eff = 0.8~0.95 L (경미 변형)', + bg: 'rgba(6,182,212,.05)', + bd: 'rgba(6,182,212,.12)', + }, + { + cond: '0.7~1.0 knot', + result: 'L_eff = 0.5~0.8 L (Catenary 현저)', + bg: 'rgba(6,182,212,.05)', + bd: 'rgba(6,182,212,.12)', + }, + { + cond: 'U > 1.0 knot', + result: '기름 통과 위험 · 배치 재계산', + bg: 'rgba(59,130,246,.05)', + bd: 'rgba(59,130,246,.12)', + danger: true, + }, + ].map((item, i) => ( +
+ {item.cond} → {item.result} +
+ ))} +
+
+
+ + {/* 유막 포집 모델 */} +
+
🛢️ 오일펜스 내 유막 포집 동역학
+
+
+
포집 기름 체적 변화율
+
+ dVoil/dt = Qin − Qout − Qloss +
Qin = Uoil·hoil·Leff +
Qout = Qskim (회수기 흡입량) +
Qloss = 증발+소산 손실 +
+
+
+
최적 회수 타이밍
+
+ 포집 기름 체적이 오일펜스 저장 용량의 70~80%에 도달하면 Skimmer 회수 작업을 + 개시합니다. 이를 초과하면 오일 overflow 발생. WING이 실시간 체적 모니터링 후 회수 알람 + 발령. +
+
+
+
+ + ); +} diff --git a/frontend/src/components/prediction/components/contents/KospsPanel.tsx b/frontend/src/components/prediction/components/contents/KospsPanel.tsx new file mode 100644 index 0000000..d432e94 --- /dev/null +++ b/frontend/src/components/prediction/components/contents/KospsPanel.tsx @@ -0,0 +1,1036 @@ +import { useState } from 'react'; +import { card, cardBg, codeBox, labelStyle, tag } from '../OilSpillTheoryView'; + +export function KospsPanel() { + const [papersOpen, setPapersOpen] = useState(false); + const [intlPapersOpen, setIntlPapersOpen] = useState(false); + const [kospsPapersOpen, setKospsPapersOpen] = useState(false); + return ( +
+
+
+
+ 🔷 +
+
+
+ KOSPS (Korea Oil Spill Prediction System) +
+
+ 한국해양연구원(KORDI) 개발 · 한국 해역 특화 유출유 확산 예측 상시 운용 시스템 +
+
+
+
+ 김혜진·이문진·오세웅·강준묵(2011)이 개발한 KOSPS는 유류오염사고 발생 즉시 신속하게 모델을 + 구동할 수 있도록 상시 운용 체계를 구축한 국내 유출유 + 확산 예측 시스템입니다. 정적자료(수심·해안선)와 동적자료(KMA 바람·HYCOM 해류·KORDI 조류)를 + 외부 FTP 연계로 자동 갱신하여 실시간 예측 상시 활용을 + 가능하게 합니다. +
+
+ 참고문헌: 김혜진·이문진·오세웅·강준묵, "유출유 확산 예측 모델의 상시 운용 체계 개발에 + 관한 연구", 해양환경안전학회지 17권 4호, pp.375-382, 2011. +
+
+ + {/* 논문·특허 근거 */} +
+
관련 논문 · 특허 근거
+ + {/* 등록특허 라벨 */} +
+ + 등록특허 + +
+ +
+ {/* 특허 1 */} +
+
+
등록번호
+
10-1567431
+
2015.11.03
+
+
+
+ 해양 유류오염사고 발생시 효율적인 방제방안 수립을 위한 유출유 확산 예측 방법 +
+
+ 발명자 : 이문진 · 김혜진 · 이승현 · 전태병 | 특허권자 : 한국해양과학기술원 +
+
+ {[ + 'ESI 방제정보지도', + 'CHARRY 실시간 조류모델', + '취송류 경험식', + 'Monte Carlo 입자추적', + '풍화 5단계', + ].map((t) => ( + + {t} + + ))} +
+
+ 국가R&D: ① 3차원 유출유 확산예측 기반 방제 지원기술 개발 (기여율 65%) ② HNS 유출 + 거동예측 및 대응정보 지원기술 개발 (기여율 35%) | 해양수산부 +
+
+
+
+
+ + {/* CHARRY 모델 */} +
+
+ CHARRY 모델 (실시간 조류예측 핵심 알고리즘) +
+
+ CHARRY는 조화분석에 의한 검조소 실시간 조위와 조석 수치모델링에 의한 조류 + 공간분포를 변조조석(Modulated Tide)을 매개로 결합하여, 실제 시간대의 전 해역 조류를 + 실시간으로 재현하는 핵심 알고리즘입니다. +
+
+
+ /* 변조조석 수식 */ +
+ ζ(t) = A(t) cos[σt − θ(t)] +
+ A²(t) = Σ Yᵢ² + 2Σ YᵢYⱼ cos[(σᵢ−σⱼ)t−(φᵢ−φⱼ)] +
+
+
+ ① 진폭 등비 증가 +
+ 검조소 조위 진폭 f배 → 전 격자 동일 f배 증가 +
+
+ ② 위상차 일정 +
+ 기준점-격자점 간 위상차는 진폭에 무관하게 일정 +
+
+
+
+ + {/* 입력자료 체계 */} +
+
+
동적 입력자료 체계
+
+ {[ + { + icon: '🌬️', + label: '바람·기온', + detail: 'KMA UM · ~12km · 2회/일', + bg: 'rgba(6,182,212,.04)', + bd: 'rgba(6,182,212,.12)', + }, + { + icon: '🌊', + label: '해류(표층)', + detail: 'HYCOM · ~9km · 1회/일', + bg: 'rgba(59,130,246,.04)', + bd: 'rgba(59,130,246,.12)', + }, + { + icon: '🌀', + label: '조류', + detail: 'KORDI 조화분석 · 500m', + bg: 'rgba(6,182,212,.04)', + bd: 'rgba(6,182,212,.12)', + }, + { + icon: '🌡️', + label: '표층수온(SST)', + detail: 'NOAA AVHRR · ~5.4km', + bg: 'rgba(6,182,212,.04)', + bd: 'rgba(6,182,212,.12)', + }, + { + icon: '💨', + label: '취송류(풍성류)', + detail: 'KMA 바람 → 경험식 계산', + bg: 'rgba(6,182,212,.04)', + bd: 'rgba(6,182,212,.12)', + }, + ].map((d) => ( +
+ + {d.icon} {d.label} + + {d.detail} +
+ ))} +
+
+
+
정적 입력자료
+
+
+
📍 수심·해안선
+
전자해도(ENC) → 500m 격자 보간
+
+
+
🗺️ 격자 구성
+
좌표변환 → 영역추출 → 격자보간 표준화
+
+
+
+
+ + {/* 취송류 경험식 */} +
+
취송류(Wind-Driven Current) 경험식
+
+
+
+ /* 취송류 유속 (이·강, 2000) */ +
+ V_WDC = 0.029 × V_wind +
+
+ /* 취송류 유향 */ +
+ θ_WDC = θ_wind + 18.6° +
+
+
+
+ V_WDC : 표면 취송류 유속 (m/s) — + 바람의 약 2.9% +
+
+ 18.6° : Ekman 편향각 — 북반구 + 기준 풍향 우편향 +
+
+ 출처 : 이문진·강용균(2000), 해양 표면취송류 + 라그랑지안 측류 및 모델링 +
+
+
+
+ {/* 상시 운용 통신 체계 */} +
+
상시 운용 통신 체계
+
+ {[ + { label: 'NFRDI', sub: 'SST', color: 'var(--color-accent)' }, + { label: 'KMA', sub: 'Wind·기온', color: 'var(--color-accent)' }, + { label: 'HYCOM', sub: '해류', color: 'var(--color-accent)' }, + { + label: 'DB 서버', + sub: '동적+정적자료', + color: 'var(--color-accent)', + strong: true, + }, + { label: 'KOSPS', sub: '확산예측', color: 'var(--color-accent)', strong: true }, + { label: '운용자', sub: '결과 수령', color: 'var(--color-accent)' }, + ].map((node, i) => ( +
+
+
+ {node.label} +
+
{node.sub}
+
+ {i < 5 && ( +
+ )} +
+ ))} +
+
+ FTP 자동 갱신 → DB 정규화 → 격자 재구성 → 모델 구동 → 결과 표출 +
+
+
⚠️ 예측 신뢰도 한계
+
+ 조류: 무한 예보 가능 +
+ 해류(HYCOM): 5일치 제한 +
+ 기온·취송류: 3일치 제한 +
72h 초과 예측 시 신뢰도 저하 +
+
+
+ + {/* 이문진 박사 특허 핵심 기술 */} +
+ {/* 헤더 */} +
+
+ 📜 +
+
+
+ 이문진 박사 특허 기반 핵심 기술 (등록특허 10-1567431) +
+
+ 해양 유류오염사고 발생시 효율적인 방제방안 수립을 위한 유출유 확산 예측 방법 · + 한국해양과학기술원 · 2015년 등록 +
+
+
+ + {/* 특허 개요 */} +
+ 본 특허는{' '} + + 방제정보지도(ESI, Environmental Sensitivity Index Map) + + 를 기반으로 실시간 조류·취송류·해수유동을 산출하고, 이를 유출유 확산 예측에 직결하는 통합 + 방법론을 특허화한 것입니다. 기상예측시스템·위성영상수신시스템·검조소를 인터넷으로 연결하여 + 실시간 기상·수온·조석 정보를 자동 수신하고,{' '} + CHARRY 모델로 실시간 조류를 예측하여 유출유 확산에 + 적용합니다. +
+ + {/* 3단계 처리 프로세스 */} +
⚙️ 특허 청구항 3단계 처리 프로세스
+
+
+
+ ① 실시간 자료 수신 (S10) +
+
+ • 기상자료 : 바람·기온·기압 분포 +
수온자료 : NGSST 위성 원격탐사 (일본 토호쿠대학, 5km 해상도) +
조석정보 : 검조소 실시간 조위 +
+
+ FTP 자동 접속·수신·좌표변환·DB구축까지 자동화 +
+
+
+
+ ② 조류·취송류 예측 (S20) +
+
+ • CHARRY 모델로 실시간 조류 예측 +
+ • 2D 해수유동 방정식 기반 조화상수 DB 구축 +
+ • 취송류 경험식 : V_WDC=0.029×V_wind, θ_WDC=θ_wind+18.6° +
• M2·S2·K1·O1 주요 4대 분조 조화분해 +
+
+ 계산격자 : 15초(약 463m) 등간격 · 총 3,225,600격자 +
+
+
+
+ ③ 유출유 확산 예측 (S30) +
+
+ • 클라이언트로부터 유출유량·유출지점 입력 수신 +
+ • 몬테카를로(Monte Carlo) 입자추적 기법 +
+ • fBm 기반 난류확산 : σ²=Atᵐ (m≈2H) +
• 풍화모델 : 퍼짐·증발·소산·유상화·침강 통합 +
+
+ ESI Map 환경민감자원 DB와 결합하여 피해 위험도 평가 +
+
+
+ + {/* 해수유동 방정식 & 풍화 5단계 */} +
+
+
+ 📐 2차원 해수유동 방정식 +
+
+ 극좌표계(Polar Coordinate) 적용 · 지구 구면효과 반영 +
+
+ {[ + { label: '기조력', desc: '제1(Love Number 0.69) + 제2(α=0.9) 포함' }, + { label: '4대 분조', desc: 'M2(1.405×10⁻⁴/s) · S2 · K1 · O1' }, + { label: '수치기법', desc: '양해법 + 경사차분법 + 양방향 순차계산법' }, + { label: '조간대', desc: 'Flather & Heaps(1975) 처리기법' }, + ].map((r) => ( +
+ {r.label} : {r.desc} +
+ ))} +
+
+
+
+ 🔁 유출유 풍화(Weathering) 5단계 +
+
+
+ ① 퍼짐 : Fay(1969) + Mackay et + al.(1980) · 두께별 3단계 모델 +
+
+ ② 증발 : Stiver & + Mackay(1984) · 증류데이터 기반 해석 (1~10일, 25%) +
+
+ ③ 소산 : 쇄파에너지·풍속 함수 · + 수면하 수직 분산 (15%) +
+
+ ④ 유상화 : Mackay et al.(1980) + + Mooney(1951) · water-in-oil +
+
+ ⑤ 침강 : 용해·생물분해 포함 · + 초기유출량 선형 감소율 적용 +
+
+
+
+ + {/* Akima 수심 보간 & NGSST 수온 */} +
+
+
+ 🗺️ Akima 수심 보간 기법 +
+
+ 전자해도(ENC) 무작위 수심자료를 계산격자로 보간할 때{' '} + Akima(1978) 2차원 5차다항식(Bivariate Quintic + Polynomial)을 사용합니다. 삼각망(TIN)을 구성하여 각 꼭지점의 편미분 연속성 조건으로 + 21개 계수를 결정합니다. +
+
+ z(x,y) = Σ Σ qᵢⱼ xⁱ yʲ{' '} + (i≤5, i+j≤5) +
+
+
+
+ 🌡️ NGSST 실시간 수온자료 +
+
+ 일본 토호쿠대학 Kawamura 교수팀의{' '} + New Generation SST(NGSST)를 FTP 자동수신합니다. + 열적외선(AVHRR·MODIS) + 마이크로파(AMSR-E) 융합으로 구름 무관 일별 수온 제공. +
+
+
+ 영역 : 116~166°E, 13~63°N + (북서태평양) +
+
+ 해상도 : 3분(약 5km) · 일 1회 + 갱신 +
+
+ 변환식 : SST(℃) = 0.15 × DN − + 3.0 +
+
+
+
+ + {/* 특허 기여율 */} +
+
+ 📋 특허 지원 국가연구개발사업 및 기여율 +
+
+
+
🔷 과제① (기여율 65%)
+
+ 3차원 유출유 확산예측 기반 해양유류오염 방제 지원기술 개발 +
+ 해양수산부 | 2013.01~2013.12 +
+
+
+
🔶 과제② (기여율 35%)
+
+ 주요 위험유해물질(HNS) 유출 거동예측 및 대응정보 지원기술 개발 +
+ 해양수산부 | 2013.01~2013.12 +
+
+
+
+ 특허권자: 한국해양과학기술원 | 발명자: 이문진·김혜진·이승현·전태병 | 등록번호: + 10-1567431 | 공고일: 2015.11.12 +
+
+
+ + {/* KOSPS관련 유출유 확산예측 논문 */} +
+
setKospsPapersOpen(!kospsPapersOpen)} + > +
+ 📄 +
+
+
+ KOSPS관련 유출유 확산예측 논문{' '} + + ▼ + +
+
+ KOSPS 개발 · 허베이스피리트 검증 · 3D 확산예측 시스템 · 방제효과 모델링 — + 한국해양환경·에너지학회 +
+
+
+ {kospsPapersOpen && ( +
+ {[ + { + tags: [ + { label: '유출유 확산', color: '#06b6d4' }, + { label: '허베이스피리트 검증', color: 'var(--color-info)' }, + { label: 'Radarsat·GNOME', color: 'var(--color-accent)' }, + ], + year: '2010', + title: '허베이스피리트호 유출유 확산예측 검증 분석', + authors: + '이문진, 김선동, 김혜진, 오세웅 (한국해양연구원 해양시스템안전연구소) | 한국해양과학기술협의회 공동학술대회 | 2010.6 | pp.3154', + desc: '허베이 스피리트호 유출사고 당시 적용된 유출유 확산예측시스템의 결과를 2가지 검증방법으로 분석. (1) 사고당시 촬영된 Radarsat 인공위성영상에 나타난 유출유 분포와 확산예측시스템의 결과를 비교 분석, (2) 미국 NOAA의 유출유 확산예측시스템인 GNOME의 결과와 동일 입력조건 하에서 비교 검증. NAP 해양유출사고 대응지원시스템 구축 연구의 일부.', + }, + { + tags: [ + { label: '3D 확산시스템', color: '#06b6d4' }, + { label: '3차원 모델', color: 'var(--color-accent)' }, + { label: 'Monte Carlo', color: 'var(--color-accent)' }, + ], + year: '2013', + title: '3차원 유출유 확산예측 시스템 연구', + authors: + '이문진, 김혜진, 강관근 (한국해양과학기술원) | 한국해양과학기술협의회 공동학술대회 | 2013.5 | pp.17–18', + desc: '해수순환에 따른 유출유의 해수중 거동을 실제와 부합하게 재현하기 위해 3차원 유출유 확산예측모델 개발. Monte Carlo Simulation 방법을 적용한 수치 추적자(numerical tracer) 방법에 근거하며, 해수유동에 의한 이송(transport)·난류에 의한 확산(turbulent diffusion)·유출유의 침강 및 표면혼합층의 혼탁효과·풍화작용(weathering effect)을 통합 모의. 허베이 스피리트호 사고에 적용(2007.12.7 07:00~2008.1.6 07:00, 약 720시간) — 바람 및 해수유동 재현 결과에 근거하여 3차원 유출유의 수직 이동을 포함한 분포 결과 및 수층 이동 재현. 한국해양과학기술원 지원 〈3차원 유출유 확산예측 기반 해양유류오염 방제지원 기술 개발〉 연구의 일부.', + }, + { + tags: [ + { label: '모델 적용', color: '#06b6d4' }, + { label: 'GS칼텍스·Captain Vangelis', color: 'var(--color-caution)' }, + ], + year: '2014', + title: '유출유 확산예측 모델의 해양사고 적용 및 개선방안 연구', + authors: + '이문진, 김혜진 (선박해양플랜트연구소/KRISO) | 한국해양과학기술협의회 공동학술대회 | 2014.5 | pp.2353', + desc: '유출유 확산예측 고도화의 일환으로 실제 해양사고에 유출유 확산예측 모델을 적용하고 개선방안 연구. 최근 발생한 GS 칼텍스 송유관 유출사고와 Captain Vangelis 유출사고에 적용하여, 사고로 발생된 유출유의 이동확산을 예측하고 현장 보고자료와 비교하여 유출유 확산 예측 모델의 정확도 및 문제점을 분석. 비교 분석을 토대로 기존 모델의 개선방안을 도출하고, 현실과 부합하는 현장상황 재현을 위한 신규 모델링 기술의 개발 방안 연구. 선박해양플랜트연구소 지원 〈3차원 유출유 확산예측 기반 해양유류오염 방제 지원기술 개발〉 연구의 일부.', + }, + { + tags: [ + { label: '방제효과', color: '#06b6d4' }, + { label: '오일펜스·유회수기·유처리제', color: 'var(--color-accent)' }, + ], + year: '2014', + title: '유출유 확산예측 모델의 방제효과 모델링 기술 연구', + authors: + '이문진, 오상우, 정정열 (선박해양플랜트연구소/KRISO) | 한국해양과학기술협의회 공동학술대회 | 2014.5 | pp.2354', + desc: '유출유 확산예측 모델의 정확도 제고를 위하여 유출유 방제효과와 모델링 기술 연구. 유출유 방제효과는 오일펜스의 차단 포집 효과, 유처리제 분산효과, 유회수기 회수효과 등을 고려하여 각각의 효과를 개별 모델로 개발하고 유출유 확산예측 모델과 통합. 오일펜스의 차단 포집효과 모델링에서는 오일펜스 자체의 누유율을 적용하여 유출유의 포집량 및 누유량을 시뮬레이션하며, 유처리제 분산효과 모델링에서는 유처리제 및 유출유 특성을 고려하여 분산에 따른 확산면적 변화를 시뮬레이션. 유회수기 회수 효과 모델링에서는 유회수기의 특성에 따라 회수율을 적용하여 유출유 해상 분포량의 변화를 시뮬레이션. 선박해양플랜트연구소 지원 〈3차원 유출유 확산예측 기반 해양유류오염 방제 지원기술 개발〉 연구의 일부.', + }, + ].map((paper) => ( +
+
+
+ {paper.tags.map((t) => ( + + {t.label} + + ))} +
+ {paper.year} +
+
{paper.title}
+
{paper.authors}
+
{paper.desc}
+
+ ))} +
+ )} +
+ + {/* 국내 학술논문 */} +
+
setPapersOpen(!papersOpen)} + > + + 📄 국내 학술논문 + + + ▼ + +
+ {papersOpen && ( +
+ {[ + { + num: '①', + title: '유출유 확산 예측 모델의 상시 운용 체계 개발에 관한 연구', + authors: '김혜진 · 이문진 · 오세웅 · 강준묵', + journal: '해양환경안전학회지', + detail: '제17권 4호, pp.375-382 | 2011', + desc: 'KOSPS 상시 운용 체계 구축 · 정적/동적 자료 FTP 자동 연계 · 서해·남해 시범 운용 성능 평가', + color: 'var(--color-accent)', + }, + { + num: '②', + title: '해양오염 방제지원시스템 구축을 위한 실시간 조류 예측 기술 개발', + authors: '이문진 · 강용균 외', + journal: '해양환경안전학회 춘계학술발표회', + detail: '| 2008', + desc: 'CHARRY 모델 인천·서해 적용 검증 · 창조/낙조 실시간 조류 재현 결과', + color: 'var(--color-accent)', + }, + { + num: '③', + title: '표면 취송류 라그랑지안 측류 및 수치모델링', + authors: '이문진 · 강용균', + journal: '한국해양학회지', + detail: '| 2000', + desc: '취송류 경험식 V=0.029·Vw, θ=θw+18.6° 도출 · 국내 연안 현장 관측 기반', + color: 'var(--color-accent)', + }, + { + num: '④', + title: '허베이스피리트호 유출유 확산예측 검증 분석', + authors: '이문진 · 김선동 · 김혜진 · 오세웅', + journal: '한국해양과학기술협의회 공동학술대회', + detail: 'pp.3154 | 2010', + desc: '허베이 스피리트호 유출사고 당시 확산예측시스템 결과 검증 · Radarsat 인공위성영상 비교 분석 · NOAA GNOME과 동일 입력조건 하 비교 검증', + color: 'var(--color-info)', + tags: ['허베이스피리트', 'Radarsat 위성영상', 'GNOME 비교검증'], + }, + { + num: '⑤', + title: '3차원 유출유 확산예측 시스템 연구', + authors: '이문진 · 김혜진 · 강관근', + journal: '한국해양과학기술협의회 공동학술대회', + detail: 'pp.17-18 | 2013', + desc: 'Monte Carlo Simulation + 수치 추적자 방법 기반 3차원 유출유 확산예측모델 개발 · 이송·난류확산·침강·혼탁효과·풍화작용 통합 모의 · 허베이 스피리트호 720시간 적용 검증', + color: 'var(--color-accent)', + tags: ['3D 확산모델', 'Monte Carlo', '수치추적자', '720시간 검증'], + }, + { + num: '⑥', + title: '유출유 확산예측 모델의 해양사고 적용 및 개선방안 연구', + authors: '이문진 · 김혜진', + journal: '한국해양과학기술협의회 공동학술대회', + detail: 'pp.2353 | 2014', + desc: 'GS 칼텍스 송유관 유출사고 · Captain Vangelis 유출사고에 모델 적용 · 현장 보고자료와 비교 분석 · 기존 모델 개선방안 도출 · 신규 모델링 기술 개발 방안', + color: 'var(--fg-default)', + tags: ['GS칼텍스', 'Captain Vangelis', '현장검증', '모델개선'], + }, + { + num: '⑦', + title: '유출유 확산예측 모델의 방제효과 모델링 기술 연구', + authors: '이문진 · 오상우 · 정정열', + journal: '한국해양과학기술협의회 공동학술대회', + detail: 'pp.2354 | 2014', + desc: '오일펜스 차단 포집 효과(누유율) · 유처리제 분산효과(확산면적 변화) · 유회수기 회수효과(해상 분포량 변화) 개별 모델 개발 및 확산예측 모델과 통합', + color: 'var(--color-accent)', + tags: ['오일펜스', '유처리제', '유회수기', '방제효과 통합'], + }, + { + num: '⑧', + title: '해양유류오염사고 위해도 평가에 관한 연구', + authors: '이문진 · 김혜진', + journal: '한국해양공학회지', + detail: '제23권 1호, pp.24-30 | 2009', + desc: '라그랑쥐 입자추적 + fBm 난류확산 모델 · CHARRY 실시간 조류예측 · 취송류 교적계수 반응함수 · 20년 과거자료 기반 100회 몬테카를로 통계 위험평가 · 가로림만 해안/어장 도달확률·도달시간 산정', + color: 'var(--color-accent)', + tags: ['Monte Carlo 100회', 'fBm 난류확산', '통계적 위험평가', '가로림만'], + }, + { + num: '⑨', + title: '해양정보기반 방제지원시스템 프로토타입 구축에 관한 연구', + authors: '김혜진 · 이문진', + journal: '한국지리정보학회지', + detail: '제11권 4호, pp.182-192 | 2008', + desc: 'KOSPS 원형 시스템 · CHARRY 모델 기반 실시간 조류예측 + fBm 난류확산 통합 · GIS 기반 ESI 방제정보지도 통합 · 통계적 피해위험도(20년 200회) + 실시간 위험도 이원 체계', + color: 'var(--color-accent)', + tags: ['KOSPS 원형', 'GIS-ESI 통합', '의사결정지원', '인천-대산'], + }, + { + num: '⑩', + title: '유출유의 초기 확산예측을 위한 고해상도 결합모형 개발', + authors: '손상영 · 이칠우 · 윤현덕 · 정태화', + journal: '한국해안·해양공학회논문집', + detail: '제29권 4호, pp.189-197 | 2017', + desc: 'Boussinesq 동수역학 모형 + MEDSLIK-II 유류 이송-확산-변형 모형 외적 결합 · Lagrangian 유막 표현 · 울산 진하해역 시뮬레이션 검증', + color: 'var(--color-accent)', + tags: ['Boussinesq', 'MEDSLIK-II', '고해상도 결합', '울산 진하'], + }, + ].map((paper) => ( +
+
+ {paper.num} +
+
+
{paper.title}
+
+ {paper.authors} | {paper.journal}{' '} + {paper.detail} +
+
{paper.desc}
+ {paper.tags && ( +
+ {paper.tags.map((t) => ( + + {t} + + ))} +
+ )} +
+
+ ))} +
+ )} +
+ + {/* 국외 핵심 논문 */} +
+
setIntlPapersOpen(!intlPapersOpen)} + > + + 🌐 국외 핵심 논문 + + + ▼ + +
+ {intlPapersOpen && ( +
+ {[ + { + num: '①', + title: 'OpenDrift v1.0: a generic framework for trajectory modelling', + authors: 'Dagestad et al.', + journal: 'Geoscientific Model Development', + detail: 'Vol.11, pp.1405-1420 | 2018', + desc: 'OpenDrift 프레임워크 전체 설계·구현·검증 · Python 모듈화 구조 · OpenOil 유출유 모듈 포함', + color: 'var(--color-accent)', + }, + { + num: '②', + title: + 'Observation-based evaluation of surface wave effects on oil spill trajectories', + authors: 'Röhrs et al.', + journal: 'J. Geophys. Res. Oceans', + detail: '| 2013', + desc: 'Stokes drift 파랑 기여 효과 · OpenOil 유출유 확산 현장 검증', + color: 'var(--color-accent)', + }, + { + num: '③', + title: 'Numerical modelling and fate assessment of oil spills at sea', + authors: 'Nordam et al.', + journal: 'Marine Pollution Bulletin', + detail: '| 2019', + desc: '3D 유출유 수직 분포 모의 · 해저면 도달 경로 분석 · 풍화 통합 수치 검증', + color: 'var(--color-accent)', + }, + { + num: '④', + title: 'The spreading of oil in the sea', + authors: 'Fay, J.A.', + journal: 'Oil on the Sea, Plenum Press', + detail: '| 1969', + desc: '유출유 퍼짐 3단계 이론(중력-관성력, 관성력-점성력, 표면장력-점성력) 원전. KOSPS 풍화모델 기반', + color: 'var(--color-accent)', + }, + { + num: '⑤', + title: 'Evaporation and dissolution of oil spills', + authors: 'Stiver, W. & Mackay, D.', + journal: 'Environmental Science & Technology', + detail: '18(11) | 1984', + desc: '증발 해석 모델 · 증류 데이터 기반 증발비 산정 · KOSPS 증발 서브모듈 직접 적용', + color: 'var(--color-accent)', + }, + { + num: '⑥', + title: 'A physical-chemical model of the dispersal of oil in water', + authors: 'Mackay, D. et al.', + journal: 'Environmental Science & Technology', + detail: '| 1980', + desc: '유상화(water-in-oil) 모델 · 소산·유상화 통합 수리 공식. KOSPS·OpenOil 양쪽 공통 적용', + color: 'var(--color-accent)', + }, + { + num: '⑦', + title: + 'Bivariate interpolation and smooth surface fitting based on local procedures', + authors: 'Akima, H.', + journal: 'Commun. ACM 17(1) | 1974 / ACM TOMS | 1978', + detail: '', + desc: '2차원 5차다항식 보간법(Bivariate Quintic). KOSPS 수심·수온 격자 보간 알고리즘 직접 적용', + color: 'var(--color-accent)', + }, + ].map((paper) => ( +
+
+ {paper.num} +
+
+
{paper.title}
+
+ {paper.authors} | {paper.journal}{' '} + {paper.detail} +
+
{paper.desc}
+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/prediction/components/contents/LagrangianPanel.tsx b/frontend/src/components/prediction/components/contents/LagrangianPanel.tsx new file mode 100644 index 0000000..e4bf5e1 --- /dev/null +++ b/frontend/src/components/prediction/components/contents/LagrangianPanel.tsx @@ -0,0 +1,143 @@ +import { card, cardBg, codeBox, labelStyle } from '../OilSpillTheoryView'; + +export function LagrangianPanel() { + return ( +
+
+
+ 🧭 라그랑지안 입자추적법(Lagrangian Particle Tracking) +
+
+ 유출유를 수많은 가상 입자(Virtual Particle)로 + 표현하여, 각 입자의 이동 경로를 시간 단계마다 추적하는 방법입니다. + KOSPS·POSEIDON·OpenDrift 3종 모두 이 방식을 채택합니다. +
+
+ +
+
+
입자 이동 지배방정식
+
+ dx/dt = U_c + α·U_w + U_stokes + U' +
+ dy/dt = V_c + α·V_w + V_stokes + V' +
+
+
+ + U_c, V_c + + 표층 해류·조류 속도 (m/s) +
+
+ + α·U_w + + 풍류 기여 (α ≈ 0.03) +
+
+ + U_stokes + + 스토크스 표류 (파랑 영향) +
+
+ + U' + + 난류 확산 (랜덤워크) +
+
+
+
+
난류 확산 (랜덤워크)
+
+ U' = R · √(2K_h / Δt) +
+ V' = R · √(2K_h / Δt) +
+
+
+ + R + + [-1, 1] 균등분포 난수 +
+
+ + K_h + + 수평 확산 계수 (m²/s) +
+
+ Δt + 시간 스텝 (일반 1시간) +
+
+
+
+ + {/* Fay(1971) 중력-점성 체제 */} +
+
+ 🛢️ 표면 유막 확산 — Fay(1971) 중력-점성 체제 +
+
+
+
+ /* 중력-관성 체제 (초기) */ +
+ R(t) = K₁ · ( + ΔρgV² /{' '} + ρw)¼ · t½ +
+
+ /* 중력-점성 체제 (후기) */ +
+ R(t) = K₂ · ( + ΔρgV² /{' '} + νw) · t¾ +
+
+
+
+ Δρ : 유류-해수 밀도차 (kg/m³) +
+
+ g : 중력가속도 9.81 m/s² +
+
+ V : 유출 체적 + (m³) +
+
+ νw : 해수 + 동점성계수 (m²/s) +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/prediction/components/contents/OceanInputPanel.tsx b/frontend/src/components/prediction/components/contents/OceanInputPanel.tsx new file mode 100644 index 0000000..025e371 --- /dev/null +++ b/frontend/src/components/prediction/components/contents/OceanInputPanel.tsx @@ -0,0 +1,64 @@ +import { card, cardBg, labelStyle } from '../OilSpillTheoryView'; + +export function OceanInputPanel() { + return ( +
+
+
해양환경 입력 데이터 체계
+
+ 유출유 확산 예측의 정확도는{' '} + 입력 해양·기상 데이터의 품질에 크게 좌우됩니다. WING + 시스템은 국내외 수치예보 모델과 실시간 관측 데이터를 연동하여 모델 정확도를 향상시킵니다. +
+
+
+
+
기상 입력
+
+ {[ + { label: 'KMA RDAPS', desc: '동아시아 12km 해상도 · 24h 예보' }, + { label: 'ECMWF ERA5', desc: '전지구 0.25° 재분석 데이터' }, + { label: 'AWS 자동기상관측', desc: '실시간 10분 단위 풍향·풍속' }, + ].map((t) => ( +
+
{t.label}
+
{t.desc}
+
+ ))} +
+
+
+
해양 입력
+
+ {[ + { label: 'NIFS ROMS', desc: '한국 근해 1km 해류·수온 예보' }, + { label: 'HYCOM', desc: '전지구 1/12° 해양 순환 모델' }, + { label: 'TPXO9 조화분석', desc: '15개 분조 기반 조류 예측' }, + ].map((t) => ( +
+
{t.label}
+
{t.desc}
+
+ ))} +
+
+
+
+ ); +} diff --git a/frontend/src/components/prediction/components/contents/OpenDriftPanel.tsx b/frontend/src/components/prediction/components/contents/OpenDriftPanel.tsx new file mode 100644 index 0000000..4a4787e --- /dev/null +++ b/frontend/src/components/prediction/components/contents/OpenDriftPanel.tsx @@ -0,0 +1,595 @@ +import { sanitizeHtml } from '@common/utils/sanitize'; +import { card, cardBg, labelStyle } from '../OilSpillTheoryView'; + +export function OpenDriftPanel() { + return ( +
+
+
+
+ 🟢 +
+
+
+ OpenDrift (오픈소스 라그랑지안 확산 프레임워크) +
+
+ 노르웨이 MET Norway · OpenOil 공개 프레임워크 · Python 기반 · IMO/IPIECA 검증 +
+
+
+
+ Dagestad et al.(2018)이 개발한 OpenDrift는 유출유·SAR·표류물·부유입자 등 다양한 해양 확산 + 현상을 모의하는{' '} + Python 기반 오픈소스 라그랑지안 프레임워크입니다. + NEMO·ROMS·HYCOM 등 다양한 해양모델 출력값을 강제력으로 사용하며,{' '} + OpenOil 모듈을 통해 유출유 거동을 상세 모의합니다. +
+
+ + 🔗 공식 문서 (opendrift.github.io) + + + Dagestad et al., Geosci. Model Dev. 11, 2018 + +
+
+ +
+ {[ + { + title: '🧩 모듈 구성', + color: 'var(--color-accent)', + items: [ + { html: 'OpenOil : 유출유 확산·풍화' }, + { html: 'OceanDrift : 표류물 추적' }, + { html: 'ShipDrift : 선박 표류' }, + { html: 'SedimentDrift : 부유퇴적물' }, + ], + }, + { + title: '🌊 지원 해양모델', + color: 'var(--color-accent)', + items: [ + { text: 'NEMO (전지구·지역)' }, + { text: 'ROMS (지역 고해상도)' }, + { text: 'HYCOM (전지구)' }, + { text: 'Copernicus CMEMS' }, + ], + }, + { + title: '✅ 검증·인증', + color: 'var(--color-info)', + items: [ + { text: 'IMO / IPIECA 기준 충족' }, + { text: 'ITOPF 뜰개 검증 완료' }, + { text: 'NOAA Oil Library 탑재' }, + { text: 'Geosci. Model Dev. 게재' }, + ], + }, + ].map((s) => ( +
+
{s.title}
+
+ {s.items.map((item, idx) => + 'html' in item ? ( +
+ ) : ( +
+ {item.text} +
+ ), + )} +
+
+ ))} +
+ + {/* OpenOil 풍화 프로세스 */} +
+
+ 🔁 OpenOil 풍화 프로세스 (IKI 프레임워크 기반) +
+
+ {[ + { + icon: '☁️', + title: '증발', + color: 'var(--fg-default)', + desc: '성분별 증기압·분자량 기반 다성분 증발', + }, + { + icon: '🌊', + title: '유화', + color: 'var(--color-accent)', + desc: 'Mackay 유화 방정식·함수율 추적', + }, + { + icon: '💧', + title: '수중 분산', + color: 'var(--color-info)', + desc: '파랑 에너지 기반 수직 혼합·재부상', + }, + { + icon: '🦠', + title: '생분해', + color: 'var(--color-accent)', + desc: '수온 의존 미생물 분해율 모의', + }, + ].map((w) => ( +
+
{w.icon}
+
+ {w.title} +
+
{w.desc}
+
+ ))} +
+
+ {/* 상시 운용 통신 체계 */} +
+
상시 운용 통신 체계
+
+ {[ + { label: 'NEMO/ROMS', sub: '해류 강제력', color: 'var(--color-accent)' }, + { label: 'HYCOM', sub: '전지구 해류', color: 'var(--color-info)' }, + { label: 'ECMWF/GFS', sub: 'Wind·파랑', color: 'var(--color-info)' }, + { label: 'NOAA Oil Lib', sub: '유종 물성 DB', color: 'var(--color-info)' }, + { + label: 'OpenDrift', + sub: 'Python 모듈 구동', + color: 'var(--color-info)', + strong: true, + }, + { label: 'OpenOil', sub: '유출유 풍화·확산', color: 'var(--color-info)', strong: true }, + { label: '결과 표출', sub: 'NetCDF·시각화', color: 'var(--color-accent)' }, + ].map((node, i) => ( +
+
+
+ {node.label} +
+
{node.sub}
+
+ {i < 6 && ( +
+ )} +
+ ))} +
+
+ 해양모델(NEMO·ROMS·HYCOM) + 기상자료(ECMWF·GFS) → NOAA Oil Library 유종 매칭 → + OpenDrift/OpenOil 모듈 구동 → NetCDF 결과 출력·시각화 +
+
+ + {/* 관련 논문 카드 섹션 */} +
+
+
+ 📄 +
+
+
+ OpenDrift / OpenOil 국내 해역 적용 연구 논문 +
+
+ 한국 연안 유출유 확산 수치모의 관련 핵심 논문 3편 — WING 모델 이론 근거 +
+
+
+
+ {/* 논문 1: Dang et al. 2024 */} +
+
+
+ {[ + { label: '국제저널', color: '#3b82f6' }, + { label: 'OpenOil 적용', color: 'var(--color-info)' }, + { label: '허베이 스피리트', color: 'var(--color-info)' }, + ].map((t) => ( + + {t.label} + + ))} +
+ 2024 +
+
+ Numerical Model Test of Spilled Oil Transport Near the Korean Coasts Using Various + Input Parametric Models +
+
+ Hai Van Dang, Suchan Joo, Junhyeok Lim, Jinhwan Hur, Sungwon Shin | Hanyang University + ERICA | Journal of Ocean Engineering and Technology, 2024 +
+
+
+ 연구 목적 +
+ OpenOil 모델에 다양한 해양-기상 강제력(Met-Ocean) 조합을 적용하여 한국 연안 유출유 + 이동 예측 정확도를 비교 평가. 허베이 스피리트(2007) 사고를 대상으로 6개 조합 검증. +
+
+ 주요 결과 +
+ CMEMS 해류모델 + ECMWF 기상 조합이 최고 성능. 동황해 조석 특성(수심 <60m 천해) + 고려 시 정확도 향상. Envisat ASAR 위성 실측과 수치 비교 검증 완료. +
+
+
+
+ 적용 강제력 모델 6종 조합 +
+
+
+ 해류 HYCOM / CMEMS +
+
+ 파랑 CMEMS / ECMWF-ERA5 +
+
+ 기상 NCEP-GFS / ECMWF +
+
+
+
+ WING 적용 의의 : OpenOil이 한국 해역에서 실효적으로 + 작동함을 검증한 국내 최신 연구. 6종 Met-Ocean 조합 비교는 WING의 OpenDrift 강제력 선택 + 기준(CMEMS+ECMWF)의 직접적 근거. +
+
+ + {/* 논문 2: 류청로 외 1998 */} +
+
+
+ {[ + { label: '국내저널', color: '#06b6d4' }, + { label: '라그랑지안 실증', color: 'var(--color-accent)' }, + { label: '동남해역 검증', color: 'var(--color-caution)' }, + ].map((t) => ( + + {t.label} + + ))} +
+ 1998 +
+
+ 한국 동남해역에서의 유출유 확산예측모델 (Oil Spill Behavior Forecasting Model in + South-eastern Coastal Area of Korea) +
+
+ 류청로, 김종규, 설동관, 강동욱 | 부경대학교 해양공학과 | 한국해양환경공학회지 Vol.1 + No.2, pp.52–59, 1998 +
+
+
+ 연구 목적 +
+ 한국 동남해역(부산-울산) 긴급방제용 실시간 유출유 확산예측모델 OILSPILLFM 개발. + 알렉산드리아호(1995) 실제 사고 힌드캐스팅으로 모델 검증. +
+
+ 핵심 수식 +
+ 라그랑지안 입자추적 + 2D 해수유동 모델(ADI법). 취송류 Vw = 0.03×V10. 난류확산 Random + Walk. 증발 1차 반응식. +
+
+
+
+ 해수유동 Navier-Stokes 2D 연속·운동방정식 +
+
+ 조류 조화분석 실시간 조류 DB 구축 +
+
+ 풍화 퍼짐·증발·유상화·생분해 5단계 +
+
+
+ WING 적용 의의 : OpenDrift·KOSPS의 라그랑지안 + 입자추적 이론적 원전. 조류 영향 미약 해역에서 취송류·해류 성분이 확산에 결정적 역할 — + OpenDrift 한국 해역 적용 정당성 근거. +
+
+ + {/* 논문 3: 정태성·조형진 2008 */} +
+
+
+ {[ + { label: '국내학술대회', color: 'var(--color-accent)' }, + { label: '태안 허베이스피리트', color: 'var(--color-info)' }, + { label: '서해 수치모의', color: 'var(--color-accent)' }, + ].map((t) => ( + + {t.label} + + ))} +
+ 2008 +
+
+ 태안 기름유출사고의 유출유 확산특성 분석 (Analysis of Oil Spill Dispersion in Taean + Coastal Zone) +
+
+ 정태성, 조형진 | 한남대학교 토목환경공학과 | 한국해안·해양공학회 학술발표논문집 제17권 + pp.60–63, 2008 +
+
+
+ 연구 목적 +
+ 2007년 12월 태안 허베이 스피리트 기름유출 사고 후 15일간 조류·취송류에 의한 유출유 + 확산패턴을 2D 수치모형으로 재현. 조류·취송류 기여도 분리 분석. +
+
+ 핵심 발견 +
+ 취송류 풍속 비율 α = 2%가 실제 관측 확산과 가장 유사(3% 과대, 2.5% 다소 빠름). + 취송류 편향각 θ = 20° 최적. 조류는 남북 확산거리, 취송류는 해안 부착에 기여. +
+
+
+
+ 해역 군산~영흥도(서해 중부) +
+
+ 격자 유한요소 삼각형, 최소 150m +
+
+ 방출 50입자/9시간 · 총 5일 모의 +
+
+ {/* 취송류 파라미터 비교 */} +
+
+ 취송류 매개변수 비교 (태안 사고 수치실험) +
+
+
+
α = 3%
+
과대 확산
+
+
+
α = 2.5%
+
다소 빠름
+
+
+
α = 2% ✓
+
최적 일치
+
+
+
θ = 20° ✓
+
최적 편향각
+
+
+
+
+ WING 적용 의의 : 서해(조류 강한 해역) 취송류 + 매개변수 α = 2%·θ = 20° 최적값은 OpenDrift 서해 운용 시 wind drift factor 설정의 직접 + 근거. 허베이 스피리트 사고는 POSEIDON(특허 10-1868791)의 검증 데이터로도 활용. +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/prediction/components/contents/OptimizationPanel.tsx b/frontend/src/components/prediction/components/contents/OptimizationPanel.tsx new file mode 100644 index 0000000..0a0c5ea --- /dev/null +++ b/frontend/src/components/prediction/components/contents/OptimizationPanel.tsx @@ -0,0 +1,314 @@ +export function OptimizationPanel() { + return ( + <> + {/* 다목적 최적화 개요 */} +
+
+ 다목적 최적화 문제 (Multi-Objective Optimization) +
+
+ 오일펜스 배치 최적화는 상충하는 복수 목적함수를 + 동시에 만족해야 하는 전형적인 다목적 최적화 문제입니다. 차단 효율 최대화와 자원 사용 + 최소화는 서로 트레이드오프 관계를 가지며,{' '} + 파레토 최적(Pareto Optimal) 해집합에서 의사결정자가 + 선택합니다. +
+
+ + {/* 목적함수 정의 */} +
+
📊 목적함수 및 제약조건 정의
+
+
+
🎯 목적함수 F(x)
+
+ 최대화: +
+ f₁(x) = Σ Ablocked,i · wESI,i{' '} + (가중 차단면적) +
+ f₂(x) = Tdeadline − Tdeploy{' '} + (여유시간) +
+ 최소화: +
+ f₃(x) = Σ Lboom,j{' '} + (총 오일펜스 사용량) +
+ f₄(x) = Σ Dvessel,k{' '} + (방제정 총 이동거리) +
+
+
+
🚫 제약조건 G(x)
+
+ g₁: U·sin(θi) ≤ Uc ∀i{' '} + (임계유속) +
+ g₂: Σ Lj ≤ Lmax{' '} + (자원 한계) +
+ g₃: Tdeploy,i ≤ Tarrive,i{' '} + (시간 제약) +
+ g₄: d(pi, shore) ≥ dmin{' '} + (연안 이격) +
+ g₅: h(pi) ≥ hmin{' '} + (수심 조건) +
+
+
+ + {/* ESI 가중치 */} +
+
+ 🏖️ ESI 가중치 wESI 설계 +
+
+ {[ + { + grade: 'ESI 1~2', + desc: '노출암반', + w: 'w = 0.2', + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.06)', + }, + { + grade: 'ESI 3~4', + desc: '모래해변', + w: 'w = 0.4', + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.06)', + }, + { + grade: 'ESI 5~6', + desc: '자갈·조약', + w: 'w = 0.6', + color: 'var(--color-caution)', + bg: 'rgba(6,182,212,.06)', + }, + { + grade: 'ESI 7~8', + desc: '갯벌·조간대', + w: 'w = 0.85', + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.06)', + }, + { + grade: 'ESI 9~10', + desc: '맹그로브·습지', + w: 'w = 1.0', + color: 'var(--color-info)', + bg: 'rgba(59,130,246,.08)', + bd: 'rgba(59,130,246,.2)', + }, + ].map((esi, i) => ( +
+
+ {esi.grade} +
+
{esi.desc}
+
{esi.w}
+
+ ))} +
+
+
+ + {/* NSGA-II 알고리즘 */} +
+
+ 🧬 NSGA-II (Non-dominated Sorting Genetic Algorithm II) +
+
+
+
+ WING의 오일펜스 배치 최적화는 다목적 유전알고리즘{' '} + NSGA-II(Deb et al., 2002)를 핵심 엔진으로 + 사용합니다. 파레토 전면(Pareto Front)을 탐색하여 차단 효율과 자원 효율의 최적 해집합을 + 제공합니다. +
+
+ {[ + '염색체 구조 : [배치지점 좌표, 방향각θ, 길이L, 형태, 배치순서]', + '집단 크기 : 100~200개체 · 세대수 50~200', + '교배 연산 : SBX(Simulated Binary Crossover) · ηc=20', + '변이 연산 : 다항식 변이(Polynomial Mutation) · ηm=20', + '선택 방식 : 비지배 정렬 + 혼잡도 거리(Crowding Distance)', + ].map((item, i) => ( +
+ {item.split(' : ')[0]} :{' '} + {item.split(' : ')[1]} +
+ ))} +
+
+
+
+ NSGA-II 5단계 진화 루프 +
+
+ {[ + { + step: '①', + title: '초기 집단 생성', + desc: '확산예측 결과 기반 랜덤 + 휴리스틱 배치안 혼합 초기화', + }, + { + step: '②', + title: '적합도 평가', + desc: '유출유 확산 시뮬레이터로 각 배치안의 차단면적·도달시간 계산', + }, + { + step: '③', + title: '비지배 정렬', + desc: '목적함수 공간에서 파레토 전면 계층(F₁, F₂, F₃…) 분류', + }, + { + step: '④', + title: '교배·변이', + desc: 'SBX + 다항식 변이로 자식 세대 생성. 제약조건 위반 수리(repair)', + }, + { + step: '⑤', + title: '엘리트 선택', + desc: '부모+자식 2N 집단에서 비지배 정렬+혼잡도 기준으로 N개 선택 → 수렴까지 반복', + }, + ].map((item, i) => ( +
+ {item.step} +
+ {item.title} : {item.desc} +
+
+ ))} +
+
+
+
+ + {/* 보조 알고리즘 비교 */} +
+
🔬 보조 최적화 알고리즘 비교 적용
+
+ + + + {['알고리즘', '유형', '장점', '단점', 'WING 활용'].map((h) => ( + + ))} + + + + {[ + { + name: 'NSGA-II', + color: 'var(--color-accent)', + type: '다목적 GA', + pros: '파레토 전면 탐색\n다양성 유지 우수', + cons: '계산비용 높음\n수렴 느림', + wing: '메인 엔진', + wingColor: 'var(--color-accent)', + }, + { + name: 'PSO', + color: 'var(--color-accent)', + type: '입자군집', + pros: '빠른 수렴\n구현 단순', + cons: '조기수렴\n다목적 취약', + wing: '단일목적 빠른 배치', + wingColor: 'var(--fg-sub)', + }, + { + name: 'SA', + color: 'var(--color-info)', + type: '모의담금질', + pros: '전역 탈출 우수\n국소최적 회피', + cons: '매개변수 민감\n느린 수렴', + wing: '긴급 단순 배치', + wingColor: 'var(--fg-sub)', + }, + { + name: 'Greedy+휴리스틱', + color: 'var(--color-accent)', + type: '결정론적', + pros: '즉시 결과\n해석 용이', + cons: '전역최적 미보장', + wing: '실시간 초기 제안', + wingColor: 'var(--color-accent)', + }, + ].map((row, i) => ( + + + + + + + + ))} + +
+ {h} +
+ {row.name} + {row.type} + {row.pros} + + {row.cons} + + {row.wing} +
+
+
+ + ); +} diff --git a/frontend/src/components/prediction/components/contents/OverviewPanel.tsx b/frontend/src/components/prediction/components/contents/OverviewPanel.tsx new file mode 100644 index 0000000..f9c5f76 --- /dev/null +++ b/frontend/src/components/prediction/components/contents/OverviewPanel.tsx @@ -0,0 +1,264 @@ +export function OverviewPanel() { + return ( + <> + {/* 인트로 카드 */} +
+
+
+
오일펜스 배치 최적화란?
+
+ 해양 유류오염 발생 시 유출유 확산 예측 결과와 + 실시간 해양환경(조류·풍향·파고)을 연동하여, 제한된 방제자원(오일펜스 길이·방제정 + 수)으로 오염 확산 차단 효율을 최대화하는 최적 + 배치 지점·형태·순서를 자동 산출하는 수치 알고리즘 체계입니다. +
+
+
+
WING 최적화 목표
+
+ {[ + { + num: '①', + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.05)', + bd: 'rgba(6,182,212,.12)', + text: '차단 면적 최대화 — 예측 유출유 확산 경계와 오일펜스 교차 면적 극대화', + }, + { + num: '②', + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.05)', + bd: 'rgba(6,182,212,.12)', + text: '도달시간 최소화 — 유출유 해안·ESI 민감구역 도달 전 선제적 차단선 구축', + }, + { + num: '③', + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.05)', + bd: 'rgba(6,182,212,.12)', + text: '자원 효율 최적화 — 가용 오일펜스 길이·방제정 수·이동시간 제약 충족', + }, + { + num: '④', + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.05)', + bd: 'rgba(6,182,212,.12)', + text: '실패 안전성 확보 — 조류 초과 시 오일펜스 이탈 방지 방향각 자동 보정', + }, + ].map((item, i) => ( +
+ + {item.num} + {' '} + {item.text} +
+ ))} +
+
+
+
+ + {/* 전체 흐름도 */} +
+
WING 오일펜스 배치 최적화 전체 흐름
+
+ {[ + { + icon: '🌊', + label: '확산예측', + sub: 'KOSPS/POSEIDON\nOpenDrift', + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.08)', + bd: 'rgba(6,182,212,.2)', + }, + { + icon: '📡', + label: '환경입력', + sub: '조류·풍향\n파고·수심', + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.08)', + bd: 'rgba(6,182,212,.2)', + }, + { + icon: '🗺️', + label: '차단선 후보', + sub: '격자탐색\n후보지점 생성', + color: 'var(--color-caution)', + bg: 'rgba(6,182,212,.08)', + bd: 'rgba(6,182,212,.2)', + }, + { + icon: '⚙️', + label: '최적화 엔진', + sub: '다목적\n유전알고리즘', + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.08)', + bd: 'rgba(6,182,212,.3)', + bold: true, + }, + { + icon: '✅', + label: '배치안 출력', + sub: '좌표·형태\n방향·순서', + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.08)', + bd: 'rgba(6,182,212,.2)', + }, + { + icon: '🗺️', + label: '지도 표시', + sub: 'ESI 중첩\n방제자원 연계', + color: 'var(--color-info)', + bg: 'rgba(59,130,246,.08)', + bd: 'rgba(59,130,246,.2)', + }, + ].map((step, i) => ( +
+
+
{step.icon}
+
+ {step.label} +
+
+ {step.sub} +
+
+ {i < 5 &&
} +
+ ))} +
+
+ + {/* 오일펜스 종류 */} +
+ {[ + { + icon: '⛽', + title: '고형 오일펜스', + color: 'var(--color-accent)', + desc: '단단한 부체와 수중커튼으로 구성. 정적 배치. 항구·좁은 수로에 적합.', + specs: [ + '내조류 한계: 0.5~1.0 knot', + '높이: 30~60cm · 수중 30~60cm', + '전개속도: 30~60m/hr', + ], + }, + { + icon: '🌊', + title: '공기충전식 오일펜스', + color: 'var(--color-info)', + desc: '공기로 부력 확보. 이동·보관 편리. 해상 광역 차단에 주로 사용.', + specs: [ + '내조류 한계: 0.7~1.5 knot', + '높이: 45~90cm · 수중 45~90cm', + '전개속도: 100~300m/hr', + ], + }, + { + icon: '🔄', + title: '자항식 오일펜스', + color: 'var(--color-accent)', + desc: '방제정 예인 또는 자체 추진. U형·V형 동적 배치. 강조류 해역 적합.', + specs: ['운용수심: 5m 이상', 'U형·V형·J형 동적 형태', '내조류: 조류각도 보정으로 극복'], + }, + ].map((boom, i) => ( +
+
+ {boom.icon} {boom.title} +
+
{boom.desc}
+
+ {boom.specs.map((spec, j) => ( +
+ {spec} +
+ ))} +
+
+ ))} +
+ + {/* 핵심 제약조건 */} +
+
+ ⚠️ 최적화 핵심 제약조건 +
+
+ {[ + { + icon: '🌊', + title: '조류 제약', + color: 'var(--color-info)', + bg: 'rgba(59,130,246,.05)', + bd: 'rgba(59,130,246,.15)', + lines: [ + '조류속도 > 임계유속 시', + '오일펜스 하단 통과 발생', + 'U<0.7 knot 유지 필수', + '임계각도 자동 계산 적용', + ], + }, + { + icon: '📏', + title: '자원 제약', + color: 'var(--color-caution)', + bg: 'rgba(6,182,212,.05)', + bd: 'rgba(6,182,212,.15)', + lines: [ + '가용 오일펜스 총 길이', + '방제정 척수·이동시간', + '앵커링 가능 수심 조건', + '연결부 허용 장력', + ], + }, + { + icon: '⏱️', + title: '시간 제약', + color: 'var(--color-info)', + bg: 'rgba(59,130,246,.05)', + bd: 'rgba(59,130,246,.15)', + lines: [ + '유출유 도달 예측시간', + '오일펜스 전개 소요시간', + '방제정 현장 도착시간', + '조석 변환 주기 고려', + ], + }, + ].map((c, i) => ( +
+
+ {c.icon} {c.title} +
+
+ {c.lines.map((l, j) => ( + + {j > 0 &&
} + {l} +
+ ))} +
+
+ ))} +
+
+ + ); +} diff --git a/frontend/src/components/prediction/components/contents/PoseidonPanel.tsx b/frontend/src/components/prediction/components/contents/PoseidonPanel.tsx new file mode 100644 index 0000000..e028a0b --- /dev/null +++ b/frontend/src/components/prediction/components/contents/PoseidonPanel.tsx @@ -0,0 +1,496 @@ +import { bodyText, card, cardBg, codeBox, labelStyle, tag } from '../OilSpillTheoryView'; + +export function PoseidonPanel() { + return ( +
+
+
+
+ 🔵 +
+
+
+ POSEIDON (입자추적 최적화 예측 시스템) +
+
+ 한국환경연구원 · (주)아라종합기술 · 한국해양기상기술 공동개발 · MOHID 해양순환모델 + 기반 · 뜰개 관측 매개변수 자동 최적화 +
+
+
+
+ 김충기·김도연 외(등록특허 10-1868791, 2018)가 개발한 POSEIDON은 실제 해양에 투하한{' '} + 뜰개(Drifter) 관측 경로와 수치모델 예측값을 비교하여, + 입자추적 모델의 매개변수를{' '} + GA·DE·HS·PSO 알고리즘으로 자동 최적화하는 핵심 기술을 + 보유합니다. 해양순환 기반 모델은 포르투갈 IST/MARETEC이 개발한{' '} + MOHID 3D 순환모델을 사용합니다. +
+
+ 참고문헌 ①: 김도연·김용혁, "유출유 확산 예측을 위한 입자 추적 모듈 최적화 방법 및 + 이를 이용한 예측 시스템", 등록특허 10-1868791, 2018. +
+ 참고문헌 ②: 이재호·임병준·김도연 외, "2016년 동아시아 해역의 MOHID 지역 해양 순환 + 모델 검증", 한국지구과학회지 39(5), pp.436-457, 2018. +
+
+ + {/* 등록특허 */} +
+
+ + 등록특허 + +
+
+
+
등록번호
+
10-1868791
+
2018 등록
+
+
+
+ 유출유 확산 예측을 위한 입자 추적 모듈 최적화 방법 및 이를 이용한 예측 시스템 + (POSEIDON) +
+
+ 발명자 : 김도연 · 김용혁 · 김충기 외 |{' '} + 출원인 : (주)아라종합기술 +
+
+ {[ + 'GA(유전알고리즘)', + 'DE(차분진화)', + 'HS(화음탐색)', + 'PSO(입자군집최적화)', + '뜰개 자동 동화', + ].map((t) => ( + + {t} + + ))} +
+
+
+
+ +
+
+
MOHID 해양순환모델 (기반 모델)
+
+ 포르투갈 IST/MARETEC에서 1985년부터 개발한 3D 수치 해양순환모델. 60여 개 모듈을 보유하며 + 조류·폭풍해일·수온·염분·난류·유출유 확산 등을 통합 모의합니다. +
+
+ {[ + { label: '격자 구성', desc: '동아시아 9km + 한반도 3km (Nesting)' }, + { label: '수직 좌표', desc: 'σ + Z-level 혼합 (GVC)' }, + { label: '검증 결과', desc: 'SST RMSE 0.42~0.78°C, SLA RMSE 0.11~0.17m' }, + { label: '경계조건', desc: 'HYCOM 재분석 (MYOCEAN 대비 우수)' }, + ].map((t) => ( +
+ {t.label} : {t.desc} +
+ ))} +
+
+
+
입자추적 매개변수 자동 최적화
+
+ 뜰개 관측 경로와 제1 모델 예측값의 단위시간별 변화량을 비교하여 제2 모델의 매개변수를 + 반복 최적화합니다. +
+
+ {[ + { label: '최적화 알고리즘', desc: 'GA · DE · HS · PSO 중 선택' }, + { label: '입력 데이터', desc: '유속·풍속 벡터 + 제1모델 변화량' }, + { label: '관측 보강', desc: 'UAV·선박 탑재 센서 동시 활용 가능' }, + { label: '지원과제', desc: '행정안전부 KCG-01-2017-05 (해양경비지원기술)' }, + ].map((t) => ( +
+ {t.label} :{' '} + {t.desc} +
+ ))} +
+
+
+ + {/* 핵심 수식 */} +
+
POSEIDON 입자추적 핵심 수식
+
+
+
+ 제1 입자추적 모델 (기본) +
+
+ Model_x = Δt × current_u + Δt × c × wind_u +
+ Model_y = Δt × current_v + Δt × c × wind_v +
+
+ c : 풍속 가중치 (예: c=0.3 → 바람의 30% 반영) +
+
+
+
+ 제2 입자추적 모델 (최적화 후) +
+
+ Revised_x = a1·current_u + a2·current_v +
+            + a3·wind_u + + a4·wind_v +
+            + a5·Model_x + + a6·Model_y + a7 +
+
+ a1~a7 : GA·DE·PSO로 최적화된 매개변수 +
+
+
+
+ {/* 상시 운용 통신 체계 */} +
+
🔄 POSEIDON_V2 상시 운용 체계
+ + {/* 외부 입력 자료 */} +
외부 입력 자료
+
+ {[ + { + label: 'HYCOM', + sub: '해류·수온·염분', + detail: 'YYYYMMDD.nc', + color: 'var(--color-info)', + }, + { + label: 'GDAPS(UM)', + sub: '바람·기온·기압', + detail: 'g512_v070_erea_*.grib2', + color: 'var(--color-accent)', + }, + { + label: 'DAIN(.enc)', + sub: '격자·수심·해안선·조석', + detail: '정적 바이너리 데이터', + color: 'var(--color-accent)', + }, + { + label: '뜰개(Drifter)', + sub: '관측 표류 경로', + detail: 'GA/DE/PSO 동화용', + color: 'var(--color-accent)', + }, + ].map((node, i) => ( +
+
+
+ {node.label} +
+
{node.sub}
+
+ {node.detail} +
+
+ {i < 3 && ( +
+ )} +
+ ))} +
+ + {/* 구분선 */} +
+ + {/* 중앙 화살표 */} +
+ ▼ DATA → PREP → 격자 보간/좌표 변환 ▼ +
+ + {/* 4대 도메인 실행 모듈 */} +
+ POSEIDON 4대 실행 모듈 (EA012 대격자 → KO108 연안 상세격자) +
+
+ {[ + { + label: 'HYDR', + script: 'RUN-HYDR.sh', + engine: 'MOHID 3D', + desc: '해류·수온·염분·수위', + color: 'var(--color-accent)', + icon: '🌊', + }, + { + label: 'WAVE', + script: 'RUN-WAVE.sh', + engine: 'SWAN', + desc: '파랑 (wave_x, wave_y)', + color: 'var(--color-info)', + icon: '🌬️', + }, + { + label: 'TIDE', + script: 'RUN-TIDE.sh', + engine: 'MOHID-TIDE', + desc: '조석 (M2·S2·K1·O1)', + color: 'var(--color-accent)', + icon: '🌀', + }, + { + label: 'OILS', + script: 'RUN-OILS.sh', + engine: 'OILS-RUN', + desc: '유출유 입자추적·확산', + color: 'var(--color-accent)', + icon: '🛢️', + strong: true, + }, + ].map((m) => ( +
+
{m.icon}
+
+ {m.label} +
+
{m.engine}
+
+ {m.script} +
+
{m.desc}
+
+ ))} +
+ + {/* 화살표 + 최적화 */} +
+ ▼ HYDR + WAVE + TIDE → OILS 강제력 입력 ▼ 뜰개 관측 → GA/DE/PSO 매개변수 자동 최적화 ▼ +
+ + {/* 출력·활용 */} +
+ {[ + { label: 'DASV 결과', sub: 'HDF5·NetCDF', color: 'var(--color-accent)' }, + { label: 'SAR/위성', sub: '원격탐사 검증', color: 'var(--color-info)' }, + { + label: '방제 의사결정', + sub: '자원배치·전략', + color: 'var(--color-accent)', + strong: true, + }, + ].map((node, i) => ( +
+
+
+ {node.label} +
+
{node.sub}
+
+ {i < 2 && ( +
+ )} +
+ ))} +
+ +
+ HYCOM(.nc) + GDAPS(.grib2) → 격자 보간(EA012/KO108) → MOHID(해류) + SWAN(파랑) + 조석 → + OILS 입자추적 → 뜰개 동화 최적화 → HDF5 결과 → 방제 의사결정 +
+
+
⚠️ 예측 신뢰도 한계
+
+ 조류: 무한 예보 가능 +
+ 해류(HYCOM): 5일치 제한 +
+ 기온·취송류: 3일치 제한 +
72h 초과 예측 시 신뢰도 저하 +
+
+
+ + {/* POSEIDON관련 유출유 확산예측 논문 */} +
+
+
+ 📄 +
+
+
POSEIDON관련 유출유 확산예측 논문
+
+ 포세이돈 시스템 소개·활용 · 최적 방제전략 · 원격탐사 연동 · MOHID 검증 — + 한국해양환경·에너지학회 외 +
+
+
+
+ {[ + { + tags: [ + { label: '시스템 소개', color: '#3b82f6' }, + { label: '활용사례', color: 'var(--color-accent)' }, + ], + year: '2019', + title: '해양오염방제 의사결정지원 시스템(포세이돈) 소개 및 활용사례', + authors: + '김충기, 윤종휘, 천정윤, 김도연 | 한국해양환경·에너지학회 학술대회논문집 | 2019.5 | pp.14', + desc: 'POSEIDON 시스템의 전체 아키텍처 및 주요 기능 소개. 유출유 확산예측·방제자원 연계·의사결정지원 모듈 구성 설명. 실제 해양오염 대응 사례 적용 결과 발표.', + }, + { + tags: [ + { label: '방제전략', color: '#3b82f6' }, + { label: '정책수립', color: 'var(--color-accent)' }, + ], + year: '2020', + title: '포세이돈 시스템을 활용한 최적 해양오염 방제전략 및 정책수립', + authors: + '윤종휘, 김충기, 천정윤, 김형만, 방기영, 홍성수 | 한국해양환경·에너지학회 학술대회논문집 | 2020.7 | pp.1452', + desc: 'POSEIDON 시스템을 활용한 방제자원 최적 배치 전략 수립 방법론 연구. 유출유 확산 시나리오별 방제효율 비교 분석 및 정책 의사결정 지원 프레임워크 제안.', + }, + { + tags: [ + { label: '원격탐사', color: '#3b82f6' }, + { label: '위성 연동', color: '#06b6d4' }, + ], + year: '2022', + title: '원격탐사 기반의 유출유 확산예측 및 해양오염 방제 지원', + authors: + '김도연, 김충기, 양찬수 | 한국해양환경·에너지학회 학술대회논문집 | 2022.11 | pp.79', + desc: '위성 원격탐사(SAR·광학) 기반 유출유 탐지 결과를 POSEIDON 확산예측 모델과 연동하는 기술 연구. 실시간 위성 영상으로 초기 유출 범위를 파악하고 예측 정확도 향상.', + }, + { + tags: [ + { label: 'MOHID 검증', color: '#3b82f6' }, + { label: '해양순환모델', color: '#06b6d4' }, + ], + year: '2018', + title: '한반도 인근 해역 MOHID 지역 해양순환 모델 검증', + authors: '이재호, 임병준, 김도연 외 | 한국지구과학회지 제39권 5호, pp.436-457 | 2018', + desc: 'POSEIDON 기반 MOHID 모델 동아시아 해역 2016년 검증. 수온·염분·해류 정확도 평가 — SST RMSE 0.42~0.78°C, SLA RMSE 0.11~0.17m.', + }, + ].map((paper) => ( +
+
+
+ {paper.tags.map((t) => ( + + {t.label} + + ))} +
+ {paper.year} +
+
{paper.title}
+
{paper.authors}
+
{paper.desc}
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/prediction/components/contents/ReferencesPanel.tsx b/frontend/src/components/prediction/components/contents/ReferencesPanel.tsx new file mode 100644 index 0000000..f7b6997 --- /dev/null +++ b/frontend/src/components/prediction/components/contents/ReferencesPanel.tsx @@ -0,0 +1,153 @@ +export function ReferencesPanel() { + const categories = [ + { + title: '⚙️ 최적화 알고리즘', + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.1)', + bd: 'rgba(6,182,212,.25)', + refs: [ + { + title: 'A Fast and Elitist Multiobjective Genetic Algorithm: NSGA-II', + author: + 'Deb, K., Pratap, A., Agarwal, S., & Meyarivan, T. | IEEE Trans. Evol. Comput. 6(2):182–197, 2002', + desc: 'NSGA-II 원전 · 비지배 정렬 + 혼잡도 거리 · 파레토 전면 탐색 알고리즘 · WING 다목적 최적화 엔진의 핵심 이론 기반', + }, + { + title: + 'An Emergency Scheduling Model for Oil Containment Boom in Dynamically Changing Marine Oil Spills', + author: + 'Xu, Y., Zhang, L., Zheng, P., Liu, G., & Zhao, D. | Ningbo University | Systems 2025, 13, 716', + desc: 'IMOGWO 다목적 최적화 · 오일필름 동적 모델 · 경제·생태 손실 정량화 · 주산해역 케이스 스터디', + highlight: true, + }, + { + title: '등록특허 10-1567431 기반 유출유 확산예측-방제 연동 시스템', + author: '이문진 외 | 한국해양과학기술원 | 2015', + desc: 'KOSPS 기반 확산예측-오일펜스 배치 연동 체계 원전 · ESI 방제정보지도 연동 · 취송류 경험식 기반 방향각 산정 근거', + }, + ], + }, + { + title: '🌊 유체역학 이론', + color: 'var(--color-info)', + bg: 'rgba(59,130,246,.1)', + bd: 'rgba(59,130,246,.25)', + refs: [ + { + title: 'Oil Boom Failure: Critical Velocity and Boom Design', + author: 'Leibovich, S. | Annual Review of Fluid Mechanics 8:177–197, 1976', + desc: '오일펜스 임계유속 이론 원전 · Froude수 기반 Splash-over 조건 · 방향각-차단효율 관계식', + }, + { + title: 'Dynamic Behavior of Oil Containment Booms in Currents', + author: 'Delvigne, G.A.L. | Spill Science & Technology Bulletin 5(3-4):181–196, 1999', + desc: '조류 중 오일펜스 항력·변형 동역학 · Catenary 형태 해석 · 실용 배치 설계 기준', + }, + { + title: 'Oil Boom Containment Efficiency in Waves and Currents', + author: + 'Wicks, M. | Proceedings of the Joint Conference on Prevention and Control of Oil Spills, 1969', + desc: '파랑+조류 복합 환경에서 오일펜스 효율 실험 · V형·U형 성능 비교 기초 자료', + }, + { + title: 'Experimental, Numerical and Optimisation Study of Oil Spill Containment Boom', + author: + 'Muttin, F., Guyot, F., Nouchi, S. & Variot, B. | Coastal Environment V, WIT Press, 2004', + desc: '유체-구조 상호작용 · 1.5D/2.5D 구조모델 · 유전알고리즘 최적화 · SPH 수치해법 · ERIKA·PRESTIGE 사고 검증', + highlight: true, + }, + ], + }, + { + title: '📐 오일펜스 배치 설계', + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.1)', + bd: 'rgba(6,182,212,.25)', + refs: [ + { + title: 'Optimization of an Oil Boom Arrangement', + author: + 'Fang, J. & Wong, K.-F.V. | Department of Mechanical Engineering, University of Miami', + desc: '오일펜스 배치 형태(V형·U형·J형) 최적화 연구 · 조류 방향각과 차단효율 관계 수치 분석 · 방제정 예인각도별 성능 비교', + }, + ], + }, + { + title: '🗺️ 방제 운용 기준', + color: 'var(--color-accent)', + bg: 'rgba(6,182,212,.1)', + bd: 'rgba(6,182,212,.25)', + refs: [ + { + title: '기름오염방제시 오일펜스 사용지침 (ITOPF 방제기술정보문서 3)', + author: 'ITOPF | 해양경찰청·해양환경관리공단 번역 | © 2011 ITOPF Ltd.', + desc: '커튼형·펜스형·해안용 분류 · 6가지 기름 유실 메커니즘 · 힘 계산 공식 F=100·A·V² · 유속별 최대 설치각도 표', + highlight: true, + }, + { + title: 'NOAA ESI 방제정보지도 기반 오일펜스 우선 배치 전략', + author: + 'NOAA Office of Response and Restoration | Open Water Oil Identification Manual, 2013', + desc: 'ESI 1~10 등급별 방제 우선순위 · 오일펜스 가중 배치 전략 · 취수원·어항 보호 기준', + }, + { + title: '해양경찰청 해양오염방제 업무매뉴얼 — 오일펜스 전개 절차', + author: '해양경찰청 해양오염대응국 | 방제업무 기본지침, 최신판', + desc: '국내 해역 오일펜스 운용 법적 기준 · 방제정 연계 전개 절차 · 서해/남해/동해 해역별 운용 특성', + }, + ], + }, + ]; + + return ( + <> +
📚 오일펜스 배치 최적화 이론 근거 문헌
+
총 12편 · 4개 카테고리
+ + {categories.map((cat, ci) => ( +
+
+ + {cat.title} + +
+
+ {cat.refs.map((ref, ri) => ( +
+
+ {ri + 1 === 1 ? '①' : ri + 1 === 2 ? '②' : ri + 1 === 3 ? '③' : '④'} +
+
+
{ref.title}
+
{ref.author}
+
{ref.desc}
+
+
+ ))} +
+
+ ))} + + ); +} diff --git a/frontend/src/components/prediction/components/contents/RoadmapPanel.tsx b/frontend/src/components/prediction/components/contents/RoadmapPanel.tsx new file mode 100644 index 0000000..ef3f2c3 --- /dev/null +++ b/frontend/src/components/prediction/components/contents/RoadmapPanel.tsx @@ -0,0 +1,134 @@ +import { card, cardBg, labelStyle } from '../OilSpillTheoryView'; + +export function RoadmapPanel() { + return ( +
+
+
+
현재 모델 한계
+
+
+ 2D 표층 확산 모의 위주 → 3D 수직 구조 반영 미흡 +
+
+ 기상·해양 결합 피드백 미적용 → 대규모 유출 시 정확도 저하 +
+
+ 조류 조화상수 분해능 한계 → 항만·연안 세부 예측 오차 +
+
+
+
+
발전 방향
+
+ {[ + { title: 'ROMS 3D 해양모델 결합', desc: '광역→상세역 Nesting 200m급 고해상도 구현' }, + { title: 'AI/ML 서로게이트 모델', desc: '수치모델 학습 기반 수초 내 근사 예측' }, + { title: '위성 SAR 실시간 동화', desc: '관측 유막 데이터 모델 보정 자동화' }, + ].map((r) => ( +
+
{r.title}
+
{r.desc}
+
+ ))} +
+
+
+ + {/* 로드맵 */} +
+
WING 유출유 모델 고도화 로드맵
+
+ {[ + { + phase: '✅ Phase 1 (완료)', + title: '2D 앙상블', + color: 'var(--color-accent)', + desc: 'KOSPS + POSEIDON\n+ OpenDrift 3종\n표층 확산 앙상블', + nextColor: 'var(--color-accent)', + }, + { + phase: '🔧 Phase 2 (진행중)', + title: 'ROMS 결합', + color: 'var(--color-accent)', + desc: '3D 수직 구조\n고해상도 연안\n조류 정밀 반영', + nextColor: 'var(--color-accent)', + }, + { + phase: '🔬 Phase 3 (계획)', + title: 'AI 융합', + color: 'var(--color-accent)', + desc: 'ML 서로게이트\n위성 SAR 동화\n실시간 보정', + nextColor: 'var(--color-info)', + }, + { + phase: '🎯 Phase 4 (목표)', + title: '자동 대응', + color: 'var(--color-accent)', + desc: '사고감지→예측\n→방제자원 배치\n전 과정 자동화', + }, + ].map((s, i) => ( +
+
+
+ {s.phase} +
+
{s.title}
+
+ {s.desc} +
+
+ {s.nextColor && ( +
+ )} +
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/prediction/components/contents/SystemOverviewPanel.tsx b/frontend/src/components/prediction/components/contents/SystemOverviewPanel.tsx new file mode 100644 index 0000000..f11a136 --- /dev/null +++ b/frontend/src/components/prediction/components/contents/SystemOverviewPanel.tsx @@ -0,0 +1,372 @@ +import { sanitizeHtml } from '@common/utils/sanitize'; +import { card, cardBg, labelStyle } from '../OilSpillTheoryView'; + +export function SystemOverviewPanel() { + return ( +
+ {/* 시스템 개요 */} +
+ {/* 1행: 유출유 확산 모델이란? + 활용 목적 */} +
+
+
📋 유출유 확산 모델이란?
+
+ 해상에서 유출된 원유 및 유류가 해수면 위에서 이동·확산·풍화되는 과정을 + 물리·화학적 이론에 기반해 전산으로 모의하는 프로그램입니다.{' '} + 라그랑지안 입자추적법을 기반으로 해류·바람·조류의 복합 영향을 시뮬레이션합니다. +
+
+
+
🎯 활용 목적
+
+
+ 유출 사고 시{' '} + 오염 확산 범위 신속 예측 +
+
+ 방제 자원 배치 및 오일펜스 전략 + 수립 +
+
+ 해안선 도달 예측 및 피해 최소화{' '} + 대응 +
+
+
+
+ {/* 2행: 해양환경 데이터 수집 + 유류 물성 반영 + 수치 시뮬레이션 */} +
+
+
🌊 해양환경 데이터 수집
+
+ 실시간 해양·기상 관측 데이터를 수집하여 모델 강제력으로 입력합니다. +
+
+
🌬️ 표층 바람 (KMA·ECMWF)
+
🌊 해류·조류 (KHOA 조위표)
+
🌡️ 수온·파고 (NIFS 수치예보)
+
+
+
+
🧮 수치 시뮬레이션
+
+ 라그랑지안 입자 시뮬레이션으로 유출유 이동·확산·풍화를 동시 계산합니다. +
+
+
📍 수만~수십만 가상 입자 추적
+
⏱️ 1시간 스텝 반복 계산
+
🗺️ 농도 분포·해안 도달 예측
+
+
+
+
🛢️ 유류 물성 반영
+
+ 유류 종류별 물리·화학적 특성을 모델에 반영하여 실제 거동을 재현합니다. +
+
+ {['비중(API)', '점도', '증발률', '용해도', '인화점', '유화특성'].map((label) => ( + + {label} + + ))} +
+
+
+
+ + {/* 모델 비교 카드 */} +
+
+
🤖 WING 탑재 유출유 확산 모델 비교
+ 3종 앙상블 운용 · 불확실성 정량화 +
+
+ {[ + { + name: 'KOSPS', + sub: 'Korea Oil Spill Prediction System', + color: 'var(--color-info)', + icon: '🔷', + desc: '한국 해역 특화 상시 운용 체계. CHARRY 조류모델 + fBm 난류확산 + 풍화 5단계로 구성된 국산 유출유 확산 시스템.', + org: '한국해양과학기술원(KIOST)', + year: '2011년 (운용체계), 특허 2015년', + strength: '한국 서해·남해 조류 정확도', + weakness: '3D 수직혼합 미반영', + }, + { + name: 'POSEIDON', + sub: '입자추적 최적화 예측 시스템', + color: 'var(--color-info)', + icon: '🔵', + desc: '뜰개(Drifter) 관측 경로 기반 매개변수 자동최적화. MOHID 3D 해양순환모델과 진화알고리즘을 결합한 차세대 시스템.', + org: '한국환경연구원, (주)아라종합기술, 한국해양기상기술', + year: '특허: 등록 10-1868791 (2018)', + strength: 'GA/DE/PSO 자동 매개변수 최적화', + weakness: '실시간 뜰개 운용 필요', + }, + { + name: 'OpenDrift', + sub: 'Python 기반 오픈소스 라그랑지안 프레임워크', + color: 'var(--color-info)', + icon: '🔵', + desc: '노르웨이 기상청 개발 오픈소스. OpenOil 모듈로 유출유 거동 모의. 전 세계 해양모델(NEMO·ROMS·HYCOM)과 범용 연동.', + org: '노르웨이 기상청(MET Norway)', + year: '논문: Dagestad et al., GMD 11, 2018', + strength: '오픈소스·모듈 확장·3D 지원', + weakness: '한국 해역 조류 정밀도', + }, + ].map((m) => ( +
+
+
+ {m.icon} +
+
+
+ {m.name} +
+
{m.sub}
+
+
+
{m.desc}
+
+
+ 📍 개발: {m.org} +
+
+ 🗓️ {m.year} +
+
+ 🎯 강점: {m.strength} +
+
+ ⚠️ 한계: {m.weakness} +
+
+
+ ))} +
+ + {/* 상세 비교 테이블 */} +
+ + + + + + + + + + + {[ + { + label: '개발기관', + k: '한국해양과학기술원
(KIOST, 구 KORDI)', + p: '한국환경연구원
(주)아라종합기술
한국해양기상기술
컨소시엄', + o: '노르웨이 기상청
(MET Norway · OpenOil)', + }, + { + label: '핵심 근거
(논문·특허)', + k: '
① 등록특허 10-1567431 (2015)
이문진·김혜진 외, 유출유 확산 예측 방법
② 해양환경안전학회지 17(4) (2011)
김혜진·이문진 외, KOSPS 상시 운용 체계
③ 해양환경안전학회 춘계 발표 (2008)
이문진 외, CHARRY 조류모델 실시간 적용
', + p: '
① 등록특허 10-1868791 (2018)
김도연·김충기 외, 입자추적 최적화 방법
② 한국지구과학회지 39(5) (2018)
이재호·임병준·김도연 외, MOHID 동아시아 검증
③ Ocean Science Journal (2019)
김충기 외, 한반도 인근 해역 POSEIDON 성능 평가
', + o: '
① Geosci. Model Dev. 11 (2018)
Dagestad et al., OpenDrift v1.0
② J. Geophys. Res. Oceans (2013)
Röhrs et al., OpenOil 유출유 검증
③ Mar. Pollut. Bull. (2019)
Nordam et al., 3D 유출유 수직분포 모의
', + }, + { + label: '추적 방식', + k: '라그랑지안 입자추적
Monte Carlo + fBm 난류확산', + p: '라그랑지안 입자추적
뜰개 동화 최적화', + o: '라그랑지안 입자추적
2D/3D 선택적 운용', + }, + { + label: '조류 예측', + k: 'CHARRY 모델
조화분석+수치모델 결합', + p: 'MOHID 조석모듈
3D 완전 연산', + o: '외부 모델 입력
NEMO·ROMS·HYCOM 등', + }, + { + label: '취송류', + k: '이문진·강용균(2000) 경험식
V=0.029×Vw, θ=θw+18.6°', + p: 'MOHID 바람응력 직접 계산
Drag coefficient 적용', + o: 'wind_drift_factor 설정
기본 3% (OpenOil 표준)', + }, + { + label: '해양 강제력', + k: 'HYCOM 해류·KMA UM 바람
NGSST 수온·검조소 조위', + p: 'MOHID 내부 계산
KMA/ECMWF 기상 입력', + o: '다중 모델 지원
NorESM·TOPAZ·GFS 등', + }, + { + label: '수심 격자', + k: '15초 등간격 (약 463m)
3,225,600 격자', + p: '동아시아 9km
한반도 3km Nesting', + o: '입력 모델 해상도 종속
~1/12° (약 9km)', + }, + { + label: '매개변수\n최적화', + k: '취송류 경험식 계수
수동 보정', + p: 'GA · DE · HS · PSO
뜰개 관측 자동 최적화
', + o: '설정파일 기반
수동 또는 DA 연동', + }, + { + label: '풍화 모델', + k: '5단계 통합
퍼짐(Fay 1969)·증발(Stiver&Mackay)
소산·유상화(Mackay 1980)·침강', + p: '물리기반 전산
MOHID Lagrangian 모듈
유류 물성 DB 연동', + o: 'OpenOil 표준
NOAA ADIOS2 유류 DB
2000종+ 유류 물성 지원', + }, + { + label: '난류 확산', + k: 'fBm 기반
σ²=Atm (m≈2H, 0.45~2.46)', + p: 'Stochastic 확산
확산계수 실측 보정', + o: 'Euler-Maruyama 기법
수평/수직 확산계수 설정', + }, + { + label: '3D 지원', + k: '미지원 (2D)', + p: '지원 (3D)', + o: '지원 (3D)', + }, + { + label: '유류 DB', + k: '국내 주요 유종
맞춤 물성 파라미터', + p: '국내외 주요 유종
물성 DB 구축', + o: 'NOAA ADIOS2 연동
2,000종+ 광범위 지원
', + }, + { + label: '오픈소스', + k: '비공개', + p: '비공개', + o: '완전 오픈소스', + }, + { + label: '예측 신뢰\n기간', + k: '조류: 무제한
해류·기상: 72h 한계', + p: '최대 72h
기상 입력 해상도 종속', + o: '최대 72h~
입력 모델 기간 종속', + }, + { + label: '앙상블', + k: '지원', + p: '지원', + o: '지원', + }, + { + label: 'WING 활용\n역할', + k: '한국 서해·남해
조류 정밀 예측

ESI 방제지도 연동', + p: '매개변수 최적화
정확도 향상

뜰개 관측 동화', + o: '광역·3D 확산
다중 시나리오

오픈소스 모듈 확장', + }, + ].map((row, i) => ( + + + ))} + +
+ 구분 + + 🔷 KOSPS + + 🔵 POSEIDON + + 🔵 OpenDrift +
')), + }} + /> + + + +
+
+ + {/* 앙상블 운용 설명 */} +
+
+ 3종 앙상블 운용 방식 : WING 시스템은 + KOSPS·POSEIDON·OpenDrift를 동시 구동하여 각 모델의 예측 결과를 중첩·비교합니다. 3종 + 결과의 공통 영역을 고신뢰 오염 범위로, 최외곽 범위를{' '} + 불확실성 경계로 표출하여 방제 의사결정의 위험도를 + 정량화합니다. +
+
+
+
+ ); +} diff --git a/frontend/src/components/prediction/components/contents/VerificationPanel.tsx b/frontend/src/components/prediction/components/contents/VerificationPanel.tsx new file mode 100644 index 0000000..533f62b --- /dev/null +++ b/frontend/src/components/prediction/components/contents/VerificationPanel.tsx @@ -0,0 +1,251 @@ +import { useState } from 'react'; +import { card, cardBg, labelStyle, tag } from '../OilSpillTheoryView'; + +export function VerificationPanel() { + const [papersOpen, setPapersOpen] = useState(false); + return ( +
+
+
모델 검증 사례
+
+ WING 시스템의 유출유 확산 모델은{' '} + 과거 실제 사고 재현 시뮬레이션을 통해 정확도가 + 검증되었습니다. +
+
+ +
+
+
2007년 허베이 스피리트 사고 재현
+ + 검증완료 + +
+
+ {[ + { value: '78.4%', label: '해안 표착 예측 일치율', color: 'var(--color-accent)' }, + { value: '±12%', label: '유막 면적 오차', color: 'var(--color-accent)' }, + { value: '±8h', label: '해안 도달 시간 오차', color: 'var(--color-accent)' }, + { value: '3종', label: '비교 검증 모델 수', color: 'var(--color-accent)' }, + ].map((s) => ( +
+
+ {s.value} +
+
{s.label}
+
+ ))} +
+
+ {/* 2014년 우이산 충돌 사고 재현 */} +
+
+
2014년 우이산 충돌 사고 재현
+ + 검증완료 + +
+
+ 여수 오동도 인근 원유 유출 사고를 KOSPS 모델로 재현한 결과, 실제 위성 SAR 영상과 비교 시{' '} + 유막 중심 이동 경로 일치율 82.1% 확인. 남해 특유의 + 복잡한 조류 패턴 반영이 관건. +
+
+ + {/* 관련 논문 */} +
+
setPapersOpen(!papersOpen)} + > + + 📄 모델 검증 관련 논문 + + + ▼ + +
+ {papersOpen && ( +
+ {[ + { + num: '①', + title: '허베이스피리트호 유출유 확산예측 검증 분석', + authors: '이문진 · 김선동 · 김혜진 · 오세웅', + journal: '한국해양과학기술협의회 공동학술대회', + detail: 'pp.3154 | 2010', + desc: 'Radarsat 인공위성영상 비교 분석 · NOAA GNOME과 동일 입력조건 하 비교 검증', + color: 'var(--color-info)', + system: 'KOSPS', + }, + { + num: '②', + title: '3차원 유출유 확산예측 시스템 연구', + authors: '이문진 · 김혜진 · 강관근', + journal: '한국해양과학기술협의회 공동학술대회', + detail: 'pp.17-18 | 2013', + desc: 'Monte Carlo Simulation 기반 3D 확산모델 · 허베이 스피리트호 720시간 적용 검증', + color: 'var(--color-accent)', + system: 'KOSPS', + }, + { + num: '③', + title: '유출유 확산예측 모델의 해양사고 적용 및 개선방안 연구', + authors: '이문진 · 김혜진', + journal: '한국해양과학기술협의회 공동학술대회', + detail: 'pp.2353 | 2014', + desc: 'GS칼텍스 송유관 · Captain Vangelis 유출사고 적용 검증 · 현장 보고자료 비교 분석', + color: 'var(--fg-default)', + system: 'KOSPS', + }, + { + num: '④', + title: '해양유류오염사고 위해도 평가에 관한 연구', + authors: '이문진 · 김혜진', + journal: '한국해양공학회지', + detail: '제23권 1호, pp.24-30 | 2009', + desc: '20년 과거자료 기반 100회 몬테카를로 통계 위험평가 · 가로림만 해안/어장 도달확률 산정', + color: 'var(--color-accent)', + system: 'KOSPS', + }, + { + num: '⑤', + title: '한반도 인근 해역 MOHID 지역 해양순환 모델 검증', + authors: '이재호 · 임병준 · 김도연 외', + journal: '한국지구과학회지', + detail: '제39권 5호, pp.436-457 | 2018', + desc: 'POSEIDON 기반 MOHID 모델 동아시아 해역 2016년 검증 · SST RMSE 0.42~0.78°C', + color: 'var(--color-info)', + system: 'POSEIDON', + }, + { + num: '⑥', + title: '원격탐사 기반의 유출유 확산예측 및 해양오염 방제 지원', + authors: '김도연 · 김충기 · 양찬수', + journal: '한국해양환경·에너지학회 학술대회논문집', + detail: 'pp.79 | 2022', + desc: '위성 원격탐사(SAR·광학) 기반 유출유 탐지 · POSEIDON 확산예측 모델 연동 검증', + color: 'var(--color-info)', + system: 'POSEIDON', + }, + { + num: '⑦', + title: 'OpenDrift v1.0: a generic framework for trajectory modelling', + authors: 'Dagestad et al.', + journal: 'Geoscientific Model Development', + detail: 'Vol.11, pp.1405-1420 | 2018', + desc: 'OpenDrift 프레임워크 설계·구현·검증 · OpenOil 유출유 모듈 다중 사례 검증', + color: 'var(--color-accent)', + system: 'OpenDrift', + }, + { + num: '⑧', + title: 'Observation-based evaluation of surface wave effects on currents', + authors: 'Röhrs et al.', + journal: 'J. Geophys. Res. Oceans', + detail: '| 2013', + desc: 'Stokes drift 파랑 기여 효과 · OpenOil 유출유 확산 현장 관측 검증', + color: 'var(--color-accent)', + system: 'OpenDrift', + }, + { + num: '⑨', + title: 'Numerical modelling and fate assessment of oil spills at sea', + authors: 'Nordam et al.', + journal: 'Marine Pollution Bulletin', + detail: '| 2019', + desc: '3D 유출유 수직 분포 모의 · 해저면 도달 경로 분석 · 풍화 통합 수치 검증', + color: 'var(--color-accent)', + system: 'OpenDrift', + }, + { + num: '⑩', + title: '유출유의 초기 확산예측을 위한 고해상도 결합모형 개발', + authors: '손상영 · 이칠우 · 윤현덕 · 정태화', + journal: '한국해안·해양공학회논문집', + detail: '제29권 4호, pp.189-197 | 2017', + desc: 'Boussinesq + MEDSLIK-II 결합 모형 · 울산 진하해역 시뮬레이션 검증', + color: 'var(--color-accent)', + system: '결합모형', + }, + ].map((paper) => ( +
+
+ {paper.num} +
+
+
+
{paper.title}
+ + {paper.system} + +
+
+ {paper.authors} | {paper.journal}{' '} + {paper.detail} +
+
{paper.desc}
+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/prediction/components/contents/WeatheringPanel.tsx b/frontend/src/components/prediction/components/contents/WeatheringPanel.tsx new file mode 100644 index 0000000..f74e7d8 --- /dev/null +++ b/frontend/src/components/prediction/components/contents/WeatheringPanel.tsx @@ -0,0 +1,113 @@ +import { bodyText, card, cardBg, codeBox, labelStyle } from '../OilSpillTheoryView'; + +export function WeatheringPanel() { + return ( +
+
+
유출유 풍화(Weathering) 프로세스
+
+ 유출유는 해면에 유출된 직후부터{' '} + 물리·화학·생물학적 풍화 과정을 거쳐 지속적으로 특성이 + 변화합니다. WING 시스템은 주요 풍화 프로세스를 통합 모의하여 시간 경과에 따른 유류 상태 + 변화를 추적합니다. +
+
+ +
+ {[ + { + title: '증발(Evaporation)', + color: 'var(--fg-default)', + desc: '유류의 경질 성분이 대기로 증발하는 과정. 유출 초기 수시간 내에 가장 활발하게 발생합니다.', + formula: 'F_e(t) = ∑ x_i·(1 - e^(-k_ei·t))', + note: 'x_i: 성분별 몰분율 / k_ei: 증발 속도상수', + }, + { + title: '유화(Emulsification)', + color: 'var(--fg-default)', + desc: '파랑에 의해 해수가 유류에 혼입되어 점도가 크게 증가하는 현상. 방제 작업을 어렵게 만듭니다.', + formula: 'dW_c/dt = K_em·U_w²·(1-W_c/W_max)', + note: 'W_c: 함수율 / K_em: 유화 속도상수', + }, + { + title: '자연분산(Dispersion)', + color: 'var(--fg-default)', + desc: '파랑 에너지에 의해 유류가 수중으로 분산되어 유막 두께가 감소하는 과정입니다.', + formula: 'D_r = 0.11·(U_w+U_c)²·(1+U_w)⁻¹', + note: 'Mackay 경험식 기반 분산율 계산', + }, + ].map((w) => ( +
+
{w.title}
+
{w.desc}
+
{w.formula}
+
{w.note}
+
+ ))} +
+ + {/* 타임라인 */} +
+
풍화 진행 타임라인 (원유 기준)
+
+ {[ + { + time: '0 ~ 6시간', + title: '초기 확산', + color: 'var(--fg-default)', + desc: '증발 20~30%\n유막 급속 확대\n중질유분 잔류', + nextColor: 'var(--color-accent)', + }, + { + time: '6 ~ 24시간', + title: '유화 진행', + color: 'var(--color-accent)', + desc: '유화율 50~70%\n점도 급증\n체적 1.5~4배', + nextColor: 'var(--color-info)', + }, + { + time: '24 ~ 72시간', + title: '안정화', + color: 'var(--color-info)', + desc: '수중 분산 증가\n자연분해 시작\n해안 표착 위험', + nextColor: 'var(--fg-disabled)', + }, + { + time: '72시간 이후', + title: '타르볼 생성', + color: 'var(--fg-disabled)', + desc: '타르볼 형성\n생물분해 우세\n장기 잔류', + }, + ].map((s, i) => ( +
+
+
+ {s.time} +
+
{s.title}
+
+ {s.desc} +
+
+ {s.nextColor && ( +
+ )} +
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/tabs/prediction/components/leftPanelTypes.ts b/frontend/src/components/prediction/components/leftPanelTypes.ts similarity index 90% rename from frontend/src/tabs/prediction/components/leftPanelTypes.ts rename to frontend/src/components/prediction/components/leftPanelTypes.ts index bd3a3c8..2d71800 100644 --- a/frontend/src/tabs/prediction/components/leftPanelTypes.ts +++ b/frontend/src/components/prediction/components/leftPanelTypes.ts @@ -1,15 +1,18 @@ -import type { PredictionModel } from './OilSpillView'; +import type { PredictionModel } from '@/types/prediction/PredictionType'; import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult, -} from '@common/types/boomLine'; -import type { Analysis } from './AnalysisListTable'; -import type { ImageAnalyzeResult, SensitiveResourceCategory } from '../services/predictionApi'; +} from '@/types/boomLine'; +import type { + PredictionAnalysis, + ImageAnalyzeResult, + SensitiveResourceCategory, +} from '@interfaces/prediction/PredictionInterface'; export interface LeftPanelProps { - selectedAnalysis?: Analysis | null; + selectedAnalysis?: PredictionAnalysis | null; enabledLayers: Set; onToggleLayer: (layerId: string, enabled: boolean) => void; accidentTime: string; diff --git a/frontend/src/tabs/prediction/index.ts b/frontend/src/components/prediction/index.ts similarity index 100% rename from frontend/src/tabs/prediction/index.ts rename to frontend/src/components/prediction/index.ts diff --git a/frontend/src/components/prediction/services/predictionApi.ts b/frontend/src/components/prediction/services/predictionApi.ts new file mode 100644 index 0000000..3514c9e --- /dev/null +++ b/frontend/src/components/prediction/services/predictionApi.ts @@ -0,0 +1,124 @@ +import { api } from '@common/services/api'; +import type { + PredictionAnalysis, + PredictionDetail, + BacktrackResult, + TrajectoryResponse, + SensitiveResourceCategory, + SensitiveResourceFeatureCollection, + SpreadParticlesGeojson, + ImageAnalyzeResult, + GscAccidentListItem, +} from '@interfaces/prediction/PredictionInterface'; + +export const fetchPredictionAnalyses = async (params?: { + search?: string; + acdntSn?: number; +}): Promise => { + const response = await api.get('/prediction/analyses', { params }); + return response.data; +}; + +export const fetchPredictionDetail = async (acdntSn: number): Promise => { + const response = await api.get(`/prediction/analyses/${acdntSn}`); + return response.data; +}; + +export const fetchBacktrack = async (sn: number): Promise => { + const response = await api.get(`/prediction/backtrack/${sn}`); + return response.data; +}; + +export const fetchBacktrackByAcdnt = async (acdntSn: number): Promise => { + const response = await api.get('/prediction/backtrack', { + params: { acdntSn }, + }); + return response.data.length > 0 ? response.data[0] : null; +}; + +export const createBacktrack = async (input: { + acdntSn: number; + lon: number; + lat: number; + srchRadiusNm?: number; + anlysRange?: string; + estSpilDtm?: string; +}): Promise => { + const response = await api.post('/prediction/backtrack', input); + return response.data; +}; + +// ============================================================ +// 확산 예측 시뮬레이션 (OpenDrift 연동) +// ============================================================ + +export const fetchAnalysisTrajectory = async ( + acdntSn: number, + predRunSn?: number, +): Promise => { + const response = await api.get( + `/prediction/analyses/${acdntSn}/trajectory`, + predRunSn != null ? { params: { predRunSn } } : undefined, + ); + return response.data; +}; + +export const fetchSensitiveResources = async ( + acdntSn: number, +): Promise => { + const response = await api.get( + `/prediction/analyses/${acdntSn}/sensitive-resources`, + ); + return response.data; +}; + +export const fetchSensitiveResourcesGeojson = async ( + acdntSn: number, +): Promise => { + const response = await api.get( + `/prediction/analyses/${acdntSn}/sensitive-resources/geojson`, + ); + return response.data; +}; + +export const fetchPredictionParticlesGeojson = async ( + acdntSn: number, +): Promise => { + const response = await api.get( + `/prediction/analyses/${acdntSn}/spread-particles`, + ); + return response.data; +}; + +export const fetchSensitivityEvaluationGeojson = async ( + acdntSn: number, +): Promise<{ type: 'FeatureCollection'; features: unknown[] }> => { + const response = await api.get<{ type: 'FeatureCollection'; features: unknown[] }>( + `/prediction/analyses/${acdntSn}/sensitivity-evaluation`, + ); + return response.data; +}; + +// ============================================================ +// 이미지 업로드 분석 +// ============================================================ + +export const analyzeImage = async (file: File, acdntNm?: string): Promise => { + const formData = new FormData(); + formData.append('image', file); + if (acdntNm?.trim()) formData.append('acdntNm', acdntNm.trim()); + const response = await api.post('/prediction/image-analyze', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 330_000, + }); + return response.data; +}; + +// ============================================================ +// GSC 외부 수집 사고 목록 (확산 예측 입력 셀렉트용) +// ============================================================ + +export const fetchGscAccidents = async (): Promise => { + const response = await api.get('/gsc/accidents'); + return response.data; +}; diff --git a/frontend/src/components/reports/components/OilSpillReportTemplate.tsx b/frontend/src/components/reports/components/OilSpillReportTemplate.tsx new file mode 100644 index 0000000..6c8996c --- /dev/null +++ b/frontend/src/components/reports/components/OilSpillReportTemplate.tsx @@ -0,0 +1,693 @@ +import { useState, useEffect, useCallback } from 'react'; +import { saveReport } from '../services/reportsApi'; +import type { + OilSpillReportData, +} from '@interfaces/reports/ReportsInterface'; +import type { + Jurisdiction, +} from '@/types/reports/ReportsType'; +import { Page1 } from './contents/Page1'; +import { Page2 } from './contents/Page2'; +import { Page3 } from './contents/Page3'; +import { Page4 } from './contents/Page4'; +import { Page5 } from './contents/Page5'; +import { Page6 } from './contents/Page6'; +import { Page7 } from './contents/Page7'; +/* eslint-disable react-refresh/only-export-components */ + +// eslint-disable-next-line react-refresh/only-export-components +export function createEmptyReport(jurisdiction?: Jurisdiction): OilSpillReportData { + const now = new Date(); + const ts = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; + return { + id: `RPT-${Date.now()}`, + title: '', + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + author: '', + reportType: '예측보고서', + analysisCategory: '', + jurisdiction: jurisdiction || '', + status: '수행중', + incident: { + name: '', + writeTime: ts, + shipName: '', + agent: '', + location: '', + lat: '', + lon: '', + occurTime: '', + accidentType: '', + pollutant: '', + spillAmount: '', + depth: '', + seabed: '', + }, + tide: [{ date: '', tideType: '', lowTide1: '', highTide1: '', lowTide2: '', highTide2: '' }], + weather: [ + { + time: '', + sunrise: '', + sunset: '', + windDir: '', + windSpeed: '', + currentDir: '', + currentSpeed: '', + waveHeight: '', + }, + ], + spread: [ + { elapsed: '3시간', weathered: '', seaRemain: '', coastAttach: '', area: '' }, + { elapsed: '6시간', weathered: '', seaRemain: '', coastAttach: '', area: '' }, + ], + analysis: '', + aquaculture: [{ type: '', area: '', distance: '' }], + beaches: [{ name: '', distance: '' }], + markets: [{ name: '', distance: '' }], + esi: [ + { code: 'ESI 1', type: '수직암반', length: '' }, + { code: 'ESI 2', type: '수평암반', length: '' }, + { code: 'ESI 3', type: '세립질 모래', length: '' }, + { code: 'ESI 4', type: '조립질 모래', length: '' }, + { code: 'ESI 5', type: '모래·자갈', length: '' }, + { code: 'ESI 6A', type: '자갈', length: '' }, + { code: 'ESI 6B', type: '투과성 사석', length: '' }, + { code: 'ESI 7', type: '반폐쇄성 해안', length: '' }, + { code: 'ESI 8A', type: '갯벌', length: '' }, + { code: 'ESI 8B', type: '염습지', length: '' }, + ], + species: [ + { category: '양서파충류', species: '' }, + { category: '조류', species: '' }, + { category: '포유류', species: '' }, + ], + habitat: [{ type: '갯벌', area: '' }], + sensitivity: [ + { level: '매우 높음', area: '', color: '#ef4444' }, + { level: '높음', area: '', color: '#f97316' }, + { level: '보통', area: '', color: '#eab308' }, + { level: '낮음', area: '', color: '#22c55e' }, + ], + vessels: [ + { + name: '', + org: '', + dist: '', + speed: '', + ton: '', + collectorType: '', + collectorCap: '', + boomType: '', + boomLength: '', + }, + ], + etcEquipment: '', + recovery: [{ shipName: '', period: '' }], + result: { + spillTotal: '', + weatheredTotal: '', + recoveredTotal: '', + seaRemainTotal: '', + coastAttachTotal: '', + }, + }; +} + +// eslint-disable-next-line react-refresh/only-export-components +export function createSampleReport(): OilSpillReportData { + return { + id: `RPT-${Date.now()}`, + title: '여수 수변공원 Diesel 유출사고 대응지원', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + author: '남해청_방제과', + reportType: '초기보고서', + analysisCategory: '유출유 확산예측', + jurisdiction: '남해청', + status: '완료', + incident: { + name: '여수 수변공원 유출사고', + writeTime: '2025.07.16 13:48', + shipName: '수변공원', + agent: '', + location: '여수 돌산 남방', + lat: '34° 43′ 37.6″', + lon: '127° 43′ 32.6″', + occurTime: '2025. 07. 08. 17:55', + accidentType: '시설 파손', + pollutant: 'Diesel', + spillAmount: '1㎘', + depth: '12m', + seabed: '모래', + }, + tide: [ + { + date: '2025-07-08 20:55', + tideType: '배꼽사리', + lowTide1: '01:53 (146)', + highTide1: '13:19 (104)', + lowTide2: '07:17 (253)', + highTide2: '20:24 (310)', + }, + { + date: '2025-07-08 23:55', + tideType: '배꼽사리', + lowTide1: '01:53 (146)', + highTide1: '13:19 (104)', + lowTide2: '07:17 (253)', + highTide2: '20:24 (310)', + }, + ], + weather: [ + { + time: '2025-07-08 20:55', + sunrise: '05:22', + sunset: '19:46', + windDir: '북동', + windSpeed: '0.0', + currentDir: '북', + currentSpeed: '0.0 / 0.0', + waveHeight: '0.0', + }, + { + time: '2025-07-08 23:55', + sunrise: '05:22', + sunset: '19:46', + windDir: '북동', + windSpeed: '0.0', + currentDir: '북', + currentSpeed: '0.0 / 0.0', + waveHeight: '0.0', + }, + ], + spread: [ + { elapsed: '3시간', weathered: '0.48', seaRemain: '0.52', coastAttach: '0.12', area: '1.75' }, + { elapsed: '6시간', weathered: '0.64', seaRemain: '0.36', coastAttach: '0.24', area: '3.49' }, + ], + analysis: '', + aquaculture: [ + { type: '', area: '3.19', distance: '0.56' }, + { type: '', area: '20.15', distance: '0.83' }, + { type: '어류', area: '3.84', distance: '1.14' }, + { type: '', area: '2.89', distance: '1.40' }, + { type: '어류', area: '0.90', distance: '1.42' }, + ], + beaches: [ + { name: '검은모래해수욕장', distance: '5.86' }, + { name: '무슬목해수욕장', distance: '6.69' }, + { name: '모사금해수욕장', distance: '8.18' }, + ], + markets: [{ name: '돌산대교 회타운', distance: '1.09' }], + esi: [ + { code: 'ESI 1', type: '수직암반', length: '29.05 km' }, + { code: 'ESI 2', type: '수평암반', length: '13.76 km' }, + { code: 'ESI 3', type: '세립질 모래', length: '1.03 km' }, + { code: 'ESI 4', type: '조립질 모래', length: '0.47 km' }, + { code: 'ESI 5', type: '모래·자갈', length: '21.10 km' }, + { code: 'ESI 6A', type: '자갈', length: '15.29 km' }, + { code: 'ESI 6B', type: '투과성 사석', length: '26.60 km' }, + { code: 'ESI 7', type: '반폐쇄성 해안', length: '9.39 km' }, + { code: 'ESI 8A', type: '갯벌', length: '0.00 km' }, + { code: 'ESI 8B', type: '염습지', length: '3.43 km' }, + ], + species: [ + { category: '양서파충류', species: '' }, + { category: '조류', species: '뿔논병아리(겨울철새), 꾀꼬리(여름철새)' }, + { category: '포유류', species: '수달' }, + ], + habitat: [{ type: '갯벌', area: '4.32 km²' }], + sensitivity: [ + { level: '매우 높음', area: '18.54', color: '#ef4444' }, + { level: '높음', area: '31.32', color: '#f97316' }, + { level: '보통', area: '59.22', color: '#eab308' }, + { level: '낮음', area: '32.13', color: '#22c55e' }, + ], + vessels: [ + { + name: '방제15호', + org: '서해청', + dist: '0.41', + speed: '7.0', + ton: '450.0', + collectorType: '흡착', + collectorCap: '140.0', + boomType: 'B', + boomLength: '60', + }, + { + name: '방제26호', + org: '서해청', + dist: '0.41', + speed: '13.0', + ton: '360.0', + collectorType: '흡착', + collectorCap: '100.0', + boomType: 'B', + boomLength: '300', + }, + { + name: '방제1001호', + org: '서해청', + dist: '0.74', + speed: '10.0', + ton: '564.0', + collectorType: '흡착', + collectorCap: '30.0', + boomType: 'B', + boomLength: '20', + }, + { + name: '전남939호', + org: '서해청', + dist: '0.74', + speed: '10.0', + ton: '149.0', + collectorType: '흡착', + collectorCap: '30.0', + boomType: 'B', + boomLength: '300', + }, + { + name: '동양방제호', + org: '서해청', + dist: '1.78', + speed: '10.0', + ton: '179.0', + collectorType: '위어,흡착', + collectorCap: '66.0', + boomType: 'C', + boomLength: '300', + }, + { + name: '화학방제2함', + org: '서해청', + dist: '3.00', + speed: '13.0', + ton: '501.0', + collectorType: '위어,브러쉬', + collectorCap: '150.0', + boomType: '특수', + boomLength: '60', + }, + { + name: '우진방제호', + org: '서해청', + dist: '13.22', + speed: '9.0', + ton: '79.0', + collectorType: '위어', + collectorCap: '20.0', + boomType: 'C', + boomLength: '300', + }, + ], + etcEquipment: '', + recovery: [{ shipName: '방제15호', period: '0' }], + result: { + spillTotal: '1', + weatheredTotal: '0.64', + recoveredTotal: '0', + seaRemainTotal: '0.36', + coastAttachTotal: '0.24', + }, + }; +} + +// ─── localStorage helpers 제거됨 — reportsApi.ts 사용 ──────── + +// ─── Styles ───────────────────────────────────────────────── +export const S = { + page: { + background: 'var(--bg-surface)', + padding: '32px 40px', + marginBottom: '24px', + borderRadius: '6px', + border: '1px solid var(--stroke-default)', + fontFamily: 'var(--font-korean)', + fontSize: 'var(--font-size-label-1)', + lineHeight: '1.6', + position: 'relative' as const, + width: '100%', + boxSizing: 'border-box' as const, + }, + sectionTitle: { + background: 'rgba(6,182,212,0.12)', + color: 'var(--color-accent)', + padding: '8px 16px', + fontSize: 'var(--font-size-title-4)', + fontWeight: 700, + marginBottom: '12px', + borderRadius: '4px', + border: '1px solid rgba(6,182,212,0.2)', + }, + subHeader: { + fontSize: 'var(--font-size-title-3)', + fontWeight: 700, + color: 'var(--color-accent)', + marginBottom: '12px', + borderBottom: '2px solid var(--stroke-default)', + paddingBottom: '6px', + }, + table: { + width: '100%', + tableLayout: 'fixed' as const, + borderCollapse: 'collapse' as const, + fontSize: 'var(--font-size-caption)', + marginBottom: '16px', + }, + th: { + background: 'var(--bg-card)', + border: '1px solid var(--stroke-default)', + padding: '6px 10px', + fontWeight: 600, + color: 'var(--fg-sub)', + textAlign: 'center' as const, + fontSize: 'var(--font-size-caption)', + }, + td: { + border: '1px solid var(--stroke-default)', + padding: '5px 10px', + textAlign: 'center' as const, + fontSize: 'var(--font-size-caption)', + color: 'var(--fg-sub)', + }, + tdLeft: { + border: '1px solid var(--stroke-default)', + padding: '5px 10px', + textAlign: 'left' as const, + fontSize: 'var(--font-size-caption)', + color: 'var(--fg-sub)', + }, + thLabel: { + background: 'var(--bg-card)', + border: '1px solid var(--stroke-default)', + padding: '6px 10px', + fontWeight: 600, + color: 'var(--fg-sub)', + textAlign: 'left' as const, + fontSize: 'var(--font-size-caption)', + width: '120px', + }, + mapPlaceholder: { + width: '100%', + height: '240px', + background: 'var(--bg-base)', + border: '2px dashed var(--stroke-default)', + borderRadius: '4px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: 'var(--fg-disabled)', + fontSize: 'var(--font-size-title-4)', + fontWeight: 600, + marginBottom: '16px', + }, +}; + +// ─── Editable cell ────────────────────────────────────────── +export const inputStyle: React.CSSProperties = { + width: '100%', + background: 'var(--bg-base)', + border: '1px solid var(--stroke-light)', + borderRadius: '3px', + padding: '4px 8px', + fontSize: 'var(--font-size-caption)', + outline: 'none', + textAlign: 'center', +}; + +// ─── Helpers (shared with contents/) ─────────────────────── +export const getSeasonKey = (occurTime: string): 'SP_G' | 'SM_G' | 'FA_G' | 'WT_G' => { + const m = occurTime.match(/\d{4}[.\-\s]+(\d{1,2})[.\-\s]/); + const month = m ? parseInt(m[1]) : 0; + if (month >= 3 && month <= 5) return 'SP_G'; + if (month >= 6 && month <= 8) return 'SM_G'; + if (month >= 9 && month <= 11) return 'FA_G'; + return 'WT_G'; +}; + +export const parseCoord = (s: string): number | null => { + const d = parseFloat(s); + if (!isNaN(d)) return d; + const m = s.match(/(\d+)[°]\s*(\d+)[′']\s*([\d.]+)[″"]/); + if (m) return Number(m[1]) + Number(m[2]) / 60 + Number(m[3]) / 3600; + return null; +}; + +export const haversineKm = (lat1: number, lon1: number, lat2: number, lon2: number): number => { + const R = 6371; + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLon = ((lon2 - lon1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +}; + +export const getGeomCentroid = (geom: { type: string; coordinates: unknown }): [number, number] | null => { + if (geom.type === 'Point') return geom.coordinates as [number, number]; + if (geom.type === 'Polygon') { + const pts = (geom.coordinates as [number, number][][])[0]; + const avg = pts.reduce((a, c) => [a[0] + c[0], a[1] + c[1]], [0, 0]); + return [avg[0] / pts.length, avg[1] / pts.length]; + } + if (geom.type === 'MultiPolygon') { + const all = (geom.coordinates as [number, number][][][]).flatMap((p) => p[0]); + const avg = all.reduce((a, c) => [a[0] + c[0], a[1] + c[1]], [0, 0]); + return [avg[0] / all.length, avg[1] / all.length]; + } + return null; +}; + +export const CATEGORY_COLORS = [ + { keywords: ['어장', '양식'], color: '#f97316', label: '어장/양식장' }, + { keywords: ['해수욕'], color: '#3b82f6', label: '해수욕장' }, + { keywords: ['수산시장', '어시장'], color: '#a855f7', label: '수산시장' }, + { keywords: ['갯벌'], color: '#92400e', label: '갯벌' }, + { keywords: ['서식지'], color: '#16a34a', label: '서식지' }, + { + keywords: ['보호종', '생물', '조류', '포유', '파충', '양서'], + color: '#ec4899', + label: '보호종/생물종', + }, +] as const; + +export const getCategoryColor = (category: string): string => { + for (const { keywords, color } of CATEGORY_COLORS) { + if ((keywords as readonly string[]).some((kw) => category.includes(kw))) return color; + } + return '#06b6d4'; +}; + +export const SENS_LEVELS = [ + { key: 5, level: '매우 높음', color: '#ef4444' }, + { key: 4, level: '높음', color: '#f97316' }, + { key: 3, level: '보통', color: '#eab308' }, + { key: 2, level: '낮음', color: '#22c55e' }, +]; + + +// ─── Main Template Component ──────────────────────────────── +interface Props { + mode: 'preview' | 'edit' | 'view'; + initialData?: OilSpillReportData; + onSave?: (data: OilSpillReportData) => void; + onBack?: () => void; +} + +export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Props) { + const [data, setData] = useState(() => initialData || createSampleReport()); + const [currentPage, setCurrentPage] = useState(0); + const [viewMode, setViewMode] = useState<'page' | 'all'>('all'); + const editing = mode === 'edit'; + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + if (initialData) setData(initialData); + }, [initialData]); + + const handleSave = useCallback(async () => { + let reportData = data; + if (!data.title) { + const title = data.incident.name || `보고서 ${new Date().toLocaleDateString('ko-KR')}`; + reportData = { ...data, title }; + } + try { + await saveReport(reportData); + onSave?.(reportData); + } catch (err) { + console.error('[reports] 저장 오류:', err); + alert('보고서 저장 중 오류가 발생했습니다.'); + } + }, [data, onSave]); + + const pages = [ + { label: '1. 사고 정보', node: }, + { + label: '2. 해양기상 + 확산예측', + node: , + }, + { label: '3. 분석', node: }, + { label: '4. 민감자원', node: }, + { label: '5. 통합민감도', node: }, + { label: '6. 방제전략', node: }, + { label: '7. 동원 결과', node: }, + ]; + + return ( +
+ {/* Toolbar */} +
+
+ {onBack && ( + + )} +

+ {editing ? ( + setData({ ...data, title: e.target.value })} + placeholder="보고서 제목 입력" + className="text-title-1 font-bold bg-bg-base border border-[var(--stroke-light)] rounded px-2.5 py-1 outline-none w-full max-w-[600px]" + /> + ) : ( + data.title || '유류오염사고 대응지원 상황도' + )} +

+ + {editing ? '편집 중' : mode === 'preview' ? '샘플' : '보기'} + +
+
+ + + {editing && ( + + )} + +
+
+ + {/* Page tabs */} + {viewMode === 'page' && ( +
+ {pages.map((p, i) => ( + + ))} +
+ )} + + {/* Pages */} +
+ {viewMode === 'all' ? ( + pages.map((p, i) =>
{p.node}
) + ) : ( +
+ {pages[currentPage].node} +
+ + + {currentPage + 1} / {pages.length} + + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx b/frontend/src/components/reports/components/OilSpreadMapPanel.tsx similarity index 99% rename from frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx rename to frontend/src/components/reports/components/OilSpreadMapPanel.tsx index 3498701..46c91bc 100644 --- a/frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx +++ b/frontend/src/components/reports/components/OilSpreadMapPanel.tsx @@ -1,5 +1,5 @@ import { useRef, useState } from 'react'; -import { MapView } from '@common/components/map/MapView'; +import { MapView } from '@components/common/map/MapView'; import type { OilReportPayload } from '@common/hooks/useSubMenu'; interface OilSpreadMapPanelProps { diff --git a/frontend/src/tabs/reports/components/ReportGenerator.tsx b/frontend/src/components/reports/components/ReportGenerator.tsx similarity index 99% rename from frontend/src/tabs/reports/components/ReportGenerator.tsx rename to frontend/src/components/reports/components/ReportGenerator.tsx index 0aa23c0..2f0d1db 100644 --- a/frontend/src/tabs/reports/components/ReportGenerator.tsx +++ b/frontend/src/components/reports/components/ReportGenerator.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; -import { createEmptyReport, type Jurisdiction } from './OilSpillReportTemplate'; +import { createEmptyReport } from './OilSpillReportTemplate'; +import type { Jurisdiction } from '@/types/reports/ReportsType'; import { consumeReportGenCategory, consumeHnsReportPayload, @@ -11,7 +12,8 @@ import { useAuthStore } from '@common/store/authStore'; import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore'; import OilSpreadMapPanel from './OilSpreadMapPanel'; import { saveReport } from '../services/reportsApi'; -import { CATEGORIES, type ReportCategory, type ReportSection } from './reportTypes'; +import { CATEGORIES, type ReportCategory } from './reportTypes'; +import type { ReportSection } from '@interfaces/reports/ReportsInterface'; import { exportAsPDF } from './reportUtils'; interface ReportGeneratorProps { diff --git a/frontend/src/tabs/reports/components/ReportsView.tsx b/frontend/src/components/reports/components/ReportsView.tsx old mode 100755 new mode 100644 similarity index 99% rename from frontend/src/tabs/reports/components/ReportsView.tsx rename to frontend/src/components/reports/components/ReportsView.tsx index c654272..c226be4 --- a/frontend/src/tabs/reports/components/ReportsView.tsx +++ b/frontend/src/components/reports/components/ReportsView.tsx @@ -18,7 +18,7 @@ import { inferAnalysisCategory, type ViewState, } from './reportUtils'; -import type { TemplateType } from './reportTypes'; +import type { TemplateType } from '@interfaces/reports/ReportsInterface'; import TemplateFormEditor from './TemplateFormEditor'; import ReportGenerator from './ReportGenerator'; import TemplateEditPage from './TemplateEditPage'; diff --git a/frontend/src/tabs/reports/components/TemplateEditPage.tsx b/frontend/src/components/reports/components/TemplateEditPage.tsx similarity index 98% rename from frontend/src/tabs/reports/components/TemplateEditPage.tsx rename to frontend/src/components/reports/components/TemplateEditPage.tsx index dce86b2..ed2fb3c 100644 --- a/frontend/src/tabs/reports/components/TemplateEditPage.tsx +++ b/frontend/src/components/reports/components/TemplateEditPage.tsx @@ -1,11 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; -import { type OilSpillReportData, type ReportType } from './OilSpillReportTemplate'; -import { - templateTypes, - type TemplateType, - ANALYSIS_SEP, - ANALYSIS_FIELD_ORDER, -} from './reportTypes'; +import type { OilSpillReportData, TemplateType } from '@interfaces/reports/ReportsInterface'; +import type { ReportType } from '@/types/reports/ReportsType'; +import { templateTypes, ANALYSIS_SEP, ANALYSIS_FIELD_ORDER } from './reportTypes'; import { saveReport } from '../services/reportsApi'; interface TemplateEditPageProps { diff --git a/frontend/src/tabs/reports/components/TemplateFormEditor.tsx b/frontend/src/components/reports/components/TemplateFormEditor.tsx similarity index 99% rename from frontend/src/tabs/reports/components/TemplateFormEditor.tsx rename to frontend/src/components/reports/components/TemplateFormEditor.tsx index 32f2123..6792eee 100644 --- a/frontend/src/tabs/reports/components/TemplateFormEditor.tsx +++ b/frontend/src/components/reports/components/TemplateFormEditor.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { createEmptyReport, type ReportType, type Jurisdiction } from './OilSpillReportTemplate'; +import { createEmptyReport } from './OilSpillReportTemplate'; +import type { ReportType, Jurisdiction } from '@/types/reports/ReportsType'; import { useAuthStore } from '@common/store/authStore'; import { templateTypes } from './reportTypes'; import { generateReportHTML, exportAsPDF, exportAsHWP } from './reportUtils'; diff --git a/frontend/src/components/reports/components/contents/AddRowBtn.tsx b/frontend/src/components/reports/components/contents/AddRowBtn.tsx new file mode 100644 index 0000000..9a61795 --- /dev/null +++ b/frontend/src/components/reports/components/contents/AddRowBtn.tsx @@ -0,0 +1,10 @@ +export function AddRowBtn({ onClick, label }: { onClick: () => void; label?: string }) { + return ( + + ); +} diff --git a/frontend/src/components/reports/components/contents/ECell.tsx b/frontend/src/components/reports/components/contents/ECell.tsx new file mode 100644 index 0000000..29d1ff2 --- /dev/null +++ b/frontend/src/components/reports/components/contents/ECell.tsx @@ -0,0 +1,40 @@ +import { S, inputStyle } from '../OilSpillReportTemplate'; + +export function ECell({ + value, + editing, + onChange, + align, + placeholder, +}: { + value: string; + editing: boolean; + onChange?: (v: string) => void; + align?: 'left' | 'center'; + placeholder?: string; +}) { + const style = align === 'left' ? S.tdLeft : S.td; + if (!editing) { + return ( + + {value || placeholder || '-'} + + ); + } + return ( + + onChange?.(e.target.value)} + placeholder={placeholder} + /> + + ); +} diff --git a/frontend/src/components/reports/components/contents/Page1.tsx b/frontend/src/components/reports/components/contents/Page1.tsx new file mode 100644 index 0000000..d00b268 --- /dev/null +++ b/frontend/src/components/reports/components/contents/Page1.tsx @@ -0,0 +1,135 @@ +import type { OilSpillReportData } from '@interfaces/reports/ReportsInterface'; +import { S } from '../OilSpillReportTemplate'; +import { ECell } from './ECell'; + +export function Page1({ + data, + editing, + onChange, +}: { + data: OilSpillReportData; + editing: boolean; + onChange: (d: OilSpillReportData) => void; +}) { + const inc = data.incident; + const set = (k: keyof typeof inc, v: string) => + onChange({ ...data, incident: { ...inc, [k]: v } }); + + return ( +
+
+ 해양오염방제지원시스템 +
+
+ 유류오염사고 대응지원 상황도 +
+
1. 사고 정보
+ + + + + + + + + + + set('name', v)} + placeholder="사고명 입력" + /> + + set('writeTime', v)} /> + + + + set('shipName', v)} + placeholder="선명" + /> + + set('agent', v)} + placeholder="제원" + /> + + + + set('location', v)} + placeholder="위치" + /> + + + + {editing && ( + + + set('lat', v)} + placeholder="34° 43′ 37.6″" + /> + + set('lon', v)} + placeholder="127° 43′ 32.6″" + /> + + )} + + + set('occurTime', v)} /> + + set('accidentType', v)} + placeholder="사고유형" + /> + + + + set('pollutant', v)} /> + + set('spillAmount', v)} + /> + + + + set('depth', v)} + placeholder="수심" + /> + + set('seabed', v)} + placeholder="저질" + /> + + +
사고명작성시간
선명(시설명)제원
사고위치좌표
위도경도
발생시각사고유형
오염물질유출 추정량
수심저질
+
+ ); +} diff --git a/frontend/src/components/reports/components/contents/Page2.tsx b/frontend/src/components/reports/components/contents/Page2.tsx new file mode 100644 index 0000000..3175b10 --- /dev/null +++ b/frontend/src/components/reports/components/contents/Page2.tsx @@ -0,0 +1,276 @@ +import type { OilSpillReportData } from '@interfaces/reports/ReportsInterface'; +import { S } from '../OilSpillReportTemplate'; +import { ECell } from './ECell'; +import { AddRowBtn } from './AddRowBtn'; + +export function Page2({ + data, + editing, + onChange, +}: { + data: OilSpillReportData; + editing: boolean; + onChange: (d: OilSpillReportData) => void; +}) { + const setTide = (i: number, k: string, v: string) => { + const t = [...data.tide]; + t[i] = { ...t[i], [k]: v }; + onChange({ ...data, tide: t }); + }; + const setWeather = (i: number, k: string, v: string) => { + const w = [...data.weather]; + w[i] = { ...w[i], [k]: v }; + onChange({ ...data, weather: w }); + }; + const setSpread = (i: number, k: string, v: string) => { + const s = [...data.spread]; + s[i] = { ...s[i], [k]: v }; + onChange({ ...data, spread: s }); + }; + + return ( +
+
+ 해양오염방제지원시스템 +
+
2. 해양기상정보
+
조석 정보
+ + + + + + + + + + + {data.tide.map((t, i) => ( + + setTide(i, 'date', v)} /> + setTide(i, 'tideType', v)} + /> + setTide(i, 'lowTide1', v)} + /> + setTide(i, 'lowTide2', v)} + /> + setTide(i, 'highTide1', v)} + /> + setTide(i, 'highTide2', v)} + /> + + ))} + +
일자물때 + 저조 + + 고조 +
+ {editing && ( + + onChange({ + ...data, + tide: [ + ...data.tide, + { + date: '', + tideType: '', + lowTide1: '', + highTide1: '', + lowTide2: '', + highTide2: '', + }, + ], + }) + } + /> + )} + +
기상 정보
+ + + + + + + + + + + + + + + {data.weather.map((w, i) => ( + + setWeather(i, 'time', v)} /> + setWeather(i, 'sunrise', v)} + /> + setWeather(i, 'sunset', v)} + /> + setWeather(i, 'windDir', v)} + /> + setWeather(i, 'windSpeed', v)} + /> + setWeather(i, 'currentDir', v)} + /> + setWeather(i, 'currentSpeed', v)} + /> + setWeather(i, 'waveHeight', v)} + /> + + ))} + +
기상 예측시간일출일몰풍향풍속(m/s)유향유속(knot/m/s)파고(m)
+ {editing && ( + + onChange({ + ...data, + weather: [ + ...data.weather, + { + time: '', + sunrise: '', + sunset: '', + windDir: '', + windSpeed: '', + currentDir: '', + currentSpeed: '', + waveHeight: '', + }, + ], + }) + } + /> + )} + +
3. 유출유 확산예측
+
+
+ {data.step3MapImage ? ( + 확산예측 3시간 지도 + ) : ( +
확산예측 3시간 지도
+ )} +
+
+ {data.step6MapImage ? ( + 확산예측 6시간 지도 + ) : ( +
확산예측 6시간 지도
+ )} +
+
+
시간별 상세정보
+ + + + + + + + + + + + {data.spread.map((s, i) => ( + + setSpread(i, 'elapsed', v)} + /> + setSpread(i, 'weathered', v)} + /> + setSpread(i, 'seaRemain', v)} + /> + setSpread(i, 'coastAttach', v)} + /> + setSpread(i, 'area', v)} /> + + ))} + +
경과시간풍화량(kl)해상잔존량(kl)연안부착량(kl)오염해역면적(km²)
+ {editing && ( + + onChange({ + ...data, + spread: [ + ...data.spread, + { elapsed: '', weathered: '', seaRemain: '', coastAttach: '', area: '' }, + ], + }) + } + /> + )} +
+ ); +} diff --git a/frontend/src/components/reports/components/contents/Page3.tsx b/frontend/src/components/reports/components/contents/Page3.tsx new file mode 100644 index 0000000..1f0562f --- /dev/null +++ b/frontend/src/components/reports/components/contents/Page3.tsx @@ -0,0 +1,58 @@ +import type { OilSpillReportData } from '@interfaces/reports/ReportsInterface'; +import { S } from '../OilSpillReportTemplate'; + +export function Page3({ + data, + editing, + onChange, +}: { + data: OilSpillReportData; + editing: boolean; + onChange: (d: OilSpillReportData) => void; +}) { + return ( +
+
+ 해양오염방제지원시스템 +
+
분석
+ {editing ? ( +